737 lines
35 KiB
JavaScript
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("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """)
|
|
.replaceAll("'", "'");
|
|
}
|