按关节槽位驱动 13 个五轴案例的三维模型

关节契约扩为槽位制:以 LinuxCNC 联合号标识关节,轴字母降为槽位属性,
重复轴(bridge-mill 的两个 Y)、并联杆长(hexapod/pentapod)和被动槽位
(max5 第 6 关节)都能表达。v1 规则保持不变并合成 jointOrder,已保存
GLB 与既有测试不受影响。

13 个结构 GLB 由 scripts/generate_physical_case_glbs.mjs 统一重建,
可动关节均为按槽位命名的 Group 并写 userData.linuxcncJoint,共 71 个
关节,槽位数与运行目录的 joint_count 逐条一致。

新增 machine-visual-bindings.json 与纯函数模块,以 machine id 为键:
坐标字母不能作键,因为三个案例共用 XYZAB、三个共用 XYZABC。主界面切换
案例时自动装载对应模型,失败时保留内置模型并显式提示。

消除前端五关节截断:visual 适配器的 joints 改为数组并新增
jointsAuthoritative;allHomed 按 joint_count 判断;面板按 worker 断言的
machine id 查找机型。

管理器解锁:坐标与案例下拉以"存在完整视觉绑定"为启用条件,13 案例全部
可选;machine-urdf.ts 新增 generic-wasm/1 profile,AC/BC 严格校验路径
不变;新增 machine-kinematics-specs.json 保存逐关节限位,INI 未声明的
限位保持 null 而不编造。

修复三个真实缺陷:构建漏发 woodpecker/gmoccapy/qtdragon 配置目录(
DEFECT-02 根因);案例切换沿用"装载需要已上电"的门控,导致关机状态下
第二次切换被静默拒绝;重复轴机型上 React 列表键冲突。

