import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as THREE from "three"; import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.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 { configureMachinePartScene, draftNodeDof, makeJointAssembly, reparentMachineNode, buildManagedMachine, expectedParentRole, loadMachinePart, machinePartTree, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type MachineTreeNode, type PhysicalZero } from "./machine-manager"; import { driveJoint, jointZero, defaultDof, isLinear, type JointDof, type CncAxis } from "./machine-joints"; import { projectXyzacVisual, type XyzacVisualSource } from "./xyzac-visual-adapter"; import { useMachineDraft, draftFingerprint, changeMachineType } from "./machine-manager-history"; import { MachineNumberInput } from "./MachineManagerInputs"; import { inspectMachineGlb, MAX_MACHINE_BYTES, type Vec3 } from "./machine-urdf"; type Props = { snapshot?: XyzacVisualSource | null; coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise }; type TestPose = { x: number; y: number; z: number; a: number; b: number; c: number }; type MotionTransform = { local: number[]; localQuaternion: number[]; world: number[]; worldQuaternion: number[] }; const zero3 = (): Vec3 => [0, 0, 0]; type EditorTab = "structure" | "motion" | "calibration"; type PreviewCommand = { action: "fit" | "selected" | "front" | "top"; serial: number }; const formatted = (value: number) => Number.isFinite(value) ? value.toFixed(3) : "—"; function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, testPose, onMotionState, command }: { command: PreviewCommand; coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; selectedId: string | null; selectedNodePath: string; testPose: TestPose; onMotionState: (state: Record) => void }) { type PreviewHost = HTMLDivElement & { machineManagerView?: (action: PreviewCommand["action"]) => void; machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, selectedNodePath: string, testPose: TestPose, coordinates: "XYZAC" | "XYZBC") => void }; const host = useRef(null); const stateRef = useRef({ parts, selectedId, selectedNodePath, testPose, coordinates }); stateRef.current = { parts, selectedId, selectedNodePath, testPose, coordinates }; const onMotionStateRef = useRef(onMotionState); onMotionStateRef.current = onMotionState; const [fault, setFault] = useState(""); useEffect(() => { host.current?.machineManagerView?.(command.action); }, [command]); useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, selectedNodePath, testPose, coordinates); }, [parts, selectedId, selectedNodePath, testPose, coordinates]); useEffect(() => { const element = host.current; if (!element) return; // Large industrial GLBs can exceed small-GPU multisample renderbuffer // limits when DevTools or display scaling changes the drawing-buffer size. const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false, depth: true, stencil: false, powerPreference: "high-performance" }); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.45; renderer.shadowMap.enabled = false; element.appendChild(renderer.domElement); const gl = renderer.getContext(), viewportLimit = gl.getParameter(gl.MAX_VIEWPORT_DIMS) as Int32Array; const maxDimension = Math.max(1, Math.floor(Math.min(Number(gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 4096), Number(gl.getParameter(gl.MAX_TEXTURE_SIZE) || 4096), Number(viewportLimit?.[0] || 4096), Number(viewportLimit?.[1] || 4096)) * .9)); const maxPixels = Math.min(maxDimension * maxDimension, 4096 * 4096); element.dataset.machineManagerWebglAntialias = String(Boolean(gl.getContextAttributes()?.antialias)); element.dataset.machineManagerMaxBuffer = String(maxDimension); 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); let dofNodes: Array<{ object: THREE.Object3D; zero: ReturnType; dof: JointDof }> = []; type RoleNode = { object: THREE.Object3D; position: THREE.Vector3; quaternion: THREE.Quaternion }; let nodes = new Map(), visualNodes = new Map>(), roleNodes = new Map(), helpers: THREE.AxesHelper[] = [], selectedJointObject: THREE.Object3D | null = null, lastParts: MachinePartDraft[] | null = null, fitSignature = "", selectedAxesScale = 1, dirty = true, renderable = false, contextLost = false, frame = 0; const selectedJointAxes = new THREE.Group(); selectedJointAxes.name = "selected-joint-local-coordinate-system"; selectedJointAxes.visible = false; const jointAxis = (direction: THREE.Vector3, color: number) => selectedJointAxes.add(new THREE.ArrowHelper(direction, new THREE.Vector3(), .28, color, .07, .045)); jointAxis(new THREE.Vector3(1, 0, 0), 0xff3030); jointAxis(new THREE.Vector3(0, 1, 0), 0x35d05b); jointAxis(new THREE.Vector3(0, 0, 1), 0x327cff); scene.add(selectedJointAxes); element.machineManagerView = (action) => { root.updateMatrixWorld(true); const target = action === "selected" ? selectedJointObject : root; if (!target) return; const bounds = new THREE.Box3().setFromObject(target); const center = bounds.isEmpty() ? target.getWorldPosition(new THREE.Vector3()) : bounds.getCenter(new THREE.Vector3()); const size = bounds.isEmpty() ? .3 : Math.max(bounds.getSize(new THREE.Vector3()).length(), .1); const direction = action === "top" ? new THREE.Vector3(0, -.001, 1) : action === "front" ? new THREE.Vector3(0, -1, .001) : new THREE.Vector3(1, -.8, .8); const distance = size / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2))) * Math.max(1, 1 / camera.aspect) * 1.15; orbit.enableDamping = false; orbit.target.copy(center); camera.position.copy(center).addScaledVector(direction.normalize(), distance); camera.near = Math.max(.001, distance / 10000); camera.far = Math.max(100, distance * 20); camera.updateProjectionMatrix(); camera.lookAt(center); orbit.update(); orbit.enableDamping = true; element.dataset.machineManagerView = action; element.dataset.machineManagerCamera = JSON.stringify({ position: camera.position.toArray(), target: orbit.target.toArray(), up: camera.up.toArray() }); dirty = true; }; const draw = () => { if (contextLost || !renderable) return; renderer.setRenderTarget(null); scene.updateMatrixWorld(true); camera.updateMatrixWorld(true); renderer.clear(); renderer.render(scene, camera); element.dataset.machineManagerRendered = "true"; element.dataset.machineManagerRenderState = "rendered"; dirty = false; }; element.machineManagerSync = (nextParts, nextSelectedId, nextNodePath, nextPose, nextCoordinates) => { if (nextParts !== lastParts) { delete element.dataset.machineManagerRendered; element.dataset.machineManagerRenderState = "syncing-scene"; selectedJointAxes.removeFromParent(); root.clear(); helpers.forEach((helper) => helper.dispose()); helpers = []; dofNodes = []; nodes = new Map(); visualNodes = new Map(); roleNodes = new Map(); for (const part of nextParts) { 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); // Clone transforms/hierarchy only. Geometry, materials, textures and // source-node transforms remain untouched on the imported GLB. const { scene: visual, nodes: pathNodes } = configureMachinePartScene(part.scene.clone(true), part); const internalRoles = Object.entries(part.nodeRoles ?? {}).map(([path, role]) => ({ object: pathNodes.get(path) ?? null, role })); visual.position.add(new THREE.Vector3(...part.originMm).multiplyScalar(-.001)); node.add(visual); visualNodes.set(part.id, pathNodes); const rootDof = draftNodeDof(part, "scene", nextCoordinates); if (rootDof) dofNodes.push({ object: node, zero: jointZero(node), dof: rootDof }); for (const [path, object] of pathNodes) { const dof = path === "scene" ? null : draftNodeDof(part, path, nextCoordinates); if (dof) dofNodes.push({ object, zero: jointZero(object), dof }); } const axes = new THREE.AxesHelper(.12); helpers.push(axes); axes.name = `origin_${part.id}`; axes.visible = part.showOrigin; node.add(axes); root.add(node); nodes.set(part.id, node); if (part.role) roleNodes.set(part.role, { object: node, position: node.position.clone(), quaternion: node.quaternion.clone() }); for (const binding of internalRoles) if (binding.object && binding.role) roleNodes.set(binding.role, { object: binding.object, position: binding.object.position.clone(), quaternion: binding.object.quaternion.clone() }); } 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 = nextParts.find((part) => part.id === cursor)?.parentId ?? null; } return false; }; for (const part of nextParts) if (part.parentId && nodes.has(part.parentId) && !cycle(part.id, part.parentId)) nodes.get(part.parentId)!.add(nodes.get(part.id)!); const signature = nextParts.map((part) => `${part.id}:${part.hash}`).join("|"); if (signature !== fitSignature) { const bounds = new THREE.Box3().setFromObject(root), center = bounds.isEmpty() ? new THREE.Vector3() : bounds.getCenter(new THREE.Vector3()), 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)); camera.lookAt(center); orbit.update(); fitSignature = signature; selectedAxesScale = THREE.MathUtils.clamp(size * .14 / .28, 1, 12); element.dataset.machineManagerBounds = JSON.stringify({ min: bounds.min.toArray(), max: bounds.max.toArray(), center: center.toArray(), size }); element.dataset.machineManagerCamera = JSON.stringify({ position: camera.position.toArray(), target: orbit.target.toArray(), up: camera.up.toArray() }); } lastParts = nextParts; } selectedJointAxes.removeFromParent(); selectedJointObject = nextSelectedId ? nextNodePath === "scene" ? nodes.get(nextSelectedId) ?? null : visualNodes.get(nextSelectedId)?.get(nextNodePath) ?? null : null; if (selectedJointObject) { selectedJointObject.add(selectedJointAxes); selectedJointAxes.position.set(0, 0, 0); selectedJointAxes.quaternion.identity(); selectedJointAxes.scale.setScalar(selectedAxesScale); selectedJointAxes.visible = nextParts.find((part) => part.id === nextSelectedId)?.showOrigin ?? true; element.dataset.machineManagerSelectedJoint = `${nextSelectedId}:${nextNodePath}`; element.dataset.machineManagerSelectedJointAxes = "local-xyz"; element.dataset.machineManagerTransformTarget = "single-dof-input"; } else { selectedJointAxes.visible = false; delete element.dataset.machineManagerSelectedJoint; delete element.dataset.machineManagerSelectedJointAxes; delete element.dataset.machineManagerTransformTarget; } for (const binding of dofNodes) driveJoint(binding.object, binding.zero, binding.dof, nextPose[binding.dof.axis.toLowerCase() as keyof TestPose]); root.updateMatrixWorld(true); let objectCount = 0, meshCount = 0; root.traverse((object) => { let cursor: THREE.Object3D | null = object, isJointHelper = false; while (cursor) { if (cursor === selectedJointAxes) { isJointHelper = true; break; } cursor = cursor.parent; } if (!isJointHelper) { objectCount += 1; if ((object as THREE.Mesh).isMesh) meshCount += 1; } }); const transforms = Object.fromEntries([...roleNodes].map(([role, binding]) => [role, { local: binding.object.position.toArray(), localQuaternion: binding.object.quaternion.toArray(), world: binding.object.getWorldPosition(new THREE.Vector3()).toArray(), worldQuaternion: binding.object.getWorldQuaternion(new THREE.Quaternion()).toArray() }])); element.dataset.machineManagerSceneObjects = String(objectCount); element.dataset.machineManagerSceneMeshes = String(meshCount); element.dataset.machineManagerRoleTransforms = JSON.stringify(transforms); element.dataset.machineManagerJointTransforms = JSON.stringify(Object.fromEntries(dofNodes.map(({ object, dof }) => [dof.axis, { localMm: object.position.toArray().map((v) => v * 1000), quaternion: object.quaternion.toArray(), world: object.getWorldPosition(new THREE.Vector3()).toArray() }]))); if (selectedJointObject) { element.dataset.machineManagerSelectedJointWorldOrigin = JSON.stringify(selectedJointObject.getWorldPosition(new THREE.Vector3()).toArray()); element.dataset.machineManagerSelectedJointWorldQuaternion = JSON.stringify(selectedJointObject.getWorldQuaternion(new THREE.Quaternion()).toArray()); element.dataset.machineManagerSelectedJointAxesScale = selectedAxesScale.toFixed(6); } const selectedState = selectedJointObject ? { local: selectedJointObject.position.toArray(), localQuaternion: selectedJointObject.quaternion.toArray(), world: selectedJointObject.getWorldPosition(new THREE.Vector3()).toArray(), worldQuaternion: selectedJointObject.getWorldQuaternion(new THREE.Quaternion()).toArray() } : null; element.dataset.machineManagerSelectedTransform = JSON.stringify(selectedState); onMotionStateRef.current(selectedState ? { ...transforms, selected: selectedState } : transforms); element.dataset.machineManagerTestPose = JSON.stringify(nextPose); element.dataset.machineManagerContextGeneration = "1"; dirty = true; draw(); }; element.machineManagerSync(stateRef.current.parts, stateRef.current.selectedId, stateRef.current.selectedNodePath, stateRef.current.testPose, stateRef.current.coordinates); const resize = () => { const width = element.clientWidth, height = element.clientHeight; if (!Number.isFinite(width) || !Number.isFinite(height) || width < 2 || height < 2) { renderable = false; element.dataset.machineManagerRenderState = "suspended-zero-size"; return; } const requested = Math.min(globalThis.devicePixelRatio || 1, 1.5); const scale = Math.min(1, maxDimension / (width * requested), maxDimension / (height * requested), Math.sqrt(maxPixels / (width * height * requested * requested))); const pixelRatio = requested * scale; renderer.setPixelRatio(pixelRatio); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false); renderable = renderer.domElement.width > 0 && renderer.domElement.height > 0 && renderer.domElement.width <= maxDimension && renderer.domElement.height <= maxDimension; element.dataset.machineManagerPixelRatio = pixelRatio.toFixed(3); element.dataset.machineManagerBufferSize = `${renderer.domElement.width}x${renderer.domElement.height}`; element.dataset.machineManagerRenderState = renderable ? "ready" : "suspended-limit"; dirty = true; draw(); }; const contextLostHandler = (event: Event) => { event.preventDefault(); contextLost = true; renderable = false; element.dataset.machineManagerRenderState = "context-lost"; setFault("WebGL 上下文已丢失,请关闭并重新打开三维机床管理器"); }; renderer.domElement.addEventListener("webglcontextlost", contextLostHandler); const observer = new ResizeObserver(resize); observer.observe(element); resize(); orbit.addEventListener("change", () => { dirty = true; }); const render = () => { if (!contextLost && renderable && (dirty || orbit.update())) draw(); frame = contextLost ? 0 : requestAnimationFrame(render); }; render(); return () => { if (frame) cancelAnimationFrame(frame); observer.disconnect(); delete element.machineManagerSync; delete element.machineManagerView; renderer.domElement.removeEventListener("webglcontextlost", contextLostHandler); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); grid.geometry.dispose(); for (const material of Array.isArray(grid.material) ? grid.material : [grid.material]) material.dispose(); selectedJointAxes.traverse((object) => { const mesh = object as THREE.Mesh; mesh.geometry?.dispose(); if (mesh.material) for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) material.dispose(); }); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); }; }, []); return
part.showOrigin).length} aria-label="三维机床装配预览,机床 Z 轴朝上" />{fault && {fault}}
; } function TreeNode({ part, node, selected, onSelect, onDrop, extra, query, expansion, locked }: { part: MachinePartDraft; node: MachineTreeNode; selected: string; onSelect: (partId: string, path: string) => void; onDrop: (source: { partId: string; path: string }, target: { partId: string; path: string }) => void; extra?: React.ReactNode; query: string; expansion: { all: boolean; serial: number }; locked: boolean }) { const key = `${part.id}:${node.path}`; const row = useRef(null); useEffect(() => { if (selected === key) { const frame = requestAnimationFrame(() => row.current?.querySelector(".machine-tree-row")?.scrollIntoView({ block: "nearest", inline: "nearest" })); return () => cancelAnimationFrame(frame); } }, [selected, key]); const [expanded, setExpanded] = useState(node.path === "scene"); useEffect(() => { if (expansion.serial) setExpanded(expansion.all); }, [expansion]); useEffect(() => { if (selected.startsWith(`${key}/`)) setExpanded(true); }, [selected, key]); const matches = (item: MachineTreeNode): boolean => item.name.toLowerCase().includes(query.toLowerCase()) || Boolean(part.nodeJoints?.[item.path]?.axis.toLowerCase().includes(query.toLowerCase())) || item.children.some(matches); const shown = !query || matches(node) || Boolean(extra); const isExpanded = Boolean(query) || expanded; const hasChildren = node.children.length > 0 || Boolean(extra); const binding = part.nodeJoints?.[node.path]?.axis ?? (node.path === "scene" ? part.role : part.nodeRoles?.[node.path]); return ; } export function MachineManagerPanel({ coordinates: controllerCoordinates, disabled, onApply, snapshot }: Props) { const [open, setOpen] = useState(false), [pending, setPending] = useState(false), [message, setMessage] = useState(""); const { history, draft, edit, undo, redo } = useMachineDraft(controllerCoordinates); const { coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero } = draft; const setParts = (change: (current: MachinePartDraft[]) => MachinePartDraft[]) => edit((current) => ({ ...current, parts: change(current.parts) })); const setCOriginMm = (value: Vec3) => edit((current) => ({ ...current, cOriginMm: value })); const setTiltOffsetMm = (value: Vec3) => edit((current) => ({ ...current, tiltOffsetMm: value })); const setPhysicalZero = (value: PhysicalZero) => edit((current) => ({ ...current, physicalZero: value })); const file = useRef(null); const [selectedId, setSelectedId] = useState(null), [selectedNodePath, setSelectedNodePath] = useState("scene"); const [tab, setTab] = useState("structure"), [query, setQuery] = useState(""); const [expansion, setExpansion] = useState({ all: false, serial: 0 }); const [command, setCommand] = useState({ action: "fit", serial: 0 }); const [jogStep, setJogStep] = useState(1); const [applied, setApplied] = useState(""), [exported, setExported] = useState(""); const [invalidFields, setInvalidFields] = useState([]); const validity = useCallback((field: string, valid: boolean) => setInvalidFields((current) => valid ? current.includes(field) ? current.filter((id) => id !== field) : current : current.includes(field) ? current : [...current, field]), []); const signature = coordinates + draftFingerprint(draft); const retainedScenes = useRef(new Set()); // Retain only assets reachable from the bounded undo/redo history. useEffect(() => { const keep = new Set([...history.past, history.current, ...history.future].flatMap((d) => d.parts.map((part) => part.scene))); for (const scene of retainedScenes.current) if (!keep.has(scene as THREE.Group)) disposeMachineScene(scene); retainedScenes.current = keep; }, [history]); useEffect(() => () => { retainedScenes.current.forEach(disposeMachineScene); }, []); const [testPose, setTestPose] = useState({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); const [motionState, setMotionState] = useState>({}); const motionStateHash = useRef(""); useEffect(() => { setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); setFollowLive(false); }, [coordinates]); useEffect(() => { if (selectedId && !parts.some((part) => part.id === selectedId)) { setSelectedId(parts[0]?.id ?? null); setSelectedNodePath("scene"); } }, [parts, selectedId]); const selected = parts.find((part) => part.id === selectedId) ?? null; const trees = useMemo(() => new Map(parts.map((part) => [part.id, machinePartTree(part)])), [parts]); const findTreeNode = (node: MachineTreeNode | undefined, path: string): MachineTreeNode | null => node ? node.path === path ? node : node.children.map((child) => findTreeNode(child, path)).find(Boolean) ?? null : null; const selectedTreeNode = selected ? findTreeNode(trees.get(selected.id), selectedNodePath) : null; const parentAllowed = (id: string, parentId: string | null) => { let cursor = parentId; const visited = new Set([id]); while (cursor) { if (visited.has(cursor)) return false; visited.add(cursor); cursor = parts.find((part) => part.id === cursor)?.parentId ?? null; } return true; }; const update = (id: string, values: Partial) => { if (pending || followLive) return; if (values.parentId && !parentAllowed(id, values.parentId)) { setMessage("不能将部件挂到自身或后代节点"); return; } setParts((current) => current.map((part) => part.id === id ? { ...part, ...values } : part)); setMessage(""); }; 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 || pending || followLive) return; setPending(true); setMessage("正在读取 GLB 部件…"); try { const results = await Promise.allSettled([...list].map(async (item) => { if (!item.name.toLowerCase().endsWith(".glb") || item.size > 32 * 1024 * 1024) throw new Error(`${item.name}:请选择不超过 32 MiB 的 GLB`); try { return await loadMachinePart(await item.arrayBuffer(), item.name); } catch { throw new Error(`${item.name}:模型无法读取,请检查是否为完整的自包含 GLB,或配置是否与模型层级一致`); } })); const loaded = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []); const failure = results.find((result) => result.status === "rejected"); if (failure?.status === "rejected") { loaded.forEach((source) => disposeMachineScene(source.scene)); throw failure.reason; } const restoredSources = loaded.filter((source) => source.restoredModel); const restored = restoredSources[0]?.restoredModel; // A saved machine is opened as one document, not appended to another // machine. Raw component imports continue to append to this draft. if (restored && loaded.length !== 1) { loaded.forEach((source) => disposeMachineScene(source.scene)); throw new Error("完整机床 GLB 请单独打开;普通部件可批量导入"); } edit((draft) => { const next = restored ? [] : [...draft.parts]; 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(), nodeParents: {}, nodeTransforms: {}, nodeJoints: source.restoredJoints ?? {}, nodeRoles: source.restoredRoles ?? {} }); } return { ...draft, parts: next, ...(restored ? { coordinates: restored.coordinates, cOriginMm: restored.calibration.cOriginMm, tiltOffsetMm: restored.calibration.tiltOffsetMm, physicalZero: restored.calibration.physicalZero as PhysicalZero } : {}) }; }); setTab("structure"); setQuery(""); setSelectedId(loaded[0].id); setSelectedNodePath("scene"); setMessage(restored ? `已打开 ${restored.coordinates} 完整机床,恢复层级、关节和标定;原草稿可撤销恢复` : `已导入 ${loaded.length} 个部件`); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } finally { setPending(false); } }; const remove = (part: MachinePartDraft) => { if (pending || followLive) return; setParts((current) => current.filter((item) => item.id !== part.id).map((item) => item.parentId === part.id ? { ...item, parentId: null } : item)); setMessage(`已删除 ${part.filename},可撤销恢复`); if (selectedId === part.id) { setSelectedId(null); setSelectedNodePath("scene"); } }; const selectTreeNode = (partId: string, path: string) => { setSelectedId(partId); setSelectedNodePath(path); }; const reparentTreeNode = (source: { partId: string; path: string }, target: { partId: string; path: string }) => { if (pending || followLive) return; if (source.partId === target.partId && source.path === target.path) return; if (source.path === "scene") { if (target.path !== "scene") { setMessage("整机/部件根节点只能拖到另一个 GLB 根节点"); return; } let cursor: string | null = target.partId; while (cursor) { if (cursor === source.partId) { setMessage("部件层级不能形成循环"); return; } cursor = parts.find((part) => part.id === cursor)?.parentId ?? null; } update(source.partId, { parentId: target.partId }); setMessage("部件装配层级已更新"); return; } if (source.partId !== target.partId) { setMessage("GLB 内部节点只能在同一 GLB 层级树内拖动"); return; } const part = parts.find((item) => item.id === source.partId)!; try { const changes = reparentMachineNode(part, source.path, target.path); machinePartTree({ ...part, ...changes }); update(part.id, changes); setSelectedId(part.id); setSelectedNodePath(source.path); setMessage("GLB 内部层级已更新,保留当前装配姿态"); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } }; const apply = async () => { if (disabled || pending || followLive || blockingReason) return; setPending(true); setMessage("正在生成机床层级和 URDF…"); let machine: ImportedMachine | null = null; try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); await onApply(machine); setApplied(signature); setMessage("三维机床已应用;请重新回零后运行程序"); } catch (error) { if (machine) disposeMachineScene(machine.scene); setMessage(error instanceof Error ? error.message : String(error)); } finally { setPending(false); } }; const exportGlb = async () => { if (pending || followLive || blockingReason) return; setPending(true); setMessage("正在生成包含 userData 的组合 GLB…"); let machine: ImportedMachine | null = null; try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); const exportScene = new THREE.Scene(); exportScene.name = machine.scene.name; exportScene.userData = machine.scene.userData; for (const child of machine.scene.children) exportScene.add(child.clone(true)); const bytes = await new GLTFExporter().parseAsync(exportScene, { binary: true, trs: true, onlyVisible: false }); if (!(bytes instanceof ArrayBuffer) || bytes.byteLength > MAX_MACHINE_BYTES) throw new Error("保存文件超过控制面板支持的 32 MiB,请减小模型后重试"); const saved = inspectMachineGlb(bytes); if (saved.structure.coordinates !== coordinates) throw new Error("保存模型的机型与当前配置不一致"); 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); setExported(signature); setMessage(`已保存 managed-${coordinates.toLowerCase()}.glb,可在控制面板“导入机床模型”中加载,自动识别 ${coordinates} 和轴配置`); } catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } finally { if (machine) disposeMachineScene(machine.scene); setPending(false); } }; const roleChange = (part: MachinePartDraft, role: MachinePartRole | "") => { if (selectedNodePath !== "scene") { update(part.id, { nodeRoles: { ...part.nodeRoles, [selectedNodePath]: role } }); return; } const parentRole = role ? expectedParentRole(role) : null; update(part.id, { role, parentId: parentRole ? parts.find((item) => item.role === parentRole)?.id ?? null : null }); }; const selectedDof = selected ? draftNodeDof(selected, selectedNodePath, coordinates) : null; const setDof = (dof: JointDof | null) => { if (!selected || pending || followLive) return; if (dof && parts.some((part) => Object.entries(part.nodeJoints ?? {}).some(([path, existing]) => existing.axis === dof.axis && (part.id !== selected.id || path !== selectedNodePath)))) { setMessage(`${dof.axis} 轴已绑定其他节点,请先取消该绑定`); return; } setParts((current) => current.map((part) => { if (part.id !== selected.id) return part; const nodeJoints = { ...(part.nodeJoints ?? {}) }; if (dof) nodeJoints[selectedNodePath] = dof; else delete nodeJoints[selectedNodePath]; return { ...part, nodeJoints }; })); setMessage(""); }; const [followLive, setFollowLive] = useState(false); const visual = useMemo(() => projectXyzacVisual(snapshot), [snapshot]); const liveReady = visual.joints.every(Number.isFinite) && visual.authoritative && visual.machineModel?.coordinate_system === coordinates; const heldLivePose = useRef(testPose); const effectivePose: TestPose = followLive && liveReady ? { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, ...Object.fromEntries([...coordinates].map((axis, i) => [axis.toLowerCase(), visual.joints[i] - physicalZero[axis.toLowerCase() as keyof PhysicalZero]])) } : followLive ? heldLivePose.current : testPose; if (followLive && liveReady) heldLivePose.current = effectivePose; const validation = useMemo(() => { try { if (Math.abs(tiltOffsetMm[coordinates === "XYZAC" ? 0 : 1]) > 1e-9) throw new Error(coordinates === "XYZAC" ? "AC 机床的 A→C 偏置 X 必须为 0" : "BC 机床的 B→C 偏置 Y 必须为 0"); const { model } = makeJointAssembly({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); const missing = [...coordinates].filter((axis) => !model.axisMap[axis as CncAxis]); return { model, error: missing.length ? `请绑定 ${missing.join("、")} 轴` : "" }; } catch (error) { return { model: null, error: error instanceof Error ? error.message : String(error) }; } }, [coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero]); const blockingReason = invalidFields.length ? "请修正标红的数值输入" : validation.error; const bindings = useMemo(() => parts.flatMap((part) => Object.entries(part.nodeJoints ?? {}).map(([path, dof]) => ({ part, path, dof, name: findTreeNode(trees.get(part.id), path)?.name ?? part.filename }))), [parts, trees]); const moveToBinding = (axis: string) => { const binding = bindings.find((b) => b.dof.axis === axis); setTab("structure"); setQuery(""); if (binding) selectTreeNode(binding.part.id, binding.path); else { setMessage(`在层级树中选择 ${axis} 轴关节,再设置唯一自由度`); } }; const changeHistory = (direction: "undo" | "redo") => { if (pending || followLive) return; if (direction === "undo") undo(); else redo(); setMessage(direction === "undo" ? "已撤销配置修改" : "已重做配置修改"); }; const exportJson = () => { if (!validation.model || pending || invalidFields.length) return; const url = URL.createObjectURL(new Blob([JSON.stringify(validation.model, null, 2)], { type: "application/json" })); const a = document.createElement("a"); a.href = url; a.download = `machine-joints-${coordinates.toLowerCase()}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); setMessage("关节 JSON 已导出;恢复完整模型请使用“保存机床模型 GLB”"); }; const poseInput = (axis: keyof TestPose, unit: string) => ; const receiveMotionState = (state: Record) => { const hash = JSON.stringify(state); if (hash !== motionStateHash.current) { motionStateHash.current = hash; setMotionState(state); } }; const selectedDriven = motionState.selected ?? null; const selectedTransform = selectedNodePath === "scene" ? selected : selected?.nodeTransforms?.[selectedNodePath] ?? (selectedTreeNode ? { translationMm: selectedTreeNode.originMm, rotationDeg: selectedTreeNode.rotationDeg } : null); const renderPartTree = (part: MachinePartDraft): React.ReactNode => child.parentId === part.id).map(renderPartTree)} />; return
setOpen(false)}>
2 · 结构与关节 → 3 · 验证与标定 → 4 · 保存 GLB{coordinates === "XYZAC" ? "A 绕 X 倾转,C 绕 Z 回转" : "B 绕 Y 倾转,C 绕 Z 回转"} · 控制面板当前 {controllerCoordinates}{coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : ""}
{parts.length} 个模型 · {bindings.filter((b) => coordinates.includes(b.dof.axis)).length}/5 轴已绑定{!parts.length ? "新建机床" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用"}
{[...coordinates].map((axis) => { const binding = bindings.find((b) => b.dof.axis === axis); return ; })}
{([ ["fit", "整机适配"], ["selected", "定位选中"], ["front", "正视"], ["top", "俯视"] ] as const).map(([action, label]) => )}Z ↑ · X 红 / Y 绿 / Z 蓝
{open && } {!parts.length &&
建立机床的数字装配

选择机床类型 → 导入 GLB → 绑定关节 → 保存机床模型

支持完整层级 · 单文件不超过 32 MiB
}

左键旋转 · 滚轮缩放 · 右键平移 · 选中关节显示局部坐标系

{([ ["structure", "结构与关节"], ["motion", "运动验证"], ["calibration", "机床标定"] ] as const).map(([id, label], index) => )}
{blockingReason ? `配置检查:${blockingReason}` : "配置检查通过 · 五轴绑定完整"}{disabled && !blockingReason && " · 关机且程序空闲后可应用"}{followLive && " · 实时跟随中,配置编辑已锁定"}{invalidFields.length > 0 && }
{message || "关闭对话框保留本次草稿;保存机床模型 GLB 后,可在控制面板“导入机床模型”中使用。"}{message && }
{ 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;