Compare commits
19 Commits
63f726dc32
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| af65b69b2c | |||
| 469881794c | |||
| ae525d6b93 | |||
| 23d07e72d1 | |||
| b15af28034 | |||
| d4d0088daf | |||
| 3cb5950d1f | |||
| 8443930edb | |||
| 1eb9840941 | |||
| 0de96bbc72 | |||
| 86cfa2b7f0 | |||
| 338500dfc2 | |||
| 8f1a22485d | |||
| a7233bc8e4 | |||
| a2c6dd1c01 | |||
| d8488641ca | |||
| 989866b3ab | |||
| 91ebd56d6d | |||
| a472911ad1 |
@@ -11,6 +11,11 @@
|
||||
五轴机床 INI 参数化设计(XYZAC / XYZBC)的当前工作入口:
|
||||
[`项目文档、任务矩阵与验收证据`](docs/trt-parametric/README.md)。本专项按该索引接续。
|
||||
|
||||
LinuxCNC 五轴/多自由度机床结构案例、运动学源码、示例 G 代码和 Web/WASM 适配状态:
|
||||
[`LinuxCNC 五轴机床案例目录`](docs/linuxcnc-five-axis-case-catalog.md)。界面使用同一份
|
||||
[`five-axis-machine-catalog.json`](web/workspace/five-axis-machine-catalog.json);本地案例路径可用
|
||||
`node scripts/validate_five_axis_catalog.mjs` 复核。
|
||||
|
||||
本仓库的目标是将 LinuxCNC `2.10.0~pre0` 的仿真行为移植到浏览器。当前已完成 posemath/TP、rs274ngc interpreter 和 fixed-tick motion queue 核心切片;这仍不是 LinuxCNC 全功能完成声明。
|
||||
|
||||
完整仿真功能范围、任务分解和 M0-M8 里程碑见 [`完整仿真移植实施计划`](docs/linuxcnc-web-wasm-complete-simulation-plan.md);技术实施接续入口为 [`nextTask1.md`](nextTask1.md)。
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
"mode": "production-preview",
|
||||
"status": "verified",
|
||||
"checks": [
|
||||
"eight GLB parts rendered with studio PMREM lighting and calibrated exposure",
|
||||
"dragging GLB root tree nodes updates component parent hierarchy",
|
||||
"origin visibility, numeric translation/rotation and TransformControls mode update",
|
||||
"eight GLB parts rendered with studio PMREM lighting",
|
||||
"eight-part roots have unique single DOFs; fixed base cannot move; numeric input drives only bound X",
|
||||
"validated assembly exports a self-contained managed GLB",
|
||||
"validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData",
|
||||
"same component assembly switches to BC hierarchy and applies BC geometry",
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
"mode": "development-source",
|
||||
"status": "verified",
|
||||
"checks": [
|
||||
"eight GLB parts rendered with studio PMREM lighting and calibrated exposure",
|
||||
"dragging GLB root tree nodes updates component parent hierarchy",
|
||||
"origin visibility, numeric translation/rotation and TransformControls mode update",
|
||||
"eight GLB parts rendered with studio PMREM lighting",
|
||||
"eight-part roots have unique single DOFs; fixed base cannot move; numeric input drives only bound X",
|
||||
"validated assembly exports a self-contained managed GLB",
|
||||
"validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData",
|
||||
"same component assembly switches to BC hierarchy and applies BC geometry",
|
||||
|
||||
15
coverage/glb-machine-manager/document/browser.report.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E17",
|
||||
"status": "verified",
|
||||
"url": "http://127.0.0.1:5180/",
|
||||
"checks": [
|
||||
"named GLB downloads and atomic browser-library copy with time/size/checksum; editor display changes mark document dirty",
|
||||
"reload and local-library open restore eight source files, exact edited hierarchy, axis/sign, visibility, origins and calibration; second save has identical metadata",
|
||||
"explicit open-file entry restores document; library redownload is byte identical; delete affects only chosen saved copy",
|
||||
"inconsistent editor metadata is rejected atomically without replacing the current draft",
|
||||
"saved GLB loads through control-panel importer and can be sent back into editor with eight original parts",
|
||||
"legacy GLB remains editable via single-assembly fallback",
|
||||
"local storage failure does not prevent GLB download and is visibly reported"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
15
coverage/glb-machine-manager/document/cloud.report.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E17",
|
||||
"status": "verified",
|
||||
"url": "https://49.232.250.35:58015/",
|
||||
"checks": [
|
||||
"named GLB downloads and atomic browser-library copy with time/size/checksum; editor display changes mark document dirty",
|
||||
"reload and local-library open restore eight source files, exact edited hierarchy, axis/sign, visibility, origins and calibration; second save has identical metadata",
|
||||
"explicit open-file entry restores document; library redownload is byte identical; delete affects only chosen saved copy",
|
||||
"inconsistent editor metadata is rejected atomically without replacing the current draft",
|
||||
"saved GLB loads through control-panel importer and can be sent back into editor with eight original parts",
|
||||
"legacy GLB remains editable via single-assembly fallback",
|
||||
"local storage failure does not prevent GLB download and is visibly reported"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"schema":"axis-control-inventory.v1","status":"verified","documented_controls":146,"duplicate_command_entries_removed":["machine_menu","view_menu","program_pane_load","tool_probe_commands"],"contract":"linuxcnc-axis-web-control-panel-source-logic.md"}
|
||||
20
coverage/glb-machine-manager/document/deployment.report.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E17-CLOUD",
|
||||
"status": "verified",
|
||||
"checked_at": "2026-09-21T13:19:10.126525+00:00",
|
||||
"url": "https://49.232.250.35:58015/",
|
||||
"source_commit": "8443930edbb91f80234bb6de053b7a3cc76245ae",
|
||||
"release": "/var/www/linuxcnc-wasm-58015/releases/20260921-linuxcnc-wasm-8443930",
|
||||
"bundle": "assets/index-EHYipPfn.js",
|
||||
"archive_sha256": "961e37e9b164ffc01831a5cba583d26767238ffe1da0edb95c00f8936bc0eadc",
|
||||
"checks": [
|
||||
"named GLB downloads and atomic browser-library copy with time/size/checksum; editor display changes mark document dirty",
|
||||
"reload and local-library open restore eight source files, exact edited hierarchy, axis/sign, visibility, origins and calibration; second save has identical metadata",
|
||||
"explicit open-file entry restores document; library redownload is byte identical; delete affects only chosen saved copy",
|
||||
"inconsistent editor metadata is rejected atomically without replacing the current draft",
|
||||
"saved GLB loads through control-panel importer and can be sent back into editor with eight original parts",
|
||||
"legacy GLB remains editable via single-assembly fallback",
|
||||
"local storage failure does not prevent GLB download and is visibly reported"
|
||||
],
|
||||
"browser_errors": []
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"schema": "machine-manager-browser.v1",
|
||||
"report_id": "GLB-MGR-E02",
|
||||
"mode": "production-preview",
|
||||
"status": "verified",
|
||||
"checks": [
|
||||
"eight GLB parts rendered with studio PMREM lighting",
|
||||
"eight-part roots have unique single DOFs; fixed base cannot move; numeric input drives only bound X",
|
||||
"validated assembly exports a self-contained managed GLB",
|
||||
"validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData",
|
||||
"same component assembly switches to BC hierarchy and applies BC geometry",
|
||||
"BC five-axis program drives managed model from authoritative WASM joints",
|
||||
"production bundle exercised through AC/BC UI apply and WASM program motion"
|
||||
],
|
||||
"browser_errors": [],
|
||||
"final": {
|
||||
"program": "running",
|
||||
"machine": "ON",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc"
|
||||
}
|
||||
}
|
||||
BIN
coverage/glb-machine-manager/document/files-and-recovery.png
Normal file
|
After Width: | Height: | Size: 155 KiB |
@@ -0,0 +1 @@
|
||||
{"report_id":"GLB-MGR-E14-HISTORY","status":"verified","checks":["undo/redo","duplicate edits ignored","new edit discards redo","30-entry bound","clear preserves draft","machine type change preserves general axes and clears incompatible tilt","undo restores type and bindings"]}
|
||||
1958
coverage/glb-machine-manager/document/mill700/browser.report.json
Normal file
BIN
coverage/glb-machine-manager/document/mill700/xyzac-live.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
4108
coverage/glb-machine-manager/document/mill700/xyzac.json
Normal file
BIN
coverage/glb-machine-manager/document/mill700/xyzbc-live.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
4108
coverage/glb-machine-manager/document/mill700/xyzbc.json
Normal file
BIN
coverage/glb-machine-manager/document/saved-eight-parts.glb
Normal file
14
coverage/glb-machine-manager/document/workbench.report.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E14",
|
||||
"status": "verified",
|
||||
"url": "http://127.0.0.1:5180/",
|
||||
"checks": [
|
||||
"search expands real nested nodes; axis overview locates selection; binding undo/redo",
|
||||
"empty input preserves last pose; repeated selection/input does not concatenate; single-axis step and reset",
|
||||
"TRT forbidden offset blocks export before build; calibration undo/redo",
|
||||
"view presets and selected fitting; deletion undo restores model with all axis bindings",
|
||||
"failed batch import leaves existing assembly intact",
|
||||
"live mode locks edits; desktop, short viewport and narrow viewport keep footer visible without overflow"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
2816
coverage/glb-machine-manager/joint-dof/browser.report.json
Normal file
@@ -0,0 +1 @@
|
||||
{"schema":"axis-control-inventory.v1","status":"verified","documented_controls":115,"duplicate_command_entries_removed":["machine_menu","view_menu","program_pane_load","tool_probe_commands"],"contract":"linuxcnc-axis-web-control-panel-source-logic.md"}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E13-CLOUD",
|
||||
"status": "verified",
|
||||
"checked_at": "2026-09-21T03:32:29.065851+00:00",
|
||||
"url": "https://49.232.250.35:58015/",
|
||||
"commit": "d8488641ca377fe9c930ea48d42249f90953529d",
|
||||
"release": "/var/www/linuxcnc-wasm-58015/releases/20260921-linuxcnc-wasm-d848864",
|
||||
"archive_sha256": "fcd454879625de8b6ecf83d8e064ca2df472a71ac591abee189b7e20e6ee506b",
|
||||
"bundle": "assets/index-DZs5acdt.js",
|
||||
"cloud_regression": {
|
||||
"schema": "machine-manager-browser.v1",
|
||||
"report_id": "GLB-MGR-E02",
|
||||
"mode": "production-preview",
|
||||
"status": "verified",
|
||||
"checks": [
|
||||
"eight GLB parts rendered with studio PMREM lighting",
|
||||
"eight-part roots have unique single DOFs; fixed base cannot move; numeric input drives only bound X",
|
||||
"validated assembly exports a self-contained managed GLB",
|
||||
"validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData",
|
||||
"same component assembly switches to BC hierarchy and applies BC geometry",
|
||||
"BC five-axis program drives managed model from authoritative WASM joints",
|
||||
"production bundle exercised through AC/BC UI apply and WASM program motion"
|
||||
],
|
||||
"browser_errors": [],
|
||||
"final": {
|
||||
"program": "running",
|
||||
"machine": "ON",
|
||||
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc"
|
||||
}
|
||||
},
|
||||
"mill700_full_validation": "coverage/glb-machine-manager/joint-dof/browser.report.json",
|
||||
"mill700_full_validation_scope": "local production build, same deployed bundle; XYZAC and XYZBC with 8 moving ticks each"
|
||||
}
|
||||
1
coverage/glb-machine-manager/joint-dof/math.report.json
Normal file
@@ -0,0 +1 @@
|
||||
{"report_id":"GLB-MGR-E13-MATH","status":"verified","checks":["parent-frame motion with nonidentity zero orientation","unit/sign/physical-zero conversion","100 updates without drift","child inheritance","rotation about own origin","malformed JSON rejected","missing live axis rejected atomically"]}
|
||||
BIN
coverage/glb-machine-manager/joint-dof/xyzac-live.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
4108
coverage/glb-machine-manager/joint-dof/xyzac.json
Normal file
BIN
coverage/glb-machine-manager/joint-dof/xyzbc-live.png
Normal file
|
After Width: | Height: | Size: 203 KiB |
4108
coverage/glb-machine-manager/joint-dof/xyzbc.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E15",
|
||||
"url": "http://127.0.0.1:5180/",
|
||||
"status": "verified",
|
||||
"checks": [
|
||||
"dialog selects BC while controller stays AC; switching drops incompatible tilt only; undo/redo restores type and bindings; saved GLB contains BC metadata",
|
||||
"controller workspace changes do not change draft type; reopening saved machine restores type, calibration and hierarchy in one undoable transaction",
|
||||
"applying BC design from AC controller changes controller type without altering design draft",
|
||||
"controller import accepts saved BC GLB from AC workspace and automatically switches to BC with physical zero"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
12
coverage/glb-machine-manager/machine-type/cloud.report.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E15",
|
||||
"url": "https://49.232.250.35:58015/",
|
||||
"status": "verified",
|
||||
"checks": [
|
||||
"dialog selects BC while controller stays AC; switching drops incompatible tilt only; undo/redo restores type and bindings; saved GLB contains BC metadata",
|
||||
"controller workspace changes do not change draft type; reopening saved machine restores type, calibration and hierarchy in one undoable transaction",
|
||||
"applying BC design from AC controller changes controller type without altering design draft",
|
||||
"controller import accepts saved BC GLB from AC workspace and automatically switches to BC with physical zero"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
BIN
coverage/glb-machine-manager/machine-type/controller-import.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
@@ -0,0 +1 @@
|
||||
{"schema":"axis-control-inventory.v1","status":"verified","documented_controls":134,"duplicate_command_entries_removed":["machine_menu","view_menu","program_pane_load","tool_probe_commands"],"contract":"linuxcnc-axis-web-control-panel-source-logic.md"}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E15-CLOUD",
|
||||
"status": "verified",
|
||||
"checked_at": "2026-09-21T09:49:43.644683+00:00",
|
||||
"url": "https://49.232.250.35:58015/",
|
||||
"source_commit": "338500dfc298270fcb2adee18b36afd23e32c9e5",
|
||||
"release": "/var/www/linuxcnc-wasm-58015/releases/20260921-linuxcnc-wasm-338500d",
|
||||
"bundle": "assets/index-C8kh60HL.js",
|
||||
"archive_sha256": "dcbcefdbf521359d4c35c09fd11c27aa110f05d7ce242d482fc5bc73b5c6250d",
|
||||
"checks": [
|
||||
"dialog selects BC while controller stays AC; switching drops incompatible tilt only; undo/redo restores type and bindings; saved GLB contains BC metadata",
|
||||
"controller workspace changes do not change draft type; reopening saved machine restores type, calibration and hierarchy in one undoable transaction",
|
||||
"applying BC design from AC controller changes controller type without altering design draft",
|
||||
"controller import accepts saved BC GLB from AC workspace and automatically switches to BC with physical zero"
|
||||
],
|
||||
"browser_errors": []
|
||||
}
|
||||
BIN
coverage/glb-machine-manager/machine-type/dialog-type.png
Normal file
|
After Width: | Height: | Size: 108 KiB |
@@ -0,0 +1 @@
|
||||
{"report_id":"GLB-MGR-E14-HISTORY","status":"verified","checks":["undo/redo","duplicate edits ignored","new edit discards redo","30-entry bound","clear preserves draft","machine type change preserves general axes and clears incompatible tilt","undo restores type and bindings"]}
|
||||
BIN
coverage/glb-machine-manager/machine-type/mill700/xyzac-live.png
Normal file
|
After Width: | Height: | Size: 147 KiB |
4108
coverage/glb-machine-manager/machine-type/mill700/xyzac.json
Normal file
BIN
coverage/glb-machine-manager/machine-type/mill700/xyzbc-live.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
4108
coverage/glb-machine-manager/machine-type/mill700/xyzbc.json
Normal file
@@ -0,0 +1,9 @@
|
||||
# 可导入的 MILL700 验收模型
|
||||
|
||||
这两份 GLB 由三维机床管理器实际保存,均已通过控制面板“导入机床模型”、五轴程序与参数恢复验证。用于演示 AC/BC 文件识别和单自由度映射;由同一个 MILL700 几何资产生成,不代表两台实机的测量标定。
|
||||
|
||||
- `MILL700-xyzac.glb`:XYZAC,主轴 JT1/JT2/JT3 → XYZ,工作台 JT1/JT2 → A/C。
|
||||
- `MILL700-xyzbc.glb`:XYZBC,主轴 JT1/JT2/JT3 → XYZ,工作台 JT1/JT2 → B/C。
|
||||
- 两者测试配置:Z 反向;物理零点 X=5、Z=7 mm,倾转轴=12°、C=9°;C 原点/倾转偏置为 0。
|
||||
- 使用时:控制面板关机且空闲 → 导入机床模型 → 导入 GLB → 选择文件;机型和关节参数从 GLB 自动读取。
|
||||
- 编辑时:三维机床管理 → 导入 GLB 部件 → 单独选择文件,恢复完整设计文档;根据需要核对并修改轴绑定和标定,再保存机床模型 GLB。
|
||||
BIN
coverage/glb-machine-manager/machine-type/test-xyzbc.glb
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E14",
|
||||
"status": "verified",
|
||||
"url": "http://127.0.0.1:5180/",
|
||||
"checks": [
|
||||
"search expands real nested nodes; axis overview locates selection; binding undo/redo",
|
||||
"empty input preserves last pose; repeated selection/input does not concatenate; single-axis step and reset",
|
||||
"TRT forbidden offset blocks export before build; calibration undo/redo",
|
||||
"view presets and selected fitting; deletion undo restores model with all axis bindings",
|
||||
"failed batch import leaves existing assembly intact",
|
||||
"live mode locks edits; desktop, short viewport and narrow viewport keep footer visible without overflow"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 91 KiB |
@@ -8,13 +8,311 @@
|
||||
"sha256": "bf37130681cc6462412e52744519516e8ade2a3212dd69153c0b1b3e415bcf66"
|
||||
},
|
||||
"checks": [
|
||||
"part translation and rotation inputs update the selected GLB assembly root",
|
||||
"root and internal node transforms update their local position and posture relative to their parent",
|
||||
"MILL700 JT3 edits preserve parent and sibling transforms; child inherits movement and rotation around the joint origin; values survive reselection and dialog reopening",
|
||||
"reparenting an edited node preserves world pose and subsequent inputs edit that node under its new parent",
|
||||
"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",
|
||||
"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"
|
||||
],
|
||||
"nodeEditing": {
|
||||
"before": {
|
||||
"parent": {
|
||||
"local": [
|
||||
-0.003969311714172363,
|
||||
-0.20427599549293518,
|
||||
0.40563321113586426
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.7138478483851618,
|
||||
-0.515726496296395,
|
||||
1.8940227031707764
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"sibling": {
|
||||
"local": [
|
||||
0.0031614303588867188,
|
||||
0.12106472253799438,
|
||||
-0.2689887285232544
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.7522836493599145,
|
||||
-0.40088159702548765,
|
||||
1.625033974647522
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"child": {
|
||||
"local": [
|
||||
0.0013986825942993164,
|
||||
0.0009530782699584961,
|
||||
-0.8500750064849854
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.4232056214947701,
|
||||
-1.3709958977290349,
|
||||
1.4128730297088623
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"selected": {
|
||||
"local": [
|
||||
-0.02080368995666504,
|
||||
-0.9040489196777344,
|
||||
0.3689253330230713
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.4241939812409632,
|
||||
-1.3723698759677156,
|
||||
2.2629480361938477
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
}
|
||||
},
|
||||
"translated": {
|
||||
"parent": {
|
||||
"local": [
|
||||
-0.003969311714172363,
|
||||
-0.20427599549293518,
|
||||
0.40563321113586426
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.7138478483851618,
|
||||
-0.515726496296395,
|
||||
1.8940227031707764
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"sibling": {
|
||||
"local": [
|
||||
0.0031614303588867188,
|
||||
0.12106472253799438,
|
||||
-0.2689887285232544
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.7522836493599145,
|
||||
-0.40088159702548765,
|
||||
1.625033974647522
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"child": {
|
||||
"local": [
|
||||
0.0013986825942993164,
|
||||
0.0009530782699584961,
|
||||
-0.8500750064849854
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.2908934330630653,
|
||||
-1.3228381995092735,
|
||||
1.3688730297088623
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"selected": {
|
||||
"local": [
|
||||
0.12,
|
||||
-0.9040489196777344,
|
||||
0.3249253330230713
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.2918817928092583,
|
||||
-1.3242121777479543,
|
||||
2.2189480361938476
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
}
|
||||
},
|
||||
"rotated": {
|
||||
"parent": {
|
||||
"local": [
|
||||
-0.003969311714172363,
|
||||
-0.20427599549293518,
|
||||
0.40563321113586426
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.7138478483851618,
|
||||
-0.515726496296395,
|
||||
1.8940227031707764
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"sibling": {
|
||||
"local": [
|
||||
0.0031614303588867188,
|
||||
0.12106472253799438,
|
||||
-0.2689887285232544
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.7522836493599145,
|
||||
-0.40088159702548765,
|
||||
1.625033974647522
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"child": {
|
||||
"local": [
|
||||
0.0013986825942993164,
|
||||
0.0009530782699584961,
|
||||
-0.8500750064849854
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.2915667029697908,
|
||||
-1.3225492316931085,
|
||||
1.3688730297088623
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.38268343236508984,
|
||||
0.9238795325112867
|
||||
]
|
||||
},
|
||||
"selected": {
|
||||
"local": [
|
||||
0.12,
|
||||
-0.9040489196777344,
|
||||
0.3249253330230713
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.21643961393810288,
|
||||
0.9762960071199334
|
||||
],
|
||||
"world": [
|
||||
-1.2918817928092583,
|
||||
-1.3242121777479543,
|
||||
2.2189480361938476
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.38268343236508984,
|
||||
0.9238795325112867
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"hierarchy": {
|
||||
"root": "MILL700-U_1",
|
||||
"nodes": 132,
|
||||
@@ -34,26 +332,26 @@
|
||||
"baseline": {
|
||||
"base": {
|
||||
"local": [
|
||||
0.12,
|
||||
0.1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.21643961393810288,
|
||||
0.9762960071199334
|
||||
0.17364817766693033,
|
||||
0.984807753012208
|
||||
],
|
||||
"world": [
|
||||
0.12,
|
||||
0.1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.21643961393810288,
|
||||
0.9762960071199335
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"x": {
|
||||
@@ -69,15 +367,15 @@
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.7247304318819034,
|
||||
-0.4850368286962905,
|
||||
-1.7799844207142423,
|
||||
-0.3224122661666029,
|
||||
1.488389492034912
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.21643961393810288,
|
||||
0.9762960071199335
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"y": {
|
||||
@@ -93,39 +391,39 @@
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.6419970838670583,
|
||||
-0.6718512577331518,
|
||||
-1.7138478483851618,
|
||||
-0.515726496296395,
|
||||
1.8940227031707764
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.21643961393810288,
|
||||
0.9762960071199335
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"z": {
|
||||
"local": [
|
||||
-0.02080368995666504,
|
||||
0.12,
|
||||
-0.9040489196777344,
|
||||
0.3689253330230713
|
||||
0.3249253330230713
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
0.21643961393810288,
|
||||
0.9762960071199334
|
||||
],
|
||||
"world": [
|
||||
-1.2787840471111187,
|
||||
-1.4999898527864315,
|
||||
2.2629480361938477
|
||||
-1.2918817928092583,
|
||||
-1.3242121777479543,
|
||||
2.2189480361938476
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.21643961393810288,
|
||||
0.9762960071199335
|
||||
0.38268343236508984,
|
||||
0.9238795325112867
|
||||
]
|
||||
},
|
||||
"tilt": {
|
||||
@@ -141,15 +439,15 @@
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.4729858525275663,
|
||||
-0.9649055930417467,
|
||||
-1.571021124066044,
|
||||
-0.8223959707837081,
|
||||
0.8832522630691528
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.21643961393810288,
|
||||
0.9762960071199335
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"c": {
|
||||
@@ -165,41 +463,41 @@
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.2050917487774973,
|
||||
-1.5396173432583025,
|
||||
-1.3542358677161157,
|
||||
-1.418269278870524,
|
||||
0.796804666519165
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.21643961393810288,
|
||||
0.9762960071199335
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
}
|
||||
},
|
||||
"driven": {
|
||||
"base": {
|
||||
"local": [
|
||||
0.12,
|
||||
0.1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.21643961393810288,
|
||||
0.9762960071199334
|
||||
0.17364817766693033,
|
||||
0.984807753012208
|
||||
],
|
||||
"world": [
|
||||
0.12,
|
||||
0.1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.21643961393810288,
|
||||
0.9762960071199335
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"x": {
|
||||
@@ -215,15 +513,15 @@
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.715667354011537,
|
||||
-0.48081064607888346,
|
||||
-1.7705874945063833,
|
||||
-0.3189920647333462,
|
||||
1.488389492034912
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.21643961393810288,
|
||||
0.9762960071199335
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"y": {
|
||||
@@ -239,39 +537,39 @@
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.641386371231506,
|
||||
-0.6494989193750118,
|
||||
-1.7112913250438162,
|
||||
-0.4935124424474202,
|
||||
1.8940227031707764
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.21643961393810288,
|
||||
0.9762960071199335
|
||||
0.17364817766693033,
|
||||
0.9848077530122079
|
||||
]
|
||||
},
|
||||
"z": {
|
||||
"local": [
|
||||
-0.02080368995666504,
|
||||
0.12,
|
||||
-0.9040489196777344,
|
||||
0.3989253330230713
|
||||
0.3549253330230713
|
||||
],
|
||||
"localQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
0.21643961393810288,
|
||||
0.9762960071199334
|
||||
],
|
||||
"world": [
|
||||
-1.2781733344755664,
|
||||
-1.4776375144282916,
|
||||
2.292948036193848
|
||||
-1.2893252694679127,
|
||||
-1.3019981238989793,
|
||||
2.2489480361938474
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0,
|
||||
0,
|
||||
0.21643961393810288,
|
||||
0.9762960071199335
|
||||
0.38268343236508984,
|
||||
0.9238795325112867
|
||||
]
|
||||
},
|
||||
"tilt": {
|
||||
@@ -287,15 +585,15 @@
|
||||
0.9914448613738104
|
||||
],
|
||||
"world": [
|
||||
-1.4729858525275663,
|
||||
-0.9649055930417467,
|
||||
-1.571021124066044,
|
||||
-0.8223959707837081,
|
||||
0.8832522630691528
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0.12743220028900526,
|
||||
0.02825103865291857,
|
||||
0.21458794303666343,
|
||||
0.9679436594388271
|
||||
0.12854320606946854,
|
||||
0.022665635416815412,
|
||||
0.1721625934348045,
|
||||
0.9763825861650425
|
||||
]
|
||||
},
|
||||
"c": {
|
||||
@@ -311,15 +609,15 @@
|
||||
0.9238795325112867
|
||||
],
|
||||
"world": [
|
||||
-1.2236785538569896,
|
||||
-1.4997578111351686,
|
||||
-1.369277957263005,
|
||||
-1.3769414774990314,
|
||||
0.6356376244864839
|
||||
],
|
||||
"worldQuaternion": [
|
||||
0.12854320606946854,
|
||||
-0.022665635416815422,
|
||||
0.5686694104253471,
|
||||
0.812144084994173
|
||||
0.12743220028900526,
|
||||
-0.02825103865291858,
|
||||
0.5327029357136194,
|
||||
0.8361761150778141
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 338 KiB After Width: | Height: | Size: 296 KiB |
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E12-CLOUD",
|
||||
"status": "verified",
|
||||
"checked_at": "2026-09-21T01:39:10.681054+00:00",
|
||||
"url": "https://49.232.250.35:58015/",
|
||||
"source_commit": "91ebd56d6d34ca3f88a73541cfe64f7bef3d8483",
|
||||
"release": "/var/www/linuxcnc-wasm-58015/releases/20260921-linuxcnc-wasm-91ebd56",
|
||||
"archive_sha256": "a4ae18ee92a08af7b532052620d9d349539e4424c0ec2f12e65fa8eafacee1a7",
|
||||
"bundle": "assets/index-CJLtuazD.js",
|
||||
"command": "PLAYWRIGHT_MODULE=/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js MACHINE_MANAGER_URL=https://49.232.250.35:58015/ node scripts/test_machine_manager_mill700_browser.mjs",
|
||||
"report": "coverage/glb-machine-manager/mill700/browser.report.json",
|
||||
"report_sha256": "3555f9efe0cebba79a0bec8afbff8a4a44cc1278d0f36ff737fae9a74e18b9e1",
|
||||
"checks": [
|
||||
"root and internal node transforms update their local position and posture relative to their parent",
|
||||
"MILL700 JT3 edits preserve parent and sibling transforms; child inherits movement and rotation around the joint origin; values survive reselection and dialog reopening",
|
||||
"reparenting an edited node preserves world pose and subsequent inputs edit that node under its new parent",
|
||||
"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",
|
||||
"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"
|
||||
],
|
||||
"browser_errors": []
|
||||
}
|
||||
|
After Width: | Height: | Size: 180 KiB |
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema": "boat-trt-programs.v1",
|
||||
"status": "verified",
|
||||
"rows": [
|
||||
{
|
||||
"coordinates": "XYZAC",
|
||||
"program": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzac.ngc",
|
||||
"status": "completed",
|
||||
"rotary": "a"
|
||||
},
|
||||
{
|
||||
"coordinates": "XYZBC",
|
||||
"program": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc",
|
||||
"status": "completed",
|
||||
"rotary": "b"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"report_id": "TRT-PROGRAM-COMPAT-BROWSER",
|
||||
"status": "verified",
|
||||
"url": "http://127.0.0.1:5180/",
|
||||
"checks": [
|
||||
"AC to BC model import replaces source/path/preview with BC boat program",
|
||||
"renamed AC file rejected on BC; previous loaded taskfile remains intact",
|
||||
"editor reload also rejects wrong-axis program without replacing loaded taskfile",
|
||||
"same-type reimport preserves custom compatible program",
|
||||
"BC to AC model import loads AC boat",
|
||||
"manager apply also switches program to matching BC family",
|
||||
"BC machine runs BC G-code after home; header/controller/program agree"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
{
|
||||
"schema": "rtcp-program-catalog-load.v1",
|
||||
"status": "verified",
|
||||
"programs": [
|
||||
{
|
||||
"id": "trt-boat-xyzac",
|
||||
"coordinates": "XYZAC",
|
||||
"source_hash": "fnv1a64:bdc95b491cd714d9",
|
||||
"preview_segments": 1825
|
||||
},
|
||||
{
|
||||
"id": "trt-impeller-xyzac",
|
||||
"coordinates": "XYZAC",
|
||||
"source_hash": "fnv1a64:76b5e2017a1b0a93",
|
||||
"preview_segments": 4492
|
||||
},
|
||||
{
|
||||
"id": "trt-xyzac-switchkins",
|
||||
"coordinates": "XYZAC",
|
||||
"source_hash": "fnv1a64:522e219683194ad2",
|
||||
"preview_segments": 29
|
||||
},
|
||||
{
|
||||
"id": "trt-xyzac-switchkins-test-1",
|
||||
"coordinates": "XYZAC",
|
||||
"source_hash": "fnv1a64:f1d649350d9dd63d",
|
||||
"preview_segments": 17
|
||||
},
|
||||
{
|
||||
"id": "trt-xyzac-switchkins-test-2",
|
||||
"coordinates": "XYZAC",
|
||||
"source_hash": "fnv1a64:00aebdd97af4c5d8",
|
||||
"preview_segments": 32
|
||||
},
|
||||
{
|
||||
"id": "trt-xyzac-switchkins-test-3",
|
||||
"coordinates": "XYZAC",
|
||||
"source_hash": "fnv1a64:cf7e5987249c1bf8",
|
||||
"preview_segments": 3
|
||||
},
|
||||
{
|
||||
"id": "gmoccapy-boat-xyzac",
|
||||
"coordinates": "XYZAC",
|
||||
"source_hash": "fnv1a64:9d09b727bef67c7c",
|
||||
"preview_segments": 1825
|
||||
},
|
||||
{
|
||||
"id": "gmoccapy-impeller-xyzac",
|
||||
"coordinates": "XYZAC",
|
||||
"source_hash": "fnv1a64:3e4cbd31dd06ec9c",
|
||||
"preview_segments": 4492
|
||||
},
|
||||
{
|
||||
"id": "trt-boat-xyzbc",
|
||||
"coordinates": "XYZBC",
|
||||
"source_hash": "fnv1a64:b1887a8c8fd29012",
|
||||
"preview_segments": 1820
|
||||
},
|
||||
{
|
||||
"id": "trt-xyzbc-switchkins",
|
||||
"coordinates": "XYZBC",
|
||||
"source_hash": "fnv1a64:622cce6b5c7277f1",
|
||||
"preview_segments": 29
|
||||
},
|
||||
{
|
||||
"id": "gmoccapy-boat-xyzbc",
|
||||
"coordinates": "XYZBC",
|
||||
"source_hash": "fnv1a64:7cef899039522872",
|
||||
"preview_segments": 1820
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"report_id": "TRT-PROGRAM-COMPAT-BROWSER",
|
||||
"status": "verified",
|
||||
"url": "https://49.232.250.35:58015/",
|
||||
"checks": [
|
||||
"AC to BC model import replaces source/path/preview with BC boat program",
|
||||
"renamed AC file rejected on BC; previous loaded taskfile remains intact",
|
||||
"editor reload also rejects wrong-axis program without replacing loaded taskfile",
|
||||
"same-type reimport preserves custom compatible program",
|
||||
"BC to AC model import loads AC boat",
|
||||
"manager apply also switches program to matching BC family",
|
||||
"BC machine runs BC G-code after home; header/controller/program agree"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"report_id": "TRT-PROGRAM-COMPAT-CLOUD",
|
||||
"status": "verified",
|
||||
"checked_at": "2026-09-21T12:03:26.786122+00:00",
|
||||
"url": "https://49.232.250.35:58015/",
|
||||
"source_commit": "0de96bb",
|
||||
"release": "/var/www/linuxcnc-wasm-58015/releases/20260921-linuxcnc-wasm-0de96bb",
|
||||
"bundle": "assets/index-VAG1dzJ6.js",
|
||||
"archive_sha256": "d6613fa681243676ea42096578c01bee863d0db7f7ece635e56b25d888fe8f89",
|
||||
"checks": [
|
||||
"AC to BC model import replaces source/path/preview with BC boat program",
|
||||
"renamed AC file rejected on BC; previous loaded taskfile remains intact",
|
||||
"editor reload also rejects wrong-axis program without replacing loaded taskfile",
|
||||
"same-type reimport preserves custom compatible program",
|
||||
"BC to AC model import loads AC boat",
|
||||
"manager apply also switches program to matching BC family",
|
||||
"BC machine runs BC G-code after home; header/controller/program agree"
|
||||
],
|
||||
"browser_errors": []
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"schema": "xyzac-program-replacement.v1",
|
||||
"status": "verified",
|
||||
"first_segments": 1,
|
||||
"failed_segments": 0,
|
||||
"second_segments": 1,
|
||||
"old_preview_retained_after_failure": false
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"schema": "xyzac-step-flow.v2",
|
||||
"status": "verified",
|
||||
"source": [
|
||||
"axis.py::task_step",
|
||||
"emctaskmain.cc::EMC_TASK_PLAN_STEP_TYPE",
|
||||
"sim_linuxcnc_tp_step_line"
|
||||
],
|
||||
"phases": [
|
||||
"start_pause",
|
||||
"source_line_pause",
|
||||
"source_line_motion",
|
||||
"motion_step_pending",
|
||||
"terminal"
|
||||
],
|
||||
"duplicate_step_idempotent": true,
|
||||
"completed_and_aborted_rerun": true
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"report_id": "TRT-PROGRAM-COMPAT-WORKER",
|
||||
"status": "verified",
|
||||
"rows": [
|
||||
{
|
||||
"coordinates": "XYZAC",
|
||||
"load_rejected": true,
|
||||
"legacy_execution_rejected": true,
|
||||
"generic_allowed": true
|
||||
},
|
||||
{
|
||||
"coordinates": "XYZBC",
|
||||
"load_rejected": true,
|
||||
"legacy_execution_rejected": true,
|
||||
"generic_allowed": true
|
||||
}
|
||||
]
|
||||
}
|
||||
15
coverage/glb-machine-manager/workbench/browser.report.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E14",
|
||||
"status": "verified",
|
||||
"url": "http://127.0.0.1:5180/",
|
||||
"checks": [
|
||||
"Open Files Cancel leaves the machine manager dialog open",
|
||||
"search expands real nested nodes; axis overview locates selection; binding undo/redo",
|
||||
"empty input preserves last pose; repeated selection/input does not concatenate; single-axis step and reset",
|
||||
"TRT forbidden offset blocks export before build; calibration undo/redo",
|
||||
"view presets and selected fitting; deletion undo restores model with all axis bindings",
|
||||
"failed batch import leaves existing assembly intact",
|
||||
"live mode locks edits; desktop, short viewport and narrow viewport keep footer visible without overflow"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
14
coverage/glb-machine-manager/workbench/cloud.report.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E14",
|
||||
"status": "verified",
|
||||
"url": "https://49.232.250.35:58015/",
|
||||
"checks": [
|
||||
"search expands real nested nodes; axis overview locates selection; binding undo/redo",
|
||||
"empty input preserves last pose; repeated selection/input does not concatenate; single-axis step and reset",
|
||||
"TRT forbidden offset blocks export before build; calibration undo/redo",
|
||||
"view presets and selected fitting; deletion undo restores model with all axis bindings",
|
||||
"failed batch import leaves existing assembly intact",
|
||||
"live mode locks edits; desktop, short viewport and narrow viewport keep footer visible without overflow"
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E14-CLOUD",
|
||||
"status": "verified",
|
||||
"checked_at": "2026-09-21T05:34:44.342351+00:00",
|
||||
"url": "https://49.232.250.35:58015/",
|
||||
"release": "/var/www/linuxcnc-wasm-58015/releases/20260921-linuxcnc-wasm-a7233bc",
|
||||
"source_commit": "a7233bc8e4c4ccea4a4906d2d947ba84b549c39d",
|
||||
"bundle": "assets/index-nddrUCCV.js",
|
||||
"archive_sha256": "c9b69be1bc3f1f1e40693eb30c4dffb140aa92129c351ac509a8430457f220c7",
|
||||
"cloud_checks": [
|
||||
"search expands real nested nodes; axis overview locates selection; binding undo/redo",
|
||||
"empty input preserves last pose; repeated selection/input does not concatenate; single-axis step and reset",
|
||||
"TRT forbidden offset blocks export before build; calibration undo/redo",
|
||||
"view presets and selected fitting; deletion undo restores model with all axis bindings",
|
||||
"failed batch import leaves existing assembly intact",
|
||||
"live mode locks edits; desktop, short viewport and narrow viewport keep footer visible without overflow"
|
||||
],
|
||||
"browser_errors": [],
|
||||
"local_ac_bc_regression": {
|
||||
"ac": "xyzac-regression.report.json",
|
||||
"bc": "xyzbc-regression.report.json",
|
||||
"samples_per_machine": 8,
|
||||
"status": "verified"
|
||||
}
|
||||
}
|
||||
BIN
coverage/glb-machine-manager/workbench/desktop.png
Normal file
|
After Width: | Height: | Size: 187 KiB |
@@ -0,0 +1 @@
|
||||
{"report_id":"GLB-MGR-E14-HISTORY","status":"verified","checks":["undo/redo","duplicate edits ignored","new edit discards redo","30-entry bound","clear preserves draft"]}
|
||||
BIN
coverage/glb-machine-manager/workbench/viewport-1154.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
coverage/glb-machine-manager/workbench/viewport-720.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
@@ -0,0 +1,983 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E13",
|
||||
"status": "verified",
|
||||
"url": "http://127.0.0.1:5180/",
|
||||
"machines": [
|
||||
{
|
||||
"coordinates": "XYZAC",
|
||||
"checks": [
|
||||
"fixed or single DOF",
|
||||
"direction/sign and parent isolation",
|
||||
"pivot and child inheritance",
|
||||
"duplicate axis rejected",
|
||||
"JSON on scene and real bed",
|
||||
"GLB roundtrip restores calibration and bindings",
|
||||
"single-file apply",
|
||||
"WASM runtime axis mapping",
|
||||
"live dialog follows actual joints"
|
||||
],
|
||||
"nodeCount": 134,
|
||||
"samples": [
|
||||
{
|
||||
"tick": 85,
|
||||
"q": [
|
||||
1.2389134479519215,
|
||||
0.6574092294801889,
|
||||
0.6448116444857956,
|
||||
1.2603902790766386,
|
||||
1.8905854186149578
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
468.8777588916775,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.806400633739475,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-203.618586263455,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.8460937508812,
|
||||
18.940227031707764,
|
||||
1.3640552739163505
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
375.2805213785855
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-19.05413065044785,
|
||||
22.693032245493615,
|
||||
-7.676433922860993
|
||||
]
|
||||
},
|
||||
"A": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
-0.09358363453630855,
|
||||
0,
|
||||
0,
|
||||
0.9956114218644615
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
-0.06200155297278678,
|
||||
0.998076052928314
|
||||
],
|
||||
"world": [
|
||||
-18.516113758087158,
|
||||
9.164775530362324,
|
||||
-8.403619073277603
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tick": 180,
|
||||
"q": [
|
||||
2.7007255572479774,
|
||||
1.5379467155631636,
|
||||
1.4830952979680165,
|
||||
2.811733782839189,
|
||||
4.217600674258783
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
470.3395710009736,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.791782512646513,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-202.738048777372,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.831475629788237,
|
||||
18.940227031707764,
|
||||
1.3728606487771806
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
374.44223772510327
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-19.039512529354887,
|
||||
22.684649408958794,
|
||||
-7.667628548000163
|
||||
]
|
||||
},
|
||||
"A": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
-0.08009685716826766,
|
||||
0,
|
||||
0,
|
||||
0.9967870853255304
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
-0.04172219310644397,
|
||||
0.999129250198586
|
||||
],
|
||||
"world": [
|
||||
-18.516113758087158,
|
||||
8.991635804504972,
|
||||
-8.41027160637488
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tick": 275,
|
||||
"q": [
|
||||
4.085999929781512,
|
||||
2.4909264365477166,
|
||||
2.3779308599752444,
|
||||
4.363077286601698,
|
||||
6.544615929902546
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
471.7248453735071,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.77792976892118,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-201.78506905638747,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.817622886062903,
|
||||
18.940227031707764,
|
||||
1.382390445987026
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
373.5474021630961
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-19.025659785629554,
|
||||
22.67570105333872,
|
||||
-7.658098750790318
|
||||
]
|
||||
},
|
||||
"A": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
-0.06659540002082798,
|
||||
0,
|
||||
0,
|
||||
0.9977800622863067
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
-0.021425628607248472,
|
||||
0.9997704448716136
|
||||
],
|
||||
"world": [
|
||||
-18.516113758087158,
|
||||
8.818379437902781,
|
||||
-8.412234333654848
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tick": 370,
|
||||
"q": [
|
||||
5.387598173682366,
|
||||
3.5088324139082143,
|
||||
3.3365312733855843,
|
||||
5.914420790364206,
|
||||
8.871631185546308
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
473.02644361740795,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.76491378648217,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-200.76716307902697,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.804606903623892,
|
||||
18.940227031707764,
|
||||
1.3925695057606307
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
372.58880174968573
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-19.012643803190542,
|
||||
22.66611504920462,
|
||||
-7.647919691016713
|
||||
]
|
||||
},
|
||||
"A": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
-0.05308173757825106,
|
||||
0,
|
||||
0,
|
||||
0.9985901707585919
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
-0.001120229000250849,
|
||||
0.9999993725432966
|
||||
],
|
||||
"world": [
|
||||
-18.516113758087158,
|
||||
8.645133439219864,
|
||||
-8.409505816305332
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tick": 465,
|
||||
"q": [
|
||||
6.598646792860076,
|
||||
4.583380703310395,
|
||||
4.365425131049058,
|
||||
7.46576429412713,
|
||||
11.198646441190695
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
474.2374922365857,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.752803300290395,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-199.69261478962477,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.79249641743212,
|
||||
18.940227031707764,
|
||||
1.403314988654653
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
371.55990789202224
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-19.00053331699877,
|
||||
22.655826110627984,
|
||||
-7.6371742081226905
|
||||
]
|
||||
},
|
||||
"A": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
-0.039558346561724904,
|
||||
0,
|
||||
0,
|
||||
0.9992172622694738
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0.01918563254624356,
|
||||
0.9998159388126399
|
||||
],
|
||||
"world": [
|
||||
-18.516113758087158,
|
||||
8.472024809519901,
|
||||
-8.402088054514548
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tick": 555,
|
||||
"q": [
|
||||
7.656454328643159,
|
||||
5.64548233448603,
|
||||
5.410254575943578,
|
||||
8.935458139797525,
|
||||
13.40318720969629
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
475.2952997723687,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.74222522493256,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-198.63051315844916,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.781918342074285,
|
||||
18.940227031707764,
|
||||
1.4139360049664091
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
370.51507844712773
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.989955241640935,
|
||||
22.64537781617904,
|
||||
-7.626553191810935
|
||||
]
|
||||
},
|
||||
"A": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
-0.026739985102743145,
|
||||
0,
|
||||
0,
|
||||
0.9996424226675782
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0.0384156022232577,
|
||||
0.9992618483189601
|
||||
],
|
||||
"world": [
|
||||
-18.516113758087158,
|
||||
8.30826888076431,
|
||||
-8.3907400525114
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tick": 655,
|
||||
"q": [
|
||||
8.72303975359065,
|
||||
6.865798191834175,
|
||||
6.656398201686621,
|
||||
10.568451301653525,
|
||||
15.852676952480286
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
476.36188519731627,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.731559370683087,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-197.41019730110102,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.77125248782481,
|
||||
18.940227031707764,
|
||||
1.4261391635398906
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
369.2689348213847
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.97928938739146,
|
||||
22.632916379921607,
|
||||
-7.614350033237454
|
||||
]
|
||||
},
|
||||
"A": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
-0.012492294152387668,
|
||||
0,
|
||||
0,
|
||||
0.999921968248928
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0.0597652512848917,
|
||||
0.9982124597192993
|
||||
],
|
||||
"world": [
|
||||
-18.516113758087158,
|
||||
8.126727064793355,
|
||||
-8.37320998204645
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tick": 745,
|
||||
"q": [
|
||||
9.579524288290253,
|
||||
7.990781709641391,
|
||||
7.858468677864376,
|
||||
12.038145147323924,
|
||||
18.057217720985886
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
477.2183697320158,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.72299452533609,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-196.28521378329378,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.762687642477815,
|
||||
18.940227031707764,
|
||||
1.4373889987179629
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
368.0668643452069
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.970724542044465,
|
||||
22.62089567515983,
|
||||
-7.603100198059381
|
||||
]
|
||||
},
|
||||
"A": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
0.0003328792010827427,
|
||||
0,
|
||||
0,
|
||||
0.9999999445957172
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "5b0ea1d5-33d0-407f-95ef-ddf8afbf315d:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0.07895686550406936,
|
||||
0.9968780333570262
|
||||
],
|
||||
"world": [
|
||||
-18.516113758087158,
|
||||
7.963825398322858,
|
||||
-8.353015220022485
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"liveSample": {
|
||||
"q": {
|
||||
"x": 5.797354360536403,
|
||||
"y": 9.401123543630533,
|
||||
"z": 2.617520495853631,
|
||||
"a": 2.0821562164739387,
|
||||
"b": 0,
|
||||
"c": 11.136927027456565
|
||||
},
|
||||
"joints": {
|
||||
"X": {
|
||||
"localMm": [
|
||||
478.436199804262,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.8710816224613631,
|
||||
0.34002411365509033,
|
||||
1.488389492034912
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-194.87487194930463,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.8750509341755355,
|
||||
0.1451492417057857,
|
||||
1.8940227031707764
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
366.30781252721766
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.8958546241322005,
|
||||
-0.7588996779719487,
|
||||
2.2603305156979943
|
||||
]
|
||||
},
|
||||
"A": {
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
0.01816924093501159,
|
||||
0,
|
||||
0,
|
||||
0.9998349257171634
|
||||
],
|
||||
"world": [
|
||||
-1.8515222072601318,
|
||||
-0.20127654075622559,
|
||||
0.8832522630691528
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0.09703509639825511,
|
||||
0.9952809603659568
|
||||
],
|
||||
"world": [
|
||||
-1.8516113758087158,
|
||||
-0.8317997114621495,
|
||||
0.7738239446948962
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"browser_errors": []
|
||||
}
|
||||
@@ -0,0 +1,983 @@
|
||||
{
|
||||
"report_id": "GLB-MGR-E13",
|
||||
"status": "verified",
|
||||
"url": "http://127.0.0.1:5180/",
|
||||
"machines": [
|
||||
{
|
||||
"coordinates": "XYZBC",
|
||||
"checks": [
|
||||
"fixed or single DOF",
|
||||
"direction/sign and parent isolation",
|
||||
"pivot and child inheritance",
|
||||
"duplicate axis rejected",
|
||||
"JSON on scene and real bed",
|
||||
"GLB roundtrip restores calibration and bindings",
|
||||
"single-file apply",
|
||||
"WASM runtime axis mapping",
|
||||
"live dialog follows actual joints"
|
||||
],
|
||||
"nodeCount": 134,
|
||||
"samples": [
|
||||
{
|
||||
"tick": 90,
|
||||
"q": [
|
||||
1.3329950123827954,
|
||||
0.7177480791808708,
|
||||
0.6399756098721868,
|
||||
1.3420399371694114,
|
||||
2.013059905754117
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
468.9718404561084,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.805459818095166,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-203.55824741375432,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.84515293523689,
|
||||
18.940227031707764,
|
||||
1.3646586624133574
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
375.2853574131991
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-19.05318983480354,
|
||||
22.693080605839754,
|
||||
-7.675830534363986
|
||||
]
|
||||
},
|
||||
"B": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
-0.09287421014032093,
|
||||
0,
|
||||
0.9956778500553337
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
-0.06093478253640957,
|
||||
0.9981417495913296
|
||||
],
|
||||
"world": [
|
||||
-18.356217359868296,
|
||||
7.982795032364486,
|
||||
-8.353592157363893
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tick": 180,
|
||||
"q": [
|
||||
2.766438027705197,
|
||||
1.6088474279360891,
|
||||
1.2716921368117247,
|
||||
2.811733782839189,
|
||||
4.217600674258783
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
470.4052834714308,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.791125387941943,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-202.66714806499908,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.830818505083666,
|
||||
18.940227031707764,
|
||||
1.3735696559009098
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
374.65364088625955
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-19.038855404650317,
|
||||
22.686763440570356,
|
||||
-7.6669195408764335
|
||||
]
|
||||
},
|
||||
"B": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
-0.08009685716826766,
|
||||
0,
|
||||
0.9967870853255304
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
-0.04172219310644397,
|
||||
0.999129250198586
|
||||
],
|
||||
"world": [
|
||||
-18.378063637032554,
|
||||
7.978996384122459,
|
||||
-8.353592157363893
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tick": 275,
|
||||
"q": [
|
||||
4.240122439171268,
|
||||
2.664612323566039,
|
||||
1.8643679838555824,
|
||||
4.363077286601698,
|
||||
6.544615929902546
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
471.87896788289686,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.77638854382728,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-201.61138316936913,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.816081660969004,
|
||||
18.940227031707764,
|
||||
1.3841273048572091
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
374.0609650392157
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-19.024118560535655,
|
||||
22.68083668209992,
|
||||
-7.656361891920135
|
||||
]
|
||||
},
|
||||
"B": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
-0.06659540002082798,
|
||||
0,
|
||||
0.9977800622863067
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
-0.021425628607248472,
|
||||
0.9997704448716136
|
||||
],
|
||||
"world": [
|
||||
-18.40122120764243,
|
||||
7.975595975242779,
|
||||
-8.353592157363893
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tick": 365,
|
||||
"q": [
|
||||
5.590120449209219,
|
||||
3.769665818331871,
|
||||
2.3605087594884826,
|
||||
5.832771132271442,
|
||||
8.749156698407162
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
473.2289658929348,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.762888563726904,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-200.5063296746033,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.802581680868627,
|
||||
18.940227031707764,
|
||||
1.3951778398048678
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
373.5648242635828
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-19.010618580435278,
|
||||
22.67587527434359,
|
||||
-7.645311356972476
|
||||
]
|
||||
},
|
||||
"B": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
-0.05379324718442026,
|
||||
0,
|
||||
0.9985520950643265
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
-0.0021890190114552117,
|
||||
0.9999976040950136
|
||||
],
|
||||
"world": [
|
||||
-18.423237322685893,
|
||||
7.972953962222888,
|
||||
-8.353592157363893
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tick": 460,
|
||||
"q": [
|
||||
6.957563664607322,
|
||||
5.041874761097972,
|
||||
2.8212639336800454,
|
||||
7.38411463603433,
|
||||
11.076171954051496
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
474.5964091083329,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.74921413157292,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-199.2341207318372,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.788907248714644,
|
||||
18.940227031707764,
|
||||
1.4078999292325285
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
373.10406908939126
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.996944148281294,
|
||||
22.671267722601673,
|
||||
-7.632589267544815
|
||||
]
|
||||
},
|
||||
"B": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
-0.04027030642080173,
|
||||
0,
|
||||
0.9991888222056804
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0.018117026989570854,
|
||||
0.9998358731977259
|
||||
],
|
||||
"world": [
|
||||
-18.44654192054848,
|
||||
7.970778743713527,
|
||||
-8.353592157363893
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tick": 550,
|
||||
"q": [
|
||||
8.19086819781266,
|
||||
6.342427968934119,
|
||||
3.2043989406756506,
|
||||
8.853808481704728,
|
||||
13.280712722557091
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
475.82971364153826,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.736881086240867,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-197.93356752400106,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.77657420338259,
|
||||
18.940227031707764,
|
||||
1.42090546131089
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
372.7209340823957
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.98461110294924,
|
||||
22.667436372531718,
|
||||
-7.6195837354664535
|
||||
]
|
||||
},
|
||||
"B": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
-0.027452251154261215,
|
||||
0,
|
||||
0.9996231159324815
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0.03734757789035257,
|
||||
0.9993023358452255
|
||||
],
|
||||
"world": [
|
||||
-18.46866667973197,
|
||||
7.969300709384652,
|
||||
-8.353592157363893
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tick": 640,
|
||||
"q": [
|
||||
9.356884433005701,
|
||||
7.730651003974226,
|
||||
3.542287286077644,
|
||||
10.323502327375126,
|
||||
15.485253491062688
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
476.9957298767313,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.725220923888937,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-196.54534448896098,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.76491404103066,
|
||||
18.940227031707764,
|
||||
1.434787691661291
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
372.38304573699367
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.97295094059731,
|
||||
22.6640574890777,
|
||||
-7.605701505116053
|
||||
]
|
||||
},
|
||||
"B": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
-0.01462968023530727,
|
||||
0,
|
||||
0.9998929805015198
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0.05656430649923953,
|
||||
0.9983989579473028
|
||||
],
|
||||
"world": [
|
||||
-18.490822069456588,
|
||||
7.968390621171865,
|
||||
-8.353592157363893
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"tick": 735,
|
||||
"q": [
|
||||
10.508052284276753,
|
||||
9.28531166241756,
|
||||
3.8576896962603726,
|
||||
11.874845831138323,
|
||||
17.812268746707485
|
||||
],
|
||||
"nodes": {
|
||||
"X": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2",
|
||||
"localMm": [
|
||||
478.14689772800233,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.713709245376226,
|
||||
14.883894920349121,
|
||||
3.4002411365509015
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1",
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-194.9906838305176,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.75340236251795,
|
||||
18.940227031707764,
|
||||
1.4503342982457244
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/2/1/0",
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
372.0676433268109
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-18.9614392620846,
|
||||
22.66090346497587,
|
||||
-7.590154898531619
|
||||
]
|
||||
},
|
||||
"B": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3",
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
-0.0010921759424831284,
|
||||
0,
|
||||
0.9999994035756775
|
||||
],
|
||||
"world": [
|
||||
-18.51522207260132,
|
||||
8.832522630691528,
|
||||
-2.0127654075622576
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"id": "aeb4c700-d786-4c4c-9a82-f6888bd4f7c0:scene/0/0/0/3/1",
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0.07682577715235232,
|
||||
0.9970445325886588
|
||||
],
|
||||
"world": [
|
||||
-18.51422543738136,
|
||||
7.9680467798152925,
|
||||
-8.353592157363893
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"liveSample": {
|
||||
"q": {
|
||||
"x": 7.335436151103451,
|
||||
"y": 11.735957758464831,
|
||||
"z": -2.7569792395159682,
|
||||
"a": 0,
|
||||
"b": 2.3271051907522295,
|
||||
"c": 11.545175317920382
|
||||
},
|
||||
"joints": {
|
||||
"X": {
|
||||
"localMm": [
|
||||
479.974281594829,
|
||||
1681.300401687622,
|
||||
-997.8671073913574
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.8695435406707959,
|
||||
0.34002411365509033,
|
||||
1.488389492034912
|
||||
]
|
||||
},
|
||||
"Y": {
|
||||
"localMm": [
|
||||
-3.9693117141723633,
|
||||
-192.54003773447033,
|
||||
405.63321113586426
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.8735128523849682,
|
||||
0.14748407592062,
|
||||
1.8940227031707764
|
||||
]
|
||||
},
|
||||
"Z": {
|
||||
"localMm": [
|
||||
-20.80368995666504,
|
||||
-904.0489196777344,
|
||||
371.68231226258723
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1
|
||||
],
|
||||
"world": [
|
||||
-1.8943165423416333,
|
||||
-0.7565648437571144,
|
||||
2.2657050154333636
|
||||
]
|
||||
},
|
||||
"B": {
|
||||
"localMm": [
|
||||
497.99561500549316,
|
||||
1139.9997472763062,
|
||||
-1603.0043363571167
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0.020306427987709715,
|
||||
0,
|
||||
0.9997938032326366
|
||||
],
|
||||
"world": [
|
||||
-1.8515222072601318,
|
||||
-0.20127654075622559,
|
||||
0.8832522630691528
|
||||
]
|
||||
},
|
||||
"C": {
|
||||
"localMm": [
|
||||
-0.08916854858398438,
|
||||
-634.0826749801636,
|
||||
-86.4475965499878
|
||||
],
|
||||
"quaternion": [
|
||||
0,
|
||||
0,
|
||||
0.10058029927953424,
|
||||
0.9949289438934015
|
||||
],
|
||||
"world": [
|
||||
-1.8551214621264505,
|
||||
-0.8353592157363892,
|
||||
0.7968795806706839
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"browser_errors": []
|
||||
}
|
||||
@@ -8,12 +8,12 @@
|
||||
|
||||
1. 对话框包含独立的 Three.js 三维预览,实时显示正在配置的机床。
|
||||
2. 支持逐个导入自包含 `.glb` 机床部件,不允许依赖外部纹理或网格文件。
|
||||
3. 每个部件可绑定 `底座、X、Y、Z、刀具、A/B、C、工件` 角色,并编辑父级关系。可运行结构必须满足:
|
||||
3. 每个部件可绑定 `底座、X、Y、Z、刀具、A/B、C、工件` 结构角色,并编辑父级关系。MGR-17 以独立的 XYZABC 自由度绑定驱动视觉层级,允许单 GLB 多关节。数控求解器仍按 TRT 的控制参考链运行:
|
||||
- 刀具链:底座 → X → Y → Z → 刀具。
|
||||
- 转台链:底座 → A/B → C → 工件。
|
||||
4. 每个部件可定义局部坐标原点。模型网格相对该原点偏移,运动关节围绕该原点运动。
|
||||
5. 每个部件可独立显示或隐藏原点坐标轴辅助线。
|
||||
6. 每个部件可用数值和 Three.js 操纵器平移、旋转;变换只修改装配配置,不修改原 GLB 字节。Three.js 管理器通过 `camera.up.set(0, 0, 1)` 使用机床 Z-up 场景,机床 XY 为地面平面;导入 GLB 的节点、几何、材质、内部层级和原始变换保持不变。
|
||||
6. 每个运动节点只允许通过绑定的唯一自由度修改位移或转角,固定节点锁定;旧六分量自由编辑和操纵器由单值关节输入替代。变换只修改预览配置,不修改原 GLB 字节。Three.js 管理器通过 `camera.up.set(0, 0, 1)` 使用机床 Z-up 场景,机床 XY 为地面平面;导入 GLB 的节点、几何、材质、内部层级和原始变换保持不变。
|
||||
7. 可定义 C 轴原点位置和 A/B 到 C 的运动学偏置。应用时写入底座场景节点及整机场景的 `userData.linuxcncMachineGeometry`,同时生成 URDF:
|
||||
- `C 原点 P = (x-rot-point, y-rot-point, z-rot-point)`。
|
||||
- `A/B 偏置 D = (x-offset, y-offset, z-offset)`。
|
||||
@@ -48,3 +48,15 @@
|
||||
- 底座 `userData` 含完整几何及物理零点字段。
|
||||
- 运行控制面板后,X/Y/Z/A或B/C Jog 和五轴程序驱动对应部件;工件路径继续附着 C 轴,刀具长度来自 WASM。
|
||||
- 非法层级、重复角色、缺失部件、错误机型和外部资源 GLB 明确拒绝,已生效模型不改变。
|
||||
|
||||
15. 树中每个节点独立编辑相对父级的位置(mm)和 XYZ 欧拉姿态(deg)。数值输入回显节点当前局部值,旋转以自身原点为中心;子节点随动,父级和兄弟节点不变。源 GLB 保留,节点编辑保存在草稿并进入组合导出。
|
||||
|
||||
16. MGR-17:每个节点可配置一个 XYZABC 自由度、父坐标轴方向和正负向。配置以 `linuxcnc.joint-model/1` JSON 写入原 userData,包含层级、零姿态、轴索引、物理零点和 RTCP 标定。管理器实时跟随与正式机床共用驱动逻辑;详见 [07-单自由度JSON与实时驱动](07-单自由度JSON与实时驱动.md)。
|
||||
|
||||
17. 工程工作台:独立滚动的结构/运动/标定页签,固定预览与底部操作区;层级搜索、轴定位、相机预设;30 步草稿撤销/重做;单轴步进;无效输入保持上一个有效值并禁止应用/导出。
|
||||
|
||||
18. MGR-19:对话框第一步独立选择设计机床类型 XYZAC/XYZBC;控制面板切换不覆盖设计草稿。机型限制轴绑定、运动验证和标定。保存完整机床 GLB,供控制面板“导入机床模型”自动识别机型并使用。
|
||||
|
||||
19. MGR-20:导入/应用跨机型模型时同步加载目标机型默认 G 代码,程序路径、源码、预览与运行机型保持一致;同机型标定保留兼容程序。不兼容轴程序在加载及执行入口拒绝。
|
||||
|
||||
20. MGR-21 文档管理:新建、打开已保存机床、编辑控制面板机床;模型名称/说明;保存 GLB 同时提供浏览器下载和本地机床库副本;刷新后从库恢复,多部件文件分组、节点路径、层级覆盖、显隐、关节和标定完整回填。
|
||||
|
||||
@@ -12,5 +12,16 @@
|
||||
10. `MGR-12`:按 GLB 原始 `Object3D.children` 显示树,支持根部件和内部节点拖放,选择节点显示父级局部原点;用 MILL700 六层结构验收。
|
||||
11. `MGR-14`:新增导入模型删除、树节点轴绑定和 XYZ/ABC 层级运动测试,输出局部/世界变换;MILL700 主轴链与工作台链验收。
|
||||
12. `MGR-15`:树选择关节后,将 XYZ 辅助轴挂在该节点局部原点,并验证轴系跟随关节及父链旋转。
|
||||
13. `MGR-16`:将根节点部件变换和内部节点 `nodeTransforms[path]` 分开保存;编辑当前节点相对父级的位置和姿态,验证子节点继承、父兄节点不受影响、拖放换父级后保持世界姿态,并验证组合 GLB 与 AC/BC 应用结果。
|
||||
|
||||
每轮只推进任务矩阵中依赖已满足的任务。代码完成后必须同步更新 03、04、05;若改变数据契约或坐标定义,先更新 06。
|
||||
|
||||
14. `MGR-17`:定义单自由度 JSON/Schema;收敛 UI 输入;增加单文件层级应用和 GLB 恢复;接入实际 WASM 关节位置;验证每轴、反向、物理零点、导出往返和锁定行为。
|
||||
|
||||
15. `MGR-18`:工作区布局与层级导航;草稿历史/资源生命周期;数值错误状态和机型标定校验;重复选中输入回归;桌面/窄屏/矮屏及 AC/BC 实时驱动验收。
|
||||
|
||||
16. `MGR-19`:把机型纳入草稿/撤销历史;添加首步机型选择;切换清理不适用倾转绑定;完整模型打开恢复机型;下载前调用控制面板读取器校验;验证从 AC 工作区导入 BC GLB 自动切换及 MILL700 两机型保存/运动闭环。
|
||||
|
||||
17. `MGR-20`:修正 applyGeometry 跨机型程序选择;加载前读取并验证目标程序;添加 Worker 轴兼容门控;测试双向模型导入、管理器应用、自定义程序保留、文件/编辑器拒绝和旧程序运行门控。
|
||||
|
||||
18. `MGR-21`:定义机床编辑文档元数据;重建原部件树再应用覆盖防止双重偏移;实现 IndexedDB 元数据/文件原子保存;文件页提供保存位置解释与恢复;测试刷新、再次保存等价、标准导入、旧版兼容、存储失败下载和 AC/BC 五轴回归。
|
||||
|
||||
@@ -71,10 +71,65 @@
|
||||
- 当前任务:已完成。
|
||||
- 下一步:按新需求继续;本专项不重复执行。
|
||||
|
||||
## R09 / 2026-09-21 / 部件平移与旋转实时同步
|
||||
## R10 / 2026-09-21 / 部件节点相对父级变换
|
||||
|
||||
- 用户反馈编辑器中的“部件平移”和“部件旋转”改变后,预览中的对应 GLB 部件没有稳定更新。
|
||||
- 预览同步逻辑新增部件根节点变换步骤:每次状态同步都把 `translationMm` 转换为米写入导入部件根节点位置,把 `rotationDeg` 转换为弧度写入根节点姿态;当前选中内部关节时仍变换该关节所属的完整 GLB 部件。
|
||||
- 轴运动诊断随后在部件装配姿态上叠加,不覆盖编辑器已经设置的部件变换;组合导出的 geometry userData 同步保存平移和旋转值。
|
||||
- MILL700 输入 X 平移 100 mm、Z 旋转 20° 后,选中关节的世界原点和世界四元数均发生对应变化;8 部件 AC/BC 生产回归输入 X=10 mm、Z=15° 后,预览、导出和应用后的 geometry 分别保存 `translationMm[0]=10`、`rotationDeg[2]=15`。
|
||||
- MGR-16 verified;验收证据 E11。
|
||||
- 更正 R09(`63f726d`):上一轮错误地让内部节点输入移动整个 GLB 根。用户要求编辑当前选中节点,原 R09 的根节点验收不足以证明内部关节编辑正确。
|
||||
|
||||
- 用户明确要求“部件平移”和“部件旋转”修改当前选中节点相对其父节点的位置和姿态。
|
||||
- 根节点继续使用部件级 `translationMm`、`rotationDeg`;GLB 内部节点使用稳定源路径保存到 `nodeTransforms[path]`。输入值是当前节点的父级局部位置(mm)和 XYZ 欧拉姿态(deg),旋转中心是节点自身原点。
|
||||
- 预览克隆、组合导出和应用运行场景统一应用同一组局部节点变换;父节点运动时子节点继承,编辑子节点不会错误移动整个 GLB 根部件。
|
||||
- 拖放内部节点后保存当前局部姿态并重新计算新的父级局部姿态,保持拖放前的世界姿态。
|
||||
- MILL700 选择 `MILL700-A-主轴-JT3`,输入 X=120 mm、RZ=25° 后,节点局部位置和四元数分别变为 `0.120 m` 和 `25°`;8 部件 AC/BC 生产回归、导出和应用继续通过。
|
||||
- MILL700 补充断言:JT3 局部 Z 减少 44 mm 时 JT2 与兄弟滑轨不动、JT4 同步移动;RZ=25° 时 JT3 原点不动、JT4 绕该原点运动;父链预先旋转 20°,位移仍按父坐标系计算。切换选择和重开对话框保留输入;带编辑姿态的门节点换父级后世界姿态不变且能继续编辑。
|
||||
- MGR-16 verified;验收证据 E12。
|
||||
|
||||
## R11 / 2026-09-21 / 单自由度关节与控制器映射
|
||||
|
||||
- 每个节点固定或只绑定一个 XYZABC 轴;父级 x/y/z 方向和 ±1 可配,平移/旋转类型及单位由数控轴确定。旧自由位置/姿态编辑改成单值关节输入。
|
||||
- 新增 `machine-joints.ts`:版本化节点与控制器映射、严格校验、零姿态驱动;预览与运行共用。
|
||||
- `makeJointAssembly` 保留已有模型层级,支持 MILL700 单文件配置完整五轴后应用;新配置不再要求八个独立 GLB。
|
||||
- 实际床身节点、控制底座和场景写入关节 JSON,兼容原 URDF 与机床几何 userData。导出显式 Scene extras;GLB 重导入恢复轴绑定、方向、物理零点和标定。
|
||||
- App 快照接入对话框实时跟随;运行模型通过实际 WASM 关节名映射驱动,并发布同 tick 的轴输入/模型变换诊断。
|
||||
- 大型关节模型运行关闭 MSAA 和实时阴影,减轻软件 WebGL 绘制开销。
|
||||
- 数学/JSON 校验、八部件 AC/BC 回归与完整 MILL700 AC/BC 实时/导出往返验证均通过;每机型 8 个不同 tick,五个轴均变化,浏览器 warning/error=0。MGR-17 verified;证据 E13。
|
||||
|
||||
## R12 / 2026-09-21 / 工程化工作台体验
|
||||
|
||||
- 三维管理器改为固定预览区 + 独立编辑区:结构与关节、运动验证、机床标定三个页签;窄窗口和矮窗口保持底部操作区可见。
|
||||
- 增加节点搜索、展开/折叠、选择后滚动定位、XYZABC 轴绑定总览和整机/选中/正视/俯视按钮。
|
||||
- 增加 30 步撤销/重做;删除、导入、轴绑定、方向、物理零点、标定和显隐变化都进入草稿历史。实时跟随锁定编辑,退出后恢复编辑。
|
||||
- 数值输入支持空值/非法值标红,错误不会写入状态;AC/BC 禁止沿倾转轴的非法偏置;批量导入失败保持现有装配。
|
||||
- 单自由度输入提供步长 `0.1/1/10` 和本轴归零,所有运动仍使用父级方向和物理零点。
|
||||
- E14 工作台浏览器验收通过:搜索、总览定位、撤销/重做、错误输入、标定校验、视图、删除恢复、导入失败保护、实时锁定和 1440/1154/720 视口无溢出。
|
||||
|
||||
- 最终 AC/BC 完整回归分别通过,各 8 个动态 tick;线上 E14 工作台回归通过。已发布 `a7233bc`,资源 `index-nddrUCCV.js`。
|
||||
|
||||
## R13 / 2026-09-21 / 对话框选择机床类型与保存整机
|
||||
|
||||
- 设计机型从外部控制面板属性改为草稿字段,标题、轴总览、绑定选项、标定与运动输入统一使用所选设计类型。顶部按选择类型→配置结构→验证标定→保存 GLB 引导。
|
||||
- AC/BC 切换只清理不适用的 A/B 轴绑定,保留模型层级、XYZ/C 和标定,并提示重新配置、核对;整个切换可撤销/重做。外部工作区切换不再改写草稿。
|
||||
- 打开已保存完整机床作为单一文档事务,自动恢复文件中的机型、关节、层级、物理零点、RTCP 参数;原草稿可撤销恢复。普通 GLB 部件导入仍为追加。
|
||||
- 保存入口改为“保存机床模型 GLB”,使用 `inspectMachineGlb` 和 32 MiB 限制校验结果,确保标准导入器可读取;成功消息提示去控制面板“导入机床模型”加载。
|
||||
- E15 针对跨机型闭环测试通过:在 AC 控制器下设计 BC、切换撤销、保存、重新打开、控制面板标准导入自动识别 BC。MILL700 两机型实测通过,分别 8 个同时五轴运动 tick、浏览器 warning/error=0。保存两份完整 GLB 验收示例;MGR-19 verified。
|
||||
|
||||
## R14 / 2026-09-21 / 机型与 G 代码匹配
|
||||
|
||||
- 根因:模型导入/管理器应用经 `applyGeometry` 更换工作区后,仍使用旧 `programText/programPath` 重载,导致 BC 运行 AC 程序。
|
||||
- 跨机型时在工作区提交前准备目标机型 catalog 默认程序,验证资源和轴兼容性;提交后同步程序编辑器、路径、预览和 Worker。同机型保留兼容自定义程序。
|
||||
- 新增共享 `programMachineMismatch`:检查文件机型标识和有效块中的不支持 A/B 轴字,忽略注释、命名参数、O 标签及表达式内部。通用三轴程序继续可用,不改写 A/B 指令。
|
||||
- Worker 在 PROGRAM_LOAD 以及 RUN/STEP/RESUME 检查源程序与展开子程序;覆盖绕过 UI 和旧恢复程序。门控缓存源文本/机型/路径,避免每帧重复扫描大程序。
|
||||
- E16 本地验证通过:AC→BC、BC→AC、管理器应用、错轴文件/编辑器拒绝、同机型保留、自定义程序与正确 BC 运行。11 个可加载 RTCP 目录程序、程序替换和单步回归通过。
|
||||
|
||||
## R15 / 2026-09-21 / 保存位置与机床文档恢复
|
||||
|
||||
- 原“保存”只有浏览器下载,无法从网页看到保存位置;新增明确下载说明和本浏览器机床库(IndexedDB),显示名称、机型、时间、大小、部件/关节数和校验摘要。
|
||||
- 增加文件与恢复页、新建机床、打开已保存机床、编辑控制面板机床、模型名称与设计说明、保存版本打开/再下载/删除。新建/打开仍可撤销。
|
||||
- 增加 `linuxcnc.machine-editor/1` GLB 元数据:原部件配置与源节点零姿态拓扑,打开时根据持久节点 ID 重建原始部件树,再应用编辑覆盖,避免合并分组与双重变换。校验恢复结果与运动 JSON 一致。
|
||||
- 文件和元数据在 IndexedDB 一次事务提交;写入失败仍允许下载并明确提示。库打开校验 SHA256;库副本独立于磁盘文件,清站点数据会删除库记录。
|
||||
- 8 部件文件往返测试已通过:刷新后逐项回填、再次保存元数据完全等价、控制面板导入再送回编辑、旧 GLB 兼容、存储失败反馈。MILL700 AC/BC 完整回归通过,各 8 个五轴动态 tick,浏览器错误 0。工作台布局与八部件生产回归通过。MGR-21 verified。
|
||||
|
||||
## R16 / 2026-09-22 / 文件选择器取消路径
|
||||
|
||||
- 修复原生文件选择器 Cancel/项目 Escape 与父 native dialog 的取消事件串扰;文件选择期间父对话框以 cancelGuard 保持打开。
|
||||
- 三维管理“导入 GLB 部件”“打开已保存机床”、机床导入模型和机床参数导入统一保护;取消后草稿、选择节点、页签和配置不变。
|
||||
- E18 工作台回归新增真实 filechooser 取消验证,浏览器错误 0。
|
||||
|
||||
@@ -17,4 +17,11 @@
|
||||
|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|
|
||||
|MGR-16 部件平移旋转同步|verified|MGR-05,MGR-14|编辑“部件平移/部件旋转”后对应 GLB 部件根节点实时改变位置和姿态;内部关节选择不改变变换归属;预览、导出和 AC/BC 应用保持一致|
|
||||
|MGR-16 部件节点相对父级变换|verified|MGR-05,MGR-14|编辑树中当前节点的“部件平移/部件旋转”后,该节点相对父级的位置和姿态改变;父节点与兄弟节点不被误改;子节点继承;预览、导出和 AC/BC 应用保持一致|
|
||||
|
||||
|MGR-17 单自由度与运行轴映射|verified|MGR-16|每节点固定或唯一 XYZABC 自由度;单值编辑;JSON 保存到场景/底座;单 GLB 完整应用;导出往返与 AC/BC 实时轴映射验收|
|
||||
|MGR-18 工程化工作台体验|verified|MGR-17|层级搜索与定位、绑定总览、撤销/重做、输入校验、步骤页签、视图预设、单轴步进、实时跟随锁定、响应式布局和失败导入保护通过|
|
||||
|MGR-19 对话框机型与保存整机|verified|MGR-18|先在对话框选择设计机型;绑定/标定受机型约束;草稿独立/可撤销;保存完整 GLB 可由控制面板导入并自动切换机型|
|
||||
|MGR-20 机型程序一致性|verified|MGR-19|导入/应用跨机型时加载目标默认程序;同机型保留;错轴程序加载/运行/单步/继续拒绝;AC↔BC 浏览器及 Worker 验收通过|
|
||||
|MGR-21 保存位置与完整文档恢复|verified|MGR-20|命名 GLB 下载与本地机床库;显式打开/刷新恢复;多部件配置精确回填;二次保存等价;标准导入、旧版兼容、存储失败和 AC/BC 回归|
|
||||
|MGR-22 文件选择器取消路径|verified|MGR-18|三维管理、机床导入、几何参数三类文件选择器取消/回焦不关闭父对话框;没有文件时保持配置和焦点|
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
- 命令:`PLAYWRIGHT_MODULE=/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs node scripts/test_machine_manager_browser.mjs`
|
||||
- report_id:`GLB-MGR-E01`
|
||||
- 报告:`coverage/glb-machine-manager/browser.report.json`
|
||||
- SHA-256:`486108398c757421d59fd5311239675b82d6c688458975b7c613118920ceafbf`
|
||||
- SHA-256:`a24ae854c1817f13b2c6c4fc03d33b855952e92eaa54f900482eaba9f3acd6de`
|
||||
- 状态:verified;浏览器错误 0。
|
||||
- 覆盖:8 部件导入和预览、原点显隐、数值/TransformControls 变换、组合 GLB 导出、AC/BC 应用、底座 userData、物理零点、非法层级拒绝、BC 五轴程序。
|
||||
- 截图:
|
||||
- `coverage/glb-machine-manager/manager-ac-config.png`,SHA-256 `b2fc2e00497ddb1e72e3170ee83ed54c21c8da9099036d84eeb4d6d6eef8619b`
|
||||
- `coverage/glb-machine-manager/managed-bc-running.png`,SHA-256 `4b2d76cd505f1e31847d09650b052a0a9c393515cb7c71ad55ac649072634005`
|
||||
- `coverage/glb-machine-manager/manager-ac-config.png`,SHA-256 `ddfc10ebc03d0c72860ce4f21a3c7e290f9c8eeaa9dcaa97ff0a1c8b3e2bc860`
|
||||
- `coverage/glb-machine-manager/managed-bc-running.png`,SHA-256 `f05cd2f3701c68fe82c5c57d6aa92b54c92d6783d1a395efb5fe96ec5a9bfe4f`
|
||||
|
||||
## E03 / R02 / 生产构建浏览器闭环
|
||||
|
||||
@@ -25,12 +25,12 @@
|
||||
- 命令:`MACHINE_MANAGER_SOURCE_MODULES=0 ... node scripts/test_machine_manager_browser.mjs`
|
||||
- report_id:`GLB-MGR-E02`
|
||||
- 报告:`coverage/glb-machine-manager/browser-production.report.json`
|
||||
- SHA-256:`7ea2ba7c09a2480973e63aedb8da1b8b0d46914eb86519781d99b718c5d3fca6`
|
||||
- SHA-256:`9549e8e731803c86780b1113afda3adb78e8846626bf4b6006d8c9241ac457a9`
|
||||
- 状态:verified;生产资产执行 AC/BC UI 应用和 BC WASM 程序运动,浏览器错误 0。
|
||||
|
||||
## E04 / R02 / 回归与静态契约
|
||||
|
||||
- `node scripts/test_axis_control_inventory.mjs`:verified,99 个文档化控件。
|
||||
- `node scripts/test_axis_control_inventory.mjs`:verified,114 个文档化控件。
|
||||
- `node web/react/src/test_dual_canvas_contract.mjs`:verified。
|
||||
- `node web/react/src/test_xyzac_threejs.mjs`:verified。
|
||||
- `npm --prefix web/react run typecheck`:通过。
|
||||
@@ -46,7 +46,7 @@
|
||||
- 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 `8943b16fe774427306ccafb5dcbc9817bc87538edbf67bbd5807dd59bd700d8b`。
|
||||
- 截图:`coverage/glb-machine-manager/mill700/mill700-studio-lighting.png`,SHA-256 `98aa3915cc80255467c6be42d40da685ef43d4ed91441ec051e1830a8d5fafd4`。
|
||||
|
||||
## E06 / R04 / Z-up 坐标
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
- 节点选择:`MILL700-A-主轴-JT2` 显示相对父节点原点 `-3.969 / -204.276 / 405.633 mm`。
|
||||
- 拖放:`MILL700-A-门1-JT1` 拖到 `MILL700-A底座` 后,草稿父路径等于目标稳定源路径;原始 GLB SHA-256 保持 `bf37130681cc6462412e52744519516e8ade2a3212dd69153c0b1b3e415bcf66`。
|
||||
- 八个独立 GLB 的根树节点拖放通过;随后 AC/BC 应用、导出和程序运动回归仍通过。
|
||||
- 控件契约:101 个控件,新增 `machine-tree-node` 和 `machine-tree-origin`;TypeScript、生产构建、双画布测试通过,浏览器错误 0。
|
||||
- 控件契约:114 个控件,包含节点相对位置/姿态输入 `machine-manager-translation`、`machine-manager-rotation`;TypeScript、生产构建、双画布测试通过,浏览器错误 0。
|
||||
|
||||
## E08 / R06 / WebGL framebuffer 稳定性
|
||||
|
||||
@@ -87,12 +87,114 @@
|
||||
- 三轴箭头组的局部 position=(0,0,0)、quaternion=identity,父对象为所选 GLB 节点;X 红/Y 绿/Z 蓝且按整机尺寸缩放,表示关节自己的局部轴并继承完整父链。
|
||||
- 节点选择和轴值驱动期间 `context-generation=1`,浏览器 warning/error=0。
|
||||
|
||||
## E11 / R09 / 部件平移旋转同步
|
||||
## E12 / R10 / 部件节点相对父级变换
|
||||
|
||||
- 替代 E11 的根节点测试结论;本轮同时验证内部关节及其父、兄弟、子节点。
|
||||
|
||||
- MILL700 命令:`PLAYWRIGHT_MODULE=/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js MACHINE_MANAGER_URL=http://127.0.0.1:5180/ node scripts/test_machine_manager_mill700_browser.mjs`。
|
||||
- MILL700 report_id:`GLB-MGR-E05`;报告 SHA-256:`1a9489e9df2633f54de395a4a116d4726a56cc0bfc8f2115681e7340e52b9fbf`。
|
||||
- MILL700:输入部件 X 平移 `100 mm` 后,选中根节点世界 X 坐标增加 `0.1 m`;输入 Z 旋转 `20°` 后,选中根节点世界四元数发生变化;132 节点、126 网格和局部坐标系检查继续通过,浏览器 warning/error=0。
|
||||
- MILL700 report_id:`GLB-MGR-E05`;报告 SHA-256:`3555f9efe0cebba79a0bec8afbff8a4a44cc1278d0f36ff737fae9a74e18b9e1`。
|
||||
- MILL700:先验证根节点 X 平移 `100 mm`、Z 旋转 `20°`;再选择内部节点 `MILL700-A-主轴-JT3`,输入相对父级 X=`120 mm`、Z 从 `368.925 mm` 减少到 `324.925 mm`、RZ=`25°`,预览发布的 selected transform 为 local X=`0.120 m`、local quaternion 对应 `25°`;132 节点、126 网格和局部坐标系检查继续通过,浏览器 warning/error=0。
|
||||
- AC/BC 生产回归命令:`PLAYWRIGHT_MODULE=/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js MACHINE_MANAGER_SOURCE_MODULES=0 MACHINE_MANAGER_URL=http://127.0.0.1:5180/ node scripts/test_machine_manager_browser.mjs`。
|
||||
- AC/BC report_id:`GLB-MGR-E02`;报告 SHA-256:`7ea2ba7c09a2480973e63aedb8da1b8b0d46914eb86519781d99b718c5d3fca6`;浏览器错误 0。
|
||||
- 8 部件验证:输入部件 X 平移 `10 mm`、Z 旋转 `15°` 后,预览中的选中部件位置和姿态变化;应用生成的 geometry userData 保存 `translationMm[0]=10`、`rotationDeg[2]=15`,随后 XYZAC 切换 XYZBC 和 BC 五轴 WASM 程序运行通过。
|
||||
- 截图:`coverage/glb-machine-manager/mill700/mill700-studio-lighting.png`,SHA-256 `2b40f1e6a92e09d54bde17543686cc2fcecee3e2b7d7d3d71296a63737a85c8d`;AC 配置 `coverage/glb-machine-manager/manager-ac-config.png`,SHA-256 `b2fc2e00497ddb1e72e3170ee83ed54c21c8da9099036d84eeb4d6d6eef8619b`。
|
||||
- AC/BC report_id:`GLB-MGR-E02`;报告 SHA-256:`9549e8e731803c86780b1113afda3adb78e8846626bf4b6006d8c9241ac457a9`;浏览器错误 0。
|
||||
- 8 部件验证:根部件输入 X 平移 `10 mm`、Z 旋转 `15°` 后,预览中的根节点位置和姿态变化;应用生成的 geometry userData 保存 `translationMm[0]=10`、`rotationDeg[2]=15`,随后 XYZAC 切换 XYZBC 和 BC 五轴 WASM 程序运行通过。
|
||||
- 截图:`coverage/glb-machine-manager/mill700/mill700-studio-lighting.png`,SHA-256 `98aa3915cc80255467c6be42d40da685ef43d4ed91441ec051e1830a8d5fafd4`;AC 配置 `coverage/glb-machine-manager/manager-ac-config.png`,SHA-256 `ddfc10ebc03d0c72860ce4f21a3c7e290f9c8eeaa9dcaa97ff0a1c8b3e2bc860`。
|
||||
|
||||
- 新增内部网格验收:mesh_0 相对父级 X=35 mm、RZ=30°;根部件仍为 X=10 mm、RZ=15°。直接解析导出 GLB 的节点 translation/rotation 并检查应用后 nodeTransforms 元数据,均一致。
|
||||
- JT3 的父、兄弟、子节点编辑前后位置与四元数记录于 MILL700 报告的 `nodeEditing`;验证重选、重开对话框、编辑后换父级和再次输入。
|
||||
|
||||
- 线上 E12:`https://49.232.250.35:58015/` 已发布代码 `91ebd56`,资源 `assets/index-CJLtuazD.js`。以线上 URL 重跑 MILL700(含父/兄弟节点隔离、子节点继承、换父级和重开对话框),全部通过、浏览器错误 0。发布报告:`coverage/glb-machine-manager/parent-transform-deployment.report.json`(`GLB-MGR-E12-CLOUD`)。
|
||||
|
||||
## E13 / R11 / 单自由度、JSON 与实时轴映射
|
||||
|
||||
- 数学与错误边界:`node scripts/test_machine_joint_model.mjs`;`GLB-MGR-E13-MATH`,报告 `coverage/glb-machine-manager/joint-dof/math.report.json`。验证父级旋转、非单位零姿态旋转、正负号、mm/deg、物理零点、100 次更新无累积、错误 JSON、缺失轴值整帧拒绝。
|
||||
- 完整浏览器命令:`MACHINE_MANAGER_URL=http://127.0.0.1:5180/ node scripts/test_machine_joint_dof_browser.mjs`;`GLB-MGR-E13`,报告 `coverage/glb-machine-manager/joint-dof/browser.report.json`。
|
||||
- AC/BC 分别导入 MILL700,五个关节独立绑定;Z 轴改方向并反向,固定节点锁定,无六分量输入和自由操纵器;重复轴拒绝,子节点继承、父兄节点保持不变。
|
||||
- 导出 JSON 与 GLB 场景 extras、实际床身底座 extras 内容一致;GLB 重新导入管理器恢复方向和物理零点,标准“导入机床模型”也可载入;单 GLB 可应用到控制面板。
|
||||
- 同时五轴程序:`M428; G21 G90 G94 G17 G40 G49; G1 X20 Y10 Z10 A/B20 C30 F120; G1 X-10 Y-5 Z-5 A/B-10 C-20 F120; M2`。每种机型采样 8 个不同渲染 tick,逐轴校验实际 WASM 关节值减物理零点、方向符号及父级局部运动。
|
||||
- 示例:`coverage/glb-machine-manager/joint-dof/xyzac.json`、`xyzbc.json`;截图 `xyzac-live.png`、`xyzbc-live.png`;Schema 用 Python `jsonschema` 对两份实例验证。
|
||||
- 八个独立部件回归:`scripts/test_machine_manager_browser.mjs` 已更新单自由度 UI;生产和源模式均通过,旧 URDF 驱动兼容性检查保留。
|
||||
- 控件清单 115 项;双画布、XYZAC Three.js 契约、TypeScript 和生产构建通过。
|
||||
|
||||
- E13 最终状态:verified。XYZAC/XYZBC 各 8 个不同渲染 tick,五个轴均发生变化;浏览器 warning/error=0。此前 E01–E12 为历史验证记录,本次现行输入契约以 E13 为准。
|
||||
|
||||
|E13 文件|SHA-256|
|
||||
|---|---|
|
||||
|`joint-dof/browser.report.json`|`7b92b906fcdce8f30a9d22e0a8f9ac116b2fd3ebe3b5eef60aa770d134d7f6b7`|
|
||||
|`joint-dof/math.report.json`|`ea994774e16ea5b506d028c8f7bcd3ff19fe8d3e2514aaa8447b870738fb07f4`|
|
||||
|`joint-dof/controls.report.json`|`03a734e9a9b09610818a010dea90eb9ba196b01dfbd3c9e4d21501c8baa0f6c8`|
|
||||
|`joint-dof/xyzac.json`|`99fa305279f05a331916138274c1bc1586ca4d4bdc93a1e34848fee160fe3fda`|
|
||||
|`joint-dof/xyzbc.json`|`5e59370657eff67156f4a6338897e35e73c0b3bd3f955345a2698b05b88ea522`|
|
||||
|`joint-dof/xyzac-live.png`|`6e4574082a11703c2b537e452aad039293100bb22a6420c2b7551d02ffef44fe`|
|
||||
|`joint-dof/xyzbc-live.png`|`51fcd171ac07ad67ef6c1d733c098f24e1938474e1de2c1322701eb33ed49647`|
|
||||
|
||||
- E13 发布:功能代码 `d848864`,线上资源 `assets/index-DZs5acdt.js`,`https://49.232.250.35:58015/`。线上八部件 AC/BC 单自由度配置、GLB 导出应用与 BC WASM 程序回归 verified、浏览器错误 0;MILL700 完整五轴/JSON 往返验收为同一生产构建的本地浏览器测试。发布报告 `joint-dof/deployment.report.json`(`GLB-MGR-E13-CLOUD`)。
|
||||
|
||||
## E14 / R12 / 工程化工作台体验
|
||||
|
||||
- 命令:`node scripts/test_machine_manager_workbench.mjs`。
|
||||
- report_id:`GLB-MGR-E14`;报告:`coverage/glb-machine-manager/workbench/browser.report.json`;SHA-256:`5be35a6bac22b90854f6d001400ceec48b92f2060e2b2edaeb8f80e5fb3bc4eb`。
|
||||
- 覆盖:深层节点搜索、搜索展开、轴总览定位、撤销/重做、空数值输入校验、TRT 非法偏置阻止应用/导出、视图预设、删除撤销、坏文件批量导入保护、实时跟随锁定、桌面/矮窗口/窄窗口布局。
|
||||
- 截图:`workbench/desktop.png` SHA-256 `876c2cf6d1edf7093752510561973117f8de9b99ac48a14654aee9527b07d533`;`workbench/viewport-1154.png` SHA-256 `90a32870ff473e05aee4caeb2cc84cc0c3144882463893b2ac5fc2e4b95efbc1`;`workbench/viewport-720.png` SHA-256 `7ca1f6010a51274659d663fb72f85c5216de869746976db33c5c007e47a6d964`。
|
||||
- 数学历史测试:`node scripts/test_machine_manager_history.mjs`,`GLB-MGR-E14-HISTORY` verified。
|
||||
|
||||
- 最终回归:`workbench/xyzac-regression.report.json`、`xyzbc-regression.report.json` 均 verified,各 8 个运动 tick,含模型 JSON/GLB 往返、轴符号、零点、实时跟随和五轴运动。输入框选中切换使用绘制前同步,重复填值不会拼接。
|
||||
- 线上 `https://49.232.250.35:58015/` 已发布功能代码 `a7233bc`,资源 `assets/index-nddrUCCV.js`;E14 工作台完整回归 verified,浏览器错误 0。发布证据:`workbench/cloud.report.json`、`workbench/deployment.report.json`。
|
||||
|
||||
## E15 / R13 / 对话框机型与保存整机
|
||||
|
||||
- `node scripts/test_machine_manager_type.mjs`:GLB-MGR-E15 verified;在 AC 控制面板下设计 BC、机型切换/撤销/重做、工作区不覆盖草稿、完整机床打开恢复、直接应用及标准 GLB 导入自动切换为 BC。
|
||||
- `JOINT_TEST_OUTPUT=.../coverage/glb-machine-manager/machine-type/mill700 SAVE_MACHINE_MODELS=.../coverage/glb-machine-manager/machine-type/saved-models node scripts/test_machine_joint_dof_browser.mjs`:MILL700 AC/BC 各 8 个动态 tick,验证 GLB 保存/重开/导入、标定、轴映射、单自由度及实时跟随,浏览器错误 0。
|
||||
- 八部件生产回归、工作台布局/撤销/搜索回归、类型检查、构建、机型历史、控件契约 134 项和双画布检查通过。
|
||||
- 保存入口下载前用 `inspectMachineGlb` 校验完整 GLB;所有测试机型通过标准“导入机床模型”。
|
||||
|
||||
|文件(相对 coverage/glb-machine-manager/machine-type)|SHA-256|
|
||||
|---|---|
|
||||
|`browser.report.json`|`5f45884a75acf14d7a3ecff6a9a50b306842216cb2c26f91d44a781aff3f0073`|
|
||||
|`dialog-type.png`|`c24f8b3fd445cf5e886b8189e13e0830e6fecf341e57499e7cf73528fd803b9d`|
|
||||
|`controller-import.png`|`071f8d9a9ac4ac85fe64bfcd51f1fb605112515eb4aaa0e00ceacd539347d67b`|
|
||||
|`mill700/browser.report.json`|`7f5c87b1a2d5491242aa87248d0cb4085e5d849065520ad56e4e64c6453216b2`|
|
||||
|`saved-models/MILL700-xyzac.glb`|`25ba366a6550c3a613a63623b8e25216d5a3a6dd2781b69481d80d0bb87033e4`|
|
||||
|`saved-models/MILL700-xyzbc.glb`|`3b87cb8504815c13127608c513eacb3536e1be1c10f9a58fcd59920939fbe0c1`|
|
||||
|
||||
- E15 线上验证通过:`https://49.232.250.35:58015/`,代码 `338500d`,资源 `index-C8kh60HL.js`;独立选型、切换撤销、保存完整 GLB、标准导入自动切换及物理零点恢复全部 verified、浏览器错误 0。报告 `machine-type/cloud.report.json`、`machine-type/deployment.report.json`。
|
||||
|
||||
## E16 / R14 / 机型与 G 代码一致性
|
||||
|
||||
- `node scripts/test_machine_program_compatibility.mjs`:TRT-PROGRAM-COMPAT-BROWSER verified;AC→BC 与 BC→AC 标准模型导入同步默认程序、管理器应用同步、改名错误轴文件拒绝、编辑器重载拒绝、同机型保留通用程序、回零后 BC 执行 BC 程序;浏览器 warning/error=0。
|
||||
- `node web/worker/test_trt_program_compatibility.mjs`:TRT-PROGRAM-COMPAT-WORKER verified;两机型分别检查零值/参数轴指令、忽略注释与参数名称、加载拒绝不替换旧程序、旧状态 RUN/STEP/RESUME 拒绝、允许通用程序。
|
||||
- 回归:`test_rtcp_program_catalog_load.mjs`(11 个主程序);`test_xyzac_program_replace.mjs`;`test_xyzac_step_flow.mjs`,全部 verified。TypeScript 和生产构建通过。
|
||||
|
||||
|证据(相对 program-compatibility)|SHA-256|
|
||||
|---|---|
|
||||
|`browser.report.json`|`5f45884a75acf14d7a3ecff6a9a50b306842216cb2c26f91d44a781aff3f0073`|
|
||||
|`worker.report.json`|`986fb9e127894aaf477ac6e5879c06b7084a112c840549532d39fdd6d3e8694e`|
|
||||
|`catalog.report.json`|`ff2e32be3b006e2d8de902012ed89724aada54c425503027ae5e027eaf9cafe2`|
|
||||
|`replace.report.json`|`fe92778b144b226cea1e91393d10e4905cfd5becf56ba9b3021b5842514c8aa7`|
|
||||
|`step.report.json`|`a109eceb646fe5b974c3ac668150c19d2bbedfc2c59dd9ee702fdad46402738d`|
|
||||
|`bc-running.png`|`2d4f3a6e64a230155b761df4ef15db81b99b5664e8f5d9c71857e30939ac691a`|
|
||||
|
||||
- `node web/worker/test_boat_trt_programs.mjs`:AC/BC 船形程序均完整运行至 completed;报告 `program-compatibility/boats.report.json`。
|
||||
|
||||
- E16 已发布 `0de96bb`,线上资源 `index-VAG1dzJ6.js`。线上重复 AC↔BC 模型导入/管理器应用、错误轴加载拒绝、同机型保留和 BC 运行,全部 verified、浏览器错误 0;`program-compatibility/cloud.report.json`、`deployment.report.json`。
|
||||
|
||||
## E17 / R15 / 文件保存与编辑文档恢复
|
||||
|
||||
- `node scripts/test_machine_document_browser.mjs`:GLB-MGR-E17 verified。测试命名保存、浏览器库原子保存、刷新再打开八个部件、层级覆盖/轴反向/显隐/原点辅助/标定/说明逐项恢复;二次保存的三个元数据对象完全等价。
|
||||
- 显式磁盘打开、库再次下载字节一致、删除仅影响副本、损坏编辑数据拒绝且保持现有草稿、控制面板标准导入再回到编辑器、旧文件兼容、存储故障时继续下载并显示失败信息全部通过;浏览器错误 0。
|
||||
- `JOINT_TEST_OUTPUT=.../coverage/glb-machine-manager/document/mill700 node scripts/test_machine_joint_dof_browser.mjs`:MILL700 AC/BC 各 8 个动态 tick;导出/打开/标准导入及实时映射 verified。
|
||||
- `test_machine_manager_workbench.mjs` 和生产 `test_machine_manager_browser.mjs` 通过;静态类型、构建、历史、数学、134→146 控件清单、双画布/XYZAC 契约通过。
|
||||
|
||||
|文件(相对 document)|SHA-256|
|
||||
|---|---|
|
||||
|`browser.report.json`|`5f45884a75acf14d7a3ecff6a9a50b306842216cb2c26f91d44a781aff3f0073`|
|
||||
|`files-and-recovery.png`|`cef9836f21a56b79916ea0b206e3013553688f988947b701ac68332dffb93af4`|
|
||||
|`saved-eight-parts.glb`|`57e268f7278119493004848b97262a1042ada6f81f30103b72f2fb0a8db66456`|
|
||||
|`mill700/browser.report.json`|`7f5c87b1a2d5491242aa87248d0cb4085e5d849065520ad56e4e64c6453216b2`|
|
||||
|
||||
- E17 已发布功能代码 `8443930`,线上资源 `index-EHYipPfn.js`;线上完整复测保存、刷新、库打开、多部件字段回填、标准导入、再次编辑、旧版文件及存储失败分支,全部 verified,浏览器错误 0。报告 `document/cloud.report.json`、`document/deployment.report.json`。
|
||||
|
||||
## E18 / R16 / 文件选择器取消
|
||||
|
||||
- `node scripts/test_machine_manager_workbench.mjs`:report `GLB-MGR-E14` 更新,首项检查真实 `filechooser` 打开后取消,三维机床管理对话框仍保持打开;随后搜索、撤销、标定、布局回归继续通过,浏览器错误 0。
|
||||
- 三个文件入口统一保护:管理器 GLB/已保存机床、控制面板机床模型、机床几何 INI。
|
||||
- 工作台报告 SHA-256:`0f6db50633be732a4b65162aff3373eccb23cfe319a1dd7d19fa4260098bde64`。
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
|
||||
## D03 / 编辑层级自由,应用层级受求解器限制
|
||||
|
||||
UI 允许用户选择父级以建立层级和发现错误;应用时只接受底座→X→Y→Z→刀具及底座→A/B→C→工件。当前 WASM 只支持这两类 TRT,不能把任意树伪装成可执行机床。
|
||||
UI 允许用户选择父级以建立层级和发现错误;应用时只接受底座→X→Y→Z→刀具及底座→A/B→C→工件。当前 WASM 只支持这两类 TRT,不能把任意树伪装成可执行机床。MGR-17 起允许任意视觉层级按单自由度绑定五轴,数控仍使用所选 AC/BC TRT;八个独立文件不再是应用前提。
|
||||
|
||||
## D04 / 原 GLB 不原地改写
|
||||
|
||||
@@ -52,6 +52,26 @@ GLB 树节点 ID 使用导入时的子节点索引路径,不使用可能重复
|
||||
|
||||
选中关节的坐标辅助必须作为该节点子对象放在局部 `(0,0,0)`,不可复制世界位置或使用整机坐标轴冒充。这样关节本身旋转时辅助轴同步旋转,父关节运动时又能自然继承父链,符合 GLB 层级矩阵语义。
|
||||
|
||||
## D14 / 部件编辑变换作用于 GLB 部件根节点
|
||||
## D14 / 部件编辑变换作用于当前树节点的父级局部空间
|
||||
|
||||
“部件平移”和“部件旋转”属于导入 GLB 的装配变换,作用对象是该 GLB 的外层部件根节点,因此选中内部关节时也移动或旋转该关节所在的完整部件。内部关节自己的轴绑定和 XYZ/ABC 诊断运动在此装配姿态之上叠加,不能把编辑器值误写到内部节点或用诊断输入覆盖编辑器值。导出和应用复用同一组 `translationMm`、`rotationDeg`,保证预览、组合 GLB 和运行机床保持一致。
|
||||
“部件平移”和“部件旋转”编辑当前选中的树节点。根节点的值保存为部件级 `translationMm`、`rotationDeg`;内部节点的值按稳定源路径保存为 `nodeTransforms[path]`,直接表示该节点相对当前父节点的位置和姿态。节点自身局部原点是旋转中心,子节点自然继承父节点变换,父节点和兄弟节点不因编辑子节点而改变。拖放改变父级后,先保持世界姿态,再重新计算局部值。导出和应用复用同一套局部变换,保证预览、组合 GLB 和运行机床一致。
|
||||
|
||||
## D15 / 单自由度约束与 JSON 运动契约(替代 D14 的自由编辑)
|
||||
|
||||
每个运动节点只能绑定一个 CNC 轴,每轴只绑定一个节点。固定装配节点无自由度。输入为相对物理零点的单个关节量;保留导入零姿态,禁止通过其他数值或自由操纵器绕过约束。节点身份使用稳定 ID,实际绘制以父级方向和正负号驱动。以 `linuxcnc.joint-model/1` 扩展既有 userData,不更换数控 RTCP 求解器;URDF 坐标框架与视觉装配分离以避免双重运动。临时测试姿态不保存为零姿态。
|
||||
|
||||
## D16 / 草稿可撤销、错误输入不执行
|
||||
|
||||
草稿历史只保存配置快照并共享源模型资源,上限 30 步;只有已脱离当前草稿和历史的资产才释放。关闭窗口保留会话草稿,完整持久保存使用导出 GLB,界面明确显示未应用/已应用/已导出状态。非法或空数值只存在于输入框,不驱动模型,不隐式归零;校验状态独立于操作消息显示。实时跟随时配置只读,数据不可用时保持最后姿态且允许退出。
|
||||
|
||||
## D17 / 设计类型属于机床文档
|
||||
|
||||
机型保存在 MachineDraft,控制面板当前类型只用于首次创建时的默认值和实时数据匹配。后续面板切换不得覆盖草稿。切换设计类型不自动把 A 轴重新命名为 B:保留通用轴及模型,清除不兼容绑定,提示重新配置,并允许撤销。标定保留并按新机型验证,不静默丢弃。完整机床 GLB 打开为一次替换事务,自动恢复文件机型,原草稿可撤销。保存 GLB 必须先通过标准导入器格式/机型校验;JSON 是辅助参数文件,完整可导入交付物为 GLB。
|
||||
|
||||
## D18 / 跨机型加载匹配程序,不转换旋转轴
|
||||
|
||||
工作区切换会改变解释器轴语义,不能继续加载旧机型的 G 代码。跨机型导入/应用自动使用目标机型 catalog 指定的默认程序,目标资源在提交配置前读取和校验;同机型标定保留兼容源程序。禁止通过把 A 文本替换为 B 冒充程序适配。加载和执行门控由 Worker 再次校验,注释和参数名不算轴指令。校验范围是 AC/BC 机型隔离,完整 G 代码解析仍由 LinuxCNC WASM 负责。
|
||||
|
||||
## D19 / 可移植 GLB 与浏览器副本
|
||||
|
||||
完整机床文档以 GLB 为交付文件;普通网页不能读取浏览器下载文件的绝对磁盘路径,因此界面明确告知查看下载记录。保存时另存完整 GLB 到当前站点 IndexedDB,并提供列表恢复,不暗示上传云端或跨设备同步。每次保存为独立副本,删除库记录不删除磁盘文件或当前草稿。原始编辑拓扑/零姿态保存为 `linuxcnc.machine-editor/1`,与运行关节 JSON 并存;精确恢复原始多文件装配后再施加覆盖,不重复叠加。旧文件缺少编辑元数据时按兼容装配恢复并保留轴/标定。临时关节试动不保存,未完成绑定的会话草稿目前不做跨刷新自动恢复。
|
||||
|
||||
68
docs/glb-machine-manager/07-单自由度JSON与实时驱动.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# 单自由度 JSON 与实时驱动
|
||||
|
||||
配置字段为 `userData.linuxcncJointModel`,版本 `linuxcnc.joint-model/1`。它与原 `linuxcncMachine`(URDF)和 `linuxcncMachineGeometry`(TRT 标定)一起保存在导出 GLB 的场景、运行底座及被标记为“床身底座”的实际模型节点中。GLB 将 userData 序列化为 extras。源 GLB 文件不覆盖。
|
||||
|
||||
## 对话框
|
||||
|
||||
1. 首先在对话框顶部选择设计机床类型 XYZAC/XYZBC(独立于当前控制面板),然后导入模型。选中层级节点,设置“唯一自由度”:X/Y/Z 是平移,A/B/C 是旋转;固定零件选择“固定(无自由度)”。每个运动节点只有一个自由度,每个数控轴只能绑定一个节点。
|
||||
2. 设置父级坐标系中的 X/Y/Z 运动方向和正负向。AC 模型可用 XYZAC,BC 模型可用 XYZBC;另一倾转轴禁用。
|
||||
3. 当前节点只有一个“位移/转角”可编辑数值,单位 mm/deg。固定节点不能编辑;已导入的局部位置、姿态作为零姿态保留。旧版六分量自由编辑和自由操纵器已被单自由度输入替代(MGR-17)。
|
||||
4. 层级运动测试与节点输入共用对应轴值,所有输入均为相对物理零点的运动量,不写数控系统。配置 JSON 和 GLB 保存零姿态、自由度和标定,不将临时测试姿态烘焙进去。
|
||||
5. “跟随数控系统实时关节位置”使用当前机型的有效 WASM 快照,锁定手动关节输入。控制面板正式模型始终由 WASM 实际关节位置驱动。
|
||||
6. 绑定齐五个轴后可应用机床、导出 GLB;单个已有层级的 GLB 也可应用。可单独导出 JSON。未完成绑定的 JSON 是草稿,控制器读取时必须通过完整性校验。
|
||||
7. 重新导入本工具导出的 GLB 到管理器,可恢复层级、自由度、正负向、C 原点、倾转轴偏置和物理零点。完整机床 GLB 单独打开时,自动恢复文件机型,原设计草稿可撤销恢复;控制面板“导入机床模型”自动切换到 GLB 指定机型。
|
||||
|
||||
## 字段契约
|
||||
|
||||
正式 JSON Schema:[joint-model.schema.json](joint-model.schema.json)。AC/BC 完整实例由验收程序生成,见 `coverage/glb-machine-manager/joint-dof/xyzac.json` 和 `xyzbc.json`。
|
||||
|
||||
| 字段 | 意义 |
|
||||
|---|---|
|
||||
| `schema` | 固定版本 `linuxcnc.joint-model/1` |
|
||||
| `coordinates` | `XYZAC` 或 `XYZBC` |
|
||||
| `units` | 设计长度 mm、角度 deg;GLB 节点实际长度 m |
|
||||
| `controller.source` | `linuxcnc-wasm.visual.joints`,取实际运动学关节值 |
|
||||
| `controller.axisOrder` | 实际快照数组的轴顺序,AC 为 `[X,Y,Z,A,C]`,BC 为 `[X,Y,Z,B,C]` |
|
||||
| `calibration.cOriginMm` | C 轴原点,沿用 TRT rot-point 参数 |
|
||||
| `calibration.tiltOffsetMm` | A/B 到 C 偏置,沿用 TRT offset 参数 |
|
||||
| `calibration.physicalZero` | 每个数控轴对应模型零姿态的实际读数 |
|
||||
| `axisMap` | 数控轴名到模型节点 ID,避免按名称或树排序推测关节 |
|
||||
| `nodes[].id / parentId` | 唯一节点 ID 与直接父级;根为 null |
|
||||
| `nodes[].source` | 源文件名、SHA-256 和导入路径,便于追溯 |
|
||||
| `nodes[].zero` | 相对父级的零姿态:位置 mm、四元数 `[x,y,z,w]`、比例 |
|
||||
| `nodes[].joint` | null 表示固定;对象表示唯一自由度 |
|
||||
| `joint.axis / type / unit` | 数控轴 / prismatic 或 revolute / mm 或 deg |
|
||||
| `joint.direction / frame` | 父级坐标系中的 x/y/z;frame 固定 parent |
|
||||
| `joint.sign` | +1 或 −1,控制模型运动与数控轴正向的关系 |
|
||||
| `joint.dof` | 固定为 1 |
|
||||
|
||||
Z 轴关节的配置片段:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "part-id:scene/0/2/1",
|
||||
"parentId": "part-id:scene/0/2",
|
||||
"name": "主轴 Z 滑台",
|
||||
"role": "z",
|
||||
"source": { "file": "MILL700-U_1.glb", "hash": "源文件SHA256", "path": "scene/0/2/1" },
|
||||
"zero": { "positionMm": [-20.804, -904.049, 368.925], "quaternion": [0,0,0,1], "scale": [1,1,1] },
|
||||
"joint": { "axis": "Z", "direction": "z", "sign": -1, "dof": 1, "type": "prismatic", "unit": "mm", "frame": "parent" }
|
||||
}
|
||||
```
|
||||
|
||||
## 驱动规则
|
||||
|
||||
控制器先根据 `controller.axisOrder` 把快照数组转换为轴名 → 实际位置,然后通过 `axisMap` 找到运动节点。GLB 节点上的 `userData.linuxcncNodeId` 对应 `nodes[].id`,不依赖可重名的中文模型名称。
|
||||
|
||||
设实际位置 q、物理零点 q0、正负方向 s,则位移或转角 d = s × (q − q0)。设 e 为 `joint.direction` 对应的父级单位向量:
|
||||
|
||||
- 平移:`p = zero.positionMm / 1000 + e * d / 1000`,四元数仍为零姿态。
|
||||
- 旋转:`Q = Quaternion(e, d * PI / 180) * zero.quaternion`,位置仍为零姿态,因此绕自身原点旋转。
|
||||
- 子节点继承父级世界矩阵;父级与兄弟节点不会因本节点运动改变。
|
||||
- 每次从零姿态求绝对变换,不进行增量累加,连续运行不产生累积漂移。
|
||||
|
||||
预览和运行复用 `machine-joints.ts` 的同一驱动函数。JSON 用于视觉关节映射;当前数控求解器仍为原生 AC/BC TRT,不根据任意视觉层级生成新的逆运动学。实际配置需要符合所选机床的机械结构、轴正向和标定;轴行程、速度和回零仍以控制器 INI 为准。
|
||||
|
||||
运行场景中的 URDF 控制坐标框架继续承担既有刀尖、刀长和刀路投影。导入模型的视觉装配位于固定底座下,仅由 JSON 关节绑定驱动,避免同一节点同时受到 URDF 和 JSON 两次运动。
|
||||
|
||||
读取时还要校验:节点 ID 唯一、父级有效且无环、每个节点最多一个自由度、轴绑定不重复、机型完整、轴索引一致、零姿态与标定为有限数值、四元数归一化。缺少实时轴值时整帧拒绝更新,避免只更新部分轴。
|
||||
38
docs/glb-machine-manager/08-保存与重新打开机床.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 保存与重新打开机床
|
||||
|
||||
## 保存在哪里
|
||||
|
||||
“保存机床模型 GLB”会生成包含几何、层级、机型、单自由度轴绑定、正负方向、零姿态和标定的完整文件:
|
||||
|
||||
- **磁盘文件**:发起浏览器下载。通常在系统“下载”目录;如果浏览器开启“每次询问保存位置”,则在用户选择的位置。浏览器下载记录可定位文件,网页不能获取其绝对路径,也不能验证用户是否取消了下载。
|
||||
- **本浏览器机床库**:保存成功时还会把相同 GLB 存入本网站的 IndexedDB。刷新页面后进入“文件与恢复”,可直接打开配置、重新下载或删除副本。列表显示保存时间、机型、大小及 SHA256 摘要,每次保存生成一个独立版本。
|
||||
|
||||
本地机床库不会自动上传云端;它只存在于同一浏览器、同一站点地址(包括协议和端口)。清理站点数据、隐私窗口退出或换设备会失去这些本地副本。因此磁盘 GLB 是重要的可移植保存方式。库保存失败时仍会下载 GLB,并显示失败信息。
|
||||
|
||||
## 再次打开和回填
|
||||
|
||||
1. 在“三维机床管理”点击“打开已保存机床”,选择之前下载的 GLB;或在“文件与恢复”的机床库点击“打开配置”。
|
||||
2. 新版保存文件恢复为原来的多个部件,回填原始部件名、父级关系、源节点路径、层级拖放覆盖、平移/姿态零位、节点结构角色、轴绑定、方向、正负向和显隐/坐标原点显示。
|
||||
3. 顶部自动恢复 XYZAC/XYZBC,标定页恢复 C 原点、A/B 偏置、物理零点;文件页恢复模型名称、设计说明。轴总览可定位相应节点。
|
||||
4. 打开操作替换当前草稿,原草稿可通过撤销恢复。关节试动值归零,不作为机床设计零姿态保存。
|
||||
5. “编辑控制面板机床”将控制面板已加载的受支持配置回填到管理器,不需要再次找磁盘文件;后续编辑不会自动改变正在运行的机床,仍需显式应用。
|
||||
6. 名称和说明在“文件与恢复”中修改;保存后生成新的 GLB 和本地版本。默认下载名为 `managed-xyzac.glb` 或 `managed-xyzbc.glb`,命名后为 `名称-xyzac.glb` 或 `名称-xyzbc.glb`。
|
||||
|
||||
旧版本机床 GLB 若缺少完整编辑数据,仍恢复已有装配层级、轴配置和标定;无法凭空恢复已丢失的原始多文件分组。普通几何 GLB 使用“导入 GLB 部件”,完整机床使用“打开已保存机床”。
|
||||
|
||||
## 提供给控制面板
|
||||
|
||||
控制面板关机且程序空闲 → “导入机床模型” → 选择保存的 GLB。文件内的机型会决定控制配置;跨 AC/BC 会加载对应机型默认程序。完成后重新回零再执行程序。
|
||||
|
||||
## 元数据
|
||||
|
||||
- `userData.linuxcncMachine`:原 URDF 控制框架。
|
||||
- `userData.linuxcncMachineGeometry`:C 原点、偏置、物理零点与部件配置。
|
||||
- `userData.linuxcncJointModel`:运行关节 ID、父链与轴映射。
|
||||
- `userData.linuxcncMachineEditor`:`linuxcnc.machine-editor/1`,包含 `info.name/description`、每个部件的 `settings`、原始 `sourceNodes`。源节点记录稳定路径、原点位置(米)、四元数、比例、名称和可见性。GLB 节点的 `linuxcncNodeId` 负责匹配实际几何。
|
||||
|
||||
加载新版文件时会重建源节点拓扑,再复用原装配逻辑应用设置,并对比恢复后的关节 JSON,防止错绑或双重偏移。源 GLB 文件不原地修改。
|
||||
|
||||
## 当前边界
|
||||
|
||||
本轮提供完整机床的保存/打开/本地版本恢复。尚未绑定完整五轴的会话草稿、撤销历史、相机视角和临时试动值不会跨刷新自动保存;关闭对话框但不刷新页面仍保留草稿。云端共享、权限审批、碰撞检查及其他机型运动学不属于本轮实现。
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
现有整机 GLB + 内嵌 URDF 导入能力继续保留。新管理器生成与现有 `linuxcnc.machine/1` 相同的严格结构,再通过已有配置、Worker、WASM 和 Three.js 投影链运行。
|
||||
|
||||
当前状态:MGR-01 至 MGR-16 全部 verified,MILL700 实测覆盖照明、Z-up、132 节点树、拖放、节点原点、关节局部坐标系、部件平移旋转同步、模型删除、轴绑定、XYZ/AC 层级运动和 WebGL 资源降级。最新台账 R09,证据 `GLB-MGR-E01/E02/E05/E07/E08/E09/E10/E11` 位于 `coverage/glb-machine-manager/`。
|
||||
当前状态:MGR-21 文件管理与恢复 verified;MGR-20 机型程序一致性 verified;MGR-22 文件选择器取消 verified;MGR-19 对话框选择机型/整机保存已 verified;MGR-17、MGR-18 已 verified;MGR-01 至 MGR-16 已完成历史验收,MILL700 实测覆盖照明、Z-up、132 节点树、拖放、节点原点、关节局部坐标系、节点相对父级平移旋转、模型删除、轴绑定、XYZ/AC 层级运动和 WebGL 资源降级。最新台账 R15,证据 `GLB-MGR-E01/E02/E05/E07/E08/E09/E10/E12/E13/E14/E15/E16/E17/E18` 位于 `coverage/glb-machine-manager/`。
|
||||
|
||||
文档:
|
||||
|
||||
@@ -23,3 +23,8 @@
|
||||
- [06-决策记录](06-决策记录.md)
|
||||
|
||||
相关旧专项:[GLB / URDF 五轴机床子项目](../glb-machine/README.md)。旧专项已完成整机导入、AC/BC 识别、几何参数同步和实际关节驱动,本专项不重复这些底层能力。
|
||||
|
||||
- [07-单自由度JSON与实时驱动](07-单自由度JSON与实时驱动.md)
|
||||
- [关节 JSON Schema](joint-model.schema.json)
|
||||
|
||||
- [08-保存与重新打开机床](08-保存与重新打开机床.md)
|
||||
|
||||
11
docs/glb-machine-manager/configuration-driven-dialog.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 三维机床管理器配置入口
|
||||
|
||||
机床角色、默认父级、管理器支持的 TRT profile、导出限位、轴向、偏置约束及试动步长集中在 `web/workspace/machine-manager-settings.json`。`web/react/src/machine-manager-config.ts` 读取并检查该配置。
|
||||
|
||||
对话框按草稿坐标生成零点、运动测试和轴绑定总览,机型列表来自五轴案例目录。切换坐标后清除不兼容的绑定并初始化新轴零点,撤销可恢复原草稿。底层数控轴类型包含 XYZABCUVW。
|
||||
|
||||
边界:管理器生成完整 GLB 并应用的运行适配器目前仍为 XYZAC、XYZBC TRT;案例列表存在某坐标不代表管理器已支持该案例的结构及 RTCP。任意五个唯一轴的 JSON 校验也不代表重复轴、附加关节、机器人或并联机构已经完成三维适配。配置文件不能替代运动学适配代码。
|
||||
|
||||
2026-09-30 保存验证:使用暂存文件生成独立源码快照,TypeScript 检查和 Vite 生产构建通过。构建使用现有 node_modules、LinuxCNC 资源和本地 WASM 二进制;保留大 chunk 提示。本轮不把既有浏览器报告作为新验收证据。
|
||||
|
||||
脚本目录迁移、通用 WASM 运行时开发、原始大型 GLB 以及 LinuxCNC 子模块本地修改不包含在本次管理器配置提交中,仍留在工作区。
|
||||
475
docs/glb-machine-manager/joint-model.schema.json
Normal file
@@ -0,0 +1,475 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "urn:linuxcnc:joint-model:1",
|
||||
"title": "Web LinuxCNC single-DOF joint model",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema",
|
||||
"coordinates",
|
||||
"units",
|
||||
"controller",
|
||||
"calibration",
|
||||
"axisMap",
|
||||
"nodes"
|
||||
],
|
||||
"properties": {
|
||||
"schema": {
|
||||
"const": "linuxcnc.joint-model/1"
|
||||
},
|
||||
"coordinates": {
|
||||
"enum": [
|
||||
"XYZAC",
|
||||
"XYZBC"
|
||||
]
|
||||
},
|
||||
"units": {
|
||||
"const": {
|
||||
"length": "mm",
|
||||
"angle": "deg",
|
||||
"gltfLength": "m"
|
||||
}
|
||||
},
|
||||
"controller": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"source",
|
||||
"axisOrder"
|
||||
],
|
||||
"properties": {
|
||||
"source": {
|
||||
"const": "linuxcnc-wasm.visual.joints"
|
||||
},
|
||||
"axisOrder": {
|
||||
"enum": [
|
||||
[
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"A",
|
||||
"C"
|
||||
],
|
||||
[
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"B",
|
||||
"C"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"calibration": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"cOriginMm",
|
||||
"tiltOffsetMm",
|
||||
"physicalZero"
|
||||
],
|
||||
"properties": {
|
||||
"cOriginMm": {
|
||||
"$ref": "#/$defs/vector3"
|
||||
},
|
||||
"tiltOffsetMm": {
|
||||
"$ref": "#/$defs/vector3"
|
||||
},
|
||||
"physicalZero": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"x",
|
||||
"y",
|
||||
"z",
|
||||
"a",
|
||||
"b",
|
||||
"c"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"x": {
|
||||
"type": "number"
|
||||
},
|
||||
"y": {
|
||||
"type": "number"
|
||||
},
|
||||
"z": {
|
||||
"type": "number"
|
||||
},
|
||||
"a": {
|
||||
"type": "number"
|
||||
},
|
||||
"b": {
|
||||
"type": "number"
|
||||
},
|
||||
"c": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"axisMap": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"X": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"Y": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"Z": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"A": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"B": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"C": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 12000,
|
||||
"items": {
|
||||
"$ref": "#/$defs/node"
|
||||
}
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"vector3": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number"
|
||||
},
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
},
|
||||
"node": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"id",
|
||||
"parentId",
|
||||
"name",
|
||||
"role",
|
||||
"source",
|
||||
"zero",
|
||||
"joint"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"parentId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"role": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"file",
|
||||
"hash",
|
||||
"path"
|
||||
],
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string"
|
||||
},
|
||||
"hash": {
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"zero": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"positionMm",
|
||||
"quaternion",
|
||||
"scale"
|
||||
],
|
||||
"properties": {
|
||||
"positionMm": {
|
||||
"$ref": "#/$defs/vector3"
|
||||
},
|
||||
"quaternion": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number",
|
||||
"minimum": -1,
|
||||
"maximum": 1
|
||||
},
|
||||
"minItems": 4,
|
||||
"maxItems": 4
|
||||
},
|
||||
"scale": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "number",
|
||||
"exclusiveMinimum": 0
|
||||
},
|
||||
"minItems": 3,
|
||||
"maxItems": 3
|
||||
}
|
||||
}
|
||||
},
|
||||
"joint": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"$ref": "#/$defs/dof"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"dof": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"axis",
|
||||
"direction",
|
||||
"sign",
|
||||
"dof",
|
||||
"type",
|
||||
"unit",
|
||||
"frame"
|
||||
],
|
||||
"properties": {
|
||||
"axis": {
|
||||
"enum": [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"A",
|
||||
"B",
|
||||
"C"
|
||||
]
|
||||
},
|
||||
"direction": {
|
||||
"enum": [
|
||||
"x",
|
||||
"y",
|
||||
"z"
|
||||
]
|
||||
},
|
||||
"sign": {
|
||||
"enum": [
|
||||
-1,
|
||||
1
|
||||
]
|
||||
},
|
||||
"dof": {
|
||||
"const": 1
|
||||
},
|
||||
"frame": {
|
||||
"const": "parent"
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"prismatic",
|
||||
"revolute"
|
||||
]
|
||||
},
|
||||
"unit": {
|
||||
"enum": [
|
||||
"mm",
|
||||
"deg"
|
||||
]
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"axis": {
|
||||
"enum": [
|
||||
"X",
|
||||
"Y",
|
||||
"Z"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "prismatic"
|
||||
},
|
||||
"unit": {
|
||||
"const": "mm"
|
||||
}
|
||||
}
|
||||
},
|
||||
"else": {
|
||||
"properties": {
|
||||
"type": {
|
||||
"const": "revolute"
|
||||
},
|
||||
"unit": {
|
||||
"const": "deg"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"coordinates": {
|
||||
"const": "XYZAC"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"controller": {
|
||||
"properties": {
|
||||
"axisOrder": {
|
||||
"const": [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"A",
|
||||
"C"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"axisMap": {
|
||||
"propertyNames": {
|
||||
"enum": [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"A",
|
||||
"C"
|
||||
]
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"joint": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"axis": {
|
||||
"enum": [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"A",
|
||||
"C"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"coordinates": {
|
||||
"const": "XYZBC"
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"controller": {
|
||||
"properties": {
|
||||
"axisOrder": {
|
||||
"const": [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"B",
|
||||
"C"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"axisMap": {
|
||||
"propertyNames": {
|
||||
"enum": [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"B",
|
||||
"C"
|
||||
]
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"items": {
|
||||
"properties": {
|
||||
"joint": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "null"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"axis": {
|
||||
"enum": [
|
||||
"X",
|
||||
"Y",
|
||||
"Z",
|
||||
"B",
|
||||
"C"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -449,7 +449,7 @@ history rows intentionally share the family ID `mdi-history-select`.
|
||||
| `workspace-case-select` | Web selection of the INI/configuration input | Local selection; selected catalog is restricted to supported XYZAC/XYZBC cases | Does not load or change machine state until `toolbar-load-case` |
|
||||
| `workspace-load-3d-chips` | Web packaged-file equivalent of `open_file_guts` | Fetch published `3D_Chips.ngc`, then `PROGRAM_LOAD`; IDLE only | Same replacement path as Open; retains Live Plot, no automatic Run/Home |
|
||||
| `workspace-rtcp-programs`, `rtcp-program-scope`, `rtcp-program-select`, `rtcp-program-source`, `rtcp-program-load`, `rtcp-program-download` | Web RTCP source-program catalog backed by the locked LinuxCNC tree | The default scope follows the active machine: AC=`XYZAC`, BC=`XYZBC`; catalog/source/download are local file operations, while compatible main programs use the same `PROGRAM_LOAD` transaction and IDLE gate as Open | AC and BC programs are separated by kinematics. The optional all-source scope retains all 32 files for inspection; remap/subroutine/reference and mismatched kinematics cannot be sent as an executable main program |
|
||||
| `machine-manager-settings`, `machine-manager-import`, `machine-manager-files`, `machine-manager-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 |
|
||||
| `machine-manager-settings`, `machine-manager-import`, `machine-manager-files`, `machine-manager-delete`, `machine-manager-translate`, `machine-manager-rotate`, `machine-manager-origin`, `machine-manager-translation`, `machine-manager-rotation`, `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, numeric translation and rotation edit that node's local position and posture relative to its current parent, 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. |
|
||||
| `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. |
|
||||
@@ -1071,3 +1071,19 @@ The document deliberately records ABI blockers and source boundaries. A
|
||||
`verified` Web slice means the implemented path matches this contract; it does
|
||||
not claim that an unavailable LinuxCNC host or official native oracle has been
|
||||
silently reproduced in the browser.
|
||||
|
||||
### MGR-17 single-DOF joint controls
|
||||
|
||||
`machine-joint-direction`, `machine-joint-sign`, `machine-joint-value`, `machine-joint-follow-live`, `machine-joint-live-state`, `machine-manager-export-json`: one CNC axis per moving node, one scalar input in mm/deg, parent-frame direction/sign, real WASM joint following and JSON export. `machine-tree-axis-role` now binds XYZABC directly; fixed nodes cannot move. Unrestricted translate/rotate gizmos and six-component inputs are retired. Runtime uses `linuxcnc.joint-model/1` with source order XYZAC/XYZBC and physical-zero subtraction.
|
||||
|
||||
### MGR-18 machine manager workbench controls
|
||||
|
||||
`machine-manager-undo`, `machine-manager-redo`, `machine-manager-draft-state`, `machine-tree-search`, `machine-tree-expand`, `machine-tree-collapse`, `machine-manager-validation`, `machine-manager-tab-structure`, `machine-manager-tab-motion`, `machine-manager-tab-calibration`, `machine-view-fit`, `machine-view-selected`, `machine-view-front`, `machine-view-top`, `machine-joint-step-minus`, `machine-joint-step-plus`, `machine-joint-reset`: bounded draft history, searchable hierarchy, axis binding overview, explicit validation, view presets, single-joint step jog and responsive workbench layout.
|
||||
|
||||
### MGR-19 document machine type
|
||||
|
||||
`machine-manager-type`, `machine-manager-type-note`: select XYZAC/XYZBC inside the design dialog before configuring structure. Design type belongs to undoable draft state and is independent of the live controller. Saving validates the GLB through the standard importer; loading a full machine restores type, bindings and calibration, and the control panel switches workspace to match the imported model.
|
||||
|
||||
### MGR-21 document save and recovery
|
||||
|
||||
`machine-document-new`, `machine-document-open`, `machine-document-open-file`, `machine-document-from-controller`, `machine-document-name`, `machine-document-description`, `machine-document-location`, `machine-library-search`, `machine-library-open`, `machine-library-download`, `machine-library-delete`, `machine-manager-tab-files`: named GLB document, complete multi-part editor recovery, explicit file opening, immutable saved copies in browser IndexedDB, location/status information, download and delete. Controller model editing reads current effective calibration. GLB remains compatible with the standard importer.
|
||||
|
||||
37
docs/linuxcnc-five-axis-case-catalog.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# LinuxCNC 五轴机床案例目录
|
||||
|
||||
本目录来自仓库内的 LinuxCNC 源码、仿真配置和 G 代码案例。网页机床管理器读取同一份 [five-axis-machine-catalog.json](../web/workspace/five-axis-machine-catalog.json),因此案例清单、运动学名称、坐标轴、源码路径和 Web/WASM 能力状态不会在界面中重复硬编码。
|
||||
|
||||
## 案例与适用范围
|
||||
|
||||
| 结构族 | LinuxCNC 运动学 | 坐标/关节 | 本地案例 | Web/WASM 状态 |
|
||||
|---|---|---:|---|---|
|
||||
| 转台倾转 AC | `xyzac-trt-kins` | XYZAC / 5 | `configs/sim/axis/vismach/5axis/table-rotary-tilting`、gmoccapy TRT | 已支持正解、逆解、RTCP |
|
||||
| 转台倾转 BC | `xyzbc-trt-kins` | XYZBC / 5 | `configs/sim/axis/vismach/5axis/table-rotary-tilting`、gmoccapy TRT | 已支持正解、逆解、RTCP |
|
||||
| 双转台 AB | `xyzab_tdr_kins` | XYZAB / 5 | `configs/sim/axis/vismach/5axis/table-dual-rotary` | 已有 LinuxCNC 案例;需要 Web/WASM 适配器 |
|
||||
| Bridge Mill | `5axiskins` | XYZBCW/Y / 7 | `configs/sim/axis/vismach/5axis/bridgemill` | 需要支持重复轴和 W 轴的适配器 |
|
||||
| MAX5 | `maxkins` | XYZBC / 6 | `configs/sim/axis/vismach/5axis/max5` | 需要 maxkins WASM 适配器 |
|
||||
| Hexapod | `genhexkins` | XYZABC / 6 | `configs/sim/axis/vismach/hexapod-sim` | 需要并联平台参数和迭代求解适配器 |
|
||||
| Pentapod | `pentakins` | XYZAB / 5 | `configs/sim/axis/pentapod` | 需要五支链参数和迭代求解适配器 |
|
||||
| PUMA | `pumakins` | XYZABC / 6 | `configs/sim/axis/vismach/puma/puma.ini` | 需要串联机器人适配器 |
|
||||
| PUMA 560 | `genserkins` | XYZABC / 6 | `configs/sim/axis/vismach/puma/puma560.ini` | 需要 DH/串联机器人适配器 |
|
||||
| 直角坐标基线 | `trivkins` | XYZAB / 5 | qtdragon、woodpecker、MAX5 trivkins | 仅用于轴映射验证,不提供非线性 RTCP |
|
||||
| C/W 直角坐标基线 | `trivkins` | XYZCW / 5 | `configs/sim/gmoccapy/gmoccapy_XYZCW.ini` | 仅用于轴映射验证,不提供非线性 RTCP |
|
||||
| 双 Y/旋转 A 基线 | `trivkins` | XYZYA / 5 | qtdragon multi-joint、woodpecker | 仅用于轴映射验证,不提供非线性 RTCP |
|
||||
| SCORBOT | `scorbot-kins` | XYZAB / 5 | `configs/sim/axis/vismach/scorbot/scorbot.ini` | 需要机器人运动学适配器 |
|
||||
|
||||
PUMA 和 Hexapod 是六自由度案例,仍然纳入目录是因为 LinuxCNC 的五轴加工/RTCP 工作流可能使用其中的五轴姿态子集;在配置器中会显示“六自由度”标签。SCARA、MELFA、SCORBOT 等同样是 LinuxCNC 的机器人运动学案例,但不属于本目录的五轴机床案例,因此不会伪装成五轴铣床类型。
|
||||
|
||||
## 适配边界
|
||||
|
||||
当前 Web/WASM 运动链已经真实接通的是 XYZAC/XYZBC TRT。其它案例已经进入机床管理器的案例库,可以查看源码配置、G 代码和所需结构参数,但在对应适配器完成以前不能在控制面板中声称已经具备正解、逆解或 RTCP。每个 GLB 机床文档应保存 `kinematics`、`coordinates`、`joints`、轴层级、局部原点、关节参数、物理零点和运动学专用参数,适配器再根据这些数据驱动 Three.js 层级。
|
||||
|
||||
## 校验
|
||||
|
||||
在仓库根目录执行:
|
||||
|
||||
```bash
|
||||
node scripts/validate_five_axis_catalog.mjs
|
||||
```
|
||||
|
||||
该命令逐项读取本地案例配置、示例 G 代码、README 和运动学源码,确认目录中的路径仍然存在。官方依据包括 [LinuxCNC 5-Axis Kinematics](https://linuxcnc.org/docs/html/motion/5-axis-kinematics.html)、[kins(9)](https://linuxcnc.org/docs/html/man/man9/kins.9.html) 和 [switchable kinematics](https://linuxcnc.org/docs/html/motion/switchkins.html)。
|
||||
@@ -13,7 +13,7 @@ const [contract, app, panel, preview] = await Promise.all([
|
||||
const controlIds = [
|
||||
"menu-file", "menu-file-edit-tool-data", "menu-file-quit", "menu-help", "menu-help-about", "about-close",
|
||||
"toolbar-estop", "toolbar-power", "toolbar-open", "toolbar-load-case", "toolbar-reload", "toolbar-run", "toolbar-step", "toolbar-pause-resume", "toolbar-stop", "toolbar-block-delete", "toolbar-optional-pause", "toolbar-zoom-in", "toolbar-zoom-out", "toolbar-view-z", "toolbar-view-z2", "toolbar-view-x", "toolbar-view-y", "toolbar-view-y2", "toolbar-view-p", "toolbar-rotate", "toolbar-clear-live-plot",
|
||||
"workspace-case-select", "workspace-load-3d-chips", "workspace-rtcp-programs", "rtcp-program-scope", "rtcp-program-select", "rtcp-program-source", "rtcp-program-load", "rtcp-program-download", "machine-manager-settings", "machine-manager-import", "machine-manager-files", "machine-manager-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",
|
||||
"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-document-new", "machine-document-open", "machine-document-open-file", "machine-document-from-controller", "machine-document-name", "machine-document-description", "machine-document-location", "machine-library-search", "machine-library-open", "machine-library-download", "machine-library-delete", "machine-manager-tab-files", "machine-manager-type", "machine-manager-type-note", "machine-manager-import", "machine-manager-files", "machine-manager-delete", "machine-manager-role", "machine-manager-parent", "machine-manager-show-origin", "machine-tree-node", "machine-tree-origin", "machine-tree-axis-role", "machine-joint-direction", "machine-joint-sign", "machine-joint-value", "machine-joint-step-minus", "machine-joint-step-plus", "machine-joint-reset", "machine-joint-follow-live", "machine-joint-live-state", "machine-manager-export-json", "machine-manager-undo", "machine-manager-redo", "machine-manager-draft-state", "machine-tree-search", "machine-tree-expand", "machine-tree-collapse", "machine-manager-validation", "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", "machine-manager-tab-structure", "machine-manager-tab-motion", "machine-manager-tab-calibration", "machine-view-fit", "machine-view-selected", "machine-view-front", "machine-view-top", "manual-home-all", "manual-home-joint-select",
|
||||
"switchkins-0", "switchkins-1", "switchkins-2",
|
||||
"tab-manual", "tab-mdi", "tab-diagnostics", "tab-preview", "tab-dro",
|
||||
"manual-axis-x", "manual-axis-y", "manual-axis-z", "manual-axis-a", "manual-axis-c",
|
||||
|
||||
59
scripts/test_machine_document_browser.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile,writeFile,mkdir } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import * as THREE from '../web/react/node_modules/three/build/three.module.js';
|
||||
import { GLTFExporter } from '../web/react/node_modules/three/examples/jsm/exporters/GLTFExporter.js';
|
||||
globalThis.FileReader=class{readAsArrayBuffer(blob){blob.arrayBuffer().then(v=>{this.result=v;this.onloadend?.();});}readAsDataURL(blob){blob.arrayBuffer().then(v=>{this.result=`data:${blob.type};base64,${Buffer.from(v).toString('base64')}`;this.onloadend?.();});}};
|
||||
const fixtures=await Promise.all(Array.from({length:8},async(_,i)=>{const s=new THREE.Scene();s.name=`part_${i}`;const mesh=new THREE.Mesh(new THREE.BoxGeometry(.05,.05,.05),new THREE.MeshStandardMaterial({color:0x458e83}));mesh.name=`mesh_${i}`;mesh.position.set(.01,.02,.03);s.add(mesh);const extra=mesh.clone();extra.name=`extra_${i}`;extra.position.x=.07;s.add(extra);const b=await new GLTFExporter().parseAsync(s,{binary:true,trs:true});return{name:`part-${i}.glb`,mimeType:'model/gltf-binary',buffer:Buffer.from(b)};}));
|
||||
const pw=await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE??'/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js'));
|
||||
const browser=await(pw.default??pw).chromium.launch({headless:true,executablePath:'/usr/bin/google-chrome',args:['--no-sandbox','--use-angle=swiftshader','--enable-unsafe-swiftshader']});const page=await browser.newPage({viewport:{width:1440,height:1000}});
|
||||
const out=new URL('../coverage/glb-machine-manager/document/',import.meta.url);await mkdir(out,{recursive:true});
|
||||
const report={report_id:'GLB-MGR-E17',status:'running',url:process.env.MACHINE_MANAGER_URL??'http://127.0.0.1:5180/',checks:[],errors:[]};
|
||||
page.on('pageerror',e=>report.errors.push(e.message));page.on('console',m=>{if(['error','warning'].includes(m.type()))report.errors.push(m.text());});
|
||||
const c=id=>page.locator(`[data-axis-control="${id}"]`),preview=page.locator('[data-machine-manager-preview]');
|
||||
const idle=()=>page.waitForFunction(()=>document.querySelector('main.axis-app')?.dataset.axisProgramLoading==='false'&&document.querySelector('main.axis-app')?.dataset.axisStateSource==='linuxcnc-wasm',null,{timeout:60000});
|
||||
const choose=async(name)=>{await c('machine-manager-tab-structure').click();await c('machine-tree-search').fill(name);await c('machine-tree-node').filter({has:page.locator('span').filter({hasText:new RegExp(`^${name}$`)})}).first().click();};
|
||||
const save=async()=>{const p=page.waitForEvent('download');await c('machine-manager-export').click();const d=await p;const bytes=await readFile(await d.path());return{bytes,filename:d.suggestedFilename(),json:JSON.parse(bytes.toString('utf8',20,20+bytes.readUInt32LE(12)))}};
|
||||
const editor=saved=>saved.json.scenes[0].extras.linuxcncMachineEditor;
|
||||
try{
|
||||
await page.goto(report.url);await idle();await c('machine-manager-settings').click();await c('machine-manager-files').setInputFiles(fixtures);await page.waitForFunction(()=>document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount==='8');
|
||||
for(const[i,a]of[[1,'X'],[2,'Y'],[3,'Z'],[5,'A'],[6,'C']]){await choose(`part_${i}`);await c('machine-tree-axis-role').selectOption(a);}
|
||||
await choose('part_3');await c('machine-joint-sign').selectOption('-1');await c('machine-manager-show-origin').uncheck();
|
||||
await choose('extra_3');const extra=c('machine-tree-node').filter({has:page.locator('span').filter({hasText:/^extra_3$/})}).first();const sourcePath=await extra.locator('xpath=ancestor::li[1]').getAttribute('data-machine-tree-path');const partId=await extra.locator('xpath=ancestor::li[1]').getAttribute('data-machine-tree-part');
|
||||
await c('machine-tree-search').fill('');const target=c('machine-tree-node').filter({has:page.locator('span').filter({hasText:/^mesh_3$/})}).first();const targetPath=await target.locator('xpath=ancestor::li[1]').getAttribute('data-machine-tree-path');
|
||||
await target.evaluate((el,p)=>{const d=new DataTransfer();d.setData('application/x-linuxcnc-machine-tree',JSON.stringify(p));el.dispatchEvent(new DragEvent('drop',{bubbles:true,cancelable:true,dataTransfer:d}));},{partId,path:sourcePath});
|
||||
await page.waitForFunction(({partId,sourcePath,targetPath})=>document.querySelector(`[data-machine-tree-part="${partId}"][data-machine-tree-path="${sourcePath}"]`)?.dataset.machineTreeParent===targetPath,{partId,sourcePath,targetPath});
|
||||
await choose('part_7');await page.locator('.machine-part-flags input').first().uncheck();
|
||||
await c('machine-manager-tab-calibration').click();const field=t=>page.locator('fieldset.machine-vector').filter({hasText:t});await field('C 轴原点').getByLabel('X',{exact:true}).fill('24');await field('A 到 C 轴偏置').getByLabel('Y',{exact:true}).fill('18');await page.locator('fieldset.machine-zero').getByLabel('A',{exact:true}).fill('12');
|
||||
await c('machine-manager-tab-files').click();await c('machine-document-name').fill('Workshop Mill');await c('machine-document-description').fill('Revision 2: calibrated A pivot');const first=await save();assert.equal(first.filename,'Workshop Mill-xyzac.glb');assert.equal(editor(first).parts.length,8);assert.equal(editor(first).info.description,'Revision 2: calibrated A pivot');
|
||||
await page.waitForFunction(()=>document.querySelectorAll('[data-machine-saved-id]').length===1);assert.match(await c('machine-document-location').textContent(),/下载.*本浏览器机床库/s);
|
||||
const id=await page.locator('[data-machine-saved-id]').first().getAttribute('data-machine-saved-id');await writeFile(new URL('saved-eight-parts.glb',out),first.bytes);
|
||||
await choose('part_3');await c('machine-manager-show-origin').check();assert.doesNotMatch(await c('machine-manager-draft-state').textContent(),/与保存版本一致/);await c('machine-manager-undo').click();assert.match(await c('machine-manager-draft-state').textContent(),/与保存版本一致/);
|
||||
report.checks.push('named GLB downloads and atomic browser-library copy with time/size/checksum; editor display changes mark document dirty');
|
||||
await page.reload();await idle();await c('machine-manager-settings').click();await c('machine-manager-tab-files').click();await page.waitForFunction(()=>document.querySelectorAll('[data-machine-saved-id]').length===1);await c('machine-library-open').click();await page.waitForFunction(()=>document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount==='8');
|
||||
await choose('part_3');assert.equal(await c('machine-tree-axis-role').inputValue(),'Z');assert.equal(await c('machine-joint-sign').inputValue(),'-1');assert.equal(await c('machine-manager-show-origin').isChecked(),false);
|
||||
await choose('part_7');assert.equal(await page.locator('.machine-part-flags input').first().isChecked(),false);
|
||||
await c('machine-manager-tab-calibration').click();assert.equal(await field('C 轴原点').getByLabel('X',{exact:true}).inputValue(),'24');assert.equal(await field('A 到 C 轴偏置').getByLabel('Y',{exact:true}).inputValue(),'18');assert.equal(await page.locator('fieldset.machine-zero').getByLabel('A',{exact:true}).inputValue(),'12');
|
||||
await c('machine-manager-tab-files').click();assert.equal(await c('machine-document-name').inputValue(),'Workshop Mill');assert.equal(await c('machine-document-description').inputValue(),'Revision 2: calibrated A pivot');
|
||||
const second=await save();assert.deepEqual(editor(second),editor(first));assert.deepEqual(second.json.scenes[0].extras.linuxcncJointModel,first.json.scenes[0].extras.linuxcncJointModel);assert.deepEqual(second.json.scenes[0].extras.linuxcncMachineGeometry,first.json.scenes[0].extras.linuxcncMachineGeometry);
|
||||
report.checks.push('reload and local-library open restore eight source files, exact edited hierarchy, axis/sign, visibility, origins and calibration; second save has identical metadata');
|
||||
await c('machine-document-new').click();assert.equal(await preview.getAttribute('data-machine-manager-part-count'),'0');await c('machine-document-open-file').setInputFiles({name:first.filename,mimeType:'model/gltf-binary',buffer:first.bytes});await page.waitForFunction(()=>document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount==='8');
|
||||
await c('machine-manager-tab-files').click();const dl=page.waitForEvent('download');await page.locator(`[data-machine-saved-id="${id}"] [data-axis-control="machine-library-download"]`).click();assert.deepEqual(await readFile(await(await dl).path()),first.bytes);
|
||||
page.once('dialog',d=>d.accept());await page.locator(`[data-machine-saved-id="${id}"] [data-axis-control="machine-library-delete"]`).click();await page.waitForFunction(id=>!document.querySelector(`[data-machine-saved-id="${id}"]`),id);assert.equal(await preview.getAttribute('data-machine-manager-part-count'),'8');
|
||||
report.checks.push('explicit open-file entry restores document; library redownload is byte identical; delete affects only chosen saved copy');
|
||||
const damaged=structuredClone(first.json);damaged.scenes[0].extras.linuxcncMachineEditor.parts[3].settings.nodeJoints.scene.sign=1;
|
||||
const text=Buffer.from(JSON.stringify(damaged));const pad=(4-text.length%4)%4;const jsonBytes=Buffer.concat([text,Buffer.alloc(pad,32)]);const oldLen=first.bytes.readUInt32LE(12),bin=first.bytes.subarray(20+oldLen),header=Buffer.alloc(20);first.bytes.copy(header,0,0,12);header.writeUInt32LE(20+jsonBytes.length+bin.length,8);header.writeUInt32LE(jsonBytes.length,12);header.writeUInt32LE(0x4e4f534a,16);
|
||||
await c('machine-document-open-file').setInputFiles({name:'damaged.glb',mimeType:'model/gltf-binary',buffer:Buffer.concat([header,jsonBytes,bin])});await page.waitForFunction(()=>document.querySelector('.machine-manager-workbench')?.getAttribute('aria-busy')==='false');assert.equal(await preview.getAttribute('data-machine-manager-part-count'),'8');assert.match(await page.locator('.machine-manager-footer').textContent(),/无法读取/);
|
||||
report.checks.push('inconsistent editor metadata is rejected atomically without replacing the current draft');
|
||||
await page.getByRole('button',{name:'清除操作消息'}).click();await page.screenshot({path:new URL('files-and-recovery.png',out).pathname});
|
||||
await page.keyboard.press('Escape');await c('machine-glb-settings').click();await c('machine-glb-file').setInputFiles({name:first.filename,mimeType:'model/gltf-binary',buffer:first.bytes});await page.waitForFunction(()=>document.querySelector('.xyzac-machine-view')?.dataset.machineJointState);await idle();await page.keyboard.press('Escape');
|
||||
await c('machine-manager-settings').click();await c('machine-document-new').click();await c('machine-document-from-controller').click();await page.waitForFunction(()=>document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount==='8');await choose('part_3');assert.equal(await c('machine-joint-sign').inputValue(),'-1');
|
||||
report.checks.push('saved GLB loads through control-panel importer and can be sent back into editor with eight original parts');
|
||||
// The legacy machine remains readable without editor-document metadata.
|
||||
await c('machine-document-open-file').setInputFiles(new URL('../coverage/glb-machine-manager/machine-type/test-xyzbc.glb',import.meta.url).pathname);await page.waitForFunction(()=>document.querySelector('[data-axis-control="machine-manager-type"]')?.value==='XYZBC');assert.equal(await preview.getAttribute('data-machine-manager-part-count'),'1');report.checks.push('legacy GLB remains editable via single-assembly fallback');
|
||||
// A quota/IDB failure must still allow download and report exact persistence result.
|
||||
await c('machine-manager-tab-files').click();await page.evaluate(()=>{indexedDB.open=()=>{throw new Error('quota');};});const fallback=await save();assert.ok(fallback.bytes.length>0);assert.match(await c('machine-document-location').textContent(),/未保存成功/);
|
||||
report.checks.push('local storage failure does not prevent GLB download and is visibly reported');
|
||||
assert.deepEqual(report.errors,[]);report.status='verified';
|
||||
}catch(e){report.status='failed';report.error=e.stack;report.text=await page.locator('body').innerText().catch(()=> '');await page.screenshot({path:new URL('failure.png',out).pathname}).catch(()=>{});throw e;}finally{await writeFile(new URL('browser.report.json',out),JSON.stringify(report,null,2)+'\n');await browser.close();}
|
||||
console.log(JSON.stringify(report));
|
||||
110
scripts/test_machine_joint_dof_browser.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import * as THREE from "../web/react/node_modules/three/build/three.module.js";
|
||||
const pw = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE ?? "/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js"));
|
||||
const browser = await (pw.default ?? pw).chromium.launch({ headless: true, executablePath: "/usr/bin/google-chrome", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
|
||||
const output = process.env.JOINT_TEST_OUTPUT ? pathToFileURL(process.env.JOINT_TEST_OUTPUT.replace(/\/?$/, "/")) : new URL("../coverage/glb-machine-manager/joint-dof/", import.meta.url); await mkdir(output, { recursive: true });
|
||||
const bytes = await readFile(new URL("../MILL700-U_1.glb", import.meta.url));
|
||||
const report = { report_id: "GLB-MGR-E13", status: "running", url: process.env.MACHINE_MANAGER_URL ?? "http://127.0.0.1:5180/", machines: [], browser_errors: [] };
|
||||
const near = (a, b, label) => { assert.equal(a.length, b.length, label); a.forEach((v, i) => assert.ok(Math.abs(v - b[i]) < 2e-6, `${label} ${i}: ${v} vs ${b[i]}`)); };
|
||||
try {
|
||||
for (const coordinates of (process.env.JOINT_TEST_COORDINATES ?? "XYZAC,XYZBC").split(",")) {
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 900 } });
|
||||
page.on("pageerror", (e) => report.browser_errors.push(e.message));
|
||||
page.on("console", (m) => { if (["error", "warning"].includes(m.type())) report.browser_errors.push(m.text()); });
|
||||
const c = (id) => page.locator(`[data-axis-control="${id}"]`);
|
||||
const preview = page.locator("[data-machine-manager-preview]");
|
||||
const idle = () => page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false" && document.querySelector("main.axis-app")?.dataset.axisStateSource === "linuxcnc-wasm", null, { timeout: 60000 });
|
||||
await page.goto(report.url); await idle();
|
||||
|
||||
await c("machine-manager-settings").click(); await c("machine-manager-type").selectOption(coordinates); assert.match(await c("workspace-case-select").inputValue(), /xyzac-trt.ini/); await c("machine-manager-files").setInputFiles({ name: "MILL700-U_1.glb", mimeType: "model/gltf-binary", buffer: bytes });
|
||||
await page.waitForFunction(() => document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132);
|
||||
assert.equal(await c("machine-joint-value").isDisabled(), true);
|
||||
assert.equal(await c("machine-manager-apply").isDisabled(), true);
|
||||
assert.equal(await c("machine-manager-translation").count(), 0); assert.equal(await c("machine-manager-rotation").count(), 0);
|
||||
const choose = async (name) => {
|
||||
await c("machine-manager-tab-structure").click();
|
||||
const node = c("machine-tree-node").filter({ has: page.locator("span").filter({ hasText: new RegExp(`^${name}$`) }) }).first();
|
||||
const path = await node.locator("xpath=ancestor::li[1]").getAttribute("data-machine-tree-path"); await node.dispatchEvent("click");
|
||||
await page.waitForFunction((path) => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerSelectedJoint?.endsWith(`:${path}`), path);
|
||||
return JSON.parse(await preview.getAttribute("data-machine-manager-selected-transform"));
|
||||
};
|
||||
const names = { X: "MILL700-A-主轴-JT1", Y: "MILL700-A-主轴-JT2", Z: "MILL700-A-主轴-JT3", [coordinates[3]]: "MILL700-A-工作台-JT1", C: "MILL700-A-工作台-JT2" };
|
||||
for (const [axis, name] of Object.entries(names)) { await choose(name); await c("machine-tree-axis-role").selectOption(axis); }
|
||||
await choose("MILL700-A底座"); await c("machine-manager-role").selectOption("base");
|
||||
assert.equal(await c("machine-manager-apply").isDisabled(), false);
|
||||
const parent = await choose(names.Y), sibling = await choose("SHS25C100020_Track_TSXXX-0000058522002"), child = await choose("MILL700-A-主轴-JT4"), zeroZ = await choose(names.Z);
|
||||
await c("machine-joint-direction").selectOption("x"); await c("machine-joint-sign").selectOption("-1"); await c("machine-joint-value").fill("30");
|
||||
const movedZ = await choose(names.Z); near(movedZ.local, [zeroZ.local[0] - .03, ...zeroZ.local.slice(1)], "single parent X translation"); near(movedZ.localQuaternion, zeroZ.localQuaternion, "locked rotation");
|
||||
assert.deepEqual(await choose(names.Y), parent); assert.deepEqual(await choose("SHS25C100020_Track_TSXXX-0000058522002"), sibling);
|
||||
const movedChild = await choose("MILL700-A-主轴-JT4"); near(movedChild.world, [child.world[0] - .03, ...child.world.slice(1)], "child follows only configured DOF");
|
||||
await choose(names.Z); await c("machine-joint-value").fill("30"); near((await choose(names.Z)).local, movedZ.local, "absolute, no accumulated movement");
|
||||
await c("machine-joint-value").fill("0"); await c("machine-joint-direction").selectOption("z");
|
||||
await choose("MILL700-A-主轴-JT4"); await c("machine-tree-axis-role").selectOption("Z"); assert.match(await page.locator(".machine-manager-footer").textContent(), /重复|已绑定/); assert.equal(await c("machine-tree-axis-role").inputValue(), "");
|
||||
const rotaryZero = await choose(names[coordinates[3]]); await c("machine-joint-value").fill("25"); const rotaryMoved = await choose(names[coordinates[3]]); near(rotaryMoved.local, rotaryZero.local, "rotation pivot fixed");
|
||||
const axis = coordinates === "XYZAC" ? new THREE.Vector3(1, 0, 0) : new THREE.Vector3(0, 1, 0);
|
||||
near(rotaryMoved.localQuaternion, new THREE.Quaternion().setFromAxisAngle(axis, 25 * Math.PI / 180).multiply(new THREE.Quaternion(...rotaryZero.localQuaternion)).toArray(), "one rotational DOF");
|
||||
await c("machine-manager-tab-motion").click(); await c("machine-test-reset").click();
|
||||
await c("machine-manager-tab-calibration").click();
|
||||
const zeroFields = page.locator("fieldset.machine-zero"); await zeroFields.getByLabel("X", { exact: true }).fill("5"); await zeroFields.getByLabel("Z", { exact: true }).fill("7"); await zeroFields.getByLabel(coordinates[3], { exact: true }).fill("12"); await zeroFields.getByLabel("C", { exact: true }).fill("9");
|
||||
const jsonDownload = page.waitForEvent("download"); await c("machine-manager-export-json").click(); const json = JSON.parse(await readFile(await (await jsonDownload).path(), "utf8"));
|
||||
assert.equal(json.schema, "linuxcnc.joint-model/1"); assert.equal(json.coordinates, coordinates); assert.deepEqual(Object.keys(json.axisMap).sort(), [...coordinates].sort()); assert.equal(json.nodes.filter((n) => n.joint).length, 5); assert.ok(json.nodes.every((n) => n.joint === null || n.joint.dof === 1));
|
||||
assert.equal(json.nodes.find((n) => n.id === json.axisMap.Z).joint.sign, -1);
|
||||
const glbDownload = page.waitForEvent("download"); await c("machine-manager-export").click(); const glb = await readFile(await (await glbDownload).path());
|
||||
if (process.env.SAVE_MACHINE_MODELS) { await mkdir(process.env.SAVE_MACHINE_MODELS, { recursive: true }); await writeFile(`${process.env.SAVE_MACHINE_MODELS}/MILL700-${coordinates.toLowerCase()}.glb`, glb); }
|
||||
const gltf = JSON.parse(glb.toString("utf8", 20, 20 + glb.readUInt32LE(12)));
|
||||
assert.deepEqual(gltf.scenes[0].extras.linuxcncJointModel, json);
|
||||
const bed = gltf.nodes.find((n) => n.name === "MILL700-A底座"); assert.deepEqual(bed.extras.linuxcncJointModel, json); assert.ok(bed.extras.linuxcncMachineGeometry);
|
||||
await writeFile(new URL(`${coordinates.toLowerCase()}.json`, output), JSON.stringify(json, null, 2) + "\n");
|
||||
await c("machine-manager-tab-structure").click(); await c("machine-manager-delete").click(); await c("machine-manager-files").setInputFiles({ name: `managed-${coordinates.toLowerCase()}.glb`, mimeType: "model/gltf-binary", buffer: glb });
|
||||
await page.waitForFunction(() => document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132);
|
||||
await choose(names.Z); assert.equal(await c("machine-tree-axis-role").inputValue(), "Z"); assert.equal(await c("machine-joint-sign").inputValue(), "-1"); await c("machine-manager-tab-calibration").click(); assert.equal(await zeroFields.getByLabel("Z", { exact: true }).inputValue(), "7");
|
||||
console.log(`${coordinates}: GLB export and editor roundtrip verified`);
|
||||
// Standard machine-import route validates exported GLB, JSON hierarchy and zero restoration.
|
||||
await page.keyboard.press("Escape"); await c("machine-glb-settings").click(); await c("machine-glb-file").setInputFiles({ name: `managed-${coordinates.toLowerCase()}.glb`, mimeType: "model/gltf-binary", buffer: glb });
|
||||
await page.waitForFunction(() => document.querySelector(".xyzac-machine-view")?.dataset.machineJointState, null, { timeout: 60000 }); await idle(); await page.keyboard.press("Escape");
|
||||
console.log(`${coordinates}: standard GLB importer verified`);
|
||||
assert.match(await c("workspace-case-select").inputValue(), new RegExp(`${coordinates.toLowerCase()}-trt.ini`));
|
||||
// Apply directly from the restored one-file hierarchy as well.
|
||||
await c("machine-manager-settings").click(); await c("machine-manager-apply").click(); await idle(); assert.match(await page.locator(".machine-manager-footer").textContent(), /已应用/); await page.keyboard.press("Escape");
|
||||
const program = `M428
|
||||
G21 G90 G94 G17 G40 G49
|
||||
G1 X20 Y10 Z10 ${coordinates[3]}20 C30 F120
|
||||
G1 X-10 Y-5 Z-5 ${coordinates[3]}-10 C-20 F120
|
||||
M2
|
||||
`;
|
||||
await c("program-file-input").setInputFiles({ name: `joint-map-${coordinates.toLowerCase()}.ngc`, mimeType: "text/plain", buffer: Buffer.from(program) }); await idle();
|
||||
console.log(`${coordinates}: apply verified; starting simultaneous five-axis WASM motion`);
|
||||
await c("toolbar-power").click(); await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisStateMachine === "ON"); await c("manual-home-all").click();
|
||||
await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisHomed === "[true,true,true,true,true]", null, { timeout: 60000 }); await c("toolbar-run").click();
|
||||
await page.waitForFunction(() => {
|
||||
const view = document.querySelector(".xyzac-machine-view");
|
||||
const q = JSON.parse(view?.dataset.machineJointInputs ?? "[]");
|
||||
return Number(document.querySelector("main.axis-app")?.dataset.axisCurrentLine) > 0 && q.some((v) => Math.abs(v) > 1e-6);
|
||||
}, null, { timeout: 60000 });
|
||||
const samples = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (samples.length) await page.waitForFunction((tick) => Number(document.querySelector(".xyzac-machine-view")?.dataset.machineJointTick) > tick, samples.at(-1).tick, { timeout: 60000 });
|
||||
const sample = await page.locator(".xyzac-machine-view").evaluate((el) => ({ tick: Number(el.dataset.machineJointTick), q: JSON.parse(el.dataset.machineJointInputs), nodes: JSON.parse(el.dataset.machineJointState) }));
|
||||
for (const [index, axisName] of [...coordinates].entries()) {
|
||||
const state = sample.nodes[axisName]; const original = json.nodes.find((n) => n.joint?.axis === axisName); const d = original.joint;
|
||||
const q = d.sign * (sample.q[index] - json.calibration.physicalZero[axisName.toLowerCase()]); const direction = new THREE.Vector3(...["x", "y", "z"].map((a) => a === d.direction ? 1 : 0));
|
||||
if ("XYZ".includes(axisName)) { near(state.localMm, new THREE.Vector3(...original.zero.positionMm).addScaledVector(direction, q).toArray(), `${axisName} runtime position`); near(state.quaternion, original.zero.quaternion, `${axisName} locked orientation`); }
|
||||
else { near(state.localMm, original.zero.positionMm, `${axisName} fixed pivot`); near(state.quaternion, new THREE.Quaternion().setFromAxisAngle(direction, q * Math.PI / 180).multiply(new THREE.Quaternion(...original.zero.quaternion)).toArray(), `${axisName} runtime rotation`); }
|
||||
}
|
||||
samples.push(sample);
|
||||
}
|
||||
for (let axisIndex = 0; axisIndex < 5; axisIndex++) assert.ok(samples.some((s) => Math.abs(s.q[axisIndex] - samples[0].q[axisIndex]) > 1e-6), `${coordinates[axisIndex]} live movement required`);
|
||||
await c("machine-manager-settings").click(); await c("machine-manager-tab-motion").click(); await c("machine-joint-follow-live").check(); assert.equal(await c("machine-joint-value").isDisabled(), true);
|
||||
await page.waitForFunction(() => document.querySelector('[data-axis-control="machine-joint-live-state"]')?.textContent.includes("WASM tick"));
|
||||
const liveSample = await page.evaluate(() => ({ q: JSON.parse(document.querySelector('[data-axis-control="machine-test-state"]').textContent), joints: JSON.parse(document.querySelector("[data-machine-manager-preview]").dataset.machineManagerJointTransforms) }));
|
||||
const z = json.nodes.find((n) => n.joint?.axis === "Z"); near(liveSample.joints.Z.localMm, [z.zero.positionMm[0], z.zero.positionMm[1], z.zero.positionMm[2] - liveSample.q.z], "dialog follows live Z sign");
|
||||
await page.screenshot({ path: new URL(`${coordinates.toLowerCase()}-live.png`, output).pathname });
|
||||
report.machines.push({ coordinates, checks: ["fixed or single DOF", "direction/sign and parent isolation", "pivot and child inheritance", "duplicate axis rejected", "JSON on scene and real bed", "GLB roundtrip restores calibration and bindings", "single-file apply", "WASM runtime axis mapping", "live dialog follows actual joints"], nodeCount: json.nodes.length, samples, liveSample });
|
||||
await page.close();
|
||||
}
|
||||
assert.deepEqual(report.browser_errors, []); report.status = "verified";
|
||||
} catch (e) { report.status = "failed"; report.error = e.stack; for (const page of browser.contexts().flatMap((c) => c.pages())) { report.pageText = await page.locator("body").innerText().catch(() => ""); await page.screenshot({ path: new URL("failure.png", output).pathname }).catch(() => {}); } throw e; }
|
||||
finally { await writeFile(new URL("browser.report.json", output), JSON.stringify(report, null, 2) + "\n"); await browser.close(); }
|
||||
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, machines: report.machines.map((m) => ({ coordinates: m.coordinates, checks: m.checks, samples: m.samples.length })), browser_errors: report.browser_errors }));
|
||||
27
scripts/test_machine_joint_model.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
import assert from "node:assert/strict";
|
||||
import * as THREE from "../web/react/node_modules/three/build/three.module.js";
|
||||
import { driveJoint, jointZero, validateJointModel, bindJointModel, defaultDof } from "../web/react/src/machine-joints.ts";
|
||||
const near = (a, b) => a.forEach((v, i) => assert.ok(Math.abs(v - b[i]) < 1e-9));
|
||||
const parent = new THREE.Group(); parent.rotation.z = Math.PI / 2;
|
||||
const joint = new THREE.Group(); joint.position.set(.1, .2, .3); joint.rotation.y = Math.PI / 6; parent.add(joint);
|
||||
const child = new THREE.Group(); child.position.set(.4, 0, 0); joint.add(child);
|
||||
const zero = jointZero(joint), childLocal = child.position.toArray();
|
||||
const dof = { axis: "X", direction: "x", sign: -1 };
|
||||
for (let i = 0; i < 100; i++) driveJoint(joint, zero, dof, 30, 10);
|
||||
near(joint.position.toArray(), [.08, .2, .3]); near(joint.quaternion.toArray(), zero.quaternion);
|
||||
parent.updateMatrixWorld(true); near(joint.getWorldPosition(new THREE.Vector3()).toArray(), [-.2, .08, .3]); near(child.position.toArray(), childLocal);
|
||||
driveJoint(joint, zero, { axis: "A", direction: "z", sign: 1 }, 100, 10);
|
||||
near(joint.position.toArray(), zero.positionMm.map((v) => v / 1000));
|
||||
near(joint.quaternion.toArray(), new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 0, 1), Math.PI / 2).multiply(new THREE.Quaternion(...zero.quaternion)).toArray());
|
||||
assert.throws(() => driveJoint(joint, zero, dof, NaN), /无效/);
|
||||
const scene = new THREE.Group(); const nodes = [..."XYZAC"].map((axis) => {
|
||||
const n = new THREE.Group(); n.name = axis; n.userData.linuxcncNodeId = axis; scene.add(n);
|
||||
const config = defaultDof(axis); return { id: axis, name: axis, parentId: null, role: "", source: { file: "test", hash: "hash", path: axis }, zero: jointZero(n), joint: { ...config, dof: 1, type: "XYZ".includes(axis) ? "prismatic" : "revolute", unit: "XYZ".includes(axis) ? "mm" : "deg", frame: "parent" } };
|
||||
});
|
||||
const model = { schema: "linuxcnc.joint-model/1", coordinates: "XYZAC", units: { length: "mm", angle: "deg", gltfLength: "m" }, controller: { source: "linuxcnc-wasm.visual.joints", axisOrder: [..."XYZAC"] }, calibration: { cOriginMm: [0,0,0], tiltOffsetMm: [0,0,0], physicalZero: { x: 2, y: 0, z: 0, a: 5, b: 0, c: 0 } }, axisMap: Object.fromEntries(nodes.map((n) => [n.id, n.id])), nodes };
|
||||
validateJointModel(model); const driver = bindJointModel(scene, model); driver.update({ X: 12, Y: 0, Z: 0, A: 35, C: 0 });
|
||||
near(driver.state().X.localMm, [10,0,0]); near(driver.state().A.quaternion, [Math.sin(Math.PI / 12),0,0,Math.cos(Math.PI / 12)]);
|
||||
const beforeInvalid = driver.state(); assert.throws(() => driver.update({ X: 999, Y: 0, Z: 0, A: 0 }), /缺少/); assert.deepEqual(driver.state(), beforeInvalid);
|
||||
const reject = (mutate) => { const copy = structuredClone(model); mutate(copy); assert.throws(() => validateJointModel(copy)); };
|
||||
reject((m) => { m.nodes[1].joint.axis = "X"; }); reject((m) => { m.nodes[0].joint.dof = 2; }); reject((m) => { m.nodes[0].joint.sign = 0; }); reject((m) => { m.axisMap.X = "Y"; }); reject((m) => { m.nodes[0].parentId = "X"; }); reject((m) => { m.calibration.physicalZero.z = NaN; }); reject((m) => { m.units.length = "m"; }); reject((m) => { m.nodes[0].zero.quaternion = [0,0,0,0]; });
|
||||
console.log(JSON.stringify({ report_id: "GLB-MGR-E13-MATH", status: "verified", checks: ["parent-frame motion with nonidentity zero orientation", "unit/sign/physical-zero conversion", "100 updates without drift", "child inheritance", "rotation about own origin", "malformed JSON rejected", "missing live axis rejected atomically"] }));
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import * as THREE from "../web/react/node_modules/three/build/three.module.js";
|
||||
import { GLTFExporter } from "../web/react/node_modules/three/examples/jsm/exporters/GLTFExporter.js";
|
||||
|
||||
@@ -31,35 +31,42 @@ try {
|
||||
await control("machine-manager-files").setInputFiles(fixtures);
|
||||
await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "8");
|
||||
assert.equal(await page.locator('[data-machine-tree-path="scene"]').count(), 8); assert.ok(await page.locator('[data-axis-control="machine-tree-node"]').count() >= 16); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "8"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-lighting"), "studio-pmrem"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-exposure"), "1.45"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-webgl-antialias"), "false"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-render-state"), "rendered"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-context-generation"), "1");
|
||||
report.checks.push("eight GLB parts rendered with studio PMREM lighting and calibrated exposure");
|
||||
const roots = page.locator('[data-machine-tree-path="scene"]'); const sourceRoot = roots.nth(7), targetRoot = roots.nth(6), sourcePart = await sourceRoot.getAttribute("data-machine-tree-part"), targetPart = await targetRoot.getAttribute("data-machine-tree-part"), transfer = await page.evaluateHandle(() => new DataTransfer()); await sourceRoot.locator("button").first().dispatchEvent("dragstart", { dataTransfer: transfer }); await targetRoot.locator("button").first().dispatchEvent("dragover", { dataTransfer: transfer }); await targetRoot.locator("button").first().dispatchEvent("drop", { dataTransfer: transfer }); await page.waitForFunction(({ sourcePart, targetPart }) => document.querySelector(`[data-machine-tree-part="${sourcePart}"][data-machine-tree-path="scene"]`)?.getAttribute("data-machine-tree-parent") === targetPart, { sourcePart, targetPart });
|
||||
report.checks.push("dragging GLB root tree nodes updates component parent hierarchy");
|
||||
|
||||
await control("machine-manager-show-origin").uncheck(); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-origin-count"), "7");
|
||||
const beforePartOrigin = JSON.parse(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-selected-joint-world-origin")); const translation = page.locator("fieldset.machine-vector").filter({ hasText: "部件平移" }); await translation.getByLabel("X", { exact: true }).fill("10"); await page.waitForFunction(({ before }) => { const current = JSON.parse(document.querySelector("[data-machine-manager-preview]")?.getAttribute("data-machine-manager-selected-joint-world-origin") ?? "[]"); return current.length === 3 && Math.abs(current[0] - before[0] - .01) < 1e-7; }, { before: beforePartOrigin });
|
||||
const beforePartQuaternion = JSON.parse(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-selected-joint-world-quaternion")); const rotation = page.locator("fieldset.machine-vector").filter({ hasText: "部件旋转" }); await rotation.getByLabel("Z", { exact: true }).fill("15"); await page.waitForFunction(({ before }) => { const current = JSON.parse(document.querySelector("[data-machine-manager-preview]")?.getAttribute("data-machine-manager-selected-joint-world-quaternion") ?? "[]"); return current.length === 4 && Math.abs(current[3] - before[3]) > 1e-4; }, { before: beforePartQuaternion });
|
||||
await control("machine-manager-rotate").click(); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-transform-mode"), "rotate");
|
||||
assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-context-generation"), "1");
|
||||
report.checks.push("eight GLB parts rendered with studio PMREM lighting");
|
||||
const selectRoot = async (index) => { const node = control("machine-tree-node").filter({ has: page.locator("span").filter({ hasText: new RegExp(`^part_${index}$`) }) }).first(); await node.dispatchEvent("click"); };
|
||||
for (const [index, axis] of [[1, "X"], [2, "Y"], [3, "Z"], [5, "A"], [6, "C"]]) { await selectRoot(index); await control("machine-tree-axis-role").selectOption(axis); }
|
||||
await selectRoot(0); assert.equal(await control("machine-joint-value").isDisabled(), true);
|
||||
await control("machine-manager-show-origin").uncheck();
|
||||
await selectRoot(1); await control("machine-joint-value").fill("10");
|
||||
await page.waitForFunction(() => Math.abs(JSON.parse(document.querySelector("[data-machine-manager-preview]").dataset.machineManagerJointTransforms).X.localMm[0] - 10) < 1e-7);
|
||||
assert.equal(await control("machine-manager-translation").count(), 0);
|
||||
await control("machine-manager-tab-motion").click(); await control("machine-test-reset").click(); await control("machine-manager-tab-structure").click();
|
||||
await page.locator(".machine-manager-dialog").screenshot({ path: new URL("manager-ac-config.png", output).pathname });
|
||||
report.checks.push("origin visibility, numeric translation/rotation and TransformControls mode update");
|
||||
|
||||
report.checks.push("eight-part roots have unique single DOFs; fixed base cannot move; numeric input drives only bound X");
|
||||
await control("machine-manager-tab-calibration").click();
|
||||
const calibrations = page.locator(".machine-manager-calibration fieldset.machine-vector");
|
||||
const cOrigin = calibrations.filter({ hasText: "C 轴原点" }); await cOrigin.getByLabel("X", { exact: true }).fill("24"); await cOrigin.getByLabel("Y", { exact: true }).fill("-12"); await cOrigin.getByLabel("Z", { exact: true }).fill("8");
|
||||
const offset = calibrations.filter({ hasText: "A 到 C 轴偏置" }); await offset.getByLabel("X", { exact: true }).fill("0"); await offset.getByLabel("Y", { exact: true }).fill("18"); await offset.getByLabel("Z", { exact: true }).fill("-45");
|
||||
const zeros = page.locator("fieldset.machine-zero"); await zeros.getByLabel("X", { exact: true }).fill("5"); await zeros.getByLabel("A", { exact: true }).fill("10"); await zeros.getByLabel("C", { exact: true }).fill("20");
|
||||
assert.equal(await control("machine-manager-export").isDisabled(), false); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-coordinate-system"), "machine-z-up"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-axis-map"), "X:x,Y:y,Z:z"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-camera-up"), "0,0,1"); const downloadPromise = page.waitForEvent("download"); await control("machine-manager-export").click(); const download = await downloadPromise; assert.equal(download.suggestedFilename(), "managed-xyzac.glb");
|
||||
const exportedBytes = await readFile(await download.path());
|
||||
const exported = JSON.parse(exportedBytes.toString("utf8", 20, 20 + exportedBytes.readUInt32LE(12)));
|
||||
const model = exported.scenes[0].extras.linuxcncJointModel;
|
||||
assert.equal(model.schema, "linuxcnc.joint-model/1");
|
||||
assert.equal(model.nodes.filter((n) => n.joint).length, 5);
|
||||
report.checks.push("validated assembly exports a self-contained managed GLB");
|
||||
assert.equal(await control("machine-manager-apply").isDisabled(), false); await control("machine-manager-apply").click();
|
||||
await page.waitForFunction(() => document.querySelector(".xyzac-machine-view")?.dataset.machineManagerSchema === "linuxcnc.machine-manager/1" && document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false", null, { timeout: 45000 });
|
||||
const geometry = JSON.parse(await page.locator(".xyzac-machine-view").getAttribute("data-machine-manager-geometry"));
|
||||
assert.deepEqual(geometry.cOriginMm, [24, -12, 8]); assert.deepEqual(geometry.tiltOffsetMm, [0, 18, -45]); assert.equal(geometry.physicalZero.x, 5); assert.equal(geometry.physicalZero.a, 10); assert.equal(geometry.physicalZero.c, 20); assert.equal(geometry.parts.length, 8); assert.equal(geometry.parts[0].translationMm[0], 10); assert.equal(geometry.parts[0].rotationDeg[2], 15);
|
||||
assert.deepEqual(geometry.cOriginMm, [24, -12, 8]); assert.deepEqual(geometry.tiltOffsetMm, [0, 18, -45]); assert.equal(geometry.physicalZero.x, 5); assert.equal(geometry.physicalZero.a, 10); assert.equal(geometry.physicalZero.c, 20); assert.equal(geometry.parts.length, 8); assert.equal(geometry.parts[1].nodeJoints.scene.axis, "X");
|
||||
assert.match(await page.locator(".xyzac-machine-view").getAttribute("data-xyzac-model-source-path"), /^managed-xyzac\.glb$/);
|
||||
report.checks.push("validated hierarchy applied through LOAD_WORKSPACE with geometry and physical zero in userData");
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
const xyzbcCase = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"; await control("workspace-case-select").selectOption(xyzbcCase);
|
||||
await page.waitForFunction((path) => document.querySelector('[data-axis-control="workspace-case-select"]')?.value === path && document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false", xyzbcCase, { timeout: 45000 });
|
||||
await control("machine-manager-settings").click(); assert.match(await page.locator(".machine-manager-dialog .machine-dialog-titlebar").textContent(), /XYZBC/);
|
||||
await control("machine-manager-settings").click(); assert.match(await page.locator(".machine-manager-dialog .machine-dialog-titlebar").textContent(), /XYZAC/); await control("machine-manager-type").selectOption("XYZBC");
|
||||
await control("machine-manager-tab-structure").click(); await selectRoot(5); await control("machine-tree-axis-role").selectOption("B");
|
||||
await control("machine-manager-tab-calibration").click();
|
||||
const bcCalibrations = page.locator(".machine-manager-calibration fieldset.machine-vector"); const bcOrigin = bcCalibrations.filter({ hasText: "C 轴原点" }); await bcOrigin.getByLabel("X", { exact: true }).fill("-18"); await bcOrigin.getByLabel("Y", { exact: true }).fill("14"); await bcOrigin.getByLabel("Z", { exact: true }).fill("12");
|
||||
const bcOffset = bcCalibrations.filter({ hasText: "B 到 C 轴偏置" }); await bcOffset.getByLabel("X", { exact: true }).fill("22"); await bcOffset.getByLabel("Y", { exact: true }).fill("0"); await bcOffset.getByLabel("Z", { exact: true }).fill("-40");
|
||||
const bcZeros = page.locator("fieldset.machine-zero"); await bcZeros.getByLabel("B", { exact: true }).fill("12"); await bcZeros.getByLabel("C", { exact: true }).fill("5"); await control("machine-manager-apply").click();
|
||||
@@ -77,7 +84,7 @@ try {
|
||||
const projection = await page.evaluate(async () => {
|
||||
const manager = await import("/src/machine-manager.ts"); const importedMachine = await import("/src/imported-machine.ts");
|
||||
const sources = await Promise.all(Array.from({ length: 8 }, async (_, index) => manager.loadMachinePart(await (await fetch("/machines/demo-ac-xyz-head.glb")).arrayBuffer(), `source-${index}.glb`)));
|
||||
const roles = manager.MACHINE_PART_ROLES; const drafts = sources.map((source, index) => { const role = roles[index]; const parentRole = manager.expectedParentRole(role); return { ...source, role, parentId: parentRole ? sources[roles.indexOf(parentRole)].id : null, visible: true, showOrigin: true, originMm: [0, 0, 0], translationMm: [0, 0, 0], rotationDeg: [0, 0, 0] }; });
|
||||
const roles = manager.MACHINE_PART_ROLES; const drafts = sources.map((source, index) => { const role = roles[index]; const parentRole = manager.expectedParentRole(role); return { ...source, role, parentId: parentRole ? sources[roles.indexOf(parentRole)].id : null, visible: true, showOrigin: true, originMm: [0, 0, 0], translationMm: [0, 0, 0], rotationDeg: [0, 0, 0], nodeParents: {}, nodeRoles: {}, nodeTransforms: {} }; });
|
||||
const run = async (coordinates, cOriginMm, tiltOffsetMm, physicalZero, joints) => {
|
||||
const asset = await manager.buildManagedMachine({ coordinates, parts: drafts, cOriginMm, tiltOffsetMm, physicalZero }); const built = importedMachine.buildImportedMachine(asset);
|
||||
built.update({ joints, offsets: { x_rot_point: cOriginMm[0], y_rot_point: cOriginMm[1], z_rot_point: cOriginMm[2], x_offset: tiltOffsetMm[0], y_offset: tiltOffsetMm[1], z_offset: tiltOffsetMm[2], tool_offset: 0 } });
|
||||
|
||||
16
scripts/test_machine_manager_history.mjs
Normal file
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { machineDraftHistory, changeMachineType } from '../web/react/src/machine-manager-history.ts';
|
||||
const draft = { coordinates:"XYZAC", parts: [], cOriginMm:[0,0,0], tiltOffsetMm:[0,0,0], physicalZero:{x:0,y:0,z:0,a:0,b:0,c:0} };
|
||||
let state = { current:draft, past:[], future:[] };
|
||||
const edit = (n) => ({ type:'edit', change:(d) => ({...d, cOriginMm:[n,0,0]}) });
|
||||
state = machineDraftHistory(state, edit(1)); state = machineDraftHistory(state,edit(1)); assert.equal(state.past.length,1);
|
||||
state = machineDraftHistory(state,{type:'undo'}); assert.equal(state.current.cOriginMm[0],0);
|
||||
state = machineDraftHistory(state,{type:'redo'}); assert.equal(state.current.cOriginMm[0],1);
|
||||
state = machineDraftHistory(state,{type:'undo'}); state = machineDraftHistory(state,edit(2)); assert.equal(state.future.length,0);
|
||||
for(let i=3;i<45;i++) state=machineDraftHistory(state,edit(i)); assert.equal(state.past.length,30);
|
||||
const current=state.current; state=machineDraftHistory(state,{type:'clear'}); assert.equal(state.current,current); assert.equal(state.past.length,0);
|
||||
|
||||
const ac = { ...draft, parts: [{ id:"p", showOrigin:true, nodeJoints:{ a:{axis:"A",direction:"x",sign:1}, c:{axis:"C",direction:"z",sign:-1}, x:{axis:"X",direction:"x",sign:1} } }] };
|
||||
const bc = changeMachineType(ac,"XYZBC"); assert.equal(bc.coordinates,"XYZBC"); assert.equal(bc.parts[0].nodeJoints.a,undefined); assert.deepEqual(bc.parts[0].nodeJoints.c,ac.parts[0].nodeJoints.c); assert.equal(ac.parts[0].nodeJoints.a.axis,"A");
|
||||
let types={current:ac,past:[],future:[]};types=machineDraftHistory(types,{type:"edit",change:d=>changeMachineType(d,"XYZBC")});types=machineDraftHistory(types,{type:"undo"});assert.equal(types.current,ac);
|
||||
console.log(JSON.stringify({report_id:'GLB-MGR-E14-HISTORY',status:'verified',checks:['undo/redo','duplicate edits ignored','new edit discards redo','30-entry bound','clear preserves draft','machine type change preserves general axes and clears incompatible tilt','undo restores type and bindings']}));
|
||||
@@ -1,43 +1,4 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const modulePath = process.env.PLAYWRIGHT_MODULE;
|
||||
if (!modulePath) { console.log(JSON.stringify({ schema: "machine-manager-mill700-browser.v1", status: "blocked", code: "playwright_unavailable" })); process.exit(0); }
|
||||
const root = new URL("../", import.meta.url);
|
||||
const modelPath = process.env.MILL700_GLB ?? new URL("MILL700-U_1.glb", root).pathname;
|
||||
const model = await readFile(modelPath); const sha256 = createHash("sha256").update(model).digest("hex");
|
||||
let offset = 12, json = null;
|
||||
while (offset < model.length) { const length = model.readUInt32LE(offset), type = model.readUInt32LE(offset + 4); if (type === 0x4e4f534a) json = JSON.parse(model.toString("utf8", offset + 8, offset + 8 + length)); offset += 8 + length; }
|
||||
assert.ok(json); const parents = new Map(); json.nodes.forEach((node, index) => (node.children ?? []).forEach((child) => parents.set(child, index)));
|
||||
const depth = (index) => parents.has(index) ? 1 + depth(parents.get(index)) : 0;
|
||||
const hierarchy = { root: json.nodes[json.scenes[json.scene ?? 0].nodes[0]].name, nodes: json.nodes.length, meshes: json.meshes.length, maxDepth: Math.max(...json.nodes.map((_, index) => depth(index))), materials: json.materials.length, metallic: json.materials.filter((material) => Number(material.pbrMetallicRoughness?.metallicFactor ?? 1) >= .9).length };
|
||||
const output = new URL("../coverage/glb-machine-manager/mill700/", import.meta.url); await mkdir(output, { recursive: true });
|
||||
const imported = await import(pathToFileURL(modulePath).href); const playwright = imported.default ?? imported;
|
||||
const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
|
||||
const page = await browser.newPage({ viewport: { width: 1600, height: 1050 } }); const errors = [];
|
||||
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); page.on("console", (message) => { if (["warning", "error"].includes(message.type())) errors.push(`console:${message.type()}:${message.text()}`); });
|
||||
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
|
||||
const report = { schema: "machine-manager-mill700-browser.v1", report_id: "GLB-MGR-E05", status: "running", model: { path: modelPath, bytes: model.length, sha256 }, checks: [] };
|
||||
try {
|
||||
await page.goto(process.env.MACHINE_MANAGER_URL ?? "http://127.0.0.1:5174/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.waitForFunction(() => document.querySelector("main.axis-app")?.dataset.axisProgramLoading === "false" && document.querySelector("main.axis-app")?.dataset.axisStateSource === "linuxcnc-wasm", null, { timeout: 45000 });
|
||||
await control("machine-manager-settings").click(); await control("machine-manager-files").setInputFiles({ name: "MILL700-U_1.glb", mimeType: "model/gltf-binary", buffer: model });
|
||||
await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "1" && document.querySelector("[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);
|
||||
const transformPreview = page.locator("[data-machine-manager-preview]"); const beforePartTransform = JSON.parse(await transformPreview.getAttribute("data-machine-manager-selected-joint-world-origin")); await control("machine-manager-translation").locator("input").nth(0).fill("100"); await page.waitForFunction(({ before }) => { const current = JSON.parse(document.querySelector("[data-machine-manager-preview]")?.getAttribute("data-machine-manager-selected-joint-world-origin") ?? "[]"); return current.length === 3 && Math.abs(current[0] - before[0] - .1) < 1e-7; }, { before: beforePartTransform }); const beforePartRotation = JSON.parse(await transformPreview.getAttribute("data-machine-manager-selected-joint-world-quaternion")); await control("machine-manager-rotation").locator("input").nth(2).fill("20"); await page.waitForFunction(({ before }) => { const current = JSON.parse(document.querySelector("[data-machine-manager-preview]")?.getAttribute("data-machine-manager-selected-joint-world-quaternion") ?? "[]"); return current.length === 4 && Math.abs(current[3] - before[3]) > 1e-4; }, { before: beforePartRotation }); report.checks.push("part translation and rotation inputs update the selected GLB assembly root");
|
||||
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 internalBeforePartOrigin = JSON.parse(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-selected-joint-world-origin")); await control("machine-manager-translation").locator("input").nth(0).fill("120"); await page.waitForFunction(({ before }) => { const current = JSON.parse(document.querySelector("[data-machine-manager-preview]")?.getAttribute("data-machine-manager-selected-joint-world-origin") ?? "[]"); return current.length === 3 && Math.abs(current[0] - before[0] - .02) < 1e-7; }, { before: internalBeforePartOrigin }); const internalBeforePartRotation = JSON.parse(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-selected-joint-world-quaternion")); await control("machine-manager-rotation").locator("input").nth(2).fill("25"); await page.waitForFunction(({ before }) => { const current = JSON.parse(document.querySelector("[data-machine-manager-preview]")?.getAttribute("data-machine-manager-selected-joint-world-quaternion") ?? "[]"); return current.length === 4 && Math.abs(current[3] - before[3]) > 1e-4; }, { before: internalBeforePartRotation }); 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("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.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; }
|
||||
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));
|
||||
// MGR-17 supersedes the unrestricted six-component pose editor. Keep this
|
||||
// established entrypoint exercising MILL700 through the single-DOF contract.
|
||||
process.env.JOINT_TEST_COORDINATES ??= "XYZAC";
|
||||
await import("./test_machine_joint_dof_browser.mjs");
|
||||
|
||||
51
scripts/test_machine_manager_type.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import * as THREE from '../web/react/node_modules/three/build/three.module.js';
|
||||
import { GLTFExporter } from '../web/react/node_modules/three/examples/jsm/exporters/GLTFExporter.js';
|
||||
globalThis.FileReader = class { readAsArrayBuffer(blob) { blob.arrayBuffer().then((result) => { this.result = result; this.onloadend?.(); }); } readAsDataURL(blob) { blob.arrayBuffer().then((result) => { this.result = `data:${blob.type};base64,${Buffer.from(result).toString('base64')}`; this.onloadend?.(); }); } };
|
||||
const scene = new THREE.Scene();scene.name='test-machine';
|
||||
const add = (name,parent) => { const n=new THREE.Mesh(new THREE.BoxGeometry(.04,.04,.04),new THREE.MeshStandardMaterial({color:0x75aab2}));n.name=name;n.position.set(.1,.1,.1);parent.add(n);return n; };
|
||||
const x=add('joint-X',scene), y=add('joint-Y',x);add('joint-Z',y);const tilt=add('joint-tilt',scene);add('joint-C',tilt);
|
||||
const bytes=Buffer.from(await new GLTFExporter().parseAsync(scene,{binary:true,trs:true}));
|
||||
const pw=await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE ?? '/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js'));
|
||||
const browser=await (pw.default??pw).chromium.launch({headless:true,executablePath:'/usr/bin/google-chrome',args:['--no-sandbox','--use-angle=swiftshader','--enable-unsafe-swiftshader']});
|
||||
const page=await browser.newPage({viewport:{width:1440,height:900}});const c=id=>page.locator(`[data-axis-control="${id}"]`);
|
||||
const output=new URL('../coverage/glb-machine-manager/machine-type/',import.meta.url);await mkdir(output,{recursive:true});
|
||||
const report={report_id:'GLB-MGR-E15',url:process.env.MACHINE_MANAGER_URL??'http://127.0.0.1:5180/',status:'running',checks:[],errors:[]};
|
||||
page.on('pageerror',e=>report.errors.push(e.message));page.on('console',m=>{if(['error','warning'].includes(m.type()))report.errors.push(m.text());});
|
||||
const idle=()=>page.waitForFunction(()=>document.querySelector('main.axis-app')?.dataset.axisProgramLoading==='false'&&document.querySelector('main.axis-app')?.dataset.axisStateSource==='linuxcnc-wasm');
|
||||
const choose=async(name)=>{await c('machine-manager-tab-structure').click();await c('machine-tree-search').fill(name);await c('machine-tree-node').filter({has:page.locator('span').filter({hasText:new RegExp(`^${name}$`)})}).first().click();};
|
||||
try{
|
||||
await page.goto(report.url);await idle();await c('machine-manager-settings').click();
|
||||
assert.equal(await c('machine-manager-type').inputValue(),'XYZAC');
|
||||
await c('machine-manager-type').selectOption('XYZBC');
|
||||
assert.match(await c('workspace-case-select').inputValue(),/xyzac-trt/);
|
||||
await c('machine-manager-files').setInputFiles({name:'machine.glb',mimeType:'model/gltf-binary',buffer:bytes});
|
||||
for(const [axis,name] of [['X','joint-X'],['Y','joint-Y'],['Z','joint-Z'],['B','joint-tilt'],['C','joint-C']]){await choose(name);await c('machine-tree-axis-role').selectOption(axis);}
|
||||
assert.equal(await c('machine-manager-export').isEnabled(),true);
|
||||
await c('machine-manager-type').selectOption('XYZAC');await choose('joint-tilt');assert.equal(await c('machine-tree-axis-role').inputValue(),'');assert.equal(await c('machine-manager-export').isDisabled(),true);assert.match(await c('machine-manager-validation').textContent(),/A/);
|
||||
await c('machine-manager-undo').click();assert.equal(await c('machine-manager-type').inputValue(),'XYZBC');assert.equal(await c('machine-tree-axis-role').inputValue(),'B');
|
||||
await c('machine-manager-redo').click();assert.equal(await c('machine-manager-type').inputValue(),'XYZAC');await c('machine-manager-undo').click();
|
||||
await c('machine-manager-tab-calibration').click();await page.locator('fieldset.machine-zero').getByLabel('B',{exact:true}).fill('12');
|
||||
const promise=page.waitForEvent('download');await c('machine-manager-export').click();const download=await promise;assert.equal(download.suggestedFilename(),'managed-xyzbc.glb');const saved=await readFile(await download.path());await writeFile(new URL('test-xyzbc.glb',output),saved);
|
||||
const json=JSON.parse(saved.toString('utf8',20,20+saved.readUInt32LE(12)));assert.equal(json.scenes[0].extras.linuxcncJointModel.coordinates,'XYZBC');assert.equal(json.scenes[0].extras.linuxcncJointModel.calibration.physicalZero.b,12);assert.deepEqual(json.scenes[0].extras.linuxcncJointModel.controller.axisOrder,[...'XYZBC']);
|
||||
report.checks.push('dialog selects BC while controller stays AC; switching drops incompatible tilt only; undo/redo restores type and bindings; saved GLB contains BC metadata');
|
||||
await page.keyboard.press('Escape');await c('workspace-case-select').selectOption('linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini');await idle();await c('workspace-case-select').selectOption('linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini');await idle();
|
||||
await c('machine-manager-settings').click();assert.equal(await c('machine-manager-type').inputValue(),'XYZBC');await c('machine-manager-type').selectOption('XYZAC');
|
||||
await c('machine-manager-files').setInputFiles({name:'managed-xyzbc.glb',mimeType:'model/gltf-binary',buffer:saved});await page.waitForFunction(()=>document.querySelector('[data-axis-control="machine-manager-type"]')?.value==='XYZBC');
|
||||
assert.equal(await page.locator('[data-machine-manager-preview]').getAttribute('data-machine-manager-part-count'),'1');await choose('joint-tilt');assert.equal(await c('machine-tree-axis-role').inputValue(),'B');
|
||||
await c('machine-manager-tab-calibration').click();assert.equal(await page.locator('fieldset.machine-zero').getByLabel('B',{exact:true}).inputValue(),'12');
|
||||
await c('machine-manager-undo').click();assert.equal(await c('machine-manager-type').inputValue(),'XYZAC');await c('machine-manager-redo').click();assert.equal(await c('machine-manager-type').inputValue(),'XYZBC');
|
||||
report.checks.push('controller workspace changes do not change draft type; reopening saved machine restores type, calibration and hierarchy in one undoable transaction');
|
||||
await page.screenshot({path:new URL('dialog-type.png',output).pathname});
|
||||
await c('machine-manager-apply').click();await page.waitForFunction(()=>document.querySelector('[data-axis-control="workspace-case-select"]')?.value.includes('xyzbc-trt'));await idle();assert.equal(await c('machine-manager-type').inputValue(),'XYZBC');
|
||||
await page.keyboard.press('Escape');await c('workspace-case-select').selectOption('linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini');await idle();await c('machine-manager-settings').click();
|
||||
report.checks.push('applying BC design from AC controller changes controller type without altering design draft');
|
||||
await page.keyboard.press('Escape');await c('machine-glb-settings').click();await c('machine-glb-file').setInputFiles({name:'managed-xyzbc.glb',mimeType:'model/gltf-binary',buffer:saved});
|
||||
await page.waitForFunction(()=>document.querySelector('.xyzac-machine-view')?.dataset.machineJointState&&document.querySelector('[data-axis-control="workspace-case-select"]')?.value.includes('xyzbc-trt'));await idle();
|
||||
const geometry=JSON.parse(await page.locator('.xyzac-machine-view').getAttribute('data-machine-manager-geometry'));assert.equal(geometry.coordinates,'XYZBC');assert.equal(geometry.physicalZero.b,12);
|
||||
await page.keyboard.press('Escape');await page.screenshot({path:new URL('controller-import.png',output).pathname});
|
||||
report.checks.push('controller import accepts saved BC GLB from AC workspace and automatically switches to BC with physical zero');assert.deepEqual(report.errors,[]);report.status='verified';
|
||||
}catch(e){report.status='failed';report.error=e.stack;await page.screenshot({path:new URL('failure.png',output).pathname}).catch(()=>{});throw e;}finally{await writeFile(new URL('browser.report.json',output),JSON.stringify(report,null,2)+'\n');await browser.close();}
|
||||
console.log(JSON.stringify(report));
|
||||
79
scripts/test_machine_manager_workbench.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const module = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE ?? '/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js'));
|
||||
const browser = await (module.default ?? module).chromium.launch({ headless: true, executablePath: '/usr/bin/google-chrome', args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
const output = new URL('../coverage/glb-machine-manager/workbench/', import.meta.url); await mkdir(output, { recursive: true });
|
||||
const report = { report_id: 'GLB-MGR-E14', status: 'running', url: process.env.MACHINE_MANAGER_URL ?? 'http://127.0.0.1:5180/', checks: [], errors: [] };
|
||||
page.on('pageerror', (e) => report.errors.push(e.message)); page.on('console', (m) => { if (['error','warning'].includes(m.type())) report.errors.push(m.text()); });
|
||||
const expectValue = async (locator, value) => { await locator.evaluate((el, expected) => new Promise((resolve, reject) => { const end = Date.now() + 5000; const check = () => Number(el.value) === expected ? resolve(true) : Date.now() > end ? reject(new Error(`value ${el.value} != ${expected}`)) : setTimeout(check, 20); check(); }), value); };
|
||||
const c = (id) => page.locator(`[data-axis-control="${id}"]`);
|
||||
const preview = page.locator('[data-machine-manager-preview]');
|
||||
const choose = async (name) => { await c('machine-manager-tab-structure').click(); await c('machine-tree-search').fill(name); const n = c('machine-tree-node').filter({ has: page.locator('span').filter({ hasText: new RegExp(`^${name}$`) }) }).first(); await n.click(); };
|
||||
try {
|
||||
await page.goto(report.url); await page.waitForFunction(() => document.querySelector('main.axis-app')?.dataset.axisProgramLoading === 'false' && document.querySelector('main.axis-app')?.dataset.axisStateSource === 'linuxcnc-wasm');
|
||||
await c('machine-manager-settings').click(); assert.equal(await page.locator('.machine-preview-empty').isVisible(), true);
|
||||
const fileDialog = page.locator('.machine-manager-dialog'); const chooser = page.waitForEvent('filechooser'); await c('machine-manager-import').click(); await chooser; await fileDialog.dispatchEvent('cancel'); assert.equal(await fileDialog.isVisible(), true); await page.evaluate(() => window.dispatchEvent(new Event('focus'))); await page.waitForTimeout(20); report.checks.push('Open Files Cancel leaves the machine manager dialog open');
|
||||
const bytes = await readFile(new URL('../MILL700-U_1.glb', import.meta.url));
|
||||
const upload = { name: 'MILL700-U_1.glb', mimeType: 'model/gltf-binary', buffer: bytes };
|
||||
await c('machine-manager-files').setInputFiles(upload); await page.waitForFunction(() => document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= 132);
|
||||
await choose('MILL700-A-主轴-JT3'); await c('machine-tree-axis-role').selectOption('Z');
|
||||
await c('machine-manager-undo').click(); assert.equal(await c('machine-tree-axis-role').inputValue(), '');
|
||||
await c('machine-manager-redo').click(); assert.equal(await c('machine-tree-axis-role').inputValue(), 'Z');
|
||||
await c('machine-axis-locate-z').click(); assert.equal(await c('machine-tree-search').inputValue(), ''); assert.match(await page.locator('.machine-part-editor h3').textContent(), /主轴-JT3/);
|
||||
assert.equal(await c('machine-tree-node').filter({ hasText:'MILL700-A-主轴-JT3' }).first().isVisible(), true);
|
||||
report.checks.push('search expands real nested nodes; axis overview locates selection; binding undo/redo');
|
||||
await c('machine-joint-value').fill('12.5'); const local = JSON.parse(await preview.getAttribute('data-machine-manager-selected-transform')).local;
|
||||
await c('machine-joint-value').fill(''); assert.equal(await c('machine-joint-value').getAttribute('aria-invalid'), 'true'); assert.match(await c('machine-manager-validation').textContent(), /修正/);
|
||||
assert.deepEqual(JSON.parse(await preview.getAttribute('data-machine-manager-selected-transform')).local, local);
|
||||
await c('machine-joint-value').fill('0'); assert.equal(await c('machine-joint-value').getAttribute('aria-invalid'), 'false');
|
||||
for (let i=0; i<4; i++) {
|
||||
await choose('MILL700-A-主轴-JT2'); await choose('MILL700-A-主轴-JT3');
|
||||
await c('machine-joint-value').fill('30'); await expectValue(c('machine-joint-value'),30);
|
||||
const v=JSON.parse(await preview.getAttribute('data-machine-manager-selected-transform')).local;
|
||||
assert.ok(Math.abs(v[2] - .3989253330230713)<1e-7, 'switching selection and repeated fill must not concatenate input');
|
||||
}
|
||||
await c('machine-joint-step-plus').click(); await expectValue(c('machine-joint-value'),31);
|
||||
await c('machine-joint-step-minus').click(); await expectValue(c('machine-joint-value'),30);
|
||||
await c('machine-joint-reset').click(); await expectValue(c('machine-joint-value'),0);
|
||||
report.checks.push('empty input preserves last pose; repeated selection/input does not concatenate; single-axis step and reset');
|
||||
for (const [axis, name] of [['X','MILL700-A-主轴-JT1'],['Y','MILL700-A-主轴-JT2'],['A','MILL700-A-工作台-JT1'],['C','MILL700-A-工作台-JT2']]) { await choose(name); await c('machine-tree-axis-role').selectOption(axis); }
|
||||
assert.match(await c('machine-manager-validation').textContent(), /检查通过/); assert.equal(await c('machine-manager-apply').isEnabled(), true);
|
||||
await c('machine-manager-tab-calibration').click(); const off = page.locator('fieldset.machine-vector').filter({ hasText:'A 到 C 轴偏置' });
|
||||
await off.getByLabel('X', { exact:true }).fill('5'); assert.match(await c('machine-manager-validation').textContent(), /必须为 0/); assert.equal(await c('machine-manager-export').isDisabled(), true);
|
||||
await c('machine-manager-undo').click(); await expectValue(off.getByLabel('X', { exact:true }), 0);
|
||||
await off.getByLabel('Y', { exact:true }).fill('18'); await c('machine-manager-undo').click(); await expectValue(off.getByLabel('Y', { exact:true }), 0); await c('machine-manager-redo').click(); await expectValue(off.getByLabel('Y', { exact:true }), 18);
|
||||
report.checks.push('TRT forbidden offset blocks export before build; calibration undo/redo');
|
||||
await c('machine-manager-tab-structure').click(); await c('machine-axis-locate-z').click();
|
||||
for (const action of ['fit','selected','front','top']) { await c(`machine-view-${action}`).click(); assert.equal(await preview.getAttribute('data-machine-manager-view'),action); }
|
||||
await c('machine-view-fit').click(); await c('machine-manager-show-origin').uncheck(); await c('machine-manager-show-origin').check();
|
||||
await c('machine-manager-delete').click(); await page.waitForFunction(() => document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount === '0');
|
||||
await c('machine-manager-undo').click(); await page.waitForFunction(() => document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount === '1');
|
||||
assert.equal(await c('machine-manager-apply').isEnabled(), true); await c('machine-axis-locate-z').click(); assert.equal(await c('machine-tree-axis-role').inputValue(), 'Z');
|
||||
report.checks.push('view presets and selected fitting; deletion undo restores model with all axis bindings');
|
||||
const validationBefore = await c('machine-manager-validation').textContent();
|
||||
await c('machine-manager-files').setInputFiles([upload,{ name:'bad.glb',mimeType:'model/gltf-binary',buffer:Buffer.from('bad') }]);
|
||||
await page.waitForFunction(() => document.querySelector('.machine-manager-workbench')?.getAttribute('aria-busy') === 'false');
|
||||
assert.equal(await preview.getAttribute('data-machine-manager-part-count'),'1'); assert.equal(await c('machine-manager-validation').textContent(),validationBefore);
|
||||
report.checks.push('failed batch import leaves existing assembly intact');
|
||||
await c('machine-manager-tab-motion').click(); await c('machine-joint-follow-live').check(); assert.equal(await c('machine-manager-undo').isDisabled(),true);
|
||||
await c('machine-manager-tab-structure').click(); assert.equal(await c('machine-tree-axis-role').isDisabled(),true); assert.equal(await c('machine-manager-delete').isDisabled(),true);
|
||||
await c('machine-manager-tab-motion').click(); await c('machine-joint-follow-live').uncheck();
|
||||
await c('machine-manager-tab-structure').click(); await c('machine-view-fit').click();
|
||||
await page.screenshot({ path:new URL('desktop.png',output).pathname });
|
||||
for (const [width,height] of [[1154,520],[720,900]]) {
|
||||
await page.setViewportSize({width,height}); await page.waitForTimeout(300);
|
||||
const bounds = await page.locator('.machine-manager-dialog').boundingBox(); assert.ok(bounds.x >= 0 && bounds.y >= 0 && bounds.x+bounds.width <= width+1 && bounds.y+bounds.height <=height+1);
|
||||
const footer = await page.locator('.machine-manager-footer').boundingBox(); assert.ok(footer.y+footer.height <=height);
|
||||
const buffer = (await preview.getAttribute('data-machine-manager-buffer-size')).split('x').map(Number); assert.ok(buffer.every((n)=>n>0 && n<=4096));
|
||||
assert.equal(await page.locator('.machine-manager-dialog').evaluate((el)=>el.scrollWidth<=el.clientWidth+1),true);
|
||||
await page.screenshot({ path:new URL(`viewport-${width}.png`,output).pathname });
|
||||
}
|
||||
report.checks.push('live mode locks edits; desktop, short viewport and narrow viewport keep footer visible without overflow');
|
||||
await page.setViewportSize({width:1440,height:900});
|
||||
await page.keyboard.press('Escape'); await c('machine-manager-settings').click(); await c('machine-axis-locate-z').click(); assert.equal(await c('machine-tree-axis-role').inputValue(),'Z');
|
||||
assert.deepEqual(report.errors, []); report.status='verified';
|
||||
} catch(e) { report.status='failed'; report.error=e.stack; await page.screenshot({path:new URL('failure.png',output).pathname}).catch(()=>{}); throw e; }
|
||||
finally { await writeFile(new URL('browser.report.json',output),JSON.stringify(report,null,2)+'\n'); await browser.close(); }
|
||||
console.log(JSON.stringify(report));
|
||||
38
scripts/test_machine_program_compatibility.mjs
Normal file
@@ -0,0 +1,38 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdir,readFile,writeFile } from 'node:fs/promises';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
const imported=await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE??'/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.js'));
|
||||
const browser=await(imported.default??imported).chromium.launch({headless:true,executablePath:'/usr/bin/google-chrome',args:['--no-sandbox','--use-angle=swiftshader','--enable-unsafe-swiftshader']});
|
||||
const page=await browser.newPage({viewport:{width:1440,height:900}});const c=id=>page.locator(`[data-axis-control="${id}"]`);
|
||||
const out=new URL('../coverage/glb-machine-manager/program-compatibility/',import.meta.url);await mkdir(out,{recursive:true});
|
||||
const report={report_id:'TRT-PROGRAM-COMPAT-BROWSER',status:'running',url:process.env.MACHINE_MANAGER_URL??'http://127.0.0.1:5180/',checks:[],errors:[]};
|
||||
page.on('pageerror',e=>report.errors.push(e.message));
|
||||
page.on('console',m=>{if(['error','warning'].includes(m.type()))report.errors.push(m.text());});
|
||||
const idle=()=>page.waitForFunction(()=>document.querySelector('main.axis-app')?.dataset.axisProgramLoading==='false'&&document.querySelector('main.axis-app')?.dataset.axisStateSource==='linuxcnc-wasm',null,{timeout:60000});
|
||||
const checkProgram=async(coordinates)=>{
|
||||
const path=`boat-${coordinates.toLowerCase()}.ngc`;
|
||||
assert.match(await c('workspace-case-select').inputValue(),new RegExp(`${coordinates.toLowerCase()}-trt.ini`));
|
||||
await page.waitForFunction(path=>document.querySelector('main.axis-app')?.dataset.axisProgramPath?.endsWith(path),path);
|
||||
assert.match(await page.locator('.case-status').textContent(),new RegExp(path));
|
||||
const text=await c('program-editor').inputValue();assert.match(text,new RegExp(`${coordinates[3]}[-+0-9.]`,'i'));
|
||||
const other=coordinates[3]==='A'?'B':'A'; assert.equal(new RegExp(`\\b${other}[-+0-9.]`,'i').test(text.replace(/\([^)]*\)/g,'').replace(/;.*/g,'')),false);
|
||||
};
|
||||
const importBc=async()=>{await c('machine-glb-settings').click();await c('machine-glb-file').setInputFiles(new URL('../coverage/glb-machine-manager/machine-type/test-xyzbc.glb',import.meta.url).pathname);await idle();await page.waitForFunction(()=>document.querySelector('[data-axis-control="workspace-case-select"]')?.value.includes('xyzbc-trt'));await page.keyboard.press('Escape');};
|
||||
try{
|
||||
await page.goto(report.url);await idle();await checkProgram('XYZAC');
|
||||
await importBc();await checkProgram('XYZBC');report.checks.push('AC to BC model import replaces source/path/preview with BC boat program');
|
||||
await c('program-file-input').setInputFiles({name:'renamed.ngc',mimeType:'text/plain',buffer:Buffer.from('G21 G90\nG1 X1 A0 C10 F120\nM2\n')});await idle();assert.match(await page.locator('.case-status').textContent(),/不匹配/);assert.match(await page.locator('main.axis-app').getAttribute('data-axis-program-path'),/boat-xyzbc.ngc$/);
|
||||
report.checks.push('renamed AC file rejected on BC; previous loaded taskfile remains intact');
|
||||
const beforeReload=await c('program-editor').inputValue();await c('program-editor').fill('G21 G90\nG1 A10 F120\nM2\n');await c('toolbar-reload').click();await idle();assert.match(await page.locator('.preview-load-indicator').textContent(),/不匹配/);assert.match(await page.locator('main.axis-app').getAttribute('data-axis-program-path'),/boat-xyzbc.ngc$/);await c('program-editor').fill(beforeReload);
|
||||
report.checks.push('editor reload also rejects wrong-axis program without replacing loaded taskfile');
|
||||
const generic='G21 G90\nG1 X1 Y2 F120\nM2\n';await c('program-file-input').setInputFiles({name:'generic.ngc',mimeType:'text/plain',buffer:Buffer.from(generic)});await idle();
|
||||
await importBc();assert.equal(await c('program-editor').inputValue(),generic);assert.equal(await page.locator('main.axis-app').getAttribute('data-axis-program-path'),'browser/generic.ngc');report.checks.push('same-type reimport preserves custom compatible program');
|
||||
await c('machine-glb-settings').click();await c('machine-glb-ac').click();await idle();await page.keyboard.press('Escape');await checkProgram('XYZAC');report.checks.push('BC to AC model import loads AC boat');
|
||||
await c('program-file-input').setInputFiles({name:'wrong-b.ngc',mimeType:'text/plain',buffer:Buffer.from('G21 G90\nG1 B0 X1 F120\nM2\n')});await idle();assert.match(await page.locator('.case-status').textContent(),/不匹配/);
|
||||
await c('workspace-load-boat').click();await idle();await checkProgram('XYZAC');
|
||||
// Saved model applied from manager must follow the same configuration transaction.
|
||||
await c('machine-manager-settings').click();await c('machine-manager-files').setInputFiles(new URL('../coverage/glb-machine-manager/machine-type/test-xyzbc.glb',import.meta.url).pathname);await page.waitForFunction(()=>document.querySelector('[data-axis-control="machine-manager-type"]')?.value==='XYZBC');await c('machine-manager-apply').click();await idle();await page.keyboard.press('Escape');await checkProgram('XYZBC');report.checks.push('manager apply also switches program to matching BC family');
|
||||
await c('toolbar-power').click();await page.waitForFunction(()=>document.querySelector('main.axis-app')?.dataset.axisStateMachine==='ON');await c('manual-home-all').click();await page.waitForFunction(()=>document.querySelector('main.axis-app')?.dataset.axisHomed==='[true,true,true,true,true]',null,{timeout:60000});await c('toolbar-run').click();await page.waitForFunction(()=>document.querySelector('main.axis-app')?.dataset.axisProgramStatus==='running');await checkProgram('XYZBC');await page.waitForFunction(()=>Number(document.querySelector('main.axis-app')?.dataset.axisCurrentLine)>0 && JSON.parse(document.querySelector('.xyzac-machine-view')?.dataset.xyzacJoints??'[]').some(v=>Math.abs(v)>1e-6),null,{timeout:60000});await page.screenshot({path:new URL('bc-running.png',out).pathname});
|
||||
report.checks.push('BC machine runs BC G-code after home; header/controller/program agree');assert.deepEqual(report.errors,[]);report.status='verified';
|
||||
}catch(e){report.status='failed';report.error=e.stack;await page.screenshot({path:new URL('failure.png',out).pathname}).catch(()=>{});throw e;}finally{await writeFile(new URL('browser.report.json',out),JSON.stringify(report,null,2)+'\n');await browser.close();}
|
||||
console.log(JSON.stringify(report));
|
||||
21
scripts/validate_five_axis_catalog.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { FIVE_AXIS_CASES } from "../web/workspace/five-axis-machine-catalog.mjs";
|
||||
|
||||
const root = resolve(new URL("..", import.meta.url).pathname);
|
||||
const checks = [];
|
||||
const errors = [];
|
||||
for (const item of FIVE_AXIS_CASES) {
|
||||
const paths = [...item.localCases, item.kinematicsSource];
|
||||
for (const relative of paths) {
|
||||
try {
|
||||
const text = await readFile(resolve(root, relative), "utf8");
|
||||
checks.push({ case: item.id, path: relative, bytes: Buffer.byteLength(text) });
|
||||
} catch (error) {
|
||||
errors.push({ case: item.id, path: relative, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
}
|
||||
const report = { report_id: "FIVE-AXIS-CATALOG-LOCAL-SOURCE", status: errors.length ? "failed" : "verified", caseCount: FIVE_AXIS_CASES.length, fileCount: checks.length, checks, errors };
|
||||
console.log(JSON.stringify(report, null, 2));
|
||||
if (errors.length) process.exitCode = 1;
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect, useId, useLayoutEffect, useRef, type PointerEvent, type ReactNode } from "react";
|
||||
|
||||
type Props = { open: boolean; title: string; className?: string; onClose: () => void; children: ReactNode };
|
||||
type Props = { open: boolean; title: string; className?: string; onClose: () => void; cancelGuard?: () => boolean; children: ReactNode };
|
||||
|
||||
// Native dialog provides focus containment, inert background, Escape and focus return.
|
||||
// Keeping children mounted preserves drafts and pending import results when closed.
|
||||
export function DraggableDialog({ open, title, className = "", onClose, children }: Props) {
|
||||
export function DraggableDialog({ open, title, className = "", onClose, cancelGuard, children }: Props) {
|
||||
const ref = useRef<HTMLDialogElement>(null);
|
||||
const titleId = useId();
|
||||
const drag = useRef<{ id: number; x: number; y: number; left: number; top: number } | null>(null);
|
||||
@@ -40,7 +40,7 @@ export function DraggableDialog({ open, title, className = "", onClose, children
|
||||
position(from.left + event.clientX - from.x, from.top + event.clientY - from.y);
|
||||
};
|
||||
const stop = () => { drag.current = null; };
|
||||
return <dialog ref={ref} className={`machine-dialog ${className}`} aria-modal="true" aria-labelledby={titleId} onCancel={(event) => { event.preventDefault(); onClose(); }} onKeyDown={(event) => {
|
||||
return <dialog ref={ref} className={`machine-dialog ${className}`} aria-modal="true" aria-labelledby={titleId} onCancel={(event) => { event.preventDefault(); if (!cancelGuard?.()) onClose(); }} onKeyDown={(event) => {
|
||||
if (event.key !== "Tab") return;
|
||||
const elements = [...event.currentTarget.querySelectorAll<HTMLElement>('button:not(:disabled), a[href], input:not(:disabled):not([type="hidden"]), select:not(:disabled), textarea:not(:disabled), summary, [tabindex="0"]')].filter((element) => element.getClientRects().length > 0);
|
||||
const first = elements[0], last = elements.at(-1);
|
||||
|
||||
@@ -8,6 +8,8 @@ export function MachineImportPanel({ model, disabled, onImport }: { model: Impor
|
||||
const file = useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const filePickerOpen = useRef(false);
|
||||
const openFilePicker = () => { if (disabled || !file.current) return; filePickerOpen.current = true; file.current.click(); const clear = () => { window.removeEventListener("focus", clear); setTimeout(() => { filePickerOpen.current = false; }, 0); }; window.addEventListener("focus", clear, { once: true }); };
|
||||
const run = async (source: File | string) => {
|
||||
if (disabled) return;
|
||||
setMessage("正在读取机床结构…");
|
||||
@@ -17,10 +19,10 @@ export function MachineImportPanel({ model, disabled, onImport }: { model: Impor
|
||||
const s = model?.structure;
|
||||
return <section className="machine-import" aria-label="GLB 机床导入" data-machine-import={model?.hash ?? "builtin"}>
|
||||
<div className="machine-import-toolbar"><button type="button" data-axis-control="machine-glb-settings" aria-haspopup="dialog" onClick={() => setOpen(true)}>导入机床模型</button></div>
|
||||
<DraggableDialog open={open} title="三维机床模型" className="model-dialog" onClose={() => setOpen(false)}>
|
||||
<DraggableDialog open={open} title="三维机床模型" className="model-dialog" cancelGuard={() => filePickerOpen.current} onClose={() => setOpen(false)}>
|
||||
<header><strong>三维机床</strong><span>{s ? `${s.coordinates} · ${model.filename}` : "内置模型"}</span></header>
|
||||
<div className="machine-import-actions">
|
||||
<button type="button" data-axis-control="machine-glb-import" disabled={disabled} onClick={() => file.current?.click()}>导入 GLB</button>
|
||||
<button type="button" data-axis-control="machine-glb-import" disabled={disabled} onClick={openFilePicker}>导入 GLB</button>
|
||||
{examples.map(({ id, label, filename }) => <span key={id}><button type="button" data-axis-control={`machine-glb-${id}`} disabled={disabled} onClick={() => void run(`${import.meta.env.BASE_URL}machines/${filename}`)}>{label}</button><a href={`${import.meta.env.BASE_URL}machines/${filename}`} download>下载 {label.replace(" 示例", "")}</a></span>)}
|
||||
</div>
|
||||
{s && <details data-machine-structure><summary>{`底座 → X → Y → Z${s.profile === "trt-xyz-gantry/1" ? "" : " → 主轴箱"};底座 → ${s.coordinates[3]} → C`}</summary><p>旋转中心 (mm):{["x", "y", "z"].map((c) => s.offsets[`${c}_rot_point`].toFixed(3)).join(" / ")}</p><p>轴间偏置 (mm):{["x", "y", "z"].map((c) => s.offsets[`${c}_offset`].toFixed(3)).join(" / ")}</p><p>运行限位、速度、回零和周期使用匹配的 INI;模型行程已校验。</p></details>}
|
||||
|
||||
14
web/react/src/MachineManagerInputs.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useEffect, useLayoutEffect, useState } from "react";
|
||||
|
||||
type Props = { value: number; onChange: (value: number) => void; label?: string; disabled?: boolean; control?: string; fieldId: string; onValidity: (field: string, valid: boolean) => void };
|
||||
export function MachineNumberInput({ value, onChange, label, disabled, control, fieldId, onValidity }: Props) {
|
||||
const [text, setText] = useState(String(value)), [invalid, setInvalid] = useState(false);
|
||||
useLayoutEffect(() => { setText(Number.isFinite(value) ? String(Number(value.toFixed(6))) : ""); setInvalid(false); onValidity(fieldId, true); }, [value, fieldId, onValidity]);
|
||||
useEffect(() => () => onValidity(fieldId, true), [fieldId, onValidity]);
|
||||
return <span className="machine-number-field"><input data-axis-control={control} aria-label={label} type="number" step="any" inputMode="decimal" disabled={disabled} value={text} aria-invalid={invalid} aria-describedby={invalid ? `${fieldId}-error` : undefined} onChange={(event) => {
|
||||
const raw = event.target.value, next = event.target.valueAsNumber;
|
||||
const valid = raw.trim() !== "" && Number.isFinite(next);
|
||||
setText(raw); setInvalid(!valid); onValidity(fieldId, valid);
|
||||
if (valid) onChange(next);
|
||||
}} />{invalid && <small id={`${fieldId}-error`} className="machine-field-error">请输入有效数值</small>}</span>;
|
||||
}
|
||||
@@ -1,74 +1,132 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { TransformControls } from "three/examples/jsm/controls/TransformControls.js";
|
||||
import { GLTFExporter } from "three/examples/jsm/exporters/GLTFExporter.js";
|
||||
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||
import { installMachineStudio, MACHINE_STUDIO } from "./machine-studio-lighting";
|
||||
import { DraggableDialog } from "./DraggableDialog";
|
||||
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 type { Vec3 } from "./machine-urdf";
|
||||
import { configureMachinePartScene, draftNodeDof, makeJointAssembly, reparentMachineNode, buildManagedMachine, expectedParentRole, loadMachinePart, machinePartTree, MACHINE_PART_ROLES, roleLabel, type MachinePartDraft, type MachinePartRole, type MachineTreeNode, type PhysicalZero } from "./machine-manager";
|
||||
import { CNC_AXES, driveJoint, jointZero, defaultDof, isFiveAxisCoordinates, isLinear, type JointDof, type CncAxis } from "./machine-joints";
|
||||
import { projectXyzacVisual, type XyzacVisualSource } from "./xyzac-visual-adapter";
|
||||
import { useMachineDraft, draftFingerprint, changeMachineType } from "./machine-manager-history";
|
||||
import { downloadMachine, listSavedMachines, saveMachineCopy, readMachineCopy, deleteMachineCopy, type SavedMachine } from "./machine-library";
|
||||
import { machineDocumentFilename } from "./machine-document";
|
||||
import { MachineNumberInput } from "./MachineManagerInputs";
|
||||
import { inspectMachineGlb, MAX_MACHINE_BYTES, type Vec3 } from "./machine-urdf";
|
||||
import { FIVE_AXIS_CASES, FIVE_AXIS_STATUS_LABELS } from "../../workspace/five-axis-machine-catalog.mjs";
|
||||
import { analyzeGlbFiveAxisScene, analysisSummary, type GlbFiveAxisAnalysis } from "./glb-five-axis-analyzer";
|
||||
import { MANAGER_SETTINGS, managerAxes, managerProfile, validateManagerCalibration, zeroAxisValues } from "./machine-manager-config";
|
||||
|
||||
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 };
|
||||
type Props = { currentMachine?: ImportedMachine | null; snapshot?: XyzacVisualSource | null; coordinates: string; disabled: boolean; onApply: (machine: ImportedMachine) => Promise<void> };
|
||||
type TestPose = Record<string, 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;
|
||||
type EditorTab = "structure" | "motion" | "calibration" | "files";
|
||||
type PreviewCommand = { action: "fit" | "selected" | "front" | "top"; serial: number };
|
||||
const formatted = (value: number) => Number.isFinite(value) ? value.toFixed(3) : "—";
|
||||
const zeroPose = (coordinates: string): TestPose => zeroAxisValues(coordinates);
|
||||
const axisUnit = (axis: CncAxis) => isLinear(axis) ? "mm" : "deg";
|
||||
const catalogCoordinateOptions = [...new Set(FIVE_AXIS_CASES.map((item) => item.coordinates))];
|
||||
const supportedCoordinateOptions = new Set(FIVE_AXIS_CASES.filter((item) => item.webWasm === "supported").map((item) => item.coordinates));
|
||||
|
||||
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<string, MotionTransform>) => void }) {
|
||||
type PreviewHost = HTMLDivElement & { machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, selectedNodePath: string, mode: "translate" | "rotate", testPose: TestPose, coordinates: "XYZAC" | "XYZBC") => void };
|
||||
function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, testPose, onMotionState, onSelectModel, command }: { command: PreviewCommand; coordinates: string; parts: MachinePartDraft[]; selectedId: string | null; selectedNodePath: string; testPose: TestPose; onMotionState: (state: Record<string, MotionTransform>) => void; onSelectModel: (partId: string) => void }) {
|
||||
type PreviewHost = HTMLDivElement & { machineManagerView?: (action: PreviewCommand["action"]) => void; machineManagerSync?: (parts: MachinePartDraft[], selectedId: string | null, selectedNodePath: string, testPose: TestPose, coordinates: string) => void };
|
||||
const host = useRef<PreviewHost>(null);
|
||||
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 stateRef = useRef({ parts, selectedId, selectedNodePath, testPose, coordinates }); stateRef.current = { parts, selectedId, selectedNodePath, testPose, coordinates };
|
||||
const onMotionStateRef = useRef(onMotionState); onMotionStateRef.current = onMotionState;
|
||||
const onSelectModelRef = useRef(onSelectModel); onSelectModelRef.current = onSelectModel;
|
||||
const [fault, setFault] = useState("");
|
||||
useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, selectedNodePath, mode, testPose, coordinates); }, [parts, selectedId, selectedNodePath, mode, testPose, coordinates]);
|
||||
useEffect(() => { host.current?.machineManagerView?.(command.action); }, [command]);
|
||||
useEffect(() => { host.current?.machineManagerSync?.(parts, selectedId, selectedNodePath, testPose, coordinates); }, [parts, selectedId, selectedNodePath, testPose, coordinates]);
|
||||
useEffect(() => {
|
||||
const element = host.current; if (!element) return;
|
||||
// Large industrial GLBs can exceed small-GPU multisample renderbuffer
|
||||
// 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" });
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace; renderer.toneMapping = THREE.ACESFilmicToneMapping; renderer.toneMappingExposure = 1.45; renderer.shadowMap.enabled = false; element.appendChild(renderer.domElement);
|
||||
renderer.shadowMap.enabled = false; element.appendChild(renderer.domElement);
|
||||
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 maxPixels = Math.min(maxDimension * maxDimension, 4096 * 4096);
|
||||
element.dataset.machineManagerWebglAntialias = String(Boolean(gl.getContextAttributes()?.antialias)); element.dataset.machineManagerMaxBuffer = String(maxDimension);
|
||||
const scene = new THREE.Scene(); scene.background = new THREE.Color(0x263438);
|
||||
const studio = new RoomEnvironment(); const pmrem = new THREE.PMREMGenerator(renderer); const environment = pmrem.fromScene(studio, .04); scene.environment = environment.texture; scene.environmentIntensity = 1.25; studio.dispose(); pmrem.dispose();
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x728083, 2.6)); scene.add(new THREE.AmbientLight(0xffffff, .65));
|
||||
const light = new THREE.DirectionalLight(0xfff5e5, 4.2); light.position.set(3, 5, 4); light.castShadow = true; scene.add(light);
|
||||
const fill = new THREE.DirectionalLight(0xdcefff, 2.4); fill.position.set(-4, 2, 3); scene.add(fill);
|
||||
const rim = new THREE.DirectionalLight(0xffffff, 1.8); rim.position.set(2, 4, -5); scene.add(rim);
|
||||
const studio = installMachineStudio(renderer, scene, "z");
|
||||
const grid = new THREE.GridHelper(4, 40, 0x8eb0b2, 0x466266); grid.name = "machine-xy-ground-grid-z-up"; grid.rotation.x = Math.PI / 2; scene.add(grid);
|
||||
const camera = new THREE.PerspectiveCamera(42, 1, .005, 100); camera.up.set(0, 0, 1); camera.position.set(2.4, -2.8, 1.8);
|
||||
const orbit = new OrbitControls(camera, renderer.domElement); orbit.enableDamping = true;
|
||||
const root = new THREE.Group(); root.name = "machine-manager-preview"; scene.add(root);
|
||||
const selectionBox = new THREE.Box3();
|
||||
const selectionHighlight = new THREE.Box3Helper(selectionBox, 0xffc857);
|
||||
selectionHighlight.name = "selected-import-outline"; selectionHighlight.visible = false; scene.add(selectionHighlight);
|
||||
// Pick only imported geometry; exclude the grid, axes and selection helpers.
|
||||
const owners = new Map<THREE.Object3D, string>();
|
||||
let pickable: THREE.Object3D[] = [];
|
||||
const raycaster = new THREE.Raycaster();
|
||||
let pointer: { id: number; x: number; y: number; dragged: boolean } | null = null;
|
||||
const pointerDown = (event: PointerEvent) => {
|
||||
if (pointer) { pointer.dragged = true; return; }
|
||||
if (event.button === 0 && event.isPrimary) pointer = { id: event.pointerId, x: event.clientX, y: event.clientY, dragged: false };
|
||||
};
|
||||
const pointerMove = (event: PointerEvent) => {
|
||||
if (pointer?.id === event.pointerId && Math.hypot(event.clientX - pointer.x, event.clientY - pointer.y) > 5) pointer.dragged = true;
|
||||
};
|
||||
const pointerCancel = () => { pointer = null; };
|
||||
const pointerUp = (event: PointerEvent) => {
|
||||
const start = pointer; pointer = null;
|
||||
if (!start || start.id !== event.pointerId || start.dragged || event.button !== 0 || Math.hypot(event.clientX - start.x, event.clientY - start.y) > 5) return;
|
||||
const rect = renderer.domElement.getBoundingClientRect();
|
||||
if (!rect.width || !rect.height) return;
|
||||
scene.updateMatrixWorld(true); camera.updateMatrixWorld(true);
|
||||
raycaster.setFromCamera(new THREE.Vector2((event.clientX - rect.left) / rect.width * 2 - 1, 1 - (event.clientY - rect.top) / rect.height * 2), camera);
|
||||
const hit = raycaster.intersectObjects(pickable, false).find(({ object }) => {
|
||||
for (let cursor: THREE.Object3D | null = object; cursor; cursor = cursor.parent) if (!cursor.visible) return false;
|
||||
return true;
|
||||
});
|
||||
const partId = hit && owners.get(hit.object);
|
||||
if (partId) onSelectModelRef.current(partId);
|
||||
};
|
||||
renderer.domElement.addEventListener("pointerdown", pointerDown);
|
||||
renderer.domElement.addEventListener("pointermove", pointerMove);
|
||||
renderer.domElement.addEventListener("pointerup", pointerUp);
|
||||
renderer.domElement.addEventListener("pointercancel", pointerCancel);
|
||||
let dofNodes: Array<{ object: THREE.Object3D; zero: ReturnType<typeof jointZero>; dof: JointDof }> = [];
|
||||
type RoleNode = { object: THREE.Object3D; position: THREE.Vector3; quaternion: THREE.Quaternion };
|
||||
let nodes = new Map<string, THREE.Group>(), visualNodes = new Map<string, Map<string, THREE.Object3D>>(), roleNodes = new Map<MachinePartRole, RoleNode>(), 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;
|
||||
let nodes = new Map<string, THREE.Group>(), visualNodes = new Map<string, Map<string, THREE.Object3D>>(), roleNodes = new Map<MachinePartRole, RoleNode>(), helpers: THREE.AxesHelper[] = [], selectedJointObject: THREE.Object3D | 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.machineManagerView = (action) => {
|
||||
root.updateMatrixWorld(true);
|
||||
const target = action === "selected" ? selectedJointObject : root;
|
||||
if (!target) return;
|
||||
const bounds = new THREE.Box3().setFromObject(target);
|
||||
const center = bounds.isEmpty() ? target.getWorldPosition(new THREE.Vector3()) : bounds.getCenter(new THREE.Vector3());
|
||||
const size = bounds.isEmpty() ? .3 : Math.max(bounds.getSize(new THREE.Vector3()).length(), .1);
|
||||
const direction = action === "top" ? new THREE.Vector3(0, -.001, 1) : action === "front" ? new THREE.Vector3(0, -1, .001) : new THREE.Vector3(1, -.8, .8);
|
||||
const distance = size / (2 * Math.tan(THREE.MathUtils.degToRad(camera.fov / 2))) * Math.max(1, 1 / camera.aspect) * 1.15;
|
||||
orbit.enableDamping = false; orbit.target.copy(center); camera.position.copy(center).addScaledVector(direction.normalize(), distance); camera.near = Math.max(.001, distance / 10000); camera.far = Math.max(100, distance * 20); camera.updateProjectionMatrix(); camera.lookAt(center); orbit.update(); orbit.enableDamping = true;
|
||||
element.dataset.machineManagerView = action; element.dataset.machineManagerCamera = JSON.stringify({ position: camera.position.toArray(), target: orbit.target.toArray(), up: camera.up.toArray() }); dirty = true;
|
||||
};
|
||||
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);
|
||||
element.machineManagerSync = (nextParts, nextSelectedId, nextNodePath, nextPose, nextCoordinates) => {
|
||||
if (nextParts !== lastParts) {
|
||||
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();
|
||||
selectedJointAxes.removeFromParent(); root.clear(); helpers.forEach((helper) => helper.dispose()); helpers = []; dofNodes = []; nodes = new Map(); visualNodes = new Map(); roleNodes = new Map();
|
||||
owners.clear(); pickable = [];
|
||||
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), pathNodes = new Map<string, THREE.Object3D>(); 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 { scene: visual, nodes: pathNodes } = configureMachinePartScene(part.scene.clone(true), part);
|
||||
visual.traverse((object) => { if ((object as THREE.Mesh).isMesh) { owners.set(object, part.id); pickable.push(object); } });
|
||||
const internalRoles = Object.entries(part.nodeRoles ?? {}).map(([path, role]) => ({ object: pathNodes.get(path) ?? null, role }));
|
||||
visual.position.add(new THREE.Vector3(...part.originMm).multiplyScalar(-.001)); node.add(visual); visualNodes.set(part.id, pathNodes);
|
||||
const rootDof = draftNodeDof(part, "scene", nextCoordinates);
|
||||
if (rootDof) dofNodes.push({ object: node, zero: jointZero(node), dof: rootDof });
|
||||
for (const [path, object] of pathNodes) { const dof = path === "scene" ? null : draftNodeDof(part, path, nextCoordinates); if (dof) dofNodes.push({ object, zero: jointZero(object), dof }); }
|
||||
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() });
|
||||
@@ -83,31 +141,47 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, mode
|
||||
}
|
||||
lastParts = nextParts;
|
||||
}
|
||||
// Apply the editor's part transform on every sync. The selected tree node
|
||||
// may be an internal joint, but translation and rotation belong to the
|
||||
// imported GLB part root and must move the complete part assembly.
|
||||
for (const part of nextParts) {
|
||||
const node = nodes.get(part.id);
|
||||
if (!node) continue;
|
||||
node.visible = part.visible;
|
||||
node.position.fromArray(part.translationMm).multiplyScalar(.001);
|
||||
node.rotation.set(...part.rotationDeg.map(THREE.MathUtils.degToRad) as Vec3);
|
||||
|
||||
selectedJointAxes.removeFromParent(); selectedJointObject = nextSelectedId ? nextNodePath === "scene" ? nodes.get(nextSelectedId) ?? null : visualNodes.get(nextSelectedId)?.get(nextNodePath) ?? null : null;
|
||||
const selectedBinding = selectedJointObject ? dofNodes.find((binding) => binding.object === selectedJointObject) ?? null : null;
|
||||
if (selectedJointObject) {
|
||||
selectedJointObject.add(selectedJointAxes); selectedJointAxes.position.set(0, 0, 0);
|
||||
// JointDof.direction is defined in the parent frame. The previous
|
||||
// preview always drew an unrotated local XYZ triad, so a C node whose
|
||||
// imported zero pose is rotated showed a blue axis different from the
|
||||
// axis actually used by driveJoint. Express the configured parent
|
||||
// axis in the joint's zero-pose local frame before drawing the helper.
|
||||
const parentAxis = selectedBinding ? new THREE.Vector3(selectedBinding.dof.direction === "x" ? 1 : 0, selectedBinding.dof.direction === "y" ? 1 : 0, selectedBinding.dof.direction === "z" ? 1 : 0) : null;
|
||||
const localAxis = parentAxis?.applyQuaternion(new THREE.Quaternion(...(selectedBinding?.zero.quaternion ?? [0, 0, 0, 1])).invert()).normalize();
|
||||
selectedJointAxes.quaternion.copy(localAxis ? new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 0, 1), localAxis) : new THREE.Quaternion());
|
||||
selectedJointAxes.scale.setScalar(selectedAxesScale); selectedJointAxes.visible = nextParts.find((part) => part.id === nextSelectedId)?.showOrigin ?? true; element.dataset.machineManagerSelectedJoint = `${nextSelectedId}:${nextNodePath}`; element.dataset.machineManagerSelectedJointAxes = selectedBinding ? `parent-${selectedBinding.dof.direction}${selectedBinding.dof.sign < 0 ? "-" : "+"}` : "local-xyz"; element.dataset.machineManagerSelectedJointAxis = localAxis?.toArray().map((value) => Number(value.toFixed(6))).join(",") ?? ""; element.dataset.machineManagerTransformTarget = "single-dof-input";
|
||||
} else { selectedJointAxes.visible = false; delete element.dataset.machineManagerSelectedJoint; delete element.dataset.machineManagerSelectedJointAxes; delete element.dataset.machineManagerSelectedJointAxis; delete element.dataset.machineManagerTransformTarget; }
|
||||
for (const binding of dofNodes) driveJoint(binding.object, binding.zero, binding.dof, nextPose[binding.dof.axis.toLowerCase()] ?? 0);
|
||||
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);
|
||||
element.dataset.machineManagerJointTransforms = JSON.stringify(Object.fromEntries(dofNodes.map(({ object, dof }) => [dof.axis, { localMm: object.position.toArray().map((v) => v * 1000), quaternion: object.quaternion.toArray(), world: object.getWorldPosition(new THREE.Vector3()).toArray() }]))); 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); } const selectedState = selectedJointObject ? { local: selectedJointObject.position.toArray(), localQuaternion: selectedJointObject.quaternion.toArray(), world: selectedJointObject.getWorldPosition(new THREE.Vector3()).toArray(), worldQuaternion: selectedJointObject.getWorldQuaternion(new THREE.Quaternion()).toArray() } : null;
|
||||
element.dataset.machineManagerSelectedTransform = JSON.stringify(selectedState);
|
||||
selectionBox.makeEmpty();
|
||||
for (const [object, partId] of owners) {
|
||||
if (partId !== nextSelectedId) continue;
|
||||
let visible = true;
|
||||
for (let cursor: THREE.Object3D | null = object; cursor; cursor = cursor.parent) if (!cursor.visible) { visible = false; break; }
|
||||
if (!visible) continue;
|
||||
const geometry = (object as THREE.Mesh).geometry;
|
||||
if (!geometry.boundingBox) geometry.computeBoundingBox();
|
||||
if (geometry.boundingBox) selectionBox.union(geometry.boundingBox.clone().applyMatrix4(object.matrixWorld));
|
||||
}
|
||||
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) { 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); 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();
|
||||
selectionHighlight.visible = !selectionBox.isEmpty();
|
||||
element.dataset.machineManagerSelectedModel = nextSelectedId ?? "";
|
||||
element.dataset.machineManagerSelectionVisible = String(selectionHighlight.visible);
|
||||
onMotionStateRef.current(selectedState ? { ...transforms, selected: selectedState } : transforms); element.dataset.machineManagerTestPose = JSON.stringify(nextPose); element.dataset.machineManagerContextGeneration = "1"; dirty = true; draw();
|
||||
};
|
||||
element.machineManagerSync(stateRef.current.parts, stateRef.current.selectedId, stateRef.current.selectedNodePath, stateRef.current.mode, stateRef.current.testPose, stateRef.current.coordinates);
|
||||
element.machineManagerSync(stateRef.current.parts, stateRef.current.selectedId, stateRef.current.selectedNodePath, 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; }
|
||||
const requested = Math.min(globalThis.devicePixelRatio || 1, 1.5);
|
||||
const scale = Math.min(1, maxDimension / (width * requested), maxDimension / (height * requested), Math.sqrt(maxPixels / (width * height * requested * requested)));
|
||||
const pixelRatio = Math.max(.5, requested * scale);
|
||||
const pixelRatio = 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"; dirty = true; draw();
|
||||
@@ -115,61 +189,156 @@ function ManagerPreview({ coordinates, parts, selectedId, selectedNodePath, mode
|
||||
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();
|
||||
orbit.addEventListener("change", () => { dirty = true; }); transform.addEventListener("change", () => { dirty = true; });
|
||||
// Tear down selection resources independently of the geometry shared with drafts.
|
||||
const disposeSelection = () => {
|
||||
renderer.domElement.removeEventListener("pointerdown", pointerDown);
|
||||
renderer.domElement.removeEventListener("pointermove", pointerMove);
|
||||
renderer.domElement.removeEventListener("pointerup", pointerUp);
|
||||
renderer.domElement.removeEventListener("pointercancel", pointerCancel);
|
||||
owners.clear(); pickable = []; selectionHighlight.dispose();
|
||||
};
|
||||
orbit.addEventListener("change", () => { dirty = true; });
|
||||
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 () => { disposeSelection(); if (frame) cancelAnimationFrame(frame); observer.disconnect(); delete element.machineManagerSync; delete element.machineManagerView; renderer.domElement.removeEventListener("webglcontextlost", contextLostHandler); orbit.dispose(); helpers.forEach((helper) => helper.dispose()); grid.geometry.dispose(); for (const material of Array.isArray(grid.material) ? grid.material : [grid.material]) material.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(); }); studio.dispose(); renderer.dispose(); renderer.domElement.remove(); };
|
||||
}, []);
|
||||
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>;
|
||||
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={MACHINE_STUDIO.lighting} data-machine-manager-tone-mapping={MACHINE_STUDIO.toneMapping} data-machine-manager-exposure={MACHINE_STUDIO.exposure} data-machine-manager-part-count={parts.length} data-machine-manager-origin-count={parts.filter((part) => part.showOrigin).length} 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, query, expansion, locked }: { 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; query: string; expansion: { all: boolean; serial: number }; locked: boolean }) {
|
||||
const key = `${part.id}:${node.path}`;
|
||||
const row = useRef<HTMLLIElement>(null);
|
||||
useEffect(() => { if (selected === key) { const frame = requestAnimationFrame(() => row.current?.querySelector(".machine-tree-row")?.scrollIntoView({ block: "nearest", inline: "nearest" })); return () => cancelAnimationFrame(frame); } }, [selected, key]);
|
||||
const [expanded, setExpanded] = useState(node.path === "scene");
|
||||
useEffect(() => { if (expansion.serial) setExpanded(expansion.all); }, [expansion]);
|
||||
useEffect(() => { if (selected.startsWith(`${key}/`)) setExpanded(true); }, [selected, key]);
|
||||
const matches = (item: MachineTreeNode): boolean => item.name.toLowerCase().includes(query.toLowerCase()) || Boolean(part.nodeJoints?.[item.path]?.axis.toLowerCase().includes(query.toLowerCase())) || item.children.some(matches);
|
||||
const shown = !query || matches(node) || Boolean(extra);
|
||||
const isExpanded = Boolean(query) || expanded;
|
||||
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"}>
|
||||
<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>}
|
||||
const binding = part.nodeJoints?.[node.path]?.axis ?? (node.path === "scene" ? part.role : part.nodeRoles?.[node.path]);
|
||||
return <li ref={row} role="treeitem" hidden={!shown} aria-selected={selected === key} aria-expanded={hasChildren ? isExpanded : 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={`${isExpanded ? "折叠" : "展开"} ${node.name}`} aria-expanded={isExpanded} disabled={!hasChildren} onClick={() => setExpanded((value) => !value)}>{hasChildren ? isExpanded ? "▾" : "▸" : "·"}</button><button type="button" draggable={!locked} title={`${node.name} · 相对父级 ${node.originMm.map(formatted).join(" / ")} mm`} 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 && !locked) { try { const source = JSON.parse(raw); if (typeof source.partId === "string" && typeof source.path === "string") onDrop(source, { partId: part.id, path: node.path }); } catch { /* Ignore unrelated drag payloads. */ } } }}><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={!isExpanded}>{node.children.map((child) => <TreeNode key={child.path} part={part} node={child} selected={selected} onSelect={onSelect} onDrop={onDrop} query={query} expansion={expansion} locked={locked} />)}{extra}</ul>}
|
||||
</li>;
|
||||
}
|
||||
|
||||
export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
|
||||
export function MachineManagerPanel({ coordinates: controllerCoordinates, disabled, onApply, snapshot, currentMachine }: Props) {
|
||||
const [open, setOpen] = useState(false), [pending, setPending] = useState(false), [message, setMessage] = useState("");
|
||||
const [parts, setParts] = useState<MachinePartDraft[]>([]); const partsRef = useRef(parts); partsRef.current = parts;
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null); const [selectedNodePath, setSelectedNodePath] = useState("scene"); const [mode, setMode] = useState<"translate" | "rotate">("translate");
|
||||
const [cOriginMm, setCOriginMm] = useState<Vec3>([0, 0, 0]); const [tiltOffsetMm, setTiltOffsetMm] = useState<Vec3>([0, 0, 0]);
|
||||
const [physicalZero, setPhysicalZero] = useState<PhysicalZero>({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 }); const file = useRef<HTMLInputElement>(null);
|
||||
const [testPose, setTestPose] = useState<TestPose>({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0 });
|
||||
const { history, draft, edit, undo, redo } = useMachineDraft(controllerCoordinates);
|
||||
const { coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero } = draft;
|
||||
const setParts = (change: (current: MachinePartDraft[]) => MachinePartDraft[]) => edit((current) => ({ ...current, parts: change(current.parts) }));
|
||||
const setCOriginMm = (value: Vec3) => edit((current) => ({ ...current, cOriginMm: value }));
|
||||
const setTiltOffsetMm = (value: Vec3) => edit((current) => ({ ...current, tiltOffsetMm: value }));
|
||||
const setPhysicalZero = (value: PhysicalZero) => edit((current) => ({ ...current, physicalZero: value }));
|
||||
const file = useRef<HTMLInputElement>(null), openFile = useRef<HTMLInputElement>(null), filePickerOpen = useRef(false);
|
||||
const beginFilePicker = (input: HTMLInputElement | null) => {
|
||||
if (!input || pending || followLive) return;
|
||||
filePickerOpen.current = true; input.click();
|
||||
const clear = () => { window.removeEventListener("focus", clear); setTimeout(() => { filePickerOpen.current = false; }, 0); };
|
||||
window.addEventListener("focus", clear, { once: true });
|
||||
};
|
||||
const documentInfo = draft.documentInfo ?? { name: "", description: "" };
|
||||
const [library, setLibrary] = useState<SavedMachine[]>([]), [libraryError, setLibraryError] = useState("");
|
||||
const [saveLocation, setSaveLocation] = useState("尚未保存文件"), [savedSignature, setSavedSignature] = useState("");
|
||||
const [libraryQuery, setLibraryQuery] = useState("");
|
||||
const refreshLibrary = useCallback(async () => { try { setLibrary(await listSavedMachines()); setLibraryError(""); } catch { setLibraryError("此浏览器无法使用本地机床库;仍可保存下载 GLB 并从磁盘打开。"); } }, []);
|
||||
useEffect(() => { if (open) void refreshLibrary(); }, [open, refreshLibrary]);
|
||||
const requestOpen = () => { if (!pending && !followLive) openFile.current?.click(); };
|
||||
const newDocument = () => { if (pending || followLive) return; edit(() => ({ coordinates, documentInfo: { name: "", description: "" }, parts: [], cOriginMm: [0, 0, 0], tiltOffsetMm: [0, 0, 0], physicalZero: zeroPose(coordinates) })); setSelectedId(null); setQuery(""); setTab("structure"); setTestPose(zeroPose(coordinates)); setSaveLocation("新建机床尚未保存;旧版本仍在本地机床库"); setMessage("已新建机床,原草稿可撤销恢复"); };
|
||||
const disposeSources = (sources: Awaited<ReturnType<typeof loadMachinePart>>[]) => { const scenes = new Set(sources.flatMap((source) => source.restoredParts?.map((part) => part.scene) ?? [source.scene])); scenes.forEach(disposeMachineScene); };
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null), [selectedNodePath, setSelectedNodePath] = useState("scene");
|
||||
const [tab, setTab] = useState<EditorTab>("structure"), [query, setQuery] = useState("");
|
||||
const [expansion, setExpansion] = useState({ all: false, serial: 0 });
|
||||
const [command, setCommand] = useState<PreviewCommand>({ action: "fit", serial: 0 });
|
||||
const [jogStep, setJogStep] = useState(MANAGER_SETTINGS.initialJogStep);
|
||||
const [applied, setApplied] = useState(""), [exported, setExported] = useState("");
|
||||
const [invalidFields, setInvalidFields] = useState<string[]>([]);
|
||||
const validity = useCallback((field: string, valid: boolean) => setInvalidFields((current) => valid ? current.includes(field) ? current.filter((id) => id !== field) : current : current.includes(field) ? current : [...current, field]), []);
|
||||
const signature = coordinates + draftFingerprint(draft);
|
||||
const retainedScenes = useRef(new Set<THREE.Object3D>());
|
||||
// Retain only assets reachable from the bounded undo/redo history.
|
||||
useEffect(() => {
|
||||
const keep = new Set([...history.past, history.current, ...history.future].flatMap((d) => d.parts.map((part) => part.scene)));
|
||||
for (const scene of retainedScenes.current) if (!keep.has(scene as THREE.Group)) disposeMachineScene(scene);
|
||||
retainedScenes.current = keep;
|
||||
}, [history]);
|
||||
useEffect(() => () => { retainedScenes.current.forEach(disposeMachineScene); }, []);
|
||||
const [testPose, setTestPose] = useState<TestPose>(() => zeroPose(coordinates));
|
||||
const [motionState, setMotionState] = useState<Record<string, MotionTransform>>({}); 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]);
|
||||
useEffect(() => { setTestPose(zeroPose(coordinates)); setFollowLive(false); }, [coordinates]);
|
||||
useEffect(() => { if (!parts.some((part) => part.id === selectedId)) { setSelectedId(parts[0]?.id ?? null); setSelectedNodePath("scene"); } }, [parts, selectedId]);
|
||||
const selected = parts.find((part) => part.id === selectedId) ?? null;
|
||||
const selectedAnalysis = useMemo<GlbFiveAxisAnalysis | null>(() => selected ? analyzeGlbFiveAxisScene(selected.scene, selected.filename) : null, [selected]);
|
||||
const trees = useMemo(() => new Map(parts.map((part) => [part.id, machinePartTree(part)])), [parts]);
|
||||
const findTreeNode = (node: MachineTreeNode | undefined, path: string): MachineTreeNode | null => node ? node.path === path ? node : node.children.map((child) => findTreeNode(child, path)).find(Boolean) ?? null : null;
|
||||
const selectedTreeNode = selected ? findTreeNode(trees.get(selected.id), selectedNodePath) : null;
|
||||
const update = (id: string, values: Partial<MachinePartDraft>) => setParts((current) => current.map((part) => part.id === id ? { ...part, ...values } : part));
|
||||
const vectorInput = (label: string, value: Vec3, set: (value: Vec3) => void, unit: string, control?: string) => <fieldset className="machine-vector" data-axis-control={control}><legend>{label} ({unit})</legend>{["X", "Y", "Z"].map((axis, index) => <label key={axis}>{axis}<input type="number" step="any" value={value[index]} onChange={(event) => { const next = [...value] as Vec3; next[index] = number(event.target.value); set(next); }} /></label>)}</fieldset>;
|
||||
const importFiles = async (list: FileList | File[] | null) => {
|
||||
if (!list?.length) return; setPending(true); setMessage("正在读取 GLB 部件…");
|
||||
const parentAllowed = (id: string, parentId: string | null) => { let cursor = parentId; const visited = new Set([id]); while (cursor) { if (visited.has(cursor)) return false; visited.add(cursor); cursor = parts.find((part) => part.id === cursor)?.parentId ?? null; } return true; };
|
||||
const update = (id: string, values: Partial<MachinePartDraft>) => { if (pending || followLive) return; if (values.parentId && !parentAllowed(id, values.parentId)) { setMessage("不能将部件挂到自身或后代节点"); return; } setParts((current) => current.map((part) => part.id === id ? { ...part, ...values } : part)); setMessage(""); };
|
||||
const vectorInput = (label: string, value: Vec3, set: (value: Vec3) => void, unit: string) => <fieldset className="machine-vector"><legend>{label} ({unit})</legend>{["X", "Y", "Z"].map((axis, index) => <label key={axis}>{axis}<MachineNumberInput fieldId={`${label}-${axis}`} value={value[index]} disabled={pending || followLive} onValidity={validity} onChange={(n) => { const next = [...value] as Vec3; next[index] = n; set(next); }} /></label>)}</fieldset>;
|
||||
const importFiles = async (list: FileList | File[] | null, savedOnly = false) => {
|
||||
if (!list?.length || pending || followLive) return; setPending(true); setMessage("正在读取 GLB 部件…");
|
||||
try {
|
||||
const loaded = await Promise.all([...list].map(async (item) => loadMachinePart(await item.arrayBuffer(), item.name)));
|
||||
setParts((current) => {
|
||||
const next = [...current];
|
||||
const results = await Promise.allSettled([...list].map(async (item) => { if (!item.name.toLowerCase().endsWith(".glb") || item.size > MAX_MACHINE_BYTES) throw new Error(`${item.name}:请选择不超过 64 MiB 的 GLB`); try { return await loadMachinePart(await item.arrayBuffer(), item.name); } catch { throw new Error(`${item.name}:模型无法读取,请检查是否为完整的自包含 GLB,或配置是否与模型层级一致`); } }));
|
||||
const loaded = results.flatMap((result) => result.status === "fulfilled" ? [result.value] : []);
|
||||
const failure = results.find((result) => result.status === "rejected");
|
||||
if (failure?.status === "rejected") { disposeSources(loaded); throw failure.reason; }
|
||||
const restoredSources = loaded.filter((source) => source.restoredModel);
|
||||
const restoredSource = restoredSources[0];
|
||||
const restored = restoredSource?.restoredModel;
|
||||
if (savedOnly && !restored) { disposeSources(loaded); throw new Error("请选择已保存的机床 GLB;普通几何部件请使用“导入 GLB 部件”"); }
|
||||
// A saved machine is opened as one document, not appended to another
|
||||
// machine. Raw component imports continue to append to this draft.
|
||||
if (restored && loaded.length !== 1) {
|
||||
disposeSources(loaded);
|
||||
throw new Error("完整机床 GLB 请单独打开;普通部件可批量导入");
|
||||
}
|
||||
const autoAnalysis = new Map(loaded.map((source) => [source.id, source.restoredModel ? null : analyzeGlbFiveAxisScene(source.scene, source.filename)]));
|
||||
const detectedCoordinates = loaded.length === 1 ? autoAnalysis.get(loaded[0].id)?.suggestedCoordinates : undefined;
|
||||
const autoCoordinates = detectedCoordinates && isFiveAxisCoordinates(detectedCoordinates) ? detectedCoordinates : draft.coordinates;
|
||||
const nextDraft = (() => {
|
||||
const next = restored ? [] : [...draft.parts];
|
||||
for (const source of loaded) {
|
||||
if (source.restoredParts) { next.push(...source.restoredParts); continue; }
|
||||
const role = MACHINE_PART_ROLES.find((candidate) => !next.some((part) => part.role === candidate)) ?? "";
|
||||
const parentRole = role ? expectedParentRole(role) : null;
|
||||
const parentId = parentRole ? next.find((part) => part.role === parentRole)?.id ?? null : null;
|
||||
next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {}, nodeRoles: {} });
|
||||
const analysis = autoAnalysis.get(source.id);
|
||||
const nodeJoints = source.restoredJoints ?? (analysis ? Object.fromEntries(analysis.axes.filter((item) => autoCoordinates.includes(item.axis)).map((item) => [item.path, defaultDof(item.axis as CncAxis)])) : {});
|
||||
// A single complete GLB such as m1.glb keeps its native hierarchy.
|
||||
// Promote the detected axis nodes to structure roles so the
|
||||
// Structure & Joints tab is immediately usable, while preserving
|
||||
// explicit roles restored from a managed machine document.
|
||||
const detectedRoles: Record<string, MachinePartRole | ""> = analysis ? Object.fromEntries(analysis.axes.filter((item) => autoCoordinates.includes(item.axis)).map((item) => [item.path, (item.axis === "A" || item.axis === "B" ? "tilt" : item.axis.toLowerCase()) as MachinePartRole])) : {};
|
||||
const nodeRoles = source.restoredRoles ?? detectedRoles;
|
||||
next.push({ ...source, role, parentId, visible: true, showOrigin: true, originMm: zero3(), translationMm: zero3(), rotationDeg: zero3(), nodeParents: {}, nodeTransforms: {}, nodeJoints, nodeRoles });
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSelectedId(loaded[0].id); setSelectedNodePath("scene"); setMessage(`已导入 ${loaded.length} 个部件`);
|
||||
return { ...draft, parts: next, ...(restored ? { coordinates: restored.coordinates, documentInfo: restoredSource?.restoredInfo ?? { name: loaded[0].filename.replace(/\.glb$/i, ""), description: "" }, cOriginMm: restored.calibration.cOriginMm, tiltOffsetMm: restored.calibration.tiltOffsetMm, physicalZero: restored.calibration.physicalZero as PhysicalZero } : { coordinates: autoCoordinates }) };
|
||||
})();
|
||||
edit(() => nextDraft);
|
||||
if (restored) setSavedSignature(nextDraft.coordinates + draftFingerprint(nextDraft));
|
||||
setTestPose(zeroPose(restored?.coordinates ?? autoCoordinates)); setTab("structure"); setQuery(""); setSelectedId(loaded[0].restoredParts?.[0].id ?? loaded[0].id); setSelectedNodePath("scene"); if (restored) setSaveLocation(`已打开文件:${loaded[0].filename};可继续编辑并保存新版本`); setMessage(restored ? `已打开 ${restored.coordinates} 完整机床,恢复层级、关节和标定;原草稿可撤销恢复` : `已导入 ${loaded.length} 个部件${detectedCoordinates && isFiveAxisCoordinates(detectedCoordinates) ? `;自动识别 ${detectedCoordinates},已绑定 ${autoAnalysis.get(loaded[0].id)?.axes.filter((item) => autoCoordinates.includes(item.axis)).length ?? 0}/${[...autoCoordinates].length} 轴` : ""}`);
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { setPending(false); }
|
||||
};
|
||||
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 remove = (part: MachinePartDraft) => {
|
||||
if (pending || followLive || !parts.some((item) => item.id === part.id)) return;
|
||||
const index = parts.findIndex((item) => item.id === part.id);
|
||||
const remaining = parts.filter((item) => item.id !== part.id);
|
||||
// Remove this import only. Separately imported children remain in the draft.
|
||||
setParts((current) => current.filter((item) => item.id !== part.id).map((item) => item.parentId === part.id ? { ...item, parentId: null } : item));
|
||||
setMessage(`已删除 ${part.filename},可撤销恢复`);
|
||||
if (selectedId === part.id) {
|
||||
setSelectedId(remaining[Math.min(index, remaining.length - 1)]?.id ?? null);
|
||||
setSelectedNodePath("scene"); setQuery("");
|
||||
}
|
||||
};
|
||||
const selectTreeNode = (partId: string, path: string) => { setSelectedId(partId); setSelectedNodePath(path); };
|
||||
const selectModel = (partId: string) => {
|
||||
if (pending || !parts.some((part) => part.id === partId)) return;
|
||||
selectTreeNode(partId, "scene"); setQuery(""); setTab("structure");
|
||||
};
|
||||
const reparentTreeNode = (source: { partId: string; path: string }, target: { partId: string; path: string }) => {
|
||||
if (pending || followLive) return;
|
||||
if (source.partId === target.partId && source.path === target.path) return;
|
||||
if (source.path === "scene") {
|
||||
if (target.path !== "scene") { setMessage("整机/部件根节点只能拖到另一个 GLB 根节点"); return; }
|
||||
@@ -179,62 +348,174 @@ export function MachineManagerPanel({ coordinates, disabled, onApply }: Props) {
|
||||
}
|
||||
if (source.partId !== target.partId) { setMessage("GLB 内部节点只能在同一 GLB 层级树内拖动"); return; }
|
||||
const part = parts.find((item) => item.id === source.partId)!;
|
||||
const candidate = { ...part, nodeParents: { ...part.nodeParents, [source.path]: target.path === "scene" ? null : target.path } };
|
||||
try { machinePartTree(candidate); update(part.id, { nodeParents: candidate.nodeParents }); setSelectedId(part.id); setSelectedNodePath(source.path); setMessage("GLB 内部层级已更新;原始导入模型未修改"); }
|
||||
try {
|
||||
const changes = reparentMachineNode(part, source.path, target.path);
|
||||
machinePartTree({ ...part, ...changes }); update(part.id, changes);
|
||||
setSelectedId(part.id); setSelectedNodePath(source.path); setMessage("GLB 内部层级已更新,保留当前装配姿态");
|
||||
}
|
||||
catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
};
|
||||
const apply = async () => {
|
||||
if (disabled) return; setPending(true); setMessage("正在生成机床层级和 URDF…"); let machine: ImportedMachine | null = null;
|
||||
try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); await onApply(machine); setMessage("三维机床已应用;请重新回零后运行程序"); }
|
||||
if (disabled || pending || followLive || blockingReason) return; setPending(true); setMessage("正在生成机床层级和 URDF…"); let machine: ImportedMachine | null = null;
|
||||
try { machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero, documentInfo }); await onApply(machine); setApplied(signature); setMessage("三维机床已应用;请重新回零后运行程序"); }
|
||||
catch (error) { if (machine) disposeMachineScene(machine.scene); setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { setPending(false); }
|
||||
};
|
||||
const exportGlb = async () => {
|
||||
if (pending || followLive || blockingReason) return;
|
||||
setPending(true); setMessage("正在生成包含 userData 的组合 GLB…"); let machine: ImportedMachine | null = null;
|
||||
try {
|
||||
machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero });
|
||||
const bytes = await new GLTFExporter().parseAsync(machine.scene, { binary: true, trs: true, onlyVisible: false });
|
||||
const url = URL.createObjectURL(new Blob([bytes as ArrayBuffer], { type: "model/gltf-binary" }));
|
||||
const link = document.createElement("a"); link.href = url; link.download = machine.filename; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
setMessage("组合 GLB 已导出,底座和整机场景包含机床标定 userData");
|
||||
machine = await buildManagedMachine({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero, documentInfo });
|
||||
const exportScene = new THREE.Scene(); exportScene.name = machine.scene.name; exportScene.userData = machine.scene.userData;
|
||||
for (const child of machine.scene.children) exportScene.add(child.clone(true));
|
||||
const bytes = await new GLTFExporter().parseAsync(exportScene, { binary: true, trs: true, onlyVisible: false });
|
||||
if (!(bytes instanceof ArrayBuffer) || bytes.byteLength > MAX_MACHINE_BYTES) throw new Error("保存文件超过控制面板支持的 64 MiB,请减小模型后重试");
|
||||
const saved = inspectMachineGlb(bytes);
|
||||
if (saved.structure.coordinates !== coordinates) throw new Error("保存模型的机型与当前配置不一致");
|
||||
const filename = machineDocumentFilename(documentInfo.name, coordinates);
|
||||
let localCopy = false;
|
||||
try {
|
||||
const sha256 = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
await saveMachineCopy({ id: crypto.randomUUID(), name: documentInfo.name.trim() || `${coordinates} 机床`, filename, coordinates, savedAt: new Date().toISOString(), size: bytes.byteLength, sha256, partCount: parts.length, jointCount: bindings.length }, bytes);
|
||||
localCopy = true; await refreshLibrary();
|
||||
} catch { setLibraryError("本地机床库保存失败,可能存储空间不足。已继续下载 GLB,请妥善保存下载文件。"); }
|
||||
downloadMachine(bytes, filename);
|
||||
setExported(signature); setSavedSignature(localCopy ? signature : "");
|
||||
setSaveLocation(`${filename}:已发起浏览器下载${localCopy ? ";同时保存到本浏览器机床库" : ";本地机床库未保存成功"}`);
|
||||
setMessage(`已保存 ${filename}。磁盘位置见浏览器下载记录(通常为“下载”目录);${localCopy ? "下次可在“文件与恢复”直接打开。" : "请从磁盘打开恢复。"} 控制面板“导入机床模型”可使用此 GLB。`);
|
||||
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { if (machine) disposeMachineScene(machine.scene); setPending(false); }
|
||||
};
|
||||
const openLibraryModel = async (entry: SavedMachine, download = false) => {
|
||||
if (pending || (followLive && !download)) return;
|
||||
setPending(true);
|
||||
try {
|
||||
const bytes = await readMachineCopy(entry.id);
|
||||
if (!bytes) throw new Error("本地文件不存在,请从磁盘重新打开");
|
||||
const digest = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((value) => value.toString(16).padStart(2, "0")).join("");
|
||||
if (digest !== entry.sha256) throw new Error("本地机床副本校验失败,请从磁盘备份重新打开");
|
||||
if (download) { downloadMachine(bytes, entry.filename); setMessage(`已再次下载 ${entry.filename},请在浏览器下载记录中查看位置`); }
|
||||
else await importFiles([new File([bytes], entry.filename, { type: "model/gltf-binary" })], true);
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
|
||||
finally { setPending(false); }
|
||||
};
|
||||
const removeLibraryModel = async (entry: SavedMachine) => {
|
||||
if (pending || !globalThis.confirm(`删除本浏览器机床库中的“${entry.name}”版本?磁盘下载文件和当前草稿不受影响。`)) return;
|
||||
setPending(true);
|
||||
try { await deleteMachineCopy(entry.id); await refreshLibrary(); setMessage("已删除本地保存版本"); } catch { setMessage("删除失败,请稍后重试"); } finally { setPending(false); }
|
||||
};
|
||||
const editControllerModel = async () => {
|
||||
if (!currentMachine || pending || followLive) return;
|
||||
setPending(true);
|
||||
try {
|
||||
const scene = new THREE.Scene(); scene.userData = JSON.parse(JSON.stringify(currentMachine.scene.userData));
|
||||
// Geometry overrides belong to the current controller model even when its
|
||||
// immutable source GLB was loaded before the override.
|
||||
if (scene.userData.linuxcncJointModel) {
|
||||
const offsets = currentMachine.structure.offsets;
|
||||
scene.userData.linuxcncJointModel.calibration = { cOriginMm: [offsets.x_rot_point, offsets.y_rot_point, offsets.z_rot_point], tiltOffsetMm: [offsets.x_offset, offsets.y_offset, offsets.z_offset], physicalZero: { ...scene.userData.linuxcncJointModel.calibration.physicalZero, ...currentMachine.physicalZero } };
|
||||
}
|
||||
for (const child of currentMachine.scene.children) scene.add(child.clone(true));
|
||||
const bytes = await new GLTFExporter().parseAsync(scene, { binary: true, trs: true, onlyVisible: false });
|
||||
await importFiles([new File([bytes as ArrayBuffer], currentMachine.filename, { type: "model/gltf-binary" })], true);
|
||||
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); } finally { setPending(false); }
|
||||
};
|
||||
const roleChange = (part: MachinePartDraft, role: MachinePartRole | "") => {
|
||||
if (selectedNodePath !== "scene") { update(part.id, { nodeRoles: { ...part.nodeRoles, [selectedNodePath]: role } }); return; }
|
||||
const parentRole = role ? expectedParentRole(role) : 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 selectedDof = selected ? draftNodeDof(selected, selectedNodePath, coordinates) : null;
|
||||
const setDof = (dof: JointDof | null) => {
|
||||
if (!selected || pending || followLive) return;
|
||||
if (dof && parts.some((part) => Object.entries(part.nodeJoints ?? {}).some(([path, existing]) => existing.axis === dof.axis && (part.id !== selected.id || path !== selectedNodePath)))) { setMessage(`${dof.axis} 轴已绑定其他节点,请先取消该绑定`); return; }
|
||||
setParts((current) => current.map((part) => { if (part.id !== selected.id) return part; const nodeJoints = { ...(part.nodeJoints ?? {}) }; if (dof) nodeJoints[selectedNodePath] = dof; else delete nodeJoints[selectedNodePath]; return { ...part, nodeJoints }; }));
|
||||
setMessage("");
|
||||
};
|
||||
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 [followLive, setFollowLive] = useState(false);
|
||||
const visual = useMemo(() => projectXyzacVisual(snapshot), [snapshot]);
|
||||
const liveReady = visual.joints.every(Number.isFinite) && visual.authoritative && visual.machineModel?.coordinate_system === coordinates;
|
||||
const heldLivePose = useRef<TestPose>(testPose);
|
||||
const effectivePose: TestPose = followLive && liveReady ? { ...zeroPose(coordinates), ...Object.fromEntries([...coordinates].map((axis, i) => [axis.toLowerCase(), visual.joints[i] - Number(physicalZero[axis.toLowerCase()] ?? 0)])) } : followLive ? heldLivePose.current : testPose;
|
||||
if (followLive && liveReady) heldLivePose.current = effectivePose;
|
||||
const validation = useMemo(() => { try { validateManagerCalibration(coordinates, cOriginMm, tiltOffsetMm); const { model } = makeJointAssembly({ coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero }); const missing = [...coordinates].filter((axis) => !model.axisMap[axis as CncAxis]); return { model, error: missing.length ? `请绑定 ${missing.join("、")} 轴` : "" }; } catch (error) { return { model: null, error: error instanceof Error ? error.message : String(error) }; } }, [coordinates, parts, cOriginMm, tiltOffsetMm, physicalZero]);
|
||||
const blockingReason = invalidFields.length ? "请修正标红的数值输入" : validation.error;
|
||||
const bindings = useMemo(() => parts.flatMap((part) => Object.entries(part.nodeJoints ?? {}).map(([path, dof]) => ({ part, path, dof, name: findTreeNode(trees.get(part.id), path)?.name ?? part.filename }))), [parts, trees]);
|
||||
const moveToBinding = (axis: string) => { const binding = bindings.find((b) => b.dof.axis === axis); setTab("structure"); setQuery(""); if (binding) selectTreeNode(binding.part.id, binding.path); else { setMessage(`在层级树中选择 ${axis} 轴关节,再设置唯一自由度`); } };
|
||||
const changeHistory = (direction: "undo" | "redo") => {
|
||||
if (pending || followLive) return;
|
||||
const next = direction === "undo" ? history.past.at(-1) : history.future[0];
|
||||
if (!next) return;
|
||||
const restoredPart = next.parts.find((part) => !parts.some((current) => current.id === part.id));
|
||||
const nextId = restoredPart?.id ?? (next.parts.some((part) => part.id === selectedId) ? selectedId : next.parts[0]?.id ?? null);
|
||||
if (nextId !== selectedId) { setSelectedId(nextId); setSelectedNodePath("scene"); setQuery(""); }
|
||||
if (direction === "undo") undo(); else redo();
|
||||
setMessage(direction === "undo" ? "已撤销配置修改" : "已重做配置修改");
|
||||
};
|
||||
const exportJson = () => {
|
||||
if (!validation.model || pending || invalidFields.length) return;
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(validation.model, null, 2)], { type: "application/json" }));
|
||||
const a = document.createElement("a"); a.href = url; a.download = `machine-joints-${coordinates.toLowerCase()}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); setMessage("关节 JSON 已导出;恢复完整模型请使用“保存机床模型 GLB”");
|
||||
};
|
||||
const poseInput = (axis: CncAxis) => { const key = axis.toLowerCase(); return <label key={axis}>{axis}<MachineNumberInput fieldId={`test-${key}`} control={`machine-test-${key}`} onValidity={validity} disabled={pending || followLive || !bindings.some((b) => b.dof.axis === axis)} value={effectivePose[key] ?? 0} onChange={(value) => setTestPose((pose) => ({ ...pose, [key]: value }))} /><small>{axisUnit(axis)}</small></label>; };
|
||||
const receiveMotionState = (state: Record<string, MotionTransform>) => { 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 => <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 selectedDriven = motionState.selected ?? null;
|
||||
const selectedTransform = selectedNodePath === "scene" ? selected : selected?.nodeTransforms?.[selectedNodePath] ?? (selectedTreeNode ? { translationMm: selectedTreeNode.originMm, rotationDeg: selectedTreeNode.rotationDeg } : null);
|
||||
const activeAxes = managerAxes(coordinates);
|
||||
const profile = managerProfile(coordinates);
|
||||
const renderPartTree = (part: MachinePartDraft): React.ReactNode => <TreeNode key={part.id} part={part} node={trees.get(part.id)!} selected={`${selectedId}:${selectedNodePath}`} onSelect={selectTreeNode} onDrop={reparentTreeNode} query={query} expansion={expansion} locked={pending || followLive} extra={parts.filter((child) => child.parentId === part.id).map(renderPartTree)} />;
|
||||
return <section className="machine-manager" aria-label="三维机床管理">
|
||||
<button type="button" data-axis-control="machine-manager-settings" aria-haspopup="dialog" onClick={() => setOpen(true)}>三维机床管理</button>
|
||||
<DraggableDialog open={open} title={`三维机床管理 · ${coordinates}`} className="machine-manager-dialog" onClose={() => setOpen(false)}>
|
||||
<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>
|
||||
<DraggableDialog open={open} title={`三维机床管理 · ${coordinates}`} className="machine-manager-dialog" cancelGuard={() => filePickerOpen.current} onClose={() => setOpen(false)}>
|
||||
<div className="machine-manager-workbench" aria-busy={pending}>
|
||||
<div className="machine-type-step"><label><strong>1 · 机床类型</strong><select data-axis-control="machine-manager-type" aria-label="设计机床类型" value={coordinates} disabled={pending || followLive} onChange={(event) => {
|
||||
const next = event.target.value;
|
||||
edit((draft) => changeMachineType(draft, next)); setTab("structure");
|
||||
setMessage(`设计机型已选择 ${next};保留模型层级,请核对 ${next} 轴绑定和标定。可撤销恢复。`);
|
||||
}}>{catalogCoordinateOptions.map((axisSet) => { const caseItem = FIVE_AXIS_CASES.find((item) => item.coordinates === axisSet); return <option key={axisSet} value={axisSet} disabled={!supportedCoordinateOptions.has(axisSet)}>{caseItem?.label ?? axisSet}{supportedCoordinateOptions.has(axisSet) ? "" : " · 待 WASM 适配"}</option>; })}</select></label><label><strong>LinuxCNC 案例/运动学</strong><select data-axis-control="machine-manager-case-type" aria-label="LinuxCNC 案例与运动学" value={FIVE_AXIS_CASES.find((item) => item.coordinates === coordinates && item.webWasm === "supported")?.id ?? FIVE_AXIS_CASES.find((item) => item.coordinates === coordinates)?.id ?? FIVE_AXIS_CASES[0].id} disabled={pending || followLive} onChange={(event) => {
|
||||
const selectedCase = FIVE_AXIS_CASES.find((item) => item.id === event.target.value);
|
||||
if (!selectedCase || !supportedCoordinateOptions.has(selectedCase.coordinates)) { setMessage(`${selectedCase?.label ?? event.target.value} 已登记源码案例,但当前 Web/WASM 运动学适配尚未完成`); return; }
|
||||
edit((draft) => changeMachineType(draft, selectedCase.coordinates)); setTab("structure"); setMessage(`已选择案例 ${selectedCase.label},设计机型同步为 ${selectedCase.coordinates}`);
|
||||
}}>{FIVE_AXIS_CASES.map((item) => <option key={item.id} value={item.id} disabled={item.webWasm !== "supported"}>{item.label}{item.webWasm === "supported" ? "" : " · 待 WASM 适配"}</option>)}</select></label><div><span>2 · 结构与关节 → 3 · 验证与标定 → 4 · 保存 GLB</span><small data-axis-control="machine-manager-type-note">{coordinates.length >= 5 ? `当前坐标 ${coordinates} · ${coordinates[3]} / ${coordinates[4]} 旋转轴` : "请从案例目录选择有效坐标"} · 控制面板当前 {controllerCoordinates}{coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : ""} · 其它源码类型见“文件与恢复”的案例库</small></div></div>
|
||||
<div className="machine-manager-toolbar"><button type="button" data-axis-control="machine-document-new" disabled={pending || followLive} onClick={newDocument}>新建机床</button><button type="button" data-axis-control="machine-document-open" disabled={pending || followLive} onClick={() => beginFilePicker(openFile.current)}>打开已保存机床</button><button type="button" data-axis-control="machine-document-from-controller" disabled={!currentMachine?.scene.userData.linuxcncJointModel || pending || followLive} onClick={() => void editControllerModel()}>编辑控制面板机床</button><button type="button" data-axis-control="machine-manager-import" disabled={pending || followLive} onClick={() => beginFilePicker(file.current)}>导入 GLB 部件</button><button type="button" data-axis-control="machine-model-delete-selected" disabled={!selected || pending || followLive} title={selected ? `删除 ${selected.filename},可撤销恢复` : "请先选择导入模型"} onClick={() => { if (selected) remove(selected); }}>删除选中模型</button><button type="button" data-axis-control="machine-manager-undo" disabled={!history.past.length || pending || followLive} onClick={() => changeHistory("undo")}>撤销</button><button type="button" data-axis-control="machine-manager-redo" disabled={!history.future.length || pending || followLive} onClick={() => changeHistory("redo")}>重做</button><span>{parts.length} 个模型 · {bindings.filter((b) => coordinates.includes(b.dof.axis)).length}/{activeAxes.length} 轴已绑定</span><span className="machine-save-state" data-axis-control="machine-manager-draft-state">{!parts.length ? "新建机床" : savedSignature === signature ? "与保存版本一致" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用"}</span></div>
|
||||
<div className="machine-axis-overview" aria-label="数控轴绑定总览">{activeAxes.map((axis) => { const binding = bindings.find((b) => b.dof.axis === axis); return <button type="button" key={axis} data-axis-control={`machine-axis-locate-${axis.toLowerCase()}`} className={binding ? "bound" : "missing"} onClick={() => moveToBinding(axis)} title={binding ? `${binding.name} · 父级 ${binding.dof.direction.toUpperCase()} ${binding.dof.sign > 0 ? "+" : "−"}` : `待绑定 ${axis} 轴`}><strong>{axis}</strong><span>{binding?.name ?? "待绑定"}</span><small>{binding ? `${binding.dof.sign > 0 ? "+" : "−"}${binding.dof.direction.toUpperCase()}` : "选择节点"}</small></button>; })}</div>
|
||||
<div className="machine-manager-layout">
|
||||
{open && <MachineManagerPanelPreview coordinates={coordinates} parts={parts} selectedId={selectedId} selectedNodePath={selectedNodePath} mode={mode} testPose={testPose} onTransform={(id, translationMm, rotationDeg) => update(id, { translationMm, rotationDeg })} onMotionState={receiveMotionState} />}
|
||||
<section className="machine-preview-area" aria-label="机床三维预览"><div className="machine-preview-tools">{([ ["fit", "整机适配"], ["selected", "定位选中"], ["front", "正视"], ["top", "俯视"] ] as const).map(([action, label]) => <button key={action} type="button" data-axis-control={`machine-view-${action}`} disabled={!parts.length || (action === "selected" && !selected)} onClick={() => setCommand((current) => ({ action, serial: current.serial + 1 }))}>{label}</button>)}<small>Z ↑ · X 红 / Y 绿 / Z 蓝</small></div>
|
||||
{open && <MachineManagerPanelPreview command={command} coordinates={coordinates} parts={parts} selectedId={selectedId} selectedNodePath={selectedNodePath} testPose={effectivePose} onMotionState={receiveMotionState} onSelectModel={selectModel} />}
|
||||
{!parts.length && <div className="machine-preview-empty"><strong>建立机床的数字装配</strong><p>选择机床类型 → 导入 GLB → 绑定关节 → 保存机床模型</p><button type="button" disabled={pending} onClick={() => beginFilePicker(file.current)}>导入机床或部件</button><small>支持完整层级 · 单文件不超过 64 MiB</small>{library.length > 0 && <button type="button" onClick={() => setTab("files")}>恢复本地机床库({library.length} 个版本)</button>}</div>}
|
||||
<p className="machine-preview-hint">单击选择模型 · 拖动旋转 · 滚轮缩放 · 右键平移 · 黄色框为选中模型</p></section>
|
||||
<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>
|
||||
{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>节点自身局部原点</span><output>X 0.000 Y 0.000 Z 0.000</output><span>关节零姿态原点(相对父节点,mm)</span><output>X {selectedTreeNode.originMm[0].toFixed(3)} Y {selectedTreeNode.originMm[1].toFixed(3)} Z {selectedTreeNode.originMm[2].toFixed(3)}</output><span>零姿态世界原点(mm)</span><output>X {selectedTreeNode.worldOriginMm[0].toFixed(3)} Y {selectedTreeNode.worldOriginMm[1].toFixed(3)} Z {selectedTreeNode.worldOriginMm[2].toFixed(3)}</output><span>零姿态局部转角(deg)</span><output>RX {selectedTreeNode.rotationDeg[0].toFixed(3)} RY {selectedTreeNode.rotationDeg[1].toFixed(3)} RZ {selectedTreeNode.rotationDeg[2].toFixed(3)}</output>{selectedDriven && <><span>当前驱动后的世界原点(mm)</span><output>X {(selectedDriven.world[0] * 1000).toFixed(3)} Y {(selectedDriven.world[1] * 1000).toFixed(3)} Z {(selectedDriven.world[2] * 1000).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", "machine-manager-origin")}{vectorInput("部件平移", selected.translationMm, (translationMm) => update(selected.id, { translationMm }), "mm", "machine-manager-translation")}{vectorInput("部件旋转", selected.rotationDeg, (rotationDeg) => update(selected.id, { rotationDeg }), "deg", "machine-manager-rotation")}</section>}
|
||||
{parts.length > 0 && <div className="machine-model-list" data-axis-control="machine-model-list" aria-label="已导入 GLB 模型">{parts.map((part) => <div key={part.id} className={`machine-model-row ${selectedId === part.id ? "selected" : ""}`} data-machine-model-id={part.id}><button type="button" data-axis-control="machine-model-select" aria-pressed={selectedId === part.id} disabled={pending} onClick={() => selectModel(part.id)} title={part.filename}><strong>{part.filename}</strong><span>{part.role ? roleLabel(part.role, coordinates) : "未分配角色"} · {part.nodeJoints ? Object.keys(part.nodeJoints).length : 0} 个关节绑定</span></button><button type="button" data-axis-control="machine-model-delete" aria-label={`删除 ${part.filename}`} disabled={pending || followLive} onClick={() => remove(part)}>删除</button></div>)}</div>}
|
||||
<div className="machine-editor-tabs" role="tablist" aria-label="机床配置步骤">{([ ["structure", "结构与关节"], ["motion", "运动验证"], ["calibration", "机床标定"], ["files", "文件与恢复"] ] as const).map(([id, label], index) => <button id={`machine-tab-${id}`} role="tab" aria-selected={tab === id} aria-controls={`machine-panel-${id}`} tabIndex={tab === id ? 0 : -1} data-axis-control={`machine-manager-tab-${id}`} key={id} type="button" onClick={() => setTab(id)} onKeyDown={(event) => { const ids: EditorTab[] = ["structure", "motion", "calibration", "files"]; const next = event.key === "ArrowRight" ? ids[(index + 1) % 4] : event.key === "ArrowLeft" ? ids[(index + 3) % 4] : event.key === "Home" ? ids[0] : event.key === "End" ? ids[3] : null; if (next) { event.preventDefault(); setTab(next); document.getElementById(`machine-tab-${next}`)?.focus(); } }}>{label}</button>)}</div>
|
||||
<div id="machine-panel-structure" className="machine-structure-panel" role="tabpanel" aria-labelledby="machine-tab-structure" hidden={tab !== "structure"}>
|
||||
<section className="machine-part-list"><header><h3>部件与层级</h3><span>{validation.model?.nodes.length ?? 0} 节点</span></header>{selectedAnalysis && <section className="glb-five-axis-analysis" data-axis-control="glb-five-axis-analysis"><header><h3>GLB 五轴结构分析</h3><button type="button" data-axis-control="glb-five-axis-export" onClick={() => { const body = JSON.stringify(selectedAnalysis, null, 2) + "\n"; const url = URL.createObjectURL(new Blob([body], { type: "application/json" })); const anchor = document.createElement("a"); anchor.href = url; anchor.download = `${selectedAnalysis.filename?.replace(/\.glb$/i, "") || "machine"}-five-axis-analysis.json`; anchor.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); }}>导出 JSON</button></header><p className="glb-analysis-summary" data-axis-control="glb-five-axis-summary">{analysisSummary(selectedAnalysis)}</p><div className="glb-analysis-grid"><span>节点 / 网格<strong>{selectedAnalysis.nodeCount} / {selectedAnalysis.meshCount}</strong></span><span>建议运动学<strong>{selectedAnalysis.suggestedKinematics}</strong></span><span>RTCP<strong>{"待确认"}</strong></span></div><div className="glb-analysis-axes" aria-label="识别的机床轴">{CNC_AXES.map((axis) => { const item = selectedAnalysis.axes.find((candidate) => candidate.axis === axis); return <span key={axis} data-glb-analysis-axis={axis} className={item ? "detected" : "missing"}><b>{axis}</b>{item ? <small>{item.nodeName}<br />{item.worldOriginMm.map((value) => value.toFixed(1)).join(" / ")} mm</small> : <small>未识别</small>}</span>; })}</div><details><summary>结构链与 RTCP 参数</summary><p>直线轴链:{selectedAnalysis.linearChain.join(" → ") || "未识别"}</p><p>旋转轴链:{selectedAnalysis.rotaryChain.join(" → ") || "未识别"}</p><p>节点父级:{selectedAnalysis.axes.map((item) => `JT_${item.axis} ← ${item.parentName || "scene"}`).join(";") || "未识别"}</p><p>{selectedAnalysis.rtcp.reason}</p><small>建议参数:{selectedAnalysis.rtcp.requiredParameters.join("、")}</small>{selectedAnalysis.warnings.length > 0 && <ul>{selectedAnalysis.warnings.map((warning) => <li key={warning}>{warning}</li>)}</ul>}</details></section>}<div className="machine-tree-tools"><input data-axis-control="machine-tree-search" type="search" aria-label="搜索节点名称或轴" placeholder="搜索节点名称 / 数控轴" value={query} onChange={(event) => setQuery(event.target.value)} /><button type="button" data-axis-control="machine-tree-expand" onClick={() => setExpansion((current) => ({ all: true, serial: current.serial + 1 }))}>展开</button><button type="button" data-axis-control="machine-tree-collapse" onClick={() => { setQuery(""); setExpansion((current) => ({ all: false, serial: current.serial + 1 })); }}>折叠</button></div>{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"><header><h3 title={selected.filename}>{selectedTreeNode?.name ?? selected.filename}<small>{selected.filename}</small></h3><button type="button" data-axis-control="machine-manager-delete" disabled={pending || followLive} title="删除整个导入文件,可通过撤销恢复" onClick={() => remove(selected)}>删除导入模型</button></header>{selectedTreeNode && <details className="machine-origin-details"><summary>零姿态与当前坐标</summary><div className="machine-tree-origin" data-axis-control="machine-tree-origin"><strong>{selectedTreeNode.name}</strong><span>节点自身局部原点</span><output>X 0.000 Y 0.000 Z 0.000</output><span>装配位置(相对父节点,mm)</span><output>X {selectedTransform!.translationMm[0].toFixed(3)} Y {selectedTransform!.translationMm[1].toFixed(3)} Z {selectedTransform!.translationMm[2].toFixed(3)}</output><span>装配姿态(相对父节点,deg)</span><output>RX {selectedTransform!.rotationDeg[0].toFixed(3)} RY {selectedTransform!.rotationDeg[1].toFixed(3)} RZ {selectedTransform!.rotationDeg[2].toFixed(3)}</output>{selectedDriven && <><span>当前世界原点(mm)</span><output>X {(selectedDriven.world[0] * 1000).toFixed(3)} Y {(selectedDriven.world[1] * 1000).toFixed(3)} Z {(selectedDriven.world[2] * 1000).toFixed(3)}</output></>}</div></details>}<label>唯一自由度<select data-axis-control="machine-tree-axis-role" disabled={pending || followLive} value={selectedDof?.axis ?? ""} onChange={(event) => setDof(event.target.value ? defaultDof(event.target.value as CncAxis) : null)}><option value="">固定(无自由度)</option>{CNC_AXES.map((axis) => <option key={axis} value={axis} disabled={!coordinates.includes(axis)}>{axis} · {axisUnit(axis)}</option>)}</select></label>
|
||||
{selectedDof && <><label>父级运动方向<select data-axis-control="machine-joint-direction" disabled={pending || followLive} value={selectedDof.direction} onChange={(event) => setDof({ ...selectedDof, direction: event.target.value as "x" | "y" | "z" })}>{["x", "y", "z"].map((axis) => <option key={axis} value={axis}>{axis.toUpperCase()}</option>)}</select></label><label>正负方向<select data-axis-control="machine-joint-sign" disabled={pending || followLive} value={selectedDof.sign} onChange={(event) => setDof({ ...selectedDof, sign: Number(event.target.value) as 1 | -1 })}><option value="1">正向 +1</option><option value="-1">反向 −1</option></select></label></>}
|
||||
<label>节点结构角色<select data-axis-control="machine-manager-role" disabled={pending || followLive} value={selectedNodePath === "scene" ? selected.role : selected.nodeRoles?.[selectedNodePath] ?? ""} 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>{selectedNodePath === "scene" ? <label>模型装配父级<select data-axis-control="machine-manager-parent" disabled={pending || followLive} value={selected.parentId ?? ""} onChange={(event) => update(selected.id, { parentId: event.target.value || null })}><option value="">无</option>{parts.filter((part) => parentAllowed(selected.id, part.id)).map((part) => <option key={part.id} value={part.id}>{part.role ? roleLabel(part.role, coordinates) : part.filename}</option>)}</select></label> : <p className="machine-parent-note">内部节点父级由层级树拖放管理</p>}<div className="machine-part-flags"><label><input type="checkbox" disabled={pending || followLive} checked={selected.visible} onChange={(event) => update(selected.id, { visible: event.target.checked })} />显示导入模型</label><label><input data-axis-control="machine-manager-show-origin" type="checkbox" disabled={pending || followLive} checked={selected.showOrigin} onChange={(event) => update(selected.id, { showOrigin: event.target.checked })} />显示坐标原点</label></div><p className="machine-transform-reference">当前节点:{selectedTreeNode?.name}。{selectedDof ? `仅允许 ${selectedDof.axis} ${isLinear(selectedDof.axis) ? "平移" : "旋转"},沿父级 ${selectedDof.direction.toUpperCase()} 方向;以导入装配姿态为零位。` : "固定节点随父级运动;请先配置唯一自由度。"}</p>
|
||||
<label>{selectedDof ? `${selectedDof.axis} ${isLinear(selectedDof.axis) ? "位移 (mm)" : "转角 (deg)"}` : "关节值"}<MachineNumberInput fieldId={`joint-${selectedId}-${selectedNodePath}`} control="machine-joint-value" onValidity={validity} disabled={pending || !selectedDof || followLive} value={selectedDof ? effectivePose[selectedDof.axis.toLowerCase()] ?? 0 : 0} onChange={(value) => { if (selectedDof) setTestPose((pose) => ({ ...pose, [selectedDof.axis.toLowerCase()]: value })); }} /></label>
|
||||
{selectedDof && <div className="machine-joint-step"><label>步长<select aria-label="关节步长" disabled={followLive || pending} value={jogStep} onChange={(event) => setJogStep(Number(event.target.value))}>{MANAGER_SETTINGS.jogSteps.map((step) => <option key={step} value={step}>{step} {isLinear(selectedDof.axis) ? "mm" : "°"}</option>)}</select></label>{([-1, 1] as const).map((direction) => <button key={direction} type="button" data-axis-control={direction < 0 ? "machine-joint-step-minus" : "machine-joint-step-plus"} disabled={followLive || pending || invalidFields.length > 0} onClick={() => setTestPose((pose) => { const axis = selectedDof.axis.toLowerCase(); return { ...pose, [axis]: Number(((pose[axis] ?? 0) + direction * jogStep).toFixed(6)) }; })}>{direction < 0 ? "−" : "+"}{jogStep}</button>)}<button type="button" data-axis-control="machine-joint-reset" disabled={followLive || pending} onClick={() => setTestPose((pose) => ({ ...pose, [selectedDof.axis.toLowerCase()]: 0 }))}>本轴归零</button></div>}
|
||||
</section>}
|
||||
{!selected && <p className="machine-selection-empty">选择层级节点,配置其唯一自由度。</p>}
|
||||
</div>
|
||||
<section id="machine-panel-motion" role="tabpanel" aria-labelledby="machine-tab-motion" hidden={tab !== "motion"} className="machine-motion-test"><label><input data-axis-control="machine-joint-follow-live" type="checkbox" checked={followLive} disabled={pending || (!liveReady && !followLive)} onChange={(event) => setFollowLive(event.target.checked)} />跟随数控系统实时关节位置</label><output data-axis-control="machine-joint-live-state">{followLive ? liveReady ? `WASM tick ${visual.tick}` : "实时数据暂不可用,已保持最后姿态;可取消跟随" : coordinates !== controllerCoordinates ? `设计机型 ${coordinates} 与控制面板 ${controllerCoordinates} 不同;应用或导入后可实时跟随` : "手动验证(相对物理零点)"}</output><header><h3>层级运动测试</h3><button type="button" data-axis-control="machine-test-reset" disabled={pending || followLive} onClick={() => setTestPose(zeroPose(coordinates))}>复位</button></header><div>{activeAxes.map((axis) => poseInput(axis))}</div><p>这些输入仅验证模型运动,不向数控系统发送运动命令。</p><details><summary>诊断数据</summary><output data-axis-control="machine-test-state">{JSON.stringify(effectivePose)}</output></details></section>
|
||||
<section id="machine-panel-calibration" role="tabpanel" aria-labelledby="machine-tab-calibration" hidden={tab !== "calibration"} className="machine-manager-calibration"><h3>机床标定</h3>{vectorInput(`${profile?.rotaryAxis ?? "旋转"} 轴原点`, cOriginMm, setCOriginMm, "mm")}{vectorInput(`${profile?.tiltAxis ?? coordinates[3] ?? "旋转"} 到 ${profile?.rotaryAxis ?? coordinates[4] ?? "旋转"} 轴偏置`, tiltOffsetMm, setTiltOffsetMm, "mm")}<fieldset className="machine-vector machine-zero"><legend>机床物理零点(按当前坐标,直线 mm / 旋转 deg)</legend>{activeAxes.map((axis) => { const key = axis.toLowerCase(); return <label key={axis}>{axis}<MachineNumberInput fieldId={`zero-${key}`} onValidity={validity} disabled={pending || followLive} value={physicalZero[key] ?? 0} onChange={(value) => setPhysicalZero({ ...physicalZero, [key]: value })} /></label>; })}</fieldset></section>
|
||||
<section id="machine-panel-files" role="tabpanel" aria-labelledby="machine-tab-files" hidden={tab !== "files"} className="machine-document-panel">
|
||||
<h3>机床文档</h3><label>模型名称<input data-axis-control="machine-document-name" maxLength={80} value={documentInfo.name} disabled={pending || followLive} placeholder={`${coordinates} 机床`} onChange={(event) => { const name = event.target.value; edit((draft) => ({ ...draft, documentInfo: { ...documentInfo, name } })); }} /></label>
|
||||
<label>设计说明<textarea data-axis-control="machine-document-description" maxLength={2000} value={documentInfo.description} disabled={pending || followLive} placeholder="用途、标定说明、版本变更…" onChange={(event) => { const description = event.target.value; edit((draft) => ({ ...draft, documentInfo: { ...documentInfo, description } })); }} /></label>
|
||||
<section className="machine-case-catalog" data-axis-control="machine-case-catalog"><header><h3>LinuxCNC 五轴案例库</h3><span>{FIVE_AXIS_CASES.length} 个结构/运动学案例</span></header><p>案例来源于仓库内 LinuxCNC 配置、示例 G 代码和运动学源码。支持的坐标可直接进入当前 Web/WASM 工作流;其它类型保留完整参数和源码索引,待对应运动学适配器接入后启用。</p><ul>{FIVE_AXIS_CASES.map((item) => <li key={item.id} data-machine-case={item.id} className={item.coordinates === coordinates ? "active" : ""}><div><strong>{item.label}</strong><span>{item.kinematics} · {item.coordinates} · {item.joints} 关节</span><small>{item.description}</small></div><em className={`machine-case-status machine-case-status-${item.webWasm}`}>{FIVE_AXIS_STATUS_LABELS[item.webWasm]}</em><details><summary>查看本地案例与参数</summary><code>{item.localCases.join("\n")}</code><small>运动学源码:{item.kinematicsSource}<br />结构参数:{item.designParameters.join("、")}</small></details></li>)}</ul><small>官方参考:<a href="https://linuxcnc.org/docs/html/motion/5-axis-kinematics.html" target="_blank" rel="noreferrer">5-Axis Kinematics</a> · <a href="https://linuxcnc.org/docs/html/man/man9/kins.9.html" target="_blank" rel="noreferrer">kins(9)</a> · <a href="https://linuxcnc.org/docs/html/motion/switchkins.html" target="_blank" rel="noreferrer">switchkins</a></small></section>
|
||||
<div className="machine-save-location" data-axis-control="machine-document-location"><strong>文件保存在哪里?</strong><p>{saveLocation}</p><p>GLB 下载到浏览器设置的目录,通常为“下载”。请打开浏览器下载记录查看实际位置;网页无法读取磁盘完整路径。</p><p>保存时另存一份到本浏览器机床库,可在刷新页面后重新打开。本地机床库仅属于当前浏览器及网站地址,不会上传云服务器;清理网站数据会删除这些副本。</p></div>
|
||||
<h3>本浏览器机床库 <small>{library.length} 个保存版本</small></h3><div className="machine-library-tools"><input data-axis-control="machine-library-search" aria-label="搜索已保存机床" placeholder="搜索名称 / 机型" value={libraryQuery} onChange={(event) => setLibraryQuery(event.target.value)} /><button type="button" onClick={() => void refreshLibrary()}>刷新</button></div>
|
||||
{libraryError && <p role="alert">{libraryError}</p>}
|
||||
{!library.length && <p>尚无保存记录。配置完成后点击“保存机床模型 GLB”,或打开磁盘中已有的机床 GLB。</p>}
|
||||
<ul className="machine-library-list">{library.filter((entry) => `${entry.name} ${entry.coordinates}`.toLowerCase().includes(libraryQuery.toLowerCase())).map((entry) => <li key={entry.id} data-machine-saved-id={entry.id}><strong>{entry.name}</strong><span>{entry.coordinates} · {entry.partCount} 部件 · {entry.jointCount} 轴 · {(entry.size / 1024 / 1024).toFixed(2)} MiB</span><time dateTime={entry.savedAt}>{new Date(entry.savedAt).toLocaleString("zh-CN", { hour12: false })}</time><small title={entry.sha256}>{entry.filename} · SHA256 {entry.sha256.slice(0, 12)}</small><div><button type="button" data-axis-control="machine-library-open" disabled={pending || followLive} onClick={() => void openLibraryModel(entry)}>打开配置</button><button type="button" data-axis-control="machine-library-download" disabled={pending} onClick={() => void openLibraryModel(entry, true)}>下载 GLB</button><button type="button" data-axis-control="machine-library-delete" disabled={pending} onClick={() => void removeLibraryModel(entry)}>删除副本</button></div></li>)}</ul>
|
||||
</section>
|
||||
</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>
|
||||
<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>
|
||||
<div className={`machine-validation ${blockingReason ? "needs-attention" : "ready"}`} data-axis-control="machine-manager-validation" role="status">{blockingReason ? `配置检查:${blockingReason}` : `配置检查通过 · ${activeAxes.length} 轴绑定完整`}{disabled && !blockingReason && " · 关机且程序空闲后可应用"}{followLive && " · 实时跟随中,配置编辑已锁定"}{invalidFields.length > 0 && <button type="button" onClick={() => { const id = invalidFields[0]; setTab(id.startsWith("joint-") ? "structure" : id.startsWith("test-") ? "motion" : "calibration"); requestAnimationFrame(() => document.querySelector<HTMLInputElement>('.machine-manager-dialog input[aria-invalid="true"]')?.focus()); }}>定位无效输入</button>}</div>
|
||||
<footer className="machine-manager-footer"><button type="button" data-axis-control="machine-manager-apply" disabled={disabled || pending || followLive || Boolean(blockingReason)} title={disabled ? "关机且程序空闲后可应用" : undefined} onClick={() => void apply()}>应用到 Web LinuxCNC</button><button type="button" data-axis-control="machine-manager-export" disabled={pending || followLive || Boolean(blockingReason)} onClick={() => void exportGlb()}>保存机床模型 GLB</button><button type="button" data-axis-control="machine-manager-export-json" disabled={!validation.model || pending || invalidFields.length > 0} onClick={exportJson}>导出关节 JSON</button><output role="status" aria-live="polite">{message || "关闭对话框保留本次草稿;保存机床模型 GLB 后,可在控制面板“导入机床模型”中使用。"}</output>{message && <button type="button" aria-label="清除操作消息" onClick={() => setMessage("")}>×</button>}</footer>
|
||||
</div>
|
||||
<input ref={openFile} data-axis-control="machine-document-open-file" hidden type="file" accept=".glb,model/gltf-binary" onChange={(event) => { const item = event.target.files?.[0]; event.target.value = ""; if (item) void importFiles([item], true); }} />
|
||||
<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); }} />
|
||||
</DraggableDialog>
|
||||
</section>;
|
||||
|
||||
@@ -12,6 +12,8 @@ export function TrtGeometryPanel({ bundle, disabled, busy, onApply, onReload }:
|
||||
const [message, setMessage] = useState("");
|
||||
const [pending, setPending] = useState(false);
|
||||
const file = useRef<HTMLInputElement>(null);
|
||||
const filePickerOpen = useRef(false);
|
||||
const openFilePicker = () => { if (locked || !file.current) return; filePickerOpen.current = true; file.current.click(); const clear = () => { window.removeEventListener("focus", clear); setTimeout(() => { filePickerOpen.current = false; }, 0); }; window.addEventListener("focus", clear, { once: true }); };
|
||||
const geometry = useMemo(() => bundle ? readTrtGeometry(bundle) : null, [bundle]);
|
||||
useEffect(() => {
|
||||
setValues(Object.fromEntries(TRT_GEOMETRY_KEYS.map((key: string) => [key, String(geometry?.offsets[key] ?? 0)])));
|
||||
@@ -50,13 +52,13 @@ export function TrtGeometryPanel({ bundle, disabled, busy, onApply, onReload }:
|
||||
const tool = (id: string, label: string, asset: string, action: () => void, off = locked) => <button type="button" data-axis-control={`geometry-${id}`} aria-label={label} title={label} disabled={off} onClick={action}><img src={axisIcon(asset)} alt="" /></button>;
|
||||
return <section className="trt-geometry" aria-label="机床几何参数" aria-busy={pending || busy} data-dirty={dirty}>
|
||||
<div className="trt-geometry-toolbar"><button type="button" data-axis-control="geometry-settings" aria-haspopup="dialog" onClick={() => setOpen(true)}>机床参数</button></div>
|
||||
<DraggableDialog open={open} title="机床几何参数" className="geometry-dialog" onClose={() => setOpen(false)}>
|
||||
<DraggableDialog open={open} title="机床几何参数" className="geometry-dialog" cancelGuard={() => filePickerOpen.current} onClose={() => setOpen(false)}>
|
||||
<header><strong>{bundle?.entrypoint.split("/").at(-1) ?? "INI"}</strong><span>mm</span><output>{dirty ? "待应用" : "已生效"}</output></header>
|
||||
<div className="trt-geometry-fields">{["旋转中心", "轴间偏置"].map((title, index) => <fieldset key={title}><legend>{title}</legend>{TRT_GEOMETRY_KEYS.slice(index * 3, index * 3 + 3).map((key: string) => <label key={key} title={key === geometry?.unsupported ? "此机型原生运动学不使用该偏置" : key.replaceAll("_", "-")}><span>{key.replaceAll("_", "-")}</span><input aria-label={key.replaceAll("_", "-")} type="number" step="any" disabled={locked || key === geometry?.unsupported} value={values[key] ?? "0"} onChange={(e) => setValues({ ...values, [key]: e.target.value })} /></label>)}</fieldset>)}</div>
|
||||
<footer>
|
||||
<button type="button" data-axis-control="geometry-apply" disabled={locked || !dirty} onClick={() => void run(() => onApply(edited()), "参数已应用,待重新回零")}><img src={axisIcon("tool_verify.gif")} alt="" />应用参数</button>
|
||||
{tool("reload", "重新读取 INI", "tool_reload.gif", () => void run(onReload, "已重新读取 INI"))}
|
||||
{tool("import", "导入 INI 参数", "tool_open.gif", () => file.current?.click())}
|
||||
{tool("import", "导入 INI 参数", "tool_open.gif", openFilePicker)}
|
||||
<button type="button" data-axis-control="geometry-save" disabled={locked} onClick={() => void run(exportIni, "INI 已保存或导出")}>保存 INI</button>
|
||||
<output role="status">{message || (disabled ? "关机且程序空闲后可修改" : "")}</output>
|
||||
</footer>
|
||||
|
||||
@@ -4,7 +4,7 @@ const VIEWER = WEB_SETTINGS.viewer;
|
||||
const CAMERA = VIEWER.camera;
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
|
||||
import { installMachineStudio } from "./machine-studio-lighting";
|
||||
import { buildImportedMachine, type ImportedMachine } from "./imported-machine";
|
||||
import { axisIcon } from "./sourceAssets";
|
||||
import { buildXyzacMachine, buildXyzbcMachine } from "./xyzac-machine-model";
|
||||
@@ -26,27 +26,19 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
|
||||
useEffect(() => {
|
||||
const element = host.current;
|
||||
if (!element) return;
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
|
||||
const managedJoints = Boolean(importedMachine?.scene.userData.linuxcncJointModel);
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: !managedJoints, alpha: false, preserveDrawingBuffer: !managedJoints });
|
||||
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, VIEWER.pixel_ratio_limit));
|
||||
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
||||
renderer.toneMappingExposure = 1;
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.enabled = !managedJoints;
|
||||
renderer.shadowMap.type = THREE.PCFShadowMap;
|
||||
renderer.domElement.dataset.engine = "three.js r185 / xyzac-trt-vismach";
|
||||
element.appendChild(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene(); scene.background = new THREE.Color(VIEWER.colors.background);
|
||||
const studio = new RoomEnvironment();
|
||||
const pmrem = new THREE.PMREMGenerator(renderer);
|
||||
const environment = pmrem.fromScene(studio, .04);
|
||||
scene.environment = environment.texture; scene.environmentIntensity = .65;
|
||||
studio.dispose(); pmrem.dispose();
|
||||
scene.add(new THREE.HemisphereLight(0xf4f8ff, 0x4b5450, 1.2));
|
||||
const key = new THREE.DirectionalLight(0xfff4df, 2.7); key.position.set(-4, 9, -6);
|
||||
const studio = installMachineStudio(renderer, scene, "y");
|
||||
const key = studio.key;
|
||||
key.castShadow = true; key.shadow.mapSize.set(2048, 2048); key.shadow.bias = -.0002; key.shadow.normalBias = .012;
|
||||
Object.assign(key.shadow.camera, { left: -6, right: 6, top: 7, bottom: -6, near: .1, far: 30 }); scene.add(key);
|
||||
const rim = new THREE.DirectionalLight(0xd5f0ff, 1.7); rim.position.set(5, 5, 5); scene.add(rim);
|
||||
const fill = new THREE.DirectionalLight(0xffffff, .7); fill.position.set(4, 2, -7); scene.add(fill);
|
||||
Object.assign(key.shadow.camera, { left: -6, right: 6, top: 7, bottom: -6, near: .1, far: 30 });
|
||||
const floor = new THREE.Mesh(new THREE.PlaneGeometry(18, 18), new THREE.ShadowMaterial({ opacity: .24 }));
|
||||
floor.rotation.x = -Math.PI / 2; floor.position.y = -2.64; floor.receiveShadow = true; scene.add(floor);
|
||||
|
||||
@@ -151,6 +143,11 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
|
||||
const render = () => {
|
||||
const state = visualRef.current, config = settings.current;
|
||||
assembly.update(state);
|
||||
if (assembly.machine.userData.linuxcncJointState) {
|
||||
element.dataset.machineJointState = JSON.stringify(assembly.machine.userData.linuxcncJointState);
|
||||
element.dataset.machineJointTick = String(state.tick);
|
||||
element.dataset.machineJointInputs = JSON.stringify(state.joints);
|
||||
}
|
||||
const paths = (config.toolpath ?? state.toolpath) as Segment[];
|
||||
pathGroup.position.copy(assembly.workOrigin.position);
|
||||
liveGroup.position.copy(assembly.workOrigin.position);
|
||||
@@ -189,7 +186,7 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
|
||||
clearLines(pathGroup); clearLines(liveGroup); pathMaterials.forEach((material) => material.dispose());
|
||||
for (const helper of [limits, offsets, extents]) helper.dispose();
|
||||
assembly.dispose(); nameplateTexture.dispose(); nameplate.geometry.dispose(); nameplate.material.dispose();
|
||||
floor.geometry.dispose(); floor.material.dispose(); key.shadow.map?.dispose(); environment.dispose();
|
||||
floor.geometry.dispose(); floor.material.dispose(); studio.dispose();
|
||||
renderer.dispose(); renderer.domElement.remove();
|
||||
};
|
||||
}, [importedMachine]);
|
||||
@@ -198,7 +195,7 @@ export function XyzacMachineView({ importedMachine, visual, toolpath, view = "p"
|
||||
const coordinates = model?.coordinate_system ?? "XYZAC";
|
||||
const managerGeometry = importedMachine?.scene.userData.linuxcncMachineGeometry;
|
||||
const controllerButton = (action: string, label: string, asset?: string, glyph?: string) => <button type="button" data-axis-control={`machine-${action}`} data-xyzac-machine-controller={action} title={label} aria-label={label} onClick={() => host.current?.xyzacMachineController?.[action]?.()}>{asset ? <img src={axisIcon(asset)} alt="" /> : glyph}</button>;
|
||||
return <div className="three-viewer xyzac-machine-view" ref={host} data-xyzac-preview="machine" data-machine-glb={importedMachine?.hash ?? ""} data-machine-manager-schema={managerGeometry?.schema ?? ""} data-machine-manager-geometry={managerGeometry ? JSON.stringify(managerGeometry) : ""} data-xyzac-model-source={importedMachine ? "glb-urdf/linuxcnc-wasm" : "linuxcnc-wasm"} data-xyzac-mouse-controls="left-rotate-middle-zoom-right-pan-wheel-zoom" data-xyzac-model-id={model?.id ?? ""} data-xyzac-model-source-path={importedMachine?.filename ?? model?.source_path ?? ""} data-xyzac-model-source-hash={importedMachine?.hash ?? model?.source_hash ?? ""} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} data-xyzac-joints={JSON.stringify(visual.joints)} data-xyzac-offsets={JSON.stringify(visual.offsets)} aria-label={`${coordinates} ${importedMachine ? "URDF" : "TRT Vismach"} machine model`}>
|
||||
return <div className="three-viewer xyzac-machine-view" ref={host} data-xyzac-preview="machine" data-machine-glb={importedMachine?.hash ?? ""} data-machine-manager-schema={managerGeometry?.schema ?? ""} data-machine-manager-geometry={managerGeometry ? JSON.stringify(managerGeometry) : ""} data-xyzac-model-source={importedMachine ? importedMachine.rawAxisBindings ? "glb-axis-analysis/linuxcnc-wasm" : "glb-urdf/linuxcnc-wasm" : "linuxcnc-wasm"} data-xyzac-axis-bindings={importedMachine?.rawAxisBindings ? JSON.stringify(importedMachine.rawAxisBindings.map(({ axis, path }) => ({ axis, path }))) : ""} data-xyzac-mouse-controls="left-rotate-middle-zoom-right-pan-wheel-zoom" data-xyzac-model-id={model?.id ?? ""} data-xyzac-model-source-path={importedMachine?.filename ?? model?.source_path ?? ""} data-xyzac-model-source-hash={importedMachine?.hash ?? model?.source_hash ?? ""} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} data-xyzac-joints={JSON.stringify(visual.joints)} data-xyzac-offsets={JSON.stringify(visual.offsets)} aria-label={`${coordinates} ${importedMachine ? "GLB" : "TRT Vismach"} machine model`}>
|
||||
<div className="xyzac-view-controller xyzac-machine-controller" role="toolbar" aria-label="Machine 3D view controller">
|
||||
{controllerButton("zoomIn", "Zoom in", "tool_zoomin.gif")}{controllerButton("zoomOut", "Zoom out", "tool_zoomout.gif")}
|
||||
{controllerButton("panLeft", "Pan left", undefined, "\u2190")}{controllerButton("panRight", "Pan right", undefined, "\u2192")}
|
||||
|
||||
134
web/react/src/glb-five-axis-analyzer.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import * as THREE from "three";
|
||||
import { CNC_AXES, defaultDof, isFiveAxisCoordinates, isLinear, validJointDof, type CncAxis, type JointDof } from "./machine-joints";
|
||||
|
||||
/** A single axis candidate found in a GLB node hierarchy. */
|
||||
export type GlbAxisEvidence = {
|
||||
axis: CncAxis;
|
||||
nodeName: string;
|
||||
path: string;
|
||||
parentName: string;
|
||||
parentPath: string;
|
||||
depth: number;
|
||||
originMm: [number, number, number];
|
||||
worldOriginMm: [number, number, number];
|
||||
expectedKind: "linear" | "rotary";
|
||||
confidence: "high" | "medium" | "low";
|
||||
reason: string;
|
||||
joint: JointDof;
|
||||
autoBind: boolean;
|
||||
};
|
||||
|
||||
export type GlbFiveAxisAnalysis = {
|
||||
schema: "linuxcnc.glb-five-axis-analysis/1";
|
||||
filename?: string;
|
||||
nodeCount: number;
|
||||
meshCount: number;
|
||||
boundsMm: { min: [number, number, number]; max: [number, number, number]; size: [number, number, number] };
|
||||
axes: GlbAxisEvidence[];
|
||||
missingAxes: string[];
|
||||
linearChain: string[];
|
||||
rotaryChain: string[];
|
||||
chains: string[][];
|
||||
coordinateCandidates: string[];
|
||||
suggestedCoordinates: string | "UNKNOWN";
|
||||
suggestedKinematics: "generic-five-axis" | "unknown";
|
||||
rtcp: { mode: "unverified" | "insufficient"; reason: string; requiredParameters: string[] };
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
const AXES = CNC_AXES;
|
||||
const axisSet = new Set<string>(CNC_AXES);
|
||||
const round = (value: number) => Number(value.toFixed(6));
|
||||
const mm = (v: THREE.Vector3): [number, number, number] => [round(v.x * 1000), round(v.y * 1000), round(v.z * 1000)];
|
||||
|
||||
function axisFromName(name: string): { axis: CncAxis; exact: boolean } | null {
|
||||
const normalized = name.trim().toUpperCase();
|
||||
const exact = normalized.match(/^JT[_-]?([XYZABCUVW])$/);
|
||||
if (exact && axisSet.has(exact[1])) return { axis: exact[1] as CncAxis, exact: true };
|
||||
const token = normalized.match(/(?:^|[_\-\s])(?:JT|JOINT|AXIS|LINEAR|ROTARY)[_-]?([XYZABCUVW])(?:$|[_\-\s])/)
|
||||
?? normalized.match(/(?:^|[_\-\s])([XYZABCUVW])[_-]?(?:AXIS|JOINT|LINEAR|ROTARY)(?:$|[_\-\s])/);
|
||||
return token && axisSet.has(token[1]) ? { axis: token[1] as CncAxis, exact: false } : null;
|
||||
}
|
||||
|
||||
function objectPath(node: THREE.Object3D): string {
|
||||
const parts: string[] = [];
|
||||
let current: THREE.Object3D | null = node;
|
||||
while (current?.parent) { const index = current.parent.children.indexOf(current); parts.unshift(String(index)); current = current.parent; }
|
||||
return `scene${parts.length ? `/${parts.join("/")}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect an already parsed Three.js GLB scene. The heuristic intentionally
|
||||
* prioritizes explicit joint metadata and uses names only as hints. It never mutates
|
||||
* geometry and can therefore be reused for previews, import validation and
|
||||
* offline model review.
|
||||
*/
|
||||
export function analyzeGlbFiveAxisScene(scene: THREE.Object3D, filename?: string): GlbFiveAxisAnalysis {
|
||||
scene.updateMatrixWorld(true);
|
||||
const axes: GlbAxisEvidence[] = [];
|
||||
const warnings: string[] = [];
|
||||
const duplicateAxes = new Set<string>();
|
||||
const bounds = new THREE.Box3();
|
||||
let nodeCount = 0, meshCount = 0;
|
||||
scene.traverse((node) => {
|
||||
nodeCount += 1;
|
||||
const mesh = node as THREE.Mesh;
|
||||
if (mesh.isMesh) { meshCount += 1; bounds.expandByObject(mesh); }
|
||||
const named = axisFromName(node.name || "");
|
||||
const data = node.userData;
|
||||
const explicit = Object.hasOwn(data, "linuxcncJoint") || Object.hasOwn(data, "linuxcnc.axis");
|
||||
if (Object.hasOwn(data, "linuxcncJoint") && data.linuxcncJoint === null) return;
|
||||
const metadata = data.linuxcncJoint ?? (Object.hasOwn(data, "linuxcnc.axis") ? { axis: data["linuxcnc.axis"], direction: data["linuxcnc.axis_direction"], sign: data["linuxcnc.axis_sign"], frame: data["linuxcnc.axis_frame"] } : undefined);
|
||||
const axis = explicit ? metadata?.axis : named?.axis;
|
||||
if (!axisSet.has(axis)) { if (explicit) warnings.push(`${node.name}:轴元数据无效,请手动绑定`); return; }
|
||||
const match = { axis: axis as CncAxis, exact: explicit };
|
||||
const direction = metadata?.direction ?? defaultDof(match.axis).direction;
|
||||
const joint: JointDof = { axis: match.axis, direction: Array.isArray(direction) ? "custom" : direction, sign: metadata?.sign ?? 1,
|
||||
...(metadata?.frame !== undefined ? { frame: metadata.frame } : {}),
|
||||
...(Array.isArray(direction) ? { vector: direction } : metadata?.vector ? { vector: metadata.vector } : {}) };
|
||||
if (!validJointDof(joint) || (metadata?.type && metadata.type !== (isLinear(match.axis) ? "prismatic" : "revolute"))) { warnings.push(`${node.name}:关节方向、参考系或类型无效,请手动配置`); return; }
|
||||
if (axes.some((item) => item.axis === match.axis)) duplicateAxes.add(match.axis);
|
||||
const parent = node.parent ?? scene;
|
||||
const evidence: GlbAxisEvidence = {
|
||||
axis: match.axis, nodeName: node.name || `(unnamed ${match.axis})`, path: objectPath(node), parentName: parent.name || "scene", parentPath: objectPath(parent), depth: objectPath(node).split("/").length - 1,
|
||||
originMm: mm(node.position), worldOriginMm: mm(node.getWorldPosition(new THREE.Vector3())), expectedKind: isLinear(match.axis) ? "linear" : "rotary",
|
||||
confidence: match.exact ? "high" : "medium", reason: explicit ? "GLB 关节元数据" : "节点名称提示;默认轴向需运动验证", joint, autoBind: validJointDof(joint),
|
||||
};
|
||||
axes.push(evidence);
|
||||
});
|
||||
const axisList = axes.sort((a, b) => AXES.indexOf(a.axis) - AXES.indexOf(b.axis));
|
||||
for (const item of axisList) if (duplicateAxes.has(item.axis)) item.autoBind = false;
|
||||
const available = new Set(axisList.filter((item) => item.autoBind).map((item) => item.axis));
|
||||
const declared = scene.userData.linuxcncJointModel?.coordinates ?? scene.userData.linuxcncMachine?.coordinates;
|
||||
const declaredValid = typeof declared === "string" && isFiveAxisCoordinates(declared) && [...declared].every((axis) => available.has(axis as CncAxis));
|
||||
const detected = AXES.filter((axis) => available.has(axis)).join("");
|
||||
const suggestedCoordinates = declaredValid ? declared : isFiveAxisCoordinates(detected) ? detected : "UNKNOWN";
|
||||
const coordinateCandidates = suggestedCoordinates === "UNKNOWN" ? [] : [suggestedCoordinates];
|
||||
const missingAxes = typeof declared === "string" && isFiveAxisCoordinates(declared) ? [...declared].filter((axis) => !available.has(axis as CncAxis)) : [];
|
||||
const sorted = [...axisList].sort((a, b) => a.depth - b.depth);
|
||||
const nearestAxis = (item: GlbAxisEvidence) => sorted.filter((other) => item.path.startsWith(`${other.path}/`)).at(-1);
|
||||
const chains = sorted.filter((item) => !sorted.some((other) => nearestAxis(other) === item)).map((leaf) => {
|
||||
const chain = [leaf.nodeName]; let node = nearestAxis(leaf);
|
||||
while (node) { chain.unshift(node.nodeName); node = nearestAxis(node); }
|
||||
return chain;
|
||||
});
|
||||
const linearChain = sorted.filter((item) => isLinear(item.axis)).map((item) => item.nodeName);
|
||||
const rotaryChain = sorted.filter((item) => !isLinear(item.axis)).map((item) => item.nodeName);
|
||||
const suggestedKinematics = coordinateCandidates.length ? "generic-five-axis" : "unknown";
|
||||
if (declared !== undefined && !declaredValid) warnings.push("声明的轴顺序与可用关节不一致,未采用该顺序");
|
||||
if (available.size !== 5) warnings.push(`检测到 ${available.size} 个唯一轴;请配置五个轴及其绑定,不会丢弃多余候选`);
|
||||
if (axisList.some((item) => item.confidence !== "high")) warnings.push("名称仅用于提示轴绑定;请确认轴向、旋转中心和父子关系");
|
||||
if (duplicateAxes.size) warnings.push(`重复轴 ${[...duplicateAxes].join("、")}:保留所有候选,未自动绑定;请手动选择唯一节点`);
|
||||
if (bounds.isEmpty()) warnings.push("模型没有可计算包围盒的网格");
|
||||
const rtcp = { mode: coordinateCandidates.length ? "unverified" as const : "insufficient" as const,
|
||||
reason: "轴名和层级不能证明 RTCP 运动学;请确认刀具/工件链、轴心及匹配的运行适配器",
|
||||
requiredParameters: ["各关节轴向和参考系", "旋转中心/零姿态", "刀具与工件节点", "运动学适配器及标定参数"] };
|
||||
const boxMin = bounds.isEmpty() ? new THREE.Vector3() : bounds.min;
|
||||
const boxMax = bounds.isEmpty() ? new THREE.Vector3() : bounds.max;
|
||||
return { schema: "linuxcnc.glb-five-axis-analysis/1", filename, nodeCount, meshCount, boundsMm: { min: mm(boxMin), max: mm(boxMax), size: mm(boxMax.clone().sub(boxMin)) }, axes: axisList, missingAxes, linearChain, rotaryChain, chains, coordinateCandidates, suggestedCoordinates, suggestedKinematics, rtcp, warnings };
|
||||
}
|
||||
|
||||
export function analysisSummary(analysis: GlbFiveAxisAnalysis): string {
|
||||
const axes = analysis.axes.map((item) => item.nodeName).join("、") || "未识别关节,可手动绑定";
|
||||
return `${axes} · ${analysis.suggestedCoordinates === "UNKNOWN" ? "待配置五轴" : analysis.suggestedCoordinates} · RTCP 待确认`;
|
||||
}
|
||||
@@ -1,24 +1,61 @@
|
||||
import * as THREE from "three";
|
||||
import { bindJointModel, defaultDof, driveJoint, jointZero, validateJointModel, type JointModel, type AxisValues, type CncAxis, type JointZero } from "./machine-joints";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { inspectMachineGlb, parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-urdf";
|
||||
import { WEB_SETTINGS } from "../../workspace/web-settings.mjs";
|
||||
import { readTrtConfiguration } from "../../workspace/trt-configuration.mjs";
|
||||
import { readTrtGeometry, writeTrtGeometry } from "../../workspace/trt-geometry.mjs";
|
||||
import type { XyzacVisualState } from "./xyzac-visual-adapter";
|
||||
export type ImportedMachine = { scene: THREE.Group; structure: MachineStructure; hash: string; filename: string; physicalZero?: Partial<Record<"x" | "y" | "z" | "a" | "b" | "c", number>> };
|
||||
import { analyzeGlbFiveAxisScene, type GlbFiveAxisAnalysis } from "./glb-five-axis-analyzer";
|
||||
export type RawAxisBinding = { axis: CncAxis; path: string; zero: JointZero };
|
||||
export type ImportedMachine = { scene: THREE.Group; structure: MachineStructure; hash: string; filename: string; physicalZero?: Partial<Record<Lowercase<CncAxis>, number>>; glbAnalysis?: GlbFiveAxisAnalysis; rawAxisBindings?: RawAxisBinding[] };
|
||||
export function disposeMachineScene(scene: THREE.Object3D) {
|
||||
const geometries = new Set<THREE.BufferGeometry>(), materials = new Set<THREE.Material>(), textures = new Set<THREE.Texture>();
|
||||
scene.traverse((node) => { const mesh = node as THREE.Mesh; if (mesh.geometry) geometries.add(mesh.geometry); if (mesh.material) for (const m of Array.isArray(mesh.material) ? mesh.material : [mesh.material]) { materials.add(m); for (const value of Object.values(m)) if (value instanceof THREE.Texture) textures.add(value); } });
|
||||
geometries.forEach((g) => g.dispose()); materials.forEach((m) => m.dispose()); textures.forEach((t) => t.dispose());
|
||||
}
|
||||
export async function loadImportedMachine(bytes: ArrayBuffer, filename: string): Promise<ImportedMachine> {
|
||||
const { structure } = inspectMachineGlb(bytes);
|
||||
let inspected: ReturnType<typeof inspectMachineGlb> | null = null;
|
||||
try { inspected = inspectMachineGlb(bytes); } catch { /* Raw five-axis GLBs are analyzed below. */ }
|
||||
const manager = new THREE.LoadingManager();
|
||||
manager.setURLModifier((url) => { if (!url.startsWith("blob:")) throw new Error("机床文件不能加载外部资源"); return url; });
|
||||
const gltf = await new GLTFLoader(manager).parseAsync(bytes, "");
|
||||
const hash = [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((n) => n.toString(16).padStart(2, "0")).join("");
|
||||
gltf.scene.traverse((node) => { if ((node as THREE.Mesh).isMesh) { node.castShadow = true; node.receiveShadow = true; } });
|
||||
return { scene: gltf.scene, structure, hash, filename };
|
||||
const model = gltf.scene.userData.linuxcncJointModel as JointModel | undefined;
|
||||
if (!inspected) {
|
||||
const analysis = analyzeGlbFiveAxisScene(gltf.scene, filename);
|
||||
if (analysis.suggestedCoordinates === "UNKNOWN" || analysis.suggestedCoordinates.length !== 5) throw new Error("GLB 未形成可用的五轴结构;请提供三个直线轴和两个旋转轴节点(支持 JT_X、AXIS_X、X_AXIS 等命名)");
|
||||
const rawAxisBindings: RawAxisBinding[] = analysis.axes.filter((item) => analysis.suggestedCoordinates.includes(item.axis)).map((item) => {
|
||||
const node = findScenePathNode(gltf.scene, item.path);
|
||||
if (!node) throw new Error(`GLB 轴节点路径不存在:${item.path}`);
|
||||
return { axis: item.axis as CncAxis, path: item.path, zero: jointZero(node) };
|
||||
});
|
||||
const structure = rawStructure(analysis, gltf.scene.name || filename);
|
||||
const physicalZero = Object.fromEntries([...analysis.suggestedCoordinates].map((axis) => [axis.toLowerCase(), 0])) as ImportedMachine["physicalZero"];
|
||||
return { scene: gltf.scene, structure, hash, filename, physicalZero, glbAnalysis: analysis, rawAxisBindings };
|
||||
}
|
||||
const structure = inspected.structure;
|
||||
if (model) {
|
||||
validateJointModel(model); if (model.coordinates !== structure.coordinates) throw new Error("关节 JSON 与 URDF 机型不一致");
|
||||
bindJointModel(gltf.scene, model);
|
||||
}
|
||||
const physicalZero = model?.calibration.physicalZero ?? gltf.scene.userData.linuxcncMachineGeometry?.physicalZero;
|
||||
if (physicalZero && Object.values(physicalZero).some((value) => typeof value !== "number" || !Number.isFinite(value))) throw new Error("物理零点无效");
|
||||
return { scene: gltf.scene, structure, hash, filename, physicalZero };
|
||||
}
|
||||
|
||||
function findScenePathNode(scene: THREE.Object3D, path: string) {
|
||||
const parts = path.split("/").slice(1).map(Number);
|
||||
let node: THREE.Object3D = scene;
|
||||
for (const index of parts) { if (!Number.isInteger(index) || !node.children[index]) return null; node = node.children[index]; }
|
||||
return node;
|
||||
}
|
||||
|
||||
function rawStructure(analysis: GlbFiveAxisAnalysis, name: string): MachineStructure {
|
||||
const coordinates = analysis.suggestedCoordinates;
|
||||
const joints = [...coordinates].map((axis) => ({ name: `raw_${axis.toLowerCase()}_joint`, type: "XYZ".includes(axis) ? "prismatic" : "revolute", parent: "raw_machine", child: `raw_${axis.toLowerCase()}`, origin: [0, 0, 0] as Vec3, axis: defaultDof(axis as CncAxis).direction === "x" ? [1, 0, 0] as Vec3 : defaultDof(axis as CncAxis).direction === "y" ? [0, 1, 0] as Vec3 : [0, 0, 1] as Vec3, coordinate: axis, limit: "XYZ".includes(axis) ? { lower: -1, upper: 1, velocity: 1, effort: 1 } : { lower: -Math.PI * 200, upper: Math.PI * 200, velocity: 100, effort: 1 } }));
|
||||
return { name, profile: "generic-five-axis/1", coordinates: coordinates as any, base: "raw_machine", tool: "raw_tool", work: "raw_work", links: ["raw_machine", "raw_tool", "raw_work"], joints, offsets: { x_rot_point: 0, y_rot_point: 0, z_rot_point: 0, x_offset: 0, y_offset: 0, z_offset: 0 }, urdf: `raw-glb://${coordinates}` };
|
||||
}
|
||||
function validateImportedConfiguration(bundle: { entrypoint: string; files: Record<string, string> }, structure: MachineStructure) {
|
||||
const config = readTrtConfiguration(bundle);
|
||||
@@ -45,6 +82,7 @@ export function configureImportedMachine<T extends { entrypoint: string; files:
|
||||
export function updateImportedMachineGeometry(asset: ImportedMachine, bundle: { entrypoint: string; files: Record<string, string> }): ImportedMachine {
|
||||
validateImportedConfiguration(bundle, asset.structure);
|
||||
const { offsets } = readTrtGeometry(bundle);
|
||||
if (asset.rawAxisBindings) return { ...asset, structure: { ...asset.structure, offsets } };
|
||||
if (Object.entries(asset.structure.offsets).every(([key, value]) => Math.abs(value - offsets[key]) < 1e-9)) return asset;
|
||||
const p = ["x", "y", "z"].map((axis) => offsets[`${axis}_rot_point`] / 1000) as Vec3;
|
||||
const d = ["x", "y", "z"].map((axis) => offsets[`${axis}_offset`] / 1000) as Vec3;
|
||||
@@ -68,11 +106,40 @@ export function updateImportedMachineGeometry(asset: ImportedMachine, bundle: {
|
||||
export function buildImportedMachine(asset: ImportedMachine) {
|
||||
const scene = asset.scene.clone(true);
|
||||
scene.userData.linuxcncMachine = { ...scene.userData.linuxcncMachine, urdf: asset.structure.urdf };
|
||||
const jointModel = scene.userData.linuxcncJointModel as JointModel | undefined;
|
||||
const jointDriver = jointModel ? bindJointModel(scene, jointModel) : null;
|
||||
const physicalZero = asset.physicalZero ?? jointModel?.calibration.physicalZero ?? scene.userData.linuxcncMachineGeometry?.physicalZero ?? {};
|
||||
const managed = scene.userData.linuxcncMachineGeometry;
|
||||
if (asset.rawAxisBindings) {
|
||||
const machine = new THREE.Group(); machine.name = asset.structure.name;
|
||||
const scale = 1000 * WEB_SETTINGS.viewer.mm_scale;
|
||||
machine.scale.set(scale, scale, -scale); machine.add(scene);
|
||||
const bindings = asset.rawAxisBindings.map((binding) => ({ ...binding, node: findScenePathNode(scene, binding.path)! }));
|
||||
const byAxis = new Map(bindings.map((binding) => [binding.axis, binding]));
|
||||
const toolTip = new THREE.Group(); toolTip.name = "raw-glb-tool-tip"; scene.add(toolTip);
|
||||
const workOrigin = new THREE.Group(); workOrigin.name = "raw-glb-work-origin"; scene.add(workOrigin);
|
||||
const update = (state: Pick<XyzacVisualState, "joints" | "offsets">) => {
|
||||
for (const [index, axis] of [...asset.structure.coordinates].entries()) {
|
||||
const binding = byAxis.get(axis as CncAxis); if (!binding) continue;
|
||||
driveJoint(binding.node, binding.zero, defaultDof(axis as CncAxis), state.joints[index], Number(physicalZero?.[axis.toLowerCase() as keyof NonNullable<ImportedMachine["physicalZero"]>] ?? 0));
|
||||
}
|
||||
machine.updateMatrixWorld(true);
|
||||
machine.userData.linuxcncJointState = Object.fromEntries(bindings.map((binding) => [binding.axis, { path: binding.path, world: binding.node.getWorldPosition(new THREE.Vector3()).toArray() }]));
|
||||
};
|
||||
return { machine, toolTip, workOrigin, joints: { rotate: machine }, update, dispose: () => undefined };
|
||||
}
|
||||
if (managed?.schema === "linuxcnc.machine-manager/1") {
|
||||
const clean = (value: number) => Object.is(value, -0) ? 0 : value;
|
||||
const effective = { ...managed, cOriginMm: [asset.structure.offsets.x_rot_point, asset.structure.offsets.y_rot_point, asset.structure.offsets.z_rot_point].map(clean), tiltOffsetMm: [asset.structure.offsets.x_offset, asset.structure.offsets.y_offset, asset.structure.offsets.z_offset].map(clean), physicalZero: { ...(asset.physicalZero ?? managed.physicalZero) } };
|
||||
scene.userData.linuxcncMachineGeometry = effective;
|
||||
if (jointModel) {
|
||||
const effectiveJoints: JointModel = { ...jointModel, calibration: { cOriginMm: effective.cOriginMm as Vec3, tiltOffsetMm: effective.tiltOffsetMm as Vec3, physicalZero: { ...physicalZero } } };
|
||||
scene.userData.linuxcncJointModel = effectiveJoints;
|
||||
scene.traverse((node) => {
|
||||
if (node.userData.linuxcncJointModel) node.userData.linuxcncJointModel = effectiveJoints;
|
||||
if (node.userData.linuxcncMachineGeometry) node.userData.linuxcncMachineGeometry = effective;
|
||||
});
|
||||
}
|
||||
const base = scene.getObjectByName(asset.structure.base); if (base) base.userData.linuxcncMachineGeometry = effective;
|
||||
}
|
||||
// Include fixed work-frame joints as well as moving pivots. The source GLB
|
||||
@@ -97,12 +164,13 @@ export function buildImportedMachine(asset: ImportedMachine) {
|
||||
cutter.rotation.x = Math.PI / 2; node(asset.structure.tool).add(cutter);
|
||||
const update = (state: Pick<XyzacVisualState, "joints" | "offsets">) => {
|
||||
for (const { joint, node: link, axis } of moving) {
|
||||
const coordinate = joint.coordinate.toLowerCase() as "x" | "y" | "z" | "a" | "b" | "c";
|
||||
const value = state.joints[asset.structure.coordinates.indexOf(joint.coordinate)] - Number(asset.physicalZero?.[coordinate] ?? 0);
|
||||
const coordinate = joint.coordinate.toLowerCase() as Lowercase<CncAxis>;
|
||||
const value = state.joints[asset.structure.coordinates.indexOf(joint.coordinate)] - Number(physicalZero[coordinate] ?? 0);
|
||||
link.position.fromArray(joint.origin); link.quaternion.identity();
|
||||
if (joint.type === "prismatic") link.position.addScaledVector(axis, value / 1000);
|
||||
else link.quaternion.setFromAxisAngle(axis, value * Math.PI / 180);
|
||||
}
|
||||
if (jointDriver) jointDriver.update(Object.fromEntries([...asset.structure.coordinates].map((axis, i) => [axis, state.joints[i]])) as AxisValues, physicalZero);
|
||||
const length = state.offsets.tool_offset / 1000;
|
||||
toolTip.position.set(0, 0, -length);
|
||||
cutter.visible = Math.abs(length) > 1e-9; cutter.scale.y = Math.abs(length); cutter.position.z = -length / 2;
|
||||
@@ -110,6 +178,7 @@ export function buildImportedMachine(asset: ImportedMachine) {
|
||||
// the master-tool difference along local C-Z, while physical pivots stay fixed.
|
||||
workOrigin.position.copy(originalWork); workOrigin.position.z -= length;
|
||||
machine.updateMatrixWorld(true);
|
||||
if (jointDriver) machine.userData.linuxcncJointState = jointDriver.state();
|
||||
};
|
||||
return { machine, toolTip, workOrigin, joints: { rotate }, update, dispose: () => { cutter.geometry.dispose(); cutter.material.dispose(); } };
|
||||
}
|
||||
|
||||
4
web/react/src/legacy.d.ts
vendored
@@ -1,4 +1,5 @@
|
||||
declare module "*.mjs" {
|
||||
export function assertProgramMachine(source: string, coordinates: string, path?: string): void;
|
||||
export const WEB_SETTINGS: typeof import("../../workspace/web-settings.json");
|
||||
export function axisImagePath(name: string, base?: string): string;
|
||||
export function readTrtConfiguration(configuration: { entrypoint: string; files: Record<string, string> }): { kinematics: { coordinates: string; axis_limits: Record<string, number[]>; axis_velocity_limits: Record<string, number> }; home_limits: Record<string, any> };
|
||||
@@ -9,6 +10,9 @@ declare module "*.mjs" {
|
||||
export const MACHINE_DEFINITIONS: readonly MachineDefinition[];
|
||||
export const DEFAULT_MACHINE: MachineDefinition;
|
||||
export function machineDefinition(value?: string): MachineDefinition | null;
|
||||
export type FiveAxisCase = { id: string; label: string; family: string; machineClass: string; coordinates: string; joints: number; kinematics: string; rtcp: string; webWasm: "supported" | "adapter-required" | "identity-only"; description: string; localCases: readonly string[]; kinematicsSource: string; designParameters: readonly string[] };
|
||||
export const FIVE_AXIS_CASES: readonly FiveAxisCase[];
|
||||
export const FIVE_AXIS_STATUS_LABELS: Record<FiveAxisCase["webWasm"], string>;
|
||||
export const SIM_CASE_CATALOG: Array<{ path: string; label?: string; category?: string; status?: string }>;
|
||||
export function loadSimulationCase(options: { path: string; baseUrl?: string }): Promise<{ status: string; entrypoint: string; files: Record<string, string>; missing: string[]; fetched: string[]; state_hash: string; halManifest: unknown[] }>;
|
||||
export function toWorkerConfiguration(bundle: unknown): Record<string, unknown>;
|
||||
|
||||
57
web/react/src/machine-document.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import * as THREE from "three";
|
||||
import type { MachinePartDraft } from "./machine-manager";
|
||||
import type { Vec3 } from "./machine-urdf";
|
||||
|
||||
export type DocumentInfo = { name: string; description: string };
|
||||
type Settings = Pick<MachinePartDraft, "id" | "filename" | "hash" | "role" | "parentId" | "visible" | "showOrigin" | "originMm" | "translationMm" | "rotationDeg" | "nodeParents" | "nodeRoles" | "nodeTransforms" | "nodeJoints">;
|
||||
type SourceNode = { path: string; name: string; position: Vec3; quaternion: [number, number, number, number]; scale: Vec3; visible: boolean };
|
||||
export type EditorDocument = { schema: "linuxcnc.machine-editor/1"; info: DocumentInfo; parts: Array<{ settings: Settings; sourceNodes: SourceNode[] }> };
|
||||
export function partSettings(part: MachinePartDraft): Settings {
|
||||
const { id, filename, hash, role, parentId, visible, showOrigin, originMm, translationMm, rotationDeg, nodeParents, nodeRoles, nodeTransforms, nodeJoints } = part;
|
||||
return JSON.parse(JSON.stringify({ id, filename, hash, role, parentId, visible, showOrigin, originMm, translationMm, rotationDeg, nodeParents, nodeRoles, nodeTransforms, nodeJoints }));
|
||||
}
|
||||
export function encodeEditorDocument(parts: MachinePartDraft[], info?: DocumentInfo): EditorDocument {
|
||||
return { schema: "linuxcnc.machine-editor/1", info: info ?? { name: "", description: "" }, parts: parts.map((part) => {
|
||||
const sourceNodes: SourceNode[] = [];
|
||||
const visit = (node: THREE.Object3D, path: string) => { sourceNodes.push({ path, name: node.name, position: node.position.toArray(), quaternion: node.quaternion.toArray(), scale: node.scale.toArray(), visible: node.visible }); node.children.forEach((child, i) => visit(child, `${path}/${i}`)); };
|
||||
visit(part.scene, "scene"); return { settings: partSettings(part), sourceNodes };
|
||||
}) };
|
||||
}
|
||||
// Recover the ORIGINAL source hierarchy and zero transforms, then reapply draft
|
||||
// overrides in the usual pipeline. This prevents double offsets and preserves
|
||||
// separate files, stable node paths, reparenting, visibility and axis settings.
|
||||
export function decodeEditorDocument(scene: THREE.Object3D, document: EditorDocument): MachinePartDraft[] {
|
||||
const fail = (): never => { throw new Error("机床编辑数据损坏,无法完整恢复部件配置"); };
|
||||
if (document?.schema !== "linuxcnc.machine-editor/1" || !document.info || typeof document.info.name !== "string" || typeof document.info.description !== "string" || !Array.isArray(document.parts) || !document.parts.length || document.parts.length > 500) fail();
|
||||
const objects = new Map<string, THREE.Object3D>();
|
||||
scene.traverse((node) => { if (node.userData.linuxcncNodeId) objects.set(node.userData.linuxcncNodeId, node); });
|
||||
const ids = new Set<string>(); let count = 0;
|
||||
const finite = (v: unknown, n: number): v is number[] => Array.isArray(v) && v.length === n && v.every(Number.isFinite);
|
||||
const parts = document.parts.map(({ settings, sourceNodes }) => {
|
||||
if (!settings || typeof settings.id !== "string" || ids.has(settings.id) || !Array.isArray(sourceNodes) || sourceNodes[0]?.path !== "scene") fail();
|
||||
ids.add(settings.id);
|
||||
if (typeof settings.filename !== "string" || typeof settings.hash !== "string" || typeof settings.visible !== "boolean" || typeof settings.showOrigin !== "boolean" || !finite(settings.originMm, 3) || !finite(settings.translationMm, 3) || !finite(settings.rotationDeg, 3)) fail();
|
||||
const restored = new Map<string, THREE.Object3D>();
|
||||
for (const entry of sourceNodes) {
|
||||
if (++count > 12000 || typeof entry.path !== "string" || !/^scene(?:\/\d+)*$/.test(entry.path) || restored.has(entry.path) || !finite(entry.position, 3) || !finite(entry.quaternion, 4) || !finite(entry.scale, 3) || typeof entry.name !== "string" || typeof entry.visible !== "boolean") fail();
|
||||
const object = objects.get(`${settings.id}:${entry.path === "scene" ? "source-scene" : entry.path}`);
|
||||
if (!object) fail();
|
||||
const node = object!.clone(false); node.name = entry.name; node.position.fromArray(entry.position); node.quaternion.fromArray(entry.quaternion); node.scale.fromArray(entry.scale); node.visible = entry.visible; node.updateMatrix();
|
||||
for (const key of ["linuxcncNodeId", "linuxcncJoint", "linuxcncJointModel", "linuxcncMachineGeometry", "linuxcncMachineEditor"]) delete node.userData[key];
|
||||
if (entry.path !== "scene") { const parent = restored.get(entry.path.slice(0, entry.path.lastIndexOf("/"))); if (!parent) fail(); parent!.add(node); }
|
||||
restored.set(entry.path, node);
|
||||
}
|
||||
return { ...JSON.parse(JSON.stringify(settings)), scene: restored.get("scene") as THREE.Group } as MachinePartDraft;
|
||||
});
|
||||
for (const part of parts) {
|
||||
let cursor: string | null = part.parentId; const visited = new Set([part.id]);
|
||||
while (cursor !== null) { if (!ids.has(cursor) || visited.has(cursor)) fail(); visited.add(cursor); cursor = parts.find((p) => p.id === cursor)!.parentId; }
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function machineDocumentFilename(name: string, coordinates: string) {
|
||||
const clean = name.trim().replace(/[<>:"/\\|?*\x00-\x1f]/g, "-").replace(/\.glb$/i, "").slice(0, 80);
|
||||
const stem = clean || "managed", suffix = `-${coordinates.toLowerCase()}`;
|
||||
return `${stem.toLowerCase().endsWith(suffix) ? stem : stem + suffix}.glb`;
|
||||
}
|
||||