新增仿真执行过程回放

结论:真实浏览器仿真页面新增 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

@@ -40,7 +40,7 @@ The project is complete for the current scope when all of the following are true
| --- | --- | --- |
| LinuxCNC semantic boundary | Stable | JS/browser remains glue, staging, OPFS, WASM/browser boundary, docs, and tests only. |
| Real browser simulation priority | Active first priority | Future batches should prioritize the real UI/browser CNC simulation page, with visible machine/session loading, G-code program state, LinuxCNC-backed interpreter execution, machine readiness/status panels, toolpath/preview rendering from LinuxCNC-produced output or validated runtime events, and browser smoke coverage. First entry point: `wasm-port/runtime/ui/simulation/index.html`; gate: `wasm-port/tests/browser/verify_real_simulation_browser.sh`. See `wasm-port/docs/real-browser-simulation-priority.md`. |
| Real browser simulation page | MVP active | `runtime/ui/simulation/index.html` loads in Chromium, exposes a selector for multiple LinuxCNC-backed test programs, runs them through interpreter WASM, renders program lines, final axis readout, canonical event table, raw canonical output, and SVG toolpath preview. Current program set covers square contour, Z pocket contour, incremental loop, G2/G3 arcs, and G81 drilling. |
| Real browser simulation page | MVP active | `runtime/ui/simulation/index.html` loads in Chromium, exposes a selector for multiple LinuxCNC-backed test programs, runs them through interpreter WASM, and now includes reset/step/play/finish playback over LinuxCNC canonical motion events. It highlights active G-code and motion rows, updates live axis readout, moves the toolhead, and grows the executed SVG toolpath. Current program set covers square contour, Z pocket contour, incremental loop, G2/G3 arcs, and G81 drilling. |
| Vendor/source guard | Stable | `verify_vendor_sync.sh` and standalone semantic guard are required every batch. |
| Interpreter WASM smoke | Stable | `interp_wasm_node_smoke=ok`. |
| Sim config inventory | Stable with skips | `executed=28`, `passed=28`, `skipped=131`, `unexpected_fail=0`; `docs/sim-configs-coverage-handoff.md` records the release-gate baseline. |
@@ -85,6 +85,9 @@ Current first priority:
- The page now includes selectable test programs for square contour, Z pocket
contour, incremental loop, G2/G3 arcs, and G81 drilling, with browser smoke
coverage for each program.
- Playback controls now expose reset/step/play/finish over LinuxCNC canonical
motion events, with active G-code line highlighting, active motion row
highlighting, live axis readout, moving toolhead, and executed-path rendering.
- The page must be visible and operator-oriented, not another release dashboard.
- It should use OPFS/session persistence, staged G-code, and existing
LinuxCNC-backed WASM SDK execution.

View File

@@ -1683,3 +1683,68 @@ browser_real_simulation_page_smoke=ok
- JS 没有解释 G-code也没有实现 planner、kinematics、tool、parameter、remap 语义;
- JS 只保存测试程序文本、调用 LinuxCNC WASM、解析 LinuxCNC-produced canonical output 以渲染
UI 状态和刀路终点。
八十、2026-06-16 继续执行记录G-code 和刀具执行过程可视化
用户反馈当前页面只能看到执行后的结果,看不到 G-code 执行过程和刀具执行过程。本批直接补齐
真实 browser simulation page 的回放能力。
本批新增实质能力:
- `runtime/ui/simulation/index.html` 新增 playback controls
- Reset
- Step Back
- Play/Pause
- Step Forward
- Finish
- Speed
- `runtime/ui/simulation/simulation-app.js` 新增 playback frame API
- `createPlaybackFrame(state, index)`
- `renderSimulationPlaybackFrame(documentRef, state, index)`
- `clampPlaybackIndex(motion, index)`
- 页面现在按 LinuxCNC canonical motion event 逐帧渲染执行过程:
- 当前 G-code 行高亮;
- 当前 motion row 高亮;
- 已执行 motion row 标记;
- XYZ/ABC live readout 随 frame 更新;
- SVG 中全路径为浅色预览;
- 已执行路径逐步增长;
- toolhead 圆点随当前 motion frame 移动;
- 页面 API 新增:
- `linuxCncRealSimulationApi.getPlaybackFrame()`
- `linuxCncRealSimulationApi.resetPlayback()`
- `linuxCncRealSimulationApi.stepPlayback(delta)`
- `linuxCncRealSimulationApi.play()`
- `linuxCncRealSimulationApi.pause()`
- `linuxCncRealSimulationApi.finishPlayback()`
- browser smoke 新增回放验证:
- reset 后 playback index 为 0
- step 后 executed toolpath 增长;
- toolhead 坐标变化;
- G-code line 和 motion row active 状态同步变化;
- finish 后 playback complete
- Node smoke 新增 playback frame contract 验证。
本批新增/更新可执行验证:
```bash
wasm-port/tests/ui/node/verify_real_simulation_programs.sh
wasm-port/tests/ui/node/verify_ui_node_smokes.sh
SKIP_INTERP_BUILD=1 wasm-port/tests/browser/verify_real_simulation_browser.sh
wasm-port/tests/docs/node/verify_real_browser_simulation_priority_docs.sh
```
关键通过输出:
```text
real_simulation_programs_node_smoke=ok
ui_node_smokes=ok
browser_real_simulation_page_smoke=ok
real_browser_simulation_priority_docs_node_smoke=ok
```
本批仍保持 LinuxCNC 语义边界:
- 回放基于 LinuxCNC WASM 输出的 canonical motion events
- JS 不解释 G-code不规划刀路不计算 CNC 语义;
- JS 只做播放索引、DOM 高亮、坐标显示、已执行路径和 toolhead 渲染。

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");