Add hierarchy axis motion validation and model deletion

This commit is contained in:
2026-09-20 21:23:14 +08:00
parent 320a69a916
commit c2892bfe36
17 changed files with 406 additions and 30 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View File

@@ -9,7 +9,9 @@
}, },
"checks": [ "checks": [
"existing 132-node, depth-6 hierarchy displayed as a draggable tree with node origins", "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" "axis inputs drive bound MILL700 hierarchy nodes without recreating WebGL",
"metallic MILL700 surfaces rendered with studio PMREM lighting; screenshot captured for visual review",
"imported model can be deleted and its tree is removed"
], ],
"hierarchy": { "hierarchy": {
"root": "MILL700-U_1", "root": "MILL700-U_1",
@@ -19,5 +21,306 @@
"materials": 126, "materials": 126,
"metallic": 110 "metallic": 110
}, },
"motion": {
"input": {
"x": 10,
"y": 20,
"z": 30,
"a": 15,
"c": 45
},
"baseline": {
"base": {
"local": [
0,
0,
0
],
"localQuaternion": [
0,
0,
0,
1
],
"world": [
0,
0,
0
],
"worldQuaternion": [
0,
0,
0,
1
]
},
"x": {
"local": [
0.4726388454437256,
1.681300401687622,
-0.9978671073913574
],
"localQuaternion": [
0,
0,
0,
1
],
"world": [
-1.8768789768218994,
0.34002411365509033,
1.488389492034912
],
"worldQuaternion": [
0,
0,
0,
1
]
},
"y": {
"local": [
-0.003969311714172363,
-0.20427599549293518,
0.40563321113586426
],
"localQuaternion": [
0,
0,
0,
1
],
"world": [
-1.8808482885360718,
0.13574811816215515,
1.8940227031707764
],
"worldQuaternion": [
0,
0,
0,
1
]
},
"z": {
"local": [
-0.02080368995666504,
-0.9040489196777344,
0.3689253330230713
],
"localQuaternion": [
0,
0,
0,
1
],
"world": [
-1.9016519784927368,
-0.7683008015155792,
2.2629480361938477
],
"worldQuaternion": [
0,
0,
0,
1
]
},
"tilt": {
"local": [
0.49799561500549316,
1.1399997472763062,
-1.6030043363571167
],
"localQuaternion": [
0,
0,
0,
1
],
"world": [
-1.8515222072601318,
-0.20127654075622559,
0.8832522630691528
],
"worldQuaternion": [
0,
0,
0,
1
]
},
"c": {
"local": [
-0.00008916854858398438,
-0.6340826749801636,
-0.08644759654998779
],
"localQuaternion": [
0,
0,
0,
1
],
"world": [
-1.8516113758087158,
-0.8353592157363892,
0.796804666519165
],
"worldQuaternion": [
0,
0,
0,
1
]
}
},
"driven": {
"base": {
"local": [
0,
0,
0
],
"localQuaternion": [
0,
0,
0,
1
],
"world": [
0,
0,
0
],
"worldQuaternion": [
0,
0,
0,
1
]
},
"x": {
"local": [
0.4826388454437256,
1.681300401687622,
-0.9978671073913574
],
"localQuaternion": [
0,
0,
0,
1
],
"world": [
-1.8668789768218994,
0.34002411365509033,
1.488389492034912
],
"worldQuaternion": [
0,
0,
0,
1
]
},
"y": {
"local": [
-0.003969311714172363,
-0.1842759954929352,
0.40563321113586426
],
"localQuaternion": [
0,
0,
0,
1
],
"world": [
-1.8708482885360718,
0.15574811816215514,
1.8940227031707764
],
"worldQuaternion": [
0,
0,
0,
1
]
},
"z": {
"local": [
-0.02080368995666504,
-0.9040489196777344,
0.3989253330230713
],
"localQuaternion": [
0,
0,
0,
1
],
"world": [
-1.8916519784927368,
-0.7483008015155792,
2.292948036193848
],
"worldQuaternion": [
0,
0,
0,
1
]
},
"tilt": {
"local": [
0.49799561500549316,
1.1399997472763062,
-1.6030043363571167
],
"localQuaternion": [
0.13052619222005157,
0,
0,
0.9914448613738104
],
"world": [
-1.8515222072601318,
-0.20127654075622559,
0.8832522630691528
],
"worldQuaternion": [
0.1305261922200516,
0,
0,
0.9914448613738103
]
},
"c": {
"local": [
-0.00008916854858398438,
-0.6340826749801636,
-0.08644759654998779
],
"localQuaternion": [
0,
0,
0.3826834323650898,
0.9238795325112867
],
"world": [
-1.8516113758087158,
-0.791379088131547,
0.6356376244864839
],
"worldQuaternion": [
0.1205904774487396,
-0.04995021125231482,
0.3794095225512604,
0.9159756150367535
]
}
}
},
"browser_errors": [] "browser_errors": []
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 235 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -22,6 +22,8 @@
9. 应用后,控制面板程序执行、Jog、RTCP 切换和刀长变化继续以 Worker/WASM 快照为唯一运动输入;模型不得在 React 中重新计算刀尖逆解。 9. 应用后,控制面板程序执行、Jog、RTCP 切换和刀长变化继续以 Worker/WASM 快照为唯一运动输入;模型不得在 React 中重新计算刀尖逆解。
10. 导入 GLB 后,若模型已有 `Object3D.children` 层级,右侧必须按原结构显示为树。树节点名称可拖放:GLB 根节点之间管理部件装配父级,同一 GLB 内部节点之间管理内部父级;循环和跨 GLB 内部节点拖放必须拒绝。 10. 导入 GLB 后,若模型已有 `Object3D.children` 层级,右侧必须按原结构显示为树。树节点名称可拖放:GLB 根节点之间管理部件装配父级,同一 GLB 内部节点之间管理内部父级;循环和跨 GLB 内部节点拖放必须拒绝。
11. 选择任意树节点时,显示其相对父节点的局部原点 XYZ 坐标。层级和原点读取自解析后的 GLB,导入过程不修改源场景;拖放结果保存为草稿覆盖。 11. 选择任意树节点时,显示其相对父节点的局部原点 XYZ 坐标。层级和原点读取自解析后的 GLB,导入过程不修改源场景;拖放结果保存为草稿覆盖。
12. 导入的整机或部件可删除;删除后释放其 GLB 几何、材质和纹理,移除树及预览引用。
13. 树节点可绑定 X/Y/Z/A或B/C 轴角色。层级运动测试输入 XYZ 毫米值和旋转轴角度后,按节点实际父子关系施加局部位移/旋转,并输出各绑定节点的局部位置、世界位置和四元数,用于验证子节点是否正确跟随父节点。
## 数据和单位 ## 数据和单位

