diff --git a/docs/glb-machine-manager/configuration-driven-dialog.md b/docs/glb-machine-manager/configuration-driven-dialog.md new file mode 100644 index 0000000..254304f --- /dev/null +++ b/docs/glb-machine-manager/configuration-driven-dialog.md @@ -0,0 +1,11 @@ +# 三维机床管理器配置入口 + +机床角色、默认父级、管理器支持的 TRT profile、导出限位、轴向、偏置约束及试动步长集中在 `web/workspace/machine-manager-settings.json`。`web/react/src/machine-manager-config.ts` 读取并检查该配置。 + +对话框按草稿坐标生成零点、运动测试和轴绑定总览,机型列表来自五轴案例目录。切换坐标后清除不兼容的绑定并初始化新轴零点,撤销可恢复原草稿。底层数控轴类型包含 XYZABCUVW。 + +边界:管理器生成完整 GLB 并应用的运行适配器目前仍为 XYZAC、XYZBC TRT;案例列表存在某坐标不代表管理器已支持该案例的结构及 RTCP。任意五个唯一轴的 JSON 校验也不代表重复轴、附加关节、机器人或并联机构已经完成三维适配。配置文件不能替代运动学适配代码。 + +2026-09-30 保存验证:使用暂存文件生成独立源码快照,TypeScript 检查和 Vite 生产构建通过。构建使用现有 node_modules、LinuxCNC 资源和本地 WASM 二进制;保留大 chunk 提示。本轮不把既有浏览器报告作为新验收证据。 + +脚本目录迁移、通用 WASM 运行时开发、原始大型 GLB 以及 LinuxCNC 子模块本地修改不包含在本次管理器配置提交中,仍留在工作区。 diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx index c1462b2..8eb6a93 100644 --- a/web/react/src/App.tsx +++ b/web/react/src/App.tsx @@ -579,7 +579,7 @@ export function App() { if (!response.ok) throw new Error("示例 GLB 读取失败"); bytes = await response.arrayBuffer(); } else { - if (source.size > MAX_MACHINE_BYTES) throw new Error("GLB 最大 32 MiB"); + if (source.size > MAX_MACHINE_BYTES) throw new Error("GLB 最大 64 MiB"); bytes = await source.arrayBuffer(); } model.current = await loadImportedMachine(bytes, typeof source === "string" ? source.split("/").at(-1)! : source.name); @@ -895,7 +895,7 @@ export function App() { { void loadCase(path); }} /> -
+
{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => )}
diff --git a/web/react/src/MachineManagerPanel.tsx b/web/react/src/MachineManagerPanel.tsx index 39e3770..9538aa2 100644 --- a/web/react/src/MachineManagerPanel.tsx +++ b/web/react/src/MachineManagerPanel.tsx @@ -2,11 +2,11 @@ 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 { installMachineStudio, MACHINE_STUDIO } from "./machine-studio-lighting"; 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 { CNC_AXES, driveJoint, jointZero, defaultDof, isFiveAxisCoordinates, 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 { downloadMachine, listSavedMachines, saveMachineCopy, readMachineCopy, deleteMachineCopy, type SavedMachine } from "./machine-library"; @@ -14,21 +14,27 @@ import { machineDocumentFilename } from "./machine-document"; import { MachineNumberInput } from "./MachineManagerInputs"; import { inspectMachineGlb, MAX_MACHINE_BYTES, type Vec3 } from "./machine-urdf"; import { FIVE_AXIS_CASES, FIVE_AXIS_STATUS_LABELS } from "../../workspace/five-axis-machine-catalog.mjs"; +import { analyzeGlbFiveAxisScene, analysisSummary, type GlbFiveAxisAnalysis } from "./glb-five-axis-analyzer"; +import { MANAGER_SETTINGS, managerAxes, managerProfile, validateManagerCalibration, zeroAxisValues } from "./machine-manager-config"; -type Props = { currentMachine?: ImportedMachine | null; 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 Props = { currentMachine?: ImportedMachine | null; snapshot?: XyzacVisualSource | null; coordinates: string; disabled: boolean; onApply: (machine: ImportedMachine) => Promise }; +type TestPose = Record; type MotionTransform = { local: number[]; localQuaternion: number[]; world: number[]; worldQuaternion: number[] }; const zero3 = (): Vec3 => [0, 0, 0]; type EditorTab = "structure" | "motion" | "calibration" | "files"; type PreviewCommand = { action: "fit" | "selected" | "front" | "top"; serial: number }; const formatted = (value: number) => Number.isFinite(value) ? value.toFixed(3) : "—"; +const zeroPose = (coordinates: string): TestPose => zeroAxisValues(coordinates); +const axisUnit = (axis: CncAxis) => isLinear(axis) ? "mm" : "deg"; +const catalogCoordinateOptions = [...new Set(FIVE_AXIS_CASES.map((item) => item.coordinates))]; const supportedCoordinateOptions = new Set(FIVE_AXIS_CASES.filter((item) => item.webWasm === "supported").map((item) => item.coordinates)); -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 }; +function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, testPose, onMotionState, onSelectModel, command }: { command: PreviewCommand; coordinates: string; parts: MachinePartDraft[]; selectedId: string | null; selectedNodePath: string; testPose: TestPose; onMotionState: (state: Record) => void; onSelectModel: (partId: string) => void }) { + type PreviewHost = HTMLDivElement & { machineManagerView?: (action: PreviewCommand["action"]) => void; machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, selectedNodePath: string, testPose: TestPose, coordinates: string) => 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 onSelectModelRef = useRef(onSelectModel); onSelectModelRef.current = onSelectModel; 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]); @@ -37,21 +43,51 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test // 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); + 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 studio = installMachineStudio(renderer, scene, "z"); 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 selectionBox = new THREE.Box3(); + const selectionHighlight = new THREE.Box3Helper(selectionBox, 0xffc857); + selectionHighlight.name = "selected-import-outline"; selectionHighlight.visible = false; scene.add(selectionHighlight); + // Pick only imported geometry; exclude the grid, axes and selection helpers. + const owners = new Map(); + let pickable: THREE.Object3D[] = []; + const raycaster = new THREE.Raycaster(); + let pointer: { id: number; x: number; y: number; dragged: boolean } | null = null; + const pointerDown = (event: PointerEvent) => { + if (pointer) { pointer.dragged = true; return; } + if (event.button === 0 && event.isPrimary) pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, dragged: false }; + }; + const pointerMove = (event: PointerEvent) => { + if (pointer?.id === event.pointerId && Math.hypot(event.clientX - pointer.x, event.clientY - pointer.y) > 5) pointer.dragged = true; + }; + const pointerCancel = () => { pointer = null; }; + const pointerUp = (event: PointerEvent) => { + const start = pointer; pointer = null; + if (!start || start.id !== event.pointerId || start.dragged || event.button !== 0 || Math.hypot(event.clientX - start.x, event.clientY - start.y) > 5) return; + const rect = renderer.domElement.getBoundingClientRect(); + if (!rect.width || !rect.height) return; + scene.updateMatrixWorld(true); camera.updateMatrixWorld(true); + raycaster.setFromCamera(new THREE.Vector2((event.clientX - rect.left) / rect.width * 2 - 1, 1 - (event.clientY - rect.top) / rect.height * 2), camera); + const hit = raycaster.intersectObjects(pickable, false).find(({ object }) => { + for (let cursor: THREE.Object3D | null = object; cursor; cursor = cursor.parent) if (!cursor.visible) return false; + return true; + }); + const partId = hit && owners.get(hit.object); + if (partId) onSelectModelRef.current(partId); + }; + renderer.domElement.addEventListener("pointerdown", pointerDown); + renderer.domElement.addEventListener("pointermove", pointerMove); + renderer.domElement.addEventListener("pointerup", pointerUp); + renderer.domElement.addEventListener("pointercancel", pointerCancel); 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; @@ -79,11 +115,13 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test 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(); + owners.clear(); pickable = []; 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); + visual.traverse((object) => { if ((object as THREE.Mesh).isMesh) { owners.set(object, part.id); pickable.push(object); } }); 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); @@ -104,11 +142,37 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test 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]); + selectedJointAxes.removeFromParent(); selectedJointObject = nextSelectedId ? nextNodePath === "scene" ? nodes.get(nextSelectedId) ?? null : visualNodes.get(nextSelectedId)?.get(nextNodePath) ?? null : null; + const selectedBinding = selectedJointObject ? dofNodes.find((binding) => binding.object === selectedJointObject) ?? null : null; + if (selectedJointObject) { + selectedJointObject.add(selectedJointAxes); selectedJointAxes.position.set(0, 0, 0); + // JointDof.direction is defined in the parent frame. The previous + // preview always drew an unrotated local XYZ triad, so a C node whose + // imported zero pose is rotated showed a blue axis different from the + // axis actually used by driveJoint. Express the configured parent + // axis in the joint's zero-pose local frame before drawing the helper. + const parentAxis = selectedBinding ? new THREE.Vector3(selectedBinding.dof.direction === "x" ? 1 : 0, selectedBinding.dof.direction === "y" ? 1 : 0, selectedBinding.dof.direction === "z" ? 1 : 0) : null; + const localAxis = parentAxis?.applyQuaternion(new THREE.Quaternion(...(selectedBinding?.zero.quaternion ?? [0, 0, 0, 1])).invert()).normalize(); + selectedJointAxes.quaternion.copy(localAxis ? new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), localAxis) : new THREE.Quaternion()); + selectedJointAxes.scale.setScalar(selectedAxesScale); selectedJointAxes.visible = nextParts.find((part) => part.id === nextSelectedId)?.showOrigin ?? true; element.dataset.machineManagerSelectedJoint = `${nextSelectedId}:${nextNodePath}`; element.dataset.machineManagerSelectedJointAxes = selectedBinding ? `parent-${selectedBinding.dof.direction}${selectedBinding.dof.sign < 0 ? "-" : "+"}` : "local-xyz"; element.dataset.machineManagerSelectedJointAxis = localAxis?.toArray().map((value) => Number(value.toFixed(6))).join(",") ?? ""; element.dataset.machineManagerTransformTarget = "single-dof-input"; + } else { selectedJointAxes.visible = false; delete element.dataset.machineManagerSelectedJoint; delete element.dataset.machineManagerSelectedJointAxes; delete element.dataset.machineManagerSelectedJointAxis; delete element.dataset.machineManagerTransformTarget; } + for (const binding of dofNodes) driveJoint(binding.object, binding.zero, binding.dof, nextPose[binding.dof.axis.toLowerCase()] ?? 0); 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); + selectionBox.makeEmpty(); + for (const [object, partId] of owners) { + if (partId !== nextSelectedId) continue; + let visible = true; + for (let cursor: THREE.Object3D | null = object; cursor; cursor = cursor.parent) if (!cursor.visible) { visible = false; break; } + if (!visible) continue; + const geometry = (object as THREE.Mesh).geometry; + if (!geometry.boundingBox) geometry.computeBoundingBox(); + if (geometry.boundingBox) selectionBox.union(geometry.boundingBox.clone().applyMatrix4(object.matrixWorld)); + } + selectionHighlight.visible = !selectionBox.isEmpty(); + element.dataset.machineManagerSelectedModel = nextSelectedId ?? ""; + element.dataset.machineManagerSelectionVisible = String(selectionHighlight.visible); 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); @@ -125,11 +189,19 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, test 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(); + // Tear down selection resources independently of the geometry shared with drafts. + const disposeSelection = () => { + renderer.domElement.removeEventListener("pointerdown", pointerDown); + renderer.domElement.removeEventListener("pointermove", pointerMove); + renderer.domElement.removeEventListener("pointerup", pointerUp); + renderer.domElement.removeEventListener("pointercancel", pointerCancel); + owners.clear(); pickable = []; selectionHighlight.dispose(); + }; 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 () => { disposeSelection(); 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(); }); studio.dispose(); renderer.dispose(); renderer.domElement.remove(); }; }, []); - return
part.showOrigin).length} aria-label="三维机床装配预览,机床 Z 轴朝上" />{fault && {fault}}
; + 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 }) { @@ -172,13 +244,13 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl const refreshLibrary = useCallback(async () => { try { setLibrary(await listSavedMachines()); setLibraryError(""); } catch { setLibraryError("此浏览器无法使用本地机床库;仍可保存下载 GLB 并从磁盘打开。"); } }, []); useEffect(() => { if (open) void refreshLibrary(); }, [open, refreshLibrary]); const requestOpen = () => { if (!pending && !followLive) openFile.current?.click(); }; - const newDocument = () => { if (pending || followLive) return; edit(() => ({ coordinates, documentInfo: { name: "", description: "" }, parts: [], cOriginMm: [0, 0, 0], tiltOffsetMm: [0, 0, 0], physicalZero: { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 } })); setSelectedId(null); setQuery(""); setTab("structure"); setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); setSaveLocation("新建机床尚未保存;旧版本仍在本地机床库"); setMessage("已新建机床,原草稿可撤销恢复"); }; + const newDocument = () => { if (pending || followLive) return; edit(() => ({ coordinates, documentInfo: { name: "", description: "" }, parts: [], cOriginMm: [0, 0, 0], tiltOffsetMm: [0, 0, 0], physicalZero: zeroPose(coordinates) })); setSelectedId(null); setQuery(""); setTab("structure"); setTestPose(zeroPose(coordinates)); setSaveLocation("新建机床尚未保存;旧版本仍在本地机床库"); setMessage("已新建机床,原草稿可撤销恢复"); }; const disposeSources = (sources: Awaited>[]) => { const scenes = new Set(sources.flatMap((source) => source.restoredParts?.map((part) => part.scene) ?? [source.scene])); scenes.forEach(disposeMachineScene); }; 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 [jogStep, setJogStep] = useState(MANAGER_SETTINGS.initialJogStep); 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]), []); @@ -191,11 +263,12 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl 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 [testPose, setTestPose] = useState(() => zeroPose(coordinates)); 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]); + useEffect(() => { setTestPose(zeroPose(coordinates)); setFollowLive(false); }, [coordinates]); + useEffect(() => { if (!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 selectedAnalysis = useMemo(() => selected ? analyzeGlbFiveAxisScene(selected.scene, selected.filename) : null, [selected]); 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; @@ -205,7 +278,7 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl const importFiles = async (list: FileList | File[] | null, savedOnly = false) => { 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 results = await Promise.allSettled([...list].map(async (item) => { if (!item.name.toLowerCase().endsWith(".glb") || item.size > MAX_MACHINE_BYTES) throw new Error(`${item.name}:请选择不超过 64 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") { disposeSources(loaded); throw failure.reason; } @@ -219,6 +292,9 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl disposeSources(loaded); throw new Error("完整机床 GLB 请单独打开;普通部件可批量导入"); } + const autoAnalysis = new Map(loaded.map((source) => [source.id, source.restoredModel ? null : analyzeGlbFiveAxisScene(source.scene, source.filename)])); + const detectedCoordinates = loaded.length === 1 ? autoAnalysis.get(loaded[0].id)?.suggestedCoordinates : undefined; + const autoCoordinates = detectedCoordinates && isFiveAxisCoordinates(detectedCoordinates) ? detectedCoordinates : draft.coordinates; const nextDraft = (() => { const next = restored ? [] : [...draft.parts]; for (const source of loaded) { @@ -226,18 +302,41 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl 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 ?? {} }); + const analysis = autoAnalysis.get(source.id); + const nodeJoints = source.restoredJoints ?? (analysis ? Object.fromEntries(analysis.axes.filter((item) => autoCoordinates.includes(item.axis)).map((item) => [item.path, defaultDof(item.axis as CncAxis)])) : {}); + // A single complete GLB such as m1.glb keeps its native hierarchy. + // Promote the detected axis nodes to structure roles so the + // Structure & Joints tab is immediately usable, while preserving + // explicit roles restored from a managed machine document. + const detectedRoles: Record = analysis ? Object.fromEntries(analysis.axes.filter((item) => autoCoordinates.includes(item.axis)).map((item) => [item.path, (item.axis === "A" || item.axis === "B" ? "tilt" : item.axis.toLowerCase()) as MachinePartRole])) : {}; + const nodeRoles = source.restoredRoles ?? detectedRoles; + next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {}, nodeTransforms: {}, nodeJoints, nodeRoles }); } - return { ...draft, parts: next, ...(restored ? { coordinates: restored.coordinates, documentInfo: restoredSource?.restoredInfo ?? { name: loaded[0].filename.replace(/\.glb$/i, ""), description: "" }, cOriginMm: restored.calibration.cOriginMm, tiltOffsetMm: restored.calibration.tiltOffsetMm, physicalZero: restored.calibration.physicalZero as PhysicalZero } : {}) }; + return { ...draft, parts: next, ...(restored ? { coordinates: restored.coordinates, documentInfo: restoredSource?.restoredInfo ?? { name: loaded[0].filename.replace(/\.glb$/i, ""), description: "" }, cOriginMm: restored.calibration.cOriginMm, tiltOffsetMm: restored.calibration.tiltOffsetMm, physicalZero: restored.calibration.physicalZero as PhysicalZero } : { coordinates: autoCoordinates }) }; })(); edit(() => nextDraft); if (restored) setSavedSignature(nextDraft.coordinates + draftFingerprint(nextDraft)); - setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); setTab("structure"); setQuery(""); setSelectedId(loaded[0].restoredParts?.[0].id ?? loaded[0].id); setSelectedNodePath("scene"); if (restored) setSaveLocation(`已打开文件:${loaded[0].filename};可继续编辑并保存新版本`); setMessage(restored ? `已打开 ${restored.coordinates} 完整机床,恢复层级、关节和标定;原草稿可撤销恢复` : `已导入 ${loaded.length} 个部件`); + setTestPose(zeroPose(restored?.coordinates ?? autoCoordinates)); setTab("structure"); setQuery(""); setSelectedId(loaded[0].restoredParts?.[0].id ?? loaded[0].id); setSelectedNodePath("scene"); if (restored) setSaveLocation(`已打开文件:${loaded[0].filename};可继续编辑并保存新版本`); setMessage(restored ? `已打开 ${restored.coordinates} 完整机床,恢复层级、关节和标定;原草稿可撤销恢复` : `已导入 ${loaded.length} 个部件${detectedCoordinates && isFiveAxisCoordinates(detectedCoordinates) ? `;自动识别 ${detectedCoordinates},已绑定 ${autoAnalysis.get(loaded[0].id)?.axes.filter((item) => autoCoordinates.includes(item.axis)).length ?? 0}/${[...autoCoordinates].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 remove = (part: MachinePartDraft) => { + if (pending || followLive || !parts.some((item) => item.id === part.id)) return; + const index = parts.findIndex((item) => item.id === part.id); + const remaining = parts.filter((item) => item.id !== part.id); + // Remove this import only. Separately imported children remain in the draft. + 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(remaining[Math.min(index, remaining.length - 1)]?.id ?? null); + setSelectedNodePath("scene"); setQuery(""); + } + }; const selectTreeNode = (partId: string, path: string) => { setSelectedId(partId); setSelectedNodePath(path); }; + const selectModel = (partId: string) => { + if (pending || !parts.some((part) => part.id === partId)) return; + selectTreeNode(partId, "scene"); setQuery(""); setTab("structure"); + }; 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; @@ -270,7 +369,7 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl 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,请减小模型后重试"); + if (!(bytes instanceof ArrayBuffer) || bytes.byteLength > MAX_MACHINE_BYTES) throw new Error("保存文件超过控制面板支持的 64 MiB,请减小模型后重试"); const saved = inspectMachineGlb(bytes); if (saved.structure.coordinates !== coordinates) throw new Error("保存模型的机型与当前配置不一致"); const filename = machineDocumentFilename(documentInfo.name, coordinates); @@ -338,61 +437,73 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl 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; + const effectivePose: TestPose = followLive && liveReady ? { ...zeroPose(coordinates), ...Object.fromEntries([...coordinates].map((axis, i) => [axis.toLowerCase(), visual.joints[i] - Number(physicalZero[axis.toLowerCase()] ?? 0)])) } : 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 validation = useMemo(() => { try { validateManagerCalibration(coordinates, cOriginMm, tiltOffsetMm); 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 changeHistory = (direction: "undo" | "redo") => { + if (pending || followLive) return; + const next = direction === "undo" ? history.past.at(-1) : history.future[0]; + if (!next) return; + const restoredPart = next.parts.find((part) => !parts.some((current) => current.id === part.id)); + const nextId = restoredPart?.id ?? (next.parts.some((part) => part.id === selectedId) ? selectedId : next.parts[0]?.id ?? null); + if (nextId !== selectedId) { setSelectedId(nextId); setSelectedNodePath("scene"); setQuery(""); } + 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 poseInput = (axis: CncAxis) => { const key = axis.toLowerCase(); return ; }; 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 activeAxes = managerAxes(coordinates); + const profile = managerProfile(coordinates); const renderPartTree = (part: MachinePartDraft): React.ReactNode => child.parentId === part.id).map(renderPartTree)} />; return
filePickerOpen.current} onClose={() => 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 ? "新建机床" : savedSignature === signature ? "与保存版本一致" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用"}
-
{[...coordinates].map((axis) => { const binding = bindings.find((b) => b.dof.axis === axis); return ; })}
+ edit((draft) => changeMachineType(draft, selectedCase.coordinates)); setTab("structure"); setMessage(`已选择案例 ${selectedCase.label},设计机型同步为 ${selectedCase.coordinates}`); + }}>{FIVE_AXIS_CASES.map((item) => )}
2 · 结构与关节 → 3 · 验证与标定 → 4 · 保存 GLB{coordinates.length >= 5 ? `当前坐标 ${coordinates} · ${coordinates[3]} / ${coordinates[4]} 旋转轴` : "请从案例目录选择有效坐标"} · 控制面板当前 {controllerCoordinates}{coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : ""} · 其它源码类型见“文件与恢复”的案例库
+
{parts.length} 个模型 · {bindings.filter((b) => coordinates.includes(b.dof.axis)).length}/{activeAxes.length} 轴已绑定{!parts.length ? "新建机床" : savedSignature === signature ? "与保存版本一致" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用"}
+
{activeAxes.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{library.length > 0 && }
} -

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

+ {open && } + {!parts.length &&
建立机床的数字装配

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

支持完整层级 · 单文件不超过 64 MiB{library.length > 0 && }
} +

单击选择模型 · 拖动旋转 · 滚轮缩放 · 右键平移 · 黄色框为选中模型

+ {parts.length > 0 &&
{parts.map((part) =>
)}
}
{([ ["structure", "结构与关节"], ["motion", "运动验证"], ["calibration", "机床标定"], ["files", "文件与恢复"] ] as const).map(([id, label], index) => )}
- - + +