Select machine type in design dialog and save importable machine GLB
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E15",
|
||||
"url": "http://127.0.0.1:5180/",
|
||||
"status": "verified",
|
||||
"checks": [
|
||||
"dialog selects BC while controller stays AC; switching drops incompatible tilt only; undo/redo restores type and bindings; saved GLB contains BC metadata",
|
||||
"controller workspace changes do not change draft type; reopening saved machine restores type, calibration and hierarchy in one undoable transaction",
|
||||
"applying BC design from AC controller changes controller type without altering design draft",
|
||||
"controller import accepts saved BC GLB from AC workspace and automatically switches to BC with physical zero"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
BIN
coverage/glb-machine-manager/machine-type/controller-import.png
Normal file
BIN
coverage/glb-machine-manager/machine-type/controller-import.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
@@ -0,0 +1 @@
|
||||
{"schema":"axis-control-inventory.v1","status":"verified","documented_controls":134,"duplicate_command_entries_removed":["machine_menu","view_menu","program_pane_load","tool_probe_commands"],"contract":"linuxcnc-axis-web-control-panel-source-logic.md"}
|
||||
BIN
coverage/glb-machine-manager/machine-type/dialog-type.png
Normal file
BIN
coverage/glb-machine-manager/machine-type/dialog-type.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
@@ -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
BIN
coverage/glb-machine-manager/machine-type/mill700/xyzac-live.png
Normal file
BIN
coverage/glb-machine-manager/machine-type/mill700/xyzac-live.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
4108
coverage/glb-machine-manager/machine-type/mill700/xyzac.json
Normal file
4108
coverage/glb-machine-manager/machine-type/mill700/xyzac.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
coverage/glb-machine-manager/machine-type/mill700/xyzbc-live.png
Normal file
BIN
coverage/glb-machine-manager/machine-type/mill700/xyzbc-live.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 145 KiB |
4108
coverage/glb-machine-manager/machine-type/mill700/xyzbc.json
Normal file
4108
coverage/glb-machine-manager/machine-type/mill700/xyzbc.json
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
|
||||
# 可导入的 MILL700 验收模型
|
||||
|
||||
这两份 GLB 由三维机床管理器实际保存,均已通过控制面板“导入机床模型”、五轴程序与参数恢复验证。用于演示 AC/BC 文件识别和单自由度映射;由同一个 MILL700 几何资产生成,不代表两台实机的测量标定。
|
||||
|
||||
- `MILL700-xyzac.glb`:XYZAC,主轴 JT1/JT2/JT3 → XYZ,工作台 JT1/JT2 → A/C。
|
||||
- `MILL700-xyzbc.glb`:XYZBC,主轴 JT1/JT2/JT3 → XYZ,工作台 JT1/JT2 → B/C。
|
||||
- 两者测试配置:Z 反向;物理零点 X=5、Z=7 mm,倾转轴=12°、C=9°;C 原点/倾转偏置为 0。
|
||||
- 使用时:控制面板关机且空闲 → 导入机床模型 → 导入 GLB → 选择文件;机型和关节参数从 GLB 自动读取。
|
||||
- 编辑时:三维机床管理 → 导入 GLB 部件 → 单独选择文件,恢复完整设计文档;根据需要核对并修改轴绑定和标定,再保存机床模型 GLB。
|
||||
BIN
coverage/glb-machine-manager/machine-type/test-xyzbc.glb
Normal file
BIN
coverage/glb-machine-manager/machine-type/test-xyzbc.glb
Normal file
Binary file not shown.
@@ -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": []
|
||||
}
|
||||
@@ -54,3 +54,5 @@
|
||||
16. MGR-17:每个节点可配置一个 XYZABC 自由度、父坐标轴方向和正负向。配置以 `linuxcnc.joint-model/1` JSON 写入原 userData,包含层级、零姿态、轴索引、物理零点和 RTCP 标定。管理器实时跟随与正式机床共用驱动逻辑;详见 [07-单自由度JSON与实时驱动](07-单自由度JSON与实时驱动.md)。
|
||||
|
||||
17. 工程工作台:独立滚动的结构/运动/标定页签,固定预览与底部操作区;层级搜索、轴定位、相机预设;30 步草稿撤销/重做;单轴步进;无效输入保持上一个有效值并禁止应用/导出。
|
||||
|
||||
18. MGR-19:对话框第一步独立选择设计机床类型 XYZAC/XYZBC;控制面板切换不覆盖设计草稿。机型限制轴绑定、运动验证和标定。保存完整机床 GLB,供控制面板“导入机床模型”自动识别机型并使用。
|
||||
|
||||
@@ -19,3 +19,5 @@
|
||||
14. `MGR-17`:定义单自由度 JSON/Schema;收敛 UI 输入;增加单文件层级应用和 GLB 恢复;接入实际 WASM 关节位置;验证每轴、反向、物理零点、导出往返和锁定行为。
|
||||
|
||||
15. `MGR-18`:工作区布局与层级导航;草稿历史/资源生命周期;数值错误状态和机型标定校验;重复选中输入回归;桌面/窄屏/矮屏及 AC/BC 实时驱动验收。
|
||||
|
||||
16. `MGR-19`:把机型纳入草稿/撤销历史;添加首步机型选择;切换清理不适用倾转绑定;完整模型打开恢复机型;下载前调用控制面板读取器校验;验证从 AC 工作区导入 BC GLB 自动切换及 MILL700 两机型保存/运动闭环。
|
||||
|
||||
@@ -103,3 +103,11 @@
|
||||
- E14 工作台浏览器验收通过:搜索、总览定位、撤销/重做、错误输入、标定校验、视图、删除恢复、导入失败保护、实时锁定和 1440/1154/720 视口无溢出。
|
||||
|
||||
- 最终 AC/BC 完整回归分别通过,各 8 个动态 tick;线上 E14 工作台回归通过。已发布 `a7233bc`,资源 `index-nddrUCCV.js`。
|
||||
|
||||
## R13 / 2026-09-21 / 对话框选择机床类型与保存整机
|
||||
|
||||
- 设计机型从外部控制面板属性改为草稿字段,标题、轴总览、绑定选项、标定与运动输入统一使用所选设计类型。顶部按选择类型→配置结构→验证标定→保存 GLB 引导。
|
||||
- AC/BC 切换只清理不适用的 A/B 轴绑定,保留模型层级、XYZ/C 和标定,并提示重新配置、核对;整个切换可撤销/重做。外部工作区切换不再改写草稿。
|
||||
- 打开已保存完整机床作为单一文档事务,自动恢复文件中的机型、关节、层级、物理零点、RTCP 参数;原草稿可撤销恢复。普通 GLB 部件导入仍为追加。
|
||||
- 保存入口改为“保存机床模型 GLB”,使用 `inspectMachineGlb` 和 32 MiB 限制校验结果,确保标准导入器可读取;成功消息提示去控制面板“导入机床模型”加载。
|
||||
- E15 针对跨机型闭环测试通过:在 AC 控制器下设计 BC、切换撤销、保存、重新打开、控制面板标准导入自动识别 BC。MILL700 两机型实测通过,分别 8 个同时五轴运动 tick、浏览器 warning/error=0。保存两份完整 GLB 验收示例;MGR-19 verified。
|
||||
|
||||
@@ -21,3 +21,4 @@
|
||||
|
||||
|MGR-17 单自由度与运行轴映射|verified|MGR-16|每节点固定或唯一 XYZABC 自由度;单值编辑;JSON 保存到场景/底座;单 GLB 完整应用;导出往返与 AC/BC 实时轴映射验收|
|
||||
|MGR-18 工程化工作台体验|verified|MGR-17|层级搜索与定位、绑定总览、撤销/重做、输入校验、步骤页签、视图预设、单轴步进、实时跟随锁定、响应式布局和失败导入保护通过|
|
||||
|MGR-19 对话框机型与保存整机|verified|MGR-18|先在对话框选择设计机型;绑定/标定受机型约束;草稿独立/可撤销;保存完整 GLB 可由控制面板导入并自动切换机型|
|
||||
|
||||
@@ -139,3 +139,19 @@
|
||||
|
||||
- 最终回归:`workbench/xyzac-regression.report.json`、`xyzbc-regression.report.json` 均 verified,各 8 个运动 tick,含模型 JSON/GLB 往返、轴符号、零点、实时跟随和五轴运动。输入框选中切换使用绘制前同步,重复填值不会拼接。
|
||||
- 线上 `https://49.232.250.35:58015/` 已发布功能代码 `a7233bc`,资源 `assets/index-nddrUCCV.js`;E14 工作台完整回归 verified,浏览器错误 0。发布证据:`workbench/cloud.report.json`、`workbench/deployment.report.json`。
|
||||
|
||||
## E15 / R13 / 对话框机型与保存整机
|
||||
|
||||
- `node scripts/test_machine_manager_type.mjs`:GLB-MGR-E15 verified;在 AC 控制面板下设计 BC、机型切换/撤销/重做、工作区不覆盖草稿、完整机床打开恢复、直接应用及标准 GLB 导入自动切换为 BC。
|
||||
- `JOINT_TEST_OUTPUT=.../coverage/glb-machine-manager/machine-type/mill700 SAVE_MACHINE_MODELS=.../coverage/glb-machine-manager/machine-type/saved-models node scripts/test_machine_joint_dof_browser.mjs`:MILL700 AC/BC 各 8 个动态 tick,验证 GLB 保存/重开/导入、标定、轴映射、单自由度及实时跟随,浏览器错误 0。
|
||||
- 八部件生产回归、工作台布局/撤销/搜索回归、类型检查、构建、机型历史、控件契约 134 项和双画布检查通过。
|
||||
- 保存入口下载前用 `inspectMachineGlb` 校验完整 GLB;所有测试机型通过标准“导入机床模型”。
|
||||
|
||||
|文件(相对 coverage/glb-machine-manager/machine-type)|SHA-256|
|
||||
|---|---|
|
||||
|`browser.report.json`|`222c43f2ef36d28f31171773efe2cbe49eff383e766c5086f18bac5bae62047a`|
|
||||
|`dialog-type.png`|`c24f8b3fd445cf5e886b8189e13e0830e6fecf341e57499e7cf73528fd803b9d`|
|
||||
|`controller-import.png`|`071f8d9a9ac4ac85fe64bfcd51f1fb605112515eb4aaa0e00ceacd539347d67b`|
|
||||
|`mill700/browser.report.json`|`05fb403ef854aba37c1ab64e0334a843d4839a6c2ce559031d25589a68346f3a`|
|
||||
|`saved-models/MILL700-xyzac.glb`|`25ba366a6550c3a613a63623b8e25216d5a3a6dd2781b69481d80d0bb87033e4`|
|
||||
|`saved-models/MILL700-xyzbc.glb`|`3b87cb8504815c13127608c513eacb3536e1be1c10f9a58fcd59920939fbe0c1`|
|
||||
|
||||
@@ -63,3 +63,7 @@ GLB 树节点 ID 使用导入时的子节点索引路径,不使用可能重复
|
||||
## D16 / 草稿可撤销、错误输入不执行
|
||||
|
||||
草稿历史只保存配置快照并共享源模型资源,上限 30 步;只有已脱离当前草稿和历史的资产才释放。关闭窗口保留会话草稿,完整持久保存使用导出 GLB,界面明确显示未应用/已应用/已导出状态。非法或空数值只存在于输入框,不驱动模型,不隐式归零;校验状态独立于操作消息显示。实时跟随时配置只读,数据不可用时保持最后姿态且允许退出。
|
||||
|
||||
## D17 / 设计类型属于机床文档
|
||||
|
||||
机型保存在 MachineDraft,控制面板当前类型只用于首次创建时的默认值和实时数据匹配。后续面板切换不得覆盖草稿。切换设计类型不自动把 A 轴重新命名为 B:保留通用轴及模型,清除不兼容绑定,提示重新配置,并允许撤销。标定保留并按新机型验证,不静默丢弃。完整机床 GLB 打开为一次替换事务,自动恢复文件机型,原草稿可撤销。保存 GLB 必须先通过标准导入器格式/机型校验;JSON 是辅助参数文件,完整可导入交付物为 GLB。
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
|
||||
## 对话框
|
||||
|
||||
1. 选中层级节点,设置“唯一自由度”:X/Y/Z 是平移,A/B/C 是旋转;固定零件选择“固定(无自由度)”。每个运动节点只有一个自由度,每个数控轴只能绑定一个节点。
|
||||
1. 首先在对话框顶部选择设计机床类型 XYZAC/XYZBC(独立于当前控制面板),然后导入模型。选中层级节点,设置“唯一自由度”:X/Y/Z 是平移,A/B/C 是旋转;固定零件选择“固定(无自由度)”。每个运动节点只有一个自由度,每个数控轴只能绑定一个节点。
|
||||
2. 设置父级坐标系中的 X/Y/Z 运动方向和正负向。AC 模型可用 XYZAC,BC 模型可用 XYZBC;另一倾转轴禁用。
|
||||
3. 当前节点只有一个“位移/转角”可编辑数值,单位 mm/deg。固定节点不能编辑;已导入的局部位置、姿态作为零姿态保留。旧版六分量自由编辑和自由操纵器已被单自由度输入替代(MGR-17)。
|
||||
4. 层级运动测试与节点输入共用对应轴值,所有输入均为相对物理零点的运动量,不写数控系统。配置 JSON 和 GLB 保存零姿态、自由度和标定,不将临时测试姿态烘焙进去。
|
||||
5. “跟随数控系统实时关节位置”使用当前机型的有效 WASM 快照,锁定手动关节输入。控制面板正式模型始终由 WASM 实际关节位置驱动。
|
||||
6. 绑定齐五个轴后可应用机床、导出 GLB;单个已有层级的 GLB 也可应用。可单独导出 JSON。未完成绑定的 JSON 是草稿,控制器读取时必须通过完整性校验。
|
||||
7. 重新导入本工具导出的 GLB 到管理器,可恢复层级、自由度、正负向、C 原点、倾转轴偏置和物理零点。GLB 机型必须与当前工作区相同。
|
||||
7. 重新导入本工具导出的 GLB 到管理器,可恢复层级、自由度、正负向、C 原点、倾转轴偏置和物理零点。完整机床 GLB 单独打开时,自动恢复文件机型,原设计草稿可撤销恢复;控制面板“导入机床模型”自动切换到 GLB 指定机型。
|
||||
|
||||
## 字段契约
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
现有整机 GLB + 内嵌 URDF 导入能力继续保留。新管理器生成与现有 `linuxcnc.machine/1` 相同的严格结构,再通过已有配置、Worker、WASM 和 Three.js 投影链运行。
|
||||
|
||||
当前状态:MGR-17、MGR-18 已 verified;MGR-01 至 MGR-16 已完成历史验收,MILL700 实测覆盖照明、Z-up、132 节点树、拖放、节点原点、关节局部坐标系、节点相对父级平移旋转、模型删除、轴绑定、XYZ/AC 层级运动和 WebGL 资源降级。最新台账 R12,证据 `GLB-MGR-E01/E02/E05/E07/E08/E09/E10/E12/E13/E14` 位于 `coverage/glb-machine-manager/`。
|
||||
当前状态:MGR-19 对话框选择机型/整机保存已 verified;MGR-17、MGR-18 已 verified;MGR-01 至 MGR-16 已完成历史验收,MILL700 实测覆盖照明、Z-up、132 节点树、拖放、节点原点、关节局部坐标系、节点相对父级平移旋转、模型删除、轴绑定、XYZ/AC 层级运动和 WebGL 资源降级。最新台账 R13,证据 `GLB-MGR-E01/E02/E05/E07/E08/E09/E10/E12/E13/E14/E15` 位于 `coverage/glb-machine-manager/`。
|
||||
|
||||
文档:
|
||||
|
||||
|
||||
@@ -1079,3 +1079,7 @@ silently reproduced in the browser.
|
||||
### MGR-18 machine manager workbench controls
|
||||
|
||||
`machine-manager-undo`, `machine-manager-redo`, `machine-manager-draft-state`, `machine-tree-search`, `machine-tree-expand`, `machine-tree-collapse`, `machine-manager-validation`, `machine-manager-tab-structure`, `machine-manager-tab-motion`, `machine-manager-tab-calibration`, `machine-view-fit`, `machine-view-selected`, `machine-view-front`, `machine-view-top`, `machine-joint-step-minus`, `machine-joint-step-plus`, `machine-joint-reset`: bounded draft history, searchable hierarchy, axis binding overview, explicit validation, view presets, single-joint step jog and responsive workbench layout.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -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-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-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",
|
||||
|
||||
@@ -4,7 +4,7 @@ import { pathToFileURL } from "node:url";
|
||||
import * as THREE from "../web/react/node_modules/three/build/three.module.js";
|
||||
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 output = new URL("../coverage/glb-machine-manager/joint-dof/", import.meta.url); await mkdir(output, { recursive: true });
|
||||
const output = process.env.JOINT_TEST_OUTPUT ? pathToFileURL(process.env.JOINT_TEST_OUTPUT.replace(/\/?$/, "/")) : new URL("../coverage/glb-machine-manager/joint-dof/", import.meta.url); await mkdir(output, { recursive: true });
|
||||
const bytes = await readFile(new URL("../MILL700-U_1.glb", import.meta.url));
|
||||
const report = { report_id: "GLB-MGR-E13", status: "running", url: process.env.MACHINE_MANAGER_URL ?? "http://127.0.0.1:5180/", machines: [], browser_errors: [] };
|
||||
const near = (a, b, label) => { assert.equal(a.length, b.length, label); a.forEach((v, i) => assert.ok(Math.abs(v - b[i]) < 2e-6, `${label} ${i}: ${v} vs ${b[i]}`)); };
|
||||
@@ -17,8 +17,8 @@ try {
|
||||
const 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 });
|
||||
await page.goto(report.url); await idle();
|
||||
if (coordinates === "XYZBC") { await c("workspace-case-select").selectOption("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"); await idle(); }
|
||||
await c("machine-manager-settings").click(); await c("machine-manager-files").setInputFiles({ name: "MILL700-U_1.glb", mimeType: "model/gltf-binary", buffer: bytes });
|
||||
|
||||
await c("machine-manager-settings").click(); await c("machine-manager-type").selectOption(coordinates); assert.match(await c("workspace-case-select").inputValue(), /xyzac-trt.ini/); await c("machine-manager-files").setInputFiles({ name: "MILL700-U_1.glb", mimeType: "model/gltf-binary", buffer: bytes });
|
||||
await page.waitForFunction(() => document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132);
|
||||
assert.equal(await c("machine-joint-value").isDisabled(), true);
|
||||
assert.equal(await c("machine-manager-apply").isDisabled(), true);
|
||||
@@ -52,6 +52,7 @@ try {
|
||||
assert.equal(json.schema, "linuxcnc.joint-model/1"); assert.equal(json.coordinates, coordinates); assert.deepEqual(Object.keys(json.axisMap).sort(), [...coordinates].sort()); assert.equal(json.nodes.filter((n) => n.joint).length, 5); assert.ok(json.nodes.every((n) => n.joint === null || n.joint.dof === 1));
|
||||
assert.equal(json.nodes.find((n) => n.id === json.axisMap.Z).joint.sign, -1);
|
||||
const glbDownload = page.waitForEvent("download"); await c("machine-manager-export").click(); const glb = await readFile(await (await glbDownload).path());
|
||||
if (process.env.SAVE_MACHINE_MODELS) { await mkdir(process.env.SAVE_MACHINE_MODELS, { recursive: true }); await writeFile(`${process.env.SAVE_MACHINE_MODELS}/MILL700-${coordinates.toLowerCase()}.glb`, glb); }
|
||||
const gltf = JSON.parse(glb.toString("utf8", 20, 20 + glb.readUInt32LE(12)));
|
||||
assert.deepEqual(gltf.scenes[0].extras.linuxcncJointModel, json);
|
||||
const bed = gltf.nodes.find((n) => n.name === "MILL700-A底座"); assert.deepEqual(bed.extras.linuxcncJointModel, json); assert.ok(bed.extras.linuxcncMachineGeometry);
|
||||
@@ -64,6 +65,7 @@ try {
|
||||
await page.keyboard.press("Escape"); await c("machine-glb-settings").click(); await c("machine-glb-file").setInputFiles({ name: `managed-${coordinates.toLowerCase()}.glb`, mimeType: "model/gltf-binary", buffer: glb });
|
||||
await page.waitForFunction(() => document.querySelector(".xyzac-machine-view")?.dataset.machineJointState, null, { timeout: 60000 }); await idle(); await page.keyboard.press("Escape");
|
||||
console.log(`${coordinates}: standard GLB importer verified`);
|
||||
assert.match(await c("workspace-case-select").inputValue(), new RegExp(`${coordinates.toLowerCase()}-trt.ini`));
|
||||
// Apply directly from the restored one-file hierarchy as well.
|
||||
await c("machine-manager-settings").click(); await c("machine-manager-apply").click(); await idle(); assert.match(await page.locator(".machine-manager-footer").textContent(), /已应用/); await page.keyboard.press("Escape");
|
||||
const program = `M428
|
||||
|
||||
@@ -64,7 +64,7 @@ try {
|
||||
await page.keyboard.press("Escape");
|
||||
const xyzbcCase = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"; await control("workspace-case-select").selectOption(xyzbcCase);
|
||||
await page.waitForFunction((path) => document.querySelector('[data-axis-control="workspace-case-select"]')?.value === path && document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false", xyzbcCase, { timeout: 45000 });
|
||||
await control("machine-manager-settings").click(); assert.match(await page.locator(".machine-manager-dialog .machine-dialog-titlebar").textContent(), /XYZBC/);
|
||||
await control("machine-manager-settings").click(); assert.match(await page.locator(".machine-manager-dialog .machine-dialog-titlebar").textContent(), /XYZAC/); await control("machine-manager-type").selectOption("XYZBC");
|
||||
await control("machine-manager-tab-structure").click(); await selectRoot(5); await control("machine-tree-axis-role").selectOption("B");
|
||||
await control("machine-manager-tab-calibration").click();
|
||||
const bcCalibrations = page.locator(".machine-manager-calibration fieldset.machine-vector"); const bcOrigin = bcCalibrations.filter({ hasText: "C 轴原点" }); await bcOrigin.getByLabel("X", { exact: true }).fill("-18"); await bcOrigin.getByLabel("Y", { exact: true }).fill("14"); await bcOrigin.getByLabel("Z", { exact: true }).fill("12");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { machineDraftHistory } from '../web/react/src/machine-manager-history.ts';
|
||||
const draft = { parts: [], cOriginMm:[0,0,0], tiltOffsetMm:[0,0,0], physicalZero:{x:0,y:0,z:0,a:0,b:0,c:0} };
|
||||
import { machineDraftHistory, changeMachineType } from '../web/react/src/machine-manager-history.ts';
|
||||
const draft = { coordinates:"XYZAC", parts: [], cOriginMm:[0,0,0], tiltOffsetMm:[0,0,0], physicalZero:{x:0,y:0,z:0,a:0,b:0,c:0} };
|
||||
let state = { current:draft, past:[], future:[] };
|
||||
const edit = (n) => ({ type:'edit', change:(d) => ({...d, cOriginMm:[n,0,0]}) });
|
||||
state = machineDraftHistory(state, edit(1)); state = machineDraftHistory(state,edit(1)); assert.equal(state.past.length,1);
|
||||
@@ -9,4 +9,8 @@ state = machineDraftHistory(state,{type:'redo'}); assert.equal(state.current.cOr
|
||||
state = machineDraftHistory(state,{type:'undo'}); state = machineDraftHistory(state,edit(2)); assert.equal(state.future.length,0);
|
||||
for(let i=3;i<45;i++) state=machineDraftHistory(state,edit(i)); assert.equal(state.past.length,30);
|
||||
const current=state.current; state=machineDraftHistory(state,{type:'clear'}); assert.equal(state.current,current); assert.equal(state.past.length,0);
|
||||
console.log(JSON.stringify({report_id:'GLB-MGR-E14-HISTORY',status:'verified',checks:['undo/redo','duplicate edits ignored','new edit discards redo','30-entry bound','clear preserves draft']}));
|
||||
|
||||
const ac = { ...draft, parts: [{ id:"p", showOrigin:true, nodeJoints:{ a:{axis:"A",direction:"x",sign:1}, c:{axis:"C",direction:"z",sign:-1}, x:{axis:"X",direction:"x",sign:1} } }] };
|
||||
const bc = changeMachineType(ac,"XYZBC"); assert.equal(bc.coordinates,"XYZBC"); assert.equal(bc.parts[0].nodeJoints.a,undefined); assert.deepEqual(bc.parts[0].nodeJoints.c,ac.parts[0].nodeJoints.c); assert.equal(ac.parts[0].nodeJoints.a.axis,"A");
|
||||
let types={current:ac,past:[],future:[]};types=machineDraftHistory(types,{type:"edit",change:d=>changeMachineType(d,"XYZBC")});types=machineDraftHistory(types,{type:"undo"});assert.equal(types.current,ac);
|
||||
console.log(JSON.stringify({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']}));
|
||||
|
||||
51
scripts/test_machine_manager_type.mjs
Normal file
51
scripts/test_machine_manager_type.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
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((result) => { this.result = result; this.onloadend?.(); }); } readAsDataURL(blob) { blob.arrayBuffer().then((result) => { this.result = `data:${blob.type};base64,${Buffer.from(result).toString('base64')}`; this.onloadend?.(); }); } };
|
||||
const scene = new THREE.Scene();scene.name='test-machine';
|
||||
const add = (name,parent) => { const n=new THREE.Mesh(new THREE.BoxGeometry(.04,.04,.04),new THREE.MeshStandardMaterial({color:0x75aab2}));n.name=name;n.position.set(.1,.1,.1);parent.add(n);return n; };
|
||||
const x=add('joint-X',scene), y=add('joint-Y',x);add('joint-Z',y);const tilt=add('joint-tilt',scene);add('joint-C',tilt);
|
||||
const bytes=Buffer.from(await new GLTFExporter().parseAsync(scene,{binary:true,trs:true}));
|
||||
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:900}});const c=id=>page.locator(`[data-axis-control="${id}"]`);
|
||||
const output=new URL('../coverage/glb-machine-manager/machine-type/',import.meta.url);await mkdir(output,{recursive:true});
|
||||
const report={report_id:'GLB-MGR-E15',url:process.env.MACHINE_MANAGER_URL??'http://127.0.0.1:5180/',status:'running',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 idle=()=>page.waitForFunction(()=>document.querySelector('main.axis-app')?.dataset.axisProgramLoading==='false'&&document.querySelector('main.axis-app')?.dataset.axisStateSource==='linuxcnc-wasm');
|
||||
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();};
|
||||
try{
|
||||
await page.goto(report.url);await idle();await c('machine-manager-settings').click();
|
||||
assert.equal(await c('machine-manager-type').inputValue(),'XYZAC');
|
||||
await c('machine-manager-type').selectOption('XYZBC');
|
||||
assert.match(await c('workspace-case-select').inputValue(),/xyzac-trt/);
|
||||
await c('machine-manager-files').setInputFiles({name:'machine.glb',mimeType:'model/gltf-binary',buffer:bytes});
|
||||
for(const [axis,name] of [['X','joint-X'],['Y','joint-Y'],['Z','joint-Z'],['B','joint-tilt'],['C','joint-C']]){await choose(name);await c('machine-tree-axis-role').selectOption(axis);}
|
||||
assert.equal(await c('machine-manager-export').isEnabled(),true);
|
||||
await c('machine-manager-type').selectOption('XYZAC');await choose('joint-tilt');assert.equal(await c('machine-tree-axis-role').inputValue(),'');assert.equal(await c('machine-manager-export').isDisabled(),true);assert.match(await c('machine-manager-validation').textContent(),/A/);
|
||||
await c('machine-manager-undo').click();assert.equal(await c('machine-manager-type').inputValue(),'XYZBC');assert.equal(await c('machine-tree-axis-role').inputValue(),'B');
|
||||
await c('machine-manager-redo').click();assert.equal(await c('machine-manager-type').inputValue(),'XYZAC');await c('machine-manager-undo').click();
|
||||
await c('machine-manager-tab-calibration').click();await page.locator('fieldset.machine-zero').getByLabel('B',{exact:true}).fill('12');
|
||||
const promise=page.waitForEvent('download');await c('machine-manager-export').click();const download=await promise;assert.equal(download.suggestedFilename(),'managed-xyzbc.glb');const saved=await readFile(await download.path());await writeFile(new URL('test-xyzbc.glb',output),saved);
|
||||
const json=JSON.parse(saved.toString('utf8',20,20+saved.readUInt32LE(12)));assert.equal(json.scenes[0].extras.linuxcncJointModel.coordinates,'XYZBC');assert.equal(json.scenes[0].extras.linuxcncJointModel.calibration.physicalZero.b,12);assert.deepEqual(json.scenes[0].extras.linuxcncJointModel.controller.axisOrder,[...'XYZBC']);
|
||||
report.checks.push('dialog selects BC while controller stays AC; switching drops incompatible tilt only; undo/redo restores type and bindings; saved GLB contains BC metadata');
|
||||
await page.keyboard.press('Escape');await c('workspace-case-select').selectOption('linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini');await idle();await c('workspace-case-select').selectOption('linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini');await idle();
|
||||
await c('machine-manager-settings').click();assert.equal(await c('machine-manager-type').inputValue(),'XYZBC');await c('machine-manager-type').selectOption('XYZAC');
|
||||
await c('machine-manager-files').setInputFiles({name:'managed-xyzbc.glb',mimeType:'model/gltf-binary',buffer:saved});await page.waitForFunction(()=>document.querySelector('[data-axis-control="machine-manager-type"]')?.value==='XYZBC');
|
||||
assert.equal(await page.locator('[data-machine-manager-preview]').getAttribute('data-machine-manager-part-count'),'1');await choose('joint-tilt');assert.equal(await c('machine-tree-axis-role').inputValue(),'B');
|
||||
await c('machine-manager-tab-calibration').click();assert.equal(await page.locator('fieldset.machine-zero').getByLabel('B',{exact:true}).inputValue(),'12');
|
||||
await c('machine-manager-undo').click();assert.equal(await c('machine-manager-type').inputValue(),'XYZAC');await c('machine-manager-redo').click();assert.equal(await c('machine-manager-type').inputValue(),'XYZBC');
|
||||
report.checks.push('controller workspace changes do not change draft type; reopening saved machine restores type, calibration and hierarchy in one undoable transaction');
|
||||
await page.screenshot({path:new URL('dialog-type.png',output).pathname});
|
||||
await c('machine-manager-apply').click();await page.waitForFunction(()=>document.querySelector('[data-axis-control="workspace-case-select"]')?.value.includes('xyzbc-trt'));await idle();assert.equal(await c('machine-manager-type').inputValue(),'XYZBC');
|
||||
await page.keyboard.press('Escape');await c('workspace-case-select').selectOption('linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini');await idle();await c('machine-manager-settings').click();
|
||||
report.checks.push('applying BC design from AC controller changes controller type without altering design draft');
|
||||
await page.keyboard.press('Escape');await c('machine-glb-settings').click();await c('machine-glb-file').setInputFiles({name:'managed-xyzbc.glb',mimeType:'model/gltf-binary',buffer:saved});
|
||||
await page.waitForFunction(()=>document.querySelector('.xyzac-machine-view')?.dataset.machineJointState&&document.querySelector('[data-axis-control="workspace-case-select"]')?.value.includes('xyzbc-trt'));await idle();
|
||||
const geometry=JSON.parse(await page.locator('.xyzac-machine-view').getAttribute('data-machine-manager-geometry'));assert.equal(geometry.coordinates,'XYZBC');assert.equal(geometry.physicalZero.b,12);
|
||||
await page.keyboard.press('Escape');await page.screenshot({path:new URL('controller-import.png',output).pathname});
|
||||
report.checks.push('controller import accepts saved BC GLB from AC workspace and automatically switches to BC with physical zero');assert.deepEqual(report.errors,[]);report.status='verified';
|
||||
}catch(e){report.status='failed';report.error=e.stack;await page.screenshot({path:new URL('failure.png',output).pathname}).catch(()=>{});throw e;}finally{await writeFile(new URL('browser.report.json',output),JSON.stringify(report,null,2)+'\n');await browser.close();}
|
||||
console.log(JSON.stringify(report));
|
||||
@@ -8,9 +8,9 @@ import { disposeMachineScene, type ImportedMachine } from "./imported-machine";
|
||||
import { configureMachinePartScene, draftNodeDof, makeJointAssembly, reparentMachineNode, buildManagedMachine, expectedParentRole, loadMachinePart, machinePartTree, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type MachineTreeNode, type PhysicalZero } from "./machine-manager";
|
||||
import { driveJoint, jointZero, defaultDof, isLinear, type JointDof, type CncAxis } from "./machine-joints";
|
||||
import { projectXyzacVisual, type XyzacVisualSource } from "./xyzac-visual-adapter";
|
||||
import { useMachineDraft, draftFingerprint } from "./machine-manager-history";
|
||||
import { useMachineDraft, draftFingerprint, changeMachineType } from "./machine-manager-history";
|
||||
import { MachineNumberInput } from "./MachineManagerInputs";
|
||||
import type { Vec3 } from "./machine-urdf";
|
||||
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 TestPose = { x: number; y: number; z: number; a: number; b: number; c: number };
|
||||
@@ -146,10 +146,10 @@ function TreeNode({ part, node, selected, onSelect, onDrop, extra, query, expans
|
||||
</li>;
|
||||
}
|
||||
|
||||
export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }: Props) {
|
||||
export function MachineManagerPanel({ coordinates: controllerCoordinates, disabled, onApply, snapshot }: Props) {
|
||||
const [open, setOpen] = useState(false), [pending, setPending] = useState(false), [message, setMessage] = useState("");
|
||||
const { history, draft, edit, undo, redo, clearHistory } = useMachineDraft();
|
||||
const { parts, cOriginMm, tiltOffsetMm, physicalZero } = draft;
|
||||
const { history, draft, edit, undo, redo } = useMachineDraft(controllerCoordinates);
|
||||
const { coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero } = draft;
|
||||
const setParts = (change: (current: MachinePartDraft[]) => MachinePartDraft[]) => edit((current) => ({ ...current, parts: change(current.parts) }));
|
||||
const setCOriginMm = (value: Vec3) => edit((current) => ({ ...current, cOriginMm: value }));
|
||||
const setTiltOffsetMm = (value: Vec3) => edit((current) => ({ ...current, tiltOffsetMm: value }));
|
||||
@@ -174,7 +174,7 @@ export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }
|
||||
useEffect(() => () => { retainedScenes.current.forEach(disposeMachineScene); }, []);
|
||||
const [testPose, setTestPose] = useState<TestPose>({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 });
|
||||
const [motionState, setMotionState] = useState<Record<string, MotionTransform>>({}); const motionStateHash = useRef("");
|
||||
useEffect(() => { setMessage(""); setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); setFollowLive(false); clearHistory(); }, [coordinates, clearHistory]);
|
||||
useEffect(() => { setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); setFollowLive(false); }, [coordinates]);
|
||||
useEffect(() => { if (selectedId && !parts.some((part) => part.id === selectedId)) { setSelectedId(parts[0]?.id ?? null); setSelectedNodePath("scene"); } }, [parts, selectedId]);
|
||||
const selected = parts.find((part) => part.id === selectedId) ?? null;
|
||||
const trees = useMemo(() => new Map(parts.map((part) => [part.id, machinePartTree(part)])), [parts]);
|
||||
@@ -190,22 +190,25 @@ export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }
|
||||
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 (loaded.some((source) => source.restoredModel && source.restoredModel.coordinates !== coordinates)) {
|
||||
const restoredSources = loaded.filter((source) => source.restoredModel);
|
||||
const restored = restoredSources[0]?.restoredModel;
|
||||
// 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));
|
||||
throw new Error(`导入机型不同,请先切换到 ${loaded.find((source) => source.restoredModel?.coordinates !== coordinates && source.restoredModel)?.restoredModel?.coordinates}`);
|
||||
throw new Error("完整机床 GLB 请单独打开;普通部件可批量导入");
|
||||
}
|
||||
const restored = loaded.find((source) => source.restoredModel)?.restoredModel;
|
||||
edit((draft) => {
|
||||
const next = [...draft.parts];
|
||||
const next = restored ? [] : [...draft.parts];
|
||||
for (const source of loaded) {
|
||||
const role = MACHINE_PART_ROLES.find((candidate) => !next.some((part) => part.role === candidate)) ?? "";
|
||||
const parentRole = role ? expectedParentRole(role) : null;
|
||||
const parentId = parentRole ? next.find((part) => part.role === parentRole)?.id ?? null : null;
|
||||
next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {}, nodeTransforms: {}, nodeJoints: source.restoredJoints ?? {}, nodeRoles: source.restoredRoles ?? {} });
|
||||
}
|
||||
return { ...draft, parts: next, ...(restored ? { cOriginMm: restored.calibration.cOriginMm, tiltOffsetMm: restored.calibration.tiltOffsetMm, physicalZero: restored.calibration.physicalZero as PhysicalZero } : {}) };
|
||||
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} 个部件`);
|
||||
setTab("structure"); setQuery(""); setSelectedId(loaded[0].id); setSelectedNodePath("scene"); setMessage(restored ? `已打开 ${restored.coordinates} 完整机床,恢复层级、关节和标定;原草稿可撤销恢复` : `已导入 ${loaded.length} 个部件`);
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { setPending(false); }
|
||||
};
|
||||
@@ -243,9 +246,12 @@ export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }
|
||||
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("组合 GLB 已导出,底座和整机场景包含机床标定 userData");
|
||||
setExported(signature); setMessage(`已保存 managed-${coordinates.toLowerCase()}.glb,可在控制面板“导入机床模型”中加载,自动识别 ${coordinates} 和轴配置`);
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { if (machine) disposeMachineScene(machine.scene); setPending(false); }
|
||||
};
|
||||
@@ -275,7 +281,7 @@ export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }
|
||||
const exportJson = () => {
|
||||
if (!validation.model || pending || invalidFields.length) return;
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(validation.model, null, 2)], { type: "application/json" }));
|
||||
const a = document.createElement("a"); a.href = url; a.download = `machine-joints-${coordinates.toLowerCase()}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); setMessage("关节 JSON 已导出;恢复完整模型请使用组合 GLB");
|
||||
const a = document.createElement("a"); a.href = url; a.download = `machine-joints-${coordinates.toLowerCase()}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); setMessage("关节 JSON 已导出;恢复完整模型请使用“保存机床模型 GLB”");
|
||||
};
|
||||
const poseInput = (axis: keyof TestPose, unit: string) => <label key={axis}>{axis.toUpperCase()}<MachineNumberInput fieldId={`test-${axis}`} control={`machine-test-${axis}`} onValidity={validity} disabled={pending || followLive || !bindings.some((b) => b.dof.axis.toLowerCase() === axis)} value={effectivePose[axis]} onChange={(value) => setTestPose((pose) => ({ ...pose, [axis]: value }))} /><small>{unit}</small></label>;
|
||||
const receiveMotionState = (state: Record<string, MotionTransform>) => { const hash = JSON.stringify(state); if (hash !== motionStateHash.current) { motionStateHash.current = hash; setMotionState(state); } };
|
||||
@@ -286,12 +292,17 @@ export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }
|
||||
<button type="button" data-axis-control="machine-manager-settings" aria-haspopup="dialog" onClick={() => setOpen(true)}>三维机床管理</button>
|
||||
<DraggableDialog open={open} title={`三维机床管理 · ${coordinates}`} className="machine-manager-dialog" onClose={() => setOpen(false)}>
|
||||
<div className="machine-manager-workbench" aria-busy={pending}>
|
||||
<div className="machine-type-step"><label><strong>1 · 机床类型</strong><select data-axis-control="machine-manager-type" aria-label="设计机床类型" value={coordinates} disabled={pending || followLive} onChange={(event) => {
|
||||
const next = event.target.value as "XYZAC" | "XYZBC";
|
||||
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-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></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>
|
||||
@@ -305,12 +316,12 @@ export function MachineManagerPanel({ coordinates, disabled, onApply, snapshot }
|
||||
</section>}
|
||||
{!selected && <p className="machine-selection-empty">选择层级节点,配置其唯一自由度。</p>}
|
||||
</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}` : "实时数据暂不可用,已保持最后姿态;可取消跟随" : "手动验证(相对物理零点)"}</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-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>
|
||||
</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>
|
||||
<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={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>
|
||||
|
||||
@@ -2,10 +2,10 @@ import { useCallback, useReducer } from "react";
|
||||
import type { MachinePartDraft, PhysicalZero } from "./machine-manager";
|
||||
import type { Vec3 } from "./machine-urdf";
|
||||
|
||||
export type MachineDraft = { parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
|
||||
export type MachineDraft = { 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 = { 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 = { 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; }
|
||||
@@ -15,11 +15,20 @@ export function machineDraftHistory(state: History, action: Action): History {
|
||||
if (current === state.current || (draftFingerprint(current) === draftFingerprint(state.current) && current.parts.every((part, i) => part.showOrigin === state.current.parts[i]?.showOrigin))) return state;
|
||||
return { current, past: [...state.past, state.current].slice(-30), future: [] };
|
||||
}
|
||||
export function useMachineDraft() {
|
||||
const [history, dispatch] = useReducer(machineDraftHistory, { current: initial, past: [], future: [] });
|
||||
export function useMachineDraft(coordinates: MachineDraft["coordinates"] = "XYZAC") {
|
||||
const [history, dispatch] = useReducer(machineDraftHistory, { current: { ...initial, coordinates }, past: [], future: [] });
|
||||
const edit = useCallback((change: (draft: MachineDraft) => MachineDraft) => dispatch({ type: "edit", change }), []);
|
||||
return { history, draft: history.current, edit, undo: () => dispatch({ type: "undo" }), redo: () => dispatch({ type: "redo" }), clearHistory: useCallback(() => dispatch({ type: "clear" }), []) };
|
||||
}
|
||||
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 })) });
|
||||
}
|
||||
|
||||
// A different tilt axis requires an explicit new binding: never relabel a physical
|
||||
// A joint as B or copy its axis direction/calibration silently.
|
||||
export function changeMachineType(draft: MachineDraft, coordinates: MachineDraft["coordinates"]): MachineDraft {
|
||||
if (draft.coordinates === coordinates) return draft;
|
||||
return { ...draft, coordinates, parts: draft.parts.map((part) => ({ ...part,
|
||||
nodeJoints: Object.fromEntries(Object.entries(part.nodeJoints ?? {}).filter(([, joint]) => coordinates.includes(joint.axis))),
|
||||
})) };
|
||||
}
|
||||
|
||||
@@ -510,3 +510,10 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
|
||||
.machine-joint-step { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
|
||||
.machine-joint-step > label { display: flex; align-items: center; gap: 6px; }
|
||||
.machine-joint-step button { padding: 5px 9px; border: 1px solid #9babad; border-radius: 3px; background: #fff; }
|
||||
.machine-type-step { display: flex; flex: 0 0 auto; align-items: center; gap: 18px; flex-wrap: wrap; padding: 8px 10px; background: #e1efed; border: 1px solid #90b4ad; border-radius: 5px; }
|
||||
.machine-type-step > label { display: flex; align-items: center; gap: 10px; }
|
||||
.machine-type-step strong { color: #27585d; white-space: nowrap; }
|
||||
.machine-type-step select { min-height: 32px; max-width: 100%; }
|
||||
.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; } }
|
||||
|
||||
Reference in New Issue
Block a user