Implement AXIS parity UI and execution baseline
This commit is contained in:
238
web/app/app.mjs
238
web/app/app.mjs
@@ -6,9 +6,13 @@ 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_CONFIGURATION = { entrypoint: "linuxcnc/configs/sim/demo/main.ini", availablePaths: ["linuxcnc/configs/sim/demo/main.ini"], rows: [] };
|
||||
const SAMPLE_HAL = { manifest: [{ component: "demo" }], lines: ["loadrt demo names=d0", "addf d0 servo-thread"] };
|
||||
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/corexy/corexy_by_hal.ini";
|
||||
|
||||
function element(document, tag, className, text = "") {
|
||||
const node = document.createElement(tag);
|
||||
@@ -23,35 +27,145 @@ function statusLine(document) {
|
||||
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");
|
||||
sessionPanel.append(element(document, "h1", "panel-title", "Simulation session"));
|
||||
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) {
|
||||
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", async () => { await workerClient.hello(); await workerClient.attach(); status.textContent = "Session ready"; }],
|
||||
["Load sample", async () => { await workerClient.loadWorkspace("/workspace", SAMPLE_CONFIGURATION, SAMPLE_HAL); status.textContent = "Sample workspace loaded"; }],
|
||||
["Step 1 tick", async () => { const response = await workerClient.step(1); status.textContent = `Tick ${response.simulation_tick}`; }],
|
||||
["Snapshot", async () => { const response = await workerClient.snapshot(); status.textContent = `Snapshot tick ${response.payload.tick}`; }],
|
||||
["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, action] of buttons) {
|
||||
const button = element(document, "button", "control-button", label);
|
||||
button.type = "button";
|
||||
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}`;
|
||||
}));
|
||||
@@ -60,49 +174,113 @@ export function mountApp({ document = globalThis.document, client = null } = {})
|
||||
sessionPanel.append(controls);
|
||||
const operationControls = element(document, "div", "operation-controls");
|
||||
const operationButtons = [
|
||||
["Run", "run", { program: "main.ngc" }],
|
||||
["Pause", "pause", {}],
|
||||
["Resume", "resume", {}],
|
||||
["Abort", "abort", {}],
|
||||
["Home", "home", {}],
|
||||
["E-stop", "estop", { active: true }],
|
||||
["Recover", "recover", {}],
|
||||
["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, action, payload] of operationButtons) {
|
||||
const button = element(document, "button", "control-button", label);
|
||||
button.type = "button";
|
||||
button.addEventListener("click", () => controller.execute(action, payload).catch((error) => {
|
||||
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");
|
||||
snapshotPanel.append(element(document, "h2", "panel-title", "Authoritative state"));
|
||||
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" });
|
||||
const recoveryPanel = renderRecoveryPanel(document, { status: "idle" }, {
|
||||
retry: () => workerClient.snapshot().catch(() => {}),
|
||||
retry_online: () => workerClient.snapshot().catch(() => {}),
|
||||
abort: () => controller.execute("abort", {}, {}).catch(() => {}),
|
||||
});
|
||||
content.append(recoveryPanel);
|
||||
content.append(renderDevicePanel(document, { controller, snapshot: {} }));
|
||||
content.append(renderHalPanel(document, { components: 145, blocked: [] }, []));
|
||||
content.append(renderMachineView(document, { pose: { x: 0, y: 0, z: 0 }, stateHash: null }));
|
||||
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") status.textContent = `Error: ${message.payload?.code ?? "worker_error"}`;
|
||||
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, recoveryPanel, get workerState() { return workerState; } };
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user