同步五轴仿真文档和验证证据

This commit is contained in:
2026-06-26 09:45:39 -04:00
parent d5d96a8e5c
commit 8df191faa5
188 changed files with 19504 additions and 42 deletions

View File

@@ -856,6 +856,9 @@ export function createSimulationStore(seed = {}) {
}
break;
case "TASK_HAL_SESSION_READY":
if (action.session?.programSourceRel && action.session.programSourceRel !== state.machineFileStaging?.selectedGcodeSourceRel) {
break;
}
setState({
taskHalSession: action.session,
taskHalFallbackReason: null,
@@ -898,6 +901,12 @@ export function createSimulationStore(seed = {}) {
operatorMessage: action.operatorMessage || state.operatorMessage,
});
break;
case "TASK_HAL_PROGRAM_STOPPED":
setState(createStoppedProgramStatePatch(state, {
reason: action.reason || "stopped",
operatorMessage: action.operatorMessage || "task/HAL program stopped",
}));
break;
case "TASK_HAL_COMMAND_FAILED":
setState({
taskHalFallbackReason: action.error,
@@ -1243,9 +1252,21 @@ export function createSimulationStore(seed = {}) {
stopTaskHalStatusLoop(action.type === "ABORT" ? "aborted" : "stopped", {
operatorMessage: action.type === "ABORT" ? "task/HAL abort requested" : "task/HAL stop requested",
});
setState(createStoppedProgramStatePatch(state, {
reason: action.type === "ABORT" ? "aborted" : "stopped",
operatorMessage: action.type === "ABORT" ? "task abort requested" : "program stop requested",
}));
runTaskHalCommandSequence([
{ type: "EMC_TASK_ABORT" },
], { operatorMessage: action.type === "ABORT" ? "task/HAL abort complete" : "task/HAL program stopped" }).catch(() => {});
], { operatorMessage: action.type === "ABORT" ? "task/HAL abort complete" : "task/HAL program stopped" })
.then(() => {
dispatch({
type: "TASK_HAL_PROGRAM_STOPPED",
reason: action.type === "ABORT" ? "aborted" : "stopped",
operatorMessage: action.type === "ABORT" ? "task/HAL abort complete" : "task/HAL program stopped",
});
})
.catch(() => {});
break;
}
setState({
@@ -1304,8 +1325,8 @@ export function createSimulationStore(seed = {}) {
{ type: "EMC_TASK_PLAN_RESUME" },
], {
operatorMessage: "task/HAL program resumed",
}).then(() => {
if (state.runState === "running" || state.machine.interpState === "reading") {
}).then((status) => {
if (shouldContinueTaskHalStatusLoop(state, status)) {
startTaskHalStatusLoop({
operatorMessage: "task/HAL status loop resumed",
});
@@ -1337,7 +1358,9 @@ export function createSimulationStore(seed = {}) {
}
if (state.taskHalRuntime?.loaded) {
stopTaskHalStatusLoop("step", { operatorMessage: "task/HAL step requested" });
runTaskHalCommandSequence([], {
runTaskHalCommandSequence([
{ type: "EMC_TASK_PLAN_STEP" },
], {
taskCycles: 1,
operatorMessage: "task/HAL stepped one cycle",
}).catch(() => {});
@@ -1412,6 +1435,20 @@ export function createSimulationStore(seed = {}) {
break;
}
if (state.taskHalRuntime?.loaded) {
setState({
machine: {
...state.machine,
mode: "manual",
allHomed: true,
interpState: "idle",
interpResumeState: "idle",
taskPaused: false,
},
runState: "idle",
axisPose: initialAxisPose,
programRuntimeFeedback: null,
operatorMessage: "task/HAL home requested",
});
runTaskHalCommandSequence([
{ type: "EMC_JOINT_HOME", joint: -1 },
], {
@@ -1421,6 +1458,7 @@ export function createSimulationStore(seed = {}) {
allHomed: true,
},
}).catch(() => {});
break;
}
setState({
machine: {
@@ -1705,6 +1743,7 @@ export function createSimulationStore(seed = {}) {
if (!state.taskHalRuntime?.loaded || !state.machineFileStaging?.save?.files?.length) {
return null;
}
stopTaskHalStatusLoop("session-initialize", { notify: false });
const preserveMachine = {
powerOn: state.machine.powerOn,
estopActive: state.machine.estopActive,
@@ -1719,6 +1758,9 @@ export function createSimulationStore(seed = {}) {
save: state.machineFileStaging.save,
selectedProgramRel: state.machineFileStaging.selectedGcodeSourceRel,
});
if (session.programSourceRel && session.programSourceRel !== state.machineFileStaging.selectedGcodeSourceRel) {
return null;
}
await state.taskHalRuntime.resetSession?.();
await state.taskHalRuntime.initSession({
profileId: session.profileId,
@@ -1746,6 +1788,9 @@ export function createSimulationStore(seed = {}) {
...deriveTaskHalCyclePeriods(state),
taskCycles: 1,
});
if (session.programSourceRel && session.programSourceRel !== state.machineFileStaging.selectedGcodeSourceRel) {
return null;
}
dispatch({ type: "TASK_HAL_SESSION_READY", session });
const status = await state.taskHalRuntime.readStatus();
dispatch({
@@ -2345,11 +2390,12 @@ function applyTaskHalStatusPatch(state, status, operatorMessage, {
: rawTaskState;
const taskMode = normalizeLinuxCncTaskMode(preserveMachine?.mode || ui.taskMode || task.mode || state.machine.mode);
const interpState = normalizeTaskHalInterpState(ui.interpState || task.interpState);
const allHomed = Boolean(preserveMachine?.allHomed ?? state.machine.allHomed);
const activeLine = state.programStartLine + Math.max(Number(ui.activeLine || 1) - 1, 0);
const kinsType = resolveTaskHalKinsType(state, status, activeLine);
const axisPose = resolveTaskHalAxisPose(state, status);
const currentVelocity = Number.isFinite(ui.currentVelocity) && ui.currentVelocity > 0
? ui.currentVelocity
const currentVelocity = Number.isFinite(ui.currentVelocity)
? Math.max(ui.currentVelocity, 0)
: state.feed.currentVelocity;
const paused = interpState === "paused" || motion.paused === true;
const aborted = motion.aborted === true;
@@ -2400,7 +2446,8 @@ function applyTaskHalStatusPatch(state, status, operatorMessage, {
interpState,
interpResumeState: paused ? state.machine.interpResumeState || "reading" : interpState,
taskPaused: paused,
allHomed: Boolean(preserveMachine?.allHomed ?? state.machine.allHomed),
allHomed,
noForceHoming: Boolean(state.machine.noForceHoming),
},
runState,
taskHalStatusLoop: loopSequence === null
@@ -2440,6 +2487,33 @@ function shouldContinueTaskHalStatusLoop(state = {}, status = {}) {
return !aborted && !paused && !complete && (interpState === "reading" || taskMode === "mdi");
}
function createStoppedProgramStatePatch(state, {
reason = "stopped",
operatorMessage = "program stopped",
} = {}) {
return {
machine: {
...state.machine,
interpState: "idle",
interpResumeState: "idle",
taskPaused: false,
},
runState: reason === "aborted" ? "stopped" : reason,
taskHalStatusLoop: {
...state.taskHalStatusLoop,
active: false,
sequence: Number(state.taskHalStatusLoop?.sequence || 0) + 1,
stopReason: reason,
lastStatusAt: new Date().toISOString(),
},
feed: {
...state.feed,
currentVelocity: 0,
},
operatorMessage,
};
}
function resolveTaskHalKinsType(state, status, activeLine) {
const ui = status?.ui || {};
const numeric = Number(ui.switchkinsType);

View File

@@ -459,18 +459,18 @@ button:active {
}
.gcode-row.active {
background: #242424;
color: #202020;
background: #21a553;
color: #0c2b17;
border-left-color: #2ebf63;
}
.gcode-row[data-line-status="done"] {
background: #eef6ec;
border-left-color: #79a96c;
background: #eeeeee;
border-left-color: #b8b8b8;
}
.gcode-row[data-line-status="running"] {
background: #242424;
background: #21a553;
border-left-color: #2ebf63;
}
@@ -480,7 +480,7 @@ button:active {
.gcode-row[data-line-status="running"] span,
.gcode-row[data-line-status="running"] code,
.gcode-row[data-line-status="running"] small {
color: #f2f2f2;
color: #ffffff;
}
.gcode-row code {

View File

@@ -201,14 +201,12 @@ function renderGcode(element, state, dispatch) {
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");
const active = lineNumber === currentLine ? " active" : "";
const status = active ? "running" : lineNumber < currentLine ? "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>
`;
})
@@ -216,7 +214,7 @@ function renderGcode(element, state, dispatch) {
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),
Math.max(((currentLine - state.programStartLine) / progressSpan) * 100, 0),
100,
);
@@ -238,7 +236,7 @@ function renderGcode(element, state, dispatch) {
</div>
<ol class="gcode-list" start="${state.programStartLine}">${rows}</ol>
<div class="gcode-progress">
<span>${state.activeLine} / ${programEndLine}</span>
<span>${currentLine} / ${programEndLine}</span>
<div><i style="width: ${progress}%"></i></div>
</div>
<section class="mdi-panel" data-mdi-mode="${state.machine.mode === "mdi"}">
@@ -284,6 +282,16 @@ function renderGcode(element, state, dispatch) {
if (!linuxCncSourceSelect.value) return;
dispatch({ type: "LOAD_LINUXCNC_GCODE_SOURCE", sourceRel: linuxCncSourceSelect.value });
});
scrollGcodeListToCurrentLine(element, currentLine);
}
function scrollGcodeListToCurrentLine(element, currentLine) {
const list = element.querySelector(".gcode-list");
const row = element.querySelector(`.gcode-row[data-program-line="${currentLine}"]`);
if (!list || !row) return;
const targetTop = row.offsetTop - list.offsetTop - (list.clientHeight - row.clientHeight) / 2;
list.scrollTop = Math.max(targetTop, 0);
}
function currentGcodeExecutionLine(state) {
@@ -471,21 +479,6 @@ function formatProgramRuntimeDtg(state) {
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);
@@ -643,6 +636,7 @@ function renderSpindleCoolant(element, state, dispatch) {
}
function renderBottomControls(element, state, dispatch) {
const promptableActions = new Set(["RUN", "STOP", "PAUSE", "RESUME", "STEP", "HOME"]);
const controls = [
["Open", "OPEN", null],
["Reload", "RELOAD", () => dispatch({ type: "RELOAD_PROGRAM" })],
@@ -669,9 +663,13 @@ function renderBottomControls(element, state, dispatch) {
${controls
.map(([label, action]) => {
const gate = bottomControlGate(state, action);
const disabled = gate.allowed ? "" : " disabled";
const promptable = promptableActions.has(action);
const disabled = gate.allowed || promptable ? "" : " disabled";
const title = gate.allowed ? "" : ` title="${escapeHtml(gate.operatorMessage || "blocked")}"`;
return `<button type="button" data-action="${action}"${disabled}${title}>${label}</button>`;
const blockedAttrs = gate.allowed
? ` data-command-ready="true" aria-disabled="false"`
: ` data-command-ready="false" aria-disabled="${promptable ? "true" : "false"}"`;
return `<button type="button" data-action="${action}"${disabled}${title}${blockedAttrs}>${label}</button>`;
})
.join("")}
`;

File diff suppressed because it is too large Load Diff

View File

@@ -44,6 +44,43 @@
}
return win.webRtcp5AxisSimulation.getState();
}
function assertActiveGcodeRowVisible(doc, expectedLine) {
const activeRow = doc.querySelector(".gcode-row.active");
const list = doc.querySelector(".gcode-list");
if (!activeRow || !list) {
throw new Error("missing active G-code row or list");
}
if (expectedLine && activeRow.dataset.programLine !== String(expectedLine)) {
throw new Error(`active G-code row should be line ${expectedLine}, got ${activeRow.dataset.programLine}`);
}
if (activeRow.dataset.lineStatus !== "running") {
throw new Error(`active G-code row should be running, got ${activeRow.dataset.lineStatus}`);
}
const activeBackground = getComputedStyle(activeRow).backgroundColor.replace(/\s/g, "");
if (activeBackground !== "rgb(33,165,83)") {
throw new Error(`active G-code row should be green, got ${activeBackground}`);
}
const previousLine = Number(activeRow.dataset.programLine || 0) - 1;
const previousRow = doc.querySelector(`.gcode-row[data-program-line="${previousLine}"]`);
if (previousRow) {
const previousBackground = getComputedStyle(previousRow).backgroundColor.replace(/\s/g, "");
if (previousRow.dataset.lineStatus !== "done") {
throw new Error(`executed G-code row should be done, got ${previousRow.dataset.lineStatus}`);
}
if (previousBackground !== "rgb(238,238,238)") {
throw new Error(`executed G-code row should be light gray, got ${previousBackground}`);
}
}
const listText = list.textContent;
if (/\bpending\b|\bstopped\s+\|\s+F\b|\brunning\s+\|\s+F\b/.test(listText)) {
throw new Error("G-code list should not render execution status text");
}
const rowBounds = activeRow.getBoundingClientRect();
const listBounds = list.getBoundingClientRect();
if (rowBounds.bottom < listBounds.top || rowBounds.top > listBounds.bottom) {
throw new Error(`active G-code row ${activeRow.dataset.programLine} is not visible after auto-scroll`);
}
}
async function runSmoke() {
await new Promise((resolve, reject) => {
@@ -236,10 +273,18 @@
throw new Error("initial machine state should show power off");
}
win.webRtcp5AxisSimulation.dispatch({ type: "RUN" });
const initiallyBlockedRunButton = doc.querySelector('[data-action="RUN"]');
if (
initiallyBlockedRunButton.disabled ||
initiallyBlockedRunButton.dataset.commandReady !== "false" ||
initiallyBlockedRunButton.getAttribute("aria-disabled") !== "true"
) {
throw new Error("RUN button should remain clickable and marked blocked before power on");
}
initiallyBlockedRunButton.click();
await wait(50);
if (!win.webRtcp5AxisSimulation.getState().operatorMessage.includes("blocked")) {
throw new Error("RUN should be blocked before power on");
throw new Error("RUN button should report why execution is blocked before power on");
}
doc.querySelector('[data-action="power"]').click();
await wait(50);
@@ -489,12 +534,10 @@
if (!doc.querySelector('[data-session-persistence="status"]')?.textContent.includes("opfs unavailable")) {
throw new Error("session save status did not render OPFS unavailable fallback");
}
if (doc.querySelector('[data-active-program-line]')?.textContent !== "Current line 2") {
if (doc.querySelector('[data-active-program-line]')?.textContent !== "Executing line 2") {
throw new Error("loaded program did not render first LinuxCNC motion line");
}
if (doc.querySelector(".gcode-row.active")?.dataset.programLine !== "2") {
throw new Error("loaded program active row should be first LinuxCNC motion line");
}
assertActiveGcodeRowVisible(doc, 2);
win.webRtcp5AxisSimulation.dispatch({ type: "RUN" });
await wait(250);
@@ -510,6 +553,25 @@
if (Number(doc.querySelector(".gcode-row.active")?.dataset.programLine || 0) < 2) {
throw new Error("RUN did not highlight a LinuxCNC task/HAL motion line");
}
assertActiveGcodeRowVisible(doc);
const stopLineBefore = win.webRtcp5AxisSimulation.getState().activeLine;
doc.querySelector('[data-action="STOP"]').click();
await wait(150);
const stoppedState = win.webRtcp5AxisSimulation.getState();
if (stoppedState.runState !== "stopped" || stoppedState.machine.interpState !== "idle") {
throw new Error(`STOP did not stop the running G-code program: ${stoppedState.runState}/${stoppedState.machine.interpState}`);
}
if (stoppedState.feed.currentVelocity !== 0) {
throw new Error(`STOP did not zero current velocity: ${stoppedState.feed.currentVelocity}`);
}
if (stoppedState.activeLine !== stopLineBefore) {
throw new Error(`STOP allowed G-code execution to continue: ${stopLineBefore} -> ${stoppedState.activeLine}`);
}
doc.querySelector('[data-action="RUN"]').click();
await wait(250);
if (win.webRtcp5AxisSimulation.getState().runState !== "running") {
throw new Error("RUN did not restart after STOP");
}
canvas = doc.querySelector("[data-five-axis-canvas]");
if (
Number(canvas.dataset.threeExecutedPathPoints ?? 0) < 1 ||

View File

@@ -119,6 +119,20 @@ store.dispatch({ type: "RESUME" });
await waitForTaskHal(store);
assert.equal(store.getState().machine.interpState, "reading");
store.dispatch({ type: "STEP" });
await waitForTaskHal(store);
state = store.getState();
assert.equal(state.machine.interpState, "paused");
assert.equal(state.machine.taskPaused, true);
assert.equal(state.taskHalStatus.task.singleStepping, true);
store.dispatch({ type: "STOP" });
await waitForTaskHal(store);
state = store.getState();
assert.equal(state.runState, "stopped");
assert.equal(state.machine.interpState, "idle");
assert.equal(state.taskHalStatus.motionStatus.motion.aborted, true);
console.log("linuxcnc_task_hal_runtime_smoke=ok");
console.log("task_hal_machine_file_smoke=ok");
console.log("switchkins_remap_hal_sync_smoke=ok");

View File

@@ -199,8 +199,16 @@ async function verifyRunReadySequence({ profileId, sourceRel }) {
assert.notEqual(state.operatorMessage, "run blocked: home machine first");
assert.equal(state.machine.allHomed, true);
const activeLineBeforeStop = state.activeLine;
store.dispatch({ type: "STOP" });
await waitForTaskHalCommand(store);
await new Promise((resolve) => setTimeout(resolve, 120));
state = store.getState();
assert.equal(state.taskHalStatusLoop.active, false);
assert.equal(state.runState, "stopped");
assert.equal(state.machine.interpState, "idle");
assert.equal(state.feed.currentVelocity, 0);
assert.equal(state.activeLine, activeLineBeforeStop);
}
async function waitForTaskHalCommand(store) {