新增仿真执行过程回放

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

@@ -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() {