新增仿真执行过程回放
结论:真实浏览器仿真页面新增 G-code 执行过程和刀具运动回放,支持 reset、step、play、finish,逐帧高亮 G-code 与 motion row,并渲染已执行刀路和移动 toolhead。
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user