Add draggable GLB hierarchy tree and node origins

This commit is contained in:
2026-09-20 18:53:30 +08:00
parent 53285d04af
commit 72b38b7d43
19 changed files with 150 additions and 38 deletions

View File

@@ -4,7 +4,8 @@
"mode": "production-preview",
"status": "verified",
"checks": [
"eight GLB parts imported and rendered in the Three.js editor",
"eight GLB parts rendered with studio PMREM lighting and calibrated exposure",
"dragging GLB root tree nodes updates component parent hierarchy",
"origin visibility, numeric translation/rotation and TransformControls mode update",
"validated assembly exports a self-contained managed GLB",
"validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData",
@@ -14,7 +15,7 @@
],
"browser_errors": [],
"final": {
"program": "aborted",
"program": "running",
"machine": "ON",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc"
}

View File

@@ -4,7 +4,8 @@
"mode": "development-source",
"status": "verified",
"checks": [
"eight GLB parts imported and rendered in the Three.js editor",
"eight GLB parts rendered with studio PMREM lighting and calibrated exposure",
"dragging GLB root tree nodes updates component parent hierarchy",
"origin visibility, numeric translation/rotation and TransformControls mode update",
"validated assembly exports a self-contained managed GLB",
"validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 77 KiB

View File

@@ -8,7 +8,7 @@
"sha256": "bf37130681cc6462412e52744519516e8ade2a3212dd69153c0b1b3e415bcf66"
},
"checks": [
"existing 132-node, depth-6 hierarchy preserved",
"existing 132-node, depth-6 hierarchy displayed as a draggable tree with node origins",
"metallic MILL700 surfaces rendered with studio PMREM lighting; screenshot captured for visual review"
],
"hierarchy": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 299 KiB

View File

@@ -20,6 +20,8 @@
- 倾转关节原点 `T=P+D`,C 关节原点 `U=-D`,工件固定原点 `-P`。
8. 可定义 X/Y/Z/A或B/C 的物理零点。模型运动量为 LinuxCNC 实际关节值减去配置的物理零点。
9. 应用后,控制面板程序执行、Jog、RTCP 切换和刀长变化继续以 Worker/WASM 快照为唯一运动输入;模型不得在 React 中重新计算刀尖逆解。
10. 导入 GLB 后,若模型已有 `Object3D.children` 层级,右侧必须按原结构显示为树。树节点名称可拖放:GLB 根节点之间管理部件装配父级,同一 GLB 内部节点之间管理内部父级;循环和跨 GLB 内部节点拖放必须拒绝。
11. 选择任意树节点时,显示其相对父节点的局部原点 XYZ 坐标。层级和原点读取自解析后的 GLB,导入过程不修改源场景;拖放结果保存为草稿覆盖。
## 数据和单位

View File

@@ -9,5 +9,6 @@
7. `MGR-07`:接入控制面板“应用机床”,复用现有 `LOAD_WORKSPACE`、`PROGRAM_LOAD`、回零失效和 GLB 资源清理事务。
8. `MGR-08`:验证 WASM 关节值到部件链的映射、刀具/工件/路径坐标一致性及 AC/BC 五轴程序。
9. `MGR-09`:浏览器交互、错误边界、类型检查、生产构建、截图和报告收口。
10. `MGR-12`:按 GLB 原始 `Object3D.children` 显示树,支持根部件和内部节点拖放,选择节点显示父级局部原点;用 MILL700 六层结构验收。
每轮只推进任务矩阵中依赖已满足的任务。代码完成后必须同步更新 03、04、05;若改变数据契约或坐标定义,先更新 06。

View File

@@ -17,6 +17,15 @@
- 管理器部件平移、局部原点、组合运行场景、原点辅助和 TransformControls 统一使用该映射;TransformControls 使用机床坐标代理,拖动结果反算为机床坐标。
- MILL700 重新验收通过,蓝色 Z 轴在截图中竖直,浏览器错误为 0。MGR-11 verified。
## R05 / 2026-09-20 / GLB 层级树、拖放与节点原点
- 三维机床管理器右侧改为 WAI-ARIA tree,数据直接来自 `GLTFLoader` 的 `Object3D.children`,不按节点名称猜测父子关系。
- 树默认只展开根层级,可逐层展开;节点行显示名称和相对父节点的局部原点摘要。选择节点后在编辑区显示精确 XYZ 毫米坐标。
- 拖动 GLB 根树名到另一 GLB 根节点,修改部件 `parentId`;拖动同一 GLB 内部节点,记录稳定源路径到目标源路径的覆盖。循环、无效路径和跨 GLB 内部拖放明确拒绝。
- 层级覆盖应用在预览克隆和组合导出,原始导入 `scene`、节点、几何、材质和原始变换不修改。
- MILL700 验证 132 节点、126 网格、深度 6 的树显示;选择 `MILL700-A-主轴-JT2` 得到父级局部原点 `X=-3.969, Y=-204.276, Z=405.633 mm`;内部节点拖放及八部件根节点拖放通过。
- 为避免大模型选择卡顿,树选择只更新坐标面板;预览使用共享只读几何/材质的节点克隆,层级变更后才重建变换树。MGR-12 verified。
## R02 / 2026-09-20 / 部件装配、标定、应用与验收完成
- 新增 `machine-manager.ts`:通用自包含 GLB 部件加载、SHA-256、深复制、八角色和两条机械链校验、AC/BC URDF 生成、组合场景、底座/整机 userData、组合哈希。

View File

@@ -12,4 +12,5 @@
|MGR-08 坐标一致性|verified|MGR-07|AC/BC 的 X/Y/Z/A或B/C、刀长、工件和路径由 WASM 状态正确驱动|
|MGR-09 浏览器验收收口|verified|MGR-04,MGR-05,MGR-08|交互/错误/构建/浏览器截图报告通过,03–05 完整更新|
|MGR-10 PBR 预览照明|verified|MGR-04|高金属度 GLB 在管理器中具有可辨识表面;PMREM、色彩空间、曝光和多向补光生效|
|MGR-11 Z-up 机床坐标|verified|MGR-04,MGR-05|机床 Z 竖直、XY 地面、原点辅助与 TransformControls 统一使用 `Three(x,y,z)=Machine(x,z,-y)`|
|MGR-11 Z-up 机床坐标|verified|MGR-04,MGR-05|`camera.up=(0,0,1)`,机床 Z 竖直、XY 地面;导入模型信息和原始变换不修改|
|MGR-12 GLB 层级树管理|verified|MGR-02,MGR-04|原始层级按 tree 显示;根/内部节点拖放、循环拒绝、节点原点坐标和 MILL700 深层结构验收通过|

