Preserve RTCP compensation across rotary jog stops

This commit is contained in:
2026-09-18 06:10:53 +08:00
parent e4600b34e3
commit 6a69511276
14 changed files with 4530 additions and 7 deletions

View File

@@ -0,0 +1,657 @@
{
"report_id": "trt-rtcp-jog-browser-v1",
"status": "verified",
"machines": [
{
"coordinates": "XYZAC",
"rows": [
{
"axis": "a",
"before": {
"joints": [
5.466649999999972,
4.066650000000003,
0,
0,
0
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": 0,
"y_offset": 20,
"z_offset": 10,
"tool_offset": 0
},
"tick": 66,
"rendered_tick": 66,
"tool_tip": [
0,
2,
0
]
},
"moving": {
"joints": [
5.466649999999972,
7.197756186722254,
-3.7831126951749035,
14.999999999999876,
0
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": 0,
"y_offset": 20,
"z_offset": 10,
"tool_offset": 0
},
"tick": 126,
"rendered_tick": 109,
"tool_tip": [
0,
2,
0
],
"tcp_error": 4.440892098500626e-16
},
"stopped": {
"joints": [
5.466649999999972,
7.637011353442125,
-4.178439757165844,
16.799999999999883,
0
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": 0,
"y_offset": 20,
"z_offset": 10,
"tool_offset": 0
},
"tick": 137,
"rendered_tick": 137,
"tool_tip": [
0,
2,
0
],
"tcp_error": 0
}
},
{
"axis": "c",
"before": {
"joints": [
5.466649999999972,
7.637011353442125,
-4.178439757165844,
16.799999999999883,
0
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": 0,
"y_offset": 20,
"z_offset": 10,
"tool_offset": 0
},
"tick": 137,
"rendered_tick": 137,
"tool_tip": [
0,
2,
0
]
},
"moving": {
"joints": [
5.130732460810536,
8.035612665739814,
-4.058094925733048,
16.799999999999883,
4.499999999999995
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": 0,
"y_offset": 20,
"z_offset": 10,
"tool_offset": 0
},
"tick": 162,
"rendered_tick": 162,
"tool_tip": [
0,
2,
0
],
"tcp_error": 4.441434166503682e-16
},
"stopped": {
"joints": [
4.98738562094063,
8.187776781292149,
-4.012153870670515,
16.799999999999883,
6.30000000000001
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": 0,
"y_offset": 20,
"z_offset": 10,
"tool_offset": 0
},
"tick": 173,
"rendered_tick": 173,
"tool_tip": [
0,
2,
0
],
"tcp_error": 0
}
},
{
"axis": "a",
"before": {
"joints": [
4.98738562094063,
8.187776781292149,
-4.012153870670515,
16.799999999999883,
6.30000000000001
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": 0,
"y_offset": 20,
"z_offset": 10,
"tool_offset": 0
},
"tick": 173,
"rendered_tick": 173,
"tool_tip": [
0,
2,
0
]
},
"moving": {
"joints": [
4.98738562094063,
9.79672537538304,
-5.223740026635218,
23.099999999999895,
6.30000000000001
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": 0,
"y_offset": 20,
"z_offset": 10,
"tool_offset": 0
},
"tick": 204,
"rendered_tick": 203,
"tool_tip": [
0,
2,
0
],
"tcp_error": 4.163336342344337e-17
},
"stopped": {
"joints": [
4.98738562094063,
10.279949305726852,
-5.5367206309661015,
24.899999999999906,
6.30000000000001
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": 0,
"y_offset": 20,
"z_offset": 10,
"tool_offset": 0
},
"tick": 215,
"rendered_tick": 215,
"tool_tip": [
0,
2,
0
],
"tcp_error": 6.938893903907228e-18
}
},
{
"axis": "c",
"before": {
"joints": [
4.98738562094063,
10.279949305726852,
-5.5367206309661015,
24.899999999999906,
6.30000000000001
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": 0,
"y_offset": 20,
"z_offset": 10,
"tool_offset": 0
},
"tick": 215,
"rendered_tick": 215,
"tool_tip": [
0,
2,
0
]
},
"moving": {
"joints": [
4.607806376235921,
10.621901380081653,
-5.37799176280733,
24.899999999999906,
10.800000000000004
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": 0,
"y_offset": 20,
"z_offset": 10,
"tool_offset": 0
},
"tick": 240,
"rendered_tick": 234,
"tool_tip": [
0,
2,
0
],
"tcp_error": 4.446309804673419e-16
},
"stopped": {
"joints": [
4.447883154027174,
10.750935773512055,
-5.318095991561005,
24.899999999999906,
12.60000000000002
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": 0,
"y_offset": 20,
"z_offset": 10,
"tool_offset": 0
},
"tick": 251,
"rendered_tick": 251,
"tool_tip": [
0,
2,
0
],
"tcp_error": 6.938893903907228e-18
}
}
]
},
{
"coordinates": "XYZBC",
"rows": [
{
"axis": "b",
"before": {
"joints": [
4.866649999999986,
4.266649999999998,
0,
0,
0
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": -20,
"y_offset": 0,
"z_offset": -15,
"tool_offset": 0
},
"tick": 315,
"rendered_tick": 315,
"tool_tip": [
0,
1.5,
0
]
},
"moving": {
"joints": [
6.460824988717725,
4.266649999999998,
-3.034423409674286,
6.766650000000004,
0
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": -20,
"y_offset": 0,
"z_offset": -15,
"tool_offset": 0
},
"tick": 346,
"rendered_tick": 346,
"tool_tip": [
0,
1.4696557659032572,
0
],
"tcp_error": 4.163336342344337e-17
},
"stopped": {
"joints": [
6.881516837115303,
4.266649999999998,
-4.012082328376053,
8.866650000000012,
0
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": -20,
"y_offset": 0,
"z_offset": -15,
"tool_offset": 0
},
"tick": 357,
"rendered_tick": 357,
"tool_tip": [
0,
1.4598791767162396,
0
],
"tcp_error": 6.938893903907228e-18
}
},
{
"axis": "c",
"before": {
"joints": [
6.881516837115303,
4.266649999999998,
-4.012082328376053,
8.866650000000012,
0
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": -20,
"y_offset": 0,
"z_offset": -15,
"tool_offset": 0
},
"tick": 357,
"rendered_tick": 357,
"tool_tip": [
0,
1.4598791767162396,
0
]
},
"moving": {
"joints": [
6.389875321215886,
4.774921995664075,
-3.935386480812939,
8.866650000000012,
6.30000000000001
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": -20,
"y_offset": 0,
"z_offset": -15,
"tool_offset": 0
},
"tick": 388,
"rendered_tick": 388,
"tool_tip": [
0,
1.4606461351918705,
0
],
"tcp_error": 2.2215299868541707e-16
},
"stopped": {
"joints": [
6.239553636866457,
4.909801419605384,
-3.911936368046927,
8.866650000000012,
8.100000000000021
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": -20,
"y_offset": 0,
"z_offset": -15,
"tool_offset": 0
},
"tick": 399,
"rendered_tick": 399,
"tool_tip": [
0,
1.4608806363195308,
0
],
"tcp_error": 1.5515838457795457e-17
}
},
{
"axis": "b",
"before": {
"joints": [
6.239553636866457,
4.909801419605384,
-3.911936368046927,
8.866650000000012,
8.100000000000021
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": -20,
"y_offset": 0,
"z_offset": -15,
"tool_offset": 0
},
"tick": 399,
"rendered_tick": 399,
"tool_tip": [
0,
1.4608806363195308,
0
]
},
"moving": {
"joints": [
7.833212442181058,
4.909801419605384,
-8.936039634310779,
19.483300000000032,
8.100000000000021
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": -20,
"y_offset": 0,
"z_offset": -15,
"tool_offset": 0
},
"tick": 441,
"rendered_tick": 424,
"tool_tip": [
0,
1.4391649802175794,
0
],
"tcp_error": 2.1942709178604383e-17
},
"stopped": {
"joints": [
8.036725480793326,
4.909801419605384,
-9.960024356917343,
21.58330000000004,
8.100000000000021
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": -20,
"y_offset": 0,
"z_offset": -15,
"tool_offset": 0
},
"tick": 452,
"rendered_tick": 452,
"tool_tip": [
0,
1.4003997564308268,
0
],
"tcp_error": 2.222613395836815e-16
}
},
{
"axis": "c",
"before": {
"joints": [
8.036725480793326,
4.909801419605384,
-9.960024356917343,
21.58330000000004,
8.100000000000021
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": -20,
"y_offset": 0,
"z_offset": -15,
"tool_offset": 0
},
"tick": 452,
"rendered_tick": 452,
"tool_tip": [
0,
1.4003997564308268,
0
]
},
"moving": {
"joints": [
7.512048008812801,
5.3428919838003095,
-9.752466730299833,
21.58330000000004,
14.40000000000003
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": -20,
"y_offset": 0,
"z_offset": -15,
"tool_offset": 0
},
"tick": 483,
"rendered_tick": 483,
"tool_tip": [
0,
1.4024753326970016,
0
],
"tcp_error": 4.220764384040162e-17
},
"stopped": {
"joints": [
7.354314924758338,
5.45498912019934,
-9.690068959643082,
21.58330000000004,
16.200000000000042
],
"offsets": {
"x_rot_point": 0,
"y_rot_point": 0,
"z_rot_point": 0,
"x_offset": -20,
"y_offset": 0,
"z_offset": -15,
"tool_offset": 0
},
"tick": 494,
"rendered_tick": 494,
"tool_tip": [
0,
1.4030993104035694,
0
],
"tcp_error": 2.2484586062492886e-16
}
}
]
}
],
"browser_errors": []
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,30 @@
# 推进台账
## R05 / 2026-09-18 / 仓库保存
- 用户授权:保存到 `https://gitea.meswork.com/wangdequan/linuxcncWebWASM.git` 的 `main` 分支。
- 保存内容:TRT-UI01 黄色预览路径、TRT-JOG01 M428 Jog 模式保持修复、对应测试、文档、报告和截图。
- 检查:已获取远程 main;提交前差异格式检查通过。功能验证沿用 E05/E06,本轮没有新增功能修改。
- 原有大体积 tick trace、linuxcnc 子模块本地改动、临时脚本和用户图片保留在本地。
- 推送完成以远程 main 与本地 HEAD 的提交 ID 一致为准;下一开发任务仍为 TRT-R01。
## R04 / 2026-09-17 / M428 旋转轴 Jog 刀尖跟随
- 用户问题:选中 M428 后 Jog 调整 A/C,机床预览没有持续体现刀尖跟随。
- 根因:`WasmLinuxcncTp.abort()` 在 JOG_STOP 时把 `activeTag` 清零,而 switchkins 仍保持 M428。首轮 Jog 有补偿,松开时关节解跳回 identity,后续 Jog 丢失补偿。另有 Jog ID 与历史程序行 ID 复用的模式映射风险。
- 改动:`web/worker/wasm-linuxcnc-tp.mjs` 停止只清理队列映射,保留当前坐标系;成功加入 Jog 时记录该段的当前模式,覆盖历史同 ID 条目。正逆解继续使用 WASM,无 Three.js/JS 补偿公式。
- 测试:新增 `web/react/src/test_trt_rtcp_jog.mjs`、`scripts/test_trt_rtcp_jog_browser.mjs`;更新 `test_xyzac_runtime_followups.mjs` 等待真实 homing 完成后再测试运行/关机。
- 验证:修复前专项在 `a-released` 明确失败(mode 0 != 1);修复后两机型均通过。检查连续/增量/反向/重复 Jog、松开无跳变、无效输入、重复停止、程序停止和关机保持、M429 对照及同 ID 段复用。浏览器真实按钮操作 A-C-A-C、B-C-B-C 均通过,快照与实际渲染刀尖一致。详见 E06。
- 产物:`coverage/trt-parametric/rtcp-jog.report.json`、`rtcp-jog-browser.report.json`、两机型截图。
- 下一步:TRT-R01;本轮未扩展到既有 checkpoint 全状态精度修复。
## R03 / 2026-09-17 / 机床预览路径颜色
- 用户要求:Preview 中工作台上的白色刀具预览路径改为黄色。
- 改动:`web/react/src/XyzacMachineView.tsx` 的普通预览路径材质由 `0xdce7e8` 改为 `0xffff00`,两种 TRT 机型共用。
- 验证:E05;对应任务 TRT-UI01。
- 下一步:仍按 TRT-R01 接续。
## R02 / 2026-09-17 / 仓库保存
- 用户授权:将本轮成果保存到 `https://gitea.meswork.com/wangdequan/linuxcncWebWASM.git`。

View File

@@ -11,3 +11,5 @@
| TRT-P05 | 参数编辑及文件流程 | P03,P04 | 已验收 | 分组布局、应用/重新读取/导入/保存、保留程序、错误反馈 | E03 |
| TRT-P06 | 回归及证据收口 | P03,P04,P05 | 已验收 | 专项、相关回归、构建、桌面/移动交互截图和像素检查通过;既有恢复偏差独立登记 | E02,E03,E04 |
| TRT-R01 | 既有 checkpoint 恢复状态精度 | P06 | 待办 | 定位 HAL tick 与 TP queue/progress 恢复差异;将恢复测试的相等性变为有效断言,再验证固定 tick 连续与恢复轨迹 | E04:遗留项 |
| TRT-UI01 | 机床预览路径改为黄色 | P04 | 已验收 | 工作台普通刀具预览路径使用黄色材质 | E05 |
| TRT-JOG01 | M428 旋转轴 Jog 刀尖跟随 | P03,P04 | 已验收 | A/B/C 连续和增量 Jog 使用 RTCP 补偿;松开、重复 Jog 不丢失模式;WASM 与机床模型刀尖位置一致;M429 对照 | E06 |

View File

@@ -56,3 +56,22 @@ PLAYWRIGHT_MODULE=/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwrigh
| `node web/worker/test_xyzac_recovery.mjs` | 退出 0,但其内部输出恢复前后 HAL tick / TP progress / queue 状态不同,`endpoint_at_checkpoint_equal=false`;登记 TRT-R01,不能声称恢复完全等价 |
本次未重跑全仓历史发布 gate、全部浏览器矩阵或官方 native 对照。专项测试验证浏览器仿真,不是实际机床控制验收。
## E05 机床预览路径颜色
- 材质检查:`XyzacMachineView.tsx` 普通预览路径使用 `0xffff00`(黄色)。
- 命令:`node web/react/src/test_xyzac_threejs.mjs`、`npm --prefix web/react run typecheck`。
- 结果:模型回归及 TypeScript 检查通过。
## E06 M428 Jog RTCP 修复
- 修复前:`node web/react/src/test_trt_rtcp_jog.mjs` 在 A 连续 Jog 松开时报 `a-released: RTCP motion mode must remain active`,实际模式 0、期望 1。
- 修复后同命令通过;[WASM 测试报告](../../coverage/trt-parametric/rtcp-jog.report.json),report_id=`trt-rtcp-jog-v1`。
- 两机型各 77 个检查点,使用非零 INI 旋转中心。覆盖 A/B/C 连续、负方向增量、重复 Jog、停止、非法值/越界拒绝、PROGRAM_ABORT、关机保持及 M429 不补偿对照;另验证历史程序行 ID 与 Jog ID 复用不污染运动学模式。
- 编程 TCP 的 XYZ 不变,机器 XYZ 关节随角度改变;模型上同一编程点与刀尖的最大距离:XYZAC `4.48e-16`、XYZBC `4.45e-16` 场景单位。停止前后关节无跳变,motion_trace 和完整面板快照都检查。
- 浏览器命令:`PLAYWRIGHT_MODULE=/home/mes006/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs node scripts/test_trt_rtcp_jog_browser.mjs`。
- 页面 `http://127.0.0.1:5174/`,实际操作开机、回零、X/Y 离开旋转中心、选择 M428、连续按住/松开 A-C-A-C 与 B-C-B-C;两个机型均保持 XYZ 补偿,渲染刀尖与 Worker 关节一致,pageerror 为零。
- [浏览器报告](../../coverage/trt-parametric/rtcp-jog-browser.report.json),report_id=`trt-rtcp-jog-browser-v1`;截图:[XYZAC](../../coverage/trt-parametric/rtcp-jog-xyzac.png)、[XYZBC](../../coverage/trt-parametric/rtcp-jog-xyzbc.png)。
- 回归通过:`node web/react/src/test_trt_parametric.mjs`、`node web/worker/test_xyzac_trt_case.mjs`、`node web/worker/test_xyzac_program_replace.mjs`、`node web/worker/test_xyzac_runtime_followups.mjs`、`node web/worker/test_boat_trt_programs.mjs`、`npm --prefix web/react run build`。构建保留既有大 chunk 提示。
- `runtime_followups` 初次失败因未等待 HOME 完成便启动程序;现等待 WASM homed,并断言 PROGRAM_LOAD/RUN 成功后再验收。
- job_id / PDF:不适用;使用原有 WASM,无核心 ABI 变更。本轮不声称修复 TRT-R01。

View File

@@ -31,3 +31,9 @@ XYZAC 不支持 x-offset,XYZBC 不支持 y-offset。六项都可见,但不
## D08 / 2026-09-17 / 异步界面状态
机型选择在 Worker 成功载入后提交;重新读取期间锁定主界面加载动作。参数应用成功但当前程序解析失败时,保留已生效参数并显示程序解析错误,旧路径已清理;不声称整笔操作回滚成功。
## D09 / 2026-09-17 / Jog 停止保持坐标系
JOG_STOP/abort 停止运动不能隐式执行 M429。保留停止姿态对应的运动学模式,只清理待执行段和历史 ID 映射;重新初始化使用原有 reset 路径。Jog 的 native tag 是段 ID,与运动学模式不同,必须关联本段入队时的当前模式,不能沿用同编号的历史程序行。
RTCP 的判据为:旋转 Jog 时刀尖在工件坐标中的 XYZ 不变,机器 XYZ 按 WASM 逆解联动;Three.js 用相同关节投影。若刀尖恰好在 C 旋转轴上,单独转 C 可没有 XYZ 位移,这属于正确几何行为;验收特意先移动 X/Y 离开旋转中心。

View File

@@ -11,7 +11,7 @@
每轮必须记录实际改动、命令、结果和下一任务。未运行的检查不能记为通过;历史通过任务仅在相关代码改变时回归。此目录是本专项接续入口,历史仿真/发布轨道仍保留原有指针,不据此扩大发布范围。
当前状态:`TRT-P01` 至 `TRT-P06` 已验收。下一项为矩阵中的 `TRT-R01`,属于既有恢复精度问题,不影响本专项参数应用流程。实现不依赖聊天记录。用户文字提及 Image #1,但本轮未收到可读取的图片资源,布局按现有结构预览实现。
当前状态:`TRT-P01` 至 `TRT-P06`、`TRT-UI01`、`TRT-JOG01` 已验收。最新修复为 M428 下停止 Jog 后保留 RTCP,避免随后 A/B/C Jog 丢失 XYZ 补偿。下一项为矩阵中的 `TRT-R01`,属于既有恢复精度问题,不影响本专项参数应用流程。实现不依赖聊天记录。用户文字提及 Image #1,但本轮未收到可读取的图片资源,布局按现有结构预览实现。
本地体验地址:`http://127.0.0.1:5174/`。启动命令:`npm --prefix web/react run dev -- --port 5174 --strictPort`;该端口被占用时选择其他端口。

View File

@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { mkdir, writeFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { buildXyzacMachine, buildXyzbcMachine } from "../web/react/src/xyzac-machine-model.ts";
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
const output = new URL("../coverage/trt-parametric/", import.meta.url);
await mkdir(output, { recursive: true });
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({ viewport: { width: 1440, height: 1000 } });
const errors = []; page.on("pageerror", (error) => errors.push(error.message));
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
const machine = page.locator(".xyzac-machine-view");
const report = { report_id: "trt-rtcp-jog-browser-v1", status: "running", machines: [] };
const sample = () => machine.evaluate((element) => ({
joints: JSON.parse(element.dataset.xyzacJoints), offsets: JSON.parse(element.dataset.xyzacOffsets),
tick: Number(element.dataset.xyzacTick), rendered_tick: Number(element.dataset.xyzacRenderedTick), tool_tip: JSON.parse(element.dataset.xyzacToolTip),
}));
const rendered = () => page.waitForFunction(() => {
const e = document.querySelector(".xyzac-machine-view"); return e && e.dataset.xyzacTick === e.dataset.xyzacRenderedTick;
});
const jog = async (axis, delta, index) => {
await control(`manual-axis-${axis}`).check();
const before = await sample();
const button = await control("manual-jog-positive").boundingBox();
await page.mouse.move(button.x + button.width / 2, button.y + button.height / 2); await page.mouse.down();
await page.waitForFunction(({ index, initial, delta }) => {
const e = document.querySelector(".xyzac-machine-view");
return JSON.parse(e.dataset.xyzacJoints)[index] >= initial + delta;
}, { index, initial: before.joints[index], delta });
await rendered(); const moving = await sample();
await page.mouse.up();
await page.waitForFunction(() => document.querySelector(".command-status")?.textContent?.includes("JOG_STOP acknowledged") || [...document.querySelectorAll("span")].some((s) => s.textContent === "JOG_STOP acknowledged"));
await rendered();
return { axis, before, moving, stopped: await sample() };
};
try {
await page.goto(process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5174/");
await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramLoading === "false");
for (const coordinates of ["XYZAC", "XYZBC"]) {
if (coordinates === "XYZBC") {
await control("toolbar-power").click();
await page.waitForFunction(() => !document.querySelector('.trt-geometry input[aria-label="x-rot-point"]').disabled);
await control("machine-type-select").selectOption({ label: "XYZBC TRT" });
await page.waitForFunction(() => document.querySelector(".xyzac-machine-view")?.dataset.xyzacModelId === "xyzbc-trt" && document.querySelector("main")?.dataset.axisProgramLoading === "false");
}
await control("toolbar-power").click(); await control("manual-home-all").click();
await page.waitForFunction(() => !document.querySelector('[data-axis-control="switchkins-1"]').disabled);
// Move off the rotary centre before enabling TCP, to make C compensation visible.
await jog("x", 4, 0); await jog("y", 3, 1);
await control("switchkins-1").click();
await page.waitForFunction(() => document.querySelector('[data-axis-control="switchkins-1"]').getAttribute("aria-pressed") === "true");
await rendered();
const initial = await sample();
const model = coordinates === "XYZAC" ? buildXyzacMachine() : buildXyzbcMachine();
model.update(initial); model.machine.updateMatrixWorld(true);
const target = model.workOrigin.worldToLocal(model.toolTip.getWorldPosition(model.workOrigin.position.clone()));
const rows = [];
for (const axis of [coordinates[3].toLowerCase(), "c", coordinates[3].toLowerCase(), "c"]) {
const row = await jog(axis, 2, axis === "c" ? 4 : 3);
assert.equal(await control("switchkins-1").getAttribute("aria-pressed"), "true");
assert.ok(row.stopped.joints.slice(0, 3).some((v, i) => Math.abs(v - row.before.joints[i]) > 1e-5), `${coordinates} ${axis}: missing visible XYZ compensation`);
for (const phase of ["moving", "stopped"]) {
const state = row[phase]; model.update(state); model.machine.updateMatrixWorld(true);
const point = model.workOrigin.localToWorld(target.clone());
const tip = model.toolTip.getWorldPosition(target.clone());
state.tcp_error = tip.distanceTo(point);
assert.ok(state.tcp_error < 1e-7, `${coordinates} ${axis} ${phase}: ${state.tcp_error}`);
if (phase === "stopped") state.tool_tip.forEach((v, i) => assert.ok(Math.abs(v - tip.toArray()[i]) < 1e-7, "rendered tool tip must match the Worker joints"));
}
rows.push(row);
}
await page.screenshot({ path: new URL(`rtcp-jog-${coordinates.toLowerCase()}.png`, output).pathname, fullPage: true });
model.dispose(); report.machines.push({ coordinates, rows });
}
assert.deepEqual(errors, []); report.status = "verified";
} catch (error) {
report.status = "failed"; report.error = String(error.stack ?? error);
await page.screenshot({ path: new URL("rtcp-jog-failure.png", output).pathname, fullPage: true });
throw error;
} finally {
report.browser_errors = errors;
await writeFile(new URL("rtcp-jog-browser.report.json", output), JSON.stringify(report, null, 2) + "\n");
await browser.close();
}
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, machines: report.machines.map((m) => ({ coordinates: m.coordinates, jogs: m.rows.length, max_tcp_error: Math.max(...m.rows.flatMap((r) => [r.moving.tcp_error, r.stopped.tcp_error])) })), browser_errors: errors }));

