part.showOrigin).length} aria-label="三维机床装配预览,机床 Z 轴朝上" />{fault && }
;
+ return
part.showOrigin).length} aria-label="三维机床装配预览,机床 Z 轴朝上" />{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) => )}
-
- {selected &&
{selectedTreeNode?.name ?? selected.filename}{selected.filename}
{selectedTreeNode && 零姿态与当前坐标
{selectedTreeNode.name}节点自身局部原点装配位置(相对父节点,mm)装配姿态(相对父节点,deg){selectedDriven && <>当前世界原点(mm)>}
}
+ 部件与层级
{validation.model?.nodes.length ?? 0} 节点{selectedAnalysis && GLB 五轴结构分析
{analysisSummary(selectedAnalysis)}
节点 / 网格{selectedAnalysis.nodeCount} / {selectedAnalysis.meshCount}建议运动学{selectedAnalysis.suggestedKinematics}RTCP{"待确认"}
{CNC_AXES.map((axis) => { const item = selectedAnalysis.axes.find((candidate) => candidate.axis === axis); return {axis}{item ? {item.nodeName}
{item.worldOriginMm.map((value) => value.toFixed(1)).join(" / ")} mm : 未识别}; })}
结构链与 RTCP 参数
直线轴链:{selectedAnalysis.linearChain.join(" → ") || "未识别"}
旋转轴链:{selectedAnalysis.rotaryChain.join(" → ") || "未识别"}
节点父级:{selectedAnalysis.axes.map((item) => `JT_${item.axis} ← ${item.parentName || "scene"}`).join(";") || "未识别"}
{selectedAnalysis.rtcp.reason}
建议参数:{selectedAnalysis.rtcp.requiredParameters.join("、")}{selectedAnalysis.warnings.length > 0 && {selectedAnalysis.warnings.map((warning) => - {warning}
)}
} } setQuery(event.target.value)} />
{parts.length ? {parts.filter((part) => !part.parentId).map(renderPartTree)}
: 导入机床或部件后显示 GLB 内部层级。
}
+ {selected && {selectedTreeNode?.name ?? selected.filename}{selected.filename}
{selectedTreeNode && 零姿态与当前坐标
{selectedTreeNode.name}节点自身局部原点装配位置(相对父节点,mm)装配姿态(相对父节点,deg){selectedDriven && <>当前世界原点(mm)>}
}
{selectedDof && <>>}
{selectedNodePath === "scene" ? : 内部节点父级由层级树拖放管理
}当前节点:{selectedTreeNode?.name}。{selectedDof ? `仅允许 ${selectedDof.axis} ${isLinear(selectedDof.axis) ? "平移" : "旋转"},沿父级 ${selectedDof.direction.toUpperCase()} 方向;以导入装配姿态为零位。` : "固定节点随父级运动;请先配置唯一自由度。"}
-
- {selectedDof && {([-1, 1] as const).map((direction) => )}
}
+
+ {selectedDof && {([-1, 1] as const).map((direction) => )}
}
}
{!selected && 选择层级节点,配置其唯一自由度。
}
-
层级运动测试
{(["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")}
+
+
机床标定
{vectorInput(`${profile?.rotaryAxis ?? "旋转"} 轴原点`, cOriginMm, setCOriginMm, "mm")}{vectorInput(`${profile?.tiltAxis ?? coordinates[3] ?? "旋转"} 到 ${profile?.rotaryAxis ?? coordinates[4] ?? "旋转"} 轴偏置`, tiltOffsetMm, setTiltOffsetMm, "mm")}
机床文档
- LinuxCNC 五轴案例库
{FIVE_AXIS_CASES.length} 个结构/运动学案例案例来源于仓库内 LinuxCNC 配置、示例 G 代码和运动学源码。选中 XYZAC/XYZBC 可直接进入当前 Web/WASM 工作流;其它类型保留完整参数和源码索引,待对应运动学适配器接入后启用。
官方参考:5-Axis Kinematics · kins(9) · switchkins
+ LinuxCNC 五轴案例库
{FIVE_AXIS_CASES.length} 个结构/运动学案例案例来源于仓库内 LinuxCNC 配置、示例 G 代码和运动学源码。支持的坐标可直接进入当前 Web/WASM 工作流;其它类型保留完整参数和源码索引,待对应运动学适配器接入后启用。
官方参考:5-Axis Kinematics · kins(9) · switchkins
文件保存在哪里?{saveLocation}
GLB 下载到浏览器设置的目录,通常为“下载”。请打开浏览器下载记录查看实际位置;网页无法读取磁盘完整路径。
保存时另存一份到本浏览器机床库,可在刷新页面后重新打开。本地机床库仅属于当前浏览器及网站地址,不会上传云服务器;清理网站数据会删除这些副本。
本浏览器机床库 {library.length} 个保存版本
setLibraryQuery(event.target.value)} />
{libraryError && {libraryError}
}
@@ -401,7 +512,7 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
- {blockingReason ? `配置检查:${blockingReason}` : "配置检查通过 · 五轴绑定完整"}{disabled && !blockingReason && " · 关机且程序空闲后可应用"}{followLive && " · 实时跟随中,配置编辑已锁定"}{invalidFields.length > 0 && }
+ {blockingReason ? `配置检查:${blockingReason}` : `配置检查通过 · ${activeAxes.length} 轴绑定完整`}{disabled && !blockingReason && " · 关机且程序空闲后可应用"}{followLive && " · 实时跟随中,配置编辑已锁定"}{invalidFields.length > 0 && }
{ const item = event.target.files?.[0]; event.target.value = ""; if (item) void importFiles([item], true); }} />
diff --git a/web/react/src/XyzacMachineView.tsx b/web/react/src/XyzacMachineView.tsx
index 98d0937..32e3a23 100644
--- a/web/react/src/XyzacMachineView.tsx
+++ b/web/react/src/XyzacMachineView.tsx
@@ -4,7 +4,7 @@ const VIEWER = WEB_SETTINGS.viewer;
const CAMERA = VIEWER.camera;
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
-import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
+import { installMachineStudio } from "./machine-studio-lighting";
import { buildImportedMachine, type ImportedMachine } from "./imported-machine";
import { axisIcon } from "./sourceAssets";
import { buildXyzacMachine, buildXyzbcMachine } from "./xyzac-machine-model";
@@ -29,25 +29,16 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
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 = !managedJoints;
renderer.shadowMap.type = THREE.PCFShadowMap;
renderer.domElement.dataset.engine = "three.js r185 / xyzac-trt-vismach";
element.appendChild(renderer.domElement);
const scene = new THREE.Scene(); scene.background = new THREE.Color(VIEWER.colors.background);
- const studio = new RoomEnvironment();
- const pmrem = new THREE.PMREMGenerator(renderer);
- const environment = pmrem.fromScene(studio, .04);
- scene.environment = environment.texture; scene.environmentIntensity = .65;
- studio.dispose(); pmrem.dispose();
- scene.add(new THREE.HemisphereLight(0xf4f8ff, 0x4b5450, 1.2));
- const key = new THREE.DirectionalLight(0xfff4df, 2.7); key.position.set(-4, 9, -6);
+ const studio = installMachineStudio(renderer, scene, "y");
+ const key = studio.key;
key.castShadow = true; key.shadow.mapSize.set(2048, 2048); key.shadow.bias = -.0002; key.shadow.normalBias = .012;
- Object.assign(key.shadow.camera, { left: -6, right: 6, top: 7, bottom: -6, near: .1, far: 30 }); scene.add(key);
- const rim = new THREE.DirectionalLight(0xd5f0ff, 1.7); rim.position.set(5, 5, 5); scene.add(rim);
- const fill = new THREE.DirectionalLight(0xffffff, .7); fill.position.set(4, 2, -7); scene.add(fill);
+ Object.assign(key.shadow.camera, { left: -6, right: 6, top: 7, bottom: -6, near: .1, far: 30 });
const floor = new THREE.Mesh(new THREE.PlaneGeometry(18, 18), new THREE.ShadowMaterial({ opacity: .24 }));
floor.rotation.x = -Math.PI / 2; floor.position.y = -2.64; floor.receiveShadow = true; scene.add(floor);
@@ -195,7 +186,7 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
clearLines(pathGroup); clearLines(liveGroup); pathMaterials.forEach((material) => material.dispose());
for (const helper of [limits, offsets, extents]) helper.dispose();
assembly.dispose(); nameplateTexture.dispose(); nameplate.geometry.dispose(); nameplate.material.dispose();
- floor.geometry.dispose(); floor.material.dispose(); key.shadow.map?.dispose(); environment.dispose();
+ floor.geometry.dispose(); floor.material.dispose(); studio.dispose();
renderer.dispose(); renderer.domElement.remove();
};
}, [importedMachine]);
@@ -204,7 +195,7 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
const coordinates = model?.coordinate_system ?? "XYZAC";
const managerGeometry = importedMachine?.scene.userData.linuxcncMachineGeometry;
const controllerButton = (action: string, label: string, asset?: string, glyph?: string) =>
;
- return
+ return
({ axis, path }))) : ""} data-xyzac-mouse-controls="left-rotate-middle-zoom-right-pan-wheel-zoom" data-xyzac-model-id={model?.id ?? ""} data-xyzac-model-source-path={importedMachine?.filename ?? model?.source_path ?? ""} data-xyzac-model-source-hash={importedMachine?.hash ?? model?.source_hash ?? ""} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} data-xyzac-joints={JSON.stringify(visual.joints)} data-xyzac-offsets={JSON.stringify(visual.offsets)} aria-label={`${coordinates} ${importedMachine ? "GLB" : "TRT Vismach"} machine model`}>
{controllerButton("zoomIn", "Zoom in", "tool_zoomin.gif")}{controllerButton("zoomOut", "Zoom out", "tool_zoomout.gif")}
{controllerButton("panLeft", "Pan left", undefined, "\u2190")}{controllerButton("panRight", "Pan right", undefined, "\u2192")}
diff --git a/web/react/src/glb-five-axis-analyzer.ts b/web/react/src/glb-five-axis-analyzer.ts
new file mode 100644
index 0000000..d21a507
--- /dev/null
+++ b/web/react/src/glb-five-axis-analyzer.ts
@@ -0,0 +1,134 @@
+import * as THREE from "three";
+import { CNC_AXES, defaultDof, isFiveAxisCoordinates, isLinear, validJointDof, type CncAxis, type JointDof } from "./machine-joints";
+
+/** A single axis candidate found in a GLB node hierarchy. */
+export type GlbAxisEvidence = {
+ axis: CncAxis;
+ nodeName: string;
+ path: string;
+ parentName: string;
+ parentPath: string;
+ depth: number;
+ originMm: [number, number, number];
+ worldOriginMm: [number, number, number];
+ expectedKind: "linear" | "rotary";
+ confidence: "high" | "medium" | "low";
+ reason: string;
+ joint: JointDof;
+ autoBind: boolean;
+};
+
+export type GlbFiveAxisAnalysis = {
+ schema: "linuxcnc.glb-five-axis-analysis/1";
+ filename?: string;
+ nodeCount: number;
+ meshCount: number;
+ boundsMm: { min: [number, number, number]; max: [number, number, number]; size: [number, number, number] };
+ axes: GlbAxisEvidence[];
+ missingAxes: string[];
+ linearChain: string[];
+ rotaryChain: string[];
+ chains: string[][];
+ coordinateCandidates: string[];
+ suggestedCoordinates: string | "UNKNOWN";
+ suggestedKinematics: "generic-five-axis" | "unknown";
+ rtcp: { mode: "unverified" | "insufficient"; reason: string; requiredParameters: string[] };
+ warnings: string[];
+};
+
+const AXES = CNC_AXES;
+const axisSet = new Set
(CNC_AXES);
+const round = (value: number) => Number(value.toFixed(6));
+const mm = (v: THREE.Vector3): [number, number, number] => [round(v.x * 1000), round(v.y * 1000), round(v.z * 1000)];
+
+function axisFromName(name: string): { axis: CncAxis; exact: boolean } | null {
+ const normalized = name.trim().toUpperCase();
+ const exact = normalized.match(/^JT[_-]?([XYZABCUVW])$/);
+ if (exact && axisSet.has(exact[1])) return { axis: exact[1] as CncAxis, exact: true };
+ const token = normalized.match(/(?:^|[_\-\s])(?:JT|JOINT|AXIS|LINEAR|ROTARY)[_-]?([XYZABCUVW])(?:$|[_\-\s])/)
+ ?? normalized.match(/(?:^|[_\-\s])([XYZABCUVW])[_-]?(?:AXIS|JOINT|LINEAR|ROTARY)(?:$|[_\-\s])/);
+ return token && axisSet.has(token[1]) ? { axis: token[1] as CncAxis, exact: false } : null;
+}
+
+function objectPath(node: THREE.Object3D): string {
+ const parts: string[] = [];
+ let current: THREE.Object3D | null = node;
+ while (current?.parent) { const index = current.parent.children.indexOf(current); parts.unshift(String(index)); current = current.parent; }
+ return `scene${parts.length ? `/${parts.join("/")}` : ""}`;
+}
+
+/**
+ * Inspect an already parsed Three.js GLB scene. The heuristic intentionally
+ * prioritizes explicit joint metadata and uses names only as hints. It never mutates
+ * geometry and can therefore be reused for previews, import validation and
+ * offline model review.
+ */
+export function analyzeGlbFiveAxisScene(scene: THREE.Object3D, filename?: string): GlbFiveAxisAnalysis {
+ scene.updateMatrixWorld(true);
+ const axes: GlbAxisEvidence[] = [];
+ const warnings: string[] = [];
+ const duplicateAxes = new Set();
+ const bounds = new THREE.Box3();
+ let nodeCount = 0, meshCount = 0;
+ scene.traverse((node) => {
+ nodeCount += 1;
+ const mesh = node as THREE.Mesh;
+ if (mesh.isMesh) { meshCount += 1; bounds.expandByObject(mesh); }
+ const named = axisFromName(node.name || "");
+ const data = node.userData;
+ const explicit = Object.hasOwn(data, "linuxcncJoint") || Object.hasOwn(data, "linuxcnc.axis");
+ if (Object.hasOwn(data, "linuxcncJoint") && data.linuxcncJoint === null) return;
+ const metadata = data.linuxcncJoint ?? (Object.hasOwn(data, "linuxcnc.axis") ? { axis: data["linuxcnc.axis"], direction: data["linuxcnc.axis_direction"], sign: data["linuxcnc.axis_sign"], frame: data["linuxcnc.axis_frame"] } : undefined);
+ const axis = explicit ? metadata?.axis : named?.axis;
+ if (!axisSet.has(axis)) { if (explicit) warnings.push(`${node.name}:轴元数据无效,请手动绑定`); return; }
+ const match = { axis: axis as CncAxis, exact: explicit };
+ const direction = metadata?.direction ?? defaultDof(match.axis).direction;
+ const joint: JointDof = { axis: match.axis, direction: Array.isArray(direction) ? "custom" : direction, sign: metadata?.sign ?? 1,
+ ...(metadata?.frame !== undefined ? { frame: metadata.frame } : {}),
+ ...(Array.isArray(direction) ? { vector: direction } : metadata?.vector ? { vector: metadata.vector } : {}) };
+ if (!validJointDof(joint) || (metadata?.type && metadata.type !== (isLinear(match.axis) ? "prismatic" : "revolute"))) { warnings.push(`${node.name}:关节方向、参考系或类型无效,请手动配置`); return; }
+ if (axes.some((item) => item.axis === match.axis)) duplicateAxes.add(match.axis);
+ const parent = node.parent ?? scene;
+ const evidence: GlbAxisEvidence = {
+ axis: match.axis, nodeName: node.name || `(unnamed ${match.axis})`, path: objectPath(node), parentName: parent.name || "scene", parentPath: objectPath(parent), depth: objectPath(node).split("/").length - 1,
+ originMm: mm(node.position), worldOriginMm: mm(node.getWorldPosition(new THREE.Vector3())), expectedKind: isLinear(match.axis) ? "linear" : "rotary",
+ confidence: match.exact ? "high" : "medium", reason: explicit ? "GLB 关节元数据" : "节点名称提示;默认轴向需运动验证", joint, autoBind: validJointDof(joint),
+ };
+ axes.push(evidence);
+ });
+ const axisList = axes.sort((a, b) => AXES.indexOf(a.axis) - AXES.indexOf(b.axis));
+ for (const item of axisList) if (duplicateAxes.has(item.axis)) item.autoBind = false;
+ const available = new Set(axisList.filter((item) => item.autoBind).map((item) => item.axis));
+ const declared = scene.userData.linuxcncJointModel?.coordinates ?? scene.userData.linuxcncMachine?.coordinates;
+ const declaredValid = typeof declared === "string" && isFiveAxisCoordinates(declared) && [...declared].every((axis) => available.has(axis as CncAxis));
+ const detected = AXES.filter((axis) => available.has(axis)).join("");
+ const suggestedCoordinates = declaredValid ? declared : isFiveAxisCoordinates(detected) ? detected : "UNKNOWN";
+ const coordinateCandidates = suggestedCoordinates === "UNKNOWN" ? [] : [suggestedCoordinates];
+ const missingAxes = typeof declared === "string" && isFiveAxisCoordinates(declared) ? [...declared].filter((axis) => !available.has(axis as CncAxis)) : [];
+ const sorted = [...axisList].sort((a, b) => a.depth - b.depth);
+ const nearestAxis = (item: GlbAxisEvidence) => sorted.filter((other) => item.path.startsWith(`${other.path}/`)).at(-1);
+ const chains = sorted.filter((item) => !sorted.some((other) => nearestAxis(other) === item)).map((leaf) => {
+ const chain = [leaf.nodeName]; let node = nearestAxis(leaf);
+ while (node) { chain.unshift(node.nodeName); node = nearestAxis(node); }
+ return chain;
+ });
+ const linearChain = sorted.filter((item) => isLinear(item.axis)).map((item) => item.nodeName);
+ const rotaryChain = sorted.filter((item) => !isLinear(item.axis)).map((item) => item.nodeName);
+ const suggestedKinematics = coordinateCandidates.length ? "generic-five-axis" : "unknown";
+ if (declared !== undefined && !declaredValid) warnings.push("声明的轴顺序与可用关节不一致,未采用该顺序");
+ if (available.size !== 5) warnings.push(`检测到 ${available.size} 个唯一轴;请配置五个轴及其绑定,不会丢弃多余候选`);
+ if (axisList.some((item) => item.confidence !== "high")) warnings.push("名称仅用于提示轴绑定;请确认轴向、旋转中心和父子关系");
+ if (duplicateAxes.size) warnings.push(`重复轴 ${[...duplicateAxes].join("、")}:保留所有候选,未自动绑定;请手动选择唯一节点`);
+ if (bounds.isEmpty()) warnings.push("模型没有可计算包围盒的网格");
+ const rtcp = { mode: coordinateCandidates.length ? "unverified" as const : "insufficient" as const,
+ reason: "轴名和层级不能证明 RTCP 运动学;请确认刀具/工件链、轴心及匹配的运行适配器",
+ requiredParameters: ["各关节轴向和参考系", "旋转中心/零姿态", "刀具与工件节点", "运动学适配器及标定参数"] };
+ const boxMin = bounds.isEmpty() ? new THREE.Vector3() : bounds.min;
+ const boxMax = bounds.isEmpty() ? new THREE.Vector3() : bounds.max;
+ return { schema: "linuxcnc.glb-five-axis-analysis/1", filename, nodeCount, meshCount, boundsMm: { min: mm(boxMin), max: mm(boxMax), size: mm(boxMax.clone().sub(boxMin)) }, axes: axisList, missingAxes, linearChain, rotaryChain, chains, coordinateCandidates, suggestedCoordinates, suggestedKinematics, rtcp, warnings };
+}
+
+export function analysisSummary(analysis: GlbFiveAxisAnalysis): string {
+ const axes = analysis.axes.map((item) => item.nodeName).join("、") || "未识别关节,可手动绑定";
+ return `${axes} · ${analysis.suggestedCoordinates === "UNKNOWN" ? "待配置五轴" : analysis.suggestedCoordinates} · RTCP 待确认`;
+}
diff --git a/web/react/src/imported-machine.ts b/web/react/src/imported-machine.ts
index 0339245..388c17e 100644
--- a/web/react/src/imported-machine.ts
+++ b/web/react/src/imported-machine.ts
@@ -1,25 +1,41 @@
import * as THREE from "three";
-import { bindJointModel, validateJointModel, type JointModel, type AxisValues } from "./machine-joints";
+import { bindJointModel, defaultDof, driveJoint, jointZero, validateJointModel, type JointModel, type AxisValues, type CncAxis, type JointZero } 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";
import { readTrtConfiguration } from "../../workspace/trt-configuration.mjs";
import { readTrtGeometry, writeTrtGeometry } from "../../workspace/trt-geometry.mjs";
import type { XyzacVisualState } from "./xyzac-visual-adapter";
-export type ImportedMachine = { scene: THREE.Group; structure: MachineStructure; hash: string; filename: string; physicalZero?: Partial> };
+import { analyzeGlbFiveAxisScene, type GlbFiveAxisAnalysis } from "./glb-five-axis-analyzer";
+export type RawAxisBinding = { axis: CncAxis; path: string; zero: JointZero };
+export type ImportedMachine = { scene: THREE.Group; structure: MachineStructure; hash: string; filename: string; physicalZero?: Partial, number>>; glbAnalysis?: GlbFiveAxisAnalysis; rawAxisBindings?: RawAxisBinding[] };
export function disposeMachineScene(scene: THREE.Object3D) {
const geometries = new Set(), materials = new Set(), textures = new Set();
scene.traverse((node) => { const mesh = node as THREE.Mesh; if (mesh.geometry) geometries.add(mesh.geometry); if (mesh.material) for (const m of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) { materials.add(m); for (const value of Object.values(m)) if (value instanceof THREE.Texture) textures.add(value); } });
geometries.forEach((g) => g.dispose()); materials.forEach((m) => m.dispose()); textures.forEach((t) => t.dispose());
}
export async function loadImportedMachine(bytes: ArrayBuffer, filename: string): Promise {
- const { structure } = inspectMachineGlb(bytes);
+ let inspected: ReturnType | null = null;
+ try { inspected = inspectMachineGlb(bytes); } catch { /* Raw five-axis GLBs are analyzed below. */ }
const manager = new THREE.LoadingManager();
manager.setURLModifier((url) => { if (!url.startsWith("blob:")) throw new Error("机床文件不能加载外部资源"); return url; });
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; } });
const model = gltf.scene.userData.linuxcncJointModel as JointModel | undefined;
+ if (!inspected) {
+ const analysis = analyzeGlbFiveAxisScene(gltf.scene, filename);
+ if (analysis.suggestedCoordinates === "UNKNOWN" || analysis.suggestedCoordinates.length !== 5) throw new Error("GLB 未形成可用的五轴结构;请提供三个直线轴和两个旋转轴节点(支持 JT_X、AXIS_X、X_AXIS 等命名)");
+ const rawAxisBindings: RawAxisBinding[] = analysis.axes.filter((item) => analysis.suggestedCoordinates.includes(item.axis)).map((item) => {
+ const node = findScenePathNode(gltf.scene, item.path);
+ if (!node) throw new Error(`GLB 轴节点路径不存在:${item.path}`);
+ return { axis: item.axis as CncAxis, path: item.path, zero: jointZero(node) };
+ });
+ const structure = rawStructure(analysis, gltf.scene.name || filename);
+ const physicalZero = Object.fromEntries([...analysis.suggestedCoordinates].map((axis) => [axis.toLowerCase(), 0])) as ImportedMachine["physicalZero"];
+ return { scene: gltf.scene, structure, hash, filename, physicalZero, glbAnalysis: analysis, rawAxisBindings };
+ }
+ const structure = inspected.structure;
if (model) {
validateJointModel(model); if (model.coordinates !== structure.coordinates) throw new Error("关节 JSON 与 URDF 机型不一致");
bindJointModel(gltf.scene, model);
@@ -28,6 +44,19 @@ export async function loadImportedMachine(bytes: ArrayBuffer, filename: string):
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 findScenePathNode(scene: THREE.Object3D, path: string) {
+ const parts = path.split("/").slice(1).map(Number);
+ let node: THREE.Object3D = scene;
+ for (const index of parts) { if (!Number.isInteger(index) || !node.children[index]) return null; node = node.children[index]; }
+ return node;
+}
+
+function rawStructure(analysis: GlbFiveAxisAnalysis, name: string): MachineStructure {
+ const coordinates = analysis.suggestedCoordinates;
+ const joints = [...coordinates].map((axis) => ({ name: `raw_${axis.toLowerCase()}_joint`, type: "XYZ".includes(axis) ? "prismatic" : "revolute", parent: "raw_machine", child: `raw_${axis.toLowerCase()}`, origin: [0, 0, 0] as Vec3, axis: defaultDof(axis as CncAxis).direction === "x" ? [1, 0, 0] as Vec3 : defaultDof(axis as CncAxis).direction === "y" ? [0, 1, 0] as Vec3 : [0, 0, 1] as Vec3, coordinate: axis, limit: "XYZ".includes(axis) ? { lower: -1, upper: 1, velocity: 1, effort: 1 } : { lower: -Math.PI * 200, upper: Math.PI * 200, velocity: 100, effort: 1 } }));
+ return { name, profile: "generic-five-axis/1", coordinates: coordinates as any, base: "raw_machine", tool: "raw_tool", work: "raw_work", links: ["raw_machine", "raw_tool", "raw_work"], joints, offsets: { x_rot_point: 0, y_rot_point: 0, z_rot_point: 0, x_offset: 0, y_offset: 0, z_offset: 0 }, urdf: `raw-glb://${coordinates}` };
+}
function validateImportedConfiguration(bundle: { entrypoint: string; files: Record }, structure: MachineStructure) {
const config = readTrtConfiguration(bundle);
if (config.kinematics.coordinates !== structure.coordinates) throw new Error("GLB 与运行配置机型不同");
@@ -53,6 +82,7 @@ export function configureImportedMachine }): ImportedMachine {
validateImportedConfiguration(bundle, asset.structure);
const { offsets } = readTrtGeometry(bundle);
+ if (asset.rawAxisBindings) return { ...asset, structure: { ...asset.structure, offsets } };
if (Object.entries(asset.structure.offsets).every(([key, value]) => Math.abs(value - offsets[key]) < 1e-9)) return asset;
const p = ["x", "y", "z"].map((axis) => offsets[`${axis}_rot_point`] / 1000) as Vec3;
const d = ["x", "y", "z"].map((axis) => offsets[`${axis}_offset`] / 1000) as Vec3;
@@ -80,6 +110,24 @@ export function buildImportedMachine(asset: ImportedMachine) {
const jointDriver = jointModel ? bindJointModel(scene, jointModel) : null;
const physicalZero = asset.physicalZero ?? jointModel?.calibration.physicalZero ?? scene.userData.linuxcncMachineGeometry?.physicalZero ?? {};
const managed = scene.userData.linuxcncMachineGeometry;
+ if (asset.rawAxisBindings) {
+ const machine = new THREE.Group(); machine.name = asset.structure.name;
+ const scale = 1000 * WEB_SETTINGS.viewer.mm_scale;
+ machine.scale.set(scale, scale, -scale); machine.add(scene);
+ const bindings = asset.rawAxisBindings.map((binding) => ({ ...binding, node: findScenePathNode(scene, binding.path)! }));
+ const byAxis = new Map(bindings.map((binding) => [binding.axis, binding]));
+ const toolTip = new THREE.Group(); toolTip.name = "raw-glb-tool-tip"; scene.add(toolTip);
+ const workOrigin = new THREE.Group(); workOrigin.name = "raw-glb-work-origin"; scene.add(workOrigin);
+ const update = (state: Pick) => {
+ for (const [index, axis] of [...asset.structure.coordinates].entries()) {
+ const binding = byAxis.get(axis as CncAxis); if (!binding) continue;
+ driveJoint(binding.node, binding.zero, defaultDof(axis as CncAxis), state.joints[index], Number(physicalZero?.[axis.toLowerCase() as keyof NonNullable] ?? 0));
+ }
+ machine.updateMatrixWorld(true);
+ machine.userData.linuxcncJointState = Object.fromEntries(bindings.map((binding) => [binding.axis, { path: binding.path, world: binding.node.getWorldPosition(new THREE.Vector3()).toArray() }]));
+ };
+ return { machine, toolTip, workOrigin, joints: { rotate: machine }, update, dispose: () => undefined };
+ }
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) } };
@@ -116,7 +164,7 @@ export function buildImportedMachine(asset: ImportedMachine) {
cutter.rotation.x = Math.PI / 2; node(asset.structure.tool).add(cutter);
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 coordinate = joint.coordinate.toLowerCase() as Lowercase;
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);
diff --git a/web/react/src/machine-joints.ts b/web/react/src/machine-joints.ts
index 35f9318..5506e5f 100644
--- a/web/react/src/machine-joints.ts
+++ b/web/react/src/machine-joints.ts
@@ -1,19 +1,22 @@
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 const CNC_AXES = ["X", "Y", "Z", "A", "B", "C", "U", "V", "W"] as const;
+export type CncAxis = typeof CNC_AXES[number];
+export type JointDof = { axis: CncAxis; direction: "x" | "y" | "z" | "custom"; sign: 1 | -1; vector?: Vec3; frame?: "parent" | "local" };
+export const isFiveAxisCoordinates = (value: string) => typeof value === "string" && value.length === 5 && new Set(value).size === 5 && [...value].every((axis) => CNC_AXES.includes(axis as CncAxis));
+export const isTrtCoordinates = (value: string): value is "XYZAC" | "XYZBC" => value === "XYZAC" || value === "XYZBC";
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;
+ joint: (JointDof & { dof: 1; type: "prismatic" | "revolute"; unit: "mm" | "deg"; frame: "parent" | "local" }) | null;
};
export type JointModel = {
schema: "linuxcnc.joint-model/1";
- coordinates: "XYZAC" | "XYZBC";
+ coordinates: string;
units: { length: "mm"; angle: "deg"; gltfLength: "m" };
controller: { source: "linuxcnc-wasm.visual.joints"; axisOrder: CncAxis[] };
calibration: { cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: Record };
@@ -21,28 +24,34 @@ export type JointModel = {
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 isLinear = (axis: CncAxis) => "XYZUVW".includes(axis);
+export const defaultDof = (axis: CncAxis): JointDof => ({ axis, direction: ({ X: "x", Y: "y", Z: "z", A: "x", B: "y", C: "z", U: "x", V: "y", W: "z" } as const)[axis], sign: 1 });
+export const jointFrame = (dof: JointDof) => dof.frame ?? "parent";
+export const validJointDof = (dof: JointDof) => Boolean(dof && CNC_AXES.includes(dof.axis) && ["x", "y", "z", "custom"].includes(dof.direction) && [1, -1].includes(dof.sign) && ["parent", "local"].includes(jointFrame(dof)) && (dof.direction !== "custom" || (Array.isArray(dof.vector) && dof.vector.length === 3 && dof.vector.every(Number.isFinite) && Math.hypot(...dof.vector) > 1e-9)));
+export const jointDirection = (dof: JointDof) => dof.direction === "custom" ? new THREE.Vector3(...dof.vector!).normalize() : new THREE.Vector3(dof.direction === "x" ? 1 : 0, dof.direction === "y" ? 1 : 0, dof.direction === "z" ? 1 : 0);
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.
+// Parent-frame motion remains the default; explicit GLB metadata may use local axes.
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} 位置无效`);
+ if (dof && (!validJointDof(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 direction = jointDirection(dof);
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)));
+ if (isLinear(dof.axis)) node.position.addScaledVector(jointFrame(dof) === "local" ? direction.applyQuaternion(node.quaternion) : direction, q / 1000);
+ else {
+ const rotation = new THREE.Quaternion().setFromAxisAngle(direction, THREE.MathUtils.degToRad(q));
+ if (jointFrame(dof) === "local") node.quaternion.multiply(rotation); else node.quaternion.premultiply(rotation);
+ }
}
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 || model.schema !== JOINT_MODEL_SCHEMA || !isFiveAxisCoordinates(model.coordinates)) fail("需要五个不重复的数控轴");
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);
@@ -54,7 +63,7 @@ export function validateJointModel(model: JointModel, complete = true) {
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 (![...model.coordinates].includes(joint.axis) || joint.dof !== 1 || !validJointDof(joint) || !["parent", "local"].includes(joint.frame) || 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);
}
diff --git a/web/react/src/machine-manager-config.ts b/web/react/src/machine-manager-config.ts
new file mode 100644
index 0000000..39d2371
--- /dev/null
+++ b/web/react/src/machine-manager-config.ts
@@ -0,0 +1,32 @@
+import settings from "../../workspace/machine-manager-settings.json";
+import { isFiveAxisCoordinates, type CncAxis } from "./machine-joints";
+import type { Vec3 } from "./machine-urdf";
+
+// These profiles describe the managed-GLB runtime adapters, independently of
+// the wider catalog of LinuxCNC program/kinematics examples.
+export const MANAGER_SETTINGS = settings;
+export const DEFAULT_MANAGER_COORDINATES = settings.profiles[0]?.coordinates ?? "";
+export const managerProfile = (coordinates: string) => settings.profiles.find((profile) => profile.coordinates === coordinates);
+export const managerAxes = (coordinates: string) => [...new Set(coordinates)] as CncAxis[];
+export const zeroAxisValues = (coordinates: string, previous: Record = {}) =>
+ Object.fromEntries(managerAxes(coordinates).map((axis) => [axis.toLowerCase(), previous[axis.toLowerCase()] ?? 0]));
+export const roleAxis = (role: string, coordinates: string): CncAxis | undefined => {
+ const profile = managerProfile(coordinates);
+ const axis = role === "tilt" ? profile?.tiltAxis : role === "c" ? profile?.rotaryAxis : settings.roles.find((item) => item.id === role)?.axis;
+ return axis && coordinates.includes(axis) ? axis as CncAxis : undefined;
+};
+export function validateManagerCalibration(coordinates: string, origin: Vec3, offset: Vec3) {
+ if (![origin, offset].every((v) => Array.isArray(v) && v.length === 3 && v.every(Number.isFinite))) throw new Error("标定需要三个有限数值");
+ const profile = managerProfile(coordinates);
+ if (!profile) return;
+ const index = ["x", "y", "z"].indexOf(profile.forbiddenOffsetDirection);
+ if (Math.abs(offset[index]) > 1e-9) throw new Error(`${profile.tiltAxis}→${profile.rotaryAxis} 偏置 ${profile.forbiddenOffsetDirection.toUpperCase()} 必须为 0(当前运动学约束)`);
+}
+
+for (const profile of settings.profiles) {
+ if (!isFiveAxisCoordinates(profile.coordinates) || !profile.coordinates.includes(profile.tiltAxis) || !profile.coordinates.includes(profile.rotaryAxis) || !["x", "y", "z"].includes(profile.forbiddenOffsetDirection) || !Array.isArray(profile.tiltDirection) || profile.tiltDirection.length !== 3 || !profile.tiltDirection.every(Number.isFinite) || !Array.isArray(profile.rotaryDirection) || profile.rotaryDirection.length !== 3 || !profile.rotaryDirection.every(Number.isFinite)) throw new Error("管理器机型配置无效");
+ for (const axis of managerAxes(profile.coordinates)) {
+ const limit = profile.limits[axis as keyof typeof profile.limits];
+ if (!limit || limit.length !== 3 || !limit.every(Number.isFinite) || limit[0] >= limit[1] || limit[2] <= 0) throw new Error(`管理器 ${axis} 行程配置无效`);
+ }
+}
diff --git a/web/react/src/machine-manager-history.ts b/web/react/src/machine-manager-history.ts
index 2895234..cbd46a0 100644
--- a/web/react/src/machine-manager-history.ts
+++ b/web/react/src/machine-manager-history.ts
@@ -1,11 +1,13 @@
import { useCallback, useReducer } from "react";
import type { MachinePartDraft, PhysicalZero } from "./machine-manager";
import type { Vec3 } from "./machine-urdf";
+import { DEFAULT_MANAGER_COORDINATES, zeroAxisValues } from "./machine-manager-config";
-export type MachineDraft = { documentInfo?: { name: string; description: string }; coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
+export type MachineDraft = { documentInfo?: { name: string; description: string }; coordinates: string; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
type History = { current: MachineDraft; past: MachineDraft[]; future: MachineDraft[] };
type Action = { type: "edit"; change: (draft: MachineDraft) => MachineDraft } | { type: "undo" | "redo" | "clear" };
-const initial: MachineDraft = { documentInfo: { name: "", description: "" }, coordinates: "XYZAC", parts: [], cOriginMm: [0, 0, 0], tiltOffsetMm: [0, 0, 0], physicalZero: { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 } };
+const zeroFor = (coordinates: string, previous: PhysicalZero = {}) => zeroAxisValues(coordinates, previous);
+const initial: MachineDraft = { documentInfo: { name: "", description: "" }, coordinates: DEFAULT_MANAGER_COORDINATES, parts: [], cOriginMm: [0, 0, 0], tiltOffsetMm: [0, 0, 0], physicalZero: zeroFor(DEFAULT_MANAGER_COORDINATES) };
export function machineDraftHistory(state: History, action: Action): History {
if (action.type === "clear") return { current: state.current, past: [], future: [] };
if (action.type === "undo") { const current = state.past.at(-1); return current ? { current, past: state.past.slice(0, -1), future: [state.current, ...state.future] } : state; }
@@ -15,7 +17,7 @@ export function machineDraftHistory(state: History, action: Action): History {
if (current === state.current || (draftFingerprint(current) === draftFingerprint(state.current) && current.parts.every((part, i) => part.showOrigin === state.current.parts[i]?.showOrigin && part.scene === state.current.parts[i]?.scene))) return state;
return { current, past: [...state.past, state.current].slice(-30), future: [] };
}
-export function useMachineDraft(coordinates: MachineDraft["coordinates"] = "XYZAC") {
+export function useMachineDraft(coordinates: MachineDraft["coordinates"] = DEFAULT_MANAGER_COORDINATES) {
const [history, dispatch] = useReducer(machineDraftHistory, { current: { ...initial, coordinates }, past: [], future: [] });
const edit = useCallback((change: (draft: MachineDraft) => MachineDraft) => dispatch({ type: "edit", change }), []);
return { history, draft: history.current, edit, undo: () => dispatch({ type: "undo" }), redo: () => dispatch({ type: "redo" }), clearHistory: useCallback(() => dispatch({ type: "clear" }), []) };
@@ -30,5 +32,5 @@ export function changeMachineType(draft: MachineDraft, coordinates: MachineDraft
if (draft.coordinates === coordinates) return draft;
return { ...draft, coordinates, parts: draft.parts.map((part) => ({ ...part,
nodeJoints: Object.fromEntries(Object.entries(part.nodeJoints ?? {}).filter(([, joint]) => coordinates.includes(joint.axis))),
- })) };
+ })), physicalZero: zeroFor(coordinates, draft.physicalZero) };
}
diff --git a/web/react/src/machine-manager.ts b/web/react/src/machine-manager.ts
index c745ecf..aaf7002 100644
--- a/web/react/src/machine-manager.ts
+++ b/web/react/src/machine-manager.ts
@@ -4,6 +4,7 @@ import { parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-ur
import { bindJointModel, defaultDof, jointZero, validateJointModel, type CncAxis, type JointDof, type JointModel, type JointRecord } from "./machine-joints";
import { decodeEditorDocument, encodeEditorDocument, partSettings, type DocumentInfo } from "./machine-document";
import type { ImportedMachine } from "./imported-machine";
+import { MANAGER_SETTINGS, managerProfile, roleAxis } from "./machine-manager-config";
export type MachinePartRole = "base" | "x" | "y" | "z" | "tool" | "tilt" | "c" | "work";
export type MachinePartSource = { id: string; filename: string; hash: string; scene: THREE.Group; restoredJoints?: Record; restoredRoles?: Record; restoredModel?: JointModel; restoredParts?: MachinePartDraft[]; restoredInfo?: DocumentInfo };
@@ -22,19 +23,24 @@ export type MachinePartDraft = MachinePartSource & {
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 };
+/** Per-coordinate machine zero, keyed by the active LinuxCNC axis letters. */
+export type PhysicalZero = Record;
export type ManagedMachineGeometry = {
schema: "linuxcnc.machine-manager/1";
- coordinates: "XYZAC" | "XYZBC";
+ coordinates: string;
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; nodeJoints?: 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);
+export const MACHINE_PART_ROLES: MachinePartRole[] = MANAGER_SETTINGS.roles.map((role) => role.id as MachinePartRole);
+export const roleLabel = (role: MachinePartRole, coordinates: string) => {
+ const configured = MANAGER_SETTINGS.roles.find((item) => item.id === role)?.label;
+ const axis = roleAxis(role, coordinates);
+ return role === "tilt" || role === "c" ? `${axis ?? "旋转"} 轴部件` : configured ?? role;
+};
+export const expectedParentRole = (role: MachinePartRole): MachinePartRole | null => MANAGER_SETTINGS.roles.find((item) => item.id === role)?.parent as MachinePartRole | null ?? null;
const finite3 = (value: Vec3, label: string) => {
@@ -164,8 +170,8 @@ export async function loadMachinePart(bytes: ArrayBuffer, filename: string): Pro
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("应用前需要正好八个机床角色部件");
+export function validateMachineAssembly(coordinates: string, parts: MachinePartDraft[]) {
+ if (parts.length !== MACHINE_PART_ROLES.length) throw new Error(`应用前需要 ${MACHINE_PART_ROLES.length} 个机床角色部件`);
const byRole = new Map();
const ids = new Set(parts.map((part) => part.id));
if (ids.size !== parts.length) throw new Error("部件 ID 重复");
@@ -186,32 +192,39 @@ export function validateMachineAssembly(coordinates: "XYZAC" | "XYZBC", parts: M
return byRole;
}
-function machineUrdf(coordinates: "XYZAC" | "XYZBC", cOriginMm: Vec3, tiltOffsetMm: Vec3) {
+function machineUrdf(coordinates: string, cOriginMm: Vec3, tiltOffsetMm: Vec3) {
+ const profile = managerProfile(coordinates);
+ if (!profile) throw new Error(`未找到 ${coordinates} 的机床管理器配置`);
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 offsetDirection = profile.forbiddenOffsetDirection;
+ const offsetIndex = ["x", "y", "z"].indexOf(offsetDirection);
+ if (Math.abs(tiltOffsetMm[offsetIndex]) > 1e-9) throw new Error(`${profile.tiltAxis}→${profile.rotaryAxis} 偏置 ${offsetDirection.toUpperCase()} 必须为 0`);
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 tilt = profile.tiltAxis;
+ const tiltAxis = profile.tiltDirection.join(" ");
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]),
+ const limits = (axis: string) => profile.limits[axis as keyof typeof profile.limits] ?? (() => { throw new Error(`未配置 ${axis} 轴限位`); })();
+ const linearLimits = (axis: string) => limits(axis).map((value) => value / 1000);
+ const rotaryLimits = (axis: string) => limits(axis).map((value) => radians(value));
+ 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", linearLimits("X")),
+ joint("y_joint", "prismatic", "x", "y", [0, 0, 0], "0 1 0", "Y", linearLimits("Y")),
+ joint("z_joint", "prismatic", "y", "z", [0, 0, 0], "0 0 1", "Z", linearLimits("Z")),
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("tilt_joint", "revolute", "base", "tilt", t, tiltAxis, tilt, rotaryLimits(tilt)),
+ joint("c_joint", "revolute", "tilt", "c", u, profile.rotaryDirection.join(" "), profile.rotaryAxis, rotaryLimits(profile.rotaryAxis)),
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; documentInfo?: DocumentInfo }): Promise {
+export async function buildManagedMachine(input: { coordinates: string; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero; documentInfo?: DocumentInfo }): Promise {
if (input.parts.some((part) => part.nodeJoints !== undefined)) return buildJointManagedMachine(input);
+ if (!managerProfile(input.coordinates)) throw new Error("当前运行时尚未为该坐标提供通用运动学适配器;结构和关节配置可以继续编辑并导出");
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);
@@ -241,14 +254,14 @@ export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC
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 {
+export function draftNodeDof(part: MachinePartDraft, path: string, coordinates: string): 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() : "";
+ const axis = role === "tilt" ? coordinates[3] : role === "c" ? coordinates[4] : role && ["x", "y", "z"].includes(role) ? role.toUpperCase() : "";
return axis ? defaultDof(axis as CncAxis) : null;
}
-type ManagedInput = { documentInfo?: DocumentInfo; coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
+type ManagedInput = { documentInfo?: DocumentInfo; coordinates: string; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
export function makeJointAssembly(input: ManagedInput, deepClone = false) {
if (!input.parts.length) throw new Error("请先导入机床模型");
@@ -287,6 +300,7 @@ export function makeJointAssembly(input: ManagedInput, deepClone = false) {
async function buildJointManagedMachine(input: ManagedInput): Promise {
// Validate using shallow transform clones before allocating deep runtime resources.
validateJointModel(makeJointAssembly(input).model);
+ if (!managerProfile(input.coordinates)) throw new Error("当前运行时尚未为该坐标提供通用运动学适配器;结构和关节配置可以继续编辑并导出");
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}`;
diff --git a/web/react/src/machine-studio-lighting.ts b/web/react/src/machine-studio-lighting.ts
new file mode 100644
index 0000000..38c1342
--- /dev/null
+++ b/web/react/src/machine-studio-lighting.ts
@@ -0,0 +1,55 @@
+import * as THREE from "three";
+import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
+
+// One display pipeline for the GLB editor and controller. Keep these settings
+// outside model data: they must never change exported paint, metal or textures.
+export const MACHINE_STUDIO = Object.freeze({
+ lighting: "neutral-studio-pmrem",
+ toneMapping: "AgX",
+ exposure: 1,
+ environmentIntensity: .65,
+ hemisphereIntensity: .2,
+ keyIntensity: 2.4,
+ fillIntensity: .55,
+ rimIntensity: .65,
+});
+
+export function installMachineStudio(renderer: THREE.WebGLRenderer, scene: THREE.Scene, up: "y" | "z") {
+ renderer.outputColorSpace = THREE.SRGBColorSpace;
+ renderer.toneMapping = THREE.AgXToneMapping;
+ renderer.toneMappingExposure = MACHINE_STUDIO.exposure;
+ renderer.domElement.dataset.machineToneMapping = MACHINE_STUDIO.toneMapping;
+ renderer.domElement.dataset.machineExposure = String(MACHINE_STUDIO.exposure);
+ renderer.domElement.dataset.machineLighting = MACHINE_STUDIO.lighting;
+
+ const room = new RoomEnvironment();
+ const pmrem = new THREE.PMREMGenerator(renderer);
+ const environment = pmrem.fromScene(room, .04);
+ room.dispose(); pmrem.dispose();
+ scene.environment = environment.texture;
+ scene.environmentIntensity = MACHINE_STUDIO.environmentIntensity;
+ // RoomEnvironment is Y-up; the editor's assembly is Z-up.
+ scene.environmentRotation.set(up === "z" ? Math.PI / 2 : 0, 0, 0);
+
+ const rig = new THREE.Group(); rig.name = "neutral-machine-studio";
+ const position = (object: THREE.Object3D, x: number, y: number, z: number) => {
+ if (up === "z") object.position.set(x, y, z);
+ else object.position.set(x, z, -y);
+ };
+ const hemisphere = new THREE.HemisphereLight(0xffffff, 0x505050, MACHINE_STUDIO.hemisphereIntensity);
+ position(hemisphere, 0, 0, 1); rig.add(hemisphere);
+ const key = new THREE.DirectionalLight(0xffffff, MACHINE_STUDIO.keyIntensity);
+ position(key, 3, -4, 6); rig.add(key, key.target);
+ const fill = new THREE.DirectionalLight(0xffffff, MACHINE_STUDIO.fillIntensity);
+ position(fill, -4, -1, 3); rig.add(fill, fill.target);
+ const rim = new THREE.DirectionalLight(0xffffff, MACHINE_STUDIO.rimIntensity);
+ position(rim, 1, 4, 4); rig.add(rim, rim.target);
+ scene.add(rig);
+
+ return { key, dispose() {
+ rig.removeFromParent();
+ key.shadow.map?.dispose();
+ if (scene.environment === environment.texture) scene.environment = null;
+ environment.dispose();
+ } };
+}
diff --git a/web/react/src/machine-urdf.ts b/web/react/src/machine-urdf.ts
index 745d9f9..0ebf481 100644
--- a/web/react/src/machine-urdf.ts
+++ b/web/react/src/machine-urdf.ts
@@ -3,10 +3,12 @@ import { Matrix4, Quaternion, Vector3 } from "three";
// Restricted, versioned URDF profile for the two native LinuxCNC TRT solvers.
export const MACHINE_SCHEMA = "linuxcnc.machine/1";
export const CNC_NS = "urn:linuxcnc:web:machine:1";
-export const MAX_MACHINE_BYTES = 32 * 1024 * 1024;
+// Large, detailed industrial machine assemblies can exceed 32 MiB before
+// compression. Keep one shared limit for import, validation and export.
+export const MAX_MACHINE_BYTES = 64 * 1024 * 1024;
export type Vec3 = [number, number, number];
export type MachineJoint = { name: string; type: string; parent: string; child: string; origin: Vec3; axis: Vec3; coordinate: string; limit?: { lower: number; upper: number; velocity: number; effort: number } };
-export type MachineStructure = { name: string; profile: string; coordinates: "XYZAC" | "XYZBC"; base: string; tool: string; work: string; links: string[]; joints: MachineJoint[]; offsets: Record; urdf: string };
+export type MachineStructure = { name: string; profile: string; coordinates: string; base: string; tool: string; work: string; links: string[]; joints: MachineJoint[]; offsets: Record; urdf: string };
const fail = (detail: string): never => { throw new Error(`机床模型无效:${detail}`); };
const close = (a: number[], b: number[], tolerance = 1e-8) => a.length === b.length && a.every((v, i) => Number.isFinite(v) && Math.abs(v - b[i]) < tolerance);
const name = (value: string | null) => value && /^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(value) ? value : fail("名称缺失或含不支持的字符");
@@ -85,7 +87,7 @@ export function parseMachineUrdf(urdf: string): MachineStructure {
}
export function inspectMachineGlb(bytes: ArrayBuffer): { json: any; structure: MachineStructure } {
- if (bytes.byteLength < 20 || bytes.byteLength > MAX_MACHINE_BYTES) fail("GLB 大小必须在 20 字节至 32 MiB 之间");
+ if (bytes.byteLength < 20 || bytes.byteLength > MAX_MACHINE_BYTES) fail("GLB 大小必须在 20 字节至 64 MiB 之间");
const view = new DataView(bytes);
if (view.getUint32(0, true) !== 0x46546c67 || view.getUint32(4, true) !== 2 || view.getUint32(8, true) !== bytes.byteLength) fail("GLB 头部或长度错误");
let offset = 12, json: any = null, binaryLength = -1;
diff --git a/web/react/src/styles.css b/web/react/src/styles.css
index b6a4c90..944dc18 100644
--- a/web/react/src/styles.css
+++ b/web/react/src/styles.css
@@ -438,7 +438,7 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.machine-preview-area { position: relative; display: flex; flex-direction: column; min-height: 0; min-width: 0; border: 1px solid #52666a; border-radius: 5px; overflow: hidden; background: #263438; }
.machine-preview-tools { display: flex; align-items: center; gap: 5px; flex: 0 0 auto; padding: 6px; background: #e3eceb; flex-wrap: wrap; }
.machine-preview-tools small { margin-left: auto; font-size: 11px; }
-.machine-manager-preview-shell { flex: 1; min-height: 0; }
+.machine-manager-preview-shell { position: relative; flex: 1; min-height: 0; }
.machine-manager-preview { position: absolute; inset: 0; min-height: 0; height: 100%; border: 0; }
.machine-preview-hint { flex: 0 0 auto; color: #d1e2df; font-size: 11px; margin: 0; padding: 6px 10px; }
.machine-preview-empty { position: absolute; inset: 48px 16px 40px; display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; gap: 12px; color: #e3eeeb; pointer-events: none; }
@@ -451,10 +451,37 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.machine-editor-tabs [aria-selected="true"] { background: #27585d; color: #fff; border-color: #27585d; }
.machine-manager-dialog [hidden] { display: none !important; }
.machine-structure-panel { display: grid; grid-template-rows: minmax(125px, .9fr) minmax(190px, 1.1fr); gap: 8px; min-height: 0; flex: 1; }
-.machine-part-list { display: flex; flex-direction: column; overflow: hidden; border-radius: 4px; }
+.machine-part-list { display: flex; flex-direction: column; overflow: auto; border-radius: 4px; }
.machine-part-list > header { display: flex; justify-content: space-between; gap: 8px; }
.machine-part-list > header span { color: #617576; font-size: 11px; }
-.machine-part-list > .machine-tree { flex: 1; min-height: 0; overflow: auto; }
+.machine-part-list > .machine-tree { flex: 1 0 90px; min-height: 90px; overflow: auto; }
+.machine-part-list > header, .machine-part-list > .machine-tree-tools, .machine-part-list > .glb-five-axis-analysis { flex-shrink: 0; }
+.machine-model-list { display: grid; flex: 0 0 auto; gap: 4px; margin: 5px 0 7px; max-height: 116px; overflow: auto; }
+.machine-model-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px; align-items: stretch; border: 1px solid #c5d7d3; border-radius: 4px; background: #fff; }
+.machine-model-row.selected { border-color: #338187; box-shadow: inset 3px 0 #338187; background: #edf7f4; }
+.machine-model-row button:focus-visible { outline: 2px solid #2b858a; outline-offset: -2px; }
+.machine-model-row > button:first-child { min-width: 0; padding: 5px 7px; text-align: left; background: transparent; }
+.machine-model-row > button:first-child strong, .machine-model-row > button:first-child span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.machine-model-row > button:first-child strong { color: #245b5e; font-size: 11px; }
+.machine-model-row > button:first-child span { margin-top: 2px; color: #627777; font-size: 10px; }
+.machine-model-row > button:last-child { align-self: center; margin: 4px; padding: 3px 6px; border: 1px solid #d3aba1; border-radius: 3px; color: #8b2d24; background: #fff; font-size: 10px; }
+.glb-five-axis-analysis { margin: 6px 0 8px; padding: 7px; border: 1px solid #9fc3bd; border-radius: 4px; background: #edf6f3; color: #315d5f; font-size: 11px; }
+.glb-five-axis-analysis > header { display: flex; align-items: center; justify-content: space-between; gap: 6px; }
+.glb-five-axis-analysis h3 { margin: 0; color: #245e60; font-size: 12px; }
+.glb-five-axis-analysis button { padding: 3px 6px; border: 1px solid #9abcb5; border-radius: 3px; background: #fff; color: #245e60; font-size: 11px; }
+.glb-analysis-summary { margin: 5px 0; font-weight: 600; }
+.glb-analysis-grid, .glb-analysis-axes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 4px; }
+.glb-analysis-grid span { padding: 4px; background: #fff; border: 1px solid #c5dcd7; border-radius: 3px; }
+.glb-analysis-grid strong { display: block; margin-top: 2px; color: #163f43; font-size: 11px; overflow-wrap: anywhere; }
+.glb-analysis-axes { grid-template-columns: repeat(6, minmax(0, 1fr)); margin-top: 5px; }
+.glb-analysis-axes > span { min-width: 0; padding: 4px 2px; text-align: center; border-radius: 3px; background: #fff; border: 1px solid #c5dcd7; }
+.glb-analysis-axes > span.detected { border-color: #4a9990; background: #e1f2ed; }
+.glb-analysis-axes > span.missing { opacity: .6; }
+.glb-analysis-axes b { display: block; color: #1b5659; font-size: 13px; }
+.glb-analysis-axes small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 9px; }
+.glb-five-axis-analysis details { margin-top: 5px; }
+.glb-five-axis-analysis details p { margin: 4px 0; }
+.glb-five-axis-analysis details ul { margin: 4px 0 0; padding-left: 16px; }
.machine-tree-tools { display: flex; gap: 4px; margin-bottom: 6px; }
.machine-tree-tools input { width: 100%; min-width: 0; }
.machine-tree-tools button { white-space: nowrap; padding: 3px 7px; }
diff --git a/web/workspace/machine-manager-settings.json b/web/workspace/machine-manager-settings.json
new file mode 100644
index 0000000..a2cbf1c
--- /dev/null
+++ b/web/workspace/machine-manager-settings.json
@@ -0,0 +1,153 @@
+{
+ "roles": [
+ {
+ "id": "base",
+ "label": "床身底座",
+ "parent": null,
+ "axis": null
+ },
+ {
+ "id": "x",
+ "label": "X 轴部件",
+ "parent": "base",
+ "axis": "X"
+ },
+ {
+ "id": "y",
+ "label": "Y 轴部件",
+ "parent": "x",
+ "axis": "Y"
+ },
+ {
+ "id": "z",
+ "label": "Z 轴部件",
+ "parent": "y",
+ "axis": "Z"
+ },
+ {
+ "id": "tool",
+ "label": "主轴/刀具",
+ "parent": "z",
+ "axis": null
+ },
+ {
+ "id": "tilt",
+ "label": "倾转部件",
+ "parent": "base",
+ "axis": null
+ },
+ {
+ "id": "c",
+ "label": "回转部件",
+ "parent": "tilt",
+ "axis": null
+ },
+ {
+ "id": "work",
+ "label": "工件/工作台",
+ "parent": "c",
+ "axis": null
+ }
+ ],
+ "profiles": [
+ {
+ "caseId": "xyzac-trt",
+ "coordinates": "XYZAC",
+ "urdfProfile": "trt-xyz-head/1",
+ "tiltAxis": "A",
+ "rotaryAxis": "C",
+ "forbiddenOffsetDirection": "x",
+ "limits": {
+ "X": [
+ -200,
+ 200,
+ 20
+ ],
+ "Y": [
+ -100,
+ 100,
+ 20
+ ],
+ "Z": [
+ -120,
+ 120,
+ 20
+ ],
+ "A": [
+ -100,
+ 50,
+ 30
+ ],
+ "C": [
+ -36000,
+ 36000,
+ 30
+ ]
+ },
+ "effort": 1000,
+ "tiltDirection": [
+ 1,
+ 0,
+ 0
+ ],
+ "rotaryDirection": [
+ 0,
+ 0,
+ 1
+ ]
+ },
+ {
+ "caseId": "xyzbc-trt",
+ "coordinates": "XYZBC",
+ "urdfProfile": "trt-xyz-head/1",
+ "tiltAxis": "B",
+ "rotaryAxis": "C",
+ "forbiddenOffsetDirection": "y",
+ "limits": {
+ "X": [
+ -200,
+ 200,
+ 20
+ ],
+ "Y": [
+ -100,
+ 100,
+ 20
+ ],
+ "Z": [
+ -120,
+ 120,
+ 20
+ ],
+ "B": [
+ -36000,
+ 36000,
+ 30
+ ],
+ "C": [
+ -36000,
+ 36000,
+ 30
+ ]
+ },
+ "effort": 1000,
+ "tiltDirection": [
+ 0,
+ 1,
+ 0
+ ],
+ "rotaryDirection": [
+ 0,
+ 0,
+ 1
+ ]
+ }
+ ],
+ "historyLimit": 30,
+ "jogSteps": [
+ 0.1,
+ 1,
+ 10
+ ],
+ "initialJogStep": 1
+}