Initial wasm simulator checkpoint
This commit is contained in:
221
web/src/app.js
Normal file
221
web/src/app.js
Normal file
@@ -0,0 +1,221 @@
|
||||
import { createWasmSimulator } from "./wasm-core.js";
|
||||
|
||||
const elements = {
|
||||
wasmState: document.querySelector("#wasmState"),
|
||||
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 = [];
|
||||
|
||||
function setStatus(node, text, alarm = false) {
|
||||
node.textContent = text;
|
||||
node.classList.toggle("alarm", alarm);
|
||||
}
|
||||
|
||||
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 getSimulator() {
|
||||
if (!simulatorPromise) {
|
||||
simulatorPromise = createWasmSimulator();
|
||||
}
|
||||
return simulatorPromise;
|
||||
}
|
||||
|
||||
async function parseProgram() {
|
||||
setStatus(elements.programState, "RUN");
|
||||
try {
|
||||
const simulator = await getSimulator();
|
||||
setStatus(elements.wasmState, "WASM ONLINE");
|
||||
const events = simulator.parse(elements.input.value, "linuxcnc", {
|
||||
backend: elements.backendSelect.value,
|
||||
});
|
||||
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) {
|
||||
setStatus(elements.wasmState, "WASM OFFLINE", true);
|
||||
setStatus(elements.programState, "ALARM", true);
|
||||
log(error.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelectorAll("[data-mode]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
elements.modeState.textContent = button.dataset.mode.toUpperCase();
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
window.addEventListener("resize", resizeCanvas);
|
||||
|
||||
updateLineCount();
|
||||
resizeCanvas();
|
||||
62
web/src/index.ts
Normal file
62
web/src/index.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
export type CncDialect = "linuxcnc" | "fanuc" | "siemens";
|
||||
|
||||
export type CncPose = {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
a: number;
|
||||
b: number;
|
||||
c: number;
|
||||
u: number;
|
||||
v: number;
|
||||
w: number;
|
||||
};
|
||||
|
||||
export type CncEventType =
|
||||
| "error"
|
||||
| "comment"
|
||||
| "set-units"
|
||||
| "set-plane"
|
||||
| "set-feed"
|
||||
| "set-spindle"
|
||||
| "tool-change"
|
||||
| "dwell"
|
||||
| "rapid"
|
||||
| "linear-feed"
|
||||
| "arc-feed"
|
||||
| "probe"
|
||||
| "program-end"
|
||||
| "rtcp-pivot"
|
||||
| "kinematics-switch"
|
||||
| "rtcp-state"
|
||||
| "set-g5x-offset"
|
||||
| "set-g92-offset"
|
||||
| "set-xy-rotation";
|
||||
|
||||
export type CncEvent = {
|
||||
type: CncEventType;
|
||||
line: number;
|
||||
plane: number;
|
||||
tool: number;
|
||||
feed: number;
|
||||
spindle: number;
|
||||
dwellSeconds: number;
|
||||
start: CncPose;
|
||||
end: CncPose;
|
||||
center: CncPose;
|
||||
arcTurns: number;
|
||||
reserved: number;
|
||||
};
|
||||
|
||||
export type SimulatorCore = {
|
||||
parse(
|
||||
program: string,
|
||||
dialect: CncDialect,
|
||||
options?: { backend?: string; rtcp?: { enabled: boolean; toolLength: number } },
|
||||
): Promise<CncEvent[]>;
|
||||
};
|
||||
|
||||
export async function createSimulatorCore(): Promise<SimulatorCore> {
|
||||
const { createWasmSimulator } = await import("./wasm-core.js");
|
||||
return createWasmSimulator();
|
||||
}
|
||||
8
web/src/wasm-core.d.ts
vendored
Normal file
8
web/src/wasm-core.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { CncDialect, CncEvent } from "./index";
|
||||
|
||||
export type WasmSimulator = {
|
||||
parse(program: string, dialect?: CncDialect, options?: { backend?: string }): CncEvent[];
|
||||
dispose(): void;
|
||||
};
|
||||
|
||||
export function createWasmSimulator(): Promise<WasmSimulator>;
|
||||
148
web/src/wasm-core.js
Normal file
148
web/src/wasm-core.js
Normal file
@@ -0,0 +1,148 @@
|
||||
const DIALECT = {
|
||||
linuxcnc: 0,
|
||||
fanuc: 1,
|
||||
siemens: 2,
|
||||
};
|
||||
|
||||
const EVENT_TYPE = {
|
||||
1: "error",
|
||||
2: "comment",
|
||||
3: "set-units",
|
||||
4: "set-plane",
|
||||
5: "set-feed",
|
||||
6: "set-spindle",
|
||||
7: "tool-change",
|
||||
8: "dwell",
|
||||
9: "rapid",
|
||||
10: "linear-feed",
|
||||
11: "arc-feed",
|
||||
12: "probe",
|
||||
13: "program-end",
|
||||
14: "rtcp-pivot",
|
||||
15: "kinematics-switch",
|
||||
16: "rtcp-state",
|
||||
17: "set-g5x-offset",
|
||||
18: "set-g92-offset",
|
||||
19: "set-xy-rotation",
|
||||
};
|
||||
|
||||
const EVENT_OFFSETS = {
|
||||
type: 4,
|
||||
line: 8,
|
||||
plane: 12,
|
||||
tool: 16,
|
||||
feed: 24,
|
||||
spindle: 32,
|
||||
dwellSeconds: 40,
|
||||
start: 48,
|
||||
end: 120,
|
||||
center: 192,
|
||||
arcTurns: 264,
|
||||
reserved: 268,
|
||||
};
|
||||
|
||||
function readPose(view, offset) {
|
||||
return {
|
||||
x: view.getFloat64(offset + 0, true),
|
||||
y: view.getFloat64(offset + 8, true),
|
||||
z: view.getFloat64(offset + 16, true),
|
||||
a: view.getFloat64(offset + 24, true),
|
||||
b: view.getFloat64(offset + 32, true),
|
||||
c: view.getFloat64(offset + 40, true),
|
||||
u: view.getFloat64(offset + 48, true),
|
||||
v: view.getFloat64(offset + 56, true),
|
||||
w: view.getFloat64(offset + 64, true),
|
||||
};
|
||||
}
|
||||
|
||||
function readEvent(module, ptr) {
|
||||
const view = new DataView(module.HEAPU8.buffer, ptr, 272);
|
||||
return {
|
||||
type: EVENT_TYPE[view.getInt32(EVENT_OFFSETS.type, true)] ?? "unknown",
|
||||
line: view.getInt32(EVENT_OFFSETS.line, true),
|
||||
plane: view.getInt32(EVENT_OFFSETS.plane, true),
|
||||
tool: view.getInt32(EVENT_OFFSETS.tool, true),
|
||||
feed: view.getFloat64(EVENT_OFFSETS.feed, true),
|
||||
spindle: view.getFloat64(EVENT_OFFSETS.spindle, true),
|
||||
dwellSeconds: view.getFloat64(EVENT_OFFSETS.dwellSeconds, true),
|
||||
start: readPose(view, EVENT_OFFSETS.start),
|
||||
end: readPose(view, EVENT_OFFSETS.end),
|
||||
center: readPose(view, EVENT_OFFSETS.center),
|
||||
arcTurns: view.getInt32(EVENT_OFFSETS.arcTurns, true),
|
||||
reserved: view.getInt32(EVENT_OFFSETS.reserved, true),
|
||||
};
|
||||
}
|
||||
|
||||
async function loadModuleFactory() {
|
||||
const module = await import("/cnc_sim.js");
|
||||
return module.default ?? module.createCncSimModule ?? globalThis.createCncSimModule;
|
||||
}
|
||||
|
||||
export async function createWasmSimulator() {
|
||||
const createModule = await loadModuleFactory();
|
||||
if (!createModule) {
|
||||
throw new Error("cnc_sim.js did not export createCncSimModule");
|
||||
}
|
||||
|
||||
const module = await createModule();
|
||||
const create = module.cwrap("cnc_sim_create", "number", []);
|
||||
const destroy = module.cwrap("cnc_sim_destroy", null, ["number"]);
|
||||
const reset = module.cwrap("cnc_sim_reset", null, ["number"]);
|
||||
const setDialect = module.cwrap("cnc_sim_set_dialect", "number", ["number", "number"]);
|
||||
const setCallback = module.cwrap("cnc_sim_set_event_callback", "number", ["number", "number", "number"]);
|
||||
const loadConfig = module.cwrap("cnc_sim_load_config_json", "number", ["number", "number", "number"]);
|
||||
const parseProgram = module.cwrap("cnc_sim_parse_program", "number", ["number", "number", "number"]);
|
||||
const lastError = module.cwrap("cnc_sim_last_error", "number", ["number"]);
|
||||
|
||||
const handle = create();
|
||||
let callbackPtr = 0;
|
||||
|
||||
return {
|
||||
parse(program, dialect = "linuxcnc", options = {}) {
|
||||
const events = [];
|
||||
reset(handle);
|
||||
setDialect(handle, DIALECT[dialect] ?? DIALECT.linuxcnc);
|
||||
|
||||
const config = JSON.stringify({
|
||||
backend: options.backend ?? "smoke",
|
||||
...(options.rtcp ? { rtcp: options.rtcp } : {}),
|
||||
});
|
||||
const configBytes = module.lengthBytesUTF8(config) + 1;
|
||||
const configPtr = module._malloc(configBytes);
|
||||
module.stringToUTF8(config, configPtr, configBytes);
|
||||
const configRc = loadConfig(handle, configPtr, configBytes - 1);
|
||||
module._free(configPtr);
|
||||
if (configRc !== 0) {
|
||||
throw new Error(module.UTF8ToString(lastError(handle)));
|
||||
}
|
||||
|
||||
if (callbackPtr) {
|
||||
module.removeFunction(callbackPtr);
|
||||
}
|
||||
callbackPtr = module.addFunction((eventPtr) => {
|
||||
events.push(readEvent(module, eventPtr));
|
||||
return 0;
|
||||
}, "iii");
|
||||
setCallback(handle, callbackPtr, 0);
|
||||
|
||||
const bytes = module.lengthBytesUTF8(program) + 1;
|
||||
const ptr = module._malloc(bytes);
|
||||
module.stringToUTF8(program, ptr, bytes);
|
||||
const rc = parseProgram(handle, ptr, bytes - 1);
|
||||
module._free(ptr);
|
||||
|
||||
if (rc !== 0) {
|
||||
throw new Error(module.UTF8ToString(lastError(handle)));
|
||||
}
|
||||
return events;
|
||||
},
|
||||
|
||||
dispose() {
|
||||
if (callbackPtr) {
|
||||
module.removeFunction(callbackPtr);
|
||||
callbackPtr = 0;
|
||||
}
|
||||
destroy(handle);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user