import * as THREE from "three"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-urdf"; import type { ImportedMachine } from "./imported-machine"; export type MachinePartRole = "base" | "x" | "y" | "z" | "tool" | "tilt" | "c" | "work"; export type MachinePartSource = { id: string; filename: string; hash: string; scene: THREE.Group }; export type MachineNodeTransform = { translationMm: Vec3; rotationDeg: Vec3 }; export type MachinePartDraft = MachinePartSource & { role: MachinePartRole | ""; parentId: string | null; visible: boolean; showOrigin: boolean; originMm: Vec3; translationMm: Vec3; rotationDeg: Vec3; nodeParents: Record; nodeRoles: Record; nodeTransforms: Record; }; export type MachineTreeNode = { path: string; name: string; type: "group" | "mesh"; originMm: Vec3; worldOriginMm: Vec3; rotationDeg: Vec3; children: MachineTreeNode[] }; export type PhysicalZero = { x: number; y: number; z: number; a: number; b: number; c: number }; export type ManagedMachineGeometry = { schema: "linuxcnc.machine-manager/1"; coordinates: "XYZAC" | "XYZBC"; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero; parts: Array<{ id: string; filename: string; hash: string; role: MachinePartRole; parentId: string | null; originMm: Vec3; translationMm: Vec3; rotationDeg: Vec3; visible: boolean; nodeParents: Record; nodeRoles: Record; nodeTransforms: Record }>; }; export const MACHINE_PART_ROLES: MachinePartRole[] = ["base", "x", "y", "z", "tool", "tilt", "c", "work"]; export const roleLabel = (role: MachinePartRole, coordinates: string) => ({ base: "床身底座", x: "X 轴部件", y: "Y 轴部件", z: "Z 轴部件", tool: "主轴/刀具", tilt: `${coordinates === "XYZBC" ? "B" : "A"} 轴部件`, c: "C 轴部件", work: "工件/工作台" }[role]); export const expectedParentRole = (role: MachinePartRole): MachinePartRole | null => ({ base: null, x: "base", y: "x", z: "y", tool: "z", tilt: "base", c: "tilt", work: "c" }[role] as MachinePartRole | null); const finite3 = (value: Vec3, label: string) => { if (value.length !== 3 || value.some((number) => !Number.isFinite(number))) throw new Error(`${label}需要三个有限数值`); }; const cloneMaterial = (material: THREE.Material) => { const cloned = material.clone(); for (const [key, value] of Object.entries(cloned)) if (value instanceof THREE.Texture) (cloned as unknown as Record)[key] = value.clone(); return cloned; }; export function cloneMachinePartScene(source: THREE.Object3D) { const clone = source.clone(true); clone.traverse((node) => { const mesh = node as THREE.Mesh; if (mesh.geometry) mesh.geometry = mesh.geometry.clone(); if (mesh.material) mesh.material = Array.isArray(mesh.material) ? mesh.material.map(cloneMaterial) : cloneMaterial(mesh.material); }); return clone; } function scenePathMap(scene: THREE.Object3D) { const nodes = new Map(), paths = new WeakMap(); const visit = (node: THREE.Object3D, path: string) => { nodes.set(path, node); paths.set(node, path); node.children.forEach((child, index) => visit(child, `${path}/${index}`)); }; visit(scene, "scene"); return { nodes, paths }; } export function applyMachineNodeParents(scene: THREE.Object3D, overrides: Record) { const { nodes } = scenePathMap(scene); for (const [sourcePath, targetPath] of Object.entries(overrides ?? {})) { const source = nodes.get(sourcePath), target = targetPath === null ? scene : nodes.get(targetPath); if (!source || source === scene || !target || source === target) throw new Error("GLB 层级拖放目标无效"); let cursor: THREE.Object3D | null = target; while (cursor) { if (cursor === source) throw new Error("GLB 层级不能形成循环"); cursor = cursor.parent; } scene.updateMatrixWorld(true); target.attach(source); scene.updateMatrixWorld(true); } return scene; } // Index source paths before reparenting so edits and axis bindings keep their identity. export function configureMachinePartScene(scene: THREE.Object3D, part: Pick) { const { nodes, paths } = scenePathMap(scene); applyMachineNodeParents(scene, part.nodeParents ?? {}); for (const [path, transform] of Object.entries(part.nodeTransforms ?? {})) { const node = nodes.get(path); if (!node || node === scene) throw new Error("部件变换节点无效"); finite3(transform.translationMm, "节点相对父级位置"); finite3(transform.rotationDeg, "节点相对父级姿态"); node.position.fromArray(transform.translationMm).multiplyScalar(.001); node.rotation.set(...transform.rotationDeg.map(THREE.MathUtils.degToRad) as Vec3, "XYZ"); node.updateMatrix(); } scene.updateMatrixWorld(true); return { scene, nodes, paths }; } export function reparentMachineNode(part: MachinePartDraft, sourcePath: string, targetPath: string) { const { scene, nodes } = configureMachinePartScene(part.scene.clone(true), part); const source = nodes.get(sourcePath), target = nodes.get(targetPath); if (!source || source === scene || !target || source === target) throw new Error("GLB 层级拖放目标无效"); for (let cursor: THREE.Object3D | null = target; cursor; cursor = cursor.parent) { if (cursor === source) throw new Error("GLB 层级不能形成循环"); } target.attach(source); // Capture edited local poses after attach: changing parent preserves the current // assembly pose, and subsequent numeric edits use the new parent's frame. const nodeTransforms = Object.fromEntries([...nodes].filter(([path]) => path !== "scene").map(([path, node]) => [path, { translationMm: node.position.toArray().map((value) => value * 1000) as Vec3, rotationDeg: new THREE.Euler().setFromQuaternion(node.quaternion, "XYZ").toArray().slice(0, 3).map((value) => THREE.MathUtils.radToDeg(Number(value))) as Vec3, }])); return { nodeParents: { ...part.nodeParents, [sourcePath]: targetPath === "scene" ? null : targetPath }, nodeTransforms }; } export function machineSceneNode(scene: THREE.Object3D, path: string) { return scenePathMap(scene).nodes.get(path) ?? null; } export function machinePartTree(part: Pick): MachineTreeNode { const { scene, paths } = configureMachinePartScene(part.scene.clone(true), part); scene.updateMatrixWorld(true); const build = (node: THREE.Object3D): MachineTreeNode => ({ path: paths.get(node)!, name: node.name || (paths.get(node) === "scene" ? part.filename : `${(node as THREE.Mesh).isMesh ? "Mesh" : "Node"} ${paths.get(node)!.split("/").at(-1)}`), type: (node as THREE.Mesh).isMesh ? "mesh" : "group", originMm: node.position.toArray().map((value) => value * 1000) as Vec3, worldOriginMm: node.getWorldPosition(new THREE.Vector3()).toArray().map((value) => value * 1000) as Vec3, rotationDeg: new THREE.Euler().setFromQuaternion(node.quaternion, "XYZ").toArray().slice(0, 3).map((value) => THREE.MathUtils.radToDeg(Number(value))) as Vec3, children: node.children.map(build), }); return build(scene); } export async function loadMachinePart(bytes: ArrayBuffer, filename: string): Promise { if (!bytes.byteLength) throw new Error("GLB 部件为空"); const manager = new THREE.LoadingManager(); manager.setURLModifier((url) => { if (!url.startsWith("blob:")) throw new Error("GLB 部件不能加载外部资源"); return url; }); const gltf = await new GLTFLoader(manager).parseAsync(bytes, ""); const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((number) => number.toString(16).padStart(2, "0")).join(""); gltf.scene.traverse((node) => { if ((node as THREE.Mesh).isMesh) { node.castShadow = true; node.receiveShadow = true; } }); return { id: crypto.randomUUID(), filename, hash, scene: gltf.scene }; } export function validateMachineAssembly(coordinates: "XYZAC" | "XYZBC", parts: MachinePartDraft[]) { if (parts.length !== MACHINE_PART_ROLES.length) throw new Error("应用前需要正好八个机床角色部件"); const byRole = new Map(); const ids = new Set(parts.map((part) => part.id)); if (ids.size !== parts.length) throw new Error("部件 ID 重复"); for (const part of parts) { if (!part.role) throw new Error(`${part.filename} 尚未指定机床角色`); if (byRole.has(part.role)) throw new Error(`${roleLabel(part.role, coordinates)}角色重复`); finite3(part.originMm, `${part.filename}局部原点`); finite3(part.translationMm, `${part.filename}平移`); finite3(part.rotationDeg, `${part.filename}旋转`); for (const [path, transform] of Object.entries(part.nodeTransforms ?? {})) { if (path === "scene") throw new Error(`${part.filename} 的 scene 节点不能重复定义部件变换`); finite3(transform.translationMm, `${part.filename}/${path} 相对父级位置`); finite3(transform.rotationDeg, `${part.filename}/${path} 相对父级姿态`); } byRole.set(part.role, part); } for (const role of MACHINE_PART_ROLES) if (!byRole.has(role)) throw new Error(`缺少${roleLabel(role, coordinates)}`); for (const [role, part] of byRole) { const parentRole = expectedParentRole(role); if (parentRole === null) { if (part.parentId !== null) throw new Error("床身底座不能设置父部件"); continue; } const expected = byRole.get(parentRole)!; if (part.parentId !== expected.id) throw new Error(`${roleLabel(role, coordinates)}必须挂到${roleLabel(parentRole, coordinates)}`); } return byRole; } function machineUrdf(coordinates: "XYZAC" | "XYZBC", cOriginMm: Vec3, tiltOffsetMm: Vec3) { finite3(cOriginMm, "C 轴原点"); finite3(tiltOffsetMm, "A/B 轴偏置"); if (coordinates === "XYZAC" && Math.abs(tiltOffsetMm[0]) > 1e-9) throw new Error("AC 原生 TRT 不支持沿 A 轴的 X 偏置"); if (coordinates === "XYZBC" && Math.abs(tiltOffsetMm[1]) > 1e-9) throw new Error("BC 原生 TRT 不支持沿 B 轴的 Y 偏置"); const metres = (values: Vec3) => values.map((value) => value / 1000) as Vec3; const p = metres(cOriginMm), d = metres(tiltOffsetMm); const t = p.map((value, index) => value + d[index]) as Vec3; const u = d.map((value) => -value) as Vec3; const work = p.map((value) => -value) as Vec3; const tilt = coordinates === "XYZAC" ? "A" : "B"; const tiltAxis = coordinates === "XYZAC" ? "1 0 0" : "0 1 0"; const radians = (degrees: number) => degrees * Math.PI / 180; const joint = (name: string, type: string, parent: string, child: string, origin: Vec3, axis = "", coordinate = "", limits: number[] = []) => ` \n \n ${axis ? `\n \n ` : ""}\n `; return `\n\n \n${["base", "x", "y", "z", "tool", "tilt", "c", "work"].map((name) => ` `).join("\n")}\n${[ joint("x_joint", "prismatic", "base", "x", [0, 0, 0], "1 0 0", "X", [-.2, .2, .02]), joint("y_joint", "prismatic", "x", "y", [0, 0, 0], "0 1 0", "Y", [-.1, .1, .02]), joint("z_joint", "prismatic", "y", "z", [0, 0, 0], "0 0 1", "Z", [-.12, .12, .02]), joint("tool_mount", "fixed", "z", "tool", [0, 0, 0]), joint("tilt_joint", "revolute", "base", "tilt", t, tiltAxis, tilt, [radians(coordinates === "XYZAC" ? -100 : -36000), radians(coordinates === "XYZAC" ? 50 : 36000), radians(30)]), joint("c_joint", "revolute", "tilt", "c", u, "0 0 1", "C", [radians(-36000), radians(36000), radians(30)]), joint("work_mount", "fixed", "c", "work", work), ].join("\n")}\n\n`; } export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero }): Promise { const byRole = validateMachineAssembly(input.coordinates, input.parts); for (const [axis, value] of Object.entries(input.physicalZero)) if (!Number.isFinite(value)) throw new Error(`${axis.toUpperCase()} 物理零点无效`); const urdf = machineUrdf(input.coordinates, input.cOriginMm, input.tiltOffsetMm); const structure: MachineStructure = parseMachineUrdf(urdf); const metadata: ManagedMachineGeometry = { schema: "linuxcnc.machine-manager/1", coordinates: input.coordinates, cOriginMm: [...input.cOriginMm], tiltOffsetMm: [...input.tiltOffsetMm], physicalZero: { ...input.physicalZero }, parts: MACHINE_PART_ROLES.map((role) => { const part = byRole.get(role)!; return { id: part.id, filename: part.filename, hash: part.hash, role, parentId: part.parentId, originMm: [...part.originMm], translationMm: [...part.translationMm], rotationDeg: [...part.rotationDeg], visible: part.visible, nodeParents: { ...(part.nodeParents ?? {}) }, nodeRoles: { ...(part.nodeRoles ?? {}) }, nodeTransforms: Object.fromEntries(Object.entries(part.nodeTransforms ?? {}).map(([path, transform]) => [path, { translationMm: [...transform.translationMm] as Vec3, rotationDeg: [...transform.rotationDeg] as Vec3 }])) }; }), }; const scene = new THREE.Group(); scene.name = `Managed_${input.coordinates}`; scene.userData.linuxcncMachine = { schema: "linuxcnc.machine/1", urdf }; scene.userData.linuxcncMachineGeometry = metadata; const frame = new THREE.Group(); frame.name = "urdf_frame"; frame.rotation.x = -Math.PI / 2; scene.add(frame); const links = Object.fromEntries(MACHINE_PART_ROLES.map((role) => { const node = new THREE.Group(); node.name = role; node.userData.link = role; return [role, node]; })) as Record; frame.add(links.base); links.base.add(links.x, links.tilt); links.x.add(links.y); links.y.add(links.z); links.z.add(links.tool); links.tilt.add(links.c); links.c.add(links.work); for (const joint of structure.joints) links[joint.child as MachinePartRole].position.fromArray(joint.origin); for (const role of MACHINE_PART_ROLES) { const part = byRole.get(role)!; const placement = new THREE.Group(); placement.name = `component_${part.id}`; placement.visible = part.visible; placement.position.fromArray(part.translationMm).multiplyScalar(.001); placement.rotation.set(...part.rotationDeg.map(THREE.MathUtils.degToRad) as Vec3); placement.userData.machinePart = { role, filename: part.filename, hash: part.hash, originMm: [...part.originMm] }; const { scene: visual } = configureMachinePartScene(cloneMachinePartScene(part.scene), part); visual.position.add(new THREE.Vector3(...part.originMm).multiplyScalar(-.001)); placement.add(visual); links[role].add(placement); } links.base.userData.linuxcncMachineGeometry = metadata; const signature = JSON.stringify({ metadata, hashes: MACHINE_PART_ROLES.map((role) => byRole.get(role)!.hash) }); const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(signature)))].map((number) => number.toString(16).padStart(2, "0")).join(""); return { scene, structure, hash, filename: `managed-${input.coordinates.toLowerCase()}.glb`, physicalZero: { ...input.physicalZero } }; }