Select machine type in design dialog and save importable machine GLB

This commit is contained in:
2026-09-21 17:41:46 +08:00
parent 8f1a22485d
commit 338500dfc2
32 changed files with 10364 additions and 32 deletions

View File

@@ -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": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

View File

@@ -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"}

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 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: 147 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -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。

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

@@ -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,供控制面板“导入机床模型”自动识别机型并使用。

View File

@@ -19,3 +19,5 @@
14. `MGR-17`:定义单自由度 JSON/Schema;收敛 UI 输入;增加单文件层级应用和 GLB 恢复;接入实际 WASM 关节位置;验证每轴、反向、物理零点、导出往返和锁定行为。
15. `MGR-18`:工作区布局与层级导航;草稿历史/资源生命周期;数值错误状态和机型标定校验;重复选中输入回归;桌面/窄屏/矮屏及 AC/BC 实时驱动验收。
16. `MGR-19`:把机型纳入草稿/撤销历史;添加首步机型选择;切换清理不适用倾转绑定;完整模型打开恢复机型;下载前调用控制面板读取器校验;验证从 AC 工作区导入 BC GLB 自动切换及 MILL700 两机型保存/运动闭环。

View File

@@ -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。

View File

@@ -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 可由控制面板导入并自动切换机型|

View File

@@ -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`|

View File

@@ -63,3 +63,7 @@ GLB 树节点 ID 使用导入时的子节点索引路径,不使用可能重复
## D16 / 草稿可撤销、错误输入不执行
草稿历史只保存配置快照并共享源模型资源,上限 30 步;只有已脱离当前草稿和历史的资产才释放。关闭窗口保留会话草稿,完整持久保存使用导出 GLB,界面明确显示未应用/已应用/已导出状态。非法或空数值只存在于输入框,不驱动模型,不隐式归零;校验状态独立于操作消息显示。实时跟随时配置只读,数据不可用时保持最后姿态且允许退出。
## D17 / 设计类型属于机床文档
机型保存在 MachineDraft,控制面板当前类型只用于首次创建时的默认值和实时数据匹配。后续面板切换不得覆盖草稿。切换设计类型不自动把 A 轴重新命名为 B:保留通用轴及模型,清除不兼容绑定,提示重新配置,并允许撤销。标定保留并按新机型验证,不静默丢弃。完整机床 GLB 打开为一次替换事务,自动恢复文件机型,原草稿可撤销。保存 GLB 必须先通过标准导入器格式/机型校验;JSON 是辅助参数文件,完整可导入交付物为 GLB。

View File

@@ -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 指定机型。
## 字段契约

View File

@@ -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/`。
文档:

View File

@@ -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.

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-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",

View File

@@ -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

View File

@@ -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");

View File

@@ -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']}));

View 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));

View File

@@ -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>

View File

@@ -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))),
})) };
}

View File

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