View File

@@ -10,5 +10,6 @@
8. `MGR-08`:验证 WASM 关节值到部件链的映射、刀具/工件/路径坐标一致性及 AC/BC 五轴程序。 8. `MGR-08`:验证 WASM 关节值到部件链的映射、刀具/工件/路径坐标一致性及 AC/BC 五轴程序。
9. `MGR-09`:浏览器交互、错误边界、类型检查、生产构建、截图和报告收口。 9. `MGR-09`:浏览器交互、错误边界、类型检查、生产构建、截图和报告收口。
10. `MGR-12`:按 GLB 原始 `Object3D.children` 显示树,支持根部件和内部节点拖放,选择节点显示父级局部原点;用 MILL700 六层结构验收。 10. `MGR-12`:按 GLB 原始 `Object3D.children` 显示树,支持根部件和内部节点拖放,选择节点显示父级局部原点;用 MILL700 六层结构验收。
11. `MGR-14`:新增导入模型删除、树节点轴绑定和 XYZ/ABC 层级运动测试,输出局部/世界变换;MILL700 主轴链与工作台链验收。
每轮只推进任务矩阵中依赖已满足的任务。代码完成后必须同步更新 03、04、05;若改变数据契约或坐标定义,先更新 06。 每轮只推进任务矩阵中依赖已满足的任务。代码完成后必须同步更新 03、04、05;若改变数据契约或坐标定义,先更新 06。

View File

@@ -34,6 +34,15 @@
- 发布 `render-state`、实际 pixel ratio、buffer size 和 GPU 上限诊断;context lost 后停止动画循环,避免重复 framebuffer 错误刷屏。 - 发布 `render-state`、实际 pixel ratio、buffer size 和 GPU 上限诊断;context lost 后停止动画循环,避免重复 framebuffer 错误刷屏。
- 模拟页面 `1154x220`、DPR 3:CSS `656x655`、drawing buffer `984x982`、GPU 上限 `8192`,控制台警告/错误 0。MILL700 层级树及 AC/BC 生产回归通过。MGR-13 verified。 - 模拟页面 `1154x220`、DPR 3:CSS `656x655`、drawing buffer `984x982`、GPU 上限 `8192`,控制台警告/错误 0。MILL700 层级树及 AC/BC 生产回归通过。MGR-13 verified。
## R07 / 2026-09-20 / 删除与层级运动验证
- 新增“删除导入模型”,删除后释放源场景资源、移除装配引用、树节点和预览。
- 任意 GLB 根/内部树节点可绑定 base、X、Y、Z、tool、A/B、C、work 角色;角色徽标显示在树节点上。同一角色唯一绑定。
- 新增层级运动测试输入:XYZ 为 mm,A/B/C 为 deg。预览恢复节点导入基准后施加输入,发布测试姿态及每个角色的 local/world position、local/world quaternion。
- MILL700 实测:主轴 JT1/JT2/JT3 绑定 XYZ,输入 10/20/30 mm 后局部坐标分别增加 0.01/0.02/0.03 m;工作台 JT1/JT2 绑定 A/C,输入 15°/45° 后局部旋转严格变化 15°/45°,C 世界坐标随 A 父级变化。
- 删除 MILL700 后 part-count=0、树节点=0。整个运动测试期间 WebGL context generation 始终为 1,没有因输入或树操作重建 renderer。
- 生产 8 部件 AC/BC 应用、导出和程序运动回归通过,warning/error=0。MGR-14 verified。
## R02 / 2026-09-20 / 部件装配、标定、应用与验收完成 ## R02 / 2026-09-20 / 部件装配、标定、应用与验收完成
- 新增 `machine-manager.ts`:通用自包含 GLB 部件加载、SHA-256、深复制、八角色和两条机械链校验、AC/BC URDF 生成、组合场景、底座/整机 userData、组合哈希。 - 新增 `machine-manager.ts`:通用自包含 GLB 部件加载、SHA-256、深复制、八角色和两条机械链校验、AC/BC URDF 生成、组合场景、底座/整机 userData、组合哈希。

View File

@@ -15,3 +15,4 @@
|MGR-11 Z-up 机床坐标|verified|MGR-04,MGR-05|`camera.up=(0,0,1)`,机床 Z 竖直、XY 地面;导入模型信息和原始变换不修改| |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 深层结构验收通过| |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-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 父子联动验收|

