Add draggable GLB hierarchy tree and node origins
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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,导入过程不修改源场景;拖放结果保存为草稿覆盖。
|
||||
|
||||
## 数据和单位
|
||||
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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、组合哈希。
|
||||
|
||||
@@ -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 深层结构验收通过|
|
||||
|
||||
@@ -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。
|
||||
|
||||
@@ -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 直接重挂。
|
||||
|
||||
@@ -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/`。
|
||||
|
||||
文档:
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user