View File

@@ -12,11 +12,11 @@
- 命令:`PLAYWRIGHT_MODULE=/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs node scripts/test_machine_manager_browser.mjs`
- report_id:`GLB-MGR-E01`
- 报告:`coverage/glb-machine-manager/browser.report.json`
- SHA-256:`d3d15da19d3e8a8c958935a0e9c3840b70fb646d5bc8ffb72805dc744df078a3`
- SHA-256:`486108398c757421d59fd5311239675b82d6c688458975b7c613118920ceafbf`
- 状态:verified;浏览器错误 0。
- 覆盖:8 部件导入和预览、原点显隐、数值/TransformControls 变换、组合 GLB 导出、AC/BC 应用、底座 userData、物理零点、非法层级拒绝、BC 五轴程序。
- 截图:
- `coverage/glb-machine-manager/manager-ac-config.png`,SHA-256 `5ca01d26cb83da726319be107aa90069ce96c3197f4297a26e1f836d171a1501`
- `coverage/glb-machine-manager/manager-ac-config.png`,SHA-256 `70238293d81a0063e413d4bf3b2b0f0bc4c8453e45256e32fb625ff9e92b9609`
- `coverage/glb-machine-manager/managed-bc-running.png`,SHA-256 `6e14ff6bc4e493f57e55bb601ded84319ce86f064f7a811595c1e4498b1827a0`
## E03 / R02 / 生产构建浏览器闭环
@@ -25,7 +25,7 @@
- 命令:`MACHINE_MANAGER_SOURCE_MODULES=0 ... node scripts/test_machine_manager_browser.mjs`
- report_id:`GLB-MGR-E02`
- 报告:`coverage/glb-machine-manager/browser-production.report.json`
- SHA-256:`5ba840aa92c37a06e2f19bce7c57234871b085e27951fb44623af74253215388`
- SHA-256:`7ea2ba7c09a2480973e63aedb8da1b8b0d46914eb86519781d99b718c5d3fca6`
- 状态:verified;生产资产执行 AC/BC UI 应用和 BC WASM 程序运动,浏览器错误 0。
## E04 / R02 / 回归与静态契约
@@ -43,13 +43,22 @@
- 状态:verified。
- 命令:`PLAYWRIGHT_MODULE=/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs node scripts/test_machine_manager_mill700_browser.mjs`
- 模型:`MILL700-U_1.glb`,9,418,852 bytes,SHA-256 `bf37130681cc6462412e52744519516e8ade2a3212dd69153c0b1b3e415bcf66`。
- report_id:`GLB-MGR-E05`;报告 SHA-256 `bdb39d0a44c2561f806c268c791b7fd03d6e6f2a0dca51d0e1bc9ce3069a4db1`。
- report_id:`GLB-MGR-E05`;报告 SHA-256 `7c479b36396abc6f7466ed8b54cc83216dd72c30ba11fa0c667e6491d47c2171`。
- 结构:132 节点、126 网格、深度 6、126 材质,其中 110 个 `metalness >= 0.9`。
- 检查:已有内部层级完整保留;管理器使用 `studio-pmrem`、sRGB 输出、ACES、曝光 `1.45` 和多向补光;浏览器错误为 0。
- 截图:`coverage/glb-machine-manager/mill700/mill700-studio-lighting.png`,SHA-256 `0a20bac14708634ddf8e7267236488c3d28526477669ba16dcb880182e2ce11a`。
- 截图:`coverage/glb-machine-manager/mill700/mill700-studio-lighting.png`,SHA-256 `e741ae55623f28399565c4296e5d679a71fba346232ce043edc5ab0c8901d6b4`。
## E06 / R04 / Z-up 坐标
- 状态:verified。
- 检查:管理器预览 DOM 标记 `data-machine-manager-coordinate-system=machine-z-up`、`data-machine-manager-camera-up=0,0,1`、`data-machine-manager-axis-map=X:x,Y:y,Z:z` 和 `data-machine-manager-model-transform=none-on-import`;MILL700 截图中蓝色 Z 轴竖直,机床 XY 网格水平,导入模型信息未被改写。
- AC/BC 管理器浏览器闭环、MILL700 浏览器闭环、TypeScript、生产构建和控件契约继续通过。
## E07 / R05 / GLB 层级树管理
- 状态:verified。
- MILL700:132 节点、126 网格、最大深度 6,全部树节点存在;树默认根展开、子层可折叠。
- 节点选择:`MILL700-A-主轴-JT2` 显示相对父节点原点 `-3.969 / -204.276 / 405.633 mm`。
- 拖放:`MILL700-A-门1-JT1` 拖到 `MILL700-A底座` 后,草稿父路径等于目标稳定源路径;原始 GLB SHA-256 保持 `bf37130681cc6462412e52744519516e8ade2a3212dd69153c0b1b3e415bcf66`。
- 八个独立 GLB 的根树节点拖放通过;随后 AC/BC 应用、导出和程序运动回归仍通过。
- 控件契约:101 个控件,新增 `machine-tree-node` 和 `machine-tree-origin`;TypeScript、生产构建、双画布测试通过,浏览器错误 0。

View File

@@ -35,3 +35,7 @@ UI 允许用户选择父级以建立层级和发现错误;应用时只接受
## D09 / 管理器采用相机 Z-up 机床坐标
管理器明确调用 `camera.up.set(0, 0, 1)`,并将默认网格旋转到机床 XY 水平面。GLB 导入不修改模型节点、几何、材质、内部层级或原始变换;相机、轨道控制器和坐标辅助负责呈现 Z-up 场景。只有用户主动编辑部件变换时才记录新的装配值。
## D10 / 层级编辑使用稳定源路径覆盖
GLB 树节点 ID 使用导入时的子节点索引路径,不使用可能重复或缺失的节点名称。拖放保存 `sourcePath -> targetPath` 草稿覆盖,每次从原始场景克隆后应用,因此重复编辑稳定且不污染导入资产。根 GLB 之间继续使用部件 `parentId`,内部节点不允许跨 GLB 直接重挂。