View File

@@ -16,7 +16,7 @@
- 状态:verified;浏览器错误 0。 - 状态:verified;浏览器错误 0。
- 覆盖:8 部件导入和预览、原点显隐、数值/TransformControls 变换、组合 GLB 导出、AC/BC 应用、底座 userData、物理零点、非法层级拒绝、BC 五轴程序。 - 覆盖:8 部件导入和预览、原点显隐、数值/TransformControls 变换、组合 GLB 导出、AC/BC 应用、底座 userData、物理零点、非法层级拒绝、BC 五轴程序。
- 截图: - 截图:
- `coverage/glb-machine-manager/manager-ac-config.png`,SHA-256 `6e53968722744d8fc4dc753d83ee8bad3c6c3ce0d41c34d64214b5586faf2dc5` - `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/managed-bc-running.png`,SHA-256 `2a28290ff58b3a0d3250ee5ebb1bc636a2cc8b8f62828b41764435501f97d385`
## E03 / R02 / 生产构建浏览器闭环 ## E03 / R02 / 生产构建浏览器闭环
@@ -43,10 +43,10 @@
- 状态:verified。 - 状态:verified。
- 命令:`PLAYWRIGHT_MODULE=/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs node scripts/test_machine_manager_mill700_browser.mjs` - 命令:`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`。 - 模型:`MILL700-U_1.glb`,9,418,852 bytes,SHA-256 `bf37130681cc6462412e52744519516e8ade2a3212dd69153c0b1b3e415bcf66`。
- report_id:`GLB-MGR-E05`;报告 SHA-256 `7c479b36396abc6f7466ed8b54cc83216dd72c30ba11fa0c667e6491d47c2171`。 - report_id:`GLB-MGR-E05`;报告 SHA-256 `11327a756a019ff933759fdf95da5a0e461dcf6987d0fa030d777fe82c96c807`。
- 结构:132 节点、126 网格、深度 6、126 材质,其中 110 个 `metalness >= 0.9`。 - 结构:132 节点、126 网格、深度 6、126 材质,其中 110 个 `metalness >= 0.9`。
- 检查:已有内部层级完整保留;管理器使用 `studio-pmrem`、sRGB 输出、ACES、曝光 `1.45` 和多向补光;浏览器错误为 0。 - 检查:已有内部层级完整保留;管理器使用 `studio-pmrem`、sRGB 输出、ACES、曝光 `1.45` 和多向补光;浏览器错误为 0。
- 截图:`coverage/glb-machine-manager/mill700/mill700-studio-lighting.png`,SHA-256 `289255511eccce697ec0c6f002a44588475e9042fa12f23646ea02c4c63a3b60`。 - 截图:`coverage/glb-machine-manager/mill700/mill700-studio-lighting.png`,SHA-256 `ad1e2a36a5db765a13ba3ce633c9cc0e146867c87397cd284988d2496d950344`。
## E06 / R04 / Z-up 坐标 ## E06 / R04 / Z-up 坐标
@@ -70,3 +70,12 @@
- 修复后:管理器 CSS `656x655`,drawing buffer `984x982`,`MAX_RENDERBUFFER_SIZE=8192`,MSAA=false,控制台 warning/error=0。 - 修复后:管理器 CSS `656x655`,drawing buffer `984x982`,`MAX_RENDERBUFFER_SIZE=8192`,MSAA=false,控制台 warning/error=0。
- renderer 诊断字段:`machine-manager-webgl-antialias=false`、`render-state=ready`、动态 `pixel-ratio/buffer-size/max-buffer`。 - renderer 诊断字段:`machine-manager-webgl-antialias=false`、`render-state=ready`、动态 `pixel-ratio/buffer-size/max-buffer`。
- MILL700 132 节点树选择/拖放通过;生产 AC/BC 管理器闭环通过,浏览器错误 0。 - MILL700 132 节点树选择/拖放通过;生产 AC/BC 管理器闭环通过,浏览器错误 0。
## E09 / R07 / MILL700 层级运动验证
- 状态:verified。
- 绑定:`主轴-JT1→X`、`主轴-JT2→Y`、`主轴-JT3→Z`、`工作台-JT1→A`、`工作台-JT2→C`。
- 输入:X=10 mm、Y=20 mm、Z=30 mm、A=15°、C=45°。
- 结果: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`。

View File

@@ -43,3 +43,7 @@ GLB 树节点 ID 使用导入时的子节点索引路径,不使用可能重复
## D11 / 管理器优先保证 framebuffer 可创建 ## D11 / 管理器优先保证 framebuffer 可创建
管理器不依赖 MSAA;高 DPI 清晰度由受限 DPR 提供。drawing buffer 必须同时满足 GPU 维度上限和 4096² 像素预算,不能把异常 DOM 尺寸直接传给 WebGL。零尺寸和 context lost 视为暂停状态,不继续调用 render 产生重复错误。 管理器不依赖 MSAA;高 DPI 清晰度由受限 DPR 提供。drawing buffer 必须同时满足 GPU 维度上限和 4096² 像素预算,不能把异常 DOM 尺寸直接传给 WebGL。零尺寸和 context lost 视为暂停状态,不继续调用 render 产生重复错误。
## D12 / 轴值驱动是层级诊断
管理器的 XYZ/ABC 输入只驱动树中显式绑定的草稿节点,用于验证父子层级和轴向;它不发送 LinuxCNC 运动命令,也不替代 Worker/WASM。正式加工仍由控制面板程序、TP 和运动学驱动。诊断输出同时保留 local/world 变换以区分节点自身运动和父级继承运动。

View File

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

View File

@@ -449,7 +449,7 @@ history rows intentionally share the family ID `mdi-history-select`.
| `workspace-case-select` | Web selection of the INI/configuration input | Local selection; selected catalog is restricted to supported XYZAC/XYZBC cases | Does not load or change machine state until `toolbar-load-case` | | `workspace-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-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 | | `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-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 | | `machine-manager-settings`, `machine-manager-import`, `machine-manager-files`, `machine-manager-delete`, `machine-manager-translate`, `machine-manager-rotate`, `machine-manager-role`, `machine-manager-parent`, `machine-manager-show-origin`, `machine-tree-node`, `machine-tree-origin`, `machine-tree-axis-role`, `machine-test-x`, `machine-test-y`, `machine-test-z`, `machine-test-a`, `machine-test-b`, `machine-test-c`, `machine-test-reset`, `machine-test-state`, `machine-manager-apply`, `machine-manager-export` | Web component-level machine assembly extension | GLB `Object3D.children` render as a tree; selecting a node shows its parent-relative origin, dragging records parent overrides, and axis-test inputs drive bound nodes through their actual parent hierarchy without changing the source scene. Imported roots can be deleted. Apply validates AC/BC and then uses `LOAD_WORKSPACE` + `PROGRAM_LOAD` while OFF and IDLE | Axis-test motion is a local hierarchy diagnostic; production task 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. | | `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 | | `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. | | `tab-manual`, `tab-mdi` | `ensure_manual` / `ensure_mdi` | `SET_MODE`; manual_ok and motion exclusion | Task mode acknowledgement and WASM motion mode are monitored separately; no local tab is mode authority. |

View File

@@ -13,7 +13,7 @@ const [contract, app, panel, preview] = await Promise.all([
const controlIds = [ const controlIds = [
"menu-file", "menu-file-edit-tool-data", "menu-file-quit", "menu-help", "menu-help-about", "about-close", "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", "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-tree-node", "machine-tree-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-delete", "machine-manager-translate", "machine-manager-rotate", "machine-manager-role", "machine-manager-parent", "machine-manager-show-origin", "machine-tree-node", "machine-tree-origin", "machine-tree-axis-role", "machine-test-x", "machine-test-y", "machine-test-z", "machine-test-a", "machine-test-b", "machine-test-c", "machine-test-reset", "machine-test-state", "machine-manager-apply", "machine-manager-export", "manual-home-all", "manual-home-joint-select",
"switchkins-0", "switchkins-1", "switchkins-2", "switchkins-0", "switchkins-1", "switchkins-2",
"tab-manual", "tab-mdi", "tab-diagnostics", "tab-preview", "tab-dro", "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", "manual-axis-x", "manual-axis-y", "manual-axis-z", "manual-axis-a", "manual-axis-c",

View File

@@ -30,7 +30,13 @@ try {
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"); 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"); 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); 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);
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");
report.checks.push("metallic MILL700 surfaces rendered with studio PMREM lighting; screenshot captured for visual review"); assert.deepEqual(errors, []); report.status = "verified"; 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; } } 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(); } 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)); console.log(JSON.stringify(report));

View File

@@ -6,25 +6,27 @@ import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js"; import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
import { DraggableDialog } from "./DraggableDialog"; import { DraggableDialog } from "./DraggableDialog";
import { disposeMachineScene, type ImportedMachine } from "./imported-machine"; import { disposeMachineScene, type ImportedMachine } from "./imported-machine";
import { applyMachineNodeParents, buildManagedMachine, expectedParentRole, loadMachinePart, machinePartTree, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type MachineTreeNode, type PhysicalZero } from "./machine-manager"; import { applyMachineNodeParents, buildManagedMachine, expectedParentRole, loadMachinePart, machinePartTree, machineSceneNode, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type MachineTreeNode, type PhysicalZero } from "./machine-manager";
import type { Vec3 } from "./machine-urdf"; import type { Vec3 } from "./machine-urdf";
type Props = { coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise<void> }; type Props = { coordinates: "XYZAC" | "XYZBC"; disabled: boolean; onApply: (machine: ImportedMachine) => Promise<void> };
type TestPose = { x: number; y: number; z: number; a: number; b: number; c: number };
const zero3 = (): Vec3 => [0, 0, 0]; const zero3 = (): Vec3 => [0, 0, 0];
const number = (value: string) => Number.isFinite(Number(value)) ? Number(value) : 0; const number = (value: string) => Number.isFinite(Number(value)) ? Number(value) : 0;
function ManagerPreview({ parts, selectedId, mode, onTransform }: { parts: MachinePartDraft[]; selectedId: string | null; mode: "translate" | "rotate"; onTransform: (id: string, translationMm: Vec3, rotationDeg: Vec3) => void }) { 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") => void }; type PreviewHost = HTMLDivElement & { machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, mode: "translate" | "rotate", testPose: TestPose, coordinates: "XYZAC" | "XYZBC") => void };
const host = useRef<PreviewHost>(null); const host = useRef<PreviewHost>(null);
const stateRef = useRef({ parts, selectedId, mode }); stateRef.current = { parts, selectedId, mode }; const stateRef = useRef({ parts, selectedId, mode, testPose, coordinates }); stateRef.current = { parts, selectedId, mode, testPose, coordinates };
const onTransformRef = useRef(onTransform); onTransformRef.current = onTransform; const onTransformRef = useRef(onTransform); onTransformRef.current = onTransform;
useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, mode); }, [parts, selectedId, mode]); const [fault, setFault] = useState("");
useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, mode, testPose, coordinates); }, [parts, selectedId, mode, testPose, coordinates]);
useEffect(() => { useEffect(() => {
const element = host.current; if (!element) return; const element = host.current; if (!element) return;
// Large industrial GLBs can exceed small-GPU multisample renderbuffer // Large industrial GLBs can exceed small-GPU multisample renderbuffer
// limits when DevTools or display scaling changes the drawing-buffer size. // limits when DevTools or display scaling changes the drawing-buffer size.
const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false, depth: true, stencil: false, powerPreference: "high-performance" }); const renderer = new THREE.WebGLRenderer({ antialias: false, alpha: false, depth: true, stencil: false, powerPreference: "high-performance" });
renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.45; renderer.shadowMap.enabled = true; renderer.shadowMap.type = THREE.PCFShadowMap; element.appendChild(renderer.domElement); renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.45; renderer.shadowMap.enabled = false; element.appendChild(renderer.domElement);
const gl = renderer.getContext(), viewportLimit = gl.getParameter(gl.MAX_VIEWPORT_DIMS) as Int32Array; const gl = renderer.getContext(), viewportLimit = gl.getParameter(gl.MAX_VIEWPORT_DIMS) as Int32Array;
const maxDimension = Math.max(1, Math.floor(Math.min(Number(gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 4096), Number(gl.getParameter(gl.MAX_TEXTURE_SIZE) || 4096), Number(viewportLimit?.[0] || 4096), Number(viewportLimit?.[1] || 4096)) * .9)); const maxDimension = Math.max(1, Math.floor(Math.min(Number(gl.getParameter(gl.MAX_RENDERBUFFER_SIZE) || 4096), Number(gl.getParameter(gl.MAX_TEXTURE_SIZE) || 4096), Number(viewportLimit?.[0] || 4096), Number(viewportLimit?.[1] || 4096)) * .9));
const maxPixels = Math.min(maxDimension * maxDimension, 4096 * 4096); const maxPixels = Math.min(maxDimension * maxDimension, 4096 * 4096);
@@ -39,23 +41,26 @@ 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 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 orbit = new OrbitControls(camera, renderer.domElement); orbit.enableDamping = true;
const root = new THREE.Group(); root.name = "machine-manager-preview"; scene.add(root); const root = new THREE.Group(); root.name = "machine-manager-preview"; scene.add(root);
let nodes = new Map<string, THREE.Group>(), helpers: THREE.AxesHelper[] = [], selectedObject: THREE.Group | null = null, selectedPartId: string | null = null, lastParts: MachinePartDraft[] | null = null, fitSignature = ""; type RoleNode = { object: THREE.Object3D; position: THREE.Vector3; quaternion: THREE.Quaternion };
let nodes = new Map<string, THREE.Group>(), roleNodes = new Map<MachinePartRole, RoleNode>(), helpers: THREE.AxesHelper[] = [], selectedObject: THREE.Group | null = null, selectedPartId: string | null = null, lastParts: MachinePartDraft[] | null = null, fitSignature = "", dirty = true;
const transform = new TransformControls(camera, renderer.domElement); transform.setMode(mode); transform.setSpace("local"); scene.add(transform.getHelper()); 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("dragging-changed", ((event: { value?: boolean }) => { orbit.enabled = !event.value; }) as never);
transform.addEventListener("objectChange", (() => { transform.addEventListener("objectChange", (() => {
if (!selectedPartId || !selectedObject) return; 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)]); 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); }) as never);
element.machineManagerSync = (nextParts, nextSelectedId, nextMode) => { element.machineManagerSync = (nextParts, nextSelectedId, nextMode, nextPose, nextCoordinates) => {
transform.setMode(nextMode); transform.setMode(nextMode);
if (nextParts !== lastParts) { if (nextParts !== lastParts) {
transform.detach(); root.clear(); helpers.forEach((helper) => helper.dispose()); helpers = []; nodes = new Map(); transform.detach(); root.clear(); helpers.forEach((helper) => helper.dispose()); helpers = []; nodes = new Map(); roleNodes = new Map();
for (const part of nextParts) { 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); 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 // Clone transforms/hierarchy only. Geometry, materials, textures and
// source-node transforms remain untouched on the imported GLB. // 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 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 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 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() });
} }
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 = nextParts.find((part) => part.id === cursor)?.parentId ?? null; } return false; }; 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 = nextParts.find((part) => part.id === cursor)?.parentId ?? null; } return false; };
for (const part of nextParts) if (part.parentId && nodes.has(part.parentId) && !cycle(part.id, part.parentId)) nodes.get(part.parentId)!.add(nodes.get(part.id)!); for (const part of nextParts) if (part.parentId && nodes.has(part.parentId) && !cycle(part.id, part.parentId)) nodes.get(part.parentId)!.add(nodes.get(part.id)!);
@@ -67,9 +72,13 @@ function ManagerPreview({ parts, selectedId, mode, onTransform }: { parts: Machi
lastParts = nextParts; lastParts = nextParts;
} }
selectedPartId = nextSelectedId; selectedObject = nextSelectedId ? nodes.get(nextSelectedId) ?? null : null; transform.detach(); if (selectedObject) transform.attach(selectedObject); selectedPartId = nextSelectedId; selectedObject = nextSelectedId ? nodes.get(nextSelectedId) ?? null : null; transform.detach(); if (selectedObject) transform.attach(selectedObject);
element.dataset.machineManagerContextGeneration = "1"; 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 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;
}; };
element.machineManagerSync(stateRef.current.parts, stateRef.current.selectedId, stateRef.current.mode); element.machineManagerSync(stateRef.current.parts, stateRef.current.selectedId, stateRef.current.mode, stateRef.current.testPose, stateRef.current.coordinates);
let renderable = false, contextLost = false; let renderable = false, contextLost = false;
const resize = () => { const resize = () => {
const width = element.clientWidth, height = element.clientHeight; const width = element.clientWidth, height = element.clientHeight;
@@ -81,21 +90,23 @@ function ManagerPreview({ parts, selectedId, mode, onTransform }: { parts: Machi
renderable = renderer.domElement.width > 0 && renderer.domElement.height > 0 && renderer.domElement.width <= maxDimension && renderer.domElement.height <= maxDimension; 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";
}; };
const contextLostHandler = (event: Event) => { event.preventDefault(); contextLost = true; renderable = false; element.dataset.machineManagerRenderState = "context-lost"; }; const contextLostHandler = (event: Event) => { event.preventDefault(); contextLost = true; renderable = false; element.dataset.machineManagerRenderState = "context-lost"; setFault("WebGL 上下文已丢失,请关闭并重新打开三维机床管理器"); };
renderer.domElement.addEventListener("webglcontextlost", contextLostHandler); 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(); let frame = 0;
const render = () => { if (!contextLost && renderable) { orbit.update(); renderer.render(scene, camera); element.dataset.machineManagerRendered = "true"; } frame = contextLost ? 0 : requestAnimationFrame(render); }; render(); 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(); }; 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(); };
}, []); }, []);
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 轴朝上" />; return <div className="machine-manager-preview-shell"><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 轴朝上" />{fault && <output className="machine-manager-webgl-fault" role="alert">{fault}</output>}</div>;
} }
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 }) { 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 key = `${part.id}:${node.path}`;
const [expanded, setExpanded] = useState(node.path === "scene"); const [expanded, setExpanded] = useState(node.path === "scene");
const hasChildren = node.children.length > 0 || Boolean(extra); const hasChildren = node.children.length > 0 || Boolean(extra);
const binding = node.path === "scene" ? part.role : part.nodeRoles?.[node.path];
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"}> 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> <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>{binding && <em>{binding.toUpperCase()}</em>}<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>} {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>; </li>;
} }
@@ -106,6 +117,7 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedNodePath, setSelectedNodePath] = useState("scene"); 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 [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); const [physicalZero, setPhysicalZero] = useState<PhysicalZero>({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); const file = useRef<HTMLInputElement>(null);
const [testPose, setTestPose] = useState<TestPose>({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 });
useEffect(() => () => { for (const part of partsRef.current) disposeMachineScene(part.scene); }, []); 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]); 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 selected = parts.find((part) => part.id === selectedId) ?? null;
@@ -124,7 +136,7 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
const role = MACHINE_PART_ROLES.find((candidate) => !next.some((part) => part.role === candidate)) ?? ""; const role = MACHINE_PART_ROLES.find((candidate) => !next.some((part) => part.role === candidate)) ?? "";
const parentRole = role ? expectedParentRole(role) : null; const parentRole = role ? expectedParentRole(role) : null;
const parentId = parentRole ? next.find((part) => part.role === parentRole)?.id ?? null : null; const parentId = parentRole ? next.find((part) => part.role === parentRole)?.id ?? null : null;
next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {} }); next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {}, nodeRoles: {} });
} }
return next; return next;
}); });
@@ -169,18 +181,33 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
const parentRole = role ? expectedParentRole(role) : null; const parentRole = role ? expectedParentRole(role) : null;
update(part.id, { role, parentId: parentRole ? parts.find((item) => item.role === parentRole)?.id ?? null : null }); update(part.id, { role, parentId: parentRole ? parts.find((item) => item.role === parentRole)?.id ?? null : null });
}; };
const selectedBinding = selected ? selectedNodePath === "scene" ? selected.role : selected.nodeRoles?.[selectedNodePath] ?? "" : "";
const assignTreeRole = (role: MachinePartRole | "") => {
if (!selected) return;
setParts((current) => current.map((part) => {
const nodeRoles = Object.fromEntries(Object.entries(part.nodeRoles ?? {}).filter(([, value]) => !role || value !== role));
let partRole = role && part.role === role ? "" : part.role;
if (part.id === selected.id) {
if (selectedNodePath === "scene") partRole = role;
else { delete nodeRoles[selectedNodePath]; if (role) nodeRoles[selectedNodePath] = role; }
}
return { ...part, role: partRole, nodeRoles } as MachinePartDraft;
}));
};
const poseInput = (axis: keyof TestPose, unit: string) => <label key={axis}>{axis.toUpperCase()}<input data-axis-control={`machine-test-${axis}`} type="number" step="any" value={testPose[axis]} onChange={(event) => setTestPose({ ...testPose, [axis]: number(event.target.value) })} /><small>{unit}</small></label>;
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)} />; 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="三维机床管理"> return <section className="machine-manager" aria-label="三维机床管理">
<button type="button" data-axis-control="machine-manager-settings" aria-haspopup="dialog" onClick={() => setOpen(true)}>三维机床管理</button> <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)}> <DraggableDialog open={open} title={`三维机床管理 · ${coordinates}`} className="machine-manager-dialog" onClose={() => setOpen(false)}>
<div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-manager-import" disabled={pending} onClick={() => file.current?.click()}>导入 GLB 部件</button><button type="button" data-axis-control="machine-manager-translate" aria-pressed={mode === "translate"} onClick={() => setMode("translate")}>平移部件</button><button type="button" data-axis-control="machine-manager-rotate" aria-pressed={mode === "rotate"} onClick={() => setMode("rotate")}>旋转部件</button><span>{parts.length}/8 部件</span></div> <div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-manager-import" disabled={pending} onClick={() => file.current?.click()}>导入 GLB 部件</button><button type="button" data-axis-control="machine-manager-translate" aria-pressed={mode === "translate"} onClick={() => setMode("translate")}>平移部件</button><button type="button" data-axis-control="machine-manager-rotate" aria-pressed={mode === "rotate"} onClick={() => setMode("rotate")}>旋转部件</button><span>{parts.length}/8 部件</span></div>
<div className="machine-manager-layout"> <div className="machine-manager-layout">
<MachineManagerPanelPreview parts={parts} selectedId={selectedId} mode={mode} onTransform={(id, translationMm, rotationDeg) => update(id, { translationMm, rotationDeg })} /> {open && <MachineManagerPanelPreview coordinates={coordinates} parts={parts} selectedId={selectedId} mode={mode} testPose={testPose} onTransform={(id, translationMm, rotationDeg) => update(id, { translationMm, rotationDeg })} />}
<div className="machine-manager-editor"> <div className="machine-manager-editor">
<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> <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>} {selected && <section className="machine-part-editor"><header><h3>{selected.filename}</h3><button type="button" data-axis-control="machine-manager-delete" onClick={() => remove(selected)}>删除导入模型</button></header>{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-tree-axis-role" value={selectedBinding} onChange={(event) => assignTreeRole(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-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>
</div> </div>
<section className="machine-motion-test"><header><h3>层级运动测试</h3><button type="button" data-axis-control="machine-test-reset" onClick={() => setTestPose({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 })}>复位</button></header><div>{(["x", "y", "z"] as const).map((axis) => poseInput(axis, "mm"))}{poseInput(coordinates === "XYZBC" ? "b" : "a", "deg")}{poseInput("c", "deg")}</div><output data-axis-control="machine-test-state">{JSON.stringify(testPose)}</output></section>
<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> <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>
<footer className="machine-manager-footer"><button type="button" data-axis-control="machine-manager-apply" disabled={disabled || pending || parts.length !== 8} title={disabled ? "关机且程序空闲后可应用" : undefined} onClick={() => void apply()}>应用到 Web LinuxCNC</button><button type="button" data-axis-control="machine-manager-export" disabled={pending || parts.length !== 8} onClick={() => void exportGlb()}>导出组合 GLB</button><output role="status">{message || (disabled ? "关机且程序空闲后可配置" : "配置写入运行模型和底座 userData")}</output></footer> <footer className="machine-manager-footer"><button type="button" data-axis-control="machine-manager-apply" disabled={disabled || pending || parts.length !== 8} title={disabled ? "关机且程序空闲后可应用" : undefined} onClick={() => void apply()}>应用到 Web LinuxCNC</button><button type="button" data-axis-control="machine-manager-export" disabled={pending || parts.length !== 8} onClick={() => void exportGlb()}>导出组合 GLB</button><output role="status">{message || (disabled ? "关机且程序空闲后可配置" : "配置写入运行模型和底座 userData")}</output></footer>
<input ref={file} data-axis-control="machine-manager-files" hidden multiple type="file" accept=".glb,model/gltf-binary" onChange={(event) => { const files = event.target.files ? [...event.target.files] : []; event.target.value = ""; void importFiles(files); }} /> <input ref={file} data-axis-control="machine-manager-files" hidden multiple type="file" accept=".glb,model/gltf-binary" onChange={(event) => { const files = event.target.files ? [...event.target.files] : []; event.target.value = ""; void importFiles(files); }} />

View File

@@ -14,6 +14,7 @@ export type MachinePartDraft = MachinePartSource & {
translationMm: Vec3; translationMm: Vec3;
rotationDeg: Vec3; rotationDeg: Vec3;
nodeParents: Record<string, string | null>; nodeParents: Record<string, string | null>;
nodeRoles: Record<string, MachinePartRole | "">;
}; };
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; children: MachineTreeNode[] };
export type PhysicalZero = { x: number; y: number; z: number; a: number; b: number; c: number }; export type PhysicalZero = { x: number; y: number; z: number; a: number; b: number; c: number };
@@ -23,7 +24,7 @@ export type ManagedMachineGeometry = {
cOriginMm: Vec3; cOriginMm: Vec3;
tiltOffsetMm: Vec3; tiltOffsetMm: Vec3;
physicalZero: PhysicalZero; physicalZero: PhysicalZero;
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> }>; 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>; nodeRoles: Record<string, MachinePartRole | ""> }>;
}; };
export const MACHINE_PART_ROLES: MachinePartRole[] = ["base", "x", "y", "z", "tool", "tilt", "c", "work"]; export const MACHINE_PART_ROLES: MachinePartRole[] = ["base", "x", "y", "z", "tool", "tilt", "c", "work"];
@@ -145,7 +146,7 @@ export async function buildManagedMachine(input: { coordinates: "XYZAC" | "XYZBC
const structure: MachineStructure = parseMachineUrdf(urdf); const structure: MachineStructure = parseMachineUrdf(urdf);
const metadata: ManagedMachineGeometry = { const metadata: ManagedMachineGeometry = {
schema: "linuxcnc.machine-manager/1", coordinates: input.coordinates, cOriginMm: [...input.cOriginMm], tiltOffsetMm: [...input.tiltOffsetMm], physicalZero: { ...input.physicalZero }, 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, nodeParents: { ...(part.nodeParents ?? {}) } }; }), 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 ?? {}) }, nodeRoles: { ...(part.nodeRoles ?? {}) } }; }),
}; };
const scene = new THREE.Group(); scene.name = `Managed_${input.coordinates}`; const scene = new THREE.Group(); scene.name = `Managed_${input.coordinates}`;
scene.userData.linuxcncMachine = { schema: "linuxcnc.machine/1", urdf }; scene.userData.linuxcncMachine = { schema: "linuxcnc.machine/1", urdf };

View File

@@ -362,8 +362,10 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.machine-manager-toolbar { margin-bottom: 10px; } .machine-manager-toolbar { margin-bottom: 10px; }
.machine-manager-toolbar button[aria-pressed="true"] { background: #27585d; color: #fff; } .machine-manager-toolbar button[aria-pressed="true"] { background: #27585d; color: #fff; }
.machine-manager-layout { display: grid; grid-template-columns: minmax(380px, 1.35fr) minmax(330px, .9fr); gap: 12px; min-height: 430px; } .machine-manager-layout { display: grid; grid-template-columns: minmax(380px, 1.35fr) minmax(330px, .9fr); gap: 12px; min-height: 430px; }
.machine-manager-preview { min-width: 0; min-height: 430px; border: 1px solid #52666a; background: #263438; overflow: hidden; } .machine-manager-preview-shell { position: relative; min-width: 0; min-height: 430px; }
.machine-manager-preview { width: 100%; height: 100%; min-width: 0; min-height: 430px; border: 1px solid #52666a; background: #263438; overflow: hidden; }
.machine-manager-preview canvas { display: block; width: 100%; height: 100%; } .machine-manager-preview canvas { display: block; width: 100%; height: 100%; }
.machine-manager-webgl-fault { position: absolute; inset: 12px; display: grid; place-items: center; padding: 16px; background: #fff0e8ed; color: #7b231b; border: 1px solid #a94a3e; text-align: center; }
.machine-manager-editor { display: grid; grid-template-rows: minmax(120px, .7fr) minmax(260px, 1.3fr); min-height: 0; gap: 8px; } .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, .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 h3, .machine-part-editor h3, .machine-manager-calibration h3 { margin: 0 0 7px; font-size: 13px; }
@@ -373,18 +375,21 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.machine-tree-row { display: grid; grid-template-columns: 22px minmax(0, 1fr); align-items: center; } .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 { width: 22px; height: 25px; padding: 0; border: 0; background: transparent; color: #315d61; cursor: pointer; }
.machine-tree-toggle:disabled { color: #829092; cursor: default; } .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"] { display: grid; grid-template-columns: minmax(110px, 1fr) auto 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"]: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"].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"]: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"] > 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 [data-axis-control="machine-tree-node"] small { color: #66787b; font: 10px ui-monospace, monospace; white-space: nowrap; }
.machine-tree [data-axis-control="machine-tree-node"] em { padding: 1px 4px; background: #2c6669; color: #fff; font-size: 9px; font-style: normal; }
.machine-tree .mesh { color: #397a80; } .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 { 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 strong { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.machine-tree-origin span { color: #536467; } .machine-tree-origin span { color: #536467; }
.machine-tree-origin output { grid-column: 1 / -1; text-align: left; } .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 > label { display: grid; grid-template-columns: 82px minmax(0, 1fr); align-items: center; gap: 8px; margin: 6px 0; }
.machine-part-editor > header, .machine-motion-test > header { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.machine-part-editor > header button { color: #8b2d24; }
.machine-part-editor select { height: 30px; min-width: 0; } .machine-part-editor select { height: 30px; min-width: 0; }
.machine-part-flags { display: flex; gap: 16px; margin: 8px 0; } .machine-part-flags { display: flex; gap: 16px; margin: 8px 0; }
.machine-vector { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; padding: 6px; } .machine-vector { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; padding: 6px; }
@@ -396,8 +401,16 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.machine-manager-footer { margin-top: 10px; } .machine-manager-footer { margin-top: 10px; }
.machine-manager-footer button { min-height: 32px; } .machine-manager-footer button { min-height: 32px; }
.machine-manager-footer output { flex: 1 1 320px; text-align: left; } .machine-manager-footer output { flex: 1 1 320px; text-align: left; }
.machine-motion-test { margin-top: 10px; padding: 8px; border: 1px solid #779092; background: #eaf2f1; }
.machine-motion-test h3 { margin: 0; font-size: 13px; }
.machine-motion-test > div { display: grid; grid-template-columns: repeat(5, minmax(88px, 1fr)); gap: 8px; margin-top: 8px; }
.machine-motion-test label { display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; align-items: center; gap: 4px; }
.machine-motion-test input { width: 100%; min-width: 0; height: 28px; }
.machine-motion-test small { color: #536467; }
.machine-motion-test output { display: block; margin-top: 6px; text-align: left; }
@media (max-width: 850px) { @media (max-width: 850px) {
.machine-manager-layout { grid-template-columns: 1fr; } .machine-manager-layout { grid-template-columns: 1fr; }
.machine-manager-preview { min-height: 360px; } .machine-manager-preview { min-height: 360px; }
.machine-manager-calibration { grid-template-columns: 1fr; } .machine-manager-calibration { grid-template-columns: 1fr; }
.machine-motion-test > div { grid-template-columns: repeat(2, minmax(0, 1fr)); }
} }