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();