diff --git a/coverage/glb-machine-manager/managed-bc-running.png b/coverage/glb-machine-manager/managed-bc-running.png index e3cd2dc..0866698 100644 Binary files a/coverage/glb-machine-manager/managed-bc-running.png and b/coverage/glb-machine-manager/managed-bc-running.png differ diff --git a/coverage/glb-machine-manager/manager-ac-config.png b/coverage/glb-machine-manager/manager-ac-config.png index 092d18b..42085d9 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 8607f9c..67cd708 100644 --- a/coverage/glb-machine-manager/mill700/browser.report.json +++ b/coverage/glb-machine-manager/mill700/browser.report.json @@ -8,8 +8,9 @@ "sha256": "bf37130681cc6462412e52744519516e8ade2a3212dd69153c0b1b3e415bcf66" }, "checks": [ + "selected MILL700 joint displays its local XYZ coordinate system at local origin", "existing 132-node, depth-6 hierarchy displayed as a draggable tree with node origins", - "axis inputs drive bound MILL700 hierarchy nodes without recreating WebGL", + "each joint rotates about its own origin while children inherit parent motion", "metallic MILL700 surfaces rendered with studio PMREM lighting; screenshot captured for visual review", "imported model can be deleted and its tree is removed" ], diff --git a/coverage/glb-machine-manager/mill700/mill700-studio-lighting.png b/coverage/glb-machine-manager/mill700/mill700-studio-lighting.png index 1b02d70..3f5f5de 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 d04a75a..08f1efd 100644 --- a/docs/glb-machine-manager/01-项目功能内容.md +++ b/docs/glb-machine-manager/01-项目功能内容.md @@ -24,6 +24,7 @@ 11. 选择任意树节点时,显示其相对父节点的局部原点 XYZ 坐标。层级和原点读取自解析后的 GLB,导入过程不修改源场景;拖放结果保存为草稿覆盖。 12. 导入的整机或部件可删除;删除后释放其 GLB 几何、材质和纹理,移除树及预览引用。 13. 树节点可绑定 X/Y/Z/A或B/C 轴角色。层级运动测试输入 XYZ 毫米值和旋转轴角度后,按节点实际父子关系施加局部位移/旋转,并输出各绑定节点的局部位置、世界位置和四元数,用于验证子节点是否正确跟随父节点。 +14. 选择任意关节树节点时,三维预览在该节点自己的局部 `(0,0,0)` 显示局部 XYZ 坐标系。坐标轴作为该节点子对象,自动继承该节点和所有父节点的变换,不显示成整机世界坐标轴。 ## 数据和单位 diff --git a/docs/glb-machine-manager/02-项目程序开发详细步骤.md b/docs/glb-machine-manager/02-项目程序开发详细步骤.md index 916180e..fd14e35 100644 --- a/docs/glb-machine-manager/02-项目程序开发详细步骤.md +++ b/docs/glb-machine-manager/02-项目程序开发详细步骤.md @@ -11,5 +11,6 @@ 9. `MGR-09`:浏览器交互、错误边界、类型检查、生产构建、截图和报告收口。 10. `MGR-12`:按 GLB 原始 `Object3D.children` 显示树,支持根部件和内部节点拖放,选择节点显示父级局部原点;用 MILL700 六层结构验收。 11. `MGR-14`:新增导入模型删除、树节点轴绑定和 XYZ/ABC 层级运动测试,输出局部/世界变换;MILL700 主轴链与工作台链验收。 +12. `MGR-15`:树选择关节后,将 XYZ 辅助轴挂在该节点局部原点,并验证轴系跟随关节及父链旋转。 每轮只推进任务矩阵中依赖已满足的任务。代码完成后必须同步更新 03、04、05;若改变数据契约或坐标定义,先更新 06。 diff --git a/docs/glb-machine-manager/03-推进台账.md b/docs/glb-machine-manager/03-推进台账.md index becd40d..ff95644 100644 --- a/docs/glb-machine-manager/03-推进台账.md +++ b/docs/glb-machine-manager/03-推进台账.md @@ -43,6 +43,13 @@ - 删除 MILL700 后 part-count=0、树节点=0。整个运动测试期间 WebGL context generation 始终为 1,没有因输入或树操作重建 renderer。 - 生产 8 部件 AC/BC 应用、导出和程序运动回归通过,warning/error=0。MGR-14 verified。 +## R08 / 2026-09-20 / 选中关节局部坐标系 + +- 用户明确每个层级节点是独立关节模型,必须显示该关节自己的局部坐标系。 +- 新增复用的 `selected-joint-local-coordinate-system` 三轴箭头组;选择树节点时挂到该节点局部 `(0,0,0)`,位置和四元数归零,因此坐标轴严格表示该节点局部 X/Y/Z。X 红、Y 绿、Z 蓝,箭头长度按整机包围盒自适应。 +- 切换节点时只把辅助轴移到新节点,不重建 renderer;节点随轴输入运动时坐标轴同步继承自身和父链变换。 +- MILL700 选择 `MILL700-A-主轴-JT2` 验证 `selected-joint=:` 和 `selected-joint-axes=local-xyz`,浏览器错误为 0。MGR-15 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 9142f0d..466996d 100644 --- a/docs/glb-machine-manager/04-任务矩阵.md +++ b/docs/glb-machine-manager/04-任务矩阵.md @@ -16,3 +16,4 @@ |MGR-12 GLB 层级树管理|verified|MGR-02,MGR-04|原始层级按 tree 显示;根/内部节点拖放、循环拒绝、节点原点坐标和 MILL700 深层结构验收通过| |MGR-13 WebGL 资源稳定性|verified|MGR-10,MGR-12|禁用超限 MSAA;drawing buffer 按 GPU 上限和像素预算缩放;零尺寸/context lost 不连续绘制;短视口 DPR3 无警告| |MGR-14 层级运动验证|verified|MGR-12,MGR-13|模型删除;树节点轴绑定;XYZ mm、A/B/C deg 输入驱动局部层级;local/world 变换和 MILL700 父子联动验收| +|MGR-15 关节局部坐标系|verified|MGR-12,MGR-14|选中树节点时局部 XYZ 辅助轴挂在节点 `(0,0,0)`,随关节和父链变换,不重建 WebGL| diff --git a/docs/glb-machine-manager/05-验收证据.md b/docs/glb-machine-manager/05-验收证据.md index 522ba05..c8d4a16 100644 --- a/docs/glb-machine-manager/05-验收证据.md +++ b/docs/glb-machine-manager/05-验收证据.md @@ -16,8 +16,8 @@ - 状态:verified;浏览器错误 0。 - 覆盖:8 部件导入和预览、原点显隐、数值/TransformControls 变换、组合 GLB 导出、AC/BC 应用、底座 userData、物理零点、非法层级拒绝、BC 五轴程序。 - 截图: - - `coverage/glb-machine-manager/manager-ac-config.png`,SHA-256 `5920cce68e8eba3adffdefaab2d475de7e49b62515a9453e76fb994e168e6ce0` - - `coverage/glb-machine-manager/managed-bc-running.png`,SHA-256 `2a28290ff58b3a0d3250ee5ebb1bc636a2cc8b8f62828b41764435501f97d385` + - `coverage/glb-machine-manager/manager-ac-config.png`,SHA-256 `b2fc2e00497ddb1e72e3170ee83ed54c21c8da9099036d84eeb4d6d6eef8619b` + - `coverage/glb-machine-manager/managed-bc-running.png`,SHA-256 `4b2d76cd505f1e31847d09650b052a0a9c393515cb7c71ad55ac649072634005` ## E03 / R02 / 生产构建浏览器闭环 @@ -43,10 +43,10 @@ - 状态: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 `11327a756a019ff933759fdf95da5a0e461dcf6987d0fa030d777fe82c96c807`。 +- report_id:`GLB-MGR-E05`;报告 SHA-256 `09fd47f80dd2f741aa1d585ce5b1b40c2ff32e211b7b6e08b1b0da18327d798e`。 - 结构: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 `ad1e2a36a5db765a13ba3ce633c9cc0e146867c87397cd284988d2496d950344`。 +- 截图:`coverage/glb-machine-manager/mill700/mill700-studio-lighting.png`,SHA-256 `8943b16fe774427306ccafb5dcbc9817bc87538edbf67bbd5807dd59bd700d8b`。 ## E06 / R04 / Z-up 坐标 @@ -79,3 +79,10 @@ - 结果:XYZ 局部增量依次为 0.01/0.02/0.03 m;A/C 局部旋转增量为 15°/45°;C 世界位置和世界四元数受 A 父节点影响,证明层级联动有效。 - 生命周期:输入和树操作期间 `context-generation=1`;warning/error=0。删除后部件数和树节点数均为 0。 - 完整变换样本保存在 `coverage/glb-machine-manager/mill700/browser.report.json` 的 `motion.baseline/driven`。 + +## E10 / R08 / 选中关节局部坐标系 + +- 状态:verified。 +- MILL700 选择 `MILL700-A-主轴-JT2` 后,预览发布稳定源路径,`selected-joint-axes=local-xyz`。 +- 三轴箭头组的局部 position=(0,0,0)、quaternion=identity,父对象为所选 GLB 节点;X 红/Y 绿/Z 蓝且按整机尺寸缩放,表示关节自己的局部轴并继承完整父链。 +- 节点选择和轴值驱动期间 `context-generation=1`,浏览器 warning/error=0。 diff --git a/docs/glb-machine-manager/06-决策记录.md b/docs/glb-machine-manager/06-决策记录.md index 5eec28b..a93d27e 100644 --- a/docs/glb-machine-manager/06-决策记录.md +++ b/docs/glb-machine-manager/06-决策记录.md @@ -47,3 +47,7 @@ GLB 树节点 ID 使用导入时的子节点索引路径,不使用可能重复 ## D12 / 轴值驱动是层级诊断 管理器的 XYZ/ABC 输入只驱动树中显式绑定的草稿节点,用于验证父子层级和轴向;它不发送 LinuxCNC 运动命令,也不替代 Worker/WASM。正式加工仍由控制面板程序、TP 和运动学驱动。诊断输出同时保留 local/world 变换以区分节点自身运动和父级继承运动。 + +## D13 / 关节坐标轴属于节点局部空间 + +选中关节的坐标辅助必须作为该节点子对象放在局部 `(0,0,0)`,不可复制世界位置或使用整机坐标轴冒充。这样关节本身旋转时辅助轴同步旋转,父关节运动时又能自然继承父链,符合 GLB 层级矩阵语义。 diff --git a/docs/glb-machine-manager/README.md b/docs/glb-machine-manager/README.md index 53026b1..ffec821 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-14 全部 verified,MILL700 实测覆盖照明、Z-up、132 节点树、拖放、节点原点、模型删除、轴绑定、XYZ/AC 层级运动和 WebGL 资源降级。最新台账 R07,证据 `GLB-MGR-E01/E02/E05/E07/E08/E09` 位于 `coverage/glb-machine-manager/`。 +当前状态:MGR-01 至 MGR-15 全部 verified,MILL700 实测覆盖照明、Z-up、132 节点树、拖放、节点原点、关节局部坐标系、模型删除、轴绑定、XYZ/AC 层级运动和 WebGL 资源降级。最新台账 R08,证据 `GLB-MGR-E01/E02/E05/E07/E08/E09/E10` 位于 `coverage/glb-machine-manager/`。 文档: diff --git a/scripts/test_machine_manager_mill700_browser.mjs b/scripts/test_machine_manager_mill700_browser.mjs index e5a2470..2976c2c 100644 --- a/scripts/test_machine_manager_mill700_browser.mjs +++ b/scripts/test_machine_manager_mill700_browser.mjs @@ -26,15 +26,15 @@ try { 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("[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); - 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 }); + 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 0\.000.*关节零姿态原点.*零姿态世界原点.*零姿态局部转角/s); const selectedPath = await selectedNode.locator("xpath=ancestor::li[1]").getAttribute("data-machine-tree-path"), selectedPart = await selectedNode.locator("xpath=ancestor::li[1]").getAttribute("data-machine-tree-part"); await page.waitForFunction(({ selectedPart, selectedPath }) => { const preview = document.querySelector("[data-machine-manager-preview]"); return preview?.dataset.machineManagerSelectedJoint === `${selectedPart}:${selectedPath}` && preview?.dataset.machineManagerSelectedJointAxes === "local-xyz" && preview?.dataset.machineManagerTransformTarget === "none-internal-joint"; }, { selectedPart, selectedPath }); const jointWorldOrigin = JSON.parse(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-selected-joint-world-origin")); assert.ok(jointWorldOrigin.length === 3 && jointWorldOrigin.every(Number.isFinite)); assert.ok(Number(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-selected-joint-axes-scale")) >= 1); 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 }); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-context-generation"), "1"); - report.hierarchy = hierarchy; report.checks.push("existing 132-node, depth-6 hierarchy displayed as a draggable tree with node origins"); - const preview = page.locator("[data-machine-manager-preview]"); assert.equal(await preview.getAttribute("data-machine-manager-lighting"), "studio-pmrem"); assert.equal(await preview.getAttribute("data-machine-manager-coordinate-system"), "machine-z-up"); assert.equal(await preview.getAttribute("data-machine-manager-axis-map"), "X:x,Y:y,Z:z"); assert.equal(await preview.getAttribute("data-machine-manager-camera-up"), "0,0,1"); assert.equal(await preview.getAttribute("data-machine-manager-model-transform"), "none-on-import"); assert.equal(await preview.getAttribute("data-machine-manager-webgl-antialias"), "false"); assert.equal(await preview.getAttribute("data-machine-manager-render-state"), "ready"); assert.equal(await preview.getAttribute("data-machine-manager-context-generation"), "1"); const [bufferWidth, bufferHeight] = (await preview.getAttribute("data-machine-manager-buffer-size")).split("x").map(Number), maxBuffer = Number(await preview.getAttribute("data-machine-manager-max-buffer")); assert.ok(bufferWidth > 0 && bufferHeight > 0 && bufferWidth <= maxBuffer && bufferHeight <= maxBuffer); + report.hierarchy = hierarchy; report.checks.push("selected MILL700 joint displays its local XYZ coordinate system at local origin"); report.checks.push("existing 132-node, depth-6 hierarchy displayed as a draggable tree with node origins"); + const preview = page.locator("[data-machine-manager-preview]"); assert.equal(await preview.getAttribute("data-machine-manager-lighting"), "studio-pmrem"); assert.equal(await preview.getAttribute("data-machine-manager-coordinate-system"), "machine-z-up"); assert.equal(await preview.getAttribute("data-machine-manager-axis-map"), "X:x,Y:y,Z:z"); assert.equal(await preview.getAttribute("data-machine-manager-camera-up"), "0,0,1"); assert.equal(await preview.getAttribute("data-machine-manager-model-transform"), "none-on-import"); assert.equal(await preview.getAttribute("data-machine-manager-webgl-antialias"), "false"); assert.equal(await preview.getAttribute("data-machine-manager-render-state"), "rendered"); assert.equal(await preview.getAttribute("data-machine-manager-context-generation"), "1"); assert.equal(Number(await preview.getAttribute("data-machine-manager-scene-meshes")), 126); assert.ok(Number(await preview.getAttribute("data-machine-manager-scene-objects")) >= 132); const bounds = JSON.parse(await preview.getAttribute("data-machine-manager-bounds")); assert.ok(bounds.min.every(Number.isFinite) && bounds.max.every(Number.isFinite) && bounds.size > 0); const [bufferWidth, bufferHeight] = (await preview.getAttribute("data-machine-manager-buffer-size")).split("x").map(Number), maxBuffer = Number(await preview.getAttribute("data-machine-manager-max-buffer")); assert.ok(bufferWidth > 0 && bufferHeight > 0 && bufferWidth <= maxBuffer && bufferHeight <= maxBuffer); const bind = async (name, role) => { const node = page.locator('[data-axis-control="machine-tree-node"]').filter({ hasText: name }).first(); await node.dispatchEvent("click"); await control("machine-tree-axis-role").selectOption(role); }; await bind("MILL700-A-主轴-JT1", "x"); await bind("MILL700-A-主轴-JT2", "y"); await bind("MILL700-A-主轴-JT3", "z"); await bind("MILL700-A-工作台-JT1", "tilt"); await bind("MILL700-A-工作台-JT2", "c"); await page.waitForFunction(() => { try { const roles = JSON.parse(document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerRoleTransforms ?? "{}"); return ["x", "y", "z", "tilt", "c"].every((role) => roles[role]); } catch { return false; } }); const baseline = JSON.parse(await preview.getAttribute("data-machine-manager-role-transforms")); await control("machine-test-x").fill("10"); await control("machine-test-y").fill("20"); await control("machine-test-z").fill("30"); await control("machine-test-a").fill("15"); await control("machine-test-c").fill("45"); await page.waitForFunction(() => JSON.parse(document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerTestPose ?? "{}").c === 45); const driven = JSON.parse(await preview.getAttribute("data-machine-manager-role-transforms")), quaternionAngle = (left, right) => 2 * Math.acos(Math.min(1, Math.abs(left.reduce((sum, value, index) => sum + value * right[index], 0)))); - assert.ok(Math.abs(driven.x.local[0] - baseline.x.local[0] - .01) < 1e-9); assert.ok(Math.abs(driven.y.local[1] - baseline.y.local[1] - .02) < 1e-9); assert.ok(Math.abs(driven.z.local[2] - baseline.z.local[2] - .03) < 1e-9); assert.ok(Math.abs(quaternionAngle(driven.tilt.localQuaternion, baseline.tilt.localQuaternion) - 15 * Math.PI / 180) < 1e-7); assert.ok(Math.abs(quaternionAngle(driven.c.localQuaternion, baseline.c.localQuaternion) - 45 * Math.PI / 180) < 1e-7); assert.ok(driven.c.world.some((value, index) => Math.abs(value - baseline.c.world[index]) > 1e-6)); assert.equal(await preview.getAttribute("data-machine-manager-context-generation"), "1"); report.motion = { input: { x: 10, y: 20, z: 30, a: 15, c: 45 }, baseline, driven }; report.checks.push("axis inputs drive bound MILL700 hierarchy nodes without recreating WebGL"); + assert.ok(Math.abs(driven.x.local[0] - baseline.x.local[0] - .01) < 1e-9); assert.ok(Math.abs(driven.y.local[1] - baseline.y.local[1] - .02) < 1e-9); assert.ok(Math.abs(driven.z.local[2] - baseline.z.local[2] - .03) < 1e-9); assert.ok(Math.abs(quaternionAngle(driven.tilt.localQuaternion, baseline.tilt.localQuaternion) - 15 * Math.PI / 180) < 1e-7); assert.ok(Math.abs(quaternionAngle(driven.c.localQuaternion, baseline.c.localQuaternion) - 45 * Math.PI / 180) < 1e-7); assert.deepEqual(driven.tilt.world, baseline.tilt.world); assert.deepEqual(driven.c.local, baseline.c.local); assert.ok(driven.c.world.some((value, index) => Math.abs(value - baseline.c.world[index]) > 1e-6)); assert.equal(await preview.getAttribute("data-machine-manager-context-generation"), "1"); report.motion = { input: { x: 10, y: 20, z: 30, a: 15, c: 45 }, baseline, driven }; report.checks.push("each joint rotates about its own origin while children inherit parent motion"); report.checks.push("metallic MILL700 surfaces rendered with studio PMREM lighting; screenshot captured for visual review"); assert.deepEqual(errors, []); report.status = "verified"; await control("machine-manager-delete").click(); await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "0" && document.querySelectorAll('[data-axis-control="machine-tree-node"]').length === 0); report.checks.push("imported model can be deleted and its tree is removed"); } 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; } diff --git a/web/react/src/MachineManagerPanel.tsx b/web/react/src/MachineManagerPanel.tsx index 4ec9866..f313155 100644 --- a/web/react/src/MachineManagerPanel.tsx +++ b/web/react/src/MachineManagerPanel.tsx @@ -6,21 +6,23 @@ 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 { applyMachineNodeParents, buildManagedMachine, expectedParentRole, loadMachinePart, machinePartTree, machineSceneNode, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type MachineTreeNode, 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 }; type TestPose = { x: number; y: number; z: number; a: number; b: number; c: number }; +type MotionTransform = { local: number[]; localQuaternion: number[]; world: number[]; worldQuaternion: number[] }; const zero3 = (): Vec3 => [0, 0, 0]; const number = (value: string) => Number.isFinite(Number(value)) ? Number(value) : 0; -function ManagerPreview({ coordinates, parts, selectedId, mode, testPose, onTransform }: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; selectedId: string | null; mode: "translate" | "rotate"; testPose: TestPose; onTransform: (id: string, translationMm: Vec3, rotationDeg: Vec3) => void }) { - type PreviewHost = HTMLDivElement & { machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, mode: "translate" | "rotate", testPose: TestPose, coordinates: "XYZAC" | "XYZBC") => void }; +function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, mode, testPose, onTransform, onMotionState }: { coordinates: "XYZAC" | "XYZBC"; parts: MachinePartDraft[]; selectedId: string | null; selectedNodePath: string; mode: "translate" | "rotate"; testPose: TestPose; onTransform: (id: string, translationMm: Vec3, rotationDeg: Vec3) => void; onMotionState: (state: Record) => void }) { + type PreviewHost = HTMLDivElement & { machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, selectedNodePath: string, mode: "translate" | "rotate", testPose: TestPose, coordinates: "XYZAC" | "XYZBC") => void }; const host = useRef(null); - const stateRef = useRef({ parts, selectedId, mode, testPose, coordinates }); stateRef.current = { parts, selectedId, mode, testPose, coordinates }; + const stateRef = useRef({ parts, selectedId, selectedNodePath, mode, testPose, coordinates }); stateRef.current = { parts, selectedId, selectedNodePath, mode, testPose, coordinates }; const onTransformRef = useRef(onTransform); onTransformRef.current = onTransform; + const onMotionStateRef = useRef(onMotionState); onMotionStateRef.current = onMotionState; const [fault, setFault] = useState(""); - useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, mode, testPose, coordinates); }, [parts, selectedId, mode, testPose, coordinates]); + useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, selectedNodePath, mode, testPose, coordinates); }, [parts, selectedId, selectedNodePath, mode, testPose, coordinates]); useEffect(() => { const element = host.current; if (!element) return; // Large industrial GLBs can exceed small-GPU multisample renderbuffer @@ -42,22 +44,31 @@ function ManagerPreview({ coordinates, parts, selectedId, mode, testPose, onTran const orbit = new OrbitControls(camera, renderer.domElement); orbit.enableDamping = true; const root = new THREE.Group(); root.name = "machine-manager-preview"; scene.add(root); type RoleNode = { object: THREE.Object3D; position: THREE.Vector3; quaternion: THREE.Quaternion }; - let nodes = new Map(), roleNodes = new Map(), helpers: THREE.AxesHelper[] = [], selectedObject: THREE.Group | null = null, selectedPartId: string | null = null, lastParts: MachinePartDraft[] | null = null, fitSignature = "", dirty = true; + let nodes = new Map(), visualNodes = new Map>(), roleNodes = new Map(), helpers: THREE.AxesHelper[] = [], selectedObject: THREE.Group | null = null, selectedJointObject: THREE.Object3D | null = null, selectedPartId: string | null = null, lastParts: MachinePartDraft[] | null = null, fitSignature = "", selectedAxesScale = 1, dirty = true, renderable = false, contextLost = false, frame = 0; + const selectedJointAxes = new THREE.Group(); selectedJointAxes.name = "selected-joint-local-coordinate-system"; selectedJointAxes.visible = false; + const jointAxis = (direction: THREE.Vector3, color: number) => selectedJointAxes.add(new THREE.ArrowHelper(direction, new THREE.Vector3(), .28, color, .07, .045)); + jointAxis(new THREE.Vector3(1, 0, 0), 0xff3030); jointAxis(new THREE.Vector3(0, 1, 0), 0x35d05b); jointAxis(new THREE.Vector3(0, 0, 1), 0x327cff); scene.add(selectedJointAxes); const transform = new TransformControls(camera, renderer.domElement); transform.setMode(mode); transform.setSpace("local"); scene.add(transform.getHelper()); transform.addEventListener("dragging-changed", ((event: { value?: boolean }) => { orbit.enabled = !event.value; }) as never); transform.addEventListener("objectChange", (() => { if (!selectedPartId || !selectedObject) return; onTransformRef.current(selectedPartId, selectedObject.position.toArray().map((value) => value * 1000) as Vec3, [THREE.MathUtils.radToDeg(selectedObject.rotation.x), THREE.MathUtils.radToDeg(selectedObject.rotation.y), THREE.MathUtils.radToDeg(selectedObject.rotation.z)]); }) as never); - element.machineManagerSync = (nextParts, nextSelectedId, nextMode, nextPose, nextCoordinates) => { + const draw = () => { + if (contextLost || !renderable) return; + renderer.setRenderTarget(null); scene.updateMatrixWorld(true); camera.updateMatrixWorld(true); renderer.clear(); renderer.render(scene, camera); + element.dataset.machineManagerRendered = "true"; element.dataset.machineManagerRenderState = "rendered"; dirty = false; + }; + element.machineManagerSync = (nextParts, nextSelectedId, nextNodePath, nextMode, nextPose, nextCoordinates) => { transform.setMode(nextMode); if (nextParts !== lastParts) { - transform.detach(); root.clear(); helpers.forEach((helper) => helper.dispose()); helpers = []; nodes = new Map(); roleNodes = new Map(); + delete element.dataset.machineManagerRendered; element.dataset.machineManagerRenderState = "syncing-scene"; + transform.detach(); selectedJointAxes.removeFromParent(); root.clear(); helpers.forEach((helper) => helper.dispose()); helpers = []; nodes = new Map(); visualNodes = new Map(); roleNodes = new Map(); for (const part of nextParts) { 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); // Clone transforms/hierarchy only. Geometry, materials, textures and // source-node transforms remain untouched on the imported GLB. - const visual = part.scene.clone(true), internalRoles = Object.entries(part.nodeRoles ?? {}).map(([path, role]) => ({ object: machineSceneNode(visual, path), role })); applyMachineNodeParents(visual, part.nodeParents); visual.position.fromArray(part.originMm).multiplyScalar(-.001); node.add(visual); + const visual = part.scene.clone(true), pathNodes = new Map(); const indexNodes = (object: THREE.Object3D, path: string) => { pathNodes.set(path, object); object.children.forEach((child, index) => indexNodes(child, `${path}/${index}`)); }; indexNodes(visual, "scene"); const internalRoles = Object.entries(part.nodeRoles ?? {}).map(([path, role]) => ({ object: pathNodes.get(path) ?? null, role })); applyMachineNodeParents(visual, part.nodeParents); visual.position.fromArray(part.originMm).multiplyScalar(-.001); node.add(visual); visualNodes.set(part.id, pathNodes); 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); if (part.role) roleNodes.set(part.role, { object: node, position: node.position.clone(), quaternion: node.quaternion.clone() }); for (const binding of internalRoles) if (binding.object && binding.role) roleNodes.set(binding.role, { object: binding.object, position: binding.object.position.clone(), quaternion: binding.object.quaternion.clone() }); @@ -67,19 +78,20 @@ function ManagerPreview({ coordinates, parts, selectedId, mode, testPose, onTran const signature = nextParts.map((part) => `${part.id}:${part.hash}`).join("|"); if (signature !== fitSignature) { const bounds = new THREE.Box3().setFromObject(root), center = bounds.isEmpty() ? new THREE.Vector3() : bounds.getCenter(new THREE.Vector3()), size = bounds.isEmpty() ? 1 : Math.max(bounds.getSize(new THREE.Vector3()).length(), .2); - orbit.target.copy(center); camera.position.copy(center).add(new THREE.Vector3(size, size * .7, size * 1.15)); orbit.update(); fitSignature = signature; + orbit.target.copy(center); camera.position.copy(center).add(new THREE.Vector3(size, size * .7, size * 1.15)); camera.lookAt(center); orbit.update(); fitSignature = signature; selectedAxesScale = THREE.MathUtils.clamp(size * .14 / .28, 1, 12); + element.dataset.machineManagerBounds = JSON.stringify({ min: bounds.min.toArray(), max: bounds.max.toArray(), center: center.toArray(), size }); element.dataset.machineManagerCamera = JSON.stringify({ position: camera.position.toArray(), target: orbit.target.toArray(), up: camera.up.toArray() }); } lastParts = nextParts; } - selectedPartId = nextSelectedId; selectedObject = nextSelectedId ? nodes.get(nextSelectedId) ?? null : null; transform.detach(); if (selectedObject) transform.attach(selectedObject); + selectedPartId = nextSelectedId; selectedObject = nextSelectedId && nextNodePath === "scene" ? nodes.get(nextSelectedId) ?? null : null; transform.detach(); if (selectedObject) transform.attach(selectedObject); + selectedJointAxes.removeFromParent(); selectedJointObject = nextSelectedId ? nextNodePath === "scene" ? nodes.get(nextSelectedId) ?? null : visualNodes.get(nextSelectedId)?.get(nextNodePath) ?? null : null; if (selectedJointObject) { selectedJointObject.add(selectedJointAxes); selectedJointAxes.position.set(0, 0, 0); selectedJointAxes.quaternion.identity(); selectedJointAxes.scale.setScalar(selectedAxesScale); selectedJointAxes.visible = true; element.dataset.machineManagerSelectedJoint = `${nextSelectedId}:${nextNodePath}`; element.dataset.machineManagerSelectedJointAxes = "local-xyz"; element.dataset.machineManagerTransformTarget = nextNodePath === "scene" ? "part-root" : "none-internal-joint"; } else { selectedJointAxes.visible = false; delete element.dataset.machineManagerSelectedJoint; delete element.dataset.machineManagerSelectedJointAxes; delete element.dataset.machineManagerTransformTarget; } for (const binding of roleNodes.values()) { binding.object.position.copy(binding.position); binding.object.quaternion.copy(binding.quaternion); } - const translate = (role: MachinePartRole, axis: "x" | "y" | "z", value: number) => { const binding = roleNodes.get(role); if (binding) binding.object.position[axis] += value / 1000; }; + const translate = (role: MachinePartRole, axis: "x" | "y" | "z", value: number) => { const binding = roleNodes.get(role); if (binding) { const direction = new THREE.Vector3(axis === "x" ? 1 : 0, axis === "y" ? 1 : 0, axis === "z" ? 1 : 0).applyQuaternion(binding.quaternion); binding.object.position.addScaledVector(direction, value / 1000); } }; const rotate = (role: MachinePartRole, axis: THREE.Vector3, degrees: number) => { const binding = roleNodes.get(role); if (binding) binding.object.quaternion.copy(binding.quaternion).multiply(new THREE.Quaternion().setFromAxisAngle(axis, THREE.MathUtils.degToRad(degrees))); }; translate("x", "x", nextPose.x); translate("y", "y", nextPose.y); translate("z", "z", nextPose.z); rotate("tilt", nextCoordinates === "XYZBC" ? new THREE.Vector3(0, 1, 0) : new THREE.Vector3(1, 0, 0), nextCoordinates === "XYZBC" ? nextPose.b : nextPose.a); rotate("c", new THREE.Vector3(0, 0, 1), nextPose.c); - root.updateMatrixWorld(true); element.dataset.machineManagerRoleTransforms = JSON.stringify(Object.fromEntries([...roleNodes].map(([role, binding]) => [role, { local: binding.object.position.toArray(), localQuaternion: binding.object.quaternion.toArray(), world: binding.object.getWorldPosition(new THREE.Vector3()).toArray(), worldQuaternion: binding.object.getWorldQuaternion(new THREE.Quaternion()).toArray() }]))); element.dataset.machineManagerTestPose = JSON.stringify(nextPose); element.dataset.machineManagerContextGeneration = "1"; dirty = true; + root.updateMatrixWorld(true); let objectCount = 0, meshCount = 0; root.traverse((object) => { let cursor: THREE.Object3D | null = object, isJointHelper = false; while (cursor) { if (cursor === selectedJointAxes) { isJointHelper = true; break; } cursor = cursor.parent; } if (!isJointHelper) { objectCount += 1; if ((object as THREE.Mesh).isMesh) meshCount += 1; } }); const transforms = Object.fromEntries([...roleNodes].map(([role, binding]) => [role, { local: binding.object.position.toArray(), localQuaternion: binding.object.quaternion.toArray(), world: binding.object.getWorldPosition(new THREE.Vector3()).toArray(), worldQuaternion: binding.object.getWorldQuaternion(new THREE.Quaternion()).toArray() }])); element.dataset.machineManagerSceneObjects = String(objectCount); element.dataset.machineManagerSceneMeshes = String(meshCount); element.dataset.machineManagerRoleTransforms = JSON.stringify(transforms); if (selectedJointObject) { element.dataset.machineManagerSelectedJointWorldOrigin = JSON.stringify(selectedJointObject.getWorldPosition(new THREE.Vector3()).toArray()); element.dataset.machineManagerSelectedJointWorldQuaternion = JSON.stringify(selectedJointObject.getWorldQuaternion(new THREE.Quaternion()).toArray()); element.dataset.machineManagerSelectedJointAxesScale = selectedAxesScale.toFixed(6); } onMotionStateRef.current(transforms); element.dataset.machineManagerTestPose = JSON.stringify(nextPose); element.dataset.machineManagerContextGeneration = "1"; dirty = true; draw(); }; - element.machineManagerSync(stateRef.current.parts, stateRef.current.selectedId, stateRef.current.mode, stateRef.current.testPose, stateRef.current.coordinates); - let renderable = false, contextLost = false; + element.machineManagerSync(stateRef.current.parts, stateRef.current.selectedId, stateRef.current.selectedNodePath, stateRef.current.mode, stateRef.current.testPose, stateRef.current.coordinates); const resize = () => { const width = element.clientWidth, height = element.clientHeight; if (!Number.isFinite(width) || !Number.isFinite(height) || width < 2 || height < 2) { renderable = false; element.dataset.machineManagerRenderState = "suspended-zero-size"; return; } @@ -88,14 +100,14 @@ function ManagerPreview({ coordinates, parts, selectedId, mode, testPose, onTran const pixelRatio = Math.max(.5, requested * scale); renderer.setPixelRatio(pixelRatio); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false); renderable = renderer.domElement.width > 0 && renderer.domElement.height > 0 && renderer.domElement.width <= maxDimension && renderer.domElement.height <= maxDimension; - element.dataset.machineManagerPixelRatio = pixelRatio.toFixed(3); element.dataset.machineManagerBufferSize = `${renderer.domElement.width}x${renderer.domElement.height}`; element.dataset.machineManagerRenderState = renderable ? "ready" : "suspended-limit"; + element.dataset.machineManagerPixelRatio = pixelRatio.toFixed(3); element.dataset.machineManagerBufferSize = `${renderer.domElement.width}x${renderer.domElement.height}`; element.dataset.machineManagerRenderState = renderable ? "ready" : "suspended-limit"; dirty = true; draw(); }; const contextLostHandler = (event: Event) => { event.preventDefault(); contextLost = true; renderable = false; element.dataset.machineManagerRenderState = "context-lost"; setFault("WebGL 上下文已丢失,请关闭并重新打开三维机床管理器"); }; renderer.domElement.addEventListener("webglcontextlost", contextLostHandler); - const observer = new ResizeObserver(resize); observer.observe(element); resize(); let frame = 0; + const observer = new ResizeObserver(resize); observer.observe(element); resize(); orbit.addEventListener("change", () => { dirty = true; }); transform.addEventListener("change", () => { dirty = true; }); - const render = () => { if (!contextLost && renderable && (dirty || orbit.update())) { renderer.render(scene, camera); element.dataset.machineManagerRendered = "true"; dirty = false; } frame = contextLost ? 0 : requestAnimationFrame(render); }; render(); - return () => { if (frame) cancelAnimationFrame(frame); observer.disconnect(); delete element.machineManagerSync; renderer.domElement.removeEventListener("webglcontextlost", contextLostHandler); transform.dispose(); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); }; + const render = () => { if (!contextLost && renderable && (dirty || orbit.update())) draw(); frame = contextLost ? 0 : requestAnimationFrame(render); }; render(); + return () => { if (frame) cancelAnimationFrame(frame); observer.disconnect(); delete element.machineManagerSync; renderer.domElement.removeEventListener("webglcontextlost", contextLostHandler); transform.dispose(); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); selectedJointAxes.traverse((object) => { const mesh = object as THREE.Mesh; mesh.geometry?.dispose(); if (mesh.material) for (const material of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) material.dispose(); }); environment.dispose(); renderer.dispose(); renderer.domElement.remove(); }; }, []); return
part.showOrigin).length} data-transform-mode={mode} aria-label="三维机床装配预览,机床 Z 轴朝上" />{fault && {fault}}
; } @@ -118,6 +130,7 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) { 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); const [testPose, setTestPose] = useState({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); + const [motionState, setMotionState] = useState>({}); const motionStateHash = useRef(""); 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; @@ -144,7 +157,7 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) { } 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); setSelectedNodePath("scene"); } }; + const remove = (part: MachinePartDraft) => { setParts((current) => current.filter((item) => item.id !== part.id).map((item) => item.parentId === part.id ? { ...item, parentId: null } : item)); requestAnimationFrame(() => disposeMachineScene(part.scene)); 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; @@ -195,16 +208,18 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) { })); }; const poseInput = (axis: keyof TestPose, unit: string) => ; + const receiveMotionState = (state: Record) => { const hash = JSON.stringify(state); if (hash !== motionStateHash.current) { motionStateHash.current = hash; setMotionState(state); } }; + const selectedDriven = selectedBinding ? motionState[selectedBinding] : null; const renderPartTree = (part: MachinePartDraft): React.ReactNode => child.parentId === part.id).map(renderPartTree)} />; return
setOpen(false)}>
{parts.length}/8 部件
- {open && update(id, { translationMm, rotationDeg })} />} + {open && update(id, { translationMm, rotationDeg })} onMotionState={receiveMotionState} />}