View File

@@ -11,7 +11,7 @@
现有整机 GLB + 内嵌 URDF 导入能力继续保留。新管理器生成与现有 `linuxcnc.machine/1` 相同的严格结构,再通过已有配置、Worker、WASM 和 Three.js 投影链运行。
当前状态:MGR-01 至 MGR-11 全部 verified,MILL700 实测证据为 `GLB-MGR-E05`。最新台账 R04,浏览器证据 `GLB-MGR-E01/E02/E05`,截图和报告位于 `coverage/glb-machine-manager/`。
当前状态:MGR-01 至 MGR-12 全部 verified,MILL700 实测覆盖照明、Z-up、132 节点层级树、拖放和节点原点。最新台账 R05,浏览器证据 `GLB-MGR-E01/E02/E05/E07`,截图和报告位于 `coverage/glb-machine-manager/`。
文档:

View File

@@ -449,7 +449,7 @@ history rows intentionally share the family ID `mdi-history-select`.
| `workspace-case-select` | Web selection of the INI/configuration input | Local selection; selected catalog is restricted to supported XYZAC/XYZBC cases | Does not load or change machine state until `toolbar-load-case` |
| `workspace-load-3d-chips` | Web packaged-file equivalent of `open_file_guts` | Fetch published `3D_Chips.ngc`, then `PROGRAM_LOAD`; IDLE only | Same replacement path as Open; retains Live Plot, no automatic Run/Home |
| `workspace-rtcp-programs`, `rtcp-program-scope`, `rtcp-program-select`, `rtcp-program-source`, `rtcp-program-load`, `rtcp-program-download` | Web RTCP source-program catalog backed by the locked LinuxCNC tree | The default scope follows the active machine: AC=`XYZAC`, BC=`XYZBC`; catalog/source/download are local file operations, while compatible main programs use the same `PROGRAM_LOAD` transaction and IDLE gate as Open | AC and BC programs are separated by kinematics. The optional all-source scope retains all 32 files for inspection; remap/subroutine/reference and mismatched kinematics cannot be sent as an executable main program |
| `machine-manager-settings`, `machine-manager-import`, `machine-manager-files`, `machine-manager-translate`, `machine-manager-rotate`, `machine-manager-role`, `machine-manager-parent`, `machine-manager-show-origin`, `machine-manager-apply`, `machine-manager-export` | Web component-level machine assembly extension | Draft import/edit/preview is local. Apply validates the supported AC/BC hierarchy, generates `linuxcnc.machine/1`, then uses the existing `LOAD_WORKSPACE` + `PROGRAM_LOAD` transaction while OFF and IDLE; Export serializes the validated scene and userData to a self-contained GLB | Three.js edits appearance/assembly metadata only; runtime joint motion, tool offset and RTCP remain Worker/WASM authoritative |
| `machine-manager-settings`, `machine-manager-import`, `machine-manager-files`, `machine-manager-translate`, `machine-manager-rotate`, `machine-manager-role`, `machine-manager-parent`, `machine-manager-show-origin`, `machine-tree-node`, `machine-tree-origin`, `machine-manager-apply`, `machine-manager-export` | Web component-level machine assembly extension | GLB `Object3D.children` render as a tree; selecting a node shows its parent-relative origin, and dragging tree names records validated parent overrides without changing the imported source scene. Apply validates the supported AC/BC hierarchy, generates `linuxcnc.machine/1`, then uses `LOAD_WORKSPACE` + `PROGRAM_LOAD` while OFF and IDLE | Three.js edits draft assembly metadata only; runtime joint motion, tool offset and RTCP remain Worker/WASM authoritative |
| `manual-home-all` | `home_all_joints` | `HOME {all:true}`; ON+IDLE, no concurrent homing/jog | Native homemod selects the configured sequences. No artificial coordinated return-to-zero motion. |
| `switchkins-0`, `switchkins-1`, `switchkins-2` | `M429`, `M428`, `M430` remaps and `motion.switchkins-type` | `KINEMATICS_SELECT`; ON, homed, IDLE, empty TP | Identity/XYZAC/User K canonical type, or stable gate reason |
| `tab-manual`, `tab-mdi` | `ensure_manual` / `ensure_mdi` | `SET_MODE`; manual_ok and motion exclusion | Task mode acknowledgement and WASM motion mode are monitored separately; no local tab is mode authority. |

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-translate", "machine-manager-rotate", "machine-manager-role", "machine-manager-parent", "machine-manager-show-origin", "machine-manager-apply", "machine-manager-export", "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-import", "machine-manager-files", "machine-manager-translate", "machine-manager-rotate", "machine-manager-role", "machine-manager-parent", "machine-manager-show-origin", "machine-tree-node", "machine-tree-origin", "machine-manager-apply", "machine-manager-export", "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

@@ -30,8 +30,10 @@ try {
await control("machine-manager-settings").click(); assert.equal(await page.locator(".machine-manager-dialog[open]").count(), 1); assert.equal(await page.locator("[data-machine-manager-preview] canvas").count(), 1);
await control("machine-manager-files").setInputFiles(fixtures);
await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "8");
assert.equal(await page.locator(".machine-part-list > div").count(), 8); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "8"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-lighting"), "studio-pmrem"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-exposure"), "1.45");
assert.equal(await page.locator('[data-machine-tree-path="scene"]').count(), 8); assert.ok(await page.locator('[data-axis-control="machine-tree-node"]').count() >= 16); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "8"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-lighting"), "studio-pmrem"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-exposure"), "1.45");
report.checks.push("eight GLB parts rendered with studio PMREM lighting and calibrated exposure");
const roots = page.locator('[data-machine-tree-path="scene"]'); const sourceRoot = roots.nth(7), targetRoot = roots.nth(6), sourcePart = await sourceRoot.getAttribute("data-machine-tree-part"), targetPart = await targetRoot.getAttribute("data-machine-tree-part"), transfer = await page.evaluateHandle(() => new DataTransfer()); await sourceRoot.locator("button").first().dispatchEvent("dragstart", { dataTransfer: transfer }); await targetRoot.locator("button").first().dispatchEvent("dragover", { dataTransfer: transfer }); await targetRoot.locator("button").first().dispatchEvent("drop", { dataTransfer: transfer }); await page.waitForFunction(({ sourcePart, targetPart }) => document.querySelector(`[data-machine-tree-part="${sourcePart}"][data-machine-tree-path="scene"]`)?.getAttribute("data-machine-tree-parent") === targetPart, { sourcePart, targetPart });
report.checks.push("dragging GLB root tree nodes updates component parent hierarchy");
await control("machine-manager-show-origin").uncheck(); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "7");
const translation = page.locator("fieldset.machine-vector").filter({ hasText: "部件平移" }); await translation.getByLabel("X", { exact: true }).fill("10");

