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%
+
@@ -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");