import { useEffect, useRef } from "react"; import { WEB_SETTINGS } from "../../workspace/web-settings.mjs"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { projectXyzacVisual, type XyzacVisualSource, type XyzacVisualState } from "./xyzac-visual-adapter"; type Point = Record; type Segment = { id?: number; source_line?: number | null; active?: boolean; kind?: string; motion_type?: number; kinematics_mode?: number; turn?: number; center?: Point; normal?: Point; samples?: Point[]; start?: Point; end?: Point }; type Props = { visual: XyzacVisualState; view?: "z" | "z2" | "x" | "y" | "y2" | "p"; zoom?: number; rotateMode?: boolean; showPath?: boolean; showLive?: boolean; showLimits?: boolean; showVelocity?: boolean; showOffsets?: boolean; showExtents?: boolean }; const DEG = Math.PI / 180; const MM = WEB_SETTINGS.viewer.mm_scale; const sourcePoint = (point: Point) => new THREE.Vector3(Number(point.x ?? 0) * MM, Number(point.z ?? 0) * MM, Number(point.y ?? 0) * MM); export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = false, showPath = true, showLive = true, showLimits = false, showVelocity = false, showOffsets = false, showExtents = false }: Props) { const host = useRef(null); const visualRef = useRef(visual); visualRef.current = visual; const viewRef = useRef(view); viewRef.current = view; const zoomRef = useRef(zoom); zoomRef.current = zoom; const rotateRef = useRef(rotateMode); rotateRef.current = rotateMode; const layersRef = useRef({ showPath, showLive, showLimits, showVelocity, showOffsets, showExtents }); layersRef.current = { showPath, showLive, showLimits, showVelocity, showOffsets, showExtents }; useEffect(() => { const element = host.current; if (!element) return; const scene = new THREE.Scene(); scene.background = new THREE.Color(WEB_SETTINGS.viewer.toolpath_colors.background); scene.add(new THREE.HemisphereLight(0xdcecff, 0x121820, 1.4)); let camera: THREE.PerspectiveCamera | THREE.OrthographicCamera = new THREE.PerspectiveCamera(40, 1, 0.001, 100); const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true }); renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, WEB_SETTINGS.viewer.pixel_ratio_limit)); renderer.domElement.dataset.engine = "three.js r185 / xyzac-tool-preview"; element.appendChild(renderer.domElement); let controls: OrbitControls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = .12; controls.enableZoom = true; controls.enablePan = true; controls.enableRotate = true; controls.screenSpacePanning = true; controls.target.set(0, 0, 0); const originGroup = new THREE.Group(); originGroup.name = "xyzac-origin-coordinate-system"; scene.add(originGroup); const origin = new THREE.Mesh(new THREE.SphereGeometry(.035, 12, 8), new THREE.MeshBasicMaterial({ color: 0xffffff })); origin.name = "xyzac-origin"; originGroup.add(origin); const originAxes = [ { label: "X", direction: new THREE.Vector3(1, 0, 0), color: 0xe5534b, position: new THREE.Vector3(.72, 0, 0) }, { label: "Y", direction: new THREE.Vector3(0, 0, 1), color: 0x55b86a, position: new THREE.Vector3(0, 0, .72) }, { label: "Z", direction: new THREE.Vector3(0, 1, 0), color: 0x4d8fe8, position: new THREE.Vector3(0, .72, 0) }, ]; for (const axis of originAxes) { const arrow = new THREE.ArrowHelper(axis.direction, new THREE.Vector3(), .62, axis.color, .1, .055); arrow.name = `xyzac-origin-axis-${axis.label.toLowerCase()}`; originGroup.add(arrow); const labelCanvas = document.createElement("canvas"); labelCanvas.width = 96; labelCanvas.height = 48; const labelContext = labelCanvas.getContext("2d"); if (labelContext) { labelContext.clearRect(0, 0, labelCanvas.width, labelCanvas.height); labelContext.fillStyle = `#${axis.color.toString(16).padStart(6, "0")}`; labelContext.font = "bold 32px sans-serif"; labelContext.textAlign = "center"; labelContext.textBaseline = "middle"; labelContext.fillText(axis.label, 48, 24); } const label = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(labelCanvas), transparent: true, depthTest: false })); label.name = `xyzac-origin-label-${axis.label.toLowerCase()}`; label.position.copy(axis.position); label.scale.set(.42, .21, 1); originGroup.add(label); } const grid = new THREE.GridHelper(2.5, 10, 0x304553, 0x18252d); grid.name = "xyzac-preview-grid"; scene.add(grid); const pathGroup = new THREE.Group(); pathGroup.name = "xyzac-preview-toolpath"; scene.add(pathGroup); const liveGroup = new THREE.Group(); liveGroup.name = "xyzac-preview-live-toolpath"; scene.add(liveGroup); const toolGroup = new THREE.Group(); toolGroup.name = "xyzac-preview-tool"; scene.add(toolGroup); const overlayGroup = new THREE.Group(); overlayGroup.name = "xyzac-preview-overlays"; scene.add(overlayGroup); const offsetAxes = new THREE.AxesHelper(1.5); offsetAxes.name = "xyzac-preview-offset-axes"; overlayGroup.add(offsetAxes); const limitGrid = new THREE.Box3Helper(new THREE.Box3(), 0xd8756d); limitGrid.name = "xyzac-preview-limits"; overlayGroup.add(limitGrid); const extents = new THREE.Box3Helper(new THREE.Box3(), 0xf2cf65); extents.name = "xyzac-preview-extents"; overlayGroup.add(extents); const toolMaterial = new THREE.MeshStandardMaterial({ color: 0xb8bec4, roughness: .45, metalness: .35 }); const tool = new THREE.Mesh(new THREE.CylinderGeometry(.04, .04, .58, 20), toolMaterial); tool.name = "xyzac-preview-tool-shaft"; toolGroup.add(tool); const tipGeometry = new THREE.ConeGeometry(.09, .28, 24); tipGeometry.rotateZ(Math.PI); tipGeometry.translate(0, .14, 0); const tip = new THREE.Mesh(tipGeometry, new THREE.MeshBasicMaterial({ color: 0xf2f2f2 })); tip.name = "xyzac-preview-tool-tip"; toolGroup.add(tip); const resize = () => { const width = Math.max(element.clientWidth, 280), height = Math.max(element.clientHeight, 260); if (camera instanceof THREE.PerspectiveCamera) camera.aspect = width / height; else { camera.left = -camera.top * width / height; camera.right = -camera.left; } camera.updateProjectionMatrix(); renderer.setSize(width, height, false); }; const observer = new ResizeObserver(resize); observer.observe(element); resize(); const fit = (segments: Segment[]) => { const points = segments.flatMap((segment) => (Array.isArray(segment.samples) && segment.samples.length > 1 ? segment.samples : [segment.start ?? {}, segment.end ?? {}]).map(sourcePoint)); if (!points.length) return { center: new THREE.Vector3(), radius: .12 }; const box = new THREE.Box3().setFromPoints(points); return { center: box.getCenter(new THREE.Vector3()), radius: Math.max(box.getSize(new THREE.Vector3()).length() * .65, .12) }; }; const clearLines = (group: THREE.Group) => { for (const child of group.children) { const line = child as THREE.Line; line.geometry?.dispose(); } group.clear(); }; const completed = new THREE.LineBasicMaterial({ color: WEB_SETTINGS.viewer.toolpath_colors.completed, depthTest: false }); const rapid = new THREE.LineDashedMaterial({ color: WEB_SETTINGS.viewer.toolpath_colors.rapid, dashSize: .025, gapSize: .018, depthTest: false }); // LinuxCNC uses yellow for jog backplot. Coordinated program execution is // red (live plot), while an unambiguous current program block is cyan. const live = new THREE.LineBasicMaterial({ color: WEB_SETTINGS.viewer.toolpath_colors.active, depthTest: false }); const liveHistory = new THREE.LineBasicMaterial({ color: WEB_SETTINGS.viewer.toolpath_colors.live, depthTest: false }); let frame = 0; let liveTick = -1; let liveTrace: THREE.Vector3[] = []; let pathSegments: Segment[] | null = null; let extentsSegments: Segment[] | null = null; // Native TP active_id is the source line/motion id and may repeat (for // example, each call of the same helix subroutine). Preserve every // occurrence instead of letting a Map overwrite all but one geometry. let segmentCandidatesBySourceLine = new Map(); let bounds = { center: new THREE.Vector3(), radius: .12 }; let playbackQueue: Array<{ tick: number; pose: Point; joints: number[]; activeSegmentId: number; activeProgress: number }> = []; let playbackStart = 0; let queuedLastTick = -1; let renderedTick = -1; let renderedPose: Point = { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 }; let renderedJoints: number[] = []; let renderedActiveSegment = 0; let renderedProgress = 0; let paintedTick = -1; let paintedPose: Point = renderedPose; let paintedJoints: number[] = renderedJoints; let paintedActiveSegment = 0; let paintedProgress = 0; let liveHistoryLine: THREE.Line | null = null; let historyVertexCount = 0; let activeLiveLine: THREE.Line | null = null; let livePathEpoch = -1; let poseEpoch = -1; let lastHistoryTick = -1; let lastActiveKey = ""; let lastPreset = ""; const presetCamera = (selected: Props["view"], selectedZoom: number) => { const distance = bounds.radius * 2.9 / Math.max(.5, selectedZoom); controls.dispose(); const aspect = Math.max(element.clientWidth, 280) / Math.max(element.clientHeight, 260); const span = bounds.radius * 1.3 / Math.max(.5, selectedZoom); camera = selected === "p" ? new THREE.PerspectiveCamera(40, aspect, .001, 100) : new THREE.OrthographicCamera(-span * aspect, span * aspect, span, -span, .001, 100); camera.up.set(0, 1, 0); if (selected === "z") camera.up.set(0, 0, 1); if (selected === "z2") camera.up.set(-1, 0, 0); if (selected === "y2") camera.up.set(1, 0, 0); controls = new OrbitControls(camera, renderer.domElement); controls.enableDamping = true; controls.dampingFactor = .12; controls.screenSpacePanning = true; controls.target.copy(bounds.center); if (selected === "z" || selected === "z2") camera.position.copy(bounds.center).add(new THREE.Vector3(0, -distance, 0)); else if (selected === "x") camera.position.copy(bounds.center).add(new THREE.Vector3(-distance, 0, 0)); else if (selected === "y") camera.position.copy(bounds.center).add(new THREE.Vector3(0, 0, distance)); else if (selected === "y2") camera.position.copy(bounds.center).add(new THREE.Vector3(0, 0, -distance)); else camera.position.copy(bounds.center).add(new THREE.Vector3(distance * .85, distance * .7, distance)); camera.lookAt(bounds.center); element.dataset.xyzacProjection = selected === "p" ? "perspective" : "orthographic"; controls.update(); }; const pan = (horizontal: number, vertical: number) => { const amount = Math.max(.02, bounds.radius * .12); const right = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0); const up = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 1); const offset = right.multiplyScalar(horizontal * amount).add(up.multiplyScalar(vertical * amount)); camera.position.add(offset); controls.target.add(offset); controls.update(); }; const zoomCamera = (factor: number) => { const offset = new THREE.Vector3().subVectors(camera.position, controls.target); offset.multiplyScalar(factor); camera.position.copy(controls.target).add(offset); controls.update(); }; (element as typeof element & { xyzacController?: Record void> }).xyzacController = { zoomIn: () => zoomCamera(.8), zoomOut: () => zoomCamera(1.25), panLeft: () => pan(-1, 0), panRight: () => pan(1, 0), panUp: () => pan(0, 1), panDown: () => pan(0, -1), reset: () => { lastPreset = ""; presetCamera(viewRef.current, zoomRef.current); }, }; const render = () => { const state = visualRef.current; const segments = state.toolpath as Segment[]; const layer = layersRef.current; const now = performance.now(); // A HOME transaction can reset the canonical pose without advancing the // simulation tick. Treat the source home sequence as an explicit pose // barrier so stale queued jog samples cannot keep the old value on the // HUD or tool marker. Keep the Live Plot history intact. if (state.poseEpoch !== poseEpoch) { poseEpoch = state.poseEpoch; playbackQueue = []; playbackStart = 0; queuedLastTick = state.tick; renderedTick = state.tick; renderedPose = state.livePose; renderedJoints = state.joints; renderedActiveSegment = state.activeSegmentId ?? 0; renderedProgress = state.activeProgress; paintedTick = renderedTick; paintedPose = renderedPose; paintedJoints = renderedJoints; paintedActiveSegment = renderedActiveSegment; paintedProgress = renderedProgress; liveTick = renderedTick; if (activeLiveLine) { activeLiveLine.geometry.dispose(); liveGroup.remove(activeLiveLine); activeLiveLine = null; } lastActiveKey = ""; } if (state.livePathEpoch !== livePathEpoch) { livePathEpoch = state.livePathEpoch; clearLines(liveGroup); liveTrace = []; playbackQueue = []; playbackStart = 0; queuedLastTick = state.tick; renderedTick = state.tick; renderedPose = state.livePose; renderedJoints = state.joints; renderedActiveSegment = state.activeSegmentId ?? 0; renderedProgress = state.activeProgress; paintedTick = renderedTick; paintedPose = renderedPose; paintedJoints = renderedJoints; paintedActiveSegment = renderedActiveSegment; paintedProgress = renderedProgress; liveTick = renderedTick; liveHistoryLine = null; activeLiveLine = null; lastHistoryTick = -1; lastActiveKey = ""; } // React can deliver a full snapshot just behind an already queued // motion-delta response. Keep the rendered pose monotonic until the // newer samples arrive; otherwise the tool visibly jumps backward. const staleState = state.tick < queuedLastTick; if (!staleState && !state.motionTrace.length && state.tick > queuedLastTick) { playbackQueue = []; playbackStart = 0; queuedLastTick = state.tick; renderedTick = state.tick; renderedPose = state.livePose; renderedJoints = state.joints; renderedActiveSegment = state.activeSegmentId ?? 0; renderedProgress = state.activeProgress; } for (const sample of staleState ? [] : state.motionTrace) { if (sample.tick > queuedLastTick) { playbackQueue.push(sample); queuedLastTick = sample.tick; } } if (playbackQueue.length && playbackStart === 0) playbackStart = now; if (playbackQueue.length) { const index = Math.min(playbackQueue.length - 1, Math.max(0, Math.floor((now - playbackStart) / 10))); const sample = playbackQueue[index]; if (sample.tick >= renderedTick) { renderedTick = sample.tick; renderedPose = sample.pose; renderedJoints = sample.joints; renderedActiveSegment = sample.activeSegmentId; renderedProgress = sample.activeProgress; } if (index > 32) { playbackQueue.splice(0, index); playbackStart += index * 10; } } else if (!staleState && state.tick >= renderedTick) { renderedTick = state.tick; renderedPose = state.livePose; renderedJoints = state.joints; renderedActiveSegment = state.activeSegmentId ?? 0; renderedProgress = state.activeProgress; } if (renderedTick < paintedTick) { renderedTick = paintedTick; renderedPose = paintedPose; renderedJoints = paintedJoints; renderedActiveSegment = paintedActiveSegment; renderedProgress = paintedProgress; } else { paintedTick = renderedTick; paintedPose = renderedPose; paintedJoints = renderedJoints; paintedActiveSegment = renderedActiveSegment; paintedProgress = renderedProgress; } // The planned path is immutable for a loaded program. Rebuilding all // geometries on every animation frame starves worker snapshots and // makes an otherwise continuous WASM trajectory appear to stutter. // Toolpath arrays are immutable between PROGRAM_LOAD/RESTART. Rebuild // geometry only when WASM publishes a new array; scanning thousands of // segments on every animation frame causes visible execution stalls. if (segments !== pathSegments) { pathSegments = segments; segmentCandidatesBySourceLine = new Map(); for (const segment of segments) { const sourceLine = Number(segment.source_line); if (!Number.isSafeInteger(sourceLine) || sourceLine <= 0) continue; const candidates = segmentCandidatesBySourceLine.get(sourceLine) ?? []; candidates.push(segment); segmentCandidatesBySourceLine.set(sourceLine, candidates); } clearLines(pathGroup); bounds = fit(segments); // Long programs can contain thousands of linear blocks. Batch their // vertices so preview loading does not block the UI on one WebGL // object and geometry allocation per G-code line. const batched = { rapid: [] as THREE.Vector3[], completed: [] as THREE.Vector3[] }; for (const segment of segments) { const start = segment.start ?? {}; const end = segment.end ?? {}; const sampled = Array.isArray(segment.samples) && segment.samples.length > 1; const points = sampled ? segment.samples!.map(sourcePoint) : [sourcePoint(start), sourcePoint(end)]; const rapidPath = segment.kind === "rapid"; const target = rapidPath ? batched.rapid : batched.completed; for (let index = 1; index < points.length; index++) target.push(points[index - 1], points[index]); } const addSegments = (points: THREE.Vector3[], material: THREE.LineBasicMaterial | THREE.LineDashedMaterial, dashed = false) => { if (points.length < 2) return; const line = new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(points), material); if (dashed) line.computeLineDistances(); line.renderOrder = 10; pathGroup.add(line); }; addSegments(batched.rapid, rapid, true); addSegments(batched.completed, completed); element.dataset.xyzacPreviewDrawCalls = String(pathGroup.children.length); element.dataset.xyzacPreviewVertices = String(batched.rapid.length + batched.completed.length); } const presetKey = `${viewRef.current}:${zoomRef.current}:${bounds.radius}:${bounds.center.x}:${bounds.center.y}:${bounds.center.z}`; if (presetKey !== lastPreset) { presetCamera(viewRef.current, zoomRef.current); lastPreset = presetKey; } if (renderedTick < liveTick) { liveTrace = []; lastHistoryTick = -1; if (liveHistoryLine) { liveHistoryLine.geometry.dispose(); liveGroup.remove(liveHistoryLine); liveHistoryLine = null; } } if (renderedTick !== liveTick) { const current = sourcePoint(renderedPose); const previous = liveTrace.at(-1); if (!previous || previous.distanceTo(current) > 1e-9) liveTrace.push(current); liveTick = renderedTick; } element.dataset.xyzacLiveTraceCount = String(liveTrace.length); element.dataset.xyzacLivePathEpoch = String(livePathEpoch); element.dataset.xyzacRenderedTick = String(renderedTick); const historyDue = liveTrace.length > 1 && (lastHistoryTick < 0 || renderedTick - lastHistoryTick >= 4); if (historyDue) { if (!liveHistoryLine) { liveHistoryLine = new THREE.Line(new THREE.BufferGeometry(), liveHistory); liveHistoryLine.renderOrder = 19; liveHistoryLine.frustumCulled = false; liveGroup.add(liveHistoryLine); historyVertexCount = 0; } let attribute = liveHistoryLine.geometry.getAttribute("position") as THREE.BufferAttribute | undefined; if (!attribute || attribute.count < liveTrace.length) { const capacity = Math.max(1024, 2 ** Math.ceil(Math.log2(liveTrace.length))); attribute = new THREE.BufferAttribute(new Float32Array(capacity * 3), 3).setUsage(THREE.DynamicDrawUsage); liveHistoryLine.geometry.dispose(); liveHistoryLine.geometry = new THREE.BufferGeometry(); liveHistoryLine.geometry.setAttribute("position", attribute); historyVertexCount = 0; } for (let index = historyVertexCount; index < liveTrace.length; index++) { const point = liveTrace[index]; attribute.setXYZ(index, point.x, point.y, point.z); } attribute.addUpdateRange(historyVertexCount * 3, (liveTrace.length - historyVertexCount) * 3); attribute.needsUpdate = true; historyVertexCount = liveTrace.length; liveHistoryLine.geometry.setDrawRange(0, historyVertexCount); lastHistoryTick = renderedTick; } const nextActiveKey = `${renderedTick}:${renderedActiveSegment}:${renderedProgress}`; if (nextActiveKey !== lastActiveKey) { if (activeLiveLine) { activeLiveLine.geometry.dispose(); liveGroup.remove(activeLiveLine); activeLiveLine = null; } const candidates = segmentCandidatesBySourceLine.get(renderedActiveSegment) ?? []; // A repeated source line has no unique preview geometry. LinuxCNC's // current-line indicator remains authoritative in the program view; // do not invent a yellow/cyan path by selecting an arbitrary repeat. const activeSegment = candidates.length === 1 ? candidates[0] : null; if (activeSegment) { const start = activeSegment.start ?? {}; const end = activeSegment.end ?? {}; const points = Array.isArray(activeSegment.samples) && activeSegment.samples.length > 1 ? activeSegment.samples.map(sourcePoint) : [sourcePoint(start), sourcePoint(end)]; if (points.length > 1) { const progress = Math.max(0, Math.min(1, renderedProgress)); const lastIndex = Math.max(1, Math.floor(progress * (points.length - 1)) + 1); const travelled = points.slice(0, lastIndex); const livePoint = sourcePoint(renderedPose); const lastPoint = travelled.at(-1); if (!lastPoint || lastPoint.distanceTo(livePoint) > 1e-9) travelled.push(livePoint); if (travelled.length > 1) { activeLiveLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints(travelled), live); activeLiveLine.renderOrder = 20; liveGroup.add(activeLiveLine); } } } lastActiveKey = nextActiveKey; } pathGroup.visible = layer.showPath; liveGroup.visible = layer.showLive; grid.scale.setScalar(Math.max(.2, bounds.radius * 2.4)); originGroup.scale.setScalar(Math.max(.015, bounds.radius * .35)); const pose = sourcePoint(renderedPose); toolGroup.position.copy(pose); const isXyzbc = String(state.machineModel?.coordinate_system ?? "XYZAC") === "XYZBC"; const tilt = -Number(renderedPose[isXyzbc ? "b" : "a"] ?? 0) * DEG; const rotary = -Number(renderedPose.c ?? 0) * DEG; toolGroup.rotation.set(isXyzbc ? 0 : tilt, rotary, isXyzbc ? tilt : 0); const toolScale = Math.min(1, Math.max(0.015, bounds.radius * 0.35)); toolGroup.scale.setScalar(toolScale); tool.position.set(0, .57, 0); tip.position.set(0, 0, 0); grid.visible = true; const limits = state.machineLimits; limitGrid.visible = layer.showLimits && Boolean(limits); if (limits?.length === 3) limitGrid.box.setFromPoints([sourcePoint({ x: limits[0][0], y: limits[1][0], z: limits[2][0] }), sourcePoint({ x: limits[0][1], y: limits[1][1], z: limits[2][1] })]); offsetAxes.visible = layer.showOffsets && Boolean(state.coordinateOffset); const offset = state.coordinateOffset ?? [0, 0, 0]; offsetAxes.position.copy(sourcePoint({ x: offset[0], y: offset[1], z: offset[2] })); extents.visible = layer.showExtents && segments.length > 0; if (extents.visible && extentsSegments !== segments) { extentsSegments = segments; extents.box.setFromPoints(segments.flatMap((segment) => (segment.samples?.length ? segment.samples : [segment.start, segment.end]).filter(Boolean).map((point) => sourcePoint(point ?? {})))); } element.dataset.xyzacLimitsVisible = String(limitGrid.visible); element.dataset.xyzacExtentsVisible = String(extents.visible); element.dataset.xyzacOffset = JSON.stringify(offset); controls.mouseButtons.LEFT = rotateRef.current ? THREE.MOUSE.ROTATE : THREE.MOUSE.PAN; controls.update(); renderer.render(scene, camera); frame = requestAnimationFrame(render); }; render(); return () => { cancelAnimationFrame(frame); observer.disconnect(); clearLines(pathGroup); clearLines(liveGroup); completed.dispose(); rapid.dispose(); live.dispose(); liveHistory.dispose(); originGroup.traverse((child) => { const mesh = child as THREE.Mesh; mesh.geometry?.dispose?.(); const material = mesh.material as (THREE.Material & { map?: THREE.Texture }) | undefined; material?.map?.dispose?.(); material?.dispose?.(); }); controls.dispose(); delete (element as typeof element & { xyzacController?: unknown }).xyzacController; renderer.dispose(); element.replaceChildren(); }; }, []); const helixCount = visual.toolpath.filter((segment) => (segment as { kind?: string }).kind === "helix").length; const sampleCount = visual.toolpath.reduce((count, segment) => count + (Array.isArray((segment as { samples?: unknown[] }).samples) ? (segment as { samples: unknown[] }).samples.length : 0), 0); const controllerButton = (action: string, label: string, glyph: string) => ["zoomIn", "zoomOut"].includes(action) ? null : ; const coordinates = String(visual.machineModel?.coordinate_system ?? "XYZAC"); return
{controllerButton("zoomIn", "Zoom in", "+")}{controllerButton("zoomOut", "Zoom out", "-")}
; } export function XyzacToolPreviewFromSnapshot({ snapshot, ...props }: Omit & { snapshot: XyzacVisualSource | null | undefined }) { return ; }