Files
linuxcncWebWASM/web/react/src/XyzacMachineView.tsx

224 lines
17 KiB
TypeScript

import { useEffect, useRef } from "react";
import { WEB_SETTINGS } from "../../workspace/web-settings.mjs";
const VIEWER = WEB_SETTINGS.viewer;
const CAMERA = VIEWER.camera;
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
import { buildImportedMachine, type ImportedMachine } from "./imported-machine";
import { axisIcon } from "./sourceAssets";
import { buildXyzacMachine, buildXyzbcMachine } 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; samples?: Point[]; start?: Point; end?: Point };
type Props = { importedMachine?: ImportedMachine | null; 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) * VIEWER.mm_scale, Number(point.z ?? 0) * VIEWER.mm_scale, Number(point.y ?? 0) * VIEWER.mm_scale);
export function XyzacMachineView({ importedMachine, 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 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 managedJoints = Boolean(importedMachine?.scene.userData.linuxcncJointModel);
const renderer = new THREE.WebGLRenderer({ antialias: !managedJoints, alpha: false, preserveDrawingBuffer: !managedJoints });
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, VIEWER.pixel_ratio_limit));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
renderer.shadowMap.enabled = !managedJoints;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.domElement.dataset.engine = "three.js r185 / xyzac-trt-vismach";
element.appendChild(renderer.domElement);
const scene = new THREE.Scene(); scene.background = new THREE.Color(VIEWER.colors.background);
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 coordinates = visualRef.current.machineModel?.coordinate_system ?? "XYZAC";
const assembly = importedMachine ? buildImportedMachine(importedMachine) : coordinates === "XYZBC" ? buildXyzbcMachine() : buildXyzacMachine();
const pathPoint = importedMachine ? (point: Point) => new THREE.Vector3(Number(point.x ?? 0), Number(point.y ?? 0), Number(point.z ?? 0)).multiplyScalar(.001) : toView;
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(`${coordinates} / 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; if (!importedMachine) assembly.machine.add(nameplate);
const camera = new THREE.PerspectiveCamera(CAMERA.fov, 1, CAMERA.near, CAMERA.far);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; controls.dampingFactor = CAMERA.damping;
controls.enableRotate = true;
controls.screenSpacePanning = true; controls.minDistance = CAMERA.min_distance; controls.maxDistance = CAMERA.max_distance;
// 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);
if (importedMachine) floor.position.y = initialBounds.min.y - .03;
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 * CAMERA.fit_margin / 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) * CAMERA.pan_fraction;
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(CAMERA.zoom_in), zoomOut: () => zoomCamera(CAMERA.zoom_out),
panLeft: () => pan(-1, 0), panRight: () => pan(1, 0), panUp: () => pan(0, 1), panDown: () => pan(0, -1),
rotateLeft: () => { controls.rotateLeft(THREE.MathUtils.degToRad(CAMERA.rotate_degrees)); controls.update(); },
rotateRight: () => { controls.rotateLeft(-THREE.MathUtils.degToRad(CAMERA.rotate_degrees)); controls.update(); },
rotateUp: () => { controls.rotateUp(THREE.MathUtils.degToRad(CAMERA.rotate_degrees)); controls.update(); },
rotateDown: () => { controls.rotateUp(-THREE.MathUtils.degToRad(CAMERA.rotate_degrees)); 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();
},
};
// The programmed path represents the part fixed on the rotary cradle.
// Keep it in the table's local frame so A/B tilt and C rotation carry the
// workpiece path together with the physical table.
const pathGroup = new THREE.Group(); pathGroup.name = "xyzac-toolpath"; assembly.joints.rotate.add(pathGroup);
const liveGroup = new THREE.Group(); liveGroup.name = "xyzac-live-toolpath"; assembly.joints.rotate.add(liveGroup);
const pathMaterials = [VIEWER.colors.path, VIEWER.colors.live, VIEWER.colors.rapid].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(), VIEWER.colors.limits); scene.add(limits);
const offsets = new THREE.AxesHelper(.8); scene.add(offsets);
const extents = new THREE.Box3Helper(initialBounds, VIEWER.colors.extents); scene.add(extents);
const render = () => {
const state = visualRef.current, config = settings.current;
assembly.update(state);
if (assembly.machine.userData.linuxcncJointState) {
element.dataset.machineJointState = JSON.stringify(assembly.machine.userData.linuxcncJointState);
element.dataset.machineJointTick = String(state.tick);
element.dataset.machineJointInputs = JSON.stringify(state.joints);
}
const paths = (config.toolpath ?? state.toolpath) as Segment[];
pathGroup.position.copy(assembly.workOrigin.position);
liveGroup.position.copy(assembly.workOrigin.position);
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(pathPoint) : [pathPoint(raw.start ?? {}), pathPoint(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;
}
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;
const coordinates = state.machineModel?.coordinate_system ?? "XYZAC";
offsets.position.set(coordinates === "XYZBC" ? Number(state.offsets.x_offset ?? 0) * VIEWER.mm_scale : 0, state.offsets.z_offset * VIEWER.mm_scale, state.offsets.y_offset * VIEWER.mm_scale);
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.xyzacToolpathFrame = assembly.joints.rotate.name;
element.dataset.xyzacToolpathAttached = String(pathGroup.parent === assembly.joints.rotate && liveGroup.parent === assembly.joints.rotate);
element.dataset.xyzacToolTip = JSON.stringify(assembly.toolTip.getWorldPosition(new THREE.Vector3()).toArray());
frame = requestAnimationFrame(render);
};
render();
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();
};
}, [importedMachine]);
const model = visual.machineModel;
const coordinates = model?.coordinate_system ?? "XYZAC";
const managerGeometry = importedMachine?.scene.userData.linuxcncMachineGeometry;
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-machine-glb={importedMachine?.hash ?? ""} data-machine-manager-schema={managerGeometry?.schema ?? ""} data-machine-manager-geometry={managerGeometry ? JSON.stringify(managerGeometry) : ""} data-xyzac-model-source={importedMachine ? "glb-urdf/linuxcnc-wasm" : "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={importedMachine?.filename ?? model?.source_path ?? ""} data-xyzac-model-source-hash={importedMachine?.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={`${coordinates} ${importedMachine ? "URDF" : "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>
</div>;
}
export function XyzacMachineViewFromSnapshot({ snapshot, ...props }: Omit<Props, "visual"> & { snapshot: XyzacVisualSource | null | undefined }) {
const visual = projectXyzacVisual(snapshot);
const geometryKey = [visual.offsets.x_rot_point, visual.offsets.y_rot_point, visual.offsets.z_rot_point, visual.offsets.x_offset, visual.offsets.y_offset, visual.offsets.z_offset].join(":");
return <XyzacMachineView key={`${visual.machineModel?.id ?? "machine-model-pending"}:${visual.machineModel?.source_hash ?? ""}:${geometryKey}`} visual={visual} {...props} />;
}