接入 LinuxCNC TP 运行反馈

This commit is contained in:
2026-06-21 23:29:56 +08:00
parent 626bcfe8e3
commit 3771b9eafe
44 changed files with 7881 additions and 326 deletions

View File

@@ -32,6 +32,9 @@ export function mountGmoccapyShell(root, store) {
);
store.subscribe((state) => render(regions, state, store.dispatch));
root.addEventListener("profile-change", (event) => {
store.dispatch({ type: "SET_PROFILE", profileId: event.detail.profileId });
});
return {
getRegions() {
@@ -46,7 +49,7 @@ function render(regions, state, dispatch) {
renderTitlebar(regions.titlebar, state);
renderPreview(regions.preview, state, dispatch);
renderDro(regions.dro, state);
renderGcode(regions.gcode, state);
renderGcode(regions.gcode, state, dispatch);
renderSidebar(regions["status-sidebar"], state, dispatch);
renderInfoTabs(regions["info-tabs"], state);
renderOverride(regions.override, state, dispatch);
@@ -61,8 +64,22 @@ function renderTitlebar(element, state) {
<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="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) {
@@ -137,7 +154,7 @@ function droRow(axis, value, dtg) {
`;
}
function renderGcode(element, state) {
function renderGcode(element, state, dispatch) {
const rows = state.programLines
.map((line, index) => {
const lineNumber = state.programStartLine + index;
@@ -158,12 +175,101 @@ function renderGcode(element, state) {
<span data-program-source="${state.programSource}">${state.programSource}</span>
<span data-active-program-line="${state.activeLine}">Current line ${state.activeLine}</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 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) {
@@ -173,10 +279,10 @@ function renderSidebar(element, state, dispatch) {
<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 === "jog"}">JOG</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 === "tcp-xyzac"}">TCP</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>
`;
@@ -191,12 +297,14 @@ function renderSidebar(element, state, dispatch) {
dispatch({ type: "SET_KINS_TYPE", kinsType: "identity" });
});
element.querySelector('[data-action="kins-tcp"]').addEventListener("click", () => {
dispatch({ type: "SET_KINS_TYPE", kinsType: "tcp-xyzac" });
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>
@@ -207,8 +315,21 @@ function renderInfoTabs(element, state) {
<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">${state.sessionPersistence.status} / ${state.sessionPersistence.path ?? "-"}</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>
@@ -216,7 +337,19 @@ function renderInfoTabs(element, state) {
<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>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>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>
@@ -227,6 +360,97 @@ function renderInfoTabs(element, state) {
`;
}
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 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 || "-"}`;
}
return `${machineFileStaging.status} ${machineFileStaging.fileCount || 0} files ${machineFileStaging.opfsRoot || "-"}`;
}
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 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 renderOverride(element, state, dispatch) {
element.innerHTML = `
<section class="meter-card">
@@ -301,6 +525,7 @@ function renderBottomControls(element, state, dispatch) {
["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 })],
@@ -308,6 +533,9 @@ function renderBottomControls(element, state, dispatch) {
["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" })],
];
@@ -345,6 +573,12 @@ 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;")