Files
linuxcncWebWASM/web/app/app.mjs

287 lines
16 KiB
JavaScript

import { WorkerClient } from "./worker-client.mjs";
import { CommandController } from "./controls.mjs";
import { EMPTY_STATE, reduceWorkerState, renderStatePanel } from "./state-panel.mjs";
import { ProjectWorkflow } from "./project-workflow.mjs";
import { renderRecoveryPanel } from "./recovery-panel.mjs";
import { renderDevicePanel } from "./device-panel.mjs";
import { renderHalPanel } from "./hal-panel.mjs";
import { renderMachineView } from "./machine-view.mjs";
import { SIM_CASE_CATALOG } from "../workspace/sim-case-catalog.mjs";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { renderDisplayFamily } from "./display-family.mjs";
import { appendAxisIcon } from "./source-assets.mjs";
const SAMPLE_PROGRAM = ["G21 G90", "G0 X0 Y0 Z0", "G1 X10 Y0 F120", "G1 X10 Y10", "G1 X0 Y10", "G1 X0 Y0", "M2"].join("\n");
const DEFAULT_CASE = "linuxcnc/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini";
function element(document, tag, className, text = "") {
const node = document.createElement(tag);
if (className) node.className = className;
if (text) node.textContent = text;
return node;
}
function statusLine(document) {
const node = element(document, "p", "status-line", "Disconnected");
node.setAttribute("role", "status");
return node;
}
function axisButton(document, label, asset, className = "control-button") {
const button = element(document, "button", className);
button.type = "button";
return appendAxisIcon(document, button, asset, label);
}
export function mountApp({ document = globalThis.document, client = null } = {}) {
if (!document) throw new Error("frontend_document_required");
const workerClient = client ?? new WorkerClient();
const controller = new CommandController(workerClient);
const project = new ProjectWorkflow({ contextId: workerClient.contextId, client: workerClient });
let workerState = { ...EMPTY_STATE };
let connected = false;
let loadedCase = null;
const root = document.querySelector("#app") ?? document.body;
root.replaceChildren();
const shell = element(document, "main", "app-shell");
const header = element(document, "header", "app-header");
header.append(element(document, "div", "brand", "LinuxCNC Web/WASM"));
const sourceMark = element(document, "span", "source-mark", "AXIS");
sourceMark.title = "AXIS source controls";
header.append(sourceMark);
const context = element(document, "span", "context-chip", workerClient.contextId);
header.append(context);
shell.append(header);
const content = element(document, "section", "workspace-grid");
const sessionPanel = element(document, "section", "panel operator-panel");
sessionPanel.append(element(document, "h1", "panel-title", "AXIS control station"));
const status = statusLine(document);
sessionPanel.append(status);
let clock = null;
let clockBusy = false;
const stopClock = () => {
if (clock !== null) globalThis.clearInterval(clock);
clock = null;
clockBusy = false;
};
const startClock = () => {
if (clock !== null) return;
clock = globalThis.setInterval(async () => {
if (clockBusy) return;
clockBusy = true;
try {
const response = await workerClient.step(1);
const program = response.payload?.program;
status.textContent = program ? `Tick ${response.simulation_tick} | line ${program.line}/${program.line_count}` : `Tick ${response.simulation_tick}`;
if (program && ["completed", "aborted", "error"].includes(program.program_status)) stopClock();
} catch (error) {
stopClock();
status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`;
} finally {
clockBusy = false;
}
}, 50);
};
const ensureSession = async () => {
if (connected) return;
await workerClient.hello();
await workerClient.attach();
connected = true;
};
const casePanel = element(document, "section", "case-browser");
casePanel.append(element(document, "h2", "panel-title", "LinuxCNC sim cases"));
const caseSearch = element(document, "input", "case-search");
caseSearch.type = "search";
caseSearch.placeholder = "Filter cases";
caseSearch.setAttribute("aria-label", "Filter LinuxCNC simulation cases");
const caseSelect = element(document, "select", "case-select");
caseSelect.setAttribute("aria-label", "LinuxCNC simulation case");
const caseSummary = element(document, "p", "case-summary", "");
const caseDiagnostics = element(document, "pre", "case-diagnostics", "");
caseDiagnostics.setAttribute("aria-live", "polite");
const caseLoadButton = axisButton(document, "Load selected case", "tool_open.gif", "control-button primary-control");
const renderCases = () => {
const query = caseSearch.value.trim().toLowerCase();
const selected = caseSelect.value || DEFAULT_CASE;
caseSelect.replaceChildren();
for (const entry of SIM_CASE_CATALOG.filter((candidate) => candidate.path === DEFAULT_CASE)) {
if (query && !`${entry.path} ${entry.label} ${entry.category} ${entry.kinematics ?? ""}`.toLowerCase().includes(query)) continue;
const option = element(document, "option");
option.value = entry.path;
option.textContent = `${entry.status === "ready" ? "STATIC READY" : "BLOCKED"} | ${entry.label} | ${entry.path.replace("linuxcnc/configs/sim/", "")}`;
caseSelect.append(option);
}
if ([...caseSelect.options].some((option) => option.value === selected)) caseSelect.value = selected;
else if (caseSelect.options.length) caseSelect.selectedIndex = 0;
updateCaseSummary();
};
const updateCaseSummary = () => {
const entry = SIM_CASE_CATALOG.find((row) => row.path === caseSelect.value);
if (!entry) { caseSummary.textContent = "No matching case"; return; }
caseSummary.textContent = `${entry.status === "ready" ? "STATIC READY" : "BLOCKED"} | ${entry.coordinates ?? "coordinates in INI/include"} | ${entry.kinematics ?? "kinematics in INI/include"} | ${entry.missing_count} indexed missing resources`;
};
caseSearch.addEventListener("input", renderCases);
let displayFamilyPanel = renderDisplayFamily(document, { casePath: DEFAULT_CASE });
caseSelect.addEventListener("change", () => {
updateCaseSummary();
const nextDisplayFamilyPanel = renderDisplayFamily(document, { casePath: caseSelect.value });
displayFamilyPanel.replaceWith(nextDisplayFamilyPanel);
displayFamilyPanel = nextDisplayFamilyPanel;
});
renderCases();
casePanel.append(caseSearch, caseSelect, caseSummary, caseLoadButton, caseDiagnostics, displayFamilyPanel);
caseLoadButton.addEventListener("click", async () => {
caseLoadButton.disabled = true;
caseDiagnostics.textContent = "Loading case files...";
try {
await ensureSession();
const bundle = await loadSimulationCase({ path: caseSelect.value });
loadedCase = bundle;
caseDiagnostics.textContent = JSON.stringify({ status: bundle.status, entrypoint: bundle.entrypoint, fetched: bundle.fetched.length, missing: bundle.missing, state_hash: bundle.state_hash }, null, 2);
const response = await workerClient.loadWorkspace("/workspace", toWorkerConfiguration(bundle));
status.textContent = `Case loaded: ${bundle.entrypoint} (${response.payload?.configuration?.state ?? "ready"})`;
} catch (error) {
const response = error.response?.payload;
caseDiagnostics.textContent = JSON.stringify(response?.startup ?? response?.preflight ?? { code: error.response?.payload?.code ?? error.message }, null, 2);
status.textContent = `Case blocked: ${error.response?.payload?.code ?? error.message}`;
} finally {
caseLoadButton.disabled = false;
}
});
sessionPanel.append(casePanel);
const programLabel = element(document, "label", "program-label", "G-code program");
const programEditor = element(document, "textarea", "program-editor");
programEditor.value = SAMPLE_PROGRAM;
programEditor.rows = 10;
programEditor.spellcheck = false;
programEditor.setAttribute("aria-label", "G-code program");
programLabel.append(programEditor);
sessionPanel.append(programLabel);
const controls = element(document, "div", "controls");
const buttons = [
["Connect", "tool_power.gif", async () => { await ensureSession(); status.textContent = "Session ready"; }],
["Load program", "tool_open.gif", async () => { const response = await workerClient.loadProgram(programEditor.value, "demo.ngc"); status.textContent = `Program loaded (${response.payload.line_count} lines)`; }],
["Step 1 tick", "tool_step.gif", async () => { const response = await workerClient.step(1); status.textContent = `Tick ${response.simulation_tick}`; }],
["Snapshot", "tool_verify.gif", async () => { const response = await workerClient.snapshot(); status.textContent = `Snapshot tick ${response.payload.tick}`; }],
];
for (const [label, asset, action] of buttons) {
const button = axisButton(document, label, asset);
button.addEventListener("click", () => Promise.resolve(action()).catch((error) => {
status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`;
}));
controls.append(button);
}
sessionPanel.append(controls);
const operationControls = element(document, "div", "operation-controls");
const operationButtons = [
["Run", "tool_run.gif", "run", {}, startClock],
["Restart", "tool_reload.gif", "restart", {}, stopClock],
["Pause", "tool_pause.gif", "pause", {}, stopClock],
["Resume", "resume_inhibit.gif", "resume", {}, startClock],
["Abort", "tool_stop.gif", "abort", {}, stopClock],
["Home", "tool_clear.gif", "home", {}],
["E-stop", "tool_estop.gif", "estop", { active: true }],
["Recover", "tool_power.gif", "recover", {}],
];
for (const [label, asset, action, payload, after] of operationButtons) {
const button = axisButton(document, label, asset, action === "estop" ? "control-button danger-control" : "control-button");
button.addEventListener("click", () => controller.execute(action, payload).then(() => after?.()).catch((error) => {
stopClock();
status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`;
}));
operationControls.append(button);
}
sessionPanel.append(operationControls);
const mdiLabel = element(document, "label", "inline-field", "MDI");
const mdiEditor = element(document, "input", "mdi-editor");
mdiEditor.value = "G0 X0";
mdiEditor.setAttribute("aria-label", "MDI command");
mdiLabel.append(mdiEditor);
const mdiButton = axisButton(document, "Execute MDI", "tool_step.gif");
mdiButton.addEventListener("click", () => controller.execute("mdi", { block: mdiEditor.value }).catch((error) => { status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`; }));
const manual = element(document, "div", "manual-controls");
manual.append(element(document, "h2", "panel-title", "Manual control"));
const axisSelect = element(document, "select", "axis-select");
axisSelect.setAttribute("aria-label", "Jog axis");
for (const axis of ["x", "y", "z"]) { const option = element(document, "option"); option.value = axis; option.textContent = axis.toUpperCase(); axisSelect.append(option); }
const jogSpeed = element(document, "input", "jog-speed");
jogSpeed.type = "number"; jogSpeed.min = "0.01"; jogSpeed.max = "100"; jogSpeed.step = "0.01"; jogSpeed.value = "1"; jogSpeed.setAttribute("aria-label", "Jog speed");
const homeButton = axisButton(document, "Home all", "tool_clear.gif");
homeButton.addEventListener("click", () => controller.execute("home", {}).catch((error) => { status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`; }));
const jogStop = axisButton(document, "Stop jog", "tool_stop.gif");
jogStop.addEventListener("click", () => controller.execute("jogStop", {}));
const jogButtons = element(document, "div", "jog-buttons");
for (const [label, direction] of [["-", -1], ["+", 1]]) {
const button = axisButton(document, `${label} jog`, label === "+" ? "tool_axis_x.gif" : "tool_axis_z.gif");
button.addEventListener("pointerdown", () => controller.execute("jogStart", { axis: axisSelect.value, direction, speed: Number(jogSpeed.value) }).catch((error) => { status.textContent = `Error: ${error.response?.payload?.code ?? error.message}`; }));
button.addEventListener("pointerup", () => controller.execute("jogStop", {}));
button.addEventListener("pointerleave", () => controller.execute("jogStop", {}));
jogButtons.append(button);
}
const feedOverride = element(document, "input", "override-slider");
feedOverride.type = "range"; feedOverride.min = "0"; feedOverride.max = "2"; feedOverride.step = "0.01"; feedOverride.value = "1"; feedOverride.setAttribute("aria-label", "Feed override");
const spindleOverride = element(document, "input", "override-slider");
spindleOverride.type = "range"; spindleOverride.min = "0"; spindleOverride.max = "2"; spindleOverride.step = "0.01"; spindleOverride.value = "1"; spindleOverride.setAttribute("aria-label", "Spindle override");
const overrideButton = axisButton(document, "Apply overrides", "tool_verify.gif");
overrideButton.addEventListener("click", () => controller.execute("override", { feed: Number(feedOverride.value), spindle: Number(spindleOverride.value) }));
manual.append(mdiLabel, mdiButton, axisSelect, jogSpeed, jogButtons, homeButton, jogStop, element(document, "label", "inline-field", "Feed override"), feedOverride, element(document, "label", "inline-field", "Spindle override"), spindleOverride, overrideButton);
sessionPanel.append(manual);
content.append(sessionPanel);
const snapshotPanel = element(document, "section", "panel snapshot-panel dro-panel");
snapshotPanel.append(element(document, "h2", "panel-title", "DRO / authoritative state"));
const state = element(document, "pre", "state-view", "Waiting for Worker state");
state.setAttribute("aria-live", "polite");
snapshotPanel.append(state);
content.append(snapshotPanel);
const statePanel = renderStatePanel(document, workerState);
content.append(statePanel);
const recoveryPanel = renderRecoveryPanel(document, { status: "idle" }, {
retry: () => workerClient.snapshot().catch(() => {}),
retry_online: () => workerClient.snapshot().catch(() => {}),
abort: () => controller.execute("abort", {}, {}).catch(() => {}),
});
content.append(recoveryPanel);
let devicePanel = renderDevicePanel(document, { controller, snapshot: {} });
content.append(devicePanel);
let halPanel = renderHalPanel(document, { components: 145, blocked: [] }, []);
content.append(halPanel);
let machinePanel = renderMachineView(document, { pose: { x: 0, y: 0, z: 0 }, stateHash: null });
content.append(machinePanel);
shell.append(content);
root.append(shell);
workerClient.onMessage((message) => {
workerState = reduceWorkerState(workerState, message);
const replacement = renderStatePanel(document, workerState);
statePanel.replaceChildren(...replacement.childNodes);
const program = workerState.program;
const motion = workerState.motion?.state ?? workerState.motion ?? {};
if (program?.state || motion.axes) {
const nextMachinePanel = renderMachineView(document, {
pose: motion.axes ?? program?.state ?? { x: 0, y: 0, z: 0 },
joints: motion.joints ?? [],
stateHash: motion.state_hash ?? program?.source_hash,
});
machinePanel.replaceWith(nextMachinePanel);
machinePanel = nextMachinePanel;
}
if (message.kind === "CAPABILITIES" || message.kind === "SESSION_READY" || message.kind === "STATE_SNAPSHOT" || message.kind === "COMMAND_RESULT") {
state.textContent = JSON.stringify(message.payload, null, 2);
}
if (message.kind === "ERROR") {
stopClock();
status.textContent = `Error: ${message.payload?.code ?? "worker_error"}`;
}
if (message.kind === "COMMAND_RESULT" || message.kind === "STATE_SNAPSHOT") {
const nextDevicePanel = renderDevicePanel(document, { controller, snapshot: workerState.devices ?? {} });
devicePanel.replaceWith(nextDevicePanel);
devicePanel = nextDevicePanel;
const nextHalPanel = renderHalPanel(document, { components: 145, blocked: workerState.hal?.records?.filter((row) => row.status === "blocked") ?? [] }, workerState.hal?.records ?? []);
halPanel.replaceWith(nextHalPanel);
halPanel = nextHalPanel;
}
});
return { client: workerClient, controller, project, root: shell, status, state, programEditor, recoveryPanel, caseSelect, caseLoadButton, caseDiagnostics, mdiEditor, get workerState() { return workerState; } };
}
if (typeof document !== "undefined" && document.querySelector("#app")) mountApp();