Files
cnc_wams/web-rtcp-5axis-sim-plan/app/src/ui/gmoccapy-shell.js

737 lines
35 KiB
JavaScript

import { renderFiveAxisScene } from "../visualization/five-axis-scene.js";
import { gateLinuxCncTaskAction } from "../state/linuxcnc-task-policy.js";
const REGIONS = [
"titlebar",
"preview",
"dro",
"gcode",
"status-sidebar",
"info-tabs",
"override",
"spindle-coolant",
"bottom-controls",
];
export function mountGmoccapyShell(root, store) {
root.innerHTML = `
<section class="gmoccapy-shell" data-shell="gmoccapy-5axis">
<header class="titlebar" data-region="titlebar"></header>
<section class="preview-panel" data-region="preview"></section>
<section class="dro-panel" data-region="dro"></section>
<section class="gcode-panel" data-region="gcode"></section>
<aside class="status-sidebar" data-region="status-sidebar"></aside>
<section class="info-tabs" data-region="info-tabs"></section>
<section class="override-panel" data-region="override"></section>
<section class="spindle-coolant-panel" data-region="spindle-coolant"></section>
<footer class="bottom-controls" data-region="bottom-controls"></footer>
</section>
`;
const regions = Object.fromEntries(
REGIONS.map((name) => [name, root.querySelector(`[data-region="${name}"]`)]),
);
store.subscribe((state) => render(regions, state, store.dispatch));
root.addEventListener("profile-change", (event) => {
store.dispatch({ type: "SET_PROFILE", profileId: event.detail.profileId });
});
return {
getRegions() {
return Object.fromEntries(
Object.entries(regions).map(([name, element]) => [name, Boolean(element)]),
);
},
};
}
function render(regions, state, dispatch) {
renderTitlebar(regions.titlebar, state);
renderPreview(regions.preview, state, dispatch);
renderDro(regions.dro, state);
renderGcode(regions.gcode, state, dispatch);
renderSidebar(regions["status-sidebar"], state, dispatch);
renderInfoTabs(regions["info-tabs"], state);
renderOverride(regions.override, state, dispatch);
renderSpindleCoolant(regions["spindle-coolant"], state, dispatch);
renderBottomControls(regions["bottom-controls"], state, dispatch);
}
function renderTitlebar(element, state) {
const currentLine = currentGcodeExecutionLine(state);
element.innerHTML = `
<div class="brand-dot" aria-hidden="true">NS</div>
<div class="title-stack">
<strong>gmoccapy Web 5 Axis for LinuxCNC RTCP Simulation</strong>
<span>${state.machineProfile} | ${state.sessionName} | ${state.sourceMode} | ${state.machine.mode}</span>
</div>
<label class="profile-select-label">
Profile
<select data-action="select-profile">
${state.availableProfiles.map((profile) => `
<option value="${profile.id}" ${profile.id === state.machineProfile ? "selected" : ""}>${profile.coordinates} ${profile.id}</option>
`).join("")}
</select>
</label>
<div class="current-line-indicator" data-current-gcode-line="${currentLine}" data-current-gcode-source="${escapeHtml(currentGcodeLineSource(state))}">
<span>G-code line</span>
<strong>${currentLine}</strong>
</div>
<div class="run-state" data-run-state="${state.runState}">${state.runState}</div>
`;
element.querySelector('[data-action="select-profile"]').addEventListener("change", (event) => {
element.dispatchEvent(new CustomEvent("profile-change", {
bubbles: true,
detail: { profileId: event.target.value },
}));
});
}
function renderPreview(element, state, dispatch) {
const tcp = state.tcpPose;
const tool = state.toolAxisVector;
if (element.dataset.previewMounted !== "true") {
element.innerHTML = `
<div class="program-path" data-preview-program-path></div>
<canvas class="toolpath-preview" data-five-axis-canvas="true" aria-label="5 axis toolpath preview"></canvas>
<div class="tool-preview-card" data-tool-preview="summary">
<strong data-tool-preview-number></strong>
<span data-tool-preview-diameter></span>
<span data-tool-preview-length></span>
<span data-tool-preview-holder></span>
</div>
<div class="rtcp-preview-badge" data-rtcp-preview-state></div>
<div class="preview-toolbar" data-preview-points>
<button type="button" data-action="view-x">X</button>
<button type="button" data-action="view-y">Y</button>
<button type="button" data-action="view-z">Z</button>
<button type="button" data-action="reset-view">Fit</button>
<button type="button" data-action="clear-preview">Clear</button>
</div>
`;
element.querySelector('[data-action="reset-view"]').addEventListener("click", () => {
dispatch({ type: "RESET_VIEW" });
});
element.querySelector('[data-action="clear-preview"]').addEventListener("click", () => {
dispatch({ type: "CLEAR_PREVIEW" });
});
for (const view of ["x", "y", "z"]) {
element.querySelector(`[data-action="view-${view}"]`).addEventListener("click", () => {
dispatch({ type: "SET_VIEW", view });
});
}
element.dataset.previewMounted = "true";
}
setText(element, "[data-preview-program-path]", state.activeProgram);
setText(element, "[data-tool-preview-number]", `T${state.toolPreview.toolNumber}`);
setText(element, "[data-tool-preview-diameter]", `D ${formatNumber(state.toolPreview.diameter, 2)} ${state.toolPreview.units}`);
setText(element, "[data-tool-preview-length]", `L ${formatNumber(state.toolPreview.length, 3)} ${state.toolPreview.units}`);
setText(element, "[data-tool-preview-holder]", state.toolPreview.holder);
const rtcpBadge = element.querySelector("[data-rtcp-preview-state]");
if (rtcpBadge) {
rtcpBadge.dataset.rtcpPreviewState = state.rtcpState;
rtcpBadge.textContent = [
`RTCP ${state.rtcpState}`,
`TCP ${formatNumber(tcp.x)} ${formatNumber(tcp.y)} ${formatNumber(tcp.z)}`,
`V ${formatNumber(tool.x, 3)} ${formatNumber(tool.y, 3)} ${formatNumber(tool.z, 3)}`,
].join(" | ");
}
const toolbar = element.querySelector("[data-preview-points]");
if (toolbar) {
toolbar.dataset.previewPoints = String(state.preview.pathPoints);
for (const view of ["x", "y", "z"]) {
toolbar.querySelector(`[data-action="view-${view}"]`)?.setAttribute(
"data-active",
state.preview.selectedView === view ? "true" : "false",
);
}
}
const canvas = element.querySelector("[data-five-axis-canvas]");
renderFiveAxisScene(canvas, state);
}
function setText(root, selector, value) {
const element = root.querySelector(selector);
if (element) {
element.textContent = value;
}
}
function renderDro(element, state) {
const { dro } = state;
const tool = state.toolAxisVector;
element.innerHTML = `
<div class="dro-grid">
${droRow("X", dro.x, dro.dtgX)}
${droRow("Y", dro.y, dro.dtgY)}
${droRow("Z", dro.z, dro.dtgZ)}
${droRow("A", dro.a, 0)}
${droRow("B", dro.b, 0)}
${droRow("C", dro.c, 0)}
</div>
<div class="tcp-strip">
<span data-rtcp-value="tcp">TCP ${formatNumber(dro.tcpX)} / ${formatNumber(dro.tcpY)} / ${formatNumber(dro.tcpZ)}</span>
<span data-rtcp-value="tool-axis">V ${formatNumber(tool.x, 3)} / ${formatNumber(tool.y, 3)} / ${formatNumber(tool.z, 3)}</span>
<span data-rtcp-value="state">RTCP ${state.rtcpState}</span>
<span>KINS ${state.kinsType}</span>
</div>
`;
}
function droRow(axis, value, dtg) {
return `
<div class="dro-row">
<span class="dro-axis">${axis}</span>
<span class="dro-mode">G54<br />Abs</span>
<strong>${formatNumber(value)}</strong>
<span class="dro-dtg">DTG<br />${formatNumber(dtg, 3)}</span>
</div>
`;
}
function renderGcode(element, state, dispatch) {
const currentLine = currentGcodeExecutionLine(state);
const rows = state.programLines
.map((line, index) => {
const lineNumber = state.programStartLine + index;
const execution = state.programLineExecution?.[lineNumber] || null;
const active = lineNumber === state.activeLine ? " active" : "";
const status = execution?.status || (lineNumber < state.activeLine ? "done" : "pending");
return `
<li class="gcode-row${active}" data-program-line="${lineNumber}" data-line-status="${escapeHtml(status)}">
<span>${lineNumber}</span>
<code>${escapeHtml(line)}</code>
<small data-line-execution="${lineNumber}">${formatLineExecution(execution, active)}</small>
</li>
`;
})
.join("");
const programEndLine = state.programStartLine + Math.max(state.programLines.length - 1, 0);
const progressSpan = Math.max(programEndLine - state.programStartLine, 1);
const progress = Math.min(
Math.max(((state.activeLine - state.programStartLine) / progressSpan) * 100, 0),
100,
);
element.innerHTML = `
<div class="gcode-header">
<strong>${escapeHtml(state.activeProgram)}</strong>
<span data-program-source="${state.programSource}">${state.programSource}</span>
<span data-active-program-line="${currentLine}" data-current-gcode-line="${currentLine}">Executing line ${currentLine}</span>
</div>
<div class="linuxcnc-source-row" data-linuxcnc-gcode-source="row">
<label>
LinuxCNC 5-axis source
<select data-action="select-linuxcnc-gcode-source" ${state.machineFileStaging.gcodeSources?.length ? "" : "disabled"}>
${renderLinuxCncGcodeSourceOptions(state)}
</select>
</label>
<button type="button" data-action="stage-linuxcnc-sources">Stage</button>
<span data-linuxcnc-gcode-source="status">${formatLinuxCncGcodeSourceStatus(state)}</span>
</div>
<ol class="gcode-list" start="${state.programStartLine}">${rows}</ol>
<div class="gcode-progress">
<span>${state.activeLine} / ${programEndLine}</span>
<div><i style="width: ${progress}%"></i></div>
</div>
<section class="mdi-panel" data-mdi-mode="${state.machine.mode === "mdi"}">
<form class="mdi-command-row" data-action="mdi-form">
<strong>MDI</strong>
<input
type="text"
data-action="mdi-command"
value="${escapeHtml(state.machine.mdiCommand)}"
spellcheck="false"
autocomplete="off"
aria-label="MDI command"
/>
<button type="submit" data-action="mdi-submit">Run</button>
</form>
<div class="mdi-history">
${mdiQuickCommands(state).map((command) => `
<button type="button" data-action="mdi-history" data-command="${escapeHtml(command)}">${escapeHtml(command)}</button>
`).join("")}
</div>
</section>
`;
const form = element.querySelector('[data-action="mdi-form"]');
const input = element.querySelector('[data-action="mdi-command"]');
form.addEventListener("submit", (event) => {
event.preventDefault();
dispatch({ type: "RUN_MDI", command: input.value });
});
input.addEventListener("change", () => {
dispatch({ type: "SET_MDI_COMMAND", command: input.value });
});
for (const button of element.querySelectorAll('[data-action="mdi-history"]')) {
button.addEventListener("click", () => {
dispatch({ type: "RUN_MDI", command: button.dataset.command });
});
}
element.querySelector('[data-action="stage-linuxcnc-sources"]').addEventListener("click", () => {
dispatch({ type: "STAGE_MACHINE_FILES_REQUEST" });
});
const linuxCncSourceSelect = element.querySelector('[data-action="select-linuxcnc-gcode-source"]');
linuxCncSourceSelect.addEventListener("change", () => {
if (!linuxCncSourceSelect.value) return;
dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel: linuxCncSourceSelect.value });
});
}
function currentGcodeExecutionLine(state) {
const feedbackLine = Number(state.programRuntimeFeedback?.line);
if (Number.isFinite(feedbackLine) && feedbackLine > 0) {
return feedbackLine;
}
const activeLine = Number(state.activeLine);
return Number.isFinite(activeLine) && activeLine > 0 ? activeLine : state.programStartLine;
}
function currentGcodeLineSource(state) {
return state.programRuntimeFeedback?.sourceMode || state.programExecutionSourceMode || "ui-state";
}
function renderLinuxCncGcodeSourceOptions(state) {
const sources = state.machineFileStaging.gcodeSources || [];
if (sources.length === 0) {
return `<option value="">stage machine files first</option>`;
}
const selected = state.machineFileStaging.selectedGcodeSourceRel || "";
return [
`<option value="">select source</option>`,
...sources.map((source) => `
<option value="${escapeHtml(source.sourceRel)}" ${source.sourceRel === selected ? "selected" : ""}>
${escapeHtml(source.filename)}
</option>
`),
].join("");
}
function formatLinuxCncGcodeSourceStatus(state) {
const sources = state.machineFileStaging.gcodeSources || [];
if (sources.length === 0) return `${state.machineFileStaging.status} / no staged LinuxCNC G-code sources`;
const selected = state.machineFileStaging.selectedGcodeSourceRel || "-";
return `${sources.length} staged / ${selected}`;
}
function mdiQuickCommands(state) {
const profileCommands = state.profile.kinematicsParameters.switchkinsTypes
.map((type) => type.mdiCommand)
.filter(Boolean);
return [
...(state.mdiHistory || []),
state.machine.mdiCommand,
"G0 X0 Y0 Z0",
"G91 X1",
"G90",
...profileCommands,
].filter((command, index, commands) => command && commands.indexOf(command) === index).slice(0, 7);
}
function renderSidebar(element, state, dispatch) {
element.innerHTML = `
<button type="button" class="sidebar-button estop" data-action="estop" data-active="${state.machine.estopActive}">E-STOP</button>
<button type="button" class="sidebar-button power" data-action="power" data-active="${state.machine.powerOn}">POWER</button>
<button type="button" class="sidebar-button" data-action="reset">RESET</button>
<button type="button" class="sidebar-button" data-action="mode-auto" data-active="${state.machine.mode === "auto"}">AUTO</button>
<button type="button" class="sidebar-button" data-action="mode-manual" data-active="${state.machine.mode === "manual"}">MANUAL</button>
<button type="button" class="sidebar-button" data-action="mode-jog" data-active="${state.machine.mode === "manual"}">JOG</button>
<button type="button" class="sidebar-button" data-action="mode-mdi" data-active="${state.machine.mode === "mdi"}">MDI</button>
<button type="button" class="sidebar-button" data-action="kins-identity" data-active="${state.kinsType === "identity"}">IDENTITY</button>
<button type="button" class="sidebar-button" data-action="kins-tcp" data-active="${state.kinsType.startsWith("tcp-")}">TCP</button>
<time>13:30:31<br />20.06.2026</time>
`;
element.querySelector('[data-action="estop"]').addEventListener("click", () => dispatch({ type: "ESTOP" }));
element.querySelector('[data-action="power"]').addEventListener("click", () => dispatch({ type: "TOGGLE_POWER" }));
element.querySelector('[data-action="reset"]').addEventListener("click", () => dispatch({ type: "RESET" }));
element.querySelector('[data-action="mode-auto"]').addEventListener("click", () => dispatch({ type: "SET_MODE", mode: "auto" }));
element.querySelector('[data-action="mode-manual"]').addEventListener("click", () => dispatch({ type: "SET_MODE", mode: "manual" }));
element.querySelector('[data-action="mode-jog"]').addEventListener("click", () => dispatch({ type: "SET_MODE", mode: "jog" }));
element.querySelector('[data-action="mode-mdi"]').addEventListener("click", () => dispatch({ type: "SET_MODE", mode: "mdi" }));
element.querySelector('[data-action="kins-identity"]').addEventListener("click", () => {
dispatch({ type: "SET_KINS_TYPE", kinsType: "identity" });
});
element.querySelector('[data-action="kins-tcp"]').addEventListener("click", () => {
const tcpKinsType = state.profile.kinematicsParameters.switchkinsTypes.find((type) => type.value === 1)?.webKinsType || "tcp-xyzac";
dispatch({ type: "SET_KINS_TYPE", kinsType: tcpKinsType });
});
}
function renderInfoTabs(element, state) {
const frame = state.rtcpFrame;
const taskPolicy = state.linuxCncTaskPolicy;
element.innerHTML = `
<nav class="tabs">
<button type="button" class="active">Tool info and G-codes</button>
<button type="button">G-code properties</button>
<button type="button">RTCP diagnostics</button>
</nav>
<dl class="info-grid">
<dt>Size:</dt><dd>${state.fileSizeBytes} bytes</dd>
<dt>Lines:</dt><dd>${state.lineCount} gcode lines</dd>
<dt>Machine:</dt><dd data-machine-state="summary">${state.machine.powerOn ? "power on" : "power off"} / ${state.machine.estopActive ? "estop" : "clear"} / ${state.machine.mode}</dd>
<dt>Task policy:</dt><dd data-linuxcnc-task-policy="boundary">${taskPolicy.semanticBoundary}</dd>
<dt>Task state:</dt><dd data-linuxcnc-task-policy="state">${taskPolicy.taskState} / ${taskPolicy.taskMode} / ${taskPolicy.interpState}</dd>
<dt>Task gates:</dt><dd data-linuxcnc-task-policy="gates">${formatLinuxCncTaskGates(taskPolicy)}</dd>
<dt>Task source:</dt><dd data-linuxcnc-task-policy="source">${formatLinuxCncTaskSources(taskPolicy)}</dd>
<dt>Current line:</dt><dd data-program-current-line="${state.activeLine}">${state.activeLine}</dd>
<dt>Program source:</dt><dd data-program-execution-source="${state.programExecutionSourceMode}">${state.programExecutionSourceMode}</dd>
<dt>Canonical:</dt><dd data-program-execution-summary="${state.programExecution?.summary?.motionEventCount ?? 0}">${state.programExecution?.summary?.motionEventCount ?? 0} motion / ${state.programExecution?.summary?.canonicalEventCount ?? 0} events</dd>
<dt>Switchkins:</dt><dd data-program-switchkins-summary="${state.programExecution?.summary?.switchkinsEventCount ?? 0}">${formatSwitchkinsSummary(state.programExecution)}</dd>
<dt>Machine run:</dt><dd data-machine-file-execution="status">${formatMachineFileExecution(state.machineFileExecution)}</dd>
<dt>Session:</dt><dd data-session-persistence="status">${formatSessionPersistence(state.sessionPersistence)}</dd>
<dt>Tool preview:</dt><dd data-tool-preview="detail">T${state.toolPreview.toolNumber} D${formatNumber(state.toolPreview.diameter, 2)} L${formatNumber(state.toolPreview.length, 3)} ${state.toolPreview.units}</dd>
<dt>Program time:</dt><dd data-program-timing="summary">${formatProgramTiming(state)}</dd>
<dt>Segment time:</dt><dd data-program-timing="segment">${formatProgramTimingSegment(state)}</dd>
<dt>Runtime feedback:</dt><dd data-program-runtime-feedback="source">${formatProgramRuntimeFeedback(state)}</dd>
<dt>Runtime DTG:</dt><dd data-program-runtime-feedback="dtg">${formatProgramRuntimeDtg(state)}</dd>
<dt>Rapid distance:</dt><dd>37.634 mm</dd>
<dt>Feed distance:</dt><dd>5814.069 mm</dd>
<dt>X bounds:</dt><dd>8.000 to 113.000 = 105.000 mm</dd>
<dt>Y bounds:</dt><dd>3.872 to 116.128 = 112.256 mm</dd>
<dt>Z bounds:</dt><dd>-90.500 to -50.000 = 40.500 mm</dd>
<dt>RTCP frame:</dt><dd data-rtcp-diagnostic="frame">${frame.apiName} ${frame.rtcpState}</dd>
<dt>Boundary:</dt><dd data-rtcp-diagnostic="boundary">${frame.semanticBoundary}</dd>
<dt>INI:</dt><dd data-linuxcnc-ini="status">${state.iniConfigReadiness.loaded ? "loaded" : "pending"} / ${state.iniConfigReadiness.path ?? "-"}</dd>
<dt>INI kins:</dt><dd data-linuxcnc-ini="kins">${state.iniConfigReadiness.kinematics ?? "-"} / ${state.iniConfigReadiness.coordinates ?? "-"}</dd>
<dt>INI limits:</dt><dd data-linuxcnc-ini="limits">${formatAxisLimitSummary(state.profile.axisLimits)}</dd>
<dt>INI joints:</dt><dd data-linuxcnc-ini="joints">${state.iniConfigReadiness.jointCount ?? 0} joints / ${state.iniConfigReadiness.axisCount ?? 0} axes</dd>
<dt>Machine files:</dt><dd data-machine-file-staging="status">${formatMachineFileStaging(state.machineFileStaging)}</dd>
<dt>LinuxCNC G-code:</dt><dd data-linuxcnc-gcode-source="selected">${state.machineFileStaging.selectedGcodeSourceRel || state.programSourceRel || "-"}</dd>
<dt>Task/HAL:</dt><dd data-task-hal-runtime="readiness">${formatTaskHalReadiness(state)}</dd>
<dt>Task/HAL cycles:</dt><dd data-task-hal-runtime="cycles">${formatTaskHalCycles(state)}</dd>
<dt>Full boundary:</dt><dd data-full-execution-boundary="status">${formatFullExecutionBoundary(state.fullExecutionBoundary)}</dd>
<dt>Planner/task:</dt><dd data-full-execution-boundary="blockers">${formatFullExecutionBlockers(state.fullExecutionBoundary)}</dd>
<dt>Boundary evidence:</dt><dd data-full-execution-boundary="evidence">${formatFullExecutionEvidence(state.fullExecutionBoundary)}</dd>
<dt>Host/native:</dt><dd data-full-execution-boundary="host-native">${formatHostNativeBoundary(state.fullExecutionBoundary)}</dd>
<dt>LinuxCNC kins:</dt><dd data-rtcp-diagnostic="kinematics-ready">${frame.readiness.linuxCncKinematicsReady ? "ready" : "pending"}</dd>
<dt>Kins context:</dt><dd data-rtcp-diagnostic="execution-context">${state.kinematicsExecutionContext}</dd>
<dt>Interpreter:</dt><dd data-linuxcnc-boundary="interpreter">${state.interpreterRuntimeReadiness?.loaded ? state.interpreterRuntimeReadiness.semanticBoundary : "pending"}</dd>
<dt>Interp context:</dt><dd data-linuxcnc-boundary="interpreter-context">${state.interpreterRuntimeReadiness?.executionContext ?? "none"}</dd>
<dt>Profile refs:</dt><dd>${state.profile.sourceReferences.length} source references</dd>
<dt>Adapter:</dt><dd data-linuxcnc-boundary="adapter">${state.linuxCncBoundaryAdapter.apiName}</dd>
<dt>Panel schema:</dt><dd data-linuxcnc-boundary="panel">${state.linuxCncBoundaryAdapter.panelSummary.schemaId} / ${state.linuxCncBoundaryAdapter.panelSummary.buttonCount} buttons</dd>
<dt>Source map:</dt><dd data-linuxcnc-boundary="source-map">${state.linuxCncBoundaryAdapter.sourceSummary.referenceCount} refs / ${state.linuxCncBoundaryAdapter.sourceSummary.sourceRequiredCount} source files</dd>
<dt>Profile summary:</dt><dd data-linuxcnc-boundary="profile-summary">${state.linuxCncBoundaryAdapter.profileSummary.coordinates} / ${state.linuxCncBoundaryAdapter.profileSummary.jointCount} joints / ${state.linuxCncBoundaryAdapter.profileSummary.toolCount} tools</dd>
<dt>Boundary ready:</dt><dd data-linuxcnc-boundary="readiness">${state.linuxCncBoundaryReadiness.ready ? "ready" : "blocked"} (${state.linuxCncBoundaryReadiness.missing.join(", ")})</dd>
</dl>
`;
}
function formatLinuxCncTaskGates(taskPolicy) {
if (!taskPolicy) return "pending";
return [
taskPolicy.canJog ? "jog" : "jog blocked",
taskPolicy.canHome ? "home" : "home blocked",
taskPolicy.canRunAuto ? "auto" : "auto blocked",
taskPolicy.canExecuteMdi ? "mdi" : "mdi blocked",
taskPolicy.canPause ? "pause" : "pause blocked",
taskPolicy.canResume ? "resume" : "resume blocked",
].join(" / ");
}
function formatLinuxCncTaskSources(taskPolicy) {
if (!taskPolicy?.sourceReferences?.length) return "pending";
return taskPolicy.sourceReferences
.map((reference) => reference.path)
.join(" | ");
}
function formatProgramTiming(state) {
const timing = state.programExecutionTiming;
if (!timing) return "pending";
return `${formatDuration(state.programElapsedSeconds)} / ${formatDuration(timing.totalSeconds)} (${formatDuration(state.programRemainingSeconds)} left)`;
}
function formatProgramTimingSegment(state) {
const segment = state.programExecutionTiming?.segments?.[state.programExecutionMotionIndex || 0];
if (!segment) return "pending";
return `${segment.motionClass} line ${segment.line ?? "-"} ${formatNumber(segment.linearDistanceMm, 3)} mm ${formatDuration(segment.durationSeconds)} @ ${formatNumber(segment.velocityMmPerMin, 1)} mm/min`;
}
function formatProgramRuntimeFeedback(state) {
const feedback = state.programRuntimeFeedback;
if (!feedback) return "pending";
return `${feedback.sourceMode} sample ${feedback.sampleIndex ?? 0} line ${feedback.line ?? "-"} queue ${feedback.queueDepth ?? 0}/${feedback.activeDepth ?? 0} @ ${formatNumber(feedback.currentVelocityMmPerMin, 1)} mm/min`;
}
function formatProgramRuntimeDtg(state) {
const feedback = state.programRuntimeFeedback;
if (!feedback) return "pending";
const dtg = feedback.dtg || {};
return `DTG ${formatNumber(dtg.x, 3)} / ${formatNumber(dtg.y, 3)} / ${formatNumber(dtg.z, 3)} distance ${formatNumber(feedback.distanceToGo, 3)}`;
}
function formatLineExecution(execution, active) {
if (!execution) return active ? "running" : "pending";
const axes = execution.axisPose || {};
return [
execution.status || (active ? "running" : "done"),
`F ${formatNumber(execution.feed, 1)}`,
`X ${formatNumber(axes.x, 3)}`,
`Y ${formatNumber(axes.y, 3)}`,
`Z ${formatNumber(axes.z, 3)}`,
`A ${formatNumber(axes.a, 3)}`,
`C ${formatNumber(axes.c, 3)}`,
`cycle ${execution.taskCycle || 0}/${execution.servoCycle || 0}`,
].join(" | ");
}
function formatDuration(seconds) {
const safeSeconds = Math.max(Number(seconds) || 0, 0);
const minutes = Math.floor(safeSeconds / 60);
const remainder = safeSeconds - minutes * 60;
return `${minutes}:${remainder.toFixed(1).padStart(4, "0")}`;
}
function formatSwitchkinsSummary(programExecution) {
const count = programExecution?.summary?.switchkinsEventCount ?? 0;
if (count === 0) return "0 events";
const codes = programExecution.summary.switchkinsCodes?.join("/") || "-";
return `${count} events ${codes}`;
}
function formatMachineFileStaging(machineFileStaging) {
if (!machineFileStaging || machineFileStaging.status === "not-staged") {
return "not staged";
}
if (machineFileStaging.status === "error") {
return `error ${machineFileStaging.lastError || "-"}`;
}
const storage = machineFileStaging.storageMode || machineFileStaging.save?.storageMode || "-";
const reason = machineFileStaging.storageCapability?.reason || machineFileStaging.save?.storageCapability?.reason || "-";
const gcodeFiles = machineFileStaging.save?.summary?.gcodeFileCount ?? 0;
return `${machineFileStaging.status} ${machineFileStaging.fileCount || 0} files ${gcodeFiles} gcode ${storage} ${reason} ${machineFileStaging.opfsRoot || "-"}`;
}
function formatSessionPersistence(sessionPersistence) {
const capability = sessionPersistence.storageCapability || {};
return [
sessionPersistence.status,
sessionPersistence.storageMode ?? "-",
capability.opfsUnavailable ? "opfs unavailable" : capability.opfsAvailable ? "opfs available" : "storage pending",
capability.reason || "-",
sessionPersistence.path ?? "-",
].join(" / ");
}
function formatMachineFileExecution(machineFileExecution) {
if (!machineFileExecution?.machineFilePlan) return "not run";
const summary = machineFileExecution.summary || {};
return `${summary.machineFileExecutionReady ? "ready" : "ran"} ${summary.motionEventCount || 0} motion ${machineFileExecution.machineFilePlan.profileId}`;
}
function formatTaskHalReadiness(state) {
const readiness = state.taskHalRuntimeReadiness;
if (!readiness?.loaded && !state.taskHalStatus) return "pending";
return [
readiness?.taskRuntimeReady ? "task" : "task pending",
readiness?.motionRuntimeReady ? "motion" : "motion pending",
readiness?.halRuntimeReady ? "hal" : "hal pending",
state.taskHalStatus?.summary?.halSyncReady ? "sync" : "sync pending",
state.taskHalFallbackReason ? `fallback ${state.taskHalFallbackReason}` : null,
].filter(Boolean).join(" / ");
}
function formatTaskHalCycles(state) {
const status = state.taskHalStatus;
if (!status?.ui) return "pending";
return `task ${status.ui.taskCycle} / servo ${status.ui.servoCycle} / queue ${status.ui.motionQueueDepth} / HAL changed ${status.ui.halChangedPinCount}`;
}
function formatFullExecutionBoundary(boundary) {
if (!boundary) return "pending";
const remap = boundary.machineFileBackedRemapReady ? "remap ready" : "remap pending";
const full = boundary.fullLinuxCncProgramExecutionReady ? "full ready" : "full blocked";
return `${boundary.phase} / ${remap} / ${full}`;
}
function formatFullExecutionBlockers(boundary) {
if (!boundary) return "pending";
return boundary.blockers.slice(0, 2).join("; ");
}
function formatFullExecutionEvidence(boundary) {
if (!boundary) return "pending";
return `${boundary.satisfied.length} satisfied / ${boundary.missing.length} missing / ${boundary.semanticBoundary}`;
}
function formatHostNativeBoundary(boundary) {
if (!boundary) return "pending";
return [
boundary.hardwareDrive ? "hardware drive enabled" : "hardware drive false",
boundary.hostRealtimeKernel ? "host realtime enabled" : "host realtime false",
boundary.externalUserMProcessReady ? "external user-M ready" : "external user-M false",
boundary.toolDbProcessReady ? "tool DB ready" : "tool DB false",
].join(" / ");
}
function renderOverride(element, state, dispatch) {
element.innerHTML = `
<section class="meter-card">
<h2>Current Velocity</h2>
<strong>${state.feed.currentVelocity}</strong><span> mm/min</span>
</section>
${overrideControl("Rapid Override", "rapid", state.feed.rapidOverride)}
${overrideControl("Feed Rate", "feed", state.feed.feedOverride, `F ${state.feed.feedRate}`)}
`;
for (const target of ["rapid", "feed"]) {
element.querySelector(`[data-action="${target}-override-down"]`).addEventListener("click", () => {
dispatch({ type: "ADJUST_OVERRIDE", target, delta: -10 });
});
element.querySelector(`[data-action="${target}-override-up"]`).addEventListener("click", () => {
dispatch({ type: "ADJUST_OVERRIDE", target, delta: 10 });
});
}
}
function overrideControl(label, target, value, prefix = "") {
return `
<section class="override-control" data-control="${target}-override">
<h2>${label}</h2>
${prefix ? `<strong>${prefix}</strong>` : ""}
<div class="stepper">
<button type="button" data-action="${target}-override-down">-</button>
<div data-value="${target}-override">${value} %</div>
<button type="button" data-action="${target}-override-up">+</button>
</div>
</section>
`;
}
function renderSpindleCoolant(element, state, dispatch) {
element.innerHTML = `
<section class="cooling">
<h2>Cooling</h2>
<button type="button" data-action="toggle-flood" class="${state.coolant.flood ? "active" : ""}">Flood</button>
<button type="button" data-action="toggle-mist" class="${state.coolant.mist ? "active" : ""}">Mist</button>
</section>
<section class="spindle">
<h2>Spindle</h2>
<strong>${state.spindle.rpm}</strong><span> rpm</span>
<div class="stepper">
<button type="button" data-action="spindle-override-down">-</button>
<div data-value="spindle-override">${state.spindle.override} %</div>
<button type="button" data-action="spindle-override-up">+</button>
</div>
<div class="spindle-range"><i style="width: ${(state.spindle.rpm / 6000) * 100}%"></i></div>
</section>
`;
element.querySelector('[data-action="toggle-flood"]').addEventListener("click", () => {
dispatch({ type: "TOGGLE_COOLANT", kind: "flood" });
});
element.querySelector('[data-action="toggle-mist"]').addEventListener("click", () => {
dispatch({ type: "TOGGLE_COOLANT", kind: "mist" });
});
element.querySelector('[data-action="spindle-override-down"]').addEventListener("click", () => {
dispatch({ type: "ADJUST_SPINDLE_OVERRIDE", delta: -10 });
});
element.querySelector('[data-action="spindle-override-up"]').addEventListener("click", () => {
dispatch({ type: "ADJUST_SPINDLE_OVERRIDE", delta: 10 });
});
}
function renderBottomControls(element, state, dispatch) {
const controls = [
["Open", "OPEN", null],
["Reload", "RELOAD", () => dispatch({ type: "RELOAD_PROGRAM" })],
["Run Ready", "RUN_READY", () => dispatch({ type: "RUN_READY" })],
["Run", "RUN", () => dispatch({ type: "RUN" })],
["Stop", "STOP", () => dispatch({ type: "STOP" })],
["Pause", "PAUSE", () => dispatch({ type: "PAUSE" })],
["Resume", "RESUME", () => dispatch({ type: "RESUME" })],
["Step", "STEP", () => dispatch({ type: "STEP" })],
["Home", "HOME", () => dispatch({ type: "HOME" })],
["X-", "JOG_X_NEG", () => dispatch({ type: "JOG", axis: "x", direction: -1 })],
["X+", "JOG_X_POS", () => dispatch({ type: "JOG", axis: "x", direction: 1 })],
["Y-", "JOG_Y_NEG", () => dispatch({ type: "JOG", axis: "y", direction: -1 })],
["Y+", "JOG_Y_POS", () => dispatch({ type: "JOG", axis: "y", direction: 1 })],
["MDI", "MDI_RUN", () => dispatch({ type: "RUN_MDI" })],
["Save Session", "SAVE_SESSION", () => dispatch({ type: "SAVE_SESSION_REQUEST" })],
["Restore Session", "RESTORE_SESSION", () => dispatch({ type: "RESTORE_SESSION_REQUEST" })],
["Audit", "AUDIT_FULL_BOUNDARY", () => dispatch({ type: "RUN_FULL_BOUNDARY_AUDIT_REQUEST" })],
["Full", "FULL", () => dispatch({ type: "TOGGLE_FULLSCREEN" })],
];
element.innerHTML = `
<input type="file" class="program-file-input" data-action="OPEN_FILE" accept=".ngc,.nc,.tap,.gcode,.txt" />
${controls
.map(([label, action]) => {
const gate = bottomControlGate(state, action);
const disabled = gate.allowed ? "" : " disabled";
const title = gate.allowed ? "" : ` title="${escapeHtml(gate.operatorMessage || "blocked")}"`;
return `<button type="button" data-action="${action}"${disabled}${title}>${label}</button>`;
})
.join("")}
`;
element.querySelector('[data-action="OPEN"]').addEventListener("click", () => {
element.querySelector('[data-action="OPEN_FILE"]').click();
});
element.querySelector('[data-action="OPEN_FILE"]').addEventListener("change", async (event) => {
const [file] = event.target.files || [];
if (!file) return;
const content = await file.text();
dispatch({
type: "LOAD_PROGRAM",
filename: file.name,
content,
});
event.target.value = "";
});
for (const [label, action, handler] of controls) {
if (!handler) continue;
const button = element.querySelector(`[data-action="${action}"]`);
button.addEventListener("click", handler);
button.setAttribute("aria-label", label);
}
}
function bottomControlGate(state, action) {
const actionMap = {
RUN: { type: "RUN" },
RUN_READY: { type: "UI_CONTROL" },
STEP: { type: "STEP" },
PAUSE: { type: "PAUSE" },
RESUME: { type: "RESUME" },
HOME: { type: "HOME" },
MDI_RUN: { type: "RUN_MDI" },
JOG_X_NEG: { type: "JOG" },
JOG_X_POS: { type: "JOG" },
JOG_Y_NEG: { type: "JOG" },
JOG_Y_POS: { type: "JOG" },
};
return gateLinuxCncTaskAction(state, actionMap[action] || { type: "UI_CONTROL" });
}
function formatNumber(value, digits = 3) {
return Number(value).toFixed(digits);
}
function formatAxisLimitSummary(axisLimits) {
return Object.entries(axisLimits || {})
.map(([axis, limit]) => `${axis}[${formatNumber(limit.min, 0)},${formatNumber(limit.max, 0)}]`)
.join(" ");
}
function escapeHtml(value) {
return String(value)
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
}