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 { axisIcon } from "./sourceAssets"; import { buildXyzacMachine, buildXyzbcMachine } from "./xyzac-machine-model"; import { projectXyzacVisual, type XyzacVisualSource, type XyzacVisualState } from "./xyzac-visual-adapter"; type Point = Record; 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 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({ visual, toolpath, view = "p", zoom = 1, rotateMode = false, showPath = false, showLive = false, showLimits = false, showVelocity = false, showOffsets = false, showExtents = false }: Props) { const host = useRef(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 renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true }); renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, VIEWER.pixel_ratio_limit)); 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 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 = coordinates === "XYZBC" ? buildXyzbcMachine() : 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(`${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; 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); 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); 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(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; } 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(); }; }, []); const model = visual.machineModel; const coordinates = model?.coordinate_system ?? "XYZAC"; const controllerButton = (action: string, label: string, asset?: string, glyph?: string) => ; return
{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")}
; } export function XyzacMachineViewFromSnapshot({ snapshot, ...props }: Omit & { 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 ; }