import { createWasmSimulator } from "./wasm-core.js"; const elements = { wasmState: document.querySelector("#wasmState"), storageState: document.querySelector("#storageState"), modeState: document.querySelector("#modeState"), unitState: document.querySelector("#unitState"), programState: document.querySelector("#programState"), lineCount: document.querySelector("#lineCount"), input: document.querySelector("#programInput"), backendSelect: document.querySelector("#backendSelect"), alarmList: document.querySelector("#alarmList"), canvas: document.querySelector("#toolpathCanvas"), parseBtn: document.querySelector("#parseBtn"), resetBtn: document.querySelector("#resetBtn"), holdBtn: document.querySelector("#holdBtn"), stopBtn: document.querySelector("#stopBtn"), axes: { x: document.querySelector("#axisX"), y: document.querySelector("#axisY"), z: document.querySelector("#axisZ"), a: document.querySelector("#axisA"), b: document.querySelector("#axisB"), c: document.querySelector("#axisC"), }, }; let simulatorPromise = null; let lastEvents = []; let isParsing = false; let hasUserEditedProgram = false; let saveProgramTimer = 0; let saveProgramSequence = 0; let saveProgramPromise = Promise.resolve(); let startupPromise = Promise.resolve(); const programPath = "programs/current.ngc"; // LinuxCNC source basis: interp_internal.hh defines RS274NGC_PARAMETER_FILE_NAME_DEFAULT. const linuxCncParameterFileName = "rs274ngc.var"; const parameterPath = `parameters/${linuxCncParameterFileName}`; const linuxCncBackend = "linuxcnc-rs274"; const opfsOptions = { required: true, mountPoint: "/cnc", workspacePath: "cnc-simulator" }; function setStatus(node, text, alarm = false) { node.textContent = text; node.classList.toggle("alarm", alarm); } function setRuntimeReady(simulator) { setStatus(elements.wasmState, "WASM ONLINE"); if (simulator.fs?.opfs) { const storageState = elements.storageState.textContent; if (storageState === "OPFS SAVING" || storageState.startsWith("OPFS SAVED")) { setStatus(elements.storageState, storageState); } else { setStatus(elements.storageState, "OPFS READY"); } } else { setStatus(elements.storageState, "OPFS OFF", true); } } function setRuntimeUnavailable() { setStatus(elements.wasmState, "WASM OFFLINE", true); setStatus(elements.storageState, "OPFS OFF", true); } function setControlsEnabled(enabled) { elements.parseBtn.disabled = !enabled; elements.resetBtn.disabled = !enabled; elements.holdBtn.disabled = !enabled; elements.stopBtn.disabled = !enabled; elements.backendSelect.disabled = !enabled; } function setStorageSaved(size) { setStatus(elements.storageState, Number.isFinite(size) ? `OPFS SAVED ${size}B` : "OPFS SAVED"); } function setProgramReady() { if (elements.programState.textContent !== "ALARM") { setStatus(elements.programState, "READY"); } } async function statIfExists(opfs, path) { if (!(await opfs.exists(path))) { return null; } return opfs.stat(path); } async function logWorkspaceState(opfs) { const programStat = await statIfExists(opfs, programPath); if (programStat?.kind === "file") { log(`OPFS PROGRAM ${programStat.size}B`); } const parameterStat = await statIfExists(opfs, parameterPath); if (parameterStat?.kind === "file") { log(`OPFS PARAMETERS ${parameterStat.size}B`); } else { log("OPFS PARAMETERS PENDING"); } } function log(message, error = false) { const line = document.createElement("div"); line.className = `alarm-line${error ? " error" : ""}`; line.textContent = message; elements.alarmList.prepend(line); } function formatAxis(value) { return Number.isFinite(value) ? value.toFixed(3) : "0.000"; } function updateDro(pose) { elements.axes.x.value = formatAxis(pose.x); elements.axes.y.value = formatAxis(pose.y); elements.axes.z.value = formatAxis(pose.z); elements.axes.a.value = formatAxis(pose.a); elements.axes.b.value = formatAxis(pose.b); elements.axes.c.value = formatAxis(pose.c); } function resizeCanvas() { const rect = elements.canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio || 1; elements.canvas.width = Math.max(1, Math.floor(rect.width * dpr)); elements.canvas.height = Math.max(1, Math.floor(rect.height * dpr)); drawToolpath(lastEvents); } function motionEvents(events) { return events.filter((event) => event.type === "rapid" || event.type === "linear-feed" || event.type === "arc-feed"); } function boundsFor(events) { const motions = motionEvents(events); if (!motions.length) { return { minX: -50, maxX: 50, minY: -50, maxY: 50 }; } const xs = []; const ys = []; for (const event of motions) { xs.push(event.start.x, event.end.x, event.center.x); ys.push(event.start.y, event.end.y, event.center.y); } return { minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys), }; } function angleDelta(startAngle, endAngle, ccw) { const full = Math.PI * 2; let delta = endAngle - startAngle; if (ccw && delta <= 0) { delta += full; } if (!ccw && delta >= 0) { delta -= full; } return delta; } function drawGrid(ctx, width, height, dpr) { ctx.fillStyle = "#090b0c"; ctx.fillRect(0, 0, width, height); ctx.strokeStyle = "#1e262a"; ctx.lineWidth = 1 * dpr; const step = 40 * dpr; ctx.beginPath(); for (let x = 0; x < width; x += step) { ctx.moveTo(x, 0); ctx.lineTo(x, height); } for (let y = 0; y < height; y += step) { ctx.moveTo(0, y); ctx.lineTo(width, y); } ctx.stroke(); } function drawToolpath(events) { const canvas = elements.canvas; const ctx = canvas.getContext("2d"); const dpr = window.devicePixelRatio || 1; const width = canvas.width; const height = canvas.height; drawGrid(ctx, width, height, dpr); const bounds = boundsFor(events); const margin = 44 * dpr; const spanX = Math.max(1, bounds.maxX - bounds.minX); const spanY = Math.max(1, bounds.maxY - bounds.minY); const scale = Math.min((width - margin * 2) / spanX, (height - margin * 2) / spanY); const toScreen = (pose) => ({ x: margin + (pose.x - bounds.minX) * scale, y: height - margin - (pose.y - bounds.minY) * scale, }); for (const event of motionEvents(events)) { const start = toScreen(event.start); const end = toScreen(event.end); ctx.beginPath(); ctx.lineWidth = event.type === "rapid" ? 1.5 * dpr : 2.4 * dpr; ctx.strokeStyle = event.type === "rapid" ? "#34d058" : event.type === "arc-feed" ? "#39b7d7" : "#f2b84b"; if (event.type === "arc-feed") { const center = toScreen(event.center); const radius = Math.hypot(start.x - center.x, start.y - center.y); const startAngle = Math.atan2(start.y - center.y, start.x - center.x); const endAngle = Math.atan2(end.y - center.y, end.x - center.x); const ccw = event.arcTurns > 0; if (Number.isFinite(radius) && radius > 0.001) { const delta = angleDelta(startAngle, endAngle, ccw); ctx.arc(center.x, center.y, radius, startAngle, startAngle + delta, !ccw); } else { ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y); } } else { ctx.moveTo(start.x, start.y); ctx.lineTo(end.x, end.y); } ctx.stroke(); } } function updateLineCount() { const count = elements.input.value.split(/\r?\n/).length; elements.lineCount.textContent = `${count} LINES`; } async function getOpfsWorkspace() { const simulator = await getSimulator(); setRuntimeReady(simulator); if (!simulator.fs.opfs) { throw new Error("OPFS workspace is required for browser program parsing"); } return simulator.fs.opfs; } async function getSimulator() { if (!simulatorPromise) { simulatorPromise = createWasmSimulator({ opfs: opfsOptions, }).then((simulator) => { setRuntimeReady(simulator); return simulator; }).catch((error) => { simulatorPromise = null; globalThis.__cncSimulatorForTest = null; setRuntimeUnavailable(); throw error; }); globalThis.__cncSimulatorForTest = simulatorPromise; } return simulatorPromise; } async function parseProgram() { if (isParsing) { return; } isParsing = true; setControlsEnabled(false); setStatus(elements.programState, "RUN"); let runtimeReady = false; let savedProgramSize = null; try { await startupPromise.catch(() => {}); const simulator = await getSimulator(); runtimeReady = true; setRuntimeReady(simulator); const parseOptions = { backend: linuxCncBackend, }; if (!simulator.fs.opfs) { throw new Error("OPFS workspace is required for browser program parsing"); } elements.backendSelect.value = linuxCncBackend; await saveProgramPromise.catch(() => {}); await simulator.fs.opfs.writeFile(programPath, elements.input.value); const programStat = await simulator.fs.opfs.stat(programPath); savedProgramSize = programStat.kind === "file" ? programStat.size : null; setStorageSaved(savedProgramSize); const events = await simulator.parseFileWithParameterFile(programPath, parameterPath, "linuxcnc", parseOptions); const parameterStat = await simulator.fs.opfs.stat(parameterPath); setStorageSaved(parameterStat.size); log(`OPFS PARAMETERS ${parameterStat.size}B`); lastEvents = events; drawToolpath(events); const finalMove = [...motionEvents(events)].pop(); if (finalMove) { updateDro(finalMove.end); } const unitEvent = [...events].reverse().find((event) => event.type === "set-units"); if (unitEvent) { elements.unitState.textContent = unitEvent.feed === 25.4 ? "INCH" : "MM"; } setStatus(elements.programState, "END"); log(`OK ${events.length} EVENTS`); } catch (error) { if (!runtimeReady) { setRuntimeUnavailable(); } if (savedProgramSize !== null) { log(`OPFS PROGRAM SAVED ${savedProgramSize}B`); } setStatus(elements.programState, "ALARM", true); log(error.message, true); } finally { isParsing = false; setControlsEnabled(true); } } async function saveProgramDraft() { const sequence = ++saveProgramSequence; saveProgramPromise = saveProgramPromise.catch(() => {}).then(async () => { const opfs = await getOpfsWorkspace(); const program = elements.input.value; setStatus(elements.storageState, "OPFS SAVING"); await opfs.writeFile(programPath, program); if (sequence === saveProgramSequence) { const stat = await opfs.stat(programPath); setStorageSaved(stat.size); setProgramReady(); } }); try { await saveProgramPromise; } catch (error) { setStatus(elements.storageState, "OPFS ALARM", true); log(error.message, true); } } function scheduleProgramSave() { window.clearTimeout(saveProgramTimer); saveProgramTimer = window.setTimeout(() => { void saveProgramDraft(); }, 350); } function flushProgramSave() { window.clearTimeout(saveProgramTimer); if (hasUserEditedProgram) { void saveProgramDraft(); } } async function restoreProgram() { try { const opfs = await getOpfsWorkspace(); if (!(await opfs.exists(programPath))) { await opfs.writeFile(programPath, elements.input.value); const stat = await opfs.stat(programPath); setStorageSaved(stat.size); setProgramReady(); log(`OPFS PROGRAM CREATED ${stat.size}B`); await logWorkspaceState(opfs); return; } const program = new TextDecoder().decode(await opfs.readFile(programPath)); if (hasUserEditedProgram) { return; } elements.input.value = program; updateLineCount(); const stat = await opfs.stat(programPath); setStorageSaved(stat.size); setProgramReady(); log("OPFS PROGRAM RESTORED"); await logWorkspaceState(opfs); } catch (error) { if (error?.name !== "NotFoundError") { log(error.message, true); } } } document.querySelectorAll("[data-mode]").forEach((button) => { button.addEventListener("click", () => { elements.modeState.textContent = button.dataset.mode.toUpperCase(); }); }); elements.backendSelect.value = linuxCncBackend; elements.parseBtn.addEventListener("click", parseProgram); elements.resetBtn.addEventListener("click", () => { lastEvents = []; updateDro({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); drawToolpath([]); setStatus(elements.programState, "RESET"); }); elements.holdBtn.addEventListener("click", () => setStatus(elements.programState, "HOLD")); elements.stopBtn.addEventListener("click", () => setStatus(elements.programState, "STOP", true)); elements.input.addEventListener("input", () => { updateLineCount(); hasUserEditedProgram = true; setStatus(elements.programState, "EDIT"); scheduleProgramSave(); }); document.addEventListener("keydown", (event) => { if (!(event.ctrlKey || event.metaKey) || event.altKey) { return; } if (event.key === "s") { event.preventDefault(); void saveProgramDraft(); } else if (event.key === "Enter") { event.preventDefault(); void parseProgram(); } }); window.addEventListener("resize", resizeCanvas); window.addEventListener("pagehide", flushProgramSave); document.addEventListener("visibilitychange", () => { if (document.visibilityState === "hidden") { flushProgramSave(); } }); updateLineCount(); resizeCanvas(); startupPromise = restoreProgram();