335 lines
24 KiB
TypeScript
335 lines
24 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
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<string, number>;
|
|
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 = 0.01;
|
|
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<HTMLDivElement>(null);
|
|
const hudRef = useRef<HTMLPreElement>(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(0x050a0e);
|
|
scene.add(new THREE.HemisphereLight(0xdcecff, 0x121820, 1.4));
|
|
const camera = new THREE.PerspectiveCamera(40, 1, 0.01, 100);
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
|
|
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, 2));
|
|
renderer.domElement.dataset.engine = "three.js r185 / xyzac-tool-preview";
|
|
element.appendChild(renderer.domElement);
|
|
const controls = 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.GridHelper(2.5, 10, 0x8b4d47, 0x4f2d2d); limitGrid.name = "xyzac-preview-limits"; overlayGroup.add(limitGrid);
|
|
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); const height = Math.max(element.clientHeight, 260); camera.aspect = width / height; 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: 0xd8dfe3, depthTest: false });
|
|
const rapid = new THREE.LineDashedMaterial({ color: 0xa85d5d, 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: 0x00ffff, depthTest: false });
|
|
const liveHistory = new THREE.LineBasicMaterial({ color: 0xff6b4a, depthTest: false });
|
|
let frame = 0;
|
|
let liveTick = -1;
|
|
let liveTrace: THREE.Vector3[] = [];
|
|
let pathSegments: 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<number, Segment[]>();
|
|
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 activeLiveLine: THREE.Line | null = null;
|
|
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.target.copy(bounds.center);
|
|
if (selected === "z") camera.position.copy(bounds.center).add(new THREE.Vector3(0, distance, .01));
|
|
else if (selected === "z2") camera.position.copy(bounds.center).add(new THREE.Vector3(distance * .3, distance * .9, distance * .3));
|
|
else if (selected === "x") camera.position.copy(bounds.center).add(new THREE.Vector3(distance, .02, .01));
|
|
else if (selected === "y") camera.position.copy(bounds.center).add(new THREE.Vector3(.01, .02, distance));
|
|
else if (selected === "y2") camera.position.copy(bounds.center).add(new THREE.Vector3(.01, .02, -distance));
|
|
else camera.position.copy(bounds.center).add(new THREE.Vector3(distance * .85, distance * .7, distance));
|
|
camera.lookAt(bounds.center);
|
|
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<string, () => 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();
|
|
// 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);
|
|
clearLines(liveGroup);
|
|
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[] };
|
|
const sampledLines: Array<{ points: THREE.Vector3[]; rapid: boolean }> = [];
|
|
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";
|
|
if (sampled) sampledLines.push({ points, rapid: rapidPath });
|
|
else (rapidPath ? batched.rapid : batched.completed).push(...points);
|
|
}
|
|
const addLine = (points: THREE.Vector3[], material: THREE.LineBasicMaterial | THREE.LineDashedMaterial, dashed = false) => {
|
|
if (points.length < 2) return;
|
|
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material);
|
|
if (dashed) line.computeLineDistances();
|
|
line.renderOrder = 10;
|
|
pathGroup.add(line);
|
|
};
|
|
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);
|
|
for (const sampled of sampledLines) addLine(sampled.points, sampled.rapid ? rapid : completed, sampled.rapid);
|
|
liveHistoryLine = null;
|
|
activeLiveLine = null;
|
|
lastHistoryTick = -1;
|
|
lastActiveKey = "";
|
|
}
|
|
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.xyzacRenderedTick = String(renderedTick);
|
|
const historyDue = liveTrace.length > 1 && (lastHistoryTick < 0 || renderedTick - lastHistoryTick >= 4);
|
|
if (historyDue) {
|
|
if (liveHistoryLine) { liveHistoryLine.geometry.dispose(); liveGroup.remove(liveHistoryLine); }
|
|
liveHistoryLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints(liveTrace), liveHistory);
|
|
liveHistoryLine.renderOrder = 19;
|
|
liveGroup.add(liveHistoryLine);
|
|
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(.35, bounds.radius * .55));
|
|
const pose = sourcePoint(renderedPose);
|
|
toolGroup.position.copy(pose);
|
|
toolGroup.rotation.set(-Number(renderedPose.a ?? 0) * DEG, -Number(renderedPose.c ?? 0) * DEG, 0);
|
|
const toolScale = Math.min(1, Math.max(0.15, bounds.radius * 0.55));
|
|
toolGroup.scale.setScalar(toolScale);
|
|
tool.position.set(0, .57, 0); tip.position.set(0, 0, 0);
|
|
grid.visible = true; limitGrid.visible = layer.showLimits; offsetAxes.visible = layer.showOffsets;
|
|
offsetAxes.position.copy(sourcePoint({ x: state.offsets.x_rot_point, y: state.offsets.y_offset, z: state.offsets.z_offset }));
|
|
if (hudRef.current) hudRef.current.textContent = `TOOL PREVIEW\nX ${Number(renderedPose.x).toFixed(3)} Y ${Number(renderedPose.y).toFixed(3)} Z ${Number(renderedPose.z).toFixed(3)}\nA ${Number(renderedPose.a).toFixed(3)} C ${Number(renderedPose.c).toFixed(3)}\nJ0 ${Number(renderedJoints[0] ?? 0).toFixed(3)} J1 ${Number(renderedJoints[1] ?? 0).toFixed(3)} J2 ${Number(renderedJoints[2] ?? 0).toFixed(3)}\nJ3 ${Number(renderedJoints[3] ?? 0).toFixed(3)} J4 ${Number(renderedJoints[4] ?? 0).toFixed(3)}\nHelix ${segments.filter((segment) => segment.kind === "helix").length} / Samples ${segments.reduce((count, segment) => count + (Array.isArray(segment.samples) ? segment.samples.length : 0), 0)}\nLive ${renderedActiveSegment || "-"} / ${renderedProgress.toFixed(3)}\nTick ${renderedTick}\nHash ${state.stateHash.slice(0, 18)}`;
|
|
controls.update(); scene.rotation.y = rotateRef.current ? (performance.now() / 5000) % (Math.PI * 2) : 0; 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 pose = visual.pose;
|
|
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) => <button type="button" data-xyzac-controller={action} title={label} aria-label={label} onClick={() => { const controller = (host.current as (HTMLDivElement & { xyzacController?: Record<string, () => void> }) | null)?.xyzacController; controller?.[action]?.(); }}>{glyph}</button>;
|
|
return <div className="three-viewer xyzac-tool-preview" ref={host} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-preview="toolpath" data-xyzac-controller="orbit" data-xyzac-origin="0,0,0" data-xyzac-origin-axes="X,Y,Z" data-xyzac-toolpath-count={visual.toolpath.length} data-xyzac-helix-count={helixCount} data-xyzac-sample-count={sampleCount} data-xyzac-samples-source="linuxcnc-wasm-sim_posemath_segment_point" data-xyzac-live-source="linuxcnc-source-tp" data-xyzac-live-progress={visual.activeProgress} data-xyzac-live-pose={JSON.stringify(visual.livePose)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} aria-label="XYZAC authoritative tool preview"><div className="xyzac-view-controller" role="toolbar" aria-label="3D preview controller">{controllerButton("zoomIn", "Zoom in", "+")}{controllerButton("zoomOut", "Zoom out", "-")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("panLeft", "Pan left", "L")}{controllerButton("panRight", "Pan right", "R")}{controllerButton("panUp", "Pan up", "U")}{controllerButton("panDown", "Pan down", "D")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("reset", "Reset 3D view", "0")}</div><pre ref={hudRef} className="axis-preview-hud" aria-label="XYZAC tool preview state">{visual.authoritative ? `TOOL PREVIEW\nX ${Number(pose.x).toFixed(3)} Y ${Number(pose.y).toFixed(3)} Z ${Number(pose.z).toFixed(3)}\nA ${Number(pose.a).toFixed(3)} C ${Number(pose.c).toFixed(3)}\nJ0 ${Number(visual.joints[0]).toFixed(3)} J1 ${Number(visual.joints[1]).toFixed(3)} J2 ${Number(visual.joints[2]).toFixed(3)}\nJ3 ${Number(visual.joints[3]).toFixed(3)} J4 ${Number(visual.joints[4]).toFixed(3)}\nHelix ${helixCount} / Samples ${sampleCount}\nLive ${visual.activeSegmentId ?? "-"} / ${visual.activeProgress.toFixed(3)}\nTick ${visual.tick}\nHash ${visual.stateHash.slice(0, 18)}` : "TOOL PREVIEW\nWASM state pending"}</pre></div>;
|
|
}
|
|
|
|
export function XyzacToolPreviewFromSnapshot({ snapshot, ...props }: Omit<Props, "visual"> & { snapshot: XyzacVisualSource | null | undefined }) {
|
|
return <XyzacToolPreview visual={projectXyzacVisual(snapshot)} {...props} />;
|
|
}
|