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 = `
`;
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 = `
NS
gmoccapy Web 5 Axis for LinuxCNC RTCP Simulation
${state.machineProfile} | ${state.sessionName} | ${state.sourceMode} | ${state.machine.mode}
G-code line
${currentLine}
${state.runState}
`;
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 = `
`;
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 = `
${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)}
TCP ${formatNumber(dro.tcpX)} / ${formatNumber(dro.tcpY)} / ${formatNumber(dro.tcpZ)}
V ${formatNumber(tool.x, 3)} / ${formatNumber(tool.y, 3)} / ${formatNumber(tool.z, 3)}
RTCP ${state.rtcpState}
KINS ${state.kinsType}
`;
}
function droRow(axis, value, dtg) {
return `
${axis}
G54
Abs
${formatNumber(value)}
DTG
${formatNumber(dtg, 3)}
`;
}
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 `
${lineNumber}
${escapeHtml(line)}
${formatLineExecution(execution, active)}
`;
})
.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 = `
${formatLinuxCncGcodeSourceStatus(state)}
${rows}
${state.activeLine} / ${programEndLine}
${mdiQuickCommands(state).map((command) => `
`).join("")}
`;
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 ``;
}
const selected = state.machineFileStaging.selectedGcodeSourceRel || "";
return [
``,
...sources.map((source) => `
`),
].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 = `
`;
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 = `
- Size:
- ${state.fileSizeBytes} bytes
- Lines:
- ${state.lineCount} gcode lines
- Machine:
- ${state.machine.powerOn ? "power on" : "power off"} / ${state.machine.estopActive ? "estop" : "clear"} / ${state.machine.mode}
- Task policy:
- ${taskPolicy.semanticBoundary}
- Task state:
- ${taskPolicy.taskState} / ${taskPolicy.taskMode} / ${taskPolicy.interpState}
- Task gates:
- ${formatLinuxCncTaskGates(taskPolicy)}
- Task source:
- ${formatLinuxCncTaskSources(taskPolicy)}
- Current line:
- ${state.activeLine}
- Program source:
- ${state.programExecutionSourceMode}
- Canonical:
- ${state.programExecution?.summary?.motionEventCount ?? 0} motion / ${state.programExecution?.summary?.canonicalEventCount ?? 0} events
- Switchkins:
- ${formatSwitchkinsSummary(state.programExecution)}
- Machine run:
- ${formatMachineFileExecution(state.machineFileExecution)}
- Session:
- ${formatSessionPersistence(state.sessionPersistence)}
- Tool preview:
- T${state.toolPreview.toolNumber} D${formatNumber(state.toolPreview.diameter, 2)} L${formatNumber(state.toolPreview.length, 3)} ${state.toolPreview.units}
- Program time:
- ${formatProgramTiming(state)}
- Segment time:
- ${formatProgramTimingSegment(state)}
- Runtime feedback:
- ${formatProgramRuntimeFeedback(state)}
- Runtime DTG:
- ${formatProgramRuntimeDtg(state)}
- Rapid distance:
- 37.634 mm
- Feed distance:
- 5814.069 mm
- X bounds:
- 8.000 to 113.000 = 105.000 mm
- Y bounds:
- 3.872 to 116.128 = 112.256 mm
- Z bounds:
- -90.500 to -50.000 = 40.500 mm
- RTCP frame:
- ${frame.apiName} ${frame.rtcpState}
- Boundary:
- ${frame.semanticBoundary}
- INI:
- ${state.iniConfigReadiness.loaded ? "loaded" : "pending"} / ${state.iniConfigReadiness.path ?? "-"}
- INI kins:
- ${state.iniConfigReadiness.kinematics ?? "-"} / ${state.iniConfigReadiness.coordinates ?? "-"}
- INI limits:
- ${formatAxisLimitSummary(state.profile.axisLimits)}
- INI joints:
- ${state.iniConfigReadiness.jointCount ?? 0} joints / ${state.iniConfigReadiness.axisCount ?? 0} axes
- Machine files:
- ${formatMachineFileStaging(state.machineFileStaging)}
- LinuxCNC G-code:
- ${state.machineFileStaging.selectedGcodeSourceRel || state.programSourceRel || "-"}
- Task/HAL:
- ${formatTaskHalReadiness(state)}
- Task/HAL cycles:
- ${formatTaskHalCycles(state)}
- Full boundary:
- ${formatFullExecutionBoundary(state.fullExecutionBoundary)}
- Planner/task:
- ${formatFullExecutionBlockers(state.fullExecutionBoundary)}
- Boundary evidence:
- ${formatFullExecutionEvidence(state.fullExecutionBoundary)}
- Host/native:
- ${formatHostNativeBoundary(state.fullExecutionBoundary)}
- LinuxCNC kins:
- ${frame.readiness.linuxCncKinematicsReady ? "ready" : "pending"}
- Kins context:
- ${state.kinematicsExecutionContext}
- Interpreter:
- ${state.interpreterRuntimeReadiness?.loaded ? state.interpreterRuntimeReadiness.semanticBoundary : "pending"}
- Interp context:
- ${state.interpreterRuntimeReadiness?.executionContext ?? "none"}
- Profile refs:
- ${state.profile.sourceReferences.length} source references
- Adapter:
- ${state.linuxCncBoundaryAdapter.apiName}
- Panel schema:
- ${state.linuxCncBoundaryAdapter.panelSummary.schemaId} / ${state.linuxCncBoundaryAdapter.panelSummary.buttonCount} buttons
- Source map:
- ${state.linuxCncBoundaryAdapter.sourceSummary.referenceCount} refs / ${state.linuxCncBoundaryAdapter.sourceSummary.sourceRequiredCount} source files
- Profile summary:
- ${state.linuxCncBoundaryAdapter.profileSummary.coordinates} / ${state.linuxCncBoundaryAdapter.profileSummary.jointCount} joints / ${state.linuxCncBoundaryAdapter.profileSummary.toolCount} tools
- Boundary ready:
- ${state.linuxCncBoundaryReadiness.ready ? "ready" : "blocked"} (${state.linuxCncBoundaryReadiness.missing.join(", ")})
`;
}
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 = `
Current Velocity
${state.feed.currentVelocity} mm/min
${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 `
${label}
${prefix ? `${prefix}` : ""}
`;
}
function renderSpindleCoolant(element, state, dispatch) {
element.innerHTML = `
Spindle
${state.spindle.rpm} rpm
${state.spindle.override} %
`;
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 = `
${controls
.map(([label, action]) => {
const gate = bottomControlGate(state, action);
const disabled = gate.allowed ? "" : " disabled";
const title = gate.allowed ? "" : ` title="${escapeHtml(gate.operatorMessage || "blocked")}"`;
return ``;
})
.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("'", "'");
}