diff --git a/coverage/glb-machine-manager/browser-production.report.json b/coverage/glb-machine-manager/browser-production.report.json index 4764258..6f13e20 100644 --- a/coverage/glb-machine-manager/browser-production.report.json +++ b/coverage/glb-machine-manager/browser-production.report.json @@ -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" } diff --git a/coverage/glb-machine-manager/browser.report.json b/coverage/glb-machine-manager/browser.report.json index 593710b..94d59bf 100644 --- a/coverage/glb-machine-manager/browser.report.json +++ b/coverage/glb-machine-manager/browser.report.json @@ -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", diff --git a/coverage/glb-machine-manager/manager-ac-config.png b/coverage/glb-machine-manager/manager-ac-config.png index 2c1548d..c397f36 100644 Binary files a/coverage/glb-machine-manager/manager-ac-config.png and b/coverage/glb-machine-manager/manager-ac-config.png differ diff --git a/coverage/glb-machine-manager/mill700/browser.report.json b/coverage/glb-machine-manager/mill700/browser.report.json index 18e8fa3..e07ad32 100644 --- a/coverage/glb-machine-manager/mill700/browser.report.json +++ b/coverage/glb-machine-manager/mill700/browser.report.json @@ -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": { diff --git a/coverage/glb-machine-manager/mill700/mill700-studio-lighting.png b/coverage/glb-machine-manager/mill700/mill700-studio-lighting.png index e564fc0..3b32d06 100644 Binary files a/coverage/glb-machine-manager/mill700/mill700-studio-lighting.png and b/coverage/glb-machine-manager/mill700/mill700-studio-lighting.png differ diff --git a/docs/glb-machine-manager/01-项目功能内容.md b/docs/glb-machine-manager/01-项目功能内容.md index 5e13332..3b3a66a 100644 --- a/docs/glb-machine-manager/01-项目功能内容.md +++ b/docs/glb-machine-manager/01-项目功能内容.md @@ -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,导入过程不修改源场景;拖放结果保存为草稿覆盖。 ## 数据和单位 diff --git a/docs/glb-machine-manager/02-项目程序开发详细步骤.md b/docs/glb-machine-manager/02-项目程序开发详细步骤.md index 2f8f404..8d785cd 100644 --- a/docs/glb-machine-manager/02-项目程序开发详细步骤.md +++ b/docs/glb-machine-manager/02-项目程序开发详细步骤.md @@ -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。 diff --git a/docs/glb-machine-manager/03-推进台账.md b/docs/glb-machine-manager/03-推进台账.md index 2ed31de..b5a9d4d 100644 --- a/docs/glb-machine-manager/03-推进台账.md +++ b/docs/glb-machine-manager/03-推进台账.md @@ -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、组合哈希。 diff --git a/docs/glb-machine-manager/04-任务矩阵.md b/docs/glb-machine-manager/04-任务矩阵.md index 79ff370..f020826 100644 --- a/docs/glb-machine-manager/04-任务矩阵.md +++ b/docs/glb-machine-manager/04-任务矩阵.md @@ -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 深层结构验收通过| diff --git a/docs/glb-machine-manager/05-验收证据.md b/docs/glb-machine-manager/05-验收证据.md index 640071e..76733d7 100644 --- a/docs/glb-machine-manager/05-验收证据.md +++ b/docs/glb-machine-manager/05-验收证据.md @@ -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。 diff --git a/docs/glb-machine-manager/06-决策记录.md b/docs/glb-machine-manager/06-决策记录.md index 7a18a27..7317d1d 100644 --- a/docs/glb-machine-manager/06-决策记录.md +++ b/docs/glb-machine-manager/06-决策记录.md @@ -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 直接重挂。 diff --git a/docs/glb-machine-manager/README.md b/docs/glb-machine-manager/README.md index ade7c33..870e676 100644 --- a/docs/glb-machine-manager/README.md +++ b/docs/glb-machine-manager/README.md @@ -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/`。 文档: diff --git a/docs/linuxcnc-axis-web-control-panel-source-logic.md b/docs/linuxcnc-axis-web-control-panel-source-logic.md index 0dd98d8..d1b6b0f 100644 --- a/docs/linuxcnc-axis-web-control-panel-source-logic.md +++ b/docs/linuxcnc-axis-web-control-panel-source-logic.md @@ -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. | diff --git a/scripts/test_axis_control_inventory.mjs b/scripts/test_axis_control_inventory.mjs index 0ef0c15..5772ea3 100644 --- a/scripts/test_axis_control_inventory.mjs +++ b/scripts/test_axis_control_inventory.mjs @@ -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", diff --git a/scripts/test_machine_manager_browser.mjs b/scripts/test_machine_manager_browser.mjs index b2f02e4..43cc675 100644 --- a/scripts/test_machine_manager_browser.mjs +++ b/scripts/test_machine_manager_browser.mjs @@ -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"); diff --git a/scripts/test_machine_manager_mill700_browser.mjs b/scripts/test_machine_manager_mill700_browser.mjs index 82c4911..5c9fc49 100644 --- a/scripts/test_machine_manager_mill700_browser.mjs +++ b/scripts/test_machine_manager_mill700_browser.mjs @@ -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)); diff --git a/web/react/src/MachineManagerPanel.tsx b/web/react/src/MachineManagerPanel.tsx index d756813..d100453 100644 --- a/web/react/src/MachineManagerPanel.tsx +++ b/web/react/src/MachineManagerPanel.tsx @@ -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 }; @@ -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(); const disposable: THREE.Object3D[] = []; + const nodes = new Map(); 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
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
  • +
    + {hasChildren && } +
  • ; +} + export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) { const [open, setOpen] = useState(false), [pending, setPending] = useState(false), [message, setMessage] = useState(""); const [parts, setParts] = useState([]); const partsRef = useRef(parts); partsRef.current = parts; - const [selectedId, setSelectedId] = useState(null); const [mode, setMode] = useState<"translate" | "rotate">("translate"); + const [selectedId, setSelectedId] = useState(null); const [selectedNodePath, setSelectedNodePath] = useState("scene"); const [mode, setMode] = useState<"translate" | "rotate">("translate"); const [cOriginMm, setCOriginMm] = useState([0, 0, 0]); const [tiltOffsetMm, setTiltOffsetMm] = useState([0, 0, 0]); const [physicalZero, setPhysicalZero] = useState({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); const file = useRef(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) => setParts((current) => current.map((part) => part.id === id ? { ...part, ...values } : part)); const vectorInput = (label: string, value: Vec3, set: (value: Vec3) => void, unit: string) =>
    {label} ({unit}){["X", "Y", "Z"].map((axis, index) => )}
    ; 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 => child.parentId === part.id).map(renderPartTree)} />; return
    setOpen(false)}> @@ -113,8 +144,8 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
    update(id, { translationMm, rotationDeg })} />
    -

    部件与层级

    {parts.length ? parts.map((part) =>
    ) :

    导入八个 GLB 部件后建立机械链。

    }
    - {selected &&

    {selected.filename}

    {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")}
    } +

    部件与层级

    {parts.length ?
      {parts.filter((part) => !part.parentId).map(renderPartTree)}
    :

    导入机床或部件后显示 GLB 内部层级。

    }
    + {selected &&

    {selected.filename}

    {selectedTreeNode &&
    {selectedTreeNode.name}相对父节点原点 (mm)X {selectedTreeNode.originMm[0].toFixed(3)} Y {selectedTreeNode.originMm[1].toFixed(3)} Z {selectedTreeNode.originMm[2].toFixed(3)}
    }
    {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")}
    }

    机床标定

    {vectorInput("C 轴原点", cOriginMm, setCOriginMm, "mm")}{vectorInput(`${coordinates === "XYZBC" ? "B" : "A"} 到 C 轴偏置`, tiltOffsetMm, setTiltOffsetMm, "mm")}
    机床物理零点 (XYZ mm / ABC deg){["x", "y", "z", coordinates === "XYZBC" ? "b" : "a", "c"].map((axis) => )}
    diff --git a/web/react/src/machine-manager.ts b/web/react/src/machine-manager.ts index 0119a04..acf3603 100644 --- a/web/react/src/machine-manager.ts +++ b/web/react/src/machine-manager.ts @@ -13,7 +13,9 @@ export type MachinePartDraft = MachinePartSource & { originMm: Vec3; translationMm: Vec3; rotationDeg: Vec3; + nodeParents: Record; }; +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 }>; }; 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(), paths = new WeakMap(); + 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) { + 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): 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 { 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) }); diff --git a/web/react/src/styles.css b/web/react/src/styles.css index 3ad4cbd..6e7b12a 100644 --- a/web/react/src/styles.css +++ b/web/react/src/styles.css @@ -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; }