Save named machine documents and restore editor configuration from GLB or local library

This commit is contained in:
2026-09-21 21:07:01 +08:00
parent 1eb9840941
commit 8443930edb
30 changed files with 10581 additions and 31 deletions

File diff suppressed because one or more lines are too long

View File

@@ -9,14 +9,16 @@ import { configureMachinePartScene, draftNodeDof, makeJointAssembly, reparentMac
import { driveJoint, jointZero, defaultDof, isLinear, type JointDof, type CncAxis } from "./machine-joints";
import { projectXyzacVisual, type XyzacVisualSource } from "./xyzac-visual-adapter";
import { useMachineDraft, draftFingerprint, changeMachineType } from "./machine-manager-history";
import { downloadMachine, listSavedMachines, saveMachineCopy, readMachineCopy, deleteMachineCopy, type SavedMachine } from "./machine-library";
import { machineDocumentFilename } from "./machine-document";
import { MachineNumberInput } from "./MachineManagerInputs";
import { inspectMachineGlb, MAX_MACHINE_BYTES, type Vec3 } from "./machine-urdf";
type Props = { snapshot?: XyzacVisualSource | null; coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise<void> };
type Props = { currentMachine?: ImportedMachine | null; snapshot?: XyzacVisualSource | null; coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise<void> };
type TestPose = { x: number; y: number; z: number; a: number; b: number; c: number };
type MotionTransform = { local: number[]; localQuaternion: number[]; world: number[]; worldQuaternion: number[] };
const zero3 = (): Vec3 => [0, 0, 0];
type EditorTab = "structure" | "motion" | "calibration";
type EditorTab = "structure" | "motion" | "calibration" | "files";
type PreviewCommand = { action: "fit" | "selected" | "front" | "top"; serial: number };
const formatted = (value: number) => Number.isFinite(value) ? value.toFixed(3) : "—";
@@ -146,7 +148,7 @@ function TreeNode({ part, node, selected, onSelect, onDrop, extra, query, expans
</li>;
}
export function MachineManagerPanel({ coordinates: controllerCoordinates, disabled, onApply, snapshot }: Props) {
export function MachineManagerPanel({ coordinates: controllerCoordinates, disabled, onApply, snapshot, currentMachine }: Props) {
const [open, setOpen] = useState(false), [pending, setPending] = useState(false), [message, setMessage] = useState("");
const { history, draft, edit, undo, redo } = useMachineDraft(controllerCoordinates);
const { coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero } = draft;
@@ -154,7 +156,16 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
const setCOriginMm = (value: Vec3) => edit((current) => ({ ...current, cOriginMm: value }));
const setTiltOffsetMm = (value: Vec3) => edit((current) => ({ ...current, tiltOffsetMm: value }));
const setPhysicalZero = (value: PhysicalZero) => edit((current) => ({ ...current, physicalZero: value }));
const file = useRef<HTMLInputElement>(null);
const file = useRef<HTMLInputElement>(null), openFile = useRef<HTMLInputElement>(null);
const documentInfo = draft.documentInfo ?? { name: "", description: "" };
const [library, setLibrary] = useState<SavedMachine[]>([]), [libraryError, setLibraryError] = useState("");
const [saveLocation, setSaveLocation] = useState("尚未保存文件"), [savedSignature, setSavedSignature] = useState("");
const [libraryQuery, setLibraryQuery] = useState("");
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 disposeSources = (sources: Awaited<ReturnType<typeof loadMachinePart>>[]) => { const scenes = new Set(sources.flatMap((source) => source.restoredParts?.map((part) => part.scene) ?? [source.scene])); scenes.forEach(disposeMachineScene); };
const [selectedId, setSelectedId] = useState<string | null>(null), [selectedNodePath, setSelectedNodePath] = useState("scene");
const [tab, setTab] = useState<EditorTab>("structure"), [query, setQuery] = useState("");
const [expansion, setExpansion] = useState({ all: false, serial: 0 });
@@ -183,32 +194,37 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
const parentAllowed = (id: string, parentId: string | null) => { let cursor = parentId; const visited = new Set([id]); while (cursor) { if (visited.has(cursor)) return false; visited.add(cursor); cursor = parts.find((part) => part.id === cursor)?.parentId ?? null; } return true; };
const update = (id: string, values: Partial<MachinePartDraft>) => { if (pending || followLive) return; if (values.parentId && !parentAllowed(id, values.parentId)) { setMessage("不能将部件挂到自身或后代节点"); return; } setParts((current) => current.map((part) => part.id === id ? { ...part, ...values } : part)); setMessage(""); };
const vectorInput = (label: string, value: Vec3, set: (value: Vec3) => void, unit: string) => <fieldset className="machine-vector"><legend>{label} ({unit})</legend>{["X", "Y", "Z"].map((axis, index) => <label key={axis}>{axis}<MachineNumberInput fieldId={`${label}-${axis}`} value={value[index]} disabled={pending || followLive} onValidity={validity} onChange={(n) => { const next = [...value] as Vec3; next[index] = n; set(next); }} /></label>)}</fieldset>;
const importFiles = async (list: FileList | File[] | null) => {
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 loaded = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
const failure = results.find((result) => result.status === "rejected");
if (failure?.status === "rejected") { loaded.forEach((source) => disposeMachineScene(source.scene)); throw failure.reason; }
if (failure?.status === "rejected") { disposeSources(loaded); throw failure.reason; }
const restoredSources = loaded.filter((source) => source.restoredModel);
const restored = restoredSources[0]?.restoredModel;
const restoredSource = restoredSources[0];
const restored = restoredSource?.restoredModel;
if (savedOnly && !restored) { disposeSources(loaded); throw new Error("请选择已保存的机床 GLB;普通几何部件请使用“导入 GLB 部件”"); }
// A saved machine is opened as one document, not appended to another
// machine. Raw component imports continue to append to this draft.
if (restored && loaded.length !== 1) {
loaded.forEach((source) => disposeMachineScene(source.scene));
disposeSources(loaded);
throw new Error("完整机床 GLB 请单独打开;普通部件可批量导入");
}
edit((draft) => {
const nextDraft = (() => {
const next = restored ? [] : [...draft.parts];
for (const source of loaded) {
if (source.restoredParts) { next.push(...source.restoredParts); continue; }
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 ?? {} });
}
return { ...draft, parts: next, ...(restored ? { coordinates: restored.coordinates, cOriginMm: restored.calibration.cOriginMm, tiltOffsetMm: restored.calibration.tiltOffsetMm, physicalZero: restored.calibration.physicalZero as PhysicalZero } : {}) };
});
setTab("structure"); setQuery(""); setSelectedId(loaded[0].id); setSelectedNodePath("scene"); setMessage(restored ? `已打开 ${restored.coordinates} 完整机床,恢复层级、关节和标定;原草稿可撤销恢复` : `已导入 ${loaded.length} 个部件`);
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 } : {}) };
})();
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} 个部件`);
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
finally { setPending(false); }
};
@@ -234,7 +250,7 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
};
const apply = async () => {
if (disabled || pending || followLive || blockingReason) return; setPending(true); setMessage("正在生成机床层级和 URDF…"); let machine: ImportedMachine | null = null;
try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); await onApply(machine); setApplied(signature); setMessage("三维机床已应用;请重新回零后运行程序"); }
try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero, documentInfo }); await onApply(machine); setApplied(signature); setMessage("三维机床已应用;请重新回零后运行程序"); }
catch (error) { if (machine) disposeMachineScene(machine.scene); setMessage(error instanceof Error ? error.message : String(error)); }
finally { setPending(false); }
};
@@ -242,19 +258,62 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
if (pending || followLive || blockingReason) return;
setPending(true); setMessage("正在生成包含 userData 的组合 GLB…"); let machine: ImportedMachine | null = null;
try {
machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero });
machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero, documentInfo });
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,请减小模型后重试");
const saved = inspectMachineGlb(bytes);
if (saved.structure.coordinates !== coordinates) throw new Error("保存模型的机型与当前配置不一致");
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);
setExported(signature); setMessage(`已保存 managed-${coordinates.toLowerCase()}.glb,可在控制面板“导入机床模型”中加载,自动识别 ${coordinates} 和轴配置`);
const filename = machineDocumentFilename(documentInfo.name, coordinates);
let localCopy = false;
try {
const sha256 = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((value) => value.toString(16).padStart(2, "0")).join("");
await saveMachineCopy({ id: crypto.randomUUID(), name: documentInfo.name.trim() || `${coordinates} 机床`, filename, coordinates, savedAt: new Date().toISOString(), size: bytes.byteLength, sha256, partCount: parts.length, jointCount: bindings.length }, bytes);
localCopy = true; await refreshLibrary();
} catch { setLibraryError("本地机床库保存失败,可能存储空间不足。已继续下载 GLB,请妥善保存下载文件。"); }
downloadMachine(bytes, filename);
setExported(signature); setSavedSignature(localCopy ? signature : "");
setSaveLocation(`${filename}:已发起浏览器下载${localCopy ? ";同时保存到本浏览器机床库" : ";本地机床库未保存成功"}`);
setMessage(`已保存 ${filename}。磁盘位置见浏览器下载记录(通常为“下载”目录);${localCopy ? "下次可在“文件与恢复”直接打开。" : "请从磁盘打开恢复。"} 控制面板“导入机床模型”可使用此 GLB。`);
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
finally { if (machine) disposeMachineScene(machine.scene); setPending(false); }
};
const openLibraryModel = async (entry: SavedMachine, download = false) => {
if (pending || (followLive && !download)) return;
setPending(true);
try {
const bytes = await readMachineCopy(entry.id);
if (!bytes) throw new Error("本地文件不存在,请从磁盘重新打开");
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((value) => value.toString(16).padStart(2, "0")).join("");
if (digest !== entry.sha256) throw new Error("本地机床副本校验失败,请从磁盘备份重新打开");
if (download) { downloadMachine(bytes, entry.filename); setMessage(`已再次下载 ${entry.filename},请在浏览器下载记录中查看位置`); }
else await importFiles([new File([bytes], entry.filename, { type: "model/gltf-binary" })], true);
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
finally { setPending(false); }
};
const removeLibraryModel = async (entry: SavedMachine) => {
if (pending || !globalThis.confirm(`删除本浏览器机床库中的“${entry.name}”版本?磁盘下载文件和当前草稿不受影响。`)) return;
setPending(true);
try { await deleteMachineCopy(entry.id); await refreshLibrary(); setMessage("已删除本地保存版本"); } catch { setMessage("删除失败,请稍后重试"); } finally { setPending(false); }
};
const editControllerModel = async () => {
if (!currentMachine || pending || followLive) return;
setPending(true);
try {
const scene = new THREE.Scene(); scene.userData = JSON.parse(JSON.stringify(currentMachine.scene.userData));
// Geometry overrides belong to the current controller model even when its
// immutable source GLB was loaded before the override.
if (scene.userData.linuxcncJointModel) {
const offsets = currentMachine.structure.offsets;
scene.userData.linuxcncJointModel.calibration = { cOriginMm: [offsets.x_rot_point, offsets.y_rot_point, offsets.z_rot_point], tiltOffsetMm: [offsets.x_offset, offsets.y_offset, offsets.z_offset], physicalZero: { ...scene.userData.linuxcncJointModel.calibration.physicalZero, ...currentMachine.physicalZero } };
}
for (const child of currentMachine.scene.children) scene.add(child.clone(true));
const bytes = await new GLTFExporter().parseAsync(scene, { binary: true, trs: true, onlyVisible: false });
await importFiles([new File([bytes as ArrayBuffer], currentMachine.filename, { type: "model/gltf-binary" })], true);
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } finally { 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;
@@ -297,15 +356,15 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
edit((draft) => changeMachineType(draft, next)); setTab("structure");
setMessage(`设计机型已选择 ${next};保留模型层级和 XYZ/C 绑定,请重新配置 ${next[3]} 轴并核对标定。可撤销恢复。`);
}}><option value="XYZAC">XYZAC · A/C 五轴转台</option><option value="XYZBC">XYZBC · B/C 五轴转台</option></select></label><div><span>2 · 结构与关节 → 3 · 验证与标定 → 4 · 保存 GLB</span><small data-axis-control="machine-manager-type-note">{coordinates === "XYZAC" ? "A 绕 X 倾转,C 绕 Z 回转" : "B 绕 Y 倾转,C 绕 Z 回转"} · 控制面板当前 {controllerCoordinates}{coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : ""}</small></div></div>
<div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-manager-import" disabled={pending || followLive} onClick={() => file.current?.click()}>导入 GLB 部件</button><button type="button" data-axis-control="machine-manager-undo" disabled={!history.past.length || pending || followLive} onClick={() => changeHistory("undo")}>撤销</button><button type="button" data-axis-control="machine-manager-redo" disabled={!history.future.length || pending || followLive} onClick={() => changeHistory("redo")}>重做</button><span>{parts.length} 个模型 · {bindings.filter((b) => coordinates.includes(b.dof.axis)).length}/5 轴已绑定</span><span className="machine-save-state" data-axis-control="machine-manager-draft-state">{!parts.length ? "新建机床" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用"}</span></div>
<div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-document-new" disabled={pending || followLive} onClick={newDocument}>新建机床</button><button type="button" data-axis-control="machine-document-open" disabled={pending || followLive} onClick={requestOpen}>打开已保存机床</button><button type="button" data-axis-control="machine-document-from-controller" disabled={!currentMachine?.scene.userData.linuxcncJointModel || pending || followLive} onClick={() => void editControllerModel()}>编辑控制面板机床</button><button type="button" data-axis-control="machine-manager-import" disabled={pending || followLive} onClick={() => file.current?.click()}>导入 GLB 部件</button><button type="button" data-axis-control="machine-manager-undo" disabled={!history.past.length || pending || followLive} onClick={() => changeHistory("undo")}>撤销</button><button type="button" data-axis-control="machine-manager-redo" disabled={!history.future.length || pending || followLive} onClick={() => changeHistory("redo")}>重做</button><span>{parts.length} 个模型 · {bindings.filter((b) => coordinates.includes(b.dof.axis)).length}/5 轴已绑定</span><span className="machine-save-state" data-axis-control="machine-manager-draft-state">{!parts.length ? "新建机床" : savedSignature === signature ? "与保存版本一致" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用"}</span></div>
<div className="machine-axis-overview" aria-label="数控轴绑定总览">{[...coordinates].map((axis) => { const binding = bindings.find((b) => b.dof.axis === axis); return <button type="button" key={axis} data-axis-control={`machine-axis-locate-${axis.toLowerCase()}`} className={binding ? "bound" : "missing"} onClick={() => moveToBinding(axis)} title={binding ? `${binding.name} · 父级 ${binding.dof.direction.toUpperCase()} ${binding.dof.sign > 0 ? "+" : "−"}` : `待绑定 ${axis} 轴`}><strong>{axis}</strong><span>{binding?.name ?? "待绑定"}</span><small>{binding ? `${binding.dof.sign > 0 ? "+" : "−"}${binding.dof.direction.toUpperCase()}` : "选择节点"}</small></button>; })}</div>
<div className="machine-manager-layout">
<section className="machine-preview-area" aria-label="机床三维预览"><div className="machine-preview-tools">{([ ["fit", "整机适配"], ["selected", "定位选中"], ["front", "正视"], ["top", "俯视"] ] as const).map(([action, label]) => <button key={action} type="button" data-axis-control={`machine-view-${action}`} disabled={!parts.length || (action === "selected" && !selected)} onClick={() => setCommand((current) => ({ action, serial: current.serial + 1 }))}>{label}</button>)}<small>Z ↑ · X 红 / Y 绿 / Z 蓝</small></div>
{open && <MachineManagerPanelPreview command={command} coordinates={coordinates} parts={parts} selectedId={selectedId} selectedNodePath={selectedNodePath} testPose={effectivePose} onMotionState={receiveMotionState} />}
{!parts.length && <div className="machine-preview-empty"><strong>建立机床的数字装配</strong><p>选择机床类型 → 导入 GLB → 绑定关节 → 保存机床模型</p><button type="button" disabled={pending} onClick={() => file.current?.click()}>导入机床或部件</button><small>支持完整层级 · 单文件不超过 32 MiB</small></div>}
{!parts.length && <div className="machine-preview-empty"><strong>建立机床的数字装配</strong><p>选择机床类型 → 导入 GLB → 绑定关节 → 保存机床模型</p><button type="button" disabled={pending} onClick={() => file.current?.click()}>导入机床或部件</button><small>支持完整层级 · 单文件不超过 32 MiB</small>{library.length > 0 && <button type="button" onClick={() => setTab("files")}>恢复本地机床库({library.length} 个版本)</button>}</div>}
<p className="machine-preview-hint">左键旋转 · 滚轮缩放 · 右键平移 · 选中关节显示局部坐标系</p></section>
<div className="machine-manager-editor">
<div className="machine-editor-tabs" role="tablist" aria-label="机床配置步骤">{([ ["structure", "结构与关节"], ["motion", "运动验证"], ["calibration", "机床标定"] ] as const).map(([id, label], index) => <button id={`machine-tab-${id}`} role="tab" aria-selected={tab === id} aria-controls={`machine-panel-${id}`} tabIndex={tab === id ? 0 : -1} data-axis-control={`machine-manager-tab-${id}`} key={id} type="button" onClick={() => setTab(id)} onKeyDown={(event) => { const ids: EditorTab[] = ["structure", "motion", "calibration"]; const next = event.key === "ArrowRight" ? ids[(index + 1) % 3] : event.key === "ArrowLeft" ? ids[(index + 2) % 3] : event.key === "Home" ? ids[0] : event.key === "End" ? ids[2] : null; if (next) { event.preventDefault(); setTab(next); document.getElementById(`machine-tab-${next}`)?.focus(); } }}>{label}</button>)}</div>
<div className="machine-editor-tabs" role="tablist" aria-label="机床配置步骤">{([ ["structure", "结构与关节"], ["motion", "运动验证"], ["calibration", "机床标定"], ["files", "文件与恢复"] ] as const).map(([id, label], index) => <button id={`machine-tab-${id}`} role="tab" aria-selected={tab === id} aria-controls={`machine-panel-${id}`} tabIndex={tab === id ? 0 : -1} data-axis-control={`machine-manager-tab-${id}`} key={id} type="button" onClick={() => setTab(id)} onKeyDown={(event) => { const ids: EditorTab[] = ["structure", "motion", "calibration", "files"]; const next = event.key === "ArrowRight" ? ids[(index + 1) % 4] : event.key === "ArrowLeft" ? ids[(index + 3) % 4] : event.key === "Home" ? ids[0] : event.key === "End" ? ids[3] : null; if (next) { event.preventDefault(); setTab(next); document.getElementById(`machine-tab-${next}`)?.focus(); } }}>{label}</button>)}</div>
<div id="machine-panel-structure" className="machine-structure-panel" role="tabpanel" aria-labelledby="machine-tab-structure" hidden={tab !== "structure"}>
<section className="machine-part-list"><header><h3>部件与层级</h3><span>{validation.model?.nodes.length ?? 0} 节点</span></header><div className="machine-tree-tools"><input data-axis-control="machine-tree-search" type="search" aria-label="搜索节点名称或轴" placeholder="搜索节点名称 / 数控轴" value={query} onChange={(event) => setQuery(event.target.value)} /><button type="button" data-axis-control="machine-tree-expand" onClick={() => setExpansion((current) => ({ all: true, serial: current.serial + 1 }))}>展开</button><button type="button" data-axis-control="machine-tree-collapse" onClick={() => { setQuery(""); setExpansion((current) => ({ all: false, serial: current.serial + 1 })); }}>折叠</button></div>{parts.length ? <ul className="machine-tree" role="tree">{parts.filter((part) => !part.parentId).map(renderPartTree)}</ul> : <p>导入机床或部件后显示 GLB 内部层级。</p>}</section>
{selected && <section className="machine-part-editor"><header><h3 title={selected.filename}>{selectedTreeNode?.name ?? selected.filename}<small>{selected.filename}</small></h3><button type="button" data-axis-control="machine-manager-delete" disabled={pending || followLive} title="删除整个导入文件,可通过撤销恢复" onClick={() => remove(selected)}>删除导入模型</button></header>{selectedTreeNode && <details className="machine-origin-details"><summary>零姿态与当前坐标</summary><div className="machine-tree-origin" data-axis-control="machine-tree-origin"><strong>{selectedTreeNode.name}</strong><span>节点自身局部原点</span><output>X 0.000 Y 0.000 Z 0.000</output><span>装配位置(相对父节点,mm)</span><output>X {selectedTransform!.translationMm[0].toFixed(3)} Y {selectedTransform!.translationMm[1].toFixed(3)} Z {selectedTransform!.translationMm[2].toFixed(3)}</output><span>装配姿态(相对父节点,deg)</span><output>RX {selectedTransform!.rotationDeg[0].toFixed(3)} RY {selectedTransform!.rotationDeg[1].toFixed(3)} RZ {selectedTransform!.rotationDeg[2].toFixed(3)}</output>{selectedDriven && <><span>当前世界原点(mm)</span><output>X {(selectedDriven.world[0] * 1000).toFixed(3)} Y {(selectedDriven.world[1] * 1000).toFixed(3)} Z {(selectedDriven.world[2] * 1000).toFixed(3)}</output></>}</div></details>}<label>唯一自由度<select data-axis-control="machine-tree-axis-role" disabled={pending || followLive} value={selectedDof?.axis ?? ""} onChange={(event) => setDof(event.target.value ? defaultDof(event.target.value as CncAxis) : null)}><option value="">固定(无自由度)</option>{[..."XYZABC"].map((axis) => <option key={axis} value={axis} disabled={!coordinates.includes(axis)}>{axis} · {"XYZ".includes(axis) ? "平移 mm" : "旋转 deg"}</option>)}</select></label>
@@ -318,11 +377,21 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
</div>
<section id="machine-panel-motion" role="tabpanel" aria-labelledby="machine-tab-motion" hidden={tab !== "motion"} className="machine-motion-test"><label><input data-axis-control="machine-joint-follow-live" type="checkbox" checked={followLive} disabled={pending || (!liveReady && !followLive)} onChange={(event) => setFollowLive(event.target.checked)} />跟随数控系统实时关节位置</label><output data-axis-control="machine-joint-live-state">{followLive ? liveReady ? `WASM tick ${visual.tick}` : "实时数据暂不可用,已保持最后姿态;可取消跟随" : coordinates !== controllerCoordinates ? `设计机型 ${coordinates} 与控制面板 ${controllerCoordinates} 不同;应用或导入后可实时跟随` : "手动验证(相对物理零点)"}</output><header><h3>层级运动测试</h3><button type="button" data-axis-control="machine-test-reset" disabled={pending || followLive} onClick={() => setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 })}>复位</button></header><div>{(["x", "y", "z"] as const).map((axis) => poseInput(axis, "mm"))}{poseInput(coordinates === "XYZBC" ? "b" : "a", "deg")}{poseInput("c", "deg")}</div><p>这些输入仅验证模型运动,不向数控系统发送运动命令。</p><details><summary>诊断数据</summary><output data-axis-control="machine-test-state">{JSON.stringify(effectivePose)}</output></details></section>
<section id="machine-panel-calibration" role="tabpanel" aria-labelledby="machine-tab-calibration" hidden={tab !== "calibration"} className="machine-manager-calibration"><h3>机床标定</h3>{vectorInput("C 轴原点", cOriginMm, setCOriginMm, "mm")}{vectorInput(`${coordinates === "XYZBC" ? "B" : "A"} 到 C 轴偏置`, tiltOffsetMm, setTiltOffsetMm, "mm")}<fieldset className="machine-vector machine-zero"><legend>机床物理零点 (XYZ mm / ABC deg)</legend>{["x", "y", "z", coordinates === "XYZBC" ? "b" : "a", "c"].map((axis) => <label key={axis}>{axis.toUpperCase()}<MachineNumberInput fieldId={`zero-${axis}`} onValidity={validity} disabled={pending || followLive} value={physicalZero[axis as keyof PhysicalZero]} onChange={(value) => setPhysicalZero({ ...physicalZero, [axis]: value })} /></label>)}</fieldset></section>
<section id="machine-panel-files" role="tabpanel" aria-labelledby="machine-tab-files" hidden={tab !== "files"} className="machine-document-panel">
<h3>机床文档</h3><label>模型名称<input data-axis-control="machine-document-name" maxLength={80} value={documentInfo.name} disabled={pending || followLive} placeholder={`${coordinates} 机床`} onChange={(event) => { const name = event.target.value; edit((draft) => ({ ...draft, documentInfo: { ...documentInfo, name } })); }} /></label>
<label>设计说明<textarea data-axis-control="machine-document-description" maxLength={2000} value={documentInfo.description} disabled={pending || followLive} placeholder="用途、标定说明、版本变更…" onChange={(event) => { const description = event.target.value; edit((draft) => ({ ...draft, documentInfo: { ...documentInfo, description } })); }} /></label>
<div className="machine-save-location" data-axis-control="machine-document-location"><strong>文件保存在哪里?</strong><p>{saveLocation}</p><p>GLB 下载到浏览器设置的目录,通常为“下载”。请打开浏览器下载记录查看实际位置;网页无法读取磁盘完整路径。</p><p>保存时另存一份到本浏览器机床库,可在刷新页面后重新打开。本地机床库仅属于当前浏览器及网站地址,不会上传云服务器;清理网站数据会删除这些副本。</p></div>
<h3>本浏览器机床库 <small>{library.length} 个保存版本</small></h3><div className="machine-library-tools"><input data-axis-control="machine-library-search" aria-label="搜索已保存机床" placeholder="搜索名称 / 机型" value={libraryQuery} onChange={(event) => setLibraryQuery(event.target.value)} /><button type="button" onClick={() => void refreshLibrary()}>刷新</button></div>
{libraryError && <p role="alert">{libraryError}</p>}
{!library.length && <p>尚无保存记录。配置完成后点击“保存机床模型 GLB”,或打开磁盘中已有的机床 GLB。</p>}
<ul className="machine-library-list">{library.filter((entry) => `${entry.name} ${entry.coordinates}`.toLowerCase().includes(libraryQuery.toLowerCase())).map((entry) => <li key={entry.id} data-machine-saved-id={entry.id}><strong>{entry.name}</strong><span>{entry.coordinates} · {entry.partCount} 部件 · {entry.jointCount} 轴 · {(entry.size / 1024 / 1024).toFixed(2)} MiB</span><time dateTime={entry.savedAt}>{new Date(entry.savedAt).toLocaleString("zh-CN", { hour12: false })}</time><small title={entry.sha256}>{entry.filename} · SHA256 {entry.sha256.slice(0, 12)}</small><div><button type="button" data-axis-control="machine-library-open" disabled={pending || followLive} onClick={() => void openLibraryModel(entry)}>打开配置</button><button type="button" data-axis-control="machine-library-download" disabled={pending} onClick={() => void openLibraryModel(entry, true)}>下载 GLB</button><button type="button" data-axis-control="machine-library-delete" disabled={pending} onClick={() => void removeLibraryModel(entry)}>删除副本</button></div></li>)}</ul>
</section>
</div>
</div>
<div className={`machine-validation ${blockingReason ? "needs-attention" : "ready"}`} data-axis-control="machine-manager-validation" role="status">{blockingReason ? `配置检查:${blockingReason}` : "配置检查通过 · 五轴绑定完整"}{disabled && !blockingReason && " · 关机且程序空闲后可应用"}{followLive && " · 实时跟随中,配置编辑已锁定"}{invalidFields.length > 0 && <button type="button" onClick={() => { const id = invalidFields[0]; setTab(id.startsWith("joint-") ? "structure" : id.startsWith("test-") ? "motion" : "calibration"); requestAnimationFrame(() => document.querySelector<HTMLInputElement>('.machine-manager-dialog input[aria-invalid="true"]')?.focus()); }}>定位无效输入</button>}</div>
<footer className="machine-manager-footer"><button type="button" data-axis-control="machine-manager-apply" disabled={disabled || pending || followLive || Boolean(blockingReason)} title={disabled ? "关机且程序空闲后可应用" : undefined} onClick={() => void apply()}>应用到 Web LinuxCNC</button><button type="button" data-axis-control="machine-manager-export" disabled={pending || followLive || Boolean(blockingReason)} onClick={() => void exportGlb()}>保存机床模型 GLB</button><button type="button" data-axis-control="machine-manager-export-json" disabled={!validation.model || pending || invalidFields.length > 0} onClick={exportJson}>导出关节 JSON</button><output role="status" aria-live="polite">{message || "关闭对话框保留本次草稿;保存机床模型 GLB 后,可在控制面板“导入机床模型”中使用。"}</output>{message && <button type="button" aria-label="清除操作消息" onClick={() => setMessage("")}>×</button>}</footer>
</div>
<input ref={openFile} data-axis-control="machine-document-open-file" hidden type="file" accept=".glb,model/gltf-binary" onChange={(event) => { const item = event.target.files?.[0]; event.target.value = ""; if (item) void importFiles([item], true); }} />
<input ref={file} data-axis-control="machine-manager-files" hidden multiple type="file" accept=".glb,model/gltf-binary" onChange={(event) => { const files = event.target.files ? [...event.target.files] : []; event.target.value = ""; void importFiles(files); }} />
</DraggableDialog>
</section>;

View File

@@ -0,0 +1,57 @@
import * as THREE from "three";
import type { MachinePartDraft } from "./machine-manager";
import type { Vec3 } from "./machine-urdf";
export type DocumentInfo = { name: string; description: string };
type Settings = Pick<MachinePartDraft, "id" | "filename" | "hash" | "role" | "parentId" | "visible" | "showOrigin" | "originMm" | "translationMm" | "rotationDeg" | "nodeParents" | "nodeRoles" | "nodeTransforms" | "nodeJoints">;
type SourceNode = { path: string; name: string; position: Vec3; quaternion: [number, number, number, number]; scale: Vec3; visible: boolean };
export type EditorDocument = { schema: "linuxcnc.machine-editor/1"; info: DocumentInfo; parts: Array<{ settings: Settings; sourceNodes: SourceNode[] }> };
export function partSettings(part: MachinePartDraft): Settings {
const { id, filename, hash, role, parentId, visible, showOrigin, originMm, translationMm, rotationDeg, nodeParents, nodeRoles, nodeTransforms, nodeJoints } = part;
return JSON.parse(JSON.stringify({ id, filename, hash, role, parentId, visible, showOrigin, originMm, translationMm, rotationDeg, nodeParents, nodeRoles, nodeTransforms, nodeJoints }));
}
export function encodeEditorDocument(parts: MachinePartDraft[], info?: DocumentInfo): EditorDocument {
return { schema: "linuxcnc.machine-editor/1", info: info ?? { name: "", description: "" }, parts: parts.map((part) => {
const sourceNodes: SourceNode[] = [];
const visit = (node: THREE.Object3D, path: string) => { sourceNodes.push({ path, name: node.name, position: node.position.toArray(), quaternion: node.quaternion.toArray(), scale: node.scale.toArray(), visible: node.visible }); node.children.forEach((child, i) => visit(child, `${path}/${i}`)); };
visit(part.scene, "scene"); return { settings: partSettings(part), sourceNodes };
}) };
}
// Recover the ORIGINAL source hierarchy and zero transforms, then reapply draft
// overrides in the usual pipeline. This prevents double offsets and preserves
// separate files, stable node paths, reparenting, visibility and axis settings.
export function decodeEditorDocument(scene: THREE.Object3D, document: EditorDocument): MachinePartDraft[] {
const fail = (): never => { throw new Error("机床编辑数据损坏,无法完整恢复部件配置"); };
if (document?.schema !== "linuxcnc.machine-editor/1" || !document.info || typeof document.info.name !== "string" || typeof document.info.description !== "string" || !Array.isArray(document.parts) || !document.parts.length || document.parts.length > 500) fail();
const objects = new Map<string, THREE.Object3D>();
scene.traverse((node) => { if (node.userData.linuxcncNodeId) objects.set(node.userData.linuxcncNodeId, node); });
const ids = new Set<string>(); let count = 0;
const finite = (v: unknown, n: number): v is number[] => Array.isArray(v) && v.length === n && v.every(Number.isFinite);
const parts = document.parts.map(({ settings, sourceNodes }) => {
if (!settings || typeof settings.id !== "string" || ids.has(settings.id) || !Array.isArray(sourceNodes) || sourceNodes[0]?.path !== "scene") fail();
ids.add(settings.id);
if (typeof settings.filename !== "string" || typeof settings.hash !== "string" || typeof settings.visible !== "boolean" || typeof settings.showOrigin !== "boolean" || !finite(settings.originMm, 3) || !finite(settings.translationMm, 3) || !finite(settings.rotationDeg, 3)) fail();
const restored = new Map<string, THREE.Object3D>();
for (const entry of sourceNodes) {
if (++count > 12000 || typeof entry.path !== "string" || !/^scene(?:\/\d+)*$/.test(entry.path) || restored.has(entry.path) || !finite(entry.position, 3) || !finite(entry.quaternion, 4) || !finite(entry.scale, 3) || typeof entry.name !== "string" || typeof entry.visible !== "boolean") fail();
const object = objects.get(`${settings.id}:${entry.path === "scene" ? "source-scene" : entry.path}`);
if (!object) fail();
const node = object!.clone(false); node.name = entry.name; node.position.fromArray(entry.position); node.quaternion.fromArray(entry.quaternion); node.scale.fromArray(entry.scale); node.visible = entry.visible; node.updateMatrix();
for (const key of ["linuxcncNodeId", "linuxcncJoint", "linuxcncJointModel", "linuxcncMachineGeometry", "linuxcncMachineEditor"]) delete node.userData[key];
if (entry.path !== "scene") { const parent = restored.get(entry.path.slice(0, entry.path.lastIndexOf("/"))); if (!parent) fail(); parent!.add(node); }
restored.set(entry.path, node);
}
return { ...JSON.parse(JSON.stringify(settings)), scene: restored.get("scene") as THREE.Group } as MachinePartDraft;
});
for (const part of parts) {
let cursor: string | null = part.parentId; const visited = new Set([part.id]);
while (cursor !== null) { if (!ids.has(cursor) || visited.has(cursor)) fail(); visited.add(cursor); cursor = parts.find((p) => p.id === cursor)!.parentId; }
}
return parts;
}
export function machineDocumentFilename(name: string, coordinates: string) {
const clean = name.trim().replace(/[<>:"/\\|?*\x00-\x1f]/g, "-").replace(/\.glb$/i, "").slice(0, 80);
const stem = clean || "managed", suffix = `-${coordinates.toLowerCase()}`;
return `${stem.toLowerCase().endsWith(suffix) ? stem : stem + suffix}.glb`;
}

View File

@@ -0,0 +1,36 @@
export type SavedMachine = { id: string; name: string; filename: string; coordinates: string; savedAt: string; size: number; sha256: string; partCount: number; jointCount: number };
const DATABASE = "linuxcnc-machine-library-v1";
function openLibrary(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE, 1);
request.onupgradeneeded = () => { request.result.createObjectStore("models", { keyPath: "id" }); request.result.createObjectStore("files"); };
request.onerror = () => reject(request.error ?? new Error("无法访问本地机床库"));
request.onblocked = () => reject(new Error("本地机床库被其他窗口占用"));
request.onsuccess = () => resolve(request.result);
});
}
async function transaction<T>(mode: IDBTransactionMode, work: (tx: IDBTransaction, result: (value: T) => void) => void): Promise<T> {
const db = await openLibrary();
return new Promise<T>((resolve, reject) => {
const tx = db.transaction(["models", "files"], mode); let value: T;
tx.oncomplete = () => { db.close(); resolve(value); };
tx.onabort = tx.onerror = () => { db.close(); reject(tx.error ?? new Error("本地机床库保存失败,可能空间不足")); };
try { work(tx, (v) => { value = v; }); } catch (error) { tx.abort(); db.close(); reject(error); }
});
}
export function listSavedMachines(): Promise<SavedMachine[]> {
return transaction("readonly", (tx, result) => { const request = tx.objectStore("models").getAll(); request.onsuccess = () => result(request.result.sort((a: SavedMachine, b: SavedMachine) => b.savedAt.localeCompare(a.savedAt))); });
}
export function saveMachineCopy(metadata: SavedMachine, bytes: ArrayBuffer) {
return transaction<void>("readwrite", (tx, result) => { tx.objectStore("models").put(metadata); tx.objectStore("files").put(bytes, metadata.id); result(); });
}
export function readMachineCopy(id: string): Promise<ArrayBuffer | undefined> {
return transaction("readonly", (tx, result) => { const request = tx.objectStore("files").get(id); request.onsuccess = () => result(request.result); });
}
export function deleteMachineCopy(id: string) {
return transaction<void>("readwrite", (tx, result) => { tx.objectStore("models").delete(id); tx.objectStore("files").delete(id); result(); });
}
export function downloadMachine(bytes: ArrayBuffer, filename: string) {
const url = URL.createObjectURL(new Blob([bytes], { type: "model/gltf-binary" }));
const a = document.createElement("a"); a.href = url; a.download = filename; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1500);
}

View File

@@ -2,17 +2,17 @@ import { useCallback, useReducer } from "react";
import type { MachinePartDraft, PhysicalZero } from "./machine-manager";
import type { Vec3 } from "./machine-urdf";
export type MachineDraft = { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
export type MachineDraft = { documentInfo?: { name: string; description: string }; coordinates: "XYZAC" | "XYZBC"; 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 = { 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 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 } };
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; }
if (action.type === "redo") { const current = state.future[0]; return current ? { current, past: [...state.past, state.current].slice(-30), future: state.future.slice(1) } : state; }
if (action.type !== "edit") return state;
const current = action.change(state.current);
if (current === state.current || (draftFingerprint(current) === draftFingerprint(state.current) && current.parts.every((part, i) => part.showOrigin === state.current.parts[i]?.showOrigin))) return state;
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") {
@@ -21,7 +21,7 @@ export function useMachineDraft(coordinates: MachineDraft["coordinates"] = "XYZA
return { history, draft: history.current, edit, undo: () => dispatch({ type: "undo" }), redo: () => dispatch({ type: "redo" }), clearHistory: useCallback(() => dispatch({ type: "clear" }), []) };
}
export function draftFingerprint(draft: MachineDraft) {
return JSON.stringify({ ...draft, parts: draft.parts.map(({ id, filename, hash, role, parentId, visible, originMm, translationMm, rotationDeg, nodeParents, nodeRoles, nodeTransforms, nodeJoints }) => ({ id, filename, hash, role, parentId, visible, originMm, translationMm, rotationDeg, nodeParents, nodeRoles, nodeTransforms, nodeJoints })) });
return JSON.stringify({ ...draft, parts: draft.parts.map(({ id, filename, hash, role, parentId, visible, showOrigin, originMm, translationMm, rotationDeg, nodeParents, nodeRoles, nodeTransforms, nodeJoints }) => ({ id, filename, hash, role, parentId, visible, showOrigin, originMm, translationMm, rotationDeg, nodeParents, nodeRoles, nodeTransforms, nodeJoints })) });
}
// A different tilt axis requires an explicit new binding: never relabel a physical

View File

@@ -2,10 +2,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 { decodeEditorDocument, encodeEditorDocument, partSettings, type DocumentInfo } from "./machine-document";
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; restoredJoints?: Record<string, JointDof>; restoredRoles?: Record<string, MachinePartRole | "">; restoredModel?: JointModel };
export type MachinePartSource = { id: string; filename: string; hash: string; scene: THREE.Group; restoredJoints?: Record<string, JointDof>; restoredRoles?: Record<string, MachinePartRole | "">; restoredModel?: JointModel; restoredParts?: MachinePartDraft[]; restoredInfo?: DocumentInfo };
export type MachineNodeTransform = { translationMm: Vec3; rotationDeg: Vec3 };
export type MachinePartDraft = MachinePartSource & {
role: MachinePartRole | "";
@@ -135,6 +136,19 @@ export async function loadMachinePart(bytes: ArrayBuffer, filename: string): Pro
const model = gltf.scene.userData.linuxcncJointModel as JointModel | undefined;
if (model) {
bindJointModel(gltf.scene, model);
const editor = gltf.scene.userData.linuxcncMachineEditor;
if (editor) {
const restoredParts = decodeEditorDocument(gltf.scene, editor);
// Exercise the exact reconstruction before accepting the file transaction.
const check = makeJointAssembly({ coordinates: model.coordinates, parts: restoredParts, cOriginMm: model.calibration.cOriginMm, tiltOffsetMm: model.calibration.tiltOffsetMm, physicalZero: model.calibration.physicalZero as PhysicalZero }).model;
validateJointModel(check);
const expected = new Map(model.nodes.map((record) => [record.id, record]));
if (check.nodes.length !== model.nodes.length || check.nodes.some((record) => {
const original = expected.get(record.id);
return !original || record.parentId !== original.parentId || JSON.stringify(record.joint) !== JSON.stringify(original.joint) || [...record.zero.positionMm, ...record.zero.quaternion, ...record.zero.scale].some((v, i) => Math.abs(v - [...original.zero.positionMm, ...original.zero.quaternion, ...original.zero.scale][i]) > 1e-5);
})) throw new Error("编辑数据与保存的机床关节结构不一致");
return { id: restoredParts[0].id, filename, hash, scene: restoredParts[0].scene, restoredParts, restoredInfo: editor.info, restoredModel: 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;
@@ -196,7 +210,7 @@ function machineUrdf(coordinates: "XYZAC" | "XYZBC", cOriginMm: Vec3, tiltOffset
].join("\n")}\n</robot>\n`;
}
export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero }): Promise<ImportedMachine> {
export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero; documentInfo?: DocumentInfo }): Promise<ImportedMachine> {
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()} 物理零点无效`);
@@ -234,7 +248,7 @@ export function draftNodeDof(part: MachinePartDraft, path: string, coordinates:
return axis ? defaultDof(axis as CncAxis) : null;
}
type ManagedInput = { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
type ManagedInput = { documentInfo?: DocumentInfo; 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("请先导入机床模型");
@@ -278,8 +292,9 @@ async function buildJointManagedMachine(input: ManagedInput): Promise<ImportedMa
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))) };
const geometry: ManagedMachineGeometry = { schema: "linuxcnc.machine-manager/1", coordinates: input.coordinates, cOriginMm: [...input.cOriginMm], tiltOffsetMm: [...input.tiltOffsetMm], physicalZero: { ...input.physicalZero }, parts: input.parts.map(partSettings) };
scene.userData.linuxcncMachineGeometry = geometry;
scene.userData.linuxcncMachineEditor = encodeEditorDocument(input.parts, input.documentInfo);
const frame = new THREE.Group(); frame.name = "urdf_frame"; frame.rotation.x = -Math.PI / 2; scene.add(frame);
const links = Object.fromEntries(MACHINE_PART_ROLES.map((role) => { const node = new THREE.Group(); node.name = role; node.userData.link = role; return [role, node]; })) as Record<MachinePartRole, THREE.Group>;
frame.add(links.base); links.base.add(links.x, links.tilt, 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);

View File

@@ -517,3 +517,16 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.machine-type-step > div { display: flex; flex-direction: column; gap: 4px; font-size: 12px; }
.machine-type-step small { color: #536467; }
@media(max-width:850px) { .machine-type-step { gap: 5px; padding: 6px; } .machine-type-step > div > span { display: none; } }
.machine-document-panel { flex: 1; min-height: 0; overflow: auto; background: #f6f9f8; padding: 12px; border: 1px solid #9babad; border-radius: 4px; }
.machine-document-panel > label { display: grid; gap: 5px; margin: 10px 0; }
.machine-document-panel textarea { min-height: 70px; width: 100%; resize: vertical; padding: 6px; border: 1px solid #9babad; background: #fff; border-radius: 3px; }
.machine-document-panel h3 { font-size: 14px; margin: 10px 0; }
.machine-save-location { padding: 10px; background: #e5f0ed; border: 1px solid #adc8c1; border-radius: 4px; font-size: 12px; line-height: 1.55; }
.machine-save-location p { margin: 7px 0; }
.machine-library-tools { display: flex; gap: 8px; }
.machine-library-tools input { flex: 1; min-width: 0; }
.machine-library-list { list-style: none; margin: 10px 0; padding: 0; }
.machine-library-list li { display: grid; gap: 5px; padding: 10px; border: 1px solid #afc3bd; border-radius: 4px; margin-bottom: 8px; background: #fff; overflow-wrap: anywhere; }
.machine-library-list span, .machine-library-list time, .machine-library-list small { font-size: 11px; color: #526c6d; }
.machine-library-list li > div { display: flex; gap: 6px; }
.machine-library-list button, .machine-library-tools button { padding: 5px 8px; border: 1px solid #9babad; border-radius: 3px; background: #eaf2f0; }