import { useEffect, useRef, useState } from "react"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { TransformControls } from "three/examples/jsm/controls/TransformControls.js"; import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js"; import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js"; import { DraggableDialog } from "./DraggableDialog"; import { disposeMachineScene, type ImportedMachine } from "./imported-machine"; import { buildManagedMachine, cloneMachinePartScene, expectedParentRole, loadMachinePart, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type PhysicalZero } from "./machine-manager"; import type { Vec3 } from "./machine-urdf"; type Props = { coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise }; const zero3 = (): Vec3 => [0, 0, 0]; const number = (value: string) => Number.isFinite(Number(value)) ? Number(value) : 0; function ManagerPreview({ parts, selectedId, mode, onTransform }: { parts: MachinePartDraft[]; selectedId: string | null; mode: "translate" | "rotate"; onTransform: (id: string, translationMm: Vec3, rotationDeg: Vec3) => void }) { const host = useRef(null); const onTransformRef = useRef(onTransform); onTransformRef.current = onTransform; useEffect(() => { const element = host.current; if (!element) return; const renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(Math.min(devicePixelRatio || 1, 2)); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.45; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap; renderer.setSize(Math.max(element.clientWidth, 320), Math.max(element.clientHeight, 360), false); element.appendChild(renderer.domElement); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x263438); const studio = new RoomEnvironment(); const pmrem = new THREE.PMREMGenerator(renderer); const environment = pmrem.fromScene(studio, .04); scene.environment = environment.texture; scene.environmentIntensity = 1.25; studio.dispose(); pmrem.dispose(); scene.add(new THREE.HemisphereLight(0xffffff, 0x728083, 2.6)); scene.add(new THREE.AmbientLight(0xffffff, .65)); const light = new THREE.DirectionalLight(0xfff5e5, 4.2); light.position.set(3, 5, 4); light.castShadow = true; scene.add(light); const fill = new THREE.DirectionalLight(0xdcefff, 2.4); fill.position.set(-4, 2, 3); scene.add(fill); const rim = new THREE.DirectionalLight(0xffffff, 1.8); rim.position.set(2, 4, -5); scene.add(rim); const grid = new THREE.GridHelper(4, 40, 0x8eb0b2, 0x466266); grid.name = "machine-xy-ground-grid-z-up"; grid.rotation.x = Math.PI / 2; scene.add(grid); const camera = new THREE.PerspectiveCamera(42, 1, .005, 100); camera.up.set(0, 0, 1); camera.position.set(2.4, -2.8, 1.8); const orbit = new OrbitControls(camera, renderer.domElement); orbit.enableDamping = true; const root = new THREE.Group(); root.name = "machine-manager-preview"; scene.add(root); const nodes = new Map(); const disposable: THREE.Object3D[] = []; for (const part of parts) { const node = new THREE.Group(); node.name = `draft_${part.id}`; node.visible = part.visible; node.position.fromArray(part.translationMm).multiplyScalar(.001); node.rotation.set(...part.rotationDeg.map(THREE.MathUtils.degToRad) as Vec3); const visual = cloneMachinePartScene(part.scene); visual.position.fromArray(part.originMm).multiplyScalar(-.001); node.add(visual); disposable.push(visual); const axes = new THREE.AxesHelper(.12); axes.name = `origin_${part.id}`; axes.visible = part.showOrigin; node.add(axes); root.add(node); nodes.set(part.id, node); } const cycle = (child: string, parent: string) => { let cursor: string | null = parent; const seen = new Set([child]); while (cursor) { if (seen.has(cursor)) return true; seen.add(cursor); cursor = parts.find((part) => part.id === cursor)?.parentId ?? null; } return false; }; for (const part of parts) if (part.parentId && nodes.has(part.parentId) && !cycle(part.id, part.parentId)) nodes.get(part.parentId)!.add(nodes.get(part.id)!); const bounds = new THREE.Box3().setFromObject(root); const center = bounds.isEmpty() ? new THREE.Vector3() : bounds.getCenter(new THREE.Vector3()); const size = bounds.isEmpty() ? 1 : Math.max(bounds.getSize(new THREE.Vector3()).length(), .2); orbit.target.copy(center); camera.position.copy(center).add(new THREE.Vector3(size, size * .7, size * 1.15)); orbit.update(); const transform = new TransformControls(camera, renderer.domElement); transform.setMode(mode); transform.setSpace("local"); scene.add(transform.getHelper()); const selected = selectedId ? nodes.get(selectedId) : null; if (selected) transform.attach(selected); transform.addEventListener("dragging-changed", ((event: { value?: boolean }) => { orbit.enabled = !event.value; }) as never); transform.addEventListener("objectChange", (() => { if (!selectedId || !selected) return; onTransformRef.current(selectedId, selected.position.toArray().map((value) => value * 1000) as Vec3, [THREE.MathUtils.radToDeg(selected.rotation.x), THREE.MathUtils.radToDeg(selected.rotation.y), THREE.MathUtils.radToDeg(selected.rotation.z)]); }) as never); 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); }; const observer = new ResizeObserver(resize); observer.observe(element); resize(); let frame = 0; const render = () => { orbit.update(); renderer.render(scene, camera); frame = requestAnimationFrame(render); }; render(); return () => { cancelAnimationFrame(frame); observer.disconnect(); transform.dispose(); orbit.dispose(); for (const object of disposable) disposeMachineScene(object); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); }; }, [parts, selectedId, mode]); return
part.showOrigin).length} data-transform-mode={mode} aria-label="三维机床装配预览,机床 Z 轴朝上" />; } export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) { const [open, setOpen] = useState(false), [pending, setPending] = useState(false), [message, setMessage] = useState(""); const [parts, setParts] = useState([]); const partsRef = useRef(parts); partsRef.current = parts; const [selectedId, setSelectedId] = useState(null); const [mode, setMode] = useState<"translate" | "rotate">("translate"); const [cOriginMm, setCOriginMm] = useState([0, 0, 0]); const [tiltOffsetMm, setTiltOffsetMm] = useState([0, 0, 0]); const [physicalZero, setPhysicalZero] = useState({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); const file = useRef(null); useEffect(() => () => { for (const part of partsRef.current) disposeMachineScene(part.scene); }, []); useEffect(() => { setMessage(""); setCOriginMm([0, 0, 0]); setTiltOffsetMm([0, 0, 0]); setPhysicalZero({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); }, [coordinates]); const selected = parts.find((part) => part.id === selectedId) ?? null; const update = (id: string, values: Partial) => setParts((current) => current.map((part) => part.id === id ? { ...part, ...values } : part)); const vectorInput = (label: string, value: Vec3, set: (value: Vec3) => void, unit: string) =>
{label} ({unit}){["X", "Y", "Z"].map((axis, index) => )}
; const importFiles = async (list: FileList | File[] | null) => { if (!list?.length) return; setPending(true); setMessage("正在读取 GLB 部件…"); try { const loaded = await Promise.all([...list].map(async (item) => loadMachinePart(await item.arrayBuffer(), item.name))); setParts((current) => { const next = [...current]; for (const source of loaded) { const role = MACHINE_PART_ROLES.find((candidate) => !next.some((part) => part.role === candidate)) ?? ""; const parentRole = role ? expectedParentRole(role) : null; const parentId = parentRole ? next.find((part) => part.role === parentRole)?.id ?? null : null; next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3() }); } return next; }); setSelectedId(loaded[0].id); setMessage(`已导入 ${loaded.length} 个部件`); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } finally { setPending(false); } }; const remove = (part: MachinePartDraft) => { disposeMachineScene(part.scene); setParts((current) => current.filter((item) => item.id !== part.id).map((item) => item.parentId === part.id ? { ...item, parentId: null } : item)); if (selectedId === part.id) setSelectedId(null); }; const apply = async () => { if (disabled) return; setPending(true); setMessage("正在生成机床层级和 URDF…"); let machine: ImportedMachine | null = null; try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); await onApply(machine); setMessage("三维机床已应用;请重新回零后运行程序"); } catch (error) { if (machine) disposeMachineScene(machine.scene); setMessage(error instanceof Error ? error.message : String(error)); } finally { setPending(false); } }; const exportGlb = async () => { setPending(true); setMessage("正在生成包含 userData 的组合 GLB…"); let machine: ImportedMachine | null = null; try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); const bytes = await new GLTFExporter().parseAsync(machine.scene, { binary: true, trs: true, onlyVisible: false }); const url = URL.createObjectURL(new Blob([bytes as ArrayBuffer], { type: "model/gltf-binary" })); const link = document.createElement("a"); link.href = url; link.download = machine.filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); setMessage("组合 GLB 已导出,底座和整机场景包含机床标定 userData"); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } finally { if (machine) disposeMachineScene(machine.scene); setPending(false); } }; const roleChange = (part: MachinePartDraft, role: MachinePartRole | "") => { const parentRole = role ? expectedParentRole(role) : null; update(part.id, { role, parentId: parentRole ? parts.find((item) => item.role === parentRole)?.id ?? null : null }); }; return
setOpen(false)}>
{parts.length}/8 部件
update(id, { translationMm, rotationDeg })} />