View File

@@ -24,11 +24,13 @@ try {
await page.goto(process.env.MACHINE_MANAGER_URL ?? "http://127.0.0.1:5174/", { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false" && document.querySelector("main.axis-app")?.dataset.axisStateSource === "linuxcnc-wasm", null, { timeout: 45000 });
await control("machine-manager-settings").click(); await control("machine-manager-files").setInputFiles({ name: "MILL700-U_1.glb", mimeType: "model/gltf-binary", buffer: model });
await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "1" && document.querySelector(".machine-part-list small")?.textContent === "MILL700-U_1.glb", null, { timeout: 60000 });
await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "1" && document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerRendered === "true" && document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132, null, { timeout: 60000 });
assert.equal(hierarchy.root, "MILL700-U_1"); assert.equal(hierarchy.nodes, 132); assert.equal(hierarchy.meshes, 126); assert.ok(hierarchy.maxDepth >= 6); assert.ok(hierarchy.metallic > 20);
report.hierarchy = hierarchy; report.checks.push("existing 132-node, depth-6 hierarchy preserved");
assert.ok(await page.locator('[data-axis-control="machine-tree-node"]').count() >= 132); const selectedNode = page.locator('[data-axis-control="machine-tree-node"]').filter({ hasText: "MILL700-A-主轴-JT2" }).first(); await selectedNode.dispatchEvent("click"); assert.match(await control("machine-tree-origin").textContent(), /相对父节点原点.*X .*Y .*Z /s); await page.screenshot({ path: new URL("mill700-studio-lighting.png", output).pathname, timeout: 60000 });
const source = page.locator('[data-axis-control="machine-tree-node"]').filter({ hasText: "MILL700-A-门1-JT1" }).first(), target = page.locator('[data-axis-control="machine-tree-node"]').filter({ hasText: "MILL700-A底座" }).first(); const sourceItem = source.locator("xpath=ancestor::li[1]"), targetItem = target.locator("xpath=ancestor::li[1]"), sourcePath = await sourceItem.getAttribute("data-machine-tree-path"), targetPath = await targetItem.getAttribute("data-machine-tree-path"), partId = await sourceItem.getAttribute("data-machine-tree-part"); await target.evaluate((element, payload) => { const transfer = new DataTransfer(); transfer.setData("application/x-linuxcnc-machine-tree", JSON.stringify({ partId: payload.partId, path: payload.sourcePath })); element.dispatchEvent(new DragEvent("drop", { bubbles: true, cancelable: true, dataTransfer: transfer })); }, { partId, sourcePath }); await page.waitForFunction(({ sourcePath, targetPath }) => document.querySelector(`[data-machine-tree-path="${sourcePath}"]`)?.getAttribute("data-machine-tree-parent") === targetPath, { sourcePath, targetPath }, { timeout: 60000 });
report.hierarchy = hierarchy; report.checks.push("existing 132-node, depth-6 hierarchy displayed as a draggable tree with node origins");
assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-lighting"), "studio-pmrem"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-coordinate-system"), "machine-z-up"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-axis-map"), "X:x,Y:y,Z:z"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-camera-up"), "0,0,1"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-model-transform"), "none-on-import");
await page.locator(".machine-manager-dialog").screenshot({ path: new URL("mill700-studio-lighting.png", output).pathname }); report.checks.push("metallic MILL700 surfaces rendered with studio PMREM lighting; screenshot captured for visual review"); assert.deepEqual(errors, []); report.status = "verified";
} catch (error) { report.status = "failed"; report.error = error instanceof Error ? error.stack : String(error); await page.screenshot({ path: new URL("failure.png", output).pathname, fullPage: true }); throw error; }
report.checks.push("metallic MILL700 surfaces rendered with studio PMREM lighting; screenshot captured for visual review"); assert.deepEqual(errors, []); report.status = "verified";
} catch (error) { report.status = "failed"; report.error = error instanceof Error ? error.stack : String(error); await page.screenshot({ path: new URL("failure.png", output).pathname, timeout: 5000 }).catch(() => undefined); throw error; }
finally { report.browser_errors = errors; await writeFile(new URL("browser.report.json", output), JSON.stringify(report, null, 2) + "\n"); await browser.close(); }
console.log(JSON.stringify(report));

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
@@ -6,7 +6,7 @@ import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
import { DraggableDialog } from "./DraggableDialog";
import { disposeMachineScene, type ImportedMachine } from "./imported-machine";
import { buildManagedMachine, cloneMachinePartScene, expectedParentRole, loadMachinePart, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type PhysicalZero } from "./machine-manager";
import { applyMachineNodeParents, buildManagedMachine, expectedParentRole, loadMachinePart, machinePartTree, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type MachineTreeNode, type PhysicalZero } from "./machine-manager";
import type { Vec3 } from "./machine-urdf";
type Props = { coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise<void> };
@@ -30,11 +30,13 @@ function ManagerPreview({ parts, selectedId, mode, onTransform }: { parts: Machi
const camera = new THREE.PerspectiveCamera(42, 1, .005, 100); camera.up.set(0, 0, 1); camera.position.set(2.4, -2.8, 1.8);
const orbit = new OrbitControls(camera, renderer.domElement); orbit.enableDamping = true;
const root = new THREE.Group(); root.name = "machine-manager-preview"; scene.add(root);
const nodes = new Map<string, THREE.Group>(); const disposable: THREE.Object3D[] = [];
const nodes = new Map<string, THREE.Group>(); const helpers: THREE.AxesHelper[] = [];
for (const part of parts) {
const node = new THREE.Group(); node.name = `draft_${part.id}`; node.visible = part.visible; node.position.fromArray(part.translationMm).multiplyScalar(.001); node.rotation.set(...part.rotationDeg.map(THREE.MathUtils.degToRad) as Vec3);
const visual = cloneMachinePartScene(part.scene); visual.position.fromArray(part.originMm).multiplyScalar(-.001); node.add(visual); disposable.push(visual);
const axes = new THREE.AxesHelper(.12); axes.name = `origin_${part.id}`; axes.visible = part.showOrigin; node.add(axes); root.add(node); nodes.set(part.id, node);
// Preview clones only Object3D transforms/hierarchy. Geometry, material,
// texture and source node transforms remain untouched on the imported GLB.
const visual = part.scene.clone(true); applyMachineNodeParents(visual, part.nodeParents); visual.position.fromArray(part.originMm).multiplyScalar(-.001); node.add(visual);
const axes = new THREE.AxesHelper(.12); helpers.push(axes); axes.name = `origin_${part.id}`; axes.visible = part.showOrigin; node.add(axes); root.add(node); nodes.set(part.id, node);
}
const cycle = (child: string, parent: string) => { let cursor: string | null = parent; const seen = new Set([child]); while (cursor) { if (seen.has(cursor)) return true; seen.add(cursor); cursor = parts.find((part) => part.id === cursor)?.parentId ?? null; } return false; };
for (const part of parts) if (part.parentId && nodes.has(part.parentId) && !cycle(part.id, part.parentId)) nodes.get(part.parentId)!.add(nodes.get(part.id)!);
@@ -49,21 +51,34 @@ function ManagerPreview({ parts, selectedId, mode, onTransform }: { parts: Machi
}) as never);
const resize = () => { const width = Math.max(element.clientWidth, 1), height = Math.max(element.clientHeight, 1); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false); };
const observer = new ResizeObserver(resize); observer.observe(element); resize(); let frame = 0;
const render = () => { orbit.update(); renderer.render(scene, camera); frame = requestAnimationFrame(render); }; render();
return () => { cancelAnimationFrame(frame); observer.disconnect(); transform.dispose(); orbit.dispose(); for (const object of disposable) disposeMachineScene(object); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); };
const render = () => { orbit.update(); renderer.render(scene, camera); element.dataset.machineManagerRendered = "true"; frame = requestAnimationFrame(render); }; render();
return () => { cancelAnimationFrame(frame); observer.disconnect(); transform.dispose(); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); };
}, [parts, selectedId, mode]);
return <div ref={host} className="machine-manager-preview" data-machine-manager-preview data-machine-manager-coordinate-system="machine-z-up" data-machine-manager-axis-map="X:x,Y:y,Z:z" data-machine-manager-camera-up="0,0,1" data-machine-manager-model-transform="none-on-import" data-machine-manager-lighting="studio-pmrem" data-machine-manager-exposure="1.45" data-machine-manager-part-count={parts.length} data-machine-manager-origin-count={parts.filter((part) => part.showOrigin).length} data-transform-mode={mode} aria-label="三维机床装配预览,机床 Z 轴朝上" />;
}
function TreeNode({ part, node, selected, onSelect, onDrop, extra }: { part: MachinePartDraft; node: MachineTreeNode; selected: string; onSelect: (partId: string, path: string) => void; onDrop: (source: { partId: string; path: string }, target: { partId: string; path: string }) => void; extra?: React.ReactNode }) {
const key = `${part.id}:${node.path}`;
const [expanded, setExpanded] = useState(node.path === "scene");
const hasChildren = node.children.length > 0 || Boolean(extra);
return <li role="treeitem" aria-expanded={hasChildren ? expanded : undefined} data-machine-tree-part={part.id} data-machine-tree-path={node.path} data-machine-tree-parent={node.path === "scene" ? part.parentId ?? "root" : Object.hasOwn(part.nodeParents, node.path) ? part.nodeParents[node.path] ?? "scene" : "source"}>
<div className="machine-tree-row"><button type="button" className="machine-tree-toggle" aria-label={`${expanded ? "折叠" : "展开"} ${node.name}`} aria-expanded={expanded} disabled={!hasChildren} onClick={() => setExpanded((value) => !value)}>{hasChildren ? expanded ? "▾" : "▸" : "·"}</button><button type="button" draggable data-axis-control="machine-tree-node" className={selected === key ? "selected" : ""} onClick={() => onSelect(part.id, node.path)} onDragStart={(event) => { event.dataTransfer.effectAllowed = "move"; event.dataTransfer.setData("application/x-linuxcnc-machine-tree", JSON.stringify({ partId: part.id, path: node.path })); }} onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; }} onDrop={(event) => { event.preventDefault(); const raw = event.dataTransfer.getData("application/x-linuxcnc-machine-tree"); if (raw) onDrop(JSON.parse(raw), { partId: part.id, path: node.path }); }}><span className={node.type}>{node.name}</span><small>{node.originMm.map((value) => Number(value.toFixed(3))).join(", ")}</small></button></div>
{hasChildren && <ul role="group" hidden={!expanded}>{node.children.map((child) => <TreeNode key={child.path} part={part} node={child} selected={selected} onSelect={onSelect} onDrop={onDrop} />)}{extra}</ul>}
</li>;
}
export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
const [open, setOpen] = useState(false), [pending, setPending] = useState(false), [message, setMessage] = useState("");
const [parts, setParts] = useState<MachinePartDraft[]>([]); const partsRef = useRef(parts); partsRef.current = parts;
const [selectedId, setSelectedId] = useState<string | null>(null); const [mode, setMode] = useState<"translate" | "rotate">("translate");
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedNodePath, setSelectedNodePath] = useState("scene"); const [mode, setMode] = useState<"translate" | "rotate">("translate");
const [cOriginMm, setCOriginMm] = useState<Vec3>([0, 0, 0]); const [tiltOffsetMm, setTiltOffsetMm] = useState<Vec3>([0, 0, 0]);
const [physicalZero, setPhysicalZero] = useState<PhysicalZero>({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); const file = useRef<HTMLInputElement>(null);
useEffect(() => () => { for (const part of partsRef.current) disposeMachineScene(part.scene); }, []);
useEffect(() => { setMessage(""); setCOriginMm([0, 0, 0]); setTiltOffsetMm([0, 0, 0]); setPhysicalZero({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); }, [coordinates]);
const selected = parts.find((part) => part.id === selectedId) ?? null;
const trees = useMemo(() => new Map(parts.map((part) => [part.id, machinePartTree(part)])), [parts]);
const findTreeNode = (node: MachineTreeNode | undefined, path: string): MachineTreeNode | null => node ? node.path === path ? node : node.children.map((child) => findTreeNode(child, path)).find(Boolean) ?? null : null;
const selectedTreeNode = selected ? findTreeNode(trees.get(selected.id), selectedNodePath) : null;
const update = (id: string, values: Partial<MachinePartDraft>) => setParts((current) => current.map((part) => part.id === id ? { ...part, ...values } : part));
const vectorInput = (label: string, value: Vec3, set: (value: Vec3) => void, unit: string) => <fieldset className="machine-vector"><legend>{label} ({unit})</legend>{["X", "Y", "Z"].map((axis, index) => <label key={axis}>{axis}<input type="number" step="any" value={value[index]} onChange={(event) => { const next = [...value] as Vec3; next[index] = number(event.target.value); set(next); }} /></label>)}</fieldset>;
const importFiles = async (list: FileList | File[] | null) => {
@@ -76,15 +91,30 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
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() });
next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {} });
}
return next;
});
setSelectedId(loaded[0].id); setMessage(`已导入 ${loaded.length} 个部件`);
setSelectedId(loaded[0].id); setSelectedNodePath("scene"); setMessage(`已导入 ${loaded.length} 个部件`);
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
finally { setPending(false); }
};
const remove = (part: MachinePartDraft) => { disposeMachineScene(part.scene); setParts((current) => current.filter((item) => item.id !== part.id).map((item) => item.parentId === part.id ? { ...item, parentId: null } : item)); if (selectedId === part.id) setSelectedId(null); };
const remove = (part: MachinePartDraft) => { disposeMachineScene(part.scene); setParts((current) => current.filter((item) => item.id !== part.id).map((item) => item.parentId === part.id ? { ...item, parentId: null } : item)); if (selectedId === part.id) { setSelectedId(null); setSelectedNodePath("scene"); } };
const selectTreeNode = (partId: string, path: string) => { setSelectedId(partId); setSelectedNodePath(path); };
const reparentTreeNode = (source: { partId: string; path: string }, target: { partId: string; path: string }) => {
if (source.partId === target.partId && source.path === target.path) return;
if (source.path === "scene") {
if (target.path !== "scene") { setMessage("整机/部件根节点只能拖到另一个 GLB 根节点"); return; }
let cursor: string | null = target.partId;
while (cursor) { if (cursor === source.partId) { setMessage("部件层级不能形成循环"); return; } cursor = parts.find((part) => part.id === cursor)?.parentId ?? null; }
update(source.partId, { parentId: target.partId }); setMessage("部件装配层级已更新"); return;
}
if (source.partId !== target.partId) { setMessage("GLB 内部节点只能在同一 GLB 层级树内拖动"); return; }
const part = parts.find((item) => item.id === source.partId)!;
const candidate = { ...part, nodeParents: { ...part.nodeParents, [source.path]: target.path === "scene" ? null : target.path } };
try { machinePartTree(candidate); update(part.id, { nodeParents: candidate.nodeParents }); setSelectedId(part.id); setSelectedNodePath(source.path); setMessage("GLB 内部层级已更新;原始导入模型未修改"); }
catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
};
const apply = async () => {
if (disabled) return; setPending(true); setMessage("正在生成机床层级和 URDF…"); let machine: ImportedMachine | null = null;
try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); await onApply(machine); setMessage("三维机床已应用;请重新回零后运行程序"); }
@@ -106,6 +136,7 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
const parentRole = role ? expectedParentRole(role) : null;
update(part.id, { role, parentId: parentRole ? parts.find((item) => item.role === parentRole)?.id ?? null : null });
};
const renderPartTree = (part: MachinePartDraft): React.ReactNode => <TreeNode key={part.id} part={part} node={trees.get(part.id)!} selected={`${selectedId}:${selectedNodePath}`} onSelect={selectTreeNode} onDrop={reparentTreeNode} extra={parts.filter((child) => child.parentId === part.id).map(renderPartTree)} />;
return <section className="machine-manager" aria-label="三维机床管理">
<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)}>
@@ -113,8 +144,8 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
<div className="machine-manager-layout">
<MachineManagerPanelPreview parts={parts} selectedId={selectedId} mode={mode} onTransform={(id, translationMm, rotationDeg) => update(id, { translationMm, rotationDeg })} />
<div className="machine-manager-editor">
<section className="machine-part-list"><h3>部件与层级</h3>{parts.length ? parts.map((part) => <div key={part.id} className={part.id === selectedId ? "selected" : ""}><button type="button" onClick={() => setSelectedId(part.id)}>{part.role ? roleLabel(part.role, coordinates) : "未分配"}<small>{part.filename}</small></button><button type="button" aria-label={`删除 ${part.filename}`} onClick={() => remove(part)}>×</button></div>) : <p>导入八个 GLB 部件后建立机械链。</p>}</section>
{selected && <section className="machine-part-editor"><h3>{selected.filename}</h3><label>机床角色<select data-axis-control="machine-manager-role" value={selected.role} onChange={(event) => roleChange(selected, event.target.value as MachinePartRole | "")}><option value="">未分配</option>{MACHINE_PART_ROLES.map((role) => <option key={role} value={role}>{roleLabel(role, coordinates)}</option>)}</select></label><label>父部件<select data-axis-control="machine-manager-parent" value={selected.parentId ?? ""} onChange={(event) => update(selected.id, { parentId: event.target.value || null })}><option value="">无</option>{parts.filter((part) => part.id !== selected.id).map((part) => <option key={part.id} value={part.id}>{part.role ? roleLabel(part.role, coordinates) : part.filename}</option>)}</select></label><div className="machine-part-flags"><label><input type="checkbox" checked={selected.visible} onChange={(event) => update(selected.id, { visible: event.target.checked })} />显示模型</label><label><input data-axis-control="machine-manager-show-origin" type="checkbox" checked={selected.showOrigin} onChange={(event) => update(selected.id, { showOrigin: event.target.checked })} />显示坐标原点</label></div>{vectorInput("模型局部原点", selected.originMm, (originMm) => update(selected.id, { originMm }), "mm")}{vectorInput("部件平移", selected.translationMm, (translationMm) => update(selected.id, { translationMm }), "mm")}{vectorInput("部件旋转", selected.rotationDeg, (rotationDeg) => update(selected.id, { rotationDeg }), "deg")}</section>}
<section className="machine-part-list"><h3>部件与层级</h3>{parts.length ? <ul className="machine-tree" role="tree">{parts.filter((part) => !part.parentId).map(renderPartTree)}</ul> : <p>导入机床或部件后显示 GLB 内部层级。</p>}</section>
{selected && <section className="machine-part-editor"><h3>{selected.filename}</h3>{selectedTreeNode && <div className="machine-tree-origin" data-axis-control="machine-tree-origin"><strong>{selectedTreeNode.name}</strong><span>相对父节点原点 (mm)</span><output>X {selectedTreeNode.originMm[0].toFixed(3)} Y {selectedTreeNode.originMm[1].toFixed(3)} Z {selectedTreeNode.originMm[2].toFixed(3)}</output></div>}<label>机床角色<select data-axis-control="machine-manager-role" value={selected.role} onChange={(event) => roleChange(selected, event.target.value as MachinePartRole | "")}><option value="">未分配</option>{MACHINE_PART_ROLES.map((role) => <option key={role} value={role}>{roleLabel(role, coordinates)}</option>)}</select></label><label>父部件<select data-axis-control="machine-manager-parent" value={selected.parentId ?? ""} onChange={(event) => update(selected.id, { parentId: event.target.value || null })}><option value="">无</option>{parts.filter((part) => part.id !== selected.id).map((part) => <option key={part.id} value={part.id}>{part.role ? roleLabel(part.role, coordinates) : part.filename}</option>)}</select></label><div className="machine-part-flags"><label><input type="checkbox" checked={selected.visible} onChange={(event) => update(selected.id, { visible: event.target.checked })} />显示模型</label><label><input data-axis-control="machine-manager-show-origin" type="checkbox" checked={selected.showOrigin} onChange={(event) => update(selected.id, { showOrigin: event.target.checked })} />显示坐标原点</label></div>{vectorInput("模型局部原点", selected.originMm, (originMm) => update(selected.id, { originMm }), "mm")}{vectorInput("部件平移", selected.translationMm, (translationMm) => update(selected.id, { translationMm }), "mm")}{vectorInput("部件旋转", selected.rotationDeg, (rotationDeg) => update(selected.id, { rotationDeg }), "deg")}</section>}
</div>
</div>
<section 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()}<input type="number" step="any" value={physicalZero[axis as keyof PhysicalZero]} onChange={(event) => setPhysicalZero({ ...physicalZero, [axis]: number(event.target.value) })} /></label>)}</fieldset></section>