验收:13 案例逐个切换装载成功、槽位键与绑定定义逐一一致、浏览器错误 0;
typecheck 与生产构建通过;视觉绑定、GLB 命名、关节契约、投影、store、
双画布、运动学规格漂移、11 案例 Worker 闭环、管理器浏览器回归全部通过。
证据在 coverage/glb-machine-manager/physical-drive/。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 06:09:12 +08:00
parent af65b69b2c
commit db29ae625f
157 changed files with 11936 additions and 885 deletions

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 137 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 249 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 102 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -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 里的 `<cnc:axis coordinate="…"/>`)→ 调用方给出的设计机型 → 同模型里明确的轴字母。仍然无法消歧时不自动绑定,并在警告里列出。
- 同名重复节点(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` 未出现在该模型层级中。

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -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 面板运行闭环。

View File

@@ -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` 记录,不编造数值。

View File

@@ -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` 解析|

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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` 为本次修改前后的对比图。

View File

@@ -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 的五轴绑定恢复和窄屏操作。

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,45 @@
# 使用说明书(HTML)与发布功能巡检
本目录存放线上发布站点 `https://49.232.250.35:58010/` 的用户使用说明书和配套的功能测试。
## 交付物
- [`LinuxCNC-Web-使用说明书.html`](LinuxCNC-Web-使用说明书.html):单文件中文使用说明书,22 张实测截图以 data URL 内嵌,可离线打开、可打印/导出 PDF,桌面与窄屏均无横向溢出。
已随前端产物发布:线上地址 <https://49.232.250.35:58010/manual/>,主页标题栏的"使用说明书"按钮与 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。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,564 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="description" content="LinuxCNC Web/WASM 五轴机床仿真平台使用说明书">
<link rel="icon" href="data:,">
<title>LinuxCNC Web/WASM 使用说明书</title>
<style>
:root{--ink:#172632;--muted:#5b7180;--line:#d7e3e8;--paper:#f5f9fa;--card:#fff;--navy:#113a50;--teal:#087f87;--cyan:#18b8be;--orange:#e88b43;--green:#16845e;--red:#c0392b;--code:#0d2633;--shadow:0 16px 45px #173b4a18}
*{box-sizing:border-box}
html{scroll-behavior:smooth}
body{margin:0;color:var(--ink);background:radial-gradient(circle at 90% 0,#1ab8c01e,transparent 28rem),linear-gradient(180deg,#e8f3f6 0,var(--paper) 27rem,#fff 100%);font:15px/1.75 Inter,"Noto Sans SC","Microsoft YaHei",sans-serif}
a{color:var(--teal);text-decoration:none}
a:hover{text-decoration:underline}
.topbar{position:sticky;top:0;z-index:5;display:flex;justify-content:space-between;gap:1rem;padding:.72rem clamp(1rem,4vw,4rem);color:#fff;background:#103a50f5;backdrop-filter:blur(14px);box-shadow:0 4px 22px #102d3d25}
.brand{display:flex;align-items:center;gap:.65rem;font-weight:800}
.mark{display:grid;place-items:center;width:2rem;height:2rem;border-radius:10px;color:#12384a;background:var(--cyan);font-size:.72rem;font-weight:900}
.actions{display:flex;gap:.5rem;flex-wrap:wrap}
.actions a,.actions button{border:1px solid #ffffff55;border-radius:999px;padding:.28rem .75rem;color:#fff;background:#ffffff12;cursor:pointer;font:inherit}
.actions a:hover,.actions button:hover{background:#ffffff2b;text-decoration:none}
main{max-width:1360px;margin:auto;padding:0 clamp(1rem,4vw,4rem) 5rem}
.hero{padding:2.6rem 0 1.4rem}
.eyebrow{margin:0 0 .6rem;color:var(--teal);font-size:.78rem;font-weight:900;letter-spacing:.16em}
.hero h1{margin:0;color:var(--navy);font-size:clamp(2rem,4.4vw,3.4rem);line-height:1.1;letter-spacing:-.045em}
.lead{max-width:62rem;margin:1rem 0 0;color:var(--muted);font-size:1.06rem}
.tags{display:flex;flex-wrap:wrap;gap:.5rem;margin:1.1rem 0 0}
.tag{display:inline-flex;gap:.3rem;border:1px solid var(--line);border-radius:999px;padding:.23rem .68rem;color:var(--muted);background:#ffffffd9;font-size:.82rem}
.tag b{color:var(--navy)}
.toc{display:grid;grid-template-columns:repeat(auto-fit,minmax(11rem,1fr));gap:.55rem;margin:1.6rem 0 2.4rem}
.toc a{display:block;padding:.62rem .8rem;border:1px solid var(--line);border-radius:12px;color:var(--navy);background:var(--card);font-size:.84rem;font-weight:800;box-shadow:0 5px 15px #173b4a08}
section{padding:1.6rem 0;border-top:1px solid var(--line)}
section>h2{margin:.2rem 0 1rem;color:var(--navy);font-size:clamp(1.35rem,2.4vw,1.9rem);letter-spacing:-.02em}
h3{margin:1.6rem 0 .6rem;color:var(--navy);font-size:1.06rem}
h4{margin:1.1rem 0 .4rem;color:var(--teal);font-size:.95rem}
p{margin:.6rem 0}
ul,ol{margin:.5rem 0 .9rem;padding-left:1.35rem}
li{margin:.25rem 0}
code,kbd{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}
code{padding:.1rem .38rem;border-radius:6px;color:#0d3b46;background:#e6f2f4;font-size:.86em}
kbd{display:inline-block;min-width:1.5rem;padding:.08rem .4rem;border:1px solid #b9cbd4;border-bottom-width:2px;border-radius:6px;color:#25384a;background:#fff;font-size:.78rem;text-align:center}
.grid2{display:grid;grid-template-columns:repeat(auto-fit,minmax(19rem,1fr));gap:1rem}
.card{border:1px solid var(--line);border-radius:16px;padding:1rem 1.15rem;background:var(--card);box-shadow:0 8px 24px #173b4a0f}
.card h4{margin-top:0}
.note{border-left:4px solid var(--orange);padding:.72rem 1rem;border-radius:0 12px 12px 0;color:#72451f;background:#fff3e7;margin:1rem 0}
.danger{border-left:4px solid var(--red);padding:.72rem 1rem;border-radius:0 12px 12px 0;color:#7d241a;background:#fdeceb;margin:1rem 0}
.ok{border-left:4px solid var(--green);padding:.72rem 1rem;border-radius:0 12px 12px 0;color:#14503a;background:#e9f7f1;margin:1rem 0}
table{width:100%;border-collapse:collapse;margin:.8rem 0 1.2rem;background:var(--card);border:1px solid var(--line);border-radius:12px;overflow:hidden;font-size:.9rem}
th,td{padding:.55rem .7rem;border-bottom:1px solid var(--line);text-align:left;vertical-align:top}
th{color:#fff;background:#17505f;font-weight:700;white-space:nowrap}
td,th{overflow-wrap:anywhere}
code{overflow-wrap:anywhere}
tbody tr:nth-child(even){background:#f7fbfc}
tbody tr:last-child td{border-bottom:none}
figure{margin:1rem 0 1.4rem}
figure img{width:100%;height:auto;border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow);display:block}
figcaption{margin-top:.5rem;color:var(--muted);font-size:.84rem}
.pill{display:inline-block;padding:.1rem .5rem;border-radius:999px;font-size:.76rem;font-weight:800}
.pill.yes{color:#0f5a3f;background:#d9f2e7}
.pill.no{color:#8a2b1f;background:#fbe2df}
.pill.part{color:#7a5410;background:#fdf0d5}
.steps{counter-reset:step;list-style:none;padding-left:0}
.steps>li{position:relative;padding-left:3rem;margin:.85rem 0}
.steps>li::before{counter-increment:step;content:counter(step);position:absolute;left:0;top:0;display:grid;place-items:center;width:2.1rem;height:2.1rem;border-radius:50%;color:#fff;background:var(--teal);font-weight:800}
.steps>li b{color:var(--navy)}
.mono{font-family:ui-monospace,SFMono-Regular,Consolas,monospace;font-size:.86rem;color:#0d3b46;background:#eef6f8;border:1px solid var(--line);border-radius:10px;padding:.6rem .8rem;display:block;overflow-x:auto}
footer{padding:2.2rem clamp(1rem,4vw,4rem) 3rem;color:#cfe4ea;background:#103a50;font-size:.86rem}
footer a{color:#8fdadf}
@media(max-width:640px){table{display:block;overflow-x:auto;-webkit-overflow-scrolling:touch}th{white-space:normal}}
@media print{.topbar,.actions,.toc{display:none}body{background:#fff}section{break-inside:avoid}figure{break-inside:avoid}figure img{box-shadow:none}a{color:var(--ink)}}
</style>
</head>
<body>
<header class="topbar">
<div class="brand"><span class="mark">CNC</span><span>LinuxCNC Web/WASM 使用说明书</span></div>
<div class="actions">
<a href="https://49.232.250.35:58010/" target="_blank" rel="noopener">打开软件</a>
<button type="button" onclick="window.print()">打印 / 导出 PDF</button>
</div>
</header>
<main>
<div class="hero">
<p class="eyebrow">WEB / WASM · 五轴机床仿真与 RTCP 教学平台</p>
<h1>LinuxCNC Web/WASM 使用说明书</h1>
<p class="lead">本手册面向机床操作者与教学使用者,说明如何用浏览器访问这套 LinuxCNC 仿真控制器:选择机床案例、上电回零、手动与 MDI 操作、运行 G 代码、使用 RTCP 程序库,以及三维机床模型的导入、标定与保存。全文操作均在浏览器内完成,不需要安装 LinuxCNC,也不连接真实机床与硬件。</p>
<div class="tags">
<span class="tag">访问地址 <b>https://49.232.250.35:58010/</b></span>
<span class="tag">来源基线 <b>LinuxCNC 2.10.0~pre0</b></span>
<span class="tag">当前可用机型 <b>XYZAC / XYZBC 转头转台(TRT)</b></span>
<span class="tag">功能巡检 <b>26 项通过</b></span>
<span class="tag">手册版本 <b>2026-10-08</b></span>
</div>
</div>
<nav class="toc" aria-label="目录">
<a href="#s1">1. 软件概览与访问</a>
<a href="#s2">2. 五分钟快速上手</a>
<a href="#s3">3. 界面总览</a>
<a href="#s4">4. 状态与运行反馈</a>
<a href="#s5">5. 机床案例与程序</a>
<a href="#s6">6. 手动控制</a>
<a href="#s7">7. MDI 与 DRO</a>
<a href="#s8">8. 倍率与显示图层</a>
<a href="#s9">9. 程序运行控制</a>
<a href="#s10">10. 三维预览与机床管理</a>
<a href="#s11">11. 诊断信息</a>
<a href="#s12">12. 快捷键与鼠标</a>
<a href="#s13">13. 支持的机床案例</a>
<a href="#s14">14. 已知问题与规避</a>
<a href="#s15">15. 常见问题</a>
<a href="#s16">16. 附录:功能测试结论</a>
</nav>
<section id="s1">
<h2>1. 软件概览与访问</h2>
<h3>1.1 这是什么</h3>
<p>这是一个把 LinuxCNC 仿真行为搬进浏览器的 Web 应用:数值密集的实时任务(解释器、轨迹规划、运动学正逆解)编译成 WebAssembly,在浏览器的独立 Worker 线程里运行;界面参照 LinuxCNC 官方 AXIS 控制面板,另加三维机床模型预览与五轴 RTCP 教学资源。</p>
<ul>
<li><b>不连接真实设备</b>:没有伺服、主轴、刀库和限位开关,所有运动都是仿真,不会造成任何机械或人身风险,适合教学与工艺验证。</li>
<li><b>无需安装</b>:支持 WebAssembly 的现代浏览器(推荐最新版 Chrome 或 Edge)直接打开即可。</li>
<li><b>数据不出本机</b>:G 代码解析、仿真运算都在浏览器里执行;导入的机床模型也只保留在页面与浏览器本地存储中。</li>
</ul>
<h3>1.2 访问地址与首次打开</h3>
<p>在浏览器地址栏输入或点击:<a href="https://49.232.250.35:58010/" target="_blank" rel="noopener">https://49.232.250.35:58010/</a></p>
<div class="note">
<b>证书提示:</b>该站点使用 IP 地址的 HTTPS 自签证书,浏览器可能提示"您的连接不是私密连接 / 证书无效"。在确认地址为 <code>49.232.250.35:58010</code> 后,选择"高级 → 继续前往"即可。企业环境下也可以让管理员把证书导入受信任列表。
</div>
<p>打开后右上角会显示 <b>Connection: READY</b>,下方状态栏从 <code>PENDING</code> 变为具体状态(如 <code>STATE_ESTOP_RESET</code>),即表示 WASM 仿真会话已经就绪。首次加载需要下载约 1.7 MB 的前端与 WASM 资源,之后浏览器会缓存。</p>
<p>标题栏右侧还有一个蓝色的 <b>使用说明书</b> 按钮,随时点击即可在新标签页打开本手册;菜单 <b>Help → 使用说明书 / User manual</b> 是同一个入口。</p>
<h3>1.3 边界说明(请提前知悉)</h3>
<ul>
<li>当前发布版本只支持 <b>XYZAC TRT</b> 与 <b>XYZBC TRT</b> 两个五轴转头转台案例的完整操作链;其余案例可以选中,但控制器不会就绪,详见 <a href="#s13">第 13 节</a> 与 <a href="#s14">第 14 节</a>。</li>
<li>少数 LinuxCNC 原生命令(如程序反向/正向、块删除、可选停止、Rapid 倍率、刀具表编辑)在本 WASM 运行时尚未开放,界面按钮置灰并在悬停时说明原因,这是设计上的"显式边界",不是故障。</li>
<li>断电崩溃恢复只覆盖页面刷新与 OPFS 检查点范围,不代表物理断电场景。</li>
</ul>
</section>
<section id="s2">
<h2>2. 五分钟快速上手</h2>
<p>下面是从零到"跑起来一段五轴程序"的最短路径,按顺序操作即可。</p>
<ol class="steps">
<li><b>打开页面。</b>访问 <a href="https://49.232.250.35:58010/" target="_blank" rel="noopener">https://49.232.250.35:58010/</a>,等待右上角变成 <code>Connection: READY</code>、案例状态显示 <code>Program: boat-xyzac.ngc</code>。</li>
<li><b>确认仿真案例。</b>"Simulation case"下拉框选择 <code>XYZAC TRT</code>(默认值)。</li>
<li><b>急停复位并上电。</b>点击工具栏最左侧红色"急停"图标解除急停,再点击其右侧"上电"图标。<b>关键:请按此顺序操作后,才会出现 <code>STATE_ON</code>。</b></li>
<li><b>回零。</b>在左下"Manual Control"页签点击 <b>HOME ALL</b>,状态栏的 <code>Machine sequence</code> 变为 <code>HOMED</code>。</li>
<li><b>动一下。</b>选轴 <code>X</code>,按住 <b>+</b> 连续 Jog,或切到 <b>MDI [F5]</b> 页签输入 <code>G0 X10 Y0 Z0</code> 后回车。</li>
<li><b>跑程序。</b>点击案例条上的 <b>Load XYZAC boat program</b>,等状态显示 <code>Program: boat-xyzac.ngc</code> 后点击工具栏"运行"图标;用"暂停/继续"和"停止"控制执行。</li>
</ol>
<figure>
<img src="screenshots/01-main-overview.png" alt="软件主界面,处于 XYZAC TRT 案例就绪状态">
<figcaption>图 1 软件主界面(XYZAC TRT,已载入 boat-xyzac.ngc,等待上电与回零)。点击可放大查看各区域位置,区域说明见第 3 节。</figcaption>
</figure>
<div class="ok"><b>提示:</b>只要不刷新页面,程序、机床模型和当前位置都会保留。刷新后仿真会按当前配置重新启动;若之前保存过检查点,可看到恢复提示。</div>
</section>
<section id="s3">
<h2>3. 界面总览</h2>
<p>界面自上而下分为六层,操作时按"选择对象 → 上电 → 回零 → 手动/程序"的次序使用即可。</p>
<table>
<thead><tr><th>区域</th><th>位置</th><th>作用</th></tr></thead>
<tbody>
<tr><td>标题栏</td><td>最上方</td><td>左侧为产品标识;右侧为 <b>使用说明书</b> 按钮(新标签页打开本手册)与连接状态指示 <b>Connection: READY</b></td></tr>
<tr><td>菜单栏</td><td>标题栏下方</td><td><b>File</b>(宿主边界项,置灰)、<b>Help → 使用说明书 / About AXIS</b></td></tr>
<tr><td>工具栏</td><td>菜单下方图标行</td><td>急停、上电、打开文件、载入案例、重载、运行、单步、暂停/继续、停止、缩放、六个视图方向、拖动旋转、清除轨迹</td></tr>
<tr><td>案例与程序条</td><td>工具栏下方</td><td>选择仿真案例、载入内置船体程序、打开 RTCP 程序库、显示当前程序与加载进度</td></tr>
<tr><td>状态栏</td><td>案例条下方</td><td>Task/解释器/运动模式/回零等运行状态,以及最近一条命令反馈</td></tr>
<tr><td>轴位与实时参数</td><td>状态栏下方</td><td>当前机型的坐标轴读数与关节值(J0…Jn)、偏置,以及主轴/进给/速度/倍率等实时量</td></tr>
<tr><td>操作区(左右分栏)</td><td>页面下半部</td><td>左:Manual Control / MDI / Diagnostics 页签;右:Preview(刀路 + 机床模型)/ DRO 页签。底部为倍率滑块与预览图层开关</td></tr>
</tbody>
</table>
<div class="note">界面图标沿用 LinuxCNC 官方 AXIS 位图资源,含义与桌面版一致(绿点=上电、双三角=运行、蓝色暂停=暂停、红色方块=停止)。把鼠标悬停在按钮上会显示名称和快捷键,按钮被禁用时会说明被禁用的原因。</div>
</section>
<section id="s4">
<h2>4. 状态与运行反馈</h2>
<h3>4.1 状态栏字段</h3>
<table>
<thead><tr><th>字段</th><th>取值示例</th><th>含义</th></tr></thead>
<tbody>
<tr><td>Task mode</td><td>MANUAL / MDI / AUTO</td><td>任务模式:手动、MDI 单句、自动运行程序</td></tr>
<tr><td>Task state</td><td>STATE_ESTOP、STATE_ESTOP_RESET、STATE_ON</td><td>机床状态:急停中、急停已解除、已上电</td></tr>
<tr><td>Interpreter</td><td>INTERP_IDLE / INTERP_READING / INTERP_PAUSED</td><td>G 代码解释器状态:空闲、读取中、已暂停</td></tr>
<tr><td>Motion mode</td><td>FREE / TELEOP / COORD</td><td>运动模式:自由、手动点动、坐标运动</td></tr>
<tr><td>Motion type</td><td>0 / 1 …</td><td>当前运动类型编号(直线、圆弧等)</td></tr>
<tr><td>Homing</td><td>IDLE / ACTIVE</td><td>是否正在回零</td></tr>
<tr><td>Machine sequence</td><td>NOT HOMED / HOMED</td><td>机床是否已完成回零</td></tr>
<tr><td>命令反馈</td><td>PROGRAM_LOAD acknowledged</td><td>最近一条命令的处理结果;被拒时也会在此显示原因</td></tr>
</tbody>
</table>
<h3>4.2 常见"操作被拒绝"及处理</h3>
<table>
<thead><tr><th>现象</th><th>原因</th><th>处理</th></tr></thead>
<tbody>
<tr><td>上电按钮置灰,提示 <code>linuxcnc_wasm_snapshot_pending</code></td><td>仿真会话还没就绪</td><td>等待状态栏出现具体状态;若超过 1 分钟仍为 PENDING,刷新页面</td></tr>
<tr><td>Jog / MDI / 主轴按钮置灰,提示 <code>axis_machine_off</code></td><td>尚未上电</td><td>先急停复位,再点上电</td></tr>
<tr><td>手动按钮置灰,提示 <code>not_homed</code></td><td>未回零</td><td>点击 HOME ALL</td></tr>
<tr><td>加载程序被拒,提示机型不匹配</td><td>程序要求的轴组与当前机型不一致(如 AC 程序用于 BC 机型)</td><td>切换仿真案例,或在 RTCP 程序库中选择当前机型分类下的程序</td></tr>
<tr><td>单步/继续被拒</td><td>当前没有处于暂停或运行状态</td><td>先运行或暂停程序</td></tr>
</tbody>
</table>
<div class="danger"><b>安全习惯:</b>本平台只做仿真。但为养成正确操作习惯,请始终遵循"急停复位 → 上电 → 回零 → 运行"的顺序,题目与机床操作一致。</div>
</section>
<section id="s5">
<h2>5. 机床案例与程序</h2>
<h3>5.1 选择仿真案例</h3>
<p>"Simulation case"下拉框列出全部案例。切换案例会重新加载该机床的配置(轴数、限位、运动学、默认程序),界面轴位显示与 Jog 轴按钮随之改变。</p>
<p>切换到 <b>XYZBC TRT</b> 后,轴组从 <code>X Y Z A C</code> 变为 <code>X Y Z B C</code>,默认程序变为 <code>boat-xyzbc.ngc</code>:</p>
<figure>
<img src="screenshots/11-case-xyzbc.png" alt="切换到 XYZBC TRT 案例后的界面">
<figcaption>图 2 切换到 XYZBC TRT 案例,轴组、机床标定参数与默认程序同步更新。</figcaption>
</figure>
<div class="note">切换案例时,界面会先自动下电并重载工作区,正在运行的程序会被终止。请先停止程序再切换机型。</div>
<h3>5.2 内置船体程序与 RTCP 程序库</h3>
<p>案例条上提供两个程序入口:</p>
<ul>
<li><b>Load XYZAC boat program</b>:一键载入该机型自带的五轴船体干运行程序,适合快速演示。</li>
<li><b>AC(XYZAC)RTCP 程序 (16)</b>:打开 LinuxCNC 官方 RTCP 程序库。</li>
</ul>
<figure>
<img src="screenshots/04-rtcp-program-library.png" alt="RTCP 程序库对话框">
<figcaption>图 3 RTCP 程序库。上方显示当前机型分类数量与可直接加载数量,中间可选择显示范围与具体程序,下方给出类型、机型、特点、源路径与兼容性判断。</figcaption>
</figure>
<p>程序库按当前机型自动过滤:AC 机型默认显示 16 个相关程序,其中 9 个可作为主程序直接加载;切换到"全部 45 个程序"可以查看和下载其它机型的源程序,但它们不会作为当前机型的可执行项。每个程序都提供:</p>
<table>
<thead><tr><th>按钮</th><th>作用</th></tr></thead>
<tbody>
<tr><td>查看源程序</td><td>在对话框内直接阅读该 <code>.ngc</code> 的完整文本(来自 LinuxCNC 源码树)</td></tr>
<tr><td>加载到控制器</td><td>把程序文本交给 LinuxCNC WASM 解释器,刷新刀路预览;机型不匹配时按钮禁用并说明原因</td></tr>
<tr><td>下载 .ngc</td><td>保存原始程序文件到本地,便于在其它软件中使用</td></tr>
</tbody>
</table>
<figure>
<img src="screenshots/22-rtcp-load.png" alt="RTCP 程序加载成功提示">
<figcaption>图 4 加载成功后在对话框底部提示"程序已加载到 LinuxCNC WASM 控制器",同时预览区刷新为新的刀路。</figcaption>
</figure>
<h3>5.3 打开本地 G 代码</h3>
<p>点击工具栏"打开文件"图标,选择本地 <code>.ngc / .nc / .gcode / .tap</code> 或纯文本文件即可载入。载入前会检查程序中的轴字是否与当前机型匹配,不匹配会拒绝并给出提示,避免用 AC 程序驱动 BC 机型。</p>
<p>工具栏"重载"图标(<kbd>Ctrl</kbd>+<kbd>R</kbd>)用于把当前编辑器中的程序重新装载到解释器,适合手工修改后快速重试。</p>
<figure>
<img src="screenshots/03-file-menu.png" alt="File 菜单中两项宿主边界功能置灰">
<figcaption>图 5 File 菜单中"编辑刀具数据""退出"属于宿主进程能力,在浏览器中明确置灰,而不是伪装成可用。</figcaption>
</figure>
</section>
<section id="s6">
<h2>6. 手动控制</h2>
<p>左下角 <b>Manual Control [F3]</b> 页签是日常操作的主要区域。所有按钮都受"上电 + 已回零 + 无程序运行"的门控约束,条件不满足时置灰并说明原因。</p>
<h3>6.1 回零</h3>
<ul>
<li><b>HOME ALL</b>:按机床配置依次回零所有关节,完成后按钮变为 RE-HOME ALL。</li>
<li><b>Joint 下拉 + Home Joint</b>:只回零指定关节,便于排查单轴问题。</li>
</ul>
<figure>
<img src="screenshots/12-manual-homed.png" alt="回零完成后的界面">
<figcaption>图 6 回零完成:状态栏 <code>Machine sequence</code> 显示 HOMED,轴位归零,手动与程序按钮全部解锁。</figcaption>
</figure>
<h3>6.2 Jog 点动</h3>
<ol>
<li>在 <b>Axis</b> 中选中要移动的轴(如 <code>X</code>)。</li>
<li>在 <b>Jog</b> 行按住 <b>-</b> 或 <b>+</b> 连续点动,松开即停。</li>
<li>需要精确步进时,把中间的步长下拉框从 <code>Continuous</code> 改为 <code>0.1000 / 0.0100 / 0.0010 / 0.0001</code>,再点按一次按钮,即按该步长走一段。</li>
<li>键盘快捷:<kbd>c</kbd> 切回连续,<kbd>i</kbd> 在步长之间循环切换。</li>
</ol>
<figure>
<img src="screenshots/13-manual-jog.png" alt="按住 Jog 正向按钮时 X 轴位置变化">
<figcaption>图 7 按住 Jog <b>+</b> 期间 X 轴数值实时跟随 WASM 运动学输出变化;松开后停止。</figcaption>
</figure>
<div class="note"><b>Jog Speed 倍率</b>位于页面底部,可按比例降低点动速度;五轴机床在回转轴接近行程极限时建议调低,便于观察。</div>
<h3>6.3 主轴、冷却液与刀具</h3>
<table>
<thead><tr><th>控件</th><th>说明</th></tr></thead>
<tbody>
<tr><td>主轴逆时针 / 顺时针图标</td><td>启动主轴相应转向,图标按下表示当前转向</td></tr>
<tr><td>Stop Spindle</td><td>停止主轴</td></tr>
<tr><td>+ / - 长按</td><td>按住期间以 100 rpm 步进增减转速,松开恢复恒定转速</td></tr>
<tr><td>Brake</td><td>主轴制动(快捷键 <kbd>B</kbd> 制动 / <kbd>b</kbd> 松开)</td></tr>
<tr><td>Mist / Flood</td><td>雾状 / 大流量冷却液开关</td></tr>
<tr><td>Tool / Probe</td><td>显示当前刀具号、刀具状态与探针状态(只读)</td></tr>
</tbody>
</table>
<figure>
<img src="screenshots/14-spindle-coolant.png" alt="主轴与冷却液操作">
<figcaption>图 8 启动主轴与打开雾状冷却液后,上方"Live parameters"面板同步显示主轴转速与指令值。</figcaption>
</figure>
<div class="note"><b>Touch Off / Tool Touch Off</b>:用于设置工件坐标系与刀具偏置。当前 WASM 运行时未开放刀具偏置 ABI,Tool Touch Off 置灰;Touch Off 的可用性随机型与状态变化。</div>
</section>
<section id="s7">
<h2>7. MDI 与 DRO</h2>
<h3>7.1 MDI 单句执行</h3>
<p>切到 <b>MDI [F5]</b> 页签,在"MDI Command"输入框键入一行 G 代码,回车或点 <b>Go</b> 执行。按 <kbd>↑</kbd> / <kbd>↓</kbd> 可在历史命令间翻找,双击历史条目也可以直接执行。</p>
<figure>
<img src="screenshots/15-mdi.png" alt="MDI 执行 G0 指令后的界面">
<figcaption>图 9 执行 <code>G0 X12.5 Y-4 Z0</code> 后,轴位与刀路预览同步更新,命令进入下方 MDI History。</figcaption>
</figure>
<p>常用 MDI 指令:</p>
<span class="mono">G0 X0 Y0 Z0 快速定位到原点<br>G1 X10 F200 以 200 mm/min 直线进给<br>G91 G1 X5 F100 增量方式移动 5 mm<br>M3 S1200 主轴正转 1200 rpm<br>G54 切入 1 号工件坐标系</span>
<h3>7.2 DRO 位置显示</h3>
<p><b>DRO</b> 页签以表格形式显示每个轴的三个读数,并提供三个显示开关:</p>
<table>
<thead><tr><th>开关</th><th>可选值</th><th>说明</th></tr></thead>
<tbody>
<tr><td>Coordinate</td><td>Machine / Relative</td><td>机床坐标(回零后原点)或相对坐标</td></tr>
<tr><td>Display</td><td>Actual / Commanded</td><td>实际反馈位置或指令目标位置</td></tr>
<tr><td>Units</td><td>mm / inch</td><td>线性轴单位;旋转轴始终为度</td></tr>
</tbody>
</table>
<figure>
<img src="screenshots/16-dro.png" alt="DRO 页签">
<figcaption>图 10 DRO 页签显示实际位置、指令位置与相对坐标三列,页脚汇总单位、当前协调模式和仿真 tick。</figcaption>
</figure>
</section>
<section id="s8">
<h2>8. 倍率与显示图层</h2>
<p>页面底部左侧是四个倍率滑块,右侧是六个预览图层开关。</p>
<table>
<thead><tr><th>控件</th><th>范围</th><th>用途</th></tr></thead>
<tbody>
<tr><td>Jog Speed</td><td>6% – 100%</td><td>手动点动速度比例</td></tr>
<tr><td>Feed Override</td><td>0% – 上限</td><td>程序进给倍率,调试时降低进给</td></tr>
<tr><td>Spindle Override</td><td>0% – 200%</td><td>程序主轴转速倍率</td></tr>
<tr><td>Max Velocity</td><td>6% – 100%</td><td>整体最大速度限制</td></tr>
<tr><td>Rapid Override</td><td>—</td><td>当前 WASM 轨迹规划器未开放,固定显示 Unavailable</td></tr>
</tbody>
</table>
<p>预览图层:<code>path</code> 刀具路径、<code>live</code> 实时刀具位置、<code>limits</code> 行程边界、<code>velocity</code> 速度着色、<code>offsets</code> 坐标偏置指示、<code>extents</code> 毛坯包络。按需勾选可以避免画面过密。</p>
<figure>
<img src="screenshots/17-overrides-layers.png" alt="倍率滑块与预览图层开关">
<figcaption>图 11 进给倍率调整到 80%,并临时关闭 velocity 图层观察刀路本体。</figcaption>
</figure>
</section>
<section id="s9">
<h2>9. 程序运行控制</h2>
<p>工具栏中部的五个按钮控制程序执行,顺序与 LinuxCNC AXIS 一致:</p>
<table>
<thead><tr><th>按钮</th><th>作用</th><th>前置条件</th></tr></thead>
<tbody>
<tr><td>运行</td><td>从当前行开始执行程序</td><td>机床已上电、已回零、程序已装载</td></tr>
<tr><td>单步</td><td>只执行下一行</td><td>程序处于运行或暂停状态</td></tr>
<tr><td>暂停 / 继续</td><td>暂停当前运动;再次点击继续</td><td>程序运行中 / 已暂停</td></tr>
<tr><td>停止</td><td>中止程序并清空运动队列</td><td>程序运行或暂停中</td></tr>
</tbody>
</table>
<figure>
<img src="screenshots/18-program-running.png" alt="程序运行中">
<figcaption>图 12 运行中:Task mode 为 AUTO、Interpreter 为 INTERP_READING,预览区显示刀具沿刀路移动,编辑器高亮当前执行行。</figcaption>
</figure>
<figure>
<img src="screenshots/19-program-paused.png" alt="程序暂停中">
<figcaption>图 13 暂停:Interpreter 变为 INTERP_PAUSED,运动停止但位置保持,可继续、单步或停止。暂停期间 MDI 会被拒绝,这是 LinuxCNC 的既有边界。</figcaption>
</figure>
<div class="note">程序编辑器支持行号槽与当前行高亮,右侧"Active G-Codes"实时显示生效的模态指令,便于教学逐行讲解。</div>
</section>
<section id="s10">
<h2>10. 三维预览与机床管理</h2>
<h3>10.1 双视图预览</h3>
<p>右侧 <b>Preview</b> 页签分成左右两个画布:左边是刀路与刀具仿真,右边是机床三维模型,两者都由同一份 WASM 关节状态驱动。工具栏的六个视图按钮(俯视、旋转俯视、侧视、正视、倒视、透视)、缩放和"拖动旋转"模式同时作用于两个视图。</p>
<figure>
<img src="screenshots/23-views-cleared.png" alt="切换视图并清除轨迹后的预览区">
<figcaption>图 14 切换视图方向并清除实时轨迹后,只保留当前刀路;机床模型姿态与 WASM 关节一致。</figcaption>
</figure>
<h3>10.2 三维机床管理</h3>
<p>预览区右上角第一个按钮打开<b>三维机床管理</b>对话框,用于把真实机床的 GLB 模型绑定为可驱动的五轴机床。对话框分四个步骤页签:</p>
<table>
<thead><tr><th>页签</th><th>内容</th></tr></thead>
<tbody>
<tr><td>部件与层级</td><td>GLB 内部结构树、搜索定位、节点自由度绑定(每个节点一个唯一自由度)、模型选择与删除、撤销/重做</td></tr>
<tr><td>运动验证</td><td>跟随数控系统实时关节位置、按物理零点手动验证、指定轴的层级运动测试</td></tr>
<tr><td>机床标定</td><td>C 轴原点、A/B 到 C 轴偏置、机床物理零点(直线 mm / 旋转 deg)</td></tr>
<tr><td>文件与恢复</td><td>模型名称与设计说明、保存整机 GLB、本地机床库、13 个 LinuxCNC 案例模板</td></tr>
</tbody>
</table>
<figure>
<img src="screenshots/06-machine-manager-structure.png" alt="三维机床管理:部件与层级">
<figcaption>图 15 "部件与层级":导入模型后在此展开层级树,选择节点并指定其自由度。</figcaption>
</figure>
<figure>
<img src="screenshots/07-machine-manager-motion.png" alt="三维机床管理:运动验证">
<figcaption>图 16 "运动验证":可锁定跟随实时关节,也可按物理零点手动输入 X/Y/Z/A/B/C 检查模型运动是否正确;这些输入只驱动模型,不向控制器发送运动命令。</figcaption>
</figure>
<figure>
<img src="screenshots/08-machine-manager-calibration.png" alt="三维机床管理:机床标定">
<figcaption>图 17 "机床标定":填写旋转中心、轴间偏置与物理零点,与 LinuxCNC 原生 TRT 运动学参数一一对应。</figcaption>
</figure>
<figure>
<img src="screenshots/09-machine-manager-files.png" alt="三维机床管理:文件与恢复">
<figcaption>图 18 "文件与恢复":命名并保存整机 GLB,或从本浏览器机床库打开、再下载、删除;下方列出 13 个 LinuxCNC 案例模板。</figcaption>
</figure>
<h3>10.3 导入机床模型</h3>
<p>第二个按钮打开<b>三维机床模型</b>对话框,可以直接试用四套内置示例(AC、BC、AC 桁架、BC 桁架),或导入自己的 GLB 文件。</p>
<figure>
<img src="screenshots/05-machine-import-dialog.png" alt="导入机床模型对话框">
<figcaption>图 19 内置示例可以一键载入或下载;单个文件上限 64 MiB,超限会给出明确提示且不影响当前模型。</figcaption>
</figure>
<div class="note">导入的模型只在浏览器内解析与显示。若模型带有 <code>JT_X / JT_Y / JT_Z / JT_A / JT_B / JT_C</code> 之类的语义节点名,管理器会自动给出五轴识别结果与运动学建议,方便快速完成绑定。</div>
<h3>10.4 机床几何参数</h3>
<p>TRT 机型还提供<b>机床参数</b>对话框,用于编辑旋转中心与轴间偏置,并支持"应用参数""重新读取""保存 INI"。修改参数后需要重新回零,界面会给出提示。</p>
<figure>
<img src="screenshots/10-geometry-parameters.png" alt="机床几何参数对话框">
<figcaption>图 20 旋转中心(X/Y/Z)与到 C 轴偏置(X/Y/Z)共六个参数直接对应 INI 中的运动学配置。</figcaption>
</figure>
</section>
<section id="s11">
<h2>11. 诊断信息</h2>
<p><b>Diagnostics</b> 页签汇总运行环境与限制,排查问题时优先看这里:</p>
<ul>
<li><b>Startup resources</b>:当前案例入口文件、解析成功的资源数与阻塞数、HAL 记录数。</li>
<li><b>Disabled operations</b>:列出当前被门控/未开放的操作及原因(如 <code>blocked_by_abi</code>)。</li>
<li><b>Source diagnostics</b>:启动过程中缺失或未解析的资源路径与行号。</li>
</ul>
<figure>
<img src="screenshots/20-diagnostics.png" alt="诊断页签">
<figcaption>图 21 诊断页显示 tick 计数、状态哈希、启动资源清单与被禁用操作的逐条原因。</figcaption>
</figure>
</section>
<section id="s12">
<h2>12. 快捷键与鼠标操作</h2>
<table>
<thead><tr><th>按键 / 操作</th><th>功能</th></tr></thead>
<tbody>
<tr><td><kbd>F1</kbd></td><td>急停 / 解除急停(切换)</td></tr>
<tr><td><kbd>F2</kbd></td><td>上电 / 下电</td></tr>
<tr><td><kbd>F3</kbd> / <kbd>F5</kbd></td><td>切到 手动控制 / MDI 页签</td></tr>
<tr><td><kbd>R</kbd></td><td>运行程序</td></tr>
<tr><td><kbd>P</kbd> / <kbd>S</kbd></td><td>暂停 / 继续</td></tr>
<tr><td><kbd>T</kbd></td><td>单步执行下一行</td></tr>
<tr><td><kbd>Esc</kbd></td><td>停止程序(对话框打开时先关闭对话框)</td></tr>
<tr><td><kbd>Ctrl</kbd>+<kbd>R</kbd></td><td>重载当前程序</td></tr>
<tr><td><kbd>Ctrl</kbd>+<kbd>K</kbd></td><td>清除实时轨迹</td></tr>
<tr><td><kbd>Home</kbd> / <kbd>End</kbd></td><td>回零当前关节 / 对当前关节 Touch Off</td></tr>
<tr><td><kbd>Ctrl</kbd>+<kbd>Home</kbd> / <kbd>Ctrl</kbd>+<kbd>End</kbd></td><td>全部回零 / 刀具 Touch Off</td></tr>
<tr><td><kbd>B</kbd> / <kbd>b</kbd></td><td>主轴制动 / 松开制动</td></tr>
<tr><td><kbd>c</kbd> / <kbd>i</kbd></td><td>Jog 切回连续 / 循环切换增量步长</td></tr>
<tr><td>单击机床模型</td><td>选中该导入模型(黄色包围框)</td></tr>
<tr><td>按住拖动预览区</td><td>旋转视角(开启"拖动旋转"模式)</td></tr>
</tbody>
</table>
<p>窄屏(手机、平板竖屏)下界面自动改为上下堆叠布局,不会出现横向滚动:</p>
<figure>
<img src="screenshots/21-mobile-390.png" alt="390 像素宽窄屏布局">
<figcaption>图 22 390 px 视口下的完整页面布局(整页截图)。</figcaption>
</figure>
</section>
<section id="s13">
<h2>13. 支持的机床案例</h2>
<p>"Simulation case"下拉框中共有 13 个案例。当前发布版本中只有两个 TRT 案例可以完成"上电 → 回零 → 运行"的完整操作链,其余 11 个通用运动学案例仍在适配中。</p>
<table>
<thead><tr><th>案例</th><th>坐标轴</th><th>运动学</th><th>状态</th><th>说明</th></tr></thead>
<tbody>
<tr><td>XYZAC TRT</td><td>X Y Z A C</td><td>xyzac-trt-kins</td><td><span class="pill yes">可用</span></td><td>A 轴绕 X 倾转、C 轴绕 Z 回转,支持 RTCP 与 M428/M429 切换</td></tr>
<tr><td>XYZBC TRT</td><td>X Y Z B C</td><td>xyzbc-trt-kins</td><td><span class="pill yes">可用</span></td><td>B 轴绕 Y 倾转、C 轴绕 Z 回转,支持 RTCP</td></tr>
<tr><td>xyzab-tdr</td><td>X Y Z A B</td><td>xyzab-tdr</td><td><span class="pill no">暂不可用</span></td><td>双旋转台;可在 Worker 层运行,浏览器侧尚未就绪</td></tr>
<tr><td>bridge-mill-5axiskins</td><td>X Y Z B C W Y</td><td>5axiskins</td><td><span class="pill no">暂不可用</span></td><td>龙门式五轴,带 W 轴沿刀轴进给</td></tr>
<tr><td>max5-maxkins</td><td>X Y Z B C</td><td>maxkins</td><td><span class="pill no">暂不可用</span></td><td>MAX5 结构</td></tr>
<tr><td>hexapod-genhexkins</td><td>X Y Z A B C</td><td>genhexkins</td><td><span class="pill no">暂不可用</span></td><td>六自由度并联平台</td></tr>
<tr><td>pentapod-pentakins</td><td>X Y Z A B</td><td>pentakins</td><td><span class="pill no">暂不可用</span></td><td>五杆并联结构</td></tr>
<tr><td>puma-pumakins</td><td>X Y Z A B C</td><td>pumakins</td><td><span class="pill no">暂不可用</span></td><td>串联关节机器人</td></tr>
<tr><td>puma-genserkins</td><td>X Y Z A B C</td><td>genserkins</td><td><span class="pill no">暂不可用</span></td><td>串联机器人(通用求解器)</td></tr>
<tr><td>trivkins-xyzab</td><td>X Y Z A B</td><td>trivkins</td><td><span class="pill no">暂不可用</span></td><td>直角坐标一一映射基线,无 RTCP</td></tr>
<tr><td>trivkins-xyzcw</td><td>X Y Z C W</td><td>trivkins</td><td><span class="pill no">暂不可用</span></td><td>车铣复合映射基线</td></tr>
<tr><td>trivkins-xyzya</td><td>X Y Z Y A</td><td>trivkins</td><td><span class="pill no">暂不可用</span></td><td>双 Y 轴映射基线</td></tr>
<tr><td>scorbot-kins</td><td>X Y Z A B</td><td>scorbot-kins</td><td><span class="pill no">暂不可用</span></td><td>SCORBOT 机械臂</td></tr>
</tbody>
</table>
<div class="note">"暂不可用"表示可以选中案例、能读出程序名,但控制器停在 PENDING、上电按钮保持禁用。当前请仅使用前两个 TRT 案例;不小心选中其它案例后刷新页面即可恢复(详见 14.2)。</div>
</section>
<section id="s14">
<h2>14. 已知问题与规避</h2>
<p>以下是本次上线功能巡检(2026-10-08)中发现并已复现的问题,均已给出规避办法。工具栏"载入精确案例"按钮曾经失效,原因是点击事件对象被当成案例路径传入,该问题已修复并重新发布,本次巡检中该按钮已能正常重新载入当前案例。</p>
<h3>14.1 选中通用运动学案例后控制器卡住</h3>
<table>
<thead><tr><th>项目</th><th>内容</th></tr></thead>
<tbody>
<tr><td>现象</td><td>在案例下拉框中选中 13 个案例里除 XYZAC/XYZBC TRT 之外任意一个(如 xyzab-tdr、max5-maxkins),状态栏所有字段停留在 <code>PENDING</code>,上电按钮提示 <code>linuxcnc_wasm_snapshot_pending</code>,此时下拉框也无法再切回其它案例</td></tr>
<tr><td>原因</td><td>通用运动学案例在浏览器侧的启动链尚未完成(MGR-27 进行中):案例引用的运动学源文件未随前端产物发布,请求返回 404,且会话启动失败后前端没有回滚</td></tr>
<tr><td>规避</td><td>当前只使用 XYZAC TRT / XYZBC TRT;若误选其它案例,<b>刷新浏览器页面</b>即可恢复</td></tr>
<tr><td>影响面</td><td>13 个案例中的 11 个;两个 TRT 案例的功能不受影响</td></tr>
</tbody>
</table>
</section>
<section id="s15">
<h2>15. 常见问题</h2>
<h4>页面一直显示 PENDING,上电按钮点不了?</h4>
<p>多数情况是仿真会话尚未就绪,等待数秒即可;若超过 1 分钟仍不变,或曾在案例下拉框里选过通用运动学案例,请刷新页面。</p>
<h4>Jog、MDI、主轴按钮都是灰的?</h4>
<p>按"急停复位 → 上电"的顺序操作,并确认状态栏 <code>Task state</code> 为 <code>STATE_ON</code>、<code>Machine sequence</code> 为 <code>HOMED</code>。把鼠标停在灰按钮上会显示具体原因。</p>
<h4>为什么 MDI 在程序暂停时无法执行?</h4>
<p>这是 LinuxCNC 的既有语义:程序运行/暂停期间 MDI 会被拒绝(<code>mdi_while_program_active</code>)。停止或让程序结束后再使用 MDI。</p>
<h4>加载 G 代码时提示机型不匹配?</h4>
<p>程序中的轴字与当前机型不一致。请先用案例下拉框切到对应机型(AC 或 BC),或使用 RTCP 程序库自动按机型过滤后的程序。</p>
<h4>浏览器提示证书不受信任?</h4>
<p>站点使用 IP 地址的 HTTPS 自签证书。确认地址无误后选择"高级 / 继续访问";如需长期使用,可让管理员把证书加入受信任列表。</p>
<h4>导入的机床模型会丢失吗?</h4>
<p>页面刷新后导入的模型需要重新导入;使用"三维机床管理 → 文件与恢复"保存到浏览器机床库的机床,可以在同一浏览器、同一地址下重新打开。清理浏览器站点数据会同时删除机床库记录。</p>
<h4>能连接真实机床吗?</h4>
<p>不能,也不在本项目范围内。本平台是纯仿真环境,硬件驱动保持在浏览器虚拟设备边界。</p>
</section>
<section id="s16">
<h2>16. 附录:功能测试结论</h2>
<p>本次发布软件的自动化功能巡检于 2026-10-08 在 <code>https://49.232.250.35:58010/</code> 上用真实 Chrome 浏览器执行,覆盖 26 个功能步骤,全部通过;另有 1 项待完成项(通用运动学案例的浏览器运行链,见第 14 节)。</p>
<table>
<thead><tr><th>范围</th><th>覆盖内容</th><th>结果</th></tr></thead>
<tbody>
<tr><td>启动与状态</td><td>页面加载、WASM 会话就绪、状态栏、轴位与实时参数来源</td><td><span class="pill yes">通过</span></td></tr>
<tr><td>菜单</td><td>Help → About、File 菜单宿主边界项置灰</td><td><span class="pill yes">通过</span></td></tr>
<tr><td>程序资源</td><td>RTCP 程序库按机型过滤、加载到控制器、打开本地 G 代码、重载</td><td><span class="pill yes">通过</span></td></tr>
<tr><td>机床模型</td><td>三维机床管理四个页签、导入机床模型、机床几何参数</td><td><span class="pill yes">通过</span></td></tr>
<tr><td>案例切换</td><td>XYZAC ↔ XYZBC 切换及轴组同步</td><td><span class="pill yes">通过</span></td></tr>
<tr><td>机床操作</td><td>急停复位、上电、HOME ALL、连续与增量 Jog</td><td><span class="pill yes">通过</span></td></tr>
<tr><td>辅助功能</td><td>主轴启停、冷却液、Tool/Probe 状态</td><td><span class="pill yes">通过</span></td></tr>
<tr><td>MDI 与 DRO</td><td>MDI 执行与历史、DRO 三种显示切换</td><td><span class="pill yes">通过</span></td></tr>
<tr><td>倍率与图层</td><td>进给倍率调整、预览图层开关</td><td><span class="pill yes">通过</span></td></tr>
<tr><td>程序运行</td><td>载入、运行、暂停、继续、停止、工具栏"载入精确案例"重新载入</td><td><span class="pill yes">通过</span></td></tr>
<tr><td>视图与诊断</td><td>视图切换、清除轨迹、诊断页、快捷键、窄屏布局</td><td><span class="pill yes">通过</span></td></tr>
<tr><td>案例矩阵</td><td>13 个案例逐一探测</td><td><span class="pill part">2 可用 / 11 待完成</span></td></tr>
</tbody>
</table>
<p>测试脚本与原始报告:<code>docs/user-manual/capture_deployed_manual.mjs</code>、<code>docs/user-manual/deployment-test.report.json</code>;截图位于 <code>docs/user-manual/screenshots/</code>。重新执行:</p>
<span class="mono">PLAYWRIGHT_MODULE=&lt;playwright&gt; node docs/user-manual/capture_deployed_manual.mjs</span>
</section>
</main>
<footer>
<p><b>LinuxCNC Web/WASM 使用说明书</b> 版本 2026-10-08 适用站点:https://49.232.250.35:58010/</p>
<p>软件基于 LinuxCNC 2.10.0~pre0 源码移植,仿真在浏览器 WebAssembly 中运行,不连接真实机床。本手册可与 <code>docs/glb-machine-manager/00-三维机床管理详细功能文档.html</code> 配合阅读。</p>
</footer>
</body>
</html>

View File

@@ -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` 子模块改动。

View File

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

View File

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

View File

@@ -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<len(heights) else .1]); cyl(n+"_bearing",.11 if i<3 else .06,.08,(0,0,0),"joint",g,"z"); linkpart=box(n+"_link",(.16,.16,.28 if i<3 else .14),(0,0,.16 if i<3 else .08),"paint" if i%2 else "steel",g); parents.append(g)
tool=group(parents[-1],"tool"); cyl("tool_holder",.025,.18,(0,0,-.10),"yellow",tool); scene["linuxcnc.parts"]=["base"]+names+["tool"]; return scene
def baseline_scene(id,coords):
scene=industrial_mill(id,coords,id,"AB"); scene["linuxcnc.baseline"] = True; return scene
def base_scene(id,coords,status,title):
bpy.ops.wm.read_factory_settings(use_empty=True); MATS.clear(); scene=bpy.context.scene; scene.name=id; scene.unit_settings.system="METRIC"; scene.unit_settings.length_unit="MILLIMETERS"; scene["linuxcncPhysicalStructure"]={"schema":"linuxcnc.physical-machine/1","caseId":id,"coordinates":coords,"kinematics":id if id not in ("trivkins-xyzab","trivkins-xyzcw","trivkins-xyzya") else "trivkins","referenceStatus":status,"title":title,"gltfUnit":"m","license":"CC0-1.0 procedural design reference"}; return scene
specs=[
('xyzac-trt',lambda:industrial_mill('xyzac-trt','XYZAC','XYZAC TRT','AC')),
('xyzbc-trt',lambda:industrial_mill('xyzbc-trt','XYZBC','XYZBC TRT','BC')),
('xyzab-tdr',lambda:industrial_mill('xyzab-tdr','XYZAB','XYZAB TDR','AB')),
('bridge-mill-5axiskins',bridge_scene),
('max5-maxkins',lambda:industrial_mill('max5-maxkins','XYZBC','MAX5 maxkins','BC')),
('hexapod-genhexkins',lambda:parallel_scene('hexapod-genhexkins',6,'Variax Hexapod')),
('pentapod-pentakins',lambda:parallel_scene('pentapod-pentakins',5,'METROM Pentapod')),
('puma-pumakins',lambda:robot_scene('puma-pumakins')),
('puma-genserkins',lambda:robot_scene('puma-genserkins')),
('trivkins-xyzab',lambda:baseline_scene('trivkins-xyzab','XYZAB')),
('trivkins-xyzcw',lambda:baseline_scene('trivkins-xyzcw','XYZCW')),
('trivkins-xyzya',lambda:baseline_scene('trivkins-xyzya','XYZYA')),
('scorbot-kins',lambda:robot_scene('scorbot-kins',True)),
]
only = None
if "--" in sys.argv:
argv = sys.argv[sys.argv.index("--") + 1:]
if "--only" in argv and argv.index("--only") + 1 < len(argv): only = argv[argv.index("--only") + 1]
for id,make in specs:
if only and id != only: continue
scene=make(); out=os.path.join(OUT,f'{id}.glb')
bpy.ops.wm.save_as_mainfile(filepath=os.path.join('/tmp',f'{id}.blend'))
bpy.ops.export_scene.gltf(filepath=out, export_format='GLB', export_apply=True, use_selection=False)
for o in list(scene.objects):
for d in list(o.data.materials) if hasattr(o.data,'materials') and o.data else []: pass
print('BLENDER_GLB_OK',id,out)

View File

@@ -0,0 +1,66 @@
// Browser acceptance: switch through all 13 five-axis cases, confirm the bound
// structure GLB loads and that the joint state published on the preview host
// carries the expected slot keys and changes while a program runs.
import { chromium } from '/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs';
import { readFile } from 'node:fs/promises';
import { mkdir } from 'node:fs/promises';
const BASE = process.env.AXIS_URL ?? 'http://127.0.0.1:5174/';
const OUT = new URL('../coverage/glb-machine-manager/physical-drive/', import.meta.url);
await mkdir(OUT, { recursive: true });
const catalog = JSON.parse(await readFile(new URL('../web/workspace/machine-visual-bindings.json', import.meta.url), 'utf8'));
const browser = await chromium.launch({ executablePath: '/usr/bin/google-chrome', headless: true, args: ['--no-sandbox'] });
const page = await browser.newPage({ viewport: { width: 1600, height: 1000 } });
const errors = [];
page.on('pageerror', (error) => 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 }));

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
Procedural design-reference assets for linuxcncWebWASM. CC0-1.0.
No manufacturer affiliation or dimensional calibration is implied.

File diff suppressed because it is too large Load Diff

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