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_MOTION_CONTROLLER_MATRIX_FIXTURES, VIRTUAL_HAL_SIM_CONFIG_MACRO_LOAD_FIXTURES, VIRTUAL_HAL_SIM_CONFIG_PROMOTION_CANDIDATES, VIRTUAL_HAL_SIM_CONFIG_SOURCE_TARGETS, VIRTUAL_HAL_SIMULATION_REPLACEMENT_TARGETS, VIRTUAL_HAL_SIMULATION_RUNTIME_CAPABILITIES, applyVirtualHalAction, applyVirtualHalPinUpdates, cloneVirtualHalState, createLinuxCncVirtualHalRuntime, createVirtualHalCommandScriptFixtureReport, createVirtualHalMotionControllerMatrixReport, createVirtualHalDroState, createVirtualHalIntegrityReport, createVirtualHalLimitsHomeState, createVirtualHalMachineStatusState, createVirtualHalPinRegistry, createVirtualHalSimConfigMacroLoadFixtureReport, createVirtualHalSimConfigPromotionCandidateReport, 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; }