import { createLinuxCncInterpSdk } from "../../sdk/src/index.js"; import { DEFAULT_SIMULATION_PROGRAM, DEFAULT_SIMULATION_PROGRAM_ID, SIMULATION_TEST_PROGRAMS, getSimulationTestProgram, getSimulationTestPrograms, } from "./programs/index.js"; export { DEFAULT_SIMULATION_PROGRAM, DEFAULT_SIMULATION_PROGRAM_ID, SIMULATION_TEST_PROGRAMS, getSimulationTestProgram, getSimulationTestPrograms, }; const AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"]; const PLANE_AXIS_MAP = { 170: ["x", "y", "z"], 180: ["x", "z", "y"], 190: ["y", "z", "x"], }; function readCanonicalNumber(line, name) { const match = line.match(new RegExp(`(?:^| )${name}=([-+0-9.eE]+)`)); return match ? Number(match[1]) : null; } function programLineMap(programText) { const lines = new Map(); programText.split(/\r?\n/).forEach((line, index) => { lines.set(index + 1, line.trim() || "(blank)"); }); return lines; } export function parseLinuxCncCanonicalMotion(resultText, programText = "") { const axes = Object.fromEntries(AXES.map((axis) => [axis, 0])); const sourceLines = programLineMap(programText); const motion = []; let activePlane = 170; for (const line of String(resultText).split("\n")) { 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; } } 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"); motion.push({ type: event[1], line: Number.isFinite(sourceLine) ? sourceLine : null, statement: Number.isFinite(sourceLine) ? (sourceLines.get(sourceLine) ?? "-") : "-", axes: { ...axes }, raw: line, }); } return motion; } export function createSimulationSummary({ programText, resultText, motion }) { const canonicalLines = String(resultText).split("\n").filter((line) => line.startsWith("canon_event=")); const finalAxes = motion.at(-1)?.axes ?? Object.fromEntries(AXES.map((axis) => [axis, 0])); const motionTypes = [...new Set(motion.map(({ type }) => type))]; return { apiName: "real-browser-simulation-summary", summaryVersion: 1, ready: motion.length > 0, phase: motion.length > 0 ? "ready" : "blocked", programLineCount: programText.split(/\r?\n/).filter(Boolean).length, canonicalEventCount: canonicalLines.length, motionEventCount: motion.length, motionTypes, finalAxes, rows: [ { id: "runtime", label: "Runtime", value: "LinuxCNC interpreter WASM" }, { id: "program-lines", label: "Program lines", value: `${programText.split(/\r?\n/).filter(Boolean).length}` }, { id: "canonical-events", label: "Canonical events", value: `${canonicalLines.length}` }, { id: "motion-events", label: "Motion events", value: `${motion.length}` }, { id: "motion-types", label: "Motion types", value: motionTypes.join(", ") || "-" }, { id: "final-position", label: "Final XYZ", value: formatPosition(finalAxes) }, ], }; } export function createToolpathViewBox(motion, padding = 0.25) { const points = motion.map(({ axes }) => ({ x: axes.x ?? 0, y: axes.y ?? 0 })); if (points.length === 0) { return { minX: -1, minY: -1, width: 2, height: 2 }; } const xs = points.map(({ x }) => x); const ys = points.map(({ y }) => y); const minX = Math.min(...xs) - padding; const maxX = Math.max(...xs) + padding; const minY = Math.min(...ys) - padding; const maxY = Math.max(...ys) + padding; return { minX, minY, width: Math.max(0.1, maxX - minX), height: Math.max(0.1, maxY - minY), }; } export function createToolpathPolylinePoints(motion) { return motion.map(({ axes }) => `${axes.x ?? 0},${-(axes.y ?? 0)}`).join(" "); } export function clampPlaybackIndex(motion, index) { if (motion.length === 0) { return -1; } if (!Number.isFinite(index)) { return motion.length - 1; } return Math.min(Math.max(Math.trunc(index), 0), motion.length - 1); } export function createPlaybackFrame(state, index = state.motion.length - 1) { const frameIndex = clampPlaybackIndex(state.motion, index); const activeMotion = frameIndex >= 0 ? state.motion[frameIndex] : null; const visibleMotion = frameIndex >= 0 ? state.motion.slice(0, frameIndex + 1) : []; const axes = activeMotion?.axes ?? Object.fromEntries(AXES.map((axis) => [axis, 0])); return { apiName: "real-browser-simulation-playback-frame", frameVersion: 1, index: frameIndex, step: frameIndex + 1, total: state.motion.length, progress: state.motion.length > 0 ? Math.round(((frameIndex + 1) / state.motion.length) * 100) : 0, activeMotion, activeLine: activeMotion?.line ?? null, activeStatement: activeMotion?.statement ?? "-", axes, visibleMotion, fullMotion: state.motion, }; } export function formatPosition(axes = {}) { return `X ${formatAxis(axes.x)} Y ${formatAxis(axes.y)} Z ${formatAxis(axes.z)}`; } function formatAxis(value) { return Number.isFinite(value) ? Number(value).toFixed(3) : "0.000"; } function setText(documentRef, selector, value) { for (const node of documentRef.querySelectorAll(selector)) { node.textContent = value; } } function renderRows(documentRef, rows) { const container = documentRef.querySelector("[data-simulation-summary-rows]"); if (!container) { return; } container.textContent = ""; for (const row of rows) { const term = documentRef.createElement("dt"); term.textContent = row.label; term.dataset.simulationRow = row.id; const detail = documentRef.createElement("dd"); detail.textContent = row.value; detail.dataset.simulationValue = row.id; container.append(term, detail); } } function renderProgramSelector(documentRef, programId) { const selector = documentRef.querySelector("[data-program-selector]"); if (!selector) { return; } selector.textContent = ""; for (const program of SIMULATION_TEST_PROGRAMS) { const option = documentRef.createElement("option"); option.value = program.id; option.textContent = `${program.category}: ${program.label}`; option.selected = program.id === programId; selector.append(option); } } function renderProgramLines(documentRef, programText, activeLine) { const container = documentRef.querySelector("[data-program-lines]"); if (!container) { return; } container.textContent = ""; programText.split(/\r?\n/).forEach((line, index) => { if (!line && index === programText.split(/\r?\n/).length - 1) { return; } const row = documentRef.createElement("div"); row.dataset.programLine = `${index + 1}`; row.dataset.active = activeLine === index + 1 ? "true" : "false"; const number = documentRef.createElement("span"); number.textContent = `${index + 1}`; const text = documentRef.createElement("code"); text.textContent = line || " "; row.append(number, text); container.append(row); }); } function renderAxisReadout(documentRef, axes) { for (const axis of ["x", "y", "z", "a", "b", "c"]) { setText(documentRef, `[data-axis="${axis}"]`, formatAxis(axes?.[axis])); } } function renderToolpath(documentRef, motion, visibleMotion = motion) { const svg = documentRef.querySelector("[data-toolpath-svg]"); const polyline = documentRef.querySelector("[data-toolpath-polyline]"); const executedPolyline = documentRef.querySelector("[data-toolpath-executed-polyline]"); const head = documentRef.querySelector("[data-toolpath-head]"); if (!svg || !polyline || !head) { return; } const viewBox = createToolpathViewBox(motion); svg.setAttribute( "viewBox", `${viewBox.minX} ${-(viewBox.minY + viewBox.height)} ${viewBox.width} ${viewBox.height}`, ); polyline.setAttribute("points", createToolpathPolylinePoints(motion)); executedPolyline?.setAttribute("points", createToolpathPolylinePoints(visibleMotion)); const last = visibleMotion.at(-1)?.axes ?? motion[0]?.axes ?? { x: 0, y: 0 }; head.setAttribute("cx", `${last.x ?? 0}`); head.setAttribute("cy", `${-(last.y ?? 0)}`); } function renderMotionTable(documentRef, motion, activeIndex = null) { const body = documentRef.querySelector("[data-motion-rows]"); if (!body) { return; } body.textContent = ""; for (const [index, item] of motion.entries()) { const row = documentRef.createElement("tr"); row.dataset.motionRow = `${index}`; row.dataset.active = activeIndex === index ? "true" : "false"; row.dataset.executed = activeIndex !== null && index <= activeIndex ? "true" : "false"; for (const value of [ item.type, item.line ?? "-", formatPosition(item.axes), item.statement, ]) { const cell = documentRef.createElement("td"); cell.textContent = `${value}`; row.append(cell); } body.append(row); } } export function renderSimulationPlaybackFrame(documentRef, state, index = state.motion.length - 1) { const frame = createPlaybackFrame(state, index); documentRef.body.dataset.playbackIndex = `${frame.index}`; documentRef.body.dataset.playbackComplete = frame.index === frame.total - 1 ? "true" : "false"; setText(documentRef, "[data-playback-step]", `${frame.step}`); setText(documentRef, "[data-playback-total]", `${frame.total}`); setText(documentRef, "[data-playback-progress]", `${frame.progress}%`); setText(documentRef, "[data-active-line]", `${frame.activeLine ?? "-"}`); setText(documentRef, "[data-active-statement]", frame.activeStatement); renderProgramLines(documentRef, state.programText, frame.activeLine); renderAxisReadout(documentRef, frame.axes); renderToolpath(documentRef, frame.fullMotion, frame.visibleMotion); renderMotionTable(documentRef, frame.fullMotion, frame.index); return frame; } export function renderSimulationState(documentRef, state) { documentRef.body.dataset.simulationReady = state.summary.ready ? "true" : "false"; documentRef.body.dataset.simulationProgramId = state.program?.id ?? "custom"; documentRef.body.dataset.simulationProgramSource = state.program?.source ?? "custom"; setText(documentRef, "[data-simulation-status]", state.summary.ready ? "ready" : "blocked"); setText(documentRef, "[data-runtime-status]", state.summary.rows[0].value); setText(documentRef, "[data-selected-program]", state.program?.label ?? "Custom program"); setText(documentRef, "[data-program-source]", state.program?.sourceLabel ?? "Custom G-code text"); setText(documentRef, "[data-canonical-output]", state.resultText); renderProgramSelector(documentRef, state.program?.id ?? "custom"); renderRows(documentRef, state.summary.rows); renderSimulationPlaybackFrame(documentRef, state, state.motion.length - 1); } export async function runRealBrowserSimulation({ documentRef = document, programId = DEFAULT_SIMULATION_PROGRAM_ID, programText = null, interpFactory = createLinuxCncInterpSdk, } = {}) { const program = programText === null ? getSimulationTestProgram(programId) : null; const resolvedProgramText = program?.text ?? programText ?? DEFAULT_SIMULATION_PROGRAM; const interp = await interpFactory(); const resultText = interp.runProgram(resolvedProgramText); const motion = parseLinuxCncCanonicalMotion(resultText, resolvedProgramText); const summary = createSimulationSummary({ programText: resolvedProgramText, resultText, motion }); const state = { apiName: "real-browser-simulation-state", stateVersion: 1, program: program ? { ...program, source: "builtin", sourceLabel: "Built-in test program", text: undefined } : { id: "custom", label: "Custom G-code program", category: "Custom", source: "custom", sourceLabel: "Custom G-code text", }, programText: resolvedProgramText, resultText, motion, summary, }; renderSimulationState(documentRef, state); return state; }