新增仿真执行过程回放

结论:真实浏览器仿真页面新增 G-code 执行过程和刀具运动回放,支持 reset、step、play、finish,逐帧高亮 G-code 与 motion row,并渲染已执行刀路和移动 toolhead。
This commit is contained in:
2026-06-16 21:31:50 +08:00
parent ddd5b78999
commit 9e149107dc
8 changed files with 345 additions and 16 deletions

View File

@@ -19,7 +19,10 @@ G-code, tool, parameter, kinematics, remap, or planner semantics.
The first real simulation entry point is `runtime/ui/simulation/index.html`,
validated by `tests/browser/verify_real_simulation_browser.sh`. It now exposes
multiple LinuxCNC-backed test programs from the page selector: square contour,
Z pocket contour, incremental loop, G2/G3 arc path, and G81 drill pattern.
Z pocket contour, incremental loop, G2/G3 arc path, and G81 drill pattern. The
page also includes playback controls that step or play through LinuxCNC
canonical motion events, highlighting the active G-code line and moving the
toolhead over the executed toolpath.
The project release gate for the current scope is:

View File

@@ -34,10 +34,11 @@ Its browser gate is:
wasm-port/tests/browser/verify_real_simulation_browser.sh
```
The current page includes a real LinuxCNC-backed test-program selector. The
browser gate runs each listed program through the WASM interpreter and verifies
that rendered program rows, canonical output, motion rows, toolpath points, and
declared motion types stay aligned with LinuxCNC-produced output. The first
The current page includes a real LinuxCNC-backed test-program selector and
motion playback controls. The browser gate runs each listed program through the
WASM interpreter and verifies that rendered program rows, canonical output,
motion rows, active-line playback, executed toolpath points, moving toolhead,
and declared motion types stay aligned with LinuxCNC-produced output. The first
program set covers linear contouring, Z moves, incremental mode, G2/G3 arcs,
and G81 drilling.

View File

@@ -95,6 +95,23 @@
cursor: wait;
opacity: 0.6;
}
.playback-controls {
border-bottom: 1px solid var(--line);
background: #fbfcfd;
padding: 10px 20px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.playback-controls .spacer {
flex: 1 1 auto;
}
.playback-readout {
color: var(--muted);
font: 12px/1.3 "SFMono-Regular", "Consolas", monospace;
white-space: nowrap;
}
.workspace {
display: grid;
grid-template-columns: minmax(280px, 0.75fr) minmax(420px, 1.35fr) minmax(300px, 0.9fr);
@@ -175,9 +192,16 @@
stroke-width: 0.01;
}
.toolpath {
fill: none;
stroke: #b7c6d2;
stroke-width: 0.028;
stroke-linecap: round;
stroke-linejoin: round;
}
.toolpath-executed {
fill: none;
stroke: var(--blue);
stroke-width: 0.035;
stroke-width: 0.045;
stroke-linecap: round;
stroke-linejoin: round;
}
@@ -254,6 +278,13 @@
td:nth-child(3), td:nth-child(4) {
font-family: "SFMono-Regular", "Consolas", monospace;
}
tr[data-executed="true"] td {
background: #f1f7fb;
}
tr[data-active="true"] td {
background: #e9f4ef;
box-shadow: inset 3px 0 0 var(--green);
}
pre {
margin: 0;
overflow: auto;
@@ -272,6 +303,8 @@
header { grid-template-columns: 1fr; }
.program-controls { justify-content: stretch; }
.program-controls select { min-width: 0; width: 100%; }
.playback-controls button { flex: 1 1 calc(50% - 8px); }
.playback-controls .spacer { display: none; }
.status-strip { justify-content: start; }
.axis-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
dl { grid-template-columns: 1fr; }
@@ -291,6 +324,24 @@
<span class="badge" data-runtime-status>LinuxCNC interpreter WASM</span>
</div>
</header>
<div class="playback-controls" aria-label="Simulation playback controls">
<button type="button" data-playback-reset>Reset</button>
<button type="button" data-playback-prev>Step Back</button>
<button type="button" data-playback-play>Play</button>
<button type="button" data-playback-next>Step Forward</button>
<button type="button" data-playback-finish>Finish</button>
<div class="spacer"></div>
<label class="playback-readout">
Speed
<select data-playback-speed aria-label="Playback speed">
<option value="900">Slow</option>
<option value="500" selected>Normal</option>
<option value="220">Fast</option>
</select>
</label>
<span class="playback-readout">Step <span data-playback-step>0</span>/<span data-playback-total>0</span></span>
<span class="playback-readout" data-playback-progress>0%</span>
</div>
<div class="workspace">
<section aria-label="G-code program">
<div class="section-head">
@@ -310,6 +361,7 @@
<line class="grid-line" x1="-10" y1="0" x2="10" y2="0"></line>
<line class="grid-line" x1="0" y1="-10" x2="0" y2="10"></line>
<polyline class="toolpath" data-toolpath-polyline points=""></polyline>
<polyline class="toolpath-executed" data-toolpath-executed-polyline points=""></polyline>
<circle class="toolhead" data-toolpath-head cx="0" cy="0" r="0.055"></circle>
</svg>
</div>
@@ -360,13 +412,88 @@
import {
DEFAULT_SIMULATION_PROGRAM_ID,
getSimulationTestPrograms,
renderSimulationPlaybackFrame,
runRealBrowserSimulation,
} from "./simulation-app.js";
const runButton = document.querySelector("[data-run-program]");
const programSelector = document.querySelector("[data-program-selector]");
const resetButton = document.querySelector("[data-playback-reset]");
const prevButton = document.querySelector("[data-playback-prev]");
const playButton = document.querySelector("[data-playback-play]");
const nextButton = document.querySelector("[data-playback-next]");
const finishButton = document.querySelector("[data-playback-finish]");
const speedSelector = document.querySelector("[data-playback-speed]");
let playbackTimer = null;
let playbackIndex = 0;
function currentState() {
return window.linuxCncRealSimulationState;
}
function stopPlayback() {
if (playbackTimer) {
clearInterval(playbackTimer);
playbackTimer = null;
}
if (playButton) {
playButton.textContent = "Play";
}
}
function renderPlayback(index) {
const state = currentState();
if (!state) {
return null;
}
const frame = renderSimulationPlaybackFrame(document, state, index);
playbackIndex = frame.index;
if (frame.index >= frame.total - 1) {
stopPlayback();
}
return frame;
}
function stepPlayback(delta) {
const state = currentState();
if (!state) {
return null;
}
return renderPlayback(playbackIndex + delta);
}
function startPlayback() {
const state = currentState();
if (!state || state.motion.length === 0) {
return;
}
if (playbackIndex >= state.motion.length - 1) {
playbackIndex = -1;
}
stopPlayback();
if (playButton) {
playButton.textContent = "Pause";
}
const delay = Number(speedSelector?.value ?? 500);
playbackTimer = setInterval(() => {
const frame = stepPlayback(1);
if (!frame || frame.index >= frame.total - 1) {
stopPlayback();
}
}, delay);
stepPlayback(1);
}
function togglePlayback() {
if (playbackTimer) {
stopPlayback();
} else {
startPlayback();
}
}
async function runSelectedProgram(programId = programSelector?.value ?? DEFAULT_SIMULATION_PROGRAM_ID) {
stopPlayback();
document.body.dataset.simulationReady = "false";
document.querySelector("[data-simulation-status]").textContent = "running";
if (runButton) {
@@ -375,6 +502,7 @@
try {
const state = await runRealBrowserSimulation({ documentRef: document, programId });
window.linuxCncRealSimulationState = state;
renderPlayback(0);
return state;
} finally {
if (runButton) {
@@ -389,6 +517,12 @@
window.linuxCncRealSimulationApi = {
getState: () => window.linuxCncRealSimulationState,
getPrograms: getSimulationTestPrograms,
getPlaybackFrame: () => renderPlayback(playbackIndex),
resetPlayback: () => renderPlayback(0),
stepPlayback: (delta = 1) => stepPlayback(delta),
play: startPlayback,
pause: stopPlayback,
finishPlayback: () => renderPlayback(currentState()?.motion.length - 1 ?? 0),
runProgramById: async (programId) => {
const nextState = await runSelectedProgram(programId);
window.linuxCncRealSimulationState = nextState;
@@ -402,7 +536,18 @@
document.querySelector("[data-canonical-output]").textContent = error?.stack ?? error?.message ?? String(error);
});
});
resetButton?.addEventListener("click", () => renderPlayback(0));
prevButton?.addEventListener("click", () => stepPlayback(-1));
playButton?.addEventListener("click", togglePlayback);
nextButton?.addEventListener("click", () => stepPlayback(1));
finishButton?.addEventListener("click", () => renderPlayback(currentState()?.motion.length - 1 ?? 0));
speedSelector?.addEventListener("change", () => {
if (playbackTimer) {
startPlayback();
}
});
} catch (error) {
stopPlayback();
document.body.dataset.simulationReady = "false";
document.querySelector("[data-simulation-status]").textContent = "failed";
document.querySelector("[data-canonical-output]").textContent = error?.stack ?? error?.message ?? String(error);

View File

@@ -204,6 +204,37 @@ export function createToolpathPolylinePoints(motion) {
return motion.map(({ axes }) => `${axes.x ?? 0},${-(axes.y ?? 0)}`).join(" ");
}
export function clampPlaybackIndex(motion, index) {
if (motion.length === 0) {
return -1;
}
if (!Number.isFinite(index)) {
return motion.length - 1;
}
return Math.min(Math.max(Math.trunc(index), 0), motion.length - 1);
}
export function createPlaybackFrame(state, index = state.motion.length - 1) {
const frameIndex = clampPlaybackIndex(state.motion, index);
const activeMotion = frameIndex >= 0 ? state.motion[frameIndex] : null;
const visibleMotion = frameIndex >= 0 ? state.motion.slice(0, frameIndex + 1) : [];
const axes = activeMotion?.axes ?? Object.fromEntries(AXES.map((axis) => [axis, 0]));
return {
apiName: "real-browser-simulation-playback-frame",
frameVersion: 1,
index: frameIndex,
step: frameIndex + 1,
total: state.motion.length,
progress: state.motion.length > 0 ? Math.round(((frameIndex + 1) / state.motion.length) * 100) : 0,
activeMotion,
activeLine: activeMotion?.line ?? null,
activeStatement: activeMotion?.statement ?? "-",
axes,
visibleMotion,
fullMotion: state.motion,
};
}
export function formatPosition(axes = {}) {
return `X ${formatAxis(axes.x)} Y ${formatAxis(axes.y)} Z ${formatAxis(axes.z)}`;
}
@@ -279,9 +310,10 @@ function renderAxisReadout(documentRef, axes) {
}
}
function renderToolpath(documentRef, motion) {
function renderToolpath(documentRef, motion, visibleMotion = motion) {
const svg = documentRef.querySelector("[data-toolpath-svg]");
const polyline = documentRef.querySelector("[data-toolpath-polyline]");
const executedPolyline = documentRef.querySelector("[data-toolpath-executed-polyline]");
const head = documentRef.querySelector("[data-toolpath-head]");
if (!svg || !polyline || !head) {
return;
@@ -293,12 +325,13 @@ function renderToolpath(documentRef, motion) {
`${viewBox.minX} ${-(viewBox.minY + viewBox.height)} ${viewBox.width} ${viewBox.height}`,
);
polyline.setAttribute("points", createToolpathPolylinePoints(motion));
const last = motion.at(-1)?.axes ?? { x: 0, y: 0 };
executedPolyline?.setAttribute("points", createToolpathPolylinePoints(visibleMotion));
const last = visibleMotion.at(-1)?.axes ?? motion[0]?.axes ?? { x: 0, y: 0 };
head.setAttribute("cx", `${last.x ?? 0}`);
head.setAttribute("cy", `${-(last.y ?? 0)}`);
}
function renderMotionTable(documentRef, motion) {
function renderMotionTable(documentRef, motion, activeIndex = null) {
const body = documentRef.querySelector("[data-motion-rows]");
if (!body) {
return;
@@ -307,6 +340,8 @@ function renderMotionTable(documentRef, motion) {
for (const [index, item] of motion.entries()) {
const row = documentRef.createElement("tr");
row.dataset.motionRow = `${index}`;
row.dataset.active = activeIndex === index ? "true" : "false";
row.dataset.executed = activeIndex !== null && index <= activeIndex ? "true" : "false";
for (const value of [
item.type,
item.line ?? "-",
@@ -321,21 +356,32 @@ function renderMotionTable(documentRef, motion) {
}
}
export function renderSimulationPlaybackFrame(documentRef, state, index = state.motion.length - 1) {
const frame = createPlaybackFrame(state, index);
documentRef.body.dataset.playbackIndex = `${frame.index}`;
documentRef.body.dataset.playbackComplete = frame.index === frame.total - 1 ? "true" : "false";
setText(documentRef, "[data-playback-step]", `${frame.step}`);
setText(documentRef, "[data-playback-total]", `${frame.total}`);
setText(documentRef, "[data-playback-progress]", `${frame.progress}%`);
setText(documentRef, "[data-active-line]", `${frame.activeLine ?? "-"}`);
setText(documentRef, "[data-active-statement]", frame.activeStatement);
renderProgramLines(documentRef, state.programText, frame.activeLine);
renderAxisReadout(documentRef, frame.axes);
renderToolpath(documentRef, frame.fullMotion, frame.visibleMotion);
renderMotionTable(documentRef, frame.fullMotion, frame.index);
return frame;
}
export function renderSimulationState(documentRef, state) {
documentRef.body.dataset.simulationReady = state.summary.ready ? "true" : "false";
documentRef.body.dataset.simulationProgramId = state.program?.id ?? "custom";
setText(documentRef, "[data-simulation-status]", state.summary.ready ? "ready" : "blocked");
setText(documentRef, "[data-runtime-status]", state.summary.rows[0].value);
setText(documentRef, "[data-selected-program]", state.program?.label ?? "Custom program");
setText(documentRef, "[data-active-line]", `${state.motion.at(-1)?.line ?? "-"}`);
setText(documentRef, "[data-active-statement]", state.motion.at(-1)?.statement ?? "-");
setText(documentRef, "[data-canonical-output]", state.resultText);
renderProgramSelector(documentRef, state.program?.id ?? "custom");
renderRows(documentRef, state.summary.rows);
renderProgramLines(documentRef, state.programText, state.motion.at(-1)?.line ?? null);
renderAxisReadout(documentRef, state.summary.finalAxes);
renderToolpath(documentRef, state.motion);
renderMotionTable(documentRef, state.motion);
renderSimulationPlaybackFrame(documentRef, state, state.motion.length - 1);
}
export function getSimulationTestPrograms() {

View File

@@ -92,14 +92,56 @@
if (!api?.getPrograms || !api?.runProgramById) {
throw new Error("simulation API missing program controls");
}
if (!api?.resetPlayback || !api?.stepPlayback || !api?.finishPlayback) {
throw new Error("simulation API missing playback controls");
}
const programs = api.getPrograms();
if (programs.length < 5) {
throw new Error("simulation test program inventory too small");
}
api.resetPlayback();
if (doc.body.dataset.playbackIndex !== "0") {
throw new Error("simulation playback did not reset to first frame");
}
const firstHead = doc.querySelector("[data-toolpath-head]");
const firstCx = firstHead?.getAttribute("cx");
const firstCy = firstHead?.getAttribute("cy");
const firstExecutedPoints = doc.querySelector("[data-toolpath-executed-polyline]")?.getAttribute("points") ?? "";
if (!doc.querySelector('[data-program-line="1"]') || doc.querySelector('[data-program-line="2"]')?.dataset.active !== "false") {
throw new Error("simulation reset playback did not render first active program line");
}
const secondFrame = api.stepPlayback(1);
if (secondFrame.index !== 1 || secondFrame.activeLine !== 2) {
throw new Error(`simulation step playback drift: ${JSON.stringify(secondFrame)}`);
}
const secondExecutedPoints = doc.querySelector("[data-toolpath-executed-polyline]")?.getAttribute("points") ?? "";
if (secondExecutedPoints === firstExecutedPoints || !secondExecutedPoints.includes("1,0")) {
throw new Error("simulation executed toolpath did not advance on step");
}
if (firstHead?.getAttribute("cx") === firstCx && firstHead?.getAttribute("cy") === firstCy) {
throw new Error("simulation toolhead did not move on playback step");
}
if (doc.querySelector('[data-program-line="2"]')?.dataset.active !== "true") {
throw new Error("simulation step playback did not highlight active G-code line");
}
if (doc.querySelector('[data-motion-row="1"]')?.dataset.active !== "true") {
throw new Error("simulation step playback did not highlight active motion row");
}
const finishedFrame = api.finishPlayback();
if (finishedFrame.index !== state.motion.length - 1 || doc.body.dataset.playbackComplete !== "true") {
throw new Error("simulation finish playback did not reach final frame");
}
for (const program of programs) {
const nextState = await api.runProgramById(program.id);
assertRenderedState(doc, nextState);
const resetFrame = api.resetPlayback();
if (resetFrame.index !== 0 || doc.body.dataset.playbackIndex !== "0") {
throw new Error(`simulation playback reset failed for ${program.id}`);
}
if (nextState.program.id !== program.id) {
throw new Error(`simulation API returned wrong program id for ${program.id}`);
}

View File

@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import {
DEFAULT_SIMULATION_PROGRAM,
DEFAULT_SIMULATION_PROGRAM_ID,
createPlaybackFrame,
createSimulationSummary,
createToolpathPolylinePoints,
getSimulationTestProgram,
@@ -80,4 +81,27 @@ assert.ok(summary.motionTypes.includes("ARC_FEED"));
assert.equal(summary.finalAxes.x, 0);
assert.equal(summary.finalAxes.y, 0);
const playbackState = {
motion: arcMotion,
};
const firstFrame = createPlaybackFrame(playbackState, 0);
assert.equal(firstFrame.apiName, "real-browser-simulation-playback-frame");
assert.equal(firstFrame.index, 0);
assert.equal(firstFrame.step, 1);
assert.equal(firstFrame.total, 3);
assert.equal(firstFrame.activeLine, 1);
assert.equal(firstFrame.visibleMotion.length, 1);
const arcFrame = createPlaybackFrame(playbackState, 1);
assert.equal(arcFrame.index, 1);
assert.equal(arcFrame.activeLine, 3);
assert.equal(arcFrame.activeStatement, "G2 X1 Y1 I1 J0 F80");
assert.equal(arcFrame.axes.x, 1);
assert.equal(arcFrame.axes.y, 1);
assert.equal(arcFrame.visibleMotion.length, 2);
const clampedFrame = createPlaybackFrame(playbackState, 99);
assert.equal(clampedFrame.index, 2);
assert.equal(clampedFrame.progress, 100);
console.log("real_simulation_programs_node_smoke=ok");