结论:虚拟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。
1325 lines
49 KiB
JavaScript
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;
|
|
}
|