部件与层级

{parts.length ? parts.map((part) =>
) :

导入八个 GLB 部件后建立机械链。

}
{selected &&

{selected.filename}

{vectorInput("模型局部原点", selected.originMm, (originMm) => update(selected.id, { originMm }), "mm")}{vectorInput("部件平移", selected.translationMm, (translationMm) => update(selected.id, { translationMm }), "mm")}{vectorInput("部件旋转", selected.rotationDeg, (rotationDeg) => update(selected.id, { rotationDeg }), "deg")}
}

机床标定

{vectorInput("C 轴原点", cOriginMm, setCOriginMm, "mm")}{vectorInput(`${coordinates === "XYZBC" ? "B" : "A"} 到 C 轴偏置`, tiltOffsetMm, setTiltOffsetMm, "mm")}
机床物理零点 (XYZ mm / ABC deg){["x", "y", "z", coordinates === "XYZBC" ? "b" : "a", "c"].map((axis) => )}
{message || (disabled ? "关机且程序空闲后可配置" : "配置写入运行模型和底座 userData")}
{ const files = event.target.files ? [...event.target.files] : []; event.target.value = ""; void importFiles(files); }} />
; } // Named wrapper keeps the recursive panel JSX readable while retaining one preview implementation. const MachineManagerPanelPreview = ManagerPreview;