287 lines
16 KiB
JavaScript
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();
|