结论:AXIS 风格浏览器仿真页面新增 Open Program、runProgramText 和 loadProgramFile,支持用户提供的 G-code 文本/文件通过 LinuxCNC WASM 执行并参与回放。
352 lines
12 KiB
JavaScript
352 lines
12 KiB
JavaScript
import { createLinuxCncInterpSdk } from "../../sdk/src/index.js";
|
|
import {
|
|
DEFAULT_SIMULATION_PROGRAM,
|
|
DEFAULT_SIMULATION_PROGRAM_ID,
|
|
SIMULATION_TEST_PROGRAMS,
|
|
getSimulationTestProgram,
|
|
getSimulationTestPrograms,
|
|
} from "./programs/index.js";
|
|
|
|
export {
|
|
DEFAULT_SIMULATION_PROGRAM,
|
|
DEFAULT_SIMULATION_PROGRAM_ID,
|
|
SIMULATION_TEST_PROGRAMS,
|
|
getSimulationTestProgram,
|
|
getSimulationTestPrograms,
|
|
};
|
|
|
|
const AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"];
|
|
const PLANE_AXIS_MAP = {
|
|
170: ["x", "y", "z"],
|
|
180: ["x", "z", "y"],
|
|
190: ["y", "z", "x"],
|
|
};
|
|
|
|
function readCanonicalNumber(line, name) {
|
|
const match = line.match(new RegExp(`(?:^| )${name}=([-+0-9.eE]+)`));
|
|
return match ? Number(match[1]) : null;
|
|
}
|
|
|
|
function programLineMap(programText) {
|
|
const lines = new Map();
|
|
programText.split(/\r?\n/).forEach((line, index) => {
|
|
lines.set(index + 1, line.trim() || "(blank)");
|
|
});
|
|
return lines;
|
|
}
|
|
|
|
export function parseLinuxCncCanonicalMotion(resultText, programText = "") {
|
|
const axes = Object.fromEntries(AXES.map((axis) => [axis, 0]));
|
|
const sourceLines = programLineMap(programText);
|
|
const motion = [];
|
|
let activePlane = 170;
|
|
|
|
for (const line of String(resultText).split("\n")) {
|
|
const plane = readCanonicalNumber(line, "plane");
|
|
if (plane && PLANE_AXIS_MAP[plane]) {
|
|
activePlane = plane;
|
|
}
|
|
|
|
const event = line.match(/^canon_event=(STRAIGHT_TRAVERSE|STRAIGHT_FEED|ARC_FEED)\b/);
|
|
if (!event) {
|
|
continue;
|
|
}
|
|
|
|
if (event[1] === "ARC_FEED") {
|
|
const [firstAxis, secondAxis, thirdAxis] = PLANE_AXIS_MAP[activePlane] ?? PLANE_AXIS_MAP[170];
|
|
const firstEnd = readCanonicalNumber(line, "first_end");
|
|
const secondEnd = readCanonicalNumber(line, "second_end");
|
|
const axisEndPoint = readCanonicalNumber(line, "axis_end_point");
|
|
if (Number.isFinite(firstEnd)) {
|
|
axes[firstAxis] = firstEnd;
|
|
}
|
|
if (Number.isFinite(secondEnd)) {
|
|
axes[secondAxis] = secondEnd;
|
|
}
|
|
if (Number.isFinite(axisEndPoint)) {
|
|
axes[thirdAxis] = axisEndPoint;
|
|
}
|
|
} else {
|
|
for (const axis of AXES) {
|
|
const value = readCanonicalNumber(line, axis);
|
|
if (value !== null && Number.isFinite(value)) {
|
|
axes[axis] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
const sourceLine = readCanonicalNumber(line, "line");
|
|
motion.push({
|
|
type: event[1],
|
|
line: Number.isFinite(sourceLine) ? sourceLine : null,
|
|
statement: Number.isFinite(sourceLine) ? (sourceLines.get(sourceLine) ?? "-") : "-",
|
|
axes: { ...axes },
|
|
raw: line,
|
|
});
|
|
}
|
|
|
|
return motion;
|
|
}
|
|
|
|
export function createSimulationSummary({ programText, resultText, motion }) {
|
|
const canonicalLines = String(resultText).split("\n").filter((line) => line.startsWith("canon_event="));
|
|
const finalAxes = motion.at(-1)?.axes ?? Object.fromEntries(AXES.map((axis) => [axis, 0]));
|
|
const motionTypes = [...new Set(motion.map(({ type }) => type))];
|
|
return {
|
|
apiName: "real-browser-simulation-summary",
|
|
summaryVersion: 1,
|
|
ready: motion.length > 0,
|
|
phase: motion.length > 0 ? "ready" : "blocked",
|
|
programLineCount: programText.split(/\r?\n/).filter(Boolean).length,
|
|
canonicalEventCount: canonicalLines.length,
|
|
motionEventCount: motion.length,
|
|
motionTypes,
|
|
finalAxes,
|
|
rows: [
|
|
{ id: "runtime", label: "Runtime", value: "LinuxCNC interpreter WASM" },
|
|
{ id: "program-lines", label: "Program lines", value: `${programText.split(/\r?\n/).filter(Boolean).length}` },
|
|
{ id: "canonical-events", label: "Canonical events", value: `${canonicalLines.length}` },
|
|
{ id: "motion-events", label: "Motion events", value: `${motion.length}` },
|
|
{ id: "motion-types", label: "Motion types", value: motionTypes.join(", ") || "-" },
|
|
{ id: "final-position", label: "Final XYZ", value: formatPosition(finalAxes) },
|
|
],
|
|
};
|
|
}
|
|
|
|
export function createToolpathViewBox(motion, padding = 0.25) {
|
|
const points = motion.map(({ axes }) => ({ x: axes.x ?? 0, y: axes.y ?? 0 }));
|
|
if (points.length === 0) {
|
|
return { minX: -1, minY: -1, width: 2, height: 2 };
|
|
}
|
|
const xs = points.map(({ x }) => x);
|
|
const ys = points.map(({ y }) => y);
|
|
const minX = Math.min(...xs) - padding;
|
|
const maxX = Math.max(...xs) + padding;
|
|
const minY = Math.min(...ys) - padding;
|
|
const maxY = Math.max(...ys) + padding;
|
|
return {
|
|
minX,
|
|
minY,
|
|
width: Math.max(0.1, maxX - minX),
|
|
height: Math.max(0.1, maxY - minY),
|
|
};
|
|
}
|
|
|
|
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)}`;
|
|
}
|
|
|
|
function formatAxis(value) {
|
|
return Number.isFinite(value) ? Number(value).toFixed(3) : "0.000";
|
|
}
|
|
|
|
function setText(documentRef, selector, value) {
|
|
for (const node of documentRef.querySelectorAll(selector)) {
|
|
node.textContent = value;
|
|
}
|
|
}
|
|
|
|
function renderRows(documentRef, rows) {
|
|
const container = documentRef.querySelector("[data-simulation-summary-rows]");
|
|
if (!container) {
|
|
return;
|
|
}
|
|
container.textContent = "";
|
|
for (const row of rows) {
|
|
const term = documentRef.createElement("dt");
|
|
term.textContent = row.label;
|
|
term.dataset.simulationRow = row.id;
|
|
const detail = documentRef.createElement("dd");
|
|
detail.textContent = row.value;
|
|
detail.dataset.simulationValue = row.id;
|
|
container.append(term, detail);
|
|
}
|
|
}
|
|
|
|
function renderProgramSelector(documentRef, programId) {
|
|
const selector = documentRef.querySelector("[data-program-selector]");
|
|
if (!selector) {
|
|
return;
|
|
}
|
|
selector.textContent = "";
|
|
for (const program of SIMULATION_TEST_PROGRAMS) {
|
|
const option = documentRef.createElement("option");
|
|
option.value = program.id;
|
|
option.textContent = `${program.category}: ${program.label}`;
|
|
option.selected = program.id === programId;
|
|
selector.append(option);
|
|
}
|
|
}
|
|
|
|
function renderProgramLines(documentRef, programText, activeLine) {
|
|
const container = documentRef.querySelector("[data-program-lines]");
|
|
if (!container) {
|
|
return;
|
|
}
|
|
container.textContent = "";
|
|
programText.split(/\r?\n/).forEach((line, index) => {
|
|
if (!line && index === programText.split(/\r?\n/).length - 1) {
|
|
return;
|
|
}
|
|
const row = documentRef.createElement("div");
|
|
row.dataset.programLine = `${index + 1}`;
|
|
row.dataset.active = activeLine === index + 1 ? "true" : "false";
|
|
const number = documentRef.createElement("span");
|
|
number.textContent = `${index + 1}`;
|
|
const text = documentRef.createElement("code");
|
|
text.textContent = line || " ";
|
|
row.append(number, text);
|
|
container.append(row);
|
|
});
|
|
}
|
|
|
|
function renderAxisReadout(documentRef, axes) {
|
|
for (const axis of ["x", "y", "z", "a", "b", "c"]) {
|
|
setText(documentRef, `[data-axis="${axis}"]`, formatAxis(axes?.[axis]));
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
const viewBox = createToolpathViewBox(motion);
|
|
svg.setAttribute(
|
|
"viewBox",
|
|
`${viewBox.minX} ${-(viewBox.minY + viewBox.height)} ${viewBox.width} ${viewBox.height}`,
|
|
);
|
|
polyline.setAttribute("points", createToolpathPolylinePoints(motion));
|
|
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, activeIndex = null) {
|
|
const body = documentRef.querySelector("[data-motion-rows]");
|
|
if (!body) {
|
|
return;
|
|
}
|
|
body.textContent = "";
|
|
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 ?? "-",
|
|
formatPosition(item.axes),
|
|
item.statement,
|
|
]) {
|
|
const cell = documentRef.createElement("td");
|
|
cell.textContent = `${value}`;
|
|
row.append(cell);
|
|
}
|
|
body.append(row);
|
|
}
|
|
}
|
|
|
|
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";
|
|
documentRef.body.dataset.simulationProgramSource = state.program?.source ?? "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-program-source]", state.program?.sourceLabel ?? "Custom G-code text");
|
|
setText(documentRef, "[data-canonical-output]", state.resultText);
|
|
renderProgramSelector(documentRef, state.program?.id ?? "custom");
|
|
renderRows(documentRef, state.summary.rows);
|
|
renderSimulationPlaybackFrame(documentRef, state, state.motion.length - 1);
|
|
}
|
|
|
|
export async function runRealBrowserSimulation({
|
|
documentRef = document,
|
|
programId = DEFAULT_SIMULATION_PROGRAM_ID,
|
|
programText = null,
|
|
interpFactory = createLinuxCncInterpSdk,
|
|
} = {}) {
|
|
const program = programText === null ? getSimulationTestProgram(programId) : null;
|
|
const resolvedProgramText = program?.text ?? programText ?? DEFAULT_SIMULATION_PROGRAM;
|
|
const interp = await interpFactory();
|
|
const resultText = interp.runProgram(resolvedProgramText);
|
|
const motion = parseLinuxCncCanonicalMotion(resultText, resolvedProgramText);
|
|
const summary = createSimulationSummary({ programText: resolvedProgramText, resultText, motion });
|
|
const state = {
|
|
apiName: "real-browser-simulation-state",
|
|
stateVersion: 1,
|
|
program: program
|
|
? { ...program, source: "builtin", sourceLabel: "Built-in test program", text: undefined }
|
|
: {
|
|
id: "custom",
|
|
label: "Custom G-code program",
|
|
category: "Custom",
|
|
source: "custom",
|
|
sourceLabel: "Custom G-code text",
|
|
},
|
|
programText: resolvedProgramText,
|
|
resultText,
|
|
motion,
|
|
summary,
|
|
};
|
|
renderSimulationState(documentRef, state);
|
|
return state;
|
|
}
|