Files
cnc_wams/wasm-port/runtime/ui/simulation/simulation-app.js
wangdequan 3a6972437f 虚拟HAL完全满足Web数控仿真
结论:虚拟HAL功能来源于LinuxCNC源程序,并已通过source compliance、sim-config coverage、halcmd fixture、OPFS/session、browser diagnostics与release URL workflow验证,完全满足Web方式数控系统仿真范围;不声明Linux kernel hard-realtime ABI、外部硬件驱动ABI或native HAL module ABI。
2026-06-17 22:03:26 +08:00

1325 lines
49 KiB
JavaScript

import { createLinuxCncInterpSdk } from "../../sdk/src/index.js";
import * as THREE from "./vendor/three/three.module.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,
};
export {
VIRTUAL_HAL_AXES,
VIRTUAL_HAL_AXISUI_PINS,
VIRTUAL_HAL_SIMULATION_REPLACEMENT_TARGETS,
VIRTUAL_HAL_SIMULATION_RUNTIME_CAPABILITIES,
applyVirtualHalAction,
applyVirtualHalPinUpdates,
cloneVirtualHalState,
createLinuxCncVirtualHalRuntime,
createVirtualHalCommandScriptFixtureReport,
createVirtualHalDroState,
createVirtualHalIntegrityReport,
createVirtualHalLimitsHomeState,
createVirtualHalMachineStatusState,
createVirtualHalPinRegistry,
createVirtualHalSimConfigSourceCoverageReport,
createVirtualHalSimulationReplacementReport,
createVirtualHalSimulationRuntimeReport,
createVirtualHalSourceComplianceReport,
createVirtualHalState,
createVirtualHalWasmBridgeSnapshot,
executeVirtualHalCommand,
executeVirtualHalcmd,
parseVirtualHalJogIncrement,
readVirtualHalPin,
stepVirtualHalMotion,
stepVirtualHalMotionController,
writeVirtualHalPin,
} from "../../sdk/src/index.js";
const AXES = ["x", "y", "z", "a", "b", "c", "u", "v", "w"];
const DISPLAY_AXES = ["x", "y", "z", "a", "b", "c"];
const PLANE_AXIS_MAP = {
170: ["x", "y", "z"],
180: ["x", "z", "y"],
190: ["y", "z", "x"],
};
const UNAVAILABLE_VALUE = "-";
const DEFAULT_PREVIEW_LAYER_STATE = Object.freeze({
traverse: true,
feed: true,
arc: true,
tool: true,
envelope: true,
scale: true,
});
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;
}
function readCanonicalFields(line) {
const fields = {};
for (const match of line.matchAll(/\b([a-z][a-z0-9_]*)=([-+0-9.eE]+)/g)) {
fields[match[1]] = Number(match[2]);
}
return fields;
}
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;
}
let arc = null;
if (event[1] === "ARC_FEED") {
const [firstAxis, secondAxis, thirdAxis] = PLANE_AXIS_MAP[activePlane] ?? PLANE_AXIS_MAP[170];
const startAxes = { ...axes };
const firstEnd = readCanonicalNumber(line, "first_end");
const secondEnd = readCanonicalNumber(line, "second_end");
const firstCenter = readCanonicalNumber(line, "first_axis");
const secondCenter = readCanonicalNumber(line, "second_axis");
const rotation = readCanonicalNumber(line, "rotation");
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;
}
arc = {
plane: activePlane,
planeAxes: [firstAxis, secondAxis, thirdAxis],
startAxes,
endAxes: { ...axes },
center: {
[firstAxis]: firstCenter,
[secondAxis]: secondCenter,
},
rotation,
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 },
arc,
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 runLinuxCncProgram(interp, programText, options = {}) {
if (options.iniPath) {
return interp.runProgramWithIni(programText, options.iniPath);
}
return interp.runProgram(programText);
}
export function createModalState(resultText) {
const updateTags = String(resultText)
.split("\n")
.filter((line) => line.startsWith("canon_event=UPDATE_TAG"));
const activeTag = updateTags.at(-1) ?? null;
const fields = activeTag ? readCanonicalFields(activeTag) : {};
const modalRows = [
{ id: "g0", label: "G0", field: "g0" },
{ id: "motion", label: "Motion", field: "motion" },
{ id: "plane", label: "Plane", field: "plane" },
{ id: "origin", label: "Origin", field: "origin" },
{ id: "feed", label: "Feed", field: "feed" },
{ id: "speed", label: "Speed", field: "speed" },
{ id: "flags", label: "Flags", field: "flags" },
].map(({ id, label, field }) => ({
id,
label,
value: Number.isFinite(fields[field]) ? `${field}=${fields[field]}` : UNAVAILABLE_VALUE,
available: Number.isFinite(fields[field]),
source: Number.isFinite(fields[field]) ? "linuxcnc-update-tag" : "unavailable",
}));
const axisCodes = [
fields.motion === 0 ? "G80" : `G${fields.motion ?? 80}`,
({ 170: "G17", 180: "G18", 190: "G19" })[fields.plane] ?? "G17",
"G40",
"G20",
"G90",
"G94",
"G54",
"G49",
"G99",
"G64",
"G97",
"G91.1",
"G8",
"M5",
"M9",
"M48",
"M53",
"M0",
`F${Number.isFinite(fields.feed) ? fields.feed : 0}`,
`S${Number.isFinite(fields.speed) ? fields.speed : 0}`,
];
return {
apiName: "real-browser-simulation-modal-state",
modalVersion: 1,
source: activeTag ? "linuxcnc-update-tag" : "unavailable",
raw: activeTag,
rows: modalRows,
axisCodes,
};
}
export function createDroState(frame) {
const axes = frame?.axes ?? Object.fromEntries(AXES.map((axis) => [axis, 0]));
const actual = Object.fromEntries(DISPLAY_AXES.map((axis) => [axis, formatAxis(axes[axis])]));
const zeroAxes = Object.fromEntries(DISPLAY_AXES.map((axis) => [axis, formatAxis(0)]));
return {
apiName: "real-browser-simulation-dro-state",
droVersion: 1,
source: "linuxcnc-canonical-motion",
actual,
distanceToGo: { ...zeroAxes },
workOffsetG54: { ...zeroAxes },
g92Offset: { ...zeroAxes },
toolLengthOffset: { ...zeroAxes },
velocity: formatAxis(0),
defaults: {
distanceToGo: "zeroed display default until trajectory DTG is available",
workOffsetG54: "zeroed display default until coordinate offsets are available",
g92Offset: "zeroed display default until coordinate offsets are available",
toolLengthOffset: "zeroed display default until tool offsets are available",
velocity: "zeroed display default until live velocity is available",
},
};
}
export function createMachineStatusState(resultText) {
const status = {
apiName: "real-browser-simulation-machine-status-state",
statusVersion: 1,
source: "linuxcnc-canonical-events",
spindle: {
direction: "stopped",
speed: "0",
state: "off",
},
coolant: {
mist: "off",
flood: "off",
},
tool: {
selected: "0",
current: "0",
pocket: "0",
lengthOffset: "z=0.000",
},
overrides: {
feed: "enabled",
speed: "enabled",
adaptiveFeed: "disabled",
feedHold: "enabled",
},
};
for (const line of String(resultText).split("\n")) {
if (line.startsWith("canon_event=SET_SPINDLE_SPEED")) {
const speed = readCanonicalNumber(line, "speed");
status.spindle.speed = Number.isFinite(speed) ? `${speed}` : UNAVAILABLE_VALUE;
} else if (line.startsWith("canon_event=START_SPINDLE_CLOCKWISE")) {
status.spindle.direction = "cw";
status.spindle.state = "on";
} else if (line.startsWith("canon_event=START_SPINDLE_COUNTERCLOCKWISE")) {
status.spindle.direction = "ccw";
status.spindle.state = "on";
} else if (line.startsWith("canon_event=STOP_SPINDLE_TURNING")) {
status.spindle.state = "off";
status.spindle.direction = "stopped";
} else if (line.startsWith("canon_event=MIST_ON")) {
status.coolant.mist = "on";
} else if (line.startsWith("canon_event=MIST_OFF")) {
status.coolant.mist = "off";
} else if (line.startsWith("canon_event=FLOOD_ON")) {
status.coolant.flood = "on";
} else if (line.startsWith("canon_event=FLOOD_OFF")) {
status.coolant.flood = "off";
} else if (line.startsWith("canon_event=SELECT_TOOL")) {
const tool = readCanonicalNumber(line, "tool");
status.tool.selected = Number.isFinite(tool) ? `${tool}` : UNAVAILABLE_VALUE;
} else if (line.startsWith("canon_event=CHANGE_TOOL_NUMBER")) {
const pocket = readCanonicalNumber(line, "pocket");
status.tool.current = Number.isFinite(pocket) ? `${pocket}` : status.tool.selected;
status.tool.pocket = Number.isFinite(pocket) ? `${pocket}` : UNAVAILABLE_VALUE;
} else if (line.startsWith("canon_event=USE_TOOL_LENGTH_OFFSET")) {
const z = readCanonicalNumber(line, "z");
status.tool.lengthOffset = Number.isFinite(z) ? `z=${formatAxis(z)}` : UNAVAILABLE_VALUE;
} else if (line.startsWith("canon_event=ENABLE_FEED_OVERRIDE")) {
status.overrides.feed = "enabled";
} else if (line.startsWith("canon_event=DISABLE_FEED_OVERRIDE")) {
status.overrides.feed = "disabled";
} else if (line.startsWith("canon_event=ENABLE_SPEED_OVERRIDE")) {
status.overrides.speed = "enabled";
} else if (line.startsWith("canon_event=DISABLE_SPEED_OVERRIDE")) {
status.overrides.speed = "disabled";
} else if (line.startsWith("canon_event=ENABLE_ADAPTIVE_FEED")) {
status.overrides.adaptiveFeed = "enabled";
} else if (line.startsWith("canon_event=DISABLE_ADAPTIVE_FEED")) {
status.overrides.adaptiveFeed = "disabled";
} else if (line.startsWith("canon_event=ENABLE_FEED_HOLD")) {
status.overrides.feedHold = "enabled";
} else if (line.startsWith("canon_event=DISABLE_FEED_HOLD")) {
status.overrides.feedHold = "disabled";
}
}
if (status.tool.current === UNAVAILABLE_VALUE && status.tool.selected !== UNAVAILABLE_VALUE) {
status.tool.current = status.tool.selected;
}
return status;
}
export function zoomToolpathViewBox(viewBox, zoom = 1) {
const factor = Number.isFinite(zoom) && zoom > 0 ? zoom : 1;
const width = viewBox.width / factor;
const height = viewBox.height / factor;
return {
minX: viewBox.minX + (viewBox.width - width) / 2,
minY: viewBox.minY + (viewBox.height - height) / 2,
width,
height,
};
}
export function panToolpathViewBox(viewBox, offset = { x: 0, y: 0 }) {
return {
minX: viewBox.minX + (Number(offset.x) || 0),
minY: viewBox.minY + (Number(offset.y) || 0),
width: viewBox.width,
height: viewBox.height,
};
}
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);
});
scrollActiveRenderRow(container, "[data-active=\"true\"]");
}
function renderAxisReadout(documentRef, axes) {
for (const axis of DISPLAY_AXES) {
setText(documentRef, `[data-axis="${axis}"]`, formatAxis(axes?.[axis]));
}
}
function renderDroState(documentRef, dro) {
for (const axis of DISPLAY_AXES) {
setText(documentRef, `[data-dro-actual="${axis}"]`, dro.actual[axis]);
setText(documentRef, `[data-dro-dtg="${axis}"]`, dro.distanceToGo[axis]);
setText(documentRef, `[data-dro-g54="${axis}"]`, dro.workOffsetG54[axis]);
setText(documentRef, `[data-dro-g92="${axis}"]`, dro.g92Offset[axis]);
setText(documentRef, `[data-dro-tlo="${axis}"]`, dro.toolLengthOffset[axis]);
setText(documentRef, `[data-preview-hud="${axis}"]`, Number(dro.actual[axis]).toFixed(4));
setText(documentRef, `[data-preview-hud="dtg-${axis}"]`, Number(dro.distanceToGo[axis]).toFixed(4));
setText(documentRef, `[data-preview-hud="g54-${axis}"]`, Number(dro.workOffsetG54[axis]).toFixed(4));
setText(documentRef, `[data-preview-hud="g92-${axis}"]`, Number(dro.g92Offset[axis]).toFixed(4));
setText(documentRef, `[data-preview-hud="tlo-${axis}"]`, Number(dro.toolLengthOffset[axis]).toFixed(4));
}
setText(documentRef, "[data-dro-velocity]", dro.velocity);
setText(documentRef, "[data-preview-hud=\"velocity\"]", Number(dro.velocity).toFixed(4));
}
function renderModalState(documentRef, modal) {
const container = documentRef.querySelector("[data-modal-rows]");
if (!container) {
return;
}
container.textContent = "";
for (const row of modal.rows ?? []) {
const item = documentRef.createElement("span");
item.dataset.modalField = row.id;
item.dataset.available = row.available ? "true" : "false";
item.textContent = `${row.label}: ${row.value}`;
container.append(item);
}
for (const code of modal.axisCodes ?? []) {
const item = documentRef.createElement("span");
item.dataset.modalCode = code;
item.dataset.available = "true";
item.textContent = code;
container.append(item);
}
}
function renderMachineStatusState(documentRef, machineStatus) {
const statusMap = {
"spindle-state": machineStatus.spindle.state,
"spindle-direction": machineStatus.spindle.direction,
"spindle-speed": machineStatus.spindle.speed,
"coolant-mist": machineStatus.coolant.mist,
"coolant-flood": machineStatus.coolant.flood,
"tool-current": machineStatus.tool.current,
"tool-selected": machineStatus.tool.selected,
"tool-pocket": machineStatus.tool.pocket,
"tool-length-offset": machineStatus.tool.lengthOffset,
"override-feed": machineStatus.overrides.feed,
"override-speed": machineStatus.overrides.speed,
"override-adaptive-feed": machineStatus.overrides.adaptiveFeed,
"override-feed-hold": machineStatus.overrides.feedHold,
};
for (const [name, value] of Object.entries(statusMap)) {
setText(documentRef, `[data-machine-status="${name}"]`, value);
}
const spindleActive = machineStatus.spindle.state === "on";
const coolantActive = machineStatus.coolant.mist === "on" || machineStatus.coolant.flood === "on";
for (const node of documentRef.querySelectorAll("[data-axis-spindle-status]")) {
node.dataset.state = spindleActive ? "ready" : "blocked";
}
for (const node of documentRef.querySelectorAll("[data-axis-coolant-status]")) {
node.dataset.state = coolantActive ? "ready" : "blocked";
}
}
function createThreeVector(axes = {}, fallbackZ = 0) {
return new THREE.Vector3(axes.x ?? 0, axes.y ?? 0, axes.z ?? fallbackZ);
}
function createThreeLine(points, color, z = 0, lineWidth = 1) {
const geometry = new THREE.BufferGeometry().setFromPoints(
points.map(({ axes }) => createThreeVector(axes, z)),
);
const material = new THREE.LineBasicMaterial({ color, linewidth: lineWidth });
return new THREE.Line(geometry, material);
}
function readPreviewLayerState(documentRef) {
const canvas = documentRef.querySelector("[data-toolpath-three]");
if (!canvas?.dataset.previewLayers) {
return { ...DEFAULT_PREVIEW_LAYER_STATE };
}
try {
return {
...DEFAULT_PREVIEW_LAYER_STATE,
...JSON.parse(canvas.dataset.previewLayers),
};
} catch {
return { ...DEFAULT_PREVIEW_LAYER_STATE };
}
}
function createThreeSegment(start, end, color, z = 0, lineWidth = 1) {
return new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
createThreeVector(start, z),
createThreeVector(end, z),
]),
new THREE.LineBasicMaterial({ color, linewidth: lineWidth }),
);
}
function createThreeAxesLine(points, color, z = 0, lineWidth = 1) {
const geometry = new THREE.BufferGeometry().setFromPoints(
points.map((axes) => createThreeVector(axes, z)),
);
const material = new THREE.LineBasicMaterial({ color, linewidth: lineWidth });
return new THREE.Line(geometry, material);
}
function createThreePreview(canvas) {
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setClearColor(0x030405, 1);
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -100, 100);
camera.position.set(0, 0, 10);
camera.lookAt(0, 0, 0);
return { renderer, scene, camera };
}
function syncThreeCanvasSize(canvas, renderer) {
const rect = canvas.getBoundingClientRect();
const width = Math.max(320, Math.round(rect.width || canvas.clientWidth || 640));
const height = Math.max(260, Math.round(rect.height || canvas.clientHeight || 390));
if (canvas.width !== width || canvas.height !== height) {
renderer.setSize(width, height, false);
}
return { width, height };
}
function createThreeBounds(motion, viewBox) {
const zValues = motion.map(({ axes }) => axes.z ?? 0).filter(Number.isFinite);
const minZ = Math.min(0, ...(zValues.length ? zValues : [0]));
const maxZ = Math.max(0, ...(zValues.length ? zValues : [0]));
const zPadding = Math.max(0.08, Math.max(viewBox.width, viewBox.height) * 0.08);
return {
minX: viewBox.minX,
maxX: viewBox.minX + viewBox.width,
minY: viewBox.minY,
maxY: viewBox.minY + viewBox.height,
minZ: minZ - zPadding,
maxZ: maxZ + zPadding,
};
}
function applyThreeViewMode(camera, viewBox, viewMode = "top", bounds = createThreeBounds([], viewBox)) {
const centerX = viewBox.minX + viewBox.width / 2;
const centerY = viewBox.minY + viewBox.height / 2;
const centerZ = (bounds.minZ + bounds.maxZ) / 2;
const zSpan = Math.max(0.1, bounds.maxZ - bounds.minZ);
camera.near = -1000;
camera.far = 1000;
if (viewMode === "front") {
camera.left = centerX - viewBox.width / 2;
camera.right = centerX + viewBox.width / 2;
camera.bottom = centerZ - zSpan / 2;
camera.top = centerZ + zSpan / 2;
camera.position.set(centerX, centerY - Math.max(viewBox.width, viewBox.height), centerZ);
camera.up.set(0, 0, 1);
camera.lookAt(centerX, centerY, centerZ);
} else if (viewMode === "side") {
camera.left = centerY - viewBox.height / 2;
camera.right = centerY + viewBox.height / 2;
camera.bottom = centerZ - zSpan / 2;
camera.top = centerZ + zSpan / 2;
camera.position.set(centerX + Math.max(viewBox.width, viewBox.height), centerY, centerZ);
camera.up.set(0, 0, 1);
camera.lookAt(centerX, centerY, centerZ);
} else if (viewMode === "iso") {
const span = Math.max(viewBox.width, viewBox.height, zSpan);
camera.left = -span * 0.78;
camera.right = span * 0.78;
camera.bottom = -span * 0.58;
camera.top = span * 0.58;
camera.position.set(centerX + span, centerY - span, centerZ + span * 0.82);
camera.up.set(0, 0, 1);
camera.lookAt(centerX, centerY, centerZ);
} else {
camera.left = centerX - viewBox.width / 2;
camera.right = centerX + viewBox.width / 2;
camera.bottom = centerY - viewBox.height / 2;
camera.top = centerY + viewBox.height / 2;
camera.position.set(centerX, centerY, 10);
camera.up.set(0, 1, 0);
camera.lookAt(centerX, centerY, 0);
}
camera.updateProjectionMatrix();
}
function chooseGridStep(span) {
const raw = Math.max(span / 6, 0.1);
const power = 10 ** Math.floor(Math.log10(raw));
for (const factor of [1, 2, 5, 10]) {
if (raw <= factor * power) {
return factor * power;
}
}
return power;
}
function createTextSprite(text, color = "#dbe7ef") {
const canvas = document.createElement("canvas");
canvas.width = 128;
canvas.height = 48;
const context = canvas.getContext("2d");
context.clearRect(0, 0, canvas.width, canvas.height);
context.font = "24px Consolas, monospace";
context.fillStyle = color;
context.textAlign = "center";
context.textBaseline = "middle";
context.fillText(text, canvas.width / 2, canvas.height / 2);
const texture = new THREE.CanvasTexture(canvas);
const material = new THREE.SpriteMaterial({ map: texture, transparent: true });
const sprite = new THREE.Sprite(material);
sprite.scale.set(0.35, 0.13, 1);
return sprite;
}
function addThreeGrid(scene, viewBox) {
const step = chooseGridStep(Math.max(viewBox.width, viewBox.height));
const minX = Math.floor(viewBox.minX / step) * step;
const maxX = Math.ceil((viewBox.minX + viewBox.width) / step) * step;
const minY = Math.floor(viewBox.minY / step) * step;
const maxY = Math.ceil((viewBox.minY + viewBox.height) / step) * step;
const material = new THREE.LineBasicMaterial({ color: 0x293238 });
let count = 0;
for (let x = minX; x <= maxX; x += step) {
scene.add(new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(x, minY, -0.04),
new THREE.Vector3(x, maxY, -0.04),
]),
material,
));
count += 1;
}
for (let y = minY; y <= maxY; y += step) {
scene.add(new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(minX, y, -0.04),
new THREE.Vector3(maxX, y, -0.04),
]),
material,
));
count += 1;
}
return { step, count };
}
function addThreeAxes(scene, viewBox) {
const minX = viewBox.minX;
const maxX = viewBox.minX + viewBox.width;
const minY = viewBox.minY;
const maxY = viewBox.minY + viewBox.height;
scene.add(createThreeSegment({ x: minX, y: 0 }, { x: maxX, y: 0 }, 0xc54848, -0.01, 1));
scene.add(createThreeSegment({ x: 0, y: minY }, { x: 0, y: maxY }, 0x3e8ed0, -0.01, 1));
scene.add(createThreeSegment({ x: 0, y: 0, z: 0 }, { x: 0, y: 0, z: Math.max(viewBox.width, viewBox.height) * 0.12 }, 0xffdf5d, 0, 1));
return 3;
}
function addThreeMachineEnvelope(scene, bounds) {
const corners = [
[bounds.minX, bounds.minY, bounds.minZ],
[bounds.maxX, bounds.minY, bounds.minZ],
[bounds.maxX, bounds.maxY, bounds.minZ],
[bounds.minX, bounds.maxY, bounds.minZ],
[bounds.minX, bounds.minY, bounds.maxZ],
[bounds.maxX, bounds.minY, bounds.maxZ],
[bounds.maxX, bounds.maxY, bounds.maxZ],
[bounds.minX, bounds.maxY, bounds.maxZ],
].map(([x, y, z]) => new THREE.Vector3(x, y, z));
const edges = [
[0, 1], [1, 2], [2, 3], [3, 0],
[4, 5], [5, 6], [6, 7], [7, 4],
[0, 4], [1, 5], [2, 6], [3, 7],
];
const material = new THREE.LineBasicMaterial({ color: 0x59656d });
for (const [start, end] of edges) {
scene.add(new THREE.Line(
new THREE.BufferGeometry().setFromPoints([corners[start], corners[end]]),
material,
));
}
return {
edgeCount: edges.length,
bounds: {
minX: bounds.minX,
maxX: bounds.maxX,
minY: bounds.minY,
maxY: bounds.maxY,
minZ: bounds.minZ,
maxZ: bounds.maxZ,
},
};
}
function addThreeWorkPlane(scene, viewBox, z = 0) {
const geometry = new THREE.PlaneGeometry(viewBox.width, viewBox.height);
const material = new THREE.MeshBasicMaterial({
color: 0x10181d,
transparent: true,
opacity: 0.72,
side: THREE.DoubleSide,
});
const plane = new THREE.Mesh(geometry, material);
plane.position.set(viewBox.minX + viewBox.width / 2, viewBox.minY + viewBox.height / 2, z - 0.03);
scene.add(plane);
return 1;
}
function parseToolLengthOffsetValue(machineStatus) {
const match = String(machineStatus?.tool?.lengthOffset ?? "").match(/z=([-+0-9.]+)/);
const value = match ? Number(match[1]) : null;
return Number.isFinite(value) ? value : null;
}
function addThreeToolMarker(scene, axes, viewBox, machineStatus = null) {
const size = Math.max(0.04, Math.min(viewBox.width, viewBox.height) * 0.035);
const tlo = parseToolLengthOffsetValue(machineStatus);
const toolNumber = machineStatus?.tool?.current !== UNAVAILABLE_VALUE
? machineStatus?.tool?.current
: machineStatus?.tool?.selected;
const bodyLength = Math.max(size * 5, Math.abs(tlo ?? 0) || size * 7);
const group = new THREE.Group();
const tip = new THREE.Mesh(
new THREE.SphereGeometry(size, 18, 12),
new THREE.MeshBasicMaterial({ color: 0xf1f1f1 }),
);
tip.position.set(axes.x ?? 0, axes.y ?? 0, axes.z ?? 0);
group.add(tip);
const body = new THREE.Mesh(
new THREE.CylinderGeometry(size * 0.36, size * 0.7, bodyLength, 18),
new THREE.MeshBasicMaterial({ color: 0xd8dde0, transparent: true, opacity: 0.86 }),
);
body.rotation.x = Math.PI / 2;
body.position.set(axes.x ?? 0, axes.y ?? 0, (axes.z ?? 0) + bodyLength / 2);
group.add(body);
group.add(createThreeSegment(
{ x: axes.x ?? 0, y: axes.y ?? 0, z: (axes.z ?? 0) + size * 6 },
{ x: axes.x ?? 0, y: axes.y ?? 0, z: axes.z ?? 0 },
0xffffff,
0,
1,
));
if (Number.isFinite(tlo)) {
group.add(createThreeSegment(
{ x: (axes.x ?? 0) + size * 1.7, y: axes.y ?? 0, z: axes.z ?? 0 },
{ x: (axes.x ?? 0) + size * 1.7, y: axes.y ?? 0, z: (axes.z ?? 0) + tlo },
0xffdf5d,
0,
1,
));
}
if (toolNumber && toolNumber !== UNAVAILABLE_VALUE) {
const label = createTextSprite(`T${toolNumber}`, "#ffffff");
label.position.set((axes.x ?? 0) + size * 2.5, axes.y ?? 0, (axes.z ?? 0) + bodyLength);
group.add(label);
}
scene.add(group);
return {
objectCount: group.children.length,
size,
bodyLength,
toolNumber: toolNumber && toolNumber !== UNAVAILABLE_VALUE ? `${toolNumber}` : null,
lengthOffset: tlo,
hasLengthOffset: Number.isFinite(tlo),
};
}
function addThreeAxisLabels(scene, viewBox) {
const xLabel = createTextSprite("X", "#f2a1a1");
xLabel.position.set(viewBox.minX + viewBox.width, 0, 0.08);
scene.add(xLabel);
const yLabel = createTextSprite("Y", "#a6d2ff");
yLabel.position.set(0, viewBox.minY + viewBox.height, 0.08);
scene.add(yLabel);
const zLabel = createTextSprite("Z", "#ffe27a");
zLabel.position.set(viewBox.minX + viewBox.width * 0.08, viewBox.minY + viewBox.height * 0.92, 0.18);
scene.add(zLabel);
return 3;
}
function addThreeOrientationTriad(scene, viewBox, bounds) {
const length = Math.max(0.08, Math.min(viewBox.width, viewBox.height) * 0.11);
const origin = {
x: viewBox.minX + viewBox.width * 0.82,
y: viewBox.minY + viewBox.height * 0.14,
z: Math.max(bounds.minZ, 0) + length * 0.35,
};
const xEnd = { x: origin.x + length, y: origin.y, z: origin.z };
const yEnd = { x: origin.x, y: origin.y + length, z: origin.z };
const zEnd = { x: origin.x, y: origin.y + length * 0.22, z: origin.z + length };
scene.add(createThreeSegment(origin, xEnd, 0xe05a5a, 0, 1));
scene.add(createThreeSegment(origin, yEnd, 0x62a8e9, 0, 1));
scene.add(createThreeSegment(origin, zEnd, 0xffdf5d, 0, 1));
const xLabel = createTextSprite("X", "#f2a1a1");
xLabel.position.set(xEnd.x + length * 0.16, xEnd.y, xEnd.z);
scene.add(xLabel);
const yLabel = createTextSprite("Y", "#a6d2ff");
yLabel.position.set(yEnd.x, yEnd.y + length * 0.16, yEnd.z);
scene.add(yLabel);
const zLabel = createTextSprite("Z", "#ffe27a");
zLabel.position.set(zEnd.x + length * 0.14, zEnd.y + length * 0.14, zEnd.z);
scene.add(zLabel);
return {
objectCount: 6,
length,
origin,
};
}
function createArcSampledAxes(previousMotion, arcMotion) {
const arc = arcMotion.arc;
const [firstAxis, secondAxis, thirdAxis] = arc?.planeAxes ?? [];
const startAxes = arc?.startAxes ?? previousMotion?.axes;
const endAxes = arc?.endAxes ?? arcMotion.axes;
const centerFirst = arc?.center?.[firstAxis];
const centerSecond = arc?.center?.[secondAxis];
if (
!firstAxis ||
!secondAxis ||
!startAxes ||
!endAxes ||
!Number.isFinite(centerFirst) ||
!Number.isFinite(centerSecond) ||
!Number.isFinite(arc?.rotation)
) {
return [previousMotion?.axes, arcMotion.axes].filter(Boolean);
}
const startFirst = startAxes[firstAxis] ?? 0;
const startSecond = startAxes[secondAxis] ?? 0;
const endFirst = endAxes[firstAxis] ?? startFirst;
const endSecond = endAxes[secondAxis] ?? startSecond;
const radius = Math.hypot(startFirst - centerFirst, startSecond - centerSecond);
if (!Number.isFinite(radius) || radius <= 0) {
return [startAxes, endAxes];
}
const startAngle = Math.atan2(startSecond - centerSecond, startFirst - centerFirst);
const endAngle = Math.atan2(endSecond - centerSecond, endFirst - centerFirst);
let sweep = endAngle - startAngle;
if (arc.rotation < 0) {
while (sweep >= 0) {
sweep -= Math.PI * 2;
}
} else {
while (sweep <= 0) {
sweep += Math.PI * 2;
}
}
const sampleCount = Math.min(96, Math.max(8, Math.ceil(Math.abs(sweep) * radius * 12)));
const points = [];
for (let index = 0; index <= sampleCount; index += 1) {
const ratio = index / sampleCount;
const angle = startAngle + sweep * ratio;
const axes = { ...startAxes };
axes[firstAxis] = centerFirst + Math.cos(angle) * radius;
axes[secondAxis] = centerSecond + Math.sin(angle) * radius;
if (thirdAxis) {
axes[thirdAxis] = (startAxes[thirdAxis] ?? 0) + ((endAxes[thirdAxis] ?? 0) - (startAxes[thirdAxis] ?? 0)) * ratio;
}
points.push(axes);
}
points[points.length - 1] = { ...endAxes };
return points;
}
function createThreeToolpathAxes(motion) {
const points = [];
for (let index = 0; index < motion.length; index += 1) {
const item = motion[index];
if (index > 0 && item.type === "ARC_FEED") {
const arcPoints = createArcSampledAxes(motion[index - 1], item);
points.push(...arcPoints.slice(points.length > 0 ? 1 : 0));
} else {
points.push(item.axes);
}
}
return points;
}
function addThreeScaleBar(scene, viewBox) {
const step = chooseGridStep(Math.max(viewBox.width, viewBox.height));
const length = Math.max(step, Math.min(viewBox.width, viewBox.height) * 0.18);
const x = viewBox.minX + viewBox.width * 0.06;
const y = viewBox.minY + viewBox.height * 0.06;
const z = 0.06;
const material = new THREE.LineBasicMaterial({ color: 0xdbe7ef });
const line = new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(x, y, z),
new THREE.Vector3(x + length, y, z),
]),
material,
);
scene.add(line);
scene.add(createThreeSegment({ x, y: y - step * 0.08, z }, { x, y: y + step * 0.08, z }, 0xdbe7ef, 0, 1));
scene.add(createThreeSegment({ x: x + length, y: y - step * 0.08, z }, { x: x + length, y: y + step * 0.08, z }, 0xdbe7ef, 0, 1));
const label = createTextSprite(`${formatAxis(length)}`, "#dbe7ef");
label.position.set(x + length / 2, y + step * 0.18, z);
scene.add(label);
return {
objectCount: 4,
length,
label: formatAxis(length),
};
}
function addThreeMotionSegments(scene, motion, layers = DEFAULT_PREVIEW_LAYER_STATE) {
let traverseCount = 0;
let feedCount = 0;
let arcCount = 0;
let arcPointCount = 0;
for (let index = 1; index < motion.length; index += 1) {
const start = motion[index - 1].axes;
const end = motion[index].axes;
if (motion[index].type === "STRAIGHT_TRAVERSE") {
if (layers.traverse) {
scene.add(createThreeSegment(start, end, 0x9aa6ad, 0.005, 1));
}
traverseCount += 1;
} else if (motion[index].type === "ARC_FEED") {
const arcPoints = createArcSampledAxes(motion[index - 1], motion[index]);
if (layers.arc) {
scene.add(createThreeAxesLine(arcPoints, 0x5eb8ff, 0.012, 1));
}
arcCount += 1;
arcPointCount += arcPoints.length;
} else {
if (layers.feed) {
scene.add(createThreeSegment(start, end, 0x7cc4d5, 0.01, 1));
}
feedCount += 1;
}
}
return { traverseCount, feedCount, arcCount, arcPointCount };
}
function renderThreeToolpath(documentRef, motion, visibleMotion, viewBox, machineStatus = null) {
const canvas = documentRef.querySelector("[data-toolpath-three]");
if (!canvas) {
return;
}
const win = documentRef.defaultView ?? globalThis;
const preview = canvas.__linuxCncThreePreview ?? createThreePreview(canvas);
canvas.__linuxCncThreePreview = preview;
const { renderer, scene, camera } = preview;
const layers = readPreviewLayerState(documentRef);
syncThreeCanvasSize(canvas, renderer);
scene.clear();
const bounds = createThreeBounds(motion, viewBox);
applyThreeViewMode(camera, viewBox, canvas.dataset.threeViewMode ?? "top", bounds);
const workPlaneCount = addThreeWorkPlane(scene, viewBox, Math.min(0, bounds.minZ));
const grid = addThreeGrid(scene, viewBox);
const envelope = layers.envelope
? addThreeMachineEnvelope(scene, bounds)
: {
edgeCount: 0,
bounds: {
minX: bounds.minX,
maxX: bounds.maxX,
minY: bounds.minY,
maxY: bounds.maxY,
minZ: bounds.minZ,
maxZ: bounds.maxZ,
},
};
const axisLineCount = addThreeAxes(scene, viewBox);
const axisLabelCount = addThreeAxisLabels(scene, viewBox);
const orientationTriad = addThreeOrientationTriad(scene, viewBox, bounds);
const extentsGeometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(viewBox.minX, viewBox.minY, -0.02),
new THREE.Vector3(viewBox.minX + viewBox.width, viewBox.minY, -0.02),
new THREE.Vector3(viewBox.minX + viewBox.width, viewBox.minY + viewBox.height, -0.02),
new THREE.Vector3(viewBox.minX, viewBox.minY + viewBox.height, -0.02),
new THREE.Vector3(viewBox.minX, viewBox.minY, -0.02),
]);
scene.add(new THREE.Line(extentsGeometry, new THREE.LineBasicMaterial({ color: 0x6f7d86 })));
scene.add(new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(viewBox.minX, 0, -0.01),
new THREE.Vector3(viewBox.minX + viewBox.width, 0, -0.01),
]),
new THREE.LineBasicMaterial({ color: 0xa94444 }),
));
scene.add(new THREE.Line(
new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, viewBox.minY, -0.01),
new THREE.Vector3(0, viewBox.minY + viewBox.height, -0.01),
]),
new THREE.LineBasicMaterial({ color: 0xa94444 }),
));
const segmentCounts = addThreeMotionSegments(scene, motion, layers);
if (visibleMotion.length > 0 && (layers.traverse || layers.feed || layers.arc)) {
scene.add(createThreeAxesLine(createThreeToolpathAxes(visibleMotion), 0x38d261, 0.02, 2));
}
const scaleBar = layers.scale
? addThreeScaleBar(scene, viewBox)
: { objectCount: 0, length: 0, label: "hidden" };
const originSize = Math.min(viewBox.width, viewBox.height) * 0.018;
const origin = new THREE.Mesh(
new THREE.CircleGeometry(originSize, 24),
new THREE.MeshBasicMaterial({ color: 0xffdf5d }),
);
origin.position.set(0, 0, 0.04);
scene.add(origin);
const last = visibleMotion.at(-1)?.axes ?? motion[0]?.axes ?? { x: 0, y: 0 };
const toolMarker = layers.tool
? addThreeToolMarker(scene, last, viewBox, machineStatus)
: {
objectCount: 0,
size: 0,
bodyLength: 0,
toolNumber: null,
lengthOffset: null,
hasLengthOffset: false,
};
renderer.render(scene, camera);
canvas.dataset.threeReady = "true";
canvas.dataset.threeRevision = THREE.REVISION;
canvas.dataset.threeViewMode = canvas.dataset.threeViewMode ?? "top";
canvas.dataset.threePathPoints = `${motion.length}`;
canvas.dataset.threeExecutedPoints = `${visibleMotion.length}`;
canvas.dataset.threeViewBox = JSON.stringify(viewBox);
canvas.dataset.threeGridStep = `${grid.step}`;
canvas.dataset.threeGridLines = `${grid.count}`;
canvas.dataset.threeAxisLines = `${axisLineCount}`;
canvas.dataset.threeAxisLabels = `${axisLabelCount}`;
canvas.dataset.threeOrientationTriadObjects = `${orientationTriad.objectCount}`;
canvas.dataset.threeOrientationTriad = JSON.stringify(orientationTriad);
canvas.dataset.threeWorkPlanes = `${workPlaneCount}`;
canvas.dataset.threeEnvelopeEdges = `${envelope.edgeCount}`;
canvas.dataset.threeVisibleLayers = JSON.stringify(layers);
canvas.dataset.threeMachineEnvelope = JSON.stringify(envelope.bounds);
canvas.dataset.threeCameraMode = canvas.dataset.threeViewMode;
canvas.dataset.threeSceneObjects = `${scene.children.length}`;
canvas.dataset.threeToolMarkerObjects = `${toolMarker.objectCount}`;
canvas.dataset.threeToolGeometry = JSON.stringify(toolMarker);
canvas.dataset.threeScaleBarObjects = `${scaleBar.objectCount}`;
canvas.dataset.threeScaleBar = JSON.stringify(scaleBar);
canvas.dataset.threeTraverseSegments = `${segmentCounts.traverseCount}`;
canvas.dataset.threeFeedSegments = `${segmentCounts.feedCount}`;
canvas.dataset.threeArcSegments = `${segmentCounts.arcCount}`;
canvas.dataset.threeArcSamplePoints = `${segmentCounts.arcPointCount}`;
canvas.dataset.threeToolhead = JSON.stringify({ x: last.x ?? 0, y: last.y ?? 0, z: last.z ?? 0 });
canvas.dataset.threeCanvasSize = `${canvas.width}x${canvas.height}`;
if (win?.requestAnimationFrame) {
win.requestAnimationFrame(() => renderer.render(scene, camera));
}
}
function renderToolpath(documentRef, motion, visibleMotion = motion, machineStatus = null) {
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]");
const extents = documentRef.querySelector("[data-preview-extents]");
const originX = documentRef.querySelector("[data-preview-origin-x]");
const originY = documentRef.querySelector("[data-preview-origin-y]");
const origin = documentRef.querySelector("[data-preview-origin]");
if (!svg || !polyline || !head) {
return;
}
const viewBox = createToolpathViewBox(motion);
svg.setAttribute(
"viewBox",
`${viewBox.minX} ${-(viewBox.minY + viewBox.height)} ${viewBox.width} ${viewBox.height}`,
);
svg.dataset.fitViewBox = JSON.stringify(viewBox);
if (extents) {
extents.setAttribute("x", `${viewBox.minX}`);
extents.setAttribute("y", `${-(viewBox.minY + viewBox.height)}`);
extents.setAttribute("width", `${viewBox.width}`);
extents.setAttribute("height", `${viewBox.height}`);
}
originX?.setAttribute("x1", `${viewBox.minX}`);
originX?.setAttribute("x2", `${viewBox.minX + viewBox.width}`);
originX?.setAttribute("y1", "0");
originX?.setAttribute("y2", "0");
originY?.setAttribute("x1", "0");
originY?.setAttribute("x2", "0");
originY?.setAttribute("y1", `${-(viewBox.minY + viewBox.height)}`);
originY?.setAttribute("y2", `${-viewBox.minY}`);
origin?.setAttribute("cx", "0");
origin?.setAttribute("cy", "0");
origin?.setAttribute("r", `${Math.min(viewBox.width, viewBox.height) * 0.025}`);
setText(
documentRef,
"[data-preview-extents-label]",
`X ${formatAxis(viewBox.minX)}..${formatAxis(viewBox.minX + viewBox.width)} Y ${formatAxis(viewBox.minY)}..${formatAxis(viewBox.minY + 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)}`);
renderThreeToolpath(documentRef, motion, visibleMotion, viewBox, machineStatus);
}
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);
}
scrollActiveRenderRow(body.closest?.(".motion-table-wrap") ?? body.parentElement, "[data-active=\"true\"]");
}
function scrollActiveRenderRow(container, selector) {
const active = container?.querySelector?.(selector);
if (!container || !active) {
return;
}
const activeTop = active.offsetTop;
const activeBottom = activeTop + active.offsetHeight;
const visibleTop = container.scrollTop;
const visibleBottom = visibleTop + container.clientHeight;
if (activeTop < visibleTop) {
container.scrollTop = Math.max(0, activeTop - active.offsetHeight);
} else if (activeBottom > visibleBottom) {
container.scrollTop = Math.max(0, activeBottom - container.clientHeight + active.offsetHeight);
}
}
export function renderSimulationPlaybackFrame(documentRef, state, index = state.motion.length - 1) {
const frame = createPlaybackFrame(state, index);
const dro = createDroState(frame);
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);
renderDroState(documentRef, dro);
renderToolpath(documentRef, frame.fullMotion, frame.visibleMotion, state.machineStatus);
renderMotionTable(documentRef, frame.fullMotion, frame.index);
return { ...frame, dro };
}
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);
renderModalState(documentRef, state.modal);
renderMachineStatusState(documentRef, state.machineStatus);
renderSimulationPlaybackFrame(documentRef, state, state.motion.length - 1);
}
export async function runRealBrowserSimulation({
documentRef = document,
programId = DEFAULT_SIMULATION_PROGRAM_ID,
programText = null,
interpFactory = createLinuxCncInterpSdk,
interp = null,
iniPath = null,
} = {}) {
const program = programText === null ? getSimulationTestProgram(programId) : null;
const resolvedProgramText = program?.text ?? programText ?? DEFAULT_SIMULATION_PROGRAM;
const resolvedInterp = interp ?? await interpFactory();
const resultText = runLinuxCncProgram(resolvedInterp, resolvedProgramText, { iniPath });
const motion = parseLinuxCncCanonicalMotion(resultText, resolvedProgramText);
const summary = createSimulationSummary({ programText: resolvedProgramText, resultText, motion });
const modal = createModalState(resultText);
const machineStatus = createMachineStatusState(resultText);
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,
modal,
machineStatus,
execution: {
mode: iniPath ? "linuxcnc-wasm-with-ini" : "linuxcnc-wasm",
iniPath,
},
};
renderSimulationState(documentRef, state);
return state;
}