224 lines
17 KiB
TypeScript
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} />;
|
|
}
|