#!/usr/bin/env node import { spawnSync } from "node:child_process"; import { createHash } from "node:crypto"; import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { basename, dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { createMemorySessionStorage } from "../app/src/runtime/five-axis-session.js"; import { createLinuxCncInterpreterRuntime, } from "../app/src/runtime/linuxcnc-interpreter-runtime.js"; import { selectMachineFileProgram, stageProfileMachineFiles, } from "../app/src/runtime/linuxcnc-machine-file-staging.js"; import { getFiveAxisProfile } from "../app/src/profiles/index.js"; const PROJECT_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const WORKSPACE_ROOT = resolve(PROJECT_ROOT, ".."); const LINUXCNC_ROOT = resolve(WORKSPACE_ROOT, "linuxcnc"); const GMOCAPY_MACHINE_REL = "configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting"; const GMOCAPY_MACHINE_DIR = resolve(LINUXCNC_ROOT, GMOCAPY_MACHINE_REL); const GMOCAPY_SOURCE_PREFIX = `${GMOCAPY_MACHINE_REL}/examples/`; const DEFAULT_CASES = [ "impeller-7bl-xyzac.ngc", "boat-xyzac.ngc", ]; const AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"]; const PLANE_AXIS_MAP = { 170: ["x", "y", "z"], 180: ["z", "x", "y"], 190: ["y", "z", "x"], }; const options = parseArgs(process.argv.slice(2)); const outputDir = resolve(PROJECT_ROOT, options.outputDir || "working_run/linuxcnc-web-parity"); const cases = options.cases.length > 0 ? options.cases : DEFAULT_CASES; mkdirSync(outputDir, { recursive: true }); const runtime = await createLinuxCncInterpreterRuntime(); const profile = getFiveAxisProfile("gmoccapy-xyzac-trt"); const staged = await stageProfileMachineFiles(profile, { storage: createMemorySessionStorage(), }); const reports = []; for (const filename of cases) { const sourceRel = `${GMOCAPY_SOURCE_PREFIX}${filename}`; const programPath = resolve(GMOCAPY_MACHINE_DIR, "examples", filename); const programText = readFileSync(programPath, "utf8"); const native = collectNativeLinuxCncResult({ filename, programPath, programText }); const web = collectWebResult({ filename, sourceRel, programText, runtime, staged }); const comparison = compareResults(native, web); const slug = filename.replace(/\.ngc$/i, ""); const nativePath = resolve(outputDir, `linuxcnc-native-${slug}.json`); const webPath = resolve(outputDir, `web-runtime-${slug}.json`); const comparisonPath = resolve(outputDir, `comparison-${slug}.json`); writeJson(nativePath, native); writeJson(webPath, web); writeJson(comparisonPath, comparison); reports.push({ filename, nativePath, webPath, comparisonPath, passed: comparison.summary.passed, mismatchCount: comparison.summary.mismatchCount, maxAxisDelta: comparison.summary.maxAxisDelta, nativeMotionCount: native.summary.motionEventCount, webMotionCount: web.summary.motionEventCount, }); } const indexPath = resolve(outputDir, "index.json"); writeJson(indexPath, { apiName: "web-rtcp-5axis-linuxcnc-web-parity-index", generatedAt: new Date().toISOString(), profileId: profile.id, outputDir, linuxCncRoot: LINUXCNC_ROOT, projectRoot: PROJECT_ROOT, cases: reports, }); console.log(`parity_output_dir=${outputDir}`); for (const report of reports) { console.log(`${report.filename}: passed=${report.passed} mismatches=${report.mismatchCount} native_motion=${report.nativeMotionCount} web_motion=${report.webMotionCount}`); } console.log(`parity_index=${indexPath}`); function collectNativeLinuxCncResult({ filename, programPath, programText }) { const tempDir = resolve("/tmp", `web-rtcp-native-rs274-${process.pid}-${Date.now()}-${filename.replace(/[^a-z0-9]/gi, "_")}`); mkdirSync(tempDir, { recursive: true }); const tempVarPath = resolve(tempDir, "xyzac.var"); copyFileSync(resolve(GMOCAPY_MACHINE_DIR, "xyzac.var"), tempVarPath); try { const result = spawnSync(resolve(LINUXCNC_ROOT, "scripts/rip-environment"), [ "rs274", "-g", "-i", "xyzac-trt.ini", "-t", "xyzac-trt.tbl", "-v", tempVarPath, `examples/${basename(programPath)}`, ], { cwd: GMOCAPY_MACHINE_DIR, encoding: "utf8", maxBuffer: 256 * 1024 * 1024, }); const resultText = `${result.stdout || ""}${result.stderr || ""}`; const canonical = parseRs274CanonicalOutput(resultText, programText); const executionPath = buildExecutionPathFromMotion(canonical.motion); return createResultArtifact({ source: "linuxcnc-native-rs274", semanticBoundary: "linuxcnc_source_tree_rs274_gmoccapy_ini_canonical_output", filename, sourceRel: `${GMOCAPY_SOURCE_PREFIX}${filename}`, programText, resultText, motion: canonical.motion, canonicalEvents: canonical.canonicalEvents, lineExecution: canonical.lineExecution, executionPath, runtime: { command: "scripts/rip-environment rs274 -g -i xyzac-trt.ini -t xyzac-trt.tbl -v examples/", exitCode: result.status, signal: result.signal, cwd: GMOCAPY_MACHINE_DIR, tempParameterFile: tempVarPath, }, }); } finally { rmSync(tempDir, { recursive: true, force: true }); } } function collectWebResult({ filename, sourceRel, programText, runtime, staged }) { const plan = selectMachineFileProgram(staged.plan, staged.save, sourceRel); const machineFileExecution = runtime.runMachineFileProgram({ plan, files: staged.save.files, executionMode: "fiveAxisRemap", }); const directExecution = runtime.runProgram(programText); const selectedExecution = machineFileExecution.summary.motionEventCount > 0 ? machineFileExecution : directExecution; return createResultArtifact({ source: "web-runtime-wasm", semanticBoundary: selectedExecution.semanticBoundary, filename, sourceRel, programText, resultText: selectedExecution.resultText, motion: selectedExecution.motion, canonicalEvents: extractWebCanonicalEvents(selectedExecution.resultText), lineExecution: buildLineExecution(selectedExecution.motion, programText), executionPath: selectedExecution.plannerTiming?.samples || [], plannerTiming: selectedExecution.plannerTiming, runtime: { selectedMode: selectedExecution === machineFileExecution ? "linuxcnc-machine-file-remap-wasm" : "linuxcnc-interpreter-wasm", directSummary: directExecution.summary, machineFileSummary: machineFileExecution.summary, machineFilePlan: machineFileExecution.machineFilePlan, machineFileDiagnostics: extractDiagnostics(machineFileExecution.resultText), }, }); } function createResultArtifact({ source, semanticBoundary, filename, sourceRel, programText, resultText, motion, canonicalEvents, lineExecution, executionPath, plannerTiming = null, runtime, }) { return { apiName: "web-rtcp-5axis-gcode-run-result", generatedAt: new Date().toISOString(), source, semanticBoundary, profileId: "gmoccapy-xyzac-trt", filename, sourceRel, program: { lineCount: programText.split(/\r?\n/).length, nonBlankLineCount: programText.split(/\r?\n/).filter((line) => line.trim()).length, sha256: sha256(programText), }, summary: { ready: motion.length > 0, canonicalEventCount: canonicalEvents.length, motionEventCount: motion.length, executedPathPointCount: executionPath.length, lineExecutionCount: lineExecution.length, motionTypes: [...new Set(motion.map((event) => event.type))], finalAxes: motion.at(-1)?.axes || Object.fromEntries(AXES.map((axis) => [axis, 0])), firstMotionLine: firstFiniteLine(motion), lastMotionLine: lastFiniteLine(motion), plannerRuntimeReady: plannerTiming?.plannerRuntimeReady === true || executionPath.length > 0, }, toolPreviewPath: motion, toolExecutionPath: executionPath, lineExecution, canonicalEvents, runtime, plannerTiming, rawResultText: resultText, }; } function parseRs274CanonicalOutput(resultText, programText) { const canonicalEvents = []; const motion = []; const axes = Object.fromEntries(AXES.map((axis) => [axis, 0])); const sourceLines = programLineMap(programText); const candidateLines = candidateMotionSourceLines(programText); let candidateIndex = 0; let activePlane = 170; let activeFeedRate = null; let activeFeedMode = "units-per-minute"; let activeLinearUnits = "mm"; for (const textLine of String(resultText).split(/\r?\n/)) { const parsed = parseRs274Line(textLine); if (!parsed) continue; canonicalEvents.push(parsed); if (parsed.name === "USE_LENGTH_UNITS") { activeLinearUnits = parsed.args[0] === "CANON_UNITS_INCHES" ? "inch" : "mm"; } if (parsed.name === "SET_FEED_MODE") { activeFeedMode = "units-per-minute"; } if (parsed.name === "COMMENT" && /inverse time/i.test(parsed.args.join(" "))) { activeFeedMode = "inverse-time"; } if (parsed.name === "SELECT_PLANE") { activePlane = Number(parsed.args[0]) || activePlane; } if (parsed.name === "SET_FEED_RATE") { const feed = Number(parsed.args[0]); activeFeedRate = Number.isFinite(feed) && feed > 0 ? feed : activeFeedRate; } if (!["STRAIGHT_TRAVERSE", "STRAIGHT_FEED", "ARC_FEED"].includes(parsed.name)) continue; const mappedLine = candidateLines[candidateIndex++] || null; if (parsed.name === "ARC_FEED") { const [firstAxis, secondAxis, thirdAxis] = PLANE_AXIS_MAP[activePlane] ?? PLANE_AXIS_MAP[170]; const [firstEnd, secondEnd, centerFirst, centerSecond, rotation, axisEndPoint] = parsed.args.map(Number); 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, centerSecond, rotation, axisEndPoint, }; } else { for (const [index, axis] of AXES.slice(0, 6).entries()) { const value = Number(parsed.args[index]); if (Number.isFinite(value)) axes[axis] = value; } } motion.push({ index: motion.length, type: parsed.name, canonicalSequence: parsed.sequence, line: mappedLine?.line ?? null, statement: mappedLine?.statement ?? "-", axes: { ...axes }, feedRate: activeFeedRate, feedMode: activeFeedMode, linearUnits: activeLinearUnits, raw: parsed.raw, lineMappingSource: mappedLine ? "source-motion-line-order" : "unmapped-native-canonical-order", }); } return { canonicalEvents, motion, lineExecution: buildLineExecution(motion, programText), }; } function parseRs274Line(line) { const match = String(line).match(/^\s*(\d+)\s+N\.+\s+([A-Z_]+)\((.*)\)\s*$/); if (!match) return null; return { sequence: Number(match[1]), name: match[2], args: splitCanonicalArgs(match[3]), raw: line, }; } function splitCanonicalArgs(text) { const args = []; let current = ""; let quoted = false; for (const char of String(text)) { if (char === "\"") { quoted = !quoted; current += char; continue; } if (char === "," && !quoted) { args.push(cleanArg(current)); current = ""; continue; } current += char; } if (current.length > 0 || text.length > 0) args.push(cleanArg(current)); return args; } function cleanArg(value) { return String(value).trim().replace(/^"(.*)"$/s, "$1"); } function extractWebCanonicalEvents(resultText) { return String(resultText).split(/\r?\n/) .filter((line) => line.startsWith("canon_event=")) .map((line, index) => ({ sequence: index + 1, name: line.slice("canon_event=".length).split(/\s+/)[0], raw: line, })); } function candidateMotionSourceLines(programText) { const candidates = []; let activeMotion = null; String(programText).split(/\r?\n/).forEach((line, index) => { const codeOnly = stripComments(line); const lineNumber = index + 1; let lineMotion = null; for (const match of codeOnly.matchAll(/\bG\s*([0-9]+(?:\.[0-9]+)?)\b/gi)) { const value = Number(match[1]); if (value === 0 || value === 1 || value === 2 || value === 3) { lineMotion = value; activeMotion = value; } } const hasAxisWord = /\b[XYZABCUVW]\s*[-+#0-9.]/i.test(codeOnly); if (lineMotion !== null || (hasAxisWord && activeMotion !== null)) { candidates.push({ line: lineNumber, statement: line.trim() || "(blank)", }); } }); return candidates; } function buildExecutionPathFromMotion(motion) { let elapsedSeconds = 0; return motion.map((event, index) => { const previous = motion[index - 1]?.axes || Object.fromEntries(AXES.map((axis) => [axis, 0])); const distance = vectorLength(["x", "y", "z"].map((axis) => ( Number(event.axes?.[axis] || 0) - Number(previous?.[axis] || 0) ))); const velocity = event.type === "STRAIGHT_TRAVERSE" ? 2100 : Math.max(Number(event.feedRate) || 100, 1); const durationSeconds = distance > 0 ? distance / (velocity / 60) : 0; elapsedSeconds += durationSeconds; return { sampleIndex: index, timeSeconds: elapsedSeconds, motionIndex: index, line: event.line, type: event.type, axes: event.axes, currentVelocityMmPerMin: velocity, source: "native-rs274-canonical-endpoint-timing-estimate", }; }); } function buildLineExecution(motion, programText) { const lines = programLineMap(programText); const byLine = new Map(); for (const [line, statement] of lines.entries()) { const executable = stripComments(statement).trim().length > 0; byLine.set(line, { line, statement, executable, executionState: executable ? "no-motion" : "blank-or-comment", canonicalEventCount: 0, motionEventCount: 0, motionTypes: [], firstMotionIndex: null, lastMotionIndex: null, finalAxes: null, events: [], }); } for (const [motionOrder, event] of motion.entries()) { const motionIndex = event.index ?? motionOrder; const line = Number(event.line); if (!Number.isFinite(line)) continue; const entry = byLine.get(line) || { line, statement: event.statement || "-", executable: true, executionState: "no-motion", canonicalEventCount: 0, motionEventCount: 0, motionTypes: [], firstMotionIndex: null, lastMotionIndex: null, finalAxes: null, events: [], }; byLine.set(line, entry); entry.executionState = "motion"; entry.canonicalEventCount += 1; entry.motionEventCount += 1; if (entry.firstMotionIndex === null) entry.firstMotionIndex = motionIndex; entry.lastMotionIndex = motionIndex; entry.finalAxes = event.axes; if (!entry.motionTypes.includes(event.type)) entry.motionTypes.push(event.type); entry.events.push({ index: motionIndex, type: event.type, axes: event.axes, feedRate: event.feedRate, raw: event.raw, }); } return [...byLine.values()].sort((left, right) => left.line - right.line); } function compareResults(native, web) { const mismatches = []; compareNumber("motionEventCount", native.summary.motionEventCount, web.summary.motionEventCount, 0, mismatches); const maxCount = Math.min(native.toolPreviewPath.length, web.toolPreviewPath.length); let maxAxisDelta = 0; for (let index = 0; index < maxCount; index += 1) { const nativeEvent = native.toolPreviewPath[index]; const webEvent = web.toolPreviewPath[index]; if (nativeEvent.type !== webEvent.type) { mismatches.push({ kind: "motion-type", index, native: nativeEvent.type, web: webEvent.type, }); } for (const axis of AXES) { const delta = Math.abs(Number(nativeEvent.axes?.[axis] || 0) - Number(webEvent.axes?.[axis] || 0)); maxAxisDelta = Math.max(maxAxisDelta, delta); if (delta > 0.001) { mismatches.push({ kind: "axis", index, axis, native: nativeEvent.axes?.[axis] ?? null, web: webEvent.axes?.[axis] ?? null, delta, }); break; } } const nativeFeed = Number(nativeEvent.feedRate); const webFeed = Number(webEvent.feedRate); if (Number.isFinite(nativeFeed) && Number.isFinite(webFeed) && Math.abs(nativeFeed - webFeed) > 0.01) { mismatches.push({ kind: "feed-rate", index, native: nativeFeed, web: webFeed, delta: Math.abs(nativeFeed - webFeed), }); } if (mismatches.length > 200) break; } const diagnostics = { nativeExecutionPathBasis: native.toolExecutionPath[0]?.source || "linuxcnc-native-rs274-canonical", webExecutionPathBasis: web.plannerTiming?.semanticBoundary || web.toolExecutionPath[0]?.source || "unknown", nativeLineMapping: native.toolPreviewPath[0]?.lineMappingSource || null, webSelectedMode: web.runtime.selectedMode, webMachineFileDiagnostics: web.runtime.machineFileDiagnostics, }; return { apiName: "web-rtcp-5axis-linuxcnc-web-parity-comparison", generatedAt: new Date().toISOString(), profileId: "gmoccapy-xyzac-trt", filename: native.filename, summary: { passed: mismatches.length === 0, mismatchCount: mismatches.length, comparedMotionEvents: maxCount, maxAxisDelta, nativeMotionCount: native.summary.motionEventCount, webMotionCount: web.summary.motionEventCount, nativeExecutedPathPointCount: native.summary.executedPathPointCount, webExecutedPathPointCount: web.summary.executedPathPointCount, webPlannerRuntimeReady: web.summary.plannerRuntimeReady, }, tolerances: { axis: 0.001, feedRate: 0.01, }, diagnostics, mismatches, }; } function compareNumber(field, nativeValue, webValue, tolerance, mismatches) { const delta = Math.abs(Number(nativeValue) - Number(webValue)); if (delta > tolerance) { mismatches.push({ kind: "summary", field, native: nativeValue, web: webValue, delta }); } } function extractDiagnostics(resultText) { const diagnostics = {}; for (const line of String(resultText).split(/\r?\n/)) { if (line.startsWith("fiveaxis_hal_switchkins:")) { const rc = line.match(/\brc=([-+0-9.eE]+)/); const found = line.match(/\bfound=([-+0-9.eE]+)/); const value = line.match(/\bvalue=([-+0-9.eE]+)/); diagnostics.fiveaxis_hal_switchkins = { rc: rc ? Number(rc[1]) : null, found: found ? Number(found[1]) : null, value: value ? Number(value[1]) : null, }; continue; } if (!line.startsWith("fiveaxis_")) continue; const equals = line.indexOf("="); if (equals < 0) continue; diagnostics[line.slice(0, equals)] = line.slice(equals + 1); } return diagnostics; } function programLineMap(programText) { const map = new Map(); String(programText).split(/\r?\n/).forEach((line, index) => { map.set(index + 1, line.trim() || "(blank)"); }); return map; } function stripComments(line) { return String(line) .replace(/\([^)]*\)/g, " ") .replace(/;.*$/g, " "); } function vectorLength(values) { return Math.sqrt(values.reduce((total, value) => total + value * value, 0)); } function firstFiniteLine(motion) { return motion.find((event) => Number.isFinite(Number(event.line)))?.line ?? null; } function lastFiniteLine(motion) { return [...motion].reverse().find((event) => Number.isFinite(Number(event.line)))?.line ?? null; } function writeJson(path, value) { mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); } function sha256(text) { return createHash("sha256").update(text).digest("hex"); } function parseArgs(args) { const parsed = { cases: [], outputDir: null, }; for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === "--out" || arg === "--output-dir") { parsed.outputDir = args[++index]; continue; } if (arg === "--case") { parsed.cases.push(args[++index]); continue; } if (arg === "--cases") { parsed.cases.push(...String(args[++index] || "").split(",").filter(Boolean)); continue; } if (arg === "--help" || arg === "-h") { console.log("Usage: node tools/collect-linuxcnc-web-parity.mjs [--out DIR] [--case file.ngc] [--cases a.ngc,b.ngc]"); process.exit(0); } parsed.cases.push(arg); } return parsed; }