View File

@@ -136,7 +136,7 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat
// workpiece path together with the physical table.
const pathGroup = new THREE.Group(); pathGroup.name = "xyzac-toolpath"; assembly.joints.rotate.add(pathGroup);
const liveGroup = new THREE.Group(); liveGroup.name = "xyzac-live-toolpath"; assembly.joints.rotate.add(liveGroup);
const pathMaterials = [0xdce7e8, 0xff9a55, 0x5c9d9a].map((color) => new THREE.LineBasicMaterial({ color }));
const pathMaterials = [0xffff00, 0xff9a55, 0x5c9d9a].map((color) => new THREE.LineBasicMaterial({ color }));
const clearLines = (group: THREE.Group) => { group.children.forEach((child) => (child as THREE.Line).geometry.dispose()); group.clear(); };
const limits = new THREE.Box3Helper(new THREE.Box3(), 0xdd7566); scene.add(limits);
const offsets = new THREE.AxesHelper(.8); scene.add(offsets);

View File

@@ -0,0 +1,123 @@
import assert from "node:assert/strict";
import { readFile, mkdir, writeFile } from "node:fs/promises";
import * as THREE from "three";
import createModule from "../../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../../workspace/sim-case-loader.mjs";
import { writeTrtGeometry } from "../../workspace/trt-geometry.mjs";
import { MACHINE_DEFINITIONS } from "../../workspace/machine-definitions.mjs";
import { SimSession, CONTEXT_ID } from "../../worker/sim-session.mjs";
import { fnv1a64 } from "../../worker/replay.mjs";
import { buildXyzacMachine, buildXyzbcMachine } from "./xyzac-machine-model.ts";
const root = new URL("../../../", import.meta.url);
const fetchImpl = async (url) => {
try { const source = await readFile(new URL(String(url).replace(/^\//, ""), root), "utf8"); return { ok: true, text: async () => source }; }
catch { return { ok: false }; }
};
const wasmBinary = await readFile(new URL("build-wasm/linuxcnc-sim-core.wasm", root));
const close = (actual, expected, message) => assert.ok(Math.abs(actual - expected) < 1e-7, `${message}: ${actual} != ${expected}`);
const rows = [];
for (const machine of MACHINE_DEFINITIONS) {
const raw = await loadSimulationCase({ path: machine.entrypoint, fetchImpl });
const bundle = writeTrtGeometry(raw, { x_rot_point: 9, y_rot_point: -7, z_rot_point: 13 });
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: `rtcp-jog-${machine.id}` }); let seq = 0;
const send = (kind, payload = {}, expectedKind = null) => {
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, seq: ++seq, operation_id: `jog-${seq}`, payload });
if (expectedKind) assert.equal(result.kind, expectedKind);
else assert.notEqual(result.kind, "ERROR", `${kind}: ${JSON.stringify(result.payload)}`);
return result;
};
send("HELLO"); send("SESSION_ATTACH");
send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
send("POWER", { active: true }); send("HOME", { all: true });
for (let i = 0; i < 500 && !session.wasmHomeLimit.read().state.homed; i++) send("SIM_STEP", { cycles: 1 });
assert.equal(session.wasmHomeLimit.read().state.homed, true);
const drain = () => {
for (let i = 0; i < 5000 && session.wasmMotion.read().state.queue_depth > 0; i++) send("SIM_STEP", { cycles: 1 });
assert.equal(session.wasmMotion.read().state.queue_depth, 0);
};
send("SET_MODE", { mode: "MDI" });
send("MDI_EXECUTE", { block: "G21 G90 G1 X12 Y8 Z6 F600" }); drain();
send("MDI_EXECUTE", { block: "M428" });
send("SET_MODE", { mode: "MANUAL" });
const model = machine.coordinates === "XYZAC" ? buildXyzacMachine() : buildXyzbcMachine();
const start = session.wasmMotion.read().state;
const target = { ...start.position };
const samples = [];
const check = (phase, snapshot = null) => {
const state = session.wasmMotion.read().state;
assert.equal(session.xyzacKinematics.activeMode, 1, phase);
assert.equal(state.active_tag, 1, `${phase}: RTCP motion mode must remain active`);
for (const axis of ["x", "y", "z"]) close(state.position[axis], target[axis], `${phase}: programmed TCP ${axis}`);
const joints = state.joint_commanded;
const offsets = session.xyzacKinematics.snapshot().offsets;
model.update({ joints, offsets }); model.machine.updateMatrixWorld(true);
const point = model.workOrigin.localToWorld(new THREE.Vector3(target.x * .01, target.z * .01, target.y * .01));
const tip = model.toolTip.getWorldPosition(new THREE.Vector3());
const error = tip.distanceTo(point);
assert.ok(error < 1e-7, `${phase}: machine model TCP error ${error}`);
if (snapshot?.visual?.joints) snapshot.visual.joints.forEach((v, i) => close(v, joints[i], `${phase}: visual joint ${i}`));
if (snapshot?.visual?.motion_trace) for (const sample of snapshot.visual.motion_trace) {
model.update({ joints: sample.joints, offsets }); model.machine.updateMatrixWorld(true);
const samplePoint = model.workOrigin.localToWorld(new THREE.Vector3(target.x * .01, target.z * .01, target.y * .01));
close(model.toolTip.getWorldPosition(new THREE.Vector3()).distanceTo(samplePoint), 0, `${phase}: live motion trace TCP`);
}
samples.push({ phase, tick: session.tick, pose: state.position, joints, error });
return state;
};
check("M428");
for (const axis of [machine.axes[3], "c", machine.axes[3], "c"]) {
const before = session.wasmMotion.read().state;
send("JOG_START", { axis, direction: 1, continuous: true, increment: null });
for (let i = 0; i < 15; i++) {
const result = send("SIM_STEP", { cycles: 1 });
check(`${axis}-continuous-${i}`, result.payload.panel_snapshot);
}
const moving = session.wasmMotion.read().state;
assert.ok(moving.position[axis] > before.position[axis]);
assert.ok(moving.joint_commanded.slice(0, 3).some((v, i) => Math.abs(v - before.joint_commanded[i]) > 1e-5), `${axis}: XYZ compensation missing`);
const stopped = send("JOG_STOP");
const after = check(`${axis}-released`, stopped.payload.panel_snapshot);
after.joint_commanded.forEach((v, i) => close(v, moving.joint_commanded[i], `${axis}: no release jump`));
send("SIM_STEP", { cycles: 1 }); check(`${axis}-idle`);
const incrementalStart = session.wasmMotion.read().state.position[axis];
send("JOG_START", { axis, direction: -1, increment: 1 }); drain();
close(check(`${axis}-increment`).position[axis], incrementalStart - 1, `${axis}: incremental endpoint`);
}
const beforeInvalid = session.wasmMotion.read().state;
send("JOG_START", { axis: machine.axes[3], direction: 1, increment: 100000 }, "ERROR");
send("JOG_START", { axis: "c", direction: 1, increment: "invalid" }, "ERROR");
assert.deepEqual(check("invalid-jog-retains-rtcp").joint_commanded, beforeInvalid.joint_commanded);
send("JOG_STOP"); check("repeated-stop");
const beforeAbort = session.wasmMotion.read().state.joint_commanded;
send("PROGRAM_ABORT");
assert.deepEqual(check("abort-retains-rtcp").joint_commanded, beforeAbort);
const poweredOff = send("POWER", { active: false });
assert.deepEqual(check("power-off-retains-rtcp", poweredOff.payload.panel_snapshot).joint_commanded, beforeAbort);
send("POWER", { active: true });
send("KINEMATICS_SELECT", { type: 0 });
const identityBefore = session.wasmMotion.read().state.joint_commanded;
send("JOG_START", { axis: machine.axes[3], direction: 1, increment: 2 }); drain();
const identityAfter = session.wasmMotion.read().state.joint_commanded;
identityAfter.slice(0, 3).forEach((v, i) => close(v, identityBefore[i], "M429 XYZ uncompensated"));
send("JOG_STOP"); assert.equal(session.wasmMotion.read().state.active_tag, 0);
// A completed program line and a later jog may share the native TP ID.
const reusedId = 400000;
const collisionStart = session.wasmMotion.read().state.position;
assert.equal(session.wasmMotion.enqueue({ end: { ...collisionStart, x: collisionStart.x + .1 }, source_line: reusedId, kinematics_mode: 0, motion_type: 1, velocity: 10 }).status, "verified");
drain(); send("KINEMATICS_SELECT", { type: 1 });
assert.equal(session.wasmMotion.jog(5, 1, 2, reusedId).status, "verified");
for (let i = 0; i < 5000 && session.wasmMotion.read().state.queue_depth > 0; i++) {
send("SIM_STEP", { cycles: 1 });
assert.equal(session.wasmMotion.read().state.active_tag, 1, "jog must replace the completed line's mode mapping");
}
assert.equal(session.wasmMotion.read().state.queue_depth, 0);
send("JOG_STOP"); assert.equal(session.wasmMotion.read().state.active_tag, 1);
model.dispose();
rows.push({ machine: machine.id, samples, identity_before: identityBefore, identity_after: identityAfter, reused_segment_id: "verified" });
}
const report = { report_id: "trt-rtcp-jog-v1", status: "verified", rows };
const dir = new URL("coverage/trt-parametric/", root); await mkdir(dir, { recursive: true });
await writeFile(new URL("rtcp-jog.report.json", dir), JSON.stringify({ ...report, state_hash: fnv1a64(report) }, null, 2) + "\n");
console.log(JSON.stringify({ report_id: report.report_id, status: report.status, rows: rows.map((r) => ({ machine: r.machine, samples: r.samples.length, max_tcp_error: Math.max(...r.samples.map((s) => s.error)) })) }));

