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 = `
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 = `
    ${escapeHtml(state.activeProgram)} ${state.programSource} Executing line ${currentLine}
    ${formatLinuxCncGcodeSourceStatus(state)}
      ${rows}
    ${state.activeLine} / ${programEndLine}
    MDI
    ${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}` : ""}
    ${value} %
    `; } function renderSpindleCoolant(element, state, dispatch) { element.innerHTML = `

    Cooling

    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("'", "'"); }