diff --git a/README.md b/README.md
index 968652c..9c13c87 100644
--- a/README.md
+++ b/README.md
@@ -5,6 +5,11 @@
机床预览区的部件级三维机床管理器(GLB 部件、层级、原点、姿态、RTCP 标定和物理零点)入口:
[`三维机床管理器文档、任务矩阵与验收证据`](docs/glb-machine-manager/README.md)。
+线上站点 `https://49.232.250.35:58010/` 的操作说明、案例支持状态与发布功能巡检结论见
+[`LinuxCNC Web/WASM 使用说明书`](docs/user-manual/LinuxCNC-Web-使用说明书.html)。
+详细功能规格见 [`三维机床管理详细功能文档`](docs/glb-machine-manager/00-三维机床管理详细功能文档.md)。
+可离线阅读的图文版见 [`三维机床管理详细功能文档(HTML)`](docs/glb-machine-manager/00-三维机床管理详细功能文档.html)。
+退出重启接续见 [`三维机床管理 HTML 文档接续入口`](nextTask-glb-machine-manager-doc.md)。
当前配置驱动改造的重启接续入口:[`nextTask-hardcoding.md`](nextTask-hardcoding.md)。
diff --git a/coverage/glb-machine-manager/machine-type/browser.report.json b/coverage/glb-machine-manager/machine-type/browser.report.json
index 8f22145..d64963e 100644
--- a/coverage/glb-machine-manager/machine-type/browser.report.json
+++ b/coverage/glb-machine-manager/machine-type/browser.report.json
@@ -1,6 +1,6 @@
{
"report_id": "GLB-MGR-E15",
- "url": "http://127.0.0.1:5180/",
+ "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",
diff --git a/coverage/glb-machine-manager/machine-type/controller-import.png b/coverage/glb-machine-manager/machine-type/controller-import.png
index 9f72cf9..72cd226 100644
Binary files a/coverage/glb-machine-manager/machine-type/controller-import.png and b/coverage/glb-machine-manager/machine-type/controller-import.png differ
diff --git a/coverage/glb-machine-manager/machine-type/dialog-type.png b/coverage/glb-machine-manager/machine-type/dialog-type.png
index 2e2410d..4c1a6fd 100644
Binary files a/coverage/glb-machine-manager/machine-type/dialog-type.png and b/coverage/glb-machine-manager/machine-type/dialog-type.png differ
diff --git a/coverage/glb-machine-manager/machine-type/failure.png b/coverage/glb-machine-manager/machine-type/failure.png
new file mode 100644
index 0000000..0f06494
Binary files /dev/null and b/coverage/glb-machine-manager/machine-type/failure.png differ
diff --git a/coverage/glb-machine-manager/machine-type/test-xyzbc.glb b/coverage/glb-machine-manager/machine-type/test-xyzbc.glb
index c9ec27d..19bfa76 100644
Binary files a/coverage/glb-machine-manager/machine-type/test-xyzbc.glb and b/coverage/glb-machine-manager/machine-type/test-xyzbc.glb differ
diff --git a/coverage/glb-machine-manager/managed-bc-running.png b/coverage/glb-machine-manager/managed-bc-running.png
index bd76d48..39deee0 100644
Binary files a/coverage/glb-machine-manager/managed-bc-running.png and b/coverage/glb-machine-manager/managed-bc-running.png differ
diff --git a/coverage/glb-machine-manager/manager-ac-config.png b/coverage/glb-machine-manager/manager-ac-config.png
index 84e78ee..cd6a1dd 100644
Binary files a/coverage/glb-machine-manager/manager-ac-config.png and b/coverage/glb-machine-manager/manager-ac-config.png differ
diff --git a/coverage/glb-machine-manager/material-rendering/after/browser.report.json b/coverage/glb-machine-manager/material-rendering/after/browser.report.json
new file mode 100644
index 0000000..ed5d34f
--- /dev/null
+++ b/coverage/glb-machine-manager/material-rendering/after/browser.report.json
@@ -0,0 +1,16 @@
+{
+ "status": "verified",
+ "baseline": false,
+ "checks": [
+ "manager export retains source PBR colors, roughness, metalness and normal maps",
+ "real m1 and saturated RGB / white / metal / dark material probes render without browser exceptions",
+ "control panel uses the same AgX neutral studio for imported GLBs"
+ ],
+ "errors": [],
+ "manager": {
+ "lighting": "neutral-studio-pmrem",
+ "toneMapping": "AgX",
+ "exposure": "1",
+ "meshes": 41
+ }
+}
\ No newline at end of file
diff --git a/coverage/glb-machine-manager/material-rendering/after/controller-m1.png b/coverage/glb-machine-manager/material-rendering/after/controller-m1.png
new file mode 100644
index 0000000..fd77291
Binary files /dev/null and b/coverage/glb-machine-manager/material-rendering/after/controller-m1.png differ
diff --git a/coverage/glb-machine-manager/material-rendering/after/m1.png b/coverage/glb-machine-manager/material-rendering/after/m1.png
new file mode 100644
index 0000000..96b0c1d
Binary files /dev/null and b/coverage/glb-machine-manager/material-rendering/after/m1.png differ
diff --git a/coverage/glb-machine-manager/material-rendering/after/rgb-materials.png b/coverage/glb-machine-manager/material-rendering/after/rgb-materials.png
new file mode 100644
index 0000000..98bea56
Binary files /dev/null and b/coverage/glb-machine-manager/material-rendering/after/rgb-materials.png differ
diff --git a/coverage/glb-machine-manager/material-rendering/before/browser.report.json b/coverage/glb-machine-manager/material-rendering/before/browser.report.json
new file mode 100644
index 0000000..cadbdde
--- /dev/null
+++ b/coverage/glb-machine-manager/material-rendering/before/browser.report.json
@@ -0,0 +1,13 @@
+{
+ "status": "verified",
+ "baseline": true,
+ "checks": [
+ "real m1 and saturated RGB / white / metal / dark material probes render without browser exceptions"
+ ],
+ "errors": [],
+ "manager": {
+ "lighting": "studio-pmrem",
+ "exposure": "1.45",
+ "meshes": 41
+ }
+}
\ No newline at end of file
diff --git a/coverage/glb-machine-manager/material-rendering/before/m1.png b/coverage/glb-machine-manager/material-rendering/before/m1.png
new file mode 100644
index 0000000..37383f0
Binary files /dev/null and b/coverage/glb-machine-manager/material-rendering/before/m1.png differ
diff --git a/coverage/glb-machine-manager/material-rendering/before/rgb-materials.png b/coverage/glb-machine-manager/material-rendering/before/rgb-materials.png
new file mode 100644
index 0000000..b4f6166
Binary files /dev/null and b/coverage/glb-machine-manager/material-rendering/before/rgb-materials.png differ
diff --git a/coverage/glb-machine-manager/material-rendering/m1-comparison.png b/coverage/glb-machine-manager/material-rendering/m1-comparison.png
new file mode 100644
index 0000000..efba391
Binary files /dev/null and b/coverage/glb-machine-manager/material-rendering/m1-comparison.png differ
diff --git a/coverage/glb-machine-manager/material-rendering/rgb-materials-comparison.png b/coverage/glb-machine-manager/material-rendering/rgb-materials-comparison.png
new file mode 100644
index 0000000..cfd6068
Binary files /dev/null and b/coverage/glb-machine-manager/material-rendering/rgb-materials-comparison.png differ
diff --git a/coverage/glb-machine-manager/model-selection/browser.report.json b/coverage/glb-machine-manager/model-selection/browser.report.json
new file mode 100644
index 0000000..6e6b92d
--- /dev/null
+++ b/coverage/glb-machine-manager/model-selection/browser.report.json
@@ -0,0 +1,13 @@
+{
+ "status": "verified",
+ "checks": [
+ "same filenames retain unique selection; model selection clears search and highlights preview",
+ "viewport click picks correct import; orbit drag returning to start does not select",
+ "delete unselected/selected import by ID; undo restores and selects it; redo repairs selection",
+ "deleting parent import preserves separately imported child; undo restores parent link",
+ "last deletion clears preview; undo restores selection from empty state",
+ "real m1 deletion/undo preserves all five bindings; live follow locks deletion",
+ "selection and deletion reachable on short and narrow viewports"
+ ],
+ "errors": []
+}
diff --git a/coverage/glb-machine-manager/model-selection/desktop.png b/coverage/glb-machine-manager/model-selection/desktop.png
new file mode 100644
index 0000000..3b18494
Binary files /dev/null and b/coverage/glb-machine-manager/model-selection/desktop.png differ
diff --git a/coverage/glb-machine-manager/physical-cases/bridge-mill-5axiskins.png b/coverage/glb-machine-manager/physical-cases/bridge-mill-5axiskins.png
new file mode 100644
index 0000000..4e7e864
Binary files /dev/null and b/coverage/glb-machine-manager/physical-cases/bridge-mill-5axiskins.png differ
diff --git a/coverage/glb-machine-manager/physical-cases/browser.report.json b/coverage/glb-machine-manager/physical-cases/browser.report.json
new file mode 100644
index 0000000..3bbcd71
--- /dev/null
+++ b/coverage/glb-machine-manager/physical-cases/browser.report.json
@@ -0,0 +1,85 @@
+{
+ "schema": "physical-case-glb-browser.v1",
+ "status": "verified",
+ "cases": [
+ {
+ "id": "xyzac-trt",
+ "tree_nodes": 21,
+ "scene_objects": "24",
+ "rendered": "true"
+ },
+ {
+ "id": "xyzbc-trt",
+ "tree_nodes": 21,
+ "scene_objects": "24",
+ "rendered": "true"
+ },
+ {
+ "id": "xyzab-tdr",
+ "tree_nodes": 21,
+ "scene_objects": "24",
+ "rendered": "true"
+ },
+ {
+ "id": "bridge-mill-5axiskins",
+ "tree_nodes": 21,
+ "scene_objects": "24",
+ "rendered": "true"
+ },
+ {
+ "id": "max5-maxkins",
+ "tree_nodes": 21,
+ "scene_objects": "24",
+ "rendered": "true"
+ },
+ {
+ "id": "hexapod-genhexkins",
+ "tree_nodes": 21,
+ "scene_objects": "24",
+ "rendered": "true"
+ },
+ {
+ "id": "pentapod-pentakins",
+ "tree_nodes": 19,
+ "scene_objects": "22",
+ "rendered": "true"
+ },
+ {
+ "id": "puma-pumakins",
+ "tree_nodes": 19,
+ "scene_objects": "22",
+ "rendered": "true"
+ },
+ {
+ "id": "puma-genserkins",
+ "tree_nodes": 19,
+ "scene_objects": "22",
+ "rendered": "true"
+ },
+ {
+ "id": "trivkins-xyzab",
+ "tree_nodes": 21,
+ "scene_objects": "24",
+ "rendered": "true"
+ },
+ {
+ "id": "trivkins-xyzcw",
+ "tree_nodes": 22,
+ "scene_objects": "25",
+ "rendered": "true"
+ },
+ {
+ "id": "trivkins-xyzya",
+ "tree_nodes": 22,
+ "scene_objects": "25",
+ "rendered": "true"
+ },
+ {
+ "id": "scorbot-kins",
+ "tree_nodes": 17,
+ "scene_objects": "20",
+ "rendered": "true"
+ }
+ ],
+ "browser_errors": []
+}
diff --git a/coverage/glb-machine-manager/physical-cases/hexapod-genhexkins.png b/coverage/glb-machine-manager/physical-cases/hexapod-genhexkins.png
new file mode 100644
index 0000000..f1e6e39
Binary files /dev/null and b/coverage/glb-machine-manager/physical-cases/hexapod-genhexkins.png differ
diff --git a/coverage/glb-machine-manager/physical-cases/max5-maxkins.png b/coverage/glb-machine-manager/physical-cases/max5-maxkins.png
new file mode 100644
index 0000000..b0a357d
Binary files /dev/null and b/coverage/glb-machine-manager/physical-cases/max5-maxkins.png differ
diff --git a/coverage/glb-machine-manager/physical-cases/pentapod-pentakins.png b/coverage/glb-machine-manager/physical-cases/pentapod-pentakins.png
new file mode 100644
index 0000000..9017d25
Binary files /dev/null and b/coverage/glb-machine-manager/physical-cases/pentapod-pentakins.png differ
diff --git a/coverage/glb-machine-manager/physical-cases/puma-genserkins.png b/coverage/glb-machine-manager/physical-cases/puma-genserkins.png
new file mode 100644
index 0000000..9fc66a8
Binary files /dev/null and b/coverage/glb-machine-manager/physical-cases/puma-genserkins.png differ
diff --git a/coverage/glb-machine-manager/physical-cases/puma-pumakins.png b/coverage/glb-machine-manager/physical-cases/puma-pumakins.png
new file mode 100644
index 0000000..1c76e12
Binary files /dev/null and b/coverage/glb-machine-manager/physical-cases/puma-pumakins.png differ
diff --git a/coverage/glb-machine-manager/physical-cases/scorbot-kins.png b/coverage/glb-machine-manager/physical-cases/scorbot-kins.png
new file mode 100644
index 0000000..3e6623d
Binary files /dev/null and b/coverage/glb-machine-manager/physical-cases/scorbot-kins.png differ
diff --git a/coverage/glb-machine-manager/physical-cases/trivkins-xyzab.png b/coverage/glb-machine-manager/physical-cases/trivkins-xyzab.png
new file mode 100644
index 0000000..464c1d9
Binary files /dev/null and b/coverage/glb-machine-manager/physical-cases/trivkins-xyzab.png differ
diff --git a/coverage/glb-machine-manager/physical-cases/trivkins-xyzcw.png b/coverage/glb-machine-manager/physical-cases/trivkins-xyzcw.png
new file mode 100644
index 0000000..668380a
Binary files /dev/null and b/coverage/glb-machine-manager/physical-cases/trivkins-xyzcw.png differ
diff --git a/coverage/glb-machine-manager/physical-cases/trivkins-xyzya.png b/coverage/glb-machine-manager/physical-cases/trivkins-xyzya.png
new file mode 100644
index 0000000..7ed4ebc
Binary files /dev/null and b/coverage/glb-machine-manager/physical-cases/trivkins-xyzya.png differ
diff --git a/coverage/glb-machine-manager/physical-cases/xyzab-tdr.png b/coverage/glb-machine-manager/physical-cases/xyzab-tdr.png
new file mode 100644
index 0000000..6092c4c
Binary files /dev/null and b/coverage/glb-machine-manager/physical-cases/xyzab-tdr.png differ
diff --git a/coverage/glb-machine-manager/physical-cases/xyzac-trt.png b/coverage/glb-machine-manager/physical-cases/xyzac-trt.png
new file mode 100644
index 0000000..90695a1
Binary files /dev/null and b/coverage/glb-machine-manager/physical-cases/xyzac-trt.png differ
diff --git a/coverage/glb-machine-manager/physical-cases/xyzbc-trt.png b/coverage/glb-machine-manager/physical-cases/xyzbc-trt.png
new file mode 100644
index 0000000..37daae2
Binary files /dev/null and b/coverage/glb-machine-manager/physical-cases/xyzbc-trt.png differ
diff --git a/coverage/glb-machine-manager/physical-drive/bridge-mill-5axiskins.png b/coverage/glb-machine-manager/physical-drive/bridge-mill-5axiskins.png
new file mode 100644
index 0000000..ef00777
Binary files /dev/null and b/coverage/glb-machine-manager/physical-drive/bridge-mill-5axiskins.png differ
diff --git a/coverage/glb-machine-manager/physical-drive/browser.report.json b/coverage/glb-machine-manager/physical-drive/browser.report.json
new file mode 100644
index 0000000..de33d48
--- /dev/null
+++ b/coverage/glb-machine-manager/physical-drive/browser.report.json
@@ -0,0 +1,266 @@
+{
+ "schema": "five-axis-live-models.v1",
+ "url": "http://127.0.0.1:5174/",
+ "cases": [
+ {
+ "id": "xyzac-trt",
+ "coordinates": "XYZAC",
+ "glbLoaded": true,
+ "slotKeys": [
+ "X",
+ "Y",
+ "Z",
+ "A",
+ "C"
+ ],
+ "expectedKeys": [
+ "X",
+ "Y",
+ "Z",
+ "A",
+ "C"
+ ]
+ },
+ {
+ "id": "xyzbc-trt",
+ "coordinates": "XYZBC",
+ "glbLoaded": true,
+ "slotKeys": [
+ "X",
+ "Y",
+ "Z",
+ "B",
+ "C"
+ ],
+ "expectedKeys": [
+ "X",
+ "Y",
+ "Z",
+ "B",
+ "C"
+ ]
+ },
+ {
+ "id": "xyzab-tdr",
+ "coordinates": "XYZAB",
+ "glbLoaded": true,
+ "slotKeys": [
+ "X",
+ "Y",
+ "Z",
+ "A",
+ "B"
+ ],
+ "expectedKeys": [
+ "X",
+ "Y",
+ "Z",
+ "A",
+ "B"
+ ]
+ },
+ {
+ "id": "bridge-mill-5axiskins",
+ "coordinates": "XYZBCWY",
+ "glbLoaded": true,
+ "slotKeys": [
+ "X",
+ "Y",
+ "Z",
+ "B",
+ "C",
+ "W",
+ "Y#2"
+ ],
+ "expectedKeys": [
+ "X",
+ "Y",
+ "Z",
+ "B",
+ "C",
+ "W",
+ "Y#2"
+ ]
+ },
+ {
+ "id": "max5-maxkins",
+ "coordinates": "XYZBC",
+ "glbLoaded": true,
+ "slotKeys": [
+ "X",
+ "Y",
+ "Z",
+ "B",
+ "C",
+ "J5"
+ ],
+ "expectedKeys": [
+ "X",
+ "Y",
+ "Z",
+ "B",
+ "C",
+ "J5"
+ ]
+ },
+ {
+ "id": "hexapod-genhexkins",
+ "coordinates": "XYZABC",
+ "glbLoaded": true,
+ "slotKeys": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4",
+ "J5"
+ ],
+ "expectedKeys": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4",
+ "J5"
+ ]
+ },
+ {
+ "id": "pentapod-pentakins",
+ "coordinates": "XYZAB",
+ "glbLoaded": true,
+ "slotKeys": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4"
+ ],
+ "expectedKeys": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4"
+ ]
+ },
+ {
+ "id": "puma-pumakins",
+ "coordinates": "XYZABC",
+ "glbLoaded": true,
+ "slotKeys": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4",
+ "J5"
+ ],
+ "expectedKeys": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4",
+ "J5"
+ ]
+ },
+ {
+ "id": "puma-genserkins",
+ "coordinates": "XYZABC",
+ "glbLoaded": true,
+ "slotKeys": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4",
+ "J5"
+ ],
+ "expectedKeys": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4",
+ "J5"
+ ]
+ },
+ {
+ "id": "trivkins-xyzab",
+ "coordinates": "XYZAB",
+ "glbLoaded": true,
+ "slotKeys": [
+ "X",
+ "Y",
+ "Z",
+ "A",
+ "B"
+ ],
+ "expectedKeys": [
+ "X",
+ "Y",
+ "Z",
+ "A",
+ "B"
+ ]
+ },
+ {
+ "id": "trivkins-xyzcw",
+ "coordinates": "XYZCW",
+ "glbLoaded": true,
+ "slotKeys": [
+ "X",
+ "Y",
+ "Z",
+ "C",
+ "W"
+ ],
+ "expectedKeys": [
+ "X",
+ "Y",
+ "Z",
+ "C",
+ "W"
+ ]
+ },
+ {
+ "id": "trivkins-xyzya",
+ "coordinates": "XYZYA",
+ "glbLoaded": true,
+ "slotKeys": [
+ "X",
+ "Y",
+ "Z",
+ "Y#2",
+ "A"
+ ],
+ "expectedKeys": [
+ "X",
+ "Y",
+ "Z",
+ "Y#2",
+ "A"
+ ]
+ },
+ {
+ "id": "scorbot-kins",
+ "coordinates": "XYZAB",
+ "glbLoaded": true,
+ "slotKeys": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4"
+ ],
+ "expectedKeys": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4"
+ ]
+ }
+ ],
+ "browser_errors": []
+}
diff --git a/coverage/glb-machine-manager/physical-drive/hexapod-genhexkins.png b/coverage/glb-machine-manager/physical-drive/hexapod-genhexkins.png
new file mode 100644
index 0000000..b63ab20
Binary files /dev/null and b/coverage/glb-machine-manager/physical-drive/hexapod-genhexkins.png differ
diff --git a/coverage/glb-machine-manager/physical-drive/max5-maxkins.png b/coverage/glb-machine-manager/physical-drive/max5-maxkins.png
new file mode 100644
index 0000000..70d7e1e
Binary files /dev/null and b/coverage/glb-machine-manager/physical-drive/max5-maxkins.png differ
diff --git a/coverage/glb-machine-manager/physical-drive/pentapod-pentakins.png b/coverage/glb-machine-manager/physical-drive/pentapod-pentakins.png
new file mode 100644
index 0000000..691a8a4
Binary files /dev/null and b/coverage/glb-machine-manager/physical-drive/pentapod-pentakins.png differ
diff --git a/coverage/glb-machine-manager/physical-drive/puma-genserkins.png b/coverage/glb-machine-manager/physical-drive/puma-genserkins.png
new file mode 100644
index 0000000..0fe0ded
Binary files /dev/null and b/coverage/glb-machine-manager/physical-drive/puma-genserkins.png differ
diff --git a/coverage/glb-machine-manager/physical-drive/puma-pumakins.png b/coverage/glb-machine-manager/physical-drive/puma-pumakins.png
new file mode 100644
index 0000000..f3d6117
Binary files /dev/null and b/coverage/glb-machine-manager/physical-drive/puma-pumakins.png differ
diff --git a/coverage/glb-machine-manager/physical-drive/scorbot-kins.png b/coverage/glb-machine-manager/physical-drive/scorbot-kins.png
new file mode 100644
index 0000000..4c91346
Binary files /dev/null and b/coverage/glb-machine-manager/physical-drive/scorbot-kins.png differ
diff --git a/coverage/glb-machine-manager/physical-drive/trivkins-xyzab.png b/coverage/glb-machine-manager/physical-drive/trivkins-xyzab.png
new file mode 100644
index 0000000..5413aa1
Binary files /dev/null and b/coverage/glb-machine-manager/physical-drive/trivkins-xyzab.png differ
diff --git a/coverage/glb-machine-manager/physical-drive/trivkins-xyzcw.png b/coverage/glb-machine-manager/physical-drive/trivkins-xyzcw.png
new file mode 100644
index 0000000..e2475ef
Binary files /dev/null and b/coverage/glb-machine-manager/physical-drive/trivkins-xyzcw.png differ
diff --git a/coverage/glb-machine-manager/physical-drive/trivkins-xyzya.png b/coverage/glb-machine-manager/physical-drive/trivkins-xyzya.png
new file mode 100644
index 0000000..f0b6f7a
Binary files /dev/null and b/coverage/glb-machine-manager/physical-drive/trivkins-xyzya.png differ
diff --git a/coverage/glb-machine-manager/physical-drive/xyzab-tdr.png b/coverage/glb-machine-manager/physical-drive/xyzab-tdr.png
new file mode 100644
index 0000000..c2ff23f
Binary files /dev/null and b/coverage/glb-machine-manager/physical-drive/xyzab-tdr.png differ
diff --git a/coverage/glb-machine-manager/physical-drive/xyzac-trt.png b/coverage/glb-machine-manager/physical-drive/xyzac-trt.png
new file mode 100644
index 0000000..b65e149
Binary files /dev/null and b/coverage/glb-machine-manager/physical-drive/xyzac-trt.png differ
diff --git a/coverage/glb-machine-manager/physical-drive/xyzbc-trt.png b/coverage/glb-machine-manager/physical-drive/xyzbc-trt.png
new file mode 100644
index 0000000..8a41744
Binary files /dev/null and b/coverage/glb-machine-manager/physical-drive/xyzbc-trt.png differ
diff --git a/coverage/glb-machine-manager/program-catalog/browser.report.json b/coverage/glb-machine-manager/program-catalog/browser.report.json
new file mode 100644
index 0000000..22eba2c
--- /dev/null
+++ b/coverage/glb-machine-manager/program-catalog/browser.report.json
@@ -0,0 +1,20 @@
+{
+ "schema": "rtcp-program-library-browser.v1",
+ "status": "verified",
+ "checks": [
+ "AC machine defaults to 15 XYZAC/common programs",
+ "all-source scope retains 32 upstream programs plus 13 case-specific dry-run programs",
+ "support remap is source-readable and execution-disabled",
+ "mismatched XYZAB program is visible and execution-disabled",
+ "XYZAC switchkins test loaded through WASM with canonical source hash and preview",
+ "generated XYZAC native dry-run loaded and completed in the Web control panel",
+ "native Hexapod program switches to its generic WASM machine profile and loads"
+ ],
+ "browser_errors": [],
+ "final": {
+ "coordinates": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini",
+ "program_path": "machine-cases/hexapod-genhexkins.ngc",
+ "program_status": "loaded",
+ "source_hash": "fnv1a64:33cc9e04e06ae391"
+ }
+}
diff --git a/coverage/glb-machine-manager/workbench/desktop.png b/coverage/glb-machine-manager/workbench/desktop.png
index a75b867..9601e79 100644
Binary files a/coverage/glb-machine-manager/workbench/desktop.png and b/coverage/glb-machine-manager/workbench/desktop.png differ
diff --git a/coverage/glb-machine-manager/workbench/viewport-1154.png b/coverage/glb-machine-manager/workbench/viewport-1154.png
index 58bdf37..90b90a8 100644
Binary files a/coverage/glb-machine-manager/workbench/viewport-1154.png and b/coverage/glb-machine-manager/workbench/viewport-1154.png differ
diff --git a/coverage/glb-machine-manager/workbench/viewport-720.png b/coverage/glb-machine-manager/workbench/viewport-720.png
index 6541c13..4cf525e 100644
Binary files a/coverage/glb-machine-manager/workbench/viewport-720.png and b/coverage/glb-machine-manager/workbench/viewport-720.png differ
diff --git a/coverage/trt-parametric/worker.report.json b/coverage/trt-parametric/worker.report.json
index 6e53886..b490116 100644
--- a/coverage/trt-parametric/worker.report.json
+++ b/coverage/trt-parametric/worker.report.json
@@ -10,341 +10,362 @@
{
"tick": 11,
"pose": {
- "x": 0.43531258808385337,
- "y": 0.29020839205590226,
- "z": 0.21765629404192668,
- "a": 0.7255209801397556,
+ "x": 0.24299999999999994,
+ "y": 0.16199999999999995,
+ "z": 0.12149999999999997,
+ "a": 0.4049999999999999,
"b": 0,
- "c": 1.0882814702096335,
+ "c": 0.6074999999999998,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 0.42972215107433426,
- 0.4238712815951402,
- -0.031028512333440617,
- 0.7255209801397556,
- 1.0882814702096335
+ 0.24126870738107997,
+ 0.2348893000862482,
+ -0.01846045585371492,
+ 0.4049999999999999,
+ 0.6074999999999998
]
},
{
"tick": 21,
"pose": {
- "x": 1.20353386768123,
- "y": 0.8023559117874867,
- "z": 0.601766933840615,
- "a": 2.0058897794687165,
+ "x": 0.9678160869523862,
+ "y": 0.6452107246349241,
+ "z": 0.4839080434761931,
+ "a": 1.6130268115873103,
"b": 0,
- "c": 3.0088346692030745,
+ "c": 2.4195402173809657,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 1.1597591273061187,
- 1.2051079866402112,
- -0.06226200807547322,
- 2.0058897794687165,
- 3.0088346692030745
+ 0.93971480019559,
+ 0.961014383566603,
+ -0.05600343387738782,
+ 1.6130268115873103,
+ 2.4195402173809657
]
},
{
"tick": 31,
"pose": {
- "x": 1.9717551472785966,
- "y": 1.3145034315190645,
- "z": 0.9858775736392983,
- "a": 3.286258578797661,
+ "x": 1.7360373665497584,
+ "y": 1.1573582443665056,
+ "z": 0.8680186832748792,
+ "a": 2.893395610916264,
"b": 0,
- "c": 4.929387868196492,
+ "c": 4.340093416374396,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 1.8515097337411197,
- 2.0262572363257014,
- -0.06100423933171584,
- 3.286258578797661,
- 4.929387868196492
+ 1.6434743220797516,
+ 1.7702368446872363,
+ -0.06501105623829595,
+ 2.893395610916264,
+ 4.340093416374396
]
},
{
"tick": 41,
"pose": {
- "x": 2.739976426875956,
- "y": 1.8266509512506375,
- "z": 1.369988213437978,
- "a": 4.5666273781265945,
+ "x": 2.5042586461471177,
+ "y": 1.6695057640980786,
+ "z": 1.2521293230735588,
+ "a": 4.173764410245196,
"b": 0,
- "c": 6.849941067189891,
+ "c": 6.260646615367795,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 2.5025546580305478,
- 2.8842113485890977,
- -0.02456597127233273,
- 4.5666273781265945,
- 6.849941067189891
+ 2.3072612238106966,
+ 2.6172419942226632,
+ -0.03964030378401873,
+ 4.173764410245196,
+ 6.260646615367795
]
},
{
"tick": 51,
"pose": {
- "x": 3.5081977064733154,
- "y": 2.3387984709822103,
- "z": 1.7540988532366577,
- "a": 5.846996177455527,
+ "x": 3.272479925744477,
+ "y": 2.1816532838296516,
+ "z": 1.6362399628722386,
+ "a": 5.45413320957413,
"b": 0,
- "c": 8.77049426618329,
+ "c": 8.181199814361195,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 3.1105637151574843,
- 3.775641078639424,
- 0.04951852031841675,
- 5.846996177455527,
- 8.77049426618329
+ 2.9287174964693956,
+ 3.498766910981569,
+ 0.022644811888046235,
+ 5.45413320957413,
+ 8.181199814361195
]
},
{
"tick": 61,
"pose": {
- "x": 4.276418986070675,
- "y": 2.8509459907137833,
- "z": 2.1382094930353377,
- "a": 7.12736497678446,
+ "x": 4.040701205341837,
+ "y": 2.6938008035612246,
+ "z": 2.0203506026709186,
+ "a": 6.734502008903062,
"b": 0,
- "c": 10.69104746517669,
+ "c": 10.101753013354594,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 3.6733002615273795,
- 4.697011687538765,
- 0.16347608480270992,
- 7.12736497678446,
- 10.69104746517669
+ 3.5055773344146646,
+ 4.411338137725343,
+ 0.12414600455426239,
+ 6.734502008903062,
+ 10.101753013354594
]
},
{
"tick": 71,
"pose": {
- "x": 5.0446402656680345,
- "y": 3.3630935104453563,
- "z": 2.5223201328340172,
- "a": 8.407733776113393,
+ "x": 4.808922484939196,
+ "y": 3.2059483232927977,
+ "z": 2.404461242469598,
+ "a": 8.014870808231995,
"b": 0,
- "c": 12.611600664170087,
+ "c": 12.022306212347992,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 4.188625453194591,
- 5.644600028114457,
- 0.31928043445841503,
- 8.407733776113393,
- 12.611600664170087
+ 4.0356714420625925,
+ 5.351288462561346,
+ 0.2669161059031424,
+ 8.014870808231995,
+ 12.022306212347992
]
},
{
"tick": 81,
"pose": {
- "x": 5.812861545265394,
- "y": 3.8752410301769293,
- "z": 2.906430772632697,
- "a": 9.688102575442324,
+ "x": 5.577143764536555,
+ "y": 3.7180958430243707,
+ "z": 2.7885718822682777,
+ "a": 9.295239607560926,
"b": 0,
- "c": 14.532153863163485,
+ "c": 13.94285941134139,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 4.654502341479983,
- 6.614512574589231,
- 0.5186390489451398,
- 9.688102575442324,
- 14.532153863163485
+ 4.516931180101328,
+ 6.314774667081458,
+ 0.4527456054216614,
+ 9.295239607560926,
+ 13.94285941134139
]
},
{
"tick": 91,
"pose": {
- "x": 6.581082824862754,
- "y": 4.387388549908502,
- "z": 3.290541412431377,
- "a": 10.968471374771257,
+ "x": 6.345365044133916,
+ "y": 4.230243362755944,
+ "z": 3.172682522066958,
+ "a": 10.57560840688986,
"b": 0,
- "c": 16.452707062156886,
+ "c": 15.863412610334791,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 5.068999799476512,
- 7.6027043183064205,
- 0.7629813063444324,
- 10.968471374771257,
- 16.452707062156886
+ 4.947392544562939,
+ 7.297796164821417,
+ 0.6831503990098113,
+ 10.57560840688986,
+ 15.863412610334791
]
},
{
"tick": 101,
"pose": {
- "x": 7.3493041044601135,
- "y": 4.899536069640075,
- "z": 3.6746520522300568,
- "a": 12.24884017410019,
+ "x": 7.113586323731275,
+ "y": 4.742390882487517,
+ "z": 3.5567931618656377,
+ "a": 11.855977206218792,
"b": 0,
- "c": 18.373260261150286,
+ "c": 17.783965809328187,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 5.430296273191196,
- 8.604998448225977,
- 1.0534478955970403,
- 12.24884017410019,
- 18.373260261150286
+ 5.325199972422579,
+ 8.296214449818592,
+ 0.9593608027219727,
+ 11.855977206218792,
+ 17.783965809328187
]
},
{
"tick": 111,
"pose": {
- "x": 8.117525384057473,
- "y": 5.411683589371648,
- "z": 4.058762692028736,
- "a": 13.529208973429121,
+ "x": 7.881807603328635,
+ "y": 5.25453840221909,
+ "z": 3.9409038016643176,
+ "a": 13.136346005547727,
"b": 0,
- "c": 20.29381346014368,
+ "c": 19.70451900832159,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 5.736683351332234,
- 9.617106731489649,
- 1.3908815598812971,
- 13.529208973429121,
- 20.29381346014368
+ 5.648609967652907,
+ 9.305773271567634,
+ 1.2823118828214888,
+ 13.136346005547727,
+ 19.70451900832159
]
},
{
"tick": 121,
"pose": {
- "x": 8.885746663654832,
- "y": 5.923831109103221,
- "z": 4.442873331827416,
- "a": 14.809577772758056,
+ "x": 8.650028882925994,
+ "y": 5.766685921950663,
+ "z": 4.325014441462997,
+ "a": 14.416714804876658,
"b": 0,
- "c": 22.214366659137085,
+ "c": 21.62507220731499,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 5.986569148019269,
- 10.634650506313353,
- 1.7758192149941703,
- 14.809577772758056,
- 22.214366659137085
+ 5.915994541928214,
+ 10.322119449528216,
+ 1.652635147912715,
+ 14.416714804876658,
+ 21.62507220731499
]
},
{
"tick": 131,
"pose": {
- "x": 9.653967943252193,
- "y": 6.435978628834794,
- "z": 4.826983971626096,
- "a": 16.089946572086987,
+ "x": 9.418250162523353,
+ "y": 6.278833441682236,
+ "z": 4.709125081261677,
+ "a": 15.697083604205591,
"b": 0,
- "c": 24.13491985813048,
+ "c": 23.54562540630839,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 6.178481492972385,
- 11.653182196773486,
- 2.2084854811311843,
- 16.089946572086987,
- 24.13491985813048
+ 6.125844464447852,
+ 11.340824237539714,
+ 2.0706516433030995,
+ 15.697083604205591,
+ 23.54562540630839
]
},
{
"tick": 141,
"pose": {
- "x": 10.422189222849552,
- "y": 6.9481261485663675,
- "z": 5.211094611424776,
- "a": 17.37031537141592,
+ "x": 10.186471442120713,
+ "y": 6.790980961413809,
+ "z": 5.093235721060356,
+ "a": 16.977452403534524,
"b": 0,
- "c": 26.05547305712388,
+ "c": 25.466178605301785,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 6.3110709240208225,
- 12.66820725672306,
- 2.6887876606358327,
- 17.37031537141592,
- 26.05547305712388
+ 6.27677231563149,
+ 12.357405146054923,
+ 2.5363664819692477,
+ 16.977452403534524,
+ 25.466178605301785
]
},
{
"tick": 151,
"pose": {
- "x": 11.19041050244691,
- "y": 7.4602736682979405,
- "z": 5.595205251223455,
- "a": 18.650684170744853,
+ "x": 10.954692721718073,
+ "y": 7.303128481145382,
+ "z": 5.477346360859037,
+ "a": 18.257821202863454,
"b": 0,
- "c": 27.97602625611728,
+ "c": 27.386731804295184,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 6.383113477065606,
- 13.675206448111613,
- 3.216312188325154,
- 18.650684170744853,
- 27.97602625611728
+ 6.367515339729723,
+ 13.367348128029015,
+ 3.049464840576775,
+ 18.257821202863454,
+ 27.386731804295184
]
},
{
"tick": 161,
"pose": {
- "x": 11.913625063560401,
- "y": 7.942416709040268,
- "z": 5.9568125317802005,
- "a": 19.85604177260067,
+ "x": 11.69947693080819,
+ "y": 7.79965128720546,
+ "z": 5.849738465404095,
+ "a": 19.49912821801365,
"b": 0,
- "c": 29.784062658901004,
+ "c": 29.248692327020475,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 6.394621744187004,
- 14.611824272990901,
- 3.755429285890239,
- 19.85604177260067,
- 29.784062658901004
+ 6.396957397127961,
+ 14.335867874536444,
+ 3.5915467763450657,
+ 19.49912821801365,
+ 29.248692327020475
]
},
{
"tick": 171,
+ "pose": {
+ "x": 11.999999772197402,
+ "y": 7.999999848131601,
+ "z": 5.999999886098701,
+ "a": 19.999999620329007,
+ "b": 0,
+ "c": 29.99999943049351,
+ "u": 0,
+ "v": 0,
+ "w": 0
+ },
+ "joints": [
+ 6.3923048525679045,
+ 14.722765040787896,
+ 3.822532394371681,
+ 19.999999620329007,
+ 29.99999943049351
+ ]
+ },
+ {
+ "tick": 181,
"pose": {
"x": 12,
"y": 8,
@@ -375,7 +396,7 @@
"z_offset": 10
},
"final": {
- "tick": 171,
+ "tick": 181,
"pose": {
"x": 12,
"y": 8,
@@ -396,7 +417,7 @@
]
},
"preview_hash": "fnv1a64:0740b3272128b1bb",
- "trace_hash": "fnv1a64:ce697b4fadfad0aa",
+ "trace_hash": "fnv1a64:4d88635666d790c0",
"model_samples": 4
},
"modified": {
@@ -404,341 +425,362 @@
{
"tick": 11,
"pose": {
- "x": 0.43531258808385337,
- "y": 0.29020839205590226,
- "z": 0.21765629404192668,
- "a": 0.7255209801397556,
+ "x": 0.24299999999999994,
+ "y": 0.16199999999999995,
+ "z": 0.12149999999999997,
+ "a": 0.4049999999999999,
"b": 0,
- "c": 1.0882814702096335,
+ "c": 0.6074999999999998,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 0.29839492329883655,
- 0.23806138837691382,
- 0.1186595600110536,
- 0.7255209801397556,
- 1.0882814702096335
+ 0.16755586117431243,
+ 0.13170582267194675,
+ 0.0656595683305703,
+ 0.4049999999999999,
+ 0.6074999999999998
]
},
{
"tick": 21,
"pose": {
- "x": 1.20353386768123,
- "y": 0.8023559117874867,
- "z": 0.601766933840615,
- "a": 2.0058897794687165,
+ "x": 0.9678160869523862,
+ "y": 0.6452107246349241,
+ "z": 0.4839080434761931,
+ "a": 1.6130268115873103,
"b": 0,
- "c": 3.0088346692030745,
+ "c": 2.4195402173809657,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 0.8047364818248148,
- 0.6809886822424431,
- 0.34027883586112573,
- 2.0058897794687165,
- 3.0088346692030745
+ 0.6522236312541274,
+ 0.5420752903639041,
+ 0.2705164541020988,
+ 1.6130268115873103,
+ 2.4195402173809657
]
},
{
"tick": 31,
"pose": {
- "x": 1.9717551472785966,
- "y": 1.3145034315190645,
- "z": 0.9858775736392983,
- "a": 3.286258578797661,
+ "x": 1.7360373665497584,
+ "y": 1.1573582443665056,
+ "z": 0.8680186832748792,
+ "a": 2.893395610916264,
"b": 0,
- "c": 4.929387868196492,
+ "c": 4.340093416374396,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 1.283301885476961,
- 1.1512719521033148,
- 0.5794302082437426,
- 3.286258578797661,
- 4.929387868196492
+ 1.1395470174778106,
+ 1.004193514160768,
+ 0.5040548484118901,
+ 2.893395610916264,
+ 4.340093416374396
]
},
{
"tick": 41,
"pose": {
- "x": 2.739976426875956,
- "y": 1.8266509512506375,
- "z": 1.369988213437978,
- "a": 4.5666273781265945,
+ "x": 2.5042586461471177,
+ "y": 1.6695057640980786,
+ "z": 1.2521293230735588,
+ "a": 4.173764410245196,
"b": 0,
- "c": 6.849941067189891,
+ "c": 6.260646615367795,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 1.7319113319947625,
- 1.6466730139855557,
- 0.8382368096709492,
- 4.5666273781265945,
- 6.849941067189891
+ 1.5975752904201315,
+ 1.492137380538665,
+ 0.7566149649412672,
+ 4.173764410245196,
+ 6.260646615367795
]
},
{
"tick": 51,
"pose": {
- "x": 3.5081977064733154,
- "y": 2.3387984709822103,
- "z": 1.7540988532366577,
- "a": 5.846996177455527,
+ "x": 3.272479925744477,
+ "y": 2.1816532838296516,
+ "z": 1.6362399628722386,
+ "a": 5.45413320957413,
"b": 0,
- "c": 8.77049426618329,
+ "c": 8.181199814361195,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 2.148462069309356,
- 2.1647608699183998,
- 1.118646742957452,
- 5.846996177455527,
- 8.77049426618329
+ 2.024181607615926,
+ 2.0035336819911826,
+ 1.0302001299965937,
+ 5.45413320957413,
+ 8.181199814361195
]
},
{
"tick": 61,
"pose": {
- "x": 4.276418986070675,
- "y": 2.8509459907137833,
- "z": 2.1382094930353377,
- "a": 7.12736497678446,
+ "x": 4.040701205341837,
+ "y": 2.6938008035612246,
+ "z": 2.0203506026709186,
+ "a": 6.734502008903062,
"b": 0,
- "c": 10.69104746517669,
+ "c": 10.101753013354594,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 2.5309325541168324,
- 2.702925032227178,
- 1.4224192021870863,
- 7.12736497678446,
- 10.69104746517669
+ 2.4173190741618305,
+ 2.5358255301257557,
+ 1.3266289219701726,
+ 6.734502008903062,
+ 10.101753013354594
]
},
{
"tick": 71,
"pose": {
- "x": 5.0446402656680345,
- "y": 3.3630935104453563,
- "z": 2.5223201328340172,
- "a": 8.407733776113393,
+ "x": 4.808922484939196,
+ "y": 3.2059483232927977,
+ "z": 2.404461242469598,
+ "a": 8.014870808231995,
"b": 0,
- "c": 12.611600664170087,
+ "c": 12.022306212347992,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 2.877386468443123,
- 3.2583897873761813,
- 1.7511115404754882,
- 8.407733776113393,
- 12.611600664170087
+ 2.7750248035530722,
+ 3.086286337930197,
+ 1.6475219419534017,
+ 8.014870808231995,
+ 12.022306212347992
]
},
{
"tick": 81,
"pose": {
- "x": 5.812861545265394,
- "y": 3.8752410301769293,
- "z": 2.906430772632697,
- "a": 9.688102575442324,
+ "x": 5.577143764536555,
+ "y": 3.7180958430243707,
+ "z": 2.7885718822682777,
+ "a": 9.295239607560926,
"b": 0,
- "c": 14.532153863163485,
+ "c": 13.94285941134139,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 3.185976587726967,
- 3.828229334530791,
- 2.106067345136779,
- 9.688102575442324,
- 14.532153863163485
+ 3.0954238320010283,
+ 3.6520346970672257,
+ 1.9942895736047923,
+ 9.295239607560926,
+ 13.94285941134139
]
},
{
"tick": 91,
"pose": {
- "x": 6.581082824862754,
- "y": 4.387388549908502,
- "z": 3.290541412431377,
- "a": 10.968471374771257,
+ "x": 6.345365044133916,
+ "y": 4.230243362755944,
+ "z": 3.172682522066958,
+ "a": 10.57560840688986,
"b": 0,
- "c": 16.452707062156886,
+ "c": 15.863412610334791,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 3.454948494173575,
- 4.409383730211715,
- 2.4884055765479562,
- 10.968471374771257,
- 16.452707062156886
+ 3.37673287991381,
+ 4.230050082705562,
+ 2.36812078991586,
+ 10.57560840688986,
+ 15.863412610334791
]
},
{
"tick": 101,
"pose": {
- "x": 7.3493041044601135,
- "y": 4.899536069640075,
- "z": 3.6746520522300568,
- "a": 12.24884017410019,
+ "x": 7.113586323731275,
+ "y": 4.742390882487517,
+ "z": 3.5567931618656377,
+ "a": 11.855977206218792,
"b": 0,
- "c": 18.373260261150286,
+ "c": 17.783965809328187,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 3.68264412936678,
- 4.998675566898388,
- 2.8990108224578464,
- 12.24884017410019,
- 18.373260261150286
+ 3.6172639544545424,
+ 4.817189314792596,
+ 2.7699730600405736,
+ 11.855977206218792,
+ 17.783965809328187
]
},
{
"tick": 111,
"pose": {
- "x": 8.117525384057473,
- "y": 5.411683589371648,
- "z": 4.058762692028736,
- "a": 13.529208973429121,
+ "x": 7.881807603328635,
+ "y": 5.25453840221909,
+ "z": 3.9409038016643176,
+ "a": 13.136346005547727,
"b": 0,
- "c": 20.29381346014368,
+ "c": 19.70451900832159,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 3.8675051803715617,
- 5.592827310218196,
- 3.338524714726148,
- 13.529208973429121,
- 20.29381346014368
+ 3.8154277873334177,
+ 5.410203701361359,
+ 3.200563404656565,
+ 13.136346005547727,
+ 19.70451900832159
]
},
{
"tick": 121,
"pose": {
- "x": 8.885746663654832,
- "y": 5.923831109103221,
- "z": 4.442873331827416,
- "a": 14.809577772758056,
+ "x": 8.650028882925994,
+ "y": 5.766685921950663,
+ "z": 4.325014441462997,
+ "a": 14.416714804876658,
"b": 0,
- "c": 22.214366659137085,
+ "c": 21.62507220731499,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 4.008076293811263,
- 6.188479216445758,
- 3.8073385505204715,
- 14.809577772758056,
- 22.214366659137085
+ 3.9697371022394945,
+ 6.005756786456001,
+ 3.6603606434267615,
+ 14.416714804876658,
+ 21.62507220731499
]
},
{
"tick": 131,
"pose": {
- "x": 9.653967943252193,
- "y": 6.435978628834794,
- "z": 4.826983971626096,
- "a": 16.089946572086987,
+ "x": 9.418250162523353,
+ "y": 6.278833441682236,
+ "z": 4.709125081261677,
+ "a": 15.697083604205591,
"b": 0,
- "c": 24.13491985813048,
+ "c": 23.54562540630839,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 4.103008112664548,
- 6.78220774944357,
- 4.305587154865833,
- 16.089946572086987,
- 24.13491985813048
+ 4.07880970657646,
+ 6.600442622568249,
+ 4.149578873048714,
+ 15.697083604205591,
+ 23.54562540630839
]
},
{
"tick": 141,
"pose": {
- "x": 10.422189222849552,
- "y": 6.9481261485663675,
- "z": 5.211094611424776,
- "a": 17.37031537141592,
+ "x": 10.186471442120713,
+ "y": 6.790980961413809,
+ "z": 5.093235721060356,
+ "a": 16.977452403534524,
"b": 0,
- "c": 26.05547305712388,
+ "c": 25.466178605301785,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 4.151060130795425,
- 7.370544413912912,
- 4.83314401615139,
- 17.37031537141592,
- 26.05547305712388
+ 4.1413714024325605,
+ 7.190804485112055,
+ 4.668172209134653,
+ 16.977452403534524,
+ 25.466178605301785
]
},
{
"tick": 151,
"pose": {
- "x": 11.19041050244691,
- "y": 7.4602736682979405,
- "z": 5.595205251223455,
- "a": 18.650684170744853,
+ "x": 10.954692721718073,
+ "y": 7.303128481145382,
+ "z": 5.477346360859037,
+ "a": 18.257821202863454,
"b": 0,
- "c": 27.97602625611728,
+ "c": 27.386731804295184,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 4.151103360505562,
- 7.949994919900462,
- 5.389617720774595,
- 18.650684170744853,
- 27.97602625611728
+ 4.156258711988499,
+ 7.7733539444356285,
+ 5.215830819780104,
+ 18.257821202863454,
+ 27.386731804295184
]
},
{
"tick": 161,
"pose": {
- "x": 11.913625063560401,
- "y": 7.942416709040268,
- "z": 5.9568125317802005,
- "a": 19.85604177260067,
+ "x": 11.69947693080819,
+ "y": 7.79965128720546,
+ "z": 5.849738465404095,
+ "a": 19.49912821801365,
"b": 0,
- "c": 29.784062658901004,
+ "c": 29.248692327020475,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 4.10636087088259,
- 8.484240758195993,
- 5.939328823631666,
- 19.85604177260067,
- 29.784062658901004
+ 4.12418367088165,
+ 8.327364076287035,
+ 5.773987247691979,
+ 19.49912821801365,
+ 29.248692327020475
]
},
{
"tick": 171,
+ "pose": {
+ "x": 11.999999772197402,
+ "y": 7.999999848131601,
+ "z": 5.999999886098701,
+ "a": 19.999999620329007,
+ "b": 0,
+ "c": 29.99999943049351,
+ "u": 0,
+ "v": 0,
+ "w": 0
+ },
+ "joints": [
+ 4.098076234035635,
+ 8.547175103891929,
+ 6.006622015702936,
+ 19.999999620329007,
+ 29.99999943049351
+ ]
+ },
+ {
+ "tick": 181,
"pose": {
"x": 12,
"y": 8,
@@ -769,7 +811,7 @@
"z_offset": -4
},
"final": {
- "tick": 171,
+ "tick": 181,
"pose": {
"x": 12,
"y": 8,
@@ -790,10 +832,10 @@
]
},
"preview_hash": "fnv1a64:0740b3272128b1bb",
- "trace_hash": "fnv1a64:10c0665d1c3fa753",
+ "trace_hash": "fnv1a64:d9e843abc9867e80",
"model_samples": 4
},
- "repeat_hash": "fnv1a64:10c0665d1c3fa753",
+ "repeat_hash": "fnv1a64:d9e843abc9867e80",
"first_divergent_tick": 11
},
{
@@ -804,341 +846,362 @@
{
"tick": 11,
"pose": {
- "x": 0.43531258808385337,
- "y": 0.29020839205590226,
- "z": 0.21765629404192668,
+ "x": 0.24299999999999994,
+ "y": 0.16199999999999995,
+ "z": 0.12149999999999997,
"a": 0,
- "b": 0.7255209801397556,
- "c": 1.0882814702096335,
+ "b": 0.4049999999999999,
+ "c": 0.6074999999999998,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 0.6207761955139439,
- 0.2984239149933897,
- -0.04225286521340266,
- 0.7255209801397556,
- 1.0882814702096335
+ 0.3476497281321649,
+ 0.16456734438722265,
+ -0.021953676275151324,
+ 0.4049999999999999,
+ 0.6074999999999998
]
},
{
"tick": 21,
"pose": {
- "x": 1.20353386768123,
- "y": 0.8023559117874867,
- "z": 0.601766933840615,
+ "x": 0.9678160869523862,
+ "y": 0.6452107246349241,
+ "z": 0.4839080434761931,
"a": 0,
- "b": 2.0058897794687165,
- "c": 3.0088346692030745,
+ "b": 1.6130268115873103,
+ "c": 2.4195402173809657,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 1.692889784467745,
- 0.8644232449825833,
- -0.14843204969818302,
- 2.0058897794687165,
- 3.0088346692030745
+ 1.3672723837031882,
+ 0.6854932177936666,
+ -0.11165774530313222,
+ 1.6130268115873103,
+ 2.4195402173809657
]
},
{
"tick": 31,
"pose": {
- "x": 1.9717551472785966,
- "y": 1.3145034315190645,
- "z": 0.9858775736392983,
+ "x": 1.7360373665497584,
+ "y": 1.1573582443665056,
+ "z": 0.8680186832748792,
"a": 0,
- "b": 3.286258578797661,
- "c": 4.929387868196492,
+ "b": 2.893395610916264,
+ "c": 4.340093416374396,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 2.7319608494340777,
- 1.4790704304247735,
- -0.29303854010070474,
- 3.286258578797661,
- 4.929387868196492
+ 2.416865813571949,
+ 1.2854166626135826,
+ -0.2447255274733866,
+ 2.893395610916264,
+ 4.340093416374396
]
},
{
"tick": 41,
"pose": {
- "x": 2.739976426875956,
- "y": 1.8266509512506375,
- "z": 1.369988213437978,
+ "x": 2.5042586461471177,
+ "y": 1.6695057640980786,
+ "z": 1.2521293230735588,
"a": 0,
- "b": 4.5666273781265945,
- "c": 6.849941067189891,
+ "b": 4.173764410245196,
+ "c": 6.260646615367795,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 3.734469586356081,
- 2.1404076016288105,
- -0.47359529339455797,
- 4.5666273781265945,
- 6.849941067189891
+ 3.430955023990105,
+ 1.932642364292698,
+ -0.41452923265797814,
+ 4.173764410245196,
+ 6.260646615367795
]
},
{
"tick": 51,
"pose": {
- "x": 3.5081977064733154,
- "y": 2.3387984709822103,
- "z": 1.7540988532366577,
+ "x": 3.272479925744477,
+ "y": 2.1816532838296516,
+ "z": 1.6362399628722386,
"a": 0,
- "b": 5.846996177455527,
- "c": 8.77049426618329,
+ "b": 5.45413320957413,
+ "c": 8.181199814361195,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 4.697109256078299,
- 2.8463701111273325,
- -0.6873909433076797,
- 5.846996177455527,
- 8.77049426618329
+ 4.4061661594334005,
+ 2.6251380422978365,
+ -0.6184277795055717,
+ 5.45413320957413,
+ 8.181199814361195
]
},
{
"tick": 61,
"pose": {
- "x": 4.276418986070675,
- "y": 2.8509459907137833,
- "z": 2.1382094930353377,
+ "x": 4.040701205341837,
+ "y": 2.6938008035612246,
+ "z": 2.0203506026709186,
"a": 0,
- "b": 7.12736497678446,
- "c": 10.69104746517669,
+ "b": 6.734502008903062,
+ "c": 10.101753013354594,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 5.616802091780549,
- 3.5947903381220483,
- -0.9314954422957076,
- 7.12736497678446,
- 10.69104746517669
+ 5.339349568752038,
+ 3.36076725652882,
+ -0.8535564930766153,
+ 6.734502008903062,
+ 10.101753013354594
]
},
{
"tick": 71,
"pose": {
- "x": 5.0446402656680345,
- "y": 3.3630935104453563,
- "z": 2.5223201328340172,
+ "x": 4.808922484939196,
+ "y": 3.2059483232927977,
+ "z": 2.404461242469598,
"a": 0,
- "b": 8.407733776113393,
- "c": 12.611600664170087,
+ "b": 8.014870808231995,
+ "c": 12.022306212347992,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 6.490714114233835,
- 4.383401667979941,
- -1.2027767333283421,
- 8.407733776113393,
- 12.611600664170087
+ 6.227594962679156,
+ 4.137293333750716,
+ -1.1168434682505861,
+ 8.014870808231995,
+ 12.022306212347992
]
},
{
"tick": 81,
"pose": {
- "x": 5.812861545265394,
- "y": 3.8752410301769293,
- "z": 2.906430772632697,
+ "x": 5.577143764536555,
+ "y": 3.7180958430243707,
+ "z": 2.7885718822682777,
"a": 0,
- "b": 9.688102575442324,
- "c": 14.532153863163485,
+ "b": 9.295239607560926,
+ "c": 13.94285941134139,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 7.3162687898287935,
- 5.209842640574597,
- -1.4979183781553669,
- 9.688102575442324,
- 14.532153863163485
+ 7.068245433572351,
+ 4.952383464802834,
+ -1.4050269125555133,
+ 9.295239607560926,
+ 13.94285941134139
]
},
{
"tick": 91,
"pose": {
- "x": 6.581082824862754,
- "y": 4.387388549908502,
- "z": 3.290541412431377,
+ "x": 6.345365044133916,
+ "y": 4.230243362755944,
+ "z": 3.172682522066958,
"a": 0,
- "b": 10.968471374771257,
- "c": 16.452707062156886,
+ "b": 10.57560840688986,
+ "c": 15.863412610334791,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 8.091159471131004,
- 6.071661261012413,
- -1.813438064699831,
- 10.968471374771257,
- 16.452707062156886
+ 7.85891027564163,
+ 5.803612966201346,
+ -1.7146733922683808,
+ 10.57560840688986,
+ 15.863412610334791
]
},
{
"tick": 101,
"pose": {
- "x": 7.3493041044601135,
- "y": 4.899536069640075,
- "z": 3.6746520522300568,
+ "x": 7.113586323731275,
+ "y": 4.742390882487517,
+ "z": 3.5567931618656377,
"a": 0,
- "b": 12.24884017410019,
- "c": 18.373260261150286,
+ "b": 11.855977206218792,
+ "c": 17.783965809328187,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 8.813360564765546,
- 6.966319466038303,
- -2.145706912515344,
- 12.24884017410019,
- 18.373260261150286
+ 8.59747654889259,
+ 6.688469699504358,
+ -2.042196901828067,
+ 11.855977206218792,
+ 17.783965809328187
]
},
{
"tick": 111,
"pose": {
- "x": 8.117525384057473,
- "y": 5.411683589371648,
- "z": 4.058762692028736,
+ "x": 7.881807603328635,
+ "y": 5.25453840221909,
+ "z": 3.9409038016643176,
"a": 0,
- "b": 13.529208973429121,
- "c": 20.29381346014368,
+ "b": 13.136346005547727,
+ "c": 19.70451900832159,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 9.48113737670538,
- 7.891197739179992,
- -2.4909694918577667,
- 13.529208973429121,
- 20.29381346014368
+ 9.282119335219008,
+ 7.604358641569745,
+ -2.38387867311514,
+ 13.136346005547727,
+ 19.70451900832159
]
},
{
"tick": 121,
"pose": {
- "x": 8.885746663654832,
- "y": 5.923831109103221,
- "z": 4.442873331827416,
+ "x": 8.650028882925994,
+ "y": 5.766685921950663,
+ "z": 4.325014441462997,
"a": 0,
- "b": 14.809577772758056,
- "c": 22.214366659137085,
+ "b": 14.416714804876658,
+ "c": 21.62507220731499,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 10.093054590514853,
- 8.843599867464375,
- -2.8453644688717326,
- 14.809577772758056,
- 22.214366659137085
+ 9.91131064049511,
+ 8.548606598607371,
+ -2.735887637998001,
+ 14.416714804876658,
+ 21.62507220731499
]
},
{
"tick": 131,
"pose": {
- "x": 9.653967943252193,
- "y": 6.435978628834794,
- "z": 4.826983971626096,
+ "x": 9.418250162523353,
+ "y": 6.278833441682236,
+ "z": 4.709125081261677,
"a": 0,
- "b": 16.089946572086987,
- "c": 24.13491985813048,
+ "b": 15.697083604205591,
+ "c": 23.54562540630839,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 10.647983339758778,
- 9.820757832324189,
- -3.2049457866916047,
- 16.089946572086987,
- 24.13491985813048
+ 10.483826902123656,
+ 9.518467056708868,
+ -3.0943014547360406,
+ 15.697083604205591,
+ 23.54562540630839
]
},
{
"tick": 141,
"pose": {
- "x": 10.422189222849552,
- "y": 6.9481261485663675,
- "z": 5.211094611424776,
+ "x": 10.186471442120713,
+ "y": 6.790980961413809,
+ "z": 5.093235721060356,
"a": 0,
- "b": 17.37031537141592,
- "c": 26.05547305712388,
+ "b": 16.977452403534524,
+ "c": 25.466178605301785,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 11.145106841607806,
- 10.819836827108439,
- -3.5657042899157076,
- 17.37031537141592,
- 26.05547305712388
+ 10.998755067268661,
+ 10.511125161330149,
+ -3.455128006377935,
+ 16.977452403534524,
+ 25.466178605301785
]
},
{
"tick": 151,
"pose": {
- "x": 11.19041050244691,
- "y": 7.4602736682979405,
- "z": 5.595205251223455,
+ "x": 10.954692721718073,
+ "y": 7.303128481145382,
+ "z": 5.477346360859037,
"a": 0,
- "b": 18.650684170744853,
- "c": 27.97602625611728,
+ "b": 18.257821202863454,
+ "c": 27.386731804295184,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 11.5839245646291,
- 11.837940393416295,
- -3.92358969794088,
- 18.650684170744853,
- 27.97602625611728
+ 11.455497212920392,
+ 11.523702818004569,
+ -3.8143272772073935,
+ 18.257821202863454,
+ 27.386731804295184
]
},
{
"tick": 161,
"pose": {
- "x": 11.913625063560401,
- "y": 7.942416709040268,
- "z": 5.9568125317802005,
+ "x": 11.69947693080819,
+ "y": 7.79965128720546,
+ "z": 5.849738465404095,
"a": 0,
- "b": 19.85604177260067,
- "c": 29.784062658901004,
+ "b": 19.49912821801365,
+ "c": 29.248692327020475,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 11.943584949172966,
- 12.811138047960743,
- -4.254236690404012,
- 19.85604177260067,
- 29.784062658901004
+ 11.842488019118306,
+ 12.521631551672836,
+ -4.157172386602891,
+ 19.49912821801365,
+ 29.248692327020475
]
},
{
"tick": 171,
+ "pose": {
+ "x": 11.999999772197402,
+ "y": 7.999999848131601,
+ "z": 5.999999886098701,
+ "a": 0,
+ "b": 19.999999620329007,
+ "c": 29.99999943049351,
+ "u": 0,
+ "v": 0,
+ "w": 0
+ },
+ "joints": [
+ 11.983077015423682,
+ 12.928202921314321,
+ -4.293154746962006,
+ 19.999999620329007,
+ 29.99999943049351
+ ]
+ },
+ {
+ "tick": 181,
"pose": {
"x": 12,
"y": 8,
@@ -1169,7 +1232,7 @@
"z_offset": -15
},
"final": {
- "tick": 171,
+ "tick": 181,
"pose": {
"x": 12,
"y": 8,
@@ -1190,7 +1253,7 @@
]
},
"preview_hash": "fnv1a64:2595e1a5d1cf33eb",
- "trace_hash": "fnv1a64:ff4a1d69daa88c50",
+ "trace_hash": "fnv1a64:64ed08ff910588cc",
"model_samples": 4
},
"modified": {
@@ -1198,341 +1261,362 @@
{
"tick": 11,
"pose": {
- "x": 0.43531258808385337,
- "y": 0.29020839205590226,
- "z": 0.21765629404192668,
+ "x": 0.24299999999999994,
+ "y": 0.16199999999999995,
+ "z": 0.12149999999999997,
"a": 0,
- "b": 0.7255209801397556,
- "c": 1.0882814702096335,
+ "b": 0.4049999999999999,
+ "c": 0.6074999999999998,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 0.1890896373468145,
- 0.1262246633519588,
- 0.5184793885068615,
- 0.7255209801397556,
- 1.0882814702096335
+ 0.10539335548359574,
+ 0.06874978581953695,
+ 0.2901820234818224,
+ 0.4049999999999999,
+ 0.6074999999999998
]
},
{
"tick": 21,
"pose": {
- "x": 1.20353386768123,
- "y": 0.8023559117874867,
- "z": 0.601766933840615,
+ "x": 0.9678160869523862,
+ "y": 0.6452107246349241,
+ "z": 0.4839080434761931,
"a": 0,
- "b": 2.0058897794687165,
- "c": 3.0088346692030745,
+ "b": 1.6130268115873103,
+ "c": 2.4195402173809657,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 0.524992860948668,
- 0.3823639705900179,
- 1.4187990309839122,
- 2.0058897794687165,
- 3.0088346692030745
+ 0.42175669269525784,
+ 0.29930511312561325,
+ 1.1444970804870227,
+ 1.6130268115873103,
+ 2.4195402173809657
]
},
{
"tick": 31,
"pose": {
- "x": 1.9717551472785966,
- "y": 1.3145034315190645,
- "z": 0.9858775736392983,
+ "x": 1.7360373665497584,
+ "y": 1.1573582443665056,
+ "z": 0.8680186832748792,
"a": 0,
- "b": 3.286258578797661,
- "c": 4.929387868196492,
+ "b": 2.893395610916264,
+ "c": 4.340093416374396,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 0.8612510860412996,
- 0.6798283334892181,
- 2.3012814518113096,
- 3.286258578797661,
- 4.929387868196492
+ 0.7582052460566509,
+ 0.5842551102038751,
+ 2.032331285340442,
+ 2.893395610916264,
+ 4.340093416374396
]
},
{
"tick": 41,
"pose": {
- "x": 2.739976426875956,
- "y": 1.8266509512506375,
- "z": 1.369988213437978,
+ "x": 2.5042586461471177,
+ "y": 1.6695057640980786,
+ "z": 1.2521293230735588,
"a": 0,
- "b": 4.5666273781265945,
- "c": 6.849941067189891,
+ "b": 4.173764410245196,
+ "c": 6.260646615367795,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 1.1951143551048027,
- 1.0170162320714038,
- 3.1671581958301296,
- 4.5666273781265945,
- 6.849941067189891
+ 1.093086758049374,
+ 0.9094307221749949,
+ 2.9031599479305346,
+ 4.173764410245196,
+ 6.260646615367795
]
},
{
"tick": 51,
"pose": {
- "x": 3.5081977064733154,
- "y": 2.3387984709822103,
- "z": 1.7540988532366577,
+ "x": 3.272479925744477,
+ "y": 2.1816532838296516,
+ "z": 1.6362399628722386,
"a": 0,
- "b": 5.846996177455527,
- "c": 8.77049426618329,
+ "b": 5.45413320957413,
+ "c": 8.181199814361195,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 1.5239884156349035,
- 1.3922271952749217,
- 4.017862243517918,
- 5.846996177455527,
- 8.77049426618329
+ 1.4237576350612429,
+ 1.273161486513839,
+ 3.758355546301283,
+ 5.45413320957413,
+ 8.181199814361195
]
},
{
"tick": 61,
"pose": {
- "x": 4.276418986070675,
- "y": 2.8509459907137833,
- "z": 2.1382094930353377,
+ "x": 4.040701205341837,
+ "y": 2.6938008035612246,
+ "z": 2.0203506026709186,
"a": 0,
- "b": 7.12736497678446,
- "c": 10.69104746517669,
+ "b": 6.734502008903062,
+ "c": 10.101753013354594,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 1.8454490485581694,
- 1.8036651962536698,
- 4.855015307951234,
- 7.12736497678446,
- 10.69104746517669
+ 1.7477400166770884,
+ 1.6736803251231542,
+ 4.599483292750644,
+ 6.734502008903062,
+ 10.101753013354594
]
},
{
"tick": 71,
"pose": {
- "x": 5.0446402656680345,
- "y": 3.3630935104453563,
- "z": 2.5223201328340172,
+ "x": 4.808922484939196,
+ "y": 3.2059483232927977,
+ "z": 2.404461242469598,
"a": 0,
- "b": 8.407733776113393,
- "c": 12.611600664170087,
+ "b": 8.014870808231995,
+ "c": 12.022306212347992,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 2.157255464108168,
- 2.249442214393575,
- 5.680414181744255,
- 8.407733776113393,
- 12.611600664170087
+ 2.062735464363172,
+ 2.109127055793646,
+ 5.428287765599858,
+ 8.014870808231995,
+ 12.022306212347992
]
},
{
"tick": 81,
"pose": {
- "x": 5.812861545265394,
- "y": 3.8752410301769293,
- "z": 2.906430772632697,
+ "x": 5.577143764536555,
+ "y": 3.7180958430243707,
+ "z": 2.7885718822682777,
"a": 0,
- "b": 9.688102575442324,
- "c": 14.532153863163485,
+ "b": 9.295239607560926,
+ "c": 13.94285941134139,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 2.4573627042376387,
- 2.7275819583097274,
- 6.496016198415771,
- 9.688102575442324,
- 14.532153863163485
+ 2.3666376749349958,
+ 2.577552066431478,
+ 6.246678635197696,
+ 9.295239607560926,
+ 13.94285941134139
]
},
{
"tick": 91,
"pose": {
- "x": 6.581082824862754,
- "y": 4.387388549908502,
- "z": 3.290541412431377,
+ "x": 6.345365044133916,
+ "y": 4.230243362755944,
+ "z": 3.172682522066958,
"a": 0,
- "b": 10.968471374771257,
- "c": 16.452707062156886,
+ "b": 10.57560840688986,
+ "c": 15.863412610334791,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 2.7439329949466504,
- 3.2360237438418107,
- 7.303923876441846,
- 10.968471374771257,
- 16.452707062156886
+ 2.657544160842008,
+ 3.0769201461452305,
+ 7.0567155512784865,
+ 10.57560840688986,
+ 15.863412610334791
]
},
{
"tick": 101,
"pose": {
- "x": 7.3493041044601135,
- "y": 4.899536069640075,
- "z": 3.6746520522300568,
+ "x": 7.113586323731275,
+ "y": 4.742390882487517,
+ "z": 3.5567931618656377,
"a": 0,
- "b": 12.24884017410019,
- "c": 18.373260261150286,
+ "b": 11.855977206218792,
+ "c": 17.783965809328187,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 3.0153459964447684,
- 3.772626520828604,
- 8.106368817774932,
- 12.24884017410019,
- 18.373260261150286
+ 2.9337668437710924,
+ 3.6051144670449116,
+ 7.860592262404443,
+ 11.855977206218792,
+ 17.783965809328187
]
},
{
"tick": 111,
"pose": {
- "x": 8.117525384057473,
- "y": 5.411683589371648,
- "z": 4.058762692028736,
+ "x": 7.881807603328635,
+ "y": 5.25453840221909,
+ "z": 3.9409038016643176,
"a": 0,
- "b": 13.529208973429121,
- "c": 20.29381346014368,
+ "b": 13.136346005547727,
+ "c": 19.70451900832159,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 3.270207903818072,
- 4.335173042214558,
- 8.905694935839493,
- 13.529208973429121,
- 20.29381346014368
+ 3.1938415127596294,
+ 4.159940710374837,
+ 8.660620041543572,
+ 13.136346005547727,
+ 19.70451900832159
]
},
{
"tick": 121,
"pose": {
- "x": 8.885746663654832,
- "y": 5.923831109103221,
- "z": 4.442873331827416,
+ "x": 8.650028882925994,
+ "y": 5.766685921950663,
+ "z": 4.325014441462997,
"a": 0,
- "b": 14.809577772758056,
- "c": 22.214366659137085,
+ "b": 14.416714804876658,
+ "c": 21.62507220731499,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 3.5073593558208955,
- 4.9213741688230375,
- 9.704341090935115,
- 14.809577772758056,
- 22.214366659137085
+ 3.436536102899666,
+ 4.739131330381026,
+ 9.459210494851341,
+ 14.416714804876658,
+ 21.62507220731499
]
},
{
"tick": 131,
"pose": {
- "x": 9.653967943252193,
- "y": 6.435978628834794,
- "z": 4.826983971626096,
+ "x": 9.418250162523353,
+ "y": 6.278833441682236,
+ "z": 4.709125081261677,
"a": 0,
- "b": 16.089946572086987,
- "c": 24.13491985813048,
+ "b": 15.697083604205591,
+ "c": 23.54562540630839,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 3.725882114537516,
- 5.528873302922237,
- 10.504823213579794,
- 16.089946572086987,
- 24.13491985813048
+ 3.6608577557883777,
+ 5.340349949102061,
+ 10.258857833425182,
+ 15.697083604205591,
+ 23.54562540630839
]
},
{
"tick": 141,
"pose": {
- "x": 10.422189222849552,
- "y": 6.9481261485663675,
- "z": 5.211094611424776,
+ "x": 10.186471442120713,
+ "y": 6.790980961413809,
+ "z": 5.093235721060356,
"a": 0,
- "b": 17.37031537141592,
- "c": 26.05547305712388,
+ "b": 16.977452403534524,
+ "c": 25.466178605301785,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 3.925104483940789,
- 6.1552509435110565,
- 11.309715998597861,
- 17.37031537141592,
- 26.05547305712388
+ 3.866058628115681,
+ 5.9611958750705565,
+ 11.062120690174867,
+ 16.977452403534524,
+ 25.466178605301785
]
},
{
"tick": 151,
"pose": {
- "x": 11.19041050244691,
- "y": 7.4602736682979405,
- "z": 5.595205251223455,
+ "x": 10.954692721718073,
+ "y": 7.303128481145382,
+ "z": 5.477346360859037,
"a": 0,
- "b": 18.650684170744853,
- "c": 27.97602625611728,
+ "b": 18.257821202863454,
+ "c": 27.386731804295184,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 4.104605440791584,
- 6.798029356063859,
- 12.121634254689384,
- 18.650684170744853,
- 27.97602625611728
+ 4.051640420157767,
+ 6.599208738720886,
+ 11.871603565989233,
+ 18.257821202863454,
+ 27.386731804295184
]
},
{
"tick": 161,
"pose": {
- "x": 11.913625063560401,
- "y": 7.942416709040268,
- "z": 5.9568125317802005,
+ "x": 11.69947693080819,
+ "y": 7.79965128720546,
+ "z": 5.849738465404095,
"a": 0,
- "b": 19.85604177260067,
- "c": 29.784062658901004,
+ "b": 19.49912821801365,
+ "c": 29.248692327020475,
"u": 0,
"v": 0,
"w": 0
},
"joints": [
- 4.255414604167775,
- 7.415870566552133,
- 12.894767684082103,
- 19.85604177260067,
- 29.784062658901004
+ 4.212596186050696,
+ 7.2317697089136,
+ 12.664815519082325,
+ 19.49912821801365,
+ 29.248692327020475
]
},
{
"tick": 171,
+ "pose": {
+ "x": 11.999999772197402,
+ "y": 7.999999848131601,
+ "z": 5.999999886098701,
+ "a": 0,
+ "b": 19.999999620329007,
+ "c": 29.99999943049351,
+ "u": 0,
+ "v": 0,
+ "w": 0
+ },
+ "joints": [
+ 4.272248602320978,
+ 7.490380860067354,
+ 12.987780718775609,
+ 19.999999620329007,
+ 29.99999943049351
+ ]
+ },
+ {
+ "tick": 181,
"pose": {
"x": 12,
"y": 8,
@@ -1563,7 +1647,7 @@
"z_offset": -4
},
"final": {
- "tick": 171,
+ "tick": 181,
"pose": {
"x": 12,
"y": 8,
@@ -1584,12 +1668,12 @@
]
},
"preview_hash": "fnv1a64:2595e1a5d1cf33eb",
- "trace_hash": "fnv1a64:f89774102510f899",
+ "trace_hash": "fnv1a64:5b9defc022c0dd0f",
"model_samples": 4
},
- "repeat_hash": "fnv1a64:f89774102510f899",
+ "repeat_hash": "fnv1a64:5b9defc022c0dd0f",
"first_divergent_tick": 11
}
],
- "state_hash": "fnv1a64:d6d3dd87c1c1c0e2"
+ "state_hash": "fnv1a64:977b2134a633e5c1"
}
diff --git a/docs/glb-five-axis-analyzer.md b/docs/glb-five-axis-analyzer.md
new file mode 100644
index 0000000..f8b6e35
--- /dev/null
+++ b/docs/glb-five-axis-analyzer.md
@@ -0,0 +1,52 @@
+# GLB 五轴机床结构分析器
+
+`web/react/src/glb-five-axis-analyzer.ts` 是三维机床管理器共用的 GLB 分析模块。它接收 Three.js `Object3D`(由 `GLTFLoader` 解析得到),不修改模型,输出可序列化的 `linuxcnc.glb-five-axis-analysis/1` 结果。节点名归类、候选排序、机型判定和 TRT 标定推导都放在纯模块 `web/workspace/glb-axis-naming.mjs` 里,便于 Node 单测直接覆盖。
+
+分析内容包括:
+
+- 关节识别词表(按优先级从高到低):
+
+ | 优先级 | 命名示例 | 说明 |
+ | --- | --- | --- |
+ | 0 | `JT_X`、`JT_C` | 精确关节名,高置信度 |
+ | 1 | `X_AXIS`、`AXIS_Z`、`B_AXIS` | 轴字母 + AXIS/JOINT |
+ | 2 | `x_slide`、`y_saddle`、`b_trunnion`、`c_table`、`a_tilt_frame`、`moving_x_table`、`w_tool_direction` | 轴字母 + 主要运动部件词;同轴零件(`y_saddle_casting`)排在主节点之后 |
+ | 3 | `tilt_table`、`rotary_axis`、`work_platter` | 通用部件名,本身不含轴字母,需按机型消歧 |
+ | 4 | `x_linear_rail_guide`、`bolt_x_1`、`a_axis_bearings`、`tilt_bearing_49` | 零件级线索,仅在没有更优候选时才可能被采用 |
+
+ 识别不到轴名(`j1_base`、`portal_column`、`fixture_plate` 等)的节点不会被误判。
+- 通用倾转/回转命名的消歧顺序:模型内嵌声明(`linuxcncMachine.coordinates` 或 URDF 里的 ``)→ 调用方给出的设计机型 → 同模型里明确的轴字母。仍然无法消歧时不自动绑定,并在警告里列出。
+- 同名重复节点(CAD 导出常见)按层级深度取最上层;只有不同命名同分时才判定为歧义。
+- 每个轴的 GLB 路径、父节点、层级深度、局部/世界原点(mm)和线性/旋转类型。
+- 整机节点数、网格数和包围盒。
+- XYZAC、XYZBC 候选,以及对应的 `xyzac-trt-kins`、`xyzbc-trt-kins` 建议;其它五轴组合给出 `generic-five-axis`。
+- 原生 TRT 标定候选(`calibration`):C 轴原点取 C 轴节点的世界坐标,倾转偏置取倾转轴节点与之的差,并按运动学约束把沿倾转轴方向的分量归零(AC 归零 X、BC 归零 Y),被归零的数值记录在 `zeroedOffsetDirection` / `zeroedOffsetValueMm` 里。三维机床管理导入后会把这两个值填进标定页作为初值。
+
+导入 GLB 后,`MachineManagerPanel` 会自动显示分析卡片。选中模型时可导出同一份 JSON,便于离线标定、审查或为其他模型复用。分析器不会因为缺少轴而强行绑定运动学;缺少 `JT_B` 的模型会明确显示 B 轴未识别。
+
+实测绑定结果:
+
+| 模型 | 坐标 | 绑定到的节点 |
+| --- | --- | --- |
+| `glb/m1.glb` | XYZAC | `JT_X`、`JT_Y`、`JT_Z`、`JT_A`、`JT_C` |
+| 内置 AC/BC 示例 | XYZAC / XYZBC | `x_slide`、`y_slide`、`z_slide`、`tilt_table`、`c_table` |
+| `xyzac-trt` / `xyzbc-trt` | XYZAC / XYZBC | `x_slide`、`y_saddle`、`z_slide`、`a_tilt_frame`/`b_trunnion`、`c_table` |
+| `max5-maxkins` | XYZBC | `x_slide`、`y_saddle`、`z_slide`、`b_trunnion`、`c_table` |
+| `bridge-mill-5axiskins` | XYZBC | `x_slide`、`y_slide`、`z_slide`、`b_axis`、`c_axis` |
+| 并联平台与串联机器人 | — | 不是 XYZABC 语义,保持"需手动绑定" |
+
+单测(纯 Node,不需要浏览器):
+
+```bash
+node web/workspace/test_glb_axis_naming.mjs
+```
+
+它覆盖 35 条命名规则用例、10 个真实 GLB 的绑定结果和 TRT 标定推导的数学期望。
+
+用浏览器验证 m1 模型:
+
+```bash
+PLAYWRIGHT_MODULE=/path/to/playwright scripts/test_glb_five_axis_analyzer.mjs
+```
+
+当前 `glb/m1.glb` 的识别结果为 `JT_X/JT_Y/JT_Z/JT_A/JT_C`,建议 `XYZAC` + `xyzac-trt-kins` 原生 TRT RTCP;`JT_B` 未出现在该模型层级中。
diff --git a/docs/glb-machine-manager/00-三维机床管理详细功能文档.html b/docs/glb-machine-manager/00-三维机床管理详细功能文档.html
new file mode 100644
index 0000000..b045095
--- /dev/null
+++ b/docs/glb-machine-manager/00-三维机床管理详细功能文档.html
@@ -0,0 +1,214 @@
+
+
+
+
+
+
+
+三维机床管理|详细功能文档
+
+
+
+
+
+
+
+GLB-MGR-FRS-001 · v1.1 · 2026-09-22
三维机床管理
详细功能文档
本网页是“三维机床管理”详细功能文档。它把 GLB 部件、层级、局部原点、RTCP 标定、物理零点、WASM 关节快照和五轴机床案例放进同一条可追溯工作流。
当前运行 XYZAC / XYZBC TRT模型交付 自包含 GLB运动权威 Worker / WASM
管理器负责装配和校准;Three.js 只做视觉投影。正式程序、Jog、RTCP 和轴值始终由 LinuxCNC WASM 快照驱动。
+
+
+
+
+
+
+
+
+
+用户把多个自包含 GLB 部件装配为可预览、可标定、可保存、可重新打开并可交给 LinuxCNC Web/WASM 运行的机床模型。源文件不被原地修改,草稿只有在校验通过后才进入控制器。
+
2当前可运行机型
XYZAC / XYZBC
分别以 A 或 B 倾转轴配合 C 回转轴,使用现有 TRT 正解、逆解和 RTCP。
+
+
8结构角色
从底座到工件
底座、X、Y、Z、刀具、A/B、C、工件是控制参考角色;新版单 GLB 完成五轴绑定即可应用。
+
+
4运行元数据
URDF + 几何 + 关节 + 编辑
四类 userData 一起保存,源文件哈希和节点路径可追溯。
+
+
+
+
+
+
图 1 · 工作台与单自由度绑定(MGR-18 验收)。一个 GLB 的内部节点完成五轴绑定;页签、轴总览和校验区均可见。
+
MILL700:Z-up 网格、层级树与局部原点。历史截图中的六分量编辑已被单自由度输入替代。
+
+
XYZBC 实时跟随:轴值来自 WASM snapshot。
+
+
四个页签
| 页签 | 主要操作 | 安全限制 |
|---|
| 结构 | 导入、搜索、拖放、删除、显隐、原点 | 不能绕过树生成任意控制链 |
| 运动 | 单自由度、方向、正负、试动、跟随 | 试动不发送 Jog |
| 标定 | C 原点、A/B 偏置、物理零点 | 不能用视觉平移代替 RTCP |
| 文件与恢复 | 名称、说明、GLB 下载、本地库 | 不覆盖源部件 |
+
+
+
用户操作顺序
- 选择设计机型 XYZAC 或 XYZBC。
- 导入自包含 GLB,确认 SHA-256 和树结构。
- 整理父级和显隐,检查源模型的局部原点与零姿态。
- 为每个运动节点绑定一个轴和方向。
- 输入 RTCP 几何、物理零点并试动。
- 校验通过后应用、导出或保存。
- 控制面板重新回零,再加载匹配程序。
+
+
+
+
+以下需求对应 Markdown 功能规格、实现任务和验收证据。
FR-01机型选择。 新建时选择 XYZAC/XYZBC;机型写入草稿,切换时清除不兼容倾转绑定并可撤销。
FR-02GLB 导入。 只接受自包含 GLB,记录文件名、大小、SHA-256 和源路径;外部资源拒绝。
FR-03树与装配。 按 Object3D.children 显示,支持根部件和内部节点拖放,拒绝自指、循环和跨文件内部挂接。
FR-04局部变换。 查看相对父级的零姿态;现行版只编辑绑定轴的单个运动量,固定节点锁定。源 GLB 不修改。
FR-05坐标辅助。 原点轴挂在节点局部 (0,0,0),随父链继承,不冒充世界轴。
FR-06单自由度。 每节点最多一个 XYZABC 轴、每轴最多一个节点;方向在父坐标系定义。
FR-07试动与跟随。 试动只改草稿;实时跟随锁定输入,轴值只来自完整 WASM snapshot。
FR-08RTCP 标定。 固定使用 C 原点 P、倾转偏置 D、T=P+D、U=-D、work=-P 的映射。
FR-09物理零点。 模型运动量为 sign × (WASM实际值 - physicalZero),不修改控制器回零。
FR-10预览渲染。 Z-up、视图预设、网格、PBR 补光、受限 DPR 和 context lost 恢复。
FR-11校验应用。 先校验结构、角色、轴、标定、URDF 和 Schema,再通过配置事务交给 Worker;失败保留旧模型。
FR-12保存恢复。 导出完整 GLB,同时下载并写入 IndexedDB;打开时重建源树再应用覆盖,避免双重偏移。
FR-13机型与程序。 跨 AC/BC 加载目标默认程序;错轴程序在加载、运行、单步和继续入口拒绝。
FR-14撤销取消。 草稿历史至少 30 步;选择器取消不关闭父对话框;打开和应用都是可回滚事务。
+
坐标与单位
UI 平移、局部原点、C 原点和偏置使用 mm;UI 旋转和物理旋转轴零点使用 deg;GLB 节点位置使用 m、旋转使用四元数;JSON 的 zero.positionMm 使用 mm;URDF 使用 m/rad。
+
+
安全门控
完整 GLB 保存/应用要求五轴绑定完整、输入和标定有效;实时快照缺失只暂停模型跟随,不改动正在运行的控制器。
+
+
+
+
+FUNCTIONAL SPECIFICATION
逐项功能说明与验收条件
以下描述按照现行管理器与 MGR-17~22 契约整理。每项给出操作、规则和可观察结果;展示能力与控制器动作分别说明。
+FR-01 机型与设计文档
入口为机床预览区“三维机床管理”。设计机型独立于控制面板当前机型;首次新建可沿用面板默认值。
操作与输入
- 选择 XYZAC 或 XYZBC,查看目标轴集合和控制面板当前类型。
- XYZAC 显示 X/Y/Z/A/C,XYZBC 显示 X/Y/Z/B/C;另一倾转轴不可选。
- 更换设计机型保留模型及通用轴;清除不兼容 A/B 绑定并重新检查标定。
规则与异常
- 设计类型进入草稿撤销历史;切换控制面板不能无提示覆盖草稿。
- 完整 GLB 打开恢复文件机型;其它案例只展示源码资料,未适配选项禁用。
验收:从 AC 草稿切换 BC,原 A 绑定被清除且不自动改名为 B;撤销能恢复原配置。
+
+FR-02 自包含 GLB 导入与资产管理
普通几何文件使用“导入 GLB 部件”;完整设计文件使用“打开已保存机床”。当前单文件上限为 64 MiB,支持仓库中约 34 MiB 的 OP30 机床资产;设备显存和节点数量仍可能限制预览性能。
操作与输入
- 解析 GLB 并检查资源自包含性;记录文件名、大小和 SHA-256。
- 生成部件 ID 和稳定源路径,显示原有 Object3D 层级。
- 多文件作为多个独立部件;同名文件不应覆盖已有 ID。
规则与异常
- 损坏文件、外部依赖或超限文件显示具体错误,保留旧草稿。
- 源字节、几何、材质和节点原始姿态不原地改写。
- 删除的是整个导入模型;仍被撤销历史引用的资源保留,失去全部引用后释放。
验收:成功导入单个 MILL700 和多个部件;错误文件不清空当前树;删除后可撤销。
+
+FR-03 层级、搜索与父级关系
结构页展示原始树和当前装配覆盖;节点身份使用 ID/路径,名称仅用于显示。
操作与输入
- 输入节点名称搜索并展开结果;选择树节点查看装配局部坐标和世界位置。
- 根部件可选择其它部件为装配父级;同一 GLB 内部节点支持拖放换父级。
- 内部节点拖放应维持世界姿态,再计算新父级下的局部变换;根部件父级选择按部件装配关系生效。
规则与异常
- 禁止把节点挂到自己或后代;拒绝缺失父级和跨 GLB 的内部节点重挂。
- 同名节点必须可以独立选中,不能按名称猜测绑定。
- 源路径保持稳定;覆盖记录与原始层级分开存储。
验收:深层树搜索能定位;拖放后子节点随父级,错误拖放回滚;源 GLB 哈希不变。
+
+FR-04 局部原点、零姿态与单轴编辑
局部原点是节点自身 (0,0,0);装配位置是该原点在父坐标系中的位置;世界位置包含全部父链。
操作与输入
- 展开“零姿态与当前坐标”,读取装配位置 mm、姿态 deg 和当前世界原点。
- 先绑定唯一自由度,再通过单个“位移/转角”字段验证节点运动。
- 固定节点不能输入独立运动量,但随父节点正常运动。
规则与异常
- 当前版不提供绕过单自由度约束的六分量自由编辑或 TransformControls。
- 历史文件中的局部装配覆盖可以恢复;试动值不能保存成新的设计零姿态。
- 模型坐标由文件提供,Z-up 通过相机和辅助坐标呈现,不隐式修改源节点。
验收:固定节点字段禁用;绑定 Z 后只能输入该轴量;子节点继承且父节点/兄弟节点不被误改。
+
+FR-05 局部坐标轴、显示与定位
结构页提供模型显隐和坐标原点开关;预览工具包括整机适配、定位选中、正视和俯视。
操作与输入
- 选择节点,在其自身原点查看 X 红、Y 绿、Z 蓝坐标轴。
- 切换“显示导入模型”“显示坐标原点”以观察遮挡和父子关系。
- 左键旋转相机、滚轮缩放、右键平移;使用定位选中查看小部件。
规则与异常
- 辅助轴是节点的子对象,局部位置为零并继承完整父链;不能只复制一次世界坐标。
- 辅助对象属于预览,不作为机床几何导出。
- 相机操作不改变模型设计、试动值或控制器状态。
验收:父关节旋转后辅助轴一起旋转;关闭辅助再打开不重建全部 WebGL 资源。
+
+FR-06 唯一自由度与轴向映射
运动节点配置 axis、direction、sign;机型完整性由五个唯一绑定轴检查。
操作与输入
- 选择 X/Y/Z 平移或 A/B/C 旋转,固定部件选择“固定(无自由度)”。
- 指定父坐标系方向 x/y/z 以及正向 +1 或反向 −1。
- 在绑定总览检查五轴是否齐全,通过轴按钮定位对应节点。
规则与异常
- 同一个轴不能绑定两个节点;每个节点最多一个自由度。
- direction 属于父坐标系;例如父级已旋转时不能把它误认为世界 X。
- 单 GLB 的多个内部节点也能完成五轴绑定;八个独立文件不再是前提。
验收:重复绑定被校验阻止;正负号可反转视觉运动;固定节点跟随父链但不响应其它轴。
+
+FR-07 诊断试动与实时关节跟随
手动验证使用相对物理零点的试动量,不向 LinuxCNC 发送运动命令。
操作与输入
- 输入轴值或选择 0.1/1/10 步长进行正负步进;支持本轴归零和全部复位。
- 检查局部/世界诊断值,先单轴再复合运动,区分自身运动和父链继承。
- 控制面板机型匹配且快照有效时开启实时跟随,查看 WASM tick。
规则与异常
- 跟随时配置编辑、手动关节输入和应用锁定;取消后回到手动验证状态。
- 实时数据暂不可用时保持最后姿态,允许用户取消跟随;缺少任意轴不做部分更新。
- 每次从 zero 求绝对变换,不做累计旋转,临时姿态不进入保存文件。
验收:同一关节值重复更新 100 次不漂移;快照缺失不出现部分轴跳动;试动不改变控制器读数。
+
+FR-08 TRT 几何与旋转中心标定
标定长度均为 mm。C 原点 P 对应 rot-point;倾转偏置 D 对应 offset。
操作与输入
- 填写 C 轴原点 P=(Px,Py,Pz) 和 A/B 到 C 偏置 D=(Dx,Dy,Dz)。
- 确认 AC 的 Dx=0、BC 的 Dy=0;非零值超过实现容差时拒绝应用。
- 生成控制参考链:倾转原点 T=P+D、C 关节相对原点 U=−D、工件固定原点=−P。
规则与异常
- 标定需经现有配置重载进入 WASM;移动视觉网格不能代替运动学参数。
- 几何元数据写入运行场景和底座,完整文件保留标定。
- 现有 AC/BC TRT 参数不能直接套用于双摆头、MAX5、机器人和并联平台。
验收:保存/打开参数等价;AC 非法 X 偏置、BC 非法 Y 偏置能定位错误;有效参数进入模型和控制框架。
+
+FR-09 物理零点与控制器坐标区别
physicalZero 表示模型 zero 对应的实际关节读数;线性单位 mm,角度单位 deg。
操作与输入
- 分别录入当前机型 X/Y/Z/A或B/C 的零姿态读数。
- 实时跟随使用 d=sign×(q−q0);例如 q=25 mm、q0=5 mm、sign=−1,模型运动量为 −20 mm。
- 手动试动输入已是相对零位量,不再把 q0 重复扣除。
规则与异常
- 不修改 INI 回零位置、G54、G92 或刀补;这些各有独立语义。
- 所有值必须有限;界面只展示当前机型五轴,序列化 physicalZero 保留 xyzabc 六个键。
验收:实际读数等于 q0 时回到模型零姿态;改变 q0 只改变可视化标定,不伪造控制器实际位置。
+
+FR-10 渲染、照明与资源生命周期
管理器独立 Three.js 预览采用 Z-up,相机朝向与模型原始轴向分开处理。
操作与输入
- 通过整机适配查看完整模型,用正视/俯视核对安装方向。
- PMREM 环境、曝光和多向补光保证金属材质可辨认。
- 按设备最大尺寸和 4096² 像素预算限制 drawing buffer,处理高 DPR 与窄屏。
规则与异常
- 不覆盖导入材质颜色、金属度和纹理来改善照明。
- 零尺寸或 context lost 暂停绘制;恢复或重开不应丢失设计草稿。
- 删除/关闭/撤销的资源清理遵守历史引用,不能释放仍需恢复的模型。
验收:高金属度模型可辨认;矮视口、高 DPI 下无连续 WebGL 错误;相机不改变 WASM tick。
+
+FR-11 配置校验与应用到控制器
应用要求关机、程序空闲、无待处理事务、未实时跟随、输入有效且目标五轴绑定完整。
操作与输入
- 先检查 ID、父级、无环、轴集合、零姿态、标定和元数据版本。
- 生成运行场景深复制、URDF 控制参考链及视觉关节映射,避免节点被驱动两次。
- 通过既有 LOAD_WORKSPACE / PROGRAM_LOAD 流程更新机型、参数和程序;完成后重新回零。
规则与异常
- 新版按五轴映射校验;旧八部件格式保留严格角色链兼容路径。
- 校验失败不替换已应用模型;配置加载失败显示诊断并由已有恢复流程处理。
- 应用不等于磁盘保存;应用后仍应导出 GLB 才能跨会话携带设计。
验收:单 GLB 完整五轴可应用;未绑定五轴不可应用;跨机型后程序与轴集合匹配。
+
+FR-12 完整保存、下载与重新打开
保存 GLB 要求绑定完整;导出 JSON 是辅助参数文件,不能替代包含几何的完整 GLB。
操作与输入
- 在“文件与恢复”填写模型名称(最多 80 字符)和设计说明(最多 2000 字符)。
- 点击“保存机床模型 GLB”,先生成并由标准导入器校验,再尝试写入 IndexedDB 并发起下载。
- 在本地库查看时间、机型、大小和哈希,选择打开配置、下载 GLB 或删除副本。
- 再次打开先重建原始多部件拓扑,再施加覆盖;恢复机型、轴向、标定和显隐。
规则与异常
- 每次保存形成独立版本;网页只知道发起下载,不知道最终磁盘路径或是否取消下载。
- 本地库存储仅限同浏览器/同源地址,清理站点数据会删除副本;存储失败仍可下载。
- 不完整草稿、撤销历史、相机视角和试动值不跨刷新自动保存。
- 旧 GLB 缺编辑元数据时恢复可用配置,不能声称精确恢复丢失的多文件分组。
验收:保存→刷新→库打开→再次保存后几何和配置等价;库失败仍有下载;删除副本不删除当前草稿或磁盘文件。
+
+FR-13 机型、程序和运行一致性
程序字母与设计/运行机型保持一致;Worker 对入口再次进行轴兼容检查。
操作与输入
- 同机型应用保留兼容程序;跨 AC/BC 先取得目标机型默认程序再提交切换。
- 确认程序路径、源文本、预览和控制面板机型一致。
- 加载、运行、单步和继续都检查错误倾转轴;不以 A/B 字符串替换完成适配。
规则与异常
- 注释和参数名称中的 A/B 不等同轴指令,完整语法仍由 LinuxCNC 解释器处理。
- 不能从“字母都是 XYZBC”推断 MAX5 与 BC TRT 运动学一致。
验收:AC↔BC 双向切换通过;同机型保留用户兼容程序;错轴文件在 Worker 拒绝。
+
+FR-14 草稿历史、取消与失败恢复
撤销历史是本会话配置快照,最多保留 30 步,源资产按引用共享。
操作与输入
- 导入、类型切换、绑定和文档打开等设计修改进入历史,按撤销/重做恢复。
- 关闭管理器保留本会话草稿;刷新只恢复显式保存的完整版本。
- 文件选择器取消或窗口回焦时保持父对话框、焦点和配置。
规则与异常
- 新建或打开文件不能偷偷应用到正在运行的控制面板。
- 无效输入保持原有效模型姿态,并可定位第一个错误字段。
- 忙碌或实时跟随时禁用冲突配置动作;相机观察仍独立于控制状态。
验收:新建后撤销找回原草稿;取消三类文件选择器均不关闭父对话框;导入失败没有资源残留。
+
+
+MACHINE STRUCTURES
先看旋转轴安装在哪里
典型五轴铣削通常由三平移轴与两姿态轴构成。相同 XYZBC 字母可以对应不同机械链,轴在刀具侧还是工件侧、父子顺序和旋转中心都决定运动学。参考 LinuxCNC 五轴运动学说明。
+
双转台 / 台台型
两个旋转轴位于工件侧。案例:AC/BC TRT 与 AB 双转台。当前可运行 AC/BC;AB 需管理器适配。
+
+
双摆头 / 头头型
两个姿态轴位于刀具侧,工件无需随姿态一起旋转。案例:Bridge Mill / 5axiskins;含 W 与重复 Y 的配置需独立适配。
+
+
摆头 + 转台 / 头台型
一个姿态轴在刀具侧、另一个在工件侧。案例:MAX5,B 倾转头配合 C 转台;不能用 BC TRT 直接替代。
+图示只表示姿态轴在刀具/工件两侧的分布,不表示机床尺寸、具体轴正向或可制造的结构。
| 区分维度 | 如何判断 | 本项目案例 |
|---|
| 运动学结构 | 双转台、双摆头、头台、串联、并联 | 按各自求解器和参数建模 |
| 床身外形 | 立式、卧式、桥式/龙门 | AC/BC 桁架 GLB 是同一 TRT 求解器的外形变体 |
| 加工方式 | 3+2 定位与连续五轴是程序/插补方式 | 不能只看五个字母就确认联动与 RTCP |
| 车铣复合 | 还涉及主轴/C 轴切换和复合工艺 | 当前目录没有完整已接通案例;XYZCW 基线不能作为车铣能力证明 |
| 轴数与关节数 | 例如双 Y 多一个驱动关节但没有增加独立加工自由度 | XYZYA 是五关节基线,不是通常的三平移+两旋转五轴铣床 |
+
范围:下文覆盖仓库案例目录的全部 13 项,另补齐三类常见姿态结构的对应关系;不声称穷尽所有工业机床结构。六自由度及映射基线均单独标明。
+
+04 · FIVE-AXIS CATALOG
每种类型的五轴机床案例
five-axis-machine-catalog.json案例与 LinuxCNC 配置、运动学源码和 G 代码一致。绿色为当前 Web/WASM 已接通;原生 XYZAC/XYZBC 使用 TRT 运行链,其余案例使用通用 WASM 运动学桥接。Hexapod/PUMA 等六自由度案例按各自关节结构运行,不伪装成 TRT 铣床。
显示 13 / 13 个案例
+
+
XYZAC · 转台倾转
当前支持- 结构
- 工件转台 A/C
- 坐标
- XYZAC · 5 关节
- 运动学
- xyzac-trt-kins
- RTCP
- native
A 绕 X 倾转、C 绕 Z 回转;配置 C 原点、A 到 C 偏置、刀长和物理零点。
案例:AC 叶轮 / 小船仿真
机械链:刀具参考链:底座→X→Y→Z→刀具;工件参考链:底座→A→C→工件。
配置要点:C 轴旋转点、倾转轴到 C 轴偏置、工具偏置、物理零点。
验证路径:先做 A、C 单轴验证,再运行 boat-xyzac 或 impeller-7bl-xyzac,检查程序点、刀尖和工件附着路径。
参数名称来自案例目录,具体 HAL/INI 字段及单位以该案例源码为准。示意图非比例,未适配案例不可直接应用到当前管理器。
展开配置、源码与程序路径
+
+
+
XYZBC · 转台倾转
当前支持- 结构
- 工件转台 B/C
- 坐标
- XYZBC · 5 关节
- 运动学
- xyzbc-trt-kins
- RTCP
- native
B 绕 Y 倾转、C 绕 Z 回转;与 AC 共用流程,但倾转轴、程序和标定严格隔离。
案例:BC 小船 / 五轴测试程序
机械链:刀具参考链保持 XYZ;工件参考链为底座→B→C→工件。
配置要点:C 轴旋转点、倾转轴到 C 轴偏置、工具偏置、物理零点。
验证路径:先做 B、C 单轴验证,加载 boat-xyzbc 或 test-xyzbc;切入 BC 时检查程序中的倾转轴为 B。
参数名称来自案例目录,具体 HAL/INI 字段及单位以该案例源码为准。示意图非比例,未适配案例不可直接应用到当前管理器。
展开配置、源码与程序路径
+
+
+
XYZAB · 双转台
需适配- 结构
- 双转台 A/B
- 坐标
- XYZAB · 5 关节
- 运动学
- xyzab_tdr_kins
- RTCP
- LinuxCNC 原生
两个转轴叠加在工件侧,配置旋转点、X/Z 偏置和 TCP。
案例:双转台 TCP 演示
机械链:以本地 TDR Vismach/HAL 的 A/B 轴序、转点和偏置为准。
配置要点:旋转点 X、旋转点 Y、旋转点 Z、X 偏置、Z 偏置、工具偏置、物理零点。
验证路径:原生运行 xyzab-tdr-demo.ngc;Web 接入前增加 XYZAB 能力、专用运动学参数和正逆解对照。
参数名称来自案例目录,具体 HAL/INI 字段及单位以该案例源码为准。示意图非比例,未适配案例不可直接应用到当前管理器。
展开配置、源码与程序路径
+
+
+
Bridge Mill · 5axiskins
需适配- 结构
- 桥式 + 姿态头
- 坐标
- XYZBCWY · 7 关节
- 运动学
- 5axiskins
- 特点
- 重复轴 / W
验证多关节映射、双 Y 和 W 刀具方向;需保存重复轴关系。
案例:桥式双摆头与双 Y
机械链:B/C 位于刀具姿态链;W 是刀具方向坐标,源码将其纳入 XYZ 运动;示例还有重复 Y。
配置要点:关节/坐标映射、重复轴映射、刀具方向坐标、工具偏置、物理零点。
验证路径:核对 7 关节映射和 pivot_length;运行 5axisgui.ngc 对照姿态。不能把 W 误画成必需的独立物理执行器。
参数名称来自案例目录,具体 HAL/INI 字段及单位以该案例源码为准。示意图非比例,未适配案例不可直接应用到当前管理器。
展开配置、源码与程序路径
+
+
+
MAX5 · maxkins
需适配- 结构
- 转台 + 摆头
- 坐标
- XYZBC · 6 关节
- 运动学
- maxkins
- 用途
- 非恒等五轴
maxkins 与 trivkins 对照案例,适合验证旋转轴顺序和工具方向。
案例:桌面头台式五轴
机械链:B 倾转主轴头;C 在工件转台。源码提示 B 轴方向与通常约定相反。
配置要点:旋转轴顺序、旋转中心、UVW 变换、工具偏置、物理零点。
验证路径:对照 max5kins.ini / max5triv.ini,检查 pivot_length、UVW 项和 B 正负;不能重用 TRT 几何参数。
参数名称来自案例目录,具体 HAL/INI 字段及单位以该案例源码为准。示意图非比例,未适配案例不可直接应用到当前管理器。
展开配置、源码与程序路径
+
+
+
Pentapod · pentakins
需适配- 结构
- 五支链并联
- 坐标
- XYZAB · 5 关节
- 运动学
- pentakins
- 参数
- base / effector
使用支链几何和迭代求解,不能直接套普通树形 XYZABC。
案例:五支链 TCP 定位
机械链:平台姿态由五条支链长度共同决定,支链关节值并不是 X/Y/Z/A/B 逐项对应。
配置要点:基座连接点、末端几何、工具偏置、收敛条件、物理零点。
验证路径:用 kinematics.hal / README 中的几何起步,对照正逆解、收敛失败和支链极限;本地条目未提供独立 NGC。
参数名称来自案例目录,具体 HAL/INI 字段及单位以该案例源码为准。示意图非比例,未适配案例不可直接应用到当前管理器。
展开配置、源码与程序路径
+
+
+
Hexapod · genhexkins
需适配- 结构
- 六支链并联
- 坐标
- XYZABC · 6 关节
- 运动学
- genhexkins
- 参数
- 基座/平台连接点
六自由度平台可承载五轴姿态任务,需要独立支链、螺杆和迭代适配。
案例:六支链 Stewart 平台
机械链:基座和动平台各有连接点;六支链长度约束 XYZABC 六自由度。
配置要点:基座连接点、平台连接点、主轴偏置、螺杆导程、迭代限值、物理零点。
验证路径:检查基座/平台点、主轴偏置、迭代参数;用原生配置验证收敛与支链运动,不套串联树求解。
参数名称来自案例目录,具体 HAL/INI 字段及单位以该案例源码为准。示意图非比例,未适配案例不可直接应用到当前管理器。
展开配置、源码与程序路径
+
+
+
PUMA · pumakins
需适配- 结构
- 六关节串联
- 坐标
- XYZABC · 6 关节
- 运动学
- pumakins
- 边界
- 不是 TRT 铣床
适合五轴刀具姿态和 TCP 视觉验证,需要连杆、限位和机器人适配器。
案例:PUMA 立方体 TCP 轨迹
机械链:六个旋转关节形成串联连杆;工作空间 XYZABC 与关节角不是相同数据。
配置要点:A2、A3、D3、D4、工具偏置、物理零点。
验证路径:使用 puma_cube.ini 和 puma_cube.ngc,检查连杆尺寸、可达性及运动学分支;需关节反馈适配。
参数名称来自案例目录,具体 HAL/INI 字段及单位以该案例源码为准。示意图非比例,未适配案例不可直接应用到当前管理器。
展开配置、源码与程序路径
+
+
+
PUMA 560 · genserkins
需适配- 结构
- DH 串联
- 坐标
- XYZABC · 6 关节
- 运动学
- genserkins
- 参数
- DH、限位、刀长
用 DH 参数描述关节链,不能直接套用 AC/BC TRT 标定。
案例:PUMA 560 的 DH 配置
机械链:每一节连杆由 DH 参数描述,工具和基座坐标变换独立。
配置要点:DH 参数、关节限位、工具偏置、物理零点。
验证路径:对照 puma560_dh.hal,验证六关节与末端姿态;关节限位、TCP 和奇异位形不能从 TRT 参数推断。
参数名称来自案例目录,具体 HAL/INI 字段及单位以该案例源码为准。示意图非比例,未适配案例不可直接应用到当前管理器。
展开配置、源码与程序路径
+
+
+
XYZAB · trivkins
映射基线- 结构
- 直角坐标 + 两旋转
- 坐标
- XYZAB · 5 关节
- 运动学
- trivkins
- RTCP
- 无非线性 RTCP
验证 GLB 树、轴序、重复轴和显示映射,不提供 TRT 求解。
案例:五轴字母映射回归
机械链:坐标与关节按恒等映射配置;没有用于旋转补偿的非线性 RTCP 求解。
配置要点:坐标顺序、重复轴映射、物理零点。
验证路径:对比 qtdragon / woodpecker 的轴序和 GUI;显示正确不等于能执行 AC/BC TRT 补偿。
参数名称来自案例目录,具体 HAL/INI 字段及单位以该案例源码为准。示意图非比例,未适配案例不可直接应用到当前管理器。
展开配置、源码与程序路径
+
+
+
XYZCW · trivkins
映射基线- 结构
- C 回转 + W 方向
- 坐标
- XYZCW · 5 关节
- 运动学
- trivkins
- 用途
- 非标准字母序
验证 C/W 显示、刀具方向轴和物理零点字段。
案例:C/W 非标准字母映射
机械链:C 为旋转字母,W 为附加线性字母;五个坐标不等于典型五轴铣削姿态结构。
配置要点:坐标顺序、刀具方向坐标、物理零点。
验证路径:检查 gmoccapy_XYZCW.ini 的轴序和单位;只能作为坐标与关节映射测试案例。
参数名称来自案例目录,具体 HAL/INI 字段及单位以该案例源码为准。示意图非比例,未适配案例不可直接应用到当前管理器。
展开配置、源码与程序路径
+
+
+
XYZYA · trivkins
映射基线- 结构
- 双 Y + 旋转 A
- 坐标
- XYZYA · 5 关节
- 运动学
- trivkins
- 用途
- 重复/非标准轴序
验证重复 Y、轴序和界面映射,不能声明具备非线性 RTCP。
案例:双 Y 龙门映射回归
机械链:同一 Y 轴由两个关节描述,另有旋转 A;不能从关节数量推断加工自由度。
配置要点:坐标顺序、重复轴映射、物理零点。
验证路径:检查 qtdragon_multi_joint 与 woodpecker 对重复 Y 的配置、显示与关节区分。
参数名称来自案例目录,具体 HAL/INI 字段及单位以该案例源码为准。示意图非比例,未适配案例不可直接应用到当前管理器。
展开配置、源码与程序路径
+
+
+
SCORBOT · scorbot-kins
需适配- 结构
- 五关节串联
- 坐标
- XYZAB · 5 关节
- 运动学
- scorbot-kins
- 边界
- 机器人案例
验证机器人装配边界和关节限位,不按 TRT 生成 C 原点或 A/B 偏置。
案例:五关节机器人
机械链:五关节串联结构;XYZAB 是任务坐标声明,不表示常规五轴铣床机械链。
配置要点:机器人连杆参数、关节限位、工具偏置、物理零点。
验证路径:对照 scorbot.ini 和源码连杆参数验证末端姿态、限位与可达范围;缺少完整 Web 接入验收。
参数名称来自案例目录,具体 HAL/INI 字段及单位以该案例源码为准。示意图非比例,未适配案例不可直接应用到当前管理器。
展开配置、源码与程序路径
+
+
没有匹配案例。可尝试输入 XYZAC、XYZBC、桥式或 trivkins。
+
+
+
+WORKED EXAMPLES
从装配到恢复的完整案例
案例 A:八个示例部件 → AC TRT
- 新建 XYZAC,导入床身、XYZ、刀具、A、C、工件部件。
- 按机械父链放置固定与运动部件,绑定 X/Y/Z/A/C;只有真实运动节点绑定自由度。
- 基于模型实测原点录入 P、D 和 q0,AC 的 Dx 必须为 0;不要照抄其它机床数值。
- 分别试动 X、Y、Z、A、C,确认下级随动和正负方向;临时量复位。
- 保存 GLB,关机且空闲时应用,重新回零并运行匹配 AC 程序。
完整 AC 演示 GLB · AC 桁架外观变体
案例 B:单个 MILL700 → 五轴模型
- 导入本地 MILL700-U_1.glb,在树内定位各滑台和旋转节点。
- 给主轴/工作台相应节点绑定五轴,固定外壳保持无自由度;同名节点用稳定路径区分。
- 检查父链和节点原点。零姿态来源于模型,不用试动值修正建模错误。
- 若改作 BC 演示,先切换设计类型,再重新绑定 B,核实轴向和结构是否匹配 BC TRT。
- 完整保存,再从文件打开,核对 5/5 绑定与标定是否一致。
MILL700 的 AC/BC 配置是仓库软件验收场景;不代表该型号实机可以不改机械结构直接互换 AC/BC。
完整 BC 演示 GLB · BC 桁架外观变体
图 4 · 文件与恢复(MGR-21 验收):名称/说明、下载位置解释、本浏览器版本库和打开/下载/删除动作。| 动作 | 影响当前草稿 | 影响控制器 | 刷新后恢复 |
|---|
| 试动 / 改相机 | 仅临时展示 | 无 | 不保存 |
| 绑定 / 标定 / 树修改 | 设计快照,可撤销 | 应用前无 | 未保存时不可恢复 |
| 应用机床 | 标记已应用 | 更新运行配置,需重新回零 | 不等于保存设计文件 |
| 保存 GLB | 记录保存状态 | 不自动应用 | 磁盘文件或库副本可打开 |
| 导出 JSON | 参数辅助输出 | 无 | 不含完整几何 |
| 删除本地库副本 | 不删除当前草稿 | 无 | 只删除对应库记录 |
05 · DATA & SAFETY
数据、坐标与安全边界
schema-driven
+
四类元数据
| 字段 | 职责 |
|---|
linuxcncMachine | 现有严格 URDF 与运行控制框架 |
linuxcncMachineGeometry | C 原点、倾转偏置、物理零点 |
linuxcncJointModel | 父链、零姿态、轴映射、方向和正负 |
linuxcncMachineEditor | 名称、说明、源部件和编辑覆盖 |
+
+
+
视觉运动映射
q = WASM 实际轴值
+q0 = physicalZero
+s = +1 或 -1
+e = 父坐标系中的 x / y / z
+
+d = s × (q - q0)
+平移:p[m] = zero.positionMm / 1000 + e × d / 1000
+旋转:Q = Quaternion(e, d × π/180) × zero.quaternion
+
+子节点继承父级矩阵;每帧从零姿态绝对计算。
+
+
应用前门控
- 机型为 XYZAC 或 XYZBC。
- 新版要求五轴唯一绑定、节点树无环;旧八部件格式按原角色链校验。
- 每轴最多一个节点,每节点最多一个自由度。
- 标定、零点、四元数和资源数据有限。
- 目标程序和 Worker 轴序一致。
+
+
错误后的保证
- 外部资源 GLB:拒绝导入,旧草稿不变。
- 非法输入:保留上一个有效值,不驱动模型。
- 缺少 snapshot 轴值:保持上一帧姿态。
- 应用或存储失败:不替换已应用模型。
- WebGL context lost:暂停渲染,控制器不变。
+
+
+
+
+错误与恢复矩阵
| 触发条件 | 界面响应 | 可恢复操作 |
|---|
| GLB 超过 64 MiB、损坏或外部资源 | 报告文件错误,保留原草稿 | 重新导出自包含轻量 GLB,再导入 |
| 父级循环 / 跨文件内部节点挂接 | 拒绝变更,不改源文件 | 选择有效父级,或把部件作为独立根装配 |
| 数值为空、非有限 / 非法偏置 | 保留原有效姿态,定位字段,阻止保存/应用 | 修正输入后重新校验 |
| 关节绑定缺失 / 重复 | 列出配置阻塞原因 | 到绑定总览定位并补齐五轴 |
| 机型不匹配 / 实时数据缺失 | 无法开始跟随或保持最后姿态 | 退出跟随;核对机型与数据连接 |
| 本地库配额或写入失败 | 提示库副本未保存,继续发起下载 | 从磁盘 GLB 打开,清理库后另存 |
| 程序含错误机型轴字 | Worker 拒绝加载/执行入口 | 加载目标机型程序,不替换字母伪造适配 |
| 文件选择取消 | 保留管理器、焦点和原配置 | 继续编辑或再次选择文件 |
字段字典与转换边界
| 字段 | 类型 / 单位 | 校验和用途 |
|---|
| schema / coordinates | 版本字符串 / XYZAC 或 XYZBC | 未知版本不得猜测解析 |
| controller.axisOrder | 五个轴名 | 与机型顺序一致;通过轴名映射视觉关节 |
| axisMap | 轴名 → 稳定节点 ID | 必须与节点 joint 一致,不依赖显示名称 |
| nodes[].id / parentId | 字符串 / 字符串或 null | 唯一 ID、有效父级、无环;当前模型 1~12000 条记录 |
| nodes[].zero.positionMm | 三个有限数,mm | 相对父级零姿态;传给 Three.js 时除以 1000 |
| nodes[].zero.quaternion | [x,y,z,w] | 归一化,顺序不可误用为 wxyz |
| nodes[].zero.scale | 三个正数 | 保留源比例,不是显示单位切换 |
| joint.axis/type/unit | XYZABC / prismatic或revolute / mm或deg | 机型、轴类型和单位一致 |
| joint.direction/frame/sign/dof | x/y/z;parent;±1;1 | 在父坐标系中施加单自由度运动 |
| calibration.cOriginMm / tiltOffsetMm | 两个三分量向量,mm | AC 不接受 X 偏置,BC 不接受 Y 偏置 |
| calibration.physicalZero | xyzabc 六字段;mm/deg | UI 编辑当前机型五轴,运行 q−q0 |
| source.file/hash/path | 字符串 | 追溯源文件和稳定源节点路径 |
| editor.info.name/description | 80 / 2000 字符上限 | 人可读文档描述,不作轴绑定身份 |
关节 Schema:linuxcnc.joint-model/1;完整样例:XYZAC JSON / XYZBC JSON。
A/B/C 字母常用于绕 X/Y/Z 的旋转,但可视化配置的 direction 指父坐标方向;具体符号以原生求解器、GLB 零姿态和实际测试为准。GLB 节点旋转存四元数,不能把 UI 欧拉角直接当作四元数数组。
+
功能验收清单
以下为验收项目清单,历史通过情况以证据文档为准;本次仅验证 HTML 文档。
- AC/BC 多部件装配、应用和实时运动
- 树搜索、拖放、删除和循环拒绝
- 局部原点、父子继承和 Z-up 预览
- 单自由度、方向、正负和物理零点
- RTCP 参数保存与重新打开
- 完整 GLB 导出与 IndexedDB 恢复
- 跨机型默认程序和错轴门控
- 取消选择器和失败回滚
+
+
案例状态含义
当前支持 已有真实 Worker/WASM 正解、逆解和 RTCP 证据。
需适配 有 LinuxCNC 本地案例,需要新的运动学适配器。
映射基线 用于树、轴序、重复轴或界面校验,不提供非线性 RTCP。
新机型必须先加入 five-axis-machine-catalog.json,记录 coordinates、joints、kinematics、案例路径、参数和能力状态。
+工程与非功能验收
- 坐标:使用父级旋转、非单位零姿态四元数、正负方向和非零 physicalZero 测试,比较 local/world;不能只检查画面非空。
- 保存往返:相同配置的几何和关节映射应等价;保存时间和版本 ID 可以变化,不要求整个 GLB 字节相同。
- 资源:重复导入、删除、撤销、重开后正确回收无引用资产,保留历史仍引用的场景。
- 响应式:桌面、窄屏、矮屏可访问预览、页签和底部操作;错误字段可定位。
- 兼容性:旧 GLB/URDF 仍可读取;缺失编辑元数据应说明恢复精度,不静默制造丢失信息。
- 控制边界:相机与手动模型试动不修改 Worker;正式运动以实际关节快照为源。
- 项目证据:TypeScript、构建、数学验证与浏览器报告按影响范围执行,具体历史结果见验收证据,不用截图替代数据断言。
本版本范围以外
云端共享、多人协作、角色审批、碰撞与刚体动力学、切削材料去除、真实硬件控制、任意视觉树自动生成逆解,以及尚未接通案例的完整机床运行,都不属于当前管理器交付范围。
关联文档与验证命令
Markdown 功能规格 · 任务矩阵 · 验收证据 · 单自由度 JSON · 保存与重新打开 · 五轴案例目录
node scripts/validate_five_axis_catalog.mjs
+node scripts/test_machine_manager_browser.mjs
+node scripts/test_machine_joint_model.mjs
+
+
+
+
+
+
+
+
diff --git a/docs/glb-machine-manager/00-三维机床管理详细功能文档.md b/docs/glb-machine-manager/00-三维机床管理详细功能文档.md
new file mode 100644
index 0000000..5e57c84
--- /dev/null
+++ b/docs/glb-machine-manager/00-三维机床管理详细功能文档.md
@@ -0,0 +1,326 @@
+# 三维机床管理详细功能文档
+
+> 文档编号:GLB-MGR-FRS-001
+> 版本:1.0
+> 状态:功能基线
+> 适用范围:LinuxCNC Web/WASM 控制面板的部件级三维机床管理器
+
+## 1. 文档目的
+
+三维机床管理器用于把多个自包含 GLB 部件装配为可预览、可标定、可保存、可重新打开并可交给 LinuxCNC Web/WASM 运行的机床模型。用户在一个对话框内完成机型选择、部件导入、层级编辑、坐标校准、轴绑定、运动验证和模型保存;控制面板继续负责程序、Jog、回零、RTCP 和实际运动。
+
+本文件是产品功能和验收的总说明。字段的机器可读定义以 [`joint-model.schema.json`](joint-model.schema.json) 为准,实施步骤见 [`02-项目程序开发详细步骤.md`](02-项目程序开发详细步骤.md),已完成项和证据见 [`04-任务矩阵.md`](04-任务矩阵.md) 与 [`05-验收证据.md`](05-验收证据.md)。
+
+## 2. 目标与边界
+
+### 2.1 目标
+
+- 在机床预览区提供“三维机床管理”入口和独立配置工作区。
+- 支持原生 XYZAC/XYZBC TRT,以及通过通用 WASM 运动学桥接接入的双转台、Bridge Mill、MAX5、Hexapod、Pentapod、PUMA、trivkins 和 SCORBOT 案例。
+- 保留每个 GLB 的原始字节、材质、几何、内部层级和导入变换;编辑结果以装配草稿保存。
+- 使预览中的节点运动、导出的 GLB、URDF/几何参数和 WASM 实际关节快照使用同一套轴映射和标定数据。
+- 提供浏览器下载和当前站点本地机床库两种保存方式,并可在刷新后恢复已保存版本。
+- 对工业级复杂模型提供统一 64 MiB GLB 限额,并将“几何模板、运动学适配、控制器可运行、实机标定”作为独立状态展示,防止把可查看模型误认为可安全运行配置。
+
+### 2.2 明确边界
+
+- 管理器是模型装配和标定工具,不是第二套运动学求解器。
+- Three.js 只负责模型、坐标辅助、轨迹投影和相机;正式运动权威始终是 Worker/WASM 快照。
+- TRT 管理器应用仍严格校验两条控制链;通用运动学案例通过各自机型运行配置和 WASM 求解器执行,不套用 TRT 链。
+- 不实现碰撞检测、质量/刚体动力学、任意 URDF 求解、云端同步、权限审批或真实硬件控制。
+- 64 MiB 是当前浏览器交付边界,不代表任意工作站都可流畅预览该上限模型;节点数、纹理显存及设备能力仍需分别校验。
+
+## 3. 使用角色与典型场景
+
+| 角色 | 主要任务 | 结果 |
+|---|---|---|
+| 机床设计人员 | 导入部件、调整层级、定义原点和轴向 | 可复用的机床设计草稿 |
+| 工艺/调试人员 | 设置 RTCP 参数、物理零点、试动和实时跟随 | 已标定的 AC/BC 视觉模型 |
+| 操作人员 | 打开已保存模型并导入控制面板 | 与目标机型匹配的运行配置 |
+| 验收人员 | 检查结构、坐标、导出文件和程序一致性 | 可追溯的验收报告 |
+
+典型流程是:新建机床 → 选择 XYZAC/XYZBC → 导入部件 → 编辑树和局部变换 → 绑定轴 → 输入 RTCP/物理零点 → 试动和校验 → 保存/导出 → 应用到控制面板 → 回零后运行程序。
+
+## 4. 工作区与界面功能
+
+### 4.1 入口和工作区布局
+
+控制面板机床预览区提供以下入口:
+
+1. **三维机床管理**:新建或编辑当前草稿。
+2. **打开已保存机床**:从文件选择器或本地机床库恢复完整 GLB。
+3. **编辑控制面板机床**:将当前已应用的受支持模型回填到管理器。
+4. **导入机床模型**:在控制器空闲且允许切换时载入已保存 GLB。
+
+管理器采用固定预览区、可滚动配置区和固定底部操作区。配置区分为四个页签:
+
+| 页签 | 内容 | 主要操作 |
+|---|---|---|
+| 结构 | 模型列表、GLB 树、搜索、显隐、父级拖放、删除 | 导入部件、选节点、改父级、显示原点 |
+| 运动 | 轴绑定、单自由度、方向/正负、单轴步进、实时跟随 | 配置 XYZABC、试动、锁定跟随 |
+| 标定 | C 原点、A/B 偏置、物理零点、机型检查 | 输入毫米/度参数、查看校验状态 |
+| 文件与恢复 | 名称、说明、保存、打开、本地版本、下载 | 导出 GLB、恢复、删除版本 |
+
+预览工具栏至少包含:选择节点、显示/隐藏原点、显示/隐藏网格、视图预设(等轴测、顶视、前视、侧视、适配整机)、缩放、重置相机和渲染状态提示。
+
+### 4.2 状态提示
+
+工作区必须能区分以下状态:`未保存草稿`、`校验通过`、`不可应用`、`已应用`、`已导出`、`实时跟随中`、`导入失败`、`渲染暂停`。错误提示显示在产生错误的页签和底部汇总区,不能只写入控制台。
+
+## 5. 详细功能需求
+
+### FR-01 机型选择
+
+1. 新建对话框第一步必须选择设计机型 `XYZAC` 或 `XYZBC`。
+2. 设计机型保存在草稿中,不随控制面板之后的机型切换被覆盖。
+3. XYZAC 只允许绑定 X、Y、Z、A、C;XYZBC 只允许绑定 X、Y、Z、B、C。
+4. 切换机型时保留通用部件和标定草稿,清除不兼容倾转轴绑定并提示用户;操作可撤销。
+5. 打开完整机床 GLB 时以文件中的机型为准,并把打开动作作为一次可撤销的草稿替换事务。
+
+### FR-02 GLB 部件导入
+
+1. 支持逐个导入自包含 `.glb` 文件,也支持导入已有完整机床 GLB。
+2. 导入前计算 SHA-256,记录文件名、大小、哈希和源节点路径。
+3. GLB 必须能够在无外部网络和无额外文件的情况下解析;外部纹理、外部网格和无法解析的资源必须拒绝。
+4. 同一文件可作为一个部件导入,不能因名称相同覆盖已有部件;每个部件获得稳定 ID。
+5. 导入失败时保留已有草稿、树和预览;已应用模型不发生变化。
+6. 删除部件时从树、预览和配置中移除,并释放其几何、材质、纹理和辅助对象。
+
+### FR-03 GLB 层级树与装配
+
+1. 右侧树必须按解析后的 `Object3D.children` 原始结构显示,不按中文名称推断身份。
+2. 节点 ID 使用部件 ID 加稳定源路径;名称可以重复,ID 不得重复。
+3. 根部件之间允许设置装配父级;同一 GLB 内部节点允许设置内部父级。
+4. 拖放换父级时默认保持节点世界姿态,并重新计算相对新父级的局部变换。
+5. 必须拒绝自指、循环、无效父级、跨 GLB 直接挂接内部节点和产生重复角色的结构。
+6. 树支持搜索、展开/折叠、定位预览、显示/隐藏和选中高亮。
+7. 选中节点时显示其直接父级、稳定路径、局部原点、当前局部变换和绑定自由度。
+
+### FR-04 部件局部原点与变换
+
+1. 部件和内部节点均可编辑相对父级的平移 `X/Y/Z`(mm)和欧拉姿态 `Rx/Ry/Rz`(deg)。
+2. 旋转中心为当前节点自己的局部 `(0,0,0)`;子节点继承父节点变换,父节点和兄弟节点不被改写。
+3. 输入框回显当前有效值;空值、非数字、超出有限数范围的输入不驱动模型。
+4. 数值变换和操纵器(如启用)必须写入装配草稿,不改写源 GLB 字节。
+5. 管理器使用机床 Z-up 视图:`camera.up = (0,0,1)`,XY 为地面;导入 GLB 的原始 Y-up 节点、几何、材质、层级和原始变换不得被隐式旋转。
+
+### FR-05 原点和局部坐标辅助
+
+1. 每个部件可单独显示/隐藏原点辅助线。
+2. 选中关节节点时,XYZ 彩色辅助轴必须作为该节点的子对象挂在局部 `(0,0,0)`。
+3. 辅助轴随节点自身旋转以及全部父链变换,不得显示为固定的世界轴。
+4. 辅助对象不参与导出模型,不改变节点包围盒和轴绑定。
+
+### FR-06 单自由度轴绑定
+
+1. 每个运动节点只能绑定一个 XYZABC 自由度;固定节点选择“固定/无自由度”。
+2. 每个数控轴最多绑定一个节点;轴绑定以稳定节点 ID 保存,不能按节点名称或树排序推断。
+3. 平移轴 X/Y/Z 的单位为 mm;旋转轴 A/B/C 的单位为 deg。
+4. 方向为父坐标系中的单位轴 `x/y/z`,并提供正向/反向 `sign=+1/-1`。
+5. 节点保留导入后的零姿态;试动值不能被烘焙为新的零姿态。
+6. 绑定不完整或重复时允许保存草稿,但禁止应用到控制器和导出为可运行完整机床。
+
+### FR-07 轴值试动与实时跟随
+
+1. 运动页提供每个已绑定轴的单值输入和步进按钮;试动只修改管理器草稿,不发送 LinuxCNC 运动命令。
+2. 试动输出至少包含节点局部位置、世界位置、四元数和当前轴值,便于检查父子继承。
+3. 可开启“跟随数控系统实时关节位置”;开启后手动轴输入锁定。
+4. 实时值只来自当前机型有效的 Worker/WASM 快照。缺少任一必需轴值时整帧拒绝,不对部分节点更新。
+5. 关闭跟随后恢复当前草稿姿态;跟随期间不改变零姿态、物理零点或保存文件。
+6. 运动变换每帧从零姿态绝对计算,不使用累加平移/旋转,避免长期运行漂移。
+
+### FR-08 RTCP 几何标定
+
+标定页提供 C 轴原点和倾转轴偏置,单位均为 mm:
+
+- C 原点 `P = (x-rot-point, y-rot-point, z-rot-point)`。
+- A/B 偏置 `D = (x-offset, y-offset, z-offset)`。
+- 倾转关节原点 `T = P + D`。
+- C 关节原点 `U = -D`。
+- 工件固定原点 `work = -P`。
+
+应用时把标定写入整机场景和底座节点 `userData.linuxcncMachineGeometry`,同时生成现有运行链可识别的 URDF/配置结构。管理器不得用外观平移代替 RTCP 参数。
+
+### FR-09 物理零点
+
+1. 为当前机型的 X/Y/Z/A或B/C 分别输入模型零姿态对应的物理读数。
+2. 渲染量按 `d = sign × (WASM实际关节值 - physicalZero)` 计算。
+3. 物理零点只用于模型校准,不修改 Worker、INI、回零位置或程序坐标。
+4. 缺失、非有限或与机型轴集合不匹配时禁止应用,并显示具体轴名。
+
+### FR-10 预览与渲染
+
+1. 预览支持选择、高亮、相机轨道控制、视图预设、适配整机、网格和辅助轴。
+2. 使用 PMREM/色彩管理/曝光/补光提升高金属度 GLB 的可辨识度,不覆盖资产材质参数。
+3. WebGL drawing buffer 同时受设备最大纹理尺寸和像素预算限制;不依赖超限 MSAA。
+4. 零尺寸或 context lost 时暂停绘制并显示可恢复状态,不连续提交无效 render。
+5. 预览相机变化不修改节点、轴值、WASM tick、程序状态或保存数据。
+
+### FR-11 校验与应用
+
+应用按钮必须按顺序执行:
+
+1. 校验机型、节点 ID、父级、无环结构和部件资源。
+2. 校验底座、X、Y、Z、刀具、A/B、C、工件角色的唯一性和受支持机械链。
+3. 校验轴绑定唯一、方向/正负/零姿态/标定数值有限,且机型轴集合完整。
+4. 校验 GLB、URDF、几何参数和关节 JSON 的版本与哈希关系。
+5. 生成运行装配的深复制场景,写入 `linuxcncMachine`、`linuxcncMachineGeometry` 和 `linuxcncJointModel`。
+6. 通过既有 `LOAD_WORKSPACE`/配置重载事务交给 Worker;失败时回滚到旧模型和旧机型。
+7. 应用成功后要求重新回零;程序运行、Jog、刀长和路径继续使用 WASM 权威状态。
+
+### FR-12 保存、导出和重新打开
+
+1. “保存机床模型 GLB”生成新的自包含完整 GLB;不覆盖任何源部件文件。
+2. 导出文件包含部件几何、层级、显隐、节点局部变换、机型、轴绑定、方向、正负向、RTCP 标定、物理零点和编辑元数据。
+3. 文件写入浏览器下载,同时尝试写入当前站点 IndexedDB 机床库;库保存失败不能阻止下载。
+4. 机床库显示名称、说明、机型、保存时间、大小和 SHA-256;每次保存形成独立版本。
+5. 重新打开时恢复多部件分组、源节点路径、拖放覆盖、局部变换、显隐、辅助轴、轴绑定、标定和机型。
+6. 导入完整机床 GLB 时先重建源节点树,再应用覆盖配置,防止重复平移或重复旋转。
+7. 普通几何 GLB 走“导入 GLB 部件”;带完整编辑元数据的 GLB 走“打开已保存机床”。
+
+### FR-13 机型与程序一致性
+
+1. 同机型打开/应用时保留兼容程序和标定。
+2. 跨 XYZAC/XYZBC 导入或应用时,先读取目标机型默认程序,再提交工作区切换。
+3. 错轴程序在加载、运行、单步和继续入口均由 Worker 拒绝;不能通过文本替换 A/B 伪造适配。
+4. 控制面板当前机型不能覆盖已打开草稿的设计机型;程序、预览、运行状态必须属于同一机型。
+
+### FR-14 撤销、重做和取消
+
+1. 草稿历史保存配置快照,至少支持 30 步撤销/重做。
+2. 关闭文件选择器或取消选择时,父对话框保持打开、原配置和焦点保持不变。
+3. 打开完整模型是一次替换事务;失败或取消时恢复打开前草稿。
+4. 关闭对话框不等同于应用;未应用草稿不能改变控制器当前模型。
+
+## 6. 数据契约与单位
+
+### 6.1 三层 userData
+
+| 字段 | 用途 |
+|---|---|
+| `userData.linuxcncMachine` | 现有严格 URDF/运行控制框架 |
+| `userData.linuxcncMachineGeometry` | C 原点、A/B 偏置、物理零点和几何标定 |
+| `userData.linuxcncJointModel` | `linuxcnc.joint-model/1` 的节点、父链和视觉轴映射 |
+| `userData.linuxcncMachineEditor` | `linuxcnc.machine-editor/1` 的名称、说明、源部件和编辑覆盖 |
+
+### 6.2 核心字段
+
+| 字段 | 规则 |
+|---|---|
+| `coordinates` | `XYZAC` 或 `XYZBC` |
+| `controller.axisOrder` | AC 为 `[X,Y,Z,A,C]`,BC 为 `[X,Y,Z,B,C]` |
+| `axisMap` | 数控轴名到稳定节点 ID 的唯一映射 |
+| `nodes[].parentId` | 直接父级;根节点为 `null` |
+| `nodes[].zero` | 位置(m)、四元数 `[x,y,z,w]`、比例 |
+| `nodes[].joint` | `null` 或一个 `dof=1` 的平移/旋转关节 |
+| `joint.direction` | 父坐标系中的 `x/y/z` |
+| `joint.sign` | `+1` 或 `-1` |
+| `calibration.physicalZero` | 各轴模型零姿态的实际读数 |
+
+UI 平移、局部原点、C 原点和偏置使用 mm;UI 旋转和物理旋转轴零点使用 deg;GLB/glTF 节点长度为 m、旋转为弧度;URDF 长度为 m、旋转为弧度。转换必须集中在适配层,不能由各组件自行猜测。
+
+### 6.3 视觉驱动公式
+
+设 WASM 实际轴值为 `q`,物理零点为 `q0`,正负为 `s`,父坐标系单位方向为 `e`:
+
+- 平移节点:`position = zero.position + e × s(q-q0)`。
+- 旋转节点:`rotation = Quaternion(e, s(q-q0)) × zero.quaternion`。
+- 子节点继承父级世界矩阵;父节点和兄弟节点不因本节点输入改变。
+
+正式控制器仍使用已有 AC/BC TRT 运动学;关节 JSON 只描述视觉层级如何跟随实际关节。
+
+## 7. 权威边界与状态流
+
+```text
+用户输入/文件
+ ↓
+MachineDraft(React 本地草稿)
+ ↓ 校验通过
+运行装配 + URDF + geometry + joint-model
+ ↓ LOAD_WORKSPACE / 配置重载
+Worker/WASM(唯一运动权威)
+ ↓ snapshot: joints / pose / tool / path
+Three.js 预览(只读投影)
+```
+
+草稿可以驱动管理器中的诊断试动,但不能直接写 HAL、发送 Jog 或修改 WASM 指针。正式模型的每次姿态更新都必须带有对应 snapshot 的 tick/state hash;React 状态只保存展示和编辑数据。
+
+工作区状态转换为:
+
+```text
+new → editing → structurally-valid → controller-valid
+ ↓ ↓
+ exportable applied → homing-required → running
+```
+
+任何导入、校验、应用或保存失败都回到失败前的稳定状态,并保留可定位错误码。`applied` 之前的草稿不可影响正在运行的控制器。
+
+## 8. 错误处理要求
+
+| 场景 | 用户可见结果 | 数据结果 |
+|---|---|---|
+| 外部资源 GLB | “仅支持自包含 GLB”及文件名 | 拒绝导入,旧草稿不变 |
+| 重复轴/角色 | 指向轴或角色的字段错误 | 禁止应用和运行 |
+| 循环/跨文件非法拖放 | 树节点错误提示 | 拖放回滚 |
+| 非法数值 | 输入框标红并保留上一个有效姿态 | 不驱动预览、不写入导出 |
+| 缺少快照轴值 | “等待完整关节快照” | 保持上一帧姿态 |
+| URDF/Schema 不匹配 | 显示版本/字段错误 | 不替换已应用模型 |
+| IndexedDB 保存失败 | 提示本地库失败并提供下载 | 下载继续,当前草稿保留 |
+| WebGL context lost | 显示渲染暂停和恢复按钮 | 轴值、草稿和控制器状态不变 |
+| 跨机型程序不兼容 | 指出目标机型和冲突轴 | 加载、运行、单步、继续均拒绝 |
+
+## 9. 非功能要求
+
+- **可追溯性**:保存源文件名、SHA-256、节点稳定路径、schema 版本、机型和导出时间。
+- **确定性**:同一源 GLB、同一草稿和同一 snapshot 必须得到相同局部/世界变换和导出元数据。
+- **资源安全**:加载、删除、重开和撤销不能持续泄漏几何、材质、纹理、辅助对象或 WebGL buffer。
+- **性能**:相机操作和模型姿态更新不阻塞 Worker tick;重复更新从零姿态计算,不累积矩阵误差。
+- **响应式**:桌面、窄屏和矮屏均能访问树、配置页签和底部操作;预览不可遮挡校验错误。
+- **兼容性**:保留现有整机 GLB+URDF 导入路径;旧文件缺少编辑元数据时按兼容模式恢复已有装配、轴和标定。
+- **离线**:已保存到本地机床库的文件不依赖网络即可打开;外部资源不得在运行时偷偷下载。
+
+## 10. 验收清单
+
+### 功能验收
+
+- [ ] AC 和 BC 均可由多个独立部件建立,完成结构、运动、标定和应用。
+- [ ] 树节点可搜索、拖放、删除、显示/隐藏,并正确拒绝循环和跨 GLB 非法关系。
+- [ ] 节点局部平移/旋转、局部坐标轴和子节点继承结果正确。
+- [ ] 单自由度绑定支持轴方向、正负号、物理零点和实时 WASM 跟随。
+- [ ] RTCP C 原点、A/B 偏置和物理零点写入相应 userData 字段,并可重新打开。
+- [ ] 导出 GLB 自包含,重新导入后配置与导出前等价。
+- [ ] 跨机型导入使用目标默认程序,错轴程序在 Worker 入口拒绝。
+- [ ] 保存、下载、IndexedDB、本地库恢复、取消文件选择器和失败回滚均可操作。
+
+### 一致性验收
+
+- [ ] 控制面板程序、Jog、回零、刀长、路径和模型姿态来自同一 Worker/WASM 快照。
+- [ ] Three.js 相机或诊断试动不会修改控制器状态。
+- [ ] 应用失败不替换旧模型;应用成功后要求重新回零。
+- [ ] 预览、组合 GLB、URDF 和 JSON 使用同一父级局部变换和轴映射。
+
+### 工程验收
+
+- [ ] TypeScript 类型检查和生产构建通过。
+- [ ] GLB 资源生命周期、Schema、URDF、AC/BC 运动、MILL700 深层父子节点和浏览器响应式场景有自动化证据。
+- [ ] 浏览器控制台无未处理异常;WebGL 零尺寸、context lost 和短视口 DPR 场景有报告。
+- [ ] 验收命令、报告、截图和哈希同步写入 [`05-验收证据.md`](05-验收证据.md)。
+
+## 11. 不属于本版本的功能
+
+- 任意五轴/机器人/并联机型的自动运动学推导。
+- 碰撞、干涉、质量、惯量、刚体动力学和切削材料去除仿真。
+- 云端机床库、跨浏览器同步、多人协作和权限审批。
+- 通过视觉层级编辑直接修改 INI 行程、速度、回零或控制器物理参数。
+- 以 JavaScript/Three.js 计算并替代 LinuxCNC 的 RTCP 逆解、前瞻或轨迹规划。
+
+## 12. 关联文档
+
+- [项目功能内容](01-项目功能内容.md)
+- [项目程序开发详细步骤](02-项目程序开发详细步骤.md)
+- [任务矩阵](04-任务矩阵.md)
+- [验收证据](05-验收证据.md)
+- [决策记录](06-决策记录.md)
+- [单自由度 JSON 与实时驱动](07-单自由度JSON与实时驱动.md)
+- [保存与重新打开机床](08-保存与重新打开机床.md)
+- [关节 JSON Schema](joint-model.schema.json)
diff --git a/docs/glb-machine-manager/01-项目功能内容.md b/docs/glb-machine-manager/01-项目功能内容.md
index 5dbbc35..1f50431 100644
--- a/docs/glb-machine-manager/01-项目功能内容.md
+++ b/docs/glb-machine-manager/01-项目功能内容.md
@@ -35,7 +35,8 @@
## 安全边界
-- 当前只生成项目已有求解器支持的 XYZAC 或 XYZBC 双分支 TRT 结构。
+- TRT 结构生成仍限定 XYZAC/XYZBC,走原有严格 URDF 校验。其它 11 个案例使用 `generic-wasm/1` URDF profile:link/joint 数量与父子链由 `machine-kinematics-specs.json` 派生,不再要求固定链;其运动学仍完全来自 WASM,管理器只负责装配与标定。
+- 关节标识是 LinuxCNC 联合号(槽位),轴字母只是槽位属性。因此重复轴(`bridge-mill` 的两个 Y)、并联杆长(`hexapod`/`pentapod`)、被动槽位(`max5` 的第 6 关节)都能表达,坐标字母不再作为机型键(三个案例共用 XYZAB、三个共用 XYZABC)。
- 层级编辑器允许建立关系,但“应用到控制器”前必须严格验证两条受支持机械链、角色唯一性和无环结构。
- 未完成八个角色时允许预览和保存草稿,不允许应用到控制器。
- 模型外观变换不能代替 INI/运动学参数;C 原点和 A/B 偏置必须通过现有配置重载进入 WASM。
@@ -60,3 +61,10 @@
19. MGR-20:导入/应用跨机型模型时同步加载目标机型默认 G 代码,程序路径、源码、预览与运行机型保持一致;同机型标定保留兼容程序。不兼容轴程序在加载及执行入口拒绝。
20. MGR-21 文档管理:新建、打开已保存机床、编辑控制面板机床;模型名称/说明;保存 GLB 同时提供浏览器下载和本地机床库副本;刷新后从库恢复,多部件文件分组、节点路径、层级覆盖、显隐、关节和标定完整回填。
+
+21. 工业模型容量:部件导入、完整机床解析、控制面板导入与组合 GLB 导出共享 64 MiB 上限;拒绝时保留现有草稿/运行机床并给出可执行的减面、纹理压缩或拆分建议。外部资源和超过 10,000 节点的 GLB 仍拒绝,容量提升不绕过结构校验。
+
+22. 工业机床案例与工作流:案例资料明确标注“可直接运行、可作几何模板、需运动学适配、仅源码参考”状态;真实/演示资产来源、授权、单位、坐标系、机型、关节映射、限位/零点是否来自实测需可追溯。案例几何不能被描述为实机标定数据;从案例创建草稿必须保留来源并要求操作者复核轴向、零点、限位及 RTCP 参数后才可应用。
+
+23. 每个五轴案例必须提供独立坐标限定的低幅干运行 G 代码,记录案例 ID、机型/关节坐标、运动学和 Web 可用状态。Web 控制器按程序绑定的机型加载对应运行配置;XYZAC/XYZBC 使用原生 TRT,另外 11 个案例使用通用 WASM 运动学桥接,不再按错误 TRT 执行。程序使用 G21、G90、G94、安全高度/小行程 G0/G1,不启用主轴、刀具更换或切削。
+24. 案例状态须区分资产/程序已创建、原生 LinuxCNC 来源存在、Web 配置已注册、WASM 运动学已接通、控制面板浏览器运行已验证。前序状态不能推导后序状态;本仓库现状只有 XYZAC/XYZBC 已完成 Web 面板运行闭环。
diff --git a/docs/glb-machine-manager/03-推进台账.md b/docs/glb-machine-manager/03-推进台账.md
index 1e7bc0b..88ee5c6 100644
--- a/docs/glb-machine-manager/03-推进台账.md
+++ b/docs/glb-machine-manager/03-推进台账.md
@@ -133,3 +133,14 @@
- 修复原生文件选择器 Cancel/项目 Escape 与父 native dialog 的取消事件串扰;文件选择期间父对话框以 cancelGuard 保持打开。
- 三维管理“导入 GLB 部件”“打开已保存机床”、机床导入模型和机床参数导入统一保护;取消后草稿、选择节点、页签和配置不变。
- E18 工作台回归新增真实 filechooser 取消验证,浏览器错误 0。
+
+## R17 / 2026-10-29 / 13 案例槽位化真实三维驱动
+
+- 关节契约为 `linuxcnc.joint-model/2`:以 LinuxCNC joint 号为槽位标识,轴字母成为槽位属性。`validateJointModel` 同时接受 v1 与 v2,v1 原规则不变并合成 `jointOrder`,已保存 GLB 与既有测试不受影响。
+- 13 个结构 GLB 由 `scripts/generate_physical_case_glbs.mjs` 重新生成:每个可动关节是 Group,名字对应槽位,写 `userData.linuxcncJoint{index,key,axis,type,unit,direction,sign,source}` 与场景级 `linuxcncPhysicalStructure`;共 71 个关节,与 `machine-catalog.json` 的 `joint_count` 逐条一致。
+- 新增 `machine-visual-bindings.json` 与纯函数模块 `machine-visual-bindings.mjs`:以 machine id / entrypoint 为键,坐标字母不作键(三个案例共用 XYZAB、三个共用 XYZABC)。校验覆盖 13 案例、槽位连续性、槽位数与运行目录一致、GLB 存在且 ≤64 MiB、节点逐槽可缺省报告。
+- 主界面 `loadCase` 在 `LOAD_WORKSPACE` 成功后按绑定配置装载对应结构 GLB;失败保留内置模型并显式提示。`loadImportedMachine` 增加绑定优先路径,`buildImportedMachine` 增加自包含 v2 装配分支,按槽位数组驱动。
+- 前端截断消除:`xyzac-visual-adapter.ts` 的 `joints` 改为数组并新增 `jointsAuthoritative`;`App.tsx` 的 `allHomed` 改为按 `joint_count` 判断;`xyzac-panel-contract.ts` 优先按 worker 断言的 machine id 查找机型。
+- 管理器解锁:坐标/案例下拉以“存在完整视觉绑定”为可用条件,13 案例全部可选;`machine-urdf.ts` 新增 `generic-wasm/1` profile,`buildJointManagedMachine` 对无 TRT profile 的机型生成通用 URDF;新增 `machine-kinematics-specs.json` 保存逐关节限位。
+- 构建资源补全:`vite.config.ts` 增加 `woodpecker`、`gmoccapy`、`qtdragon` 三个配置目录,此前 `woodpecker_xyzab.ini`、`gmoccapy_XYZCW.ini`、`woodpecker_xyzya.ini` 在生产构建中 404。
+- 已知缺口如实记录:`max5-maxkins` 的 INI 声明 J3=A、J4=B、J5=C,而 `maxkins` 按 `coordinates` 位置读取,槽位标注与求解器存在偏移;`puma-pumakins` 的 J5 缺少 `MAX_LIMIT`;三个 trivkins 案例的旋转槽位无 `[JOINT_n]` 段;`scorbot-kins` 的 INI 不声明任何关节限位。以上在 `machine-kinematics-specs.json` 中以 `null` 记录,不编造数值。
diff --git a/docs/glb-machine-manager/04-任务矩阵.md b/docs/glb-machine-manager/04-任务矩阵.md
index f40ae6a..68d1cbe 100644
--- a/docs/glb-machine-manager/04-任务矩阵.md
+++ b/docs/glb-machine-manager/04-任务矩阵.md
@@ -25,3 +25,12 @@
|MGR-20 机型程序一致性|verified|MGR-19|导入/应用跨机型时加载目标默认程序;同机型保留;错轴程序加载/运行/单步/继续拒绝;AC↔BC 浏览器及 Worker 验收通过|
|MGR-21 保存位置与完整文档恢复|verified|MGR-20|命名 GLB 下载与本地机床库;显式打开/刷新恢复;多部件配置精确回填;二次保存等价;标准导入、旧版兼容、存储失败和 AC/BC 回归|
|MGR-22 文件选择器取消路径|verified|MGR-18|三维管理、机床导入、几何参数三类文件选择器取消/回焦不关闭父对话框;没有文件时保持配置和焦点|
+|MGR-23 工业模型容量边界|verified|MGR-21|部件导入、完整机床校验/导入、组合导出使用同一 64 MiB 常量;超限反馈一致;构建与工作台浏览器回归通过|
+|MGR-24 可复用工业案例工作流|planned|MGR-23|案例可作为有来源/许可/适配状态/单位/坐标/参数完整度标签的创建模板;导入后生成独立草稿、保留来源追溯;实机限位和标定显式复核;案例/资产授权与浏览器容量可检查|
+|MGR-25 真实资产使用可用性|planned|MGR-23|覆盖仓库已有大体量 OP10/OP20/OP30 与机器人资产的导入/预览/可编辑/保存路径;提供轻量代理或优化建议、进度/取消/显存诊断,失败不破坏草稿|
+|MGR-26 13 案例 G 代码与程序目录|verified|MGR-20|13 个案例各有独立坐标限定、低幅有界干运行程序;目录来源/运动学/状态可查;AC/BC 在 Web WASM 控制面板加载、预览、回零、运行完成;其它案例切换到各自通用 WASM 机型后加载|
+|MGR-27 其余原生运动学 Web 控制器适配|in-progress|MGR-26|已接入通用 WASM 配置、WASM planner→`sim_kinematics_inverse` 采样、机型参数/限位和 11 个案例程序;Worker 级完成 11 案例加载/运行闭环。仍需逐机型浏览器视觉模型、原生 HAL/回零语义和完整源配置资源验收;不能用 TRT alias 代替原求解器|
+|MGR-28 案例视觉绑定配置|verified|MGR-27|`machine-visual-bindings.json` 以 machine id 为键覆盖 13 案例;槽位、节点、方向、正负向、物理零点可查;坐标字母不再作为键|
+|MGR-29 N 关节槽位契约|verified|MGR-17|`linuxcnc.joint-model/2` 以 LinuxCNC joint 号为槽位标识,轴字母降为槽位属性;v1 保持原规则合成 `jointOrder`;重复轴、并联杆长、被动槽位可表达|
+|MGR-30 13 案例真实三维驱动|verified|MGR-28,MGR-29|13 个结构 GLB 的可动节点均为按槽位命名的 Group 并带 `userData.linuxcncJoint`;主界面切换案例自动装载对应模型;5/6/7 关节均按槽位驱动|
+|MGR-31 构建资源补全|verified|MGR-27|`woodpecker`、`gmoccapy`、`qtdragon` 三个案例配置目录进入生产构建,13 案例 entrypoint 全部可在 `dist` 解析|
diff --git a/docs/glb-machine-manager/05-验收证据.md b/docs/glb-machine-manager/05-验收证据.md
index 40a6fb9..55b8def 100644
--- a/docs/glb-machine-manager/05-验收证据.md
+++ b/docs/glb-machine-manager/05-验收证据.md
@@ -198,3 +198,27 @@
- `node scripts/test_machine_manager_workbench.mjs`:report `GLB-MGR-E14` 更新,首项检查真实 `filechooser` 打开后取消,三维机床管理对话框仍保持打开;随后搜索、撤销、标定、布局回归继续通过,浏览器错误 0。
- 三个文件入口统一保护:管理器 GLB/已保存机床、控制面板机床模型、机床几何 INI。
- 工作台报告 SHA-256:`0f6db50633be732a4b65162aff3373eccb23cfe319a1dd7d19fa4260098bde64`。
+
+## E18 / 2026-10-29 / 13 案例真实三维驱动验收
+
+命令与产物:
+
+```
+node web/workspace/test_machine_visual_bindings.mjs
+node web/workspace/test_glb_axis_naming.mjs
+node --experimental-strip-types scripts/test_machine_joint_model.mjs
+node scripts/test_five_axis_live_models.mjs
+npm --prefix web/react run typecheck && npm --prefix web/react run build
+```
+
+结果:
+
+| 检查 | 结果 |
+|---|---|
+| 视觉绑定配置 | 13/13 案例覆盖,槽位数与 `joint_count` 一致,GLB ≤64 MiB,坐标字母不作键 |
+| GLB 节点命名 | 13 个结构 GLB 按槽位命名并带 `userData.linuxcncJoint`;重复轴与额外直线轴案例返回 UNKNOWN 由绑定文件接管 |
+| 关节契约 v1 | `GLB-MGR-E13-MATH` 通过,已保存 GLB 不受 v2 影响 |
+| 浏览器逐案例 | 13/13 装载成功、槽位键与 `machine-visual-bindings.json` 逐一一致、浏览器错误 0 |
+| 构建 | typecheck 与 vite build 通过;`dist` 含 13 个 GLB 与三个此前缺失的案例 INI |
+
+报告与截图:`coverage/glb-machine-manager/physical-drive/browser.report.json` 与同目录 13 张 PNG。
diff --git a/docs/glb-machine-manager/09-13案例真实结构与建模方案.md b/docs/glb-machine-manager/09-13案例真实结构与建模方案.md
new file mode 100644
index 0000000..0597c9c
--- /dev/null
+++ b/docs/glb-machine-manager/09-13案例真实结构与建模方案.md
@@ -0,0 +1,93 @@
+# 13 个五轴案例的真实结构与建模方案
+
+本文把案例目录转换成可以采购资料、测量实机或直接建模的结构方案。每项案例都区分三种信息:
+
+- **实机参考**:已有公开实机或产品系列,可以用来收集外形、轴向和典型尺寸。
+- **LinuxCNC 原始结构**:仓库中的仿真模型、INI、运动学源码和 G 代码,是坐标链与参数的权威来源。
+- **本项目设计方案**:没有同构商品机时,给出满足该运动学的可制造结构,作为 GLB 装配的起点。
+
+商业型号的外观尺寸、行程、负载和精度不能直接当作本项目标定值。导入管理器前应使用实机测量或设计图中的数据填写旋转中心、偏置、零点和限位。
+
+## 统一建模约定
+
+GLB 采用米制、局部节点原点位于关节轴线或丝杠中心,运行配置保存毫米和角度单位。每个模型至少拆成 `base`、线性轴部件、旋转轴部件、主轴/刀具、工件/工作台和防护外壳。关节树中的轴名必须与案例坐标字母一致;重复轴必须保留为两个独立节点,不能合并成一个视觉节点。
+
+```mermaid
+flowchart LR
+ B[床身/底座] --> X[X 线性轴]
+ X --> Y[Y 线性轴]
+ Y --> Z[Z 线性轴]
+ Z --> T[主轴/刀具]
+ B --> R1[第一旋转轴]
+ R1 --> R2[第二旋转轴]
+ R2 --> W[工件/转台]
+```
+
+`XYZAC`、`XYZBC`、`XYZAB` 是传统五轴机床链;`XYZBCWY` 含 W 刀具方向和重复 Y 关节;`XYZABC` 机器人/并联平台使用六个姿态/位移自由度;`XYZYA` 以两个 Y 关节驱动同一龙门轴。
+
+## 逐项结构闭包
+
+|案例|真实结构参考|可直接建模的本项目方案|关节树和关键部件|建模/测量重点|
+|---|---|---|---|---|
+|XYZAC TRT|HERMLE C 42 U 类摆动转台五轴加工中心|立柱固定、X/Y/Z 主轴链,工作台上 A 摆动座和 C 回转盘|`base→X→Y→Z→tool`;`base→A→C→work`|A 轴绕 X,C 轴绕 Z;测 P、A-C 偏置、刀尖长度和转台面高度|
+|XYZBC TRT|Haas UMC-500/750 类 B/C 摆台机|固定立柱和 Z 主轴,Y 底座滑台,B 倾转耳轴、C 圆盘|`base→X→Y→Z→tool`;`base→B→C→work`|B 轴绕 Y;Haas 官方资料明确给出 B/C 轴和转台对准关系;测 MRZP、B-C 交点和 C 面跳动|
+|XYZAB 双转台|LinuxCNC `xyzab_tdr_kins` 双旋转台示例;可类比双转台五轴头|X/Y/Z 主轴链加 A 外框和 B 内框,工件夹具固定在 B|`base→X→Y→Z→tool`;`base→A→B→work`|A/B 旋转顺序、两个旋转中心、X/Z 偏置和刀长;A/B 不是 AC/BC 的字母替换|
+|Bridge Mill 5axiskins|FIDIA BRIDGE、AWEA Mega 5P 等固定龙门、双旋转头|固定龙门,X 工作台、Y 横梁、Z 滑枕,B 倾转头、C 主轴回转;W 是刀具方向坐标,另有可选第二个 Y 关节|`base→X→Y→Z→B→C→tool`,`Y_left/Y_right` 为同轴双驱,W 不新增实体滑台|W 是由 5axiskins 纳入 XYZ 的刀向补偿,不能画成额外电动轴;桥架、横梁、滑枕和双摆头分开建模|
+|MAX5 maxkins|LinuxCNC MAX5 桌面五轴机;运动学文档描述“倾转 B 头 + 水平 C 台”|固定床身,X/Y/Z 线性链;B 倾转主轴头;C 水平回转工件台|主轴:`base→X→Y→Z→B→tool`;工件:`base→C→work`;W 可作为刀长关节|B 头回转半径、C 台中心、W/刀长;MAX5 的非恒等坐标转换不能当 trivkins|
+|Hexapod genhexkins|Giddings & Lewis Variax 六支链平台|六根电动伸缩支链连接固定底座和移动平台,平台安装主轴或工件|`base→strut_1..6→platform→tool/work`|底座球铰点、平台球铰点、丝杠导程、平台初始姿态和迭代容差;六支链是并联关系,不是串联父子链|
+|Pentapod pentakins|METROM Pentapod P-Series 五支链加工中心|五根伸缩支链连接固定框架和移动刀具平台,平台带主轴|`base→strut_1..5→platform→tool`|五个底座点、平台环半径/高度、刀具偏置、初始姿态;METROM 官方资料明确使用 Pentapod 并联运动学|
+|PUMA pumakins|Unimation PUMA 560、PUMA 6 轴工业机器人|底座回转 J1、肩 J2、肘 J3、腕 J4/J5/J6,末端安装刀具|`base→J1→J2→J3→J4→J5→J6→tool`|A2/A3/D3/D4/D6、关节零位和腕部奇异区;机器人外形可使用仓库 PUMA OBJ 部件|
+|PUMA 560 genserkins|PUMA 560 同一实体结构,使用 Modified DH 参数|同 PUMA 六关节结构,单独保存 DH 表和关节限位|`base→J1→J2→J3→J4→J5→J6→tool`|每关节 `A/ALPHA/D`,单位和角度零位;PUMA pumakins 与 genserkins 必须分别保存参数|
+|trivkins XYZAB|五轴立式铣床/转台的轴映射基线|普通 X/Y/Z 三轴床身,A 摆台外框,B 转台内盘|`base→X→Y→Z→tool`;`base→A→B→work`|一一映射验证用;不声明 RTCP,主要检查节点名、轴顺序、正负方向和限位|
+|trivkins XYZCW|带 C 回转工作台和 W 刀向补偿的桥式/摆头机|X/Y/Z 线性链,C 回转工作台,W 刀具方向滑块|`base→X→Y→Z→tool`;`base→C→work`;`tool→W→tip`|C 与 W 的字母顺序和单位;W 是附加直线自由度,不可误当 A/B 旋转|
+|trivkins XYZYA|双 Y 龙门 + A 摆台/摆头|X 横向、Y_left/Y_right 双驱龙门、Z 主轴,A 工作台或摆头|5 个关节:`X,Y_left,Z,Y_right,A`;视觉上将两个 Y 归入同一龙门组件,A 作为工件或刀具旋转节点|两个 Y 关节同步、主从误差和回零;坐标字母与关节数量不同,不能按 5 个独立坐标误画|
+|SCORBOT scorbot-kins|SCORBOT-ER 4U/ER 4PC 教学机器人|底座回转、肩、肘、腕俯仰、腕回转五关节,末端夹具/刀具|`base→J1→J2→J3→J4→J5→tool`|连杆长度、关节方向、机械限位和夹具 TCP;SCORBOT 是机器人,不按五轴铣床 TRT 处理|
+
+## 13 个可建模机床的部件清单
+
+### 1. XYZAC / XYZBC / XYZAB
+
+这三类可共用一套立式五轴床身资产:床身、立柱、X 滑台、Y 鞍座、Z 滑枕、主轴箱、刀柄、旋转轴外框、旋转轴内盘、夹具、工件和护罩。通过不同的旋转节点和轴向即可得到 AC、BC、AB 三种装配。推荐先做中等精度教学模型:床身 900×700×500 mm、线性行程 X/Y/Z 为 500/400/400 mm,旋转盘直径 250 mm;这些是设计初值,不是实机规格。
+
+### 2. Bridge Mill
+
+部件分为地基、左右立柱、横梁、X 工作台、Y 横梁滑座、Z 滑枕、B 摆头、C 主轴壳和两个 Y 伺服箱。W 作为刀向补偿字段写入关节模型,不生成额外的可动滑套。建议模型中把左右立柱和横梁做成固定结构,把 `Y_left`、`Y_right` 做成两个可选显示节点,验收时分别输入相同和不同的 Y 值观察龙门扭转。
+
+### 3. MAX5
+
+部件分为桌面床身、X/Y/Z 导轨、B 倾转主轴头、C 水平回转台、刀具和工件。B 头轴线与主轴端之间保存 `pivot_length`;C 台中心保存为旋转中心。模型初始 B=0 时刀具指向 Z 负方向,C=0 时夹具基准面对 X 轴。
+
+### 4. Hexapod / Pentapod
+
+并联平台需要显示固定底板、移动平台、球铰/万向铰、伸缩杆、丝杠/电缸、平台法兰、主轴和刀具。每根支链必须是独立模型节点,不能把六根杆合并成一个网格;这样可以在管理器中显示长度、当前关节值和支链编号。平台与基座之间的杆长由运动学计算,GLB 只保存零位几何和连接点。
+
+### 5. PUMA / PUMA 560 / SCORBOT
+
+机器人模型需要底座、转台、肩部连杆、肘部连杆、腕座、腕部三轴、法兰、刀柄/夹具。仓库已有 PUMA OBJ,可直接转换为 GLB 部件;SCORBOT 可按相同树结构用圆柱连杆和关节壳体建立参数化替代模型。每个关节均需保存轴线、零姿态、正方向和机械限位。
+
+### 6. 三种 trivkins 基线
+
+基线模型可以复用前述床身或龙门资产,但必须提供三个独立装配文档:XYZAB 双转台、XYZCW C/W、XYZYA 双 Y。它们用于验证轴字母映射和 GLB 层级,不应在界面中显示成具有 RTCP 的商业五轴机。
+
+## 来源与使用方式
+
+- LinuxCNC 5-Axis Kinematics、`kins(9)`、`switchkins` 和 13 个本地配置是轴链、运动学名称和参数边界的第一来源。
+- HERMLE C 42、Haas UMC-500/750、FIDIA BRIDGE、AWEA Mega 5P、METROM Pentapod、Giddings & Lewis Variax、PUMA 560 和 SCORBOT 资料用于外形和结构类比。
+- 本项目自设计结构必须标记为 `design_reference`,不能写成实机测量结果。每个 GLB 应附 `source`, `license`, `units`, `kinematics`, `zero_pose`, `joint_limits` 和 `calibration_status`。
+
+后续生成 GLB 时,先按本文部件清单建立零位资产,再在三维机床管理器里绑定关节;完成实机测量后只修改配置和标定 JSON,不修改源网格。
+
+## 2026-10-29 更新:模型已按槽位重建
+
+13 个结构 GLB 由 `scripts/generate_physical_case_glbs.mjs` 统一生成,每个可动关节是
+Group,名字即槽位名(`x`、`y`、`z`、`a`/`b`/`c`、`w`、`y_2`、`j0`…`j5`、`strut_1`…`strut_6`),
+并带 `userData.linuxcncJoint = { index, key, axis, type, unit, direction, sign, source }`。
+关节总数 71,与 `machine-catalog.json` 的 `joint_count` 逐条一致。
+
+槽位键与 LinuxCNC joint 号一一对应:`bridge-mill-5axiskins` 的第 7 槽是重复 Y 驱动
+(`Y#2`,同时域),`max5-maxkins` 的第 6 槽是被动关节(`J5`,模型可见但不参与驱动),
+并联平台的 `J0..J5`/`J0..J4` 为杆长关节,轴字母为空。
+
+轴字母与槽位并非一一对应:`trivkins-xyzcw` 是 `X,Y,Z,C,W`(C 旋转在前,W 直线在后),
+`trivkins-xyzya` 是 `X,Y,Z,Y#2,A`(第二个 Y 为直线关节)。按坐标字母推断关节顺序会出错,
+一律以 `machine-visual-bindings.json` 的 `jointOrder` 为准。
diff --git a/docs/glb-machine-manager/10-Blender建模工作流.md b/docs/glb-machine-manager/10-Blender建模工作流.md
new file mode 100644
index 0000000..5591715
--- /dev/null
+++ b/docs/glb-machine-manager/10-Blender建模工作流.md
@@ -0,0 +1,21 @@
+# Blender 建模工作流
+
+Blender 已安装为 4.0.2,可用于编辑和检查 13 个结构 GLB。GLB 中的长度使用米;Blender 场景显示单位设置为毫米,导入模型时保留 `machine_root` 和物理结构节点。
+
+打开某个案例:
+
+```bash
+blender web/react/public/machine-cases/physical/hexapod-genhexkins.glb
+```
+
+为模型建立 LinuxCNC 建模工作文件:
+
+```bash
+blender -b --python scripts/blender_machine_template.py -- \
+ --input web/react/public/machine-cases/physical/hexapod-genhexkins.glb \
+ --output /tmp/hexapod-machine.blend
+```
+
+模板脚本会设置毫米单位、保留原有结构、添加 `MACHINE_ORIGIN` 和 X/Y/Z 参考轴,并写入 LinuxCNC 工作流自定义属性。编辑完成后使用 Blender 的 glTF 2.0 导出器导出自包含 GLB,再通过“三维机床管理 → 导入 GLB 部件”载入检查。
+
+结构节点命名、父子关系和运动学参数仍以 [13 案例真实结构与建模方案](09-13案例真实结构与建模方案.md) 和 [`five-axis-physical-structures.json`](../../web/workspace/five-axis-physical-structures.json) 为准。
diff --git a/docs/glb-machine-manager/README.md b/docs/glb-machine-manager/README.md
index 3f5ceaa..d529aeb 100644
--- a/docs/glb-machine-manager/README.md
+++ b/docs/glb-machine-manager/README.md
@@ -11,10 +11,13 @@
现有整机 GLB + 内嵌 URDF 导入能力继续保留。新管理器生成与现有 `linuxcnc.machine/1` 相同的严格结构,再通过已有配置、Worker、WASM 和 Three.js 投影链运行。
-当前状态: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/`。
+当前状态:MGR-27 已将 11 个非 TRT 案例接入通用 WASM 运动学路径:配置目录、轴/关节数量、参数、限位、WASM planner 和逐采样 `sim_kinematics_inverse` 均由案例机型驱动;13 个案例程序均可进入 Web 控制器,原生 AC/BC TRT 和 11 个通用案例分别使用对应运行链。Worker 回归脚本为 `web/worker/test_generic_machine_cases.mjs`。仍需逐机型浏览器三维模型与原生 HAL/回零语义验收,MGR-24/25 继续覆盖案例模板和大型资产体验。既有证据位于 `coverage/glb-machine-manager/`。
文档:
+- [00-三维机床管理详细功能文档](00-三维机床管理详细功能文档.md)
+- [00-三维机床管理详细功能文档(HTML 图文版,含 13 项机型案例)](00-三维机床管理详细功能文档.html)
+- [HTML 文档接续入口](../../nextTask-glb-machine-manager-doc.md)
- [01-项目功能内容](01-项目功能内容.md)
- [02-项目程序开发详细步骤](02-项目程序开发详细步骤.md)
- [03-推进台账](03-推进台账.md)
@@ -28,3 +31,9 @@
- [关节 JSON Schema](joint-model.schema.json)
- [08-保存与重新打开机床](08-保存与重新打开机床.md)
+
+13 个案例的实机参考、结构树、部件拆分和可建模设计方案见:[13 案例真实结构与建模方案](09-13案例真实结构与建模方案.md),机器可读索引见 [`five-axis-physical-structures.json`](../../web/workspace/five-axis-physical-structures.json)。
+
+13 个可导入的结构 GLB 位于 [`web/react/public/machine-cases/physical/`](../../web/react/public/machine-cases/physical/),生成器为 [`scripts/generate_physical_case_glbs.mjs`](../../scripts/generate_physical_case_glbs.mjs)。它们包含结构节点和运动学元数据;带 `real-reference` 的文件用于实机结构类比,带 `design-reference` 的文件用于满足原理的设计机。浏览器导入截图和报告位于 [`coverage/glb-machine-manager/physical-cases/`](../../coverage/glb-machine-manager/physical-cases/)。
+
+Blender 安装与建模操作见:[Blender 建模工作流](10-Blender建模工作流.md),模板脚本为 [`scripts/blender_machine_template.py`](../../scripts/blender_machine_template.py)。
diff --git a/docs/glb-machine-manager/configuration-driven-dialog.md b/docs/glb-machine-manager/configuration-driven-dialog.md
index 254304f..271cf00 100644
--- a/docs/glb-machine-manager/configuration-driven-dialog.md
+++ b/docs/glb-machine-manager/configuration-driven-dialog.md
@@ -9,3 +9,15 @@
2026-09-30 保存验证:使用暂存文件生成独立源码快照,TypeScript 检查和 Vite 生产构建通过。构建使用现有 node_modules、LinuxCNC 资源和本地 WASM 二进制;保留大 chunk 提示。本轮不把既有浏览器报告作为新验收证据。
脚本目录迁移、通用 WASM 运行时开发、原始大型 GLB 以及 LinuxCNC 子模块本地修改不包含在本次管理器配置提交中,仍留在工作区。
+
+## 2026-10-29 更新:机型层已扩展到 13 案例
+
+`profiles` 只保留 AC/BC 两条 TRT 专用的偏置约束;其余案例的槽位、方向、限位改由
+`web/workspace/machine-visual-bindings.json`(视觉绑定)与
+`web/workspace/machine-kinematics-specs.json`(逐关节限位,来自本地 INI)提供。
+管理器下拉的可用条件从“案例目录标记 supported”改为“存在完整视觉绑定”,13 个案例
+全部可选。`machine-urdf.ts` 新增 `generic-wasm/1` profile:不校验固定链,link/joint
+数量由配置派生,AC/BC 仍走原有严格校验。
+
+限位缺口如实保留:`puma-pumakins` 的 J5 无 `MAX_LIMIT`,三个 trivkins 案例的旋转槽位
+无 `[JOINT_n]` 段,`scorbot-kins` 的 INI 不声明关节限位;这些在 specs 中记为 `null`。
diff --git a/docs/glb-machine-manager/material-rendering.md b/docs/glb-machine-manager/material-rendering.md
new file mode 100644
index 0000000..a357135
--- /dev/null
+++ b/docs/glb-machine-manager/material-rendering.md
@@ -0,0 +1,23 @@
+# GLB 材质显示与中性光照
+
+三维机床管理器和 Web 控制面板共用 `web/react/src/machine-studio-lighting.ts`。显示设置独立于 GLB 文件、关节绑定和机床标定。
+
+本次调整:
+
+- 使用 AgX 色调映射和 sRGB 显示输出,改善高亮处的红绿蓝偏色。
+- 管理器曝光从 1.45 调整为 1;环境反射强度从 1.25 调整为 0.65。
+- 使用中性白色主光、补光、轮廓光和弱半球光,取消额外环境光叠加及偏黄/偏蓝灯光。主光强度为 2.4,补光为 0.55,轮廓光为 0.65,半球光为 0.2。
+- 环境反射适配管理器的 Z-up 与控制面板的 Y-up 坐标约定。
+- 保留原有阴影和抗锯齿资源策略,避免为大型 GLB 新增高分辨率渲染目标。
+
+不按节点名、轴名或文件名重新着色,不修改源材质的颜色、金属度、粗糙度或纹理,也不对已由 GLTFLoader 处理的颜色贴图进行重复色彩转换。保存机床模型仍保留原始材质参数;灯光不会成为导出模型的一部分。
+
+验证:
+
+```bash
+PLAYWRIGHT_MODULE=/absolute/path/to/playwright/index.js \
+MACHINE_MANAGER_URL=http://127.0.0.1:5173/ \
+node scripts/test_machine_material_rendering.mjs
+```
+
+测试使用真实 `glb/m1.glb` 及红、绿、蓝、白、金属、深色球体,对比预览截图,并通过实际管理器导出 GLB 校验材质参数。控制面板直接导入 GLB 后也检查其渲染管线。结果与截图位于 `coverage/glb-machine-manager/material-rendering/`,其中 `m1-comparison.png`、`rgb-materials-comparison.png` 为本次修改前后的对比图。
diff --git a/docs/glb-machine-manager/model-selection.md b/docs/glb-machine-manager/model-selection.md
new file mode 100644
index 0000000..b28908d
--- /dev/null
+++ b/docs/glb-machine-manager/model-selection.md
@@ -0,0 +1,22 @@
+# 导入模型的选择与删除
+
+在“三维机床管理”中导入 GLB 后,右侧“已导入 GLB 模型”列表按导入实例显示文件名、角色和关节数量。同名文件也可以独立选择、删除。
+
+- 点击模型列表中的文件名,或者在三维预览中单击模型,选择整份导入模型;黄色包围框标出选中对象。拖动仍用于旋转视角,不会更换选中模型。
+- 点击结构树中的节点,继续编辑内部关节;所属导入模型在列表中保持选中。
+- 点击工具栏“删除选中模型”,或模型行右侧“删除”,移除该份导入模型及其内部节点。另行导入并挂载到它的模型保留,解除父级关系。
+- 删除后自动选中相邻模型;删除最后一个模型后显示空白导入界面。
+- “撤销”恢复模型、关节绑定及父级关系,并选中恢复的模型;“重做”再次执行删除。
+- 实时跟随或文件操作进行中禁用删除。删除仅修改管理器草稿,不删除磁盘 GLB,也不会立即替换控制面板正在使用的模型。
+
+模型列表固定在右侧编辑区域顶部,可独立滚动;层级树或分析结果滚动不会隐藏模型选择入口。
+
+回归验证(先启动 `web/react` 的 Vite 服务):
+
+```bash
+PLAYWRIGHT_MODULE=/absolute/path/to/playwright/index.js \
+MACHINE_MANAGER_URL=http://127.0.0.1:5173/ \
+node scripts/test_machine_model_selection.mjs
+```
+
+该测试覆盖同名模型、预览点击与拖动区分、定向删除、撤销/重做、父级解除、清空恢复、真实 m1.glb 的五轴绑定恢复和窄屏操作。
diff --git a/docs/user-manual/LinuxCNC-Web-使用说明书.html b/docs/user-manual/LinuxCNC-Web-使用说明书.html
new file mode 100644
index 0000000..688d568
--- /dev/null
+++ b/docs/user-manual/LinuxCNC-Web-使用说明书.html
@@ -0,0 +1,558 @@
+
+
+
+
+
+
+
+LinuxCNC Web/WASM 使用说明书
+
+
+
+
+
+
+
+
WEB / WASM · 五轴机床仿真与 RTCP 教学平台
+
LinuxCNC Web/WASM 使用说明书
+
本手册面向机床操作者与教学使用者,说明如何用浏览器访问这套 LinuxCNC 仿真控制器:选择机床案例、上电回零、手动与 MDI 操作、运行 G 代码、使用 RTCP 程序库,以及三维机床模型的导入、标定与保存。全文操作均在浏览器内完成,不需要安装 LinuxCNC,也不连接真实机床与硬件。
+
+ 访问地址 https://49.232.250.35:58010/
+ 来源基线 LinuxCNC 2.10.0~pre0
+ 当前可用机型 XYZAC / XYZBC 转头转台(TRT)
+ 功能巡检 26 项通过
+ 手册版本 2026-10-08
+
+
+
+
+
+
+ 1. 软件概览与访问
+
+ 1.1 这是什么
+ 这是一个把 LinuxCNC 仿真行为搬进浏览器的 Web 应用:数值密集的实时任务(解释器、轨迹规划、运动学正逆解)编译成 WebAssembly,在浏览器的独立 Worker 线程里运行;界面参照 LinuxCNC 官方 AXIS 控制面板,另加三维机床模型预览与五轴 RTCP 教学资源。
+
+ - 不连接真实设备:没有伺服、主轴、刀库和限位开关,所有运动都是仿真,不会造成任何机械或人身风险,适合教学与工艺验证。
+ - 无需安装:支持 WebAssembly 的现代浏览器(推荐最新版 Chrome 或 Edge)直接打开即可。
+ - 数据不出本机:G 代码解析、仿真运算都在浏览器里执行;导入的机床模型也只保留在页面与浏览器本地存储中。
+
+
+ 1.2 访问地址与首次打开
+ 在浏览器地址栏输入或点击:https://49.232.250.35:58010/
+
+ 证书提示:该站点使用 IP 地址的 HTTPS 自签证书,浏览器可能提示"您的连接不是私密连接 / 证书无效"。在确认地址为 49.232.250.35:58010 后,选择"高级 → 继续前往"即可。企业环境下也可以让管理员把证书导入受信任列表。
+
+ 打开后右上角会显示 Connection: READY,下方状态栏从 PENDING 变为具体状态(如 STATE_ESTOP_RESET),即表示 WASM 仿真会话已经就绪。首次加载需要下载约 1.7 MB 的前端与 WASM 资源,之后浏览器会缓存。
+ 标题栏右侧还有一个蓝色的 使用说明书 按钮,随时点击即可在新标签页打开本手册;菜单 Help → 使用说明书 / User manual 是同一个入口。
+
+ 1.3 边界说明(请提前知悉)
+
+ - 当前发布版本只支持 XYZAC TRT 与 XYZBC TRT 两个五轴转头转台案例的完整操作链;其余案例可以选中,但控制器不会就绪,详见 第 13 节 与 第 14 节。
+ - 少数 LinuxCNC 原生命令(如程序反向/正向、块删除、可选停止、Rapid 倍率、刀具表编辑)在本 WASM 运行时尚未开放,界面按钮置灰并在悬停时说明原因,这是设计上的"显式边界",不是故障。
+ - 断电崩溃恢复只覆盖页面刷新与 OPFS 检查点范围,不代表物理断电场景。
+
+
+
+
+ 2. 五分钟快速上手
+ 下面是从零到"跑起来一段五轴程序"的最短路径,按顺序操作即可。
+
+ - 打开页面。访问 https://49.232.250.35:58010/,等待右上角变成
Connection: READY、案例状态显示 Program: boat-xyzac.ngc。
+ - 确认仿真案例。"Simulation case"下拉框选择
XYZAC TRT(默认值)。
+ - 急停复位并上电。点击工具栏最左侧红色"急停"图标解除急停,再点击其右侧"上电"图标。关键:请按此顺序操作后,才会出现
STATE_ON。
+ - 回零。在左下"Manual Control"页签点击 HOME ALL,状态栏的
Machine sequence 变为 HOMED。
+ - 动一下。选轴
X,按住 + 连续 Jog,或切到 MDI [F5] 页签输入 G0 X10 Y0 Z0 后回车。
+ - 跑程序。点击案例条上的 Load XYZAC boat program,等状态显示
Program: boat-xyzac.ngc 后点击工具栏"运行"图标;用"暂停/继续"和"停止"控制执行。
+
+
+
+ 图 1 软件主界面(XYZAC TRT,已载入 boat-xyzac.ngc,等待上电与回零)。点击可放大查看各区域位置,区域说明见第 3 节。
+
+ 提示:只要不刷新页面,程序、机床模型和当前位置都会保留。刷新后仿真会按当前配置重新启动;若之前保存过检查点,可看到恢复提示。
+
+
+
+ 3. 界面总览
+ 界面自上而下分为六层,操作时按"选择对象 → 上电 → 回零 → 手动/程序"的次序使用即可。
+
+ | 区域 | 位置 | 作用 |
+
+ | 标题栏 | 最上方 | 左侧为产品标识;右侧为 使用说明书 按钮(新标签页打开本手册)与连接状态指示 Connection: READY |
+ | 菜单栏 | 标题栏下方 | File(宿主边界项,置灰)、Help → 使用说明书 / About AXIS |
+ | 工具栏 | 菜单下方图标行 | 急停、上电、打开文件、载入案例、重载、运行、单步、暂停/继续、停止、缩放、六个视图方向、拖动旋转、清除轨迹 |
+ | 案例与程序条 | 工具栏下方 | 选择仿真案例、载入内置船体程序、打开 RTCP 程序库、显示当前程序与加载进度 |
+ | 状态栏 | 案例条下方 | Task/解释器/运动模式/回零等运行状态,以及最近一条命令反馈 |
+ | 轴位与实时参数 | 状态栏下方 | 当前机型的坐标轴读数与关节值(J0…Jn)、偏置,以及主轴/进给/速度/倍率等实时量 |
+ | 操作区(左右分栏) | 页面下半部 | 左:Manual Control / MDI / Diagnostics 页签;右:Preview(刀路 + 机床模型)/ DRO 页签。底部为倍率滑块与预览图层开关 |
+
+
+ 界面图标沿用 LinuxCNC 官方 AXIS 位图资源,含义与桌面版一致(绿点=上电、双三角=运行、蓝色暂停=暂停、红色方块=停止)。把鼠标悬停在按钮上会显示名称和快捷键,按钮被禁用时会说明被禁用的原因。
+
+
+
+ 4. 状态与运行反馈
+
+ 4.1 状态栏字段
+
+ | 字段 | 取值示例 | 含义 |
+
+ | Task mode | MANUAL / MDI / AUTO | 任务模式:手动、MDI 单句、自动运行程序 |
+ | Task state | STATE_ESTOP、STATE_ESTOP_RESET、STATE_ON | 机床状态:急停中、急停已解除、已上电 |
+ | Interpreter | INTERP_IDLE / INTERP_READING / INTERP_PAUSED | G 代码解释器状态:空闲、读取中、已暂停 |
+ | Motion mode | FREE / TELEOP / COORD | 运动模式:自由、手动点动、坐标运动 |
+ | Motion type | 0 / 1 … | 当前运动类型编号(直线、圆弧等) |
+ | Homing | IDLE / ACTIVE | 是否正在回零 |
+ | Machine sequence | NOT HOMED / HOMED | 机床是否已完成回零 |
+ | 命令反馈 | PROGRAM_LOAD acknowledged | 最近一条命令的处理结果;被拒时也会在此显示原因 |
+
+
+
+ 4.2 常见"操作被拒绝"及处理
+
+ | 现象 | 原因 | 处理 |
+
+ 上电按钮置灰,提示 linuxcnc_wasm_snapshot_pending | 仿真会话还没就绪 | 等待状态栏出现具体状态;若超过 1 分钟仍为 PENDING,刷新页面 |
+ Jog / MDI / 主轴按钮置灰,提示 axis_machine_off | 尚未上电 | 先急停复位,再点上电 |
+ 手动按钮置灰,提示 not_homed | 未回零 | 点击 HOME ALL |
+ | 加载程序被拒,提示机型不匹配 | 程序要求的轴组与当前机型不一致(如 AC 程序用于 BC 机型) | 切换仿真案例,或在 RTCP 程序库中选择当前机型分类下的程序 |
+ | 单步/继续被拒 | 当前没有处于暂停或运行状态 | 先运行或暂停程序 |
+
+
+ 安全习惯:本平台只做仿真。但为养成正确操作习惯,请始终遵循"急停复位 → 上电 → 回零 → 运行"的顺序,题目与机床操作一致。
+
+
+
+ 5. 机床案例与程序
+
+ 5.1 选择仿真案例
+ "Simulation case"下拉框列出全部案例。切换案例会重新加载该机床的配置(轴数、限位、运动学、默认程序),界面轴位显示与 Jog 轴按钮随之改变。
+ 切换到 XYZBC TRT 后,轴组从 X Y Z A C 变为 X Y Z B C,默认程序变为 boat-xyzbc.ngc:
+
+
+ 图 2 切换到 XYZBC TRT 案例,轴组、机床标定参数与默认程序同步更新。
+
+ 切换案例时,界面会先自动下电并重载工作区,正在运行的程序会被终止。请先停止程序再切换机型。
+
+ 5.2 内置船体程序与 RTCP 程序库
+ 案例条上提供两个程序入口:
+
+ - Load XYZAC boat program:一键载入该机型自带的五轴船体干运行程序,适合快速演示。
+ - AC(XYZAC)RTCP 程序 (16):打开 LinuxCNC 官方 RTCP 程序库。
+
+
+
+ 图 3 RTCP 程序库。上方显示当前机型分类数量与可直接加载数量,中间可选择显示范围与具体程序,下方给出类型、机型、特点、源路径与兼容性判断。
+
+ 程序库按当前机型自动过滤:AC 机型默认显示 16 个相关程序,其中 9 个可作为主程序直接加载;切换到"全部 45 个程序"可以查看和下载其它机型的源程序,但它们不会作为当前机型的可执行项。每个程序都提供:
+
+ | 按钮 | 作用 |
+
+ | 查看源程序 | 在对话框内直接阅读该 .ngc 的完整文本(来自 LinuxCNC 源码树) |
+ | 加载到控制器 | 把程序文本交给 LinuxCNC WASM 解释器,刷新刀路预览;机型不匹配时按钮禁用并说明原因 |
+ | 下载 .ngc | 保存原始程序文件到本地,便于在其它软件中使用 |
+
+
+
+
+ 图 4 加载成功后在对话框底部提示"程序已加载到 LinuxCNC WASM 控制器",同时预览区刷新为新的刀路。
+
+
+ 5.3 打开本地 G 代码
+ 点击工具栏"打开文件"图标,选择本地 .ngc / .nc / .gcode / .tap 或纯文本文件即可载入。载入前会检查程序中的轴字是否与当前机型匹配,不匹配会拒绝并给出提示,避免用 AC 程序驱动 BC 机型。
+ 工具栏"重载"图标(Ctrl+R)用于把当前编辑器中的程序重新装载到解释器,适合手工修改后快速重试。
+
+
+ 图 5 File 菜单中"编辑刀具数据""退出"属于宿主进程能力,在浏览器中明确置灰,而不是伪装成可用。
+
+
+
+
+ 6. 手动控制
+ 左下角 Manual Control [F3] 页签是日常操作的主要区域。所有按钮都受"上电 + 已回零 + 无程序运行"的门控约束,条件不满足时置灰并说明原因。
+
+ 6.1 回零
+
+ - HOME ALL:按机床配置依次回零所有关节,完成后按钮变为 RE-HOME ALL。
+ - Joint 下拉 + Home Joint:只回零指定关节,便于排查单轴问题。
+
+
+
+ 图 6 回零完成:状态栏 Machine sequence 显示 HOMED,轴位归零,手动与程序按钮全部解锁。
+
+
+ 6.2 Jog 点动
+
+ - 在 Axis 中选中要移动的轴(如
X)。
+ - 在 Jog 行按住 - 或 + 连续点动,松开即停。
+ - 需要精确步进时,把中间的步长下拉框从
Continuous 改为 0.1000 / 0.0100 / 0.0010 / 0.0001,再点按一次按钮,即按该步长走一段。
+ - 键盘快捷:c 切回连续,i 在步长之间循环切换。
+
+
+
+ 图 7 按住 Jog + 期间 X 轴数值实时跟随 WASM 运动学输出变化;松开后停止。
+
+ Jog Speed 倍率位于页面底部,可按比例降低点动速度;五轴机床在回转轴接近行程极限时建议调低,便于观察。
+
+ 6.3 主轴、冷却液与刀具
+
+ | 控件 | 说明 |
+
+ | 主轴逆时针 / 顺时针图标 | 启动主轴相应转向,图标按下表示当前转向 |
+ | Stop Spindle | 停止主轴 |
+ | + / - 长按 | 按住期间以 100 rpm 步进增减转速,松开恢复恒定转速 |
+ | Brake | 主轴制动(快捷键 B 制动 / b 松开) |
+ | Mist / Flood | 雾状 / 大流量冷却液开关 |
+ | Tool / Probe | 显示当前刀具号、刀具状态与探针状态(只读) |
+
+
+
+
+ 图 8 启动主轴与打开雾状冷却液后,上方"Live parameters"面板同步显示主轴转速与指令值。
+
+ Touch Off / Tool Touch Off:用于设置工件坐标系与刀具偏置。当前 WASM 运行时未开放刀具偏置 ABI,Tool Touch Off 置灰;Touch Off 的可用性随机型与状态变化。
+
+
+
+ 7. MDI 与 DRO
+
+ 7.1 MDI 单句执行
+ 切到 MDI [F5] 页签,在"MDI Command"输入框键入一行 G 代码,回车或点 Go 执行。按 ↑ / ↓ 可在历史命令间翻找,双击历史条目也可以直接执行。
+
+
+ 图 9 执行 G0 X12.5 Y-4 Z0 后,轴位与刀路预览同步更新,命令进入下方 MDI History。
+
+ 常用 MDI 指令:
+ G0 X0 Y0 Z0 快速定位到原点
G1 X10 F200 以 200 mm/min 直线进给
G91 G1 X5 F100 增量方式移动 5 mm
M3 S1200 主轴正转 1200 rpm
G54 切入 1 号工件坐标系
+
+ 7.2 DRO 位置显示
+ DRO 页签以表格形式显示每个轴的三个读数,并提供三个显示开关:
+
+ | 开关 | 可选值 | 说明 |
+
+ | Coordinate | Machine / Relative | 机床坐标(回零后原点)或相对坐标 |
+ | Display | Actual / Commanded | 实际反馈位置或指令目标位置 |
+ | Units | mm / inch | 线性轴单位;旋转轴始终为度 |
+
+
+
+
+ 图 10 DRO 页签显示实际位置、指令位置与相对坐标三列,页脚汇总单位、当前协调模式和仿真 tick。
+
+
+
+
+ 8. 倍率与显示图层
+ 页面底部左侧是四个倍率滑块,右侧是六个预览图层开关。
+
+ | 控件 | 范围 | 用途 |
+
+ | Jog Speed | 6% – 100% | 手动点动速度比例 |
+ | Feed Override | 0% – 上限 | 程序进给倍率,调试时降低进给 |
+ | Spindle Override | 0% – 200% | 程序主轴转速倍率 |
+ | Max Velocity | 6% – 100% | 整体最大速度限制 |
+ | Rapid Override | — | 当前 WASM 轨迹规划器未开放,固定显示 Unavailable |
+
+
+ 预览图层:path 刀具路径、live 实时刀具位置、limits 行程边界、velocity 速度着色、offsets 坐标偏置指示、extents 毛坯包络。按需勾选可以避免画面过密。
+
+
+ 图 11 进给倍率调整到 80%,并临时关闭 velocity 图层观察刀路本体。
+
+
+
+
+ 9. 程序运行控制
+ 工具栏中部的五个按钮控制程序执行,顺序与 LinuxCNC AXIS 一致:
+
+ | 按钮 | 作用 | 前置条件 |
+
+ | 运行 | 从当前行开始执行程序 | 机床已上电、已回零、程序已装载 |
+ | 单步 | 只执行下一行 | 程序处于运行或暂停状态 |
+ | 暂停 / 继续 | 暂停当前运动;再次点击继续 | 程序运行中 / 已暂停 |
+ | 停止 | 中止程序并清空运动队列 | 程序运行或暂停中 |
+
+
+
+
+ 图 12 运行中:Task mode 为 AUTO、Interpreter 为 INTERP_READING,预览区显示刀具沿刀路移动,编辑器高亮当前执行行。
+
+
+
+ 图 13 暂停:Interpreter 变为 INTERP_PAUSED,运动停止但位置保持,可继续、单步或停止。暂停期间 MDI 会被拒绝,这是 LinuxCNC 的既有边界。
+
+ 程序编辑器支持行号槽与当前行高亮,右侧"Active G-Codes"实时显示生效的模态指令,便于教学逐行讲解。
+
+
+
+ 10. 三维预览与机床管理
+
+ 10.1 双视图预览
+ 右侧 Preview 页签分成左右两个画布:左边是刀路与刀具仿真,右边是机床三维模型,两者都由同一份 WASM 关节状态驱动。工具栏的六个视图按钮(俯视、旋转俯视、侧视、正视、倒视、透视)、缩放和"拖动旋转"模式同时作用于两个视图。
+
+
+ 图 14 切换视图方向并清除实时轨迹后,只保留当前刀路;机床模型姿态与 WASM 关节一致。
+
+
+ 10.2 三维机床管理
+ 预览区右上角第一个按钮打开三维机床管理对话框,用于把真实机床的 GLB 模型绑定为可驱动的五轴机床。对话框分四个步骤页签:
+
+ | 页签 | 内容 |
+
+ | 部件与层级 | GLB 内部结构树、搜索定位、节点自由度绑定(每个节点一个唯一自由度)、模型选择与删除、撤销/重做 |
+ | 运动验证 | 跟随数控系统实时关节位置、按物理零点手动验证、指定轴的层级运动测试 |
+ | 机床标定 | C 轴原点、A/B 到 C 轴偏置、机床物理零点(直线 mm / 旋转 deg) |
+ | 文件与恢复 | 模型名称与设计说明、保存整机 GLB、本地机床库、13 个 LinuxCNC 案例模板 |
+
+
+
+
+ 图 15 "部件与层级":导入模型后在此展开层级树,选择节点并指定其自由度。
+
+
+
+ 图 16 "运动验证":可锁定跟随实时关节,也可按物理零点手动输入 X/Y/Z/A/B/C 检查模型运动是否正确;这些输入只驱动模型,不向控制器发送运动命令。
+
+
+
+ 图 17 "机床标定":填写旋转中心、轴间偏置与物理零点,与 LinuxCNC 原生 TRT 运动学参数一一对应。
+
+
+
+ 图 18 "文件与恢复":命名并保存整机 GLB,或从本浏览器机床库打开、再下载、删除;下方列出 13 个 LinuxCNC 案例模板。
+
+
+ 10.3 导入机床模型
+ 第二个按钮打开三维机床模型对话框,可以直接试用四套内置示例(AC、BC、AC 桁架、BC 桁架),或导入自己的 GLB 文件。
+
+
+ 图 19 内置示例可以一键载入或下载;单个文件上限 64 MiB,超限会给出明确提示且不影响当前模型。
+
+ 导入的模型只在浏览器内解析与显示。若模型带有 JT_X / JT_Y / JT_Z / JT_A / JT_B / JT_C 之类的语义节点名,管理器会自动给出五轴识别结果与运动学建议,方便快速完成绑定。
+
+ 10.4 机床几何参数
+ TRT 机型还提供机床参数对话框,用于编辑旋转中心与轴间偏置,并支持"应用参数""重新读取""保存 INI"。修改参数后需要重新回零,界面会给出提示。
+
+
+ 图 20 旋转中心(X/Y/Z)与到 C 轴偏置(X/Y/Z)共六个参数直接对应 INI 中的运动学配置。
+
+
+
+
+ 11. 诊断信息
+ Diagnostics 页签汇总运行环境与限制,排查问题时优先看这里:
+
+ - Startup resources:当前案例入口文件、解析成功的资源数与阻塞数、HAL 记录数。
+ - Disabled operations:列出当前被门控/未开放的操作及原因(如
blocked_by_abi)。
+ - Source diagnostics:启动过程中缺失或未解析的资源路径与行号。
+
+
+
+ 图 21 诊断页显示 tick 计数、状态哈希、启动资源清单与被禁用操作的逐条原因。
+
+
+
+
+ 12. 快捷键与鼠标操作
+
+ | 按键 / 操作 | 功能 |
+
+ | F1 | 急停 / 解除急停(切换) |
+ | F2 | 上电 / 下电 |
+ | F3 / F5 | 切到 手动控制 / MDI 页签 |
+ | R | 运行程序 |
+ | P / S | 暂停 / 继续 |
+ | T | 单步执行下一行 |
+ | Esc | 停止程序(对话框打开时先关闭对话框) |
+ | Ctrl+R | 重载当前程序 |
+ | Ctrl+K | 清除实时轨迹 |
+ | Home / End | 回零当前关节 / 对当前关节 Touch Off |
+ | Ctrl+Home / Ctrl+End | 全部回零 / 刀具 Touch Off |
+ | B / b | 主轴制动 / 松开制动 |
+ | c / i | Jog 切回连续 / 循环切换增量步长 |
+ | 单击机床模型 | 选中该导入模型(黄色包围框) |
+ | 按住拖动预览区 | 旋转视角(开启"拖动旋转"模式) |
+
+
+ 窄屏(手机、平板竖屏)下界面自动改为上下堆叠布局,不会出现横向滚动:
+
+
+ 图 22 390 px 视口下的完整页面布局(整页截图)。
+
+
+
+
+ 13. 支持的机床案例
+ "Simulation case"下拉框中共有 13 个案例。13 个案例都可以在案例下拉框中直接切换:控制器用各自对应的 WASM 运行配置启动,预览区自动加载该案例的结构 GLB,并按 LinuxCNC 联合号(槽位)实时驱动关节。所有运动学求解仍由 LinuxCNC WASM 完成。
+
+ | 案例 | 坐标轴 | 运动学 | 状态 | 说明 |
+
+ | XYZAC TRT | X Y Z A C | xyzac-trt-kins | 可用 | A 轴绕 X 倾转、C 轴绕 Z 回转,支持 RTCP 与 M428/M429 切换 |
+ | XYZBC TRT | X Y Z B C | xyzbc-trt-kins | 可用 | B 轴绕 Y 倾转、C 轴绕 Z 回转,支持 RTCP |
+ | xyzab-tdr | X Y Z A B | xyzab-tdr | 可用 | 双旋转台;可在 Worker 层运行,浏览器侧尚未就绪 |
+ | bridge-mill-5axiskins | X Y Z B C W Y | 5axiskins | 可用 | 龙门式五轴,带 W 轴沿刀轴进给 |
+ | max5-maxkins | X Y Z B C | maxkins | 可用 | MAX5 结构 |
+ | hexapod-genhexkins | X Y Z A B C | genhexkins | 可用 | 六自由度并联平台 |
+ | pentapod-pentakins | X Y Z A B | pentakins | 可用 | 五杆并联结构 |
+ | puma-pumakins | X Y Z A B C | pumakins | 可用 | 串联关节机器人 |
+ | puma-genserkins | X Y Z A B C | genserkins | 可用 | 串联机器人(通用求解器) |
+ | trivkins-xyzab | X Y Z A B | trivkins | 可用 | 直角坐标一一映射基线,无 RTCP |
+ | trivkins-xyzcw | X Y Z C W | trivkins | 可用 | 车铣复合映射基线 |
+ | trivkins-xyzya | X Y Z Y A | trivkins | 可用 | 双 Y 轴映射基线 |
+ | scorbot-kins | X Y Z A B | scorbot-kins | 可用 | SCORBOT 机械臂 |
+
+
+ "可用"表示该案例可直接切换:控制器用对应的 WASM 运行配置启动,预览区加载该案例的结构 GLB,并按 LinuxCNC 联合号(槽位)显示实时关节值。TRT 案例另支持 RTCP 与 M428/M429 切换。切换案例会先给机床下电,重新运行程序前需要重新上电并 HOME ALL。
+
+
+
+ 14. 已知问题与规避
+ 以下是本次上线功能巡检(2026-10-08)中发现并已复现的问题,均已给出规避办法。工具栏"载入精确案例"按钮曾经失效,原因是点击事件对象被当成案例路径传入,该问题已修复并重新发布,本次巡检中该按钮已能正常重新载入当前案例。
+
+ 14.1 通用运动学案例的浏览器启动链(已修复)
+ 历史现象:13 个案例中除 XYZAC/XYZBC TRT 之外的通用案例在浏览器里停在 PENDING,上电按钮提示 linuxcnc_wasm_snapshot_pending,案例下拉框也无法再切换。
+ 根因有两处:一是 woodpecker、gmoccapy、qtdragon 三个案例配置目录没有随前端产物发布,请求返回 404;二是案例切换沿用了 AXIS"装载需要机床已上电"的门控,而切换本身会先下电,于是从第二次切换起被静默拒绝。
+ 修复内容:构建复制清单补齐三个配置目录;案例切换只受"程序正在装载/运行/暂停"限制,不再要求上电。验收结果:13 个案例逐个切换全部装载成功,槽位键与案例定义逐一一致,浏览器错误 0(coverage/glb-machine-manager/physical-drive/browser.report.json)。
+
+
+
+ 15. 常见问题
+
+ 页面一直显示 PENDING,上电按钮点不了?
+ 多数情况是仿真会话尚未就绪,等待数秒即可;若超过 1 分钟仍不变,或曾在案例下拉框里选过通用运动学案例,请刷新页面。
+
+ Jog、MDI、主轴按钮都是灰的?
+ 按"急停复位 → 上电"的顺序操作,并确认状态栏 Task state 为 STATE_ON、Machine sequence 为 HOMED。把鼠标停在灰按钮上会显示具体原因。
+
+ 为什么 MDI 在程序暂停时无法执行?
+ 这是 LinuxCNC 的既有语义:程序运行/暂停期间 MDI 会被拒绝(mdi_while_program_active)。停止或让程序结束后再使用 MDI。
+
+ 加载 G 代码时提示机型不匹配?
+ 程序中的轴字与当前机型不一致。请先用案例下拉框切到对应机型(AC 或 BC),或使用 RTCP 程序库自动按机型过滤后的程序。
+
+ 浏览器提示证书不受信任?
+ 站点使用 IP 地址的 HTTPS 自签证书。确认地址无误后选择"高级 / 继续访问";如需长期使用,可让管理员把证书加入受信任列表。
+
+ 导入的机床模型会丢失吗?
+ 页面刷新后导入的模型需要重新导入;使用"三维机床管理 → 文件与恢复"保存到浏览器机床库的机床,可以在同一浏览器、同一地址下重新打开。清理浏览器站点数据会同时删除机床库记录。
+
+ 能连接真实机床吗?
+ 不能,也不在本项目范围内。本平台是纯仿真环境,硬件驱动保持在浏览器虚拟设备边界。
+
+
+
+ 16. 附录:功能测试结论
+ 本次发布软件的自动化功能巡检于 2026-10-08 在 https://49.232.250.35:58010/ 上用真实 Chrome 浏览器执行,覆盖 26 个功能步骤,全部通过;另有 1 项待完成项(通用运动学案例的浏览器运行链,见第 14 节)。
+
+ | 范围 | 覆盖内容 | 结果 |
+
+ | 启动与状态 | 页面加载、WASM 会话就绪、状态栏、轴位与实时参数来源 | 通过 |
+ | 菜单 | Help → About、File 菜单宿主边界项置灰 | 通过 |
+ | 程序资源 | RTCP 程序库按机型过滤、加载到控制器、打开本地 G 代码、重载 | 通过 |
+ | 机床模型 | 三维机床管理四个页签、导入机床模型、机床几何参数 | 通过 |
+ | 案例切换 | XYZAC ↔ XYZBC 切换及轴组同步 | 通过 |
+ | 机床操作 | 急停复位、上电、HOME ALL、连续与增量 Jog | 通过 |
+ | 辅助功能 | 主轴启停、冷却液、Tool/Probe 状态 | 通过 |
+ | MDI 与 DRO | MDI 执行与历史、DRO 三种显示切换 | 通过 |
+ | 倍率与图层 | 进给倍率调整、预览图层开关 | 通过 |
+ | 程序运行 | 载入、运行、暂停、继续、停止、工具栏"载入精确案例"重新载入 | 通过 |
+ | 视图与诊断 | 视图切换、清除轨迹、诊断页、快捷键、窄屏布局 | 通过 |
+ | 案例矩阵 | 13 个案例逐一探测 | 2 可用 / 11 待完成 |
+
+
+ 测试脚本与原始报告:docs/user-manual/capture_deployed_manual.mjs、docs/user-manual/deployment-test.report.json;截图位于 docs/user-manual/screenshots/。重新执行:
+ PLAYWRIGHT_MODULE=<playwright> node docs/user-manual/capture_deployed_manual.mjs
+
+
+
+
+
+
diff --git a/docs/user-manual/README.md b/docs/user-manual/README.md
new file mode 100644
index 0000000..4b7d8d9
--- /dev/null
+++ b/docs/user-manual/README.md
@@ -0,0 +1,45 @@
+# 使用说明书(HTML)与发布功能巡检
+
+本目录存放线上发布站点 `https://49.232.250.35:58010/` 的用户使用说明书和配套的功能测试。
+
+## 交付物
+
+- [`LinuxCNC-Web-使用说明书.html`](LinuxCNC-Web-使用说明书.html):单文件中文使用说明书,22 张实测截图以 data URL 内嵌,可离线打开、可打印/导出 PDF,桌面与窄屏均无横向溢出。
+ 已随前端产物发布:线上地址 ,主页标题栏的"使用说明书"按钮与 Help 菜单指向同一入口。
+- `manual-content.html`:说明书正文源稿(图片以相对路径引用),修改内容后重新构建即可。
+- `build_manual.mjs`:把正文中的截图内联,生成最终单文件 HTML。
+- `capture_deployed_manual.mjs`:线上站点功能巡检脚本,同时采集说明书截图。
+- `local-walkthrough.mjs`:按说明书顺序在本地发布站点上完整走一遍标准使用流程的演练脚本(`WALKTHROUGH_HEADED=1` 可在桌面开可见窗口逐步演示),输出到 `local-walkthrough/`。
+- `deployment-test.report.json`:最近一次巡检报告(步骤结果、缺陷、案例矩阵、HTTP 失败记录)。
+- `local-walkthrough/walkthrough.report.json`:本地发布演练的分步结果与发现。
+- `screenshots/`:说明书插图原始 PNG。
+
+## 重新生成
+
+```bash
+# 1. 重新巡检线上站点并刷新截图与报告(约 8 分钟)
+PLAYWRIGHT_MODULE=/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs \
+node docs/user-manual/capture_deployed_manual.mjs
+
+# 2. 重新构建单文件说明书
+node docs/user-manual/build_manual.mjs
+```
+
+脚本可用 `MANUAL_URL` 指向其它地址(例如本地 `http://127.0.0.1:5174/`),用 `CHROME_PATH` 指定浏览器。
+
+## 最近一次巡检结论(2026-10-08)
+
+- 26 个功能步骤全部通过:启动与状态、菜单、RTCP 程序库、机床模型对话框、案例切换、急停/上电/回零、连续与增量 Jog、主轴与冷却液、MDI、DRO、倍率与图层、工具栏"载入精确案例"重新载入、程序运行/暂停/继续/停止、视图、诊断、快捷键、390 px 窄屏布局。
+- 案例可用性:2026-10-29 复测 13 个案例全部可切换并装载各自结构模型(`coverage/glb-machine-manager/physical-drive/browser.report.json`,13/13,浏览器错误 0)。
+- 待完成项 0 项。
+
+## 修复记录
+
+- 2026-10-08 `DEFECT-01` 已修复:工具栏"载入精确案例"按钮此前把点击事件对象当成案例路径(`simulation_case_unknown`)。现已在 `loadCase` 入口统一把非字符串入参回退为当前案例,线上版本 `20261008c-linuxcnc-wasm-af65b69` 验证点击后正常出现 `Loading` 并回到 `PROGRAM_LOAD acknowledged`。
+
+注意:重新载入案例会按设计先给机床下电并复位回零状态,重新运行程序前需要重新上电并 HOME ALL。
+
+## 本地发布演练发现
+
+- `FINDING-01`(中):MDI 执行完成后(`Planner queued=0`、`Task mode=MDI`),"Load XYZAC boat program" 仍被门控拒绝并提示 `blocked:xyzac_motion_active`,需要切回 Manual Control 页签(发出一条模式命令刷新门控)或刷新页面才能装载程序。属于门控状态刷新滞后,不是 MDI 运动本身的问题。
+- 2026-10-29 `DEFECT-02` 已修复:`woodpecker`、`gmoccapy`、`qtdragon` 三个案例配置目录进入构建复制清单,案例切换不再要求机床已上电。13 案例逐个切换验收通过,13/13 装载成功、槽位键与案例定义一致、浏览器错误 0。
diff --git a/docs/user-manual/build_manual.mjs b/docs/user-manual/build_manual.mjs
new file mode 100644
index 0000000..6cf7dc5
--- /dev/null
+++ b/docs/user-manual/build_manual.mjs
@@ -0,0 +1,35 @@
+// 把 manual-content.html 中的截图内联为 data URL,输出可离线单文件阅读的使用说明书。
+//
+// 运行:node docs/user-manual/build_manual.mjs
+// 输出:docs/user-manual/LinuxCNC-Web-使用说明书.html
+
+import { readFile, stat, writeFile } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const contentPath = resolve(here, "manual-content.html");
+const outputPath = resolve(here, "LinuxCNC-Web-使用说明书.html");
+
+const source = await readFile(contentPath, "utf8");
+const references = [...source.matchAll(/src="(screenshots\/[^"]+\.png)"/g)].map((match) => match[1]);
+const unique = [...new Set(references)];
+
+let html = source;
+let totalBytes = 0;
+for (const reference of unique) {
+ const file = resolve(here, reference);
+ const info = await stat(file).catch(() => null);
+ if (!info) throw new Error(`缺少截图:${reference}`);
+ const base64 = (await readFile(file)).toString("base64");
+ totalBytes += info.size;
+ html = html.replaceAll(`src="${reference}"`, `src="data:image/png;base64,${base64}"`);
+}
+
+const remaining = [...html.matchAll(/src="(screenshots\/[^"]+)"/g)];
+if (remaining.length) throw new Error(`仍有未内联的图片:${remaining.map((match) => match[1]).join(", ")}`);
+
+await writeFile(outputPath, html);
+const outputSize = (await stat(outputPath)).size;
+console.log(`内联 ${unique.length} 张截图(原始 ${(totalBytes / 1024 / 1024).toFixed(2)} MiB)`);
+console.log(`已生成 ${outputPath}(${(outputSize / 1024 / 1024).toFixed(2)} MiB)`);
diff --git a/docs/user-manual/capture_deployed_manual.mjs b/docs/user-manual/capture_deployed_manual.mjs
new file mode 100644
index 0000000..4c29a60
--- /dev/null
+++ b/docs/user-manual/capture_deployed_manual.mjs
@@ -0,0 +1,554 @@
+// 对线上发布站点做功能巡检并采集使用说明书插图。
+//
+// 运行:
+// PLAYWRIGHT_MODULE=/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs \
+// node docs/user-manual/capture_deployed_manual.mjs
+//
+// 可选环境变量:MANUAL_URL(默认线上 58010 站点)、CHROME_PATH。
+// 输出:docs/user-manual/screenshots/*.png 与 docs/user-manual/deployment-test.report.json
+//
+// 结构:
+// 阶段一 在 XYZAC 上跑完整操作链(上电、回零、Jog、主轴、MDI、程序运行等)。
+// 阶段二 逐案例探测仿真案例在浏览器中的可用性(每案例独立页面)。
+
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const shotDir = resolve(here, "screenshots");
+const reportPath = resolve(here, "deployment-test.report.json");
+const baseUrl = process.env.MANUAL_URL ?? "https://49.232.250.35:58010/";
+const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome";
+const playwrightModule = process.env.PLAYWRIGHT_MODULE;
+
+const results = [];
+const consoleErrors = [];
+const failedRequests = [];
+const artifacts = [];
+const defects = [];
+const caseMatrix = [];
+
+await mkdir(shotDir, { recursive: true });
+
+const { chromium } = await import(playwrightModule);
+const browser = await chromium.launch({
+ executablePath: chromePath,
+ args: ["--no-sandbox", "--ignore-certificate-errors", "--enable-unsafe-swiftshader"],
+});
+
+const newPage = async () => {
+ const page = await browser.newPage({ ignoreHTTPSErrors: true, viewport: { width: 1440, height: 900 } });
+ page.on("pageerror", (error) => consoleErrors.push({ kind: "pageerror", text: String(error) }));
+ page.on("console", (message) => { if (message.type() === "error") consoleErrors.push({ kind: "console", text: message.text() }); });
+ page.on("requestfailed", (request) => failedRequests.push(`${request.url()} ${request.failure()?.errorText ?? ""}`));
+ page.on("response", (response) => { if (response.status() >= 400) failedRequests.push(`HTTP ${response.status()} ${response.url()}`); });
+ return page;
+};
+
+const page = await newPage();
+
+const step = async (name, body) => {
+ const started = Date.now();
+ try {
+ const detail = await body();
+ results.push({ name, status: "pass", ms: Date.now() - started, detail: detail ?? null });
+ console.log(`PASS ${name}${detail ? ` — ${typeof detail === "string" ? detail : JSON.stringify(detail)}` : ""}`);
+ } catch (error) {
+ results.push({ name, status: "fail", ms: Date.now() - started, detail: String(error?.message ?? error) });
+ console.log(`FAIL ${name} — ${error?.message ?? error}`);
+ }
+};
+
+const shot = async (name, { full = false } = {}) => {
+ await page.screenshot({ path: resolve(shotDir, `${name}.png`), fullPage: full });
+ artifacts.push(`${name}.png`);
+};
+
+const text = async (selector) => (await page.locator(selector).first().innerText()).replace(/\s+/g, " ").trim();
+const statusText = () => text(".machine-status");
+const positions = async () => page.locator(".xyzac-position-grid output").allInnerTexts();
+const settle = async (ms = 600) => { await page.waitForTimeout(ms); };
+
+const closeDialog = async () => {
+ const dialog = page.locator("dialog[open]").first();
+ if (!(await dialog.count())) return;
+ const button = dialog.locator('header button[aria-label^="关闭"]').first();
+ if (await button.count()) await button.click();
+ else await page.keyboard.press("Escape");
+ await dialog.waitFor({ state: "hidden", timeout: 5000 }).catch(() => {});
+};
+
+const waitFor = async (predicate, timeout, label) => {
+ const deadline = Date.now() + timeout;
+ let last = "";
+ while (Date.now() < deadline) {
+ last = await predicate();
+ if (last) return last;
+ await page.waitForTimeout(400);
+ }
+ throw new Error(`${label} 在 ${timeout}ms 内未满足,最后观察:${String(last).slice(0, 160)}`);
+};
+
+const waitState = async (pattern, timeout = 60000) => {
+ const deadline = Date.now() + timeout;
+ let last = "";
+ while (Date.now() < deadline) {
+ last = await statusText();
+ if (pattern.test(last)) return last;
+ await page.waitForTimeout(400);
+ }
+ throw new Error(`状态 ${pattern} 在 ${timeout}ms 内未满足,最后状态:${last.slice(0, 200)}`);
+};
+
+const waitCase = (timeout = 120000) => waitFor(async () => {
+ const value = (await page.locator(".case-status").innerText()).trim();
+ return value.startsWith("Program:") ? value : null;
+}, timeout, "案例程序加载完成");
+
+// 重新载入案例会按设计先给机床下电并复位回零状态,运行程序前需要重新上电并回零。
+const ensureReady = async () => {
+ const estop = page.locator('[data-axis-control="toolbar-estop"]');
+ if ((await estop.getAttribute("aria-label"))?.includes("Cancel")) { await estop.click(); await settle(1500); }
+ const power = page.locator('[data-axis-control="toolbar-power"]');
+ if ((await power.getAttribute("aria-label"))?.includes("Power on") && !(await power.isDisabled())) {
+ await power.click();
+ await waitState(/STATE_ON|STATE_IDLE/, 40000);
+ }
+ if (!/Machine sequence HOMED/.test(await statusText())) {
+ await page.locator('[data-axis-control="tab-manual"]').click();
+ await page.locator('[data-axis-control="manual-home-all"]').click();
+ await waitState(/Machine sequence HOMED/, 60000);
+ }
+ return statusText();
+};
+
+// canLoad 为假时下拉框会拒绝切换,这里按值重试直到状态真正生效。
+const selectCase = async (value, timeout = 45000) => {
+ const select = page.locator('[data-axis-control="workspace-case-select"]');
+ const deadline = Date.now() + timeout;
+ while (Date.now() < deadline) {
+ await select.selectOption(value);
+ await settle(2500);
+ if ((await select.inputValue()) === value) { await waitCase(); await settle(2500); return value; }
+ }
+ throw new Error(`案例切换未生效:${value}`);
+};
+
+// 冷却液等开关是受 WASM 状态回传驱动的受控复选框,点击后要等状态回传再判断,避免误判失败。
+const setToggle = async (selector, target, attempts = 6) => {
+ const box = page.locator(selector);
+ for (let attempt = 0; attempt < attempts; attempt += 1) {
+ if ((await box.isChecked()) === target) return true;
+ await box.click();
+ await settle(1200);
+ }
+ return (await box.isChecked()) === target;
+};
+
+// ---------------------------------------------------------------- 阶段一:TRT 主线
+await step("打开线上站点并等待 WASM 会话就绪", async () => {
+ await page.goto(baseUrl, { waitUntil: "load", timeout: 90000 });
+ await page.waitForSelector('[data-axis-control="workspace-case-select"]', { timeout: 60000 });
+ await waitState(/STATE_/, 60000);
+ await waitCase(90000);
+ await settle(2500);
+ const cases = await page.locator('[data-axis-control="workspace-case-select"] option').allInnerTexts();
+ await shot("01-main-overview");
+ return { title: await page.title(), canvases: await page.locator("canvas").count(), cases: cases.length };
+});
+
+await step("状态栏、轴位与实时参数由 LinuxCNC WASM 驱动", async () => {
+ const status = await statusText();
+ const live = await page.locator("[data-xyzac-parameter-source]").first().getAttribute("data-xyzac-parameter-source");
+ const axes = await positions();
+ if (!/Task mode/.test(status)) throw new Error(`状态栏异常:${status}`);
+ if (live !== "linuxcnc-wasm") throw new Error(`实时参数来源不是 WASM:${live}`);
+ if (axes.length < 5) throw new Error(`轴位显示数量不足:${axes.length}`);
+ return { live, axes, status: status.slice(0, 120) };
+});
+
+await step("Help 菜单可打开 About 对话框", async () => {
+ await page.locator('[data-axis-control="menu-help"]').click();
+ await page.locator('[data-axis-control="menu-help-about"]').click();
+ const dialog = page.locator(".axis-about-dialog");
+ await dialog.waitFor({ state: "visible", timeout: 5000 });
+ await shot("02-about-dialog");
+ const body = (await dialog.innerText()).replace(/\s+/g, " ");
+ await page.locator('[data-axis-control="about-close"]').click();
+ await dialog.waitFor({ state: "hidden", timeout: 5000 });
+ return body.slice(0, 120);
+});
+
+await step("File 菜单把宿主边界项正确置灰", async () => {
+ await page.locator('[data-axis-control="menu-file"]').click();
+ const states = [
+ await page.locator('[data-axis-control="menu-file-edit-tool-data"]').isDisabled(),
+ await page.locator('[data-axis-control="menu-file-quit"]').isDisabled(),
+ ];
+ await shot("03-file-menu");
+ await page.keyboard.press("Escape");
+ if (states.some((disabled) => !disabled)) throw new Error(`宿主边界项未置灰:${states}`);
+ return states;
+});
+
+await step("RTCP 程序库按机型过滤并列出可加载程序", async () => {
+ await page.locator('[data-axis-control="workspace-rtcp-programs"]').click();
+ const panel = page.locator("dialog.rtcp-program-dialog");
+ await panel.waitFor({ state: "visible", timeout: 8000 });
+ await settle(600);
+ await shot("04-rtcp-program-library");
+ const summary = (await panel.locator(".rtcp-program-summary").innerText()).replace(/\s+/g, " ");
+ const options = await panel.locator('[data-axis-control="rtcp-program-select"] option').count();
+ const scopes = await panel.locator('[data-axis-control="rtcp-program-scope"] option').allInnerTexts();
+ await closeDialog();
+ if (options < 3) throw new Error(`RTCP 程序条目不足:${options}`);
+ return { options, summary, scopes };
+});
+
+await step("导入机床模型对话框列出内置示例与下载入口", async () => {
+ await page.locator('[data-axis-control="machine-glb-settings"]').click();
+ const dialog = page.locator("dialog[open]");
+ await dialog.waitFor({ state: "visible", timeout: 8000 });
+ await settle(800);
+ await shot("05-machine-import-dialog");
+ const downloads = await dialog.locator("a[download]").count();
+ const summary = (await dialog.innerText()).replace(/\s+/g, " ");
+ await closeDialog();
+ return { downloads, summary: summary.slice(0, 160) };
+});
+
+await step("三维机床管理四个步骤页签可切换", async () => {
+ await page.locator('[data-axis-control="machine-manager-settings"]').click();
+ const dialog = page.locator("dialog[open]");
+ await dialog.waitFor({ state: "visible", timeout: 8000 });
+ await settle(1200);
+ const seen = [];
+ for (const [id, name] of [["structure", "06-machine-manager-structure"], ["motion", "07-machine-manager-motion"], ["calibration", "08-machine-manager-calibration"], ["files", "09-machine-manager-files"]]) {
+ await page.locator(`[data-axis-control="machine-manager-tab-${id}"]`).click();
+ await settle(900);
+ await shot(name);
+ seen.push((await text(`#machine-panel-${id}`)).slice(0, 70));
+ }
+ await closeDialog();
+ return seen;
+});
+
+await step("机床参数对话框显示旋转中心与轴间偏置字段", async () => {
+ await page.locator('[data-axis-control="geometry-settings"]').click();
+ const dialog = page.locator("dialog[open]");
+ await dialog.waitFor({ state: "visible", timeout: 8000 });
+ await settle(600);
+ await shot("10-geometry-parameters");
+ const fieldCount = await dialog.locator('input[type="number"]').count();
+ await closeDialog();
+ if (fieldCount < 4) throw new Error(`几何参数字段不足:${fieldCount}`);
+ return fieldCount;
+});
+
+await step("案例切换到 XYZBC 后机型与轴组同步改变", async () => {
+ await selectCase("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini");
+ await shot("11-case-xyzbc");
+ const axes = await page.locator(".axis-radio span").allInnerTexts();
+ if (axes.join("") !== "XYZBC") throw new Error(`切换机型后轴组异常:${axes}`);
+ await selectCase("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini");
+ const back = await page.locator(".axis-radio span").allInnerTexts();
+ if (back.join("") !== "XYZAC") throw new Error(`切回 XYZAC 失败:${back}`);
+ return { axes, back };
+});
+
+await step("急停复位并可上电", async () => {
+ const estop = page.locator('[data-axis-control="toolbar-estop"]');
+ if ((await estop.getAttribute("aria-label"))?.includes("Cancel")) { await estop.click(); await settle(1500); }
+ await page.locator('[data-axis-control="toolbar-power"]').click();
+ return (await waitState(/STATE_ON|STATE_IDLE/, 40000)).slice(0, 120);
+});
+
+await step("HOME ALL 完成全部关节回零", async () => {
+ await page.locator('[data-axis-control="manual-home-all"]').click();
+ const status = await waitState(/Machine sequence HOMED/, 60000);
+ await shot("12-manual-homed");
+ return status.slice(0, 120);
+});
+
+await step("连续 Jog:按住 X 正向位置变化,松手停止", async () => {
+ await page.locator('[data-axis-control="manual-axis-x"]').check();
+ const before = (await positions())[0];
+ await page.locator('[data-axis-control="manual-jog-positive"]').hover();
+ await page.mouse.down();
+ await settle(1800);
+ await shot("13-manual-jog");
+ await page.mouse.up();
+ await settle(1500);
+ const after = (await positions())[0];
+ if (before === after) throw new Error(`连续 Jog 后 X 未变化:${before}`);
+ return { before, after };
+});
+
+await step("增量 Jog:0.1000 步长按一次步进", async () => {
+ await page.locator('[data-axis-control="manual-jog-increment"]').selectOption("0.1000");
+ const before = Number((await positions())[0]);
+ await page.locator('[data-axis-control="manual-jog-negative"]').hover();
+ await page.mouse.down();
+ await settle(1200);
+ await page.mouse.up();
+ await settle(2000);
+ const after = Number((await positions())[0]);
+ await page.locator('[data-axis-control="manual-jog-increment"]').selectOption("Continuous");
+ if (!(Math.abs(Math.abs(before - after) - 0.1) < 0.04)) throw new Error(`增量步长不符:${before} -> ${after}`);
+ return { before, after };
+});
+
+await step("主轴启停与冷却液开关可用", async () => {
+ await page.locator('[data-axis-control="tab-manual"]').click();
+ await page.locator('[data-axis-control="manual-spindle-cw"]').click();
+ await settle(2500);
+ const live = await text('[data-axis-feature="AXIS-LIVE-PARAMETERS"]');
+ const mistOn = await setToggle('[data-axis-control="manual-coolant-mist"]', true);
+ await shot("14-spindle-coolant");
+ await page.locator('[data-axis-control="manual-spindle-stop"]').click();
+ const mistOff = await setToggle('[data-axis-control="manual-coolant-mist"]', false);
+ if (!mistOn || !mistOff) throw new Error(`冷却液开关未按预期切换:开=${mistOn} 关=${mistOff}`);
+ return { mistOn, mistOff, live: live.slice(0, 110) };
+});
+
+await step("MDI 指令执行并写入历史", async () => {
+ await page.locator('[data-axis-control="tab-mdi"]').click();
+ await page.locator('[data-axis-control="mdi-command"]').fill("G0 X12.5 Y-4 Z0");
+ await page.locator('[data-axis-control="mdi-submit"]').click();
+ await settle(3000);
+ await shot("15-mdi");
+ const history = await page.locator('[data-axis-control="mdi-history-select"]').first().innerText();
+ if (!history.includes("G0")) throw new Error(`MDI 历史缺记录:${history}`);
+ return history;
+});
+
+await step("DRO 视图可切换坐标、显示与单位", async () => {
+ await page.locator('[data-axis-control="tab-dro"]').click();
+ await page.locator('[data-axis-control="dro-coordinate"]').selectOption("relative");
+ await page.locator('[data-axis-control="dro-display"]').selectOption("commanded");
+ await page.locator('[data-axis-control="dro-units"]').selectOption("inch");
+ await settle(1500);
+ await shot("16-dro");
+ const rows = await page.locator(".dro-row").count();
+ const footer = await text(".dro-footer");
+ await page.locator('[data-axis-control="dro-units"]').selectOption("mm");
+ await page.locator('[data-axis-control="dro-coordinate"]').selectOption("machine");
+ await page.locator('[data-axis-control="dro-display"]').selectOption("actual");
+ await settle(800);
+ return { rows, footer: footer.slice(0, 110) };
+});
+
+await step("倍率滑块与预览图层开关可用", async () => {
+ await page.locator('[data-axis-control="override-feed"]').fill("80");
+ await settle(1500);
+ const label = (await page.locator("section.axis-metrics").innerText()).replace(/\s+/g, " ");
+ const layer = page.locator('[data-axis-control="preview-layer-velocity"]');
+ await layer.uncheck();
+ await settle(500);
+ const unchecked = !(await layer.isChecked());
+ await layer.check();
+ await shot("17-overrides-layers");
+ if (!unchecked) throw new Error("预览图层开关无效");
+ return label.slice(0, 150);
+});
+
+await step("工具栏『载入精确案例』按钮重新载入当前案例", async () => {
+ await page.locator('[data-axis-control="tab-preview"]').click();
+ await page.locator('[data-axis-control="toolbar-load-case"]').click();
+ // 修复前这里会停在 blocked: simulation_case_unknown,永远不会回到 Program: 状态。
+ const settled = await waitCase(120000);
+ const status = (await page.locator(".case-status").innerText()).trim();
+ if (/^blocked:/.test(status)) throw new Error(`载入精确案例失败:${status}`);
+ return status.slice(0, 80);
+});
+
+await step("载入案例程序并运行、暂停、继续、停止", async () => {
+ await ensureReady();
+ await page.locator('[data-axis-control="workspace-load-boat"]').click();
+ await waitCase();
+ await settle(3000);
+ await page.locator('[data-axis-control="toolbar-run"]').click();
+ const running = await waitState(/STATE_RUNNING|INTERP_READING|INTERP_WAITING/, 60000);
+ await settle(2500);
+ await shot("18-program-running");
+ let paused = "";
+ if (await page.locator('[data-axis-control="toolbar-pause-resume"]').isEnabled()) {
+ await page.locator('[data-axis-control="toolbar-pause-resume"]').click();
+ paused = await waitState(/PAUSED|INTERP_PAUSED/, 30000);
+ await settle(1500);
+ await shot("19-program-paused");
+ await page.locator('[data-axis-control="toolbar-pause-resume"]').click();
+ await settle(2500);
+ }
+ await page.locator('[data-axis-control="toolbar-stop"]').click();
+ await settle(3000);
+ return { running: running.slice(0, 100), paused: paused.slice(0, 100), stopped: (await statusText()).slice(0, 100) };
+});
+
+await step("RTCP 程序库『加载到控制器』可用", async () => {
+ await page.locator('[data-axis-control="workspace-rtcp-programs"]').click();
+ const panel = page.locator("dialog.rtcp-program-dialog");
+ await panel.waitFor({ state: "visible", timeout: 8000 });
+ await settle(800);
+ try {
+ const load = panel.locator('[data-axis-control="rtcp-program-load"]');
+ if (!(await load.isEnabled())) throw new Error("默认主程序当前不可加载");
+ await load.click();
+ await panel.locator('output[role="status"]').filter({ hasText: "程序已加载" }).waitFor({ state: "visible", timeout: 60000 });
+ await shot("22-rtcp-load");
+ return (await panel.locator('output[role="status"]').innerText()).trim();
+ } finally {
+ await closeDialog();
+ await settle(2500);
+ }
+});
+
+await step("工具栏『打开 G 代码文件』可装载本地 .ngc", async () => {
+ await page.setInputFiles('[data-axis-control="program-file-input"]', "web/react/public/machine-cases/xyzac-trt.ngc");
+ await waitCase(90000);
+ await settle(2500);
+ return (await page.locator(".case-status").innerText()).trim();
+});
+
+await step("工具栏『重载当前程序』可用", async () => {
+ await page.locator('[data-axis-control="toolbar-reload"]').click();
+ await settle(4000);
+ return (await statusText()).slice(0, 110);
+});
+
+await step("工具栏视图按钮与清除轨迹可用", async () => {
+ for (const id of ["toolbar-view-z", "toolbar-view-x", "toolbar-view-y", "toolbar-view-p"]) {
+ await page.locator(`[data-axis-control="${id}"]`).click();
+ await settle(500);
+ }
+ const pressed = await page.locator('[data-axis-control="toolbar-view-p"]').getAttribute("aria-pressed");
+ await page.locator('[data-axis-control="toolbar-rotate"]').click();
+ await page.locator('[data-axis-control="toolbar-clear-live-plot"]').click();
+ await settle(1200);
+ await shot("23-views-cleared");
+ if (pressed !== "true") throw new Error(`视图按钮未置为按下:${pressed}`);
+ return { pressed };
+});
+
+await step("诊断页显示启动资源与门控", async () => {
+ await page.locator('[data-axis-control="tab-diagnostics"]').click();
+ await settle(1200);
+ await shot("20-diagnostics");
+ const body = await text("#axis-panel-diagnostics");
+ if (!/Disabled operations|Startup resources/.test(body)) throw new Error(`诊断页内容异常:${body.slice(0, 100)}`);
+ return body.slice(0, 150);
+});
+
+await step("快捷键 F1 / F3 / F5 生效", async () => {
+ await page.locator("body").click({ position: { x: 5, y: 5 } });
+ await page.keyboard.press("F1");
+ const estop = await waitState(/ESTOP/, 15000);
+ await page.keyboard.press("F1");
+ await settle(2000);
+ await page.keyboard.press("F3");
+ await settle(800);
+ const manualSelected = await page.locator('[data-axis-control="tab-manual"]').getAttribute("aria-selected");
+ await page.keyboard.press("F5");
+ await settle(800);
+ const mdiSelected = await page.locator('[data-axis-control="tab-mdi"]').getAttribute("aria-selected");
+ if (manualSelected !== "true" || mdiSelected !== "true") throw new Error(`F3/F5 未切换页签:${manualSelected}/${mdiSelected}`);
+ return { estopTriggered: /ESTOP/.test(estop), manualSelected, mdiSelected };
+});
+
+await step("窄视口 390x844 无横向溢出", async () => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await settle(2500);
+ await shot("21-mobile-390", { full: true });
+ const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
+ await page.setViewportSize({ width: 1440, height: 900 });
+ if (overflow > 2) throw new Error(`出现横向溢出 ${overflow}px`);
+ return { overflow };
+});
+
+await page.close();
+
+// ---------------------------------------------------------------- 阶段二:案例矩阵
+const MATRIX = [
+ ["xyzac-trt", "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini"],
+ ["xyzbc-trt", "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"],
+ ["xyzab-tdr", "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini"],
+ ["max5-maxkins", "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini"],
+ ["trivkins-xyzya", "linuxcnc/configs/sim/woodpecker/woodpecker_xyzya.ini"],
+ ["hexapod-genhexkins", "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini"],
+ ["scorbot-kins", "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini"],
+];
+
+console.log("\n阶段二:案例可用性矩阵(每个案例独立页面)");
+for (const [id, entrypoint] of MATRIX) {
+ const probe = await newPage();
+ const fourXx = [];
+ probe.on("response", (response) => { if (response.status() >= 400) fourXx.push(`${response.status()} ${response.url().replace(baseUrl.replace(/\/$/, ""), "")}`); });
+ try {
+ await probe.goto(baseUrl, { waitUntil: "load", timeout: 90000 });
+ await probe.waitForSelector('[data-axis-control="workspace-case-select"]', { timeout: 60000 });
+ await probe.waitForFunction(() => document.querySelector(".case-status")?.textContent?.startsWith("Program:"), null, { timeout: 90000 });
+ await probe.waitForTimeout(4000);
+ const select = probe.locator('[data-axis-control="workspace-case-select"]');
+ let applied = false;
+ for (let attempt = 0; attempt < 5 && !applied; attempt += 1) {
+ await select.selectOption(entrypoint);
+ await probe.waitForTimeout(2500);
+ applied = (await select.inputValue()) === entrypoint;
+ }
+ await probe.waitForTimeout(20000);
+ const power = probe.locator('[data-axis-control="toolbar-power"]');
+ const usable = applied && !(await power.isDisabled());
+ const status = (await probe.locator(".machine-status").innerText()).replace(/\s+/g, " ");
+ caseMatrix.push({
+ id,
+ applied,
+ power: usable ? "enabled" : `disabled:${await power.getAttribute("title")}`,
+ state: status.slice(0, 90),
+ http_4xx: fourXx.slice(0, 2),
+ verdict: usable ? "usable" : applied ? "loads_but_controller_pending" : "switch_rejected",
+ });
+ console.log(` ${usable ? "可用 " : "不可用"} ${id.padEnd(20)} ${usable ? "可上电" : status.slice(0, 46)}`);
+ } catch (error) {
+ caseMatrix.push({ id, applied: false, power: "unknown", state: String(error?.message ?? error).slice(0, 120), http_4xx: fourXx.slice(0, 2), verdict: "probe_error" });
+ console.log(` 探测失败 ${id} — ${String(error?.message ?? error).slice(0, 80)}`);
+ } finally {
+ await probe.close();
+ }
+}
+
+const pendingCases = caseMatrix.filter((row) => row.verdict !== "usable");
+if (pendingCases.length) {
+ defects.push({
+ id: "DEFECT-02",
+ severity: "high",
+ control: "Simulation case 下拉框中的通用运动学案例",
+ observed: `${pendingCases.map((row) => row.id).join("、")} 载入后控制器停在 PENDING,电源按钮保持禁用(linuxcnc_wasm_snapshot_pending),此时下拉框无法切回其它案例,只能刷新页面恢复`,
+ expected: "选中案例后可上电、回零并运行其干运行程序",
+ root_cause: "通用案例运行链在浏览器侧尚未完成:案例的运动学源文件(如 linuxcnc/src/emc/kinematics/trivkins.c、linuxcnc/src/hal/components/xyzab_tdr_kins.comp)没有随前端产物发布,请求返回 404;且会话启动失败后前端不回滚,卡在 pending",
+ workaround: "当前只使用 XYZAC TRT / XYZBC TRT 两个案例;误选其它案例后刷新浏览器页面",
+ });
+}
+
+const report = {
+ report_id: "LINUXCNC-WEB-DEPLOYED-MANUAL-TEST",
+ url: baseUrl,
+ checked_at: new Date().toISOString(),
+ // 只有步骤失败才判定 failed;已知缺陷(DEFECT-xx)单独记录,不掩盖功能测试结论。
+ status: results.every((row) => row.status === "pass") ? "verified" : "failed",
+ passed: results.filter((row) => row.status === "pass").length,
+ failed: results.filter((row) => row.status === "fail").length,
+ results,
+ defects,
+ case_matrix: caseMatrix,
+ console_errors: consoleErrors,
+ http_and_request_failures: failedRequests,
+ screenshots: artifacts,
+};
+await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`);
+console.log(`\n${report.status.toUpperCase()} ${report.passed} passed / ${report.failed} failed → ${reportPath}`);
+if (defects.length) console.log(`缺陷/限制 ${defects.length} 项:`, defects.map((item) => item.id).join("、"));
+if (consoleErrors.length) console.log("console/page errors:", JSON.stringify(consoleErrors.slice(0, 3)));
+
+await browser.close();
+process.exit(report.status === "verified" ? 0 : 1);
diff --git a/docs/user-manual/deployment-test.report.json b/docs/user-manual/deployment-test.report.json
new file mode 100644
index 0000000..f499886
--- /dev/null
+++ b/docs/user-manual/deployment-test.report.json
@@ -0,0 +1,364 @@
+{
+ "report_id": "LINUXCNC-WEB-DEPLOYED-MANUAL-TEST",
+ "url": "https://49.232.250.35:58010/",
+ "checked_at": "2026-10-08T10:52:07.892Z",
+ "status": "verified",
+ "passed": 26,
+ "failed": 0,
+ "results": [
+ {
+ "name": "打开线上站点并等待 WASM 会话就绪",
+ "status": "pass",
+ "ms": 20508,
+ "detail": {
+ "title": "LinuxCNC Web/WASM",
+ "canvases": 2,
+ "cases": 13
+ }
+ },
+ {
+ "name": "状态栏、轴位与实时参数由 LinuxCNC WASM 驱动",
+ "status": "pass",
+ "ms": 542,
+ "detail": {
+ "live": "linuxcnc-wasm",
+ "axes": [
+ "0.0000",
+ "0.0000",
+ "0.0000",
+ "0.000",
+ "0.000"
+ ],
+ "status": "Task mode MANUAL Task state STATE_ESTOP_RESET Interpreter INTERP_IDLE Motion mode FREE Motion type 0 Homing IDLE Machine"
+ }
+ },
+ {
+ "name": "Help 菜单可打开 About 对话框",
+ "status": "pass",
+ "ms": 5995,
+ "detail": "LinuxCNC AXIS Web/WASM XYZAC TRT OK"
+ },
+ {
+ "name": "File 菜单把宿主边界项正确置灰",
+ "status": "pass",
+ "ms": 2899,
+ "detail": [
+ true,
+ true
+ ]
+ },
+ {
+ "name": "RTCP 程序库按机型过滤并列出可加载程序",
+ "status": "pass",
+ "ms": 5690,
+ "detail": {
+ "options": 16,
+ "summary": "AC(XYZAC) 程序 当前分类:16 个 可直接加载:9 LinuxCNC 源程序:32 个 · 含案例干运行程序共 45 个",
+ "scopes": [
+ "当前 AC(XYZAC) 机床",
+ "全部 45 个程序"
+ ]
+ }
+ },
+ {
+ "name": "导入机床模型对话框列出内置示例与下载入口",
+ "status": "pass",
+ "ms": 5585,
+ "detail": {
+ "downloads": 4,
+ "summary": "三维机床模型 × 三维机床 内置模型 导入 GLB AC 示例 下载 AC BC 示例 下载 BC AC 示例桁架型 下载 AC桁架型 BC 示例桁架型 下载 BC桁架型 单个 GLB,包含层级模型及 URDF,最大 64 MiB"
+ }
+ },
+ {
+ "name": "三维机床管理四个步骤页签可切换",
+ "status": "pass",
+ "ms": 19399,
+ "detail": [
+ "部件与层级 0 节点 展开 折叠 导入机床或部件后显示 GLB 内部层级。 选择层级节点,配置其唯一自由度。",
+ "跟随数控系统实时关节位置 手动验证(相对物理零点) 层级运动测试 复位 X mm Y mm Z mm A deg C deg 这些输入仅验证",
+ "机床标定 C 轴原点 (mm) X Y Z A 到 C 轴偏置 (mm) X Y Z 机床物理零点(按当前坐标,直线 mm / 旋转 deg",
+ "机床文档 模型名称 设计说明 LinuxCNC 五轴案例库 13 个结构/运动学案例 案例来源于仓库内 LinuxCNC 配置、示例 G 代"
+ ]
+ },
+ {
+ "name": "机床参数对话框显示旋转中心与轴间偏置字段",
+ "status": "pass",
+ "ms": 5161,
+ "detail": 6
+ },
+ {
+ "name": "案例切换到 XYZBC 后机型与轴组同步改变",
+ "status": "pass",
+ "ms": 40918,
+ "detail": {
+ "axes": [
+ "X",
+ "Y",
+ "Z",
+ "B",
+ "C"
+ ],
+ "back": [
+ "X",
+ "Y",
+ "Z",
+ "A",
+ "C"
+ ]
+ }
+ },
+ {
+ "name": "急停复位并可上电",
+ "status": "pass",
+ "ms": 1791,
+ "detail": "Task mode MANUAL Task state STATE_ON Interpreter INTERP_IDLE Motion mode FREE Motion type 0 Homing IDLE Machine sequence"
+ },
+ {
+ "name": "HOME ALL 完成全部关节回零",
+ "status": "pass",
+ "ms": 2910,
+ "detail": "Task mode MANUAL Task state STATE_ON Interpreter INTERP_IDLE Motion mode TELEOP Motion type 0 Homing IDLE Machine sequen"
+ },
+ {
+ "name": "连续 Jog:按住 X 正向位置变化,松手停止",
+ "status": "pass",
+ "ms": 7696,
+ "detail": {
+ "before": "0.0000",
+ "after": "0.9075"
+ }
+ },
+ {
+ "name": "增量 Jog:0.1000 步长按一次步进",
+ "status": "pass",
+ "ms": 6327,
+ "detail": {
+ "before": 0.9075,
+ "after": 0.8075
+ }
+ },
+ {
+ "name": "主轴启停与冷却液开关可用",
+ "status": "pass",
+ "ms": 13600,
+ "detail": {
+ "mistOn": true,
+ "mistOff": true,
+ "live": "Live parameters LinuxCNC WASM Spindle 1.0 rpm Cmd 1.0 rpm / S 0.0 rpm Feed actual 0.0 mm/min Program 0.0 mm/mi"
+ }
+ },
+ {
+ "name": "MDI 指令执行并写入历史",
+ "status": "pass",
+ "ms": 7596,
+ "detail": "G0 X12.5 Y-4 Z0"
+ },
+ {
+ "name": "DRO 视图可切换坐标、显示与单位",
+ "status": "pass",
+ "ms": 6126,
+ "detail": {
+ "rows": 5,
+ "footer": "Units: inch / deg relative / commanded Tick: 144 Hash: fnv1a64:0780"
+ }
+ },
+ {
+ "name": "倍率滑块与预览图层开关可用",
+ "status": "pass",
+ "ms": 4898,
+ "detail": "Jog Speed: 100% Feed Override: 80% Spindle Override: 100% Max Velocity: 100% Rapid Override Unavailable path live limits velocity offsets extents"
+ },
+ {
+ "name": "工具栏『载入精确案例』按钮重新载入当前案例",
+ "status": "pass",
+ "ms": 19213,
+ "detail": "Program: boat-xyzac.ngc"
+ },
+ {
+ "name": "载入案例程序并运行、暂停、继续、停止",
+ "status": "pass",
+ "ms": 38054,
+ "detail": {
+ "running": "Task mode AUTO Task state STATE_ON Interpreter INTERP_READING Motion mode COORD Motion type 0 Homing",
+ "paused": "Task mode AUTO Task state STATE_ON Interpreter INTERP_PAUSED Motion mode COORD Motion type 1 Homing ",
+ "stopped": "Task mode MANUAL Task state STATE_ON Interpreter INTERP_IDLE Motion mode TELEOP Motion type 0 Homing"
+ }
+ },
+ {
+ "name": "RTCP 程序库『加载到控制器』可用",
+ "status": "pass",
+ "ms": 11529,
+ "detail": "程序已加载到 LinuxCNC WASM 控制器"
+ },
+ {
+ "name": "工具栏『打开 G 代码文件』可装载本地 .ngc",
+ "status": "pass",
+ "ms": 3960,
+ "detail": "Program: xyzac-trt.ngc"
+ },
+ {
+ "name": "工具栏『重载当前程序』可用",
+ "status": "pass",
+ "ms": 5257,
+ "detail": "Task mode MANUAL Task state STATE_ON Interpreter INTERP_IDLE Motion mode TELEOP Motion type 0 Homing IDLE Mach"
+ },
+ {
+ "name": "工具栏视图按钮与清除轨迹可用",
+ "status": "pass",
+ "ms": 10442,
+ "detail": {
+ "pressed": "true"
+ }
+ },
+ {
+ "name": "诊断页显示启动资源与门控",
+ "status": "pass",
+ "ms": 3151,
+ "detail": "TASK_READY Tick 324 Hash fnv1a64:8cadf3b1b75faa04 Startup resources Entrypoint: linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt"
+ },
+ {
+ "name": "快捷键 F1 / F3 / F5 生效",
+ "status": "pass",
+ "ms": 5683,
+ "detail": {
+ "estopTriggered": true,
+ "manualSelected": "true",
+ "mdiSelected": "true"
+ }
+ },
+ {
+ "name": "窄视口 390x844 无横向溢出",
+ "status": "pass",
+ "ms": 3884,
+ "detail": {
+ "overflow": 0
+ }
+ }
+ ],
+ "defects": [
+ {
+ "id": "DEFECT-02",
+ "severity": "high",
+ "control": "Simulation case 下拉框中的通用运动学案例",
+ "observed": "xyzab-tdr、max5-maxkins、trivkins-xyzya、hexapod-genhexkins、scorbot-kins 载入后控制器停在 PENDING,电源按钮保持禁用(linuxcnc_wasm_snapshot_pending),此时下拉框无法切回其它案例,只能刷新页面恢复",
+ "expected": "选中案例后可上电、回零并运行其干运行程序",
+ "root_cause": "通用案例运行链在浏览器侧尚未完成:案例的运动学源文件(如 linuxcnc/src/emc/kinematics/trivkins.c、linuxcnc/src/hal/components/xyzab_tdr_kins.comp)没有随前端产物发布,请求返回 404;且会话启动失败后前端不回滚,卡在 pending",
+ "workaround": "当前只使用 XYZAC TRT / XYZBC TRT 两个案例;误选其它案例后刷新浏览器页面"
+ }
+ ],
+ "case_matrix": [
+ {
+ "id": "xyzac-trt",
+ "applied": true,
+ "power": "enabled",
+ "state": "Task mode MANUAL Task state STATE_ESTOP_RESET Interpreter INTERP_IDLE Motion mode FREE Mot",
+ "http_4xx": [],
+ "verdict": "usable"
+ },
+ {
+ "id": "xyzbc-trt",
+ "applied": true,
+ "power": "enabled",
+ "state": "Task mode MANUAL Task state STATE_ESTOP_RESET Interpreter INTERP_IDLE Motion mode FREE Mot",
+ "http_4xx": [],
+ "verdict": "usable"
+ },
+ {
+ "id": "xyzab-tdr",
+ "applied": true,
+ "power": "disabled:linuxcnc_wasm_snapshot_pending",
+ "state": "Task mode PENDING Task state PENDING Interpreter PENDING Motion mode PENDING Motion type -",
+ "http_4xx": [
+ "404 /linuxcnc/src/hal/components/xyzab_tdr_kins.comp"
+ ],
+ "verdict": "loads_but_controller_pending"
+ },
+ {
+ "id": "max5-maxkins",
+ "applied": true,
+ "power": "disabled:linuxcnc_wasm_snapshot_pending",
+ "state": "Task mode PENDING Task state PENDING Interpreter PENDING Motion mode PENDING Motion type -",
+ "http_4xx": [],
+ "verdict": "loads_but_controller_pending"
+ },
+ {
+ "id": "trivkins-xyzya",
+ "applied": true,
+ "power": "disabled:linuxcnc_wasm_snapshot_pending",
+ "state": "Task mode PENDING Task state PENDING Interpreter PENDING Motion mode PENDING Motion type -",
+ "http_4xx": [
+ "404 /linuxcnc/src/emc/kinematics/trivkins.c"
+ ],
+ "verdict": "loads_but_controller_pending"
+ },
+ {
+ "id": "hexapod-genhexkins",
+ "applied": true,
+ "power": "disabled:linuxcnc_wasm_snapshot_pending",
+ "state": "Task mode PENDING Task state PENDING Interpreter PENDING Motion mode PENDING Motion type -",
+ "http_4xx": [
+ "404 /linuxcnc/src/emc/kinematics/genhexkins.c"
+ ],
+ "verdict": "loads_but_controller_pending"
+ },
+ {
+ "id": "scorbot-kins",
+ "applied": true,
+ "power": "disabled:linuxcnc_wasm_snapshot_pending",
+ "state": "Task mode PENDING Task state PENDING Interpreter PENDING Motion mode PENDING Motion type -",
+ "http_4xx": [
+ "404 /linuxcnc/src/emc/kinematics/scorbot-kins.c"
+ ],
+ "verdict": "loads_but_controller_pending"
+ }
+ ],
+ "console_errors": [
+ {
+ "kind": "console",
+ "text": "Failed to load resource: the server responded with a status of 404 (Not Found)"
+ },
+ {
+ "kind": "console",
+ "text": "Failed to load resource: the server responded with a status of 404 (Not Found)"
+ },
+ {
+ "kind": "console",
+ "text": "Failed to load resource: the server responded with a status of 404 (Not Found)"
+ },
+ {
+ "kind": "console",
+ "text": "Failed to load resource: the server responded with a status of 404 (Not Found)"
+ }
+ ],
+ "http_and_request_failures": [
+ "HTTP 404 https://49.232.250.35:58010/linuxcnc/src/hal/components/xyzab_tdr_kins.comp",
+ "HTTP 404 https://49.232.250.35:58010/linuxcnc/src/emc/kinematics/trivkins.c",
+ "HTTP 404 https://49.232.250.35:58010/linuxcnc/src/emc/kinematics/genhexkins.c",
+ "HTTP 404 https://49.232.250.35:58010/linuxcnc/src/emc/kinematics/scorbot-kins.c"
+ ],
+ "screenshots": [
+ "01-main-overview.png",
+ "02-about-dialog.png",
+ "03-file-menu.png",
+ "04-rtcp-program-library.png",
+ "05-machine-import-dialog.png",
+ "06-machine-manager-structure.png",
+ "07-machine-manager-motion.png",
+ "08-machine-manager-calibration.png",
+ "09-machine-manager-files.png",
+ "10-geometry-parameters.png",
+ "11-case-xyzbc.png",
+ "12-manual-homed.png",
+ "13-manual-jog.png",
+ "14-spindle-coolant.png",
+ "15-mdi.png",
+ "16-dro.png",
+ "17-overrides-layers.png",
+ "18-program-running.png",
+ "19-program-paused.png",
+ "22-rtcp-load.png",
+ "23-views-cleared.png",
+ "20-diagnostics.png",
+ "21-mobile-390.png"
+ ]
+}
diff --git a/docs/user-manual/local-machine-manager-demo.mjs b/docs/user-manual/local-machine-manager-demo.mjs
new file mode 100644
index 0000000..e70eb55
--- /dev/null
+++ b/docs/user-manual/local-machine-manager-demo.mjs
@@ -0,0 +1,182 @@
+// 可见演示:打开"三维机床管理"对话框,导入机床模型并测试。
+// 流程:打开网站 → 打开三维机床管理 → 查看案例库 → 导入 AC 示例机床 → 查看层级与轴绑定
+// → 运动验证(手动输入各轴)→ 查看标定参数 → 应用到 Web LinuxCNC → 回到主页上电/回零/Jog 验证模型跟随。
+//
+// 运行:
+// DISPLAY=:1 PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs \
+// node docs/user-manual/local-machine-manager-demo.mjs
+
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const repoRoot = resolve(here, "../..");
+const baseUrl = process.env.DEMO_URL ?? "http://127.0.0.1:5174/";
+const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome";
+const pace = Number(process.env.DEMO_PACE_MS ?? 2500);
+// 默认用带 JT_X/JT_Y/JT_Z/JT_A/JT_C 语义节点名的真实机床模型,分析器才能自动识别五轴。
+const glbPath = process.env.DEMO_GLB ?? resolve(repoRoot, "glb/m1.glb");
+const shotDir = resolve(here, "local-walkthrough");
+
+const { chromium } = await import(process.env.PLAYWRIGHT_MODULE);
+const browser = await chromium.launch({
+ executablePath: chromePath,
+ headless: false,
+ args: ["--no-sandbox", "--start-maximized", "--disable-infobars", "--enable-unsafe-swiftshader"],
+});
+const page = await browser.newPage({ viewport: null });
+await page.bringToFront().catch(() => {});
+
+const wait = (ms) => page.waitForTimeout(ms);
+const text = async (selector) => (await page.locator(selector).first().innerText()).replace(/\s+/g, " ").trim();
+const status = async () => text(".machine-status");
+const axes = async () => page.locator(".xyzac-position-grid output").allInnerTexts();
+const shot = (name) => page.screenshot({ path: resolve(shotDir, name) });
+const say = (step, message) => console.log(`\n[${step}] ${message}`);
+const waitFor = async (check, timeout, label) => {
+ const deadline = Date.now() + timeout;
+ let last = "";
+ while (Date.now() < deadline) {
+ last = await check();
+ if (last) return last;
+ await wait(400);
+ }
+ throw new Error(`${label} 超时(最后:${last || "无"})`);
+};
+const waitStatus = (pattern, timeout = 60000) => waitFor(async () => {
+ const value = await status();
+ return pattern.test(value) ? value : null;
+}, timeout, `等待 ${pattern}`);
+const dialog = () => page.locator("dialog[open]").first();
+
+say(1, `打开网站 ${baseUrl}`);
+await page.goto(baseUrl, { waitUntil: "load", timeout: 60000 });
+await page.waitForSelector('[data-axis-control="workspace-case-select"]', { timeout: 60000 });
+await waitFor(async () => ((await text(".case-status")).startsWith("Program:") ? "ok" : null), 90000, "案例就绪");
+await wait(2000);
+console.log(` 连接:${await text(".connection-state")}|机型:${await text(".xyzac-section-title")}|状态:${await status()}`);
+await shot("mgr-1-home.png");
+
+say(2, "打开“三维机床管理”对话框(此时还没有任何机床模型)");
+await page.locator('[data-axis-control="machine-manager-settings"]').click();
+await dialog().waitFor({ state: "visible", timeout: 10000 });
+await wait(2500);
+console.log(` 草稿状态:${await text('[data-axis-control="machine-manager-draft-state"]')}|${await text('[data-axis-control="machine-manager-validation"]')}`);
+await shot("mgr-2-dialog-empty.png");
+await wait(pace);
+
+say(3, "查看“文件与恢复”里的 LinuxCNC 五轴案例库");
+await page.locator('[data-axis-control="machine-manager-tab-files"]').click();
+await wait(2000);
+const caseCount = await page.locator("[data-machine-case]").count();
+const cases = await page.locator("[data-machine-case] strong").allInnerTexts();
+console.log(` 案例库 ${caseCount} 项:${cases.slice(0, 4).join("、")} …`);
+console.log(` 案例卡片示例:${(await text("[data-machine-case]")).slice(0, 120)}`);
+await shot("mgr-3-case-library.png");
+await wait(pace);
+
+say(4, `回到“部件与层级”,导入机床模型 ${glbPath.split("/").pop()}`);
+await page.locator('[data-axis-control="machine-manager-tab-structure"]').click();
+await wait(1200);
+const [chooser] = await Promise.all([
+ page.waitForEvent("filechooser"),
+ page.locator('[data-axis-control="machine-manager-import"]').click(),
+]);
+await chooser.setFiles(glbPath);
+await waitFor(async () => ((await page.locator("[data-axis-control=machine-model-list]").count()) ? "ok" : null), 60000, "模型导入");
+await waitFor(async () => (/[1-9]\d*\/\d+ 轴已绑定/.test(await text(".machine-manager-toolbar")) ? "ok" : null), 120000, "关节自动绑定").catch(() => {});
+await wait(4000);
+console.log(` 模型列表:${await text("[data-axis-control=machine-model-list]")}`);
+console.log(` 工具栏:${await text(".machine-manager-toolbar")}`);
+await shot("mgr-4-imported-structure.png");
+await wait(pace);
+
+say(5, "查看 GLB 五轴分析结果与层级树");
+const analysis = await page.locator('[data-axis-control="glb-five-axis-analysis"]').innerText().catch(() => "(无分析卡片)");
+console.log(` 分析:${analysis.replace(/\s+/g, " ").slice(0, 260)}`);
+const treeNodes = await page.locator('[data-axis-control="machine-tree-node"]').count();
+console.log(` 层级树节点数:${treeNodes}`);
+await shot("mgr-5-analysis-tree.png");
+await wait(pace);
+
+say(6, "运动验证:手动驱动模型各轴(不发运动指令给控制器)");
+await page.locator('[data-axis-control="machine-manager-tab-motion"]').click();
+await wait(1500);
+const poseJson = async () => {
+ const details = page.locator("#machine-panel-motion details").first();
+ await details.evaluate((element) => { element.open = true; }).catch(() => {});
+ await wait(300);
+ return text('[data-axis-control="machine-test-state"]');
+};
+const before = await text('[data-axis-control="machine-test-state"]');
+const beforePose = await poseJson();
+const inputs = { x: "25", y: "15", z: "-8", a: "30", c: "45" };
+const firstField = page.locator('[data-axis-control="machine-test-x"]');
+if (await firstField.isDisabled()) {
+ console.log(" ⚠ 运动验证输入仍为禁用:该模型没有自动识别出关节,需要手动绑定自由度后才能测试");
+} else {
+for (const [axis, value] of Object.entries(inputs)) {
+ const field = page.locator(`[data-axis-control="machine-test-${axis}"]`);
+ if (await field.count()) { await field.fill(value); await field.blur().catch(() => {}); await wait(600); }
+}
+}
+await wait(2000);
+const afterPose = await poseJson();
+const readBack = {};
+for (const axis of Object.keys(inputs)) readBack[axis.toUpperCase()] = await page.locator(`[data-axis-control="machine-test-${axis}"]`).inputValue().catch(() => "-");
+console.log(` 输入前模型姿态:${beforePose || before || "(空)"}`);
+console.log(` 输入后模型姿态:${afterPose}`);
+console.log(` 各轴输入回读:${JSON.stringify(readBack)}`);
+console.log(` 姿态已改变:${beforePose !== afterPose && afterPose ? "是" : "否"}`);
+await shot("mgr-6-motion-test.png");
+await wait(pace);
+
+say(7, "复位运动验证输入,并查看机床标定参数");
+await page.locator('[data-axis-control="machine-test-reset"]').click();
+await wait(1500);
+await page.locator('[data-axis-control="machine-manager-tab-calibration"]').click();
+await wait(1500);
+const calibration = await text("#machine-panel-calibration");
+console.log(` 标定页:${calibration.slice(0, 220)}`);
+await shot("mgr-7-calibration.png");
+await wait(pace);
+
+say(8, "把配置好的机床应用到 Web LinuxCNC");
+const applyButton = page.locator('[data-axis-control="machine-manager-apply"]');
+console.log(` 应用按钮:${(await applyButton.isDisabled()) ? "禁用" : "可用"}|检查:${await text('[data-axis-control="machine-manager-validation"]')}`);
+if (!(await applyButton.isDisabled())) {
+ await applyButton.click();
+ await wait(6000);
+ console.log(` 应用反馈:${await text(".machine-manager-footer")}`);
+}
+await shot("mgr-8-applied.png");
+await wait(pace);
+
+say(9, "关闭对话框,回到主页确认机床模型已经是导入的那一台");
+await page.locator('dialog[open] header button[aria-label^="关闭"]').click();
+await wait(3000);
+console.log(` 机型:${await text(".xyzac-section-title")}|程序:${await text(".case-status")}`);
+await shot("mgr-9-home-after-apply.png");
+await wait(pace);
+
+say(10, "上电 → HOME ALL → Jog X,验证导入的机床模型跟随运动");
+const estop = page.locator('[data-axis-control="toolbar-estop"]');
+if ((await estop.getAttribute("aria-label"))?.includes("Cancel")) { await estop.click(); await wait(1500); }
+await page.locator('[data-axis-control="toolbar-power"]').click();
+await waitStatus(/STATE_ON|STATE_IDLE/, 40000);
+console.log(` 上电:${(await status()).match(/Task state (\S+)/)?.[1]}`);
+await page.locator('[data-axis-control="manual-home-all"]').click();
+await waitStatus(/Machine sequence HOMED/, 60000);
+console.log(` 回零:${(await status()).match(/Machine sequence (NOT HOMED|HOMED)/)?.[1]}|轴位 ${(await axes()).join(" / ")}`);
+await page.locator('[data-axis-control="manual-axis-x"]').check();
+await page.locator('[data-axis-control="manual-jog-positive"]').hover();
+await page.mouse.down();
+await wait(2000);
+await page.mouse.up();
+await wait(1500);
+console.log(` Jog 后轴位:${(await axes()).join(" / ")}`);
+await shot("mgr-10-machine-moving.png");
+
+console.log("\n机床测试演示结束,窗口保持 20 秒后关闭。");
+await wait(20000);
+await browser.close();
diff --git a/docs/user-manual/local-run-demo.mjs b/docs/user-manual/local-run-demo.mjs
new file mode 100644
index 0000000..09ec7d7
--- /dev/null
+++ b/docs/user-manual/local-run-demo.mjs
@@ -0,0 +1,121 @@
+// 最小演示:打开本地发布站点 → 上电 → HOME ALL → 载入并执行程序。
+// 默认在桌面开可见窗口执行,逐步打印中文说明,方便人工观察。
+//
+// 运行:
+// DISPLAY=:1 PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs \
+// node docs/user-manual/local-run-demo.mjs
+//
+// 环境变量:DEMO_URL(默认 http://127.0.0.1:5174/)、DEMO_PACE_MS(每步停留,默认 2500)、
+// DEMO_RUN_HOLD_MS(程序运行观察时长,默认 45000)、CHROME_PATH。
+
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const baseUrl = process.env.DEMO_URL ?? "http://127.0.0.1:5174/";
+const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome";
+const pace = Number(process.env.DEMO_PACE_MS ?? 2500);
+const runHold = Number(process.env.DEMO_RUN_HOLD_MS ?? 45000);
+const shotDir = resolve(here, "local-walkthrough");
+
+const { chromium } = await import(process.env.PLAYWRIGHT_MODULE);
+const browser = await chromium.launch({
+ executablePath: chromePath,
+ headless: false,
+ args: ["--no-sandbox", "--start-maximized", "--disable-infobars", "--enable-unsafe-swiftshader"],
+});
+const page = await browser.newPage({ viewport: null });
+await page.bringToFront().catch(() => {});
+
+const wait = (ms) => page.waitForTimeout(ms);
+const status = async () => (await page.locator(".machine-status").innerText()).replace(/\s+/g, " ").trim();
+const command = async () => (await page.locator(".command-status").innerText()).trim();
+const axes = async () => page.locator(".xyzac-position-grid output").allInnerTexts();
+const shot = (name) => page.screenshot({ path: resolve(shotDir, name) });
+const say = (step, message) => console.log(`\n[${step}] ${message}`);
+const detail = async () => {
+ const text = await status();
+ return `${text.match(/Task state (\S+)/)?.[1]} / ${text.match(/Interpreter (\S+)/)?.[1]} / ${text.match(/Machine sequence (NOT HOMED|HOMED)/)?.[1]}`;
+};
+const waitFor = async (check, timeout, label) => {
+ const deadline = Date.now() + timeout;
+ let last = "";
+ while (Date.now() < deadline) {
+ last = await check();
+ if (last) return last;
+ await wait(400);
+ }
+ throw new Error(`${label} 超时(最后:${last || "无"})`);
+};
+const waitStatus = (pattern, timeout = 60000) => waitFor(async () => {
+ const text = await status();
+ return pattern.test(text) ? text : null;
+}, timeout, `等待 ${pattern}`);
+const waitCase = () => waitFor(async () => ((await page.locator(".case-status").innerText()).trim().startsWith("Program:") ? "ok" : null), 120000, "程序装载");
+
+say(1, `打开网站 ${baseUrl}`);
+await page.goto(baseUrl, { waitUntil: "load", timeout: 60000 });
+await page.waitForSelector('[data-axis-control="workspace-case-select"]', { timeout: 60000 });
+await waitCase();
+await waitStatus(/STATE_/, 60000);
+await wait(2000);
+console.log(` 连接:${(await page.locator(".connection-state").innerText()).trim()}|当前程序:${(await page.locator(".case-status").innerText()).trim()}|状态:${await detail()}`);
+await shot("demo-1-opened.png");
+
+say(2, "机床尚未上电,确认运动操作被门控挡住");
+const jogButton = page.locator('[data-axis-control="manual-jog-positive"]');
+console.log(` Jog 按钮:${(await jogButton.isDisabled()) ? `禁用(${await jogButton.getAttribute("title")})` : "可用"}|任务模式:${(await status()).match(/Task mode (\S+)/)?.[1]}`);
+await wait(pace);
+
+say(3, "急停复位,然后上电");
+const estop = page.locator('[data-axis-control="toolbar-estop"]');
+if ((await estop.getAttribute("aria-label"))?.includes("Cancel")) { await estop.click(); await wait(1500); }
+await page.locator('[data-axis-control="toolbar-power"]').click();
+const powered = await waitStatus(/STATE_ON|STATE_IDLE/, 40000);
+console.log(` 上电完成:${powered.match(/Task state (\S+)/)?.[1]}|反馈:${await command()}`);
+await shot("demo-2-powered.png");
+await wait(pace);
+
+say(4, "HOME ALL 全部关节回零");
+await page.locator('[data-axis-control="manual-home-all"]').click();
+await waitStatus(/Machine sequence HOMED/, 60000);
+await wait(1500);
+console.log(` 回零完成:${await detail()}|轴位:${(await axes()).join(" / ")}`);
+await shot("demo-3-homed.png");
+await wait(pace);
+
+say(5, "载入 XYZAC 船体程序");
+await page.locator('[data-axis-control="workspace-load-boat"]').click();
+await waitCase();
+await wait(3000);
+console.log(` 程序:${(await page.locator(".case-status").innerText()).trim()}|反馈:${await command()}`);
+await shot("demo-4-program-loaded.png");
+await wait(pace);
+
+say(6, "执行程序,观察解释器与轴运动");
+await page.locator('[data-axis-control="toolbar-run"]').click();
+await waitStatus(/STATE_RUNNING|INTERP_READING|INTERP_WAITING/, 60000);
+console.log(` 运行中:${await detail()}`);
+const samples = [];
+const holdUntil = Date.now() + runHold;
+let index = 0;
+while (Date.now() < holdUntil) {
+ await wait(3000);
+ const current = await axes();
+ samples.push(current.slice(0, 5).join(" / "));
+ index += 1;
+ console.log(` +${String(index * 3).padStart(2)}s ${current.slice(0, 5).join(" ")}`);
+ if (index % 3 === 0) await shot(`demo-5-running-${index / 3}.png`);
+}
+console.log(` 轴运动采样(X / Y / Z / A / C):\n ${samples.join("\n ")}`);
+await shot("demo-6-running-final.png");
+
+say(7, "停止程序");
+if (await page.locator('[data-axis-control="toolbar-stop"]').isEnabled()) await page.locator('[data-axis-control="toolbar-stop"]').click();
+await wait(3000);
+console.log(` 已停止:${await detail()}|反馈:${await command()}`);
+await shot("demo-7-stopped.png");
+
+console.log("\n演示结束,窗口保持 20 秒后关闭。");
+await wait(20000);
+await browser.close();
diff --git a/docs/user-manual/local-walkthrough.mjs b/docs/user-manual/local-walkthrough.mjs
new file mode 100644
index 0000000..751bb43
--- /dev/null
+++ b/docs/user-manual/local-walkthrough.mjs
@@ -0,0 +1,312 @@
+// 按"使用说明书"的操作顺序,在本地发布站点上完整走一遍标准使用流程,并逐步取证。
+//
+// 运行:
+// PLAYWRIGHT_MODULE=/path/to/playwright/index.mjs \
+// node docs/user-manual/local-walkthrough.mjs
+//
+// 环境变量:WALKTHROUGH_URL(默认 http://127.0.0.1:5174/)、CHROME_PATH。
+// WALKTHROUGH_HEADED=1 在桌面打开可见窗口执行(便于人工观察)
+// WALKTHROUGH_PACE_MS 每一步之间的停留时间,默认可见模式 1500ms、无头模式 0
+// WALKTHROUGH_HOLD_MS 结束前保持窗口的时间,默认可见模式 20000ms、无头模式 0
+// 输出:docs/user-manual/local-walkthrough/(截图 + 分步报告 JSON)
+
+import { mkdir, writeFile } from "node:fs/promises";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const here = dirname(fileURLToPath(import.meta.url));
+const outDir = resolve(here, "local-walkthrough");
+const baseUrl = process.env.WALKTHROUGH_URL ?? "http://127.0.0.1:5174/";
+const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome";
+const headed = process.env.WALKTHROUGH_HEADED === "1";
+const pace = Number(process.env.WALKTHROUGH_PACE_MS ?? (headed ? 1500 : 0));
+const hold = Number(process.env.WALKTHROUGH_HOLD_MS ?? (headed ? 20000 : 0));
+
+await mkdir(outDir, { recursive: true });
+
+const { chromium } = await import(process.env.PLAYWRIGHT_MODULE);
+const browser = await chromium.launch({
+ executablePath: chromePath,
+ headless: !headed,
+ args: headed
+ ? ["--no-sandbox", "--start-maximized", "--disable-infobars", "--enable-unsafe-swiftshader"]
+ : ["--no-sandbox", "--enable-unsafe-swiftshader"],
+});
+const page = await browser.newPage({ viewport: headed ? null : { width: 1440, height: 900 } });
+await page.bringToFront().catch(() => {});
+const consoleErrors = [];
+page.on("pageerror", (error) => consoleErrors.push(String(error)));
+page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
+
+const steps = [];
+const findings = [];
+const settle = (ms = 500) => page.waitForTimeout(ms);
+const text = async (selector) => (await page.locator(selector).first().innerText()).replace(/\s+/g, " ").trim();
+const status = () => text(".machine-status");
+const command = () => text(".command-status");
+const axes = async () => page.locator(".xyzac-position-grid output").allInnerTexts();
+const caseStatus = async () => (await page.locator(".case-status").innerText()).trim();
+const loadGateState = async () => {
+ const button = page.locator('[data-axis-control="workspace-load-boat"]');
+ return (await button.isDisabled()) ? `blocked:${await button.getAttribute("title")}` : "enabled";
+};
+const machineSequence = async () => (await status()).match(/Machine sequence (NOT HOMED|HOMED)/)?.[1] ?? "-";
+
+const waitFor = async (predicate, timeout, label) => {
+ const deadline = Date.now() + timeout;
+ let last = "";
+ while (Date.now() < deadline) {
+ last = await predicate();
+ if (last) return last;
+ await page.waitForTimeout(400);
+ }
+ throw new Error(`${label} 超时,最后观察:${String(last).slice(0, 160)}`);
+};
+
+const waitStatus = async (pattern, timeout = 60000) => {
+ const deadline = Date.now() + timeout;
+ let last = "";
+ while (Date.now() < deadline) {
+ last = await status();
+ if (pattern.test(last)) return last;
+ await page.waitForTimeout(400);
+ }
+ throw new Error(`等待 ${pattern} 超时,最后状态:${last.slice(0, 160)}`);
+};
+
+const waitCase = (timeout = 120000) => waitFor(async () => {
+ const value = await caseStatus();
+ return value.startsWith("Program:") ? value : null;
+}, timeout, "案例程序就绪");
+
+const record = async (index, title, body) => {
+ const started = Date.now();
+ console.log(`\n▶ 第 ${index} 步 ${title}`);
+ try {
+ const detail = await body();
+ const shot = `step-${String(index).padStart(2, "0")}.png`;
+ await page.screenshot({ path: resolve(outDir, shot) });
+ steps.push({ index, title, status: "ok", ms: Date.now() - started, detail, screenshot: shot });
+ console.log(` ✔ 完成${detail ? ` — ${JSON.stringify(detail)}` : ""}`);
+ } catch (error) {
+ const shot = `step-${String(index).padStart(2, "0")}-failed.png`;
+ await page.screenshot({ path: resolve(outDir, shot) }).catch(() => {});
+ steps.push({ index, title, status: "failed", ms: Date.now() - started, detail: String(error?.message ?? error), screenshot: shot });
+ console.log(` ✘ 失败 — ${error?.message ?? error}`);
+ }
+ if (pace) await page.waitForTimeout(pace);
+};
+
+console.log(`本地发布站点使用流程演练:${baseUrl}\n`);
+console.log(headed ? "模式:桌面可见窗口(可以看着它操作)" : "模式:无头浏览器");
+
+// 1. 打开页面,等待 WASM 会话就绪
+await record(1, "打开页面并等待仿真会话就绪", async () => {
+ await page.goto(baseUrl, { waitUntil: "load", timeout: 60000 });
+ await page.waitForSelector('[data-axis-control="workspace-case-select"]', { timeout: 60000 });
+ await waitCase(90000);
+ await waitStatus(/STATE_/, 60000);
+ await settle(2000);
+ return {
+ 标题: await page.title(),
+ 连接: (await text(".connection-state")),
+ 默认案例: await page.locator('[data-axis-control="workspace-case-select"]').inputValue().then((v) => v.split("/").pop()),
+ 当前程序: await caseStatus(),
+ 可渲染画布: await page.locator("canvas").count(),
+ };
+});
+
+// 2. 上电前:确认门控把运动操作锁住
+await record(2, "上电前确认门控:手动与运动操作被锁", async () => {
+ const jog = page.locator('[data-axis-control="manual-jog-positive"]');
+ const mdi = page.locator('[data-axis-control="mdi-command"]');
+ return {
+ 任务状态: (await status()).match(/Task state (\S+)/)?.[1],
+ 回零状态: await machineSequence(),
+ 装载程序按钮: await loadGateState(),
+ Jog按钮: await jog.isDisabled() ? `禁用(${await jog.getAttribute("title")})` : "可用",
+ MDI输入: await mdi.isDisabled() ? "禁用" : "可用",
+ };
+});
+
+// 3. 急停复位 + 上电
+await record(3, "急停复位并上电", async () => {
+ const estop = page.locator('[data-axis-control="toolbar-estop"]');
+ if ((await estop.getAttribute("aria-label"))?.includes("Cancel")) { await estop.click(); await settle(1500); }
+ const reset = await status();
+ await page.locator('[data-axis-control="toolbar-power"]').click();
+ const on = await waitStatus(/STATE_ON|STATE_IDLE/, 40000);
+ return { 复位后: reset.match(/Task state (\S+)/)?.[1], 上电后: on.match(/Task state (\S+)/)?.[1] };
+});
+
+// 4. 回零
+await record(4, "HOME ALL 全部关节回零", async () => {
+ await page.locator('[data-axis-control="manual-home-all"]').click();
+ await waitStatus(/Machine sequence HOMED/, 60000);
+ await settle(1500);
+ return { 回零状态: await machineSequence(), 轴位: await axes() };
+});
+
+// 5. 连续 Jog
+await record(5, "手动连续 Jog:选中 X 轴,按住 + 移动,松手停止", async () => {
+ await page.locator('[data-axis-control="manual-axis-x"]').check();
+ const before = (await axes())[0];
+ await page.locator('[data-axis-control="manual-jog-positive"]').hover();
+ await page.mouse.down();
+ await settle(1800);
+ await page.mouse.up();
+ await settle(1500);
+ const moved = (await axes())[0];
+ await settle(1500);
+ const stopped = (await axes())[0];
+ return { 移动前: before, 松开瞬间: moved, "1.5s后": stopped, 结论: moved !== before && stopped === moved ? "移动后停止" : "需复核" };
+});
+
+// 6. 增量 Jog
+await record(6, "增量 Jog:0.1000 步长点动一次", async () => {
+ await page.locator('[data-axis-control="manual-jog-increment"]').selectOption("0.1000");
+ const before = Number((await axes())[0]);
+ await page.locator('[data-axis-control="manual-jog-negative"]').hover();
+ await page.mouse.down();
+ await settle(1200);
+ await page.mouse.up();
+ await settle(2000);
+ const after = Number((await axes())[0]);
+ await page.locator('[data-axis-control="manual-jog-increment"]').selectOption("Continuous");
+ return { 步长: "0.1000 mm", 前: before, 后: after, 实际位移: Number((before - after).toFixed(4)) };
+});
+
+// 7. MDI
+await record(7, "MDI 执行 G0 X12.5 Y-4 Z0", async () => {
+ await page.locator('[data-axis-control="tab-mdi"]').click();
+ await page.locator('[data-axis-control="mdi-command"]').fill("G0 X12.5 Y-4 Z0");
+ await page.locator('[data-axis-control="mdi-submit"]').click();
+ await settle(3500);
+ return { 命令: "G0 X12.5 Y-4 Z0", 轴位: await axes(), 反馈: await command(), 历史: await page.locator('[data-axis-control="mdi-history-select"]').first().innerText() };
+});
+
+// 8. 载入案例自带的五轴程序
+await record(8, "MDI 之后切回手动模式(否则装载门控保持失效)", async () => {
+ const afterMdi = await loadGateState();
+ const modeBefore = (await status()).match(/Task mode (\S+)/)?.[1];
+ await page.locator('[data-axis-control="tab-manual"]').click();
+ await settle(3000);
+ const afterManual = await loadGateState();
+ if (afterMdi.startsWith("blocked:xyzac_motion_active") && afterManual === "enabled") {
+ findings.push({
+ id: "FINDING-01",
+ severity: "medium",
+ control: "MDI 之后的装载门控",
+ observed: `MDI 执行完成后(Planner queued=0、Task mode=${modeBefore}),"Load XYZAC boat program" 仍被拒绝,提示 ${afterMdi};此时必须切回手动模式或刷新页面才能载入程序`,
+ expectation: "MDI 运动结束、队列排空后装载程序应当自动恢复可用",
+ workaround: "点一下左侧 Manual Control [F3] 页签(发出一条模式命令刷新门控),或刷新页面",
+ });
+ }
+ return { MDI后: afterMdi, 切回手动后: afterManual, 任务模式: (await status()).match(/Task mode (\S+)/)?.[1] };
+});
+
+// 9. 载入案例自带的五轴程序
+await record(9, "载入 XYZAC 船体程序并生成刀路", async () => {
+ await page.locator('[data-axis-control="tab-preview"]').click();
+ await page.locator('[data-axis-control="workspace-load-boat"]').click();
+ await waitCase();
+ await settle(3000);
+ return { 程序: await caseStatus(), 反馈: await command() };
+});
+
+// 10. 运行程序并采样轴运动
+await record(10, "运行程序,观察解释器与轴运动", async () => {
+ await page.locator('[data-axis-control="toolbar-run"]').click();
+ await waitStatus(/STATE_RUNNING|INTERP_READING|INTERP_WAITING/, 60000);
+ const samples = [];
+ for (let i = 0; i < 4; i += 1) {
+ await settle(1200);
+ samples.push((await axes()).slice(0, 4).map(Number));
+ }
+ return { 任务模式: (await status()).match(/Task mode (\S+)/)?.[1], 解释器: (await status()).match(/Interpreter (\S+)/)?.[1], 轴位采样: samples };
+});
+
+// 11. 暂停并确认位置冻结
+await record(11, "暂停程序,确认位置冻结", async () => {
+ await page.locator('[data-axis-control="toolbar-pause-resume"]').click();
+ await waitStatus(/PAUSED|INTERP_PAUSED/, 30000);
+ const first = await axes();
+ await settle(2000);
+ const second = await axes();
+ return { 解释器: (await status()).match(/Interpreter (\S+)/)?.[1], 首次读数: first, "2s后读数": second, 位置冻结: JSON.stringify(first) === JSON.stringify(second) };
+});
+
+// 12. 继续并停止
+await record(12, "继续运行后停止程序", async () => {
+ await page.locator('[data-axis-control="toolbar-pause-resume"]').click();
+ await settle(2500);
+ const resumed = (await status()).match(/Interpreter (\S+)/)?.[1];
+ await page.locator('[data-axis-control="toolbar-stop"]').click();
+ await settle(3000);
+ return { 继续后解释器: resumed, 停止后解释器: (await status()).match(/Interpreter (\S+)/)?.[1], 反馈: await command() };
+});
+
+// 13. DRO 读数
+await record(13, "查看 DRO 位置显示并切换单位", async () => {
+ await page.locator('[data-axis-control="tab-dro"]').click();
+ await page.locator('[data-axis-control="dro-units"]').selectOption("inch");
+ await settle(1200);
+ const inch = await page.locator(".dro-row").allInnerTexts();
+ await page.locator('[data-axis-control="dro-units"]').selectOption("mm");
+ await page.locator('[data-axis-control="dro-coordinate"]').selectOption("relative");
+ await settle(1200);
+ return { 行数: inch.length, 英寸读数示例: inch[0]?.replace(/\s+/g, " "), 页脚: await text(".dro-footer") };
+});
+
+// 14. 切换机型
+await record(14, "切换到 XYZBC TRT 机型,轴组随之改变", async () => {
+ await page.locator('[data-axis-control="workspace-case-select"]').selectOption("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini");
+ await settle(2500);
+ await waitCase();
+ await settle(2500);
+ const bc = await page.locator(".axis-radio span").allInnerTexts();
+ await page.locator('[data-axis-control="workspace-case-select"]').selectOption("linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini");
+ await settle(2500);
+ return { XYZBC轴组: bc, 当前程序: await caseStatus() };
+});
+
+// 15. 打开使用说明书
+await record(15, "从标题栏按钮打开使用说明书", async () => {
+ const [manual] = await Promise.all([page.waitForEvent("popup"), page.locator('[data-axis-control="user-manual"]').click()]);
+ await manual.waitForLoadState("load", { timeout: 60000 });
+ await manual.waitForTimeout(1500);
+ const info = { 标题: await manual.title(), 地址: manual.url(), 章节: await manual.locator("section").count(), 插图: await manual.locator("figure img").count() };
+ await manual.screenshot({ path: resolve(outDir, "step-15-manual.png") });
+ await manual.close();
+ return info;
+});
+
+// 16. 诊断页
+await record(16, "查看诊断页的启动资源与门控", async () => {
+ await page.locator('[data-axis-control="tab-diagnostics"]').click();
+ await settle(1200);
+ const body = await text("#axis-panel-diagnostics");
+ return { 摘要: body.slice(0, 220) };
+});
+
+const report = {
+ report_id: "LINUXCNC-WEB-LOCAL-WALKTHROUGH",
+ url: baseUrl,
+ ran_at: new Date().toISOString(),
+ status: steps.every((step) => step.status === "ok") ? "verified" : "failed",
+ steps,
+ findings,
+ console_errors: consoleErrors,
+};
+await writeFile(resolve(outDir, "walkthrough.report.json"), `${JSON.stringify(report, null, 2)}\n`);
+console.log(`\n${report.status.toUpperCase()} ${steps.filter((s) => s.status === "ok").length}/${steps.length} 步完成 → ${resolve(outDir, "walkthrough.report.json")}`);
+if (consoleErrors.length) console.log("页面错误:", consoleErrors.slice(0, 3));
+if (findings.length) console.log(`\n发现问题 ${findings.length} 项:`);
+for (const item of findings) console.log(` ${item.id}|${item.control}|${item.observed}`);
+
+if (hold) {
+ console.log(`演练结束,窗口保持 ${Math.round(hold / 1000)} 秒供查看…`);
+ await page.waitForTimeout(hold);
+}
+await browser.close();
+process.exit(report.status === "verified" ? 0 : 1);
diff --git a/docs/user-manual/manual-content.html b/docs/user-manual/manual-content.html
new file mode 100644
index 0000000..87800c8
--- /dev/null
+++ b/docs/user-manual/manual-content.html
@@ -0,0 +1,564 @@
+
+
+
+
+
+
+
+LinuxCNC Web/WASM 使用说明书
+
+
+
+
+
+
+
+
WEB / WASM · 五轴机床仿真与 RTCP 教学平台
+
LinuxCNC Web/WASM 使用说明书
+
本手册面向机床操作者与教学使用者,说明如何用浏览器访问这套 LinuxCNC 仿真控制器:选择机床案例、上电回零、手动与 MDI 操作、运行 G 代码、使用 RTCP 程序库,以及三维机床模型的导入、标定与保存。全文操作均在浏览器内完成,不需要安装 LinuxCNC,也不连接真实机床与硬件。
+
+ 访问地址 https://49.232.250.35:58010/
+ 来源基线 LinuxCNC 2.10.0~pre0
+ 当前可用机型 XYZAC / XYZBC 转头转台(TRT)
+ 功能巡检 26 项通过
+ 手册版本 2026-10-08
+
+
+
+
+
+
+ 1. 软件概览与访问
+
+ 1.1 这是什么
+ 这是一个把 LinuxCNC 仿真行为搬进浏览器的 Web 应用:数值密集的实时任务(解释器、轨迹规划、运动学正逆解)编译成 WebAssembly,在浏览器的独立 Worker 线程里运行;界面参照 LinuxCNC 官方 AXIS 控制面板,另加三维机床模型预览与五轴 RTCP 教学资源。
+
+ - 不连接真实设备:没有伺服、主轴、刀库和限位开关,所有运动都是仿真,不会造成任何机械或人身风险,适合教学与工艺验证。
+ - 无需安装:支持 WebAssembly 的现代浏览器(推荐最新版 Chrome 或 Edge)直接打开即可。
+ - 数据不出本机:G 代码解析、仿真运算都在浏览器里执行;导入的机床模型也只保留在页面与浏览器本地存储中。
+
+
+ 1.2 访问地址与首次打开
+ 在浏览器地址栏输入或点击:https://49.232.250.35:58010/
+
+ 证书提示:该站点使用 IP 地址的 HTTPS 自签证书,浏览器可能提示"您的连接不是私密连接 / 证书无效"。在确认地址为 49.232.250.35:58010 后,选择"高级 → 继续前往"即可。企业环境下也可以让管理员把证书导入受信任列表。
+
+ 打开后右上角会显示 Connection: READY,下方状态栏从 PENDING 变为具体状态(如 STATE_ESTOP_RESET),即表示 WASM 仿真会话已经就绪。首次加载需要下载约 1.7 MB 的前端与 WASM 资源,之后浏览器会缓存。
+ 标题栏右侧还有一个蓝色的 使用说明书 按钮,随时点击即可在新标签页打开本手册;菜单 Help → 使用说明书 / User manual 是同一个入口。
+
+ 1.3 边界说明(请提前知悉)
+
+ - 当前发布版本只支持 XYZAC TRT 与 XYZBC TRT 两个五轴转头转台案例的完整操作链;其余案例可以选中,但控制器不会就绪,详见 第 13 节 与 第 14 节。
+ - 少数 LinuxCNC 原生命令(如程序反向/正向、块删除、可选停止、Rapid 倍率、刀具表编辑)在本 WASM 运行时尚未开放,界面按钮置灰并在悬停时说明原因,这是设计上的"显式边界",不是故障。
+ - 断电崩溃恢复只覆盖页面刷新与 OPFS 检查点范围,不代表物理断电场景。
+
+
+
+
+ 2. 五分钟快速上手
+ 下面是从零到"跑起来一段五轴程序"的最短路径,按顺序操作即可。
+
+ - 打开页面。访问 https://49.232.250.35:58010/,等待右上角变成
Connection: READY、案例状态显示 Program: boat-xyzac.ngc。
+ - 确认仿真案例。"Simulation case"下拉框选择
XYZAC TRT(默认值)。
+ - 急停复位并上电。点击工具栏最左侧红色"急停"图标解除急停,再点击其右侧"上电"图标。关键:请按此顺序操作后,才会出现
STATE_ON。
+ - 回零。在左下"Manual Control"页签点击 HOME ALL,状态栏的
Machine sequence 变为 HOMED。
+ - 动一下。选轴
X,按住 + 连续 Jog,或切到 MDI [F5] 页签输入 G0 X10 Y0 Z0 后回车。
+ - 跑程序。点击案例条上的 Load XYZAC boat program,等状态显示
Program: boat-xyzac.ngc 后点击工具栏"运行"图标;用"暂停/继续"和"停止"控制执行。
+
+
+
+ 图 1 软件主界面(XYZAC TRT,已载入 boat-xyzac.ngc,等待上电与回零)。点击可放大查看各区域位置,区域说明见第 3 节。
+
+ 提示:只要不刷新页面,程序、机床模型和当前位置都会保留。刷新后仿真会按当前配置重新启动;若之前保存过检查点,可看到恢复提示。
+
+
+
+ 3. 界面总览
+ 界面自上而下分为六层,操作时按"选择对象 → 上电 → 回零 → 手动/程序"的次序使用即可。
+
+ | 区域 | 位置 | 作用 |
+
+ | 标题栏 | 最上方 | 左侧为产品标识;右侧为 使用说明书 按钮(新标签页打开本手册)与连接状态指示 Connection: READY |
+ | 菜单栏 | 标题栏下方 | File(宿主边界项,置灰)、Help → 使用说明书 / About AXIS |
+ | 工具栏 | 菜单下方图标行 | 急停、上电、打开文件、载入案例、重载、运行、单步、暂停/继续、停止、缩放、六个视图方向、拖动旋转、清除轨迹 |
+ | 案例与程序条 | 工具栏下方 | 选择仿真案例、载入内置船体程序、打开 RTCP 程序库、显示当前程序与加载进度 |
+ | 状态栏 | 案例条下方 | Task/解释器/运动模式/回零等运行状态,以及最近一条命令反馈 |
+ | 轴位与实时参数 | 状态栏下方 | 当前机型的坐标轴读数与关节值(J0…Jn)、偏置,以及主轴/进给/速度/倍率等实时量 |
+ | 操作区(左右分栏) | 页面下半部 | 左:Manual Control / MDI / Diagnostics 页签;右:Preview(刀路 + 机床模型)/ DRO 页签。底部为倍率滑块与预览图层开关 |
+
+
+ 界面图标沿用 LinuxCNC 官方 AXIS 位图资源,含义与桌面版一致(绿点=上电、双三角=运行、蓝色暂停=暂停、红色方块=停止)。把鼠标悬停在按钮上会显示名称和快捷键,按钮被禁用时会说明被禁用的原因。
+
+
+
+ 4. 状态与运行反馈
+
+ 4.1 状态栏字段
+
+ | 字段 | 取值示例 | 含义 |
+
+ | Task mode | MANUAL / MDI / AUTO | 任务模式:手动、MDI 单句、自动运行程序 |
+ | Task state | STATE_ESTOP、STATE_ESTOP_RESET、STATE_ON | 机床状态:急停中、急停已解除、已上电 |
+ | Interpreter | INTERP_IDLE / INTERP_READING / INTERP_PAUSED | G 代码解释器状态:空闲、读取中、已暂停 |
+ | Motion mode | FREE / TELEOP / COORD | 运动模式:自由、手动点动、坐标运动 |
+ | Motion type | 0 / 1 … | 当前运动类型编号(直线、圆弧等) |
+ | Homing | IDLE / ACTIVE | 是否正在回零 |
+ | Machine sequence | NOT HOMED / HOMED | 机床是否已完成回零 |
+ | 命令反馈 | PROGRAM_LOAD acknowledged | 最近一条命令的处理结果;被拒时也会在此显示原因 |
+
+
+
+ 4.2 常见"操作被拒绝"及处理
+
+ | 现象 | 原因 | 处理 |
+
+ 上电按钮置灰,提示 linuxcnc_wasm_snapshot_pending | 仿真会话还没就绪 | 等待状态栏出现具体状态;若超过 1 分钟仍为 PENDING,刷新页面 |
+ Jog / MDI / 主轴按钮置灰,提示 axis_machine_off | 尚未上电 | 先急停复位,再点上电 |
+ 手动按钮置灰,提示 not_homed | 未回零 | 点击 HOME ALL |
+ | 加载程序被拒,提示机型不匹配 | 程序要求的轴组与当前机型不一致(如 AC 程序用于 BC 机型) | 切换仿真案例,或在 RTCP 程序库中选择当前机型分类下的程序 |
+ | 单步/继续被拒 | 当前没有处于暂停或运行状态 | 先运行或暂停程序 |
+
+
+ 安全习惯:本平台只做仿真。但为养成正确操作习惯,请始终遵循"急停复位 → 上电 → 回零 → 运行"的顺序,题目与机床操作一致。
+
+
+
+ 5. 机床案例与程序
+
+ 5.1 选择仿真案例
+ "Simulation case"下拉框列出全部案例。切换案例会重新加载该机床的配置(轴数、限位、运动学、默认程序),界面轴位显示与 Jog 轴按钮随之改变。
+ 切换到 XYZBC TRT 后,轴组从 X Y Z A C 变为 X Y Z B C,默认程序变为 boat-xyzbc.ngc:
+
+
+ 图 2 切换到 XYZBC TRT 案例,轴组、机床标定参数与默认程序同步更新。
+
+ 切换案例时,界面会先自动下电并重载工作区,正在运行的程序会被终止。请先停止程序再切换机型。
+
+ 5.2 内置船体程序与 RTCP 程序库
+ 案例条上提供两个程序入口:
+
+ - Load XYZAC boat program:一键载入该机型自带的五轴船体干运行程序,适合快速演示。
+ - AC(XYZAC)RTCP 程序 (16):打开 LinuxCNC 官方 RTCP 程序库。
+
+
+
+ 图 3 RTCP 程序库。上方显示当前机型分类数量与可直接加载数量,中间可选择显示范围与具体程序,下方给出类型、机型、特点、源路径与兼容性判断。
+
+ 程序库按当前机型自动过滤:AC 机型默认显示 16 个相关程序,其中 9 个可作为主程序直接加载;切换到"全部 45 个程序"可以查看和下载其它机型的源程序,但它们不会作为当前机型的可执行项。每个程序都提供:
+
+ | 按钮 | 作用 |
+
+ | 查看源程序 | 在对话框内直接阅读该 .ngc 的完整文本(来自 LinuxCNC 源码树) |
+ | 加载到控制器 | 把程序文本交给 LinuxCNC WASM 解释器,刷新刀路预览;机型不匹配时按钮禁用并说明原因 |
+ | 下载 .ngc | 保存原始程序文件到本地,便于在其它软件中使用 |
+
+
+
+
+ 图 4 加载成功后在对话框底部提示"程序已加载到 LinuxCNC WASM 控制器",同时预览区刷新为新的刀路。
+
+
+ 5.3 打开本地 G 代码
+ 点击工具栏"打开文件"图标,选择本地 .ngc / .nc / .gcode / .tap 或纯文本文件即可载入。载入前会检查程序中的轴字是否与当前机型匹配,不匹配会拒绝并给出提示,避免用 AC 程序驱动 BC 机型。
+ 工具栏"重载"图标(Ctrl+R)用于把当前编辑器中的程序重新装载到解释器,适合手工修改后快速重试。
+
+
+ 图 5 File 菜单中"编辑刀具数据""退出"属于宿主进程能力,在浏览器中明确置灰,而不是伪装成可用。
+
+
+
+
+ 6. 手动控制
+ 左下角 Manual Control [F3] 页签是日常操作的主要区域。所有按钮都受"上电 + 已回零 + 无程序运行"的门控约束,条件不满足时置灰并说明原因。
+
+ 6.1 回零
+
+ - HOME ALL:按机床配置依次回零所有关节,完成后按钮变为 RE-HOME ALL。
+ - Joint 下拉 + Home Joint:只回零指定关节,便于排查单轴问题。
+
+
+
+ 图 6 回零完成:状态栏 Machine sequence 显示 HOMED,轴位归零,手动与程序按钮全部解锁。
+
+
+ 6.2 Jog 点动
+
+ - 在 Axis 中选中要移动的轴(如
X)。
+ - 在 Jog 行按住 - 或 + 连续点动,松开即停。
+ - 需要精确步进时,把中间的步长下拉框从
Continuous 改为 0.1000 / 0.0100 / 0.0010 / 0.0001,再点按一次按钮,即按该步长走一段。
+ - 键盘快捷:c 切回连续,i 在步长之间循环切换。
+
+
+
+ 图 7 按住 Jog + 期间 X 轴数值实时跟随 WASM 运动学输出变化;松开后停止。
+
+ Jog Speed 倍率位于页面底部,可按比例降低点动速度;五轴机床在回转轴接近行程极限时建议调低,便于观察。
+
+ 6.3 主轴、冷却液与刀具
+
+ | 控件 | 说明 |
+
+ | 主轴逆时针 / 顺时针图标 | 启动主轴相应转向,图标按下表示当前转向 |
+ | Stop Spindle | 停止主轴 |
+ | + / - 长按 | 按住期间以 100 rpm 步进增减转速,松开恢复恒定转速 |
+ | Brake | 主轴制动(快捷键 B 制动 / b 松开) |
+ | Mist / Flood | 雾状 / 大流量冷却液开关 |
+ | Tool / Probe | 显示当前刀具号、刀具状态与探针状态(只读) |
+
+
+
+
+ 图 8 启动主轴与打开雾状冷却液后,上方"Live parameters"面板同步显示主轴转速与指令值。
+
+ Touch Off / Tool Touch Off:用于设置工件坐标系与刀具偏置。当前 WASM 运行时未开放刀具偏置 ABI,Tool Touch Off 置灰;Touch Off 的可用性随机型与状态变化。
+
+
+
+ 7. MDI 与 DRO
+
+ 7.1 MDI 单句执行
+ 切到 MDI [F5] 页签,在"MDI Command"输入框键入一行 G 代码,回车或点 Go 执行。按 ↑ / ↓ 可在历史命令间翻找,双击历史条目也可以直接执行。
+
+
+ 图 9 执行 G0 X12.5 Y-4 Z0 后,轴位与刀路预览同步更新,命令进入下方 MDI History。
+
+ 常用 MDI 指令:
+ G0 X0 Y0 Z0 快速定位到原点
G1 X10 F200 以 200 mm/min 直线进给
G91 G1 X5 F100 增量方式移动 5 mm
M3 S1200 主轴正转 1200 rpm
G54 切入 1 号工件坐标系
+
+ 7.2 DRO 位置显示
+ DRO 页签以表格形式显示每个轴的三个读数,并提供三个显示开关:
+
+ | 开关 | 可选值 | 说明 |
+
+ | Coordinate | Machine / Relative | 机床坐标(回零后原点)或相对坐标 |
+ | Display | Actual / Commanded | 实际反馈位置或指令目标位置 |
+ | Units | mm / inch | 线性轴单位;旋转轴始终为度 |
+
+
+
+
+ 图 10 DRO 页签显示实际位置、指令位置与相对坐标三列,页脚汇总单位、当前协调模式和仿真 tick。
+
+
+
+
+ 8. 倍率与显示图层
+ 页面底部左侧是四个倍率滑块,右侧是六个预览图层开关。
+
+ | 控件 | 范围 | 用途 |
+
+ | Jog Speed | 6% – 100% | 手动点动速度比例 |
+ | Feed Override | 0% – 上限 | 程序进给倍率,调试时降低进给 |
+ | Spindle Override | 0% – 200% | 程序主轴转速倍率 |
+ | Max Velocity | 6% – 100% | 整体最大速度限制 |
+ | Rapid Override | — | 当前 WASM 轨迹规划器未开放,固定显示 Unavailable |
+
+
+ 预览图层:path 刀具路径、live 实时刀具位置、limits 行程边界、velocity 速度着色、offsets 坐标偏置指示、extents 毛坯包络。按需勾选可以避免画面过密。
+
+
+ 图 11 进给倍率调整到 80%,并临时关闭 velocity 图层观察刀路本体。
+
+
+
+
+ 9. 程序运行控制
+ 工具栏中部的五个按钮控制程序执行,顺序与 LinuxCNC AXIS 一致:
+
+ | 按钮 | 作用 | 前置条件 |
+
+ | 运行 | 从当前行开始执行程序 | 机床已上电、已回零、程序已装载 |
+ | 单步 | 只执行下一行 | 程序处于运行或暂停状态 |
+ | 暂停 / 继续 | 暂停当前运动;再次点击继续 | 程序运行中 / 已暂停 |
+ | 停止 | 中止程序并清空运动队列 | 程序运行或暂停中 |
+
+
+
+
+ 图 12 运行中:Task mode 为 AUTO、Interpreter 为 INTERP_READING,预览区显示刀具沿刀路移动,编辑器高亮当前执行行。
+
+
+
+ 图 13 暂停:Interpreter 变为 INTERP_PAUSED,运动停止但位置保持,可继续、单步或停止。暂停期间 MDI 会被拒绝,这是 LinuxCNC 的既有边界。
+
+ 程序编辑器支持行号槽与当前行高亮,右侧"Active G-Codes"实时显示生效的模态指令,便于教学逐行讲解。
+
+
+
+ 10. 三维预览与机床管理
+
+ 10.1 双视图预览
+ 右侧 Preview 页签分成左右两个画布:左边是刀路与刀具仿真,右边是机床三维模型,两者都由同一份 WASM 关节状态驱动。工具栏的六个视图按钮(俯视、旋转俯视、侧视、正视、倒视、透视)、缩放和"拖动旋转"模式同时作用于两个视图。
+
+
+ 图 14 切换视图方向并清除实时轨迹后,只保留当前刀路;机床模型姿态与 WASM 关节一致。
+
+
+ 10.2 三维机床管理
+ 预览区右上角第一个按钮打开三维机床管理对话框,用于把真实机床的 GLB 模型绑定为可驱动的五轴机床。对话框分四个步骤页签:
+
+ | 页签 | 内容 |
+
+ | 部件与层级 | GLB 内部结构树、搜索定位、节点自由度绑定(每个节点一个唯一自由度)、模型选择与删除、撤销/重做 |
+ | 运动验证 | 跟随数控系统实时关节位置、按物理零点手动验证、指定轴的层级运动测试 |
+ | 机床标定 | C 轴原点、A/B 到 C 轴偏置、机床物理零点(直线 mm / 旋转 deg) |
+ | 文件与恢复 | 模型名称与设计说明、保存整机 GLB、本地机床库、13 个 LinuxCNC 案例模板 |
+
+
+
+
+ 图 15 "部件与层级":导入模型后在此展开层级树,选择节点并指定其自由度。
+
+
+
+ 图 16 "运动验证":可锁定跟随实时关节,也可按物理零点手动输入 X/Y/Z/A/B/C 检查模型运动是否正确;这些输入只驱动模型,不向控制器发送运动命令。
+
+
+
+ 图 17 "机床标定":填写旋转中心、轴间偏置与物理零点,与 LinuxCNC 原生 TRT 运动学参数一一对应。
+
+
+
+ 图 18 "文件与恢复":命名并保存整机 GLB,或从本浏览器机床库打开、再下载、删除;下方列出 13 个 LinuxCNC 案例模板。
+
+
+ 10.3 导入机床模型
+ 第二个按钮打开三维机床模型对话框,可以直接试用四套内置示例(AC、BC、AC 桁架、BC 桁架),或导入自己的 GLB 文件。
+
+
+ 图 19 内置示例可以一键载入或下载;单个文件上限 64 MiB,超限会给出明确提示且不影响当前模型。
+
+ 导入的模型只在浏览器内解析与显示。若模型带有 JT_X / JT_Y / JT_Z / JT_A / JT_B / JT_C 之类的语义节点名,管理器会自动给出五轴识别结果与运动学建议,方便快速完成绑定。
+
+ 10.4 机床几何参数
+ TRT 机型还提供机床参数对话框,用于编辑旋转中心与轴间偏置,并支持"应用参数""重新读取""保存 INI"。修改参数后需要重新回零,界面会给出提示。
+
+
+ 图 20 旋转中心(X/Y/Z)与到 C 轴偏置(X/Y/Z)共六个参数直接对应 INI 中的运动学配置。
+
+
+
+
+ 11. 诊断信息
+ Diagnostics 页签汇总运行环境与限制,排查问题时优先看这里:
+
+ - Startup resources:当前案例入口文件、解析成功的资源数与阻塞数、HAL 记录数。
+ - Disabled operations:列出当前被门控/未开放的操作及原因(如
blocked_by_abi)。
+ - Source diagnostics:启动过程中缺失或未解析的资源路径与行号。
+
+
+
+ 图 21 诊断页显示 tick 计数、状态哈希、启动资源清单与被禁用操作的逐条原因。
+
+
+
+
+ 12. 快捷键与鼠标操作
+
+ | 按键 / 操作 | 功能 |
+
+ | F1 | 急停 / 解除急停(切换) |
+ | F2 | 上电 / 下电 |
+ | F3 / F5 | 切到 手动控制 / MDI 页签 |
+ | R | 运行程序 |
+ | P / S | 暂停 / 继续 |
+ | T | 单步执行下一行 |
+ | Esc | 停止程序(对话框打开时先关闭对话框) |
+ | Ctrl+R | 重载当前程序 |
+ | Ctrl+K | 清除实时轨迹 |
+ | Home / End | 回零当前关节 / 对当前关节 Touch Off |
+ | Ctrl+Home / Ctrl+End | 全部回零 / 刀具 Touch Off |
+ | B / b | 主轴制动 / 松开制动 |
+ | c / i | Jog 切回连续 / 循环切换增量步长 |
+ | 单击机床模型 | 选中该导入模型(黄色包围框) |
+ | 按住拖动预览区 | 旋转视角(开启"拖动旋转"模式) |
+
+
+ 窄屏(手机、平板竖屏)下界面自动改为上下堆叠布局,不会出现横向滚动:
+
+
+ 图 22 390 px 视口下的完整页面布局(整页截图)。
+
+
+
+
+ 13. 支持的机床案例
+ "Simulation case"下拉框中共有 13 个案例。当前发布版本中只有两个 TRT 案例可以完成"上电 → 回零 → 运行"的完整操作链,其余 11 个通用运动学案例仍在适配中。
+
+ | 案例 | 坐标轴 | 运动学 | 状态 | 说明 |
+
+ | XYZAC TRT | X Y Z A C | xyzac-trt-kins | 可用 | A 轴绕 X 倾转、C 轴绕 Z 回转,支持 RTCP 与 M428/M429 切换 |
+ | XYZBC TRT | X Y Z B C | xyzbc-trt-kins | 可用 | B 轴绕 Y 倾转、C 轴绕 Z 回转,支持 RTCP |
+ | xyzab-tdr | X Y Z A B | xyzab-tdr | 暂不可用 | 双旋转台;可在 Worker 层运行,浏览器侧尚未就绪 |
+ | bridge-mill-5axiskins | X Y Z B C W Y | 5axiskins | 暂不可用 | 龙门式五轴,带 W 轴沿刀轴进给 |
+ | max5-maxkins | X Y Z B C | maxkins | 暂不可用 | MAX5 结构 |
+ | hexapod-genhexkins | X Y Z A B C | genhexkins | 暂不可用 | 六自由度并联平台 |
+ | pentapod-pentakins | X Y Z A B | pentakins | 暂不可用 | 五杆并联结构 |
+ | puma-pumakins | X Y Z A B C | pumakins | 暂不可用 | 串联关节机器人 |
+ | puma-genserkins | X Y Z A B C | genserkins | 暂不可用 | 串联机器人(通用求解器) |
+ | trivkins-xyzab | X Y Z A B | trivkins | 暂不可用 | 直角坐标一一映射基线,无 RTCP |
+ | trivkins-xyzcw | X Y Z C W | trivkins | 暂不可用 | 车铣复合映射基线 |
+ | trivkins-xyzya | X Y Z Y A | trivkins | 暂不可用 | 双 Y 轴映射基线 |
+ | scorbot-kins | X Y Z A B | scorbot-kins | 暂不可用 | SCORBOT 机械臂 |
+
+
+ "暂不可用"表示可以选中案例、能读出程序名,但控制器停在 PENDING、上电按钮保持禁用。当前请仅使用前两个 TRT 案例;不小心选中其它案例后刷新页面即可恢复(详见 14.2)。
+
+
+
+ 14. 已知问题与规避
+ 以下是本次上线功能巡检(2026-10-08)中发现并已复现的问题,均已给出规避办法。工具栏"载入精确案例"按钮曾经失效,原因是点击事件对象被当成案例路径传入,该问题已修复并重新发布,本次巡检中该按钮已能正常重新载入当前案例。
+
+ 14.1 选中通用运动学案例后控制器卡住
+
+ | 项目 | 内容 |
+
+ | 现象 | 在案例下拉框中选中 13 个案例里除 XYZAC/XYZBC TRT 之外任意一个(如 xyzab-tdr、max5-maxkins),状态栏所有字段停留在 PENDING,上电按钮提示 linuxcnc_wasm_snapshot_pending,此时下拉框也无法再切回其它案例 |
+ | 原因 | 通用运动学案例在浏览器侧的启动链尚未完成(MGR-27 进行中):案例引用的运动学源文件未随前端产物发布,请求返回 404,且会话启动失败后前端没有回滚 |
+ | 规避 | 当前只使用 XYZAC TRT / XYZBC TRT;若误选其它案例,刷新浏览器页面即可恢复 |
+ | 影响面 | 13 个案例中的 11 个;两个 TRT 案例的功能不受影响 |
+
+
+
+
+
+ 15. 常见问题
+
+ 页面一直显示 PENDING,上电按钮点不了?
+ 多数情况是仿真会话尚未就绪,等待数秒即可;若超过 1 分钟仍不变,或曾在案例下拉框里选过通用运动学案例,请刷新页面。
+
+ Jog、MDI、主轴按钮都是灰的?
+ 按"急停复位 → 上电"的顺序操作,并确认状态栏 Task state 为 STATE_ON、Machine sequence 为 HOMED。把鼠标停在灰按钮上会显示具体原因。
+
+ 为什么 MDI 在程序暂停时无法执行?
+ 这是 LinuxCNC 的既有语义:程序运行/暂停期间 MDI 会被拒绝(mdi_while_program_active)。停止或让程序结束后再使用 MDI。
+
+ 加载 G 代码时提示机型不匹配?
+ 程序中的轴字与当前机型不一致。请先用案例下拉框切到对应机型(AC 或 BC),或使用 RTCP 程序库自动按机型过滤后的程序。
+
+ 浏览器提示证书不受信任?
+ 站点使用 IP 地址的 HTTPS 自签证书。确认地址无误后选择"高级 / 继续访问";如需长期使用,可让管理员把证书加入受信任列表。
+
+ 导入的机床模型会丢失吗?
+ 页面刷新后导入的模型需要重新导入;使用"三维机床管理 → 文件与恢复"保存到浏览器机床库的机床,可以在同一浏览器、同一地址下重新打开。清理浏览器站点数据会同时删除机床库记录。
+
+ 能连接真实机床吗?
+ 不能,也不在本项目范围内。本平台是纯仿真环境,硬件驱动保持在浏览器虚拟设备边界。
+
+
+
+ 16. 附录:功能测试结论
+ 本次发布软件的自动化功能巡检于 2026-10-08 在 https://49.232.250.35:58010/ 上用真实 Chrome 浏览器执行,覆盖 26 个功能步骤,全部通过;另有 1 项待完成项(通用运动学案例的浏览器运行链,见第 14 节)。
+
+ | 范围 | 覆盖内容 | 结果 |
+
+ | 启动与状态 | 页面加载、WASM 会话就绪、状态栏、轴位与实时参数来源 | 通过 |
+ | 菜单 | Help → About、File 菜单宿主边界项置灰 | 通过 |
+ | 程序资源 | RTCP 程序库按机型过滤、加载到控制器、打开本地 G 代码、重载 | 通过 |
+ | 机床模型 | 三维机床管理四个页签、导入机床模型、机床几何参数 | 通过 |
+ | 案例切换 | XYZAC ↔ XYZBC 切换及轴组同步 | 通过 |
+ | 机床操作 | 急停复位、上电、HOME ALL、连续与增量 Jog | 通过 |
+ | 辅助功能 | 主轴启停、冷却液、Tool/Probe 状态 | 通过 |
+ | MDI 与 DRO | MDI 执行与历史、DRO 三种显示切换 | 通过 |
+ | 倍率与图层 | 进给倍率调整、预览图层开关 | 通过 |
+ | 程序运行 | 载入、运行、暂停、继续、停止、工具栏"载入精确案例"重新载入 | 通过 |
+ | 视图与诊断 | 视图切换、清除轨迹、诊断页、快捷键、窄屏布局 | 通过 |
+ | 案例矩阵 | 13 个案例逐一探测 | 2 可用 / 11 待完成 |
+
+
+ 测试脚本与原始报告:docs/user-manual/capture_deployed_manual.mjs、docs/user-manual/deployment-test.report.json;截图位于 docs/user-manual/screenshots/。重新执行:
+ PLAYWRIGHT_MODULE=<playwright> node docs/user-manual/capture_deployed_manual.mjs
+
+
+
+
+
+
diff --git a/nextTask-glb-machine-manager-doc.md b/nextTask-glb-machine-manager-doc.md
new file mode 100644
index 0000000..f1c31b9
--- /dev/null
+++ b/nextTask-glb-machine-manager-doc.md
@@ -0,0 +1,64 @@
+# 三维机床管理 HTML 文档接续入口
+
+更新时间:2026-09-23
+工作目录:`/home/mes006/linuxcncWebWASM`
+
+## 当前任务
+
+用户要求把 [`docs/glb-machine-manager/00-三维机床管理详细功能文档.md`](docs/glb-machine-manager/00-三维机床管理详细功能文档.md) 制作为图文并茂的 HTML,并包含各类五轴机床案例。该任务已完成,HTML 文件为:
+
+[`docs/glb-machine-manager/00-三维机床管理详细功能文档.html`](docs/glb-machine-manager/00-三维机床管理详细功能文档.html)
+
+## 已完成内容
+
+- HTML 单文件中文文档,含响应式布局、打印/PDF 样式、案例搜索、状态筛选、图片放大查看和深浅色阅读切换。
+- 14 项 FR-01~FR-14 功能逐项说明,每项包含操作与输入、规则与异常、验收条件。
+- 补充结构分类:双转台、双摆头、头台混合、串联机器人、并联平台、直角坐标映射基线。
+- 覆盖 `five-axis-machine-catalog.json` 中全部 13 个案例:
+ - 当前支持:XYZAC TRT、XYZBC TRT。
+ - 需适配:XYZAB 双转台、Bridge Mill/5axiskins、MAX5/maxkins、Pentapod、Hexapod、PUMA/pumakins、PUMA 560/genserkins、SCORBOT。
+ - 映射基线:trivkins XYZAB、XYZCW、XYZYA。
+- 每个案例包含结构说明、坐标/关节数、运动学、参数重点、验证路径,以及本地 INI/源码/NGC 链接。
+- 6 张仓库已有图片已经内嵌为 data URL,HTML 可脱离 HTTP 服务离线打开;外部官方资料只保留文字链接。
+- 补充 AC 八部件流程、MILL700 单 GLB 五轴绑定流程、保存/恢复动作表、字段字典、单位和错误恢复矩阵。
+- 明确采用当前 MGR-17~22 实现:单文件完成五轴绑定即可应用;单自由度输入替代历史六分量自由编辑;八个独立部件不再是当前前提。
+- 已在 [`README.md`](README.md) 和 [`docs/glb-machine-manager/README.md`](docs/glb-machine-manager/README.md) 增加 HTML 入口。
+
+## 已验证
+
+1. HTML 链接和锚点检查:13 个案例、14 项详细需求、全部相对资源链接存在,无重复 ID。
+2. `node scripts/validate_five_axis_catalog.mjs`:`verified`,13 cases,62 个本地配置/源码文件。
+3. Chrome(`/usr/bin/google-chrome` + Playwright)浏览器报告:
+ - 1440、768、390、320 px 均无横向溢出;图片均成功加载。
+ - 案例筛选:全部 13、当前支持 2、需适配 8、映射基线 3。
+ - 搜索 Pentapod 返回 1 个;无匹配结果提示正常。
+ - 截图放大/关闭正常;无 page error、无 request failed、无外部网络请求。
+ - 打印模式包含全部 13 个案例,生成的 PDF 为 A4 29 页。
+ - 将 HTML 复制到 `/tmp` 后以 `file:` 直接打开,13 个案例仍可显示。
+4. 临时验证报告和截图位于 `/tmp/machine-doc-browser-report.json`、`/tmp/machine-doc-desktop.png`、`/tmp/machine-doc-cases.png`、`/tmp/machine-doc-mobile.png`、`/tmp/machine-doc-print.pdf`;这些不属于仓库交付,不要自动复制进仓库。
+
+## 当前工作区注意事项
+
+- 本任务新增 HTML 尚未提交;README 两处有本任务链接改动。
+- 工作区存在用户原有未提交内容和 dirty 的 `linuxcnc` 子模块。重启后先执行:
+
+```bash
+git status --short --branch
+git diff -- README.md docs/glb-machine-manager/README.md
+git -C linuxcnc status --short
+```
+
+- 不要执行 `git reset --hard`、`git clean`、覆盖子模块,也不要 `git add .`;提交时只选择本任务 HTML、两个 README 和本接续文件(如果用户要求提交)。
+- HTML 文件约 1.55 MiB,主要因为内嵌截图;不要把 `/tmp` 验证产物提交。
+
+## 重启后的第一轮
+
+1. 读取本文件,查看 HTML 的 `阅读基线`、`功能逐项说明与验收条件`、`每种类型的五轴机床案例`、`字段字典与转换边界`。
+2. 检查工作区状态,确认没有其它代理或用户改动需要覆盖。
+3. 若用户只说“继续”,先报告 HTML 已完成及验证结果,询问新的修改目标;不要重复生成文档或重跑大范围 LinuxCNC 回归。
+4. 若用户要继续优化,优先检查:HTML 是否需要压缩图片、是否需要新增真实机床截图、是否要把案例卡片改为从 JSON 动态生成;新增案例必须同步 `five-axis-machine-catalog.json` 和本地路径校验。
+5. 若用户要求提交,按最小文件集合检查 diff,再提交;不要包含已有 coverage、GLB、子模块或临时文件改动。
+
+重启后可直接发送:
+
+> 请在 `/home/mes006/linuxcncWebWASM` 接续工作。先读取 `nextTask-glb-machine-manager-doc.md`,检查工作区状态。三维机床管理 HTML 图文文档已经完成并通过 13 案例、14 项需求、四种视口、打印和离线打开验证;不要重复生成,等待我提供新的修改目标。保留其它未提交文件和 `linuxcnc` 子模块改动。
diff --git a/scripts/blender_machine_template.py b/scripts/blender_machine_template.py
new file mode 100644
index 0000000..16e4269
--- /dev/null
+++ b/scripts/blender_machine_template.py
@@ -0,0 +1,63 @@
+"""Prepare a LinuxCNC machine GLB for Blender based editing.
+
+Usage:
+ blender -b --python scripts/blender_machine_template.py -- \
+ --input web/react/public/machine-cases/physical/hexapod-genhexkins.glb \
+ --output /tmp/hexapod-machine.blend
+"""
+import argparse
+import bpy
+from mathutils import Vector
+
+
+def arg_value(name, default=None):
+ argv = bpy.app.handlers if False else __import__("sys").argv
+ if "--" not in argv:
+ return default
+ args = argv[argv.index("--") + 1:]
+ for index, value in enumerate(args):
+ if value == name and index + 1 < len(args):
+ return args[index + 1]
+ return default
+
+
+input_path = arg_value("--input")
+output_path = arg_value("--output", "/tmp/linuxcnc-machine.blend")
+if not input_path:
+ raise SystemExit("--input GLB is required")
+
+bpy.ops.wm.read_factory_settings(use_empty=True)
+bpy.ops.import_scene.gltf(filepath=input_path)
+scene = bpy.context.scene
+scene.unit_settings.system = "METRIC"
+scene.unit_settings.length_unit = "MILLIMETERS"
+scene["linuxcnc.workflow"] = "linuxcncWebWASM machine manager"
+scene["linuxcnc.source_glb"] = input_path
+
+collection = bpy.data.collections.new("LinuxCNC_Reference_Axes")
+scene.collection.children.link(collection)
+for name, direction, color in [
+ ("AXIS_X", (1, 0, 0), (1, 0, 0, 1)),
+ ("AXIS_Y", (0, 1, 0), (0, 1, 0, 1)),
+ ("AXIS_Z", (0, 0, 1), (0, 0, 1, 1)),
+]:
+ empty = bpy.data.objects.new(name, None)
+ empty.empty_display_type = "ARROWS"
+ empty.empty_display_size = 0.15
+ empty.color = color
+ empty["linuxcnc.axis_direction"] = direction
+ collection.objects.link(empty)
+
+origin = bpy.data.objects.new("MACHINE_ORIGIN", None)
+origin.empty_display_type = "PLAIN_AXES"
+origin.empty_display_size = 0.25
+origin["linuxcnc.role"] = "base"
+collection.objects.link(origin)
+
+for obj in scene.objects:
+ if obj.name.startswith("physical_") or obj.name == "machine_root":
+ obj["linuxcnc.editable_structure"] = True
+ obj["linuxcnc.units"] = "m in GLB / mm in editor"
+
+bpy.ops.wm.save_as_mainfile(filepath=output_path)
+print(f"BLENDER_MACHINE_TEMPLATE_OK {output_path}")
diff --git a/scripts/generate_physical_case_glbs.mjs b/scripts/generate_physical_case_glbs.mjs
new file mode 100644
index 0000000..cbc5e91
--- /dev/null
+++ b/scripts/generate_physical_case_glbs.mjs
@@ -0,0 +1,234 @@
+// Deterministic generator for the 13 physical-case structure GLBs.
+// Every movable joint is a Group named after its slot key and carries
+// userData.linuxcncJoint so the analyzer, the manager and the runtime can
+// bind it without name heuristics. Motion itself is never computed here:
+// the models only encode zero pose, hierarchy and joint axes.
+import { mkdir, writeFile } from 'node:fs/promises';
+import { createHash } from 'node:crypto';
+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((value) => { this.result = value; this.onloadend?.(); }); }
+ readAsDataURL(blob) { blob.arrayBuffer().then((value) => { this.result = `data:${blob.type};base64,${Buffer.from(value).toString('base64')}`; this.onloadend?.(); }); }
+};
+
+const out = new URL('../web/react/public/machine-cases/physical/', import.meta.url);
+await mkdir(out, { recursive: true });
+const colors = { base: 0x334a55, steel: 0x9fb3bb, blue: 0x167f9a, orange: 0xe39a35, dark: 0x17242b, white: 0xd8e3e6, joint: 0xc56d26, black: 0x11181c, gold: 0xd7b45a };
+const material = (name, metalness = .35, roughness = .38) => new THREE.MeshStandardMaterial({ color: colors[name] ?? colors.steel, metalness, roughness });
+const group = (parent, name, position = [0, 0, 0]) => { const g = new THREE.Group(); g.name = name; g.position.fromArray(position); parent.add(g); return g; };
+const box = (parent, name, size, position, mat = 'steel') => { const m = new THREE.Mesh(new THREE.BoxGeometry(...size), material(mat)); m.name = name; m.position.fromArray(position); parent.add(m); return m; };
+const cyl = (parent, name, radius, length, position, mat = 'steel', axis = 'z') => { const m = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, length, 32), material(mat)); m.name = name; m.position.fromArray(position); if (axis === 'x') m.rotation.z = Math.PI / 2; if (axis === 'y') m.rotation.x = Math.PI / 2; parent.add(m); return m; };
+const bar = (parent, name, a, b, radius = 10, mat = 'steel') => { const va = new THREE.Vector3(...a), vb = new THREE.Vector3(...b), delta = vb.clone().sub(va); const m = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, delta.length(), 24), material(mat)); m.name = name; m.position.copy(va.clone().add(vb).multiplyScalar(.5)); m.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), delta.normalize()); parent.add(m); return m; };
+
+// One movable slot. dir/sign are the joint axis in the parent frame; the model
+// keeps its zero pose so the runtime can evaluate absolute transforms.
+function joint(parent, caseId, index, key, axis, kind, dir, sign = 1, position = [0, 0, 0], source = 'kinematics') {
+ const g = group(parent, key.toLowerCase().replace(/[^a-z0-9]+/g, '_'), position);
+ g.userData.linuxcncNodeId = `${caseId}:${key}`;
+ g.userData.linuxcncJoint = { index, key, axis, type: kind, unit: kind === 'revolute' ? 'deg' : 'mm', direction: dir, sign, drive: 'joint', source };
+ return g;
+}
+function strut(parent, caseId, index) {
+ const g = group(parent, `strut_${index + 1}`);
+ g.userData.linuxcncNodeId = `${caseId}:J${index}`;
+ g.userData.linuxcncJoint = { index, key: `J${index}`, axis: null, type: 'strut', unit: 'mm', direction: 'z', sign: 1, drive: 'joint', source: 'kinematics' };
+ return g;
+}
+function setup(id, coordinates, kinematics, referenceStatus) {
+ const scene = new THREE.Scene(); scene.name = `physical_${id}`;
+ scene.userData.linuxcncPhysicalStructure = { schema: 'linuxcnc.physical-machine/1', caseId: id, coordinates, kinematics, referenceStatus, units: 'mm/deg', gltfUnit: 'm', generated: true, generator: 'scripts/generate_physical_case_glbs.mjs', license: 'CC0-1.0 procedural design reference', parts: [] };
+ return scene;
+}
+function bed(scene, width = 900, depth = 700) { const base = group(scene, 'base'); box(base, 'bed_casting', [width, depth, 100], [0, 0, -50], 'base'); box(base, 'chip_tray', [width * .95, depth * .88, 18], [0, 0, 12], 'dark'); return base; }
+
+// Vertical mill body. The slot list is explicit so every case can describe its
+// own joint order without a second, implicit convention inside the builder.
+function linearMill(scene, { id, slots }) {
+ const base = bed(scene);
+ box(base, 'column', [140, 420, 520], [0, 130, 300], 'base');
+ const nodes = new Map();
+ let parent = base;
+ // Slots tagged `under` are built on their named branch below, not in the Z chain.
+ for (const slot of slots.filter((item) => item.branch !== 'table' && !item.under)) {
+ const g = joint(parent, id, slot.index, slot.key, slot.axis, slot.kind, slot.dir, slot.sign ?? 1, slot.position ?? [0, 0, 0], slot.source);
+ nodes.set(slot.index, g);
+ if (slot.kind === 'prismatic') box(g, `${slot.key.toLowerCase()}_body`, slot.size ?? [240, 200, 50], [0, 0, 0], slot.mat ?? 'blue');
+ else cyl(g, `${slot.key.toLowerCase()}_bearings`, 64, 380, [0, 0, 0], 'joint', slot.dir);
+ parent = g;
+ }
+ const tool = group(parent, 'tool', [0, 0, -90]);
+ cyl(tool, 'spindle', 52, 180, [0, 0, 40], 'dark');
+ cyl(tool, 'tool_holder', 18, 90, [0, 0, -110], 'joint');
+ cyl(tool, 'tool', 8, 90, [0, 0, -200], 'gold');
+ const wSlot = slots.find((item) => item.under === 'tool');
+ if (wSlot) { const w = joint(tool, id, wSlot.index, wSlot.key, wSlot.axis, wSlot.kind, wSlot.dir, wSlot.sign ?? 1, [0, 0, 0], wSlot.source); nodes.set(wSlot.index, w); cyl(w, 'w_tool_direction', 16, 120, [0, 0, -60], 'gold'); }
+ const tableSlots = slots.filter((item) => item.branch === 'table');
+ let tableParent = base;
+ for (const [order, slot] of tableSlots.entries()) {
+ const g = joint(tableParent, id, slot.index, slot.key, slot.axis, slot.kind, slot.dir, slot.sign ?? 1, slot.position ?? [0, 0, 55 - order * 5], slot.source);
+ nodes.set(slot.index, g);
+ if (slot.kind === 'revolute') cyl(g, `${slot.key.toLowerCase()}_bearings`, 100, 380, [0, 0, 0], 'joint', slot.dir === 'x' ? 'x' : slot.dir === 'y' ? 'y' : 'z');
+ else box(g, `${slot.key.toLowerCase()}_slide`, [200, 160, 60], [0, 0, 0], 'orange');
+ tableParent = g;
+ }
+ const last = tableSlots.length ? nodes.get(tableSlots.at(-1).index) : base;
+ cyl(last, 'work_platter', 180, 28, [0, 0, 60], 'steel');
+ box(last, 'workholding', [190, 110, 28], [0, 0, 88], 'orange');
+ const gantry = slots.find((item) => item.under === 'base');
+ if (gantry) { const g = joint(base, id, gantry.index, gantry.key, gantry.axis, gantry.kind, gantry.dir, gantry.sign ?? 1, gantry.position ?? [0, 220, 80], gantry.source); nodes.set(gantry.index, g); box(g, 'y_right_gantry_drive', [120, 160, 120], [0, 0, 0], 'orange'); }
+ scene.userData.linuxcncPhysicalStructure.parts = ['base', ...slots.map((slot) => slot.key.toLowerCase()), 'tool'];
+ return scene;
+}
+function bridge(scene, id) {
+ const base = bed(scene, 1800, 1000);
+ box(base, 'foundation', [1900, 1100, 60], [0, 0, -80], 'dark');
+ box(base, 'left_column', [160, 360, 900], [-700, 100, 450], 'base');
+ box(base, 'right_column', [160, 360, 900], [700, 100, 450], 'base');
+ box(base, 'bridge_beam', [1500, 240, 160], [0, 100, 820], 'blue');
+ const x = joint(base, id, 0, 'X', 'X', 'prismatic', 'x');
+ box(x, 'moving_table', [1450, 720, 75], [0, 0, 50], 'steel');
+ const yTwin = joint(base, id, 6, 'Y#2', 'Y', 'prismatic', 'y', 1, [700, 0, 500], 'duplicate');
+ box(yTwin, 'y_right_gantry', [90, 120, 120], [0, 0, 0], 'orange');
+ box(base, 'y_left_servo', [90, 120, 120], [-700, 0, 500], 'orange');
+ const y = joint(base, id, 1, 'Y', 'Y', 'prismatic', 'y', 1, [0, 0, 500]);
+ box(y, 'bridge_carriage', [360, 300, 140], [0, 0, 220], 'blue');
+ const z = joint(y, id, 2, 'Z', 'Z', 'prismatic', 'z');
+ box(z, 'ram', [180, 180, 430], [0, 0, 0], 'steel');
+ const b = joint(z, id, 3, 'B', 'B', 'revolute', 'y', -1, [0, 0, -220]);
+ cyl(b, 'b_axis_bearings', 92, 260, [0, 0, 0], 'joint', 'y');
+ const c = joint(b, id, 4, 'C', 'C', 'revolute', 'z', 1, [0, 0, -160]);
+ cyl(c, 'c_spindle_axis', 54, 220, [0, 0, -60], 'dark');
+ const tool = group(c, 'tool', [0, 0, -170]);
+ const w = joint(tool, id, 5, 'W', 'W', 'prismatic', 'z', 1, [0, 0, 0]);
+ cyl(w, 'w_tool_direction', 16, 120, [0, 0, -60], 'gold');
+ scene.userData.linuxcncPhysicalStructure.parts = ['base', 'x_slide', 'y_slide', 'z_slide', 'b_head', 'c_head', 'tool', 'y_right_gantry', 'w_tool_direction'];
+ return scene;
+}
+function max5(scene, id) {
+ const base = bed(scene);
+ box(base, 'column', [160, 420, 480], [0, 150, 300], 'base');
+ const x = joint(base, id, 0, 'X', 'X', 'prismatic', 'x');
+ box(x, 'x_table', [520, 360, 55], [0, 0, 0], 'blue');
+ const y = joint(x, id, 1, 'Y', 'Y', 'prismatic', 'y', 1, [0, 0, 80]);
+ box(y, 'y_saddle', [260, 220, 50], [0, 0, -25], 'steel');
+ const z = joint(y, id, 2, 'Z', 'Z', 'prismatic', 'z', 1, [0, 0, 170]);
+ box(z, 'z_ram', [135, 145, 260], [0, 0, 60], 'blue');
+ const b = joint(z, id, 3, 'B', 'B', 'revolute', 'y', -1, [0, 0, -80]);
+ cyl(b, 'b_head', 70, 180, [0, 0, -60], 'dark', 'y');
+ const tool = group(b, 'tool', [0, 0, -140]); cyl(tool, 'tool', 10, 110, [0, 0, -55], 'gold');
+ const c = joint(base, id, 4, 'C', 'C', 'revolute', 'z', 1, [0, 0, 60]);
+ cyl(c, 'c_horizontal_table', 200, 40, [0, 0, 0], 'steel');
+ box(c, 'workholding', [190, 110, 28], [0, 0, 34], 'orange');
+ joint(base, id, 5, 'J5', null, 'prismatic', 'z', 1, [0, 0, 0], 'passive');
+ scene.userData.linuxcncPhysicalStructure.parts = ['base', 'x_slide', 'y_saddle', 'z_slide', 'b_head', 'tool', 'c_horizontal_table'];
+ return scene;
+}
+function parallel(scene, id, count) {
+ const base = bed(scene, 1100, 850);
+ box(base, 'base_plate', [900, 700, 40], [0, 0, 20], 'steel');
+ const platform = group(base, 'moving_platform', [0, 0, 600]);
+ for (let i = 0; i < count; i += 1) {
+ const angle = i * Math.PI * 2 / count;
+ const anchor = [420 * Math.cos(angle), 420 * Math.sin(angle), 20];
+ const s = strut(base, id, i);
+ s.position.fromArray(anchor);
+ const lower = group(s, `strut_${i + 1}_lower`); bar(lower, 'rod', [0, 0, 0], [0, 0, 540], 18, 'steel');
+ const upper = group(s, `strut_${i + 1}_upper`, [0, 0, 540]); cyl(upper, 'rod', 11, 60, [0, 0, 0], 'joint', 'z');
+ s.userData.linuxcncStrut = { index: i, lower: lower.name, upper: upper.name, baseAnchorMm: anchor, platformAnchorMm: [170 * Math.cos(angle + .3), 170 * Math.sin(angle + .3), 560], zeroLengthMm: 540 };
+ }
+ box(platform, 'platform_plate', [300, 300, 45], [0, 0, 0], 'blue');
+ cyl(platform, 'spindle', 50, 180, [0, 0, -110], 'dark');
+ cyl(platform, 'tool', 16, 120, [0, 0, -250], 'gold');
+ scene.userData.linuxcncPhysicalStructure.parts = ['base', ...Array.from({ length: count }, (_, i) => `strut_${i + 1}`), 'moving_platform', 'spindle', 'tool'];
+ return scene;
+}
+function robot(scene, id, count) {
+ const base = bed(scene, 650, 650);
+ cyl(base, 'base_turntable', 150, 70, [0, 0, 30], 'dark');
+ const j1 = joint(base, id, 0, 'J0', null, 'revolute', 'z', 1, [0, 0, 60]);
+ const j2 = joint(j1, id, 1, 'J1', null, 'revolute', 'y', 1, [0, 0, 200]);
+ box(j2, 'upper_arm', [100, 100, 260], [0, 0, 125], 'blue');
+ const j3 = joint(j2, id, 2, 'J2', null, 'revolute', 'y', 1, [0, 0, 260]);
+ box(j3, 'forearm', [82, 82, 260], [0, 0, 130], 'steel');
+ const j4 = joint(j3, id, 3, 'J3', null, 'revolute', 'z', 1, [0, 0, 260]);
+ cyl(j4, 'wrist_1', 55, 110, [0, 0, 55], 'joint', 'z');
+ const j5 = joint(j4, id, 4, 'J4', null, 'revolute', 'y', 1, [0, 0, 110]);
+ cyl(j5, 'wrist_2', 48, 95, [0, 0, 48], 'joint', 'y');
+ let tip = j5;
+ if (count > 5) { const j6 = joint(j5, id, 5, 'J5', null, 'revolute', 'z', 1, [0, 0, 96]); cyl(j6, 'wrist_3', 40, 90, [0, 0, 45], 'joint', 'z'); tip = j6; }
+ const tool = group(tip, 'tool', [0, 0, 90]); cyl(tool, 'tool', 14, 120, [0, 0, 60], 'gold');
+ scene.userData.linuxcncPhysicalStructure.parts = ['base', ...Array.from({ length: count }, (_, i) => `J${i}`), 'tool'];
+ return scene;
+}
+const specs = [
+ ['xyzac-trt', () => linearMill(setup('xyzac-trt', 'XYZAC', 'xyzac-trt-kins', 'real-reference'), { id: 'xyzac-trt', slots: [
+ { index: 0, key: 'X', axis: 'X', kind: 'prismatic', dir: 'x', size: [520, 360, 55] },
+ { index: 1, key: 'Y', axis: 'Y', kind: 'prismatic', dir: 'y', position: [0, 0, 80], size: [260, 220, 50], mat: 'steel' },
+ { index: 2, key: 'Z', axis: 'Z', kind: 'prismatic', dir: 'z', position: [0, 0, 170], size: [135, 145, 290] },
+ { index: 3, key: 'A', axis: 'A', kind: 'revolute', dir: 'x', branch: 'table', position: [0, 0, 55] },
+ { index: 4, key: 'C', axis: 'C', kind: 'revolute', dir: 'z', branch: 'table', position: [0, 0, 50] },
+ ] })],
+ ['xyzbc-trt', () => linearMill(setup('xyzbc-trt', 'XYZBC', 'xyzbc-trt-kins', 'real-reference'), { id: 'xyzbc-trt', slots: [
+ { index: 0, key: 'X', axis: 'X', kind: 'prismatic', dir: 'x', size: [520, 360, 55] },
+ { index: 1, key: 'Y', axis: 'Y', kind: 'prismatic', dir: 'y', position: [0, 0, 80], size: [260, 220, 50], mat: 'steel' },
+ { index: 2, key: 'Z', axis: 'Z', kind: 'prismatic', dir: 'z', position: [0, 0, 170], size: [135, 145, 290] },
+ { index: 3, key: 'B', axis: 'B', kind: 'revolute', dir: 'y', branch: 'table', position: [0, 0, 55] },
+ { index: 4, key: 'C', axis: 'C', kind: 'revolute', dir: 'z', branch: 'table', position: [0, 0, 50] },
+ ] })],
+ ['xyzab-tdr', () => linearMill(setup('xyzab-tdr', 'XYZAB', 'xyzab_tdr_kins', 'design-reference'), { id: 'xyzab-tdr', slots: [
+ { index: 0, key: 'X', axis: 'X', kind: 'prismatic', dir: 'x', size: [520, 360, 55] },
+ { index: 1, key: 'Y', axis: 'Y', kind: 'prismatic', dir: 'y', position: [0, 0, 80], size: [260, 220, 50], mat: 'steel' },
+ { index: 2, key: 'Z', axis: 'Z', kind: 'prismatic', dir: 'z', position: [0, 0, 170], size: [135, 145, 290] },
+ { index: 3, key: 'A', axis: 'A', kind: 'revolute', dir: 'x', branch: 'table', position: [0, 0, 55] },
+ { index: 4, key: 'B', axis: 'B', kind: 'revolute', dir: 'y', branch: 'table', position: [0, 0, 50] },
+ ] })],
+ ['bridge-mill-5axiskins', () => bridge(setup('bridge-mill-5axiskins', 'XYZBCWY', '5axiskins', 'real-reference'), 'bridge-mill-5axiskins')],
+ ['max5-maxkins', () => max5(setup('max5-maxkins', 'XYZBC', 'maxkins', 'design-reference'), 'max5-maxkins')],
+ ['hexapod-genhexkins', () => parallel(setup('hexapod-genhexkins', 'XYZABC', 'genhexkins', 'real-reference'), 'hexapod-genhexkins', 6)],
+ ['pentapod-pentakins', () => parallel(setup('pentapod-pentakins', 'XYZAB', 'pentakins', 'real-reference'), 'pentapod-pentakins', 5)],
+ ['puma-pumakins', () => robot(setup('puma-pumakins', 'XYZABC', 'pumakins', 'real-reference'), 'puma-pumakins', 6)],
+ ['puma-genserkins', () => robot(setup('puma-genserkins', 'XYZABC', 'genserkins', 'real-reference'), 'puma-genserkins', 6)],
+ ['trivkins-xyzab', () => linearMill(setup('trivkins-xyzab', 'XYZAB', 'trivkins', 'design-reference'), { id: 'trivkins-xyzab', slots: [
+ { index: 0, key: 'X', axis: 'X', kind: 'prismatic', dir: 'x', size: [520, 360, 55] },
+ { index: 1, key: 'Y', axis: 'Y', kind: 'prismatic', dir: 'y', position: [0, 0, 80], size: [260, 220, 50], mat: 'steel' },
+ { index: 2, key: 'Z', axis: 'Z', kind: 'prismatic', dir: 'z', position: [0, 0, 170], size: [135, 145, 290] },
+ { index: 3, key: 'A', axis: 'A', kind: 'revolute', dir: 'x', branch: 'table', position: [0, 0, 55] },
+ { index: 4, key: 'B', axis: 'B', kind: 'revolute', dir: 'y', branch: 'table', position: [0, 0, 50] },
+ ] })],
+ ['trivkins-xyzcw', () => linearMill(setup('trivkins-xyzcw', 'XYZCW', 'trivkins', 'design-reference'), { id: 'trivkins-xyzcw', slots: [
+ { index: 0, key: 'X', axis: 'X', kind: 'prismatic', dir: 'x', size: [520, 360, 55] },
+ { index: 1, key: 'Y', axis: 'Y', kind: 'prismatic', dir: 'y', position: [0, 0, 80], size: [260, 220, 50], mat: 'steel' },
+ { index: 2, key: 'Z', axis: 'Z', kind: 'prismatic', dir: 'z', position: [0, 0, 170], size: [135, 145, 290] },
+ { index: 3, key: 'C', axis: 'C', kind: 'revolute', dir: 'z', branch: 'table', position: [0, 0, 55] },
+ { index: 4, key: 'W', axis: 'W', kind: 'prismatic', dir: 'z', under: 'tool', position: [0, 0, 0] },
+ ] })],
+ ['trivkins-xyzya', () => linearMill(setup('trivkins-xyzya', 'XYZYA', 'trivkins', 'design-reference'), { id: 'trivkins-xyzya', slots: [
+ { index: 0, key: 'X', axis: 'X', kind: 'prismatic', dir: 'x', size: [520, 360, 55] },
+ { index: 1, key: 'Y', axis: 'Y', kind: 'prismatic', dir: 'y', position: [0, 0, 80], size: [260, 220, 50], mat: 'steel' },
+ { index: 2, key: 'Z', axis: 'Z', kind: 'prismatic', dir: 'z', position: [0, 0, 170], size: [135, 145, 290] },
+ { index: 3, key: 'Y#2', axis: 'Y', kind: 'prismatic', dir: 'y', under: 'base', position: [-420, 0, 80], source: 'duplicate' },
+ { index: 4, key: 'A', axis: 'A', kind: 'revolute', dir: 'x', branch: 'table', position: [0, 0, 55] },
+ ] })],
+ ['scorbot-kins', () => robot(setup('scorbot-kins', 'XYZAB', 'scorbot-kins', 'real-reference'), 'scorbot-kins', 5)],
+];
+const manifest = { schema: 'linuxcnc.physical-machine-glb/1', license: 'CC0-1.0 procedural design reference', generator: 'scripts/generate_physical_case_glbs.mjs', cases: [] };
+for (const [id, make] of specs) {
+ const scene = make();
+ const exportScene = new THREE.Scene(); exportScene.name = scene.name; exportScene.userData = scene.userData;
+ const machineRoot = group(exportScene, 'machine_root');
+ while (scene.children.length) machineRoot.add(scene.children[0]);
+ machineRoot.scale.setScalar(0.001);
+ exportScene.updateMatrixWorld(true);
+ const bytes = await new GLTFExporter().parseAsync(exportScene, { binary: true, trs: true, onlyVisible: false });
+ const buffer = Buffer.from(bytes);
+ await writeFile(new URL(`${id}.glb`, out), buffer);
+ const structure = scene.userData.linuxcncPhysicalStructure;
+ const joints = []; machineRoot.traverse((node) => { if (node.userData.linuxcncJoint) joints.push({ ...node.userData.linuxcncJoint, node: node.name, nodeId: node.userData.linuxcncNodeId }); });
+ manifest.cases.push({ id, coordinates: structure.coordinates, kinematics: structure.kinematics, filename: `${id}.glb`, bytes: buffer.length, sha256: createHash('sha256').update(buffer).digest('hex'), referenceStatus: structure.referenceStatus, parts: structure.parts, joints: joints.sort((a, b) => a.index - b.index) });
+ exportScene.traverse((o) => o.geometry?.dispose());
+}
+await writeFile(new URL('manifest.json', out), `${JSON.stringify(manifest, null, 2)}\n`);
+await writeFile(new URL('LICENSE.txt', out), 'Procedural design-reference assets for linuxcncWebWASM. CC0-1.0.\nNo manufacturer affiliation or dimensional calibration is implied.\n');
+console.log(JSON.stringify({ schema: 'physical-case-glb-generation.v1', cases: manifest.cases.length, joints: manifest.cases.reduce((sum, c) => sum + c.joints.length, 0) }));
diff --git a/scripts/generate_realistic_machine_glbs.py b/scripts/generate_realistic_machine_glbs.py
new file mode 100644
index 0000000..95b60ae
--- /dev/null
+++ b/scripts/generate_realistic_machine_glbs.py
@@ -0,0 +1,165 @@
+"""Generate detailed Blender GLB design references for the 13 LinuxCNC cases.
+
+The meshes are procedural design references. Dimensions are plausible layout
+values in metres and are carried as editable GLB geometry, while kinematics and
+calibration remain the responsibility of the machine manager metadata.
+"""
+import bpy, json, math, os, hashlib, sys
+from mathutils import Vector
+
+ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
+OUT = os.path.join(ROOT, "web", "react", "public", "machine-cases", "physical")
+os.makedirs(OUT, exist_ok=True)
+
+COLORS = {
+ "cast": (0.16, 0.22, 0.25, 1), "paint": (0.08, 0.35, 0.42, 1),
+ "light": (0.48, 0.58, 0.61, 1), "steel": (0.20, 0.27, 0.30, 1),
+ "rail": (0.68, 0.76, 0.78, 1), "black": (0.015, 0.022, 0.025, 1),
+ "yellow": (0.86, 0.48, 0.08, 1), "orange": (0.75, 0.23, 0.05, 1),
+ "glass": (0.03, 0.18, 0.21, 1), "rubber": (0.02, 0.025, 0.027, 1),
+ "joint": (0.78, 0.28, 0.05, 1),
+}
+MATS = {}
+
+def mat(name, metallic=0.35, rough=0.32):
+ if name in MATS: return MATS[name]
+ m = bpy.data.materials.new("M_" + name)
+ m.diffuse_color = COLORS[name]
+ m.use_nodes = True
+ bsdf = m.node_tree.nodes.get("Principled BSDF")
+ bsdf.inputs["Base Color"].default_value = COLORS[name]
+ bsdf.inputs["Metallic"].default_value = metallic
+ bsdf.inputs["Roughness"].default_value = rough
+ MATS[name] = m
+ return m
+
+def collection(name):
+ c = bpy.data.collections.new(name); bpy.context.scene.collection.children.link(c); return c
+
+def link(obj, parent):
+ for c in list(obj.users_collection): c.objects.unlink(obj)
+ parent.objects.link(obj); return obj
+
+def group(parent, name, position=(0, 0, 0)):
+ g = bpy.data.objects.new(name, None)
+ if hasattr(parent, "objects"):
+ parent.objects.link(g)
+ else:
+ g.parent = parent
+ g.location = position
+ return g
+
+def root_object(scene):
+ root = bpy.data.objects.new("machine_root", None)
+ scene.collection.objects.link(root)
+ return root
+
+def box(name, size, loc, material="steel", parent=None, bevel=0.012):
+ bpy.ops.mesh.primitive_cube_add(location=loc)
+ o=bpy.context.object; o.name=name; o.dimensions=size; bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
+ o.data.materials.append(mat(material))
+ if bevel:
+ mod=o.modifiers.new("edge_radius", "BEVEL"); mod.width=bevel; mod.segments=3
+ if parent: o.parent=parent
+ return o
+
+def cyl(name, radius, depth, loc, material="steel", parent=None, axis="z", vertices=32):
+ bpy.ops.mesh.primitive_cylinder_add(vertices=vertices, radius=radius, depth=depth, location=loc)
+ o=bpy.context.object; o.name=name; o.data.materials.append(mat(material))
+ if axis=="x": o.rotation_euler[1]=math.pi/2
+ elif axis=="y": o.rotation_euler[0]=math.pi/2
+ if parent: o.parent=parent
+ return o
+
+def beam(name, a, b, radius, material="steel", parent=None):
+ a,b=Vector(a),Vector(b); d=b-a
+ o=cyl(name,radius,d.length/1.0,a+d/2,material,parent,"z",24)
+ o.rotation_mode="QUATERNION"; o.rotation_quaternion=Vector((0,0,1)).rotation_difference(d.normalized()); return o
+
+def bolt_row(parent, start, count, step, axis="x", radius=0.006):
+ for i in range(count):
+ p=list(start); p["xyz".index(axis)] += i*step; cyl(f"bolt_{axis}_{i+1}",radius,0.012,p,"black",parent,axis)
+
+def rail(parent, name, loc, length, axis="x"):
+ box(name+"_guide", (length,.026,.026) if axis=="x" else (.026,length,.026) if axis=="y" else (.026,.026,length), loc, "rail", parent, .004)
+ bolt_row(parent, [loc[0]-length/2+0.04,loc[1],loc[2]], max(2,int(length/.16)), .16, axis, .004)
+
+def rotary_group(parent,name,axis,loc,radius=0.22,thickness=.08):
+ g=group(parent,name,loc)
+ cyl(name+"_bearing",radius*.38,thickness*1.8,(0,0,0),"black",g,axis)
+ cyl(name+"_axis",radius*.25,thickness*2.1,(0,0,0),"yellow",g,axis)
+ return g
+
+def industrial_mill(id, coords, title, rotary):
+ scene=base_scene(id,coords,"design-reference" if id not in ("xyzac-trt","xyzbc-trt") else "real-reference",title)
+ root=root_object(scene)
+ base=group(root,"base"); box("bed_casting",(1.45,.95,.18),(0,0,.09),"cast",base); box("chip_tray",(1.38,.82,.035),(0,0,.20),"black",base)
+ for x in (-.62,.62): box("column",(.14,.20,1.15),(x,.22,.78),"cast",base); rail(base,"vertical_rail",(x, .105, .78),.86,"z")
+ box("bridge_crossbeam",(1.28,.20,.16),(0,.22,1.27),"paint",base)
+ x=group(root,"x_slide"); box("x_carriage",(.30,.22,.18),(0,.10,1.15),"paint",x); rail(x,"x_linear_rail",(0,.04,1.26),1.05,"x")
+ y=group(x,"y_saddle"); box("y_saddle_casting",(.30,.33,.14),(0,0,1.12),"cast",y); rail(y,"y_linear_rail",(0,-.13,1.02),.62,"y")
+ z=group(y,"z_slide"); box("z_ram",(.22,.22,.56),(0,.0,.80),"paint",z); rail(z,"z_linear_rail",(.12,0,.80),.48,"z")
+ tool=group(z,"tool"); cyl("spindle_motor",.075,.24,(0,0,.45),"black",tool); cyl("spindle_quill",.048,.23,(0,0,.22),"rail",tool); cyl("tool_holder",.024,.11,(0,0,.04),"yellow",tool)
+ tilt=group(base, "a_tilt_frame" if rotary=="AC" else "b_trunnion" if rotary=="BC" else "a_outer_table", [0,-.03,.27])
+ axis="x" if rotary in ("AC","AB") else "y"; rotary_group(tilt,"tilt_axis",axis,(0,0,0),.32,.10)
+ work=group(tilt,"c_table" if rotary in ("AC","BC") else "b_inner_table")
+ rotary_group(work,"rotary_axis","z",(0,0,.08),.27,.10); cyl("work_platter",.24,.06,(0,0,.15),"rail",work); box("fixture_plate",(.28,.18,.04),(0,0,.20),"yellow",work)
+ scene["linuxcnc.parts"]=["base","x_slide","y_saddle","z_slide","tool",tilt.name,work.name]
+ return scene
+
+def bridge_scene():
+ scene=base_scene("bridge-mill-5axiskins","XYZBCWY","real-reference","Bridge Mill bi-rotary head")
+ root=root_object(scene); base=group(root,"base")
+ box("foundation",(2.4,1.4,.22),(0,0,.11),"cast",base); box("moving_x_table",(2.05,1.05,.11),(0,0,.28),"steel",base)
+ for x in (-.95,.95): box("portal_column",(.20,.42,1.75),(x,.18,1.0),"cast",base)
+ box("portal_beam",(2.15,.42,.22),(0,.18,1.82),"paint",base); x=group(root,"x_slide"); box("bridge_carriage",(.38,.36,.24),(0,.18,1.63),"steel",x); y=group(x,"y_slide"); box("cross_carriage",(.38,.50,.20),(0,0,1.62),"paint",y); z=group(y,"z_slide"); box("ram",(.22,.22,.65),(0,0,1.32),"steel",z)
+ b=group(z,"b_head"); rotary_group(b,"b_axis","y",(0,0,.95),.23,.10); c=group(b,"c_head"); rotary_group(c,"c_axis","z",(0,0,.8),.17,.10); cyl("spindle",.055,.25,(0,0,.58),"black",c); cyl("tool",.022,.18,(0,0,.36),"yellow",c); box("y_left_drive",(.10,.20,.18),(-1.04,.18,1.72),"orange",base); box("y_right_drive",(.10,.20,.18),(1.04,.18,1.72),"orange",base); scene["linuxcnc.parts"]=["base","x_slide","y_slide","z_slide","b_head","c_head","y_left_drive","y_right_drive"]; return scene
+
+def parallel_scene(id,count,title):
+ scene=base_scene(id,"XYZABC" if count==6 else "XYZAB","real-reference",title); root=root_object(scene); base=group(root,"base"); box("base_plate",(1.15,.90,.10),(0,0,.05),"cast",base); plat=group(root,"moving_platform",[0,0,.75]); box("platform_plate",(.46,.46,.07),(0,0,0),"paint",plat)
+ for i in range(count):
+ a=2*math.pi*i/count; b=2*math.pi*i/count+.4; beam(f"strut_{i+1}_lower",[.43*math.cos(a),.43*math.sin(a),.13],[.20*math.cos(b),.20*math.sin(b),.67],.027,"steel",base); beam(f"strut_{i+1}_upper",[.20*math.cos(b),.20*math.sin(b),.67],[.0,.0,.75],.014,"yellow",plat)
+ cyl("spindle",.07,.22,(0,0,-.16),"black",plat); cyl("tool",.025,.18,(0,0,-.36),"yellow",plat); scene["linuxcnc.parts"]=["base"]+[f"strut_{i+1}" for i in range(count)]+["moving_platform","spindle","tool"]; return scene
+
+def robot_scene(id, five=False):
+ coords="XYZAB" if five else "XYZABC"; scene=base_scene(id,coords,"real-reference","SCORBOT ER-4U" if five else "PUMA serial robot"); root=root_object(scene); base=group(root,"base"); box("robot_pedestal",(.55,.55,.18),(0,0,.09),"cast",base)
+ parents=[base]; names=["j1_base","j2_shoulder","j3_elbow","j4_wrist","j5_pitch"]+([] if five else ["j6_wrist"])
+ heights=[.25,.32,.27,.14,.10,.08]
+ for i,n in enumerate(names):
+ g=group(parents[-1],n,[0,0,heights[i] if i errors.push(String(error)));
+page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
+await page.goto(BASE, { waitUntil: 'domcontentloaded' });
+await page.waitForFunction(() => document.querySelector('[data-axis-state-source="linuxcnc-wasm"]'), null, { timeout: 120000 });
+
+// A case switch powers the machine off, and `gates.load` requires
+// machineOn && idle, so every switch after the first one needs power on first.
+async function ensureLoadable() {
+ for (let attempt = 0; attempt < 4; attempt += 1) {
+ const state = await page.evaluate(() => {
+ const button = (id) => document.querySelector(`[data-axis-control="${id}"]`);
+ return { estop: button("toolbar-estop")?.getAttribute("aria-pressed"), power: button("toolbar-power")?.getAttribute("aria-pressed"), powerDisabled: button("toolbar-power")?.hasAttribute("disabled") };
+ });
+ if (state.estop === "true") { await button("toolbar-estop"); continue; }
+ if (state.power !== "true" && !state.powerDisabled) { await button("toolbar-power"); await page.waitForTimeout(1500); continue; }
+ return;
+ }
+}
+async function button(id) { await page.click(`[data-axis-control="${id}"]`).catch(() => undefined); await page.waitForTimeout(1200); }
+
+const rows = [];
+for (const item of catalog.cases) {
+ await ensureLoadable();
+ await page.selectOption('[data-axis-control="workspace-case-select"]', item.entrypoint);
+ // Wait for THIS case's model, not merely "some" model: the previous case's
+ // GLB stays mounted until the new one is parsed, which made an earlier run
+ // report slot keys one case behind.
+ const expectedModel = item.glb.default;
+ const loaded = await page.waitForFunction(
+ (name) => {
+ const node = document.querySelector('[data-xyzac-preview="machine"]');
+ const model = node?.getAttribute('data-xyzac-model-source-path');
+ const glb = node?.getAttribute('data-machine-glb');
+ return model === name && glb ? glb : null;
+ },
+ expectedModel, { timeout: 90000 }).catch(() => null);
+ const glb = loaded ? await loaded.jsonValue() : null;
+ const slots = await page.evaluate(() => {
+ const raw = document.querySelector('[data-xyzac-preview="machine"]')?.getAttribute('data-machine-joint-state');
+ if (!raw) return null;
+ try { return Object.keys(JSON.parse(raw)); } catch { return null; }
+ });
+ await page.screenshot({ path: new URL(`${item.machineId}.png`, OUT).pathname, fullPage: false });
+ rows.push({ id: item.machineId, coordinates: item.coordinates, glbLoaded: Boolean(glb), slotKeys: slots, expectedKeys: item.jointOrder.map((slot) => slot.key) });
+ console.log(JSON.stringify({ id: item.machineId, glbLoaded: Boolean(glb), slots }));
+}
+await browser.close();
+const report = { schema: 'five-axis-live-models.v1', url: BASE, cases: rows, browser_errors: errors };
+await readFile;
+(await import('node:fs/promises')).writeFile(new URL('browser.report.json', OUT), `${JSON.stringify(report, null, 2)}\n`);
+console.log(JSON.stringify({ cases: rows.length, loaded: rows.filter((row) => row.glbLoaded).length, errors: errors.length }));
diff --git a/scripts/test_glb_five_axis_analyzer.mjs b/scripts/test_glb_five_axis_analyzer.mjs
new file mode 100644
index 0000000..22038d9
--- /dev/null
+++ b/scripts/test_glb_five_axis_analyzer.mjs
@@ -0,0 +1,44 @@
+import assert from "node:assert/strict";
+import { pathToFileURL } from "node:url";
+
+const playwright = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
+const { chromium } = playwright.default ?? playwright;
+const browser = await chromium.launch({ executablePath: "/usr/bin/google-chrome", headless: true, args: ["--no-sandbox", "--use-angle=swiftshader", "--enable-unsafe-swiftshader"] });
+const page = await browser.newPage();
+try {
+ await page.goto(process.env.MACHINE_MANAGER_URL ?? "http://127.0.0.1:5173/");
+ const report = await page.evaluate(async () => {
+ const { GLTFLoader } = await import("/node_modules/three/examples/jsm/loaders/GLTFLoader.js");
+ const { analyzeGlbFiveAxisScene } = await import("/src/glb-five-axis-analyzer.ts");
+ const response = await fetch("/@fs/home/mes006/linuxcncWebWASM/glb/m1.glb");
+ if (!response.ok) throw new Error(`m1.glb HTTP ${response.status}`);
+ const gltf = await new GLTFLoader().parseAsync(await response.arrayBuffer(), "");
+ return analyzeGlbFiveAxisScene(gltf.scene, "m1.glb");
+ });
+ assert.deepEqual(report.axes.map(({ axis }) => axis), ["X", "Y", "Z", "A", "C"]);
+ assert.equal(report.suggestedCoordinates, "XYZAC");
+ assert.equal(report.suggestedKinematics, "xyzac-trt-kins");
+ assert.equal(report.rtcp.mode, "native-trt");
+ assert.ok(report.boundsMm.size.every((value) => value > 100));
+ await page.locator('[data-axis-control="machine-glb-settings"]').click();
+ await page.locator('[data-axis-control="machine-glb-file"]').setInputFiles("/home/mes006/linuxcncWebWASM/glb/m1.glb");
+ await page.waitForFunction(() => document.querySelector(".xyzac-machine-view")?.getAttribute("data-xyzac-model-source") === "glb-axis-analysis/linuxcnc-wasm", undefined, { timeout: 30000 });
+ const runtime = await page.locator(".xyzac-machine-view").evaluate((element) => ({ source: element.getAttribute("data-xyzac-model-source"), bindings: JSON.parse(element.getAttribute("data-xyzac-axis-bindings") || "[]") }));
+ assert.equal(runtime.source, "glb-axis-analysis/linuxcnc-wasm");
+ assert.deepEqual(runtime.bindings.map(({ axis }) => axis), ["X", "Y", "Z", "A", "C"]);
+ const driven = await page.evaluate(async () => {
+ const { loadImportedMachine, buildImportedMachine } = await import("/src/imported-machine.ts");
+ const bytes = await (await fetch("/@fs/home/mes006/linuxcncWebWASM/glb/m1.glb")).arrayBuffer();
+ const asset = await loadImportedMachine(bytes, "m1.glb");
+ const assembly = buildImportedMachine(asset);
+ assembly.update({ joints: [12, 23, 34, 10, 40], offsets: { x_rot_point: 0, y_rot_point: 0, z_rot_point: 0, x_offset: 0, y_offset: 0, z_offset: 0, tool_offset: 0 } });
+ const nodes = Object.fromEntries(["JT_X", "JT_Y", "JT_Z", "JT_A", "JT_C"].map((name) => { const node = assembly.machine.getObjectByName(name); return [name, { position: node?.position.toArray(), quaternion: node?.quaternion.toArray() }]; }));
+ assembly.dispose(); return nodes;
+ });
+ assert.ok(Math.abs(driven.JT_X.position[0] - 0.12007432234287262) < 1e-9);
+ assert.ok(Math.abs(driven.JT_A.quaternion[0]) > 0.01);
+ assert.ok(Math.abs(driven.JT_C.quaternion[2]) > 0.01);
+ console.log(JSON.stringify({ report_id: "glb-five-axis-analyzer-v1", status: "verified", axes: report.axes.map(({ axis, nodeName }) => ({ axis, nodeName })), suggested: report.suggestedCoordinates, kinematics: report.suggestedKinematics, bounds_mm: report.boundsMm, runtime, driven }));
+} finally {
+ await browser.close();
+}
diff --git a/scripts/test_machine_manager_browser.mjs b/scripts/test_machine_manager_browser.mjs
index 9fe78be..9570cc7 100644
--- a/scripts/test_machine_manager_browser.mjs
+++ b/scripts/test_machine_manager_browser.mjs
@@ -30,7 +30,7 @@ try {
await control("machine-manager-settings").click(); assert.equal(await page.locator(".machine-manager-dialog[open]").count(), 1); assert.equal(await page.locator("[data-machine-manager-preview] canvas").count(), 1);
await control("machine-manager-files").setInputFiles(fixtures);
await page.waitForFunction(() => document.querySelector("[data-machine-manager-preview]")?.dataset.machineManagerPartCount === "8");
- assert.equal(await page.locator('[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");
+ 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"), "neutral-studio-pmrem"); assert.equal(await page.locator("[data-machine-manager-preview]").getAttribute("data-machine-manager-exposure"), "1"); 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");
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); }
diff --git a/scripts/test_machine_material_rendering.mjs b/scripts/test_machine_material_rendering.mjs
new file mode 100644
index 0000000..bf1b5fe
--- /dev/null
+++ b/scripts/test_machine_material_rendering.mjs
@@ -0,0 +1,79 @@
+import assert from 'node:assert/strict';
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import { fileURLToPath, 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 pw = process.env.PLAYWRIGHT_MODULE ? await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href) : await import('playwright');
+const browser = await (pw.default ?? pw).chromium.launch({ executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] });
+const baseline = process.env.RENDER_BASELINE === '1';
+const output = new URL(`../coverage/glb-machine-manager/material-rendering/${baseline ? 'before' : 'after'}/`, import.meta.url);
+await mkdir(output, { recursive: true });
+const page = await browser.newPage({ viewport: { width: 1600, height: 1100 } });
+page.setDefaultTimeout(30000);
+const report = { status: 'running', baseline, checks: [], errors: [] };
+page.on('pageerror', (e) => report.errors.push(e.message));
+const c = (id) => page.locator(`[data-axis-control="${id}"]`);
+const preview = page.locator('[data-machine-manager-preview]');
+async function importAndCapture(file, stem) {
+ await c('machine-manager-files').setInputFiles(file);
+ await page.waitForFunction(() => document.querySelector('.machine-manager-workbench')?.getAttribute('aria-busy') === 'false' && document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerPartCount === '1');
+ await c('machine-view-fit').click();
+ await page.waitForFunction(() => document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerView === 'fit' && document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerRenderState === 'rendered');
+ await preview.screenshot({ path: fileURLToPath(new URL(`${stem}.png`, output)) });
+}
+try {
+ await page.goto(process.env.MACHINE_MANAGER_URL ?? 'http://127.0.0.1:5173/');
+ await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false');
+ await c('machine-manager-settings').click();
+ await importAndCapture(fileURLToPath(new URL('../glb/m1.glb', import.meta.url)), 'm1');
+ report.manager = await preview.evaluate((el) => ({ lighting: el.dataset.machineManagerLighting, toneMapping: el.dataset.machineManagerToneMapping, exposure: el.dataset.machineManagerExposure, meshes: Number(el.dataset.machineManagerSceneMeshes) }));
+ assert.equal(await page.locator('.machine-axis-overview .bound').count(), 5);
+ if (!baseline) {
+ assert.equal(report.manager.toneMapping, 'AgX');
+ assert.ok(Number(report.manager.exposure) <= 1);
+ // Exercise export from the actual manager: render settings must not recolor saved materials.
+ const downloadPromise = page.waitForEvent('download'); await c('machine-manager-export').click();
+ const saved = await readFile(await (await downloadPromise).path());
+ const original = await readFile(new URL('../glb/m1.glb', import.meta.url));
+ const json = (buffer) => JSON.parse(buffer.toString('utf8', 20, 20 + buffer.readUInt32LE(12)));
+ const materials = json(saved).materials, sourceMaterials = json(original).materials;
+ for (const source of sourceMaterials) {
+ const target = materials.find((m) => m.name === source.name); assert.ok(target, source.name);
+ const a = source.pbrMetallicRoughness ?? {}, b = target.pbrMetallicRoughness ?? {};
+ for (const key of ['metallicFactor', 'roughnessFactor']) assert.ok(Math.abs((a[key] ?? 1) - (b[key] ?? 1)) < 1e-6, `${source.name}/${key}`);
+ const color = a.baseColorFactor ?? [1, 1, 1, 1], savedColor = b.baseColorFactor ?? [1, 1, 1, 1];
+ color.forEach((value, i) => assert.ok(Math.abs(value - savedColor[i]) < 1e-6, `${source.name}/color${i}`));
+ if (source.normalTexture) assert.ok(target.normalTexture, `${source.name}/normal map`);
+ }
+ report.checks.push('manager export retains source PBR colors, roughness, metalness and normal maps');
+ }
+ await c('machine-document-new').click();
+ const fixture = new THREE.Scene();
+ const swatches = [0xff0000, 0x00ff00, 0x0000ff, 0xbdbdbd, 0xffffff, 0x222222];
+ for (let i = 0; i < swatches.length; i++) {
+ const mesh = new THREE.Mesh(new THREE.SphereGeometry(.13, 48, 24), new THREE.MeshStandardMaterial({ color: swatches[i], roughness: i === 3 ? .22 : .38, metalness: i === 3 ? 1 : 0 }));
+ mesh.name = `material-probe-${i}`; mesh.position.set((i % 3 - 1) * .35, i < 3 ? 0 : .35, .2); fixture.add(mesh);
+ }
+ const buffer = Buffer.from(await new GLTFExporter().parseAsync(fixture, { binary: true }));
+ await importAndCapture({ name: 'rgb-material-probes.glb', mimeType: 'model/gltf-binary', buffer }, 'rgb-materials');
+ fixture.traverse((object) => { object.geometry?.dispose(); object.material?.dispose(); });
+ report.checks.push('real m1 and saturated RGB / white / metal / dark material probes render without browser exceptions');
+ if (!baseline) {
+ await page.keyboard.press('Escape'); await c('machine-glb-settings').click();
+ await c('machine-glb-file').setInputFiles(fileURLToPath(new URL('../glb/m1.glb', import.meta.url)));
+ await page.waitForFunction(() => document.querySelector('.xyzac-machine-view')?.dataset.machineGlb && document.querySelector('main')?.dataset.axisProgramLoading === 'false');
+ await page.keyboard.press('Escape');
+ const viewer = page.locator('.xyzac-machine-view');
+ assert.equal(await viewer.locator('canvas').getAttribute('data-machine-tone-mapping'), 'AgX');
+ await viewer.screenshot({ path: fileURLToPath(new URL('controller-m1.png', output)) });
+ report.checks.push('control panel uses the same AgX neutral studio for imported GLBs');
+ }
+ assert.deepEqual(report.errors, []); report.status = 'verified';
+} catch (error) { report.status = 'failed'; report.error = String(error.stack ?? error); throw error; }
+finally { await writeFile(new URL('browser.report.json', output), JSON.stringify(report, null, 2)); await browser.close(); }
+console.log(JSON.stringify(report));
diff --git a/scripts/test_machine_model_selection.mjs b/scripts/test_machine_model_selection.mjs
new file mode 100644
index 0000000..4bd8c64
--- /dev/null
+++ b/scripts/test_machine_model_selection.mjs
@@ -0,0 +1,131 @@
+import assert from 'node:assert/strict';
+import { mkdir, readFile, writeFile } from 'node:fs/promises';
+import { fileURLToPath, pathToFileURL } from 'node:url';
+import * as THREE from '../web/react/node_modules/three/build/three.module.js';
+
+const playwright = process.env.PLAYWRIGHT_MODULE
+ ? await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href) : await import('playwright');
+const browser = await (playwright.default ?? playwright).chromium.launch({
+ executablePath: process.env.CHROME_PATH ?? '/usr/bin/google-chrome', headless: true,
+ args: ['--no-sandbox', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'],
+});
+const output = new URL('../coverage/glb-machine-manager/model-selection/', import.meta.url);
+await mkdir(output, { recursive: true });
+const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
+page.setDefaultTimeout(15000);
+const errors = []; page.on('pageerror', (error) => errors.push(error.message));
+const report = { status: 'running', checks: [], errors };
+const c = (id) => page.locator(`[data-axis-control="${id}"]`);
+const preview = page.locator('[data-machine-manager-preview]');
+const rows = () => page.locator('[data-machine-model-id]');
+const row = (id) => page.locator(`[data-machine-model-id="${id}"]`);
+const modelCount = (n) => page.waitForFunction((n) => document.querySelectorAll('[data-machine-model-id]').length === n && document.querySelector('.machine-manager-workbench')?.getAttribute('aria-busy') === 'false', n);
+const selected = (id) => page.waitForFunction((id) => document.querySelector(`[data-machine-model-id="${id}"] [aria-pressed="true"]`) && document.querySelector('[data-machine-manager-preview]')?.dataset.machineManagerSelectedModel === id, id);
+// Small independent GLBs give identical filenames but distinct import identities.
+function fixture(x) {
+ const geometry = new THREE.BoxGeometry(.22, .22, .22).toNonIndexed();
+ const position = geometry.getAttribute('position');
+ const binary = Buffer.from(position.array.buffer);
+ const json = { asset: { version: '2.0' }, scene: 0, scenes: [{ nodes: [0] }],
+ nodes: [{ name: 'fixture', translation: [x, 0, .3], children: [1] }, { name: 'solid', mesh: 0 }],
+ meshes: [{ primitives: [{ attributes: { POSITION: 0 }, material: 0 }] }],
+ materials: [{ doubleSided: true, pbrMetallicRoughness: { baseColorFactor: [.15, .6, .5, 1] } }],
+ accessors: [{ bufferView: 0, componentType: 5126, count: position.count, type: 'VEC3', min: [-.11, -.11, -.11], max: [.11, .11, .11] }],
+ bufferViews: [{ buffer: 0, byteLength: binary.length }], buffers: [{ byteLength: binary.length }] };
+ const raw = Buffer.from(JSON.stringify(json));
+ const payload = Buffer.concat([raw, Buffer.alloc((4 - raw.length % 4) % 4, 32)]);
+ const header = Buffer.alloc(20); header.writeUInt32LE(0x46546c67, 0); header.writeUInt32LE(2, 4);
+ header.writeUInt32LE(28 + payload.length + binary.length, 8); header.writeUInt32LE(payload.length, 12); header.writeUInt32LE(0x4e4f534a, 16);
+ const binHeader = Buffer.alloc(8); binHeader.writeUInt32LE(binary.length, 0); binHeader.writeUInt32LE(0x004e4942, 4);
+ geometry.dispose();
+ return { name: 'same-name.glb', mimeType: 'model/gltf-binary', buffer: Buffer.concat([header, payload, binHeader, binary]) };
+}
+try {
+ await page.goto(process.env.MACHINE_MANAGER_URL ?? 'http://127.0.0.1:5173/');
+ await page.waitForFunction(() => document.querySelector('main')?.dataset.axisProgramLoading === 'false');
+ await c('machine-manager-settings').click();
+ assert.equal(await c('machine-model-delete-selected').isDisabled(), true);
+ await c('machine-manager-files').setInputFiles([fixture(-.35), fixture(.35)]);
+ await modelCount(2);
+ const [first, second] = await rows().evaluateAll((items) => items.map((el) => el.dataset.machineModelId));
+ assert.notEqual(first, second); await selected(first);
+ await row(second).locator('[data-axis-control="machine-model-select"]').click(); await selected(second);
+ await c('machine-tree-search').fill('no-matching-node');
+ await row(first).locator('[data-axis-control="machine-model-select"]').click(); await selected(first);
+ assert.equal(await c('machine-tree-search').inputValue(), '');
+ assert.equal(await preview.getAttribute('data-machine-manager-selection-visible'), 'true');
+ report.checks.push('same filenames retain unique selection; model selection clears search and highlights preview');
+
+ const cameraState = JSON.parse(await preview.getAttribute('data-machine-manager-camera'));
+ const canvas = preview.locator('canvas'), box = await canvas.boundingBox();
+ const camera = new THREE.PerspectiveCamera(42, box.width / box.height, .005, 100);
+ camera.position.fromArray(cameraState.position); camera.up.fromArray(cameraState.up);
+ camera.lookAt(new THREE.Vector3(...cameraState.target)); camera.updateMatrixWorld(true);
+ const ndc = new THREE.Vector3(.35, 0, .3).project(camera);
+ const point = { x: box.x + (ndc.x + 1) * box.width / 2, y: box.y + (1 - ndc.y) * box.height / 2 };
+ await page.mouse.click(point.x, point.y); await selected(second);
+ await row(first).locator('[data-axis-control="machine-model-select"]').click(); await selected(first);
+ await page.mouse.move(point.x, point.y); await page.mouse.down();
+ await page.mouse.move(point.x + 40, point.y + 20, { steps: 4 });
+ await page.mouse.move(point.x, point.y, { steps: 4 }); await page.mouse.up();
+ await selected(first);
+ report.checks.push('viewport click picks correct import; orbit drag returning to start does not select');
+
+ await row(second).locator('[data-axis-control="machine-model-delete"]').click();
+ await modelCount(1); await selected(first);
+ await c('machine-manager-undo').click(); await modelCount(2); await selected(second);
+ await c('machine-model-delete-selected').click(); await modelCount(1); await selected(first);
+ await c('machine-manager-undo').click(); await modelCount(2); await selected(second);
+ await c('machine-manager-redo').click(); await modelCount(1); await selected(first);
+ report.checks.push('delete unselected/selected import by ID; undo restores and selects it; redo repairs selection');
+
+ await c('machine-manager-undo').click(); await modelCount(2);
+ // The second imported part was parented to the first by the assembly editor.
+ await row(first).locator('[data-axis-control="machine-model-select"]').click();
+ await c('machine-model-delete-selected').click(); await modelCount(1); await selected(second);
+ assert.equal(await c('machine-manager-parent').inputValue(), '');
+ await c('machine-manager-undo').click(); await modelCount(2); await selected(first);
+ await row(second).locator('[data-axis-control="machine-model-select"]').click();
+ assert.equal(await c('machine-manager-parent').inputValue(), first);
+ report.checks.push('deleting parent import preserves separately imported child; undo restores parent link');
+
+ await c('machine-model-delete-selected').click(); await modelCount(1);
+ await c('machine-model-delete-selected').click(); await modelCount(0);
+ assert.equal(await c('machine-model-delete-selected').isDisabled(), true);
+ assert.equal(await page.locator('.machine-preview-empty').isVisible(), true);
+ await c('machine-manager-undo').click(); await modelCount(1); await selected(first);
+ await c('machine-manager-redo').click(); await modelCount(0);
+ report.checks.push('last deletion clears preview; undo restores selection from empty state');
+
+ const m1 = fileURLToPath(new URL('../glb/m1.glb', import.meta.url));
+ const bytes = await readFile(m1);
+ await c('machine-manager-files').setInputFiles({ name: 'm1.glb', mimeType: 'model/gltf-binary', buffer: bytes });
+ await modelCount(1); const m1id = await rows().first().getAttribute('data-machine-model-id');
+ await selected(m1id); assert.equal(await page.locator('.machine-axis-overview .bound').count(), 5);
+ await c('machine-model-delete-selected').click(); await modelCount(0);
+ await c('machine-manager-undo').click(); await modelCount(1); await selected(m1id);
+ assert.equal(await page.locator('.machine-axis-overview .bound').count(), 5);
+ await c('machine-manager-tab-motion').click(); await c('machine-joint-follow-live').check();
+ assert.equal(await c('machine-model-delete-selected').isDisabled(), true);
+ await c('machine-manager-tab-structure').click(); assert.equal(await c('machine-model-delete').isDisabled(), true);
+ await c('machine-manager-tab-motion').click(); await c('machine-joint-follow-live').uncheck();
+ await c('machine-manager-tab-structure').click();
+ report.checks.push('real m1 deletion/undo preserves all five bindings; live follow locks deletion');
+ await page.screenshot({ path: fileURLToPath(new URL('desktop.png', output)) });
+ for (const [width, height] of [[1154, 520], [720, 900]]) {
+ await page.setViewportSize({ width, height });
+ await c('machine-model-select').click(); await selected(m1id);
+ await c('machine-model-delete').click(); await modelCount(0);
+ await c('machine-manager-undo').click(); await modelCount(1); await selected(m1id);
+ }
+ assert.deepEqual(errors, []); report.checks.push('selection and deletion reachable on short and narrow viewports');
+ report.status = 'verified';
+} catch (error) {
+ report.status = 'failed'; report.error = String(error.stack ?? error);
+ await page.screenshot({ path: fileURLToPath(new URL('failure.png', output)) }).catch(() => {});
+ throw error;
+} finally {
+ await writeFile(new URL('browser.report.json', output), JSON.stringify(report, null, 2) + '\n');
+ await browser.close();
+}
+console.log(JSON.stringify(report));
diff --git a/scripts/test_physical_case_glb_browser.mjs b/scripts/test_physical_case_glb_browser.mjs
new file mode 100644
index 0000000..5f0e433
--- /dev/null
+++ b/scripts/test_physical_case_glb_browser.mjs
@@ -0,0 +1,52 @@
+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 playwright = imported.default ?? imported;
+const browser = await playwright.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 errors = [];
+page.on('pageerror', (error) => errors.push(`pageerror:${error.message}`));
+page.on('console', (message) => { if (message.type() === 'error') errors.push(`console:${message.text()}`); });
+const control = (id) => page.locator(`[data-axis-control="${id}"]`);
+const cases = [
+ ['xyzac-trt', 7], ['xyzbc-trt', 7], ['xyzab-tdr', 7], ['bridge-mill-5axiskins', 10], ['max5-maxkins', 7],
+ ['hexapod-genhexkins', 10], ['pentapod-pentakins', 9], ['puma-pumakins', 8], ['puma-genserkins', 8],
+ ['trivkins-xyzab', 7], ['trivkins-xyzcw', 7], ['trivkins-xyzya', 7], ['scorbot-kins', 7],
+];
+const report = { schema: 'physical-case-glb-browser.v1', status: 'running', cases: [], browser_errors: errors };
+const out = new URL('../coverage/glb-machine-manager/physical-cases/', import.meta.url);
+await mkdir(out, { recursive: true });
+try {
+ await page.goto(process.env.PHYSICAL_CASE_URL ?? 'http://127.0.0.1:5174/', { waitUntil: 'domcontentloaded', timeout: 30000 });
+ await page.waitForFunction(() => document.querySelector('main.axis-app')?.dataset.axisStateSource === 'linuxcnc-wasm', null, { timeout: 60000 });
+ await control('machine-manager-settings').click();
+ for (const [id, minimumNodes] of cases) {
+ await control('machine-document-new').click();
+ const chooser = page.waitForEvent('filechooser');
+ await control('machine-manager-import').click();
+ const fileChooser = await chooser;
+ await fileChooser.setFiles({ name: `${id}.glb`, mimeType: 'model/gltf-binary', buffer: await readFile(new URL(`../web/react/public/machine-cases/physical/${id}.glb`, import.meta.url)) });
+ await page.waitForFunction(() => document.querySelector('.machine-manager-workbench')?.getAttribute('aria-busy') === 'false', null, { timeout: 30000 });
+ const nodes = page.locator('[data-axis-control="machine-tree-node"]');
+ await page.waitForFunction((minimum) => document.querySelectorAll('[data-axis-control="machine-tree-node"]').length >= minimum, minimumNodes, { timeout: 30000 });
+ assert.ok(await nodes.count() >= minimumNodes, id);
+ const preview = page.locator('[data-machine-manager-preview]');
+ assert.equal(await preview.getAttribute('data-machine-manager-render-state'), 'rendered', id);
+ await page.screenshot({ path: new URL(`${id}.png`, out).pathname, fullPage: true });
+ report.cases.push({ id, tree_nodes: await nodes.count(), scene_objects: await preview.getAttribute('data-machine-manager-scene-objects'), rendered: await preview.getAttribute('data-machine-manager-rendered') });
+ }
+ assert.deepEqual(errors, []);
+ report.status = 'verified';
+} catch (error) {
+ report.status = 'failed';
+ report.error = error instanceof Error ? error.stack : String(error);
+ await page.screenshot({ path: new URL('failure.png', out).pathname, fullPage: true }).catch(() => {});
+ throw error;
+} finally {
+ report.browser_errors = errors;
+ await writeFile(new URL('browser.report.json', out), `${JSON.stringify(report, null, 2)}\n`);
+ await browser.close();
+}
+console.log(JSON.stringify(report));
diff --git a/wasm-core/CMakeLists.txt b/wasm-core/CMakeLists.txt
index 369de26..269df44 100644
--- a/wasm-core/CMakeLists.txt
+++ b/wasm-core/CMakeLists.txt
@@ -300,7 +300,7 @@ if(EMSCRIPTEN)
set_target_properties(sim_core_module PROPERTIES
OUTPUT_NAME "linuxcnc-sim-core"
SUFFIX ".js"
- LINK_FLAGS "-sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,worker -sEXPORTED_FUNCTIONS=['_malloc','_free','_sim_checkpoint_build_id','_sim_core_checkpoint_size','_sim_core_checkpoint_export','_sim_core_checkpoint_restore','_sim_protocol_checkpoint_size','_sim_protocol_checkpoint_export','_sim_protocol_checkpoint_restore','_sim_event_checkpoint_size','_sim_event_checkpoint_export','_sim_event_checkpoint_restore','_sim_create','_sim_load_workspace','_sim_command','_sim_step','_sim_read_snapshot','_sim_read_event','_sim_export_state','_sim_import_state','_sim_destroy','_sim_protocol_reset','_sim_protocol_accept','_sim_protocol_pause','_sim_protocol_resume','_sim_protocol_state_value','_sim_protocol_last_seq','_sim_protocol_tick','_sim_protocol_set_tick','_sim_event_reset','_sim_event_append','_sim_event_count','_sim_event_cursor','_sim_event_read','_sim_event_export','_sim_event_import','_sim_task_size','_sim_task_init','_sim_task_reset','_sim_task_attach','_sim_task_set_command','_sim_task_set_tick','_sim_task_set_mode','_sim_task_set_program','_sim_task_transition','_sim_task_fault','_sim_task_estop','_sim_task_recover','_sim_task_restore','_sim_task_read_state','_sim_task_canonical','_sim_task_state_hash','_sim_scheduler_size','_sim_scheduler_init','_sim_scheduler_reset','_sim_scheduler_step','_sim_scheduler_restore','_sim_scheduler_read','_sim_scheduler_tick','_sim_scheduler_state_hash','_sim_scheduler_canonical','_sim_hal_reset','_sim_hal_register_pin','_sim_hal_set_pin','_sim_hal_get_pin','_sim_hal_set_param','_sim_hal_get_param','_sim_hal_connect','_sim_hal_register_thread','_sim_hal_register_function','_sim_hal_register_component','_sim_hal_set_function_failure','_sim_hal_step','_sim_hal_checkpoint_size','_sim_hal_checkpoint_export','_sim_hal_checkpoint_restore','_sim_hal_tick','_sim_hal_function_calls','_sim_hal_pin_count','_sim_hal_thread_count','_sim_hal_function_count','_sim_hal_component_count','_sim_hal_param_count','_sim_device_init','_sim_device_reset','_sim_device_encoder_set_speed','_sim_device_encoder_set_config','_sim_device_encoder_set_index_enable','_sim_device_coolant_command','_sim_device_coolant_set_fault','_sim_device_door_set','_sim_device_interlock_set','_sim_device_interlock_reset','_sim_device_estop_set','_sim_device_step','_sim_device_ack_issue','_sim_device_ack_ack','_sim_device_ack_abort','_sim_device_ack_step','_sim_device_state_hash','_sim_device_canonical','_sim_posemath_normalize','_sim_posemath_dot','_sim_posemath_line_point','_sim_posemath_is_finite','_sim_posemath_pose_is_finite','_sim_posemath_pose_line_point','_sim_posemath_segment_validate','_sim_posemath_segment_point','_sim_posemath_hash_state','_sim_tp_init','_sim_tp_reset','_sim_tp_enqueue','_sim_tp_step','_sim_tp_read_state','_sim_tp_state_hash','_sim_interpreter_size','_sim_interpreter_init','_sim_interpreter_reset','_sim_interpreter_execute','_sim_interpreter_read_state','_sim_interpreter_read_motion','_sim_interpreter_motion_size','_sim_interpreter_canonical','_sim_interpreter_state_hash','_sim_interpreter_block_hash','_sim_interpreter_modal_hash','_sim_interpreter_error_column','_sim_interpreter_error_code','_sim_interpreter_parameter_set','_sim_interpreter_parameter_get','_sim_motion_init','_sim_motion_size','_sim_motion_command_size','_sim_motion_state_size','_sim_motion_reset','_sim_motion_enqueue','_sim_motion_step','_sim_motion_read_state','_sim_motion_queue_depth','_sim_motion_state_hash','_sim_motion_canonical','_sim_kinematics_init','_sim_kinematics_forward','_sim_kinematics_forward_ex','_sim_kinematics_inverse','_sim_kinematics_inverse_ex','_sim_kinematics_home','_sim_kinematics_validate_pose','_sim_kinematics_state_hash','_sim_kinematics_switch_init','_sim_kinematics_switch_register','_sim_kinematics_switch_begin','_sim_kinematics_switch_abort','_sim_kinematics_switch_commit','_sim_kinematics_switch_select','_sim_kinematics_switch_forward','_sim_kinematics_switch_inverse','_sim_kinematics_switch_active','_sim_kinematics_switch_state_hash','_sim_kinematics_user_init','_sim_kinematics_user_bind_switch','_sim_kinematics_user_forward','_sim_kinematics_user_inverse','_sim_kinematics_user_reject_dynamic','_sim_kinematics_user_state_hash','_sim_kinematics_registry_count','_sim_kinematics_registry_get','_sim_kinematics_registry_find','_sim_kinematics_registry_require','_sim_kinematics_registry_validate_config','_sim_kinematics_registry_state_hash','_sim_kinematics_motion_init','_sim_kinematics_motion_reset','_sim_kinematics_motion_enqueue','_sim_kinematics_motion_pause','_sim_kinematics_motion_step','_sim_kinematics_motion_peek','_sim_kinematics_motion_pop','_sim_kinematics_motion_depth','_sim_kinematics_motion_state_hash','_sim_kinematics_feedback_init','_sim_kinematics_feedback_reset','_sim_kinematics_feedback_update','_sim_kinematics_feedback_read','_sim_kinematics_feedback_state_hash','_sim_kinematics_home_limit_init','_sim_kinematics_home_limit_reset','_sim_kinematics_home_limit_home','_sim_kinematics_home_limit_check','_sim_kinematics_home_limit_read','_sim_kinematics_home_limit_state_hash','_sim_io_init','_sim_io_reset','_sim_io_register_pins','_sim_io_spindle_command','_sim_io_spindle_set_inhibit','_sim_io_spindle_set_fault','_sim_io_spindle_set_index_enable','_sim_io_spindle_set_feedback','_sim_io_spindle_orient','_sim_io_spindle_set_oriented','_sim_io_tool_prepare','_sim_io_tool_set_prepared','_sim_io_tool_change','_sim_io_tool_set_changed','_sim_io_tool_abort','_sim_io_tool_set_fault','_sim_io_tool_clear_fault','_sim_io_probe_arm','_sim_io_probe_update','_sim_io_probe_disarm','_sim_io_set_estop','_sim_io_recover','_sim_io_step','_sim_io_read_state','_sim_io_state_hash','_sim_io_canonical'] -sEXPORTED_RUNTIME_METHODS=['ccall','cwrap']"
+ LINK_FLAGS "-sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=web,worker -sEXPORTED_FUNCTIONS=['_malloc','_free','_sim_checkpoint_build_id','_sim_core_checkpoint_size','_sim_core_checkpoint_export','_sim_core_checkpoint_restore','_sim_protocol_checkpoint_size','_sim_protocol_checkpoint_export','_sim_protocol_checkpoint_restore','_sim_event_checkpoint_size','_sim_event_checkpoint_export','_sim_event_checkpoint_restore','_sim_create','_sim_load_workspace','_sim_command','_sim_step','_sim_read_snapshot','_sim_read_event','_sim_export_state','_sim_import_state','_sim_destroy','_sim_protocol_reset','_sim_protocol_accept','_sim_protocol_pause','_sim_protocol_resume','_sim_protocol_state_value','_sim_protocol_last_seq','_sim_protocol_tick','_sim_protocol_set_tick','_sim_event_reset','_sim_event_append','_sim_event_count','_sim_event_cursor','_sim_event_read','_sim_event_export','_sim_event_import','_sim_task_size','_sim_task_init','_sim_task_reset','_sim_task_attach','_sim_task_set_command','_sim_task_set_tick','_sim_task_set_mode','_sim_task_set_program','_sim_task_transition','_sim_task_fault','_sim_task_estop','_sim_task_recover','_sim_task_restore','_sim_task_read_state','_sim_task_canonical','_sim_task_state_hash','_sim_scheduler_size','_sim_scheduler_init','_sim_scheduler_reset','_sim_scheduler_step','_sim_scheduler_restore','_sim_scheduler_read','_sim_scheduler_tick','_sim_scheduler_state_hash','_sim_scheduler_canonical','_sim_hal_reset','_sim_hal_register_pin','_sim_hal_set_pin','_sim_hal_get_pin','_sim_hal_set_param','_sim_hal_get_param','_sim_hal_connect','_sim_hal_register_thread','_sim_hal_register_function','_sim_hal_register_component','_sim_hal_set_function_failure','_sim_hal_step','_sim_hal_checkpoint_size','_sim_hal_checkpoint_export','_sim_hal_checkpoint_restore','_sim_hal_tick','_sim_hal_function_calls','_sim_hal_pin_count','_sim_hal_thread_count','_sim_hal_function_count','_sim_hal_component_count','_sim_hal_param_count','_sim_device_init','_sim_device_reset','_sim_device_encoder_set_speed','_sim_device_encoder_set_config','_sim_device_encoder_set_index_enable','_sim_device_coolant_command','_sim_device_coolant_set_fault','_sim_device_door_set','_sim_device_interlock_set','_sim_device_interlock_reset','_sim_device_estop_set','_sim_device_step','_sim_device_ack_issue','_sim_device_ack_ack','_sim_device_ack_abort','_sim_device_ack_step','_sim_device_state_hash','_sim_device_canonical','_sim_posemath_normalize','_sim_posemath_dot','_sim_posemath_line_point','_sim_posemath_is_finite','_sim_posemath_pose_is_finite','_sim_posemath_pose_line_point','_sim_posemath_segment_validate','_sim_posemath_segment_point','_sim_posemath_hash_state','_sim_tp_init','_sim_tp_reset','_sim_tp_enqueue','_sim_tp_step','_sim_tp_read_state','_sim_tp_state_hash','_sim_interpreter_size','_sim_interpreter_init','_sim_interpreter_reset','_sim_interpreter_execute','_sim_interpreter_sync_position','_sim_interpreter_read_state','_sim_interpreter_read_motion','_sim_interpreter_motion_size','_sim_interpreter_canonical','_sim_interpreter_state_hash','_sim_interpreter_block_hash','_sim_interpreter_modal_hash','_sim_interpreter_error_column','_sim_interpreter_error_code','_sim_interpreter_parameter_set','_sim_interpreter_parameter_get','_sim_motion_init','_sim_motion_size','_sim_motion_command_size','_sim_motion_state_size','_sim_motion_reset','_sim_motion_enqueue','_sim_motion_step','_sim_motion_read_state','_sim_motion_queue_depth','_sim_motion_state_hash','_sim_motion_canonical','_sim_kinematics_init','_sim_kinematics_forward','_sim_kinematics_forward_ex','_sim_kinematics_inverse','_sim_kinematics_inverse_ex','_sim_kinematics_home','_sim_kinematics_validate_pose','_sim_kinematics_state_hash','_sim_kinematics_switch_init','_sim_kinematics_switch_register','_sim_kinematics_switch_begin','_sim_kinematics_switch_abort','_sim_kinematics_switch_commit','_sim_kinematics_switch_select','_sim_kinematics_switch_forward','_sim_kinematics_switch_inverse','_sim_kinematics_switch_active','_sim_kinematics_switch_state_hash','_sim_kinematics_user_init','_sim_kinematics_user_bind_switch','_sim_kinematics_user_forward','_sim_kinematics_user_inverse','_sim_kinematics_user_reject_dynamic','_sim_kinematics_user_state_hash','_sim_kinematics_registry_count','_sim_kinematics_registry_get','_sim_kinematics_registry_find','_sim_kinematics_registry_require','_sim_kinematics_registry_validate_config','_sim_kinematics_registry_state_hash','_sim_kinematics_motion_init','_sim_kinematics_motion_reset','_sim_kinematics_motion_enqueue','_sim_kinematics_motion_pause','_sim_kinematics_motion_step','_sim_kinematics_motion_peek','_sim_kinematics_motion_pop','_sim_kinematics_motion_depth','_sim_kinematics_motion_state_hash','_sim_kinematics_feedback_init','_sim_kinematics_feedback_reset','_sim_kinematics_feedback_update','_sim_kinematics_feedback_read','_sim_kinematics_feedback_state_hash','_sim_kinematics_home_limit_init','_sim_kinematics_home_limit_reset','_sim_kinematics_home_limit_home','_sim_kinematics_home_limit_check','_sim_kinematics_home_limit_read','_sim_kinematics_home_limit_state_hash','_sim_io_init','_sim_io_reset','_sim_io_register_pins','_sim_io_spindle_command','_sim_io_spindle_set_inhibit','_sim_io_spindle_set_fault','_sim_io_spindle_set_index_enable','_sim_io_spindle_set_feedback','_sim_io_spindle_orient','_sim_io_spindle_set_oriented','_sim_io_tool_prepare','_sim_io_tool_set_prepared','_sim_io_tool_change','_sim_io_tool_set_changed','_sim_io_tool_abort','_sim_io_tool_set_fault','_sim_io_tool_clear_fault','_sim_io_probe_arm','_sim_io_probe_update','_sim_io_probe_disarm','_sim_io_set_estop','_sim_io_recover','_sim_io_step','_sim_io_read_state','_sim_io_state_hash','_sim_io_canonical'] -sEXPORTED_RUNTIME_METHODS=['ccall','cwrap']"
)
get_target_property(_sim_module_link_flags sim_core_module LINK_FLAGS)
string(REPLACE "_sim_task_recover','_sim_task_restore" "_sim_task_recover','_sim_task_command_gate','_sim_task_restore" _sim_module_link_flags "${_sim_module_link_flags}")
@@ -317,7 +317,7 @@ if(EMSCRIPTEN)
string(REPLACE "_sim_device_init" "_sim_device_io_size','_sim_device_init" _sim_module_link_flags "${_sim_module_link_flags}")
string(REPLACE "_sim_motion_step','_sim_motion_read_state" "_sim_motion_step','_sim_motion_set_profile','_sim_motion_set_lookahead','_sim_motion_set_feed_override','_sim_motion_peek','_sim_motion_read_state" _sim_module_link_flags "${_sim_module_link_flags}")
string(REPLACE "_sim_motion_state_hash','_sim_motion_canonical" "_sim_motion_state_hash','_sim_motion_plan_hash','_sim_motion_canonical" _sim_module_link_flags "${_sim_module_link_flags}")
- string(REPLACE "_sim_motion_init" "_sim_motion_init','_sim_linuxcnc_tp_checkpoint_size','_sim_linuxcnc_tp_checkpoint_export','_sim_linuxcnc_tp_checkpoint_restore','_sim_linuxcnc_tp_size','_sim_linuxcnc_tp_queue_capacity','_sim_linuxcnc_tp_init','_sim_linuxcnc_tp_reset','_sim_linuxcnc_tp_set_profile','_sim_linuxcnc_tp_set_feed_override','_sim_linuxcnc_tp_set_velocity_scale','_sim_linuxcnc_tp_set_jog_scale','_sim_linuxcnc_tp_set_axis_limit','_sim_linuxcnc_tp_set_axis_dynamics','_sim_linuxcnc_tp_set_feed_limit','_sim_linuxcnc_tp_set_scale_minimum','_sim_linuxcnc_tp_set_blend','_sim_linuxcnc_tp_jog','_sim_linuxcnc_tp_velocity_scale','_sim_linuxcnc_tp_velocity_limit','_sim_linuxcnc_tp_jog_scale','_sim_linuxcnc_tp_jog_velocity','_sim_linuxcnc_tp_feed_override','_sim_linuxcnc_tp_set_dynamics','_sim_linuxcnc_tp_axis_limit','_sim_linuxcnc_tp_step_line','_sim_linuxcnc_tp_is_stepping','_sim_linuxcnc_tp_set_paused','_sim_linuxcnc_tp_abort','_sim_linuxcnc_tp_set_position','_sim_linuxcnc_tp_set_goal_position','_sim_linuxcnc_tp_add_line','_sim_linuxcnc_tp_add_circle','_sim_linuxcnc_tp_step','_sim_linuxcnc_tp_idle','_sim_linuxcnc_tp_read_state','_sim_linuxcnc_tp_queue_depth','_sim_linuxcnc_tp_queue_segment','_sim_linuxcnc_tp_restore_progress','_sim_linuxcnc_tp_state_hash','_sim_linuxcnc_tp_canonical" _sim_module_link_flags "${_sim_module_link_flags}")
+ string(REPLACE "_sim_motion_init" "_sim_motion_init','_sim_motion_set_position','_sim_linuxcnc_tp_checkpoint_size','_sim_linuxcnc_tp_checkpoint_export','_sim_linuxcnc_tp_checkpoint_restore','_sim_linuxcnc_tp_size','_sim_linuxcnc_tp_queue_capacity','_sim_linuxcnc_tp_init','_sim_linuxcnc_tp_reset','_sim_linuxcnc_tp_set_profile','_sim_linuxcnc_tp_set_feed_override','_sim_linuxcnc_tp_set_velocity_scale','_sim_linuxcnc_tp_set_jog_scale','_sim_linuxcnc_tp_set_axis_limit','_sim_linuxcnc_tp_set_axis_dynamics','_sim_linuxcnc_tp_set_feed_limit','_sim_linuxcnc_tp_set_scale_minimum','_sim_linuxcnc_tp_set_blend','_sim_linuxcnc_tp_jog','_sim_linuxcnc_tp_velocity_scale','_sim_linuxcnc_tp_velocity_limit','_sim_linuxcnc_tp_jog_scale','_sim_linuxcnc_tp_jog_velocity','_sim_linuxcnc_tp_feed_override','_sim_linuxcnc_tp_set_dynamics','_sim_linuxcnc_tp_axis_limit','_sim_linuxcnc_tp_step_line','_sim_linuxcnc_tp_is_stepping','_sim_linuxcnc_tp_set_paused','_sim_linuxcnc_tp_abort','_sim_linuxcnc_tp_set_position','_sim_linuxcnc_tp_set_goal_position','_sim_linuxcnc_tp_add_line','_sim_linuxcnc_tp_add_circle','_sim_linuxcnc_tp_step','_sim_linuxcnc_tp_idle','_sim_linuxcnc_tp_read_state','_sim_linuxcnc_tp_queue_depth','_sim_linuxcnc_tp_queue_segment','_sim_linuxcnc_tp_restore_progress','_sim_linuxcnc_tp_state_hash','_sim_linuxcnc_tp_canonical" _sim_module_link_flags "${_sim_module_link_flags}")
string(REPLACE "_sim_linuxcnc_tp_velocity_limit" "_sim_linuxcnc_tp_velocity_limit','_sim_linuxcnc_tp_velocity_mm_min" _sim_module_link_flags "${_sim_module_link_flags}")
set_target_properties(sim_core_module PROPERTIES LINK_FLAGS "${_sim_module_link_flags}")
endif()
diff --git a/wasm-core/include/sim_motion_queue.h b/wasm-core/include/sim_motion_queue.h
index b7faf16..d4d169d 100644
--- a/wasm-core/include/sim_motion_queue.h
+++ b/wasm-core/include/sim_motion_queue.h
@@ -64,6 +64,7 @@ uint32_t sim_motion_size(void);
uint32_t sim_motion_command_size(void);
uint32_t sim_motion_state_size(void);
int sim_motion_reset(sim_motion_queue *queue);
+int sim_motion_set_position(sim_motion_queue *queue, const sim_posemath_pose *position);
int sim_motion_enqueue(sim_motion_queue *queue, const sim_motion_command *command, uint32_t *id_out);
int sim_motion_step(sim_motion_queue *queue);
int sim_motion_set_profile(sim_motion_queue *queue, int profile);
diff --git a/wasm-core/src/sim_motion_queue.c b/wasm-core/src/sim_motion_queue.c
index ee109e7..45e3127 100644
--- a/wasm-core/src/sim_motion_queue.c
+++ b/wasm-core/src/sim_motion_queue.c
@@ -124,6 +124,20 @@ int sim_motion_init(sim_motion_queue *queue, double cycle_time, double velocity_
return sim_motion_reset(queue);
}
+int sim_motion_set_position(sim_motion_queue *queue, const sim_posemath_pose *position) {
+ if (queue == 0 || position == 0) return SIM_MOTION_ERR_ARGUMENT;
+ if (queue->count != 0) return SIM_MOTION_ERR_STATE;
+ if (!sim_posemath_pose_is_finite(position)) return SIM_MOTION_ERR_NONFINITE;
+ queue->state.position = *position;
+ queue->state.velocity = 0.0;
+ queue->state.progress = 0.0;
+ queue->state.active_id = 0;
+ queue->state.queue_depth = 0;
+ queue->state.motion_type = 0;
+ queue->state.status = SIM_MOTION_OK;
+ return SIM_MOTION_OK;
+}
+
uint32_t sim_motion_size(void) { return (uint32_t)sizeof(sim_motion_queue); }
uint32_t sim_motion_command_size(void) { return (uint32_t)sizeof(sim_motion_command); }
uint32_t sim_motion_state_size(void) { return (uint32_t)sizeof(sim_motion_state); }
diff --git a/web/react/public/machine-cases/bridge-mill-5axiskins.ngc b/web/react/public/machine-cases/bridge-mill-5axiskins.ngc
new file mode 100644
index 0000000..7e77595
--- /dev/null
+++ b/web/react/public/machine-cases/bridge-mill-5axiskins.ngc
@@ -0,0 +1,13 @@
+(CASE_ID: bridge-mill-5axiskins)
+(CONTROL_PROFILE: XYZBCWY)
+(KINEMATICS: 5axiskins)
+(STATUS: adapter-required)
+(NOTE: W 沿刀具方向;配置含重复 Y 关节。)
+(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
+G21 G90 G94 G17 G40 G49 G80
+G54
+G1 X5 Y5 Z-2 B3 C8 W1 F120
+G1 X15 Y-5 Z-4 B-3 C-8 W-1 F120
+G1 X5 Y5 Z-2 B3 C8 W1 F120
+G1 X0 Y0 Z0 F120
+M30
diff --git a/web/react/public/machine-cases/hexapod-genhexkins.ngc b/web/react/public/machine-cases/hexapod-genhexkins.ngc
new file mode 100644
index 0000000..29f85b3
--- /dev/null
+++ b/web/react/public/machine-cases/hexapod-genhexkins.ngc
@@ -0,0 +1,9 @@
+(CASE_ID: hexapod-genhexkins)
+(CONTROL_PROFILE: XYZABC)
+(KINEMATICS: genhexkins)
+(STATUS: adapter-required)
+(NOTE: 六支链并联平台;XYZABC 六自由度。)
+(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
+G21 G90 G94 G17 G40 G49 G80
+; HOME-POSE VALIDATION: runtime initializes and validates the native WASM inverse at its configured home pose.
+M30
diff --git a/web/react/public/machine-cases/max5-maxkins.ngc b/web/react/public/machine-cases/max5-maxkins.ngc
new file mode 100644
index 0000000..d2ab952
--- /dev/null
+++ b/web/react/public/machine-cases/max5-maxkins.ngc
@@ -0,0 +1,13 @@
+(CASE_ID: max5-maxkins)
+(CONTROL_PROFILE: XYZBC)
+(KINEMATICS: maxkins)
+(STATUS: adapter-required)
+(NOTE: B/C 非恒等旋转映射;不是 TRT 替代。)
+(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
+G21 G90 G94 G17 G40 G49 G80
+G54
+G1 X5 Y5 Z-2 B3 C8 F120
+G1 X15 Y-5 Z-4 B-3 C-8 F120
+G1 X5 Y5 Z-2 B3 C8 F120
+G1 X0 Y0 Z0 F120
+M30
diff --git a/web/react/public/machine-cases/pentapod-pentakins.ngc b/web/react/public/machine-cases/pentapod-pentakins.ngc
new file mode 100644
index 0000000..8859ccb
--- /dev/null
+++ b/web/react/public/machine-cases/pentapod-pentakins.ngc
@@ -0,0 +1,9 @@
+(CASE_ID: pentapod-pentakins)
+(CONTROL_PROFILE: XYZAB)
+(KINEMATICS: pentakins)
+(STATUS: adapter-required)
+(NOTE: 五支链并联平台;XYZAB 五轴。)
+(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
+G21 G90 G94 G17 G40 G49 G80
+; HOME-POSE VALIDATION: runtime initializes and validates the native WASM inverse at its configured home pose.
+M30
diff --git a/web/react/public/machine-cases/physical/LICENSE.txt b/web/react/public/machine-cases/physical/LICENSE.txt
new file mode 100644
index 0000000..90d4225
--- /dev/null
+++ b/web/react/public/machine-cases/physical/LICENSE.txt
@@ -0,0 +1,2 @@
+Procedural design-reference assets for linuxcncWebWASM. CC0-1.0.
+No manufacturer affiliation or dimensional calibration is implied.
diff --git a/web/react/public/machine-cases/physical/bridge-mill-5axiskins.glb b/web/react/public/machine-cases/physical/bridge-mill-5axiskins.glb
new file mode 100644
index 0000000..f693cff
Binary files /dev/null and b/web/react/public/machine-cases/physical/bridge-mill-5axiskins.glb differ
diff --git a/web/react/public/machine-cases/physical/hexapod-genhexkins.glb b/web/react/public/machine-cases/physical/hexapod-genhexkins.glb
new file mode 100644
index 0000000..c14ee13
Binary files /dev/null and b/web/react/public/machine-cases/physical/hexapod-genhexkins.glb differ
diff --git a/web/react/public/machine-cases/physical/manifest.json b/web/react/public/machine-cases/physical/manifest.json
new file mode 100644
index 0000000..2d7e216
--- /dev/null
+++ b/web/react/public/machine-cases/physical/manifest.json
@@ -0,0 +1,1199 @@
+{
+ "schema": "linuxcnc.physical-machine-glb/1",
+ "license": "CC0-1.0 procedural design reference",
+ "generator": "scripts/generate_physical_case_glbs.mjs",
+ "cases": [
+ {
+ "id": "xyzac-trt",
+ "coordinates": "XYZAC",
+ "kinematics": "xyzac-trt-kins",
+ "filename": "xyzac-trt.glb",
+ "bytes": 62980,
+ "sha256": "971df673aa823c6be82e1c3cb6ea3cb86127d957d276c86afa5be11aaf01e814",
+ "referenceStatus": "real-reference",
+ "parts": [
+ "base",
+ "x",
+ "y",
+ "z",
+ "a",
+ "c",
+ "tool"
+ ],
+ "joints": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "x",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "x",
+ "nodeId": "xyzac-trt:X"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "y",
+ "nodeId": "xyzac-trt:Y"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "z",
+ "nodeId": "xyzac-trt:Z"
+ },
+ {
+ "index": 3,
+ "key": "A",
+ "axis": "A",
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "x",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "a",
+ "nodeId": "xyzac-trt:A"
+ },
+ {
+ "index": 4,
+ "key": "C",
+ "axis": "C",
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "c",
+ "nodeId": "xyzac-trt:C"
+ }
+ ]
+ },
+ {
+ "id": "xyzbc-trt",
+ "coordinates": "XYZBC",
+ "kinematics": "xyzbc-trt-kins",
+ "filename": "xyzbc-trt.glb",
+ "bytes": 62980,
+ "sha256": "f6e33ff6bbc29115e71166d7a034eca40d7ad78aeb58fc38b235bc2568a3f116",
+ "referenceStatus": "real-reference",
+ "parts": [
+ "base",
+ "x",
+ "y",
+ "z",
+ "b",
+ "c",
+ "tool"
+ ],
+ "joints": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "x",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "x",
+ "nodeId": "xyzbc-trt:X"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "y",
+ "nodeId": "xyzbc-trt:Y"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "z",
+ "nodeId": "xyzbc-trt:Z"
+ },
+ {
+ "index": 3,
+ "key": "B",
+ "axis": "B",
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "b",
+ "nodeId": "xyzbc-trt:B"
+ },
+ {
+ "index": 4,
+ "key": "C",
+ "axis": "C",
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "c",
+ "nodeId": "xyzbc-trt:C"
+ }
+ ]
+ },
+ {
+ "id": "xyzab-tdr",
+ "coordinates": "XYZAB",
+ "kinematics": "xyzab_tdr_kins",
+ "filename": "xyzab-tdr.glb",
+ "bytes": 63036,
+ "sha256": "40ba8c1ff5b419354f2bee29751d4cfd421b99f41fcb495b31b4dd096dc1f221",
+ "referenceStatus": "design-reference",
+ "parts": [
+ "base",
+ "x",
+ "y",
+ "z",
+ "a",
+ "b",
+ "tool"
+ ],
+ "joints": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "x",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "x",
+ "nodeId": "xyzab-tdr:X"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "y",
+ "nodeId": "xyzab-tdr:Y"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "z",
+ "nodeId": "xyzab-tdr:Z"
+ },
+ {
+ "index": 3,
+ "key": "A",
+ "axis": "A",
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "x",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "a",
+ "nodeId": "xyzab-tdr:A"
+ },
+ {
+ "index": 4,
+ "key": "B",
+ "axis": "B",
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "b",
+ "nodeId": "xyzab-tdr:B"
+ }
+ ]
+ },
+ {
+ "id": "bridge-mill-5axiskins",
+ "coordinates": "XYZBCWY",
+ "kinematics": "5axiskins",
+ "filename": "bridge-mill-5axiskins.glb",
+ "bytes": 46884,
+ "sha256": "60fe5f3b1e65d48553adff1c6ae418ebb06f7dd67b1f17146ceca55c70ff9c4b",
+ "referenceStatus": "real-reference",
+ "parts": [
+ "base",
+ "x_slide",
+ "y_slide",
+ "z_slide",
+ "b_head",
+ "c_head",
+ "tool",
+ "y_right_gantry",
+ "w_tool_direction"
+ ],
+ "joints": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "x",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "x",
+ "nodeId": "bridge-mill-5axiskins:X"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "y",
+ "nodeId": "bridge-mill-5axiskins:Y"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "z",
+ "nodeId": "bridge-mill-5axiskins:Z"
+ },
+ {
+ "index": 3,
+ "key": "B",
+ "axis": "B",
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "y",
+ "sign": -1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "b",
+ "nodeId": "bridge-mill-5axiskins:B"
+ },
+ {
+ "index": 4,
+ "key": "C",
+ "axis": "C",
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "c",
+ "nodeId": "bridge-mill-5axiskins:C"
+ },
+ {
+ "index": 5,
+ "key": "W",
+ "axis": "W",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "w",
+ "nodeId": "bridge-mill-5axiskins:W"
+ },
+ {
+ "index": 6,
+ "key": "Y#2",
+ "axis": "Y",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "duplicate",
+ "node": "y_2",
+ "nodeId": "bridge-mill-5axiskins:Y#2"
+ }
+ ]
+ },
+ {
+ "id": "max5-maxkins",
+ "coordinates": "XYZBC",
+ "kinematics": "maxkins",
+ "filename": "max5-maxkins.glb",
+ "bytes": 39172,
+ "sha256": "ea72a292e8ca07097cb7ca9116418f796e476e1b4d25a687fa1fc7c488459121",
+ "referenceStatus": "design-reference",
+ "parts": [
+ "base",
+ "x_slide",
+ "y_saddle",
+ "z_slide",
+ "b_head",
+ "tool",
+ "c_horizontal_table"
+ ],
+ "joints": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "x",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "x",
+ "nodeId": "max5-maxkins:X"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "y",
+ "nodeId": "max5-maxkins:Y"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "z",
+ "nodeId": "max5-maxkins:Z"
+ },
+ {
+ "index": 3,
+ "key": "B",
+ "axis": "B",
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "y",
+ "sign": -1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "b",
+ "nodeId": "max5-maxkins:B"
+ },
+ {
+ "index": 4,
+ "key": "C",
+ "axis": "C",
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "c",
+ "nodeId": "max5-maxkins:C"
+ },
+ {
+ "index": 5,
+ "key": "J5",
+ "axis": null,
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "passive",
+ "node": "j5",
+ "nodeId": "max5-maxkins:J5"
+ }
+ ]
+ },
+ {
+ "id": "hexapod-genhexkins",
+ "coordinates": "XYZABC",
+ "kinematics": "genhexkins",
+ "filename": "hexapod-genhexkins.glb",
+ "bytes": 114100,
+ "sha256": "ce325b72a40ea2a2883050aad28431322e9e80e8f59a642a80f044917b3b61fc",
+ "referenceStatus": "real-reference",
+ "parts": [
+ "base",
+ "strut_1",
+ "strut_2",
+ "strut_3",
+ "strut_4",
+ "strut_5",
+ "strut_6",
+ "moving_platform",
+ "spindle",
+ "tool"
+ ],
+ "joints": [
+ {
+ "index": 0,
+ "key": "J0",
+ "axis": null,
+ "type": "strut",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "strut_1",
+ "nodeId": "hexapod-genhexkins:J0"
+ },
+ {
+ "index": 1,
+ "key": "J1",
+ "axis": null,
+ "type": "strut",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "strut_2",
+ "nodeId": "hexapod-genhexkins:J1"
+ },
+ {
+ "index": 2,
+ "key": "J2",
+ "axis": null,
+ "type": "strut",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "strut_3",
+ "nodeId": "hexapod-genhexkins:J2"
+ },
+ {
+ "index": 3,
+ "key": "J3",
+ "axis": null,
+ "type": "strut",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "strut_4",
+ "nodeId": "hexapod-genhexkins:J3"
+ },
+ {
+ "index": 4,
+ "key": "J4",
+ "axis": null,
+ "type": "strut",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "strut_5",
+ "nodeId": "hexapod-genhexkins:J4"
+ },
+ {
+ "index": 5,
+ "key": "J5",
+ "axis": null,
+ "type": "strut",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "strut_6",
+ "nodeId": "hexapod-genhexkins:J5"
+ }
+ ]
+ },
+ {
+ "id": "pentapod-pentakins",
+ "coordinates": "XYZAB",
+ "kinematics": "pentakins",
+ "filename": "pentapod-pentakins.glb",
+ "bytes": 99124,
+ "sha256": "a6decf540aaf259de334e01d3a78b177baee183a9863cd8e2ac5047908a0ed92",
+ "referenceStatus": "real-reference",
+ "parts": [
+ "base",
+ "strut_1",
+ "strut_2",
+ "strut_3",
+ "strut_4",
+ "strut_5",
+ "moving_platform",
+ "spindle",
+ "tool"
+ ],
+ "joints": [
+ {
+ "index": 0,
+ "key": "J0",
+ "axis": null,
+ "type": "strut",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "strut_1",
+ "nodeId": "pentapod-pentakins:J0"
+ },
+ {
+ "index": 1,
+ "key": "J1",
+ "axis": null,
+ "type": "strut",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "strut_2",
+ "nodeId": "pentapod-pentakins:J1"
+ },
+ {
+ "index": 2,
+ "key": "J2",
+ "axis": null,
+ "type": "strut",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "strut_3",
+ "nodeId": "pentapod-pentakins:J2"
+ },
+ {
+ "index": 3,
+ "key": "J3",
+ "axis": null,
+ "type": "strut",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "strut_4",
+ "nodeId": "pentapod-pentakins:J3"
+ },
+ {
+ "index": 4,
+ "key": "J4",
+ "axis": null,
+ "type": "strut",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "strut_5",
+ "nodeId": "pentapod-pentakins:J4"
+ }
+ ]
+ },
+ {
+ "id": "puma-pumakins",
+ "coordinates": "XYZABC",
+ "kinematics": "pumakins",
+ "filename": "puma-pumakins.glb",
+ "bytes": 49852,
+ "sha256": "5fa9568bcf2aee60caf95318d348b24cd7a8ef1579dec60ed886b8cd87c3b55b",
+ "referenceStatus": "real-reference",
+ "parts": [
+ "base",
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4",
+ "J5",
+ "tool"
+ ],
+ "joints": [
+ {
+ "index": 0,
+ "key": "J0",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j0",
+ "nodeId": "puma-pumakins:J0"
+ },
+ {
+ "index": 1,
+ "key": "J1",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j1",
+ "nodeId": "puma-pumakins:J1"
+ },
+ {
+ "index": 2,
+ "key": "J2",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j2",
+ "nodeId": "puma-pumakins:J2"
+ },
+ {
+ "index": 3,
+ "key": "J3",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j3",
+ "nodeId": "puma-pumakins:J3"
+ },
+ {
+ "index": 4,
+ "key": "J4",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j4",
+ "nodeId": "puma-pumakins:J4"
+ },
+ {
+ "index": 5,
+ "key": "J5",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j5",
+ "nodeId": "puma-pumakins:J5"
+ }
+ ]
+ },
+ {
+ "id": "puma-genserkins",
+ "coordinates": "XYZABC",
+ "kinematics": "genserkins",
+ "filename": "puma-genserkins.glb",
+ "bytes": 49868,
+ "sha256": "68e268b9cd2f3414f83a09091791da880c8a28f12215fca060047252669a2287",
+ "referenceStatus": "real-reference",
+ "parts": [
+ "base",
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4",
+ "J5",
+ "tool"
+ ],
+ "joints": [
+ {
+ "index": 0,
+ "key": "J0",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j0",
+ "nodeId": "puma-genserkins:J0"
+ },
+ {
+ "index": 1,
+ "key": "J1",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j1",
+ "nodeId": "puma-genserkins:J1"
+ },
+ {
+ "index": 2,
+ "key": "J2",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j2",
+ "nodeId": "puma-genserkins:J2"
+ },
+ {
+ "index": 3,
+ "key": "J3",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j3",
+ "nodeId": "puma-genserkins:J3"
+ },
+ {
+ "index": 4,
+ "key": "J4",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j4",
+ "nodeId": "puma-genserkins:J4"
+ },
+ {
+ "index": 5,
+ "key": "J5",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j5",
+ "nodeId": "puma-genserkins:J5"
+ }
+ ]
+ },
+ {
+ "id": "trivkins-xyzab",
+ "coordinates": "XYZAB",
+ "kinematics": "trivkins",
+ "filename": "trivkins-xyzab.glb",
+ "bytes": 63064,
+ "sha256": "e232dfb0701f057af22d45b2a71f33a9eb3628abbd2202670faf2645502764ad",
+ "referenceStatus": "design-reference",
+ "parts": [
+ "base",
+ "x",
+ "y",
+ "z",
+ "a",
+ "b",
+ "tool"
+ ],
+ "joints": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "x",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "x",
+ "nodeId": "trivkins-xyzab:X"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "y",
+ "nodeId": "trivkins-xyzab:Y"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "z",
+ "nodeId": "trivkins-xyzab:Z"
+ },
+ {
+ "index": 3,
+ "key": "A",
+ "axis": "A",
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "x",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "a",
+ "nodeId": "trivkins-xyzab:A"
+ },
+ {
+ "index": 4,
+ "key": "B",
+ "axis": "B",
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "b",
+ "nodeId": "trivkins-xyzab:B"
+ }
+ ]
+ },
+ {
+ "id": "trivkins-xyzcw",
+ "coordinates": "XYZCW",
+ "kinematics": "trivkins",
+ "filename": "trivkins-xyzcw.glb",
+ "bytes": 62956,
+ "sha256": "9c5dad0fc0011d69d49b88c54d84f2f158b90924f9616c9c476f770724bcab12",
+ "referenceStatus": "design-reference",
+ "parts": [
+ "base",
+ "x",
+ "y",
+ "z",
+ "c",
+ "w",
+ "tool"
+ ],
+ "joints": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "x",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "x",
+ "nodeId": "trivkins-xyzcw:X"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "y",
+ "nodeId": "trivkins-xyzcw:Y"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "z",
+ "nodeId": "trivkins-xyzcw:Z"
+ },
+ {
+ "index": 3,
+ "key": "C",
+ "axis": "C",
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "c",
+ "nodeId": "trivkins-xyzcw:C"
+ },
+ {
+ "index": 4,
+ "key": "W",
+ "axis": "W",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "w",
+ "nodeId": "trivkins-xyzcw:W"
+ }
+ ]
+ },
+ {
+ "id": "trivkins-xyzya",
+ "coordinates": "XYZYA",
+ "kinematics": "trivkins",
+ "filename": "trivkins-xyzya.glb",
+ "bytes": 56816,
+ "sha256": "d1da84be00a4ebaa6a3b0ad2fa1e15767b8ac3134a30672856d4726556763c92",
+ "referenceStatus": "design-reference",
+ "parts": [
+ "base",
+ "x",
+ "y",
+ "z",
+ "y#2",
+ "a",
+ "tool"
+ ],
+ "joints": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "x",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "x",
+ "nodeId": "trivkins-xyzya:X"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "y",
+ "nodeId": "trivkins-xyzya:Y"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "z",
+ "nodeId": "trivkins-xyzya:Z"
+ },
+ {
+ "index": 3,
+ "key": "Y#2",
+ "axis": "Y",
+ "type": "prismatic",
+ "unit": "mm",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "duplicate",
+ "node": "y_2",
+ "nodeId": "trivkins-xyzya:Y#2"
+ },
+ {
+ "index": 4,
+ "key": "A",
+ "axis": "A",
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "x",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "a",
+ "nodeId": "trivkins-xyzya:A"
+ }
+ ]
+ },
+ {
+ "id": "scorbot-kins",
+ "coordinates": "XYZAB",
+ "kinematics": "scorbot-kins",
+ "filename": "scorbot-kins.glb",
+ "bytes": 41556,
+ "sha256": "5dd8ec985d6c2449c979281e5f51cd5fbe855ba0ece381e111ccdbe755d9a48d",
+ "referenceStatus": "real-reference",
+ "parts": [
+ "base",
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4",
+ "tool"
+ ],
+ "joints": [
+ {
+ "index": 0,
+ "key": "J0",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j0",
+ "nodeId": "scorbot-kins:J0"
+ },
+ {
+ "index": 1,
+ "key": "J1",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j1",
+ "nodeId": "scorbot-kins:J1"
+ },
+ {
+ "index": 2,
+ "key": "J2",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j2",
+ "nodeId": "scorbot-kins:J2"
+ },
+ {
+ "index": 3,
+ "key": "J3",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "z",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j3",
+ "nodeId": "scorbot-kins:J3"
+ },
+ {
+ "index": 4,
+ "key": "J4",
+ "axis": null,
+ "type": "revolute",
+ "unit": "deg",
+ "direction": "y",
+ "sign": 1,
+ "drive": "joint",
+ "source": "kinematics",
+ "node": "j4",
+ "nodeId": "scorbot-kins:J4"
+ }
+ ]
+ }
+ ]
+}
diff --git a/web/react/public/machine-cases/physical/max5-maxkins.glb b/web/react/public/machine-cases/physical/max5-maxkins.glb
new file mode 100644
index 0000000..9319482
Binary files /dev/null and b/web/react/public/machine-cases/physical/max5-maxkins.glb differ
diff --git a/web/react/public/machine-cases/physical/pentapod-pentakins.glb b/web/react/public/machine-cases/physical/pentapod-pentakins.glb
new file mode 100644
index 0000000..05d0122
Binary files /dev/null and b/web/react/public/machine-cases/physical/pentapod-pentakins.glb differ
diff --git a/web/react/public/machine-cases/physical/puma-genserkins.glb b/web/react/public/machine-cases/physical/puma-genserkins.glb
new file mode 100644
index 0000000..eb6ef5b
Binary files /dev/null and b/web/react/public/machine-cases/physical/puma-genserkins.glb differ
diff --git a/web/react/public/machine-cases/physical/puma-pumakins.glb b/web/react/public/machine-cases/physical/puma-pumakins.glb
new file mode 100644
index 0000000..8a468ed
Binary files /dev/null and b/web/react/public/machine-cases/physical/puma-pumakins.glb differ
diff --git a/web/react/public/machine-cases/physical/scorbot-kins.glb b/web/react/public/machine-cases/physical/scorbot-kins.glb
new file mode 100644
index 0000000..9d72036
Binary files /dev/null and b/web/react/public/machine-cases/physical/scorbot-kins.glb differ
diff --git a/web/react/public/machine-cases/physical/trivkins-xyzab.glb b/web/react/public/machine-cases/physical/trivkins-xyzab.glb
new file mode 100644
index 0000000..c5e7557
Binary files /dev/null and b/web/react/public/machine-cases/physical/trivkins-xyzab.glb differ
diff --git a/web/react/public/machine-cases/physical/trivkins-xyzcw.glb b/web/react/public/machine-cases/physical/trivkins-xyzcw.glb
new file mode 100644
index 0000000..bf938d5
Binary files /dev/null and b/web/react/public/machine-cases/physical/trivkins-xyzcw.glb differ
diff --git a/web/react/public/machine-cases/physical/trivkins-xyzya.glb b/web/react/public/machine-cases/physical/trivkins-xyzya.glb
new file mode 100644
index 0000000..dd09a34
Binary files /dev/null and b/web/react/public/machine-cases/physical/trivkins-xyzya.glb differ
diff --git a/web/react/public/machine-cases/physical/xyzab-tdr.glb b/web/react/public/machine-cases/physical/xyzab-tdr.glb
new file mode 100644
index 0000000..9b3d20a
Binary files /dev/null and b/web/react/public/machine-cases/physical/xyzab-tdr.glb differ
diff --git a/web/react/public/machine-cases/physical/xyzac-trt.glb b/web/react/public/machine-cases/physical/xyzac-trt.glb
new file mode 100644
index 0000000..9f8cb59
Binary files /dev/null and b/web/react/public/machine-cases/physical/xyzac-trt.glb differ
diff --git a/web/react/public/machine-cases/physical/xyzbc-trt.glb b/web/react/public/machine-cases/physical/xyzbc-trt.glb
new file mode 100644
index 0000000..fac36ca
Binary files /dev/null and b/web/react/public/machine-cases/physical/xyzbc-trt.glb differ
diff --git a/web/react/public/machine-cases/puma-genserkins.ngc b/web/react/public/machine-cases/puma-genserkins.ngc
new file mode 100644
index 0000000..c445cc0
--- /dev/null
+++ b/web/react/public/machine-cases/puma-genserkins.ngc
@@ -0,0 +1,13 @@
+(CASE_ID: puma-genserkins)
+(CONTROL_PROFILE: XYZABC)
+(KINEMATICS: genserkins)
+(STATUS: adapter-required)
+(NOTE: DH 参数驱动的六轴串联机器人。)
+(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
+G21 G90 G94 G17 G40 G49 G80
+G54
+G1 X0 Y0 Z0 A0 B0 C0 F120
+G1 X0 Y0 Z0 A0 B0 C0 F120
+G1 X0 Y0 Z0 A0 B0 C0 F120
+G1 X0 Y0 Z0 F120
+M30
diff --git a/web/react/public/machine-cases/puma-pumakins.ngc b/web/react/public/machine-cases/puma-pumakins.ngc
new file mode 100644
index 0000000..f441510
--- /dev/null
+++ b/web/react/public/machine-cases/puma-pumakins.ngc
@@ -0,0 +1,9 @@
+(CASE_ID: puma-pumakins)
+(CONTROL_PROFILE: XYZABC)
+(KINEMATICS: pumakins)
+(STATUS: adapter-required)
+(NOTE: PUMA 六关节串联机器人。)
+(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
+G21 G90 G94 G17 G40 G49 G80
+; HOME-POSE VALIDATION: runtime initializes and validates the native WASM inverse at its configured home pose.
+M30
diff --git a/web/react/public/machine-cases/scorbot-kins.ngc b/web/react/public/machine-cases/scorbot-kins.ngc
new file mode 100644
index 0000000..3f053c7
--- /dev/null
+++ b/web/react/public/machine-cases/scorbot-kins.ngc
@@ -0,0 +1,9 @@
+(CASE_ID: scorbot-kins)
+(CONTROL_PROFILE: XYZAB)
+(KINEMATICS: scorbot-kins)
+(STATUS: adapter-required)
+(NOTE: 五关节串联机器人;不是五轴铣床 TRT。)
+(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
+G21 G90 G94 G17 G40 G49 G80
+; HOME-POSE VALIDATION: runtime initializes and validates the native WASM inverse at its configured home pose.
+M30
diff --git a/web/react/public/machine-cases/trivkins-xyzab.ngc b/web/react/public/machine-cases/trivkins-xyzab.ngc
new file mode 100644
index 0000000..0f23ec4
--- /dev/null
+++ b/web/react/public/machine-cases/trivkins-xyzab.ngc
@@ -0,0 +1,13 @@
+(CASE_ID: trivkins-xyzab)
+(CONTROL_PROFILE: XYZAB)
+(KINEMATICS: trivkins)
+(STATUS: identity-only)
+(NOTE: 五轴一一映射基线;不提供 RTCP。)
+(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
+G21 G90 G94 G17 G40 G49 G80
+G54
+G1 X5 Y5 Z-2 A3 B3 F120
+G1 X15 Y-5 Z-4 A-3 B-3 F120
+G1 X5 Y5 Z-2 A3 B3 F120
+G1 X0 Y0 Z0 F120
+M30
diff --git a/web/react/public/machine-cases/trivkins-xyzcw.ngc b/web/react/public/machine-cases/trivkins-xyzcw.ngc
new file mode 100644
index 0000000..0956e75
--- /dev/null
+++ b/web/react/public/machine-cases/trivkins-xyzcw.ngc
@@ -0,0 +1,13 @@
+(CASE_ID: trivkins-xyzcw)
+(CONTROL_PROFILE: XYZCW)
+(KINEMATICS: trivkins)
+(STATUS: identity-only)
+(NOTE: C/W 轴字母映射基线;不提供 RTCP。)
+(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
+G21 G90 G94 G17 G40 G49 G80
+G54
+G1 X5 Y5 Z-2 C8 W1 F120
+G1 X15 Y-5 Z-4 C-8 W-1 F120
+G1 X5 Y5 Z-2 C8 W1 F120
+G1 X0 Y0 Z0 F120
+M30
diff --git a/web/react/public/machine-cases/trivkins-xyzya.ngc b/web/react/public/machine-cases/trivkins-xyzya.ngc
new file mode 100644
index 0000000..c8b26e5
--- /dev/null
+++ b/web/react/public/machine-cases/trivkins-xyzya.ngc
@@ -0,0 +1,13 @@
+(CASE_ID: trivkins-xyzya)
+(CONTROL_PROFILE: XYZYA)
+(KINEMATICS: trivkins)
+(STATUS: identity-only)
+(NOTE: Y 轴重复映射与旋转 A 基线;不提供 RTCP。)
+(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
+G21 G90 G94 G17 G40 G49 G80
+G54
+G1 X5 Y5 Z-2 A3 F120
+G1 X15 Y-5 Z-4 A-3 F120
+G1 X5 Y5 Z-2 A3 F120
+G1 X0 Y0 Z0 F120
+M30
diff --git a/web/react/public/machine-cases/xyzab-tdr.ngc b/web/react/public/machine-cases/xyzab-tdr.ngc
new file mode 100644
index 0000000..f3a7100
--- /dev/null
+++ b/web/react/public/machine-cases/xyzab-tdr.ngc
@@ -0,0 +1,13 @@
+(CASE_ID: xyzab-tdr)
+(CONTROL_PROFILE: XYZAB)
+(KINEMATICS: xyzab_tdr_kins)
+(STATUS: adapter-required)
+(NOTE: LinuxCNC xyzab_tdr_kins;当前 Web 尚未接入该求解器。)
+(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
+G21 G90 G94 G17 G40 G49 G80
+G54
+G1 X5 Y5 Z-2 A3 B3 F120
+G1 X15 Y-5 Z-4 A-3 B-3 F120
+G1 X5 Y5 Z-2 A3 B3 F120
+G1 X0 Y0 Z0 F120
+M30
diff --git a/web/react/public/machine-cases/xyzac-trt.ngc b/web/react/public/machine-cases/xyzac-trt.ngc
new file mode 100644
index 0000000..f44de78
--- /dev/null
+++ b/web/react/public/machine-cases/xyzac-trt.ngc
@@ -0,0 +1,13 @@
+(CASE_ID: xyzac-trt)
+(CONTROL_PROFILE: XYZAC)
+(KINEMATICS: xyzac-trt-kins)
+(STATUS: supported)
+(NOTE: A 绕 X、C 绕 Z;Web LinuxCNC WASM 原生可运行。)
+(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
+G21 G90 G94 G17 G40 G49 G80
+G54
+G1 X5 Y5 Z-2 A3 C8 F120
+G1 X15 Y-5 Z-4 A-3 C-8 F120
+G1 X5 Y5 Z-2 A3 C8 F120
+G1 X0 Y0 Z0 F120
+M30
diff --git a/web/react/public/machine-cases/xyzbc-trt.ngc b/web/react/public/machine-cases/xyzbc-trt.ngc
new file mode 100644
index 0000000..5349138
--- /dev/null
+++ b/web/react/public/machine-cases/xyzbc-trt.ngc
@@ -0,0 +1,13 @@
+(CASE_ID: xyzbc-trt)
+(CONTROL_PROFILE: XYZBC)
+(KINEMATICS: xyzbc-trt-kins)
+(STATUS: supported)
+(NOTE: B 绕 Y、C 绕 Z;Web LinuxCNC WASM 原生可运行。)
+(DRY RUN ONLY - NO SPINDLE, TOOL CHANGE, OR CUTTING)
+G21 G90 G94 G17 G40 G49 G80
+G54
+G1 X5 Y5 Z-2 B3 C8 F120
+G1 X15 Y-5 Z-4 B-3 C-8 F120
+G1 X5 Y5 Z-2 B3 C8 F120
+G1 X0 Y0 Z0 F120
+M30
diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx
index 8eb6a93..5736cd6 100644
--- a/web/react/src/App.tsx
+++ b/web/react/src/App.tsx
@@ -18,6 +18,7 @@ import { XyzacDiagnostics, XyzacLiveParameters, XyzacProgramPane, XyzacTrtPanel
import { projectXyzacPanel, type XyzacOperationTrace } from "./xyzac-panel-contract";
import { DEFAULT_MACHINE, MACHINE_DEFINITIONS, machineDefinition } from "../../workspace/machine-definitions.mjs";
import { RtcpProgramLibrary, type RtcpProgram } from "./RtcpProgramLibrary";
+import { bindingGlbPath, visualBinding } from "../../workspace/machine-visual-bindings.mjs";
import { assertProgramMachine } from "../../workspace/program-machine-compatibility.mjs";
import { MachineManagerPanel } from "./MachineManagerPanel";
@@ -97,7 +98,7 @@ function ManualControl({ action, axes = DEFAULT_MACHINE.axes, canJog, canHome, c
const jog = (direction: number) => action("JOG_START", { axis: selectedAxis, direction, increment: increment === "Continuous" ? null : Number(increment), continuous: increment === "Continuous", units: metric ? "mm" : "inch" });
const jogRelease = increment === "Continuous" ? () => action("JOG_STOP") : undefined;
return
-
Axis:
{displayedAxes.map((axis) =>
)}
+
Axis:
{displayedAxes.map((axis, index) =>
)}
Jog:
jog(-1)} onRelease={jogRelease} label={`Jog ${selectedAxis.toUpperCase()} negative`}>-
jog(1)} onRelease={jogRelease} label={`Jog ${selectedAxis.toUpperCase()} positive`}>+
@@ -488,8 +489,15 @@ export function App() {
stopPreviewProgress();
setPreviewProgress({ percent: 100, phase: "error", detail: `刀具路径加载失败: ${message}` });
};
- const loadCase = async (requestedCasePath = casePath) => {
- if (!canLoad) return;
+ const loadCase = async (requestedCasePath: string = casePath) => {
+ // 工具栏按钮使用 onClick={loadCase},DOM 会把点击事件当作第一个参数传入。
+ // 这里统一把非字符串(事件对象)或空值按"当前案例"处理,避免误报 simulation_case_unknown。
+ if (typeof requestedCasePath !== "string" || !requestedCasePath) requestedCasePath = casePath;
+ // AXIS gates file loading on machine power, but switching the simulation
+ // case is a workspace operation that must work at OFF: loadCase issues
+ // LOAD_WORKSPACE and PROGRAM_LOAD with gate:false. Only a program that is
+ // loading, running or paused blocks the switch.
+ if (programLoading || programRunning || programPaused) return;
const requestedMachine = machineDefinition(requestedCasePath) ?? DEFAULT_MACHINE;
setProgramLoading(true);
startPreviewProgress(`${requestedMachine.coordinates} 程序`);
@@ -505,12 +513,31 @@ export function App() {
if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing");
const normalizedSource = normalizeProgramSource(source);
setCaseStatus("starting exact case...");
- if (!latestLoadGate.current.enabled) throw new Error(latestLoadGate.current.reason);
+ // The entry guard above already rejects a loading, running or paused
+ // program. The workspace gates below run with gate:false, so the AXIS
+ // `load` gate (which requires machineOn) must not block a case switch
+ // performed at OFF.
if (machineOn) await operationController.execute("POWER", { active: false }, { gate: false });
const operation = await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }, { state: axisState, gate: false });
setGeometryBundle(bundle);
setCasePath(requestedCasePath);
setImportedMachine(null);
+ // Load the case's bound structure model so the preview drives real joints
+ // instead of falling back to the built-in AC/BC placeholder. Failures keep
+ // the placeholder and are reported, never silently ignored.
+ const binding = visualBinding(requestedMachine.id);
+ if (binding) {
+ try {
+ const url = `${import.meta.env.BASE_URL}${bindingGlbPath(binding.machineId)}`;
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(`GLB ${response.status}`);
+ const bytes = await response.arrayBuffer();
+ if (bytes.byteLength > MAX_MACHINE_BYTES) throw new Error("GLB 超过 64 MiB");
+ setImportedMachine(await loadImportedMachine(bytes, binding.glb.default, { binding }));
+ } catch (error) {
+ setCommandStatus(`机床模型未载入:${error instanceof Error ? error.message : String(error)};继续使用内置模型`);
+ }
+ }
const resultPayload = (operation.response as { payload?: { configuration?: { state?: string } } }).payload;
markPreviewProgress(28, "wasm", "LinuxCNC WASM 配置已加载,准备解析刀具路径");
setProgramText(normalizedSource); setProgramPath(programPathForCase);
@@ -632,7 +659,7 @@ export function App() {
};
const loadRtcpProgram = async (program: RtcpProgram, source: string) => {
if (!canLoad) throw new Error(controlReason("load"));
- if (program.role !== "main" || !program.coordinates.includes(selectedMachine.coordinates)) throw new Error(`rtcp_program_incompatible:${program.coordinates.join("/")}`);
+ if (program.role !== "main" || (!program.machineId && !program.coordinates.includes(selectedMachine.coordinates))) throw new Error(`rtcp_program_incompatible:${program.coordinates.join("/")}`);
const normalizedSource = normalizeProgramSource(source);
setProgramLoading(true);
startPreviewProgress(program.label);
@@ -641,13 +668,21 @@ export function App() {
try {
if (!normalizedSource.trim()) throw new Error("program_asset_empty");
if (!latestLoadGate.current.enabled) throw new Error(latestLoadGate.current.reason);
+ if (program.machineId && program.machineId !== selectedMachine.id) {
+ const targetMachine = machineDefinition(program.machineId);
+ if (!targetMachine || targetMachine.runtime !== "generic-wasm") throw new Error(`machine_runtime_unavailable:${program.machineId}`);
+ const bundle = await loadSimulationCase({ path: targetMachine.entrypoint, baseUrl: "." });
+ if (bundle.status !== "ready") throw new Error(`machine_case_blocked:${bundle.missing.join(",")}`);
+ await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }, { gate: false });
+ setCasePath(targetMachine.entrypoint); setGeometryBundle(bundle); setImportedMachine(null);
+ }
setProgramText(normalizedSource); setProgramPath(program.path);
markPreviewProgress(38, "wasm", "LinuxCNC WASM 正在解析 RTCP 刀具路径");
const programLoad = await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: program.path }, { state: axisState, gate: false });
finishPreviewProgress(program.path, programLoad.response);
setOperationTrace(operationController.exportTrace());
setCommandStatus("PROGRAM_LOAD acknowledged");
- setCaseStatus(`${selectedMachine.coordinates}: ${program.label} loaded through linuxcnc-wasm`);
+ setCaseStatus(`${program.machineId ?? selectedMachine.id}: ${program.label} loaded through linuxcnc-wasm`);
} catch (error) {
setOperationTrace(operationController.exportTrace());
const message = error instanceof Error ? error.message : "rtcp_program_load_failed";
@@ -859,7 +894,9 @@ export function App() {
const visibleExecutionLineText = programLoading ? "" : executionLineText;
const visibleProgramStatus = programLoading ? "loading" : String(state.program?.program_status ?? "unknown");
const homedAxes = Array.isArray(state.snapshot?.motion?.homed_axes) ? state.snapshot.motion.homed_axes : [];
- const allHomed = homedAxes.length >= 5 && homedAxes.slice(0, 5).every(Boolean);
+ // Homing is per LinuxCNC joint, not per coordinate letter: bridge mill has
+ // seven joints and robot/parallel cases have six.
+ const allHomed = homedAxes.length >= selectedMachine.joint_count && homedAxes.slice(0, selectedMachine.joint_count).every(Boolean);
const xyzacCommand = (kind: string, payload: Record
= {}, action?: string) => { void dispatch(kind, payload, true, action ?? null).catch(() => undefined); };
const loadedProgramPath = String(state.program?.path ?? programPath);
const loadedProgramName = loadedProgramPath.split("/").at(-1) ?? loadedProgramPath;
@@ -888,14 +925,14 @@ export function App() {
return () => { if (uiCacheSaveTimer.current) { clearTimeout(uiCacheSaveTimer.current); uiCacheSaveTimer.current = null; } };
}, [client, wasmSnapshotReady, selectedAxis, leftTab, rightTab, view, zoom, rotateMode, coordType, displayType, metric, touchOffSystem, layers, casePath]);
return
-
AXISLinuxCNC Web/WASM Connection: {state.status.toUpperCase()}
-
+
AXISLinuxCNC Web/WASM使用说明书Connection: {state.status.toUpperCase()}
+
execute("PROGRAM_STEP")} /> execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} pressed={relief.pauseResume} /> stopProgram()} pressed={relief.abort} /> setZoom((value) => Math.min(WEB_SETTINGS.ui.zoom_max, value + WEB_SETTINGS.ui.zoom_step))} /> setZoom((value) => Math.max(WEB_SETTINGS.ui.zoom_min, value - WEB_SETTINGS.ui.zoom_step))} /> { setView("z"); setRightTab("preview"); }} /> { setView("z2"); setRightTab("preview"); }} /> { setView("x"); setRightTab("preview"); }} /> { setView("y"); setRightTab("preview"); }} /> { setView("y2"); setRightTab("preview"); }} /> { setView("p"); setRightTab("preview"); }} /> setRotateMode((value) => !value)} /> xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} />
- Load {selectedMachine.coordinates} boat program{programLoading ? "Loading program..." : caseStatus.startsWith("blocked:") ? caseStatus : `Program: ${loadedProgramName}`}{previewProgress && {previewProgress.detail} }
+ Load {selectedMachine.coordinates} boat program{programLoading ? "Loading program..." : caseStatus.startsWith("blocked:") ? caseStatus : `Program: ${loadedProgramName}`}{previewProgress && {previewProgress.detail} }
{ void loadCase(path); }} />
- Home Joint promptTouchOff(false)} onToolTouchOff={() => promptTouchOff(true)} active={leftTab === "manual"} metric={metric} reasons={Object.fromEntries(Object.keys(gates).map((name) => [name, controlReason(name as keyof typeof gates)]))} devices={state.devices ?? {}} />
+ Home Joint promptTouchOff(false)} onToolTouchOff={() => promptTouchOff(true)} active={leftTab === "manual"} metric={metric} reasons={Object.fromEntries(Object.keys(gates).map((name) => [name, controlReason(name as keyof typeof gates)]))} devices={state.devices ?? {}} />
{selectedMachine.runtime === "trt" && }
{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => )}
diff --git a/web/react/src/MachineImportPanel.tsx b/web/react/src/MachineImportPanel.tsx
index 414efcd..68504e4 100644
--- a/web/react/src/MachineImportPanel.tsx
+++ b/web/react/src/MachineImportPanel.tsx
@@ -25,9 +25,10 @@ export function MachineImportPanel({ model, disabled, onImport }: { model: Impor
{examples.map(({ id, label, filename }) => 下载 {label.replace(" 示例", "")})}
- {s &&
{`底座 → X → Y → Z${s.profile === "trt-xyz-gantry/1" ? "" : " → 主轴箱"};底座 → ${s.coordinates[3]} → C`}
旋转中心 (mm):{["x", "y", "z"].map((c) => s.offsets[`${c}_rot_point`].toFixed(3)).join(" / ")}
轴间偏置 (mm):{["x", "y", "z"].map((c) => s.offsets[`${c}_offset`].toFixed(3)).join(" / ")}
运行限位、速度、回零和周期使用匹配的 INI;模型行程已校验。
}
- {s && s.urdf !== model.scene.userData.linuxcncMachine?.urdf &&
模型已按当前几何参数调整,原 GLB 文件未改写。
}
-
+ {s &&
{s.profile === "generic-wasm/1" ? `槽位关节链:${[...s.coordinates].join(" / ")}(${s.joints.filter((j) => j.type !== "fixed").length} 个运动关节)` : `底座 → X → Y → Z${s.profile === "trt-xyz-gantry/1" ? "" : " → 主轴箱"};底座 → ${s.coordinates[3]} → C`}
{s.profile === "generic-wasm/1" ? 该案例使用通用 WASM 运动学,旋转中心由运动学参数和 INI 决定,不使用 TRT 偏置。
: <>旋转中心 (mm):{["x", "y", "z"].map((c) => Number(s.offsets[`${c}_rot_point`] ?? 0).toFixed(3)).join(" / ")}
轴间偏置 (mm):{["x", "y", "z"].map((c) => Number(s.offsets[`${c}_offset`] ?? 0).toFixed(3)).join(" / ")}
>}运行限位、速度、回零和周期使用匹配的 INI;模型行程已校验。
}
+ {model?.glbAnalysis &&
GLB 自动分析 · {model.glbAnalysis.suggestedCoordinates} · {model.rawAxisBindings?.length ?? 0}/5 轴已绑定
{model.glbAnalysis.axes.map((axis) => `JT_${axis.axis} ← ${axis.parentName}`).join(";")}
实时关节值将直接驱动 GLB 节点;轴原点、方向和旋转中心可在三维机床管理中复核。
{model.glbAnalysis.warnings.length > 0 && {model.glbAnalysis.warnings.join(";")}}}
+ {s && model.scene.userData.linuxcncMachine && s.urdf !== model.scene.userData.linuxcncMachine.urdf &&
模型已按当前几何参数调整,原 GLB 文件未改写。
}
+
{ const selected = event.target.files?.[0]; event.target.value = ""; if (selected) void run(selected); }} />
;
diff --git a/web/react/src/MachineManagerPanel.tsx b/web/react/src/MachineManagerPanel.tsx
index 9538aa2..aaab298 100644
--- a/web/react/src/MachineManagerPanel.tsx
+++ b/web/react/src/MachineManagerPanel.tsx
@@ -14,6 +14,7 @@ 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 { hasVisualBinding } from "../../workspace/machine-visual-bindings.mjs";
import { analyzeGlbFiveAxisScene, analysisSummary, type GlbFiveAxisAnalysis } from "./glb-five-axis-analyzer";
import { MANAGER_SETTINGS, managerAxes, managerProfile, validateManagerCalibration, zeroAxisValues } from "./machine-manager-config";
@@ -27,7 +28,11 @@ 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));
+// A coordinate is selectable when the case has a complete visual binding, not
+// when the catalog marks it "supported": all 13 cases are driven by WASM
+// kinematics now, and the binding file is the authority for the visual side.
+const supportedCoordinateOptions = new Set(FIVE_AXIS_CASES.filter((item) => hasVisualBinding(item.id)).map((item) => item.coordinates));
+const caseSelectable = (item: { id: string }) => hasVisualBinding(item.id);
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
) => 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 };
@@ -292,9 +297,18 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
disposeSources(loaded);
throw new Error("完整机床 GLB 请单独打开;普通部件可批量导入");
}
- const autoAnalysis = new Map(loaded.map((source) => [source.id, source.restoredModel ? null : analyzeGlbFiveAxisScene(source.scene, source.filename)]));
+ // 先按模型自身命名/声明识别;无法消歧时再用"设计机型"作为提示,
+ // 这样先选机型再导入即可自动绑定(tilt_table / rotary_axis 这类通用命名依赖机型)。
+ const analyzeSource = (source: { scene: THREE.Group; filename: string }) => {
+ const automatic = analyzeGlbFiveAxisScene(source.scene, source.filename);
+ if (automatic.suggestedCoordinates !== "UNKNOWN") return automatic;
+ const hinted = analyzeGlbFiveAxisScene(source.scene, source.filename, { expectedCoordinates: draft.coordinates });
+ return hinted.suggestedCoordinates !== "UNKNOWN" ? hinted : automatic;
+ };
+ const autoAnalysis = new Map(loaded.map((source) => [source.id, source.restoredModel ? null : analyzeSource(source)]));
const detectedCoordinates = loaded.length === 1 ? autoAnalysis.get(loaded[0].id)?.suggestedCoordinates : undefined;
const autoCoordinates = detectedCoordinates && isFiveAxisCoordinates(detectedCoordinates) ? detectedCoordinates : draft.coordinates;
+ const autoCalibration = loaded.length === 1 && !restored ? autoAnalysis.get(loaded[0].id)?.calibration ?? null : null;
const nextDraft = (() => {
const next = restored ? [] : [...draft.parts];
for (const source of loaded) {
@@ -312,11 +326,13 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
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 { ...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 }) };
+ 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, ...(autoCalibration ? { cOriginMm: autoCalibration.cOriginMm as Vec3, tiltOffsetMm: autoCalibration.tiltOffsetMm as Vec3, physicalZero: autoCalibration.physicalZero as PhysicalZero } : {}) }) };
})();
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} 轴` : ""}`);
+ 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} 轴` : ""}${autoCalibration ? ";已按旋转轴几何填入标定初值" : ""}`);
} catch (error) { setMessage(error instanceof Error ? error.message : String(error)); }
finally { setPending(false); }
};
@@ -473,11 +489,11 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
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 ; })}2 · 结构与关节 → 3 · 验证与标定 → 4 · 保存 GLB{coordinates.length >= 5 ? `当前坐标 ${coordinates} · ${coordinates[3]} / ${coordinates[4]} 旋转轴` : "请从案例目录选择有效坐标"} · 控制面板当前 {controllerCoordinates}{coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : ""} · 其它源码类型见“文件与恢复”的案例库
+ }}>{FIVE_AXIS_CASES.map((item) =>
)}
2 · 结构与关节 → 3 · 验证与标定 → 4 · 保存 GLB{coordinates.length >= 5 ? `当前坐标 ${coordinates} · ${coordinates[3]} / ${coordinates[4]} 旋转轴` : "请从案例目录选择有效坐标"} · 控制面板当前 {controllerCoordinates}{coordinates !== controllerCoordinates ? ";应用或导入模型时切换" : ""} · 其它源码类型见“文件与恢复”的案例库
{parts.length} 个模型 · {bindings.filter((b) => coordinates.includes(b.dof.axis)).length}/{activeAxes.length} 轴已绑定{!parts.length ? "新建机床" : savedSignature === signature ? "与保存版本一致" : applied === signature ? "已应用当前配置" : exported === signature ? "已导出 · 尚未应用" : "配置尚未应用"}
{activeAxes.map((axis) => { const binding = bindings.find((b) => b.dof.axis === axis); return ; })}
@@ -489,7 +505,7 @@ export function MachineManagerPanel({ coordinates: controllerCoordinates, disabl
{parts.length > 0 &&
{parts.map((part) =>
)}
}
{([ ["structure", "结构与关节"], ["motion", "运动验证"], ["calibration", "机床标定"], ["files", "文件与恢复"] ] as const).map(([id, label], index) => )}
-
部件与层级
{validation.model?.nodes.length ?? 0} 节点{selectedAnalysis && GLB 五轴结构分析
{analysisSummary(selectedAnalysis)}
节点 / 网格{selectedAnalysis.nodeCount} / {selectedAnalysis.meshCount}建议运动学{selectedAnalysis.suggestedKinematics}RTCP{"待确认"}
{CNC_AXES.map((axis) => { const item = selectedAnalysis.axes.find((candidate) => candidate.axis === axis); return {axis}{item ? {item.nodeName}
{item.worldOriginMm.map((value) => value.toFixed(1)).join(" / ")} mm : 未识别}; })}
结构链与 RTCP 参数
直线轴链:{selectedAnalysis.linearChain.join(" → ") || "未识别"}
旋转轴链:{selectedAnalysis.rotaryChain.join(" → ") || "未识别"}
节点父级:{selectedAnalysis.axes.map((item) => `JT_${item.axis} ← ${item.parentName || "scene"}`).join(";") || "未识别"}
{selectedAnalysis.rtcp.reason}
建议参数:{selectedAnalysis.rtcp.requiredParameters.join("、")}{selectedAnalysis.warnings.length > 0 && {selectedAnalysis.warnings.map((warning) => - {warning}
)}
} } setQuery(event.target.value)} />
{parts.length ? {parts.filter((part) => !part.parentId).map(renderPartTree)}
: 导入机床或部件后显示 GLB 内部层级。
}
+
部件与层级
{validation.model?.nodes.length ?? 0} 节点{selectedAnalysis && GLB 五轴结构分析
{analysisSummary(selectedAnalysis)}
节点 / 网格{selectedAnalysis.nodeCount} / {selectedAnalysis.meshCount}建议运动学{selectedAnalysis.suggestedKinematics}RTCP{selectedAnalysis.rtcp.mode === "native-trt" ? "原生 TRT" : "待确认"}
{CNC_AXES.map((axis) => { const item = selectedAnalysis.axes.find((candidate) => candidate.axis === axis); return {axis}{item ? {item.nodeName}
{item.worldOriginMm.map((value) => value.toFixed(1)).join(" / ")} mm : 未识别}; })}
结构链与 RTCP 参数
直线轴链:{selectedAnalysis.linearChain.join(" → ") || "未识别"}
旋转轴链:{selectedAnalysis.rotaryChain.join(" → ") || "未识别"}
节点父级:{selectedAnalysis.axes.map((item) => `JT_${item.axis} ← ${item.parentName || "scene"}`).join(";") || "未识别"}
{selectedAnalysis.rtcp.reason}
{selectedAnalysis.calibration && 标定候选:C 轴原点 {selectedAnalysis.calibration.cOriginMm.map((value) => value.toFixed(1)).join(" / ")} mm;{selectedAnalysis.suggestedCoordinates[3]} 到 C 轴偏置 {selectedAnalysis.calibration.tiltOffsetMm.map((value) => value.toFixed(1)).join(" / ")} mm
}建议参数:{selectedAnalysis.rtcp.requiredParameters.join("、")}{selectedAnalysis.warnings.length > 0 && {selectedAnalysis.warnings.map((warning) => - {warning}
)}
} } setQuery(event.target.value)} />
{parts.length ? {parts.filter((part) => !part.parentId).map(renderPartTree)}
: 导入机床或部件后显示 GLB 内部层级。
}
{selected &&
{selectedTreeNode?.name ?? selected.filename}{selected.filename}
{selectedTreeNode && 零姿态与当前坐标
{selectedTreeNode.name}节点自身局部原点装配位置(相对父节点,mm)装配姿态(相对父节点,deg){selectedDriven && <>当前世界原点(mm)>}
}
{selectedDof && <>>}
{selectedNodePath === "scene" ? : 内部节点父级由层级树拖放管理
}当前节点:{selectedTreeNode?.name}。{selectedDof ? `仅允许 ${selectedDof.axis} ${isLinear(selectedDof.axis) ? "平移" : "旋转"},沿父级 ${selectedDof.direction.toUpperCase()} 方向;以导入装配姿态为零位。` : "固定节点随父级运动;请先配置唯一自由度。"}
diff --git a/web/react/src/RtcpProgramLibrary.tsx b/web/react/src/RtcpProgramLibrary.tsx
index 5ee4cbd..1c61799 100644
--- a/web/react/src/RtcpProgramLibrary.tsx
+++ b/web/react/src/RtcpProgramLibrary.tsx
@@ -11,6 +11,7 @@ export type RtcpProgram = {
role: "main" | "support" | "reference";
features: string[];
note: string;
+ machineId?: string;
};
type Catalog = {
@@ -26,8 +27,10 @@ const roleLabel = (role: RtcpProgram["role"]) => role === "main" ? "主程序" :
const assetUrl = (path: string) => `${import.meta.env.BASE_URL}${path}`;
const machineLabel = (coordinates: string) => coordinates === "XYZAC" ? "AC(XYZAC)" : coordinates === "XYZBC" ? "BC(XYZBC)" : coordinates;
-export function RtcpProgramLibrary({ coordinates, disabled, disabledReason, onLoad }: {
+export function RtcpProgramLibrary({ coordinates, machineId, machineRuntime, disabled, disabledReason, onLoad }: {
coordinates: string;
+ machineId: string;
+ machineRuntime: string;
disabled: boolean;
disabledReason: string;
onLoad: (program: RtcpProgram, source: string) => Promise;
@@ -41,13 +44,13 @@ export function RtcpProgramLibrary({ coordinates, disabled, disabledReason, onLo
const [message, setMessage] = useState("");
const [pending, setPending] = useState(false);
const selected = RTCP_PROGRAM_CATALOG.programs.find((program) => program.id === selectedId) ?? defaultProgram;
- const compatible = selected.role === "main" && selected.coordinates.includes(coordinates);
+ const compatible = selected.role === "main" && (selected.machineId ? selected.machineId.length > 0 : selected.coordinates.includes(coordinates) && machineRuntime === "trt");
const machinePrograms = useMemo(() => RTCP_PROGRAM_CATALOG.programs.filter((program) => program.coordinates.includes(coordinates)), [coordinates]);
const visiblePrograms = scope === "machine" ? machinePrograms : RTCP_PROGRAM_CATALOG.programs;
const grouped = RTCP_PROGRAM_CATALOG.groups.map((group) => ({ ...group, programs: visiblePrograms.filter((program) => program.group === group.id) })).filter((group) => group.programs.length > 0);
useEffect(() => {
setScope("machine"); setSelectedId(defaultProgram.id); setSource(""); setSourcePath(""); setMessage("");
- }, [coordinates, defaultProgram.id]);
+ }, [coordinates, machineId, machineRuntime, defaultProgram.id]);
const readSource = async () => {
if (sourcePath === selected.path && source) return source;
@@ -69,16 +72,16 @@ export function RtcpProgramLibrary({ coordinates, disabled, disabledReason, onLo
finally { setPending(false); }
};
const compatibility = compatible
- ? `${coordinates} 当前机型可加载;运动由 LinuxCNC WASM 解释器与运动学执行。`
+ ? `${selected.machineId ? `${selected.machineId} · ` : ""}${coordinates} 当前机型可加载;运动由 LinuxCNC WASM 解释器与配置的运动学执行。`
: selected.role !== "main"
? `${roleLabel(selected.role)}用于 remap、O-call 或算法查阅,不作为独立主程序加载。`
- : `需要 ${selected.coordinates.join("/")} 运动学;当前机型为 ${coordinates}。`;
+ : selected.machineId ? `需要机型 ${selected.machineId};当前机型为 ${machineId}。` : `该源程序要求原生 TRT;当前运行时为 ${machineRuntime}。`;
return
setOpen(false)}>
- {machineLabel(coordinates)} 程序当前分类:{machinePrograms.length} 个可直接加载:{machinePrograms.filter((program) => program.role === "main").length}完整源程序:{RTCP_PROGRAM_CATALOG.total} 个
-
+ {machineLabel(coordinates)} 程序当前分类:{machinePrograms.length} 个可直接加载:{machinePrograms.filter((program) => program.role === "main").length}LinuxCNC 源程序:32 个 · 含案例干运行程序共 {RTCP_PROGRAM_CATALOG.total} 个
+
diff --git a/web/react/src/XyzacTrtPanel.tsx b/web/react/src/XyzacTrtPanel.tsx
index 6117cfc..ca42078 100644
--- a/web/react/src/XyzacTrtPanel.tsx
+++ b/web/react/src/XyzacTrtPanel.tsx
@@ -32,7 +32,7 @@ export function XyzacManualPanel({ view }: { view: XyzacPanelView }) {
return
{machine.coordinates} authority{view.kinematics?.linear_units ?? "mm"} / {view.kinematics?.angular_units ?? "deg"}
- {axes.map((axis, index) =>
{axis.toUpperCase()}J{index} {view.joints.length === machine.joint_count ? Number(view.joints[index]).toFixed(4) : "-"}
)}
+ {axes.map((axis, index) =>
{axis.toUpperCase()}J{index} {view.joints.length === machine.joint_count ? Number(view.joints[index]).toFixed(4) : "-"}
)}
{offsetRows.map((row) => {row})}
;
diff --git a/web/react/src/glb-five-axis-analyzer.ts b/web/react/src/glb-five-axis-analyzer.ts
index d21a507..5b4fd22 100644
--- a/web/react/src/glb-five-axis-analyzer.ts
+++ b/web/react/src/glb-five-axis-analyzer.ts
@@ -1,5 +1,8 @@
import * as THREE from "three";
import { CNC_AXES, defaultDof, isFiveAxisCoordinates, isLinear, validJointDof, type CncAxis, type JointDof } from "./machine-joints";
+import { managerProfile } from "./machine-manager-config";
+import type { Vec3 } from "./machine-urdf";
+import { classifyAxisNodeName as classifyAxisName, deriveTrtCalibration, selectAxisNodes } from "../../workspace/glb-axis-naming.mjs";
/** A single axis candidate found in a GLB node hierarchy. */
export type GlbAxisEvidence = {
@@ -16,6 +19,18 @@ export type GlbAxisEvidence = {
reason: string;
joint: JointDof;
autoBind: boolean;
+ rank: number;
+};
+
+/** TRT 标定候选:由旋转轴节点的世界坐标推导,写入管理器标定页作为初值。 */
+export type GlbTrtCalibration = {
+ schema: "linuxcnc.trt-calibration-candidate/1";
+ source: "axis-world-origin";
+ cOriginMm: Vec3;
+ tiltOffsetMm: Vec3;
+ physicalZero: Record;
+ zeroedOffsetDirection?: "x" | "y" | "z";
+ zeroedOffsetValueMm?: number;
};
export type GlbFiveAxisAnalysis = {
@@ -31,23 +46,22 @@ export type GlbFiveAxisAnalysis = {
chains: string[][];
coordinateCandidates: string[];
suggestedCoordinates: string | "UNKNOWN";
- suggestedKinematics: "generic-five-axis" | "unknown";
- rtcp: { mode: "unverified" | "insufficient"; reason: string; requiredParameters: string[] };
+ suggestedKinematics: "xyzac-trt-kins" | "xyzbc-trt-kins" | "generic-five-axis" | "unknown";
+ rtcp: { mode: "native-trt" | "unverified" | "insufficient"; reason: string; requiredParameters: string[] };
+ calibration: GlbTrtCalibration | null;
warnings: string[];
};
+export type AxisNameHint = { axis: CncAxis; confidence: "high" | "medium" | "low"; rank: number; reason: string; ambiguous?: "tilt" | "rotary" };
+
const AXES = CNC_AXES;
const axisSet = new Set(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;
+/** 纯函数:把 GLB 节点名归类为某个数控轴及其可信度(规则见 web/workspace/glb-axis-naming.mjs)。 */
+export function classifyAxisNodeName(name: string): AxisNameHint | null {
+ return classifyAxisName(name) as AxisNameHint | null;
}
function objectPath(node: THREE.Object3D): string {
@@ -57,54 +71,85 @@ function objectPath(node: THREE.Object3D): string {
return `scene${parts.length ? `/${parts.join("/")}` : ""}`;
}
+/**
+ * 内嵌 URDF 的机床(内置 AC/BC 示例)用场景元数据没有 coordinates 字段,
+ * 但关节上带有 标注,按文档顺序即可还原轴序。
+ */
+function coordinatesFromUrdf(urdf: unknown): string | null {
+ if (typeof urdf !== "string") return null;
+ const axes = [...urdf.matchAll(/ match[1]);
+ const unique = [...new Set(axes)];
+ return unique.length === 5 ? unique.join("") : null;
+}
+
/**
* 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 {
+export function analyzeGlbFiveAxisScene(scene: THREE.Object3D, filename?: string, options: { expectedCoordinates?: string } = {}): GlbFiveAxisAnalysis {
scene.updateMatrixWorld(true);
- const axes: GlbAxisEvidence[] = [];
const warnings: string[] = [];
- const duplicateAxes = new Set();
const bounds = new THREE.Box3();
let nodeCount = 0, meshCount = 0;
+ type Candidate = { evidence: GlbAxisEvidence; hint: AxisNameHint | null; explicitAxis: CncAxis | null };
+ const candidates: Candidate[] = [];
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;
+ const hint = classifyAxisNodeName(node.name || "");
+ const axis = explicit ? metadata?.axis : hint?.axis;
if (!axisSet.has(axis)) { if (explicit) warnings.push(`${node.name}:轴元数据无效,请手动绑定`); return; }
- const match = { axis: axis as CncAxis, exact: explicit };
+ const match = { axis: axis as CncAxis, 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),
+ confidence: explicit ? "high" : hint?.confidence ?? "low", reason: explicit ? "GLB 关节元数据" : hint?.reason ?? "节点名称提示;默认轴向需运动验证",
+ joint, autoBind: false, rank: explicit ? -1 : hint?.rank ?? 5,
};
- axes.push(evidence);
+ candidates.push({ evidence, hint, explicitAxis: explicit ? (match.axis as CncAxis) : null });
});
- 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 expected = isFiveAxisCoordinates(options.expectedCoordinates ?? "") ? options.expectedCoordinates as string : null;
+ const declaredRaw = scene.userData.linuxcncJointModel?.coordinates ?? scene.userData.linuxcncMachine?.coordinates ?? coordinatesFromUrdf(scene.userData.linuxcncMachine?.urdf);
+ const declaredHint = typeof declaredRaw === "string" && isFiveAxisCoordinates(declaredRaw) ? declaredRaw : null;
+ const coordinateHint = expected ?? declaredHint;
+ const profile = coordinateHint ? managerProfile(coordinateHint) : undefined;
+ const selection = selectAxisNodes(
+ candidates.map((item) => ({ name: item.evidence.nodeName, path: item.evidence.path, depth: item.evidence.depth, explicitAxis: item.explicitAxis })),
+ { declaredCoordinates: declaredHint, expectedCoordinates: expected, tiltAxis: profile?.tiltAxis ?? null, rotaryAxis: profile?.rotaryAxis ?? null },
+ ) as { axes: Array<{ axis: string; path: string; rank: number; confidence: GlbAxisEvidence["confidence"]; reason: string; autoBind: boolean }>; suggestedCoordinates: string; missingAxes: string[]; warnings: string[] };
+ warnings.push(...selection.warnings);
+ const axisList = selection.axes.flatMap((item) => {
+ const candidate = candidates.find((entry) => entry.evidence.path === item.path);
+ if (!candidate) return [];
+ const axis = item.axis as CncAxis;
+ candidate.evidence.axis = axis;
+ candidate.evidence.rank = item.rank;
+ candidate.evidence.confidence = item.confidence;
+ candidate.evidence.reason = item.reason;
+ candidate.evidence.autoBind = item.autoBind;
+ candidate.evidence.expectedKind = isLinear(axis) ? "linear" : "rotary";
+ candidate.evidence.joint = { ...candidate.evidence.joint, axis };
+ return [candidate.evidence];
+ });
+ const suggestedCoordinates: string | "UNKNOWN" = selection.suggestedCoordinates as string | "UNKNOWN";
const coordinateCandidates = suggestedCoordinates === "UNKNOWN" ? [] : [suggestedCoordinates];
- const missingAxes = typeof declared === "string" && isFiveAxisCoordinates(declared) ? [...declared].filter((axis) => !available.has(axis as CncAxis)) : [];
+ const missingAxes = selection.missingAxes;
+ const available = new Set(axisList.filter((item) => item.autoBind).map((item) => item.axis));
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) => {
@@ -114,21 +159,40 @@ export function analyzeGlbFiveAxisScene(scene: THREE.Object3D, filename?: string
});
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("声明的轴顺序与可用关节不一致,未采用该顺序");
+ const suggestedKinematics: GlbFiveAxisAnalysis["suggestedKinematics"] =
+ suggestedCoordinates === "XYZAC" ? "xyzac-trt-kins"
+ : suggestedCoordinates === "XYZBC" ? "xyzbc-trt-kins"
+ : coordinateCandidates.length ? "generic-five-axis" : "unknown";
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: ["各关节轴向和参考系", "旋转中心/零姿态", "刀具与工件节点", "运动学适配器及标定参数"] };
+
+ // 原生 TRT 标定候选:C 轴回转中心取 C 轴节点世界坐标,倾转偏置取倾转轴节点与之的差,
+ // 并沿倾转轴方向(该机型运动学约束为 0 的分量)归零。
+ const trtProfile = suggestedCoordinates === "XYZAC" || suggestedCoordinates === "XYZBC" ? managerProfile(suggestedCoordinates) : undefined;
+ const calibration = (trtProfile ? deriveTrtCalibration({
+ coordinates: suggestedCoordinates,
+ profile: trtProfile,
+ cWorldMm: axisList.find((item) => item.axis === trtProfile.rotaryAxis)?.worldOriginMm,
+ tiltWorldMm: axisList.find((item) => item.axis === trtProfile.tiltAxis)?.worldOriginMm,
+ }) : null) as GlbTrtCalibration | null;
+ if (calibration?.zeroedOffsetDirection && calibration.zeroedOffsetValueMm !== undefined) {
+ warnings.push(`按 ${suggestedCoordinates} 运动学约束,${trtProfile?.tiltAxis}→${trtProfile?.rotaryAxis} 偏置的 ${calibration.zeroedOffsetDirection.toUpperCase()} 分量 ${calibration.zeroedOffsetValueMm.toFixed(3)} mm 已归零`);
+ }
+ const rtcp = calibration
+ ? { mode: "native-trt" as const,
+ reason: `已按旋转轴节点的世界坐标推导 ${suggestedCoordinates} 的原生 TRT 标定候选(C 轴原点与${trtProfile?.tiltAxis}→C 偏置);请在三维机床管理中复核后回零验证`,
+ requiredParameters: ["各关节轴向和参考系", "旋转中心/零姿态", "刀具与工件节点", "运动学适配器及标定参数"] }
+ : { mode: coordinateCandidates.length ? "unverified" as const : "insufficient" as const,
+ reason: coordinateCandidates.length ? "已识别五轴结构但无法推导原生 TRT 标定;请确认刀具/工件链和轴心" : "轴名和层级不能证明 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 };
+ 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, calibration, warnings };
}
export function analysisSummary(analysis: GlbFiveAxisAnalysis): string {
const axes = analysis.axes.map((item) => item.nodeName).join("、") || "未识别关节,可手动绑定";
- return `${axes} · ${analysis.suggestedCoordinates === "UNKNOWN" ? "待配置五轴" : analysis.suggestedCoordinates} · RTCP 待确认`;
+ const rtcp = analysis.rtcp.mode === "native-trt" ? "原生 TRT RTCP" : "RTCP 待确认";
+ return `${axes} · ${analysis.suggestedCoordinates === "UNKNOWN" ? "待配置五轴" : analysis.suggestedCoordinates} · ${rtcp}`;
}
diff --git a/web/react/src/imported-machine.ts b/web/react/src/imported-machine.ts
index 388c17e..2f1eb98 100644
--- a/web/react/src/imported-machine.ts
+++ b/web/react/src/imported-machine.ts
@@ -1,11 +1,12 @@
import * as THREE from "three";
-import { bindJointModel, defaultDof, driveJoint, jointZero, validateJointModel, type JointModel, type AxisValues, type CncAxis, type JointZero } from "./machine-joints";
+import { bindJointModel, defaultDof, driveJoint, jointZero, validateJointModel, type JointModel, type JointRecordV2, type JointModelV2, type JointSlot, type JointDofV2, 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";
+import { bindingJointModel, type VisualBinding } from "../../workspace/machine-visual-bindings.mjs";
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, number>>; glbAnalysis?: GlbFiveAxisAnalysis; rawAxisBindings?: RawAxisBinding[] };
@@ -14,7 +15,7 @@ export function disposeMachineScene(scene: THREE.Object3D) {
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 {
+export async function loadImportedMachine(bytes: ArrayBuffer, filename: string, options: { binding?: VisualBinding | null } = {}): Promise {
let inspected: ReturnType | null = null;
try { inspected = inspectMachineGlb(bytes); } catch { /* Raw five-axis GLBs are analyzed below. */ }
const manager = new THREE.LoadingManager();
@@ -23,6 +24,13 @@ export async function loadImportedMachine(bytes: ArrayBuffer, filename: string):
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; } });
const model = gltf.scene.userData.linuxcncJointModel as JointModel | undefined;
+ // A curated case binding wins over name heuristics when the GLB ships no
+ // joint model of its own: the binding names each slot's node explicitly.
+ if (!model && options.binding) {
+ const bound = bindMachineScene(gltf.scene, options.binding);
+ const structure = boundStructure(options.binding);
+ return { scene: gltf.scene, structure, hash, filename, physicalZero: Object.fromEntries(bound.controller.jointOrder.map((slot) => [slot.key.toLowerCase(), 0])) };
+ }
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 等命名)");
@@ -106,7 +114,7 @@ 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 jointModel = scene.userData.linuxcncJointModel as (JointModel | JointModelV2) | undefined;
const jointDriver = jointModel ? bindJointModel(scene, jointModel) : null;
const physicalZero = asset.physicalZero ?? jointModel?.calibration.physicalZero ?? scene.userData.linuxcncMachineGeometry?.physicalZero ?? {};
const managed = scene.userData.linuxcncMachineGeometry;
@@ -128,12 +136,40 @@ export function buildImportedMachine(asset: ImportedMachine) {
};
return { machine, toolTip, workOrigin, joints: { rotate: machine }, update, dispose: () => undefined };
}
+ // Self-contained v2 assembly: a bound GLB carries its own joint model, so
+ // there is no URDF link tree to look up. Drive slot nodes directly and hang
+ // the tool tip / work origin on the nodes the binding named.
+ if (jointModel?.schema === "linuxcnc.joint-model/2" && !managed) {
+ 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 slotNodes = new Map();
+ scene.traverse((node) => { const slot = node.userData.linuxcncJoint?.index; if (Number.isInteger(slot) && !slotNodes.has(slot)) slotNodes.set(slot, node); });
+ const toolTip = new THREE.Group(); toolTip.name = "bound-tool-tip";
+ const workOrigin = new THREE.Group(); workOrigin.name = "bound-work-origin";
+ const toolNode = scene.getObjectByName("tool") ?? [...slotNodes.values()].at(-1) ?? scene;
+ toolNode.add(toolTip); scene.add(workOrigin);
+ const driver = bindJointModel(scene, jointModel) as { update(values: number[]): void; state(): Record };
+ const update = (state: Pick) => {
+ const values = [...state.joints];
+ if (values.length !== jointModel.controller.jointOrder.length) return;
+ driver.update(values);
+ const length = state.offsets.tool_offset / 1000;
+ toolTip.position.set(0, 0, -length);
+ machine.updateMatrixWorld(true);
+ machine.userData.linuxcncJointState = driver.state();
+ };
+ 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 } } };
+ // v1 keys physical zero by axis letter, v2 by slot index; keep each shape.
+ const effectiveJoints: JointModel | JointModelV2 = jointModel.schema === "linuxcnc.joint-model/2"
+ ? { ...jointModel, calibration: { ...jointModel.calibration, cOriginMm: effective.cOriginMm as Vec3, tiltOffsetMm: effective.tiltOffsetMm as Vec3 } }
+ : { ...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;
@@ -170,7 +206,14 @@ export function buildImportedMachine(asset: ImportedMachine) {
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);
+ if (jointDriver) {
+ // v2 models are slot-indexed: pass the array straight through so 6- and
+ // 7-joint machines (robots, parallel platforms, duplicate gantries) are
+ // driven by their own joint values rather than a coordinate-letter map.
+ const driver = jointDriver as { update(values: number[] | AxisValues, physicalZero?: Record): void };
+ if (jointModel?.schema === "linuxcnc.joint-model/2") driver.update([...state.joints]);
+ else driver.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;
@@ -182,3 +225,44 @@ export function buildImportedMachine(asset: ImportedMachine) {
};
return { machine, toolTip, workOrigin, joints: { rotate }, update, dispose: () => { cutter.geometry.dispose(); cutter.material.dispose(); } };
}
+
+// Appended helpers: bind a scene to a case's visual binding and drive it from
+// slot-indexed WASM joints. No kinematics is computed here.
+export function boundStructure(binding: VisualBinding): MachineStructure {
+ const joints = binding.jointOrder.map((slot, index) => ({
+ name: `slot_${index}`, type: slot.kind === "revolute" ? "revolute" : "prismatic",
+ parent: "bound_machine", child: `bound_${index}`, origin: [0, 0, 0] as Vec3,
+ axis: ([1, 0, 0] as Vec3), coordinate: slot.axis ?? "", limit: { lower: slot.kind === "revolute" ? -360 : -1000, upper: slot.kind === "revolute" ? 360 : 1000, velocity: 100, effort: 1 },
+ ...( { slot: String(index) } as Record ),
+ }));
+ return { name: binding.machineId, profile: "generic-wasm/1", coordinates: binding.coordinates, base: "bound_machine", tool: "bound_tool", work: "bound_work", links: ["bound_machine", "bound_tool", "bound_work", ...joints.map((j) => j.child)], joints, offsets: {}, urdf: `bound-glb://${binding.machineId}` };
+}
+
+export function bindMachineScene(scene: THREE.Object3D, binding: Parameters[0]) {
+ const ids: string[] = [];
+ scene.traverse((node) => { if (node.userData.linuxcncNodeId) ids.push(node.userData.linuxcncNodeId); });
+ const draft = bindingJointModel(binding, ids);
+ if (draft.missing.length) throw new Error(`模型缺少关节节点:${draft.missing.join("、")}`);
+ const records: JointRecordV2[] = [];
+ scene.traverse((node) => {
+ const id = node.userData.linuxcncNodeId;
+ if (!id) return;
+ const index = draft.slotMap.indexOf(id);
+ const slot = index >= 0 ? draft.controller.jointOrder[index] : null;
+ records.push({
+ id, parentId: node.parent?.userData.linuxcncNodeId ?? null, name: node.name || id, role: "",
+ source: { file: binding.machineId, hash: binding.machineId, path: id }, zero: jointZero(node),
+ joint: slot ? { axis: (slot.axis as CncAxis | null), slot: index, direction: (node.userData.linuxcncJoint?.direction ?? "z") as JointDofV2["direction"], sign: (node.userData.linuxcncJoint?.sign ?? 1) as 1 | -1, dof: 1 as const, type: slot.kind as JointRecordV2["joint"] extends null ? never : NonNullable["type"], unit: slot.unit as "mm" | "deg", frame: "parent" as const } : null,
+ });
+ });
+ const model: JointModelV2 = {
+ schema: "linuxcnc.joint-model/2", machineId: String(draft.machineId), coordinates: String(draft.coordinates),
+ units: { length: "mm", angle: "deg", gltfLength: "m" },
+ controller: { source: "linuxcnc-wasm.visual.joints", jointOrder: draft.controller.jointOrder.map((slot) => ({ ...slot, axis: (slot.axis as CncAxis | null), kind: slot.kind as JointSlot["kind"], unit: slot.unit as "mm" | "deg", source: slot.source as JointSlot["source"] })) },
+ calibration: { cOriginMm: [...draft.calibration.cOriginMm] as Vec3, tiltOffsetMm: [...draft.calibration.tiltOffsetMm] as Vec3, physicalZero: [...draft.calibration.physicalZero] },
+ slotMap: [...draft.slotMap], nodes: records,
+ };
+ validateJointModel(model);
+ scene.userData.linuxcncJointModel = model;
+ return model;
+}
diff --git a/web/react/src/legacy.d.ts b/web/react/src/legacy.d.ts
index 7c5f329..22413eb 100644
--- a/web/react/src/legacy.d.ts
+++ b/web/react/src/legacy.d.ts
@@ -16,6 +16,16 @@ declare module "*.mjs" {
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; missing: string[]; fetched: string[]; state_hash: string; halManifest: unknown[] }>;
export function toWorkerConfiguration(bundle: unknown): Record;
+ export function classifyAxisNodeName(name: string): { axis: string; confidence: "high" | "medium" | "low"; rank: number; reason: string; ambiguous?: "tilt" | "rotary" } | null;
+ export function selectAxisNodes(descriptors: Array<{ name: string; path: string; depth: number; explicitAxis?: string | null }>, options?: { declaredCoordinates?: string | null; expectedCoordinates?: string | null; tiltAxis?: string | null; rotaryAxis?: string | null }): { axes: Array<{ axis: string; name: string; path: string; depth: number; rank: number; confidence: "high" | "medium" | "low"; reason: string; expectedKind: "linear" | "rotary"; autoBind: boolean }>; suggestedCoordinates: string; missingAxes: string[]; warnings: string[]; unresolvedAmbiguous: number };
+ export function deriveTrtCalibration(input: { coordinates: string; profile?: { tiltAxis: string; rotaryAxis: string; forbiddenOffsetDirection: string }; cWorldMm?: number[]; tiltWorldMm?: number[] }): { schema: string; source: string; cOriginMm: number[]; tiltOffsetMm: number[]; physicalZero: Record; zeroedOffsetDirection?: string; zeroedOffsetValueMm?: number } | null;
+ export type VisualBindingSlot = { index: number; key: string; axis: string | null; kind: "prismatic" | "revolute" | "strut"; unit: "mm" | "deg"; source: "kinematics" | "extra" | "duplicate" | "passive" };
+ export type VisualBinding = { machineId: string; entrypoint: string; coordinates: string; kinematics: string; jointCount: number; kinematicsJointCount: number; extraJointCount: number; jointOrder: readonly VisualBindingSlot[]; glb: { default: string }; bindings: ReadonlyArray<{ slot: number; node: string; direction: "x" | "y" | "z"; sign: 1 | -1 }>; anchors: unknown; calibration: { cOriginMm: readonly number[]; tiltOffsetMm: readonly number[]; physicalZero: readonly number[] }; referenceStatus: string };
+ export const VISUAL_BINDINGS: { schema: string; glbRoot: string; cases: readonly VisualBinding[] };
+ export function visualBinding(value: unknown): VisualBinding | null;
+ export function hasVisualBinding(value: unknown): boolean;
+ export function bindingGlbPath(value: unknown): string | null;
+ export function bindingJointModel(binding: VisualBinding, nodeIds: readonly string[]): { schema: string; machineId: string; coordinates: string; units: { length: string; angle: string; gltfLength: string }; controller: { source: string; jointOrder: VisualBindingSlot[] }; calibration: { cOriginMm: number[]; tiltOffsetMm: number[]; physicalZero: number[] }; slotMap: Array; missing: string[] };
export class SimSession { contextId: string; sessionId: string; constructor(core: unknown); accept(message: unknown): unknown; }
export class XyzacSession extends SimSession { authority(): Record; }
export function handleWorkerMessage(session: unknown, input: unknown): Promise;
diff --git a/web/react/src/machine-case-program-manifest.json b/web/react/src/machine-case-program-manifest.json
new file mode 100644
index 0000000..c29e7c4
--- /dev/null
+++ b/web/react/src/machine-case-program-manifest.json
@@ -0,0 +1,123 @@
+{
+ "schema": "linuxcnc.web-five-axis-case-programs/1",
+ "description": "13 case-specific bounded dry-run programs are available through the matching Web/WASM motion and kinematics path.",
+ "cases": [
+ {
+ "id": "xyzac-trt",
+ "coordinates": "XYZAC",
+ "kinematics": "xyzac-trt-kins",
+ "label": "XYZAC TRT 五轴转台倾转",
+ "webStatus": "supported",
+ "note": "A 绕 X、C 绕 Z;Web LinuxCNC WASM 原生可运行。 Web WASM 通用配置和逆运动学采样已接入。",
+ "path": "machine-cases/xyzac-trt.ngc"
+ },
+ {
+ "id": "xyzbc-trt",
+ "coordinates": "XYZBC",
+ "kinematics": "xyzbc-trt-kins",
+ "label": "XYZBC TRT 五轴转台倾转",
+ "webStatus": "supported",
+ "note": "B 绕 Y、C 绕 Z;Web LinuxCNC WASM 原生可运行。 Web WASM 通用配置和逆运动学采样已接入。",
+ "path": "machine-cases/xyzbc-trt.ngc"
+ },
+ {
+ "id": "xyzab-tdr",
+ "coordinates": "XYZAB",
+ "kinematics": "xyzab_tdr_kins",
+ "label": "XYZAB 双转台 TDR",
+ "webStatus": "supported",
+ "note": "LinuxCNC xyzab_tdr_kins;Web 通用 WASM 运动学已接入。 Web WASM 通用配置和逆运动学采样已接入。",
+ "path": "machine-cases/xyzab-tdr.ngc"
+ },
+ {
+ "id": "bridge-mill-5axiskins",
+ "coordinates": "XYZBCWY",
+ "kinematics": "5axiskins",
+ "label": "Bridge Mill / XYZBCWY",
+ "webStatus": "supported",
+ "note": "W 沿刀具方向;配置含重复 Y 关节。 Web WASM 通用配置和逆运动学采样已接入。",
+ "path": "machine-cases/bridge-mill-5axiskins.ngc"
+ },
+ {
+ "id": "max5-maxkins",
+ "coordinates": "XYZBC",
+ "kinematics": "maxkins",
+ "label": "MAX5 / maxkins",
+ "webStatus": "supported",
+ "note": "B/C 非恒等旋转映射;不是 TRT 替代。 Web WASM 通用配置和逆运动学采样已接入。",
+ "path": "machine-cases/max5-maxkins.ngc"
+ },
+ {
+ "id": "hexapod-genhexkins",
+ "coordinates": "XYZABC",
+ "kinematics": "genhexkins",
+ "label": "Hexapod / genhexkins",
+ "webStatus": "supported",
+ "note": "六支链并联平台;XYZABC 六自由度。 Web WASM 通用配置和逆运动学采样已接入。",
+ "path": "machine-cases/hexapod-genhexkins.ngc"
+ },
+ {
+ "id": "pentapod-pentakins",
+ "coordinates": "XYZAB",
+ "kinematics": "pentakins",
+ "label": "Pentapod / pentakins",
+ "webStatus": "supported",
+ "note": "五支链并联平台;XYZAB 五轴。 Web WASM 通用配置和逆运动学采样已接入。",
+ "path": "machine-cases/pentapod-pentakins.ngc"
+ },
+ {
+ "id": "puma-pumakins",
+ "coordinates": "XYZABC",
+ "kinematics": "pumakins",
+ "label": "PUMA / pumakins",
+ "webStatus": "supported",
+ "note": "PUMA 六关节串联机器人。 Web WASM 通用配置和逆运动学采样已接入。",
+ "path": "machine-cases/puma-pumakins.ngc"
+ },
+ {
+ "id": "puma-genserkins",
+ "coordinates": "XYZABC",
+ "kinematics": "genserkins",
+ "label": "PUMA 560 / genserkins",
+ "webStatus": "supported",
+ "note": "DH 参数驱动的六轴串联机器人。 Web WASM 通用配置和逆运动学采样已接入。",
+ "path": "machine-cases/puma-genserkins.ngc"
+ },
+ {
+ "id": "trivkins-xyzab",
+ "coordinates": "XYZAB",
+ "kinematics": "trivkins",
+ "label": "trivkins XYZAB 映射基线",
+ "webStatus": "supported",
+ "note": "五轴一一映射基线;不提供 RTCP。 Web WASM 通用配置和逆运动学采样已接入。",
+ "path": "machine-cases/trivkins-xyzab.ngc"
+ },
+ {
+ "id": "trivkins-xyzcw",
+ "coordinates": "XYZCW",
+ "kinematics": "trivkins",
+ "label": "trivkins XYZCW 映射基线",
+ "webStatus": "supported",
+ "note": "C/W 轴字母映射基线;不提供 RTCP。 Web WASM 通用配置和逆运动学采样已接入。",
+ "path": "machine-cases/trivkins-xyzcw.ngc"
+ },
+ {
+ "id": "trivkins-xyzya",
+ "coordinates": "XYZYA",
+ "kinematics": "trivkins",
+ "label": "trivkins XYZYA 双 Y 映射基线",
+ "webStatus": "supported",
+ "note": "Y 轴重复映射与旋转 A 基线;不提供 RTCP。 Web WASM 通用配置和逆运动学采样已接入。",
+ "path": "machine-cases/trivkins-xyzya.ngc"
+ },
+ {
+ "id": "scorbot-kins",
+ "coordinates": "XYZAB",
+ "kinematics": "scorbot-kins",
+ "label": "SCORBOT / scorbot-kins",
+ "webStatus": "supported",
+ "note": "五关节串联机器人;不是五轴铣床 TRT。 Web WASM 通用配置和逆运动学采样已接入。",
+ "path": "machine-cases/scorbot-kins.ngc"
+ }
+ ]
+}
diff --git a/web/react/src/machine-joints.ts b/web/react/src/machine-joints.ts
index 5506e5f..cbc5747 100644
--- a/web/react/src/machine-joints.ts
+++ b/web/react/src/machine-joints.ts
@@ -5,6 +5,8 @@ export const CNC_AXES = ["X", "Y", "Z", "A", "B", "C", "U", "V", "W"] as const;
export type CncAxis = typeof CNC_AXES[number];
export type JointDof = { axis: CncAxis; direction: "x" | "y" | "z" | "custom"; sign: 1 | -1; vector?: Vec3; frame?: "parent" | "local" };
export const isFiveAxisCoordinates = (value: string) => typeof value === "string" && value.length === 5 && new Set(value).size === 5 && [...value].every((axis) => CNC_AXES.includes(axis as CncAxis));
+// Slot-based coordinates (v2): 1..9 letters, duplicates allowed (e.g. XYZBCWY, XYZYA).
+export const isCncCoordinateString = (value: unknown) => typeof value === "string" && value.length >= 1 && value.length <= 9 && /^[XYZABCUVW]+$/.test(value);
export const isTrtCoordinates = (value: string): value is "XYZAC" | "XYZBC" => value === "XYZAC" || value === "XYZBC";
export type AxisValues = Partial>;
export type JointZero = { positionMm: Vec3; quaternion: [number, number, number, number]; scale: Vec3 };
@@ -23,24 +25,58 @@ export type JointModel = {
axisMap: Partial>;
nodes: JointRecord[];
};
+// v2: joints are identified by LinuxCNC joint number (slot), not by axis letter.
+// An axis letter is an optional attribute of a slot, so duplicate axes (bridge
+// mill's second Y), kinematics-internal joints (hexapod struts have no letter)
+// and passive slots (max5 joint 5) all fit the same contract.
+export type JointSlotKind = "prismatic" | "revolute" | "strut";
+export type JointSlot = {
+ index: number;
+ key: string;
+ axis: CncAxis | null;
+ kind: JointSlotKind;
+ unit: "mm" | "deg";
+ source: "kinematics" | "extra" | "duplicate" | "passive";
+ drive?: string;
+};
+export type JointDofV2 = { axis: CncAxis | null; slot: number; direction: "x" | "y" | "z" | "custom"; sign: 1 | -1; vector?: Vec3; frame?: "parent" | "local" };
+export type JointRecordV2 = {
+ id: string; parentId: string | null; name: string; role: string;
+ source: { file: string; hash: string; path: string };
+ zero: JointZero;
+ joint: (JointDofV2 & { dof: 1; type: "prismatic" | "revolute" | "strut"; unit: "mm" | "deg"; frame: "parent" | "local" }) | null;
+};
+export type JointModelV2 = {
+ schema: "linuxcnc.joint-model/2";
+ machineId: string;
+ coordinates: string;
+ units: { length: "mm"; angle: "deg"; gltfLength: "m" };
+ controller: { source: "linuxcnc-wasm.visual.joints"; jointOrder: JointSlot[] };
+ calibration: { cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: number[] };
+ slotMap: Array;
+ nodes: JointRecordV2[];
+};
export const JOINT_MODEL_SCHEMA = "linuxcnc.joint-model/1";
+export const JOINT_MODEL_SCHEMA_V2 = "linuxcnc.joint-model/2";
export const isLinear = (axis: CncAxis) => "XYZUVW".includes(axis);
export const defaultDof = (axis: CncAxis): JointDof => ({ axis, direction: ({ X: "x", Y: "y", Z: "z", A: "x", B: "y", C: "z", U: "x", V: "y", W: "z" } as const)[axis], sign: 1 });
-export const jointFrame = (dof: JointDof) => dof.frame ?? "parent";
+export const jointFrame = (dof: JointDof | JointDofV2) => dof.frame ?? "parent";
export const validJointDof = (dof: JointDof) => Boolean(dof && CNC_AXES.includes(dof.axis) && ["x", "y", "z", "custom"].includes(dof.direction) && [1, -1].includes(dof.sign) && ["parent", "local"].includes(jointFrame(dof)) && (dof.direction !== "custom" || (Array.isArray(dof.vector) && dof.vector.length === 3 && dof.vector.every(Number.isFinite) && Math.hypot(...dof.vector) > 1e-9)));
-export const jointDirection = (dof: JointDof) => dof.direction === "custom" ? new THREE.Vector3(...dof.vector!).normalize() : new THREE.Vector3(dof.direction === "x" ? 1 : 0, dof.direction === "y" ? 1 : 0, dof.direction === "z" ? 1 : 0);
+export const validJointDofV2 = (dof: JointDofV2, slotCount: number) => Boolean(dof && Number.isInteger(dof.slot) && dof.slot >= 0 && dof.slot < slotCount && (dof.axis === null || CNC_AXES.includes(dof.axis)) && ["x", "y", "z", "custom"].includes(dof.direction) && [1, -1].includes(dof.sign) && ["parent", "local"].includes(jointFrame(dof)) && (dof.direction !== "custom" || (Array.isArray(dof.vector) && dof.vector.length === 3 && dof.vector.every(Number.isFinite) && Math.hypot(...dof.vector) > 1e-9)));
+export const jointDirection = (dof: JointDof | JointDofV2) => dof.direction === "custom" ? new THREE.Vector3(...dof.vector!).normalize() : new THREE.Vector3(dof.direction === "x" ? 1 : 0, dof.direction === "y" ? 1 : 0, dof.direction === "z" ? 1 : 0);
+export const jointIsLinear = (dof: JointDof | JointDofV2) => (dof as JointDofV2).slot !== undefined ? (dof as JointDofV2).axis === null || isLinear((dof as JointDofV2).axis as CncAxis) : isLinear((dof as JointDof).axis);
export const jointZero = (node: THREE.Object3D): JointZero => ({ positionMm: node.position.toArray().map((v) => v * 1000) as Vec3, quaternion: node.quaternion.toArray(), scale: node.scale.toArray() });
// Absolute evaluation from a stored zero pose: no incremental accumulation.
// Parent-frame motion remains the default; explicit GLB metadata may use local axes.
-export function driveJoint(node: THREE.Object3D, zero: JointZero, dof: JointDof | null, value: number, physicalZero = 0) {
- if (dof && (!validJointDof(dof) || ![value, physicalZero].every(Number.isFinite))) throw new Error(`轴 ${dof.axis} 方向或位置无效`);
+export function driveJoint(node: THREE.Object3D, zero: JointZero, dof: JointDof | JointDofV2 | null, value: number, physicalZero = 0) {
+ if (dof && ![value, physicalZero].every(Number.isFinite)) throw new Error(`轴 ${(dof as JointDofV2).slot !== undefined ? `J${(dof as JointDofV2).slot}` : (dof as JointDof).axis} 方向或位置无效`);
node.position.fromArray(zero.positionMm).multiplyScalar(.001);
node.quaternion.fromArray(zero.quaternion); node.scale.fromArray(zero.scale);
if (dof) {
const direction = jointDirection(dof);
const q = dof.sign * (value - physicalZero);
- if (isLinear(dof.axis)) node.position.addScaledVector(jointFrame(dof) === "local" ? direction.applyQuaternion(node.quaternion) : direction, q / 1000);
+ if (jointIsLinear(dof)) node.position.addScaledVector(jointFrame(dof) === "local" ? direction.applyQuaternion(node.quaternion) : direction, q / 1000);
else {
const rotation = new THREE.Quaternion().setFromAxisAngle(direction, THREE.MathUtils.degToRad(q));
if (jointFrame(dof) === "local") node.quaternion.multiply(rotation); else node.quaternion.premultiply(rotation);
@@ -49,9 +85,11 @@ export function driveJoint(node: THREE.Object3D, zero: JointZero, dof: JointDof
node.updateMatrix();
}
-export function validateJointModel(model: JointModel, complete = true) {
+export function validateJointModel(model: JointModel | JointModelV2, complete = true): JointModel | JointModelV2 {
const fail = (message: string): never => { throw new Error(`关节配置无效:${message}`); };
- if (!model || model.schema !== JOINT_MODEL_SCHEMA || !isFiveAxisCoordinates(model.coordinates)) fail("需要五个不重复的数控轴");
+ if (!model || (model.schema !== JOINT_MODEL_SCHEMA && model.schema !== JOINT_MODEL_SCHEMA_V2)) fail("schema 必须是 linuxcnc.joint-model/1 或 /2");
+ if (model.schema === JOINT_MODEL_SCHEMA_V2) return validateJointModelV2(model, complete, fail);
+ if (!isFiveAxisCoordinates(model.coordinates)) fail("需要五个不重复的数控轴");
if (model.units?.length !== "mm" || model.units?.angle !== "deg" || model.units?.gltfLength !== "m" || model.controller?.source !== "linuxcnc-wasm.visual.joints" || model.controller.axisOrder.join("") !== model.coordinates) fail("控制器来源、单位或轴顺序错误");
if (!Array.isArray(model.nodes) || !model.nodes.length || model.nodes.length > 12000) fail("节点数量错误");
const finite = (v: unknown, count: number) => Array.isArray(v) && v.length === count && v.every(Number.isFinite);
@@ -78,20 +116,96 @@ export function validateJointModel(model: JointModel, complete = true) {
return model;
}
-export function bindJointModel(scene: THREE.Object3D, model: JointModel) {
+function validateJointModelV2(model: JointModelV2, complete: boolean, fail: (message: string) => never): JointModelV2 {
+ if (typeof model.machineId !== "string" || !model.machineId.trim()) fail("machineId 缺失");
+ if (!isCncCoordinateString(model.coordinates)) fail("coordinates 必须是 1-9 个 XYZABCUVW 字母");
+ if (model.units?.length !== "mm" || model.units?.angle !== "deg" || model.units?.gltfLength !== "m" || model.controller?.source !== "linuxcnc-wasm.visual.joints") fail("控制器来源或单位错误");
+ const order = model.controller?.jointOrder;
+ if (!Array.isArray(order) || order.length < 1 || order.length > 9) fail("jointOrder 数量错误");
+ const slots = new Map(), keys = new Set();
+ for (const [position, slot] of order.entries()) {
+ if (!slot || slot.index !== position) fail(`jointOrder 槽位 ${position} 索引不连续`);
+ if (typeof slot.key !== "string" || !slot.key.trim() || keys.has(slot.key)) fail(`槽位 ${position} key 缺失或重复`);
+ keys.add(slot.key);
+ if (!["prismatic", "revolute", "strut"].includes(slot.kind)) fail(`槽位 ${slot.key} 类型错误`);
+ const wantsMm = slot.kind !== "revolute";
+ if (slot.unit !== (wantsMm ? "mm" : "deg")) fail(`槽位 ${slot.key} 单位与类型不符`);
+ if (slot.axis !== null && !CNC_AXES.includes(slot.axis)) fail(`槽位 ${slot.key} 轴字母错误`);
+ if (!["kinematics", "extra", "duplicate", "passive"].includes(slot.source)) fail(`槽位 ${slot.key} 来源错误`);
+ slots.set(position, slot);
+ }
+ const finite = (v: unknown, count: number) => Array.isArray(v) && v.length === count && v.every(Number.isFinite);
+ if (!finite(model.calibration?.cOriginMm, 3) || !finite(model.calibration?.tiltOffsetMm, 3) || !finite(model.calibration?.physicalZero, order.length)) fail("标定参数错误");
+ if (!Array.isArray(model.nodes) || !model.nodes.length || model.nodes.length > 12000) fail("节点数量错误");
+ const nodes = new Map(), bound = new Map();
+ for (const record of model.nodes) {
+ if (!record.id || nodes.has(record.id)) fail("节点 ID 缺失或重复");
+ nodes.set(record.id, record);
+ if (!finite(record.zero?.positionMm, 3) || !finite(record.zero?.quaternion, 4) || !finite(record.zero?.scale, 3) || record.zero.scale.some((v) => v <= 0) || Math.abs(record.zero.quaternion.reduce((s, v) => s + v * v, 0) - 1) > 1e-5) fail(`${record.name} 零姿态错误`);
+ const joint = record.joint;
+ if (!joint) continue;
+ if (joint.dof !== 1 || !validJointDofV2(joint, order.length) || !["parent", "local"].includes(joint.frame)) fail(`${record.name} 单自由度错误`);
+ const slot = slots.get(joint.slot)!;
+ if (joint.type !== slot.kind || joint.unit !== slot.unit) fail(`${record.name} 与槽位 ${slot.key} 的类型/单位不符`);
+ if (joint.axis !== null && slot.axis !== null && joint.axis !== slot.axis) fail(`${record.name} 轴字母与槽位 ${slot.key} 不符`);
+ if (bound.has(joint.slot)) fail(`槽位 ${slot.key} 重复绑定`);
+ bound.set(joint.slot, record.id);
+ }
+ for (const record of model.nodes) {
+ const seen = new Set([record.id]); let parent = record.parentId;
+ while (parent !== null) { if (seen.has(parent) || !nodes.has(parent)) fail("父级不存在或层级循环"); seen.add(parent); parent = nodes.get(parent)!.parentId; }
+ }
+ if (!Array.isArray(model.slotMap) || model.slotMap.length !== order.length) fail("slotMap 长度与 jointOrder 不符");
+ for (const [index, id] of model.slotMap.entries()) {
+ if (id === null || id === undefined) { if (bound.has(index)) fail(`槽位 ${slots.get(index)!.key} 索引与绑定不一致`); continue; }
+ if (bound.get(index) !== id) fail(`槽位 ${slots.get(index)!.key} 索引与绑定不一致`);
+ }
+ if (complete) for (const slot of order) if (slot.source !== "passive" && !bound.has(slot.index)) fail(`请完整绑定槽位 ${slot.key}`);
+ return model;
+}
+
+export function bindJointModel(scene: THREE.Object3D, model: JointModel | JointModelV2) {
validateJointModel(model);
const nodes = new Map();
scene.traverse((node) => { const id = node.userData.linuxcncNodeId; if (id) { if (nodes.has(id)) throw new Error("关节模型节点 ID 重复"); nodes.set(id, node); } });
- const bindings = model.nodes.map((record) => {
+ const records = model.nodes as Array;
+ const bindings = records.map((record) => {
const node = nodes.get(record.id);
if (!node || (node.parent?.userData.linuxcncNodeId ?? null) !== record.parentId) throw new Error(`关节节点 ${record.name} 与 JSON 层级不一致`);
return { node, record };
});
+ if (model.schema === JOINT_MODEL_SCHEMA_V2) {
+ const order = model.controller.jointOrder;
+ const slotPhysicalZero = model.calibration.physicalZero;
+ return {
+ update(values: AxisValues | number[]) {
+ const list = Array.isArray(values) ? values : order.map((slot) => slot.axis ? (values as AxisValues)[slot.axis] : undefined);
+ for (const { record } of bindings) {
+ const joint = record.joint as JointRecordV2["joint"];
+ if (joint && !Number.isFinite(list[joint.slot]) ) throw new Error(`缺少 J${joint.slot} 实时位置`);
+ if (joint && !Number.isFinite(slotPhysicalZero[joint.slot])) throw new Error(`缺少 J${joint.slot} 物理零点或数值无效`);
+ }
+ for (const { node, record } of bindings) {
+ const joint = record.joint as JointRecordV2["joint"];
+ driveJoint(node, record.zero, joint, joint ? Number(list[joint.slot]) : 0, joint ? Number(slotPhysicalZero[joint.slot] ?? 0) : 0);
+ }
+ },
+ // Keyed and ordered by joint slot, not by GLB traversal order.
+ state: () => Object.fromEntries(bindings
+ .filter(({ record }) => record.joint)
+ .sort((a, b) => (a.record.joint as NonNullable).slot - (b.record.joint as NonNullable).slot)
+ .map(({ node, record }) => {
+ const joint = record.joint as NonNullable;
+ const slot = order[joint.slot];
+ return [slot.key, { index: joint.slot, key: slot.key, axis: slot.axis, id: record.id, localMm: node.position.toArray().map((v) => v * 1000), quaternion: node.quaternion.toArray(), world: node.getWorldPosition(new THREE.Vector3()).toArray() }];
+ })),
+ };
+ }
return {
- update(values: AxisValues, physicalZero = model.calibration.physicalZero) {
- for (const { record } of bindings) if (record.joint && (!Number.isFinite(values[record.joint.axis]) || !Number.isFinite(physicalZero[record.joint.axis.toLowerCase()] ?? 0))) throw new Error(`缺少 ${record.joint.axis} 轴实时位置或物理零点无效`);
- for (const { node, record } of bindings) driveJoint(node, record.zero, record.joint, record.joint ? values[record.joint.axis]! : 0, record.joint ? Number(physicalZero[record.joint.axis.toLowerCase()] ?? 0) : 0);
+ update(values: AxisValues, physicalZero: Record = model.calibration.physicalZero) {
+ for (const { record } of bindings) if (record.joint && (!Number.isFinite(values[(record.joint as JointDof).axis]) || !Number.isFinite(physicalZero[(record.joint as JointDof).axis.toLowerCase()] ?? 0))) throw new Error(`缺少 ${(record.joint as JointDof).axis} 轴实时位置或物理零点无效`);
+ for (const { node, record } of bindings) driveJoint(node, record.zero, record.joint as JointDof | null, record.joint ? values[(record.joint as JointDof).axis]! : 0, record.joint ? Number(physicalZero[(record.joint as JointDof).axis.toLowerCase()] ?? 0) : 0);
},
- state: () => Object.fromEntries(bindings.filter(({ record }) => record.joint).map(({ node, record }) => [record.joint!.axis, { id: record.id, localMm: node.position.toArray().map((v) => v * 1000), quaternion: node.quaternion.toArray(), world: node.getWorldPosition(new THREE.Vector3()).toArray() }])),
+ state: () => Object.fromEntries(bindings.filter(({ record }) => record.joint).map(({ node, record }) => [(record.joint as JointDof).axis, { id: record.id, localMm: node.position.toArray().map((v) => v * 1000), quaternion: node.quaternion.toArray(), world: node.getWorldPosition(new THREE.Vector3()).toArray() }])),
};
}
diff --git a/web/react/src/machine-manager-config.ts b/web/react/src/machine-manager-config.ts
index 39d2371..dd675d5 100644
--- a/web/react/src/machine-manager-config.ts
+++ b/web/react/src/machine-manager-config.ts
@@ -1,4 +1,5 @@
import settings from "../../workspace/machine-manager-settings.json";
+import specs from "../../workspace/machine-kinematics-specs.json";
import { isFiveAxisCoordinates, type CncAxis } from "./machine-joints";
import type { Vec3 } from "./machine-urdf";
@@ -30,3 +31,10 @@ for (const profile of settings.profiles) {
if (!limit || limit.length !== 3 || !limit.every(Number.isFinite) || limit[0] >= limit[1] || limit[2] <= 0) throw new Error(`管理器 ${axis} 行程配置无效`);
}
}
+
+// Per-case joint slots and limits, derived from the LinuxCNC INIs by
+// web/workspace/machine-kinematics-specs.json. Null means the source config
+// declares no limit for that joint; the manager must not invent one.
+export type ManagerSpec = { machineId: string; coordinates: string; jointCount: number; jointOrder: string[]; limitsByJoint: Array<[number, number] | null>; parameters: Record };
+export const MANAGER_SPECS: ManagerSpec[] = specs.cases as unknown as ManagerSpec[];
+export const managerSpec = (machineId: string) => MANAGER_SPECS.find((spec) => spec.machineId === machineId) ?? null;
diff --git a/web/react/src/machine-manager.ts b/web/react/src/machine-manager.ts
index aaf7002..573bf4d 100644
--- a/web/react/src/machine-manager.ts
+++ b/web/react/src/machine-manager.ts
@@ -4,7 +4,7 @@ import { parseMachineUrdf, type MachineStructure, type Vec3 } from "./machine-ur
import { bindJointModel, defaultDof, jointZero, validateJointModel, type CncAxis, type JointDof, type JointModel, type JointRecord } from "./machine-joints";
import { decodeEditorDocument, encodeEditorDocument, partSettings, type DocumentInfo } from "./machine-document";
import type { ImportedMachine } from "./imported-machine";
-import { MANAGER_SETTINGS, managerProfile, roleAxis } from "./machine-manager-config";
+import { MANAGER_SETTINGS, managerProfile, managerSpec, roleAxis } from "./machine-manager-config";
export type MachinePartRole = "base" | "x" | "y" | "z" | "tool" | "tilt" | "c" | "work";
export type MachinePartSource = { id: string; filename: string; hash: string; scene: THREE.Group; restoredJoints?: Record; restoredRoles?: Record; restoredModel?: JointModel; restoredParts?: MachinePartDraft[]; restoredInfo?: DocumentInfo };
@@ -222,9 +222,22 @@ function machineUrdf(coordinates: string, cOriginMm: Vec3, tiltOffsetMm: Vec3) {
].join("\n")}\n\n`;
}
-export async function buildManagedMachine(input: { coordinates: string; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero; documentInfo?: DocumentInfo }): Promise {
+// Generic profile: one link per bound slot plus base/tool/work. No chain
+// assertions and no TRT offset algebra: each joint carries its own origin and
+// axis from the specification, so robots, parallel platforms and duplicate
+// gantry axes fit without a second code path.
+function genericMachineUrdf(machineId: string, coordinates: string, slotKeys: string[], limitsBySlot: Array<[number, number] | null>) {
+ const links = ["base", ...slotKeys.map((key, index) => `slot_${index}_${key.toLowerCase().replace(/[^a-z0-9]+/g, "_")}`), "tool"];
+ const joints = slotKeys.map((key, index) => {
+ const limit = limitsBySlot[index] ?? [-360, 360];
+ const rotary = "ABCUVW".includes(key.replace(/#\d+$/, "").toUpperCase()[0] ?? "");
+ return ` \n \n \n \n `;
+ });
+ return `\n\n \n${[...links.map((link) => ` `), ...joints].join("\n")}\n\n`;
+}
+
+export async function buildManagedMachine(input: { machineId?: string; coordinates: string; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero; documentInfo?: DocumentInfo }): Promise {
if (input.parts.some((part) => part.nodeJoints !== undefined)) return buildJointManagedMachine(input);
- if (!managerProfile(input.coordinates)) throw new Error("当前运行时尚未为该坐标提供通用运动学适配器;结构和关节配置可以继续编辑并导出");
const byRole = validateMachineAssembly(input.coordinates, input.parts);
for (const [axis, value] of Object.entries(input.physicalZero)) if (!Number.isFinite(value)) throw new Error(`${axis.toUpperCase()} 物理零点无效`);
const urdf = machineUrdf(input.coordinates, input.cOriginMm, input.tiltOffsetMm);
@@ -261,7 +274,7 @@ export function draftNodeDof(part: MachinePartDraft, path: string, coordinates:
return axis ? defaultDof(axis as CncAxis) : null;
}
-type ManagedInput = { documentInfo?: DocumentInfo; coordinates: string; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
+type ManagedInput = { machineId?: string; documentInfo?: DocumentInfo; coordinates: string; parts: MachinePartDraft[]; cOriginMm: Vec3; tiltOffsetMm: Vec3; physicalZero: PhysicalZero };
export function makeJointAssembly(input: ManagedInput, deepClone = false) {
if (!input.parts.length) throw new Error("请先导入机床模型");
@@ -300,9 +313,18 @@ export function makeJointAssembly(input: ManagedInput, deepClone = false) {
async function buildJointManagedMachine(input: ManagedInput): Promise {
// Validate using shallow transform clones before allocating deep runtime resources.
validateJointModel(makeJointAssembly(input).model);
- if (!managerProfile(input.coordinates)) throw new Error("当前运行时尚未为该坐标提供通用运动学适配器;结构和关节配置可以继续编辑并导出");
- const urdf = machineUrdf(input.coordinates, input.cOriginMm, input.tiltOffsetMm), structure = parseMachineUrdf(urdf);
const { assembly, model } = makeJointAssembly(input, true);
+ const profile = managerProfile(input.coordinates);
+ // Slot order comes from the joint model itself; limits are looked up per
+ // machine id and stay null when the INI declares none.
+ const slotKeys = [..."XYZABCUVW"].length ? [...new Set(input.coordinates)].map((axis, index) => axis) : [];
+ const spec = managerSpec(input.machineId ?? "");
+ const urdf = profile
+ ? machineUrdf(input.coordinates, input.cOriginMm, input.tiltOffsetMm)
+ : genericMachineUrdf(input.machineId ?? input.coordinates, input.coordinates,
+ slotKeys.length === 0 ? [] : slotKeys,
+ (spec?.limitsByJoint ?? slotKeys.map(() => null)));
+ const structure = parseMachineUrdf(urdf);
const scene = new THREE.Group(); scene.name = `Managed_${input.coordinates}`;
scene.userData.linuxcncMachine = { schema: "linuxcnc.machine/1", urdf };
scene.userData.linuxcncJointModel = model;
diff --git a/web/react/src/machine-urdf.ts b/web/react/src/machine-urdf.ts
index 0ebf481..dc62992 100644
--- a/web/react/src/machine-urdf.ts
+++ b/web/react/src/machine-urdf.ts
@@ -27,7 +27,11 @@ export function parseMachineUrdf(urdf: string): MachineStructure {
if (robot.tagName !== "robot" || robot.namespaceURI) fail("需要 URDF robot 根元素");
const spec = one(robot, "machine", CNC_NS);
const profile = spec.getAttribute("profile") ?? "";
- if (!["trt-xyz-head/1", "trt-xyz-gantry/1"].includes(profile)) fail("不支持的运动学 profile");
+ // `generic-wasm/1` carries no fixed chain: link/joint counts and the parent
+ // order come from the managed spec, so robots, parallel platforms and
+ // duplicate-axis gantries can be described without a second code path.
+ if (!["trt-xyz-head/1", "trt-xyz-gantry/1", "generic-wasm/1"].includes(profile)) fail("不支持的运动学 profile");
+ if (profile === "generic-wasm/1") return parseGenericMachineUrdf(urdf, robot, spec);
const gantry = profile === "trt-xyz-gantry/1";
const base = name(spec.getAttribute("base")), tool = name(spec.getAttribute("tool")), work = name(spec.getAttribute("work"));
const links: string[] = [], joints: MachineJoint[] = [];
@@ -86,6 +90,44 @@ export function parseMachineUrdf(urdf: string): MachineStructure {
return { name: name(robot.getAttribute("name")), profile, coordinates, base, tool, work, links, joints, offsets, urdf };
}
+function parseGenericMachineUrdf(urdf: string, robot: Element, spec: Element): MachineStructure {
+ const base = name(spec.getAttribute("base")), tool = name(spec.getAttribute("tool")), work = spec.getAttribute("work") ? name(spec.getAttribute("work")) : "";
+ const declared = (spec.getAttribute("coordinates") ?? "").replace(/\s+/g, "").toUpperCase();
+ if (!/^[XYZABCUVW]{1,9}$/.test(declared)) fail("generic 机型的 coordinates 无效");
+ const links: string[] = [], joints: MachineJoint[] = [];
+ for (const el of [...robot.children]) {
+ if (el === spec) continue;
+ if (el.namespaceURI || !["link", "joint"].includes(el.tagName)) fail(`不支持 ${el.tagName}`);
+ if (el.tagName === "link") { links.push(name(el.getAttribute("name"))); continue; }
+ const type = el.getAttribute("type") ?? "";
+ if (!["fixed", "prismatic", "revolute"].includes(type)) fail(`不支持关节类型 ${type}`);
+ const origin = one(el, "origin");
+ if (!close(vector(origin.getAttribute("rpy")), [0, 0, 0])) fail("generic 机型不支持旋转的关节零位 rpy");
+ const joint: MachineJoint = { name: name(el.getAttribute("name")), type, parent: name(one(el, "parent").getAttribute("link")), child: name(one(el, "child").getAttribute("link")), origin: vector(origin.getAttribute("xyz")), axis: [0, 0, 0], coordinate: "" };
+ if (type !== "fixed") {
+ joint.axis = vector(one(el, "axis").getAttribute("xyz"));
+ const cncAxis = [...el.children].find((c) => c.namespaceURI === CNC_NS && c.localName === "axis");
+ joint.coordinate = cncAxis?.getAttribute("coordinate") ?? "";
+ const slot = cncAxis?.getAttribute("joint") ?? "";
+ if (slot) (joint as MachineJoint & { joint?: string }).joint = slot;
+ const limit = one(el, "limit");
+ joint.limit = { lower: scalar(limit.getAttribute("lower")), upper: scalar(limit.getAttribute("upper")), velocity: scalar(limit.getAttribute("velocity")), effort: scalar(limit.getAttribute("effort")) };
+ if (joint.limit.lower >= joint.limit.upper || joint.limit.velocity <= 0 || joint.limit.effort <= 0) fail("关节限位/速度无效");
+ }
+ joints.push(joint);
+ }
+ if (new Set(links).size !== links.length || new Set(joints.map((j) => j.name)).size !== joints.length) fail("link/joint 名称重复");
+ if (!links.includes(base)) fail("base 未在 link 中声明");
+ if (tool && !links.includes(tool)) fail("tool 未在 link 中声明");
+ if (new Set(joints.map((j) => j.child)).size !== joints.length || joints.some((j) => !links.includes(j.parent) || !links.includes(j.child) || j.child === base)) fail("重复父节点、未知 link 或根节点错误");
+ const visited = new Set();
+ const visit = (link: string) => { if (visited.has(link)) fail("结构存在环"); visited.add(link); for (const j of joints.filter((j) => j.parent === link)) visit(j.child); };
+ visit(base); if (visited.size !== links.length) fail("存在断开的结构或环");
+ const active = joints.filter((j) => j.type !== "fixed");
+ if (active.length !== declared.length) fail(`运动关节数 ${active.length} 与坐标长度 ${declared.length} 不符`);
+ return { name: name(robot.getAttribute("name")), profile: "generic-wasm/1", coordinates: declared, base, tool, work, links, joints, offsets: {}, urdf };
+}
+
export function inspectMachineGlb(bytes: ArrayBuffer): { json: any; structure: MachineStructure } {
if (bytes.byteLength < 20 || bytes.byteLength > MAX_MACHINE_BYTES) fail("GLB 大小必须在 20 字节至 64 MiB 之间");
const view = new DataView(bytes);
diff --git a/web/react/src/rtcp-program-manifest.json b/web/react/src/rtcp-program-manifest.json
index 96cf575..0957a64 100644
--- a/web/react/src/rtcp-program-manifest.json
+++ b/web/react/src/rtcp-program-manifest.json
@@ -1,14 +1,43 @@
{
"schema": "linuxcnc.rtcp-program-library/1",
"source": "LinuxCNC 2.10 source tree",
- "total": 32,
+ "total": 45,
"groups": [
- { "id": "trt-axis", "label": "TRT 旋转倾斜台 XYZAC/XYZBC", "count": 16 },
- { "id": "trt-gmoccapy", "label": "gmoccapy TRT 示例", "count": 5 },
- { "id": "xyzab-tdr", "label": "XYZAB 双旋转台", "count": 3 },
- { "id": "bridgemill", "label": "Bridge Mill / 5axiskins", "count": 4 },
- { "id": "hexapod", "label": "Hexapod genhexkins RTCP", "count": 3 },
- { "id": "standalone", "label": "独立五轴动态刀长示例", "count": 1 }
+ {
+ "id": "trt-axis",
+ "label": "TRT 旋转倾斜台 XYZAC/XYZBC",
+ "count": 16
+ },
+ {
+ "id": "trt-gmoccapy",
+ "label": "gmoccapy TRT 示例",
+ "count": 5
+ },
+ {
+ "id": "xyzab-tdr",
+ "label": "XYZAB 双旋转台",
+ "count": 3
+ },
+ {
+ "id": "bridgemill",
+ "label": "Bridge Mill / 5axiskins",
+ "count": 4
+ },
+ {
+ "id": "hexapod",
+ "label": "Hexapod genhexkins RTCP",
+ "count": 3
+ },
+ {
+ "id": "standalone",
+ "label": "独立五轴动态刀长示例",
+ "count": 1
+ },
+ {
+ "id": "five-axis-case-tests",
+ "label": "13 个机型原生干运行测试",
+ "count": 13
+ }
],
"programs": [
{
@@ -16,9 +45,17 @@
"group": "trt-axis",
"label": "Boat XYZAC",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzac.ngc",
- "coordinates": ["XYZAC"],
+ "coordinates": [
+ "XYZAC"
+ ],
"role": "main",
- "features": ["M428", "G43", "G54", "XYZAC TCP", "CAM 点列"],
+ "features": [
+ "M428",
+ "G43",
+ "G54",
+ "XYZAC TCP",
+ "CAM 点列"
+ ],
"note": "XYZAC 连续五轴船体加工示例;程序内显式进入 TCP 模式。"
},
{
@@ -26,9 +63,17 @@
"group": "trt-axis",
"label": "Boat XYZBC",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/boat-xyzbc.ngc",
- "coordinates": ["XYZBC"],
+ "coordinates": [
+ "XYZBC"
+ ],
"role": "main",
- "features": ["M428", "G43", "G93/G94", "XYZBC TCP", "CAM 点列"],
+ "features": [
+ "M428",
+ "G43",
+ "G93/G94",
+ "XYZBC TCP",
+ "CAM 点列"
+ ],
"note": "XYZBC 连续五轴船体加工示例,包含逆时间进给。"
},
{
@@ -36,9 +81,16 @@
"group": "trt-axis",
"label": "Impeller 7BL XYZAC",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/impeller-7bl-xyzac.ngc",
- "coordinates": ["XYZAC"],
+ "coordinates": [
+ "XYZAC"
+ ],
"role": "main",
- "features": ["M428", "G93", "XYZAC TCP", "叶轮 CAM"],
+ "features": [
+ "M428",
+ "G93",
+ "XYZAC TCP",
+ "叶轮 CAM"
+ ],
"note": "高密度 XYZAC 叶轮轨迹,用于连续联动与逆时间进给测试。"
},
{
@@ -46,9 +98,16 @@
"group": "trt-axis",
"label": "XYZAC switchkins 四象限螺旋",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc",
- "coordinates": ["XYZAC"],
+ "coordinates": [
+ "XYZAC"
+ ],
"role": "main",
- "features": ["O-call", "M428/M429", "G2 螺旋", "四象限"],
+ "features": [
+ "O-call",
+ "M428/M429",
+ "G2 螺旋",
+ "四象限"
+ ],
"note": "调用 xyzac_switchkins_sub,在 identity 与 TCP 运动学之间切换。"
},
{
@@ -56,9 +115,15 @@
"group": "trt-axis",
"label": "XYZAC switchkins test 1",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_1.ngc",
- "coordinates": ["XYZAC"],
+ "coordinates": [
+ "XYZAC"
+ ],
"role": "main",
- "features": ["M428/M429", "坐标对照", "C 轴旋转"],
+ "features": [
+ "M428/M429",
+ "坐标对照",
+ "C 轴旋转"
+ ],
"note": "对比 identity/TCP 切换前后的关节与刀尖坐标。"
},
{
@@ -66,9 +131,15 @@
"group": "trt-axis",
"label": "XYZAC switchkins test 2",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_2.ngc",
- "coordinates": ["XYZAC"],
+ "coordinates": [
+ "XYZAC"
+ ],
"role": "main",
- "features": ["M428/M429", "G64", "多姿态轨迹"],
+ "features": [
+ "M428/M429",
+ "G64",
+ "多姿态轨迹"
+ ],
"note": "覆盖多组 A/C 姿态与 TCP/identity 切换。"
},
{
@@ -76,9 +147,15 @@
"group": "trt-axis",
"label": "XYZAC switchkins test 3",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins_test_3.ngc",
- "coordinates": ["XYZAC"],
+ "coordinates": [
+ "XYZAC"
+ ],
"role": "main",
- "features": ["M428/M429", "M1", "预期坐标检查"],
+ "features": [
+ "M428/M429",
+ "M1",
+ "预期坐标检查"
+ ],
"note": "带人工检查点和预期坐标的最小 switchkins 验证程序。"
},
{
@@ -86,9 +163,16 @@
"group": "trt-axis",
"label": "XYZBC switchkins 四象限螺旋",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzbc_switchkins.ngc",
- "coordinates": ["XYZBC"],
+ "coordinates": [
+ "XYZBC"
+ ],
"role": "main",
- "features": ["O-call", "M428/M429", "G2 螺旋", "四象限"],
+ "features": [
+ "O-call",
+ "M428/M429",
+ "G2 螺旋",
+ "四象限"
+ ],
"note": "调用 xyzbc_switchkins_sub,验证 B/C 型 TRT 的 TCP 切换。"
},
{
@@ -96,9 +180,17 @@
"group": "trt-axis",
"label": "TRT M428 TCP remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/428remap.ngc",
- "coordinates": ["XYZAC", "XYZBC"],
+ "coordinates": [
+ "XYZAC",
+ "XYZBC"
+ ],
"role": "support",
- "features": ["M68", "M66", "motion.switchkins-type", "TCP"],
+ "features": [
+ "M68",
+ "M66",
+ "motion.switchkins-type",
+ "TCP"
+ ],
"note": "支持文件:选择 TRT TCP 运动学并同步确认 HAL 切换结果。"
},
{
@@ -106,9 +198,16 @@
"group": "trt-axis",
"label": "TRT M429 identity remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/429remap.ngc",
- "coordinates": ["XYZAC", "XYZBC"],
+ "coordinates": [
+ "XYZAC",
+ "XYZBC"
+ ],
"role": "support",
- "features": ["M68", "M66", "identity kinematics"],
+ "features": [
+ "M68",
+ "M66",
+ "identity kinematics"
+ ],
"note": "支持文件:选择 identity/trivkins,用于安全定位和坐标建立。"
},
{
@@ -116,9 +215,16 @@
"group": "trt-axis",
"label": "TRT M430 userk remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/430remap.ngc",
- "coordinates": ["XYZAC", "XYZBC"],
+ "coordinates": [
+ "XYZAC",
+ "XYZBC"
+ ],
"role": "support",
- "features": ["M68", "M66", "user kinematics"],
+ "features": [
+ "M68",
+ "M66",
+ "user kinematics"
+ ],
"note": "支持文件:选择 userk 扩展运动学。"
},
{
@@ -126,9 +232,15 @@
"group": "trt-axis",
"label": "TRT centering 子程序",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/centering.ngc",
- "coordinates": ["XYZAC"],
+ "coordinates": [
+ "XYZAC"
+ ],
"role": "support",
- "features": ["HAL 旋转中心", "M428/M429", "小方形轨迹"],
+ "features": [
+ "HAL 旋转中心",
+ "M428/M429",
+ "小方形轨迹"
+ ],
"note": "支持子程序:检查旋转中心在 C 轴转动时是否保持固定。"
},
{
@@ -136,9 +248,15 @@
"group": "trt-axis",
"label": "TRT helix AC 子程序",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/helix_ac.ngc",
- "coordinates": ["XYZAC"],
+ "coordinates": [
+ "XYZAC"
+ ],
"role": "support",
- "features": ["M428/M429", "G2 螺旋", "A/C 姿态"],
+ "features": [
+ "M428/M429",
+ "G2 螺旋",
+ "A/C 姿态"
+ ],
"note": "支持子程序:XYZAC 姿态下的 TCP 螺旋轨迹。"
},
{
@@ -146,9 +264,15 @@
"group": "trt-axis",
"label": "TRT helix BC 子程序",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/helix_bc.ngc",
- "coordinates": ["XYZBC"],
+ "coordinates": [
+ "XYZBC"
+ ],
"role": "support",
- "features": ["M428/M429", "G2 螺旋", "B/C 姿态"],
+ "features": [
+ "M428/M429",
+ "G2 螺旋",
+ "B/C 姿态"
+ ],
"note": "支持子程序:XYZBC 姿态下的 TCP 螺旋轨迹。"
},
{
@@ -156,9 +280,15 @@
"group": "trt-axis",
"label": "XYZAC switchkins 主子程序",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/xyzac_switchkins_sub.ngc",
- "coordinates": ["XYZAC"],
+ "coordinates": [
+ "XYZAC"
+ ],
"role": "support",
- "features": ["O-sub", "四象限", "helix_ac"],
+ "features": [
+ "O-sub",
+ "四象限",
+ "helix_ac"
+ ],
"note": "支持子程序:参数化调用 helix_ac 完成四象限测试。"
},
{
@@ -166,20 +296,31 @@
"group": "trt-axis",
"label": "XYZBC switchkins 主子程序",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/remap_subs/xyzbc_switchkins_sub.ngc",
- "coordinates": ["XYZBC"],
+ "coordinates": [
+ "XYZBC"
+ ],
"role": "support",
- "features": ["O-sub", "四象限", "helix_bc"],
+ "features": [
+ "O-sub",
+ "四象限",
+ "helix_bc"
+ ],
"note": "支持子程序:参数化调用 helix_bc 完成四象限测试。"
},
-
{
"id": "gmoccapy-boat-xyzac",
"group": "trt-gmoccapy",
"label": "gmoccapy Boat XYZAC",
"path": "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/boat-xyzac.ngc",
- "coordinates": ["XYZAC"],
+ "coordinates": [
+ "XYZAC"
+ ],
"role": "main",
- "features": ["直接 TRT kinematics", "G43", "CAM 点列"],
+ "features": [
+ "直接 TRT kinematics",
+ "G43",
+ "CAM 点列"
+ ],
"note": "原配置直接加载 XYZAC TRT;在 Web switchkins 配置中运行前应选择 M428/TCP。"
},
{
@@ -187,9 +328,16 @@
"group": "trt-gmoccapy",
"label": "gmoccapy Boat XYZBC",
"path": "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/boat-xyzbc.ngc",
- "coordinates": ["XYZBC"],
+ "coordinates": [
+ "XYZBC"
+ ],
"role": "main",
- "features": ["直接 TRT kinematics", "G43", "G93/G94", "CAM 点列"],
+ "features": [
+ "直接 TRT kinematics",
+ "G43",
+ "G93/G94",
+ "CAM 点列"
+ ],
"note": "XYZBC 船体示例;Web switchkins 配置运行前应选择 M428/TCP。"
},
{
@@ -197,9 +345,15 @@
"group": "trt-gmoccapy",
"label": "gmoccapy Impeller 7BL XYZAC",
"path": "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/impeller-7bl-xyzac.ngc",
- "coordinates": ["XYZAC"],
+ "coordinates": [
+ "XYZAC"
+ ],
"role": "main",
- "features": ["直接 TRT kinematics", "G93", "叶轮 CAM"],
+ "features": [
+ "直接 TRT kinematics",
+ "G93",
+ "叶轮 CAM"
+ ],
"note": "无显式 M428;Web switchkins 配置运行前应选择 M428/TCP。"
},
{
@@ -207,9 +361,15 @@
"group": "trt-gmoccapy",
"label": "gmoccapy test-xyzac 子程序",
"path": "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/test-xyzac.ngc",
- "coordinates": ["XYZAC"],
+ "coordinates": [
+ "XYZAC"
+ ],
"role": "support",
- "features": ["O-sub", "刀具更换", "几何偏置"],
+ "features": [
+ "O-sub",
+ "刀具更换",
+ "几何偏置"
+ ],
"note": "支持子程序,需要由 gmoccapy/NGCGUI 传参调用。"
},
{
@@ -217,20 +377,32 @@
"group": "trt-gmoccapy",
"label": "gmoccapy test-xyzbc 子程序",
"path": "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/test-xyzbc.ngc",
- "coordinates": ["XYZBC"],
+ "coordinates": [
+ "XYZBC"
+ ],
"role": "support",
- "features": ["O-sub", "刀具更换", "几何偏置"],
+ "features": [
+ "O-sub",
+ "刀具更换",
+ "几何偏置"
+ ],
"note": "支持子程序,需要由 gmoccapy/NGCGUI 传参调用。"
},
-
{
"id": "xyzab-tdr-demo",
"group": "xyzab-tdr",
"label": "XYZAB TDR demo",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/demos/xyzab-tdr-demo.ngc",
- "coordinates": ["XYZAB"],
+ "coordinates": [
+ "XYZAB"
+ ],
"role": "main",
- "features": ["M428/M429", "HAL 旋转中心", "G10 L20", "G43"],
+ "features": [
+ "M428/M429",
+ "HAL 旋转中心",
+ "G10 L20",
+ "G43"
+ ],
"note": "需要 XYZAB 双旋转台运动学;当前 Web 控制面板仅执行 XYZAC/XYZBC。"
},
{
@@ -238,9 +410,15 @@
"group": "xyzab-tdr",
"label": "XYZAB M428 TCP remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/remap_subs/428remap.ngc",
- "coordinates": ["XYZAB"],
+ "coordinates": [
+ "XYZAB"
+ ],
"role": "support",
- "features": ["M68", "M66", "xyzab_tdr_kins"],
+ "features": [
+ "M68",
+ "M66",
+ "xyzab_tdr_kins"
+ ],
"note": "XYZAB TCP 运动学切换支持文件。"
},
{
@@ -248,20 +426,33 @@
"group": "xyzab-tdr",
"label": "XYZAB M429 identity remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/remap_subs/429remap.ngc",
- "coordinates": ["XYZAB"],
+ "coordinates": [
+ "XYZAB"
+ ],
"role": "support",
- "features": ["M68", "M66", "identity kinematics"],
+ "features": [
+ "M68",
+ "M66",
+ "identity kinematics"
+ ],
"note": "XYZAB identity 运动学切换支持文件。"
},
-
{
"id": "bridgemill-5axisgui",
"group": "bridgemill",
"label": "Bridge Mill 5axisgui",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/5axisgui.ngc",
- "coordinates": ["XYZBCW"],
+ "coordinates": [
+ "XYZBCW"
+ ],
"role": "main",
- "features": ["5axiskins", "B/C/W", "G43", "O-code 循环", "球面钻孔"],
+ "features": [
+ "5axiskins",
+ "B/C/W",
+ "G43",
+ "O-code 循环",
+ "球面钻孔"
+ ],
"note": "需要 XYZBCW Bridge Mill / 5axiskins;W 轴沿刀具方向进给。"
},
{
@@ -269,9 +460,15 @@
"group": "bridgemill",
"label": "Bridge Mill M428 remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/remap_subs/428remap.ngc",
- "coordinates": ["XYZBCW"],
+ "coordinates": [
+ "XYZBCW"
+ ],
"role": "support",
- "features": ["M68", "M66", "5axiskins"],
+ "features": [
+ "M68",
+ "M66",
+ "5axiskins"
+ ],
"note": "Bridge Mill 非平凡运动学切换支持文件。"
},
{
@@ -279,9 +476,15 @@
"group": "bridgemill",
"label": "Bridge Mill M429 identity remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/remap_subs/429remap.ngc",
- "coordinates": ["XYZBCW"],
+ "coordinates": [
+ "XYZBCW"
+ ],
"role": "support",
- "features": ["M68", "M66", "identity kinematics"],
+ "features": [
+ "M68",
+ "M66",
+ "identity kinematics"
+ ],
"note": "Bridge Mill identity 运动学切换支持文件。"
},
{
@@ -289,20 +492,32 @@
"group": "bridgemill",
"label": "Bridge Mill M430 userk remap",
"path": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/remap_subs/430remap.ngc",
- "coordinates": ["XYZBCW"],
+ "coordinates": [
+ "XYZBCW"
+ ],
"role": "support",
- "features": ["M68", "M66", "user kinematics"],
+ "features": [
+ "M68",
+ "M66",
+ "user kinematics"
+ ],
"note": "Bridge Mill userk 运动学切换支持文件。"
},
-
{
"id": "hexapod-remap-m428",
"group": "hexapod",
"label": "Hexapod M428 genhexkins remap",
"path": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/remap_subs/428remap.ngc",
- "coordinates": ["GENHEX"],
+ "coordinates": [
+ "GENHEX"
+ ],
"role": "support",
- "features": ["genhexkins", "M68", "M66", "RTCP tool-offset"],
+ "features": [
+ "genhexkins",
+ "M68",
+ "M66",
+ "RTCP tool-offset"
+ ],
"note": "Hexapod genhexkins RTCP 运动学切换支持文件。"
},
{
@@ -310,9 +525,15 @@
"group": "hexapod",
"label": "Hexapod M429 identity remap",
"path": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/remap_subs/429remap.ngc",
- "coordinates": ["GENHEX"],
+ "coordinates": [
+ "GENHEX"
+ ],
"role": "support",
- "features": ["identity kinematics", "M68", "M66"],
+ "features": [
+ "identity kinematics",
+ "M68",
+ "M66"
+ ],
"note": "Hexapod identity 运动学切换支持文件。"
},
{
@@ -320,21 +541,267 @@
"group": "hexapod",
"label": "Hexapod M430 userk remap",
"path": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/remap_subs/430remap.ngc",
- "coordinates": ["GENHEX"],
+ "coordinates": [
+ "GENHEX"
+ ],
"role": "support",
- "features": ["user kinematics", "M68", "M66"],
+ "features": [
+ "user kinematics",
+ "M68",
+ "M66"
+ ],
"note": "Hexapod userk 运动学切换支持文件。"
},
-
{
"id": "standalone-cone",
"group": "standalone",
"label": "cone.ngc 动态刀长示例",
"path": "linuxcnc/nc_files/cone.ngc",
- "coordinates": ["XYZBCW"],
+ "coordinates": [
+ "XYZBCW"
+ ],
"role": "reference",
- "features": ["G43.1", "B/C/W", "动态刀长", "锥面轨迹"],
+ "features": [
+ "G43.1",
+ "B/C/W",
+ "动态刀长",
+ "锥面轨迹"
+ ],
"note": "独立算法参考程序,需要带 B/C/W 的模拟五轴配置。"
+ },
+ {
+ "id": "web-case-xyzac-trt",
+ "group": "five-axis-case-tests",
+ "label": "XYZAC TRT 五轴转台倾转",
+ "path": "machine-cases/xyzac-trt.ngc",
+ "coordinates": [
+ "XYZAC"
+ ],
+ "role": "main",
+ "features": [
+ "xyzac-trt-kins",
+ "G0/G1 有界试动",
+ "supported"
+ ],
+ "note": "A 绕 X、C 绕 Z;Web LinuxCNC WASM 原生可运行。 Web LinuxCNC/WASM 通用运动学链已接入。",
+ "machineId": "xyzac-trt",
+ "webStatus": "supported"
+ },
+ {
+ "id": "web-case-xyzbc-trt",
+ "group": "five-axis-case-tests",
+ "label": "XYZBC TRT 五轴转台倾转",
+ "path": "machine-cases/xyzbc-trt.ngc",
+ "coordinates": [
+ "XYZBC"
+ ],
+ "role": "main",
+ "features": [
+ "xyzbc-trt-kins",
+ "G0/G1 有界试动",
+ "supported"
+ ],
+ "note": "B 绕 Y、C 绕 Z;Web LinuxCNC WASM 原生可运行。 Web LinuxCNC/WASM 通用运动学链已接入。",
+ "machineId": "xyzbc-trt",
+ "webStatus": "supported"
+ },
+ {
+ "id": "web-case-xyzab-tdr",
+ "group": "five-axis-case-tests",
+ "label": "XYZAB 双转台 TDR",
+ "path": "machine-cases/xyzab-tdr.ngc",
+ "coordinates": [
+ "XYZAB"
+ ],
+ "role": "main",
+ "features": [
+ "xyzab_tdr_kins",
+ "G0/G1 有界试动",
+ "adapter-required"
+ ],
+ "note": "LinuxCNC xyzab_tdr_kins;Web 通用 WASM 运动学已接入。 Web LinuxCNC/WASM 通用运动学链已接入。",
+ "machineId": "xyzab-tdr",
+ "webStatus": "supported"
+ },
+ {
+ "id": "web-case-bridge-mill-5axiskins",
+ "group": "five-axis-case-tests",
+ "label": "Bridge Mill / XYZBCWY",
+ "path": "machine-cases/bridge-mill-5axiskins.ngc",
+ "coordinates": [
+ "XYZBCWY"
+ ],
+ "role": "main",
+ "features": [
+ "5axiskins",
+ "G0/G1 有界试动",
+ "adapter-required"
+ ],
+ "note": "W 沿刀具方向;配置含重复 Y 关节。 Web LinuxCNC/WASM 通用运动学链已接入。",
+ "machineId": "bridge-mill-5axiskins",
+ "webStatus": "supported"
+ },
+ {
+ "id": "web-case-max5-maxkins",
+ "group": "five-axis-case-tests",
+ "label": "MAX5 / maxkins",
+ "path": "machine-cases/max5-maxkins.ngc",
+ "coordinates": [
+ "XYZBC"
+ ],
+ "role": "main",
+ "features": [
+ "maxkins",
+ "G0/G1 有界试动",
+ "adapter-required"
+ ],
+ "note": "B/C 非恒等旋转映射;不是 TRT 替代。 Web LinuxCNC/WASM 通用运动学链已接入。",
+ "machineId": "max5-maxkins",
+ "webStatus": "supported"
+ },
+ {
+ "id": "web-case-hexapod-genhexkins",
+ "group": "five-axis-case-tests",
+ "label": "Hexapod / genhexkins",
+ "path": "machine-cases/hexapod-genhexkins.ngc",
+ "coordinates": [
+ "XYZABC"
+ ],
+ "role": "main",
+ "features": [
+ "genhexkins",
+ "G0/G1 有界试动",
+ "adapter-required"
+ ],
+ "note": "六支链并联平台;XYZABC 六自由度。 Web LinuxCNC/WASM 通用运动学链已接入。",
+ "machineId": "hexapod-genhexkins",
+ "webStatus": "supported"
+ },
+ {
+ "id": "web-case-pentapod-pentakins",
+ "group": "five-axis-case-tests",
+ "label": "Pentapod / pentakins",
+ "path": "machine-cases/pentapod-pentakins.ngc",
+ "coordinates": [
+ "XYZAB"
+ ],
+ "role": "main",
+ "features": [
+ "pentakins",
+ "G0/G1 有界试动",
+ "adapter-required"
+ ],
+ "note": "五支链并联平台;XYZAB 五轴。 Web LinuxCNC/WASM 通用运动学链已接入。",
+ "machineId": "pentapod-pentakins",
+ "webStatus": "supported"
+ },
+ {
+ "id": "web-case-puma-pumakins",
+ "group": "five-axis-case-tests",
+ "label": "PUMA / pumakins",
+ "path": "machine-cases/puma-pumakins.ngc",
+ "coordinates": [
+ "XYZABC"
+ ],
+ "role": "main",
+ "features": [
+ "pumakins",
+ "G0/G1 有界试动",
+ "adapter-required"
+ ],
+ "note": "PUMA 六关节串联机器人。 Web LinuxCNC/WASM 通用运动学链已接入。",
+ "machineId": "puma-pumakins",
+ "webStatus": "supported"
+ },
+ {
+ "id": "web-case-puma-genserkins",
+ "group": "five-axis-case-tests",
+ "label": "PUMA 560 / genserkins",
+ "path": "machine-cases/puma-genserkins.ngc",
+ "coordinates": [
+ "XYZABC"
+ ],
+ "role": "main",
+ "features": [
+ "genserkins",
+ "G0/G1 有界试动",
+ "adapter-required"
+ ],
+ "note": "DH 参数驱动的六轴串联机器人。 Web LinuxCNC/WASM 通用运动学链已接入。",
+ "machineId": "puma-genserkins",
+ "webStatus": "supported"
+ },
+ {
+ "id": "web-case-trivkins-xyzab",
+ "group": "five-axis-case-tests",
+ "label": "trivkins XYZAB 映射基线",
+ "path": "machine-cases/trivkins-xyzab.ngc",
+ "coordinates": [
+ "XYZAB"
+ ],
+ "role": "main",
+ "features": [
+ "trivkins",
+ "G0/G1 有界试动",
+ "identity-only"
+ ],
+ "note": "五轴一一映射基线;不提供 RTCP。 Web LinuxCNC/WASM 通用运动学链已接入。",
+ "machineId": "trivkins-xyzab",
+ "webStatus": "supported"
+ },
+ {
+ "id": "web-case-trivkins-xyzcw",
+ "group": "five-axis-case-tests",
+ "label": "trivkins XYZCW 映射基线",
+ "path": "machine-cases/trivkins-xyzcw.ngc",
+ "coordinates": [
+ "XYZCW"
+ ],
+ "role": "main",
+ "features": [
+ "trivkins",
+ "G0/G1 有界试动",
+ "identity-only"
+ ],
+ "note": "C/W 轴字母映射基线;不提供 RTCP。 Web LinuxCNC/WASM 通用运动学链已接入。",
+ "machineId": "trivkins-xyzcw",
+ "webStatus": "supported"
+ },
+ {
+ "id": "web-case-trivkins-xyzya",
+ "group": "five-axis-case-tests",
+ "label": "trivkins XYZYA 双 Y 映射基线",
+ "path": "machine-cases/trivkins-xyzya.ngc",
+ "coordinates": [
+ "XYZYA"
+ ],
+ "role": "main",
+ "features": [
+ "trivkins",
+ "G0/G1 有界试动",
+ "identity-only"
+ ],
+ "note": "Y 轴重复映射与旋转 A 基线;不提供 RTCP。 Web LinuxCNC/WASM 通用运动学链已接入。",
+ "machineId": "trivkins-xyzya",
+ "webStatus": "supported"
+ },
+ {
+ "id": "web-case-scorbot-kins",
+ "group": "five-axis-case-tests",
+ "label": "SCORBOT / scorbot-kins",
+ "path": "machine-cases/scorbot-kins.ngc",
+ "coordinates": [
+ "XYZAB"
+ ],
+ "role": "main",
+ "features": [
+ "scorbot-kins",
+ "G0/G1 有界试动",
+ "adapter-required"
+ ],
+ "note": "五关节串联机器人;不是五轴铣床 TRT。 Web LinuxCNC/WASM 通用运动学链已接入。",
+ "machineId": "scorbot-kins",
+ "webStatus": "supported"
}
]
}
diff --git a/web/react/src/styles.css b/web/react/src/styles.css
index 944dc18..e57c8d7 100644
--- a/web/react/src/styles.css
+++ b/web/react/src/styles.css
@@ -155,6 +155,10 @@ button { color: inherit; }
.manual-action-row { margin-top: 8px; }
.manual-jog-block select { min-height: 25px; min-width: 104px; border: 1px solid var(--border-dark); background: white; }
.axis-text-button { min-height: 26px; padding: 3px 9px; border: 1px solid #858585; border-radius: 2px; background: linear-gradient(#f4f4f4, #d6d6d6); box-shadow: inset 1px 1px #fff; cursor: pointer; }
+.axis-header-actions { display: flex; align-items: center; gap: 6px; }
+/* 使用说明书入口:在新标签页打开单文件 HTML,样式与标题栏的 Connection 指示一致。 */
+.axis-manual-link { padding: 2px 8px; border: 1px solid #2f5d78; border-radius: 2px; color: #fff; background: linear-gradient(#3f7fa5, #2a5f81); font: 11px Arial, Helvetica, sans-serif; text-decoration: none; white-space: nowrap; }
+.axis-manual-link:hover { background: linear-gradient(#4a90b8, #31698c); }
.axis-text-button:hover:not(:disabled), .axis-text-button:focus-visible { border-color: var(--focus); background: #d8e5f3; outline: none; }
.axis-text-button:active:not(:disabled), .axis-text-button.danger { background: #c3c3c3; box-shadow: inset 1px 1px #999; }
.axis-text-button:disabled { cursor: not-allowed; opacity: .5; }
diff --git a/web/react/src/test_rtcp_program_catalog.mjs b/web/react/src/test_rtcp_program_catalog.mjs
index 1437975..7268492 100644
--- a/web/react/src/test_rtcp_program_catalog.mjs
+++ b/web/react/src/test_rtcp_program_catalog.mjs
@@ -3,9 +3,10 @@ import { readFile, stat } from "node:fs/promises";
const root = new URL("../../../", import.meta.url);
const catalog = JSON.parse(await readFile(new URL("web/react/src/rtcp-program-manifest.json", root), "utf8"));
+const caseManifest = JSON.parse(await readFile(new URL("web/react/src/machine-case-program-manifest.json", root), "utf8"));
assert.equal(catalog.schema, "linuxcnc.rtcp-program-library/1");
-assert.equal(catalog.total, 32);
-assert.equal(catalog.programs.length, 32);
+assert.equal(catalog.total, 45);
+assert.equal(catalog.programs.length, 45);
assert.deepEqual(Object.fromEntries(catalog.groups.map((group) => [group.id, group.count])), {
"trt-axis": 16,
"trt-gmoccapy": 5,
@@ -13,24 +14,41 @@ assert.deepEqual(Object.fromEntries(catalog.groups.map((group) => [group.id, gro
bridgemill: 4,
hexapod: 3,
standalone: 1,
+ "five-axis-case-tests": 13,
});
-assert.equal(new Set(catalog.programs.map((program) => program.id)).size, 32);
-assert.equal(new Set(catalog.programs.map((program) => program.path)).size, 32);
+assert.equal(new Set(catalog.programs.map((program) => program.id)).size, 45);
+assert.equal(new Set(catalog.programs.map((program) => program.path)).size, 45);
for (const group of catalog.groups) assert.equal(catalog.programs.filter((program) => program.group === group.id).length, group.count, group.id);
for (const program of catalog.programs) {
assert.ok(["main", "support", "reference"].includes(program.role), program.id);
- assert.ok(program.path.startsWith("linuxcnc/") && program.path.endsWith(".ngc"), program.path);
- assert.ok((await stat(new URL(program.path, root))).isFile(), program.path);
- const collected = new URL(`test_Rtcp/programs/${program.path.slice("linuxcnc/".length)}`, root);
- assert.equal(await readFile(new URL(program.path, root), "utf8"), await readFile(collected, "utf8"), `${program.id}: collected source drift`);
+ assert.ok((program.path.startsWith("linuxcnc/") || program.path.startsWith("machine-cases/")) && program.path.endsWith(".ngc"), program.path);
+ const source = program.path.startsWith("linuxcnc/") ? new URL(program.path, root) : new URL(`web/react/public/${program.path}`, root);
+ assert.ok((await stat(source)).isFile(), program.path);
+ if (program.path.startsWith("linuxcnc/")) {
+ const collected = new URL(`test_Rtcp/programs/${program.path.slice("linuxcnc/".length)}`, root);
+ assert.equal(await readFile(source, "utf8"), await readFile(collected, "utf8"), `${program.id}: collected source drift`);
+ }
}
const loadable = (coordinates) => catalog.programs.filter((program) => program.role === "main" && program.coordinates.includes(coordinates));
const related = (coordinates) => catalog.programs.filter((program) => program.coordinates.includes(coordinates));
-assert.equal(loadable("XYZAC").length, 8);
-assert.equal(loadable("XYZBC").length, 3);
-assert.equal(related("XYZAC").length, 15);
-assert.equal(related("XYZBC").length, 9);
-assert.equal(catalog.programs.filter((program) => program.role === "main").length, 13);
+assert.equal(loadable("XYZAC").length, 9);
+assert.equal(loadable("XYZBC").length, 5);
+assert.equal(related("XYZAC").length, 16);
+assert.equal(related("XYZBC").length, 11);
+assert.equal(catalog.programs.filter((program) => program.role === "main").length, 26);
assert.equal(catalog.programs.filter((program) => program.role === "support").length, 18);
assert.equal(catalog.programs.filter((program) => program.role === "reference").length, 1);
-console.log(JSON.stringify({ schema: "rtcp-program-catalog-test.v1", status: "verified", total: 32, related: { XYZAC: 15, XYZBC: 9 }, loadable: { XYZAC: 8, XYZBC: 3 }, groups: catalog.groups }));
+assert.equal(caseManifest.schema, "linuxcnc.web-five-axis-case-programs/1");
+assert.equal(caseManifest.cases.length, 13);
+for (const item of caseManifest.cases) {
+ const program = catalog.programs.find((row) => row.id === `web-case-${item.id}`);
+ assert.ok(program, item.id);
+ assert.deepEqual(program.coordinates, [item.coordinates]);
+ const text = await readFile(new URL(`web/react/public/${item.path}`, root), "utf8");
+ assert.match(text, new RegExp(`\\(CASE_ID: ${item.id}\\)`));
+ assert.match(text, new RegExp(`\\(CONTROL_PROFILE: ${item.coordinates}\\)`));
+ assert.match(text, /G21 G90 G94/);
+ assert.match(text, /M30/);
+ if (item.webStatus !== "supported") assert.match(program.note, /Web 按钮保持禁用/);
+}
+console.log(JSON.stringify({ schema: "rtcp-program-catalog-test.v1", status: "verified", total: 45, related: { XYZAC: 16, XYZBC: 11 }, loadable: { XYZAC: 9, XYZBC: 5 }, groups: catalog.groups }));
diff --git a/web/react/src/test_trt_parametric.mjs b/web/react/src/test_trt_parametric.mjs
index eade2d3..80b45c3 100644
--- a/web/react/src/test_trt_parametric.mjs
+++ b/web/react/src/test_trt_parametric.mjs
@@ -88,7 +88,7 @@ async function execute(bundle, source) {
return { trace, offsets: expected, final: trace.at(-1), preview_hash: fnv1a64(preview.payload.preview), trace_hash: fnv1a64(trace), model_samples: samples.length };
}
-for (const machine of MACHINE_DEFINITIONS) {
+for (const machine of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
const bundle = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
assert.equal(bundle.status, "ready");
const initial = readTrtGeometry(bundle);
diff --git a/web/react/src/test_trt_rtcp_jog.mjs b/web/react/src/test_trt_rtcp_jog.mjs
index 359ac76..f942c36 100644
--- a/web/react/src/test_trt_rtcp_jog.mjs
+++ b/web/react/src/test_trt_rtcp_jog.mjs
@@ -17,7 +17,7 @@ const fetchImpl = async (url) => {
const wasmBinary = await readFile(new URL("build-wasm/linuxcnc-sim-core.wasm", root));
const close = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 1e-7, `${message}: ${actual} != ${expected}`);
const rows = [];
-for (const machine of MACHINE_DEFINITIONS) {
+for (const machine of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
const raw = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
const bundle = writeTrtGeometry(raw, { x_rot_point: 9, y_rot_point: -7, z_rot_point: 13 });
const core = await createModule({ wasmBinary });
diff --git a/web/react/src/xyzac-panel-contract.ts b/web/react/src/xyzac-panel-contract.ts
index b03cb6b..7c9ba96 100644
--- a/web/react/src/xyzac-panel-contract.ts
+++ b/web/react/src/xyzac-panel-contract.ts
@@ -37,13 +37,18 @@ type XyzacPanelSource = {
xyzac?: XyzacCapabilitySnapshot;
gates?: Record;
authoritative?: Record;
- visual?: { pose?: Record; joints?: number[] };
+ visual?: { pose?: Record; joints?: number[]; machine_model?: { id?: string; coordinate_system?: string; joint_count?: number } };
};
export function projectXyzacPanel(snapshot: XyzacPanelSource | null | undefined): XyzacPanelView {
const rawKinematics = snapshot?.kinematics;
const rawCapability = snapshot?.xyzac;
- const machine = MACHINE_DEFINITIONS.find((entry) => entry.coordinates === rawKinematics?.coordinates) ?? DEFAULT_MACHINE;
+ // Coordinates are not a key: three cases share XYZAB and three share XYZABC.
+ // Prefer the worker-asserted machine id, then the entrypoint, then coordinates.
+ const assertedId = (snapshot?.visual?.machine_model as { id?: string } | undefined)?.id;
+ const machine = (assertedId ? MACHINE_DEFINITIONS.find((entry) => entry.id === assertedId) : undefined)
+ ?? MACHINE_DEFINITIONS.find((entry) => entry.coordinates === rawKinematics?.coordinates)
+ ?? DEFAULT_MACHINE;
const kinematics = rawKinematics && rawKinematics.coordinates === machine.coordinates && rawKinematics.joint_count === machine.joint_count ? rawKinematics : null;
const capability = rawCapability && rawCapability.coordinates === machine.coordinates && rawCapability.joint_count === machine.joint_count ? rawCapability : null;
const visualPose = snapshot?.visual?.pose;
diff --git a/web/react/src/xyzac-visual-adapter.ts b/web/react/src/xyzac-visual-adapter.ts
index 29d774c..46443d0 100644
--- a/web/react/src/xyzac-visual-adapter.ts
+++ b/web/react/src/xyzac-visual-adapter.ts
@@ -6,7 +6,8 @@ export type XyzacVisualState = {
tick: number;
stateHash: string;
pose: XyzacKinematicsSnapshot["pose"];
- joints: [number, number, number, number, number];
+ joints: number[];
+ jointsAuthoritative: boolean;
livePose: XyzacKinematicsSnapshot["pose"];
activeProgress: number;
poseEpoch: number;
@@ -41,13 +42,16 @@ export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefine
const pose = visualPose && poseAxes.every((axis) => Number.isFinite(visualPose[axis]))
? { ...ZERO_POSE, ...visualPose }
: kinematics?.pose ?? ZERO_POSE;
- const visualJoints = snapshot?.visual?.joints;
- const joints: [number, number, number, number, number] = visualJoints?.length === 5
- ? visualJoints.slice(0, 5).map((value) => Number(value)) as [number, number, number, number, number]
- : kinematics?.joints?.length === 5 ? [...kinematics.joints] as [number, number, number, number, number] : [0, 0, 0, 0, 0];
+ // Joint arrays are slot-indexed and may be 5..9 long. Never truncate here:
+ // the consumer binds slot i to the node recorded for joint i.
+ const declaredCount = Number((snapshot?.visual?.machine_model as { joint_count?: number } | undefined)?.joint_count ?? kinematics?.joint_count ?? 0);
+ const visualJoints = Array.isArray(snapshot?.visual?.joints) ? snapshot.visual.joints.map(Number) : null;
+ const plannerJoints = Array.isArray(kinematics?.joints) ? kinematics.joints.map(Number) : null;
+ const joints = visualJoints?.length ? visualJoints : plannerJoints?.length ? plannerJoints : [];
+ const jointsAuthoritative = joints.length > 0 && joints.every(Number.isFinite) && (!declaredCount || joints.length === declaredCount);
const rawProgress = snapshot?.visual?.active_progress;
const activeProgress = Number.isFinite(rawProgress) ? Math.max(0, Math.min(1, Number(rawProgress))) : 0;
- const motionTrace = (snapshot?.visual?.motion_trace ?? []).filter((sample) => sample && Number.isSafeInteger(sample.tick) && sample.pose && Array.isArray(sample.joints)).map((sample) => ({ tick: sample.tick, pose: { ...ZERO_POSE, ...sample.pose }, joints: sample.joints.slice(0, 5).map(Number), activeSegmentId: Number(sample.active_segment_id ?? 0), activeProgress: Math.max(0, Math.min(1, Number(sample.active_progress ?? 0))) }));
+ const motionTrace = (snapshot?.visual?.motion_trace ?? []).filter((sample) => sample && Number.isSafeInteger(sample.tick) && sample.pose && Array.isArray(sample.joints)).map((sample) => ({ tick: sample.tick, pose: { ...ZERO_POSE, ...sample.pose }, joints: sample.joints.map(Number), activeSegmentId: Number(sample.active_segment_id ?? 0), activeProgress: Math.max(0, Math.min(1, Number(sample.active_progress ?? 0))) }));
return {
schema: "xyzac-visual-state.v1",
authoritative: Boolean(snapshot?.kinematics && snapshot?.visual),
@@ -55,6 +59,7 @@ export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefine
stateHash: snapshot?.state_hash ?? "",
pose,
joints,
+ jointsAuthoritative,
livePose: pose,
activeProgress,
poseEpoch: Number.isSafeInteger(snapshot?.visual?.pose_epoch) ? Number(snapshot?.visual?.pose_epoch) : 0,
diff --git a/web/react/vite.config.ts b/web/react/vite.config.ts
index 43dbada..0c3d0f5 100644
--- a/web/react/vite.config.ts
+++ b/web/react/vite.config.ts
@@ -34,6 +34,12 @@ function linuxcncAssets(): Plugin {
const output = options.dir ? resolve(options.dir) : resolve(repoRoot, "web/react/dist");
const copies = [
["linuxcnc/configs/sim/axis", "linuxcnc/configs/sim/axis"],
+ // Every case entrypoint in machine-catalog.json must exist in the build.
+ // These three live outside configs/sim/axis and used to 404 in the
+ // deployed bundle, leaving the controller stuck in PENDING.
+ ["linuxcnc/configs/sim/woodpecker", "linuxcnc/configs/sim/woodpecker"],
+ ["linuxcnc/configs/sim/gmoccapy", "linuxcnc/configs/sim/gmoccapy"],
+ ["linuxcnc/configs/sim/qtdragon", "linuxcnc/configs/sim/qtdragon"],
["linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples", "linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples"],
["linuxcnc/configs/nc_files", "linuxcnc/configs/nc_files"],
["linuxcnc/nc_files", "linuxcnc/nc_files"],
@@ -43,12 +49,14 @@ function linuxcncAssets(): Plugin {
["linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py", "linuxcnc/src/hal/user_comps/vismach/xyzac-trt-gui.py"],
["linuxcnc/src/hal/user_comps/vismach/xyzbc-trt-gui.py", "linuxcnc/src/hal/user_comps/vismach/xyzbc-trt-gui.py"],
["linuxcnc/configs/sim/qtvcp_screens/qtaxis_lathe/qtaxis/images", "linuxcnc/configs/sim/qtvcp_screens/qtaxis_lathe/qtaxis/images"],
+ ["docs/user-manual/LinuxCNC-Web-使用说明书.html", "manual/index.html"],
];
for (const [from, to] of copies) cpSync(resolve(repoRoot, from), resolve(output, to), { recursive: true, dereference: false });
},
};
}
+// 生产构建会把使用说明书一起复制到 dist/manual/index.html。
export default defineConfig({
plugins: [linuxcncAssets(), tailwindcss(), react()],
base: "./",
diff --git a/web/worker/program-runner.mjs b/web/worker/program-runner.mjs
index db93b32..d67e29e 100644
--- a/web/worker/program-runner.mjs
+++ b/web/worker/program-runner.mjs
@@ -361,6 +361,19 @@ export class ProgramRunner {
return this.snapshot();
}
+ synchronizePosition(position) {
+ if (!this.loaded || !position || ["x", "y", "z", "a", "b", "c", "u", "v", "w"].some((axis) => !Number.isFinite(Number(position[axis] ?? 0)))) return { status: "error", code: "program_position_invalid" };
+ const pointer = this.core._malloc(72);
+ if (!pointer) return { status: "error", code: "program_position_allocation_failed" };
+ try {
+ this.core.HEAPF64.set(["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => Number(position[axis] ?? 0)), pointer / 8);
+ const result = call(this.core, "sim_interpreter_sync_position", "number", ["number", "number"], [this.interpreter, pointer]);
+ if (result !== 0) return { status: "error", code: `program_position_sync_${result}` };
+ this.state = { ...this.state, ...position };
+ return { status: "verified", state: { ...this.state } };
+ } finally { this.core._free(pointer); }
+ }
+
preview() {
if (!this.loaded) return { status: "error", code: "program_not_loaded" };
const source = this.source;
diff --git a/web/worker/sim-session.mjs b/web/worker/sim-session.mjs
index 02d9e67..37ed781 100644
--- a/web/worker/sim-session.mjs
+++ b/web/worker/sim-session.mjs
@@ -29,6 +29,7 @@ import { WasmLinuxcncTp } from "./wasm-linuxcnc-tp.mjs";
import { WasmHomeLimit } from "./wasm-home-limit.mjs";
import { WasmNativeHoming } from "./wasm-native-homing.mjs";
import { WasmXyzacKinematics, XYZAC_MODES } from "./wasm-xyzac-kinematics.mjs";
+import { WasmGenericKinematics } from "./wasm-generic-kinematics.mjs";
import { WasmXyzacIo } from "./wasm-xyzac-io.mjs";
import { WasmXyzacDeviceIo } from "./wasm-xyzac-device-io.mjs";
import { WasmProgramRuntime } from "./wasm-program-runtime.mjs";
@@ -140,6 +141,8 @@ export class SimSession {
this.wasmMotion = planner === "linuxcnc-tp" ? new WasmLinuxcncTp(core) : new WasmMotionQueue(core);
this.wasmHomeLimit = new WasmHomeLimit(core);
this.xyzacKinematics = null;
+ this.genericKinematics = null;
+ this.genericMotionEnabled = false;
this.xyzacIo = null;
this.xyzacDeviceIo = null;
this.xyzacProgram = null;
@@ -338,7 +341,25 @@ export class SimSession {
if (this.xyzacProgram) return this.wasmMotionProjection();
if (this.motionAuthority !== "wasm") return this.motion.export();
const wasm = this.wasmMotion.read();
- return wasm.status === "verified" ? { ...this.motion.export(), axes: { ...wasm.state.position }, queue_depth: wasm.state.queue_depth } : this.motion.export();
+ return wasm.status === "verified" ? { ...this.motion.export(), axes: { ...wasm.state.position }, joints: [...(wasm.state.joint_commanded ?? this.genericKinematics?.snapshot().joints ?? [])], queue_depth: wasm.state.queue_depth } : this.motion.export();
+ }
+
+ genericWasmProgramLoad(input) {
+ const source = input.payload?.source ?? input.payload?.text ?? input.payload?.program_source;
+ const loaded = this.program.load(source, input.payload?.path ?? null);
+ if (loaded.status !== "verified") return loaded;
+ this.wasmMotion.reset(); this.wasmMotion.setPaused(false); this.wasmMotion.setKinematics(this.genericKinematics);
+ const home = this.genericKinematics.home();
+ if (home.status !== "verified") return home;
+ const aligned = this.wasmMotion.setPosition(home.pose);
+ if (aligned.status !== "verified") return aligned;
+ const synchronized = this.program.synchronizePosition(home.pose);
+ if (synchronized.status !== "verified") return synchronized;
+ this.task.setProgram("loaded", 0);
+ const preview = this.program.preview();
+ loaded.preview = preview.preview ?? { status: "error", code: preview.code ?? "program_preview_failed" };
+ this.previewState = loaded.preview;
+ return loaded;
}
wasmDeviceProjection({ includeBytes = false } = {}) {
@@ -469,12 +490,10 @@ export class SimSession {
// Generic WASM-compatible cases expose their historical XYZ panel shape;
// the selected XYZAC case is the only panel that publishes all nine pose
// coordinates and the five inverse joints.
- const position = this.xyzacProgram
- ? wasmPosition
- : { x: wasmPosition.x, y: wasmPosition.y, z: wasmPosition.z };
- const inverseJoints = this.xyzacKinematics?.snapshot?.()?.joints;
+ const position = this.xyzacProgram || this.genericKinematics ? wasmPosition : { x: wasmPosition.x, y: wasmPosition.y, z: wasmPosition.z };
+ const inverseJoints = this.xyzacKinematics?.snapshot?.()?.joints ?? this.genericKinematics?.snapshot?.()?.joints;
const joints = this.motionAuthority === "wasm" && wasmMotion.status === "verified"
- ? (Array.isArray(wasmMotion.state.joint_commanded) && wasmMotion.state.joint_commanded.length === 5 ? [...wasmMotion.state.joint_commanded] : inverseJoints?.length === 5 ? [...inverseJoints] : [position.x, position.y, position.z, position[this.machineDefinition?.coordinates === "XYZBC" ? "b" : "a"] ?? 0, position.c ?? 0])
+ ? (Array.isArray(wasmMotion.state.joint_commanded) && wasmMotion.state.joint_commanded.length === this.machineDefinition?.joint_count ? [...wasmMotion.state.joint_commanded] : inverseJoints?.length === this.machineDefinition?.joint_count ? [...inverseJoints] : [position.x, position.y, position.z, position[this.machineDefinition?.coordinates === "XYZBC" ? "b" : "a"] ?? 0, position.c ?? 0])
: authoritative.kinematics.joints;
const preview = this.previewState?.status === "verified" ? this.previewState : null;
// Keep the Vismach model identity in the same authoritative visual
@@ -497,7 +516,7 @@ export class SimSession {
rotary_axis: "Z",
joints: machineDefinition?.coordinates ? ["table-x", "saddle-y", "spindle-z", `tilt-${machineDefinition.coordinates[3].toLowerCase()}`, "rotate-c"] : ["table-x", "saddle-y", "spindle-z", "tilt-a", "rotate-c"],
components: ["base", "column", "head", "spindle", "tool", "table", "saddle", "zslide"],
- } : null;
+ } : this.genericKinematics ? { schema: "linuxcnc-generic-kinematics-model.v1", id: this.machineDefinition?.id ?? this.genericKinematics.type, source: "linuxcnc-wasm-worker", source_path: this.machineDefinition?.source_path ?? "", source_hash: this.machineDefinition?.source_path && this.workspaceFiles?.[this.machineDefinition.source_path] ? fnv1a64(this.workspaceFiles[this.machineDefinition.source_path]) : null, coordinate_system: this.genericKinematics.coordinates, kinematics: this.genericKinematics.type, joint_count: this.genericKinematics.jointCount, components: ["kinematics-driven-joints"] } : null;
const visual = {
schema: "axis-visual.v1",
pose: { ...position },
@@ -1704,7 +1723,8 @@ export class SimSession {
if (!text(rootPath) || !rootPath.startsWith("/") || rootPath.includes("..")) return finish(this.error(input, "workspace_path", "invalid_workspace_path"), "failed");
let startupLoaded = false;
let configuration = input.payload?.configuration ?? null;
- if (machineDefinition(configuration?.entrypoint) || machineDefinition(configuration?.kinematics?.type)) {
+ const requestedMachine = machineDefinition(configuration?.entrypoint) ?? machineDefinition(configuration?.kinematics?.type);
+ if (requestedMachine?.runtime === "trt") {
try {
configuration = { ...configuration, ...readTrtConfiguration(configuration) };
} catch (error) {
@@ -1762,7 +1782,9 @@ export class SimSession {
}
const configuredMachine = this.machineDefinition ?? machineDefinition(configuration?.kinematics?.type);
const isTrt = configuredMachine?.runtime === "trt" || configuration?.kinematics?.type === "xyzac-trt" || configuration?.kinematics?.type === "xyzbc-trt";
- this.motionAuthority = configuration?.entrypoint?.includes("3axis-tutorial") || isTrt ? "wasm" : "js";
+ const isGenericWasm = configuredMachine?.runtime === "generic-wasm" || configuration?.kinematics?.runtime === "generic-wasm";
+ this.motionAuthority = configuration?.entrypoint?.includes("3axis-tutorial") || isTrt || isGenericWasm ? "wasm" : "js";
+ this.genericKinematics?.destroy(); this.genericKinematics = null;
if (isTrt) {
this.xyzacKinematics?.destroy();
this.xyzacKinematics = new WasmXyzacKinematics(this.core, { ...configuration.kinematics.offsets, coordinates: configuredMachine?.coordinates ?? configuration.kinematics.coordinates ?? "XYZAC", limits: configuration.home_limits });
@@ -1796,6 +1818,24 @@ export class SimSession {
this.xyzacAxisAccelerationLimits = configuration.kinematics.axis_acceleration_limits;
this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
}
+ if (isGenericWasm) {
+ this.xyzacProgram?.destroy?.(); this.xyzacProgram = null;
+ this.xyzacKinematics?.destroy(); this.xyzacKinematics = null;
+ this.wasmMotion.destroy?.();
+ this.xyzacMotionCycleTime = Number(configuration.kinematics.servo_cycle_time ?? 0.01);
+ this.xyzacTaskCycleTicks = 1;
+ this.wasmMotion = new WasmMotionQueue(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: Number(configuration.kinematics.max_linear_velocity ?? 1000) });
+ this.genericKinematics = new WasmGenericKinematics(this.core, {
+ type: configuration.kinematics.type, coordinates: configuration.kinematics.coordinates,
+ jointCount: configuration.kinematics.joint_count, kinematicsJointCount: configuration.kinematics.kinematics_joint_count, extraJointCount: configuration.kinematics.extra_joint_count, limits: configuration.home_limits ?? {},
+ linearUnits: configuration.kinematics.linear_units ?? "mm", angularUnits: configuration.kinematics.angular_units ?? "deg",
+ parameters: configuration.kinematics.parameters ?? {},
+ });
+ this.wasmMotion.setKinematics(this.genericKinematics);
+ const home = this.genericKinematics.snapshot().pose;
+ const aligned = this.wasmMotion.setPosition(home);
+ if (aligned.status !== "verified") throw new Error(aligned.code ?? "generic_wasm_home_position_failed");
+ }
if (configuration?.home_limits && this.motionAuthority === "wasm") {
this.wasmHomeLimit.destroy();
this.wasmHomeLimit = this.xyzacProgram ? new WasmNativeHoming(this.core, configuration, this.xyzacKinematics) : new WasmHomeLimit(this.core, {
@@ -1825,7 +1865,7 @@ export class SimSession {
}
const configuredKinematics = input.payload?.kinematics ?? configuration?.kinematics?.type ?? null;
if (isTrt) this.machineConfigurationHash = fnv1a64({ kinematics: configuration.kinematics, home_limits: configuration.home_limits, motion_scale_min: WEB_SETTINGS.ui.motion_scale_min });
- if (configuredKinematics && !(this.motionAuthority === "wasm" && isTrt)) {
+ if (configuredKinematics && !(this.motionAuthority === "wasm" && (isTrt || isGenericWasm))) {
const kinematics = this.authoritative.setKinematics(configuredKinematics);
if (kinematics.status === "blocked") return finish(this.envelope("ERROR", input, { status: "blocked", code: kinematics.code, kinematics: this.authoritative.export() }), "failed");
}
@@ -2044,6 +2084,24 @@ export class SimSession {
}
return;
}
+ if (this.genericKinematics) {
+ if (this.program.status !== "running" || this.wasmMotion.read().state.queue_depth !== 0) return;
+ const steps = this.program.tick(1);
+ program_steps.push(...steps);
+ for (const step of steps) {
+ if (!step.motion) continue;
+ step.motion.source_line = Number(step.program?.execution_source_line ?? step.line ?? step.motion.source_line ?? 0);
+ step.motion.source_file = step.program?.execution_source_file ?? this.program.path;
+ const linearNoop = step.motion.motion_type === 1 && ["x", "y", "z", "a", "b", "c", "u", "v", "w"].every((axis) => Math.abs(Number(step.motion.start[axis] ?? 0) - Number(step.motion.end[axis] ?? 0)) < 1e-9);
+ if (!linearNoop) {
+ step.motion.start = { ...this.wasmMotion.read().state.position };
+ const queued = this.wasmMotion.enqueue(step.motion);
+ if (queued.status !== "verified") throw new Error(queued.code ?? "generic_wasm_motion_enqueue_failed");
+ }
+ break;
+ }
+ return;
+ }
const steps = this.program.tick(1);
program_steps.push(...steps);
for (const step of steps) {
@@ -2177,6 +2235,9 @@ export class SimSession {
active_progress: traceState.progress,
});
}
+ } else if (this.genericKinematics && wasmMotion.status === "verified") {
+ const position = this.motion.applyWasmPosition(wasmMotion.state.position, wasmMotion.state.velocity, nextTick);
+ if (position.status !== "verified") throw new Error(position.code);
} else if (this.program.loaded) this.authoritative.applyMotion(this.motion);
if (this.motionAuthority === "wasm" && this.wasmHomeLimit.read().state.homed) {
const homeCheck = this.wasmHomeLimit.check(this.xyzacProgram ? this.wasmMotionProjection().joints : this.motion.state.joints);
@@ -2233,7 +2294,7 @@ export class SimSession {
if (program) {
const status = this.xyzacProgram ? (program.status === 4 ? "completed" : program.status === 2 ? "running" : program.status === 3 ? "paused" : "loaded") : (program.program_status === "completed" ? "completed" : program.program_status === "running" ? "running" : program.program_status);
this.task.setProgram(status, this.xyzacProgram ? (program.execution_source_line ?? program.current_line) : program.line);
- if (status === "completed" && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) this.task.transition("STOPPED");
+ if ((status === "completed" || this.program.snapshot().program_status === "completed") && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) { this.task.transition("STOPPED"); if (this.genericKinematics) this.task.setProgram("completed", this.program.currentLine); }
if (this.xyzacProgram && this.task.state.mode === "MDI" && this.task.state.state === "RUNNING" && this.wasmMotion.read().state.queue_depth === 0) this.task.transition("STOPPED");
}
const snapshotState = { tick: this.tick, cycles, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), plc: this.plc.export(), hal_runtime: this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export(), device_io: this.xyzacDeviceIo ? this.xyzacDeviceIo.export() : this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), xyzac_timing: { servo_cycle_time: this.xyzacMotionCycleTime, task_cycle_ticks: this.xyzacTaskCycleTicks } };
@@ -2314,13 +2375,17 @@ export class SimSession {
return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null,
}), finalStatus);
}
+ if (this.genericKinematics) {
+ const result = this.genericWasmProgramLoad(input);
+ return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, task: this.task.export(), motion_authority: "wasm", wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
+ }
restoreMode();
- const result = this.program.load(source, input.payload?.path ?? null);
+ const result = this.genericKinematics ? this.genericWasmProgramLoad(input) : this.program.load(source, input.payload?.path ?? null);
if (result.status === "verified") {
- this.authoritative.reset();
- this.wasmMotion.reset();
- this.wasmMotion.setPaused(false);
- this.motion.applyWasmPosition(this.wasmMotion.read().state.position, 0, this.tick);
+ if (!this.genericKinematics) {
+ this.authoritative.reset(); this.wasmMotion.reset(); this.wasmMotion.setPaused(false);
+ this.motion.applyWasmPosition(this.wasmMotion.read().state.position, 0, this.tick);
+ }
this.task.setProgram("loaded", 0);
const preview = this.program.preview();
result.preview = preview.preview ?? { status: "error", code: preview.code ?? "program_preview_failed" };
@@ -2344,6 +2409,16 @@ export class SimSession {
if (result.status === "verified") { this.wasmMotion.setPaused(false); this.task.setMode("AUTO"); this.wasmHomeLimit.setMode("COORD"); if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); }
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, reopened_taskfile: ["completed", "aborted"].includes(previousProgramStatus), start_line: 0, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
}
+ if (this.genericKinematics) {
+ const previousProgramStatus = this.program.status;
+ const result = this.program.start();
+ if (result.status === "verified") {
+ this.wasmMotion.setPaused(false); this.task.setMode("AUTO");
+ if (["STOPPED", "READY"].includes(this.task.state.state)) { if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); }
+ this.task.setProgram("running", this.program.currentLine);
+ }
+ return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, reopened_taskfile: ["completed", "aborted"].includes(previousProgramStatus), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
+ }
const result = this.program.start();
if (result.status === "verified") {
this.task.setMode("AUTO");
@@ -2405,14 +2480,16 @@ export class SimSession {
if (result.status === "verified") {
if (action === "pause") {
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(true);
- this.task.transition("PAUSED");
+ if (this.task.state.state === "RUNNING") this.task.transition("PAUSED");
+ if (this.genericKinematics) this.task.setProgram("paused", this.program.currentLine);
} else if (action === "resume") {
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
- this.task.transition("RUNNING");
+ if (this.task.state.state === "PAUSED") this.task.transition("RUNNING");
+ if (this.genericKinematics) this.task.setProgram("running", this.program.currentLine);
} else {
if (this.motionAuthority === "wasm") this.wasmMotion.abort();
else this.authoritative.abort();
- this.task.transition("STOPPED");
+ if (["RUNNING", "PAUSED"].includes(this.task.state.state)) this.task.transition("STOPPED");
this.task.setProgram("aborted", this.program.currentLine);
}
}
diff --git a/web/worker/test_generic_machine_cases.mjs b/web/worker/test_generic_machine_cases.mjs
new file mode 100644
index 0000000..f5b7eb1
--- /dev/null
+++ b/web/worker/test_generic_machine_cases.mjs
@@ -0,0 +1,46 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import createModule from "../../build-wasm/linuxcnc-sim-core.js";
+import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
+import { MACHINE_DEFINITIONS } from "../workspace/machine-definitions.mjs";
+import { CONTEXT_ID, SimSession } from "./sim-session.mjs";
+
+const root = "/home/mes006/linuxcncWebWASM";
+const fetchImpl = async (url) => {
+ const relative = String(url).replace(/^\.?\//, "");
+ for (const path of [`${root}/${relative}`, `${root}/web/react/public/${relative}`]) {
+ try { const body = await readFile(path); return { ok: true, text: async () => body.toString("utf8") }; } catch { /* try next root */ }
+ }
+ return { ok: false, text: async () => "" };
+};
+const wasmBinary = await readFile(`${root}/build-wasm/linuxcnc-sim-core.wasm`);
+const rows = [];
+
+for (const machine of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "generic-wasm")) {
+ const core = await createModule({ wasmBinary });
+ const session = new SimSession(core, { sessionId: machine.id, planner: "queue" });
+ let seq = 0;
+ const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: machine.id, kind, seq: ++seq, operation_id: `${machine.id}-${seq}`, payload });
+ send("HELLO"); send("SESSION_ATTACH");
+ const bundle = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
+ assert.equal(bundle.status, "ready", machine.id);
+ const configuration = toWorkerConfiguration(bundle);
+ const workspace = send("LOAD_WORKSPACE", { root_path: "/workspace", configuration });
+ assert.equal(workspace.kind, "COMMAND_RESULT", machine.id);
+ const loaded = send("PROGRAM_LOAD", { source: bundle.files[machine.program], path: machine.program });
+ assert.equal(loaded.kind, "COMMAND_RESULT", `${machine.id}: ${loaded.payload?.code ?? "load"}`);
+ assert.equal(send("PROGRAM_RUN").kind, "COMMAND_RESULT", machine.id);
+ let last = null;
+ for (let tick = 0; tick < 12000; tick += 1) {
+ last = send("SIM_STEP", { cycles: 1 });
+ assert.notEqual(last.kind, "ERROR", `${machine.id}: ${last.payload?.code ?? "step"}`);
+ if (last.payload?.program?.program_status === "completed" && Number(last.payload?.wasm_motion?.state?.queue_depth ?? 0) === 0) break;
+ }
+ assert.equal(last?.payload?.program?.program_status, "completed", machine.id);
+ const joints = last.payload.wasm_motion.state.joint_commanded;
+ assert.equal(joints.length, machine.joint_count, machine.id);
+ assert.ok(joints.every(Number.isFinite), machine.id);
+ rows.push({ id: machine.id, kinematics: machine.kinematics, coordinates: machine.coordinates, program: machine.program, joints });
+}
+
+console.log(JSON.stringify({ schema: "generic-wasm-machine-cases.v1", status: "verified", case_count: rows.length, rows }));
diff --git a/web/worker/test_jog_limit_recovery.mjs b/web/worker/test_jog_limit_recovery.mjs
index 4deef31..eb6753b 100644
--- a/web/worker/test_jog_limit_recovery.mjs
+++ b/web/worker/test_jog_limit_recovery.mjs
@@ -11,7 +11,7 @@ await mkdir(output, { recursive: true });
const fetchImpl = async (url) => { try { const body = await readFile(new URL(String(url).replace(/^\//, ''), root), 'utf8'); return { ok: true, text: async () => body }; } catch { return { ok: false }; } };
const report = { report_id: 'jog-limit-recovery-v1', status: 'running', machines: [] };
try {
- for (const definition of MACHINE_DEFINITIONS) {
+ for (const definition of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
const core = await createModule({ wasmBinary: await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root)) });
const session = new XyzacSession(core, { sessionId: `jog-limit-${definition.id}`, planner: 'linuxcnc-tp' }); let seq = 0;
const send = (kind, payload = {}, expected = null) => {
diff --git a/web/worker/test_trt_checkpoint_exact.mjs b/web/worker/test_trt_checkpoint_exact.mjs
index 78618b4..7b8d934 100644
--- a/web/worker/test_trt_checkpoint_exact.mjs
+++ b/web/worker/test_trt_checkpoint_exact.mjs
@@ -35,7 +35,7 @@ async function open(machine) {
return { session, core, send, adoptSequence: (value) => { seq = value; } };
}
const rows = [];
-for (const machine of MACHINE_DEFINITIONS) {
+for (const machine of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
const original = await open(machine);
const idle = original.send("CHECKPOINT").payload.checkpoint;
const idleRestored = await open(machine);
diff --git a/web/worker/test_trt_configuration.mjs b/web/worker/test_trt_configuration.mjs
index d4f11e1..fca46d7 100644
--- a/web/worker/test_trt_configuration.mjs
+++ b/web/worker/test_trt_configuration.mjs
@@ -24,7 +24,7 @@ function edit(bundle, values) {
return { ...bundle, files: { ...bundle.files, [bundle.entrypoint]: content } };
}
const rows = [];
-for (const machine of MACHINE_DEFINITIONS) {
+for (const machine of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
const original = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
const modified = edit(original, { "AXIS_X.MAX_VELOCITY": 4, "AXIS_X.MAX_ACCELERATION": 8, "AXIS_X.MAX_LIMIT": 15, "JOINT_0.MAX_LIMIT": 17, "TRAJ.MAX_LINEAR_VELOCITY": 13, "EMCMOT.SERVO_PERIOD": 2000000, "TASK.CYCLE_TIME": .02, "DISPLAY.MAX_FEED_OVERRIDE": 1.3 });
const config = toWorkerConfiguration(modified);
diff --git a/web/worker/test_trt_switch_stationary.mjs b/web/worker/test_trt_switch_stationary.mjs
index e27058e..cd82152 100644
--- a/web/worker/test_trt_switch_stationary.mjs
+++ b/web/worker/test_trt_switch_stationary.mjs
@@ -10,7 +10,7 @@ const fetchImpl = async (url) => { try { const body = await readFile(new URL(Str
const report = { report_id: 'trt-stationary-switch-worker-v1', status: 'running', machines: [] };
const closeJoints = (actual, expected, label) => actual.forEach((v, i) => assert.ok(Math.abs(v - expected[i]) < 1e-8, `${label} J${i}: ${v} != ${expected[i]}`));
try {
- for (const definition of MACHINE_DEFINITIONS) {
+ for (const definition of MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "trt")) {
const core = await createModule({ wasmBinary: await readFile(new URL('build-wasm/linuxcnc-sim-core.wasm', root)) });
const session = new SimSession(core, { sessionId: `switch-${definition.id}` }); let seq = 0;
const send = (kind, payload = {}, expected = 'COMMAND_RESULT') => {
diff --git a/web/worker/wasm-generic-kinematics.mjs b/web/worker/wasm-generic-kinematics.mjs
new file mode 100644
index 0000000..afff900
--- /dev/null
+++ b/web/worker/wasm-generic-kinematics.mjs
@@ -0,0 +1,100 @@
+import { fnv1a64 } from "./replay.mjs";
+
+const CONFIG_SIZE = 1608;
+const KINEMATICS_SIZE = 1760;
+const POSE_SIZE = 72;
+const JOINT_BYTES = 9 * 8;
+const CONFIG = Object.freeze({ type: 0, mode: 4, joint_count: 8, kinematics_joint_count: 12, extra_joint_count: 16, coordinates: 20, limit_enabled: 1448, joint_min: 1464, joint_max: 1536 });
+const POSE_AXES = Object.freeze(["x", "y", "z", "a", "b", "c", "u", "v", "w"]);
+const TYPES = Object.freeze({ trivkins: 1, "xyzab-tdr": 1, xyzac: 2, xyzbc: 3, xyzbcw: 4, corexy: 5, rose: 6, maxkins: 7, rotatekins: 8, "5axiskins": 9, scara: 10, pumakins: 11, "scorbot-kins": 12, genserkins: 13, lineardeltakins: 14, rotarydeltakins: 15, tripodkins: 16, genhexkins: 17, pentakins: 18 });
+
+function call(core, name, returnType, argTypes, args) {
+ const fn = core?.[`_${name}`] ?? core?.[name];
+ if (typeof fn === "function") return fn(...args);
+ if (typeof core?.ccall === "function") return core.ccall(name, returnType, argTypes, args);
+ throw new Error(`missing core export: ${name}`);
+}
+
+export class WasmGenericKinematics {
+ constructor(core, { type, coordinates, jointCount, kinematicsJointCount = jointCount, extraJointCount = jointCount - kinematicsJointCount, limits = {}, linearUnits = "mm", angularUnits = "deg", parameters = {} }) {
+ this.core = core;
+ this.type = String(type ?? "").toLowerCase();
+ this.coordinates = String(coordinates ?? "").replace(/\s+/g, "").toUpperCase();
+ this.jointCount = Number(jointCount);
+ this.kinematicsJointCount = Number(kinematicsJointCount);
+ this.extraJointCount = Number(extraJointCount);
+ this.limits = structuredClone(limits);
+ this.linearUnits = linearUnits;
+ this.angularUnits = angularUnits;
+ this.parameters = structuredClone(parameters ?? {});
+ const typeId = TYPES[this.type];
+ if (!typeId || !this.coordinates || !Number.isInteger(this.jointCount) || this.jointCount < 1 || this.jointCount > 9 || !Number.isInteger(this.kinematicsJointCount) || this.kinematicsJointCount < 1 || this.kinematicsJointCount > this.jointCount || this.extraJointCount !== this.jointCount - this.kinematicsJointCount) throw new Error("wasm_generic_kinematics_profile_invalid");
+ if (["sim_kinematics_init", "sim_kinematics_inverse", "sim_kinematics_forward", "sim_kinematics_home"].some((name) => typeof core?.[`_${name}`] !== "function" && typeof core?.[name] !== "function") || typeof core?._malloc !== "function") throw new Error("wasm_generic_kinematics_unavailable");
+ this.config = core._malloc(CONFIG_SIZE); this.kinematics = core._malloc(KINEMATICS_SIZE); this.pose = core._malloc(POSE_SIZE); this.joints = core._malloc(JOINT_BYTES); this.info = core._malloc(32);
+ this.pointers = [this.config, this.kinematics, this.pose, this.joints, this.info];
+ if (this.pointers.some((pointer) => !pointer)) throw new Error("wasm_generic_kinematics_allocation_failed");
+ core.HEAPU8.fill(0, this.config, this.config + CONFIG_SIZE); core.HEAPU8.fill(0, this.kinematics, this.kinematics + KINEMATICS_SIZE); core.HEAPU8.fill(0, this.pose, this.pose + POSE_SIZE); core.HEAPU8.fill(0, this.joints, this.joints + JOINT_BYTES); core.HEAPU8.fill(0, this.info, this.info + 32);
+ const view = new DataView(core.HEAPU8.buffer, this.config, CONFIG_SIZE);
+ view.setInt32(CONFIG.type, typeId, true); view.setInt32(CONFIG.mode, 0, true); view.setUint32(CONFIG.joint_count, this.jointCount, true); view.setUint32(CONFIG.kinematics_joint_count, this.kinematicsJointCount, true); view.setUint32(CONFIG.extra_joint_count, this.extraJointCount, true);
+ for (const [index, axis] of [...this.coordinates].entries()) {
+ core.HEAPU8[this.config + CONFIG.coordinates + index] = axis.charCodeAt(0);
+ const range = limits[axis.toLowerCase()];
+ if (!range) continue;
+ if (range) {
+ if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isFinite) || range[0] >= range[1]) throw new Error(`wasm_generic_kinematics_limit_invalid:${axis}`);
+ core.HEAPU8[this.config + CONFIG.limit_enabled + index] = 1;
+ view.setFloat64(CONFIG.joint_min + index * 8, range[0], true); view.setFloat64(CONFIG.joint_max + index * 8, range[1], true);
+ }
+ }
+ const scalarOffsets = { x_rot_point: 40, y_rot_point: 48, z_rot_point: 56, x_offset: 64, y_offset: 72, z_offset: 80, tool_offset: 88, pivot_length: 96, puma_a2: 104, puma_a3: 112, puma_d3: 120, puma_d4: 128, puma_d6: 136 };
+ for (const [key, offset] of Object.entries(scalarOffsets)) if (Number.isFinite(Number(this.parameters[key]))) view.setFloat64(offset, Number(this.parameters[key]), true);
+ if (Array.isArray(this.parameters.dh_a) && Array.isArray(this.parameters.dh_alpha) && Array.isArray(this.parameters.dh_d)) {
+ if (![this.parameters.dh_a, this.parameters.dh_alpha, this.parameters.dh_d].every((array) => array.length === 6 && array.every(Number.isFinite))) throw new Error("wasm_generic_kinematics_dh_invalid");
+ view.setUint32(192, 6, true);
+ for (let index = 0; index < 6; index += 1) {
+ view.setFloat64(200 + index * 8, this.parameters.dh_a[index], true);
+ view.setFloat64(272 + index * 8, this.parameters.dh_alpha[index], true);
+ view.setFloat64(344 + index * 8, this.parameters.dh_d[index], true);
+ }
+ }
+ const initialized = call(core, "sim_kinematics_init", "number", ["number", "number"], [this.kinematics, this.config]);
+ if (initialized !== 0) throw new Error(`wasm_generic_kinematics_init_${initialized}`);
+ this.lastJoints = new Array(this.jointCount).fill(0); this.lastPose = Object.fromEntries(POSE_AXES.map((axis) => [axis, 0]));
+ const home = this.home();
+ if (home.status !== "verified") throw new Error(`wasm_generic_kinematics_home_${home.code}`);
+ }
+
+ inverse(pose) {
+ if (!pose || POSE_AXES.some((axis) => !Number.isFinite(Number(pose[axis] ?? 0)))) return { status: "error", code: "wasm_generic_kinematics_pose_nonfinite" };
+ this.core.HEAPF64.set(POSE_AXES.map((axis) => Number(pose[axis] ?? 0)), this.pose / 8);
+ const result = call(this.core, "sim_kinematics_inverse", "number", ["number", "number", "number", "number"], [this.kinematics, this.pose, this.joints, this.kinematicsJointCount]);
+ if (result !== 0) return { status: "error", code: `wasm_generic_kinematics_inverse_${result}`, result };
+ this.lastJoints = [...this.core.HEAPF64.subarray(this.joints / 8, this.joints / 8 + this.jointCount)]; this.lastPose = Object.fromEntries(POSE_AXES.map((axis) => [axis, Number(pose[axis] ?? 0)]));
+ return { status: "verified", joints: [...this.lastJoints] };
+ }
+
+ forward(joints = this.lastJoints) {
+ if (!Array.isArray(joints) || joints.length < this.jointCount || joints.slice(0, this.jointCount).some((value) => !Number.isFinite(value))) return { status: "error", code: "wasm_generic_kinematics_joints_invalid" };
+ this.core.HEAPF64.set(joints.slice(0, this.jointCount), this.joints / 8);
+ const result = call(this.core, "sim_kinematics_forward", "number", ["number", "number", "number", "number"], [this.kinematics, this.joints, this.kinematicsJointCount, this.pose]);
+ if (result !== 0) return { status: "error", code: `wasm_generic_kinematics_forward_${result}`, result };
+ this.lastJoints = joints.slice(0, this.jointCount); this.lastPose = Object.fromEntries(POSE_AXES.map((axis, index) => [axis, this.core.HEAPF64[this.pose / 8 + index]]));
+ return { status: "verified", pose: { ...this.lastPose }, joints: [...this.lastJoints] };
+ }
+
+ home() {
+ const result = call(this.core, "sim_kinematics_home", "number", ["number", "number", "number", "number"], [this.kinematics, this.joints, this.kinematicsJointCount, this.pose]);
+ if (result !== 0) return { status: "error", code: `wasm_generic_kinematics_home_${result}`, result };
+ this.lastJoints = [...this.core.HEAPF64.subarray(this.joints / 8, this.joints / 8 + this.jointCount)]; this.lastPose = Object.fromEntries(POSE_AXES.map((axis, index) => [axis, this.core.HEAPF64[this.pose / 8 + index]]));
+ return { status: "verified", pose: { ...this.lastPose }, joints: [...this.lastJoints] };
+ }
+
+ snapshot(pose = this.lastPose, joints = this.lastJoints) {
+ const unsigned = { schema: "wasm-generic-kinematics.v1", type: this.type, coordinates: this.coordinates, joint_count: this.jointCount, linear_units: this.linearUnits, angular_units: this.angularUnits, pose: { ...pose }, joints: [...joints], limits: structuredClone(this.limits), source: "linuxcnc-wasm-sim_kinematics" };
+ return { status: "verified", ...unsigned, state_hash: fnv1a64(unsigned) };
+ }
+
+ destroy() { for (const pointer of this.pointers ?? []) if (pointer && typeof this.core?._free === "function") this.core._free(pointer); this.pointers = []; }
+}
+
+export { TYPES as WASM_KINEMATICS_TYPES };
diff --git a/web/worker/wasm-motion-queue.mjs b/web/worker/wasm-motion-queue.mjs
index 4498e3a..a9d21ce 100644
--- a/web/worker/wasm-motion-queue.mjs
+++ b/web/worker/wasm-motion-queue.mjs
@@ -74,6 +74,8 @@ export class WasmMotionQueue {
this.cycleTime = cycleTime;
this.velocityLimit = velocityLimit;
this.controls = controls();
+ this.kinematics = null;
+ this.kinematicState = null;
if (!this.available) return;
const queueSize = call(core, "sim_motion_size", "number", [], []);
this.queueSize = queueSize;
@@ -110,6 +112,25 @@ export class WasmMotionQueue {
return result === 0 ? { status: "verified", source_line: segment.source_line ?? null } : { status: "error", code: `wasm_motion_enqueue_${result}`, reason: MOTION_ERROR_REASONS[result] ?? `wasm_motion_enqueue_${result}`, message_key: MOTION_ERROR_REASONS[result] ?? `wasm_motion_enqueue_${result}`, source: "linuxcnc-wasm-motion" };
}
+ setPosition(position) {
+ if (!this.available || !position || this.read().state.queue_depth !== 0) return { status: "error", code: "wasm_motion_position_requires_idle" };
+ const pose = ["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => Number(position[axis] ?? 0));
+ if (pose.some((value) => !Number.isFinite(value))) return { status: "error", code: "wasm_motion_position_nonfinite" };
+ const pointer = this.core._malloc(72);
+ if (!pointer) return { status: "error", code: "wasm_motion_position_allocation_failed" };
+ try {
+ this.core.HEAPF64.set(pose, pointer / 8);
+ const result = call(this.core, "sim_motion_set_position", "number", ["number", "number"], [this.queue, pointer]);
+ return result === 0 ? this.read() : { status: "error", code: `wasm_motion_position_${result}` };
+ } finally { this.core._free(pointer); }
+ }
+
+ setKinematics(kinematics) {
+ this.kinematics = kinematics ?? null;
+ this.kinematicState = this.kinematics?.snapshot?.() ?? null;
+ return this.read();
+ }
+
reset() {
if (!this.available) return this.read();
const previousFeedOverride = this.controls.feed_override;
@@ -203,7 +224,15 @@ export class WasmMotionQueue {
if (this.controls.paused) return { status: "error", code: "wasm_motion_step_-605" };
const result = call(this.core, "sim_motion_step", "number", ["number"], [this.queue]);
if (result !== 0) return { status: "error", code: `wasm_motion_step_${result}`, reason: MOTION_ERROR_REASONS[result] ?? `wasm_motion_step_${result}`, message_key: MOTION_ERROR_REASONS[result] ?? `wasm_motion_step_${result}`, source: "linuxcnc-wasm-motion" };
- return this.read();
+ const stepped = this.read();
+ if (stepped.status === "verified" && this.kinematics) {
+ const inverse = this.kinematics.inverse(stepped.state.position);
+ if (inverse.status !== "verified") return { status: "error", code: inverse.code ?? "wasm_motion_kinematics_inverse_failed", source: "linuxcnc-wasm-sim_kinematics" };
+ stepped.state.joint_commanded = [...inverse.joints];
+ stepped.state.kinematics = this.kinematics.snapshot(stepped.state.position, inverse.joints);
+ this.kinematicState = stepped.state.kinematics;
+ }
+ return stepped;
}
read() {
@@ -212,7 +241,13 @@ export class WasmMotionQueue {
if (result !== 0) return { status: "error", code: `wasm_motion_read_${result}` };
const doubles = this.core.HEAPF64.subarray(this.state / 8, this.state / 8 + 11);
const ints = this.core.HEAPU32.subarray(this.state / 4 + 22, this.state / 4 + 26);
- return { status: "verified", state: { position: { x: doubles[0], y: doubles[1], z: doubles[2], a: doubles[3], b: doubles[4], c: doubles[5], u: doubles[6], v: doubles[7], w: doubles[8] }, velocity: this.controls.paused ? 0 : doubles[9], progress: doubles[10], tick: ints[0], queue_depth: ints[1], active_id: ints[2], active_tag: ints[3], motion_type: this.core.HEAP32[this.state / 4 + 26], status: this.core.HEAP32[this.state / 4 + 27], source: "wasm_motion_queue" } };
+ const position = { x: doubles[0], y: doubles[1], z: doubles[2], a: doubles[3], b: doubles[4], c: doubles[5], u: doubles[6], v: doubles[7], w: doubles[8] };
+ const state = { position, velocity: this.controls.paused ? 0 : doubles[9], progress: doubles[10], tick: ints[0], queue_depth: ints[1], active_id: ints[2], active_tag: ints[3], motion_type: this.core.HEAP32[this.state / 4 + 26], status: this.core.HEAP32[this.state / 4 + 27], source: "wasm_motion_queue" };
+ if (this.kinematics) {
+ const kin = this.kinematics.snapshot();
+ state.joint_commanded = [...kin.joints]; state.kinematics = kin;
+ }
+ return { status: "verified", state };
}
exportState() {
diff --git a/web/workspace/five-axis-machine-catalog.json b/web/workspace/five-axis-machine-catalog.json
index f021fd4..31d8d95 100644
--- a/web/workspace/five-axis-machine-catalog.json
+++ b/web/workspace/five-axis-machine-catalog.json
@@ -37,7 +37,13 @@
"linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/test-xyzac.ngc"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/xyzac-trt-kins.c",
- "designParameters": ["rotaryPoint", "tiltToRotaryOffset", "toolOffset", "physicalZero"]
+ "designParameters": [
+ "rotaryPoint",
+ "tiltToRotaryOffset",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "physicalStructureRef": "five-axis-physical-structures.json#xyzac-trt"
},
{
"id": "xyzbc-trt",
@@ -57,7 +63,13 @@
"linuxcnc/configs/sim/gmoccapy/non_trivial_kinematics/table-rotary-tilting/examples/boat-xyzbc.ngc"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/xyzbc-trt-kins.c",
- "designParameters": ["rotaryPoint", "tiltToRotaryOffset", "toolOffset", "physicalZero"]
+ "designParameters": [
+ "rotaryPoint",
+ "tiltToRotaryOffset",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "physicalStructureRef": "five-axis-physical-structures.json#xyzbc-trt"
},
{
"id": "xyzab-tdr",
@@ -77,7 +89,16 @@
"linuxcnc/configs/sim/qtdragon/qtdragon_xyz45/qtdragon_xyzab.ini"
],
"kinematicsSource": "linuxcnc/src/hal/components/xyzab_tdr_kins.comp",
- "designParameters": ["xRotPoint", "yRotPoint", "zRotPoint", "xOffset", "zOffset", "toolOffset", "physicalZero"]
+ "designParameters": [
+ "xRotPoint",
+ "yRotPoint",
+ "zRotPoint",
+ "xOffset",
+ "zOffset",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "physicalStructureRef": "five-axis-physical-structures.json#xyzab-tdr"
},
{
"id": "bridge-mill-5axiskins",
@@ -96,7 +117,14 @@
"linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZCW.ini"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/5axiskins.c",
- "designParameters": ["jointCoordinateMap", "duplicateAxisMap", "toolDirectionAxis", "toolOffset", "physicalZero"]
+ "designParameters": [
+ "jointCoordinateMap",
+ "duplicateAxisMap",
+ "toolDirectionAxis",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "physicalStructureRef": "five-axis-physical-structures.json#bridge-mill-5axiskins"
},
{
"id": "max5-maxkins",
@@ -116,7 +144,14 @@
"linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZAB.ini"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/maxkins.c",
- "designParameters": ["rotaryAxisOrder", "rotaryCenters", "uvwTransform", "toolOffset", "physicalZero"]
+ "designParameters": [
+ "rotaryAxisOrder",
+ "rotaryCenters",
+ "uvwTransform",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "physicalStructureRef": "five-axis-physical-structures.json#max5-maxkins"
},
{
"id": "hexapod-genhexkins",
@@ -136,7 +171,15 @@
"linuxcnc/src/emc/kinematics/genhexkins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/genhexkins.c",
- "designParameters": ["baseJointPoints", "platformJointPoints", "spindleOffset", "screwLead", "iterationLimits", "physicalZero"]
+ "designParameters": [
+ "baseJointPoints",
+ "platformJointPoints",
+ "spindleOffset",
+ "screwLead",
+ "iterationLimits",
+ "physicalZero"
+ ],
+ "physicalStructureRef": "five-axis-physical-structures.json#hexapod-genhexkins"
},
{
"id": "pentapod-pentakins",
@@ -156,7 +199,14 @@
"linuxcnc/src/emc/kinematics/pentakins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/pentakins.c",
- "designParameters": ["baseJointPoints", "effectorGeometry", "toolOffset", "convergenceCriterion", "physicalZero"]
+ "designParameters": [
+ "baseJointPoints",
+ "effectorGeometry",
+ "toolOffset",
+ "convergenceCriterion",
+ "physicalZero"
+ ],
+ "physicalStructureRef": "five-axis-physical-structures.json#pentapod-pentakins"
},
{
"id": "puma-pumakins",
@@ -176,7 +226,15 @@
"linuxcnc/src/emc/kinematics/pumakins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/pumakins.c",
- "designParameters": ["A2", "A3", "D3", "D4", "toolOffset", "physicalZero"]
+ "designParameters": [
+ "A2",
+ "A3",
+ "D3",
+ "D4",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "physicalStructureRef": "five-axis-physical-structures.json#puma-pumakins"
},
{
"id": "puma-genserkins",
@@ -196,7 +254,13 @@
"linuxcnc/src/emc/kinematics/genserkins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/genserkins.c",
- "designParameters": ["DHParameters", "jointLimits", "toolOffset", "physicalZero"]
+ "designParameters": [
+ "DHParameters",
+ "jointLimits",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "physicalStructureRef": "five-axis-physical-structures.json#puma-genserkins"
},
{
"id": "trivkins-xyzab",
@@ -216,7 +280,12 @@
"linuxcnc/src/emc/kinematics/trivkins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/trivkins.c",
- "designParameters": ["coordinateOrder", "duplicateAxisMap", "physicalZero"]
+ "designParameters": [
+ "coordinateOrder",
+ "duplicateAxisMap",
+ "physicalZero"
+ ],
+ "physicalStructureRef": "five-axis-physical-structures.json#trivkins-xyzab"
},
{
"id": "trivkins-xyzcw",
@@ -234,7 +303,12 @@
"linuxcnc/src/emc/kinematics/trivkins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/trivkins.c",
- "designParameters": ["coordinateOrder", "toolDirectionAxis", "physicalZero"]
+ "designParameters": [
+ "coordinateOrder",
+ "toolDirectionAxis",
+ "physicalZero"
+ ],
+ "physicalStructureRef": "five-axis-physical-structures.json#trivkins-xyzcw"
},
{
"id": "trivkins-xyzya",
@@ -253,7 +327,12 @@
"linuxcnc/src/emc/kinematics/trivkins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/trivkins.c",
- "designParameters": ["coordinateOrder", "duplicateAxisMap", "physicalZero"]
+ "designParameters": [
+ "coordinateOrder",
+ "duplicateAxisMap",
+ "physicalZero"
+ ],
+ "physicalStructureRef": "five-axis-physical-structures.json#trivkins-xyzya"
},
{
"id": "scorbot-kins",
@@ -271,7 +350,13 @@
"linuxcnc/src/emc/kinematics/scorbot-kins.c"
],
"kinematicsSource": "linuxcnc/src/emc/kinematics/scorbot-kins.c",
- "designParameters": ["robotLinkParameters", "jointLimits", "toolOffset", "physicalZero"]
+ "designParameters": [
+ "robotLinkParameters",
+ "jointLimits",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "physicalStructureRef": "five-axis-physical-structures.json#scorbot-kins"
}
]
}
diff --git a/web/workspace/five-axis-physical-structures.json b/web/workspace/five-axis-physical-structures.json
new file mode 100644
index 0000000..104c45c
--- /dev/null
+++ b/web/workspace/five-axis-physical-structures.json
@@ -0,0 +1,526 @@
+{
+ "schema": "linuxcnc.five-axis-physical-structures/1",
+ "units": {
+ "length": "mm",
+ "angle": "deg",
+ "gltf_length": "m",
+ "gltf_angle": "rad"
+ },
+ "sourcePolicy": "real-reference values are not calibration values; design-reference models require explicit design_reference status",
+ "cases": [
+ {
+ "id": "xyzac-trt",
+ "family": "table-rotary-tilting",
+ "reference": "HERMLE C 42 U 类摆动转台",
+ "status": "design-plus-real-reference",
+ "parts": [
+ "base",
+ "x",
+ "y",
+ "z",
+ "tool",
+ "a-tilt-frame",
+ "c-table",
+ "workholding",
+ "guard"
+ ],
+ "jointTree": "base→x→y→z→tool; base→a→c→work",
+ "parameters": [
+ "rotaryPoint",
+ "tiltToRotaryOffset",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "source": "https://www.hermle.de/en/machining-centres-automation/models/machining-centre-c-42-gen2/",
+ "assetPlan": {
+ "zeroPoseRequired": true,
+ "separateJointNodes": true,
+ "includeOriginHelpers": true,
+ "metadata": [
+ "source",
+ "license",
+ "kinematics",
+ "coordinates",
+ "zero_pose",
+ "joint_limits",
+ "calibration_status"
+ ]
+ }
+ },
+ {
+ "id": "xyzbc-trt",
+ "family": "table-rotary-tilting",
+ "reference": "Haas UMC-500/750 B/C 摆台",
+ "status": "real-reference",
+ "parts": [
+ "base",
+ "x",
+ "y",
+ "z",
+ "tool",
+ "b-trunnion",
+ "c-table",
+ "workholding",
+ "guard"
+ ],
+ "jointTree": "base→x→y→z→tool; base→b→c→work",
+ "parameters": [
+ "MRZP",
+ "bCIntersection",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "source": "https://www.haascnc.com/service/online-manuals/umc-series/umc---geometry.html",
+ "assetPlan": {
+ "zeroPoseRequired": true,
+ "separateJointNodes": true,
+ "includeOriginHelpers": true,
+ "metadata": [
+ "source",
+ "license",
+ "kinematics",
+ "coordinates",
+ "zero_pose",
+ "joint_limits",
+ "calibration_status"
+ ]
+ }
+ },
+ {
+ "id": "xyzab-tdr",
+ "family": "table-dual-rotary",
+ "reference": "LinuxCNC xyzab_tdr 双转台;双转台五轴通用设计",
+ "status": "linuxcnc-reference-plus-design",
+ "parts": [
+ "base",
+ "x",
+ "y",
+ "z",
+ "tool",
+ "a-outer-table",
+ "b-inner-table",
+ "workholding"
+ ],
+ "jointTree": "base→x→y→z→tool; base→a→b→work",
+ "parameters": [
+ "xRotPoint",
+ "yRotPoint",
+ "zRotPoint",
+ "xOffset",
+ "zOffset",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "source": "https://linuxcnc.org/docs/html/man/man9/xyzab_tdr_kins.9.html",
+ "assetPlan": {
+ "zeroPoseRequired": true,
+ "separateJointNodes": true,
+ "includeOriginHelpers": true,
+ "metadata": [
+ "source",
+ "license",
+ "kinematics",
+ "coordinates",
+ "zero_pose",
+ "joint_limits",
+ "calibration_status"
+ ]
+ }
+ },
+ {
+ "id": "bridge-mill-5axiskins",
+ "family": "bridge-mill",
+ "reference": "FIDIA BRIDGE / AWEA Mega 5P 固定龙门双摆头",
+ "status": "real-reference-plus-design",
+ "parts": [
+ "foundation",
+ "left-column",
+ "right-column",
+ "bridge",
+ "x-table",
+ "y-carriage",
+ "z-ram",
+ "b-head",
+ "c-head",
+ "y-left-drive",
+ "y-right-drive"
+ ],
+ "jointTree": "base→x→y→z→b→c→tool; y-left/y-right duplicate gantry joints; w is a tool-direction coordinate",
+ "parameters": [
+ "jointCoordinateMap",
+ "duplicateAxisMap",
+ "toolDirectionAxis",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "source": "https://www.fidia.it/en/products/bridge/",
+ "assetPlan": {
+ "zeroPoseRequired": true,
+ "separateJointNodes": true,
+ "includeOriginHelpers": true,
+ "metadata": [
+ "source",
+ "license",
+ "kinematics",
+ "coordinates",
+ "zero_pose",
+ "joint_limits",
+ "calibration_status"
+ ]
+ }
+ },
+ {
+ "id": "max5-maxkins",
+ "family": "table-and-head-rotary",
+ "reference": "LinuxCNC MAX5 桌面五轴:B 倾转头+C 水平转台",
+ "status": "linuxcnc-reference-plus-design",
+ "parts": [
+ "base",
+ "x",
+ "y",
+ "z",
+ "b-head",
+ "tool",
+ "c-horizontal-table",
+ "workholding"
+ ],
+ "jointTree": "base→x→y→z→b→tool; base→c→work",
+ "parameters": [
+ "pivotLength",
+ "rotaryCenters",
+ "uvwTransform",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "source": "https://linuxcnc.org/docs/master/html/man/man9/kins.9.html",
+ "assetPlan": {
+ "zeroPoseRequired": true,
+ "separateJointNodes": true,
+ "includeOriginHelpers": true,
+ "metadata": [
+ "source",
+ "license",
+ "kinematics",
+ "coordinates",
+ "zero_pose",
+ "joint_limits",
+ "calibration_status"
+ ]
+ }
+ },
+ {
+ "id": "hexapod-genhexkins",
+ "family": "parallel-platform",
+ "reference": "Giddings & Lewis Variax 六支链平台",
+ "status": "real-reference-plus-design",
+ "parts": [
+ "base-platform",
+ "strut-1",
+ "strut-2",
+ "strut-3",
+ "strut-4",
+ "strut-5",
+ "strut-6",
+ "moving-platform",
+ "spindle",
+ "tool"
+ ],
+ "jointTree": "base→six independent struts→moving-platform→tool",
+ "parameters": [
+ "baseJointPoints",
+ "platformJointPoints",
+ "spindleOffset",
+ "screwLead",
+ "iterationLimits",
+ "physicalZero"
+ ],
+ "source": "https://collection.sciencemuseumgroup.org.uk/objects/co8003352/pair-of-hexapod-legs-used-with-the-variax-machine-tool-made-by-giddings-lewis-of-wisconsin-in-1993",
+ "assetPlan": {
+ "zeroPoseRequired": true,
+ "separateJointNodes": true,
+ "includeOriginHelpers": true,
+ "metadata": [
+ "source",
+ "license",
+ "kinematics",
+ "coordinates",
+ "zero_pose",
+ "joint_limits",
+ "calibration_status"
+ ]
+ }
+ },
+ {
+ "id": "pentapod-pentakins",
+ "family": "parallel-platform",
+ "reference": "METROM Pentapod P-Series",
+ "status": "real-reference-plus-design",
+ "parts": [
+ "base-frame",
+ "strut-1",
+ "strut-2",
+ "strut-3",
+ "strut-4",
+ "strut-5",
+ "moving-platform",
+ "spindle",
+ "tool"
+ ],
+ "jointTree": "base→five independent struts→platform→tool",
+ "parameters": [
+ "baseJointPoints",
+ "effectorGeometry",
+ "toolOffset",
+ "convergenceCriterion",
+ "physicalZero"
+ ],
+ "source": "https://metrom.com/5-axis-machining-center/",
+ "assetPlan": {
+ "zeroPoseRequired": true,
+ "separateJointNodes": true,
+ "includeOriginHelpers": true,
+ "metadata": [
+ "source",
+ "license",
+ "kinematics",
+ "coordinates",
+ "zero_pose",
+ "joint_limits",
+ "calibration_status"
+ ]
+ }
+ },
+ {
+ "id": "puma-pumakins",
+ "family": "serial-robot",
+ "reference": "Unimation PUMA 560 类六轴机器人",
+ "status": "real-reference",
+ "parts": [
+ "base",
+ "j1-turntable",
+ "j2-shoulder",
+ "j3-elbow",
+ "j4-wrist-roll",
+ "j5-wrist-pitch",
+ "j6-wrist-yaw",
+ "flange",
+ "tool"
+ ],
+ "jointTree": "base→j1→j2→j3→j4→j5→j6→tool",
+ "parameters": [
+ "A2",
+ "A3",
+ "D3",
+ "D4",
+ "D6",
+ "jointLimits",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "source": "https://www.linuxcnc.org/docs/html/motion/switchkins.html",
+ "assetPlan": {
+ "zeroPoseRequired": true,
+ "separateJointNodes": true,
+ "includeOriginHelpers": true,
+ "metadata": [
+ "source",
+ "license",
+ "kinematics",
+ "coordinates",
+ "zero_pose",
+ "joint_limits",
+ "calibration_status"
+ ]
+ }
+ },
+ {
+ "id": "puma-genserkins",
+ "family": "serial-robot",
+ "reference": "PUMA 560 Modified-DH 六轴机器人",
+ "status": "real-reference-plus-design",
+ "parts": [
+ "base",
+ "j1",
+ "j2",
+ "j3",
+ "j4",
+ "j5",
+ "j6",
+ "flange",
+ "tool"
+ ],
+ "jointTree": "base→j1→j2→j3→j4→j5→j6→tool",
+ "parameters": [
+ "DHParameters",
+ "jointLimits",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "source": "https://www.linuxcnc.org/docs/master/html/fr/man/man9/kins.9.html",
+ "assetPlan": {
+ "zeroPoseRequired": true,
+ "separateJointNodes": true,
+ "includeOriginHelpers": true,
+ "metadata": [
+ "source",
+ "license",
+ "kinematics",
+ "coordinates",
+ "zero_pose",
+ "joint_limits",
+ "calibration_status"
+ ]
+ }
+ },
+ {
+ "id": "trivkins-xyzab",
+ "family": "identity-baseline",
+ "reference": "通用立式五轴双转台验证机",
+ "status": "design-reference",
+ "parts": [
+ "base",
+ "x",
+ "y",
+ "z",
+ "tool",
+ "a-frame",
+ "b-table",
+ "workholding"
+ ],
+ "jointTree": "base→x→y→z→tool; base→a→b→work",
+ "parameters": [
+ "coordinateOrder",
+ "duplicateAxisMap",
+ "physicalZero"
+ ],
+ "source": null,
+ "assetPlan": {
+ "zeroPoseRequired": true,
+ "separateJointNodes": true,
+ "includeOriginHelpers": true,
+ "metadata": [
+ "source",
+ "license",
+ "kinematics",
+ "coordinates",
+ "zero_pose",
+ "joint_limits",
+ "calibration_status"
+ ]
+ }
+ },
+ {
+ "id": "trivkins-xyzcw",
+ "family": "identity-baseline",
+ "reference": "通用桥式 C/W 刀向补偿验证机",
+ "status": "design-reference",
+ "parts": [
+ "base",
+ "x",
+ "y",
+ "z",
+ "tool",
+ "c-table",
+ "w-tool-sleeve",
+ "workholding"
+ ],
+ "jointTree": "base→x→y→z→tool; base→c→work; tool→w→tip",
+ "parameters": [
+ "coordinateOrder",
+ "toolDirectionAxis",
+ "physicalZero"
+ ],
+ "source": null,
+ "assetPlan": {
+ "zeroPoseRequired": true,
+ "separateJointNodes": true,
+ "includeOriginHelpers": true,
+ "metadata": [
+ "source",
+ "license",
+ "kinematics",
+ "coordinates",
+ "zero_pose",
+ "joint_limits",
+ "calibration_status"
+ ]
+ }
+ },
+ {
+ "id": "trivkins-xyzya",
+ "family": "identity-gantry-rotary",
+ "reference": "双 Y 龙门+A 转台验证机",
+ "status": "design-reference",
+ "parts": [
+ "base",
+ "x",
+ "y-left",
+ "y-right",
+ "z",
+ "tool",
+ "a-table",
+ "workholding"
+ ],
+ "jointTree": "five joints: x,y-left,z,y-right,a; y-left/y-right drive one gantry axis",
+ "parameters": [
+ "coordinateOrder",
+ "duplicateAxisMap",
+ "gantrySynchronization",
+ "physicalZero"
+ ],
+ "source": null,
+ "assetPlan": {
+ "zeroPoseRequired": true,
+ "separateJointNodes": true,
+ "includeOriginHelpers": true,
+ "metadata": [
+ "source",
+ "license",
+ "kinematics",
+ "coordinates",
+ "zero_pose",
+ "joint_limits",
+ "calibration_status"
+ ]
+ }
+ },
+ {
+ "id": "scorbot-kins",
+ "family": "serial-robot",
+ "reference": "SCORBOT-ER 4U/ER 4PC 五关节机器人",
+ "status": "real-reference-plus-design",
+ "parts": [
+ "base",
+ "j1-base-turn",
+ "j2-shoulder",
+ "j3-elbow",
+ "j4-pitch",
+ "j5-wrist-roll",
+ "flange",
+ "tool"
+ ],
+ "jointTree": "base→j1→j2→j3→j4→j5→tool",
+ "parameters": [
+ "robotLinkParameters",
+ "jointLimits",
+ "toolOffset",
+ "physicalZero"
+ ],
+ "source": "https://www.theoldrobots.com/book45/USBC-document.pdf",
+ "assetPlan": {
+ "zeroPoseRequired": true,
+ "separateJointNodes": true,
+ "includeOriginHelpers": true,
+ "metadata": [
+ "source",
+ "license",
+ "kinematics",
+ "coordinates",
+ "zero_pose",
+ "joint_limits",
+ "calibration_status"
+ ]
+ }
+ }
+ ]
+}
diff --git a/web/workspace/glb-axis-naming.mjs b/web/workspace/glb-axis-naming.mjs
new file mode 100644
index 0000000..541f828
--- /dev/null
+++ b/web/workspace/glb-axis-naming.mjs
@@ -0,0 +1,146 @@
+// GLB 节点名 → 数控轴 的识别规则,以及由旋转轴世界坐标推导的 TRT 标定候选。
+// 抽成纯函数模块,便于被分析器(TypeScript)和 Node 单测(test_glb_axis_naming.mjs)共用。
+
+export const CNC_AXIS_LETTERS = Object.freeze(["X", "Y", "Z", "A", "B", "C", "U", "V", "W"]);
+
+// 主要运动部件词:轴字母与它相邻时可信度最高(直线部件在前、回转部件在后)。
+export const PRIMARY_WORDS = Object.freeze(["SLIDE", "CARRIAGE", "SADDLE", "TRUNNION", "TILT", "TABLE", "FRAME", "PLATTER", "RAM", "TOOL"]);
+// 轴字母后出现这些词只说明“和某个轴相关”,属于零件级线索,优先级最低。
+export const PART_WORDS = Object.freeze(["LINEAR", "ROTARY", "BEARING", "BEARINGS", "DRIVE", "GUIDE", "RAIL", "BOLT", "CASTING", "MOTOR", "SHAFT", "HOLDER", "HEAD", "SPINDLE", "COLUMN", "SCREW", "WAY", "WAYS", "PEDESTAL", "CRADLE", "PLINTH", "FOOT"]);
+// 不含轴字母的通用词:需要结合机型或同模型其它命名消歧。
+export const AMBIGUOUS_WORDS = Object.freeze([{ word: "TILT", kind: "tilt" }, { word: "TRUNNION", kind: "tilt" }, { word: "ROTARY", kind: "rotary" }, { word: "PLATTER", kind: "rotary" }]);
+
+const axisSet = new Set(CNC_AXIS_LETTERS);
+const rankOfPrimary = (word) => 2 + PRIMARY_WORDS.indexOf(word) * 0.1;
+
+/**
+ * 把 GLB 节点名归类为某个数控轴。
+ * 规则优先级:JT_X 精确名 → 轴字母 + AXIS/JOINT → 轴字母 + 主要运动部件词 → 零件级线索 → 待消歧的通用词。
+ * @returns {{axis: string, confidence: "high"|"medium"|"low", rank: number, reason: string, ambiguous?: "tilt"|"rotary"} | null}
+ */
+export function classifyAxisNodeName(name) {
+ const normalized = String(name ?? "").trim().toUpperCase();
+ if (!normalized) return null;
+ const exact = normalized.match(/^JT[_-]?([XYZABCUVW])$/);
+ if (exact && axisSet.has(exact[1])) return { axis: exact[1], confidence: "high", rank: 0, reason: "JT_ 轴名(精确)" };
+ const tokens = normalized.split(/[^A-Z0-9]+/).filter(Boolean);
+ const letters = tokens.filter((token) => token.length === 1 && axisSet.has(token));
+ const words = tokens.filter((token) => token.length > 1);
+ const partWord = words.find((word) => PART_WORDS.includes(word));
+ if (letters.length === 1) {
+ const axis = letters[0];
+ // AXIS/JOINT 只算一次:b_axis 是关节本身,b_axis_bearing / b_axis_axis 是它的零件。
+ const jointWords = words.filter((word) => word === "AXIS" || word === "JOINT").length;
+ const hasJointWord = jointWords > 0;
+ if (jointWords === 1 && !partWord) return { axis, confidence: "medium", rank: 1, reason: "轴字母 + AXIS/JOINT 命名" };
+ const letterIndex = tokens.indexOf(letters[0]);
+ const primaryIndex = tokens.findIndex((token) => PRIMARY_WORDS.includes(token));
+ // 允许 moving_x_table / a_outer_table 这类“轴字母在部件词之前”的命名。
+ if (!hasJointWord && primaryIndex > letterIndex) {
+ const primary = tokens[primaryIndex];
+ // 只有真正的零件词才算"附加层级"(y_saddle_casting 低于 y_saddle);
+ // direction / 序号之类的修饰词不降级(w_tool_direction 仍是 W 主节点)。
+ const extras = tokens.slice(primaryIndex + 1).filter((token) => PART_WORDS.includes(token)).length;
+ return { axis, confidence: "medium", rank: rankOfPrimary(primary) + Math.min(extras, 3) * 0.02, reason: `轴字母 + ${primary} 部件命名` };
+ }
+ if (partWord || hasJointWord) return { axis, confidence: "low", rank: 4, reason: "零件级轴名线索,需人工确认" };
+ }
+ const ambiguous = AMBIGUOUS_WORDS.find((item) => words.includes(item.word));
+ if (ambiguous) {
+ // 通用词 + 零件词(tilt_bearing、trunnion_pedestal)只是同轴零件,排序要低于 tilt_table 这类主轴节点。
+ const rank = partWord ? 4 : 3 + AMBIGUOUS_WORDS.indexOf(ambiguous) * 0.1;
+ return { axis: "C", confidence: "low", rank, reason: `通用${ambiguous.kind === "tilt" ? "倾转" : "回转"}部件命名,需结合机型判断`, ambiguous: ambiguous.kind };
+ }
+ return null;
+}
+
+/**
+ * 由旋转轴节点的世界坐标推导原生 TRT 标定候选:
+ * C 轴原点取 C 轴节点世界坐标,倾转偏置取倾转轴节点与之的差,
+ * 并按运动学约束把沿倾转轴方向(该机型要求为 0)的分量归零。
+ */
+export function deriveTrtCalibration({ coordinates, profile, cWorldMm, tiltWorldMm }) {
+ if (!profile || !["XYZAC", "XYZBC"].includes(coordinates)) return null;
+ if (![cWorldMm, tiltWorldMm].every((value) => Array.isArray(value) && value.length === 3 && value.every((item) => Number.isFinite(item)))) return null;
+ const offsetIndex = ["x", "y", "z"].indexOf(profile.forbiddenOffsetDirection);
+ if (offsetIndex < 0) return null;
+ const cOriginMm = cWorldMm.map((value) => Number(value.toFixed(6)));
+ const tiltOffsetMm = tiltWorldMm.map((value, index) => Number((value - cWorldMm[index]).toFixed(6)));
+ const zeroed = tiltOffsetMm[offsetIndex];
+ tiltOffsetMm[offsetIndex] = 0;
+ return {
+ schema: "linuxcnc.trt-calibration-candidate/1",
+ source: "axis-world-origin",
+ cOriginMm,
+ tiltOffsetMm,
+ physicalZero: Object.fromEntries([...coordinates].map((axis) => [axis.toLowerCase(), 0])),
+ ...(Math.abs(zeroed) > 1e-6 ? { zeroedOffsetDirection: profile.forbiddenOffsetDirection, zeroedOffsetValueMm: zeroed } : {}),
+ };
+}
+
+/**
+ * 从节点描述里挑选每个数控轴的最佳绑定候选,并判定机型坐标。
+ * 纯函数:不依赖 three.js,可被分析器和 Node 单测共用。
+ *
+ * @param {{name: string, path: string, depth: number, explicitAxis?: string | null}[]} descriptors
+ * @param {{declaredCoordinates?: string | null, expectedCoordinates?: string | null, tiltAxis?: string | null, rotaryAxis?: string | null}} options
+ */
+export function selectAxisNodes(descriptors, options = {}) {
+ const warnings = [];
+ const items = [];
+ for (const descriptor of descriptors) {
+ const hint = descriptor.explicitAxis
+ ? { axis: descriptor.explicitAxis, confidence: "high", rank: -1, reason: "GLB 关节元数据" }
+ : classifyAxisNodeName(descriptor.name);
+ if (!hint || !axisSet.has(hint.axis)) continue;
+ items.push({ path: descriptor.path, name: descriptor.name, depth: Number(descriptor.depth) || 0, hint, autoBind: false });
+ }
+
+ // 通用倾转/回转命名消歧:优先用调用方给的机型信息,其次用同模型里明确的轴字母。
+ const explicitAxes = new Set(items.filter((item) => !item.hint.ambiguous).map((item) => item.hint.axis));
+ const tiltAxis = options.tiltAxis ?? (explicitAxes.has("A") && !explicitAxes.has("B") ? "A" : explicitAxes.has("B") && !explicitAxes.has("A") ? "B" : null);
+ const rotaryAxis = options.rotaryAxis ?? (explicitAxes.has("C") ? "C" : null);
+ let unresolvedAmbiguous = 0;
+ for (const item of items) {
+ if (!item.hint.ambiguous) continue;
+ const resolved = item.hint.ambiguous === "tilt" ? tiltAxis : rotaryAxis;
+ // 无法消歧的候选不参与机型判定,也不自动绑定。
+ if (!resolved) { unresolvedAmbiguous += 1; item.hint = { ...item.hint, rank: 9, reason: `${item.hint.reason}(缺少机型线索)` }; continue; }
+ item.hint = { ...item.hint, axis: resolved, reason: `${item.hint.reason} → ${resolved}` };
+ }
+ if (unresolvedAmbiguous) warnings.push(`${unresolvedAmbiguous} 个通用倾转/回转节点缺少机型线索(可先选机型再导入)`);
+
+ // 每个轴取最优候选;同级并列视为歧义,不自动绑定。
+ const chosen = new Map();
+ const byAxis = new Map();
+ for (const item of items) { const list = byAxis.get(item.hint.axis) ?? []; list.push(item); byAxis.set(item.hint.axis, list); }
+ for (const [axis, list] of byAxis) {
+ list.sort((a, b) => a.hint.rank - b.hint.rank || a.depth - b.depth);
+ const best = list[0];
+ // 同名重复节点(CAD 导出常见)按深度取最上层;只有不同命名同分才算歧义。
+ const tied = list.filter((item) => item.hint.rank === best.hint.rank && item.name !== best.name);
+ if (tied.length > 1) { warnings.push(`轴 ${axis} 有 ${tied.length} 个同级候选(${tied.map((item) => item.name).join("、")}),未自动绑定;请手动选择唯一节点`); continue; }
+ chosen.set(axis, best);
+ for (const item of list) item.autoBind = item === best && best.hint.rank <= 3;
+ }
+
+ const declared = typeof options.declaredCoordinates === "string" && options.declaredCoordinates.length === 5 ? options.declaredCoordinates : null;
+ const expected = typeof options.expectedCoordinates === "string" && options.expectedCoordinates.length === 5 ? options.expectedCoordinates : null;
+ const primaryAxes = CNC_AXIS_LETTERS.filter((axis) => (chosen.get(axis)?.hint.rank ?? 9) <= 3);
+ const declaredValid = declared ? [...declared].every((axis) => chosen.has(axis)) : false;
+ const expectedValid = expected ? [...expected].every((axis) => chosen.has(axis)) : false;
+ const suggestedCoordinates = declaredValid ? declared : expectedValid ? expected : primaryAxes.length === 5 ? primaryAxes.join("") : "UNKNOWN";
+ if (declared && !declaredValid) warnings.push("GLB 声明的轴顺序与可用关节不一致,未采用该顺序");
+ if (suggestedCoordinates === "UNKNOWN" && primaryAxes.length !== 5) warnings.push(`检测到 ${primaryAxes.length} 个主轴候选(${primaryAxes.join("") || "无"});需要五个轴才能自动绑定`);
+
+ const bound = new Set(suggestedCoordinates === "UNKNOWN" ? [] : [...suggestedCoordinates]);
+ const axes = CNC_AXIS_LETTERS.flatMap((axis) => {
+ const best = chosen.get(axis);
+ if (!best) return [];
+ const autoBind = best.autoBind && bound.has(axis);
+ return [{ axis, name: best.name, path: best.path, depth: best.depth, rank: best.hint.rank, confidence: best.hint.confidence, reason: best.reason ?? best.hint.reason, expectedKind: "XYZUVW".includes(axis) ? "linear" : "rotary", autoBind }];
+ });
+ const available = new Set(axes.filter((item) => item.autoBind).map((item) => item.axis));
+ const missingAxes = suggestedCoordinates === "UNKNOWN" ? [] : [...suggestedCoordinates].filter((axis) => !available.has(axis));
+ return { axes, suggestedCoordinates, missingAxes, warnings, unresolvedAmbiguous };
+}
diff --git a/web/workspace/machine-catalog.json b/web/workspace/machine-catalog.json
index df2e282..9230dd7 100644
--- a/web/workspace/machine-catalog.json
+++ b/web/workspace/machine-catalog.json
@@ -30,6 +30,250 @@
"helix_bc"
],
"runtime_fixture": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini"
+ },
+ {
+ "id": "xyzab-tdr",
+ "coordinates": "XYZAB",
+ "kinematics_type": 1,
+ "runtime": "generic-wasm",
+ "kinematics": "xyzab-tdr",
+ "kinematics_source": "linuxcnc/src/hal/components/xyzab_tdr_kins.comp",
+ "kinematics_parameters": {
+ "x_rot_point": 0,
+ "y_rot_point": 0,
+ "z_rot_point": 0,
+ "x_offset": -20,
+ "z_offset": -10,
+ "tool_offset": 0
+ },
+ "joint_count": 5,
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini",
+ "program": "machine-cases/xyzab-tdr.ngc",
+ "source_path": "linuxcnc/src/hal/components/xyzab_tdr_kins.comp",
+ "model": "dual-rotary-table",
+ "units": {
+ "linear": "mm",
+ "angular": "deg"
+ },
+ "source_entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini"
+ },
+ {
+ "id": "bridge-mill-5axiskins",
+ "coordinates": "XYZBCWY",
+ "kinematics_type": 9,
+ "runtime": "generic-wasm",
+ "kinematics": "5axiskins",
+ "kinematics_source": "linuxcnc/src/emc/kinematics/5axiskins.c",
+ "kinematics_parameters": {
+ "pivot_length": 100
+ },
+ "joint_count": 7,
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/5axis.ini",
+ "program": "machine-cases/bridge-mill-5axiskins.ngc",
+ "source_path": "linuxcnc/src/emc/kinematics/5axiskins.c",
+ "model": "bridge-mill-xyzbcwy",
+ "units": {
+ "linear": "mm",
+ "angular": "deg"
+ },
+ "source_entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/5axis.ini"
+ },
+ {
+ "id": "max5-maxkins",
+ "coordinates": "XYZBC",
+ "kinematics_type": 7,
+ "runtime": "generic-wasm",
+ "kinematics": "maxkins",
+ "kinematics_source": "linuxcnc/src/emc/kinematics/maxkins.c",
+ "kinematics_parameters": {
+ "pivot_length": 0
+ },
+ "joint_count": 6,
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini",
+ "program": "machine-cases/max5-maxkins.ngc",
+ "source_path": "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini",
+ "model": "max5-maxkins",
+ "units": {
+ "linear": "machine-config",
+ "angular": "machine-config"
+ },
+ "source_entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini",
+ "kinematics_joint_count": 5,
+ "extra_joint_count": 1
+ },
+ {
+ "id": "hexapod-genhexkins",
+ "coordinates": "XYZABC",
+ "kinematics_type": 17,
+ "runtime": "generic-wasm",
+ "kinematics": "genhexkins",
+ "kinematics_source": "linuxcnc/src/emc/kinematics/genhexkins.c",
+ "joint_count": 6,
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini",
+ "program": "machine-cases/hexapod-genhexkins.ngc",
+ "source_path": "linuxcnc/src/emc/kinematics/genhexkins.c",
+ "model": "hexapod-six-strut",
+ "units": {
+ "linear": "mm",
+ "angular": "deg"
+ },
+ "source_entrypoint": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini"
+ },
+ {
+ "id": "pentapod-pentakins",
+ "coordinates": "XYZAB",
+ "kinematics_type": 18,
+ "runtime": "generic-wasm",
+ "kinematics": "pentakins",
+ "kinematics_source": "linuxcnc/src/emc/kinematics/pentakins.c",
+ "joint_count": 5,
+ "entrypoint": "linuxcnc/configs/sim/axis/pentapod/pentapod.ini",
+ "program": "machine-cases/pentapod-pentakins.ngc",
+ "source_path": "linuxcnc/src/emc/kinematics/pentakins.c",
+ "model": "pentapod-five-strut",
+ "units": {
+ "linear": "mm",
+ "angular": "deg"
+ },
+ "source_entrypoint": "linuxcnc/configs/sim/axis/pentapod/pentapod.ini"
+ },
+ {
+ "id": "puma-pumakins",
+ "coordinates": "XYZABC",
+ "kinematics_type": 11,
+ "runtime": "generic-wasm",
+ "kinematics": "pumakins",
+ "kinematics_source": "linuxcnc/src/emc/kinematics/pumakins.c",
+ "kinematics_parameters": {
+ "puma_a2": 400,
+ "puma_a3": 50,
+ "puma_d3": 100,
+ "puma_d4": 400,
+ "puma_d6": 95
+ },
+ "joint_count": 6,
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma.ini",
+ "program": "machine-cases/puma-pumakins.ngc",
+ "source_path": "linuxcnc/src/emc/kinematics/pumakins.c",
+ "model": "puma-six-axis-serial",
+ "units": {
+ "linear": "mm",
+ "angular": "deg"
+ },
+ "source_entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma.ini"
+ },
+ {
+ "id": "puma-genserkins",
+ "coordinates": "XYZABC",
+ "kinematics_type": 13,
+ "runtime": "generic-wasm",
+ "kinematics": "genserkins",
+ "kinematics_source": "linuxcnc/src/emc/kinematics/genserkins.c",
+ "kinematics_parameters": {
+ "dh_joint_count": 6,
+ "dh_a": [
+ 0,
+ 17,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "dh_alpha": [
+ 0,
+ 1.570796326,
+ 0,
+ 1.570796326,
+ -1.570796326,
+ 1.570796326
+ ],
+ "dh_d": [
+ 26.45,
+ -5.5,
+ 0,
+ 17.05,
+ 0,
+ 2.2
+ ]
+ },
+ "joint_count": 6,
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma560.ini",
+ "program": "machine-cases/puma-genserkins.ngc",
+ "source_path": "linuxcnc/src/emc/kinematics/genserkins.c",
+ "model": "puma560-genser-dh",
+ "units": {
+ "linear": "inch-config",
+ "angular": "deg"
+ },
+ "source_entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma560.ini"
+ },
+ {
+ "id": "trivkins-xyzab",
+ "coordinates": "XYZAB",
+ "kinematics_type": 1,
+ "runtime": "generic-wasm",
+ "kinematics": "trivkins",
+ "joint_count": 5,
+ "entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzab.ini",
+ "program": "machine-cases/trivkins-xyzab.ngc",
+ "source_path": "linuxcnc/src/emc/kinematics/trivkins.c",
+ "model": "xyzab-identity",
+ "units": {
+ "linear": "mm",
+ "angular": "deg"
+ },
+ "source_entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzab.ini"
+ },
+ {
+ "id": "trivkins-xyzcw",
+ "coordinates": "XYZCW",
+ "kinematics_type": 1,
+ "runtime": "generic-wasm",
+ "kinematics": "trivkins",
+ "joint_count": 5,
+ "entrypoint": "linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZCW.ini",
+ "program": "machine-cases/trivkins-xyzcw.ngc",
+ "source_path": "linuxcnc/src/emc/kinematics/trivkins.c",
+ "model": "xyzcw-identity",
+ "units": {
+ "linear": "mm",
+ "angular": "deg"
+ },
+ "source_entrypoint": "linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZCW.ini"
+ },
+ {
+ "id": "trivkins-xyzya",
+ "coordinates": "XYZYA",
+ "kinematics_type": 1,
+ "runtime": "generic-wasm",
+ "kinematics": "trivkins",
+ "joint_count": 5,
+ "entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzya.ini",
+ "program": "machine-cases/trivkins-xyzya.ngc",
+ "source_path": "linuxcnc/src/emc/kinematics/trivkins.c",
+ "model": "xyzya-identity-gantry",
+ "units": {
+ "linear": "mm",
+ "angular": "deg"
+ },
+ "source_entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzya.ini"
+ },
+ {
+ "id": "scorbot-kins",
+ "coordinates": "XYZAB",
+ "kinematics_type": 12,
+ "runtime": "generic-wasm",
+ "kinematics": "scorbot-kins",
+ "joint_count": 5,
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini",
+ "program": "machine-cases/scorbot-kins.ngc",
+ "source_path": "linuxcnc/src/emc/kinematics/scorbot-kins.c",
+ "model": "scorbot-five-axis-serial",
+ "units": {
+ "linear": "mm",
+ "angular": "deg"
+ },
+ "source_entrypoint": "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini"
}
]
}
diff --git a/web/workspace/machine-definitions.mjs b/web/workspace/machine-definitions.mjs
index af5a7ed..e8a5ad6 100644
--- a/web/workspace/machine-definitions.mjs
+++ b/web/workspace/machine-definitions.mjs
@@ -1,11 +1,12 @@
import catalog from "./machine-catalog.json" with { type: "json" };
export const MACHINE_DEFINITIONS = Object.freeze(catalog.machines.map((entry) => {
- if (!entry.id || !entry.entrypoint || !entry.program || !entry.source_path || !["XYZAC", "XYZBC"].includes(entry.coordinates)) throw new Error("machine_catalog_invalid");
- return Object.freeze({ ...entry, label: `${entry.coordinates} TRT`, axes: [...entry.coordinates.toLowerCase()], joint_count: entry.coordinates.length, runtime: "trt" });
+ if (!entry.id || !entry.entrypoint || !entry.program || !entry.source_path || typeof entry.coordinates !== "string" || !entry.coordinates.length || entry.coordinates.length > 9) throw new Error("machine_catalog_invalid");
+ return Object.freeze({ ...entry, label: entry.runtime === "generic-wasm" ? entry.id : `${entry.coordinates} TRT`, axes: [...entry.coordinates.toLowerCase()], joint_count: entry.joint_count ?? entry.coordinates.length, runtime: entry.runtime ?? "trt" });
}));
if (new Set(MACHINE_DEFINITIONS.map((entry) => entry.id)).size !== MACHINE_DEFINITIONS.length) throw new Error("machine_catalog_duplicate");
export const DEFAULT_MACHINE = MACHINE_DEFINITIONS.find((entry) => entry.id === catalog.default_machine);
+export const GENERIC_WASM_MACHINES = Object.freeze(MACHINE_DEFINITIONS.filter((entry) => entry.runtime === "generic-wasm"));
if (!DEFAULT_MACHINE) throw new Error("machine_catalog_default_missing");
export function machineDefinition(value) {
return MACHINE_DEFINITIONS.find((entry) => [entry.id, entry.entrypoint, entry.coordinates].includes(value)) ?? null;
diff --git a/web/workspace/machine-kinematics-specs.json b/web/workspace/machine-kinematics-specs.json
new file mode 100644
index 0000000..1977ccb
--- /dev/null
+++ b/web/workspace/machine-kinematics-specs.json
@@ -0,0 +1,489 @@
+{
+ "schema": "linuxcnc.machine-kinematics-specs/1",
+ "description": "Per-case joint slots and limits, derived from the LinuxCNC INIs. limits[j] = [min,max] for joint index j; null means the source config declares none.",
+ "cases": [
+ {
+ "machineId": "xyzab-tdr",
+ "coordinates": "XYZAB",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini",
+ "kinematics": "xyzab-tdr",
+ "jointCount": 5,
+ "jointOrder": [
+ "X",
+ "Y",
+ "Z",
+ "A",
+ "B"
+ ],
+ "limitsByJoint": [
+ [
+ -200.0,
+ 200.0
+ ],
+ [
+ -200.0,
+ 200.0
+ ],
+ [
+ -300.0,
+ 300.0
+ ],
+ [
+ -3600.0,
+ 3600.0
+ ],
+ [
+ -180.0,
+ 180.0
+ ]
+ ],
+ "declaredKinematicsJointCount": 5,
+ "extraJointCount": 0,
+ "parameters": {
+ "x_rot_point": 0,
+ "y_rot_point": 0,
+ "z_rot_point": 0,
+ "x_offset": -20,
+ "z_offset": -10,
+ "tool_offset": 0
+ },
+ "limitsSource": "linuxcnc-ini"
+ },
+ {
+ "machineId": "bridge-mill-5axiskins",
+ "coordinates": "XYZBCWY",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/5axis.ini",
+ "kinematics": "5axiskins",
+ "jointCount": 7,
+ "jointOrder": [
+ "X",
+ "Y",
+ "Z",
+ "B",
+ "C",
+ "W",
+ "Y#2"
+ ],
+ "limitsByJoint": [
+ [
+ -500.0,
+ 500.0
+ ],
+ [
+ -500.0,
+ 500.0
+ ],
+ [
+ -500.0,
+ 100.0
+ ],
+ [
+ -120.0,
+ 120.0
+ ],
+ [
+ -36000.0,
+ 36000.0
+ ],
+ [
+ -100.0,
+ 500.0
+ ],
+ [
+ -500.0,
+ 500.0
+ ]
+ ],
+ "declaredKinematicsJointCount": 7,
+ "extraJointCount": 0,
+ "parameters": {
+ "pivot_length": 100
+ },
+ "limitsSource": "linuxcnc-ini"
+ },
+ {
+ "machineId": "max5-maxkins",
+ "coordinates": "XYZBC",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini",
+ "kinematics": "maxkins",
+ "jointCount": 6,
+ "jointOrder": [
+ "X",
+ "Y",
+ "Z",
+ "B",
+ "C",
+ "J5"
+ ],
+ "limitsByJoint": [
+ [
+ -100.0,
+ 200.0
+ ],
+ [
+ -60.0,
+ 60.0
+ ],
+ [
+ -149.0,
+ 1.0
+ ],
+ [
+ -10.0,
+ 10.0
+ ],
+ [
+ -90.0,
+ 90.0
+ ],
+ [
+ -3600.0,
+ 3600.0
+ ]
+ ],
+ "declaredKinematicsJointCount": 5,
+ "extraJointCount": 1,
+ "parameters": {
+ "pivot_length": 0
+ },
+ "limitsSource": "linuxcnc-ini"
+ },
+ {
+ "machineId": "hexapod-genhexkins",
+ "coordinates": "XYZABC",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini",
+ "kinematics": "genhexkins",
+ "jointCount": 6,
+ "jointOrder": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4",
+ "J5"
+ ],
+ "limitsByJoint": [
+ [
+ -1000.0,
+ 1000.0
+ ],
+ [
+ -1000.0,
+ 1000.0
+ ],
+ [
+ -1000.0,
+ 1000.0
+ ],
+ [
+ -1000.0,
+ 1000.0
+ ],
+ [
+ -1000.0,
+ 1000.0
+ ],
+ [
+ -1000.0,
+ 1000.0
+ ]
+ ],
+ "declaredKinematicsJointCount": 6,
+ "extraJointCount": 0,
+ "parameters": {},
+ "limitsSource": "linuxcnc-ini"
+ },
+ {
+ "machineId": "pentapod-pentakins",
+ "coordinates": "XYZAB",
+ "entrypoint": "linuxcnc/configs/sim/axis/pentapod/pentapod.ini",
+ "kinematics": "pentakins",
+ "jointCount": 5,
+ "jointOrder": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4"
+ ],
+ "limitsByJoint": [
+ [
+ 400.0,
+ 1500.0
+ ],
+ [
+ 400.0,
+ 1500.0
+ ],
+ [
+ 400.0,
+ 1500.0
+ ],
+ [
+ 400.0,
+ 1500.0
+ ],
+ [
+ 400.0,
+ 1500.0
+ ]
+ ],
+ "declaredKinematicsJointCount": 5,
+ "extraJointCount": 0,
+ "parameters": {},
+ "limitsSource": "linuxcnc-ini"
+ },
+ {
+ "machineId": "puma-pumakins",
+ "coordinates": "XYZABC",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma.ini",
+ "kinematics": "pumakins",
+ "jointCount": 6,
+ "jointOrder": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4",
+ "J5"
+ ],
+ "limitsByJoint": [
+ [
+ -170.0,
+ 170.0
+ ],
+ [
+ -85.0,
+ 50.0
+ ],
+ [
+ -70.0,
+ 75.0
+ ],
+ [
+ -250.0,
+ 250.0
+ ],
+ [
+ -120.0,
+ 120.0
+ ],
+ null
+ ],
+ "declaredKinematicsJointCount": 6,
+ "extraJointCount": 0,
+ "parameters": {
+ "puma_a2": 400,
+ "puma_a3": 50,
+ "puma_d3": 100,
+ "puma_d4": 400,
+ "puma_d6": 95
+ },
+ "limitsSource": "linuxcnc-ini (missing: J5)"
+ },
+ {
+ "machineId": "puma-genserkins",
+ "coordinates": "XYZABC",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma560.ini",
+ "kinematics": "genserkins",
+ "jointCount": 6,
+ "jointOrder": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4",
+ "J5"
+ ],
+ "limitsByJoint": [
+ [
+ -180.0,
+ 180.0
+ ],
+ [
+ -360.0,
+ 360.0
+ ],
+ [
+ -215.0,
+ 35.0
+ ],
+ [
+ -360.0,
+ 360.0
+ ],
+ [
+ -360.0,
+ 360.0
+ ],
+ [
+ -360.0,
+ 360.0
+ ]
+ ],
+ "declaredKinematicsJointCount": 6,
+ "extraJointCount": 0,
+ "parameters": {
+ "dh_joint_count": 6,
+ "dh_a": [
+ 0,
+ 17,
+ 0,
+ 0,
+ 0,
+ 0
+ ],
+ "dh_alpha": [
+ 0,
+ 1.570796326,
+ 0,
+ 1.570796326,
+ -1.570796326,
+ 1.570796326
+ ],
+ "dh_d": [
+ 26.45,
+ -5.5,
+ 0,
+ 17.05,
+ 0,
+ 2.2
+ ]
+ },
+ "limitsSource": "linuxcnc-ini"
+ },
+ {
+ "machineId": "trivkins-xyzab",
+ "coordinates": "XYZAB",
+ "entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzab.ini",
+ "kinematics": "trivkins",
+ "jointCount": 5,
+ "jointOrder": [
+ "X",
+ "Y",
+ "Z",
+ "A",
+ "B"
+ ],
+ "limitsByJoint": [
+ [
+ -0.001,
+ 520.0
+ ],
+ [
+ -0.001,
+ 630.0
+ ],
+ [
+ -115.0,
+ 10.0
+ ],
+ null,
+ null
+ ],
+ "declaredKinematicsJointCount": 5,
+ "extraJointCount": 0,
+ "parameters": {},
+ "limitsSource": "linuxcnc-ini (missing: A,B)"
+ },
+ {
+ "machineId": "trivkins-xyzcw",
+ "coordinates": "XYZCW",
+ "entrypoint": "linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZCW.ini",
+ "kinematics": "trivkins",
+ "jointCount": 5,
+ "jointOrder": [
+ "X",
+ "Y",
+ "Z",
+ "C",
+ "W"
+ ],
+ "limitsByJoint": [
+ [
+ -0.0,
+ 600.0
+ ],
+ [
+ -0.0,
+ 400.0
+ ],
+ [
+ -400.0,
+ 4.0
+ ],
+ null,
+ [
+ -2.0,
+ 4.0
+ ]
+ ],
+ "declaredKinematicsJointCount": 5,
+ "extraJointCount": 0,
+ "parameters": {},
+ "limitsSource": "linuxcnc-ini (missing: C)"
+ },
+ {
+ "machineId": "trivkins-xyzya",
+ "coordinates": "XYZYA",
+ "entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzya.ini",
+ "kinematics": "trivkins",
+ "jointCount": 5,
+ "jointOrder": [
+ "X",
+ "Y",
+ "Z",
+ "Y#2",
+ "A"
+ ],
+ "limitsByJoint": [
+ [
+ -0.001,
+ 520.0
+ ],
+ [
+ -0.001,
+ 630.0
+ ],
+ [
+ -115.0,
+ 10.0
+ ],
+ [
+ -0.001,
+ 630.0
+ ],
+ null
+ ],
+ "declaredKinematicsJointCount": 5,
+ "extraJointCount": 0,
+ "parameters": {},
+ "limitsSource": "linuxcnc-ini (missing: A)"
+ },
+ {
+ "machineId": "scorbot-kins",
+ "coordinates": "XYZAB",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini",
+ "kinematics": "scorbot-kins",
+ "jointCount": 5,
+ "jointOrder": [
+ "J0",
+ "J1",
+ "J2",
+ "J3",
+ "J4"
+ ],
+ "limitsByJoint": [
+ null,
+ null,
+ null,
+ null,
+ null
+ ],
+ "declaredKinematicsJointCount": 5,
+ "extraJointCount": 0,
+ "parameters": {},
+ "limitsSource": "linuxcnc-ini (missing: J0,J1,J2,J3,J4)"
+ }
+ ]
+}
diff --git a/web/workspace/machine-visual-bindings.json b/web/workspace/machine-visual-bindings.json
new file mode 100644
index 0000000..db4d7eb
--- /dev/null
+++ b/web/workspace/machine-visual-bindings.json
@@ -0,0 +1,1558 @@
+{
+ "schema": "linuxcnc.machine-visual-bindings/1",
+ "glbRoot": "machine-cases/physical/",
+ "cases": [
+ {
+ "machineId": "xyzac-trt",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
+ "coordinates": "XYZAC",
+ "kinematics": "xyzac-trt-kins",
+ "jointCount": 5,
+ "kinematicsJointCount": 5,
+ "extraJointCount": 0,
+ "jointOrder": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 3,
+ "key": "A",
+ "axis": "A",
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 4,
+ "key": "C",
+ "axis": "C",
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ }
+ ],
+ "glb": {
+ "default": "xyzac-trt.glb"
+ },
+ "bindings": [
+ {
+ "slot": 0,
+ "node": "x",
+ "nodeId": "xyzac-trt:X",
+ "direction": "x",
+ "sign": 1
+ },
+ {
+ "slot": 1,
+ "node": "y",
+ "nodeId": "xyzac-trt:Y",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 2,
+ "node": "z",
+ "nodeId": "xyzac-trt:Z",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 3,
+ "node": "a",
+ "nodeId": "xyzac-trt:A",
+ "direction": "x",
+ "sign": 1
+ },
+ {
+ "slot": 4,
+ "node": "c",
+ "nodeId": "xyzac-trt:C",
+ "direction": "z",
+ "sign": 1
+ }
+ ],
+ "anchors": null,
+ "calibration": {
+ "cOriginMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "tiltOffsetMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "physicalZero": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "referenceStatus": "real-reference"
+ },
+ {
+ "machineId": "xyzbc-trt",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini",
+ "coordinates": "XYZBC",
+ "kinematics": "xyzbc-trt-kins",
+ "jointCount": 5,
+ "kinematicsJointCount": 5,
+ "extraJointCount": 0,
+ "jointOrder": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 3,
+ "key": "B",
+ "axis": "B",
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 4,
+ "key": "C",
+ "axis": "C",
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ }
+ ],
+ "glb": {
+ "default": "xyzbc-trt.glb"
+ },
+ "bindings": [
+ {
+ "slot": 0,
+ "node": "x",
+ "nodeId": "xyzbc-trt:X",
+ "direction": "x",
+ "sign": 1
+ },
+ {
+ "slot": 1,
+ "node": "y",
+ "nodeId": "xyzbc-trt:Y",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 2,
+ "node": "z",
+ "nodeId": "xyzbc-trt:Z",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 3,
+ "node": "b",
+ "nodeId": "xyzbc-trt:B",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 4,
+ "node": "c",
+ "nodeId": "xyzbc-trt:C",
+ "direction": "z",
+ "sign": 1
+ }
+ ],
+ "anchors": null,
+ "calibration": {
+ "cOriginMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "tiltOffsetMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "physicalZero": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "referenceStatus": "real-reference"
+ },
+ {
+ "machineId": "xyzab-tdr",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/table-dual-rotary/xyzab-tdr.ini",
+ "coordinates": "XYZAB",
+ "kinematics": "xyzab_tdr_kins",
+ "jointCount": 5,
+ "kinematicsJointCount": 5,
+ "extraJointCount": 0,
+ "jointOrder": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 3,
+ "key": "A",
+ "axis": "A",
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 4,
+ "key": "B",
+ "axis": "B",
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ }
+ ],
+ "glb": {
+ "default": "xyzab-tdr.glb"
+ },
+ "bindings": [
+ {
+ "slot": 0,
+ "node": "x",
+ "nodeId": "xyzab-tdr:X",
+ "direction": "x",
+ "sign": 1
+ },
+ {
+ "slot": 1,
+ "node": "y",
+ "nodeId": "xyzab-tdr:Y",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 2,
+ "node": "z",
+ "nodeId": "xyzab-tdr:Z",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 3,
+ "node": "a",
+ "nodeId": "xyzab-tdr:A",
+ "direction": "x",
+ "sign": 1
+ },
+ {
+ "slot": 4,
+ "node": "b",
+ "nodeId": "xyzab-tdr:B",
+ "direction": "y",
+ "sign": 1
+ }
+ ],
+ "anchors": null,
+ "calibration": {
+ "cOriginMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "tiltOffsetMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "physicalZero": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "referenceStatus": "design-reference"
+ },
+ {
+ "machineId": "bridge-mill-5axiskins",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/bridgemill/5axis.ini",
+ "coordinates": "XYZBCWY",
+ "kinematics": "5axiskins",
+ "jointCount": 7,
+ "kinematicsJointCount": 7,
+ "extraJointCount": 0,
+ "jointOrder": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 3,
+ "key": "B",
+ "axis": "B",
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 4,
+ "key": "C",
+ "axis": "C",
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 5,
+ "key": "W",
+ "axis": "W",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 6,
+ "key": "Y#2",
+ "axis": "Y",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "duplicate"
+ }
+ ],
+ "glb": {
+ "default": "bridge-mill-5axiskins.glb"
+ },
+ "bindings": [
+ {
+ "slot": 0,
+ "node": "x",
+ "nodeId": "bridge-mill-5axiskins:X",
+ "direction": "x",
+ "sign": 1
+ },
+ {
+ "slot": 1,
+ "node": "y",
+ "nodeId": "bridge-mill-5axiskins:Y",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 2,
+ "node": "z",
+ "nodeId": "bridge-mill-5axiskins:Z",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 3,
+ "node": "b",
+ "nodeId": "bridge-mill-5axiskins:B",
+ "direction": "y",
+ "sign": -1
+ },
+ {
+ "slot": 4,
+ "node": "c",
+ "nodeId": "bridge-mill-5axiskins:C",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 5,
+ "node": "w",
+ "nodeId": "bridge-mill-5axiskins:W",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 6,
+ "node": "y_2",
+ "nodeId": "bridge-mill-5axiskins:Y#2",
+ "direction": "y",
+ "sign": 1
+ }
+ ],
+ "anchors": null,
+ "calibration": {
+ "cOriginMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "tiltOffsetMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "physicalZero": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "referenceStatus": "real-reference"
+ },
+ {
+ "machineId": "max5-maxkins",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini",
+ "coordinates": "XYZBC",
+ "kinematics": "maxkins",
+ "jointCount": 6,
+ "kinematicsJointCount": 5,
+ "extraJointCount": 1,
+ "jointOrder": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 3,
+ "key": "B",
+ "axis": "B",
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 4,
+ "key": "C",
+ "axis": "C",
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 5,
+ "key": "J5",
+ "axis": null,
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "passive"
+ }
+ ],
+ "glb": {
+ "default": "max5-maxkins.glb"
+ },
+ "bindings": [
+ {
+ "slot": 0,
+ "node": "x",
+ "nodeId": "max5-maxkins:X",
+ "direction": "x",
+ "sign": 1
+ },
+ {
+ "slot": 1,
+ "node": "y",
+ "nodeId": "max5-maxkins:Y",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 2,
+ "node": "z",
+ "nodeId": "max5-maxkins:Z",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 3,
+ "node": "b",
+ "nodeId": "max5-maxkins:B",
+ "direction": "y",
+ "sign": -1
+ },
+ {
+ "slot": 4,
+ "node": "c",
+ "nodeId": "max5-maxkins:C",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 5,
+ "node": "j5",
+ "nodeId": "max5-maxkins:J5",
+ "direction": "z",
+ "sign": 1
+ }
+ ],
+ "anchors": null,
+ "calibration": {
+ "cOriginMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "tiltOffsetMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "physicalZero": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "referenceStatus": "design-reference"
+ },
+ {
+ "machineId": "hexapod-genhexkins",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/hexapod-sim/hexapod.ini",
+ "coordinates": "XYZABC",
+ "kinematics": "genhexkins",
+ "jointCount": 6,
+ "kinematicsJointCount": 6,
+ "extraJointCount": 0,
+ "jointOrder": [
+ {
+ "index": 0,
+ "key": "J0",
+ "axis": null,
+ "kind": "strut",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 1,
+ "key": "J1",
+ "axis": null,
+ "kind": "strut",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 2,
+ "key": "J2",
+ "axis": null,
+ "kind": "strut",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 3,
+ "key": "J3",
+ "axis": null,
+ "kind": "strut",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 4,
+ "key": "J4",
+ "axis": null,
+ "kind": "strut",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 5,
+ "key": "J5",
+ "axis": null,
+ "kind": "strut",
+ "unit": "mm",
+ "source": "kinematics"
+ }
+ ],
+ "glb": {
+ "default": "hexapod-genhexkins.glb"
+ },
+ "bindings": [
+ {
+ "slot": 0,
+ "node": "strut_1",
+ "nodeId": "hexapod-genhexkins:J0",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 1,
+ "node": "strut_2",
+ "nodeId": "hexapod-genhexkins:J1",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 2,
+ "node": "strut_3",
+ "nodeId": "hexapod-genhexkins:J2",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 3,
+ "node": "strut_4",
+ "nodeId": "hexapod-genhexkins:J3",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 4,
+ "node": "strut_5",
+ "nodeId": "hexapod-genhexkins:J4",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 5,
+ "node": "strut_6",
+ "nodeId": "hexapod-genhexkins:J5",
+ "direction": "z",
+ "sign": 1
+ }
+ ],
+ "anchors": null,
+ "calibration": {
+ "cOriginMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "tiltOffsetMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "physicalZero": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "referenceStatus": "real-reference"
+ },
+ {
+ "machineId": "pentapod-pentakins",
+ "entrypoint": "linuxcnc/configs/sim/axis/pentapod/pentapod.ini",
+ "coordinates": "XYZAB",
+ "kinematics": "pentakins",
+ "jointCount": 5,
+ "kinematicsJointCount": 5,
+ "extraJointCount": 0,
+ "jointOrder": [
+ {
+ "index": 0,
+ "key": "J0",
+ "axis": null,
+ "kind": "strut",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 1,
+ "key": "J1",
+ "axis": null,
+ "kind": "strut",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 2,
+ "key": "J2",
+ "axis": null,
+ "kind": "strut",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 3,
+ "key": "J3",
+ "axis": null,
+ "kind": "strut",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 4,
+ "key": "J4",
+ "axis": null,
+ "kind": "strut",
+ "unit": "mm",
+ "source": "kinematics"
+ }
+ ],
+ "glb": {
+ "default": "pentapod-pentakins.glb"
+ },
+ "bindings": [
+ {
+ "slot": 0,
+ "node": "strut_1",
+ "nodeId": "pentapod-pentakins:J0",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 1,
+ "node": "strut_2",
+ "nodeId": "pentapod-pentakins:J1",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 2,
+ "node": "strut_3",
+ "nodeId": "pentapod-pentakins:J2",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 3,
+ "node": "strut_4",
+ "nodeId": "pentapod-pentakins:J3",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 4,
+ "node": "strut_5",
+ "nodeId": "pentapod-pentakins:J4",
+ "direction": "z",
+ "sign": 1
+ }
+ ],
+ "anchors": null,
+ "calibration": {
+ "cOriginMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "tiltOffsetMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "physicalZero": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "referenceStatus": "real-reference"
+ },
+ {
+ "machineId": "puma-pumakins",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma.ini",
+ "coordinates": "XYZABC",
+ "kinematics": "pumakins",
+ "jointCount": 6,
+ "kinematicsJointCount": 6,
+ "extraJointCount": 0,
+ "jointOrder": [
+ {
+ "index": 0,
+ "key": "J0",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 1,
+ "key": "J1",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 2,
+ "key": "J2",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 3,
+ "key": "J3",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 4,
+ "key": "J4",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 5,
+ "key": "J5",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ }
+ ],
+ "glb": {
+ "default": "puma-pumakins.glb"
+ },
+ "bindings": [
+ {
+ "slot": 0,
+ "node": "j0",
+ "nodeId": "puma-pumakins:J0",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 1,
+ "node": "j1",
+ "nodeId": "puma-pumakins:J1",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 2,
+ "node": "j2",
+ "nodeId": "puma-pumakins:J2",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 3,
+ "node": "j3",
+ "nodeId": "puma-pumakins:J3",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 4,
+ "node": "j4",
+ "nodeId": "puma-pumakins:J4",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 5,
+ "node": "j5",
+ "nodeId": "puma-pumakins:J5",
+ "direction": "z",
+ "sign": 1
+ }
+ ],
+ "anchors": null,
+ "calibration": {
+ "cOriginMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "tiltOffsetMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "physicalZero": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "referenceStatus": "real-reference"
+ },
+ {
+ "machineId": "puma-genserkins",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/puma/puma560.ini",
+ "coordinates": "XYZABC",
+ "kinematics": "genserkins",
+ "jointCount": 6,
+ "kinematicsJointCount": 6,
+ "extraJointCount": 0,
+ "jointOrder": [
+ {
+ "index": 0,
+ "key": "J0",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 1,
+ "key": "J1",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 2,
+ "key": "J2",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 3,
+ "key": "J3",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 4,
+ "key": "J4",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 5,
+ "key": "J5",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ }
+ ],
+ "glb": {
+ "default": "puma-genserkins.glb"
+ },
+ "bindings": [
+ {
+ "slot": 0,
+ "node": "j0",
+ "nodeId": "puma-genserkins:J0",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 1,
+ "node": "j1",
+ "nodeId": "puma-genserkins:J1",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 2,
+ "node": "j2",
+ "nodeId": "puma-genserkins:J2",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 3,
+ "node": "j3",
+ "nodeId": "puma-genserkins:J3",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 4,
+ "node": "j4",
+ "nodeId": "puma-genserkins:J4",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 5,
+ "node": "j5",
+ "nodeId": "puma-genserkins:J5",
+ "direction": "z",
+ "sign": 1
+ }
+ ],
+ "anchors": null,
+ "calibration": {
+ "cOriginMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "tiltOffsetMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "physicalZero": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "referenceStatus": "real-reference"
+ },
+ {
+ "machineId": "trivkins-xyzab",
+ "entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzab.ini",
+ "coordinates": "XYZAB",
+ "kinematics": "trivkins",
+ "jointCount": 5,
+ "kinematicsJointCount": 5,
+ "extraJointCount": 0,
+ "jointOrder": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 3,
+ "key": "A",
+ "axis": "A",
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 4,
+ "key": "B",
+ "axis": "B",
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ }
+ ],
+ "glb": {
+ "default": "trivkins-xyzab.glb"
+ },
+ "bindings": [
+ {
+ "slot": 0,
+ "node": "x",
+ "nodeId": "trivkins-xyzab:X",
+ "direction": "x",
+ "sign": 1
+ },
+ {
+ "slot": 1,
+ "node": "y",
+ "nodeId": "trivkins-xyzab:Y",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 2,
+ "node": "z",
+ "nodeId": "trivkins-xyzab:Z",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 3,
+ "node": "a",
+ "nodeId": "trivkins-xyzab:A",
+ "direction": "x",
+ "sign": 1
+ },
+ {
+ "slot": 4,
+ "node": "b",
+ "nodeId": "trivkins-xyzab:B",
+ "direction": "y",
+ "sign": 1
+ }
+ ],
+ "anchors": null,
+ "calibration": {
+ "cOriginMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "tiltOffsetMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "physicalZero": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "referenceStatus": "design-reference"
+ },
+ {
+ "machineId": "trivkins-xyzcw",
+ "entrypoint": "linuxcnc/configs/sim/gmoccapy/gmoccapy_XYZCW.ini",
+ "coordinates": "XYZCW",
+ "kinematics": "trivkins",
+ "jointCount": 5,
+ "kinematicsJointCount": 5,
+ "extraJointCount": 0,
+ "jointOrder": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 3,
+ "key": "C",
+ "axis": "C",
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 4,
+ "key": "W",
+ "axis": "W",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ }
+ ],
+ "glb": {
+ "default": "trivkins-xyzcw.glb"
+ },
+ "bindings": [
+ {
+ "slot": 0,
+ "node": "x",
+ "nodeId": "trivkins-xyzcw:X",
+ "direction": "x",
+ "sign": 1
+ },
+ {
+ "slot": 1,
+ "node": "y",
+ "nodeId": "trivkins-xyzcw:Y",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 2,
+ "node": "z",
+ "nodeId": "trivkins-xyzcw:Z",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 3,
+ "node": "c",
+ "nodeId": "trivkins-xyzcw:C",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 4,
+ "node": "w",
+ "nodeId": "trivkins-xyzcw:W",
+ "direction": "z",
+ "sign": 1
+ }
+ ],
+ "anchors": null,
+ "calibration": {
+ "cOriginMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "tiltOffsetMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "physicalZero": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "referenceStatus": "design-reference"
+ },
+ {
+ "machineId": "trivkins-xyzya",
+ "entrypoint": "linuxcnc/configs/sim/woodpecker/woodpecker_xyzya.ini",
+ "coordinates": "XYZYA",
+ "kinematics": "trivkins",
+ "jointCount": 5,
+ "kinematicsJointCount": 5,
+ "extraJointCount": 0,
+ "jointOrder": [
+ {
+ "index": 0,
+ "key": "X",
+ "axis": "X",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 1,
+ "key": "Y",
+ "axis": "Y",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 2,
+ "key": "Z",
+ "axis": "Z",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "kinematics"
+ },
+ {
+ "index": 3,
+ "key": "Y#2",
+ "axis": "Y",
+ "kind": "prismatic",
+ "unit": "mm",
+ "source": "duplicate"
+ },
+ {
+ "index": 4,
+ "key": "A",
+ "axis": "A",
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ }
+ ],
+ "glb": {
+ "default": "trivkins-xyzya.glb"
+ },
+ "bindings": [
+ {
+ "slot": 0,
+ "node": "x",
+ "nodeId": "trivkins-xyzya:X",
+ "direction": "x",
+ "sign": 1
+ },
+ {
+ "slot": 1,
+ "node": "y",
+ "nodeId": "trivkins-xyzya:Y",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 2,
+ "node": "z",
+ "nodeId": "trivkins-xyzya:Z",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 3,
+ "node": "y_2",
+ "nodeId": "trivkins-xyzya:Y#2",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 4,
+ "node": "a",
+ "nodeId": "trivkins-xyzya:A",
+ "direction": "x",
+ "sign": 1
+ }
+ ],
+ "anchors": null,
+ "calibration": {
+ "cOriginMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "tiltOffsetMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "physicalZero": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "referenceStatus": "design-reference"
+ },
+ {
+ "machineId": "scorbot-kins",
+ "entrypoint": "linuxcnc/configs/sim/axis/vismach/scorbot/scorbot.ini",
+ "coordinates": "XYZAB",
+ "kinematics": "scorbot-kins",
+ "jointCount": 5,
+ "kinematicsJointCount": 5,
+ "extraJointCount": 0,
+ "jointOrder": [
+ {
+ "index": 0,
+ "key": "J0",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 1,
+ "key": "J1",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 2,
+ "key": "J2",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 3,
+ "key": "J3",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ },
+ {
+ "index": 4,
+ "key": "J4",
+ "axis": null,
+ "kind": "revolute",
+ "unit": "deg",
+ "source": "kinematics"
+ }
+ ],
+ "glb": {
+ "default": "scorbot-kins.glb"
+ },
+ "bindings": [
+ {
+ "slot": 0,
+ "node": "j0",
+ "nodeId": "scorbot-kins:J0",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 1,
+ "node": "j1",
+ "nodeId": "scorbot-kins:J1",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 2,
+ "node": "j2",
+ "nodeId": "scorbot-kins:J2",
+ "direction": "y",
+ "sign": 1
+ },
+ {
+ "slot": 3,
+ "node": "j3",
+ "nodeId": "scorbot-kins:J3",
+ "direction": "z",
+ "sign": 1
+ },
+ {
+ "slot": 4,
+ "node": "j4",
+ "nodeId": "scorbot-kins:J4",
+ "direction": "y",
+ "sign": 1
+ }
+ ],
+ "anchors": null,
+ "calibration": {
+ "cOriginMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "tiltOffsetMm": [
+ 0,
+ 0,
+ 0
+ ],
+ "physicalZero": [
+ 0,
+ 0,
+ 0,
+ 0,
+ 0
+ ]
+ },
+ "referenceStatus": "real-reference"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/web/workspace/machine-visual-bindings.mjs b/web/workspace/machine-visual-bindings.mjs
new file mode 100644
index 0000000..c635d10
--- /dev/null
+++ b/web/workspace/machine-visual-bindings.mjs
@@ -0,0 +1,84 @@
+// Case id -> GLB node binding. Pure functions shared by the manager dialog and
+// the runtime view. Motion values always come from LinuxCNC WASM joints; this
+// module only says which scene node receives which joint slot.
+import bindings from "./machine-visual-bindings.json" with { type: "json" };
+import { MACHINE_DEFINITIONS } from "./machine-definitions.mjs";
+
+export const VISUAL_BINDINGS_SCHEMA = "linuxcnc.machine-visual-bindings/1";
+const SLOT_KINDS = new Set(["prismatic", "revolute", "strut"]);
+const SLOT_SOURCES = new Set(["kinematics", "extra", "duplicate", "passive"]);
+const AXIS_LETTER = /^[XYZABCUVW]$/;
+
+const fail = (message) => { throw new Error(`视觉绑定配置无效:${message}`); };
+
+if (bindings.schema !== VISUAL_BINDINGS_SCHEMA) fail("schema");
+if (typeof bindings.glbRoot !== "string" || !bindings.glbRoot) fail("glbRoot");
+if (!Array.isArray(bindings.cases) || !bindings.cases.length) fail("cases");
+
+const catalog = new Map(MACHINE_DEFINITIONS.map((machine) => [machine.id, machine]));
+const byId = new Map();
+const byEntrypoint = new Map();
+
+for (const item of bindings.cases) {
+ if (!item.machineId || byId.has(item.machineId)) fail(`machineId 缺失或重复:${item.machineId}`);
+ const machine = catalog.get(item.machineId);
+ if (!machine) fail(`案例目录中没有 ${item.machineId}`);
+ if (item.entrypoint !== machine.entrypoint) fail(`${item.machineId} entrypoint 与运行目录不一致`);
+ if (item.coordinates !== machine.coordinates) fail(`${item.machineId} coordinates 与运行目录不一致`);
+ if (!Array.isArray(item.jointOrder) || item.jointOrder.length !== machine.joint_count) fail(`${item.machineId} 槽位数 ${item.jointOrder?.length} 与 joint_count ${machine.joint_count} 不符`);
+ if (!Array.isArray(item.bindings) || item.bindings.length !== item.jointOrder.length) fail(`${item.machineId} 绑定数量与槽位数不符`);
+ const keys = new Set(), bound = new Set();
+ for (const [position, slot] of item.jointOrder.entries()) {
+ if (slot.index !== position) fail(`${item.machineId} 槽位索引不连续`);
+ if (typeof slot.key !== "string" || !slot.key || keys.has(slot.key)) fail(`${item.machineId} 槽位 key 缺失或重复`);
+ keys.add(slot.key);
+ if (!SLOT_KINDS.has(slot.kind)) fail(`${item.machineId}/${slot.key} 类型`);
+ if (slot.unit !== (slot.kind === "revolute" ? "deg" : "mm")) fail(`${item.machineId}/${slot.key} 单位`);
+ if (slot.axis !== null && !AXIS_LETTER.test(slot.axis)) fail(`${item.machineId}/${slot.key} 轴字母`);
+ if (!SLOT_SOURCES.has(slot.source)) fail(`${item.machineId}/${slot.key} 来源`);
+ }
+ for (const binding of item.bindings) {
+ if (!Number.isInteger(binding.slot) || binding.slot < 0 || binding.slot >= item.jointOrder.length) fail(`${item.machineId} 绑定槽位越界`);
+ if (bound.has(binding.slot)) fail(`${item.machineId} 槽位 ${binding.slot} 重复绑定`);
+ bound.add(binding.slot);
+ if (typeof binding.node !== "string" || !binding.node) fail(`${item.machineId} 槽位 ${binding.slot} 缺少节点名`);
+ if (!["x", "y", "z"].includes(binding.direction)) fail(`${item.machineId} 槽位 ${binding.slot} 方向`);
+ if (![1, -1].includes(binding.sign)) fail(`${item.machineId} 槽位 ${binding.slot} 正负向`);
+ }
+ for (const slot of item.jointOrder) if (slot.source !== "passive" && !bound.has(slot.index)) fail(`${item.machineId} 槽位 ${slot.key} 未绑定节点`);
+ const zeros = item.calibration?.physicalZero;
+ if (!Array.isArray(zeros) || zeros.length !== item.jointOrder.length || !zeros.every(Number.isFinite)) fail(`${item.machineId} 物理零点`);
+ byId.set(item.machineId, Object.freeze(item));
+ byEntrypoint.set(item.entrypoint, byId.get(item.machineId));
+}
+
+export const VISUAL_BINDINGS = Object.freeze({ schema: VISUAL_BINDINGS_SCHEMA, glbRoot: bindings.glbRoot, cases: Object.freeze([...byId.values()]) });
+export const glbPath = (binding) => binding ? `${bindings.glbRoot}${binding.glb.default}` : null;
+export const visualBinding = (value) => byId.get(String(value ?? "")) ?? byEntrypoint.get(String(value ?? "")) ?? null;
+export const hasVisualBinding = (value) => Boolean(visualBinding(value));
+export const bindingGlbPath = (value) => glbPath(visualBinding(value));
+
+// Build a v2 joint model from a binding plus the GLB node ids that carry
+// userData.linuxcncNodeId. Fails per slot instead of guessing a node.
+export function bindingJointModel(binding, nodeIds) {
+ if (!binding) fail("缺少绑定");
+ const ids = new Set(nodeIds);
+ const slotMap = binding.jointOrder.map((slot) => {
+ const row = binding.bindings.find((item) => item.slot === slot.index);
+ // Node identity is userData.linuxcncNodeId, not the display name: two
+ // machines may both call their node "x".
+ const id = row?.nodeId ?? row?.node ?? null;
+ return id && ids.has(id) ? id : null;
+ });
+ const missing = binding.jointOrder.filter((slot, index) => slotMap[index] === null && slot.source !== "passive").map((slot) => slot.key);
+ return {
+ schema: "linuxcnc.joint-model/2",
+ machineId: binding.machineId,
+ coordinates: binding.coordinates,
+ units: { length: "mm", angle: "deg", gltfLength: "m" },
+ controller: { source: "linuxcnc-wasm.visual.joints", jointOrder: binding.jointOrder.map((slot) => ({ ...slot })) },
+ calibration: { cOriginMm: [...binding.calibration.cOriginMm], tiltOffsetMm: [...binding.calibration.tiltOffsetMm], physicalZero: [...binding.calibration.physicalZero] },
+ slotMap,
+ missing,
+ };
+}
diff --git a/web/workspace/sim-case-loader.mjs b/web/workspace/sim-case-loader.mjs
index dc4704a..2af09a9 100644
--- a/web/workspace/sim-case-loader.mjs
+++ b/web/workspace/sim-case-loader.mjs
@@ -147,6 +147,28 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
if (typeof fetchImpl !== "function") throw new Error("simulation_case_fetch_required");
const catalog = findSimulationCase(path);
if (!catalog) throw new Error("simulation_case_unknown");
+ const genericMachine = machineDefinition(path);
+ if (genericMachine?.runtime === "generic-wasm") {
+ const axes = [...genericMachine.coordinates];
+ const linear = genericMachine.units?.linear ?? "mm", angular = genericMachine.units?.angular ?? "deg";
+ const lines = ["[EMC]", `MACHINE = ${genericMachine.label}`, "[DISPLAY]", "DISPLAY = axis", "MAX_FEED_OVERRIDE = 1", "[TASK]", "TASK = milltask", "CYCLE_TIME = 0.010", "[EMCMOT]", "EMCMOT = motmod", "SERVO_PERIOD = 1000000", "[TRAJ]", `COORDINATES = ${axes.join(" ")}`, `LINEAR_UNITS = ${linear}`, `ANGULAR_UNITS = ${angular}`, "DEFAULT_LINEAR_VELOCITY = 10", "MAX_LINEAR_VELOCITY = 20", "MAX_LINEAR_ACCELERATION = 100", "[KINS]", `JOINTS = ${genericMachine.joint_count}`, `KINEMATICS = ${genericMachine.kinematics}`];
+ for (let index = 0; index < genericMachine.joint_count; index += 1) {
+ const axis = axes[index] ?? `J${index}`;
+ const range = axis.toUpperCase() === axis && "XYZ".includes(axis.toUpperCase()) ? [-1000, 1000] : [-360, 360];
+ lines.push(`[JOINT_${index}]`, `TYPE = ${"XYZ".includes(axis.toUpperCase()) && axis !== "W" ? "LINEAR" : "ANGULAR"}`, `MIN_LIMIT = ${range[0]}`, `MAX_LIMIT = ${range[1]}`, "MAX_VELOCITY = 10", "MAX_ACCELERATION = 100", "HOME = 0");
+ }
+ const files = { [genericMachine.entrypoint]: `${lines.join("\n")}\n` };
+ const programPath = genericMachine.program;
+ const program = await fetchText(fetchImpl, baseUrl, programPath);
+ if (program === null) throw new Error(`generic_machine_program_missing:${programPath}`);
+ files[programPath] = program;
+ if (genericMachine.source_path && genericMachine.source_path.startsWith("linuxcnc/") && genericMachine.source_path !== genericMachine.entrypoint) {
+ const source = await fetchText(fetchImpl, baseUrl, genericMachine.source_path);
+ if (source !== null) files[genericMachine.source_path] = source;
+ }
+ const unsigned = { schema: SIMULATION_CASE_SCHEMA, entrypoint: genericMachine.entrypoint, files, fetched: [genericMachine.entrypoint, programPath, genericMachine.source_path].filter((item) => item && item in files).sort(), missing: [], optional_missing: [], runtime_fixtures: [{ kind: "web-generic-kinematics", kinematics: genericMachine.kinematics, source_entrypoint: genericMachine.source_entrypoint }] };
+ return { ...clone(unsigned), source_ini: files[genericMachine.entrypoint], catalog: { path: genericMachine.entrypoint, coordinates: genericMachine.coordinates, label: genericMachine.label, status: "web_generic_wasm" }, halManifest: [], status: "ready", state_hash: fnv1a64(unsigned) };
+ }
const files = {};
const fetched = [];
const missing = [];
@@ -239,6 +261,23 @@ export async function loadSimulationCase({ path, fetchImpl = globalThis.fetch, b
export function toWorkerConfiguration(bundle) {
if (!bundle || bundle.schema !== SIMULATION_CASE_SCHEMA) throw new Error("simulation_case_bundle_invalid");
+ const genericMachine = machineDefinition(bundle.entrypoint);
+ if (genericMachine?.runtime === "generic-wasm") {
+ const parsed = parseIni({ workspace: new WorkspaceRuntime({ files: bundle.files }), entrypoint: bundle.entrypoint });
+ const axes = String(parsed.sections.TRAJ.COORDINATES ?? "").replace(/\s+/g, "").toUpperCase();
+ const joints = Number(parsed.sections.KINS.JOINTS);
+ if (axes !== genericMachine.coordinates || joints !== genericMachine.joint_count) throw new Error("generic_machine_configuration_mismatch");
+ const limits = {};
+ for (let index = 0; index < joints; index += 1) {
+ if (index >= axes.length) continue;
+ const section = parsed.sections[`JOINT_${index}`];
+ const range = [Number(section?.MIN_LIMIT), Number(section?.MAX_LIMIT)];
+ if (!range.every(Number.isFinite) || range[0] >= range[1]) throw new Error(`generic_machine_joint_limit:${index}`);
+ limits[axes[index].toLowerCase()] = range;
+ }
+ const kinematicsJointCount = genericMachine.kinematics_joint_count ?? joints;
+ return { entrypoint: bundle.entrypoint, files: clone(bundle.files), halManifest: [], state_hash: bundle.state_hash, kinematics: { type: genericMachine.kinematics, runtime: "generic-wasm", coordinates: axes, joint_count: joints, kinematics_joint_count: kinematicsJointCount, extra_joint_count: genericMachine.extra_joint_count ?? joints - kinematicsJointCount, linear_units: genericMachine.units?.linear ?? "mm", angular_units: genericMachine.units?.angular ?? "deg", limits, parameters: clone(genericMachine.kinematics_parameters ?? {}) }, home_limits: { joint_count: joints, kinematics_type: genericMachine.kinematics_type, coordinates: axes, ...limits } };
+ }
const kinematics = CASE_KINEMATICS[bundle.entrypoint] ? clone(CASE_KINEMATICS[bundle.entrypoint]) : null;
const trt = machineDefinition(bundle.entrypoint) ? readTrtConfiguration(bundle) : null;
let homeLimits = null;
diff --git a/web/workspace/test_glb_axis_naming.mjs b/web/workspace/test_glb_axis_naming.mjs
new file mode 100644
index 0000000..37a9e4a
--- /dev/null
+++ b/web/workspace/test_glb_axis_naming.mjs
@@ -0,0 +1,158 @@
+// GLB 关节识别与 TRT 标定推导的单测(纯 Node,不需要浏览器)。
+// 运行:node web/workspace/test_glb_axis_naming.mjs
+
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, resolve } from "node:path";
+import { classifyAxisNodeName, deriveTrtCalibration, selectAxisNodes } from "./glb-axis-naming.mjs";
+import managerSettings from "./machine-manager-settings.json" with { type: "json" };
+
+const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
+const profileOf = (coordinates) => managerSettings.profiles.find((profile) => profile.coordinates === coordinates) ?? null;
+
+// ---------------------------------------------------------------- 命名规则
+const NAMING_CASES = [
+ // [节点名, 期望轴, 期望等级, 期望优先级, 说明]
+ ["JT_X", "X", "high", 0, "JT_ 精确名"],
+ ["JT_C", "C", "high", 0, "JT_ 精确名"],
+ ["X_AXIS", "X", "medium", 1, "轴字母 + AXIS"],
+ ["AXIS_Z", "Z", "medium", 1, "AXIS + 轴字母"],
+ ["b_axis", "B", "medium", 1, "轴字母 + AXIS"],
+ ["b_axis_bearing", "B", "low", 4, "同轴零件:优先级低于 b_axis"],
+ ["b_axis_axis", "B", "low", 4, "同轴零件:优先级低于 b_axis"],
+ ["a_axis_bearings", "A", "low", 4, "同轴零件:优先级低于 a_outer_table"],
+ ["x_slide", "X", "medium", 2, "直线滑台"],
+ ["y_saddle", "Y", "medium", 2.2, "Y 鞍座"],
+ ["y_saddle_casting", "Y", "medium", 2.22, "铸件:优先级低于 y_saddle"],
+ ["z_ram", "Z", "medium", 2.8, "Z 滑枕"],
+ ["moving_x_table", "X", "medium", 2.5, "轴字母在部件词之前"],
+ ["a_outer_table", "A", "medium", 2.5, "外转台 = A"],
+ ["b_inner_table", "B", "medium", 2.5, "内转台 = B"],
+ ["c_table", "C", "medium", 2.5, "回转工作台 = C"],
+ ["b_trunnion", "B", "medium", 2.3, "B 轴摇篮"],
+ ["a_tilt_frame", "A", "medium", 2.4, "A 轴倾转架"],
+ ["w_tool_direction", "W", "medium", 2.9, "W 刀具方向轴"],
+ ["x_linear_rail_guide", "X", "low", 4, "导轨属于零件级线索"],
+ ["bolt_x_1", "X", "low", 4, "螺栓属于零件级线索"],
+];
+for (const [name, axis, confidence, rank, note] of NAMING_CASES) {
+ const result = classifyAxisNodeName(name);
+ assert.ok(result, `${name} 应当被识别为轴(${note})`);
+ assert.equal(result.axis, axis, `${name} 轴`);
+ assert.equal(result.confidence, confidence, `${name} 可信度`);
+ assert.ok(Math.abs(result.rank - rank) < 1e-9, `${name} 优先级应为 ${rank},实际 ${result.rank}`);
+}
+
+for (const name of ["tilt_axis", "rotary_axis", "tilt_table", "rotary_axis_bearing", "work_platter"]) {
+ const result = classifyAxisNodeName(name);
+ assert.ok(result?.ambiguous, `${name} 应当是需要消歧的通用部件名`);
+}
+
+for (const name of ["j1_base", "j2_shoulder", "portal_column", "column.001", "fixture_plate", "machine_root", "支腿", "", "tool"]) {
+ assert.equal(classifyAxisNodeName(name), null, `${name} 不应被识别为数控轴`);
+}
+// 通用词 + 零件词的优先级低于主轴节点,避免把 tilt_bearing 当成倾转轴。
+assert.ok(classifyAxisNodeName("tilt_bearing_49").rank > classifyAxisNodeName("tilt_table").rank);
+assert.ok(classifyAxisNodeName("trunnion_pedestal_48").rank > classifyAxisNodeName("tilt_table").rank);
+
+// ---------------------------------------------------------------- 真实 GLB 名称
+function readDescriptor(path) {
+ const buffer = readFileSync(path);
+ const jsonLength = buffer.readUInt32LE(12);
+ const json = JSON.parse(buffer.subarray(20, 20 + jsonLength).toString("utf8"));
+ const nodes = json.nodes ?? [];
+ const depth = new Array(nodes.length).fill(0);
+ const walk = (index, level) => { depth[index] = level; for (const child of nodes[index].children ?? []) walk(child, level + 1); };
+ const scene = json.scenes?.[json.scene ?? 0];
+ (scene?.nodes ?? []).forEach((index) => walk(index, 0));
+ const urdf = scene?.extras?.linuxcncMachine?.urdf;
+ const declared = scene?.extras?.linuxcncMachine?.coordinates ?? (typeof urdf === "string"
+ ? [...new Set([...urdf.matchAll(/ match[1]))].join("")
+ : null);
+ return {
+ // Joint metadata written by scripts/generate_physical_case_glbs.mjs is the
+ // authoritative axis hint; single-letter slot names carry no other clue.
+ nodes: nodes.map((node, index) => ({ name: node.name ?? "", path: `scene/${index}`, depth: depth[index], explicitAxis: node.extras?.linuxcncJoint?.axis ?? node.extras?.["linuxcnc.axis"] ?? null })),
+ declared: typeof declared === "string" && declared.length === 5 ? declared : null,
+ };
+}
+
+const analysis = (relativePath) => {
+ const { nodes, declared } = readDescriptor(resolve(repoRoot, relativePath));
+ const profile = declared ? profileOf(declared) : null;
+ return selectAxisNodes(nodes, { declaredCoordinates: declared, tiltAxis: profile?.tiltAxis ?? null, rotaryAxis: profile?.rotaryAxis ?? null });
+};
+const boundAxes = (result) => result.axes.filter((item) => item.autoBind).map((item) => `${item.axis}:${item.name}`);
+
+// 真实机床(带 JT_ 命名)与自带案例:不依赖任何提示即可绑定。
+const AUTO_CASES = [
+ ["glb/m1.glb", "XYZAC", ["X:JT_X", "Y:JT_Y", "Z:JT_Z", "A:JT_A", "C:JT_C"]],
+ ["web/react/public/machine-cases/physical/xyzac-trt.glb", "XYZAC", ["X:x", "Y:y", "Z:z", "A:a", "C:c"]],
+ ["web/react/public/machine-cases/physical/xyzbc-trt.glb", "XYZBC", ["X:x", "Y:y", "Z:z", "B:b", "C:c"]],
+ ["web/react/public/machine-cases/physical/xyzab-tdr.glb", "XYZAB", ["X:x", "Y:y", "Z:z", "A:a", "B:b"]],
+ ["web/react/public/machine-cases/physical/trivkins-xyzab.glb", "XYZAB", ["X:x", "Y:y", "Z:z", "A:a", "B:b"]],
+ ["web/react/public/machine-cases/physical/trivkins-xyzcw.glb", "XYZCW", ["X:x", "Y:y", "Z:z", "C:c", "W:w"]],
+];
+for (const [file, coordinates, expected] of AUTO_CASES) {
+ const result = analysis(file);
+ assert.equal(result.suggestedCoordinates, coordinates, `${file} 机型`);
+ assert.deepEqual(boundAxes(result), expected, `${file} 绑定节点`);
+ assert.deepEqual(result.missingAxes, [], `${file} 不应缺轴`);
+}
+
+// 内置示例:场景内嵌 URDF 声明了轴序,按声明消歧后同样"导入即绑定"。
+const DECLARED_CASES = [
+ ["web/react/public/machines/demo-ac-xyz-head.glb", "XYZAC"],
+ ["web/react/public/machines/demo-ac-gantry.glb", "XYZAC"],
+ ["web/react/public/machines/demo-bc-gantry.glb", "XYZBC"],
+];
+for (const [file, coordinates] of DECLARED_CASES) {
+ const result = analysis(file);
+ assert.equal(result.suggestedCoordinates, coordinates, `${file} 机型`);
+ assert.deepEqual(boundAxes(result).map((item) => item.split(":")[0]), [...coordinates], `${file} 应绑定五个轴`);
+}
+
+// 重复轴与额外直线轴案例:分析器要求恰好五个唯一字母,因此这两类必须返回
+// UNKNOWN,由 machine-visual-bindings.json 按槽位显式绑定。
+// bridge-mill: XYZBCW + 重复 Y —— 六个不同字母
+// xyzya: XYZ + A + 重复 Y —— 只有四个不同字母
+for (const [id, reason] of [["bridge-mill-5axiskins", "六个不同轴字母"], ["trivkins-xyzya", "重复 Y 合并后只有四个字母"]]) {
+ const result = analysis(`web/react/public/machine-cases/physical/${id}.glb`);
+ assert.equal(result.suggestedCoordinates, "UNKNOWN", `${id} 不按五轴自动识别(${reason})`);
+ assert.deepEqual(boundAxes(result), [], `${id} 不应自动绑定`);
+}
+
+// 并联平台与串联机器人不是 XYZABC 语义,保持"需手动绑定",不误报。
+for (const id of ["hexapod-genhexkins", "pentapod-pentakins", "puma-pumakins", "puma-genserkins", "scorbot-kins"]) {
+ const result = analysis(`web/react/public/machine-cases/physical/${id}.glb`);
+ assert.equal(result.suggestedCoordinates, "UNKNOWN", `${id} 不应被当作五轴 XYZABC 机床`);
+ assert.deepEqual(boundAxes(result), [], `${id} 不应自动绑定`);
+}
+
+// ---------------------------------------------------------------- TRT 标定推导
+const xyzac = profileOf("XYZAC");
+const calibration = deriveTrtCalibration({ coordinates: "XYZAC", profile: xyzac, cWorldMm: [10, 20, 30], tiltWorldMm: [110, 50, 70] });
+assert.deepEqual(calibration.cOriginMm, [10, 20, 30], "C 轴原点取 C 轴节点世界坐标");
+assert.deepEqual(calibration.tiltOffsetMm, [0, 30, 40], "AC 机型把沿 A 轴的 X 分量归零");
+assert.equal(calibration.zeroedOffsetValueMm, 100, "记录被归零的分量");
+assert.equal(calibration.zeroedOffsetDirection, "x");
+assert.deepEqual(calibration.physicalZero, { x: 0, y: 0, z: 0, a: 0, c: 0 });
+
+const xyzbc = profileOf("XYZBC");
+const bcCalibration = deriveTrtCalibration({ coordinates: "XYZBC", profile: xyzbc, cWorldMm: [1, 2, 3], tiltWorldMm: [11, 22, 33] });
+assert.deepEqual(bcCalibration.tiltOffsetMm, [10, 0, 30], "BC 机型把沿 B 轴的 Y 分量归零");
+assert.equal(bcCalibration.zeroedOffsetValueMm, 20);
+
+assert.equal(deriveTrtCalibration({ coordinates: "XYZAB", profile: null, cWorldMm: [0, 0, 0], tiltWorldMm: [1, 1, 1] }), null, "非 TRT 机型不产出原生标定");
+assert.equal(deriveTrtCalibration({ coordinates: "XYZAC", profile: xyzac, cWorldMm: null, tiltWorldMm: [1, 1, 1] }), null, "缺坐标时不产出标定");
+
+console.log(JSON.stringify({
+ report_id: "glb-axis-naming-v1",
+ status: "verified",
+ naming_cases: NAMING_CASES.length + 5 + 9,
+ auto_binding_cases: AUTO_CASES.length,
+ declared_binding_cases: DECLARED_CASES.length,
+ manual_only_cases: 5,
+ trt_calibration: calibration,
+}, null, 1));
diff --git a/web/workspace/test_machine_kinematics_specs.mjs b/web/workspace/test_machine_kinematics_specs.mjs
new file mode 100644
index 0000000..412ed81
--- /dev/null
+++ b/web/workspace/test_machine_kinematics_specs.mjs
@@ -0,0 +1,53 @@
+// Drift check: machine-kinematics-specs.json must match the limits declared in
+// the LinuxCNC INIs. A null limit means the source declares none; inventing a
+// number here would put a fabricated value in front of the operator.
+import assert from "node:assert/strict";
+import { readFileSync, existsSync } from "node:fs";
+import { dirname, join, resolve } from "node:path";
+import { MACHINE_DEFINITIONS } from "./machine-definitions.mjs";
+
+// This file lives in web/workspace/, so the repo root is two levels up.
+const root = resolve(new URL("../..", import.meta.url).pathname);
+const specs = JSON.parse(readFileSync(join(root, "web/workspace/machine-kinematics-specs.json"), "utf8"));
+assert.equal(specs.schema, "linuxcnc.machine-kinematics-specs/1");
+
+function readCase(entrypoint) {
+ const path = join(root, entrypoint);
+ if (!existsSync(path)) return null;
+ let text = readFileSync(path, "utf8");
+ for (const match of text.matchAll(/^\s*(?:#)?INCLUDE\s+(\S+)/gim)) {
+ const include = join(dirname(path), match[1]);
+ if (existsSync(include)) text += `\n${readFileSync(include, "utf8")}`;
+ }
+ return text;
+}
+// `m` alone makes `$` match at the first line end, which truncated every body.
+// The lookahead uses a "no more input" test instead of `$` so the section runs
+// to the next header or to the end of the file.
+const section = (text, name) => {
+ const match = text.match(new RegExp(`^\\[${name}\\]\\s*(?:\\r?\\n)([\\s\\S]*?)(?=^\\[|(?![\\s\\S]))`, "im"));
+ return match ? match[1] : null;
+};
+const value = (body, key) => body?.match(new RegExp(`^\\s*${key}\\s*=\\s*(\\S+)`, "im"))?.[1] ?? null;
+
+const generic = MACHINE_DEFINITIONS.filter((machine) => machine.runtime === "generic-wasm");
+assert.equal(specs.cases.length, generic.length, "每个通用案例都应有规格");
+for (const machine of generic) {
+ const spec = specs.cases.find((item) => item.machineId === machine.id);
+ assert.ok(spec, `${machine.id} 缺少规格`);
+ assert.equal(spec.jointCount, machine.joint_count, `${machine.id} 槽位数`);
+ assert.equal(spec.coordinates, machine.coordinates, `${machine.id} 坐标`);
+ assert.equal(spec.jointOrder.length, machine.joint_count, `${machine.id} 槽位键数量`);
+ assert.equal(spec.limitsByJoint.length, machine.joint_count, `${machine.id} 限位数量`);
+ const text = readCase(machine.entrypoint);
+ assert.ok(text, `${machine.id} 的 INI 不存在:${machine.entrypoint}`);
+ for (let index = 0; index < machine.joint_count; index += 1) {
+ const body = section(text, `JOINT_${index}`);
+ const lower = value(body, "MIN_LIMIT"), upper = value(body, "MAX_LIMIT");
+ const expected = lower !== null && upper !== null && Number.isFinite(Number(lower)) && Number.isFinite(Number(upper))
+ ? [Number(lower), Number(upper)] : null;
+ assert.deepEqual(spec.limitsByJoint[index], expected,
+ `${machine.id} J${index} 限位与 INI 不一致(spec=${JSON.stringify(spec.limitsByJoint[index])} ini=${JSON.stringify(expected)})`);
+ }
+}
+console.log(JSON.stringify({ schema: "machine-kinematics-specs.v1", status: "verified", cases: specs.cases.length, checks: ["槽位数与运行目录一致", "逐关节限位与 INI 完全一致", "INI 未声明的限位保持 null"] }));
diff --git a/web/workspace/test_machine_visual_bindings.mjs b/web/workspace/test_machine_visual_bindings.mjs
new file mode 100644
index 0000000..5d4d7d4
--- /dev/null
+++ b/web/workspace/test_machine_visual_bindings.mjs
@@ -0,0 +1,38 @@
+import assert from "node:assert/strict";
+import { existsSync, statSync } from "node:fs";
+import { MACHINE_DEFINITIONS } from "./machine-definitions.mjs";
+import { VISUAL_BINDINGS, bindingGlbPath, bindingJointModel, hasVisualBinding, visualBinding } from "./machine-visual-bindings.mjs";
+
+const MAX_BYTES = 64 * 1024 * 1024;
+assert.equal(VISUAL_BINDINGS.cases.length, MACHINE_DEFINITIONS.length, "每个案例都必须有绑定");
+for (const machine of MACHINE_DEFINITIONS) {
+ const binding = visualBinding(machine.id);
+ assert.ok(binding, `${machine.id} 缺少绑定`);
+ assert.equal(binding.jointCount, machine.joint_count, `${machine.id} 槽位数`);
+ assert.equal(binding.coordinates, machine.coordinates, `${machine.id} 坐标`);
+ const file = `web/react/public/${bindingGlbPath(machine.id)}`;
+ assert.ok(existsSync(file), `${machine.id} GLB 缺失`);
+ assert.ok(statSync(file).size <= MAX_BYTES, `${machine.id} GLB 超限`);
+}
+assert.ok(hasVisualBinding("xyzac-trt"));
+assert.ok(visualBinding("linuxcnc/configs/sim/axis/vismach/5axis/max5/max5kins.ini"), "entrypoint 查找");
+assert.equal(visualBinding("no-such-case"), null);
+assert.equal(visualBinding("XYZAB"), null, "坐标字母不得作为键(三个案例共用)");
+
+// bindingJointModel reports per-slot gaps instead of guessing nodes.
+const bridge = visualBinding("bridge-mill-5axiskins");
+const nodes = bridge.bindings.map((item) => item.nodeId);
+const full = bindingJointModel(bridge, nodes);
+assert.deepEqual(full.missing, []);
+assert.equal(full.slotMap.length, 7);
+assert.equal(full.controller.jointOrder[6].key, "Y#2");
+const partial = bindingJointModel(bridge, nodes.filter((id) => id !== bridge.bindings.find((item) => item.node === "w").nodeId));
+assert.deepEqual(partial.missing, ["W"]);
+assert.equal(partial.slotMap[5], null);
+
+// Passive slots are allowed to stay unbound (max5 joint 5 has no visual motion).
+const max5 = visualBinding("max5-maxkins");
+const max5Model = bindingJointModel(max5, max5.bindings.map((item) => item.nodeId));
+assert.deepEqual(max5Model.missing, []);
+
+console.log(JSON.stringify({ schema: "machine-visual-bindings.v1", status: "verified", cases: VISUAL_BINDINGS.cases.length, checks: ["13 案例全覆盖", "槽位数与 joint_count 一致", "GLB 存在且 ≤64 MiB", "坐标字母不作键", "缺节点逐槽报告", "被动槽允许不绑定"] }));