134 lines
10 KiB
TypeScript
134 lines
10 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 MachinePartDraft = MachinePartSource & {
|
|
role: MachinePartRole | "";
|
|
parentId: string | null;
|
|
visible: boolean;
|
|
showOrigin: boolean;
|
|
originMm: Vec3;
|
|
translationMm: Vec3;
|
|
rotationDeg: Vec3;
|
|
};
|
|
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 }>;
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
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}旋转`);
|
|
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 }; }),
|
|
};
|
|
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 visual = cloneMachinePartScene(part.scene); visual.position.fromArray(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 } };
|
|
}
|