View File

@@ -31,9 +31,11 @@ assert.equal(blocked.payload.code, "axis_gate");
assert.equal(blocked.payload.reason, "xyzac_machine_off");
assert.equal(blocked.payload.message_key, "xyzac_machine_off");
first.send("POWER", { active: true });
first.send("HOME", { all: true });
first.send("PROGRAM_LOAD", { source: PROGRAM, path: "followups.ngc" });
first.send("PROGRAM_RUN");
assert.equal(first.send("HOME", { all: true }).kind, "COMMAND_RESULT");
for (let tick = 0; tick < 500 && !first.session.wasmHomeLimit.read().state.homed; tick++) first.send("SIM_STEP", { cycles: 1 });
assert.equal(first.session.wasmHomeLimit.read().state.homed, true);
assert.equal(first.send("PROGRAM_LOAD", { source: PROGRAM, path: "followups.ngc" }).kind, "COMMAND_RESULT");
assert.equal(first.send("PROGRAM_RUN").kind, "COMMAND_RESULT");
const compact = first.send("SIM_STEP", { cycles: 1, response: "motion_delta" });
assert.equal(compact.payload.snapshot_mode, "motion_delta");
assert.equal(compact.payload.panel_snapshot, undefined);

View File

@@ -95,7 +95,15 @@ export class WasmLinuxcncTp {
setVelocityScale(value) { if (!this.available) return this.read(); const result = call(this.core, "sim_linuxcnc_tp_set_velocity_scale", "number", ["number", "number"], [this.planner, Number(value)]); if (result !== 0) throw new Error(`linuxcnc_tp_velocity_scale_${result}`); return this.read(); }
setJogScale(value) { if (!this.available) return this.read(); const result = call(this.core, "sim_linuxcnc_tp_set_jog_scale", "number", ["number", "number"], [this.planner, Number(value)]); if (result !== 0) throw new Error(`linuxcnc_tp_jog_scale_${result}`); return this.read(); }
setAxisLimit(axis, minimum, maximum) { if (!this.available) return this.read(); const result = call(this.core, "sim_linuxcnc_tp_set_axis_limit", "number", ["number", "number", "number", "number"], [this.planner, Number(axis), Number(minimum), Number(maximum)]); if (result !== 0) throw new Error(`linuxcnc_tp_axis_limit_${result}`); return this.read(); }
jog(axis, direction, increment, tag = 0) { if (!this.available) return { status: "blocked", code: "linuxcnc_tp_unavailable" }; const result = call(this.core, "sim_linuxcnc_tp_jog", "number", ["number", "number", "number", "number", "number"], [this.planner, Number(axis), Number(direction), Number(increment), Number(tag)]); return result === 0 ? { status: "verified", command: "JOG_START" } : { status: "error", code: `linuxcnc_tp_jog_${result}` }; }
jog(axis, direction, increment, tag = 0) {
if (!this.available) return { status: "blocked", code: "linuxcnc_tp_unavailable" };
const result = call(this.core, "sim_linuxcnc_tp_jog", "number", ["number", "number", "number", "number", "number"], [this.planner, Number(axis), Number(direction), Number(increment), Number(tag)]);
if (result !== 0) return { status: "error", code: `linuxcnc_tp_jog_${result}` };
// The native jog tag is a segment ID, not a kinematics mode. Replace any
// earlier program-line mapping with this jog's current coordinate frame.
if (Number(tag) > 0) this.segmentTags.set(Number(tag), this.activeTag);
return { status: "verified", command: "JOG_START" };
}
jogVelocity(axis) { if (!this.available) return 0; const index = { x: 0, y: 1, z: 2, a: 3, b: 4, c: 5, u: 6, v: 7, w: 8 }[String(axis).toLowerCase()]; return index === undefined ? 0 : Number(call(this.core, "sim_linuxcnc_tp_jog_velocity", "number", ["number", "number"], [this.planner, index])); }
setPaused(value) { this.controls.paused = Boolean(value); if (this.available) call(this.core, "sim_linuxcnc_tp_set_paused", "number", ["number", "number"], [this.planner, this.controls.paused ? 1 : 0]); return this.read(); }
stepLine() {
@@ -214,7 +222,13 @@ export class WasmLinuxcncTp {
}
reset() { if (this.available) call(this.core, "sim_linuxcnc_tp_reset", "number", ["number"], [this.planner]); this.controls.paused = false; this.activeTag = 0; this.segmentTags.clear(); this.lastActiveId = 0; this.queueDepthOverride = null; this.executionState = 3; return this.read(); }
abort() { if (this.available) call(this.core, "sim_linuxcnc_tp_abort", "number", ["number"], [this.planner]); this.activeTag = 0; this.segmentTags.clear(); this.lastActiveId = 0; this.queueDepthOverride = null; this.executionState = 3; return this.read(); }
abort() {
if (this.available) call(this.core, "sim_linuxcnc_tp_abort", "number", ["number"], [this.planner]);
// Stopping motion is not M429: keep the coordinate frame of the stopped
// pose, so its WASM inverse and the next jog retain RTCP compensation.
this.segmentTags.clear(); this.lastActiveId = 0; this.queueDepthOverride = null; this.executionState = 3;
return this.read();
}
exportState() {
const current = this.read();
const segments = [];