diff --git a/PROJECT_COMPLETION_TRACKER.md b/PROJECT_COMPLETION_TRACKER.md index c09d7b2..18fbbc5 100644 --- a/PROJECT_COMPLETION_TRACKER.md +++ b/PROJECT_COMPLETION_TRACKER.md @@ -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. diff --git a/text15.txt b/text15.txt index d02ed5d..24e8bea 100644 --- a/text15.txt +++ b/text15.txt @@ -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 渲染。 diff --git a/wasm-port/README.md b/wasm-port/README.md index b1b88a0..1d34520 100644 --- a/wasm-port/README.md +++ b/wasm-port/README.md @@ -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: diff --git a/wasm-port/docs/real-browser-simulation-priority.md b/wasm-port/docs/real-browser-simulation-priority.md index b12f856..ccb838b 100644 --- a/wasm-port/docs/real-browser-simulation-priority.md +++ b/wasm-port/docs/real-browser-simulation-priority.md @@ -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. diff --git a/wasm-port/runtime/ui/simulation/index.html b/wasm-port/runtime/ui/simulation/index.html index c03fcee..35c3f71 100644 --- a/wasm-port/runtime/ui/simulation/index.html +++ b/wasm-port/runtime/ui/simulation/index.html @@ -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 @@ LinuxCNC interpreter WASM +
+ + + + + +
+ + Step 0/0 + 0% +
@@ -310,6 +361,7 @@ +
@@ -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); diff --git a/wasm-port/runtime/ui/simulation/simulation-app.js b/wasm-port/runtime/ui/simulation/simulation-app.js index 4f8817b..53f2c06 100644 --- a/wasm-port/runtime/ui/simulation/simulation-app.js +++ b/wasm-port/runtime/ui/simulation/simulation-app.js @@ -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() { diff --git a/wasm-port/tests/browser/real_simulation_page_smoke.html b/wasm-port/tests/browser/real_simulation_page_smoke.html index 788d80c..bcdbd10 100644 --- a/wasm-port/tests/browser/real_simulation_page_smoke.html +++ b/wasm-port/tests/browser/real_simulation_page_smoke.html @@ -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}`); } diff --git a/wasm-port/tests/ui/node/verify_real_simulation_programs.mjs b/wasm-port/tests/ui/node/verify_real_simulation_programs.mjs index 5934095..b1b5b97 100644 --- a/wasm-port/tests/ui/node/verify_real_simulation_programs.mjs +++ b/wasm-port/tests/ui/node/verify_real_simulation_programs.mjs @@ -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");