Files
linuxcncWebWASM/web/react/src/machine-manager.ts

211 lines
16 KiB
TypeScript

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<string, string | null>;
nodeRoles: Record<string, MachinePartRole | "">;
nodeTransforms: Record<string, MachineNodeTransform>;
};
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<string, string | null>; nodeRoles: Record<string, MachinePartRole | "">; nodeTransforms: Record<string, MachineNodeTransform> }>;
};
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<string, unknown>)[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<string, THREE.Object3D>(), paths = new WeakMap<THREE.Object3D, string>();
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<string, string | null>) {
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<MachinePartDraft, "nodeParents" | "nodeTransforms">) {
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<MachinePartDraft, "filename" | "scene" | "nodeParents" | "nodeTransforms">): 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<MachinePartSource> {
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<MachinePartRole, MachinePartDraft>();
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[] = []) => ` <joint name="${name}" type="${type}">\n <parent link="${parent}"/><child link="${child}"/>\n <origin xyz="${origin.join(" ")}" rpy="0 0 0"/>${axis ? `\n <axis xyz="${axis}"/><cnc:axis coordinate="${coordinate}"/>\n <limit lower="${limits[0]}" upper="${limits[1]}" velocity="${limits[2]}" effort="1000"/>` : ""}\n </joint>`;
return `<?xml version="1.0"?>\n<robot name="managed_${coordinates.toLowerCase()}" xmlns:cnc="urn:linuxcnc:web:machine:1">\n <cnc:machine profile="trt-xyz-head/1" base="base" tool="tool" work="work"/>\n${["base", "x", "y", "z", "tool", "tilt", "c", "work"].map((name) => ` <link name="${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</robot>\n`;
}
export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero }): Promise<ImportedMachine> {
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<MachinePartRole, THREE.Group>;
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 } };
}