433 lines
13 KiB
JavaScript
433 lines
13 KiB
JavaScript
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();
|