新增仿真执行过程回放
结论:真实浏览器仿真页面新增 G-code 执行过程和刀具运动回放,支持 reset、step、play、finish,逐帧高亮 G-code 与 motion row,并渲染已执行刀路和移动 toolhead。
This commit is contained in:
@@ -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. |
|
| 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 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. |
|
| 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`. |
|
| 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. |
|
| 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
|
- The page now includes selectable test programs for square contour, Z pocket
|
||||||
contour, incremental loop, G2/G3 arcs, and G81 drilling, with browser smoke
|
contour, incremental loop, G2/G3 arcs, and G81 drilling, with browser smoke
|
||||||
coverage for each program.
|
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.
|
- The page must be visible and operator-oriented, not another release dashboard.
|
||||||
- It should use OPFS/session persistence, staged G-code, and existing
|
- It should use OPFS/session persistence, staged G-code, and existing
|
||||||
LinuxCNC-backed WASM SDK execution.
|
LinuxCNC-backed WASM SDK execution.
|
||||||
|
|||||||
65
text15.txt
65
text15.txt
@@ -1683,3 +1683,68 @@ browser_real_simulation_page_smoke=ok
|
|||||||
- JS 没有解释 G-code,也没有实现 planner、kinematics、tool、parameter、remap 语义;
|
- JS 没有解释 G-code,也没有实现 planner、kinematics、tool、parameter、remap 语义;
|
||||||
- JS 只保存测试程序文本、调用 LinuxCNC WASM、解析 LinuxCNC-produced canonical output 以渲染
|
- JS 只保存测试程序文本、调用 LinuxCNC WASM、解析 LinuxCNC-produced canonical output 以渲染
|
||||||
UI 状态和刀路终点。
|
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 渲染。
|
||||||
|
|||||||
@@ -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`,
|
The first real simulation entry point is `runtime/ui/simulation/index.html`,
|
||||||
validated by `tests/browser/verify_real_simulation_browser.sh`. It now exposes
|
validated by `tests/browser/verify_real_simulation_browser.sh`. It now exposes
|
||||||
multiple LinuxCNC-backed test programs from the page selector: square contour,
|
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:
|
The project release gate for the current scope is:
|
||||||
|
|
||||||
|
|||||||
@@ -34,10 +34,11 @@ Its browser gate is:
|
|||||||
wasm-port/tests/browser/verify_real_simulation_browser.sh
|
wasm-port/tests/browser/verify_real_simulation_browser.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
The current page includes a real LinuxCNC-backed test-program selector. The
|
The current page includes a real LinuxCNC-backed test-program selector and
|
||||||
browser gate runs each listed program through the WASM interpreter and verifies
|
motion playback controls. The browser gate runs each listed program through the
|
||||||
that rendered program rows, canonical output, motion rows, toolpath points, and
|
WASM interpreter and verifies that rendered program rows, canonical output,
|
||||||
declared motion types stay aligned with LinuxCNC-produced output. The first
|
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,
|
program set covers linear contouring, Z moves, incremental mode, G2/G3 arcs,
|
||||||
and G81 drilling.
|
and G81 drilling.
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,23 @@
|
|||||||
cursor: wait;
|
cursor: wait;
|
||||||
opacity: 0.6;
|
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 {
|
.workspace {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(280px, 0.75fr) minmax(420px, 1.35fr) minmax(300px, 0.9fr);
|
grid-template-columns: minmax(280px, 0.75fr) minmax(420px, 1.35fr) minmax(300px, 0.9fr);
|
||||||
@@ -175,9 +192,16 @@
|
|||||||
stroke-width: 0.01;
|
stroke-width: 0.01;
|
||||||
}
|
}
|
||||||
.toolpath {
|
.toolpath {
|
||||||
|
fill: none;
|
||||||
|
stroke: #b7c6d2;
|
||||||
|
stroke-width: 0.028;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
}
|
||||||
|
.toolpath-executed {
|
||||||
fill: none;
|
fill: none;
|
||||||
stroke: var(--blue);
|
stroke: var(--blue);
|
||||||
stroke-width: 0.035;
|
stroke-width: 0.045;
|
||||||
stroke-linecap: round;
|
stroke-linecap: round;
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
@@ -254,6 +278,13 @@
|
|||||||
td:nth-child(3), td:nth-child(4) {
|
td:nth-child(3), td:nth-child(4) {
|
||||||
font-family: "SFMono-Regular", "Consolas", monospace;
|
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 {
|
pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -272,6 +303,8 @@
|
|||||||
header { grid-template-columns: 1fr; }
|
header { grid-template-columns: 1fr; }
|
||||||
.program-controls { justify-content: stretch; }
|
.program-controls { justify-content: stretch; }
|
||||||
.program-controls select { min-width: 0; width: 100%; }
|
.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; }
|
.status-strip { justify-content: start; }
|
||||||
.axis-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.axis-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
dl { grid-template-columns: 1fr; }
|
dl { grid-template-columns: 1fr; }
|
||||||
@@ -291,6 +324,24 @@
|
|||||||
<span class="badge" data-runtime-status>LinuxCNC interpreter WASM</span>
|
<span class="badge" data-runtime-status>LinuxCNC interpreter WASM</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</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">
|
<div class="workspace">
|
||||||
<section aria-label="G-code program">
|
<section aria-label="G-code program">
|
||||||
<div class="section-head">
|
<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="-10" y1="0" x2="10" y2="0"></line>
|
||||||
<line class="grid-line" x1="0" y1="-10" x2="0" y2="10"></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" 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>
|
<circle class="toolhead" data-toolpath-head cx="0" cy="0" r="0.055"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
@@ -360,13 +412,88 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_SIMULATION_PROGRAM_ID,
|
DEFAULT_SIMULATION_PROGRAM_ID,
|
||||||
getSimulationTestPrograms,
|
getSimulationTestPrograms,
|
||||||
|
renderSimulationPlaybackFrame,
|
||||||
runRealBrowserSimulation,
|
runRealBrowserSimulation,
|
||||||
} from "./simulation-app.js";
|
} from "./simulation-app.js";
|
||||||
|
|
||||||
const runButton = document.querySelector("[data-run-program]");
|
const runButton = document.querySelector("[data-run-program]");
|
||||||
const programSelector = document.querySelector("[data-program-selector]");
|
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) {
|
async function runSelectedProgram(programId = programSelector?.value ?? DEFAULT_SIMULATION_PROGRAM_ID) {
|
||||||
|
stopPlayback();
|
||||||
document.body.dataset.simulationReady = "false";
|
document.body.dataset.simulationReady = "false";
|
||||||
document.querySelector("[data-simulation-status]").textContent = "running";
|
document.querySelector("[data-simulation-status]").textContent = "running";
|
||||||
if (runButton) {
|
if (runButton) {
|
||||||
@@ -375,6 +502,7 @@
|
|||||||
try {
|
try {
|
||||||
const state = await runRealBrowserSimulation({ documentRef: document, programId });
|
const state = await runRealBrowserSimulation({ documentRef: document, programId });
|
||||||
window.linuxCncRealSimulationState = state;
|
window.linuxCncRealSimulationState = state;
|
||||||
|
renderPlayback(0);
|
||||||
return state;
|
return state;
|
||||||
} finally {
|
} finally {
|
||||||
if (runButton) {
|
if (runButton) {
|
||||||
@@ -389,6 +517,12 @@
|
|||||||
window.linuxCncRealSimulationApi = {
|
window.linuxCncRealSimulationApi = {
|
||||||
getState: () => window.linuxCncRealSimulationState,
|
getState: () => window.linuxCncRealSimulationState,
|
||||||
getPrograms: getSimulationTestPrograms,
|
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) => {
|
runProgramById: async (programId) => {
|
||||||
const nextState = await runSelectedProgram(programId);
|
const nextState = await runSelectedProgram(programId);
|
||||||
window.linuxCncRealSimulationState = nextState;
|
window.linuxCncRealSimulationState = nextState;
|
||||||
@@ -402,7 +536,18 @@
|
|||||||
document.querySelector("[data-canonical-output]").textContent = error?.stack ?? error?.message ?? String(error);
|
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) {
|
} catch (error) {
|
||||||
|
stopPlayback();
|
||||||
document.body.dataset.simulationReady = "false";
|
document.body.dataset.simulationReady = "false";
|
||||||
document.querySelector("[data-simulation-status]").textContent = "failed";
|
document.querySelector("[data-simulation-status]").textContent = "failed";
|
||||||
document.querySelector("[data-canonical-output]").textContent = error?.stack ?? error?.message ?? String(error);
|
document.querySelector("[data-canonical-output]").textContent = error?.stack ?? error?.message ?? String(error);
|
||||||
|
|||||||
@@ -204,6 +204,37 @@ export function createToolpathPolylinePoints(motion) {
|
|||||||
return motion.map(({ axes }) => `${axes.x ?? 0},${-(axes.y ?? 0)}`).join(" ");
|
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 = {}) {
|
export function formatPosition(axes = {}) {
|
||||||
return `X ${formatAxis(axes.x)} Y ${formatAxis(axes.y)} Z ${formatAxis(axes.z)}`;
|
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 svg = documentRef.querySelector("[data-toolpath-svg]");
|
||||||
const polyline = documentRef.querySelector("[data-toolpath-polyline]");
|
const polyline = documentRef.querySelector("[data-toolpath-polyline]");
|
||||||
|
const executedPolyline = documentRef.querySelector("[data-toolpath-executed-polyline]");
|
||||||
const head = documentRef.querySelector("[data-toolpath-head]");
|
const head = documentRef.querySelector("[data-toolpath-head]");
|
||||||
if (!svg || !polyline || !head) {
|
if (!svg || !polyline || !head) {
|
||||||
return;
|
return;
|
||||||
@@ -293,12 +325,13 @@ function renderToolpath(documentRef, motion) {
|
|||||||
`${viewBox.minX} ${-(viewBox.minY + viewBox.height)} ${viewBox.width} ${viewBox.height}`,
|
`${viewBox.minX} ${-(viewBox.minY + viewBox.height)} ${viewBox.width} ${viewBox.height}`,
|
||||||
);
|
);
|
||||||
polyline.setAttribute("points", createToolpathPolylinePoints(motion));
|
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("cx", `${last.x ?? 0}`);
|
||||||
head.setAttribute("cy", `${-(last.y ?? 0)}`);
|
head.setAttribute("cy", `${-(last.y ?? 0)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMotionTable(documentRef, motion) {
|
function renderMotionTable(documentRef, motion, activeIndex = null) {
|
||||||
const body = documentRef.querySelector("[data-motion-rows]");
|
const body = documentRef.querySelector("[data-motion-rows]");
|
||||||
if (!body) {
|
if (!body) {
|
||||||
return;
|
return;
|
||||||
@@ -307,6 +340,8 @@ function renderMotionTable(documentRef, motion) {
|
|||||||
for (const [index, item] of motion.entries()) {
|
for (const [index, item] of motion.entries()) {
|
||||||
const row = documentRef.createElement("tr");
|
const row = documentRef.createElement("tr");
|
||||||
row.dataset.motionRow = `${index}`;
|
row.dataset.motionRow = `${index}`;
|
||||||
|
row.dataset.active = activeIndex === index ? "true" : "false";
|
||||||
|
row.dataset.executed = activeIndex !== null && index <= activeIndex ? "true" : "false";
|
||||||
for (const value of [
|
for (const value of [
|
||||||
item.type,
|
item.type,
|
||||||
item.line ?? "-",
|
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) {
|
export function renderSimulationState(documentRef, state) {
|
||||||
documentRef.body.dataset.simulationReady = state.summary.ready ? "true" : "false";
|
documentRef.body.dataset.simulationReady = state.summary.ready ? "true" : "false";
|
||||||
documentRef.body.dataset.simulationProgramId = state.program?.id ?? "custom";
|
documentRef.body.dataset.simulationProgramId = state.program?.id ?? "custom";
|
||||||
setText(documentRef, "[data-simulation-status]", state.summary.ready ? "ready" : "blocked");
|
setText(documentRef, "[data-simulation-status]", state.summary.ready ? "ready" : "blocked");
|
||||||
setText(documentRef, "[data-runtime-status]", state.summary.rows[0].value);
|
setText(documentRef, "[data-runtime-status]", state.summary.rows[0].value);
|
||||||
setText(documentRef, "[data-selected-program]", state.program?.label ?? "Custom program");
|
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);
|
setText(documentRef, "[data-canonical-output]", state.resultText);
|
||||||
renderProgramSelector(documentRef, state.program?.id ?? "custom");
|
renderProgramSelector(documentRef, state.program?.id ?? "custom");
|
||||||
renderRows(documentRef, state.summary.rows);
|
renderRows(documentRef, state.summary.rows);
|
||||||
renderProgramLines(documentRef, state.programText, state.motion.at(-1)?.line ?? null);
|
renderSimulationPlaybackFrame(documentRef, state, state.motion.length - 1);
|
||||||
renderAxisReadout(documentRef, state.summary.finalAxes);
|
|
||||||
renderToolpath(documentRef, state.motion);
|
|
||||||
renderMotionTable(documentRef, state.motion);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSimulationTestPrograms() {
|
export function getSimulationTestPrograms() {
|
||||||
|
|||||||
@@ -92,14 +92,56 @@
|
|||||||
if (!api?.getPrograms || !api?.runProgramById) {
|
if (!api?.getPrograms || !api?.runProgramById) {
|
||||||
throw new Error("simulation API missing program controls");
|
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();
|
const programs = api.getPrograms();
|
||||||
if (programs.length < 5) {
|
if (programs.length < 5) {
|
||||||
throw new Error("simulation test program inventory too small");
|
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) {
|
for (const program of programs) {
|
||||||
const nextState = await api.runProgramById(program.id);
|
const nextState = await api.runProgramById(program.id);
|
||||||
assertRenderedState(doc, nextState);
|
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) {
|
if (nextState.program.id !== program.id) {
|
||||||
throw new Error(`simulation API returned wrong program id for ${program.id}`);
|
throw new Error(`simulation API returned wrong program id for ${program.id}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
|
|||||||
import {
|
import {
|
||||||
DEFAULT_SIMULATION_PROGRAM,
|
DEFAULT_SIMULATION_PROGRAM,
|
||||||
DEFAULT_SIMULATION_PROGRAM_ID,
|
DEFAULT_SIMULATION_PROGRAM_ID,
|
||||||
|
createPlaybackFrame,
|
||||||
createSimulationSummary,
|
createSimulationSummary,
|
||||||
createToolpathPolylinePoints,
|
createToolpathPolylinePoints,
|
||||||
getSimulationTestProgram,
|
getSimulationTestProgram,
|
||||||
@@ -80,4 +81,27 @@ assert.ok(summary.motionTypes.includes("ARC_FEED"));
|
|||||||
assert.equal(summary.finalAxes.x, 0);
|
assert.equal(summary.finalAxes.x, 0);
|
||||||
assert.equal(summary.finalAxes.y, 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");
|
console.log("real_simulation_programs_node_smoke=ok");
|
||||||
|
|||||||
Reference in New Issue
Block a user