Add dual canvas XYZBC and refined machine visualization

This commit is contained in:
2026-09-10 05:47:14 +08:00
parent d90933b980
commit 17887c199a
15 changed files with 935 additions and 92 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1,115 +1,198 @@
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
import { axisIcon } from "./sourceAssets";
import { buildXyzacMachine } from "./xyzac-machine-model";
import { projectXyzacVisual, type XyzacVisualSource, type XyzacVisualState } from "./xyzac-visual-adapter";
type Point = Record<string, number>;
type Segment = { id?: number; active?: boolean; motion_type?: number; turn?: number; center?: Point; normal?: Point; samples?: Point[]; start?: Point; end?: Point };
type Segment = { id?: number; active?: boolean; motion_type?: number; samples?: Point[]; start?: Point; end?: Point };
type Props = { visual: XyzacVisualState; toolpath?: Segment[]; view?: "z" | "z2" | "x" | "y" | "y2" | "p"; zoom?: number; rotateMode?: boolean; showPath?: boolean; showLive?: boolean; showLimits?: boolean; showVelocity?: boolean; showOffsets?: boolean; showExtents?: boolean };
type Controller = Record<string, () => void>;
type Host = HTMLDivElement & { xyzacMachineController?: Controller };
const toView = (point: Point) => new THREE.Vector3(Number(point.x ?? 0) * .01, Number(point.z ?? 0) * .01, Number(point.y ?? 0) * .01);
const DEG = Math.PI / 180;
const MM = 0.01;
function material(color: number) { return new THREE.MeshStandardMaterial({ color, roughness: .78, metalness: .12 }); }
function sourcePosition(x: number, y: number, z: number) { return new THREE.Vector3(x * MM, z * MM, y * MM); }
function sourceBox(width: number, depth: number, height: number, color: number) { return new THREE.Mesh(new THREE.BoxGeometry(width * MM, height * MM, depth * MM), material(color)); }
function sourceCylinderZ(radius: number, height: number, color: number) { return new THREE.Mesh(new THREE.CylinderGeometry(radius * MM, radius * MM, height * MM, 32), material(color)); }
function translate(name: string, object: THREE.Object3D, x: number, y: number, z: number) { object.name = name; object.position.copy(sourcePosition(x, y, z)); return object; }
export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotateMode = false, showPath = true, showLive = true, showLimits = false, showVelocity = false, showOffsets = false, showExtents = false }: Props) {
const host = useRef<HTMLDivElement>(null);
export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotateMode = false, showPath = false, showLive = false, showLimits = false, showVelocity = false, showOffsets = false, showExtents = false }: Props) {
const host = useRef<Host>(null);
const visualRef = useRef(visual); visualRef.current = visual;
const toolpathRef = useRef(toolpath); toolpathRef.current = toolpath;
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 };
const settings = useRef({ toolpath, view, zoom, rotateMode, showPath, showLive, showLimits, showOffsets, showExtents });
settings.current = { toolpath, view, zoom, rotateMode, showPath, showLive, showLimits, showOffsets, showExtents };
useEffect(() => {
const element = host.current;
if (!element) return;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x081017);
scene.add(new THREE.HemisphereLight(0xe6f2ff, 0x202830, 1.65));
const key = new THREE.DirectionalLight(0xffffff, 2.1); key.position.set(5, 8, 10); scene.add(key);
const camera = new THREE.PerspectiveCamera(38, 1, .1, 100);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.domElement.dataset.engine = "three.js r185 / xyzac-trt-vismach";
element.appendChild(renderer.domElement);
const machine = new THREE.Group(); machine.name = "xyzac-machine"; scene.add(machine);
machine.add(translate("base", sourceBox(240, 260, 150, 0x43616b), 0, 30, -175));
machine.add(translate("column", sourceBox(100, 100, 510, 0x3b7a72), 0, 150, 5));
const head = new THREE.Group(); head.name = "head"; head.position.copy(sourcePosition(0, 0, 200)); machine.add(head);
head.add(translate("head-body", sourceBox(60, 60, 135, 0x277c83), 0, 0, 87.5));
head.add(translate("motor", sourceCylinderZ(30, 65, 0x277c83), 0, 200, 165));
const spindle = new THREE.Group(); spindle.name = "spindle"; spindle.position.copy(sourcePosition(0, 0, 20)); head.add(spindle);
spindle.add(translate("spindle-housing", sourceCylinderZ(20, 135, 0x31888d), 0, 0, 67.5));
spindle.add(translate("spindle-nose", sourceCylinderZ(10, 20, 0x4da0a0), 0, 0, 10));
const tool = new THREE.Group(); tool.name = "tool-offset"; spindle.add(tool);
tool.add(translate("tool-holder", sourceCylinderZ(6, 70, 0x9da7a8), 0, 0, 35));
tool.add(translate("tool", sourceCylinderZ(3, 70, 0xd85f4a), 0, 0, 105));
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x252b2c);
const studio = new RoomEnvironment();
const pmrem = new THREE.PMREMGenerator(renderer);
const environment = pmrem.fromScene(studio, .04);
scene.environment = environment.texture; scene.environmentIntensity = .65;
studio.dispose(); pmrem.dispose();
scene.add(new THREE.HemisphereLight(0xf4f8ff, 0x4b5450, 1.2));
const key = new THREE.DirectionalLight(0xfff4df, 2.7); key.position.set(-4, 9, -6);
key.castShadow = true; key.shadow.mapSize.set(2048, 2048); key.shadow.bias = -.0002; key.shadow.normalBias = .012;
Object.assign(key.shadow.camera, { left: -6, right: 6, top: 7, bottom: -6, near: .1, far: 30 }); scene.add(key);
const rim = new THREE.DirectionalLight(0xd5f0ff, 1.7); rim.position.set(5, 5, 5); scene.add(rim);
const fill = new THREE.DirectionalLight(0xffffff, .7); fill.position.set(4, 2, -7); scene.add(fill);
const floor = new THREE.Mesh(new THREE.PlaneGeometry(18, 18), new THREE.ShadowMaterial({ opacity: .24 }));
floor.rotation.x = -Math.PI / 2; floor.position.y = -2.64; floor.receiveShadow = true; scene.add(floor);
const assembly = buildXyzacMachine(); scene.add(assembly.machine);
assembly.update(visualRef.current);
const nameplateCanvas = document.createElement("canvas"); nameplateCanvas.width = 512; nameplateCanvas.height = 128;
const label = nameplateCanvas.getContext("2d");
if (label) {
label.fillStyle = "#176b67"; label.fillRect(0, 0, 512, 128);
label.fillStyle = "#f0f4f3"; label.font = "bold 68px sans-serif"; label.fillText("AXIS", 18, 82);
label.font = "24px sans-serif"; label.fillText("XYZAC / TRT", 235, 76);
}
const nameplateTexture = new THREE.CanvasTexture(nameplateCanvas); nameplateTexture.colorSpace = THREE.SRGBColorSpace;
const nameplate = new THREE.Mesh(new THREE.PlaneGeometry(.84, .21), new THREE.MeshBasicMaterial({ map: nameplateTexture }));
nameplate.position.set(-.57, -1.48, -1.046); nameplate.rotation.y = Math.PI; assembly.machine.add(nameplate);
const camera = new THREE.PerspectiveCamera(36, 1, .02, 150);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.dampingFactor = .12;
controls.enableRotate = true;
controls.screenSpacePanning = true; controls.minDistance = .25; controls.maxDistance = 65;
// Keep the standard Three.js mouse contract: left-drag orbits, middle
// drag zooms, right-drag pans, and the wheel zooms. The machine canvas
// must remain directly navigable without first toggling a mode button.
controls.mouseButtons.LEFT = THREE.MOUSE.ROTATE;
controls.mouseButtons.MIDDLE = THREE.MOUSE.DOLLY;
controls.mouseButtons.RIGHT = THREE.MOUSE.PAN;
controls.touches.ONE = THREE.TOUCH.ROTATE;
controls.touches.TWO = THREE.TOUCH.DOLLY_PAN;
let frame = 0, lastPreset = "", lastGeometry: Segment[] | null = null, lastPathKey = "";
const initialBounds = new THREE.Box3().setFromObject(assembly.machine);
const modelCenter = initialBounds.getCenter(new THREE.Vector3());
const presetCamera = () => {
const { view: selected, zoom: scale } = settings.current;
const direction = selected === "z" ? new THREE.Vector3(0, 1, -.001)
: selected === "z2" ? new THREE.Vector3(.001, 1, 0)
: selected === "x" ? new THREE.Vector3(1, 0, 0)
: selected === "y" ? new THREE.Vector3(0, 0, 1)
: selected === "y2" ? new THREE.Vector3(0, 0, -1) : new THREE.Vector3(.95, .6, -1.5);
// Clear residual damping so a previous drag cannot move the fitted view.
controls.enableDamping = false; controls.update(); controls.enableDamping = true;
direction.normalize();
camera.position.copy(modelCenter).add(direction); camera.lookAt(modelCenter); camera.updateMatrix();
const right = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0);
const up = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 1);
const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov / 2));
let distance = 0;
// Fit all eight bounding-box corners in camera space, including depth.
for (const x of [initialBounds.min.x, initialBounds.max.x]) for (const y of [initialBounds.min.y, initialBounds.max.y]) for (const z of [initialBounds.min.z, initialBounds.max.z]) {
const point = new THREE.Vector3(x, y, z).sub(modelCenter);
distance = Math.max(distance, point.dot(direction) + Math.max(Math.abs(point.dot(right)) / (tanV * camera.aspect), Math.abs(point.dot(up)) / tanV));
}
controls.target.copy(modelCenter); camera.position.copy(modelCenter).addScaledVector(direction, distance * 1.12 / Math.max(.5, scale));
camera.lookAt(controls.target); controls.update();
};
const resize = () => {
const width = Math.max(element.clientWidth, 1), height = Math.max(element.clientHeight, 1);
camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false);
lastPreset = "";
};
const observer = new ResizeObserver(resize); observer.observe(element); resize();
const pan = (x: number, y: number) => {
const amount = camera.position.distanceTo(controls.target) * .035;
camera.updateMatrix();
const offset = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0).multiplyScalar(x * amount)
.addScaledVector(new THREE.Vector3().setFromMatrixColumn(camera.matrix, 1), y * amount);
camera.position.add(offset); controls.target.add(offset); controls.update();
};
const zoomCamera = (factor: number) => {
const offset = camera.position.clone().sub(controls.target);
offset.setLength(THREE.MathUtils.clamp(offset.length() * factor, controls.minDistance, controls.maxDistance));
camera.position.copy(controls.target).add(offset); controls.update();
};
element.xyzacMachineController = {
zoomIn: () => zoomCamera(.8), zoomOut: () => zoomCamera(1.25),
panLeft: () => pan(-1, 0), panRight: () => pan(1, 0), panUp: () => pan(0, 1), panDown: () => pan(0, -1),
rotateLeft: () => { controls.rotateLeft(Math.PI / 12); controls.update(); },
rotateRight: () => { controls.rotateLeft(-Math.PI / 12); controls.update(); },
rotateUp: () => { controls.rotateUp(Math.PI / 12); controls.update(); },
rotateDown: () => { controls.rotateUp(-Math.PI / 12); controls.update(); },
reset: () => { lastPreset = ""; },
tool: () => {
const target = assembly.toolTip.getWorldPosition(new THREE.Vector3()).add(new THREE.Vector3(0, .2, 0));
controls.target.copy(target); camera.position.copy(target).add(new THREE.Vector3(.95, .55, -1.5)); controls.update();
},
};
const zslide = new THREE.Group(); zslide.name = "spindle-z"; machine.add(zslide);
const saddle = new THREE.Group(); saddle.name = "saddle-y"; zslide.add(saddle);
saddle.add(translate("saddle-body", sourceBox(150, 106, 32, 0x8b9697), 0, 0, -89));
saddle.add(translate("z-slide", sourceBox(100, 220, 75, 0xb8bdbe), 0, 10, -142.5));
const tableX = new THREE.Group(); tableX.name = "table-x"; saddle.add(tableX);
tableX.add(translate("table-bed", sourceBox(300, 100, 17, 0x686f72), 0, 0, -60.5));
tableX.add(translate("ways", sourceBox(300, 80, 6, 0x50575a), 0, 0, -72));
const tilt = new THREE.Group(); tilt.name = "tilt-a"; tableX.add(tilt);
const yOffset = 20; const zOffset = 10;
tilt.position.copy(sourcePosition(0, yOffset, zOffset));
tilt.rotation.x = 0;
const rotate = new THREE.Group(); rotate.name = "rotate-c"; tilt.add(rotate);
rotate.position.copy(sourcePosition(0, -yOffset, -zOffset));
rotate.add(translate("trunnion", sourceBox(145, 80, 35, 0xb17540), 0, 0, -30));
rotate.add(translate("table", sourceCylinderZ(50, 18, 0xb17540), 0, 0, -9));
rotate.rotation.y = 0;
const yoke = new THREE.Group(); yoke.name = "yoke"; tilt.add(yoke);
yoke.add(translate("yoke-plate", sourceBox(130, 80, 10, 0xb17540), 0, 0, -30));
const brackets = new THREE.Group(); brackets.name = "brackets"; tableX.add(brackets);
brackets.position.copy(sourcePosition(0, yOffset, zOffset));
brackets.add(translate("bracket-left", sourceBox(10, 80, 50, 0x9b6b3b), -72, 0, -25));
brackets.add(translate("bracket-right", sourceBox(10, 80, 50, 0x9b6b3b), 72, 0, -25));
const pathGroup = new THREE.Group(); pathGroup.name = "xyzac-toolpath"; scene.add(pathGroup);
const liveGroup = new THREE.Group(); liveGroup.name = "xyzac-live-toolpath"; scene.add(liveGroup);
const overlayGroup = new THREE.Group(); overlayGroup.name = "xyzac-overlays"; scene.add(overlayGroup);
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 toView = (point: Point) => new THREE.Vector3(Number(point.x ?? 0) * MM, Number(point.z ?? 0) * MM, Number(point.y ?? 0) * MM);
let frame = 0;
const pathMaterials = [0xdce7e8, 0xff9a55, 0x5c9d9a].map((color) => new THREE.LineBasicMaterial({ color }));
const clearLines = (group: THREE.Group) => { group.children.forEach((child) => (child as THREE.Line).geometry.dispose()); group.clear(); };
const limits = new THREE.Box3Helper(new THREE.Box3(), 0xdd7566); scene.add(limits);
const offsets = new THREE.AxesHelper(.8); scene.add(offsets);
const extents = new THREE.Box3Helper(initialBounds, 0xe6c56b); scene.add(extents);
const render = () => {
const state = visualRef.current; const [tableXJoint, saddleYJoint, spindleZJoint, tiltAJoint, rotateCJoint] = state.joints;
tableX.position.x = -tableXJoint * MM; saddle.position.z = -saddleYJoint * MM; zslide.position.y = (200 - spindleZJoint) * MM;
tilt.rotation.x = -tiltAJoint * DEG; rotate.rotation.y = -rotateCJoint * DEG; tool.position.y = -state.offsets.tool_offset * MM;
pathGroup.clear(); liveGroup.clear();
const completed = new THREE.LineBasicMaterial({ color: 0xdce7e8 }); const active = new THREE.LineBasicMaterial({ color: 0xff9a55 }); const rapid = new THREE.LineBasicMaterial({ color: 0x5c9d9a });
for (const raw of (toolpathRef.current && toolpathRef.current.length > 0 ? toolpathRef.current : state.toolpath) as Segment[]) {
const start = raw.start ?? {}; const end = raw.end ?? {};
const points = Array.isArray(raw.samples) && raw.samples.length > 1 ? raw.samples.map(toView) : [toView(start), toView(end)];
const group = raw.active || raw.id === state.activeSegmentId ? liveGroup : pathGroup; const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), raw.active || raw.id === state.activeSegmentId ? active : raw.motion_type === 0 ? rapid : completed); group.add(line);
const state = visualRef.current, config = settings.current;
assembly.update(state);
const paths = (config.toolpath ?? state.toolpath) as Segment[];
const pathKey = `${config.showPath}:${config.showLive}:${state.activeSegmentId}`;
if (paths !== lastGeometry || pathKey !== lastPathKey) {
clearLines(pathGroup); clearLines(liveGroup);
if (config.showPath || config.showLive) for (const raw of paths) {
const live = raw.active || raw.id === state.activeSegmentId;
if (live ? !config.showLive : !config.showPath) continue;
const points = raw.samples?.length ? raw.samples.map(toView) : [toView(raw.start ?? {}), toView(raw.end ?? {})];
(live ? liveGroup : pathGroup).add(new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), pathMaterials[live ? 1 : raw.motion_type === 0 ? 2 : 0]));
}
lastGeometry = paths; lastPathKey = pathKey;
}
const layer = layersRef.current;
pathGroup.visible = layer.showPath; liveGroup.visible = layer.showLive;
overlayGroup.clear();
if (layer.showLimits) { const limits = new THREE.LineSegments(new THREE.EdgesGeometry(new THREE.BoxGeometry(4, 2, 3)), new THREE.LineBasicMaterial({ color: 0xdd7566 })); limits.position.set(0, .8, 0); overlayGroup.add(limits); }
if (layer.showOffsets) { const offset = new THREE.AxesHelper(1.8); offset.name = "xyzac-offset-axes"; offset.position.set(state.offsets.x_rot_point * MM, state.offsets.z_offset * MM, state.offsets.y_offset * MM); overlayGroup.add(offset); }
overlayGroup.visible = layer.showLimits || layer.showOffsets || layer.showExtents || layer.showVelocity;
const distance = 9 / Math.max(.5, zoomRef.current); const selected = viewRef.current;
if (selected === "z") camera.position.set(0, distance, .01); else if (selected === "z2") camera.position.set(distance * .3, distance * .9, distance * .3); else if (selected === "x") camera.position.set(distance, 2, .01); else if (selected === "y") camera.position.set(.01, 2, distance); else if (selected === "y2") camera.position.set(.01, 2, -distance); else camera.position.set(6 / Math.max(.5, zoomRef.current), 5 / Math.max(.5, zoomRef.current), 7 / Math.max(.5, zoomRef.current));
camera.lookAt(0, 0, 0); scene.rotation.y = rotateRef.current ? (performance.now() / 5000) % (Math.PI * 2) : 0; renderer.render(scene, camera); frame = requestAnimationFrame(render);
limits.visible = config.showLimits && Boolean(state.machineLimits?.length === 3);
if (limits.visible) {
const [x, y, z] = state.machineLimits!;
limits.box.setFromPoints([toView({ x: x[0], y: y[0], z: z[0] }), toView({ x: x[1], y: y[1], z: z[1] })]);
}
offsets.visible = config.showOffsets;
offsets.position.set(0, state.offsets.z_offset * .01, state.offsets.y_offset * .01);
extents.visible = config.showExtents;
const preset = `${config.view}:${config.zoom}`;
if (preset !== lastPreset) { presetCamera(); lastPreset = preset; }
controls.update(); renderer.render(scene, camera);
element.dataset.xyzacRenderedTick = String(state.tick);
element.dataset.xyzacToolTip = JSON.stringify(assembly.toolTip.getWorldPosition(new THREE.Vector3()).toArray());
frame = requestAnimationFrame(render);
};
render();
return () => { cancelAnimationFrame(frame); observer.disconnect(); renderer.dispose(); element.replaceChildren(); };
return () => {
cancelAnimationFrame(frame); observer.disconnect(); controls.dispose(); delete element.xyzacMachineController;
clearLines(pathGroup); clearLines(liveGroup); pathMaterials.forEach((material) => material.dispose());
for (const helper of [limits, offsets, extents]) helper.dispose();
assembly.dispose(); nameplateTexture.dispose(); nameplate.geometry.dispose(); nameplate.material.dispose();
floor.geometry.dispose(); floor.material.dispose(); key.shadow.map?.dispose(); environment.dispose();
renderer.dispose(); renderer.domElement.remove();
};
}, []);
const pose = visual.pose;
return <div className="three-viewer xyzac-machine-view" ref={host} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} data-xyzac-joints={JSON.stringify(visual.joints)} data-xyzac-offsets={JSON.stringify(visual.offsets)} aria-label="XYZAC TRT Vismach machine"><pre className="axis-preview-hud" aria-label="XYZAC authoritative visual state">{visual.authoritative ? `XYZAC TRT\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)}\nJ ${visual.joints.map((value) => Number(value).toFixed(2)).join(" ")}\nTick ${visual.tick}\nHash ${visual.stateHash.slice(0, 18)}` : "XYZAC TRT\nWASM state pending"}</pre></div>;
const model = visual.machineModel;
const controllerButton = (action: string, label: string, asset?: string, glyph?: string) => <button type="button" data-axis-control={`machine-${action}`} data-xyzac-machine-controller={action} title={label} aria-label={label} onClick={() => host.current?.xyzacMachineController?.[action]?.()}>{asset ? <img src={axisIcon(asset)} alt="" /> : glyph}</button>;
return <div className="three-viewer xyzac-machine-view" ref={host} data-xyzac-preview="machine" data-xyzac-model-source="linuxcnc-wasm" data-xyzac-mouse-controls="left-rotate-middle-zoom-right-pan-wheel-zoom" data-xyzac-model-id={model?.id ?? ""} data-xyzac-model-source-path={model?.source_path ?? ""} data-xyzac-model-source-hash={model?.source_hash ?? ""} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} data-xyzac-joints={JSON.stringify(visual.joints)} data-xyzac-offsets={JSON.stringify(visual.offsets)} aria-label="XYZAC TRT Vismach machine model">
<div className="xyzac-view-controller xyzac-machine-controller" role="toolbar" aria-label="Machine 3D view controller">
{controllerButton("zoomIn", "Zoom in", "tool_zoomin.gif")}{controllerButton("zoomOut", "Zoom out", "tool_zoomout.gif")}
{controllerButton("panLeft", "Pan left", undefined, "\u2190")}{controllerButton("panRight", "Pan right", undefined, "\u2192")}
{controllerButton("panUp", "Pan up", undefined, "\u2191")}{controllerButton("panDown", "Pan down", undefined, "\u2193")}
{controllerButton("rotateLeft", "Rotate left", undefined, "\u21ba")}{controllerButton("rotateRight", "Rotate right", undefined, "\u21bb")}
{controllerButton("rotateUp", "Rotate upward", undefined, "\u2b06")}{controllerButton("rotateDown", "Rotate downward", undefined, "\u2b07")}
{controllerButton("reset", "Reset machine view", undefined, "\u21ba")}{controllerButton("tool", "Tool close-up", "tool_axis_z.gif")}
</div>
<pre className="axis-preview-hud xyzac-machine-hud" aria-label="XYZAC authoritative visual state">{visual.authoritative ? `XYZAC TRT\n${visual.joints.map((value, index) => `${["X", "Y", "Z", "A", "C"][index]} ${value.toFixed(3)}`).join("\n")}${showVelocity ? `\nV ${Number(visual.velocityMmMin ?? 0).toFixed(1)}` : ""}` : "XYZAC TRT\nWASM state pending"}</pre>
</div>;
}
export function XyzacMachineViewFromSnapshot({ snapshot, toolpath, ...props }: Omit<Props, "visual"> & { snapshot: XyzacVisualSource | null | undefined }) {
return <XyzacMachineView visual={projectXyzacVisual(snapshot)} toolpath={toolpath} {...props} />;
export function XyzacMachineViewFromSnapshot({ snapshot, ...props }: Omit<Props, "visual"> & { snapshot: XyzacVisualSource | null | undefined }) {
return <XyzacMachineView visual={projectXyzacVisual(snapshot)} {...props} />;
}

View File

@@ -159,6 +159,7 @@ export type PanelSnapshotV1 = {
active_segment_id?: number | null;
active_progress?: number;
motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>;
machine_model?: XyzacMachineModel;
source: "wasm_canonical_motion";
};
authoritative: Record<string, unknown>;
@@ -169,6 +170,17 @@ export type PanelSnapshotV1 = {
io?: Record<string, unknown>;
};
export type XyzacMachineModel = {
schema: "linuxcnc-vismach-model.v1";
id: string;
source: "linuxcnc-wasm-worker";
source_path: string;
source_hash: string | null;
coordinate_system: "XYZAC";
joints: string[];
components: string[];
};
export type Capabilities = {
context_id?: string;
abi?: Record<string, unknown>;

View File

@@ -171,6 +171,9 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.empty-history { color: var(--muted); }
.axis-preview { display: flex; min-height: 0; flex: 1; padding: 4px; overflow: hidden; background: #000; }
.axis-preview-split { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 4px; width: 100%; min-width: 0; min-height: 0; }
.axis-preview-pane { display: flex; min-width: 0; min-height: 0; overflow: hidden; }
.axis-preview-pane > .three-viewer { min-width: 0; min-height: 0; }
.three-viewer { position: relative; width: 100%; height: 100%; min-height: 0; flex: 1; overflow: hidden; border: 1px solid #303030; background: #000; }
.three-viewer canvas { display: block; width: 100%; height: 100%; }
.axis-preview-hud { position: absolute; z-index: 1; top: 11px; left: 34px; margin: 0; color: #f2f2f2; font: 12px/1.45 ui-monospace, monospace; white-space: pre; pointer-events: none; text-shadow: 0 1px #000; }
@@ -178,6 +181,10 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.xyzac-view-controller button { width: 27px; height: 25px; padding: 0; border: 1px solid #93a1a8; background: #e6ebed; color: #182126; font: 700 14px/1 ui-monospace, monospace; cursor: pointer; }
.xyzac-view-controller button:hover, .xyzac-view-controller button:focus-visible { background: #fff; outline: 2px solid #e7b84b; outline-offset: 1px; }
.xyzac-controller-divider { width: 100%; height: 1px; grid-column: 1 / -1; background: #68747c; }
.xyzac-machine-hud { top: 12px; left: 12px; padding: 5px 7px; background: #252b2cde; font-size: 11px; line-height: 1.5; }
.xyzac-machine-controller { top: auto; bottom: 10px; right: 10px; grid-template-columns: repeat(4, 30px); background: #252b2cef; border-color: #6d7b7b; }
.xyzac-machine-controller button { display: grid; place-items: center; width: 30px; height: 29px; font-size: 18px; }
.xyzac-machine-controller img { width: 18px; height: 18px; object-fit: contain; }
.dro-view { background: #ececec; }
.dro-mode-row, .dro-column-headings, .dro-row { display: grid; grid-template-columns: 42px repeat(3, minmax(100px, 1fr)); align-items: center; gap: 9px; }
.dro-mode-row { padding: 5px 6px; border-bottom: 1px solid #888; color: #555; font-size: 11px; }
@@ -250,7 +257,8 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.xyzac-control-band { grid-template-columns: 1fr; }
.axis-top-pane { grid-template-columns: 1fr; height: auto; min-height: 0; flex: 0 0 auto; }
.axis-notebook { min-height: 330px; }
.axis-preview { min-height: 320px; }
.axis-preview { min-height: 732px; }
.axis-preview-split { grid-template-columns: 1fr; grid-template-rows: repeat(2, 360px); align-self: start; }
.axis-program-pane { flex: 0 0 auto; height: 740px; min-height: 740px; grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) 140px; }
.program-main-layout { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); grid-column: 1; grid-row: 1; overflow: hidden; }
.program-editor-wrap { grid-column: 1; grid-row: 1; }

View File

@@ -0,0 +1,21 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
const app = await readFile(new URL("./App.tsx", import.meta.url), "utf8");
const tool = await readFile(new URL("./XyzacToolPreview.tsx", import.meta.url), "utf8");
const machine = await readFile(new URL("./XyzacMachineView.tsx", import.meta.url), "utf8");
const css = await readFile(new URL("./styles.css", import.meta.url), "utf8");
assert.match(app, /axis-preview-split/);
assert.match(app, /XyzacToolPreviewFromSnapshot snapshot=\{state\.snapshot\}/);
assert.match(app, /XyzacMachineViewFromSnapshot snapshot=\{state\.snapshot\}/);
assert.match(app, /showPath=\{false\} showLive=\{false\}/);
assert.match(tool, /new THREE\.WebGLRenderer/);
assert.match(tool, /data-xyzac-preview="toolpath"/);
assert.match(machine, /new THREE\.WebGLRenderer/);
assert.match(machine, /data-xyzac-preview="machine"/);
assert.match(machine, /data-xyzac-model-source-hash/);
for (const token of ["OrbitControls", "enableRotate", "mouseButtons.LEFT = THREE.MOUSE.ROTATE", "mouseButtons.MIDDLE = THREE.MOUSE.DOLLY", "mouseButtons.RIGHT = THREE.MOUSE.PAN", "touches.ONE = THREE.TOUCH.ROTATE", "touches.TWO = THREE.TOUCH.DOLLY_PAN", "xyzacMachineController", "zoomIn", "zoomOut", "panLeft", "panRight", "panUp", "panDown", "rotateLeft", "rotateRight", "rotateUp", "rotateDown", "Reset machine view"]) assert.match(machine, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.match(css, /\.axis-preview-split\s*\{/);
console.log(JSON.stringify({ schema: "xyzac-dual-canvas-contract.v1", status: "verified", shared_snapshot: true, independent_renderers: 2, machine_path_layers: "disabled" }));

View File

@@ -1,8 +1,10 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { projectXyzacVisual, XYZAC_MACHINE_GROUPS } from "./xyzac-visual-adapter.ts";
import * as THREE from "three";
import { buildXyzacMachine } from "./xyzac-machine-model.ts";
const source = await readFile(new URL("./XyzacMachineView.tsx", import.meta.url), "utf8");
const source = await readFile(new URL("./XyzacMachineView.tsx", import.meta.url), "utf8") + await readFile(new URL("./xyzac-machine-model.ts", import.meta.url), "utf8");
const adapter = await readFile(new URL("./xyzac-visual-adapter.ts", import.meta.url), "utf8");
const snapshot = {
schema: "axis-panel-snapshot.v1",
@@ -25,7 +27,7 @@ const snapshot = {
state_hash: "fnv1a64:kinematics",
wasm_state_hash: "fnv1a64:wasm-kinematics",
},
visual: { active_segment_id: 7, active_progress: 0.5, pose: { x: 12, y: -5, z: 20, a: 30, c: 45 }, joints: [12, -5, 20, 30, 45], toolpath: [{ id: 7, active: true, start: { x: 0, y: 0, z: 0 }, end: { x: 12, y: -5, z: 20 }, motion_type: 1 }] },
visual: { active_segment_id: 7, active_progress: 0.5, pose: { x: 12, y: -5, z: 20, a: 30, c: 45 }, joints: [12, -5, 20, 30, 45], toolpath: [{ id: 7, active: true, start: { x: 0, y: 0, z: 0 }, end: { x: 12, y: -5, z: 20 }, motion_type: 1 }], machine_model: { schema: "linuxcnc-vismach-model.v1", id: "xyzac-trt", source: "linuxcnc-wasm-worker", source_path: "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py", source_hash: "fnv1a64:model", coordinate_system: "XYZAC", joints: ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c"], components: ["base", "column"] } },
};
const visual = projectXyzacVisual(snapshot);
assert.equal(visual.schema, "xyzac-visual-state.v1");
@@ -37,8 +39,48 @@ assert.deepEqual(visual.offsets, snapshot.kinematics.offsets);
assert.equal(visual.activeSegmentId, 7);
assert.equal(visual.activeProgress, 0.5);
assert.equal(visual.source, "linuxcnc-wasm-worker");
assert.equal(visual.machineModel?.id, "xyzac-trt");
assert.equal(visual.machineModel?.source_hash, "fnv1a64:model");
assert.deepEqual(XYZAC_MACHINE_GROUPS, ["xyzac-machine", "table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c", "tool-offset", "xyzac-toolpath", "xyzac-live-toolpath"]);
for (const token of ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c", "tool-offset", "tableX.position.x = -tableXJoint * MM", "saddle.position.z = -saddleYJoint * MM", "zslide.position.y = (200 - spindleZJoint) * MM", "tilt.rotation.x = -tiltAJoint * DEG", "rotate.rotation.y = -rotateCJoint * DEG", "tool.position.y = -state.offsets.tool_offset * MM", "data-xyzac-tick", "data-xyzac-state-hash", "data-xyzac-active-segment", "data-xyzac-joints", "data-xyzac-offsets"]) assert.ok(source.includes(token), token);
for (const forbidden of [/accept\s*\(/, /postMessage\s*\(/, /_sim_/, /_malloc/, /navigator\.storage/, /HAL_SET/, /KINEMATICS_SELECT/]) assert.equal(forbidden.test(source), false, String(forbidden));
assert.ok(adapter.includes("source: \"linuxcnc-wasm-worker\""));
const model = buildXyzacMachine();
const original = structuredClone(visual);
const zero = { joints: [0, 0, 0, 0, 0], offsets: { x_rot_point: 0, y_offset: 20, z_offset: 10, tool_offset: 0 } };
const close = (actual, expected) => assert.ok(Math.abs(actual - expected) < 1e-7, `${actual} != ${expected}`);
const position = (node) => node.getWorldPosition(new THREE.Vector3());
model.update(zero);
close(position(model.toolTip).y, 2);
close(position(model.workOrigin).y, 2);
for (const length of [0, 15, 30, -5]) {
model.update({ ...zero, offsets: { ...zero.offsets, tool_offset: length } });
close(position(model.toolTip).y, (200 - length) * .01);
const cutter = model.machine.getObjectByName("four-flute-end-mill");
const cutterBounds = new THREE.Box3().setFromObject(cutter);
close(cutterBounds.min.y, position(model.toolTip).y);
close(cutterBounds.max.x - cutterBounds.min.x, .06);
close(position(model.machine.getObjectByName("collet-nut")).y, 2.095);
}
model.update({ ...zero, joints: [12, -5, 20, 0, 0] });
close(position(model.workOrigin).x, -.12);
close(position(model.workOrigin).y, 1.8);
close(position(model.workOrigin).z, .05);
model.update({ ...zero, joints: [0, 0, 0, 90, 45] });
close(position(model.workOrigin).y, 1.9);
close(position(model.workOrigin).z, .3);
assert.equal(model.joints.rotate.parent.name, "c-rotary-base");
assert.equal(model.machine.getObjectByName("c-drive-housing").parent, model.joints.rotate.parent);
model.update(visual);
assert.deepEqual(visual, original, "rendering must not change the WASM snapshot");
const geometrySet = new Set();
model.machine.traverse((node) => { if (node.isMesh) geometrySet.add(node.geometry); });
for (let tick = 0; tick < 100; tick++) model.update(visual);
const afterGeometrySet = new Set();
model.machine.traverse((node) => { if (node.isMesh) afterGeometrySet.add(node.geometry); });
assert.deepEqual(afterGeometrySet, geometrySet, "motion must reuse model geometry");
const disposed = new Set();
geometrySet.forEach((geometry) => geometry.addEventListener("dispose", () => disposed.add(geometry)));
model.dispose();
assert.equal(disposed.size, geometrySet.size);
console.log(JSON.stringify({ schema: "xyzac-threejs-rtcp-10.v1", status: "verified", tick: visual.tick, state_hash: visual.stateHash, groups: XYZAC_MACHINE_GROUPS, active_segment: visual.activeSegmentId, direction: { table_x: -1, saddle_y: -1, spindle_z: -1, tilt_a: "x+", rotate_c: "z+", tool_offset: "z-" }, clear_visual_nonmutating: true, source: visual.source }));

View File

@@ -0,0 +1,234 @@
import * as THREE from "three";
import { RoundedBoxGeometry } from "three/examples/jsm/geometries/RoundedBoxGeometry.js";
import type { XyzacVisualState } from "./xyzac-visual-adapter";
const MM = .01;
const DEG = Math.PI / 180;
// Source coordinates remain millimetres, Z up. The existing viewer uses Y up.
export function buildXyzacMachine() {
const geometries = new Map<string, THREE.BufferGeometry>();
const materials = {
enamel: new THREE.MeshStandardMaterial({ color: 0xd9e1e1, roughness: .32, metalness: .25 }),
teal: new THREE.MeshStandardMaterial({ color: 0x167f79, roughness: .3, metalness: .3 }),
dark: new THREE.MeshStandardMaterial({ color: 0x303638, roughness: .42, metalness: .35 }),
steel: new THREE.MeshStandardMaterial({ color: 0xb2bdc4, roughness: .27, metalness: .85 }),
polished: new THREE.MeshStandardMaterial({ color: 0xe2e8ec, roughness: .19, metalness: .9 }),
recess: new THREE.MeshStandardMaterial({ color: 0x111718, roughness: .66, metalness: .2 }),
rubber: new THREE.MeshStandardMaterial({ color: 0x222729, roughness: .88 }),
carbide: new THREE.MeshStandardMaterial({ color: 0xb9aa79, roughness: .29, metalness: .8 }),
warning: new THREE.MeshStandardMaterial({ color: 0xe7bd48, roughness: .48, metalness: .12 }),
lamp: new THREE.MeshStandardMaterial({ color: 0xe2f8ff, emissive: 0xd4f2ff, emissiveIntensity: 1.5 }),
};
type Finish = keyof typeof materials;
const geometry = (key: string, create: () => THREE.BufferGeometry) => {
if (!geometries.has(key)) geometries.set(key, create());
return geometries.get(key)!;
};
const group = (parent: THREE.Object3D | null, name: string, x = 0, y = 0, z = 0) => {
const node = new THREE.Group(); node.name = name; node.position.set(x * MM, z * MM, y * MM);
parent?.add(node); return node;
};
const mesh = (parent: THREE.Object3D, name: string, shape: THREE.BufferGeometry, finish: Finish, x: number, y: number, z: number) => {
const node = new THREE.Mesh(shape, materials[finish]); node.name = name;
node.position.set(x * MM, z * MM, y * MM); node.castShadow = true; node.receiveShadow = true;
parent.add(node); return node;
};
const box = (parent: THREE.Object3D, name: string, x: number, y: number, z: number, w: number, d: number, h: number, finish: Finish, bevel = 1) =>
mesh(parent, name, geometry(`box:${w}:${d}:${h}:${bevel}`, () => bevel
? new RoundedBoxGeometry(w * MM, h * MM, d * MM, 2, Math.min(bevel, w / 3, d / 3, h / 3) * MM)
: new THREE.BoxGeometry(w * MM, h * MM, d * MM)), finish, x, y, z);
const cylinder = (parent: THREE.Object3D, name: string, x: number, y: number, z: number, r: number, h: number, finish: Finish, axis: "x" | "y" | "z" = "z", sides = 48) => {
const node = mesh(parent, name, geometry(`cylinder:${r}:${h}:${sides}`, () => new THREE.CylinderGeometry(r * MM, r * MM, h * MM, sides)), finish, x, y, z);
if (axis === "x") node.rotation.z = Math.PI / 2;
if (axis === "y") node.rotation.x = Math.PI / 2;
return node;
};
const bolt = (parent: THREE.Object3D, x: number, y: number, z: number, axis: "x" | "y" | "z" = "z") => {
cylinder(parent, "socket-head-bolt", x, y, z, 2.3, 1.5, "polished", axis, 12);
cylinder(parent, "hex-socket", x + (axis === "x" ? .8 : 0), y - (axis === "y" ? .8 : 0), z + (axis === "z" ? .8 : 0), 1.05, .16, "recess", axis, 6);
};
const lathe = (parent: THREE.Object3D, name: string, profile: number[][], finish: Finish) =>
mesh(parent, name, geometry(name, () => new THREE.LatheGeometry(profile.map(([r, z]) => new THREE.Vector2(r * MM, z * MM)), 64)), finish, 0, 0, 0);
const machine = group(null, "xyzac-machine");
for (const x of [-95, 95]) for (const y of [-72, 135]) {
cylinder(machine, "levelling-pad", x, y, -259, 17, 9, "rubber");
cylinder(machine, "levelling-stud", x, y, -249, 8, 15, "steel");
}
box(machine, "base", 0, 30, -178, 240, 260, 146, "enamel", 9);
box(machine, "base-plinth", 0, 30, -239, 242, 262, 20, "dark", 4);
box(machine, "base-front-panel", 0, -101, -177, 205, 4, 96, "teal", 4);
box(machine, "service-door", 68, -104, -177, 48, 2, 71, "dark", 2);
box(machine, "door-handle", 53, -108, -177, 3, 6, 23, "steel");
for (let i = 0; i < 6; i++) box(machine, "cabinet-vent", -55, -104, -192 + i * 7, 63, 1, 2, "recess", .2);
for (const x of [-91, 91]) for (const z of [-217, -135]) bolt(machine, x, -105, z, "y");
box(machine, "chip-tray", 0, 18, -101, 252, 244, 9, "steel", 3);
for (const x of [-123, 123]) box(machine, "tray-side-lip", x, 18, -93, 5, 244, 16, "dark");
box(machine, "tray-front-lip", 0, -102, -93, 246, 5, 16, "dark");
box(machine, "column", 0, 150, 5, 100, 100, 510, "enamel", 5);
for (const x of [-51, 51]) {
box(machine, "column-side-insert", x, 155, 20, 3, 62, 413, "teal");
box(machine, "column-rib", x, 190, -120, 10, 16, 244, "dark", 2);
}
for (const x of [-33, 33]) {
box(machine, "z-linear-rail", x, 98, 2, 11, 7, 365, "steel");
for (let z = -155; z < 185; z += 45) bolt(machine, x, 93, z, "y");
}
cylinder(machine, "z-leadscrew", 0, 87, -25, 6, 330, "polished");
for (let z = -185; z < 141; z += 7) cylinder(machine, "leadscrew-thread", 0, 87, z, 6.6, 1.5, "steel");
box(machine, "z-servo", 0, -105, -169, 49, 21, 50, "dark", 3);
box(machine, "z-servo-cover", 0, -117, -169, 40, 3, 38, "teal", 2);
const head = group(machine, "head", 0, 0, 200);
// Native head spans Y=-30..240; this bridge physically connects the spindle to the column.
box(head, "head-body", 0, 105, 97.5, 70, 270, 75, "enamel", 5);
box(head, "head-top-cover", 0, 107, 137, 72, 269, 9, "teal", 3);
box(head, "spindle-front-panel", 0, -31, 96, 57, 3, 54, "teal", 2);
for (const x of [-23, 23]) for (const z of [75, 119]) bolt(head, x, -34, z, "y");
for (let i = 0; i < 6; i++) box(head, "head-cooling-vent", 36, 70 + i * 11, 103, 1, 6, 29, "recess", .2);
cylinder(head, "motor", 0, 200, 168, 29, 61, "dark");
cylinder(head, "motor-cap", 0, 200, 200, 30, 7, "teal");
for (let i = 0; i < 16; i++) {
const angle = i * Math.PI / 8;
const fin = box(head, "motor-cooling-fin", 30 * Math.cos(angle), 200 + 30 * Math.sin(angle), 170, 3, 4, 48, "steel", .5);
fin.rotation.y = -angle;
}
box(head, "motor-terminal-box", 32, 200, 168, 16, 32, 24, "dark", 2);
const spindle = group(head, "spindle", 0, 0, 20);
lathe(spindle, "spindle-housing", [[0,20],[20,20],[22,23],[22,38],[20,40],[20,115],[0,115]], "steel");
lathe(spindle, "spindle-nose", [[0,0],[10,0],[15,5],[16,17],[20,20],[0,20]], "polished");
cylinder(spindle, "spindle-seal", 0, 0, 3, 11.5, 2, "dark");
for (const x of [-26, 26]) {
const nozzle = cylinder(head, "coolant-nozzle", x, 0, 45, 2.6, 33, "teal");
nozzle.rotation.z = x < 0 ? .4 : -.4;
const tip = cylinder(head, "coolant-nozzle-tip", x - Math.sign(x) * 18 * Math.sin(.4), 0, 45 - 18 * Math.cos(.4), 2.2, 5, "steel");
tip.rotation.z = nozzle.rotation.z;
}
box(head, "work-light", 0, 50, 59, 40, 9, 3, "lamp");
// Gauge face Z=220, source tooltip Z=200-tool_offset. Keep the cutter tip at that datum.
lathe(head, "collet-holder", [[0,8],[5.5,8],[6.5,9],[6.5,14],[8,16],[8,20],[0,20]], "dark");
cylinder(head, "collet-nut", 0, 0, 9.5, 6.9, 5, "steel", "z", 12);
const tool = group(head, "tool-offset");
const toolTip = group(tool, "tool-tip");
const flutes = mesh(tool, "four-flute-end-mill", createFlutedCutter(), "carbide", 0, 0, 0);
geometries.set("flutes", flutes.geometry);
const shank = cylinder(tool, "ground-tool-shank", 0, 0, 8, 3, 1, "polished");
const zslide = group(machine, "spindle-z");
box(zslide, "knee-casting", 0, 10, -142.5, 100, 220, 75, "enamel", 5);
box(zslide, "knee-front-cover", 0, -102, -143, 85, 5, 56, "teal", 3);
for (const x of [-34, 34]) bolt(zslide, x, -106, -144, "y");
for (const x of [-33, 33]) box(zslide, "y-guideway", x, 8, -103, 14, 181, 6, "steel");
const saddle = group(zslide, "saddle-y");
box(saddle, "saddle-body", 0, 0, -89, 150, 106, 32, "dark", 3);
for (const y of [-32, 32]) box(saddle, "x-guideway", 0, y, -73, 148, 12, 5, "polished");
const tableX = group(saddle, "table-x");
box(tableX, "ways", 0, 0, -72, 300, 80, 6, "dark");
box(tableX, "table-bed", 0, 0, -60.5, 300, 100, 17, "steel", 1.5);
for (const y of [-36, -12, 12, 36]) box(tableX, "table-t-slot", 0, y, -51.94, 297, 3.2, .35, "recess", .1);
box(tableX, "table-front-scale", 0, -50.6, -59, 276, 1, 8, "dark", .1);
for (let x = -130; x <= 130; x += 10) box(tableX, "linear-scale-mark", x, -51.2, -59, .5, .2, x % 50 === 0 ? 6 : 3, "polished", 0);
const brackets = group(tableX, "brackets");
box(brackets, "trunnion-mount", 0, 0, -46, 154, 80, 12, "dark", 2);
for (const x of [-72, 72]) {
box(brackets, "trunnion-upright", x, 0, -23, 12, 68, 47, "enamel", 3);
cylinder(brackets, "bearing-housing", x, 0, 0, 19, 16, "enamel", "x");
cylinder(brackets, "bearing-seal", x < 0 ? -82 : 82, 0, 0, 12, 3, "dark", "x");
for (const y of [-27, 27]) bolt(brackets, x, y, -38);
}
box(brackets, "a-axis-servo", -94, 0, 0, 23, 48, 46, "teal", 4);
cylinder(brackets, "a-axis-end-cap", -107, 0, 0, 16, 3, "dark", "x");
const tilt = group(tableX, "tilt-a");
box(tilt, "yoke-plate", 0, 0, -30, 130, 80, 10, "teal", 2);
for (const x of [-60, 60]) {
box(tilt, "yoke-side-plate", x, 0, -15, 10, 80, 34, "teal", 2);
cylinder(tilt, "a-trunnion-shaft", x, 0, 0, 10, 22, "steel", "x");
}
const rotaryBase = group(tilt, "c-rotary-base");
box(rotaryBase, "c-drive-housing", 0, 0, -24, 104, 100, 12, "dark", 3);
cylinder(rotaryBase, "c-bearing-ring", 0, 0, -17, 49, 5, "teal");
const rotate = group(rotaryBase, "rotate-c");
cylinder(rotate, "table", 0, 0, -9, 50, 18, "steel");
cylinder(rotate, "rotary-face", 0, 0, -.7, 48.7, 1.4, "polished");
cylinder(rotate, "centre-bore", 0, 0, .1, 5, .25, "recess");
for (let i = 0; i < 8; i++) {
const angle = i * Math.PI / 4;
cylinder(rotate, "fixture-hole", 35 * Math.cos(angle), 35 * Math.sin(angle), .1, 2, .3, "recess");
}
for (let i = 0; i < 4; i++) {
const slot = box(rotate, "radial-t-slot", 0, 0, .14, 3, 42, .3, "recess", .1);
slot.position.x = Math.sin(i * Math.PI / 2) * .28;
slot.position.z = Math.cos(i * Math.PI / 2) * .28;
slot.rotation.y = i * Math.PI / 2;
}
for (let i = 0; i < 72; i++) {
const a = i * Math.PI / 36;
const mark = box(rotate, "rotary-scale-mark", 49.7 * Math.sin(a), 49.7 * Math.cos(a), -6, .55, .3, i % 6 === 0 ? 5 : 2.5, "recess", 0);
mark.rotation.y = a;
}
box(rotate, "c-index-marker", 48, 0, 1, 5, 6, 2, "warning", .4);
const workOrigin = group(rotate, "work-origin");
let toolLength = NaN;
const update = (state: Pick<XyzacVisualState, "joints" | "offsets">) => {
const [tableXJoint, saddleYJoint, spindleZJoint, tiltAJoint, rotateCJoint] = state.joints;
tableX.position.x = -tableXJoint * MM;
saddle.position.z = -saddleYJoint * MM;
zslide.position.y = (200 - spindleZJoint) * MM;
tilt.rotation.x = -tiltAJoint * DEG;
rotate.rotation.y = -rotateCJoint * DEG;
tool.position.y = -state.offsets.tool_offset * MM;
tilt.position.set(0, state.offsets.z_offset * MM, state.offsets.y_offset * MM);
brackets.position.copy(tilt.position);
rotaryBase.position.copy(tilt.position).negate();
if (toolLength !== state.offsets.tool_offset) {
toolLength = state.offsets.tool_offset;
const reach = Math.max(.5, 10 + toolLength);
const cuttingLength = Math.min(18, reach * .75);
flutes.scale.y = cuttingLength / 8;
shank.scale.y = Math.max(.2, reach - cuttingLength);
shank.position.y = (cuttingLength + (reach - cuttingLength) / 2) * MM;
}
};
const dispose = () => {
for (const value of geometries.values()) value.dispose();
for (const value of Object.values(materials)) value.dispose();
};
return { machine, update, dispose, toolTip, workOrigin, joints: { tableX, saddle, zslide, tilt, rotate }, tool };
}
// Four helical reliefs with narrow cutting lands, closed end faces, and a flat cutter tip.
function createFlutedCutter() {
const radial = 96, axial = 32;
const positions: number[] = [], indices: number[] = [];
for (let row = 0; row <= axial; row++) {
const t = row / axial;
for (let col = 0; col <= radial; col++) {
const angle = col / radial * Math.PI * 2;
const phase = (angle * 4 - t * Math.PI * 2 + Math.PI * 4) % (Math.PI * 2);
const relief = Math.pow(Math.max(0, Math.sin(phase / 2)), 2);
const radius = (3 - 1.35 * relief) * .01;
positions.push(radius * Math.cos(angle), t * .08, radius * Math.sin(angle));
if (row < axial && col < radial) {
const a = row * (radial + 1) + col, b = a + radial + 1;
indices.push(a, b, a + 1, a + 1, b, b + 1);
}
}
}
const bottom = positions.length / 3; positions.push(0, 0, 0);
const top = positions.length / 3; positions.push(0, .08, 0);
for (let col = 0; col < radial; col++) {
indices.push(bottom, col, col + 1);
const a = axial * (radial + 1) + col;
indices.push(top, a + 1, a);
}
const result = new THREE.BufferGeometry();
result.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
result.setIndex(indices); result.computeVertexNormals(); return result;
}

View File

@@ -1,4 +1,4 @@
import type { XyzacKinematicsSnapshot } from "./protocol";
import type { XyzacKinematicsSnapshot, XyzacMachineModel } from "./protocol";
export type XyzacVisualState = {
schema: "xyzac-visual-state.v1";
@@ -18,6 +18,7 @@ export type XyzacVisualState = {
velocityMmMin?: number;
activeSegmentId: number | null;
toolpath: Array<Record<string, unknown>>;
machineModel: XyzacMachineModel | null;
source: "linuxcnc-wasm-worker";
};
@@ -29,7 +30,7 @@ export type XyzacVisualSource = {
state_hash?: string;
kinematics?: XyzacKinematicsSnapshot;
motion?: { velocity_mm_min?: number };
visual?: { active_segment_id?: number | null; active_progress?: number; pose_epoch?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record<string, number>; joints?: number[]; toolpath?: Array<Record<string, unknown>> };
visual?: { active_segment_id?: number | null; active_progress?: number; pose_epoch?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record<string, number>; joints?: number[]; toolpath?: Array<Record<string, unknown>>; machine_model?: XyzacMachineModel };
};
export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefined): XyzacVisualState {
@@ -63,6 +64,7 @@ export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefine
velocityMmMin: (snapshot?.visual as { velocity_mm_min?: number })?.velocity_mm_min ?? snapshot?.motion?.velocity_mm_min,
activeSegmentId: snapshot?.visual?.active_segment_id ?? null,
toolpath: snapshot?.visual?.toolpath ?? [],
machineModel: snapshot?.visual?.machine_model ?? null,
source: "linuxcnc-wasm-worker",
};
}

View File

@@ -39,6 +39,7 @@ function linuxcncAssets(): Plugin {
["linuxcnc/linuxcnc.gif", "linuxcnc/linuxcnc.gif"],
["linuxcnc/share/axis/images", "linuxcnc/share/axis/images"],
["linuxcnc/lib/hallib", "linuxcnc/lib/hallib"],
["linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py", "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py"],
["linuxcnc/configs/sim/qtvcp_screens/qtaxis_lathe/qtaxis/images", "linuxcnc/configs/sim/qtvcp_screens/qtaxis_lathe/qtaxis/images"],
];
for (const [from, to] of copies) cpSync(resolve(repoRoot, from), resolve(output, to), { recursive: true, dereference: false });

View File

@@ -168,6 +168,7 @@ export class SimSession {
this.previewState = null;
this.livePathEpoch = 0;
this.workspaceFiles = {};
this.visualModelSourceHash = null;
call(core, "sim_protocol_reset");
if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []);
}
@@ -460,6 +461,23 @@ export class SimSession {
? (Array.isArray(wasmMotion.state.joint_commanded) && wasmMotion.state.joint_commanded.length === 5 ? [...wasmMotion.state.joint_commanded] : inverseJoints?.length === 5 ? [...inverseJoints] : [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0])
: authoritative.kinematics.joints;
const preview = this.previewState?.status === "verified" ? this.previewState : null;
// Keep the Vismach model identity in the same authoritative visual
// envelope as the canonical toolpath and live motion trace. The source
// hash comes from the exact resource resolved during WASM startup, so a
// renderer can audit that its geometry matches the loaded LinuxCNC case.
const modelResource = this.configurationState?.exact_resources?.find?.((row) => row.role === "vismach_source");
const modelSourcePath = "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py";
const modelSourceText = this.workspaceFiles?.[modelSourcePath];
const machineModel = this.xyzacProgram ? {
schema: "linuxcnc-vismach-model.v1",
id: "xyzac-trt",
source: "linuxcnc-wasm-worker",
source_path: modelResource?.path ?? modelSourcePath,
source_hash: modelResource?.data_hash ?? this.visualModelSourceHash ?? (typeof modelSourceText === "string" ? fnv1a64(modelSourceText) : null),
coordinate_system: "XYZAC",
joints: ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c"],
components: ["base", "column", "head", "spindle", "tool", "table", "saddle", "zslide"],
} : null;
const visual = {
schema: "axis-visual.v1",
pose: { ...position },
@@ -477,6 +495,7 @@ export class SimSession {
velocity_mm_min: motion.velocity_mm_min ?? 0,
machine_limits: this.wasmMotion.axisLimits?.() ?? null,
coordinate_offset: program?.coordinate_context?.offsets?.[(program?.coordinate_context?.system ?? 1) - 1]?.slice(0, 3) ?? null,
...(machineModel ? { machine_model: machineModel } : {}),
};
const axisCoordinates = [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0];
const coordinateContext = program?.coordinate_context;
@@ -1567,6 +1586,8 @@ export class SimSession {
const configuration = input.payload?.configuration ?? null;
if (configuration) {
this.workspaceFiles = { ...(configuration.files ?? configuration.workspace ?? configuration.workspace_files ?? {}) };
const visualSourcePath = "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py";
this.visualModelSourceHash = typeof this.workspaceFiles[visualSourcePath] === "string" ? fnv1a64(this.workspaceFiles[visualSourcePath]) : null;
const startupWorkspace = configuration.workspace ?? configuration.files ?? configuration.workspace_files;
if (startupWorkspace) {
const startup = new ConfigurationStartupStateMachine({

View File

@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { CONTEXT_ID, SimSession } from "./sim-session.mjs";
import { fnv1a64 } from "./replay.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
assert.equal(bundle.status, "ready");
const sourcePath = "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py";
const sourceHash = fnv1a64(bundle.files[sourcePath]);
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "xyzac-visual-data-bridge" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${kind.toLowerCase()}-${seq}`, payload });
send("HELLO");
send("SESSION_ATTACH");
const loaded = send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.payload.status, "verified");
const panel = loaded.payload.panel_snapshot;
assert.equal(panel.visual.source, "wasm_canonical_motion");
assert.equal(panel.visual.machine_model.schema, "linuxcnc-vismach-model.v1");
assert.equal(panel.visual.machine_model.source, "linuxcnc-wasm-worker");
assert.equal(panel.visual.machine_model.source_path, sourcePath);
assert.equal(panel.visual.machine_model.source_hash, sourceHash);
assert.deepEqual(panel.visual.machine_model.joints, ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c"]);
assert.equal(panel.visual.machine_model.coordinate_system, "XYZAC");
assert.ok(panel.visual.toolpath === undefined || Array.isArray(panel.visual.toolpath), "toolpath is optional on compact startup snapshots");
const loadedProgram = send("PROGRAM_LOAD", {
source: "G21\nG90\nG1 X1 Y2 Z3 A4 C5 F100\nM2\n",
path: "bridge.ngc",
});
const visual = loadedProgram.payload.panel_snapshot.visual;
assert.equal(visual.machine_model.source_hash, sourceHash);
assert.ok(Array.isArray(visual.toolpath) && visual.toolpath.length > 0);
assert.equal(visual.source, "wasm_canonical_motion");
assert.match(loadedProgram.payload.panel_snapshot.state_hash, /^fnv1a64:/);
console.log(JSON.stringify({
schema: "xyzac-visual-data-bridge.v1",
status: "verified",
source: sourcePath,
source_hash: sourceHash,
toolpath_segments: visual.toolpath.length,
shared_snapshot_fields: ["visual.machine_model", "visual.toolpath", "visual.motion_trace", "visual.pose", "visual.joints"],
}));

View File

@@ -214,6 +214,18 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
queue.push(candidate);
}
}
// The XYZAC Vismach executable is the authoritative geometry contract for
// the Web renderer. It is not referenced as a normal INI resource, so
// resolve it explicitly and carry it through the Worker configuration for
// source hashing and model provenance.
if (catalog.path === "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini") {
const visualSource = "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py";
if (!(visualSource in files)) {
const content = await fetchText(fetchImpl, baseUrl, visualSource);
if (content === null) missing.push(visualSource);
else { files[visualSource] = content; fetched.push(visualSource); }
}
}
const fixture = RUNTIME_FIXTURES[catalog.path];
if (fixture) {
if (!inManifest(fixture.generated_hal)) missing.push(fixture.generated_hal);