部件与层级

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

{selected.filename}

{selectedTreeNode &&
{selectedTreeNode.name}节点自身局部原点X 0.000 Y 0.000 Z 0.000关节零姿态原点(相对父节点,mm)X {selectedTreeNode.originMm[0].toFixed(3)} Y {selectedTreeNode.originMm[1].toFixed(3)} Z {selectedTreeNode.originMm[2].toFixed(3)}零姿态世界原点(mm)X {selectedTreeNode.worldOriginMm[0].toFixed(3)} Y {selectedTreeNode.worldOriginMm[1].toFixed(3)} Z {selectedTreeNode.worldOriginMm[2].toFixed(3)}零姿态局部转角(deg)RX {selectedTreeNode.rotationDeg[0].toFixed(3)} RY {selectedTreeNode.rotationDeg[1].toFixed(3)} RZ {selectedTreeNode.rotationDeg[2].toFixed(3)}{selectedDriven && <>当前驱动后的世界原点(mm)X {(selectedDriven.world[0] * 1000).toFixed(3)} Y {(selectedDriven.world[1] * 1000).toFixed(3)} Z {(selectedDriven.world[2] * 1000).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")}
}

层级运动测试

{(["x", "y", "z"] as const).map((axis) => poseInput(axis, "mm"))}{poseInput(coordinates === "XYZBC" ? "b" : "a", "deg")}{poseInput("c", "deg")}
{JSON.stringify(testPose)}
diff --git a/web/react/src/machine-manager.ts b/web/react/src/machine-manager.ts index d82cba4..b2fc559 100644 --- a/web/react/src/machine-manager.ts +++ b/web/react/src/machine-manager.ts @@ -16,7 +16,7 @@ export type MachinePartDraft = MachinePartSource & { nodeParents: Record; nodeRoles: Record; }; -export type MachineTreeNode = { path: string; name: string; type: "group" | "mesh"; originMm: Vec3; children: MachineTreeNode[] }; +export type MachineTreeNode = { path: string; name: string; type: "group" | "mesh"; originMm: Vec3; worldOriginMm: Vec3; rotationDeg: 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"; @@ -74,11 +74,14 @@ export function machineSceneNode(scene: THREE.Object3D, path: string) { export function machinePartTree(part: Pick): MachineTreeNode { const scene = part.scene.clone(true), { paths } = scenePathMap(scene); applyMachineNodeParents(scene, part.nodeParents ?? {}); + scene.updateMatrixWorld(true); 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, + worldOriginMm: node.getWorldPosition(new THREE.Vector3()).toArray().map((value) => value * 1000) as Vec3, + rotationDeg: new THREE.Euler().setFromQuaternion(node.quaternion, "XYZ").toArray().slice(0, 3).map((value) => THREE.MathUtils.radToDeg(Number(value))) as Vec3, children: node.children.map(build), }); return build(scene);