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

View File

@@ -0,0 +1,15 @@
{
"report_id": "GLB-MGR-E17",
"status": "verified",
"url": "http://127.0.0.1:5180/",
"checks": [
"named GLB downloads and atomic browser-library copy with time/size/checksum; editor display changes mark document dirty",
"reload and local-library open restore eight source files, exact edited hierarchy, axis/sign, visibility, origins and calibration; second save has identical metadata",
"explicit open-file entry restores document; library redownload is byte identical; delete affects only chosen saved copy",
"inconsistent editor metadata is rejected atomically without replacing the current draft",
"saved GLB loads through control-panel importer and can be sent back into editor with eight original parts",
"legacy GLB remains editable via single-assembly fallback",
"local storage failure does not prevent GLB download and is visibly reported"
],
"errors": []
}

View File

@@ -0,0 +1 @@
{"schema":"axis-control-inventory.v1","status":"verified","documented_controls":146,"duplicate_command_entries_removed":["machine_menu","view_menu","program_pane_load","tool_probe_commands"],"contract":"linuxcnc-axis-web-control-panel-source-logic.md"}

View File

@@ -0,0 +1,21 @@
{
"schema": "machine-manager-browser.v1",
"report_id": "GLB-MGR-E02",
"mode": "production-preview",
"status": "verified",
"checks": [
"eight GLB parts rendered with studio PMREM lighting",
"eight-part roots have unique single DOFs; fixed base cannot move; numeric input drives only bound X",
"validated assembly exports a self-contained managed GLB",
"validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData",
"same component assembly switches to BC hierarchy and applies BC geometry",
"BC five-axis program drives managed model from authoritative WASM joints",
"production bundle exercised through AC/BC UI apply and WASM program motion"
],
"browser_errors": [],
"final": {
"program": "running",
"machine": "ON",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

View File

@@ -0,0 +1 @@
{"report_id":"GLB-MGR-E14-HISTORY","status":"verified","checks":["undo/redo","duplicate edits ignored","new edit discards redo","30-entry bound","clear preserves draft","machine type change preserves general axes and clears incompatible tilt","undo restores type and bindings"]}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,14 @@
{
"report_id": "GLB-MGR-E14",
"status": "verified",
"url": "http://127.0.0.1:5180/",
"checks": [
"search expands real nested nodes; axis overview locates selection; binding undo/redo",
"empty input preserves last pose; repeated selection/input does not concatenate; single-axis step and reset",
"TRT forbidden offset blocks export before build; calibration undo/redo",
"view presets and selected fitting; deletion undo restores model with all axis bindings",
"failed batch import leaves existing assembly intact",
"live mode locks edits; desktop, short viewport and narrow viewport keep footer visible without overflow"
],
"errors": []
}

View File

@@ -58,3 +58,5 @@
18. MGR-19:对话框第一步独立选择设计机床类型 XYZAC/XYZBC;控制面板切换不覆盖设计草稿。机型限制轴绑定、运动验证和标定。保存完整机床 GLB,供控制面板“导入机床模型”自动识别机型并使用。
19. MGR-20:导入/应用跨机型模型时同步加载目标机型默认 G 代码,程序路径、源码、预览与运行机型保持一致;同机型标定保留兼容程序。不兼容轴程序在加载及执行入口拒绝。
20. MGR-21 文档管理:新建、打开已保存机床、编辑控制面板机床;模型名称/说明;保存 GLB 同时提供浏览器下载和本地机床库副本;刷新后从库恢复,多部件文件分组、节点路径、层级覆盖、显隐、关节和标定完整回填。

View File

@@ -23,3 +23,5 @@
16. `MGR-19`:把机型纳入草稿/撤销历史;添加首步机型选择;切换清理不适用倾转绑定;完整模型打开恢复机型;下载前调用控制面板读取器校验;验证从 AC 工作区导入 BC GLB 自动切换及 MILL700 两机型保存/运动闭环。
17. `MGR-20`:修正 applyGeometry 跨机型程序选择;加载前读取并验证目标程序;添加 Worker 轴兼容门控;测试双向模型导入、管理器应用、自定义程序保留、文件/编辑器拒绝和旧程序运行门控。
18. `MGR-21`:定义机床编辑文档元数据;重建原部件树再应用覆盖防止双重偏移;实现 IndexedDB 元数据/文件原子保存;文件页提供保存位置解释与恢复;测试刷新、再次保存等价、标准导入、旧版兼容、存储失败下载和 AC/BC 五轴回归。

View File

@@ -119,3 +119,11 @@
- 新增共享 `programMachineMismatch`:检查文件机型标识和有效块中的不支持 A/B 轴字,忽略注释、命名参数、O 标签及表达式内部。通用三轴程序继续可用,不改写 A/B 指令。
- Worker 在 PROGRAM_LOAD 以及 RUN/STEP/RESUME 检查源程序与展开子程序;覆盖绕过 UI 和旧恢复程序。门控缓存源文本/机型/路径,避免每帧重复扫描大程序。
- E16 本地验证通过:AC→BC、BC→AC、管理器应用、错轴文件/编辑器拒绝、同机型保留、自定义程序与正确 BC 运行。11 个可加载 RTCP 目录程序、程序替换和单步回归通过。
## R15 / 2026-09-21 / 保存位置与机床文档恢复
- 原“保存”只有浏览器下载,无法从网页看到保存位置;新增明确下载说明和本浏览器机床库(IndexedDB),显示名称、机型、时间、大小、部件/关节数和校验摘要。
- 增加文件与恢复页、新建机床、打开已保存机床、编辑控制面板机床、模型名称与设计说明、保存版本打开/再下载/删除。新建/打开仍可撤销。
- 增加 `linuxcnc.machine-editor/1` GLB 元数据:原部件配置与源节点零姿态拓扑,打开时根据持久节点 ID 重建原始部件树,再应用编辑覆盖,避免合并分组与双重变换。校验恢复结果与运动 JSON 一致。
- 文件和元数据在 IndexedDB 一次事务提交;写入失败仍允许下载并明确提示。库打开校验 SHA256;库副本独立于磁盘文件,清站点数据会删除库记录。
- 8 部件文件往返测试已通过:刷新后逐项回填、再次保存元数据完全等价、控制面板导入再送回编辑、旧 GLB 兼容、存储失败反馈。MILL700 AC/BC 完整回归通过,各 8 个五轴动态 tick,浏览器错误 0。工作台布局与八部件生产回归通过。MGR-21 verified。

View File

@@ -23,3 +23,4 @@
|MGR-18 工程化工作台体验|verified|MGR-17|层级搜索与定位、绑定总览、撤销/重做、输入校验、步骤页签、视图预设、单轴步进、实时跟随锁定、响应式布局和失败导入保护通过|
|MGR-19 对话框机型与保存整机|verified|MGR-18|先在对话框选择设计机型;绑定/标定受机型约束;草稿独立/可撤销;保存完整 GLB 可由控制面板导入并自动切换机型|
|MGR-20 机型程序一致性|verified|MGR-19|导入/应用跨机型时加载目标默认程序;同机型保留;错轴程序加载/运行/单步/继续拒绝;AC↔BC 浏览器及 Worker 验收通过|
|MGR-21 保存位置与完整文档恢复|verified|MGR-20|命名 GLB 下载与本地机床库;显式打开/刷新恢复;多部件配置精确回填;二次保存等价;标准导入、旧版兼容、存储失败和 AC/BC 回归|

View File

@@ -176,3 +176,17 @@
- `node web/worker/test_boat_trt_programs.mjs`:AC/BC 船形程序均完整运行至 completed;报告 `program-compatibility/boats.report.json`。
- E16 已发布 `0de96bb`,线上资源 `index-VAG1dzJ6.js`。线上重复 AC↔BC 模型导入/管理器应用、错误轴加载拒绝、同机型保留和 BC 运行,全部 verified、浏览器错误 0;`program-compatibility/cloud.report.json`、`deployment.report.json`。
## E17 / R15 / 文件保存与编辑文档恢复
- `node scripts/test_machine_document_browser.mjs`:GLB-MGR-E17 verified。测试命名保存、浏览器库原子保存、刷新再打开八个部件、层级覆盖/轴反向/显隐/原点辅助/标定/说明逐项恢复;二次保存的三个元数据对象完全等价。
- 显式磁盘打开、库再次下载字节一致、删除仅影响副本、损坏编辑数据拒绝且保持现有草稿、控制面板标准导入再回到编辑器、旧文件兼容、存储故障时继续下载并显示失败信息全部通过;浏览器错误 0。
- `JOINT_TEST_OUTPUT=.../coverage/glb-machine-manager/document/mill700 node scripts/test_machine_joint_dof_browser.mjs`:MILL700 AC/BC 各 8 个动态 tick;导出/打开/标准导入及实时映射 verified。
- `test_machine_manager_workbench.mjs` 和生产 `test_machine_manager_browser.mjs` 通过;静态类型、构建、历史、数学、134→146 控件清单、双画布/XYZAC 契约通过。
|文件(相对 document)|SHA-256|
|---|---|
|`browser.report.json`|`5f45884a75acf14d7a3ecff6a9a50b306842216cb2c26f91d44a781aff3f0073`|
|`files-and-recovery.png`|`ae48e693668a8e100b81fa2cbfe1a6d4dbc21600a230994f73f2d85d2876e389`|
|`saved-eight-parts.glb`|`f155f46e6d5566e407a2f708a66ba61332366fb2ba5007b01ac99ed04494769e`|
|`mill700/browser.report.json`|`7f5c87b1a2d5491242aa87248d0cb4085e5d849065520ad56e4e64c6453216b2`|

View File

@@ -71,3 +71,7 @@ GLB 树节点 ID 使用导入时的子节点索引路径,不使用可能重复
## D18 / 跨机型加载匹配程序,不转换旋转轴
工作区切换会改变解释器轴语义,不能继续加载旧机型的 G 代码。跨机型导入/应用自动使用目标机型 catalog 指定的默认程序,目标资源在提交配置前读取和校验;同机型标定保留兼容源程序。禁止通过把 A 文本替换为 B 冒充程序适配。加载和执行门控由 Worker 再次校验,注释和参数名不算轴指令。校验范围是 AC/BC 机型隔离,完整 G 代码解析仍由 LinuxCNC WASM 负责。
## D19 / 可移植 GLB 与浏览器副本
完整机床文档以 GLB 为交付文件;普通网页不能读取浏览器下载文件的绝对磁盘路径,因此界面明确告知查看下载记录。保存时另存完整 GLB 到当前站点 IndexedDB,并提供列表恢复,不暗示上传云端或跨设备同步。每次保存为独立副本,删除库记录不删除磁盘文件或当前草稿。原始编辑拓扑/零姿态保存为 `linuxcnc.machine-editor/1`,与运行关节 JSON 并存;精确恢复原始多文件装配后再施加覆盖,不重复叠加。旧文件缺少编辑元数据时按兼容装配恢复并保留轴/标定。临时关节试动不保存,未完成绑定的会话草稿目前不做跨刷新自动恢复。

View File

@@ -0,0 +1,38 @@
# 保存与重新打开机床
## 保存在哪里
“保存机床模型 GLB”会生成包含几何、层级、机型、单自由度轴绑定、正负方向、零姿态和标定的完整文件:
- **磁盘文件**:发起浏览器下载。通常在系统“下载”目录;如果浏览器开启“每次询问保存位置”,则在用户选择的位置。浏览器下载记录可定位文件,网页不能获取其绝对路径,也不能验证用户是否取消了下载。
- **本浏览器机床库**:保存成功时还会把相同 GLB 存入本网站的 IndexedDB。刷新页面后进入“文件与恢复”,可直接打开配置、重新下载或删除副本。列表显示保存时间、机型、大小及 SHA256 摘要,每次保存生成一个独立版本。
本地机床库不会自动上传云端;它只存在于同一浏览器、同一站点地址(包括协议和端口)。清理站点数据、隐私窗口退出或换设备会失去这些本地副本。因此磁盘 GLB 是重要的可移植保存方式。库保存失败时仍会下载 GLB,并显示失败信息。
## 再次打开和回填
1. 在“三维机床管理”点击“打开已保存机床”,选择之前下载的 GLB;或在“文件与恢复”的机床库点击“打开配置”。
2. 新版保存文件恢复为原来的多个部件,回填原始部件名、父级关系、源节点路径、层级拖放覆盖、平移/姿态零位、节点结构角色、轴绑定、方向、正负向和显隐/坐标原点显示。
3. 顶部自动恢复 XYZAC/XYZBC,标定页恢复 C 原点、A/B 偏置、物理零点;文件页恢复模型名称、设计说明。轴总览可定位相应节点。
4. 打开操作替换当前草稿,原草稿可通过撤销恢复。关节试动值归零,不作为机床设计零姿态保存。
5. “编辑控制面板机床”将控制面板已加载的受支持配置回填到管理器,不需要再次找磁盘文件;后续编辑不会自动改变正在运行的机床,仍需显式应用。
6. 名称和说明在“文件与恢复”中修改;保存后生成新的 GLB 和本地版本。默认下载名为 `managed-xyzac.glb` 或 `managed-xyzbc.glb`,命名后为 `名称-xyzac.glb` 或 `名称-xyzbc.glb`。
旧版本机床 GLB 若缺少完整编辑数据,仍恢复已有装配层级、轴配置和标定;无法凭空恢复已丢失的原始多文件分组。普通几何 GLB 使用“导入 GLB 部件”,完整机床使用“打开已保存机床”。
## 提供给控制面板
控制面板关机且程序空闲 → “导入机床模型” → 选择保存的 GLB。文件内的机型会决定控制配置;跨 AC/BC 会加载对应机型默认程序。完成后重新回零再执行程序。
## 元数据
- `userData.linuxcncMachine`:原 URDF 控制框架。
- `userData.linuxcncMachineGeometry`:C 原点、偏置、物理零点与部件配置。
- `userData.linuxcncJointModel`:运行关节 ID、父链与轴映射。
- `userData.linuxcncMachineEditor`:`linuxcnc.machine-editor/1`,包含 `info.name/description`、每个部件的 `settings`、原始 `sourceNodes`。源节点记录稳定路径、原点位置(米)、四元数、比例、名称和可见性。GLB 节点的 `linuxcncNodeId` 负责匹配实际几何。
加载新版文件时会重建源节点拓扑,再复用原装配逻辑应用设置,并对比恢复后的关节 JSON,防止错绑或双重偏移。源 GLB 文件不原地修改。
## 当前边界
本轮提供完整机床的保存/打开/本地版本恢复。尚未绑定完整五轴的会话草稿、撤销历史、相机视角和临时试动值不会跨刷新自动保存;关闭对话框但不刷新页面仍保留草稿。云端共享、权限审批、碰撞检查及其他机型运动学不属于本轮实现。

View File

@@ -11,7 +11,7 @@
现有整机 GLB + 内嵌 URDF 导入能力继续保留。新管理器生成与现有 `linuxcnc.machine/1` 相同的严格结构,再通过已有配置、Worker、WASM 和 Three.js 投影链运行。
当前状态:MGR-20 机型程序一致性 verified;MGR-19 对话框选择机型/整机保存已 verified;MGR-17、MGR-18 已 verified;MGR-01 至 MGR-16 已完成历史验收,MILL700 实测覆盖照明、Z-up、132 节点树、拖放、节点原点、关节局部坐标系、节点相对父级平移旋转、模型删除、轴绑定、XYZ/AC 层级运动和 WebGL 资源降级。最新台账 R14,证据 `GLB-MGR-E01/E02/E05/E07/E08/E09/E10/E12/E13/E14/E15/E16` 位于 `coverage/glb-machine-manager/`。
当前状态:MGR-21 文件管理与恢复 verified;MGR-20 机型程序一致性 verified;MGR-19 对话框选择机型/整机保存已 verified;MGR-17、MGR-18 已 verified;MGR-01 至 MGR-16 已完成历史验收,MILL700 实测覆盖照明、Z-up、132 节点树、拖放、节点原点、关节局部坐标系、节点相对父级平移旋转、模型删除、轴绑定、XYZ/AC 层级运动和 WebGL 资源降级。最新台账 R15,证据 `GLB-MGR-E01/E02/E05/E07/E08/E09/E10/E12/E13/E14/E15/E16/E17` 位于 `coverage/glb-machine-manager/`。
文档:
@@ -26,3 +26,5 @@
- [07-单自由度JSON与实时驱动](07-单自由度JSON与实时驱动.md)
- [关节 JSON Schema](joint-model.schema.json)
- [08-保存与重新打开机床](08-保存与重新打开机床.md)

View File

@@ -1083,3 +1083,7 @@ silently reproduced in the browser.
### MGR-19 document machine type
`machine-manager-type`, `machine-manager-type-note`: select XYZAC/XYZBC inside the design dialog before configuring structure. Design type belongs to undoable draft state and is independent of the live controller. Saving validates the GLB through the standard importer; loading a full machine restores type, bindings and calibration, and the control panel switches workspace to match the imported model.
### MGR-21 document save and recovery
`machine-document-new`, `machine-document-open`, `machine-document-open-file`, `machine-document-from-controller`, `machine-document-name`, `machine-document-description`, `machine-document-location`, `machine-library-search`, `machine-library-open`, `machine-library-download`, `machine-library-delete`, `machine-manager-tab-files`: named GLB document, complete multi-part editor recovery, explicit file opening, immutable saved copies in browser IndexedDB, location/status information, download and delete. Controller model editing reads current effective calibration. GLB remains compatible with the standard importer.

View File

@@ -13,7 +13,7 @@ const [contract, app, panel, preview] = await Promise.all([
const controlIds = [
"menu-file", "menu-file-edit-tool-data", "menu-file-quit", "menu-help", "menu-help-about", "about-close",
"toolbar-estop", "toolbar-power", "toolbar-open", "toolbar-load-case", "toolbar-reload", "toolbar-run", "toolbar-step", "toolbar-pause-resume", "toolbar-stop", "toolbar-block-delete", "toolbar-optional-pause", "toolbar-zoom-in", "toolbar-zoom-out", "toolbar-view-z", "toolbar-view-z2", "toolbar-view-x", "toolbar-view-y", "toolbar-view-y2", "toolbar-view-p", "toolbar-rotate", "toolbar-clear-live-plot",
"workspace-case-select", "workspace-load-3d-chips", "workspace-rtcp-programs", "rtcp-program-scope", "rtcp-program-select", "rtcp-program-source", "rtcp-program-load", "rtcp-program-download", "machine-manager-settings", "machine-manager-type", "machine-manager-type-note", "machine-manager-import", "machine-manager-files", "machine-manager-delete", "machine-manager-role", "machine-manager-parent", "machine-manager-show-origin", "machine-tree-node", "machine-tree-origin", "machine-tree-axis-role", "machine-joint-direction", "machine-joint-sign", "machine-joint-value", "machine-joint-step-minus", "machine-joint-step-plus", "machine-joint-reset", "machine-joint-follow-live", "machine-joint-live-state", "machine-manager-export-json", "machine-manager-undo", "machine-manager-redo", "machine-manager-draft-state", "machine-tree-search", "machine-tree-expand", "machine-tree-collapse", "machine-manager-validation", "machine-test-x", "machine-test-y", "machine-test-z", "machine-test-a", "machine-test-b", "machine-test-c", "machine-test-reset", "machine-test-state", "machine-manager-apply", "machine-manager-export", "machine-manager-tab-structure", "machine-manager-tab-motion", "machine-manager-tab-calibration", "machine-view-fit", "machine-view-selected", "machine-view-front", "machine-view-top", "manual-home-all", "manual-home-joint-select",
"workspace-case-select", "workspace-load-3d-chips", "workspace-rtcp-programs", "rtcp-program-scope", "rtcp-program-select", "rtcp-program-source", "rtcp-program-load", "rtcp-program-download", "machine-manager-settings", "machine-document-new", "machine-document-open", "machine-document-open-file", "machine-document-from-controller", "machine-document-name", "machine-document-description", "machine-document-location", "machine-library-search", "machine-library-open", "machine-library-download", "machine-library-delete", "machine-manager-tab-files", "machine-manager-type", "machine-manager-type-note", "machine-manager-import", "machine-manager-files", "machine-manager-delete", "machine-manager-role", "machine-manager-parent", "machine-manager-show-origin", "machine-tree-node", "machine-tree-origin", "machine-tree-axis-role", "machine-joint-direction", "machine-joint-sign", "machine-joint-value", "machine-joint-step-minus", "machine-joint-step-plus", "machine-joint-reset", "machine-joint-follow-live", "machine-joint-live-state", "machine-manager-export-json", "machine-manager-undo", "machine-manager-redo", "machine-manager-draft-state", "machine-tree-search", "machine-tree-expand", "machine-tree-collapse", "machine-manager-validation", "machine-test-x", "machine-test-y", "machine-test-z", "machine-test-a", "machine-test-b", "machine-test-c", "machine-test-reset", "machine-test-state", "machine-manager-apply", "machine-manager-export", "machine-manager-tab-structure", "machine-manager-tab-motion", "machine-manager-tab-calibration", "machine-view-fit", "machine-view-selected", "machine-view-front", "machine-view-top", "manual-home-all", "manual-home-joint-select",
"switchkins-0", "switchkins-1", "switchkins-2",
"tab-manual", "tab-mdi", "tab-diagnostics", "tab-preview", "tab-dro",
"manual-axis-x", "manual-axis-y", "manual-axis-z", "manual-axis-a", "manual-axis-c",

View File

@@ -0,0 +1,59 @@
import assert from 'node:assert/strict';
import { readFile,writeFile,mkdir } from 'node:fs/promises';
import { pathToFileURL } from 'node:url';
import * as THREE from '../web/react/node_modules/three/build/three.module.js';
import { GLTFExporter } from '../web/react/node_modules/three/examples/jsm/exporters/GLTFExporter.js';
globalThis.FileReader=class{readAsArrayBuffer(blob){blob.arrayBuffer().then(v=>{this.result=v;this.onloadend?.();});}readAsDataURL(blob){blob.arrayBuffer().then(v=>{this.result=`data:${blob.type};base64,${Buffer.from(v).toString('base64')}`;this.onloadend?.();});}};
const fixtures=await Promise.all(Array.from({length:8},async(_,i)=>{const s=new THREE.Scene();s.name=`part_${i}`;const mesh=new THREE.Mesh(new THREE.BoxGeometry(.05,.05,.05),new THREE.MeshStandardMaterial({color:0x458e83}));mesh.name=`mesh_${i}`;mesh.position.set(.01,.02,.03);s.add(mesh);const extra=mesh.clone();extra.name=`extra_${i}`;extra.position.x=.07;s.add(extra);const b=await new GLTFExporter().parseAsync(s,{binary:true,trs:true});return{name:`part-${i}.glb`,mimeType:'model/gltf-binary',buffer:Buffer.from(b)};}));
const pw=await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE??'/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js'));
const browser=await(pw.default??pw).chromium.launch({headless:true,executablePath:'/usr/bin/google-chrome',args:['--no-sandbox','--use-angle=swiftshader','--enable-unsafe-swiftshader']});const page=await browser.newPage({viewport:{width:1440,height:1000}});
const out=new URL('../coverage/glb-machine-manager/document/',import.meta.url);await mkdir(out,{recursive:true});
const report={report_id:'GLB-MGR-E17',status:'running',url:process.env.MACHINE_MANAGER_URL??'http://127.0.0.1:5180/',checks:[],errors:[]};
page.on('pageerror',e=>report.errors.push(e.message));page.on('console',m=>{if(['error','warning'].includes(m.type()))report.errors.push(m.text());});
const c=id=>page.locator(`[data-axis-control="${id}"]`),preview=page.locator('[data-machine-manager-preview]');
const idle=()=>page.waitForFunction(()=>document.querySelector('main.axis-app')?.dataset.axisProgramLoading==='false'&&document.querySelector('main.axis-app')?.dataset.axisStateSource==='linuxcnc-wasm',null,{timeout:60000});
const choose=async(name)=>{await c('machine-manager-tab-structure').click();await c('machine-tree-search').fill(name);await c('machine-tree-node').filter({has:page.locator('span').filter({hasText:new RegExp(`^${name}$`)})}).first().click();};
const save=async()=>{const p=page.waitForEvent('download');await c('machine-manager-export').click();const d=await p;const bytes=await readFile(await d.path());return{bytes,filename:d.suggestedFilename(),json:JSON.parse(bytes.toString('utf8',20,20+bytes.readUInt32LE(12)))}};
const editor=saved=>saved.json.scenes[0].extras.linuxcncMachineEditor;
try{
await page.goto(report.url);await idle();await c('machine-manager-settings').click();await c('machine-manager-files').setInputFiles(fixtures);await page.waitForFunction(()=>document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount==='8');
for(const[i,a]of[[1,'X'],[2,'Y'],[3,'Z'],[5,'A'],[6,'C']]){await choose(`part_${i}`);await c('machine-tree-axis-role').selectOption(a);}
await choose('part_3');await c('machine-joint-sign').selectOption('-1');await c('machine-manager-show-origin').uncheck();
await choose('extra_3');const extra=c('machine-tree-node').filter({has:page.locator('span').filter({hasText:/^extra_3$/})}).first();const sourcePath=await extra.locator('xpath=ancestor::li[1]').getAttribute('data-machine-tree-path');const partId=await extra.locator('xpath=ancestor::li[1]').getAttribute('data-machine-tree-part');
await c('machine-tree-search').fill('');const target=c('machine-tree-node').filter({has:page.locator('span').filter({hasText:/^mesh_3$/})}).first();const targetPath=await target.locator('xpath=ancestor::li[1]').getAttribute('data-machine-tree-path');
await target.evaluate((el,p)=>{const d=new DataTransfer();d.setData('application/x-linuxcnc-machine-tree',JSON.stringify(p));el.dispatchEvent(new DragEvent('drop',{bubbles:true,cancelable:true,dataTransfer:d}));},{partId,path:sourcePath});
await page.waitForFunction(({partId,sourcePath,targetPath})=>document.querySelector(`[data-machine-tree-part="${partId}"][data-machine-tree-path="${sourcePath}"]`)?.dataset.machineTreeParent===targetPath,{partId,sourcePath,targetPath});
await choose('part_7');await page.locator('.machine-part-flags input').first().uncheck();
await c('machine-manager-tab-calibration').click();const field=t=>page.locator('fieldset.machine-vector').filter({hasText:t});await field('C 轴原点').getByLabel('X',{exact:true}).fill('24');await field('A 到 C 轴偏置').getByLabel('Y',{exact:true}).fill('18');await page.locator('fieldset.machine-zero').getByLabel('A',{exact:true}).fill('12');
await c('machine-manager-tab-files').click();await c('machine-document-name').fill('Workshop Mill');await c('machine-document-description').fill('Revision 2: calibrated A pivot');const first=await save();assert.equal(first.filename,'Workshop Mill-xyzac.glb');assert.equal(editor(first).parts.length,8);assert.equal(editor(first).info.description,'Revision 2: calibrated A pivot');
await page.waitForFunction(()=>document.querySelectorAll('[data-machine-saved-id]').length===1);assert.match(await c('machine-document-location').textContent(),/下载.*本浏览器机床库/s);
const id=await page.locator('[data-machine-saved-id]').first().getAttribute('data-machine-saved-id');await writeFile(new URL('saved-eight-parts.glb',out),first.bytes);
await choose('part_3');await c('machine-manager-show-origin').check();assert.doesNotMatch(await c('machine-manager-draft-state').textContent(),/与保存版本一致/);await c('machine-manager-undo').click();assert.match(await c('machine-manager-draft-state').textContent(),/与保存版本一致/);
report.checks.push('named GLB downloads and atomic browser-library copy with time/size/checksum; editor display changes mark document dirty');
await page.reload();await idle();await c('machine-manager-settings').click();await c('machine-manager-tab-files').click();await page.waitForFunction(()=>document.querySelectorAll('[data-machine-saved-id]').length===1);await c('machine-library-open').click();await page.waitForFunction(()=>document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount==='8');
await choose('part_3');assert.equal(await c('machine-tree-axis-role').inputValue(),'Z');assert.equal(await c('machine-joint-sign').inputValue(),'-1');assert.equal(await c('machine-manager-show-origin').isChecked(),false);
await choose('part_7');assert.equal(await page.locator('.machine-part-flags input').first().isChecked(),false);
await c('machine-manager-tab-calibration').click();assert.equal(await field('C 轴原点').getByLabel('X',{exact:true}).inputValue(),'24');assert.equal(await field('A 到 C 轴偏置').getByLabel('Y',{exact:true}).inputValue(),'18');assert.equal(await page.locator('fieldset.machine-zero').getByLabel('A',{exact:true}).inputValue(),'12');
await c('machine-manager-tab-files').click();assert.equal(await c('machine-document-name').inputValue(),'Workshop Mill');assert.equal(await c('machine-document-description').inputValue(),'Revision 2: calibrated A pivot');
const second=await save();assert.deepEqual(editor(second),editor(first));assert.deepEqual(second.json.scenes[0].extras.linuxcncJointModel,first.json.scenes[0].extras.linuxcncJointModel);assert.deepEqual(second.json.scenes[0].extras.linuxcncMachineGeometry,first.json.scenes[0].extras.linuxcncMachineGeometry);
report.checks.push('reload and local-library open restore eight source files, exact edited hierarchy, axis/sign, visibility, origins and calibration; second save has identical metadata');
await c('machine-document-new').click();assert.equal(await preview.getAttribute('data-machine-manager-part-count'),'0');await c('machine-document-open-file').setInputFiles({name:first.filename,mimeType:'model/gltf-binary',buffer:first.bytes});await page.waitForFunction(()=>document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount==='8');
await c('machine-manager-tab-files').click();const dl=page.waitForEvent('download');await page.locator(`[data-machine-saved-id="${id}"] [data-axis-control="machine-library-download"]`).click();assert.deepEqual(await readFile(await(await dl).path()),first.bytes);
page.once('dialog',d=>d.accept());await page.locator(`[data-machine-saved-id="${id}"] [data-axis-control="machine-library-delete"]`).click();await page.waitForFunction(id=>!document.querySelector(`[data-machine-saved-id="${id}"]`),id);assert.equal(await preview.getAttribute('data-machine-manager-part-count'),'8');
report.checks.push('explicit open-file entry restores document; library redownload is byte identical; delete affects only chosen saved copy');
const damaged=structuredClone(first.json);damaged.scenes[0].extras.linuxcncMachineEditor.parts[3].settings.nodeJoints.scene.sign=1;
const text=Buffer.from(JSON.stringify(damaged));const pad=(4-text.length%4)%4;const jsonBytes=Buffer.concat([text,Buffer.alloc(pad,32)]);const oldLen=first.bytes.readUInt32LE(12),bin=first.bytes.subarray(20+oldLen),header=Buffer.alloc(20);first.bytes.copy(header,0,0,12);header.writeUInt32LE(20+jsonBytes.length+bin.length,8);header.writeUInt32LE(jsonBytes.length,12);header.writeUInt32LE(0x4e4f534a,16);
await c('machine-document-open-file').setInputFiles({name:'damaged.glb',mimeType:'model/gltf-binary',buffer:Buffer.concat([header,jsonBytes,bin])});await page.waitForFunction(()=>document.querySelector('.machine-manager-workbench')?.getAttribute('aria-busy')==='false');assert.equal(await preview.getAttribute('data-machine-manager-part-count'),'8');assert.match(await page.locator('.machine-manager-footer').textContent(),/无法读取/);
report.checks.push('inconsistent editor metadata is rejected atomically without replacing the current draft');
await page.getByRole('button',{name:'清除操作消息'}).click();await page.screenshot({path:new URL('files-and-recovery.png',out).pathname});
await page.keyboard.press('Escape');await c('machine-glb-settings').click();await c('machine-glb-file').setInputFiles({name:first.filename,mimeType:'model/gltf-binary',buffer:first.bytes});await page.waitForFunction(()=>document.querySelector('.xyzac-machine-view')?.dataset.machineJointState);await idle();await page.keyboard.press('Escape');
await c('machine-manager-settings').click();await c('machine-document-new').click();await c('machine-document-from-controller').click();await page.waitForFunction(()=>document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount==='8');await choose('part_3');assert.equal(await c('machine-joint-sign').inputValue(),'-1');
report.checks.push('saved GLB loads through control-panel importer and can be sent back into editor with eight original parts');
// The legacy machine remains readable without editor-document metadata.
await c('machine-document-open-file').setInputFiles(new URL('../coverage/glb-machine-manager/machine-type/test-xyzbc.glb',import.meta.url).pathname);await page.waitForFunction(()=>document.querySelector('[data-axis-control="machine-manager-type"]')?.value==='XYZBC');assert.equal(await preview.getAttribute('data-machine-manager-part-count'),'1');report.checks.push('legacy GLB remains editable via single-assembly fallback');
// A quota/IDB failure must still allow download and report exact persistence result.
await c('machine-manager-tab-files').click();await page.evaluate(()=>{indexedDB.open=()=>{throw new Error('quota');};});const fallback=await save();assert.ok(fallback.bytes.length>0);assert.match(await c('machine-document-location').textContent(),/未保存成功/);
report.checks.push('local storage failure does not prevent GLB download and is visibly reported');
assert.deepEqual(report.errors,[]);report.status='verified';
}catch(e){report.status='failed';report.error=e.stack;report.text=await page.locator('body').innerText().catch(()=> '');await page.screenshot({path:new URL('failure.png',out).pathname}).catch(()=>{});throw e;}finally{await writeFile(new URL('browser.report.json',out),JSON.stringify(report,null,2)+'\n');await browser.close();}
console.log(JSON.stringify(report));

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; }