Compare commits

..

19 Commits

Author SHA1 Message Date
af65b69b2c Make 3D machine manager axis controls configuration driven 2026-09-30 20:30:07 +08:00
469881794c Expose LinuxCNC case selector in machine manager 2026-09-22 08:42:00 +08:00
ae525d6b93 Cover remaining five-axis case variants 2026-09-22 08:02:55 +08:00
23d07e72d1 Add LinuxCNC five-axis case catalog 2026-09-22 07:36:23 +08:00
b15af28034 Keep machine dialogs open when file picker is cancelled 2026-09-22 05:24:55 +08:00
d4d0088daf Complete machine document library and recovery workflow 2026-09-22 04:42:20 +08:00
3cb5950d1f Record deployed document library and complete model recovery verification 2026-09-21 21:19:10 +08:00
8443930edb Save named machine documents and restore editor configuration from GLB or local library 2026-09-21 21:07:01 +08:00
1eb9840941 Record deployed AC BC program compatibility regression 2026-09-21 20:03:26 +08:00
0de96bbc72 Keep G-code matched to imported AC or BC machine type 2026-09-21 19:52:31 +08:00
86cfa2b7f0 Record machine type selection and saved model cloud verification 2026-09-21 17:49:43 +08:00
338500dfc2 Select machine type in design dialog and save importable machine GLB 2026-09-21 17:41:46 +08:00
8f1a22485d Record workbench release and final AC BC verification 2026-09-21 13:34:44 +08:00
a7233bc8e4 Improve machine manager engineering workbench 2026-09-21 12:54:56 +08:00
a2c6dd1c01 Record single-DOF release and cloud validation 2026-09-21 11:32:29 +08:00
d8488641ca Configure single-DOF machine joints and drive them from WASM axes 2026-09-21 11:24:22 +08:00
989866b3ab Record deployed parent-relative joint editing verification 2026-09-21 09:39:10 +08:00
91ebd56d6d Verify isolated joint edits and parent frame inheritance 2026-09-21 09:19:45 +08:00
a472911ad1 Apply hierarchy node transforms relative to parents 2026-09-21 09:11:38 +08:00
114 changed files with 37322 additions and 330 deletions

View File

@@ -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)。

View File

@@ -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",

View File

@@ -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",

View 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": []
}

View 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": []
}

View File

@@ -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"}

View 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": []
}

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

View File

@@ -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"]}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

File diff suppressed because it is too large Load Diff

View 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": []
}

File diff suppressed because it is too large Load Diff

View 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"}

View File

@@ -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"
}

View 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"]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

File diff suppressed because it is too large Load Diff

View 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": []
}

View 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": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

View File

@@ -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"}

View File

@@ -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": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

View File

@@ -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"]}

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

File diff suppressed because it is too large Load Diff

View 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。

View 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": []
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -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
]
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 338 KiB

After

Width:  |  Height:  |  Size: 296 KiB

View File

@@ -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": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

View File

@@ -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"
}
]
}

View File

@@ -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": []
}

View File

@@ -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
}
]
}

View File

@@ -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": []
}

View File

@@ -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": []
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}
]
}

View 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": []
}

View 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": []
}

View File

@@ -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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 187 KiB

View File

@@ -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"]}

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

View File

@@ -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": []
}

View File

@@ -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": []
}

View File

@@ -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 同时提供浏览器下载和本地机床库副本;刷新后从库恢复,多部件文件分组、节点路径、层级覆盖、显隐、关节和标定完整回填。

View File

@@ -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 五轴回归。

View File

@@ -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。

View File

@@ -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|三维管理、机床导入、几何参数三类文件选择器取消/回焦不关闭父对话框;没有文件时保持配置和焦点|

View File

@@ -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`。

View File

@@ -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 并存;精确恢复原始多文件装配后再施加覆盖,不重复叠加。旧文件缺少编辑元数据时按兼容装配恢复并保留轴/标定。临时关节试动不保存,未完成绑定的会话草稿目前不做跨刷新自动恢复。

View 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 唯一、父级有效且无环、每个节点最多一个自由度、轴绑定不重复、机型完整、轴索引一致、零姿态与标定为有限数值、四元数归一化。缺少实时轴值时整帧拒绝更新,避免只更新部分轴。

View 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 文件不原地修改。
## 当前边界
本轮提供完整机床的保存/打开/本地版本恢复。尚未绑定完整五轴的会话草稿、撤销历史、相机视角和临时试动值不会跨刷新自动保存;关闭对话框但不刷新页面仍保留草稿。云端共享、权限审批、碰撞检查及其他机型运动学不属于本轮实现。

View File

@@ -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)

View 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 子模块本地修改不包含在本次管理器配置提交中,仍留在工作区。

View 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"
]
}
}
}
]
}
}
}
}
}
}
}
]
}

View File

@@ -449,7 +449,7 @@ history rows intentionally share the family ID `mdi-history-select`.
| `workspace-case-select` | Web selection of the INI/configuration input | Local selection; selected catalog is restricted to supported XYZAC/XYZBC cases | Does not load or change machine state until `toolbar-load-case` |
| `workspace-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.

View 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)。

View File

@@ -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",

View 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));

View 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 }));

View 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"] }));

View File

@@ -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 } });

View 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']}));

View File

@@ -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");

View 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));

View 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));

View 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));

View 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;

File diff suppressed because one or more lines are too long

View File

@@ -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);

View File

@@ -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>}

View 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>;
}

View File

@@ -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>;

View File

@@ -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>

View File

@@ -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")}

View 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 待确认`;
}

View File

@@ -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(); } };
}

View File

@@ -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>;

View 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`;
}

Some files were not shown because too many files have changed in this diff Show More