part.showOrigin).length} data-transform-mode={mode} aria-label="三维机床装配预览,机床 Z 轴朝上" />{fault && }
part.showOrigin).length} aria-label="三维机床装配预览,机床 Z 轴朝上" />{fault && }
;
}
function TreeNode({ part, node, selected, onSelect, onDrop, extra }: { 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 }) {
const key = `${part.id}:${node.path}`;
const [expanded, setExpanded] = useState(node.path === "scene");
const hasChildren = node.children.length > 0 || Boolean(extra);
- const binding = node.path === "scene" ? part.role : part.nodeRoles?.[node.path];
+ const binding = part.nodeJoints?.[node.path]?.axis ?? (node.path === "scene" ? part.role : part.nodeRoles?.[node.path]);
return
{hasChildren && {node.children.map((child) => )}{extra}
}
;
}
-export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
+export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }: Props) {
const [open, setOpen] = useState(false), [pending, setPending] = useState(false), [message, setMessage] = useState("");
const [parts, setParts] = useState
([]); const partsRef = useRef(parts); partsRef.current = parts;
- const [selectedId, setSelectedId] = useState(null); const [selectedNodePath, setSelectedNodePath] = useState("scene"); const [mode, setMode] = useState<"translate" | "rotate">("translate");
+ const [selectedId, setSelectedId] = useState(null); const [selectedNodePath, setSelectedNodePath] = useState("scene");
const [cOriginMm, setCOriginMm] = useState([0, 0, 0]); const [tiltOffsetMm, setTiltOffsetMm] = useState([0, 0, 0]);
const [physicalZero, setPhysicalZero] = useState({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); const file = useRef(null);
const [testPose, setTestPose] = useState({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 });
@@ -143,30 +138,27 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
const selectedTreeNode = selected ? findTreeNode(trees.get(selected.id), selectedNodePath) : null;
const update = (id: string, values: Partial) => setParts((current) => current.map((part) => part.id === id ? { ...part, ...values } : part));
const vectorInput = (label: string, value: Vec3, set: (value: Vec3) => void, unit: string, control?: string) => ;
- const updateSelectedTransform = (field: "translationMm" | "rotationDeg", value: Vec3) => {
- if (!selected || !selectedTreeNode) return;
- if (selectedNodePath === "scene") { update(selected.id, { [field]: value }); return; }
- setParts((current) => current.map((part) => {
- if (part.id !== selected.id) return part;
- const currentTransform = part.nodeTransforms?.[selectedNodePath] ?? { translationMm: selectedTreeNode!.originMm, rotationDeg: selectedTreeNode!.rotationDeg };
- return { ...part, nodeTransforms: { ...(part.nodeTransforms ?? {}), [selectedNodePath]: { ...currentTransform, [field]: value } } };
- }));
- };
const importFiles = async (list: FileList | File[] | null) => {
if (!list?.length) return; setPending(true); setMessage("正在读取 GLB 部件…");
try {
const loaded = await Promise.all([...list].map(async (item) => loadMachinePart(await item.arrayBuffer(), item.name)));
+ if (loaded.some((source) => source.restoredModel && source.restoredModel.coordinates !== coordinates)) {
+ loaded.forEach((source) => disposeMachineScene(source.scene));
+ throw new Error(`导入机型不同,请先切换到 ${loaded.find((source) => source.restoredModel?.coordinates !== coordinates && source.restoredModel)?.restoredModel?.coordinates}`);
+ }
setParts((current) => {
const next = [...current];
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: {}, nodeRoles: {}, nodeTransforms: {} });
+ next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {}, nodeTransforms: {}, nodeJoints: source.restoredJoints ?? {}, nodeRoles: source.restoredRoles ?? {} });
}
return next;
});
- setSelectedId(loaded[0].id); setSelectedNodePath("scene"); setMessage(`已导入 ${loaded.length} 个部件`);
+ const restored = loaded.find((source) => source.restoredModel)?.restoredModel;
+ if (restored) { setCOriginMm(restored.calibration.cOriginMm); setTiltOffsetMm(restored.calibration.tiltOffsetMm); setPhysicalZero(restored.calibration.physicalZero as PhysicalZero); setMessage(`已恢复 ${restored.coordinates} 单自由度配置`); }
+ 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); }
};
@@ -199,7 +191,9 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
setPending(true); setMessage("正在生成包含 userData 的组合 GLB…"); let machine: ImportedMachine | null = null;
try {
machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero });
- const bytes = await new GLTFExporter().parseAsync(machine.scene, { binary: true, trs: true, onlyVisible: false });
+ 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 });
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);
setMessage("组合 GLB 已导出,底座和整机场景包含机床标定 userData");
@@ -207,23 +201,28 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
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 selectedBinding = selected ? selectedNodePath === "scene" ? selected.role : selected.nodeRoles?.[selectedNodePath] ?? "" : "";
- const assignTreeRole = (role: MachinePartRole | "") => {
+ const selectedDof = selected ? draftNodeDof(selected, selectedNodePath, coordinates) : null;
+ const setDof = (dof: JointDof | null) => {
if (!selected) return;
- setParts((current) => current.map((part) => {
- const nodeRoles = Object.fromEntries(Object.entries(part.nodeRoles ?? {}).filter(([, value]) => !role || value !== role));
- let partRole = role && part.role === role ? "" : part.role;
- if (part.id === selected.id) {
- if (selectedNodePath === "scene") partRole = role;
- else { delete nodeRoles[selectedNodePath]; if (role) nodeRoles[selectedNodePath] = role; }
- }
- return { ...part, role: partRole, nodeRoles } as MachinePartDraft;
- }));
+ 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 poseInput = (axis: keyof TestPose, unit: string) => ;
+ const [followLive, setFollowLive] = useState(false);
+ const visual = useMemo(() => projectXyzacVisual(snapshot), [snapshot]);
+ const liveReady = visual.authoritative && visual.machineModel?.coordinate_system === coordinates;
+ 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]])) } : testPose;
+ const validation = useMemo(() => { try { 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 exportJson = () => {
+ if (!validation.model) 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);
+ };
+ 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);
@@ -231,18 +230,21 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
return
setOpen(false)}>
- {parts.length}/8 部件
+ {parts.length} 个导入模型 · 每个运动节点一个自由度
- {open &&
update(id, { translationMm, rotationDeg })} onMotionState={receiveMotionState} />}
+ {open && }
部件与层级
{parts.length ? {parts.filter((part) => !part.parentId).map(renderPartTree)}
: 导入机床或部件后显示 GLB 内部层级。
}
- {selected &&
{selected.filename}
{selectedTreeNode && {selectedTreeNode.name}节点自身局部原点装配位置(相对父节点,mm)装配姿态(相对父节点,deg){selectedDriven && <>当前世界原点(mm)>}
}{selectedNodePath === "scene" && vectorInput("导入文件装配原点", selected.originMm, (originMm) => update(selected.id, { originMm }), "mm", "machine-manager-origin")}
- {selectedTransform && <>编辑对象:{selectedTreeNode?.name}。数值为相对父级的位置和 XYZ 欧拉角;旋转中心为本节点原点。
{vectorInput("部件平移(相对父级位置)", selectedTransform.translationMm, (value) => updateSelectedTransform("translationMm", value), "mm", "machine-manager-translation")}{vectorInput("部件旋转(相对父级姿态)", selectedTransform.rotationDeg, (value) => updateSelectedTransform("rotationDeg", value), "deg", "machine-manager-rotation")}>}}
+ {selected &&
{selected.filename}
{selectedTreeNode && {selectedTreeNode.name}节点自身局部原点装配位置(相对父节点,mm)装配姿态(相对父节点,deg){selectedDriven && <>当前世界原点(mm)>}
}
+ {selectedDof && <>>}
+ 当前节点:{selectedTreeNode?.name}。{selectedDof ? `仅允许 ${selectedDof.axis} ${isLinear(selectedDof.axis) ? "平移" : "旋转"},沿父级 ${selectedDof.direction.toUpperCase()} 方向;以导入装配姿态为零位。` : "固定节点随父级运动;请先配置唯一自由度。"}
+
+ }
- 层级运动测试
{(["x", "y", "z"] as const).map((axis) => poseInput(axis, "mm"))}{poseInput(coordinates === "XYZBC" ? "b" : "a", "deg")}{poseInput("c", "deg")}
+ 层级运动测试
{(["x", "y", "z"] as const).map((axis) => poseInput(axis, "mm"))}{poseInput(coordinates === "XYZBC" ? "b" : "a", "deg")}{poseInput("c", "deg")}
机床标定
{vectorInput("C 轴原点", cOriginMm, setCOriginMm, "mm")}{vectorInput(`${coordinates === "XYZBC" ? "B" : "A"} 到 C 轴偏置`, tiltOffsetMm, setTiltOffsetMm, "mm")}
-
+
{ const files = event.target.files ? [...event.target.files] : []; event.target.value = ""; void importFiles(files); }} />
;
diff --git a/web/react/src/XyzacMachineView.tsx b/web/react/src/XyzacMachineView.tsx
index 519a805..98d0937 100644
--- a/web/react/src/XyzacMachineView.tsx
+++ b/web/react/src/XyzacMachineView.tsx
@@ -26,11 +26,12 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
useEffect(() => {
const element = host.current;
if (!element) return;
- const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
+ const managedJoints = Boolean(importedMachine?.scene.userData.linuxcncJointModel);
+ const renderer = new THREE.WebGLRenderer({ antialias: !managedJoints, alpha: false, preserveDrawingBuffer: !managedJoints });
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, VIEWER.pixel_ratio_limit));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
- renderer.shadowMap.enabled = true;
+ renderer.shadowMap.enabled = !managedJoints;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.domElement.dataset.engine = "three.js r185 / xyzac-trt-vismach";
element.appendChild(renderer.domElement);
@@ -151,6 +152,11 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
const render = () => {
const state = visualRef.current, config = settings.current;
assembly.update(state);
+ if (assembly.machine.userData.linuxcncJointState) {
+ element.dataset.machineJointState = JSON.stringify(assembly.machine.userData.linuxcncJointState);
+ element.dataset.machineJointTick = String(state.tick);
+ element.dataset.machineJointInputs = JSON.stringify(state.joints);
+ }
const paths = (config.toolpath ?? state.toolpath) as Segment[];
pathGroup.position.copy(assembly.workOrigin.position);
liveGroup.position.copy(assembly.workOrigin.position);
diff --git a/web/react/src/imported-machine.ts b/web/react/src/imported-machine.ts
index e089a41..0339245 100644
--- a/web/react/src/imported-machine.ts
+++ b/web/react/src/imported-machine.ts
@@ -1,4 +1,5 @@
import * as THREE from "three";
+import { bindJointModel, validateJointModel, type JointModel, type AxisValues } from "./machine-joints";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { inspectMachineGlb, parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-urdf";
import { WEB_SETTINGS } from "../../workspace/web-settings.mjs";
@@ -18,7 +19,14 @@ export async function loadImportedMachine(bytes: ArrayBuffer, filename: string):
const gltf = await new GLTFLoader(manager).parseAsync(bytes, "");
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((n) => n.toString(16).padStart(2, "0")).join("");
gltf.scene.traverse((node) => { if ((node as THREE.Mesh).isMesh) { node.castShadow = true; node.receiveShadow = true; } });
- return { scene: gltf.scene, structure, hash, filename };
+ const model = gltf.scene.userData.linuxcncJointModel as JointModel | undefined;
+ if (model) {
+ validateJointModel(model); if (model.coordinates !== structure.coordinates) throw new Error("关节 JSON 与 URDF 机型不一致");
+ bindJointModel(gltf.scene, model);
+ }
+ const physicalZero = model?.calibration.physicalZero ?? gltf.scene.userData.linuxcncMachineGeometry?.physicalZero;
+ if (physicalZero && Object.values(physicalZero).some((value) => typeof value !== "number" || !Number.isFinite(value))) throw new Error("物理零点无效");
+ return { scene: gltf.scene, structure, hash, filename, physicalZero };
}
function validateImportedConfiguration(bundle: { entrypoint: string; files: Record }, structure: MachineStructure) {
const config = readTrtConfiguration(bundle);
@@ -68,11 +76,22 @@ export function updateImportedMachineGeometry(asset: ImportedMachine, bundle: {
export function buildImportedMachine(asset: ImportedMachine) {
const scene = asset.scene.clone(true);
scene.userData.linuxcncMachine = { ...scene.userData.linuxcncMachine, urdf: asset.structure.urdf };
+ const jointModel = scene.userData.linuxcncJointModel as JointModel | undefined;
+ const jointDriver = jointModel ? bindJointModel(scene, jointModel) : null;
+ const physicalZero = asset.physicalZero ?? jointModel?.calibration.physicalZero ?? scene.userData.linuxcncMachineGeometry?.physicalZero ?? {};
const managed = scene.userData.linuxcncMachineGeometry;
if (managed?.schema === "linuxcnc.machine-manager/1") {
const clean = (value: number) => Object.is(value, -0) ? 0 : value;
const effective = { ...managed, cOriginMm: [asset.structure.offsets.x_rot_point, asset.structure.offsets.y_rot_point, asset.structure.offsets.z_rot_point].map(clean), tiltOffsetMm: [asset.structure.offsets.x_offset, asset.structure.offsets.y_offset, asset.structure.offsets.z_offset].map(clean), physicalZero: { ...(asset.physicalZero ?? managed.physicalZero) } };
scene.userData.linuxcncMachineGeometry = effective;
+ if (jointModel) {
+ const effectiveJoints: JointModel = { ...jointModel, calibration: { cOriginMm: effective.cOriginMm as Vec3, tiltOffsetMm: effective.tiltOffsetMm as Vec3, physicalZero: { ...physicalZero } } };
+ scene.userData.linuxcncJointModel = effectiveJoints;
+ scene.traverse((node) => {
+ if (node.userData.linuxcncJointModel) node.userData.linuxcncJointModel = effectiveJoints;
+ if (node.userData.linuxcncMachineGeometry) node.userData.linuxcncMachineGeometry = effective;
+ });
+ }
const base = scene.getObjectByName(asset.structure.base); if (base) base.userData.linuxcncMachineGeometry = effective;
}
// Include fixed work-frame joints as well as moving pivots. The source GLB
@@ -98,11 +117,12 @@ export function buildImportedMachine(asset: ImportedMachine) {
const update = (state: Pick) => {
for (const { joint, node: link, axis } of moving) {
const coordinate = joint.coordinate.toLowerCase() as "x" | "y" | "z" | "a" | "b" | "c";
- const value = state.joints[asset.structure.coordinates.indexOf(joint.coordinate)] - Number(asset.physicalZero?.[coordinate] ?? 0);
+ const value = state.joints[asset.structure.coordinates.indexOf(joint.coordinate)] - Number(physicalZero[coordinate] ?? 0);
link.position.fromArray(joint.origin); link.quaternion.identity();
if (joint.type === "prismatic") link.position.addScaledVector(axis, value / 1000);
else link.quaternion.setFromAxisAngle(axis, value * Math.PI / 180);
}
+ if (jointDriver) jointDriver.update(Object.fromEntries([...asset.structure.coordinates].map((axis, i) => [axis, state.joints[i]])) as AxisValues, physicalZero);
const length = state.offsets.tool_offset / 1000;
toolTip.position.set(0, 0, -length);
cutter.visible = Math.abs(length) > 1e-9; cutter.scale.y = Math.abs(length); cutter.position.z = -length / 2;
@@ -110,6 +130,7 @@ export function buildImportedMachine(asset: ImportedMachine) {
// the master-tool difference along local C-Z, while physical pivots stay fixed.
workOrigin.position.copy(originalWork); workOrigin.position.z -= length;
machine.updateMatrixWorld(true);
+ if (jointDriver) machine.userData.linuxcncJointState = jointDriver.state();
};
return { machine, toolTip, workOrigin, joints: { rotate }, update, dispose: () => { cutter.geometry.dispose(); cutter.material.dispose(); } };
}
diff --git a/web/react/src/machine-joints.ts b/web/react/src/machine-joints.ts
new file mode 100644
index 0000000..35f9318
--- /dev/null
+++ b/web/react/src/machine-joints.ts
@@ -0,0 +1,88 @@
+import * as THREE from "three";
+import type { Vec3 } from "./machine-urdf";
+
+export type CncAxis = "X" | "Y" | "Z" | "A" | "B" | "C";
+export type JointDof = { axis: CncAxis; direction: "x" | "y" | "z"; sign: 1 | -1 };
+export type AxisValues = Partial>;
+export type JointZero = { positionMm: Vec3; quaternion: [number, number, number, number]; scale: Vec3 };
+export type JointRecord = {
+ id: string; parentId: string | null; name: string; role: string;
+ source: { file: string; hash: string; path: string };
+ zero: JointZero;
+ joint: (JointDof & { dof: 1; type: "prismatic" | "revolute"; unit: "mm" | "deg"; frame: "parent" }) | null;
+};
+export type JointModel = {
+ schema: "linuxcnc.joint-model/1";
+ coordinates: "XYZAC" | "XYZBC";
+ units: { length: "mm"; angle: "deg"; gltfLength: "m" };
+ controller: { source: "linuxcnc-wasm.visual.joints"; axisOrder: CncAxis[] };
+ calibration: { cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: Record };
+ axisMap: Partial>;
+ nodes: JointRecord[];
+};
+export const JOINT_MODEL_SCHEMA = "linuxcnc.joint-model/1";
+export const isLinear = (axis: CncAxis) => "XYZ".includes(axis);
+export const defaultDof = (axis: CncAxis): JointDof => ({ axis, direction: ({ X: "x", Y: "y", Z: "z", A: "x", B: "y", C: "z" } as const)[axis], sign: 1 });
+export const jointZero = (node: THREE.Object3D): JointZero => ({ positionMm: node.position.toArray().map((v) => v * 1000) as Vec3, quaternion: node.quaternion.toArray(), scale: node.scale.toArray() });
+
+// Absolute evaluation from a stored zero pose: no incremental accumulation.
+// Direction is expressed in the parent frame; a rotation leaves the pivot at p0.
+export function driveJoint(node: THREE.Object3D, zero: JointZero, dof: JointDof | null, value: number, physicalZero = 0) {
+ if (dof && ![value, physicalZero].every(Number.isFinite)) throw new Error(`轴 ${dof.axis} 位置无效`);
+ node.position.fromArray(zero.positionMm).multiplyScalar(.001);
+ node.quaternion.fromArray(zero.quaternion); node.scale.fromArray(zero.scale);
+ if (dof) {
+ const direction = new THREE.Vector3(dof.direction === "x" ? 1 : 0, dof.direction === "y" ? 1 : 0, dof.direction === "z" ? 1 : 0);
+ const q = dof.sign * (value - physicalZero);
+ if (isLinear(dof.axis)) node.position.addScaledVector(direction, q / 1000);
+ else node.quaternion.premultiply(new THREE.Quaternion().setFromAxisAngle(direction, THREE.MathUtils.degToRad(q)));
+ }
+ node.updateMatrix();
+}
+
+export function validateJointModel(model: JointModel, complete = true) {
+ const fail = (message: string): never => { throw new Error(`关节配置无效:${message}`); };
+ if (!model || model.schema !== JOINT_MODEL_SCHEMA || !["XYZAC", "XYZBC"].includes(model.coordinates)) fail("schema 或机型错误");
+ if (model.units?.length !== "mm" || model.units?.angle !== "deg" || model.units?.gltfLength !== "m" || model.controller?.source !== "linuxcnc-wasm.visual.joints" || model.controller.axisOrder.join("") !== model.coordinates) fail("控制器来源、单位或轴顺序错误");
+ if (!Array.isArray(model.nodes) || !model.nodes.length || model.nodes.length > 12000) fail("节点数量错误");
+ const finite = (v: unknown, count: number) => Array.isArray(v) && v.length === count && v.every(Number.isFinite);
+ if (!finite(model.calibration?.cOriginMm, 3) || !finite(model.calibration?.tiltOffsetMm, 3) || !model.calibration?.physicalZero || ![...model.coordinates].every((a) => Number.isFinite(model.calibration.physicalZero[a.toLowerCase()]))) fail("标定参数错误");
+ const nodes = new Map(), axes = new Map();
+ for (const record of model.nodes) {
+ if (!record.id || nodes.has(record.id)) fail("节点 ID 缺失或重复");
+ nodes.set(record.id, record);
+ if (!finite(record.zero?.positionMm, 3) || !finite(record.zero?.quaternion, 4) || !finite(record.zero?.scale, 3) || record.zero.scale.some((v) => v <= 0) || Math.abs(record.zero.quaternion.reduce((s, v) => s + v * v, 0) - 1) > 1e-5) fail(`${record.name} 零姿态错误`);
+ const joint = record.joint;
+ if (joint) {
+ if (![...model.coordinates].includes(joint.axis) || joint.dof !== 1 || !["x", "y", "z"].includes(joint.direction) || ![1, -1].includes(joint.sign) || joint.frame !== "parent" || joint.type !== (isLinear(joint.axis) ? "prismatic" : "revolute") || joint.unit !== (isLinear(joint.axis) ? "mm" : "deg")) fail(`${record.name} 单自由度错误`);
+ if (axes.has(joint.axis)) fail(`${joint.axis} 轴重复绑定`);
+ axes.set(joint.axis, record.id);
+ }
+ }
+ for (const record of model.nodes) {
+ const seen = new Set([record.id]); let parent = record.parentId;
+ while (parent !== null) { if (seen.has(parent) || !nodes.has(parent)) fail("父级不存在或层级循环"); seen.add(parent); parent = nodes.get(parent)!.parentId; }
+ }
+ if (!model.axisMap || Object.keys(model.axisMap).length !== axes.size) fail("轴索引错误");
+ for (const [axis, id] of axes) if (model.axisMap[axis] !== id) fail(`${axis} 轴索引错误`);
+ if (complete && [...model.coordinates].some((axis) => !axes.has(axis as CncAxis))) fail(`请完整绑定 ${model.coordinates} 的五个关节`);
+ return model;
+}
+
+export function bindJointModel(scene: THREE.Object3D, model: JointModel) {
+ validateJointModel(model);
+ const nodes = new Map();
+ scene.traverse((node) => { const id = node.userData.linuxcncNodeId; if (id) { if (nodes.has(id)) throw new Error("关节模型节点 ID 重复"); nodes.set(id, node); } });
+ const bindings = model.nodes.map((record) => {
+ const node = nodes.get(record.id);
+ if (!node || (node.parent?.userData.linuxcncNodeId ?? null) !== record.parentId) throw new Error(`关节节点 ${record.name} 与 JSON 层级不一致`);
+ return { node, record };
+ });
+ return {
+ update(values: AxisValues, physicalZero = model.calibration.physicalZero) {
+ for (const { record } of bindings) if (record.joint && (!Number.isFinite(values[record.joint.axis]) || !Number.isFinite(physicalZero[record.joint.axis.toLowerCase()] ?? 0))) throw new Error(`缺少 ${record.joint.axis} 轴实时位置或物理零点无效`);
+ for (const { node, record } of bindings) driveJoint(node, record.zero, record.joint, record.joint ? values[record.joint.axis]! : 0, record.joint ? Number(physicalZero[record.joint.axis.toLowerCase()] ?? 0) : 0);
+ },
+ state: () => Object.fromEntries(bindings.filter(({ record }) => record.joint).map(({ node, record }) => [record.joint!.axis, { id: record.id, localMm: node.position.toArray().map((v) => v * 1000), quaternion: node.quaternion.toArray(), world: node.getWorldPosition(new THREE.Vector3()).toArray() }])),
+ };
+}
diff --git a/web/react/src/machine-manager.ts b/web/react/src/machine-manager.ts
index 034fa41..870dd92 100644
--- a/web/react/src/machine-manager.ts
+++ b/web/react/src/machine-manager.ts
@@ -1,10 +1,11 @@
import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-urdf";
+import { bindJointModel, defaultDof, jointZero, validateJointModel, type CncAxis, type JointDof, type JointModel, type JointRecord } from "./machine-joints";
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 MachinePartSource = { id: string; filename: string; hash: string; scene: THREE.Group; restoredJoints?: Record; restoredRoles?: Record; restoredModel?: JointModel };
export type MachineNodeTransform = { translationMm: Vec3; rotationDeg: Vec3 };
export type MachinePartDraft = MachinePartSource & {
role: MachinePartRole | "";
@@ -17,6 +18,7 @@ export type MachinePartDraft = MachinePartSource & {
nodeParents: Record;
nodeRoles: Record;
nodeTransforms: Record;
+ nodeJoints?: 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 };
@@ -26,7 +28,7 @@ export type ManagedMachineGeometry = {
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 }>;
+ 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; nodeJoints?: Record }>;
};
export const MACHINE_PART_ROLES: MachinePartRole[] = ["base", "x", "y", "z", "tool", "tilt", "c", "work"];
@@ -130,6 +132,21 @@ export async function loadMachinePart(bytes: ArrayBuffer, filename: string): Pro
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; } });
+ const model = gltf.scene.userData.linuxcncJointModel as JointModel | undefined;
+ if (model) {
+ bindJointModel(gltf.scene, model);
+ const assembly = gltf.scene.getObjectByProperty("name", "joint_model_assembly");
+ if (!assembly?.userData.linuxcncVisualAssembly) throw new Error("缺少已保存的关节装配");
+ const scene = assembly.clone(true) as THREE.Group;
+ const records = new Map(model.nodes.map((record) => [record.id, record]));
+ const restoredJoints: Record = {}, restoredRoles: Record = {};
+ for (const [path, node] of scenePathMap(scene).nodes) {
+ const record = records.get(node.userData.linuxcncNodeId);
+ if (record?.joint) restoredJoints[path] = { axis: record.joint.axis, direction: record.joint.direction, sign: record.joint.sign };
+ if (record?.role) restoredRoles[path] = record.role as MachinePartRole;
+ }
+ return { id: crypto.randomUUID(), filename, hash, scene, restoredJoints, restoredRoles, restoredModel: model };
+ }
return { id: crypto.randomUUID(), filename, hash, scene: gltf.scene };
}
@@ -180,6 +197,7 @@ function machineUrdf(coordinates: "XYZAC" | "XYZBC", cOriginMm: Vec3, tiltOffset
}
export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero }): Promise {
+ if (input.parts.some((part) => part.nodeJoints !== undefined)) return buildJointManagedMachine(input);
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);
@@ -208,3 +226,69 @@ export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC
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 } };
}
+
+export function draftNodeDof(part: MachinePartDraft, path: string, coordinates: "XYZAC" | "XYZBC"): JointDof | null {
+ if (part.nodeJoints !== undefined) return part.nodeJoints[path] ?? null;
+ const role = path === "scene" ? part.role : part.nodeRoles?.[path];
+ const axis = role === "tilt" ? coordinates[3] : role && ["x", "y", "z", "c"].includes(role) ? role.toUpperCase() : "";
+ return axis ? defaultDof(axis as CncAxis) : null;
+}
+
+type ManagedInput = { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
+
+export function makeJointAssembly(input: ManagedInput, deepClone = false) {
+ if (!input.parts.length) throw new Error("请先导入机床模型");
+ const assembly = new THREE.Group(); assembly.name = "joint_model_assembly"; assembly.userData.linuxcncVisualAssembly = true;
+ const placements = new Map(), records: JointRecord[] = [];
+ for (const part of input.parts) {
+ if (placements.has(part.id)) throw new Error("部件 ID 重复");
+ finite3(part.translationMm, "部件位置"); finite3(part.rotationDeg, "部件姿态"); finite3(part.originMm, "装配原点");
+ 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);
+ placements.set(part.id, placement); assembly.add(placement);
+ const { scene, nodes } = configureMachinePartScene(deepClone ? cloneMachinePartScene(part.scene) : part.scene.clone(true), part);
+ scene.position.add(new THREE.Vector3(...part.originMm).multiplyScalar(-.001)); placement.add(scene);
+ const addRecord = (node: THREE.Object3D, path: string, role: string, dof: JointDof | null) => {
+ const id = `${part.id}:${path}`; node.userData.linuxcncNodeId = id;
+ const joint: JointRecord["joint"] = dof ? { ...dof, dof: 1, type: "XYZ".includes(dof.axis) ? "prismatic" : "revolute", unit: "XYZ".includes(dof.axis) ? "mm" : "deg", frame: "parent" } : null;
+ node.userData.linuxcncJoint = joint;
+ records.push({ id, parentId: null, name: path === "scene" ? part.filename : node.name, role, source: { file: part.filename, hash: part.hash, path }, zero: jointZero(node), joint });
+ };
+ addRecord(placement, "scene", part.role, draftNodeDof(part, "scene", input.coordinates));
+ for (const [path, node] of nodes) addRecord(node, path === "scene" ? "source-scene" : path, part.nodeRoles?.[path] ?? "", path === "scene" ? null : draftNodeDof(part, path, input.coordinates));
+ }
+ for (const part of input.parts) if (part.parentId) {
+ const parent = placements.get(part.parentId); if (!parent) throw new Error("父部件不存在");
+ let cursor: string | null = part.parentId; const seen = new Set([part.id]);
+ while (cursor) { if (seen.has(cursor)) throw new Error("部件层级循环"); seen.add(cursor); cursor = input.parts.find((p) => p.id === cursor)?.parentId ?? null; }
+ parent.add(placements.get(part.id)!);
+ }
+ const recordMap = new Map(records.map((record) => [record.id, record]));
+ assembly.traverse((node) => { const record = recordMap.get(node.userData.linuxcncNodeId); if (record) record.parentId = node.parent?.userData.linuxcncNodeId ?? null; });
+ const model: JointModel = { schema: "linuxcnc.joint-model/1", coordinates: input.coordinates, units: { length: "mm", angle: "deg", gltfLength: "m" }, controller: { source: "linuxcnc-wasm.visual.joints", axisOrder: [...input.coordinates] as CncAxis[] }, calibration: { cOriginMm: [...input.cOriginMm], tiltOffsetMm: [...input.tiltOffsetMm], physicalZero: { ...input.physicalZero } }, nodes: records, axisMap: Object.fromEntries(records.filter((r) => r.joint).map((r) => [r.joint!.axis, r.id])) };
+ validateJointModel(model, false);
+ return { assembly, model };
+}
+
+async function buildJointManagedMachine(input: ManagedInput): Promise {
+ // Validate using shallow transform clones before allocating deep runtime resources.
+ validateJointModel(makeJointAssembly(input).model);
+ const urdf = machineUrdf(input.coordinates, input.cOriginMm, input.tiltOffsetMm), structure = parseMachineUrdf(urdf);
+ const { assembly, model } = makeJointAssembly(input, true);
+ const scene = new THREE.Group(); scene.name = `Managed_${input.coordinates}`;
+ scene.userData.linuxcncMachine = { schema: "linuxcnc.machine/1", urdf };
+ scene.userData.linuxcncJointModel = model;
+ const geometry: ManagedMachineGeometry = { schema: "linuxcnc.machine-manager/1", coordinates: input.coordinates, cOriginMm: [...input.cOriginMm], tiltOffsetMm: [...input.tiltOffsetMm], physicalZero: { ...input.physicalZero }, parts: input.parts.map(({ scene: _scene, restoredJoints: _j, restoredRoles: _r, restoredModel: _m, ...part }) => JSON.parse(JSON.stringify(part))) };
+ scene.userData.linuxcncMachineGeometry = geometry;
+ 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, assembly); 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);
+ // The existing URDF frames retain the controller's tool/path reference. Only
+ // the explicit JSON bindings drive the visual assembly, preventing double motion.
+ links.base.userData.linuxcncJointModel = model; links.base.userData.linuxcncMachineGeometry = geometry;
+ const bases = new Set(model.nodes.filter((n) => n.role === "base").map((n) => n.id));
+ assembly.traverse((node) => { if (bases.has(node.userData.linuxcncNodeId)) { node.userData.linuxcncJointModel = model; node.userData.linuxcncMachineGeometry = geometry; } });
+ const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify({ model, geometry }))))].map((v) => v.toString(16).padStart(2, "0")).join("");
+ return { scene, structure, hash, filename: `managed-${input.coordinates.toLowerCase()}.glb`, physicalZero: { ...input.physicalZero } };
+}