View File

@@ -13,7 +13,9 @@ export type MachinePartDraft = MachinePartSource & {
originMm: Vec3;
translationMm: Vec3;
rotationDeg: Vec3;
nodeParents: Record<string, string | null>;
};
export type MachineTreeNode = { path: string; name: string; type: "group" | "mesh"; originMm: Vec3; children: MachineTreeNode[] };
export type PhysicalZero = { x: number; y: number; z: number; a: number; b: number; c: number };
export type ManagedMachineGeometry = {
schema: "linuxcnc.machine-manager/1";
@@ -21,7 +23,7 @@ export type ManagedMachineGeometry = {
cOriginMm: Vec3;
tiltOffsetMm: Vec3;
physicalZero: PhysicalZero;
parts: Array<{ id: string; filename: string; hash: string; role: MachinePartRole; parentId: string | null; originMm: Vec3; translationMm: Vec3; rotationDeg: Vec3; visible: boolean }>;
parts: Array<{ id: string; filename: string; hash: string; role: MachinePartRole; parentId: string | null; originMm: Vec3; translationMm: Vec3; rotationDeg: Vec3; visible: boolean; nodeParents: Record<string, string | null> }>;
};
export const MACHINE_PART_ROLES: MachinePartRole[] = ["base", "x", "y", "z", "tool", "tilt", "c", "work"];
@@ -47,6 +49,40 @@ export function cloneMachinePartScene(source: THREE.Object3D) {
return clone;
}
function scenePathMap(scene: THREE.Object3D) {
const nodes = new Map<string, THREE.Object3D>(), paths = new WeakMap<THREE.Object3D, string>();
const visit = (node: THREE.Object3D, path: string) => { nodes.set(path, node); paths.set(node, path); node.children.forEach((child, index) => visit(child, `${path}/${index}`)); };
visit(scene, "scene"); return { nodes, paths };
}
export function applyMachineNodeParents(scene: THREE.Object3D, overrides: Record<string, string | null>) {
const { nodes } = scenePathMap(scene);
for (const [sourcePath, targetPath] of Object.entries(overrides ?? {})) {
const source = nodes.get(sourcePath), target = targetPath === null ? scene : nodes.get(targetPath);
if (!source || source === scene || !target || source === target) throw new Error("GLB 层级拖放目标无效");
let cursor: THREE.Object3D | null = target;
while (cursor) { if (cursor === source) throw new Error("GLB 层级不能形成循环"); cursor = cursor.parent; }
scene.updateMatrixWorld(true); target.attach(source); scene.updateMatrixWorld(true);
}
return scene;
}
export function machineSceneNode(scene: THREE.Object3D, path: string) {
return scenePathMap(scene).nodes.get(path) ?? null;
}
export function machinePartTree(part: Pick<MachinePartDraft, "filename" | "scene" | "nodeParents">): MachineTreeNode {
const scene = part.scene.clone(true), { paths } = scenePathMap(scene); applyMachineNodeParents(scene, part.nodeParents ?? {});
const build = (node: THREE.Object3D): MachineTreeNode => ({
path: paths.get(node)!,
name: node.name || (paths.get(node) === "scene" ? part.filename : `${(node as THREE.Mesh).isMesh ? "Mesh" : "Node"} ${paths.get(node)!.split("/").at(-1)}`),
type: (node as THREE.Mesh).isMesh ? "mesh" : "group",
originMm: node.position.toArray().map((value) => value * 1000) as Vec3,
children: node.children.map(build),
});
return build(scene);
}
export async function loadMachinePart(bytes: ArrayBuffer, filename: string): Promise<MachinePartSource> {
if (!bytes.byteLength) throw new Error("GLB 部件为空");
const manager = new THREE.LoadingManager();
@@ -109,7 +145,7 @@ export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC
const structure: MachineStructure = parseMachineUrdf(urdf);
const metadata: ManagedMachineGeometry = {
schema: "linuxcnc.machine-manager/1", coordinates: input.coordinates, cOriginMm: [...input.cOriginMm], tiltOffsetMm: [...input.tiltOffsetMm], physicalZero: { ...input.physicalZero },
parts: MACHINE_PART_ROLES.map((role) => { const part = byRole.get(role)!; return { id: part.id, filename: part.filename, hash: part.hash, role, parentId: part.parentId, originMm: [...part.originMm], translationMm: [...part.translationMm], rotationDeg: [...part.rotationDeg], visible: part.visible }; }),
parts: MACHINE_PART_ROLES.map((role) => { const part = byRole.get(role)!; return { id: part.id, filename: part.filename, hash: part.hash, role, parentId: part.parentId, originMm: [...part.originMm], translationMm: [...part.translationMm], rotationDeg: [...part.rotationDeg], visible: part.visible, nodeParents: { ...(part.nodeParents ?? {}) } }; }),
};
const scene = new THREE.Group(); scene.name = `Managed_${input.coordinates}`;
scene.userData.linuxcncMachine = { schema: "linuxcnc.machine/1", urdf };
@@ -124,7 +160,7 @@ export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC
placement.position.fromArray(part.translationMm).multiplyScalar(.001);
placement.rotation.set(...part.rotationDeg.map(THREE.MathUtils.degToRad) as Vec3);
placement.userData.machinePart = { role, filename: part.filename, hash: part.hash, originMm: [...part.originMm] };
const visual = cloneMachinePartScene(part.scene); visual.position.fromArray(part.originMm).multiplyScalar(-.001); placement.add(visual); links[role].add(placement);
const visual = applyMachineNodeParents(cloneMachinePartScene(part.scene), part.nodeParents ?? {}); visual.position.fromArray(part.originMm).multiplyScalar(-.001); placement.add(visual); links[role].add(placement);
}
links.base.userData.linuxcncMachineGeometry = metadata;
const signature = JSON.stringify({ metadata, hashes: MACHINE_PART_ROLES.map((role) => byRole.get(role)!.hash) });

View File

@@ -367,10 +367,23 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.machine-manager-editor { display: grid; grid-template-rows: minmax(120px, .7fr) minmax(260px, 1.3fr); min-height: 0; gap: 8px; }
.machine-part-list, .machine-part-editor, .machine-manager-calibration { border: 1px solid #9babad; background: #f6f9f8; padding: 8px; min-height: 0; overflow: auto; }
.machine-part-list h3, .machine-part-editor h3, .machine-manager-calibration h3 { margin: 0 0 7px; font-size: 13px; }
.machine-part-list > div { display: grid; grid-template-columns: minmax(0, 1fr) 30px; margin: 3px 0; }
.machine-part-list > div > button:first-child { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px; text-align: left; min-width: 0; }
.machine-part-list > div.selected > button:first-child { background: #d6e9e6; border-color: #27585d; }
.machine-part-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.machine-tree, .machine-tree ul { margin: 0; padding: 0; list-style: none; }
.machine-tree ul { margin-left: 14px; padding-left: 8px; border-left: 1px solid #a6b8ba; }
.machine-tree li { min-width: 0; }
.machine-tree-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: center; }
.machine-tree-toggle { width: 22px; height: 25px; padding: 0; border: 0; background: transparent; color: #315d61; cursor: pointer; }
.machine-tree-toggle:disabled { color: #829092; cursor: default; }
.machine-tree [data-axis-control="machine-tree-node"] { display: grid; grid-template-columns: minmax(110px, 1fr) auto; align-items: center; gap: 4px; width: 100%; min-height: 25px; padding: 2px 5px; border: 1px solid transparent; background: transparent; text-align: left; cursor: grab; }
.machine-tree [data-axis-control="machine-tree-node"]:hover { background: #e4efed; border-color: #9db4b5; }
.machine-tree [data-axis-control="machine-tree-node"].selected { background: #cfe5e2; border-color: #27585d; }
.machine-tree [data-axis-control="machine-tree-node"]:active { cursor: grabbing; }
.machine-tree [data-axis-control="machine-tree-node"] > span:first-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.machine-tree [data-axis-control="machine-tree-node"] small { color: #66787b; font: 10px ui-monospace, monospace; white-space: nowrap; }
.machine-tree .mesh { color: #397a80; }
.machine-tree-origin { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 4px 10px; padding: 7px; margin-bottom: 8px; border: 1px solid #9db4b5; background: #e8f2f0; }
.machine-tree-origin strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.machine-tree-origin span { color: #536467; }
.machine-tree-origin output { grid-column: 1 / -1; text-align: left; }
.machine-part-editor > label { display: grid; grid-template-columns: 82px minmax(0, 1fr); align-items: center; gap: 8px; margin: 6px 0; }
.machine-part-editor select { height: 30px; min-width: 0; }
.machine-part-flags { display: flex; gap: 16px; margin: 8px 0; }