Align AXIS control flow and fix pause and program rendering
Document source UI gates, explicit native live-plot clearing, and remaining parity boundaries. Fix pause/resume transport continuity, repeated stepping, coordinate previews, and spindle override state. Optimize large program snapshots and rendering, and cover the control flows with native Tcl, WASM, and browser checks. Validation: 22 native tests and source/browser checks pass. Worker/App regression: 113 of 123 pass; 10 pre-existing failures remain documented with no new failures.
This commit is contained in:
@@ -1,5 +1,353 @@
|
|||||||
# XYZAC TRT 面板按钮对标测试报告
|
# XYZAC TRT 面板按钮对标测试报告
|
||||||
|
|
||||||
|
## 2026-09-08 10:33 恢复原生清图方式(北京时间,当前结论)
|
||||||
|
|
||||||
|
按最新明确要求“按原生的方法如何清除的,web 也同样方式清除”,先更新
|
||||||
|
流程文件“原生清图方式(10:14)”,再修改实现。本节取代下方历史版本中
|
||||||
|
自动换程序清图的说明,不再保留该扩展行为。
|
||||||
|
|
||||||
|
源码路径:`axis.tcl` 工具栏 Clear Live Plot / Ctrl-K →
|
||||||
|
`axis.py::clear_live_plot` → `LivePlotter.clear` → `positionlogger.clear`。
|
||||||
|
`open_file_guts`、`reload_file` 创建/替换程序预览,但不调用这一清图路径。
|
||||||
|
|
||||||
|
当前 Web 行为:
|
||||||
|
|
||||||
|
| 操作 | 加工轨迹 Live Plot | 程序预览 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Open、同文件 Reload、编辑后 Reload、加载 Chips | 保留 | 替换 |
|
||||||
|
| Run、Pause、Resume、Stop、正常结束 | 保留 | 保留 |
|
||||||
|
| 工具栏 Clear Live Plot / Ctrl+K | 清除,运行时继续累积后续运动 | 保留 |
|
||||||
|
|
||||||
|
实现仅移除五个 Web 加载入口的自动清图标志,以及 Worker PROGRAM_LOAD
|
||||||
|
中的自动清图分支。旧调用即使附带退役的 `clear_live_plot_on_replace` 参数,
|
||||||
|
也不会额外清图。显式 VISUAL_CLEAR、暂停/恢复通信修复和 Chips 性能优化保留。
|
||||||
|
|
||||||
|
验证结果:
|
||||||
|
|
||||||
|
- Worker 验证换文件、同文件、编辑后、解析失败与 gate 拒绝均不改变
|
||||||
|
live epoch;显式 Clear 才推进 epoch,且 task/motion/program 哈希不变。
|
||||||
|
- 浏览器 Open 和 Reload 保留轨迹;工具栏 Clear 清为 0、静态预览保留。
|
||||||
|
- Ctrl+K 将 41 个实际运动轨迹点清为 0,12 段静态预览完整保留。
|
||||||
|
- 默认程序与 Chips 的重复暂停/清图/倍率/继续、暂停中 Step 后继续和
|
||||||
|
移动端清图/继续均通过;185 条桌面有序命令、4 项预期 task 拒绝,无通信错误。
|
||||||
|
- 源码契约、TypeScript/生产构建通过;桌面/移动 canvas 检查通过。
|
||||||
|
- 全量 Worker/App:123 项,113 通过、10 项原有失败;与上一轮失败集合
|
||||||
|
相同,没有新增失败。既有完整 AXIS 能力边界不因此改变。
|
||||||
|
|
||||||
|
原始记录:`/tmp/axis-native-clear-browser.json`、
|
||||||
|
`/tmp/axis-native-clear-pause.json`、`/tmp/axis-native-clear-regression.log`。
|
||||||
|
本地预览:`http://127.0.0.1:5175/`,刷新后使用新版本;未进行远程部署。
|
||||||
|
|
||||||
|
## 2026-09-08 09:57 暂停/恢复一致性修复(北京时间,当前结论)
|
||||||
|
|
||||||
|
遵循“先核对源码、更新流程文档,再修改代码”的顺序。本次修复仅涉及已连接
|
||||||
|
通信层对 PAUSED 的处理,以及 React 对 task 拒绝的采样处理;既有轨迹、
|
||||||
|
G-code、TP 步长、进给、程序切换和 UI 控件集合继续使用原实现。
|
||||||
|
|
||||||
|
### 已复现的原因
|
||||||
|
|
||||||
|
| 命令 | 发送 seq | 旧通信层 last_seq | 结果 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| PROGRAM_PAUSE | 9 | 9 | 成功暂停 |
|
||||||
|
| VISUAL_CLEAR | 10 | 9 | protocol.invalid_state |
|
||||||
|
| PROGRAM_RESUME | 11 | 9 | protocol.sequence_gap |
|
||||||
|
| PROGRAM_ABORT | 12 | 9 | protocol.sequence_gap |
|
||||||
|
|
||||||
|
旧 `sim_protocol.c` 在 PAUSED 使用另一份命令白名单,拒绝了 Tcl 仍允许的
|
||||||
|
Clear、Step、主轴倍率等操作,并且不消费 seq。随后继续和停止都无法恢复
|
||||||
|
通信连续性。该问题独立于 TP 是否能恢复,也不能通过改写 task 状态解决。
|
||||||
|
|
||||||
|
源依据是 `axis.py::task_pause/task_resume/task_pauseresume` 和
|
||||||
|
`emctaskmain.cc` 的 PAUSED 命令分支、`interpResumeState` 保存/恢复:暂停
|
||||||
|
保留队列和恢复状态,NML 通道继续接受命令,再由 task 判断执行是否合法。
|
||||||
|
|
||||||
|
### 修复及保持不变的行为
|
||||||
|
|
||||||
|
- READY/PAUSED 均接收顺序正确的合法通信帧;Worker task gate 继续负责拒绝
|
||||||
|
非法机床操作,拒绝事件按该 seq 入账,后续 Resume/Stop 不再断档。
|
||||||
|
- schema、握手、operation_id、真正乱序和 tick 回退校验继续生效;不放宽
|
||||||
|
CNC gate、不丢弃错误、不自动重发副作用、不用 Reload/清 TP 实现恢复。
|
||||||
|
- React 遇到 `axis_gate` 拒绝时保留 canonical 状态。若 task 仍运行则继续
|
||||||
|
原有单请求采样;真正 FAULT/OFF/ESTOP、恢复或其他错误继续停止采样。
|
||||||
|
- 原生协议单测加入 `-UNDEBUG`,确保 Release 构建确实执行断言;补充暂停中
|
||||||
|
通信接收和真正坏帧拒绝。旧 MDI 通信拒绝断言改由真实 Worker task 拒绝
|
||||||
|
用例覆盖,未删除机床拒绝验证。
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 原生构建 / CTest | 22/22 通过,协议断言已启用 |
|
||||||
|
| `test_xyzac_pause_resume_consistency.mjs` | 153 条真实 WASM 命令,通信 seq 与事件 cursor 一致;重复暂停、清图/倍率、非法命令、单步后继续、WAITING 完成、MDI 暂停/继续、最后停止均通过 |
|
||||||
|
| `test_xyzac_pause_resume_browser.mjs` | 默认程序与 Chips 各三轮暂停/清图/倍率/继续;重复命令拒绝不终止后续运动;暂停中单步后继续、移动端暂停/清图/继续/停止通过;桌面段 194 条有序命令、4 个预期 task 拒绝,无通信错误 |
|
||||||
|
| 既有按钮浏览器检查 | 20/20 组通过,浏览器错误 0 |
|
||||||
|
| 既有轨迹生命周期 | 换新文件清图、同源重复执行保留、Pause/Resume/Stop 保留、显式 Clear 通过 |
|
||||||
|
| 源码公式/清单 | Tcl 7488 条断言、源码 hash 和 82 个控件 ID 通过 |
|
||||||
|
| TypeScript / Vite | 通过;保留既有 bundle 大小提示 |
|
||||||
|
| 全量 Worker/App | **123 项,113 通过,10 项旧失败**;与上一轮失败集合相同,新增失败 0 |
|
||||||
|
|
||||||
|
剩余旧失败仍为三轴 adapter/完成状态/工具 IO 和配置数量问题,详见下方
|
||||||
|
历史记录;不能把本次专项通过写成完整 LinuxCNC 发布或全仓测试通过。
|
||||||
|
|
||||||
|
原始记录:`/tmp/axis-pause-resume-build.log`、
|
||||||
|
`/tmp/axis-pause-resume-browser.json`、`/tmp/axis-pause-resume-regression.log`、
|
||||||
|
`/tmp/axis-pause-buttons-regression.json`、`/tmp/axis-pause-plot-regression.json`。
|
||||||
|
桌面/移动截图:`/tmp/axis-pause-resume-desktop.png`、
|
||||||
|
`/tmp/axis-pause-resume-mobile.png`。
|
||||||
|
|
||||||
|
本地生产预览为 `http://127.0.0.1:5175/`。刷新页面才会启动新 WASM Worker;
|
||||||
|
本次未远程部署,也不会在旧页面已损坏的通信序号上静默重放程序。
|
||||||
|
|
||||||
|
## 2026-09-08 换程序清图与 Chips 性能(当前结论)
|
||||||
|
|
||||||
|
本次按“核对 AXIS 源码 → 先完善流程文件 → 修改实现 → 复现验收”执行。
|
||||||
|
对应流程见 `linuxcnc-axis-web-control-panel-source-logic.md` 的“换程序轨迹与
|
||||||
|
长程序性能”及更新后的“Toolpath display lifecycle”。
|
||||||
|
|
||||||
|
### 清图语义
|
||||||
|
|
||||||
|
`axis.py::open_file_guts` 替换静态 AxisCanon,不调用 LivePlotter.clear;
|
||||||
|
原生保留历史加工轨迹。因此这不是原生 Open 的缺陷。本次按用户明确要求,
|
||||||
|
Web 文件替换发送 `clear_live_plot_on_replace:true`,在新文件/新文本解析
|
||||||
|
成功后,通过与 VISUAL_CLEAR 共用的 `clearLivePlot()` 递增可视 epoch,
|
||||||
|
一次发布新预览与新 epoch。这是已记录的 `WEB_EXTENSION`,不冒充原生行为。
|
||||||
|
|
||||||
|
默认 PROGRAM_LOAD 保留轨迹;同文件同文本 Reload、Run/Pause/Resume/Stop
|
||||||
|
保留轨迹。取消、门控拒绝或解析失败不自动清图。加载期间也不再用 null
|
||||||
|
快照造成假 epoch=0;画布只按真正的 Worker epoch 清除历史和待播放样本。
|
||||||
|
|
||||||
|
### 性能原因与测量
|
||||||
|
|
||||||
|
修改前 CPU profile 中,82.9% 样本位于 JS `fnv1a64`,主要来自每次 program
|
||||||
|
snapshot 对 200509 字节源程序重复计算 hash。此外,全部 canonical 线段
|
||||||
|
都有 samples,旧绘图分支将其各建成独立 Three.js Line,导致约 4684 次
|
||||||
|
静态 draw call,而非代码注释中声称的批量绘图。
|
||||||
|
|
||||||
|
修正为源文本 hash 按不可变文本缓存,保留 WASM 动态状态的实时读取;
|
||||||
|
同一 servo 批次只读取一次不变的 program paused 状态;相邻 canonical
|
||||||
|
顶点合并成 rapid/feed 两个 LineSegments;Live Plot 使用增量更新的 GPU
|
||||||
|
缓冲,不再每几帧复制整段历史。未修改 G-code、F 值、倍率、TP 或 tick 步长。
|
||||||
|
|
||||||
|
同一主机、相同输入、两次 Chips 加载,200 task ticks(2 秒仿真)测试:
|
||||||
|
|
||||||
|
| 指标 | 修改前 | 修改后 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 第一次 Chips 加载 | 18.904 s | 1.822 s |
|
||||||
|
| 再次 Chips 加载 | 18.394 s | 1.688 s |
|
||||||
|
| 推进 2 秒仿真(第一次) | 10.376 s CPU/墙钟 | 0.325 s CPU/墙钟 |
|
||||||
|
| 推进 2 秒仿真(再次) | 10.268 s | 0.296 s |
|
||||||
|
| 200 次长程序 snapshot | 755 ms | 40 ms |
|
||||||
|
| 5-tick 请求 p95(第一次) | 287 ms | 13.4 ms |
|
||||||
|
|
||||||
|
两个尝试的 source hash、preview hash、preview 段数、末端位置、速度、队列、
|
||||||
|
进给倍率、执行行号在修改前后逐项完全一致;清图 epoch 是唯一预期的可视
|
||||||
|
生命周期差异。以上是计算吞吐提升,不是放大机床速度。浏览器仍按 1 倍
|
||||||
|
仿真时间推进,最多一个采样请求在途。
|
||||||
|
|
||||||
|
### 完整浏览器场景
|
||||||
|
|
||||||
|
在 `http://127.0.0.1:5175/` 运行实际配置的第一份 `xyzac_switchkins.ngc`
|
||||||
|
直到完成,然后点击加载 `linuxcnc/nc_files/3D_Chips.ngc`,再运行/暂停/
|
||||||
|
恢复/停止,结果:
|
||||||
|
|
||||||
|
| 项目 | 实际结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 第一份程序完成时 Live Plot | 4349 点 |
|
||||||
|
| 第二份加载完成后旧 Live Plot | 0 点,新 epoch=2 |
|
||||||
|
| Chips 浏览器加载 | 2624 ms |
|
||||||
|
| 静态预览 | 4683 段,2 次 draw call;旧程序结束在 Z10,首个 G0 Z10 是正确消去的零位移 |
|
||||||
|
| 运行 5.020 s 墙钟 | 推进 5.050 s 仿真,比例 1.006 |
|
||||||
|
| Worker 请求 p95 | 18.2 ms |
|
||||||
|
| 暂停/停止响应 | 149 / 151 ms |
|
||||||
|
| 浏览器错误 | 0 |
|
||||||
|
|
||||||
|
另外验证同源 Reload 保留轨迹、显式 Clear 只清 Live Plot、不清静态预览;
|
||||||
|
默认 Worker PROGRAM_LOAD 的原生保留路径及成功/失败/拒绝 Web 替换均通过。
|
||||||
|
|
||||||
|
移动端长程序编辑区的自动高度曾扩展到数万像素,已改为固定 grid 高度和
|
||||||
|
内部滚动。390/768/1440 宽度下页面高度为 2520/2427/1559,编辑器可滚动
|
||||||
|
81957 像素但只渲染 30/30/37 行;移动端运行比 0.999,错误 0。桌面/移动端
|
||||||
|
canvas 非空,Chips 实体刀路正常显示,截图已检查。
|
||||||
|
|
||||||
|
### 复现与回归
|
||||||
|
|
||||||
|
- `scripts/benchmark_xyzac_program_replacement.mjs`:实际 WASM 前后基准。
|
||||||
|
- `scripts/test_xyzac_second_program_browser.mjs`:完整首程序完成后换 Chips。
|
||||||
|
- `scripts/test_xyzac_chips_layout_browser.mjs`:三种尺寸、长编辑器滚动及移动运行。
|
||||||
|
- `scripts/test_xyzac_live_path_lifecycle_browser.mjs`:暂停/恢复/停止、换文件、重复运行、显式清图。
|
||||||
|
- 源码契约、82 个控件 ID、TypeScript 和生产构建通过。
|
||||||
|
- 针对性 Worker/React 回归 9/9;全量 Worker/App **122 项,112 通过、10 项原有失败**,与上轮失败集合相同。
|
||||||
|
|
||||||
|
原始输出:`/tmp/axis-chips-before.json`、`/tmp/axis-chips-after.json`、
|
||||||
|
`/tmp/axis-chips-browser.json`、`/tmp/axis-chips-layout.json`、
|
||||||
|
`/tmp/axis-chips-lifecycle-browser.log`、`/tmp/axis-chips-full-regression.log`。
|
||||||
|
截图:`/tmp/axis-chips-replacement-1440.png`、`/tmp/axis-chips-replacement-390.png`。
|
||||||
|
|
||||||
|
本次仅修正上述换程序和性能问题,保持既有完整 AXIS/所有配置的能力边界,
|
||||||
|
未进行远程部署;线上页面不会因本地代码修改自动更新。
|
||||||
|
|
||||||
|
## 2026-09-08 第二次源码复核(当前结论)
|
||||||
|
|
||||||
|
按要求先复核并修正流程文件,再修改 Worker/WASM 和 Web。当前流程文件的
|
||||||
|
“源码复核修正”和“尚未取得完整等价证据”两节是本次审核入口;早期的
|
||||||
|
SOURCE_ADAPTER 标签不得用来掩盖其中列出的功能缺口。
|
||||||
|
|
||||||
|
### 源码证据
|
||||||
|
|
||||||
|
新增 `scripts/test_axis_source_ui_oracle.mjs`,直接执行锁定的
|
||||||
|
`axis.tcl::update_state`,对 **576 种状态组合、13 条控件公式作 7488 条断言**,
|
||||||
|
结果通过。源码 Python/Tcl hash 检查和 **82 个控件 ID** 清单检查也通过。
|
||||||
|
该测试不运行桌面 AXIS,更不替代完整 motion 数值 oracle;它验证原始 Tcl
|
||||||
|
公式与 Web 的 `ui_gates` 投影一致。
|
||||||
|
|
||||||
|
### 本次修正
|
||||||
|
|
||||||
|
| 项目 | 源码定义与实现结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| UI 与命令门控 | `ui_gates` 对标 Tcl,`gates` 对标命令接收。Run/Step 不再用未回零状态改变原生 UI 公式;MDI UI 可用时,handler 仍可按 manual_ok 拒绝并保留输入 |
|
||||||
|
| OFF 状态 | `STATE_ESTOP_RESET` 在 AXIS 显示为 OFF;不再根据 last_error 猜测机器状态,也不再将 FAULT 标签直接投影成 ESTOP |
|
||||||
|
| Step | 初次从 0 行进入暂停;后续可连续解析;已有暂停运动通过 C/WASM 的 TP 执行 ID 边界单步。原生 C 测试验证两条排队运动分两次完成,不能一次排空 |
|
||||||
|
| WAITING | 程序读完但运动未结束可暂停;恢复原来的完成/等待状态,不重新开始解释;MDI 运动期间正确投影 WAITING 并保持互斥 |
|
||||||
|
| 模式恢复 | ON+IDLE 的 AUTO 边沿及文件加载后按选中标签发送 SET_MODE;在消息处理时排队,避免延迟 effect 覆盖后续 Run |
|
||||||
|
| Home Axis | switchkins 始终为 KINEMATICS_BOTH。世界轴字母回零正确拒绝;Worker 独立支持 `HOME {joint:0..4}` 的原生数字 joint 入口,未伪装为世界轴按钮 |
|
||||||
|
| 主轴覆盖率 | 独立保留 requested_rpm/direction;覆盖率 0 后恢复原转速与方向;React 不再按输出 RPM 的正负推断按钮方向 |
|
||||||
|
| 手动/MDI 控件组 | 轴单选框及 MDI 历史列表加入各自的源控件组门控;c/i/I 切换步长;Escape 可调用原生 task_stop,中止手动或 MDI 运动 |
|
||||||
|
| 预览 | Rotate 只切换鼠标 Drag/Rotate;取消自动旋转。limits 读取 C/WASM XYZ 软限位;offsets 读取 G5x;velocity/extents 现在确实改变对应显示。按 glnav.py 修正五个正交视图、Z2 的平面旋转及短程序过大的标记 |
|
||||||
|
|
||||||
|
### 验收结果
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| 原始 Tcl 公式对照 | 7488/7488 通过 |
|
||||||
|
| 原生 CTest | 22/22 通过;包含双段 TP 单步和方向限位 |
|
||||||
|
| 实际 WASM 控制链 | `test_axis_source_execution.mjs` 的 UI/命令门控、Step、WAITING/MDI、回零类型、主轴零覆盖率均通过 |
|
||||||
|
| 生产构建浏览器按钮 | 20/20 组通过 |
|
||||||
|
| 生产构建事件/显示流程 | 12/12 组通过;包含重复 Step、模式恢复、旋转不自转、真实图层、1440×1000 和 390×844 画布像素检查 |
|
||||||
|
| 控件清单 | 82 个已登记 ID/家族;初始 DOM 80 个控件,About/历史为动态控件 |
|
||||||
|
| 前端投影测试 | 4/4 通过 |
|
||||||
|
| TypeScript / Vite | 通过;既有 bundle 大小提示仍存在 |
|
||||||
|
| 全部 Worker/App 测试 | **122 项,112 通过,10 失败** |
|
||||||
|
|
||||||
|
剩余 10 项均为上轮已在修改前源码复现的旧三轴/配置问题:
|
||||||
|
`test_axis3_selected_case`、`test_axis3_tutorial_motion`、`test_axis3_tutorial_program`、
|
||||||
|
`test_axis3_tutorial_sequence`、`test_axis3_tutorial_tool_io`、`test_axis3_visible_completion`、
|
||||||
|
`test_panel_gate_matrix`、`test_panel_snapshot_projection`、`test_sim_case_matrix_s8_06`、
|
||||||
|
`test_wasm_canonical_state`(均为 `web/worker/*.mjs`)。没有把这些失败计为通过。
|
||||||
|
|
||||||
|
`test_xyzac_manual_control` 已使用真实 joint 编号回零、等待 MDI 队列排空后再
|
||||||
|
加载程序;原测试的 Z180 超过选定 INI 的 Z 最大值 120,正常路径改为 Z18,
|
||||||
|
并继续保留越界拒绝和活动 MDI 拒绝断言。ESTOP_RESET 断言改为检查 source
|
||||||
|
state;界面显示 OFF。首次 Step 及暂停恢复测试已按源 task 分支更新。
|
||||||
|
|
||||||
|
日志:`/tmp/axis-source-round2-final-regression.log`、
|
||||||
|
`/tmp/axis-source-round2-camera-final.log`、`/tmp/axis-source-round2-browser-buttons2.log`。
|
||||||
|
截图:`/tmp/axis-source-flow-1440.png`、`/tmp/axis-source-flow-390.png`。
|
||||||
|
本地生产预览:`http://127.0.0.1:5175/`,未执行远程部署。
|
||||||
|
|
||||||
|
### 仍不属于完整对标
|
||||||
|
|
||||||
|
完整 joint/world 模式、Run 边界警告对话框、独立角速度和物理单位滑块、
|
||||||
|
原生 task/NML 指令级 Step 细分、全部 G92/旋转/刀具补偿图层、optional stop、
|
||||||
|
block delete、rapid override、Tool Touch Off 等仍有明确缺口。TP 执行 ID 在
|
||||||
|
伺服采样后变化,单步边界允许原生单周期内的微小推进,未伪造精确端点。
|
||||||
|
新 IO 状态布局由 `sim_io_size` 读取;旧布局 checkpoint 会明确拒绝,不能
|
||||||
|
把旧二进制状态直接套入新结构。
|
||||||
|
|
||||||
|
因此本轮结论是**指定控制路径经源码验证并修正,仍有公开的源功能缺口**;
|
||||||
|
不是“全部 LinuxCNC/AXIS 功能已经完全等价”。`release_status: STOP_RELEASE`。
|
||||||
|
|
||||||
|
## 2026-09-08 源流程文件驱动验收
|
||||||
|
|
||||||
|
本次按“核对 AXIS 源码 → 完善流程文件 → 修改 WASM/Worker → 修改 Web → 验收”执行。
|
||||||
|
当前规范为 [`linuxcnc-axis-web-control-panel-source-logic.md`](../docs/linuxcnc-axis-web-control-panel-source-logic.md),
|
||||||
|
覆盖 **82 个控件 ID/动态家族**,并同步了 DOT 与 PNG 流程图。
|
||||||
|
本节是当前结论;后面的旧版本记录仅用于历史追溯,不能替代本节或当前流程文件。
|
||||||
|
|
||||||
|
状态:`VERIFIED_WITH_SCOPE_GAPS`;发布边界继续为 `STOP_RELEASE`。
|
||||||
|
本地生产构建预览:`http://127.0.0.1:5175/`。
|
||||||
|
|
||||||
|
### 按流程修正
|
||||||
|
|
||||||
|
| 流程 | 当前行为与验证 |
|
||||||
|
| --- | --- |
|
||||||
|
| 文件打开/重载 | 统一 Worker 加载门控;运行、暂停、活动 TP 队列和读取中的互斥;异步读取后再次检查门控 |
|
||||||
|
| 按住点动 | 一次 `JOG_START`,C/WASM 选择方向限位;浏览器只发 `SIM_STEP`;释放/取消/移出/失焦/隐藏页只发送一次 `JOG_STOP` |
|
||||||
|
| 增量点动 | 松开后继续采样直到 WASM 队列排空;线性轴按显示单位转换,A/C 保持角度 |
|
||||||
|
| MDI | trim、清空输入、相邻历史去重、1000 条历史上限、单击选取、双击一次执行、Up/Down 循环选择 |
|
||||||
|
| 主轴/冷却/Stop | 区分原生命令接收条件与 Tcl 控件启用条件;主轴指令自行切 MANUAL,F9/F10 先停转分支保留;冷却复选框只在 ON+IDLE 可用,Stop 按钮只在 ON+非 IDLE 可用 |
|
||||||
|
| 覆盖率 | Feed/Jog/Max/Spindle 设置在 OFF/ESTOP 仍可提交;快照回显数值,设置本身不得使主轴启动 |
|
||||||
|
| 对刀 | G10 不改变机器位置;X/Y/Z 单位转换与 A/C 角度分开;G54..G59.3 偏置保留到 Reload,重新生成预览并更新 relative 显示 |
|
||||||
|
| 程序完成 | M2/EOF 后等待最后 TP 段排空;验收同时检查队列排空前 Run 禁用、排空后任务 STOPPED |
|
||||||
|
| 快捷键/弹窗 | F1 在文本输入中仍有效;大小写和 Ctrl/Alt/Meta 不误映射机器命令;About 焦点、Escape 和菜单关闭按流程处理 |
|
||||||
|
| 显示 | 修复菜单弹层裁切、文件栏按钮被挤压和窄屏数值裁切;减少画布调试文本遮挡 |
|
||||||
|
|
||||||
|
### 实际结果
|
||||||
|
|
||||||
|
| 检查 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `scripts/build-wasm.sh` | WASM 构建及内置样例通过;native CTest **22/22** 通过 |
|
||||||
|
| 源码契约/控件清单 | Python/Tcl 源文件 SHA-256 与锁定值一致;**82** 个控件 ID 已登记 |
|
||||||
|
| `test_xyzac_panel_buttons_browser.mjs` | 最新生产构建 **20/20** 组通过,浏览器错误 0;含菜单、案例、急停和断电恢复 |
|
||||||
|
| `test_axis_panel_source_flow_browser.mjs` | **11/11** 组通过;实际统计 Worker 命令,验证五轴双向、快速释放、失焦、键盘按住、MDI 历史、对刀与相对坐标 |
|
||||||
|
| 浏览器控件清单 | 初始 80 个控件加动态 About,历史家族另在专项生成并验证;无未登记或非法重复 ID |
|
||||||
|
| 桌面/移动端 | 1440×1000、390×844;画布非空、视图改变像素、资源加载正常、无页面横向溢出 |
|
||||||
|
| `web/react/src/test_*.mjs` | **4/4** 投影测试通过 |
|
||||||
|
| TypeScript/Vite | typecheck/build 通过;保留既有大 bundle 提示 |
|
||||||
|
| 全部 `web/worker/test_*.mjs` + `web/app/test_*.mjs` | **121 项,110 通过,11 失败**;不能标记全仓通过 |
|
||||||
|
|
||||||
|
原始输出:`/tmp/axis-panel-core-build-final.log`、
|
||||||
|
`/tmp/axis-panel-browser-production.log`、`/tmp/axis-panel-source-flow-acceptance.log`、
|
||||||
|
`/tmp/axis-panel-worker-acceptance.log`。
|
||||||
|
截图:`/tmp/axis-source-flow-1440.png`、`/tmp/axis-source-flow-390.png`。
|
||||||
|
|
||||||
|
### 修改前复现与保留失败
|
||||||
|
|
||||||
|
使用主仓库提交 `845075f4a37baca145ecec572d93d8aade17ba32` 的独立源码目录
|
||||||
|
`/tmp/axis-source-baseline.mK3EZO` 重建原始 WASM,复用相同 LinuxCNC 资源,
|
||||||
|
对 13 个相关用例复测:1 通过、12 失败。
|
||||||
|
其中 `test_xyzac_hal_tool_alarm.mjs` 的 MDI 空闲态主轴拒绝已修正;
|
||||||
|
最终剩余 11 项均属于该基线失败集合,**新增失败数为 0**。
|
||||||
|
复现输出:`/tmp/axis-panel-baseline-failures.log`。
|
||||||
|
|
||||||
|
| 保留用例(均位于 `web/worker/`) | 失败原因 |
|
||||||
|
| --- | --- |
|
||||||
|
| `test_axis3_selected_case.mjs`、`test_axis3_tutorial_motion.mjs`、`test_axis3_tutorial_sequence.mjs`、`test_panel_gate_matrix.mjs`、`test_panel_snapshot_projection.mjs`、`test_wasm_canonical_state.mjs` | 旧三轴 motion adapter 缺少 `wasmMotion.jog` |
|
||||||
|
| `test_axis3_tutorial_program.mjs`、`test_axis3_visible_completion.mjs` | 旧三轴任务完成状态仍为 RUNNING |
|
||||||
|
| `test_axis3_tutorial_tool_io.mjs` | 旧三轴工具/IO 路径被门控拒绝 |
|
||||||
|
| `test_sim_case_matrix_s8_06.mjs` | 资源矩阵实际数量 236 与旧断言 235 不一致 |
|
||||||
|
| `test_xyzac_manual_control.mjs` | 旧用例要求在 MDI 运动队列未排空时接受 `PROGRAM_LOAD`,与当前文档的加载互斥相冲突;保留拒绝行为 |
|
||||||
|
|
||||||
|
`test_xyzac_machine_sequence.mjs` 已按源码补充 M2 后的有限队列排空检查,
|
||||||
|
继续断言 TP 队列非空时 Run 禁用、最终 task STOPPED、queue=0;输出记录实际
|
||||||
|
`drain_ticks`,不再把固定 8000 个采样等同于完成。
|
||||||
|
原先关于“Spindle 必须预先 MANUAL”及“OFF/ESTOP 禁止覆盖率”的断言已按
|
||||||
|
AXIS handler/Tcl 原公式修正,并增加实际 WASM 状态断言。
|
||||||
|
|
||||||
|
### 明确边界
|
||||||
|
|
||||||
|
Rapid Override、独立 Angular Jog Speed、Override Limits、Block Delete、
|
||||||
|
Optional Stop、完整 Tool Touch Off/工具偏置 ABI,以及桌面工具编辑器/Quit
|
||||||
|
仍按流程记录为 `SOURCE_BLOCKED` 或 host boundary。禁用功能不计入已实现
|
||||||
|
源码对标;当前证据也不代表完整原生 free/teleop 减速、全部坐标旋转/G92/
|
||||||
|
刀具补偿、所有 LinuxCNC 配置或完整 TP 数值 oracle 已闭合。
|
||||||
|
|
||||||
|
复现本次验收:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/build-wasm.sh
|
||||||
|
node scripts/test_axis_control_contract.mjs
|
||||||
|
node scripts/test_axis_control_inventory.mjs
|
||||||
|
node --experimental-strip-types web/worker/test_xyzac_source_widget_gates.mjs
|
||||||
|
node --experimental-strip-types web/worker/test_xyzac_touch_off_source_flow.mjs
|
||||||
|
npm run build --prefix web/react
|
||||||
|
# 本地预览运行后,向这两个脚本传入 PLAYWRIGHT_MODULE 和 XYZAC_PANEL_URL
|
||||||
|
node scripts/test_xyzac_panel_buttons_browser.mjs
|
||||||
|
node scripts/test_axis_panel_source_flow_browser.mjs
|
||||||
|
node --experimental-strip-types --test --test-concurrency=4 web/worker/test_*.mjs web/app/test_*.mjs
|
||||||
|
```
|
||||||
|
|
||||||
|
## 历史记录(不作为当前验收结论)
|
||||||
|
|
||||||
## 结论
|
## 结论
|
||||||
|
|
||||||
本报告针对当前选定案例
|
本报告针对当前选定案例
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ digraph LinuxCncAxisWebControlLogic {
|
|||||||
contract [label="源程序锁定契约\nSOURCE_EXACT · SOURCE_ADAPTER\nSOURCE_BLOCKED · WEB_EXTENSION", fillcolor="#fff5c9", color="#b08a2e", penwidth=2.2];
|
contract [label="源程序锁定契约\nSOURCE_EXACT · SOURCE_ADAPTER\nSOURCE_BLOCKED · WEB_EXTENSION", fillcolor="#fff5c9", color="#b08a2e", penwidth=2.2];
|
||||||
gesture [label="浏览器控件手势\ndata-axis-control\n82 个文档控制 ID", fillcolor="#edf6f8", color="#3c8091"];
|
gesture [label="浏览器控件手势\ndata-axis-control\n82 个文档控制 ID", fillcolor="#edf6f8", color="#3c8091"];
|
||||||
react [label="React handler\n只发意图,不是状态权威", fillcolor="#edf6f8", color="#3c8091"];
|
react [label="React handler\n只发意图,不是状态权威", fillcolor="#edf6f8", color="#3c8091"];
|
||||||
operation [label="AxisOperationController\n串行操作 · operation_id · ledger", fillcolor="#eef2fb", color="#526da8"];
|
operation [label="AxisOperationController\noperation_id · ledger\nWeb 通道暂停仍接收合法连续 seq", fillcolor="#eef2fb", color="#526da8"];
|
||||||
gate [label="SimSession command gate\n最终门控与稳定拒绝原因", fillcolor="#fcebdc", color="#bd6a28", penwidth=2.2];
|
gate [label="SimSession command gate\n最终门控与稳定拒绝原因", fillcolor="#fcebdc", color="#bd6a28", penwidth=2.2];
|
||||||
wasm [label="LinuxCNC/WASM 权威\nTask · Interpreter · TP\nIO · Home/Limit · Kinematics", fillcolor="#e9f5e8", color="#43844b", penwidth=2.2];
|
wasm [label="LinuxCNC/WASM 权威\nTask · Interpreter · TP\nIO · Home/Limit · Kinematics", fillcolor="#e9f5e8", color="#43844b", penwidth=2.2];
|
||||||
snapshot [label="Canonical snapshot\naxis_state · gates · motion\nprogram · devices · visual", fillcolor="#e9f5e8", color="#43844b"];
|
snapshot [label="Canonical snapshot\naxis_state · ui_gates / gates · motion\nprogram · devices · visual", fillcolor="#e9f5e8", color="#43844b"];
|
||||||
projection [label="React projection\n按钮 enabled/pressed\nDRO · Preview · Diagnostics", fillcolor="#edf6f8", color="#3c8091"];
|
projection [label="React projection\n按钮 enabled/pressed\nDRO · Preview · Diagnostics", fillcolor="#edf6f8", color="#3c8091"];
|
||||||
|
|
||||||
{ rank=same; source; contract; gesture; react; operation; }
|
{ rank=same; source; contract; gesture; react; operation; }
|
||||||
@@ -98,7 +98,7 @@ digraph LinuxCncAxisWebControlLogic {
|
|||||||
on_unhomed [label="ON · NOT HOMED\nOpen / Home / joint-mode manual\nAUTO/MDI 由 task homing 策略拒绝", fillcolor="#fff5c9", color="#b08a2e"];
|
on_unhomed [label="ON · NOT HOMED\nOpen / Home / joint-mode manual\nAUTO/MDI 由 task homing 策略拒绝", fillcolor="#fff5c9", color="#b08a2e"];
|
||||||
idle [label="ON · HOMED · IDLE\nRun / Step / Manual / MDI\n按各自源门控启用", fillcolor="#e9f5e8", color="#43844b", penwidth=2.1];
|
idle [label="ON · HOMED · IDLE\nRun / Step / Manual / MDI\n按各自源门控启用", fillcolor="#e9f5e8", color="#43844b", penwidth=2.1];
|
||||||
running [label="AUTO · RUNNING\n解释器读取 + TP 执行\nPause / Stop / Power Off / ESTOP", fillcolor="#e5effa", color="#526da8", penwidth=2.1];
|
running [label="AUTO · RUNNING\n解释器读取 + TP 执行\nPause / Stop / Power Off / ESTOP", fillcolor="#e5effa", color="#526da8", penwidth=2.1];
|
||||||
paused [label="AUTO · PAUSED\n轨迹冻结但保留\nResume / Stop / Power Off / ESTOP", fillcolor="#fcebdc", color="#bd6a28", penwidth=2.1];
|
paused [label="AUTO / MDI · PAUSED\n轨迹与恢复状态保留;通信连接持续\nClear / Override / Step 后仍可 Resume\n命令拒绝不产生 seq 断档", fillcolor="#fcebdc", color="#bd6a28", penwidth=2.1];
|
||||||
ended [label="INTERP_IDLE · completed / aborted\nWeb 状态标签不是 LinuxCNC 枚举\n保留 taskfile;Run 重新打开第 0 行", fillcolor="#e9f5e8", color="#43844b"];
|
ended [label="INTERP_IDLE · completed / aborted\nWeb 状态标签不是 LinuxCNC 枚举\n保留 taskfile;Run 重新打开第 0 行", fillcolor="#e9f5e8", color="#43844b"];
|
||||||
|
|
||||||
{ rank=same; estop; off; on_unhomed; idle; }
|
{ rank=same; estop; off; on_unhomed; idle; }
|
||||||
@@ -135,14 +135,14 @@ digraph LinuxCncAxisWebControlLogic {
|
|||||||
];
|
];
|
||||||
|
|
||||||
manual_controls [
|
manual_controls [
|
||||||
label="手动控制\n\nmanual-axis-x / y / z / a / c (本地选择)\nmanual-jog-increment (本地选择)\nmanual-jog-negative / positive (JOG_START -> release JOG_STOP)\nmanual-home-axis (HOME axis)\nmanual-touch-off (TOUCH_OFF / G10 L20)\nmanual-tool-touch-off (blocked_by_abi_tool_offsets)\nmanual-override-limits (blocked_by_abi)\nmanual-spindle-ccw / stop / cw (SPINDLE_COMMAND)\nmanual-spindle-decrease / increase\n (SPINDLE_COMMAND -> release SPINDLE_CONSTANT)\nmanual-spindle-brake (SPINDLE_BRAKE)\nmanual-coolant-mist / flood (COOLANT_COMMAND)",
|
label="手动控制\n\nmanual-axis-x / y / z / a / c (本地选择)\nmanual-jog-increment (本地选择)\nmanual-jog-negative / positive (JOG_START -> release JOG_STOP)\nmanual-home-axis (BOTH: world-axis 拒绝)\nmanual-touch-off (TOUCH_OFF / G10 L20)\nmanual-tool-touch-off (blocked_by_abi_tool_offsets)\nmanual-override-limits (blocked_by_abi)\nmanual-spindle-ccw / stop / cw (SPINDLE_COMMAND)\nmanual-spindle-decrease / increase\n (SPINDLE_COMMAND -> release SPINDLE_CONSTANT)\nmanual-spindle-brake (SPINDLE_BRAKE)\nmanual-coolant-mist / flood (COOLANT_COMMAND)",
|
||||||
fontname="Noto Sans Mono CJK SC",
|
fontname="Noto Sans Mono CJK SC",
|
||||||
fillcolor="#e9f5e8",
|
fillcolor="#e9f5e8",
|
||||||
color="#43844b"
|
color="#43844b"
|
||||||
];
|
];
|
||||||
|
|
||||||
program_view_controls [
|
program_view_controls [
|
||||||
label="MDI / 视图 / DRO / 编辑\n\nmdi-command / mdi-submit (MDI_EXECUTE)\nmdi-history-select (本地选择,不自动执行)\ntab-manual / tab-mdi (SET_MODE)\ntab-diagnostics / tab-preview / tab-dro (本地投影)\ntoolbar-zoom-in / zoom-out (本地相机)\ntoolbar-view-z / z2 / x / y / y2 / p (本地视图)\ntoolbar-rotate (本地交互模式)\ntoolbar-clear-live-plot (VISUAL_CLEAR)\npreview-panLeft / panRight / panUp / panDown / reset\ndro-coordinate / dro-display / dro-units\nprogram-editor (待提交文本;toolbar-reload 才生效)",
|
label="MDI / 视图 / DRO / 编辑\n\nmdi-command / mdi-submit (MDI_EXECUTE)\nmdi-history-select (单击选择;双击 MDI_EXECUTE)\ntab-manual / tab-mdi (SET_MODE)\ntab-diagnostics / tab-preview / tab-dro (本地投影)\ntoolbar-zoom-in / zoom-out (本地相机)\ntoolbar-view-z / z2 / x / y / y2 / p (本地视图)\ntoolbar-rotate (本地交互模式)\ntoolbar-clear-live-plot (VISUAL_CLEAR)\npreview-panLeft / panRight / panUp / panDown / reset\ndro-coordinate / dro-display / dro-units\nprogram-editor (待提交文本;toolbar-reload 才生效)",
|
||||||
fontname="Noto Sans Mono CJK SC",
|
fontname="Noto Sans Mono CJK SC",
|
||||||
fillcolor="#edf6f8",
|
fillcolor="#edf6f8",
|
||||||
color="#3c8091"
|
color="#3c8091"
|
||||||
@@ -177,17 +177,17 @@ digraph LinuxCncAxisWebControlLogic {
|
|||||||
color="#b08a2e"
|
color="#b08a2e"
|
||||||
];
|
];
|
||||||
load_tx [
|
load_tx [
|
||||||
label="文件替换事务\n1 原生 UI gate:INTERP_IDLE\n2 Web 安全适配:TP/MDI queue=0\n3 save mode -> ensure MANUAL\n4 可选 FILTER -> restore saved mode\n5 first line 0 -> task_plan_synch\n6 program_open / PROGRAM_LOAD\n7 新 AxisCanon + 状态派生 preview init\n8 set_mode_from_tab;Live Plot 不清除",
|
label="文件替换事务\n1 原生 UI gate:INTERP_IDLE\n2 Web 安全适配:TP/MDI queue=0\n3 save mode -> ensure MANUAL\n4 可选 FILTER -> restore saved mode\n5 first line 0 -> task_plan_synch\n6 program_open / PROGRAM_LOAD\n7 新 AxisCanon + 状态派生 preview init\n8 set_mode_from_tab;与原生一致:Live Plot 保留",
|
||||||
fillcolor="#eef2fb",
|
fillcolor="#eef2fb",
|
||||||
color="#526da8"
|
color="#526da8"
|
||||||
];
|
];
|
||||||
manual_tx [
|
manual_tx [
|
||||||
label="手动 / MDI 事务\nmanual_ok = ON 且非运行\nHOME: ensure MANUAL -> home\nJOG: press start -> release stop\nTOUCH_OFF: save mode -> MDI -> G10 L20\n -> wait/sync/reload -> restore mode\nMDI: queue 未满 -> ensure MDI -> c.mdi",
|
label="手动 / MDI 事务\nmanual_ok = ON 且非运行\nHOME: ensure MANUAL -> home\nJOG: 一次 start -> WASM 限位目标 -> SIM_STEP\n -> release / blur / hidden 一次 stop\nTOUCH_OFF: save mode -> MDI -> G10 L20\n -> 保留位置、偏置 -> preview / relative -> restore mode\nMDI: queue 未满 -> ensure MDI -> c.mdi",
|
||||||
fillcolor="#e9f5e8",
|
fillcolor="#e9f5e8",
|
||||||
color="#43844b"
|
color="#43844b"
|
||||||
];
|
];
|
||||||
run_tx [
|
run_tx [
|
||||||
label="程序执行事务\nRun UI gate:ON + INTERP_IDLE\nrun_warn -> ensure AUTO -> AUTO_RUN line 0\nStep UI gate:ON + taskfile\nPause:READING/WAITING;Resume:PAUSED\nStop:ON + 非 IDLE -> abort/wait/plan close\nCompleted/Aborted:taskfile 保留;Run reopen line 0",
|
label="程序执行事务\nRun UI gate:ON + INTERP_IDLE\nrun_warn (原生;Web 警告待补齐) -> ensure AUTO -> AUTO_RUN line 0\nStep UI gate:ON + taskfile\nStep: 首次暂停 / 读下一行 / TP 执行 ID 边界\nPause:READING/WAITING;Resume:PAUSED\nStop:ON + 非 IDLE -> abort/wait/plan close\nCompleted/Aborted:taskfile 保留;Run reopen line 0",
|
||||||
fillcolor="#fcebdc",
|
fillcolor="#fcebdc",
|
||||||
color="#bd6a28"
|
color="#bd6a28"
|
||||||
];
|
];
|
||||||
@@ -211,7 +211,7 @@ digraph LinuxCncAxisWebControlLogic {
|
|||||||
live_running [label="RUNNING\npositionlogger / motion_trace 累积", fillcolor="#e9f5e8", color="#43844b"];
|
live_running [label="RUNNING\npositionlogger / motion_trace 累积", fillcolor="#e9f5e8", color="#43844b"];
|
||||||
live_paused [label="PAUSED\n冻结且保留同一轨迹", fillcolor="#fcebdc", color="#bd6a28"];
|
live_paused [label="PAUSED\n冻结且保留同一轨迹", fillcolor="#fcebdc", color="#bd6a28"];
|
||||||
live_retained [label="STOPPED / COMPLETED\n保留完整加工轨迹", fillcolor="#e9f5e8", color="#43844b"];
|
live_retained [label="STOPPED / COMPLETED\n保留完整加工轨迹", fillcolor="#e9f5e8", color="#43844b"];
|
||||||
live_clear [label="唯一源程序清除边界\nCtrl-K / Clear Live Plot\nVISUAL_CLEAR -> live_path_epoch + 1\nRun · Reload · Open 均不得清除", fillcolor="#f8e7e5", color="#b94f45", penwidth=2.1];
|
live_clear [label="唯一源程序清除边界\nCtrl-K / Clear Live Plot\nVISUAL_CLEAR -> live_path_epoch + 1\n原生及 Web:Open / Reload / Run 均保留", fillcolor="#f8e7e5", color="#b94f45", penwidth=2.1];
|
||||||
|
|
||||||
f_anchor [shape=point, width=0, height=0, label="", style=invis];
|
f_anchor [shape=point, width=0, height=0, label="", style=invis];
|
||||||
{ rank=same; static_preview; f_anchor; live_clear; }
|
{ rank=same; static_preview; f_anchor; live_clear; }
|
||||||
@@ -223,7 +223,7 @@ digraph LinuxCncAxisWebControlLogic {
|
|||||||
live_running -> live_retained [label="Stop / Complete"];
|
live_running -> live_retained [label="Stop / Complete"];
|
||||||
live_clear -> live_empty [label="epoch change"];
|
live_clear -> live_empty [label="epoch change"];
|
||||||
live_retained -> live_running [label="Run / 重跑 / 换程序后继续累积", color="#43844b"];
|
live_retained -> live_running [label="Run / 重跑 / 换程序后继续累积", color="#43844b"];
|
||||||
live_retained -> live_clear [label="仅 Clear Live Plot", color="#b94f45"];
|
live_retained -> live_clear [label="仅 Clear Live Plot / Ctrl-K", color="#b94f45"];
|
||||||
static_preview -> live_running [label="相互独立", style=dashed, dir=both, arrowtail=none, color="#526da8"];
|
static_preview -> live_running [label="相互独立", style=dashed, dir=both, arrowtail=none, color="#526da8"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
# LinuxCNC AXIS Web Control Panel Source Logic
|
# LinuxCNC AXIS Web Control Panel Source Logic
|
||||||
|
|
||||||
Status: source-locked implementation contract
|
Status: source-locked contract; verified paths and open source gaps are recorded separately
|
||||||
Scope: the selected `XYZAC TRT` Web/WASM panel and its LinuxCNC-compatible
|
Scope: the selected `XYZAC TRT` Web/WASM panel and its LinuxCNC-compatible
|
||||||
Worker command path
|
Worker command path
|
||||||
Source baseline: LinuxCNC submodule `239dcf4284762ad7e9c65d390bc5524d855c2f8e`
|
Source baseline: LinuxCNC submodule `239dcf4284762ad7e9c65d390bc5524d855c2f8e`
|
||||||
|
|
||||||
|
执行顺序:**源程序核对 → 完善本流程文件 → Worker/WASM 实现 → Web 控件实现 → 逐控件验收**。
|
||||||
|
本文件覆盖当前 XYZAC TRT Web 面板的全部交互控件,包括按钮、菜单、
|
||||||
|
选项卡、单选框、复选框、滑块、输入框、文件选择和弹窗。下文的稳定控件 ID
|
||||||
|
是实现与验收的连接点;源码中的桌面专属功能和 ABI 缺口必须保留明确记录,
|
||||||
|
不得把“已列入文档”或“按钮已禁用”计为功能完成。
|
||||||
|
|
||||||
Relationship map: [`linuxcnc-axis-web-control-panel-source-logic.png`](linuxcnc-axis-web-control-panel-source-logic.png)
|
Relationship map: [`linuxcnc-axis-web-control-panel-source-logic.png`](linuxcnc-axis-web-control-panel-source-logic.png)
|
||||||
|
|
||||||
Reproducible graph source: [`linuxcnc-axis-web-control-panel-source-logic.dot`](linuxcnc-axis-web-control-panel-source-logic.dot)
|
Reproducible graph source: [`linuxcnc-axis-web-control-panel-source-logic.dot`](linuxcnc-axis-web-control-panel-source-logic.dot)
|
||||||
@@ -22,7 +28,8 @@ browser gesture
|
|||||||
-> React projection
|
-> React projection
|
||||||
```
|
```
|
||||||
|
|
||||||
The React state is never an authority. A rejected Worker command must remain
|
React machine state is never an authority; local presentation and input state
|
||||||
|
do not carry that restriction. A rejected Worker command must remain
|
||||||
rejected in the UI, with the source gate reason visible as the disabled-button
|
rejected in the UI, with the source gate reason visible as the disabled-button
|
||||||
title and in Diagnostics.
|
title and in Diagnostics.
|
||||||
|
|
||||||
@@ -31,11 +38,14 @@ title and in Diagnostics.
|
|||||||
| Mark | Meaning |
|
| Mark | Meaning |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `SOURCE_EXACT` | Same user action, source handler, UI enable formula, command ordering and observable result as AXIS. |
|
| `SOURCE_EXACT` | Same user action, source handler, UI enable formula, command ordering and observable result as AXIS. |
|
||||||
| `SOURCE_ADAPTER` | Browser/WASM transport differs, but the accepted action and observable machine semantics are the same. Any stronger Web safety gate is stated explicitly. |
|
| `SOURCE_ADAPTER` | Browser/WASM transport or presentation differs. The tested machine semantics match the cited source for the explicitly stated domain; omitted domains and stronger Web gates are listed as gaps, not claimed as identical. |
|
||||||
| `SOURCE_BLOCKED` | AXIS has the action, but the selected WASM or browser boundary cannot represent its canonical state. It stays disabled or absent and is never emulated with local state. |
|
| `SOURCE_BLOCKED` | AXIS has the action, but the selected WASM or browser boundary cannot represent its canonical state. It stays disabled or absent and is never emulated with local state. |
|
||||||
| `WEB_EXTENSION` | No AXIS control exists. The control must be visibly and textually separated from source parity and must not alter the meaning or gate of an AXIS action. |
|
| `WEB_EXTENSION` | No corresponding native control or automatic flow exists. Its differences must be explicitly documented and never counted as source parity. Automatic file-load clearing has been withdrawn; current clearing follows the native explicit action. |
|
||||||
|
|
||||||
Only `SOURCE_EXACT` and `SOURCE_ADAPTER` count as implemented AXIS parity.
|
Only `SOURCE_EXACT` and `SOURCE_ADAPTER` count as implemented AXIS parity.
|
||||||
|
These marks describe the intended mapping, not a blanket acceptance result.
|
||||||
|
The explicit differences below remain open until their end-to-end tests pass;
|
||||||
|
a passing source hash or an entry in the inventory does not close a difference.
|
||||||
`SOURCE_BLOCKED` is an honest capability boundary. `WEB_EXTENSION` is never
|
`SOURCE_BLOCKED` is an honest capability boundary. `WEB_EXTENSION` is never
|
||||||
evidence of parity. If this document and the pinned source disagree, the pinned
|
evidence of parity. If this document and the pinned source disagree, the pinned
|
||||||
source wins and this document, its graph and the Web implementation must be
|
source wins and this document, its graph and the Web implementation must be
|
||||||
@@ -78,6 +88,7 @@ fingerprinted so the audit fails on an unreviewed source drift:
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `src/emc/usr_intf/axis/scripts/axis.py` | `328c038a1e8fefd952e53c8e398e3fa98b04602351c44318edd1b044e8f63ffa` | Handlers, mode transitions, keyboard bindings, preview and live-plot ownership |
|
| `src/emc/usr_intf/axis/scripts/axis.py` | `328c038a1e8fefd952e53c8e398e3fa98b04602351c44318edd1b044e8f63ffa` | Handlers, mode transitions, keyboard bindings, preview and live-plot ownership |
|
||||||
| `share/axis/tcl/axis.tcl` | `c9f0b4dc686717b37565f85fb0ab8cc8ded9cfa0028cc0aaaccdbcf808c4c524` | Widgets, menu/toolbar wiring and exact dynamic enable formulas |
|
| `share/axis/tcl/axis.tcl` | `c9f0b4dc686717b37565f85fb0ab8cc8ded9cfa0028cc0aaaccdbcf808c4c524` | Widgets, menu/toolbar wiring and exact dynamic enable formulas |
|
||||||
|
| `lib/python/glnav.py` | `b2d811a21954966655a40f107c2af217cc7d57bae0dc308dc4698f5859ed3f11` | Orthographic/perspective presets, rotation, camera framing |
|
||||||
| `src/emc/task/emctask.cc` | pinned by submodule commit | Machine state edges and observed `ESTOP`/`ESTOP_RESET`/`ON` state |
|
| `src/emc/task/emctask.cc` | pinned by submodule commit | Machine state edges and observed `ESTOP`/`ESTOP_RESET`/`ON` state |
|
||||||
| `src/emc/task/emctaskmain.cc` | pinned by submodule commit | Task command acceptance, homing enforcement, run/pause/resume/abort and task-plan reopen/close |
|
| `src/emc/task/emctaskmain.cc` | pinned by submodule commit | Task command acceptance, homing enforcement, run/pause/resume/abort and task-plan reopen/close |
|
||||||
| `configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini` plus `remap_subs/{428,429,430}remap.ngc` | pinned by submodule commit | Selected axes, limits, speeds, startup file and switchkins remaps |
|
| `configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini` plus `remap_subs/{428,429,430}remap.ngc` | pinned by submodule commit | Selected axes, limits, speeds, startup file and switchkins remaps |
|
||||||
@@ -144,7 +155,7 @@ families because their cardinality comes from the selected INI.
|
|||||||
| Optional Stop and Block Delete | `toggle_optional_stop`, `toggle_block_delete` | Visible `SOURCE_BLOCKED`; block delete's source wait, MANUAL switch and preview reload must not be approximated by a local checkbox |
|
| Optional Stop and Block Delete | `toggle_optional_stop`, `toggle_block_delete` | Visible `SOURCE_BLOCKED`; block delete's source wait, MANUAL switch and preview reload must not be approximated by a local checkbox |
|
||||||
| MDI history clear/copy/paste/select/double-click/Up/Down | `clear_mdi_history`, `mdi_history_hist2clip`, `mdi_history_clip2hist`, `mdi_history_butt_1`, `mdi_history_double_butt_1`, `mdi_up_cmd`, `mdi_down_cmd` | Select/Up/Down/local history: `SOURCE_ADAPTER`; clipboard/file persistence variants are browser/host boundaries |
|
| MDI history clear/copy/paste/select/double-click/Up/Down | `clear_mdi_history`, `mdi_history_hist2clip`, `mdi_history_clip2hist`, `mdi_history_butt_1`, `mdi_history_double_butt_1`, `mdi_up_cmd`, `mdi_down_cmd` | Select/Up/Down/local history: `SOURCE_ADAPTER`; clipboard/file persistence variants are browser/host boundaries |
|
||||||
| Calibration, HAL Configuration, HAL Meter, HAL Scope, LinuxCNC Status, Debug Level | external `exec` commands in Machine menu | Desktop-process boundary; not rendered and never mapped to Diagnostics as though it were the same action |
|
| Calibration, HAL Configuration, HAL Meter, HAL Scope, LinuxCNC Status, Debug Level | external `exec` commands in Machine menu | Desktop-process boundary; not rendered and never mapped to Diagnostics as though it were the same action |
|
||||||
| Home all/joint and Unhome all/joint | `home_all_joints`, `home_joint_number`, `unhome_all_joints`, `unhome_joint_number` | Home all/selected axis: `SOURCE_ADAPTER`; Unhome and joint-mode selector: `SOURCE_BLOCKED` |
|
| Home all/joint and Unhome all/joint | `home_all_joints`, `home_joint_number`, `unhome_all_joints`, `unhome_joint_number` | Home all: `SOURCE_ADAPTER`; world-axis Home rejects for KINEMATICS_BOTH; individual joint selection and Unhome remain `SOURCE_BLOCKED` |
|
||||||
| Zero P1..P9 and G92 coordinate systems | `clear_offset` | `SOURCE_BLOCKED`; Touch Off is not an alias for clearing a coordinate system |
|
| Zero P1..P9 and G92 coordinate systems | `clear_offset` | `SOURCE_BLOCKED`; Touch Off is not an alias for clearing a coordinate system |
|
||||||
| Tool touch-off target workpiece/fixture | `toggle_tto_g11` selecting `G10 L10`/`G10 L11` | `SOURCE_BLOCKED` with Tool Touch Off; no local target state is exposed |
|
| Tool touch-off target workpiece/fixture | `toggle_tto_g11` selecting `G10 L10`/`G10 L11` | `SOURCE_BLOCKED` with Tool Touch Off; no local target state is exposed |
|
||||||
| Top/Z2/Side/Front/Y2/Perspective, zoom and rotate | `set_view_*`, `zoomin`, `zoomout`, rotate Tcl command | `SOURCE_ADAPTER`; Y2 is toolbar-only in the native layout |
|
| Top/Z2/Side/Front/Y2/Perspective, zoom and rotate | `set_view_*`, `zoomin`, `zoomout`, rotate Tcl command | `SOURCE_ADAPTER`; Y2 is toolbar-only in the native layout |
|
||||||
@@ -154,7 +165,7 @@ families because their cardinality comes from the selected INI.
|
|||||||
| Clear Live Plot | `clear_live_plot` -> `LivePlotter.clear` -> `positionlogger.clear` | `SOURCE_ADAPTER`; this is the only native live-trace deletion action |
|
| Clear Live Plot | `clear_live_plot` -> `LivePlotter.clear` -> `positionlogger.clear` | `SOURCE_ADAPTER`; this is the only native live-trace deletion action |
|
||||||
| Axis/joint selection and jog increment | `ja_rbutton`, `jogincrement`, `activate_ja_widget` | XYZAC axis and increment selection: `SOURCE_ADAPTER`; native joint selection is `SOURCE_BLOCKED` |
|
| Axis/joint selection and jog increment | `ja_rbutton`, `jogincrement`, `activate_ja_widget` | XYZAC axis and increment selection: `SOURCE_ADAPTER`; native joint selection is `SOURCE_BLOCKED` |
|
||||||
| Jog negative/positive and key jog | `jog_minus`, `jog_plus`, `jog_on`, `jog_off` | `SOURCE_ADAPTER`; press/release semantics and Manual-tab guard apply |
|
| Jog negative/positive and key jog | `jog_minus`, `jog_plus`, `jog_on`, `jog_off` | `SOURCE_ADAPTER`; press/release semantics and Manual-tab guard apply |
|
||||||
| Home Axis, Touch Off, Tool Touch Off, Override Limits | corresponding `zerohome`/`override` widgets | Home/Touch Off: `SOURCE_ADAPTER`; Tool Touch Off/Override Limits: visible `SOURCE_BLOCKED` |
|
| Home Axis, Touch Off, Tool Touch Off, Override Limits | corresponding `zerohome`/`override` widgets | World-axis Home reproduces source rejection for KINEMATICS_BOTH; Touch Off: selected-coordinate adapter; Tool Touch Off/Override Limits: visible `SOURCE_BLOCKED` |
|
||||||
| Spindle CCW/Stop/CW, slower/faster, brake | `spindle`, `spindle_decrease`, `spindle_increase`, `spindle_constant`, `brake` | `SOURCE_ADAPTER`; button-state formulas and press/release ordering apply |
|
| Spindle CCW/Stop/CW, slower/faster, brake | `spindle`, `spindle_decrease`, `spindle_increase`, `spindle_constant`, `brake` | `SOURCE_ADAPTER`; button-state formulas and press/release ordering apply |
|
||||||
| Mist and Flood | `mist`, `flood`, `mist_toggle`, `flood_toggle` | `SOURCE_ADAPTER`; independent canonical IO bits |
|
| Mist and Flood | `mist`, `flood`, `mist_toggle`, `flood_toggle` | `SOURCE_ADAPTER`; independent canonical IO bits |
|
||||||
| MDI entry, Go, Enter, history activation | `send_mdi`, `send_mdi_command` | `SOURCE_ADAPTER`; source trim, mode and adjacent-history de-duplication apply |
|
| MDI entry, Go, Enter, history activation | `send_mdi`, `send_mdi_command` | `SOURCE_ADAPTER`; source trim, mode and adjacent-history de-duplication apply |
|
||||||
@@ -186,6 +197,69 @@ command are blocked instead of being silently mapped to another operation.
|
|||||||
|
|
||||||
## State Gates
|
## State Gates
|
||||||
|
|
||||||
|
### 源码复核修正(2026-09-08)
|
||||||
|
|
||||||
|
本节更正旧文档中把实现假设写成源码事实的内容;以下定义优先于历史报告。
|
||||||
|
|
||||||
|
1. **三个门控层分别记录。** `ui_gates` 只按 `axis.tcl::update_state`
|
||||||
|
投影按钮公式;`gates` 为最终命令接收条件;React 只额外处理浏览器加载互斥。
|
||||||
|
Run 在 ON+IDLE 时启用,即使尚未回零,点击后仍由 task 拒绝;Step 只要求
|
||||||
|
ON+taskfile。MDI 输入/Go 的 UI 只要求 ON+队列未满,`send_mdi` 的
|
||||||
|
`manual_ok` 不通过时保留输入且不提交。按钮可用不是命令必然成功。
|
||||||
|
2. **OFF 是显示字符串。** `axis.tcl` 将 `STATE_ESTOP_RESET` 显示成 OFF。
|
||||||
|
source task state 必须为 ESTOP、ESTOP_RESET、ON,不得由 `last_error=-904`
|
||||||
|
猜测状态;清除错误或调整覆盖率不能改变机器的 source state。
|
||||||
|
同样,task 的 FAULT 标签不能覆盖 IO 的 ready/estop 位变成假 ESTOP。
|
||||||
|
3. **WAITING 必须可暂停。** `emctaskmain.cc` 在解释器读完、TP 尚未排空时
|
||||||
|
保持 WAITING;Pause 保存原解释器状态,Resume 恢复该状态,不能把已读完
|
||||||
|
的程序重新设为 READING。MDI 在其运动执行期间同样是活动任务。
|
||||||
|
4. **Step 的三个分支。** `emctaskmain.cc:1059/1149/1224/1299`:IDLE 首次
|
||||||
|
Step 从第 0 行启动并暂停;PAUSED 且有运动队列时调用 `emcTrajStep`,
|
||||||
|
按执行 ID 推进并再次暂停;无排队运动时释放下一条 task 指令。
|
||||||
|
`motion/control.c:2186` 在执行 ID 改变后暂停 TP,不能“解析一行后永久
|
||||||
|
冻结”,也不能一次 Step 把全部已排队运动执行完。Web 单步的源码行解析
|
||||||
|
与原生 NML 指令细分仍是需单独验证的适配边界,不能用行数相同替代运动断言。
|
||||||
|
5. **回零类型不能混同。** `switchkins.c::kinematicsType()` 始终返回
|
||||||
|
`KINEMATICS_BOTH`;M429 的 `active_type=0` 不代表 KINEMATICS_IDENTITY。
|
||||||
|
`home_joint` 对世界坐标字母会拒绝,而数字 joint 允许回零。
|
||||||
|
当前面板的轴字母按钮不能冒充 joint 模式;Home Axis 保留原 UI 公式,
|
||||||
|
点击返回 `home_joint_requires_joint_mode`。Home All 仍可用。
|
||||||
|
6. **自动恢复标签模式。** `axis.tcl::update_state` 在 ON+IDLE 的状态边沿,
|
||||||
|
当前 task_mode 为 AUTO 时调用 `set_mode_from_tab`;显式 Open/Reload 完成后
|
||||||
|
也调用它。Web 必须通过排队的 `SET_MODE` 恢复所选 Manual/MDI 页,不能只
|
||||||
|
改标签样式;命令发出后不可让延迟的 effect 覆盖后续 Run。
|
||||||
|
7. **Escape 是 task_stop。** 即使 Stop 按钮因 IDLE 禁用,Escape 仍能调用
|
||||||
|
`c.abort()`,包括中止手动连续/增量点动和 MDI 运动。菜单/弹窗 Escape
|
||||||
|
先结束当前 UI,文本编辑器不得屏蔽机器急停 F1。
|
||||||
|
8. **主轴 direction 与输出 RPM 独立。** `motion.h::spindle_status_t.direction`
|
||||||
|
是方向命令,覆盖率设为 0 不得把 CCW 投影成 CW;恢复覆盖率时必须恢复
|
||||||
|
之前的未缩放速度。按钮按压态读取 WASM direction,调速量作用于未缩放
|
||||||
|
的指令速度,再由覆盖率得到输出转速。
|
||||||
|
9. **视图按钮必须改变对应内容。** `.toolbar.rotate` 只切换左键 Drag/Rotate,
|
||||||
|
不启动自动旋转;`show_machine_speed` 显示规范速度,`show_extents` 显示
|
||||||
|
canonical 路径范围;`show_machine_limits` 使用配置到 WASM 的真实 XYZ
|
||||||
|
软限位;`show_offsets` 使用 G5x 平移偏置。所有显示均不改机器/程序状态。
|
||||||
|
10. **预置投影。** `glnav.py:376-450` 的 Z/Z2/X/Y/Y2 为正交,P 为透视。
|
||||||
|
Z2 是顶视图在屏幕平面旋转 -90 度,不是斜俯视。按源码矩阵,屏幕
|
||||||
|
right/up 轴依次是 Z=`X/Y`、Z2=`Y/-X`、X=`Y/Z`、Y=`X/Z`、
|
||||||
|
Y2=`Z/X`。浏览器坐标变换必须保持此映射。相机取景距离和尺寸缩放是
|
||||||
|
显示适配,坐标标记/刀具图形不得在短程序上放大到遮挡路径。
|
||||||
|
|
||||||
|
### 尚未取得完整等价证据
|
||||||
|
|
||||||
|
| 功能 | 原生行为 / 当前边界 | 禁止的错误结论 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Run 警告 | `run_warn` 对 XYZ 预览边界给出确认;当前 WASM 没有完整含刀偏 extents 警告事务 | 不得声称已复制 run_warn 的确认/取消交互 |
|
||||||
|
| Joint/World、Home Axis | KINEMATICS_BOTH 需要 joint 数字选择与 FREE/TELEOP 状态;当前世界轴面板只支持 Home All | 不得用 switchkins 编号判断是否允许坐标轴回零 |
|
||||||
|
| 速度滑块 | 原生线性/角速度独立,显示物理单位;当前 Jog/Max 的百分比与 6% 下限属于已公开的 Web 适配 | 不得把单一比例视为独立 angular jog 控制 |
|
||||||
|
| 预览图层 | limits 现从 WASM XYZ 软限位 getter 读取;offsets 使用已实现的 G5x 平移;extents 使用 canonical 采样包围框 | G92/旋转/刀具偏置范围与原生文字标注仍未全量覆盖,不能称完整原生图层复刻 |
|
||||||
|
| commanded/actual | 仿真当前两者相同,需要分别存在的 canonical 字段才可声明跟随误差显示 | 不得从 React 标签推导真实反馈差异 |
|
||||||
|
| TOOL_TOUCH_OFF、rapidrate、optional stop、block delete、unhome、桌面工具 | 原文 ledger 中的具体 ABI/host 缺口继续有效 | SOURCE_BLOCKED 只表示已说明边界,不表示功能完成 |
|
||||||
|
|
||||||
|
以上差异不能通过修改流程文件降低为“源码本来如此”;必须保留来源、真实
|
||||||
|
处理方式及未闭合原因。本次改善以已经存在的控件事务为范围,完整 LinuxCNC
|
||||||
|
GUI/伺服移植仍不能由此宣称完成。
|
||||||
|
|
||||||
AXIS does not have one combined `RUNNING/STOPPED/READY` enum. Source parity is
|
AXIS does not have one combined `RUNNING/STOPPED/READY` enum. Source parity is
|
||||||
derived from independent LinuxCNC status dimensions; Web-friendly labels may be
|
derived from independent LinuxCNC status dimensions; Web-friendly labels may be
|
||||||
displayed, but they may not replace these dimensions as gate authority.
|
displayed, but they may not replace these dimensions as gate authority.
|
||||||
@@ -249,6 +323,13 @@ The Web may disable an action while a browser-only asynchronous load is in
|
|||||||
flight. Any other gate stronger than this table must be identified as a Web
|
flight. Any other gate stronger than this table must be identified as a Web
|
||||||
safety adaptation, not attributed to AXIS.
|
safety adaptation, not attributed to AXIS.
|
||||||
|
|
||||||
|
`panel_snapshot.ui_gates` 是面向控件的源公式投影,`gates` 为命令接收门控。
|
||||||
|
例如原生 abort 命令可随时接收,但 Stop 按钮必须 ON+非 IDLE;冷却命令可在
|
||||||
|
执行过程中接收,但 Manual 页冷却复选框必须 ON+IDLE。不能直接把较宽的
|
||||||
|
命令接收条件当作控件启用条件。Spindle 与 Touch Off 的源 handler 会自行
|
||||||
|
切换模式,不能要求用户预先处于 MANUAL。F9/F10、B/b 用 `source_keyboard`
|
||||||
|
传输标记保留不切换模式的源码分支;该标记不绕过 Worker/WASM 门控。
|
||||||
|
|
||||||
## Control Matrix
|
## Control Matrix
|
||||||
|
|
||||||
| Panel control | React command | Source flow and required result |
|
| Panel control | React command | Source flow and required result |
|
||||||
@@ -271,7 +352,7 @@ safety adaptation, not attributed to AXIS.
|
|||||||
| Manual / MDI tabs | `SET_MODE` | `SOURCE_ADAPTER`: native tab callbacks are `ensure_manual` and `ensure_mdi`, including `set_motion_teleop` transitions. `axis.tcl::update_state` calls `set_mode_from_tab` only when returning to ON+IDLE from a non-idle/task-state transition while task mode is AUTO. No unrelated React effect may force a mode. |
|
| Manual / MDI tabs | `SET_MODE` | `SOURCE_ADAPTER`: native tab callbacks are `ensure_manual` and `ensure_mdi`, including `set_motion_teleop` transitions. `axis.tcl::update_state` calls `set_mode_from_tab` only when returning to ON+IDLE from a non-idle/task-state transition while task mode is AUTO. No unrelated React effect may force a mode. |
|
||||||
| Joint/World (teleop) mode | not exposed | AXIS View menu calls `set_teleop_mode`/`c.teleop_enable`; the selected WASM motion ABI has no canonical teleop setter, so Web does not pretend its coordinate mode is a joint/world toggle. |
|
| Joint/World (teleop) mode | not exposed | AXIS View menu calls `set_teleop_mode`/`c.teleop_enable`; the selected WASM motion ABI has no canonical teleop setter, so Web does not pretend its coordinate mode is a joint/world toggle. |
|
||||||
| Jog `-` / `+` | `JOG_START`, `SIM_STEP`, `JOG_STOP` | `SOURCE_ADAPTER`: source requires `manual_ok` and the visible Manual tab. Incremental sends one `JOG_INCREMENT`; Continuous sends one `JOG_CONTINUOUS` and motion continues until the deferred release issues `JOG_STOP`. Web servo sampling is transport/runtime work, not repeated user jog commands. |
|
| Jog `-` / `+` | `JOG_START`, `SIM_STEP`, `JOG_STOP` | `SOURCE_ADAPTER`: source requires `manual_ok` and the visible Manual tab. Incremental sends one `JOG_INCREMENT`; Continuous sends one `JOG_CONTINUOUS` and motion continues until the deferred release issues `JOG_STOP`. Web servo sampling is transport/runtime work, not repeated user jog commands. |
|
||||||
| Home Axis | `HOME {axis}` | `manual_ok`, `ensure_mode(MANUAL)`, then source joint-home command. Individual axis home is rejected outside identity kinematics, matching `home_joint`. |
|
| Home Axis | `HOME {axis}` | `manual_ok`, then `home_joint` checks axis versus joint selection. The selected switchkins module is always KINEMATICS_BOTH, so a world-axis letter is rejected with `home_joint_requires_joint_mode`; M429 does not change this type. |
|
||||||
| Home All / Re-home All | `HOME {all:true}` | `SOURCE_ADAPTER`: `manual_ok`, ensure MANUAL, if `all_homed()` ask the native re-home confirmation, then `set_motion_teleop(0)`, `c.home(-1)`, wait. Native text remains Home All; the Web `RE-HOME ALL` label is presentation-only and cannot trigger a hidden home during load. |
|
| Home All / Re-home All | `HOME {all:true}` | `SOURCE_ADAPTER`: `manual_ok`, ensure MANUAL, if `all_homed()` ask the native re-home confirmation, then `set_motion_teleop(0)`, `c.home(-1)`, wait. Native text remains Home All; the Web `RE-HOME ALL` label is presentation-only and cannot trigger a hidden home during load. |
|
||||||
| Touch Off | `TOUCH_OFF {axis,value,system,units}` | `SOURCE_ADAPTER`: source UI condition is the Touch Off formula above; task-layer MDI additionally enforces homing unless `NO_FORCE_HOMING`. Prompt for `P0 Current` through `P9 G59.3`, save mode, ensure MDI, scale linear units, issue `G10 L20 <P-token> <axis>[value*scale]`, wait, redraw/reload, restore saved mode and TELEOP. |
|
| Touch Off | `TOUCH_OFF {axis,value,system,units}` | `SOURCE_ADAPTER`: source UI condition is the Touch Off formula above; task-layer MDI additionally enforces homing unless `NO_FORCE_HOMING`. Prompt for `P0 Current` through `P9 G59.3`, save mode, ensure MDI, scale linear units, issue `G10 L20 <P-token> <axis>[value*scale]`, wait, redraw/reload, restore saved mode and TELEOP. |
|
||||||
| Tool Touch Off | `TOOL_TOUCH_OFF {axis,value,system,units}` | LinuxCNC source requires `G10 L10/L11` plus `G43`, but the selected WASM interpreter does not expose canonical tool-offset storage. The control is therefore disabled with `blocked_by_abi_tool_offsets`; it never sends a fake fixed `G43`. |
|
| Tool Touch Off | `TOOL_TOUCH_OFF {axis,value,system,units}` | LinuxCNC source requires `G10 L10/L11` plus `G43`, but the selected WASM interpreter does not expose canonical tool-offset storage. The control is therefore disabled with `blocked_by_abi_tool_offsets`; it never sends a fake fixed `G43`. |
|
||||||
@@ -305,9 +386,9 @@ history rows intentionally share the family ID `mdi-history-select`.
|
|||||||
| `menu-help-about`, `about-close` | `.menu.help` About entry and `.about.ok` | Local modal open/close | About visibility changes only |
|
| `menu-help-about`, `about-close` | `.menu.help` About entry and `.about.ok` | Local modal open/close | About visibility changes only |
|
||||||
| `toolbar-estop` | `.toolbar.machine_estop` -> `estop_clicked` | `ESTOP {active}`; accepted from every task state | Toggle source E-stop edge; active edge aborts interpreter/TP |
|
| `toolbar-estop` | `.toolbar.machine_estop` -> `estop_clicked` | `ESTOP {active}`; accepted from every task state | Toggle source E-stop edge; active edge aborts interpreter/TP |
|
||||||
| `toolbar-power` | `.toolbar.machine_power` -> `onoff_clicked` | `POWER {active}`; source widget enabled whenever status is not ESTOP | Send ON only from ESTOP_RESET, otherwise OFF; stable post-off status normally projects ESTOP_RESET |
|
| `toolbar-power` | `.toolbar.machine_power` -> `onoff_clicked` | `POWER {active}`; source widget enabled whenever status is not ESTOP | Send ON only from ESTOP_RESET, otherwise OFF; stable post-off status normally projects ESTOP_RESET |
|
||||||
| `toolbar-open`, `program-file-input` | `.toolbar.file_open` -> `open_file`/`open_file_guts` | File picker then `PROGRAM_LOAD`; interpreter must be IDLE | One replacement transaction; input itself is hidden and only the toolbar button opens it |
|
| `toolbar-open`, `program-file-input` | `.toolbar.file_open` -> `open_file`/`open_file_guts` | File picker then `PROGRAM_LOAD`; interpreter must be IDLE | Replaces static program preview; retains Live Plot exactly as native Open |
|
||||||
| `toolbar-load-case` | `WEB_EXTENSION`; uses INI `DISPLAY`, `HAL`, `KINS` and `OPEN_FILE` inputs | `LOAD_WORKSPACE` then Open adapter; IDLE plus browser async/queue exclusion | Reloads the selected exact case; never counts as an AXIS control or adds Power/Home/Run aliases |
|
| `toolbar-load-case` | `WEB_EXTENSION`; uses INI `DISPLAY`, `HAL`, `KINS` and `OPEN_FILE` inputs | `LOAD_WORKSPACE` then Open adapter; IDLE plus browser async/queue exclusion | Reloads the selected exact case; never counts as an AXIS control or adds Power/Home/Run aliases |
|
||||||
| `toolbar-reload` | `.toolbar.reload` -> `reload_file` | `PROGRAM_LOAD` of current editor source/path; IDLE taskfile required | Replaces preview/interpreter at line 0; this is the only visible commit action for edited source |
|
| `toolbar-reload` | `.toolbar.reload` -> `reload_file` | `PROGRAM_LOAD` of current editor source/path; IDLE taskfile required | Replaces preview/interpreter at line 0; retains Live Plot whether or not source text changed |
|
||||||
| `toolbar-run` | `.toolbar.program_run` -> `task_run` | `PROGRAM_RUN`; native widget ON+IDLE; task layer additionally requires a taskfile and homing unless `NO_FORCE_HOMING=1` | AUTO/READING from line 0; completed/aborted taskfile reopens directly |
|
| `toolbar-run` | `.toolbar.program_run` -> `task_run` | `PROGRAM_RUN`; native widget ON+IDLE; task layer additionally requires a taskfile and homing unless `NO_FORCE_HOMING=1` | AUTO/READING from line 0; completed/aborted taskfile reopens directly |
|
||||||
| `toolbar-step` | `.toolbar.program_step` -> `task_step` | `PROGRAM_STEP`; native widget ON+taskfile, then handler/task gates | AUTO_STEP at the next source boundary; do not add an IDLE-only UI gate |
|
| `toolbar-step` | `.toolbar.program_step` -> `task_step` | `PROGRAM_STEP`; native widget ON+taskfile, then handler/task gates | AUTO_STEP at the next source boundary; do not add an IDLE-only UI gate |
|
||||||
| `toolbar-pause-resume` | `.toolbar.program_pause` -> `task_pauseresume` | Native widget ON+non-IDLE; handler distinguishes `paused` and accepts AUTO/MDI | One source toggle whose icon/label and command follow canonical pause state and resume-inhibit |
|
| `toolbar-pause-resume` | `.toolbar.program_pause` -> `task_pauseresume` | Native widget ON+non-IDLE; handler distinguishes `paused` and accepts AUTO/MDI | One source toggle whose icon/label and command follow canonical pause state and resume-inhibit |
|
||||||
@@ -318,16 +399,16 @@ history rows intentionally share the family ID `mdi-history-select`.
|
|||||||
| `toolbar-rotate` | `.toolbar.rotate` | Local drag/rotate projection | Toggles pressed state and viewer interaction only |
|
| `toolbar-rotate` | `.toolbar.rotate` | Local drag/rotate projection | Toggles pressed state and viewer interaction only |
|
||||||
| `toolbar-clear-live-plot` | `clear_live_plot` -> `positionlogger.clear()` | Worker `VISUAL_CLEAR`; always visual-only | Advances `live_path_epoch`, clears Live Plot only and retains static preview |
|
| `toolbar-clear-live-plot` | `clear_live_plot` -> `positionlogger.clear()` | Worker `VISUAL_CLEAR`; always visual-only | Advances `live_path_epoch`, clears Live Plot only and retains static preview |
|
||||||
| `workspace-case-select` | Web selection of the INI/configuration input | Local selection; selected catalog is restricted to supported XYZAC case | Does not load or change machine state until `toolbar-load-case` |
|
| `workspace-case-select` | Web selection of the INI/configuration input | Local selection; selected catalog is restricted to supported XYZAC case | Does not load or change machine state until `toolbar-load-case` |
|
||||||
| `workspace-load-3d-chips` | Web packaged-file equivalent of `open_file_guts` | Fetch published `3D_Chips.ngc`, then `PROGRAM_LOAD`; IDLE only | Same canonical file replacement path as Open; no automatic Run/Home |
|
| `workspace-load-3d-chips` | Web packaged-file equivalent of `open_file_guts` | Fetch published `3D_Chips.ngc`, then `PROGRAM_LOAD`; IDLE only | Same replacement path as Open; retains Live Plot, no automatic Run/Home |
|
||||||
| `manual-home-all` | Machine Homing menu -> `home_all_joints` | `HOME {all:true}`; `manual_ok`, then MANUAL | Repeat asks source confirmation; Web Re-home label is non-authoritative presentation only |
|
| `manual-home-all` | Machine Homing menu -> `home_all_joints` | `HOME {all:true}`; `manual_ok`, then MANUAL | Repeat asks source confirmation; Web Re-home label is non-authoritative presentation only |
|
||||||
| `switchkins-0`, `switchkins-1`, `switchkins-2` | `M429`, `M428`, `M430` remaps and `motion.switchkins-type` | `KINEMATICS_SELECT`; ON, homed, IDLE, empty TP | Identity/XYZAC/User K canonical type, or stable gate reason |
|
| `switchkins-0`, `switchkins-1`, `switchkins-2` | `M429`, `M428`, `M430` remaps and `motion.switchkins-type` | `KINEMATICS_SELECT`; ON, homed, IDLE, empty TP | Identity/XYZAC/User K canonical type, or stable gate reason |
|
||||||
| `tab-manual`, `tab-mdi` | AXIS notebook F3/F5 `ensure_manual`/`ensure_mdi` | `SET_MODE` adapter; source `manual_ok` plus joint/teleop transitions apply | Tab selection plus acknowledged source-equivalent MANUAL/MDI and motion-mode transition |
|
| `tab-manual`, `tab-mdi` | AXIS notebook F3/F5 `ensure_manual`/`ensure_mdi` | `SET_MODE` adapter under `manual_ok` | Acknowledged MANUAL/MDI task mode. Explicit FREE/TELEOP/joint switching is an unimplemented ABI boundary; the selected panel uses its homed world-axis motion adapter. |
|
||||||
| `tab-diagnostics` | Web diagnostics boundary | Local tab only | Reads Worker state/gates; sends no command |
|
| `tab-diagnostics` | Web diagnostics boundary | Local tab only | Reads Worker state/gates; sends no command |
|
||||||
| `tab-preview`, `tab-dro` | AXIS preview/DRO presentation | Local tab only | Changes visible projection; machine state unchanged |
|
| `tab-preview`, `tab-dro` | AXIS preview/DRO presentation | Local tab only | Changes visible projection; machine state unchanged |
|
||||||
| `manual-axis-x`, `manual-axis-y`, `manual-axis-z`, `manual-axis-a`, `manual-axis-c` | AXIS axis radio group / `ja_rbutton` | Local axis selection | Supplies the selected axis to Jog/Home/Touch Off; sends no motion by itself |
|
| `manual-axis-x`, `manual-axis-y`, `manual-axis-z`, `manual-axis-a`, `manual-axis-c` | AXIS axis radio group / `ja_rbutton` | Local axis selection; source manualgroup ON+IDLE | Supplies the selected axis to Jog/Home/Touch Off; sends no motion by itself |
|
||||||
| `manual-jog-increment` | `jogincrement` combobox | Local choice: Continuous or configured increment | Determines `JOG_CONTINUOUS` versus `JOG_INCREMENT` payload only |
|
| `manual-jog-increment` | `jogincrement` combobox | Local choice: Continuous or configured increment | Determines `JOG_CONTINUOUS` versus `JOG_INCREMENT` payload only |
|
||||||
| `manual-jog-negative`, `manual-jog-positive` | `jogminus`, `jogplus` -> `jog_on` | `JOG_START`; visible Manual tab and `manual_ok`; source chooses joint/teleop mode from kinematics+homing | Incremental submits once; Continuous remains active until one release/cancel `JOG_STOP` |
|
| `manual-jog-negative`, `manual-jog-positive` | `jogminus`, `jogplus` -> `jog_on` | `JOG_START`; visible Manual tab and `manual_ok`; source chooses joint/teleop mode from kinematics+homing | Incremental submits once; Continuous remains active until one release/cancel `JOG_STOP` |
|
||||||
| `manual-home-axis` | `home_joint` | `HOME {axis}`; `manual_ok`, MANUAL, identity kinematics for coordinate-axis home | Canonical joint home; repeated home asks confirmation |
|
| `manual-home-axis` | `home_joint` | UI ON+IDLE; `HOME {axis}` checks KINEMATICS_BOTH | World-axis letter rejected before confirmation/mode mutation; individual numeric joint input remains unavailable |
|
||||||
| `manual-touch-off` | `touch_off_system` | `TOUCH_OFF`; exact Touch Off UI formula, `manual_ok`, valid axis/value/system, plus task-layer homing policy | MDI `G10 L20`, wait, reload and restore saved task/motion mode |
|
| `manual-touch-off` | `touch_off_system` | `TOUCH_OFF`; exact Touch Off UI formula, `manual_ok`, valid axis/value/system, plus task-layer homing policy | MDI `G10 L20`, wait, reload and restore saved task/motion mode |
|
||||||
| `manual-tool-touch-off` | `touch_off_tool` | Permanently disabled for selected ABI | `blocked_by_abi_tool_offsets`; never approximated with `G92`/fixed `G43` |
|
| `manual-tool-touch-off` | `touch_off_tool` | Permanently disabled for selected ABI | `blocked_by_abi_tool_offsets`; never approximated with `G92`/fixed `G43` |
|
||||||
| `manual-override-limits` | `toggle_override_limits` | Permanently disabled for selected ABI | No state mutation; explanatory ABI title |
|
| `manual-override-limits` | `toggle_override_limits` | Permanently disabled for selected ABI | No state mutation; explanatory ABI title |
|
||||||
@@ -335,8 +416,8 @@ history rows intentionally share the family ID `mdi-history-select`.
|
|||||||
| `manual-spindle-decrease`, `manual-spindle-increase` | `spindle_decrease/increase`; ButtonRelease -> `spindle_constant` | Enabled only while spindle direction is nonzero, matching `axis.tcl update_state` | Press sends speed edge without crossing through zero into the opposite direction; release/cancel sends `SPINDLE_CONSTANT` |
|
| `manual-spindle-decrease`, `manual-spindle-increase` | `spindle_decrease/increase`; ButtonRelease -> `spindle_constant` | Enabled only while spindle direction is nonzero, matching `axis.tcl update_state` | Press sends speed edge without crossing through zero into the opposite direction; release/cancel sends `SPINDLE_CONSTANT` |
|
||||||
| `manual-spindle-brake` | `brake` | `SPINDLE_BRAKE`; same manual source gate | Canonical brake state controls checkbox |
|
| `manual-spindle-brake` | `brake` | `SPINDLE_BRAKE`; same manual source gate | Canonical brake state controls checkbox |
|
||||||
| `manual-coolant-mist`, `manual-coolant-flood` | manual-group `mist`/`flood` controls | `COOLANT_COMMAND`; checkboxes use the source ON+IDLE manual-group gate | Independent canonical coolant bits; F7/F8 inverse the polled source value |
|
| `manual-coolant-mist`, `manual-coolant-flood` | manual-group `mist`/`flood` controls | `COOLANT_COMMAND`; checkboxes use the source ON+IDLE manual-group gate | Independent canonical coolant bits; F7/F8 inverse the polled source value |
|
||||||
| `mdi-command`, `mdi-submit` | MDI entry and `send_mdi` | Local text; submit/Enter sends `MDI_EXECUTE` under `manual_ok`/queue gate | Trimmed nonempty block, MDI mode, canonical interpreter result |
|
| `mdi-command`, `mdi-submit` | MDI entry and `send_mdi` | UI ON+queue capacity; handler separately requires `manual_ok` | Disabled handler retains input; accepted trimmed block enters MDI and receives canonical interpreter result |
|
||||||
| `mdi-history-select` | AXIS MDI history row selection | Local history selection | Copies selected prior command into entry; does not execute until Submit/Enter |
|
| `mdi-history-select` | `mdi_history_butt_1`, `mdi_history_double_butt_1` | Single click selects; double-click submits through the same MDI gate | Single click copies the block only; double-click executes once, trims/clears entry and de-duplicates adjacent history |
|
||||||
| `preview-panLeft`, `preview-panRight`, `preview-panUp`, `preview-panDown`, `preview-reset` | Web accessible equivalent of Gremlin mouse pan/reset | Local camera only | Moves/resets camera; no Worker command |
|
| `preview-panLeft`, `preview-panRight`, `preview-panUp`, `preview-panDown`, `preview-reset` | Web accessible equivalent of Gremlin mouse pan/reset | Local camera only | Moves/resets camera; no Worker command |
|
||||||
| `dro-coordinate`, `dro-display`, `dro-units` | `coord_type`, `display_type`, `metric` | Local snapshot projection | Machine/Relative, Actual/Commanded and mm/inch display only |
|
| `dro-coordinate`, `dro-display`, `dro-units` | `coord_type`, `display_type`, `metric` | Local snapshot projection | Machine/Relative, Actual/Commanded and mm/inch display only |
|
||||||
| `override-jog-speed` | `update_jog_slider_vel` | `SET_JOG_SCALE`; no native machine-state UI gate | Canonical linear jog value used by later jogs |
|
| `override-jog-speed` | `update_jog_slider_vel` | `SET_JOG_SCALE`; no native machine-state UI gate | Canonical linear jog value used by later jogs |
|
||||||
@@ -366,8 +447,8 @@ React must derive its button gates from that state on the next snapshot.
|
|||||||
| `POWER(active=false)` | Source toolbar allows the toggle whenever status is not ESTOP, including active execution | Motion/task/IO abort, trajectory disables, volatile-home joints unhome, task plan synchronizes; taskfile retained | Observed state returns to ESTOP_RESET; nonvolatile homing is not falsely cleared by the UI |
|
| `POWER(active=false)` | Source toolbar allows the toggle whenever status is not ESTOP, including active execution | Motion/task/IO abort, trajectory disables, volatile-home joints unhome, task plan synchronizes; taskfile retained | Observed state returns to ESTOP_RESET; nonvolatile homing is not falsely cleared by the UI |
|
||||||
| `HOME(all/axis)` | AXIS homing UI: ON+IDLE; handler `manual_ok`, then MANUAL | Home authority updates `homed[]`; individual coordinate-axis home is rejected for non-identity/duplicate coordinates | AUTO's task layer may run only when all required joints are homed unless `NO_FORCE_HOMING=1` |
|
| `HOME(all/axis)` | AXIS homing UI: ON+IDLE; handler `manual_ok`, then MANUAL | Home authority updates `homed[]`; individual coordinate-axis home is rejected for non-identity/duplicate coordinates | AUTO's task layer may run only when all required joints are homed unless `NO_FORCE_HOMING=1` |
|
||||||
| `PROGRAM_LOAD` | Native Open/Reload requires interpreter IDLE; Web additionally excludes active TP/MDI queue and concurrent parse | Save mode, ensure MANUAL, restore mode, set line 0, synchronize, open/replace taskfile and canon, parse status-derived preview, then apply tab mode | New taskfile/preview replace the old selection. Static preview replacement is independent of Live Plot data. |
|
| `PROGRAM_LOAD` | Native Open/Reload requires interpreter IDLE; Web additionally excludes active TP/MDI queue and concurrent parse | Save mode, ensure MANUAL, restore mode, set line 0, synchronize, open/replace taskfile and canon, parse status-derived preview, then apply tab mode | New taskfile/preview replace the old selection. Static preview replacement is independent of Live Plot data. |
|
||||||
| `KINEMATICS_SELECT` | Machine on, homed, idle, TP queue empty, valid type 0/1/2 | M429/M428/M430 selection updates canonical kinematics and the HAL switchkins type | Identity is required for individual coordinate-axis homing; a non-empty queue blocks selection |
|
| `KINEMATICS_SELECT` | Machine on, homed, idle, TP queue empty, valid type 0/1/2 | M429/M428/M430 selection updates canonical kinematics and the HAL switchkins type | KINEMATICS_BOTH remains unchanged; a non-empty queue blocks selection |
|
||||||
| `SET_MODE(MANUAL)` | Source task rejects leaving active AUTO; tab handler also requires `manual_ok` | Task mode MANUAL plus source joint/teleop selection | Manual widgets are still controlled by ON+IDLE, not mode alone |
|
| `SET_MODE(MANUAL)` | Source task rejects leaving active AUTO; tab handler also requires `manual_ok` | Task mode MANUAL acknowledged; explicit joint/teleop switching remains the stated boundary | Manual widgets are still controlled by ON+IDLE, not mode alone |
|
||||||
| `SET_MODE(MDI)` | Tab handler requires `manual_ok`; task mode transition follows source motion-mode calls | Task mode MDI | MDI widgets use ON+queue-capacity; submission still calls `manual_ok` |
|
| `SET_MODE(MDI)` | Tab handler requires `manual_ok`; task mode transition follows source motion-mode calls | Task mode MDI | MDI widgets use ON+queue-capacity; submission still calls `manual_ok` |
|
||||||
| `PROGRAM_RUN` | Native toolbar formula is ON+IDLE and does not test taskfile. Web adds current-taskfile validation; task acceptance adds all-homed unless `NO_FORCE_HOMING=1`. Completed/aborted are valid idle starts. | `task_run`: warning, ensure AUTO, `AUTO_RUN(start_line)`, reset next start line to 0. Task layer reopens the retained file when plan is closed. | During non-IDLE, native Run relief is pressed and Open/Reload/Run/manual controls disable; on IDLE, direct Run is available again. |
|
| `PROGRAM_RUN` | Native toolbar formula is ON+IDLE and does not test taskfile. Web adds current-taskfile validation; task acceptance adds all-homed unless `NO_FORCE_HOMING=1`. Completed/aborted are valid idle starts. | `task_run`: warning, ensure AUTO, `AUTO_RUN(start_line)`, reset next start line to 0. Task layer reopens the retained file when plan is closed. | During non-IDLE, native Run relief is pressed and Open/Reload/Run/manual controls disable; on IDLE, direct Run is available again. |
|
||||||
| `PROGRAM_PAUSE` | Source handler: AUTO plus READING/WAITING; combined toolbar: AUTO/MDI plus non-IDLE and not currently paused | Trajectory pauses, previous interpreter state is saved, interpreter becomes PAUSED | Combined toolbar remains enabled and becomes Resume; Stop, E-stop and Power remain enabled by their own formulas |
|
| `PROGRAM_PAUSE` | Source handler: AUTO plus READING/WAITING; combined toolbar: AUTO/MDI plus non-IDLE and not currently paused | Trajectory pauses, previous interpreter state is saved, interpreter becomes PAUSED | Combined toolbar remains enabled and becomes Resume; Stop, E-stop and Power remain enabled by their own formulas |
|
||||||
@@ -404,6 +485,225 @@ the Worker gate remains final authority if a click races a status transition.
|
|||||||
|
|
||||||
## Required Transactions
|
## Required Transactions
|
||||||
|
|
||||||
|
### 原生清图方式(2026-09-08 10:14,北京时间)
|
||||||
|
|
||||||
|
按最新要求“原生如何清除,Web 也同样方式清除”,本节取代早期自动加载清图
|
||||||
|
扩展。以 `axis.py::reload_file/open_file_guts/clear_live_plot`、
|
||||||
|
`LivePlotter.clear` 和 `axis.tcl:379/664` 为准:
|
||||||
|
|
||||||
|
| 操作 | 加工 Live Plot | 静态程序预览 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Open、Reload(同文件或编辑后)、加载 Chips | 保留 | 替换为新文件解析结果 |
|
||||||
|
| Run、再次 Run、Pause、Resume、Stop、正常结束 | 保留 | 保留 |
|
||||||
|
| Clear Live Plot 工具栏按钮 / Ctrl+K | 清除;运行时重新累积后续实际运动 | 保留 |
|
||||||
|
| 隐藏/显示 Live Plot | 仅改变可见性 | 不受影响 |
|
||||||
|
|
||||||
|
```text
|
||||||
|
Clear Live Plot 按钮或 Ctrl+K
|
||||||
|
-> axis.py::clear_live_plot -> LivePlotter.clear -> positionlogger.clear
|
||||||
|
Web 对应:VISUAL_CLEAR -> Worker clearLivePlot -> live_path_epoch + 1
|
||||||
|
-> React 删除旧加工轨迹及待播放样本,保留当前静态预览
|
||||||
|
-> 暂停/停止时保持空白,运行时从后续 WASM 运动继续积累
|
||||||
|
```
|
||||||
|
|
||||||
|
Web 不再随 PROGRAM_LOAD 自动清图,也不再发送
|
||||||
|
`clear_live_plot_on_replace`。旧请求即使带有该扩展参数,加载路径也不再
|
||||||
|
执行清图副作用。该退役参数不能成为隐藏的第二种清图入口。
|
||||||
|
清图仍可在暂停时使用,且不能损坏 Resume/Stop 的通信序号和队列。
|
||||||
|
|
||||||
|
### 暂停后恢复与通信一致性(2026-09-08 09:50,北京时间)
|
||||||
|
|
||||||
|
本次可复现故障:`PROGRAM_PAUSE(seq=9)` 成功后,UI 仍允许的清图
|
||||||
|
`VISUAL_CLEAR(seq=10)` 被旧通信层的 PAUSED 白名单拒绝,`last_seq` 停在 9。
|
||||||
|
随后 Resume(seq=11)、Stop(seq=12) 均报 `protocol.sequence_gap`,机器永久
|
||||||
|
停在暂停态。暂停后调节主轴倍率、执行 Step 或重复 Pause 也可能触发该问题。
|
||||||
|
|
||||||
|
源依据:`axis.py::task_pause/task_resume/task_pauseresume`;
|
||||||
|
`emctaskmain.cc:2248-2286` 保存/恢复 `interpResumeState` 并调用
|
||||||
|
`emcTrajPause/Resume`;AUTO+PAUSED 命令分支继续接收控制命令并按 task 规则
|
||||||
|
处理。暂停不关闭 NML 通道。`axis.tcl::update_state` 在暂停时仍启用 Step、
|
||||||
|
Resume、Stop,倍率和 Clear Live Plot 也没有暂停禁用公式。
|
||||||
|
|
||||||
|
```text
|
||||||
|
合法、顺序正确的帧 -> 通信层接收 seq(READY/PAUSED 均保持连接)
|
||||||
|
-> Worker ledger 与 task gate
|
||||||
|
-> 接受:执行相应 WASM 命令 + canonical snapshot
|
||||||
|
-> 拒绝:保留 gate_reason + 记录该 seq,不改变正在暂停/运行的运动
|
||||||
|
-> 下一合法帧继续使用 seq+1
|
||||||
|
|
||||||
|
PAUSE -> 保存 READING/WAITING/MDI 的恢复状态,保留解释器与 TP 队列
|
||||||
|
-> Clear / Override / Step / 被拒绝的命令不破坏通信序号
|
||||||
|
-> RESUME -> 恢复原状态和原队列 -> 后续 SIM_STEP 的位置持续前进
|
||||||
|
```
|
||||||
|
|
||||||
|
一致性约束:
|
||||||
|
|
||||||
|
- 通信 PAUSED 标记只表示已接收的暂停意图,不再维护另一套机床指令白名单。
|
||||||
|
schema/session/sequence/tick/operation_id 的格式校验继续有效;格式错误或
|
||||||
|
真正乱序帧仍拒绝,不能无条件跳过序号或自动重发副作用命令。
|
||||||
|
- Worker task gate 继续拒绝运行/暂停中的 Load、MDI、Home、Jog 等不合法操作。
|
||||||
|
“通信接收”不等于“机床执行”;拒绝必须保留错误和原机床状态。
|
||||||
|
- UI 的非致命命令错误不能自行停止仍在运行的 WASM 采样。是否继续采样取决于
|
||||||
|
canonical machine/task 状态;真正 ESTOP/OFF/FAULT/恢复过程仍停止采样。
|
||||||
|
- Pause/Resume 期间只允许一个在途采样;拒绝命令、清图、倍率操作和重复点击
|
||||||
|
后仍能继续。不得以 Reload/重置程序或清 TP 队列作为恢复手段。
|
||||||
|
- 连续两次 Pause/Resume、WAITING(文件读完但 TP 未排空)、MDI 暂停、暂停中
|
||||||
|
Step、暂停中清图/倍率、非法操作后 Resume、Resume 后 Stop 均需验证。
|
||||||
|
- 按上方最新原生规则保留 Open/Reload 的加工轨迹;Chips 的批量绘图与
|
||||||
|
实时推进、Home/Jog、主轴/冷却和 OPFS 偏好行为继续回归;不能为修复通信
|
||||||
|
删除这些已实现功能或放宽其 task gate。
|
||||||
|
|
||||||
|
验收以实际命令 seq、错误、队列/位置、恢复后的多个连续采样为准。
|
||||||
|
Resume 返回 acknowledged 或按钮图标改变,均不足以证明“继续执行”成功。
|
||||||
|
|
||||||
|
### 换程序轨迹与长程序性能(2026-09-08)
|
||||||
|
|
||||||
|
本次问题场景:执行第一份程序后,打开 `linuxcnc/nc_files/3D_Chips.ngc`,
|
||||||
|
旧加工轨迹仍可见,且第二份程序运行变慢。
|
||||||
|
|
||||||
|
**源码事实与本次 Web 要求分别记录。** `axis.py::open_file_guts` 创建新
|
||||||
|
`AxisCanon`,但不调用 `LivePlotter.clear()`;`clear_live_plot` 才调用
|
||||||
|
positionlogger 清图。因此原生 Open/Reload 保留 Live Plot 不是源码错误。
|
||||||
|
此前实现的自动换文件清图扩展已按 10:14 的最新原生要求退役。
|
||||||
|
现在所有 Web PROGRAM_LOAD 均保持原生保留语义,清图由用户显式触发。
|
||||||
|
|
||||||
|
```text
|
||||||
|
Web Open / 打包程序 / Reload
|
||||||
|
-> PROGRAM_LOAD {source, path}
|
||||||
|
-> 原生 Open 门控与解析,旧程序执行必须先结束
|
||||||
|
-> 文件解析成功 -> 发布新静态预览和程序状态,live_path_epoch 保持原值
|
||||||
|
-> React 替换静态刀路,保留加工轨迹
|
||||||
|
取消 / gate 拒绝 / 解析失败 -> 不清加工轨迹
|
||||||
|
Clear Live Plot / Ctrl+K -> 显式清除加工轨迹
|
||||||
|
```
|
||||||
|
|
||||||
|
读取/解析期间,画布继续持有最近的 Worker 快照,不能将 `snapshot=null`
|
||||||
|
临时投影成 `live_path_epoch=0` 而提前清图。加载成功或失败都保留 Live Plot;
|
||||||
|
只有显式清图的 Worker 新 epoch 才删除轨迹。失败文件的静态预览可为空。
|
||||||
|
|
||||||
|
清图只修改可视历史,不改 task/TP/IO/坐标/偏置/Feed Override。当前程序
|
||||||
|
Run 仍需用户单独操作,不能因加载文件而自动上电、回零或运行。
|
||||||
|
|
||||||
|
**执行时间依据。** 选定 INI 的 SERVO_PERIOD=1 ms、TASK CYCLE_TIME=10 ms。
|
||||||
|
一次 `SIM_STEP {cycles:5}` 必须执行 5 个原有 task tick/50 个 servo tick;
|
||||||
|
提升速度应减少每个 tick 的额外计算与绘图负担,不能放大位移、进给或
|
||||||
|
逻辑步长。`3D_Chips.ngc` 的 F 值以实际文件为准,经 WASM TP 的配置速度
|
||||||
|
和加速度限制;文件开头的注释不是替代 F 指令的依据。
|
||||||
|
|
||||||
|
性能核对要求:
|
||||||
|
|
||||||
|
- 主程序 source hash 在成功加载源文本时计算并保存;后续 snapshot 读取
|
||||||
|
同一不可变文本的 hash,不得每个 servo/task tick 重扫整份 G-code。
|
||||||
|
- 连续 servo 子周期内解释器没有变化时,不重复构造完整 program snapshot。
|
||||||
|
- 静态预览将 canonical samples 的相邻顶点合并到少量 LineSegments;
|
||||||
|
4684 段不能生成 4684 次静态 draw call;合并不得连接不相邻轨迹。
|
||||||
|
- 原生 AXIS 程序文本位于带滚动条的固定面板。Web 移动端也必须固定编辑器/
|
||||||
|
执行记录的 grid 行高,长程序只增加内部 scrollHeight,不能把整个页面
|
||||||
|
撑到数万像素或一次渲染所有行号。截图检查同时验证页面高度和内部滚动。
|
||||||
|
- 采样仍保持最多一个在途请求;不因 CPU 慢而叠加定时器或无限补发 tick。
|
||||||
|
- 验收记录第一程序完成 → Chips 加载 → 执行 → 再加载 Chips 的用时、
|
||||||
|
tick/墙钟比、Worker 请求延迟、绘图调用数;检查 Pause/Stop 响应和
|
||||||
|
canonical 路径/位置/状态哈希,不能仅以肉眼感觉变快为证据。
|
||||||
|
|
||||||
|
从原点加载 Chips 有 4684 段;选定启动程序结束在 Z10 后再加载时,首条
|
||||||
|
`G0 Z10` 为零位移,由 WASM 消去,预览为 4683 段。这是当前位置同步的
|
||||||
|
正常结果,不能为满足固定段数而插入假轨迹或重置机床位置。
|
||||||
|
|
||||||
|
基线测量:同一运行时重复读取 200 次快照,139 字节程序约 42 ms,
|
||||||
|
200509 字节 Chips 约 797 ms。该差异与重复文本哈希有关;最终性能结果
|
||||||
|
以验收报告中的本机前后测量为准。
|
||||||
|
|
||||||
|
### 全部控件的事件与验收规则
|
||||||
|
|
||||||
|
上面的 Complete Web Control Inventory 是控件全集。每一行均继承以下规则,
|
||||||
|
因此“只有点击回调,没有拒绝、结束或状态回显”的控件不满足本流程。
|
||||||
|
|
||||||
|
| 控件类别 | 事件入口与前置检查 | 成功路径 | 取消、拒绝和验收 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 机器、程序、回零、MDI、主轴、冷却、覆盖率、switchkins | 使用该行指定的 Worker gate;仅追加启动/文件读取中的异步互斥 | `operation_id → Worker → WASM → COMMAND_RESULT → panel_snapshot`;UI 使用最新快照回显 | 错误保留 `code/message_key/gate_reason` 和操作记录;不显示 acknowledged,不在 React 里补成功状态 |
|
||||||
|
| Open、Reload、案例和打包程序 | 四条入口共用 `gates.load`,包含 PAUSED 与活动 TP 队列;Reload 还要求已有 taskfile | 文件读取完成后再次检查门控,再提交一次 `PROGRAM_LOAD` | 用户取消文件选择不发命令;门控已变化则不替换编辑器文本、路径或预览;解析失败属于已选新文件的源码错误 |
|
||||||
|
| 点动和主轴调速按住按钮 | 主指针按下或聚焦按钮的 Space/Enter 按下;重复 keydown/第二指针不重复提交 | 记录本次手势的 axis/direction/increment 与 release 操作;按下和释放有固定命令顺序 | pointerup/cancel/leave/lost capture、keyup、窗口失焦、页面隐藏、离开 Manual 页只结束同一手势一次;release 早于 ACK 时仍按 start→stop 顺序处理 |
|
||||||
|
| 坐标轴、步长、DRO、视图、图层、选项卡 | 使用表内本地显示状态;切换 Manual/MDI 另走 `SET_MODE` | 更新选择或投影,不能变更 canonical machine state | 每个选项均验证显示变化;隐藏 Live Plot 不得删除轨迹;旋转、平移和缩放须验证实际画布变化 |
|
||||||
|
| 系统对刀、重复回零 | 源码门控通过后显示参数/确认对话框 | 确认后只发送一次 `TOUCH_OFF` 或 `HOME` | 取消不发运动/对刀命令;Home All 的原生 ensure MANUAL 在确认之前,Web 确认前模式处理属于适配差异;空白/非有限数字/非法 P 或 G 系统标识不提交 |
|
||||||
|
| About 和菜单 | 本地打开、关闭、Escape;About 打开时焦点进入,关闭后返回入口 | 显示/隐藏相应内容 | 弹窗打开期间不触发普通机器快捷键;F1 急停仍可用;关闭动作不触发 Stop |
|
||||||
|
| ABI/桌面边界控件 | 原生 disabled 属性与明确原因 | 无成功路径 | `SOURCE_BLOCKED` 不得出现机器命令或本地假开关;全部在浏览器清单中检查 |
|
||||||
|
|
||||||
|
菜单工具项、所有六种预置视图、五个轴选择、正负点动、两种冷却和所有滑块
|
||||||
|
都逐一计入验收;不能仅用同组中的一个按钮代表整组。动态历史项只允许
|
||||||
|
`mdi-history-select` 家族 ID 重复,其余 ID 必须唯一。
|
||||||
|
|
||||||
|
### MDI 输入和历史
|
||||||
|
|
||||||
|
来源:`axis.py:2413` (`mdi_up_cmd`)、`:2430` (`mdi_down_cmd`)、
|
||||||
|
`:2448` (`send_mdi`)、`:2454` (`send_mdi_command`)、`:2541` (double-click)。
|
||||||
|
历史上限来自 `axis.py:165` 的 1000 条,最后有一个空白输入位置。
|
||||||
|
|
||||||
|
```text
|
||||||
|
Go / Enter / 历史双击
|
||||||
|
-> manual_ok + MDI gate
|
||||||
|
-> trim 非空命令(Web 对纯空白不提交)
|
||||||
|
-> 清空输入、ensure MDI
|
||||||
|
-> 若与上一条不同则追加历史(最多 1000 条,按旧到新排序)
|
||||||
|
-> MDI_EXECUTE
|
||||||
|
-> ACK / interpreter error + canonical snapshot
|
||||||
|
单击历史 -> 仅选中并填入输入框
|
||||||
|
Up / Down -> 在历史与末尾空白位置循环,不发送命令
|
||||||
|
```
|
||||||
|
|
||||||
|
历史记录的是用户提交意图;解释器报错的命令也保留历史,不能据此判定执行成功。
|
||||||
|
浏览器会话内历史属于本地输入状态;写入桌面历史文件是单独的 host boundary。
|
||||||
|
`axis.tcl:1715/1724` 的 manualgroup 包含轴单选框,而不包含步长 combobox;
|
||||||
|
mdigroup 包含输入框、Go 和历史列表。Web 必须连同历史列表一起投影 mdigroup
|
||||||
|
门控;AUTO 活动但 UI 公式允许 MDI 时可以选历史,提交仍受 `manual_ok` 拒绝,
|
||||||
|
不能清空输入或追加历史。机床关闭时该组整体禁用。
|
||||||
|
|
||||||
|
### 快捷键必须使用源码的大小写和修饰键
|
||||||
|
|
||||||
|
`axis.py:3124` 将 F1 绑定到 `all`,所以编辑器、MDI 输入和弹窗聚焦时 F1
|
||||||
|
仍然有效;不得先因 INPUT/TEXTAREA/SELECT 聚焦而返回。非安全字母快捷键
|
||||||
|
不抢占文本输入、浏览器 Ctrl/Meta/Alt 组合,也不得通过 `toLowerCase()`
|
||||||
|
把没有源码绑定的大写字符转换成机器命令。`Shift-Home` 是清零,不是回零;
|
||||||
|
该能力缺失时明确拒绝,不能落入 Home 分支。`o` 必须进入同一 Open 门控。
|
||||||
|
|
||||||
|
`axis.py:2871/2879` 中 F9/F10 都先检查 spindle direction:任何非零方向
|
||||||
|
均发送 OFF,只有已停转时才分别发送 FORWARD/REVERSE。因此 F9 在 CCW
|
||||||
|
运行时、F10 在 CW 运行时必须先停转。主轴方向单选按钮仍直接选择方向。
|
||||||
|
F11/F12 的每次 Tk KeyPress 调用一次调速,源码并未屏蔽系统按键重复。
|
||||||
|
Web 当前抑制重复 keydown 是显式浏览器输入适配,不属于源码限制。
|
||||||
|
|
||||||
|
### 连续点动的运动权威
|
||||||
|
|
||||||
|
`axis.py:3241/3268/3282` 定义按下、释放和失焦;
|
||||||
|
`src/emc/motion/command.c:769` 的 `EMCMOT_JOG_CONT` 将目标设为该方向的
|
||||||
|
点动限位,释放通过 abort 停止。Web 使用同一目标选择规则:
|
||||||
|
|
||||||
|
```text
|
||||||
|
一次 JOG_START {axis, direction, continuous:true}
|
||||||
|
-> Worker 将连续模式编码为 TP jog 的 increment=0
|
||||||
|
-> C/WASM 从配置限位选择目标、使用配置速度/加速度提交一次轨迹
|
||||||
|
-> 浏览器定时器仅发送 SIM_STEP,不再发送 JOG_START 或计算距离
|
||||||
|
-> 一次 JOG_STOP -> C/WASM abort -> 队列清空 -> 停止采样
|
||||||
|
```
|
||||||
|
|
||||||
|
正数 increment 保留单次增量点动语义,负值/非有限值拒绝。增量点动也持续
|
||||||
|
采样直至 WASM 队列排空,不能只执行一个 tick 后悬停;释放不截断增量。
|
||||||
|
选定案例的毫米/角度转换在 Worker 输入边界完成,A/C 不按 25.4 缩放。
|
||||||
|
连续点动到达配置软限位后不得超限,反向点动必须仍可执行。
|
||||||
|
这属于选定 TP 的 `SOURCE_ADAPTER`:当前 abort 使用 TP 清队列,完整原生
|
||||||
|
free/teleop 伺服减速数值对照不属于已验证的按钮语义,不能据此宣称完整 motion parity。
|
||||||
|
|
||||||
|
### 验收证据与失败条件
|
||||||
|
|
||||||
|
| 证据 | 必须覆盖 | 失败判定 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 源码契约检查 | Python/Tcl hash、处理函数、精确 gate、每个控件 ID | 源码漂移、漏 ID、出现未记录按钮 |
|
||||||
|
| Worker + 实际 WASM | 正常命令、OFF/ESTOP/运行/暂停拒绝、连续/增量点动、错误回传、对刀、重复运行、Live Plot 生命周期 | 无 ACK 或 canonical 输出、拒绝变成功、轨迹越界或错误清图 |
|
||||||
|
| 浏览器逐控件审计 | 全部控件及动态 About/历史;按下释放、失焦、取消、快捷键、门控与错误 | 未覆盖控件、重复 ID、按钮标题缺拒绝原因、控制操作仅改变本地状态 |
|
||||||
|
| 桌面与移动端 | 实际 Three.js 画布像素、视图交互、资源加载、文字/布局边界 | 空白画布、资源错误、横向溢出或控件遮挡 |
|
||||||
|
|
||||||
|
运行命令与实际结果记录在 `doc-rtcp/xyzac-panel-button-parity-test-report.md`。
|
||||||
|
本地浏览器证据必须标明本地 URL,不能写成已经部署或已验证线上版本。
|
||||||
|
|
||||||
### Startup program selection
|
### Startup program selection
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -498,6 +798,25 @@ display token, records the normalized `source_system`, and executes the same
|
|||||||
canonical P form. This keeps the source command and the interpreter encoding
|
canonical P form. This keeps the source command and the interpreter encoding
|
||||||
auditable without changing the UI behavior.
|
auditable without changing the UI behavior.
|
||||||
|
|
||||||
|
对刀的数据闭环以 `interp_convert.cc::convert_setup/convert_coordinate_system`
|
||||||
|
为参照:G10 只修改指定 P 系统的偏置,不能移动机器当前位置。执行前先用
|
||||||
|
TP 实际位置同步解释器;毫米/英寸只转换线性轴,A/C 使用角度值。`G59.1`、
|
||||||
|
`G59.2`、`G59.3` 合法,`G54.1` 等非源码系统标识必须拒绝。
|
||||||
|
|
||||||
|
```text
|
||||||
|
TP 实际位置 -> 解释器同步 -> G10 L20 Pn AxisValue
|
||||||
|
-> WASM 保存九个坐标系偏置,机器位置不变
|
||||||
|
-> 使用相同偏置、当前位置和模态状态重新生成静态 preview
|
||||||
|
-> 发布偏置 + machine/relative 显示投影,live epoch 不变
|
||||||
|
-> 恢复保存的 task mode(包括错误返回分支)
|
||||||
|
```
|
||||||
|
|
||||||
|
文件 Reload/Open 和 Run 重开当前文件必须保留坐标偏置;临时预览解释器从
|
||||||
|
WASM 快照初始化偏置,不能默认所有偏置为零。Worker 的 relative 投影只做
|
||||||
|
`TP machine position - WASM active coordinate offset`,不得回写成运动目标。
|
||||||
|
带 G92、坐标旋转或刀具长度的完整坐标变换仍属于对应 ABI 的能力边界,
|
||||||
|
不得用这个平移投影冒充它们;此处验证当前选定案例的 G54..G59.3 平移偏置。
|
||||||
|
|
||||||
### Continuous jog
|
### Continuous jog
|
||||||
|
|
||||||
AXIS binds Button-1 to continuous jog and ButtonRelease to `jog_stop`.
|
AXIS binds Button-1 to continuous jog and ButtonRelease to `jog_stop`.
|
||||||
@@ -509,8 +828,8 @@ The Web equivalent keeps one active `{axis,direction}` gesture, sends one
|
|||||||
|
|
||||||
### Toolpath display lifecycle
|
### Toolpath display lifecycle
|
||||||
|
|
||||||
The panel renders two different path layers. They must never share an implicit
|
The panel renders two different path layers. Both the current Web panel and
|
||||||
clear condition:
|
the native AXIS follow the lifecycles below:
|
||||||
|
|
||||||
| Layer | Source owner | Visible/retained | Cleared or replaced |
|
| Layer | Source owner | Visible/retained | Cleared or replaced |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
@@ -520,9 +839,8 @@ clear condition:
|
|||||||
The Worker publishes a monotonic `visual.live_path_epoch`. React may clear the
|
The Worker publishes a monotonic `visual.live_path_epoch`. React may clear the
|
||||||
machining Live Plot only when this value changes. Array identity, a full versus
|
machining Live Plot only when this value changes. Array identity, a full versus
|
||||||
delta snapshot, Pause, Resume, Stop, task mode, queue depth and completion are
|
delta snapshot, Pause, Resume, Stop, task mode, queue depth and completion are
|
||||||
not clear signals. To match the source, the epoch changes only for the explicit
|
not clear signals. The epoch changes only for `VISUAL_CLEAR` (or restoration
|
||||||
`VISUAL_CLEAR` adapter (or restoration of a previously persisted epoch); it
|
of a persisted epoch), never as an Open/Reload side effect.
|
||||||
must not change on a program lifecycle command.
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
PROGRAM_LOAD accepted -> live epoch unchanged -> replace static preview only
|
PROGRAM_LOAD accepted -> live epoch unchanged -> replace static preview only
|
||||||
@@ -539,11 +857,9 @@ If Clear Plot is used while motion is still active, history before the clear
|
|||||||
edge is removed and `positionlogger` semantics immediately begin accumulating
|
edge is removed and `positionlogger` semantics immediately begin accumulating
|
||||||
the subsequent motion in the new epoch. At IDLE, the cleared trace stays empty.
|
the subsequent motion in the new epoch. At IDLE, the cleared trace stays empty.
|
||||||
|
|
||||||
This source-exact rule supersedes the earlier Web extension that cleared the
|
The native rule above is the current command contract. The previous automatic
|
||||||
trace on Run, Restart or Load. That extension contradicted native AXIS and is no
|
Web replacement-clear extension is retired, including requests sent by an old
|
||||||
longer part of this contract. A product variant that wants per-job traces must
|
caller with its obsolete flag. Clear Live Plot is an explicit user operation.
|
||||||
provide a separately named export/archive action; it may not change the AXIS
|
|
||||||
Clear Live Plot lifecycle while claiming source parity.
|
|
||||||
|
|
||||||
## Implementation Invariants
|
## Implementation Invariants
|
||||||
|
|
||||||
@@ -563,8 +879,9 @@ Clear Live Plot lifecycle while claiming source parity.
|
|||||||
7. Executable program source/path is never part of UI preference persistence;
|
7. Executable program source/path is never part of UI preference persistence;
|
||||||
refresh performs one INI-authorized startup load.
|
refresh performs one INI-authorized startup load.
|
||||||
8. Live Plot clearing is controlled only by the Worker `live_path_epoch`;
|
8. Live Plot clearing is controlled only by the Worker `live_path_epoch`;
|
||||||
React object/array replacement is never a lifecycle signal, and only the
|
React object/array replacement is never a lifecycle signal. The
|
||||||
explicit `VISUAL_CLEAR` source adapter advances the epoch.
|
explicit `VISUAL_CLEAR` source adapter advances the epoch. Program loading
|
||||||
|
does not call the clear operation, including same-file Reload and edited text.
|
||||||
9. Every contract row carries or inherits one of `SOURCE_EXACT`,
|
9. Every contract row carries or inherits one of `SOURCE_EXACT`,
|
||||||
`SOURCE_ADAPTER`, `SOURCE_BLOCKED` or `WEB_EXTENSION`; extensions and blocked
|
`SOURCE_ADAPTER`, `SOURCE_BLOCKED` or `WEB_EXTENSION`; extensions and blocked
|
||||||
capabilities are never counted as source parity.
|
capabilities are never counted as source parity.
|
||||||
@@ -582,6 +899,16 @@ rerun contract is covered by `web/worker/test_xyzac_program_rerun.mjs` and the
|
|||||||
published-site execution sequence is covered by
|
published-site execution sequence is covered by
|
||||||
`scripts/test_xyzac_program_execution_browser_strict.mjs`.
|
`scripts/test_xyzac_program_execution_browser_strict.mjs`.
|
||||||
The source-surface audit is enforced by `scripts/test_axis_control_contract.mjs`.
|
The source-surface audit is enforced by `scripts/test_axis_control_contract.mjs`.
|
||||||
|
The press/release, focus-loss, MDI history, modal keyboard and desktop/mobile
|
||||||
|
canvas paths are checked by `scripts/test_axis_panel_source_flow_browser.mjs`.
|
||||||
|
`web/worker/test_xyzac_source_widget_gates.mjs` checks UI-versus-command gates,
|
||||||
|
OFF/ESTOP overrides and the source mode transitions against real WASM.
|
||||||
|
`scripts/test_axis_source_ui_oracle.mjs` executes the original Tcl
|
||||||
|
`update_state` procedure across 576 combinations and compares 13 widget
|
||||||
|
predicates (7488 assertions), rather than comparing two rewritten formulas.
|
||||||
|
`web/worker/test_axis_source_execution.mjs` verifies repeated Step, queued
|
||||||
|
motion Step, WAITING/MDI Pause-Resume, source OFF state, world-axis home rejection
|
||||||
|
and spindle override 0-to-100 restoration using the actual WASM runtime.
|
||||||
The exhaustive control-ID audit is enforced by
|
The exhaustive control-ID audit is enforced by
|
||||||
`scripts/test_axis_control_inventory.mjs` and
|
`scripts/test_axis_control_inventory.mjs` and
|
||||||
`scripts/test_axis_control_inventory_browser.mjs`; the latter rejects any
|
`scripts/test_axis_control_inventory_browser.mjs`; the latter rejects any
|
||||||
@@ -595,8 +922,9 @@ When LinuxCNC source behavior or the selected machine configuration changes:
|
|||||||
4. Update the React handler only after the Worker contract is verified.
|
4. Update the React handler only after the Worker contract is verified.
|
||||||
5. Add normal, blocked and recovery assertions to the focused test.
|
5. Add normal, blocked and recovery assertions to the focused test.
|
||||||
6. Regenerate the relationship graph from its DOT source.
|
6. Regenerate the relationship graph from its DOT source.
|
||||||
7. Run the panel browser test, build the React bundle, and publish a new
|
7. Run the panel browser tests, build the React bundle and record the local
|
||||||
release directory without replacing the previous rollback target.
|
preview URL. Publishing is a separate explicitly authorized action; local
|
||||||
|
evidence must not be represented as a deployment.
|
||||||
|
|
||||||
The document deliberately records ABI blockers and source boundaries. A
|
The document deliberately records ABI blockers and source boundaries. A
|
||||||
`verified` Web slice means the implemented path matches this contract; it does
|
`verified` Web slice means the implemented path matches this contract; it does
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 MiB After Width: | Height: | Size: 2.5 MiB |
56
scripts/benchmark_xyzac_program_replacement.mjs
Normal file
56
scripts/benchmark_xyzac_program_replacement.mjs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import createModule from "../build-wasm/linuxcnc-sim-core.js";
|
||||||
|
import { loadSimulationCase, toWorkerConfiguration } from "../web/workspace/sim-case-loader.mjs";
|
||||||
|
import { XyzacSession, CONTEXT_ID } from "../web/worker/xyzac-session.mjs";
|
||||||
|
|
||||||
|
const root = new URL("../", import.meta.url);
|
||||||
|
const read = (path) => readFile(new URL(path, root), "utf8");
|
||||||
|
const bundle = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", fetchImpl: async (url) => {
|
||||||
|
try { const body = await read(String(url).replace(/^\//, "")); return { ok: true, text: async () => body }; }
|
||||||
|
catch { return { ok: false, text: async () => "" }; }
|
||||||
|
} });
|
||||||
|
const core = await createModule({ wasmBinary: await readFile(new URL("build-wasm/linuxcnc-sim-core.wasm", root)) });
|
||||||
|
const session = new XyzacSession(core, { sessionId: "replacement-benchmark" });
|
||||||
|
let seq = 0;
|
||||||
|
const send = (kind, payload = {}) => {
|
||||||
|
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, payload, seq: ++seq, operation_id: `bench-${seq}` });
|
||||||
|
assert.notEqual(result.kind, "ERROR", `${kind}: ${result.payload.gate_reason ?? result.payload.message_key}`);
|
||||||
|
return result.payload;
|
||||||
|
};
|
||||||
|
send("HELLO"); send("SESSION_ATTACH"); send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||||
|
send("PROGRAM_LOAD", { source: "G21 G90\nG0 X0 Y0 Z1\nG1 X1 F600\nG1 X0\nM2\n", path: "first.ngc" });
|
||||||
|
send("POWER", { active: true }); send("HOME", { all: true }); send("PROGRAM_RUN");
|
||||||
|
for (let n = 0; n < 1000 && session.task.state.state !== "STOPPED"; n++) send("SIM_STEP", { cycles: 5, response: "motion_delta" });
|
||||||
|
assert.equal(session.task.state.state, "STOPPED");
|
||||||
|
const path = "linuxcnc/nc_files/3D_Chips.ngc", source = await read(path);
|
||||||
|
const ticks = Number(process.env.BENCH_TICKS ?? 200);
|
||||||
|
const rows = [];
|
||||||
|
for (let attempt = 1; attempt <= 2; attempt++) {
|
||||||
|
const start = performance.now();
|
||||||
|
const loaded = send("PROGRAM_LOAD", { source, path });
|
||||||
|
const loadMs = performance.now() - start;
|
||||||
|
const snapshotStart = performance.now();
|
||||||
|
for (let sample = 0; sample < 200; sample++) session.xyzacProgram.snapshot();
|
||||||
|
const snapshotMs = performance.now() - snapshotStart;
|
||||||
|
send("PROGRAM_RUN");
|
||||||
|
const runStart = performance.now();
|
||||||
|
let result;
|
||||||
|
const latencies = [];
|
||||||
|
for (let tick = 0; tick < ticks; tick += 5) {
|
||||||
|
const requestStart = performance.now();
|
||||||
|
result = send("SIM_STEP", { cycles: 5, response: "motion_delta" });
|
||||||
|
latencies.push(performance.now() - requestStart);
|
||||||
|
}
|
||||||
|
const runMs = performance.now() - runStart;
|
||||||
|
const state = session.wasmMotion.read().state;
|
||||||
|
rows.push({ attempt, load_ms: loadMs, snapshot_200_ms: snapshotMs, run_ms: runMs, task_ticks: ticks, simulated_ms: ticks * 10, p95_step_ms: latencies.sort((a, b) => a - b)[Math.floor(latencies.length * .95)], source_hash: loaded.program.source_hash, preview_hash: loaded.preview.preview_hash, preview_segments: loaded.preview.segments.length, final_line: result.program.current_line, pose: state.position, queue: state.queue_depth, feed: state.feed_override, velocity: state.velocity, live_epoch: loaded.panel_snapshot.visual.live_path_epoch });
|
||||||
|
send("PROGRAM_ABORT");
|
||||||
|
if (attempt === 1) {
|
||||||
|
// Reset the machine position through the interpreter, preserving source
|
||||||
|
// semantics, so the repeated load has the same physical starting point.
|
||||||
|
send("MDI_EXECUTE", { block: "G90 G0 X0 Y0 Z1" });
|
||||||
|
for (let tick = 0; tick < 2000 && session.wasmMotion.read().state.queue_depth; tick++) send("SIM_STEP", { cycles: 5, response: "motion_delta" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify({ schema: "xyzac-replacement-benchmark.v1", rows }));
|
||||||
@@ -15,6 +15,9 @@ const [axisPy, axisTcl, taskSource, contract, app, worker, programCore, runtime]
|
|||||||
read("web/worker/wasm-program-runtime.mjs"),
|
read("web/worker/wasm-program-runtime.mjs"),
|
||||||
]);
|
]);
|
||||||
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
const sha256 = (value) => createHash("sha256").update(value).digest("hex");
|
||||||
|
const glnav = await read("linuxcnc/lib/python/glnav.py");
|
||||||
|
assert.equal(sha256(glnav), "b2d811a21954966655a40f107c2af217cc7d57bae0dc308dc4698f5859ed3f11", "glnav.py drifted from the documented source lock");
|
||||||
|
assert.match(glnav, /def set_view_z2\(self\):[\s\S]*?glRotatef\(-90, 0, 0, 1\)[\s\S]*?self\.perspective = False/);
|
||||||
const squash = (value) => value.replace(/\\\s*/g, " ").replace(/\s+/g, " ");
|
const squash = (value) => value.replace(/\\\s*/g, " ").replace(/\s+/g, " ");
|
||||||
const axisPySquashed = squash(axisPy);
|
const axisPySquashed = squash(axisPy);
|
||||||
const axisTclSquashed = squash(axisTcl);
|
const axisTclSquashed = squash(axisTcl);
|
||||||
@@ -60,8 +63,11 @@ for (const sourceHandler of ["PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_RESTART"])
|
|||||||
const start = worker.indexOf(`case "${sourceHandler}"`);
|
const start = worker.indexOf(`case "${sourceHandler}"`);
|
||||||
const next = worker.indexOf("\n case ", start + 8);
|
const next = worker.indexOf("\n case ", start + 8);
|
||||||
assert.ok(start >= 0, `missing Worker handler ${sourceHandler}`);
|
assert.ok(start >= 0, `missing Worker handler ${sourceHandler}`);
|
||||||
assert.doesNotMatch(worker.slice(start, next < 0 ? worker.length : next), /livePathEpoch\s*\+=/, `${sourceHandler} must not clear native Live Plot`);
|
assert.doesNotMatch(worker.slice(start, next < 0 ? worker.length : next), /livePathEpoch\s*\+=/, `${sourceHandler} must use the shared explicit clear boundary`);
|
||||||
|
assert.doesNotMatch(worker.slice(start, next < 0 ? worker.length : next), /clearLivePlot\(/, `${sourceHandler} must preserve the native Live Plot`);
|
||||||
}
|
}
|
||||||
|
assert.doesNotMatch(app, /clear_live_plot_on_replace/);
|
||||||
|
assert.doesNotMatch(worker, /clear_live_plot_on_replace/);
|
||||||
|
|
||||||
for (const term of ["SOURCE_EXACT", "SOURCE_ADAPTER", "SOURCE_BLOCKED", "WEB_EXTENSION", "Source-lock manifest", "Source-exact widget enable formulas", "Exhaustive native-action ledger", "manual_ok", "ensure_mode", "open_file_guts", "EMC_TASK_PLAN_RUN", "PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_STEP", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "JOG_START", "HOME", "TOUCH_OFF", "Run after completion or Stop", "Toolpath display lifecycle", "live_path_epoch", "positionlogger", "blocked_by_abi_tool_offsets", "Button Dependency Matrix", "explicit `VISUAL_CLEAR` source adapter advances the epoch"]) assert.match(contract, new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `contract missing ${term}`);
|
for (const term of ["SOURCE_EXACT", "SOURCE_ADAPTER", "SOURCE_BLOCKED", "WEB_EXTENSION", "Source-lock manifest", "Source-exact widget enable formulas", "Exhaustive native-action ledger", "manual_ok", "ensure_mode", "open_file_guts", "EMC_TASK_PLAN_RUN", "PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_STEP", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "JOG_START", "HOME", "TOUCH_OFF", "Run after completion or Stop", "Toolpath display lifecycle", "live_path_epoch", "positionlogger", "blocked_by_abi_tool_offsets", "Button Dependency Matrix", "explicit `VISUAL_CLEAR` source adapter advances the epoch"]) assert.match(contract, new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `contract missing ${term}`);
|
||||||
assert.match(contract, /Native AXIS does \*\*not\*\* bind lowercase `c` to C-axis selection/);
|
assert.match(contract, /Native AXIS does \*\*not\*\* bind lowercase `c` to C-axis selection/);
|
||||||
|
|||||||
@@ -44,7 +44,8 @@ const missingIds = inventory.filter((entry) => !entry.id);
|
|||||||
assert.deepEqual(missingIds, [], `actionable controls without data-axis-control: ${JSON.stringify(missingIds)}`);
|
assert.deepEqual(missingIds, [], `actionable controls without data-axis-control: ${JSON.stringify(missingIds)}`);
|
||||||
const ids = inventory.map((entry) => entry.id);
|
const ids = inventory.map((entry) => entry.id);
|
||||||
for (const id of ids) assert.ok(contract.includes(`\`${id}\``), `runtime control missing from contract: ${id}`);
|
for (const id of ids) assert.ok(contract.includes(`\`${id}\``), `runtime control missing from contract: ${id}`);
|
||||||
assert.equal(new Set(ids).size, ids.length, `duplicate control IDs: ${JSON.stringify(ids.filter((id, index) => ids.indexOf(id) !== index))}`);
|
const uniqueIds = ids.filter((id) => id !== "mdi-history-select");
|
||||||
|
assert.equal(new Set(uniqueIds).size, uniqueIds.length, `duplicate control IDs: ${JSON.stringify(uniqueIds.filter((id, index) => uniqueIds.indexOf(id) !== index))}`);
|
||||||
for (const removed of ["Prepare T1", "Change", "Arm Probe", "Disarm", "Reload editor"]) assert.equal(inventory.some((entry) => entry.text === removed), false, `removed control still rendered: ${removed}`);
|
for (const removed of ["Prepare T1", "Change", "Arm Probe", "Disarm", "Reload editor"]) assert.equal(inventory.some((entry) => entry.text === removed), false, `removed control still rendered: ${removed}`);
|
||||||
assert.equal(await main.locator('[data-axis-control="menu-file-edit-tool-data"]').isDisabled(), true);
|
assert.equal(await main.locator('[data-axis-control="menu-file-edit-tool-data"]').isDisabled(), true);
|
||||||
assert.equal(await main.locator('[data-axis-control="menu-file-quit"]').isDisabled(), true);
|
assert.equal(await main.locator('[data-axis-control="menu-file-quit"]').isDisabled(), true);
|
||||||
|
|||||||
235
scripts/test_axis_panel_source_flow_browser.mjs
Normal file
235
scripts/test_axis_panel_source_flow_browser.mjs
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
const modulePath = process.env.PLAYWRIGHT_MODULE;
|
||||||
|
assert.ok(modulePath, "PLAYWRIGHT_MODULE is required");
|
||||||
|
const { chromium } = await import(pathToFileURL(modulePath).href);
|
||||||
|
const url = process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5174/";
|
||||||
|
const browser = await chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--disable-gpu"] });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||||
|
const errors = [];
|
||||||
|
const checks = [];
|
||||||
|
let dialogAnswers = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
page.on("dialog", (dialog) => dialogAnswers.length ? dialog.accept(dialogAnswers.shift()) : dialog.dismiss());
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.axisMessages = [];
|
||||||
|
window.axisResults = [];
|
||||||
|
const NativeWorker = window.Worker;
|
||||||
|
window.Worker = class extends NativeWorker {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args);
|
||||||
|
this.addEventListener("message", (event) => {
|
||||||
|
const value = event.data;
|
||||||
|
window.axisResults.push({ kind: value.kind, seq: value.seq, payload: value.payload });
|
||||||
|
if (window.axisResults.length > 100) window.axisResults.shift();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
postMessage(value, ...args) {
|
||||||
|
window.axisMessages.push({ kind: value.kind, seq: value.seq, payload: value.payload });
|
||||||
|
super.postMessage(value, ...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
|
||||||
|
const wait = (fn, arg) => page.waitForFunction(fn, arg, { timeout: 45000 });
|
||||||
|
const ready = () => wait(() => document.querySelector("main")?.dataset.axisProgramLoading === "false");
|
||||||
|
const queueEmpty = () => wait(() => document.querySelector("main")?.dataset.axisTpQueueDepth === "0");
|
||||||
|
const messages = (kind) => page.evaluate((kind) => window.axisMessages.filter((entry) => entry.kind === kind), kind);
|
||||||
|
const commandCount = async (kind) => (await messages(kind)).length;
|
||||||
|
const ack = (kind, after = 0) => wait(({ kind, after }) => window.axisResults.some((result) => result.seq > after && result.kind === "COMMAND_RESULT" && window.axisMessages.some((message) => message.seq === result.seq && message.kind === kind)), { kind, after });
|
||||||
|
const latestSeq = () => page.evaluate(() => window.axisMessages.filter((entry) => !entry.kind.startsWith("UI_STATE_")).at(-1)?.seq ?? 0);
|
||||||
|
const clickAck = async (id, kind) => { const seq = await latestSeq(); await control(id).click(); await ack(kind, seq); };
|
||||||
|
const pose = () => page.locator('[data-xyzac-preview="toolpath"]').evaluate((node) => JSON.parse(node.dataset.xyzacLivePose));
|
||||||
|
const check = async (name, run) => { await run(); checks.push(name); process.stderr.write(`[source-flow] ${name}: verified\n`); };
|
||||||
|
const hold = async (id, release, axis) => {
|
||||||
|
const starts = await commandCount("JOG_START"), stops = await commandCount("JOG_STOP"), seq = await latestSeq();
|
||||||
|
const before = await pose();
|
||||||
|
await control(id).dispatchEvent("pointerdown", { pointerId: 1, button: 0 });
|
||||||
|
await ack("JOG_START", seq);
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const during = await pose();
|
||||||
|
assert.notEqual(during[axis], before[axis], `no ${axis} motion`);
|
||||||
|
assert.equal(await commandCount("JOG_START"), starts + 1, "sampling must not reissue JOG_START");
|
||||||
|
await release();
|
||||||
|
await ack("JOG_STOP", seq);
|
||||||
|
await queueEmpty();
|
||||||
|
const stopped = await pose();
|
||||||
|
await page.waitForTimeout(180);
|
||||||
|
assert.deepEqual(await pose(), stopped, "motion continued after release");
|
||||||
|
assert.equal(await commandCount("JOG_STOP"), stops + 1, "release must stop exactly once");
|
||||||
|
};
|
||||||
|
const pixels = () => page.locator('[data-xyzac-preview="toolpath"] canvas').evaluate((canvas) => {
|
||||||
|
const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
|
||||||
|
if (!gl) return { colors: 0 };
|
||||||
|
const data = new Uint8Array(canvas.width * canvas.height * 4);
|
||||||
|
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, data);
|
||||||
|
const colors = new Set(); let hash = 2166136261;
|
||||||
|
for (let i = 0; i < data.length; i += 16) { const value = (data[i] << 16) | (data[i + 1] << 8) | data[i + 2]; colors.add(value); hash = Math.imul(hash ^ value, 16777619); }
|
||||||
|
return { colors: colors.size, hash, width: canvas.width, height: canvas.height };
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||||
|
await ready();
|
||||||
|
await check("startup-single-load", async () => assert.equal(await commandCount("PROGRAM_LOAD"), 1));
|
||||||
|
await clickAck("toolbar-power", "POWER");
|
||||||
|
await clickAck("manual-home-all", "HOME");
|
||||||
|
await check("mdi-trim-clear-deduplicate-history", async () => {
|
||||||
|
await control("tab-mdi").click();
|
||||||
|
const input = control("mdi-command");
|
||||||
|
const submit = async (text) => { const seq = await latestSeq(); await input.fill(text); await control("mdi-submit").click(); await ack("MDI_EXECUTE", seq); await queueEmpty(); assert.equal(await input.inputValue(), ""); };
|
||||||
|
await submit(" G21 "); await submit("G21");
|
||||||
|
assert.equal(await control("mdi-history-select").count(), 1);
|
||||||
|
await submit("G90");
|
||||||
|
assert.deepEqual(await control("mdi-history-select").allTextContents(), ["G21", "G90"]);
|
||||||
|
assert.equal((await messages("MDI_EXECUTE"))[0].payload.block, "G21");
|
||||||
|
await input.press("ArrowUp"); assert.equal(await input.inputValue(), "G90");
|
||||||
|
await input.press("ArrowUp"); assert.equal(await input.inputValue(), "G21");
|
||||||
|
await input.press("ArrowUp"); assert.equal(await input.inputValue(), "");
|
||||||
|
await input.press("ArrowDown"); assert.equal(await input.inputValue(), "G21");
|
||||||
|
const count = await commandCount("MDI_EXECUTE");
|
||||||
|
await control("mdi-history-select").last().click();
|
||||||
|
assert.equal(await commandCount("MDI_EXECUTE"), count);
|
||||||
|
const seq = await latestSeq(); await control("mdi-history-select").last().dblclick(); await ack("MDI_EXECUTE", seq);
|
||||||
|
assert.equal(await commandCount("MDI_EXECUTE"), count + 1);
|
||||||
|
assert.equal(await control("mdi-history-select").count(), 2);
|
||||||
|
});
|
||||||
|
await control("tab-manual").click();
|
||||||
|
await check("continuous-five-axes-both-directions", async () => {
|
||||||
|
for (const axis of ["x", "y", "z", "a", "c"]) {
|
||||||
|
await control(`manual-axis-${axis}`).check();
|
||||||
|
for (const direction of ["positive", "negative"]) {
|
||||||
|
const id = `manual-jog-${direction}`;
|
||||||
|
await hold(id, async () => {
|
||||||
|
await control(id).dispatchEvent("pointerup", { pointerId: 1, button: 0 });
|
||||||
|
await control(id).dispatchEvent("pointercancel", { pointerId: 1, button: 0 });
|
||||||
|
}, axis);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await control("manual-axis-x").check();
|
||||||
|
await check("jog-blur-and-hidden-tab-stop", async () => {
|
||||||
|
await hold("manual-jog-positive", () => page.evaluate(() => window.dispatchEvent(new Event("blur"))), "x");
|
||||||
|
await hold("manual-jog-positive", () => control("tab-diagnostics").click(), "x");
|
||||||
|
await control("tab-manual").click();
|
||||||
|
});
|
||||||
|
await check("jog-fast-release-and-keyboard-hold", async () => {
|
||||||
|
let starts = await commandCount("JOG_START"), stops = await commandCount("JOG_STOP");
|
||||||
|
await control("manual-jog-positive").evaluate((button) => {
|
||||||
|
button.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, pointerId: 9, button: 0 }));
|
||||||
|
button.dispatchEvent(new PointerEvent("pointerup", { bubbles: true, pointerId: 9, button: 0 }));
|
||||||
|
});
|
||||||
|
await wait((stops) => window.axisMessages.filter((m) => m.kind === "JOG_STOP").length === stops + 1, stops);
|
||||||
|
await queueEmpty(); assert.equal(await commandCount("JOG_START"), starts + 1);
|
||||||
|
await control("manual-jog-positive").focus();
|
||||||
|
starts = await commandCount("JOG_START"); stops = await commandCount("JOG_STOP");
|
||||||
|
await page.keyboard.down("Space"); await page.keyboard.down("Space"); await page.waitForTimeout(200); await page.keyboard.up("Space");
|
||||||
|
await wait((stops) => window.axisMessages.filter((m) => m.kind === "JOG_STOP").length === stops + 1, stops);
|
||||||
|
await queueEmpty(); assert.equal(await commandCount("JOG_START"), starts + 1);
|
||||||
|
});
|
||||||
|
await check("increment-completes-after-release", async () => {
|
||||||
|
await control("manual-jog-increment").selectOption("0.1000");
|
||||||
|
const before = await pose(), seq = await latestSeq(), stops = await commandCount("JOG_STOP");
|
||||||
|
await control("manual-jog-positive").press("Space"); await ack("JOG_START", seq); await queueEmpty();
|
||||||
|
assert.ok(Math.abs((await pose()).x - before.x - 0.1) < 1e-8);
|
||||||
|
assert.equal(await commandCount("JOG_STOP"), stops);
|
||||||
|
await control("manual-jog-increment").selectOption("Continuous");
|
||||||
|
});
|
||||||
|
await check("touch-off-preview-and-relative-coordinate", async () => {
|
||||||
|
const before = await pose();
|
||||||
|
dialogAnswers = ["2", "P1 G54"];
|
||||||
|
await clickAck("manual-touch-off", "TOUCH_OFF");
|
||||||
|
assert.deepEqual(await pose(), before);
|
||||||
|
await control("tab-dro").click(); await control("dro-coordinate").selectOption("relative");
|
||||||
|
assert.equal(Number(await page.locator(".dro-row").first().locator("output").first().textContent()), 2);
|
||||||
|
await control("dro-coordinate").selectOption("machine"); await control("tab-preview").click();
|
||||||
|
dialogAnswers = [String(before.x), "P1 G54"];
|
||||||
|
await clickAck("manual-touch-off", "TOUCH_OFF");
|
||||||
|
});
|
||||||
|
await check("spindle-key-toggle-and-release", async () => {
|
||||||
|
await clickAck("manual-spindle-ccw", "SPINDLE_COMMAND");
|
||||||
|
let seq = await latestSeq(); await page.keyboard.press("F9"); await ack("SPINDLE_COMMAND", seq);
|
||||||
|
assert.equal((await messages("SPINDLE_COMMAND")).at(-1).payload.direction, 0);
|
||||||
|
await clickAck("manual-spindle-cw", "SPINDLE_COMMAND");
|
||||||
|
seq = await latestSeq(); await page.keyboard.press("F10"); await ack("SPINDLE_COMMAND", seq);
|
||||||
|
assert.equal((await messages("SPINDLE_COMMAND")).at(-1).payload.direction, 0);
|
||||||
|
await clickAck("manual-spindle-cw", "SPINDLE_COMMAND");
|
||||||
|
seq = await latestSeq(); await control("manual-spindle-increase").dispatchEvent("pointerdown", { pointerId: 2, button: 0 }); await ack("SPINDLE_COMMAND", seq);
|
||||||
|
await page.evaluate(() => window.dispatchEvent(new Event("blur"))); await ack("SPINDLE_CONSTANT", seq);
|
||||||
|
await clickAck("manual-spindle-stop", "SPINDLE_COMMAND");
|
||||||
|
});
|
||||||
|
await check("paused-open-gate-and-source-key-case", async () => {
|
||||||
|
await clickAck("toolbar-run", "PROGRAM_RUN"); await clickAck("toolbar-pause-resume", "PROGRAM_PAUSE");
|
||||||
|
assert.ok(await control("toolbar-open").isDisabled());
|
||||||
|
assert.ok(await control("toolbar-open").getAttribute("title"));
|
||||||
|
const count = await commandCount("PROGRAM_LOAD");
|
||||||
|
await page.keyboard.press("Control+r"); await page.waitForTimeout(100);
|
||||||
|
assert.equal(await commandCount("PROGRAM_LOAD"), count);
|
||||||
|
await clickAck("toolbar-stop", "PROGRAM_ABORT");
|
||||||
|
await wait(() => document.querySelector("main")?.dataset.axisTaskMode === "MANUAL");
|
||||||
|
const commands = await commandCount("PROGRAM_RUN");
|
||||||
|
await page.keyboard.press("Control+r"); await ready();
|
||||||
|
assert.equal(await commandCount("PROGRAM_RUN"), commands, "Ctrl-R must never become Run");
|
||||||
|
await page.keyboard.press("R"); assert.equal(await commandCount("PROGRAM_RUN"), commands);
|
||||||
|
});
|
||||||
|
await check("repeat-step-and-restore-tab-mode", async () => {
|
||||||
|
const input = control("program-file-input");
|
||||||
|
const seq = await latestSeq();
|
||||||
|
await input.setInputFiles({ name: "source-step.ngc", mimeType: "text/plain", buffer: Buffer.from("G21 G90\nG1 X2 F600\nG1 X3\nM2\n") });
|
||||||
|
await ack("PROGRAM_LOAD", seq); await ready();
|
||||||
|
const x = (await pose()).x;
|
||||||
|
await clickAck("toolbar-step", "PROGRAM_STEP");
|
||||||
|
assert.equal((await pose()).x, x);
|
||||||
|
await clickAck("toolbar-step", "PROGRAM_STEP");
|
||||||
|
await clickAck("toolbar-step", "PROGRAM_STEP");
|
||||||
|
await clickAck("toolbar-step", "PROGRAM_STEP"); await queueEmpty();
|
||||||
|
assert.ok(Math.abs((await pose()).x - 2) < 1e-7);
|
||||||
|
await clickAck("toolbar-step", "PROGRAM_STEP"); await clickAck("toolbar-step", "PROGRAM_STEP"); await queueEmpty();
|
||||||
|
assert.ok(Math.abs((await pose()).x - 3) < 1e-7);
|
||||||
|
await clickAck("toolbar-stop", "PROGRAM_ABORT");
|
||||||
|
await wait(() => document.querySelector("main")?.dataset.axisTaskMode === "MANUAL");
|
||||||
|
});
|
||||||
|
await check("about-modal-and-estop-from-editor", async () => {
|
||||||
|
await control("menu-help").click(); await control("menu-help-about").click();
|
||||||
|
assert.equal(await page.evaluate(() => document.activeElement?.getAttribute("data-axis-control")), "about-close");
|
||||||
|
const runs = await commandCount("PROGRAM_RUN"); await page.keyboard.press("r"); assert.equal(await commandCount("PROGRAM_RUN"), runs);
|
||||||
|
await page.keyboard.press("Escape"); assert.equal(await control("about-close").count(), 0);
|
||||||
|
await control("program-editor").focus(); const seq = await latestSeq(); await page.keyboard.press("F1"); await ack("ESTOP", seq);
|
||||||
|
await wait(() => document.querySelector("[data-axis-state-machine]")?.dataset.axisStateMachine === "ESTOP");
|
||||||
|
assert.ok(await control("toolbar-power").isDisabled());
|
||||||
|
await clickAck("toolbar-estop", "ESTOP"); await clickAck("toolbar-power", "POWER");
|
||||||
|
});
|
||||||
|
await check("desktop-mobile-canvas-and-layout", async () => {
|
||||||
|
for (const viewport of [{ width: 1440, height: 1000 }, { width: 390, height: 844 }]) {
|
||||||
|
await page.setViewportSize(viewport); await control("tab-preview").click(); await control("toolbar-view-p").click(); await page.waitForTimeout(200);
|
||||||
|
for (const preset of ["z", "z2", "x", "y", "y2", "p"]) {
|
||||||
|
await control(`toolbar-view-${preset}`).click();
|
||||||
|
await wait((preset) => document.querySelector("[data-xyzac-preview]")?.dataset.xyzacProjection === (preset === "p" ? "perspective" : "orthographic"), preset);
|
||||||
|
}
|
||||||
|
const before = await pixels(); assert.ok(before.colors > 20, JSON.stringify(before));
|
||||||
|
await control("toolbar-rotate").click(); await page.waitForTimeout(250);
|
||||||
|
const stationary = await pixels(); await page.waitForTimeout(250);
|
||||||
|
assert.equal((await pixels()).hash, stationary.hash, "rotate mode must not animate the camera");
|
||||||
|
await control("preview-layer-velocity").check();
|
||||||
|
await wait(() => document.querySelector(".axis-preview-hud")?.textContent.includes("mm/min"));
|
||||||
|
await control("preview-layer-velocity").uncheck();
|
||||||
|
await control("preview-layer-limits").check();
|
||||||
|
await wait(() => document.querySelector("[data-xyzac-preview]")?.dataset.xyzacLimitsVisible === "true");
|
||||||
|
await control("preview-layer-limits").uncheck();
|
||||||
|
await control("preview-layer-extents").check();
|
||||||
|
await wait(() => document.querySelector("[data-xyzac-preview]")?.dataset.xyzacExtentsVisible === "true");
|
||||||
|
await control("preview-layer-extents").uncheck();
|
||||||
|
await control("toolbar-view-x").click(); await page.waitForTimeout(200);
|
||||||
|
const after = await pixels(); assert.notEqual(after.hash, before.hash, "view did not change canvas");
|
||||||
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1), true, "horizontal page overflow");
|
||||||
|
assert.equal(await page.locator("img").evaluateAll((images) => images.some((image) => !image.complete || image.naturalWidth === 0)), false);
|
||||||
|
await page.screenshot({ path: `/tmp/axis-source-flow-${viewport.width}.png`, fullPage: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(JSON.stringify({ schema: "axis-panel-source-flow-browser.v1", status: "verified", url, checks, browser_errors: errors }));
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(JSON.stringify(await page.evaluate(() => ({ messages: window.axisMessages.slice(-12), results: window.axisResults.slice(-3).map((entry) => ({ kind: entry.kind, seq: entry.seq, status: entry.payload?.status, reason: entry.payload?.gate_reason ?? entry.payload?.message_key })) }))) + "\n");
|
||||||
|
await page.screenshot({ path: "/tmp/axis-source-flow-failure.png", fullPage: true });
|
||||||
|
throw error;
|
||||||
|
} finally { await browser.close(); }
|
||||||
70
scripts/test_axis_source_ui_oracle.mjs
Normal file
70
scripts/test_axis_source_ui_oracle.mjs
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { axisSourceUi } from "../web/worker/axis-source-ui.mjs";
|
||||||
|
|
||||||
|
const source = await readFile(new URL("../linuxcnc/share/axis/tcl/axis.tcl", import.meta.url), "utf8");
|
||||||
|
const start = source.indexOf("proc update_state {args} {");
|
||||||
|
const end = source.indexOf("\nproc set_mode_from_tab", start);
|
||||||
|
assert.ok(start >= 0 && end > start);
|
||||||
|
const formulas = source.slice(start, end);
|
||||||
|
const scenarios = [];
|
||||||
|
for (const task_state of ["STATE_ESTOP", "STATE_ESTOP_RESET", "STATE_ON"])
|
||||||
|
for (const interp_state of ["INTERP_IDLE", "INTERP_READING", "INTERP_PAUSED", "INTERP_WAITING"])
|
||||||
|
for (const taskfile of [false, true])
|
||||||
|
for (const queued_mdi_commands of [0, 10])
|
||||||
|
for (const motion_mode of ["FREE", "TELEOP"])
|
||||||
|
for (const kinematics_type of ["BOTH", "IDENTITY"])
|
||||||
|
for (const spindle_direction of [0, -1, 1]) scenarios.push({ task_state, interp_state, taskfile, queued_mdi_commands, motion_mode, kinematics_type, spindle_direction, tool: taskfile ? 1 : 0, paused: interp_state === "INTERP_PAUSED" });
|
||||||
|
|
||||||
|
const widgetMap = { power: ".toolbar.machine_power", load: ".toolbar.file_open", reload: ".toolbar.reload", run: ".toolbar.program_run", step: ".toolbar.program_step", pauseResume: ".toolbar.program_pause", abort: ".toolbar.program_stop", manual: "manualgroup", mdi: "mdigroup", spindleAdjust: ".manual.spindlef.spindleminus", touchOff: ".manual.jogf.zerohome.zero", toolTouchOff: ".manual.jogf.zerohome.tooltouch", overrideLimits: ".manual.jogf.override" };
|
||||||
|
const prelude = `
|
||||||
|
proc _ {x} { return $x }
|
||||||
|
proc relief {condition args} {}
|
||||||
|
proc state {condition args} { foreach widget $args { set ::result($widget) [uplevel #0 [list expr $condition]] } }
|
||||||
|
proc enable_group {group} { set ::result($group) 1 }
|
||||||
|
proc disable_group {group} { set ::result($group) 0 }
|
||||||
|
proc set_mode_from_tab {} {}
|
||||||
|
proc unknown {name args} { if {[lindex $args 0] eq "configure"} { set ::result($name) [expr {[lindex $args 2] eq "normal"}] }; return "" }
|
||||||
|
foreach name {STATE_ESTOP STATE_ESTOP_RESET STATE_ON INTERP_IDLE INTERP_READING INTERP_PAUSED INTERP_WAITING TRAJ_MODE_FREE TRAJ_MODE_TELEOP KINEMATICS_IDENTITY KINEMATICS_BOTH TASK_MODE_AUTO} { set ::$name $name }
|
||||||
|
set ::_tabs_manual .manual
|
||||||
|
set ::manualgroup manualgroup
|
||||||
|
set ::mdigroup mdigroup
|
||||||
|
set ::has_editor 1
|
||||||
|
set ::has_ladder 0
|
||||||
|
set ::highlight_line -1
|
||||||
|
set ::task_mode MANUAL
|
||||||
|
set ::coord_type 0
|
||||||
|
set ::display_type 0
|
||||||
|
set ::block_delete 0
|
||||||
|
set ::optional_stop 0
|
||||||
|
set ::last_interp_state INTERP_IDLE
|
||||||
|
set ::last_task_state STATE_ON
|
||||||
|
set ::on_any_limit 0
|
||||||
|
set ::max_queued_mdi_commands 10
|
||||||
|
${formulas}
|
||||||
|
`;
|
||||||
|
const script = prelude + scenarios.map((s) => `
|
||||||
|
set ::task_state ${s.task_state}
|
||||||
|
set ::interp_state ${s.interp_state}
|
||||||
|
set ::taskfile ${s.taskfile ? "program.ngc" : '""'}
|
||||||
|
set ::queued_mdi_commands ${s.queued_mdi_commands}
|
||||||
|
set ::motion_mode TRAJ_MODE_${s.motion_mode}
|
||||||
|
set ::kinematics_type KINEMATICS_${s.kinematics_type}
|
||||||
|
set ::spindledir ${s.spindle_direction}
|
||||||
|
set ::tool ${s.tool || '"No tool"'}
|
||||||
|
set ::interp_pause ${Number(s.paused)}
|
||||||
|
update_state
|
||||||
|
puts [list ${Object.values(widgetMap).map((key) => `$::result(${key})`).join(" ")}]
|
||||||
|
`).join("\n");
|
||||||
|
const result = spawnSync("tclsh", [], { input: script, encoding: "utf8", maxBuffer: 4 * 1024 * 1024 });
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
assert.equal(result.stderr, "");
|
||||||
|
const rows = result.stdout.trim().split("\n");
|
||||||
|
assert.equal(rows.length, scenarios.length);
|
||||||
|
for (const [index, row] of rows.entries()) {
|
||||||
|
const expected = row.split(" ").map(Number);
|
||||||
|
const actual = axisSourceUi(scenarios[index]).gates;
|
||||||
|
Object.keys(widgetMap).forEach((name, column) => assert.equal(Number(actual[name].enabled), expected[column], `${name}: ${JSON.stringify(scenarios[index])}`));
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify({ schema: "axis-source-ui-oracle.v1", status: "verified", source: "axis.tcl::update_state", scenarios: scenarios.length, predicates: Object.keys(widgetMap).length, assertions: scenarios.length * Object.keys(widgetMap).length }));
|
||||||
46
scripts/test_xyzac_chips_layout_browser.mjs
Normal file
46
scripts/test_xyzac_chips_layout_browser.mjs
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
|
||||||
|
const browser = await chromium.launch({ executablePath: "/usr/bin/google-chrome", headless: true, args: ["--disable-gpu"] });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
const button = (id) => page.locator(`[data-axis-control="${id}"]`);
|
||||||
|
try {
|
||||||
|
await page.goto(process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/");
|
||||||
|
await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramLoading === "false");
|
||||||
|
await button("workspace-load-3d-chips").click();
|
||||||
|
await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramPath === "linuxcnc/nc_files/3D_Chips.ngc" && document.querySelector("main")?.dataset.axisProgramLoading === "false");
|
||||||
|
const dimensions = [];
|
||||||
|
for (const width of [390, 768, 1440]) {
|
||||||
|
await page.setViewportSize({ width, height: width === 1440 ? 1000 : 844 });
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
const size = await page.evaluate(() => {
|
||||||
|
const editor = document.querySelector("#axis-program");
|
||||||
|
return { width: innerWidth, pageWidth: document.documentElement.scrollWidth, pageHeight: document.documentElement.scrollHeight, editorHeight: editor.clientHeight, editorScrollHeight: editor.scrollHeight, renderedLines: document.querySelectorAll(".program-gutter span").length };
|
||||||
|
});
|
||||||
|
assert.ok(size.pageWidth <= width + 1 && size.pageHeight < 5000, JSON.stringify(size));
|
||||||
|
assert.ok(size.editorHeight > 180 && size.editorHeight < 700 && size.editorScrollHeight > 80000, JSON.stringify(size));
|
||||||
|
assert.ok(size.renderedLines < 100, JSON.stringify(size));
|
||||||
|
await button("program-editor").evaluate((editor) => { editor.scrollTop = editor.scrollHeight; editor.dispatchEvent(new Event("scroll")); });
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
assert.ok((await page.locator(".program-gutter span").last().textContent()).includes("4712"));
|
||||||
|
await button("program-editor").evaluate((editor) => { editor.scrollTop = 0; editor.dispatchEvent(new Event("scroll")); });
|
||||||
|
dimensions.push(size);
|
||||||
|
}
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await button("toolbar-power").click(); await button("manual-home-all").click(); await button("toolbar-run").click();
|
||||||
|
await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramStatus === "running");
|
||||||
|
const tick = () => page.locator("[data-xyzac-preview]").getAttribute("data-xyzac-tick").then(Number);
|
||||||
|
const start = await tick(); const wall = performance.now();
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
const ratio = (await tick() - start) * 10 / (performance.now() - wall);
|
||||||
|
assert.ok(ratio > .75, `mobile simulation ratio ${ratio}`);
|
||||||
|
await button("toolbar-stop").click();
|
||||||
|
await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramStatus === "aborted");
|
||||||
|
await button("toolbar-view-p").click();
|
||||||
|
await page.waitForTimeout(200);
|
||||||
|
await page.screenshot({ path: "/tmp/axis-chips-replacement-390.png", fullPage: true });
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
console.log(JSON.stringify({ schema: "xyzac-chips-layout.v1", status: "verified", dimensions, mobile_simulation_ratio: ratio, browser_errors: errors }));
|
||||||
|
} finally { await browser.close(); }
|
||||||
@@ -88,19 +88,23 @@ const stopped = await wait((value) => value.operation === "Operation IDLE" && va
|
|||||||
if (stopped.epoch !== running.epoch || stopped.liveCount < pausedStable.liveCount || stopped.previewCount !== loadedLong.previewCount) throw new Error(`stop_cleared_path:${JSON.stringify(stopped)}`);
|
if (stopped.epoch !== running.epoch || stopped.liveCount < pausedStable.liveCount || stopped.previewCount !== loadedLong.previewCount) throw new Error(`stop_cleared_path:${JSON.stringify(stopped)}`);
|
||||||
|
|
||||||
const loadedShort = await load("live-short.ngc", shortSource);
|
const loadedShort = await load("live-short.ngc", shortSource);
|
||||||
if (loadedShort.epoch <= stopped.epoch || loadedShort.liveCount !== 0) throw new Error(`load_did_not_clear_live_path:${JSON.stringify({ stopped, loadedShort })}`);
|
if (loadedShort.epoch !== stopped.epoch || loadedShort.liveCount < stopped.liveCount) throw new Error(`open_cleared_native_live_path:${JSON.stringify({ stopped, loadedShort })}`);
|
||||||
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run_short");
|
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run_short");
|
||||||
const completed = await wait((value) => value.program === "completed" && value.operation === "Operation IDLE" && value.liveCount >= 2, "short_completed");
|
const completed = await wait((value) => value.program === "completed" && value.operation === "Operation IDLE" && value.liveCount >= 2, "short_completed");
|
||||||
await page.waitForTimeout(1000);
|
await page.waitForTimeout(1000);
|
||||||
const completedStable = await state();
|
const completedStable = await state();
|
||||||
if (completedStable.epoch !== completed.epoch || completedStable.liveCount < completed.liveCount || completedStable.previewCount !== loadedShort.previewCount) throw new Error(`completion_cleared_path:${JSON.stringify({ completed, completedStable })}`);
|
if (completedStable.epoch !== completed.epoch || completedStable.liveCount < completed.liveCount || completedStable.previewCount !== loadedShort.previewCount) throw new Error(`completion_cleared_path:${JSON.stringify({ completed, completedStable })}`);
|
||||||
|
await click(main.locator('[data-axis-control="toolbar-reload"]'), "reload_same_file");
|
||||||
|
await wait((value) => value.program === "loaded" && value.loading === "false", "same_file_reloaded");
|
||||||
|
const reloaded = await state();
|
||||||
|
if (reloaded.epoch !== completedStable.epoch || reloaded.liveCount < completedStable.liveCount) throw new Error(`reload_cleared_native_live_path:${JSON.stringify(reloaded)}`);
|
||||||
|
|
||||||
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "rerun_short");
|
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "rerun_short");
|
||||||
const rerun = await wait((value) => value.epoch > completedStable.epoch && value.liveCount < completedStable.liveCount, "rerun_new_epoch");
|
const rerun = await wait((value) => value.program === "running" && value.epoch === completedStable.epoch && value.liveCount >= completedStable.liveCount, "rerun_retains_native_epoch");
|
||||||
await wait((value) => value.program === "completed" && value.operation === "Operation IDLE", "rerun_completed");
|
await wait((value) => value.program === "completed" && value.operation === "Operation IDLE", "rerun_completed");
|
||||||
|
|
||||||
const replacement = await load("live-replacement.ngc", replacementSource);
|
const replacement = await load("live-replacement.ngc", replacementSource);
|
||||||
if (replacement.epoch <= rerun.epoch || replacement.liveCount !== 0 || replacement.previewCount !== 1) throw new Error(`replacement_lifecycle_invalid:${JSON.stringify({ rerun, replacement })}`);
|
if (replacement.epoch !== rerun.epoch || replacement.liveCount < rerun.liveCount || replacement.previewCount !== 1) throw new Error(`replacement_lifecycle_invalid:${JSON.stringify({ rerun, replacement })}`);
|
||||||
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run_replacement");
|
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run_replacement");
|
||||||
await wait((value) => value.liveCount >= 1 && Number(value.line) > 0, "replacement_trace");
|
await wait((value) => value.liveCount >= 1 && Number(value.line) > 0, "replacement_trace");
|
||||||
await wait((value) => value.operation === "Operation IDLE", "replacement_idle");
|
await wait((value) => value.operation === "Operation IDLE", "replacement_idle");
|
||||||
@@ -109,6 +113,15 @@ await click(main.locator('button[aria-label="Clear live plot [Ctrl-K]"]'), "clea
|
|||||||
const cleared = await wait((value) => value.epoch > beforeClear.epoch && value.liveCount <= 1, "cleared_live_plot");
|
const cleared = await wait((value) => value.epoch > beforeClear.epoch && value.liveCount <= 1, "cleared_live_plot");
|
||||||
if (cleared.previewCount !== beforeClear.previewCount) throw new Error(`clear_removed_static_preview:${JSON.stringify({ beforeClear, cleared })}`);
|
if (cleared.previewCount !== beforeClear.previewCount) throw new Error(`clear_removed_static_preview:${JSON.stringify({ beforeClear, cleared })}`);
|
||||||
|
|
||||||
|
await load("live-key-clear.ngc", shortSource);
|
||||||
|
await click(main.locator('[data-axis-control="toolbar-run"]'), "run_for_keyboard_clear");
|
||||||
|
await wait((value) => value.operation === "Operation IDLE" && value.program === "completed" && value.liveCount > 2, "regenerate_live_trace");
|
||||||
|
const beforeKeyClear = await state();
|
||||||
|
await page.evaluate(() => document.activeElement?.blur());
|
||||||
|
await page.keyboard.press("Control+k");
|
||||||
|
const keyCleared = await wait((value) => value.epoch === beforeKeyClear.epoch + 1 && value.liveCount === 0, "ctrl_k_clears_live_trace");
|
||||||
|
if (keyCleared.previewCount !== beforeKeyClear.previewCount || keyCleared.queue !== beforeKeyClear.queue || keyCleared.program !== beforeKeyClear.program) throw new Error("ctrl_k_changed_program_or_queue");
|
||||||
|
|
||||||
const desktopPixels = await canvasPixels(page);
|
const desktopPixels = await canvasPixels(page);
|
||||||
if (desktopPixels.changed < 100) throw new Error(`desktop_canvas_blank:${JSON.stringify(desktopPixels)}`);
|
if (desktopPixels.changed < 100) throw new Error(`desktop_canvas_blank:${JSON.stringify(desktopPixels)}`);
|
||||||
await page.screenshot({ path: "/tmp/xyzac-live-path-lifecycle-desktop.png", fullPage: true });
|
await page.screenshot({ path: "/tmp/xyzac-live-path-lifecycle-desktop.png", fullPage: true });
|
||||||
@@ -118,5 +131,6 @@ const mobilePixels = await canvasPixels(page);
|
|||||||
if (mobilePixels.changed < 100) throw new Error(`mobile_canvas_blank:${JSON.stringify(mobilePixels)}`);
|
if (mobilePixels.changed < 100) throw new Error(`mobile_canvas_blank:${JSON.stringify(mobilePixels)}`);
|
||||||
await page.screenshot({ path: "/tmp/xyzac-live-path-lifecycle-mobile.png", fullPage: true });
|
await page.screenshot({ path: "/tmp/xyzac-live-path-lifecycle-mobile.png", fullPage: true });
|
||||||
|
|
||||||
console.log(JSON.stringify({ schema: "xyzac-live-path-lifecycle-browser.v1", status: browserErrors.length ? "failed" : "verified", lifecycle: { loadedLong, running, paused: pausedStable, resumed, stopped, loadedShort, completed: completedStable, rerun, replacement, beforeClear, cleared }, canvas_pixels: { desktop: desktopPixels, mobile: mobilePixels }, browser_errors: browserErrors, screenshots: ["/tmp/xyzac-live-path-lifecycle-desktop.png", "/tmp/xyzac-live-path-lifecycle-mobile.png"] }));
|
console.log(JSON.stringify({ schema: "xyzac-live-path-lifecycle-browser.v1", status: browserErrors.length ? "failed" : "verified", lifecycle: { loadedLong, running, paused: pausedStable, resumed, stopped, loadedShort, completed: completedStable, reloaded, rerun, replacement, beforeClear, cleared, beforeKeyClear, keyCleared }, canvas_pixels: { desktop: desktopPixels, mobile: mobilePixels }, browser_errors: browserErrors, screenshots: ["/tmp/xyzac-live-path-lifecycle-desktop.png", "/tmp/xyzac-live-path-lifecycle-mobile.png"] }));
|
||||||
|
if (browserErrors.length) process.exitCode = 1;
|
||||||
await browser.close();
|
await browser.close();
|
||||||
|
|||||||
@@ -113,10 +113,8 @@ try {
|
|||||||
results.push(await record("VISUAL CLEAR", async () => { await click(main.locator('button[aria-label="Clear live plot [Ctrl-K]"]'), "visual_clear"); await wait((s) => s.error === null, "visual_clear_ack"); }));
|
results.push(await record("VISUAL CLEAR", async () => { await click(main.locator('button[aria-label="Clear live plot [Ctrl-K]"]'), "visual_clear"); await wait((s) => s.error === null, "visual_clear_ack"); }));
|
||||||
results.push(await record("MANUAL MODE", async () => { await main.getByRole("tab", { name: "Manual Control [F3]" }).click(); await wait((s) => s.mode === "MANUAL" || s.command?.includes("SET_MODE acknowledged"), "manual_mode_ack"); }));
|
results.push(await record("MANUAL MODE", async () => { await main.getByRole("tab", { name: "Manual Control [F3]" }).click(); await wait((s) => s.mode === "MANUAL" || s.command?.includes("SET_MODE acknowledged"), "manual_mode_ack"); }));
|
||||||
results.push(await record("HOME/TOUCH OFF boundaries", async () => {
|
results.push(await record("HOME/TOUCH OFF boundaries", async () => {
|
||||||
const dialogCount = dialogs.length;
|
|
||||||
await click(main.locator('button[data-axis-control="manual-home-axis"]'), "home_axis_repeat");
|
await click(main.locator('button[data-axis-control="manual-home-axis"]'), "home_axis_repeat");
|
||||||
await page.waitForTimeout(100);
|
await wait((s) => s.command?.includes("home_joint_requires_joint_mode"), "world_axis_home_source_rejection");
|
||||||
if (dialogs.length !== dialogCount + 1 || !dialogs.at(-1)?.message.includes("already homed")) throw new Error("home_axis_confirmation_missing");
|
|
||||||
await click(main.locator('button[data-axis-control="manual-touch-off"]'), "touch_off");
|
await click(main.locator('button[data-axis-control="manual-touch-off"]'), "touch_off");
|
||||||
await wait((s) => s.error === null && s.command?.includes("TOUCH_OFF acknowledged"), "touch_off_ack");
|
await wait((s) => s.error === null && s.command?.includes("TOUCH_OFF acknowledged"), "touch_off_ack");
|
||||||
if (!await main.locator('[data-axis-control="manual-tool-touch-off"]').isDisabled()) throw new Error("tool_touch_off_must_be_disabled");
|
if (!await main.locator('[data-axis-control="manual-tool-touch-off"]').isDisabled()) throw new Error("tool_touch_off_must_be_disabled");
|
||||||
@@ -138,7 +136,7 @@ try {
|
|||||||
results.push(await record("SPINDLE", async () => {
|
results.push(await record("SPINDLE", async () => {
|
||||||
await click(main.locator('button[aria-label="Spindle clockwise"]'), "spindle_cw");
|
await click(main.locator('button[aria-label="Spindle clockwise"]'), "spindle_cw");
|
||||||
await wait((s) => s.error === null && s.command?.includes("SPINDLE_COMMAND acknowledged"), "spindle_cw_ack");
|
await wait((s) => s.error === null && s.command?.includes("SPINDLE_COMMAND acknowledged"), "spindle_cw_ack");
|
||||||
for (const id of ["manual-spindle-decrease", "manual-spindle-increase"]) {
|
for (const id of ["manual-spindle-increase", "manual-spindle-decrease"]) {
|
||||||
const adjust = main.locator(`button[data-axis-control="${id}"]`);
|
const adjust = main.locator(`button[data-axis-control="${id}"]`);
|
||||||
await adjust.dispatchEvent("pointerdown", { pointerId: 1, button: 0 });
|
await adjust.dispatchEvent("pointerdown", { pointerId: 1, button: 0 });
|
||||||
await wait((s) => s.error === null && s.command?.includes("SPINDLE_COMMAND acknowledged"), `${id}_press_ack`);
|
await wait((s) => s.error === null && s.command?.includes("SPINDLE_COMMAND acknowledged"), `${id}_press_ack`);
|
||||||
@@ -215,6 +213,30 @@ try {
|
|||||||
results.push(await record("DISABLED unsupported buttons", async () => {
|
results.push(await record("DISABLED unsupported buttons", async () => {
|
||||||
for (const name of ["Toggle skip lines with slash", "Toggle optional pause"]) if (!await main.locator(`button[aria-label="${name}"]`).isDisabled()) throw new Error(`${name}:must_be_disabled`);
|
for (const name of ["Toggle skip lines with slash", "Toggle optional pause"]) if (!await main.locator(`button[aria-label="${name}"]`).isDisabled()) throw new Error(`${name}:must_be_disabled`);
|
||||||
}));
|
}));
|
||||||
|
results.push(await record("MENUS, ABOUT and CASE SELECTION", async () => {
|
||||||
|
await main.locator('[data-axis-control="workspace-case-select"]').selectOption({ index: 0 });
|
||||||
|
await main.locator('[data-axis-control="menu-file"]').click();
|
||||||
|
for (const id of ["menu-file-edit-tool-data", "menu-file-quit"]) {
|
||||||
|
const button = main.locator(`[data-axis-control="${id}"]`);
|
||||||
|
if (!await button.isDisabled() || !await button.getAttribute("title")) throw new Error(`${id}:missing_boundary`);
|
||||||
|
}
|
||||||
|
await main.locator('[data-axis-control="menu-help"]').click();
|
||||||
|
await main.locator('[data-axis-control="menu-help-about"]').click();
|
||||||
|
await main.locator('[data-axis-control="about-close"]').click();
|
||||||
|
await main.locator('[data-axis-control="tab-diagnostics"]').click();
|
||||||
|
await main.locator('[data-axis-control="tab-manual"]').click();
|
||||||
|
}));
|
||||||
|
results.push(await record("POWER OFF and ESTOP recovery", async () => {
|
||||||
|
await click(main.locator('[data-axis-control="toolbar-power"]'), "power_off");
|
||||||
|
await wait((s) => s.machine !== "Machine ON", "power_off_ack");
|
||||||
|
await click(main.locator('[data-axis-control="toolbar-estop"]'), "estop");
|
||||||
|
await wait((s) => s.machine === "Machine ESTOP", "estop_ack");
|
||||||
|
if (!await main.locator('[data-axis-control="toolbar-power"]').isDisabled()) throw new Error("power_enabled_during_estop");
|
||||||
|
await click(main.locator('[data-axis-control="toolbar-estop"]'), "release_estop");
|
||||||
|
await wait((s) => s.machine !== "Machine ESTOP", "release_estop_ack");
|
||||||
|
await click(main.locator('[data-axis-control="toolbar-power"]'), "power_restore");
|
||||||
|
await wait((s) => s.machine === "Machine ON", "power_restore_ack");
|
||||||
|
}));
|
||||||
} finally {
|
} finally {
|
||||||
const buttons = await main.locator("button").evaluateAll((items) => items.map((button) => ({ text: button.textContent?.trim() ?? "", aria: button.getAttribute("aria-label"), disabled: button.disabled })));
|
const buttons = await main.locator("button").evaluateAll((items) => items.map((button) => ({ text: button.textContent?.trim() ?? "", aria: button.getAttribute("aria-label"), disabled: button.disabled })));
|
||||||
const visibleLabels = buttons.map((button) => button.aria ?? button.text).filter(Boolean);
|
const visibleLabels = buttons.map((button) => button.aria ?? button.text).filter(Boolean);
|
||||||
@@ -222,5 +244,6 @@ try {
|
|||||||
for (const [label, count] of duplicateLabels) if (["Emergency Stop [F1]", "Power on [F2]", "Home all axes", "Run program", "Clear plot", "Stop Jog", "Stop Spindle"].includes(label)) failures.push({ name: "duplicate_visible_control", result: "failed", reason: `${label}:${count}` });
|
for (const [label, count] of duplicateLabels) if (["Emergency Stop [F1]", "Power on [F2]", "Home all axes", "Run program", "Clear plot", "Stop Jog", "Stop Spindle"].includes(label)) failures.push({ name: "duplicate_visible_control", result: "failed", reason: `${label}:${count}` });
|
||||||
await page.screenshot({ path: "/tmp/xyzac-panel-buttons-browser.png", fullPage: true });
|
await page.screenshot({ path: "/tmp/xyzac-panel-buttons-browser.png", fullPage: true });
|
||||||
console.log(JSON.stringify({ schema: "xyzac-panel-buttons-browser.v1", status: failures.length === 0 && browserErrors.length === 0 ? "verified" : "failed", url, results, button_count: buttons.length, disabled_button_count: buttons.filter((button) => button.disabled).length, duplicate_labels: duplicateLabels, failures, browser_errors: browserErrors, final_state: await state(), screenshot: "/tmp/xyzac-panel-buttons-browser.png" }));
|
console.log(JSON.stringify({ schema: "xyzac-panel-buttons-browser.v1", status: failures.length === 0 && browserErrors.length === 0 ? "verified" : "failed", url, results, button_count: buttons.length, disabled_button_count: buttons.filter((button) => button.disabled).length, duplicate_labels: duplicateLabels, failures, browser_errors: browserErrors, final_state: await state(), screenshot: "/tmp/xyzac-panel-buttons-browser.png" }));
|
||||||
|
if (failures.length || browserErrors.length) process.exitCode = 1;
|
||||||
await browser.close();
|
await browser.close();
|
||||||
}
|
}
|
||||||
|
|||||||
122
scripts/test_xyzac_pause_resume_browser.mjs
Normal file
122
scripts/test_xyzac_pause_resume_browser.mjs
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
assert.ok(process.env.PLAYWRIGHT_MODULE, "PLAYWRIGHT_MODULE is required");
|
||||||
|
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
|
||||||
|
const url = process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/";
|
||||||
|
const browser = await chromium.launch({ executablePath: "/usr/bin/google-chrome", headless: true, args: ["--disable-gpu"] });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||||
|
const browserErrors = [], checks = [];
|
||||||
|
page.on("pageerror", (error) => browserErrors.push(error.message));
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.axisPauseRequests = []; window.axisPauseResults = [];
|
||||||
|
const NativeWorker = window.Worker;
|
||||||
|
window.Worker = class extends NativeWorker {
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args);
|
||||||
|
this.addEventListener("message", ({ data }) => window.axisPauseResults.push({ seq: data.seq, kind: data.kind, status: data.payload?.status, error: data.payload?.gate_reason ?? data.payload?.message_key ?? data.payload?.code }));
|
||||||
|
}
|
||||||
|
postMessage(data, ...args) {
|
||||||
|
if (!data.kind.startsWith("UI_STATE_")) window.axisPauseRequests.push({ seq: data.seq, kind: data.kind });
|
||||||
|
super.postMessage(data, ...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
|
||||||
|
const state = () => page.locator("main.axis-app").evaluate((main) => {
|
||||||
|
const viewer = main.querySelector("[data-xyzac-preview]");
|
||||||
|
return { loading: main.dataset.axisProgramLoading, program: main.dataset.axisProgramStatus, path: main.dataset.axisProgramPath,
|
||||||
|
mode: main.dataset.axisTaskMode, operation: main.querySelector("[data-axis-state-operation]")?.dataset.axisStateOperation,
|
||||||
|
pose: viewer?.dataset.xyzacLivePose, tick: Number(viewer?.dataset.xyzacTick), epoch: viewer?.dataset.xyzacLivePathEpoch,
|
||||||
|
queue: Number(main.dataset.axisTpQueueDepth), command: main.querySelector(".command-status")?.textContent,
|
||||||
|
error: main.querySelector(".diagnostic-error")?.textContent };
|
||||||
|
});
|
||||||
|
const wait = async (predicate, name, timeout = 30000) => {
|
||||||
|
const start = performance.now(); let latest;
|
||||||
|
while (performance.now() - start < timeout) { latest = await state(); if (predicate(latest)) return latest; await page.waitForTimeout(50); }
|
||||||
|
throw new Error(`${name}: ${JSON.stringify(latest)}`);
|
||||||
|
};
|
||||||
|
const action = async (id, kind) => {
|
||||||
|
const seq = await page.evaluate(() => window.axisPauseRequests.at(-1)?.seq ?? 0);
|
||||||
|
await control(id).click();
|
||||||
|
await page.waitForFunction(({ seq, kind }) => window.axisPauseRequests.some((request) => request.seq > seq && request.kind === kind && window.axisPauseResults.some((r) => r.seq === request.seq && r.kind === "COMMAND_RESULT")), { seq, kind });
|
||||||
|
};
|
||||||
|
const clearFocus = () => page.evaluate(() => { document.activeElement?.blur(); });
|
||||||
|
const moveAfterResume = async (paused) => {
|
||||||
|
await action("toolbar-pause-resume", "PROGRAM_RESUME");
|
||||||
|
const resumed = await wait((s) => s.operation === "RUNNING" && s.tick >= paused.tick + 10 && s.pose !== paused.pose, "resume must move");
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
assert.ok((await state()).tick > resumed.tick, "sampling stopped after one resume frame");
|
||||||
|
return resumed;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await page.goto(url);
|
||||||
|
await wait((s) => s.loading === "false" && s.program === "loaded", "startup");
|
||||||
|
await action("toolbar-power", "POWER"); await action("manual-home-all", "HOME");
|
||||||
|
for (const program of ["configured", "chips"]) {
|
||||||
|
if (program === "chips") {
|
||||||
|
await action("workspace-load-3d-chips", "PROGRAM_LOAD");
|
||||||
|
await wait((s) => s.path === "linuxcnc/nc_files/3D_Chips.ngc" && s.loading === "false", "chips load");
|
||||||
|
}
|
||||||
|
await action("toolbar-run", "PROGRAM_RUN");
|
||||||
|
await wait((s) => s.operation === "RUNNING" && s.queue > 0, "running");
|
||||||
|
for (let round = 0; round < 3; round++) {
|
||||||
|
await action("toolbar-pause-resume", "PROGRAM_PAUSE");
|
||||||
|
const paused = await wait((s) => s.operation === "PAUSED", "paused");
|
||||||
|
await action("toolbar-clear-live-plot", "VISUAL_CLEAR");
|
||||||
|
for (const id of ["override-spindle", "override-max-velocity", "override-feed", "override-jog-speed"]) {
|
||||||
|
await control(id).focus(); await page.keyboard.press("ArrowLeft"); await page.waitForTimeout(150);
|
||||||
|
await page.keyboard.press("ArrowRight"); await page.waitForTimeout(150);
|
||||||
|
}
|
||||||
|
await page.waitForTimeout(150);
|
||||||
|
assert.equal((await state()).pose, paused.pose, "a paused control changed position");
|
||||||
|
assert.ok(await control("toolbar-pause-resume").isEnabled());
|
||||||
|
await moveAfterResume(paused);
|
||||||
|
}
|
||||||
|
await clearFocus();
|
||||||
|
// A stale/duplicate Resume is a task rejection, not a connection failure.
|
||||||
|
await page.keyboard.press("s");
|
||||||
|
await wait((s) => s.command?.includes("PROGRAM_RESUME blocked"), "rejected running resume");
|
||||||
|
const refused = await state();
|
||||||
|
await wait((s) => s.tick > refused.tick + 5, "refused command must not stop a running task");
|
||||||
|
await action("toolbar-pause-resume", "PROGRAM_PAUSE");
|
||||||
|
const paused = await state();
|
||||||
|
await clearFocus(); await page.keyboard.press("p");
|
||||||
|
await wait((s) => s.command?.includes("PROGRAM_PAUSE blocked"), "duplicate pause rejection");
|
||||||
|
await moveAfterResume(paused);
|
||||||
|
await action("toolbar-stop", "PROGRAM_ABORT");
|
||||||
|
await wait((s) => s.operation === "IDLE" && s.queue === 0, "stop after resume");
|
||||||
|
checks.push(`${program}: repeated pause/clear/overrides/resume + rejection/stop`);
|
||||||
|
}
|
||||||
|
await action("toolbar-run", "PROGRAM_RUN");
|
||||||
|
await wait((s) => s.queue > 0, "step precondition");
|
||||||
|
await action("toolbar-pause-resume", "PROGRAM_PAUSE");
|
||||||
|
const beforeStep = await state();
|
||||||
|
await action("toolbar-step", "PROGRAM_STEP");
|
||||||
|
await wait((s) => s.pose !== beforeStep.pose, "paused Step moves");
|
||||||
|
const afterStep = await state();
|
||||||
|
await moveAfterResume(afterStep);
|
||||||
|
await action("toolbar-stop", "PROGRAM_ABORT");
|
||||||
|
checks.push("paused Step then Resume then Stop");
|
||||||
|
const requests = await page.evaluate(() => window.axisPauseRequests);
|
||||||
|
requests.forEach((request, index) => assert.equal(request.seq, index + 1));
|
||||||
|
const errors = await page.evaluate(() => window.axisPauseResults.filter((r) => r.kind === "ERROR"));
|
||||||
|
assert.equal(errors.length, 4, JSON.stringify(errors));
|
||||||
|
assert.ok(errors.every((r) => !r.error?.startsWith("protocol.")), JSON.stringify(errors));
|
||||||
|
assert.deepEqual(browserErrors, []);
|
||||||
|
await page.screenshot({ path: "/tmp/axis-pause-resume-desktop.png", fullPage: true });
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await action("toolbar-run", "PROGRAM_RUN");
|
||||||
|
await wait((s) => s.queue > 0, "mobile running");
|
||||||
|
await action("toolbar-pause-resume", "PROGRAM_PAUSE");
|
||||||
|
const mobilePaused = await state();
|
||||||
|
await action("toolbar-clear-live-plot", "VISUAL_CLEAR");
|
||||||
|
await moveAfterResume(mobilePaused);
|
||||||
|
await action("toolbar-stop", "PROGRAM_ABORT");
|
||||||
|
await page.screenshot({ path: "/tmp/axis-pause-resume-mobile.png", fullPage: true });
|
||||||
|
checks.push("mobile pause/clear/resume/stop");
|
||||||
|
console.log(JSON.stringify({ schema: "xyzac-pause-resume-browser.v1", status: "verified", url, checks, ordered_commands: requests.length, expected_task_rejections: errors.length, browser_errors: browserErrors }));
|
||||||
|
} catch (error) {
|
||||||
|
process.stderr.write(JSON.stringify({ state: await state(), recent: await page.evaluate(() => ({ requests: window.axisPauseRequests.slice(-8), results: window.axisPauseResults.slice(-8) })) }) + "\n");
|
||||||
|
throw error;
|
||||||
|
} finally { await browser.close(); }
|
||||||
120
scripts/test_xyzac_second_program_browser.mjs
Normal file
120
scripts/test_xyzac_second_program_browser.mjs
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { pathToFileURL } from "node:url";
|
||||||
|
|
||||||
|
assert.ok(process.env.PLAYWRIGHT_MODULE, "PLAYWRIGHT_MODULE is required");
|
||||||
|
const { chromium } = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
|
||||||
|
const url = process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/";
|
||||||
|
const browser = await chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--disable-gpu"] });
|
||||||
|
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||||
|
const errors = [];
|
||||||
|
page.on("pageerror", (error) => errors.push(error.message));
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const NativeWorker = window.Worker;
|
||||||
|
window.stepTimes = [];
|
||||||
|
window.workerErrors = [];
|
||||||
|
window.Worker = class extends NativeWorker {
|
||||||
|
pending = new Map();
|
||||||
|
constructor(...args) {
|
||||||
|
super(...args);
|
||||||
|
this.addEventListener("message", (event) => {
|
||||||
|
const value = event.data, request = this.pending.get(value.seq);
|
||||||
|
if (request?.kind === "SIM_STEP") window.stepTimes.push(performance.now() - request.time);
|
||||||
|
if (value.kind === "ERROR") window.workerErrors.push(value.payload?.gate_reason ?? value.payload?.message_key ?? value.payload?.code);
|
||||||
|
if (request) this.pending.delete(value.seq);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
postMessage(value, ...args) {
|
||||||
|
this.pending.set(value.seq, { kind: value.kind, time: performance.now() });
|
||||||
|
super.postMessage(value, ...args);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const control = (id) => page.locator(`[data-axis-control="${id}"]`);
|
||||||
|
const state = () => page.locator("main.axis-app").evaluate((main) => {
|
||||||
|
const viewer = main.querySelector("[data-xyzac-preview]");
|
||||||
|
return { path: main.dataset.axisProgramPath, loading: main.dataset.axisProgramLoading, status: main.dataset.axisProgramStatus, mode: main.dataset.axisTaskMode, queue: Number(main.dataset.axisTpQueueDepth), line: Number(main.dataset.axisCurrentLine), tick: Number(viewer?.dataset.xyzacTick), epoch: Number(viewer?.dataset.xyzacLivePathEpoch), live: Number(viewer?.dataset.xyzacLiveTraceCount), preview: Number(viewer?.dataset.xyzacToolpathCount), draws: Number(viewer?.dataset.xyzacPreviewDrawCalls), vertices: Number(viewer?.dataset.xyzacPreviewVertices), pose: JSON.parse(viewer?.dataset.xyzacLivePose ?? "{}"), error: main.querySelector(".diagnostic-error")?.textContent ?? null };
|
||||||
|
});
|
||||||
|
const wait = async (predicate, name, timeout = 30000) => {
|
||||||
|
const start = performance.now(); let latest;
|
||||||
|
while (performance.now() - start < timeout) { latest = await state(); if (predicate(latest)) return latest; await page.waitForTimeout(100); }
|
||||||
|
throw new Error(`${name}: ${JSON.stringify(latest)}`);
|
||||||
|
};
|
||||||
|
const sampleRun = async () => {
|
||||||
|
await control("toolbar-run").click();
|
||||||
|
await wait((s) => s.status === "running", "second run");
|
||||||
|
await page.evaluate(() => { window.stepTimes = []; });
|
||||||
|
const before = await state(), started = performance.now();
|
||||||
|
await page.waitForTimeout(5000);
|
||||||
|
const after = await state(), wall = performance.now() - started;
|
||||||
|
const simulated = (after.tick - before.tick) * 10;
|
||||||
|
const latencies = await page.evaluate(() => window.stepTimes);
|
||||||
|
const p95 = latencies.sort((a, b) => a - b)[Math.floor(latencies.length * .95)];
|
||||||
|
assert.ok(simulated / wall >= .75, `simulation fell behind real time: ${simulated}/${wall}`);
|
||||||
|
assert.ok(p95 < 150, `Worker p95 ${p95} ms`);
|
||||||
|
assert.ok(after.line > before.line && after.live > 0 && after.queue > 0);
|
||||||
|
const pauseStart = performance.now();
|
||||||
|
await control("toolbar-pause-resume").click();
|
||||||
|
const paused = await wait((s) => s.status === "paused", "pause");
|
||||||
|
const pauseMs = performance.now() - pauseStart;
|
||||||
|
await page.waitForTimeout(350);
|
||||||
|
assert.deepEqual((await state()).pose, paused.pose);
|
||||||
|
await control("toolbar-pause-resume").click();
|
||||||
|
await wait((s) => s.status === "running", "resume");
|
||||||
|
const stopStart = performance.now();
|
||||||
|
await control("toolbar-stop").click();
|
||||||
|
await wait((s) => s.status === "aborted" && s.queue === 0, "stop");
|
||||||
|
const stopMs = performance.now() - stopStart;
|
||||||
|
assert.ok(pauseMs < 1500 && stopMs < 1500);
|
||||||
|
return { wall_ms: wall, simulated_ms: simulated, ratio: simulated / wall, p95_step_ms: p95, pause_ms: pauseMs, stop_ms: stopMs, line: after.line };
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await page.goto(url, { waitUntil: "domcontentloaded" });
|
||||||
|
await wait((s) => s.loading === "false" && s.preview > 0, "startup");
|
||||||
|
await control("toolbar-power").click();
|
||||||
|
await control("manual-home-all").click();
|
||||||
|
await control("toolbar-run").click();
|
||||||
|
process.stderr.write("[second-program] running configured first program to completion\n");
|
||||||
|
const first = await wait((s) => s.status === "completed" && s.queue === 0 && s.live > 0, "first complete", 180000);
|
||||||
|
// Chips begins at Z10. The configured first program ends at Z10, so its
|
||||||
|
// zero-motion positioning block correctly produces no preview segment.
|
||||||
|
const expectedSegments = Math.abs(first.pose.z - 10) < 1e-9 ? 4683 : 4684;
|
||||||
|
const loadStart = performance.now();
|
||||||
|
await control("workspace-load-3d-chips").click();
|
||||||
|
const chips = await wait((s) => s.path === "linuxcnc/nc_files/3D_Chips.ngc" && s.loading === "false" && s.preview === expectedSegments, "chips replacement", 15000);
|
||||||
|
const loadMs = performance.now() - loadStart;
|
||||||
|
assert.equal(chips.epoch, first.epoch);
|
||||||
|
assert.ok(chips.live >= first.live, "native Open must retain live history");
|
||||||
|
assert.ok(chips.draws <= 2 && chips.vertices === expectedSegments * 2);
|
||||||
|
assert.deepEqual(chips.pose, first.pose, "file replacement changed machine pose");
|
||||||
|
process.stderr.write(`[second-program] replaced: ${loadMs.toFixed(0)} ms, ${chips.draws} static draw calls, native live trace retained\n`);
|
||||||
|
await control("toolbar-clear-live-plot").click();
|
||||||
|
const explicitlyCleared = await wait((s) => s.live === 0 && s.epoch === chips.epoch + 1, "native explicit clear");
|
||||||
|
const execution = await sampleRun();
|
||||||
|
const stopped = await state();
|
||||||
|
await control("toolbar-reload").click();
|
||||||
|
const same = await wait((s) => s.status === "loaded" && s.loading === "false", "same reload");
|
||||||
|
assert.equal(same.epoch, stopped.epoch, "same source Reload must retain trace");
|
||||||
|
assert.ok(same.live >= stopped.live);
|
||||||
|
await control("toolbar-clear-live-plot").click();
|
||||||
|
await wait((s) => s.live === 0 && s.epoch > same.epoch, "explicit clear");
|
||||||
|
for (const width of [1440, 390]) {
|
||||||
|
await page.setViewportSize({ width, height: width === 390 ? 844 : 1000 });
|
||||||
|
await control("toolbar-view-p").click();
|
||||||
|
await page.waitForTimeout(250);
|
||||||
|
const colors = await page.locator("[data-xyzac-preview] canvas").evaluate((canvas) => {
|
||||||
|
const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
|
||||||
|
const pixels = new Uint8Array(canvas.width * canvas.height * 4);
|
||||||
|
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
|
||||||
|
const colors = new Set();
|
||||||
|
for (let i = 0; i < pixels.length; i += 16) colors.add((pixels[i] << 16) | (pixels[i + 1] << 8) | pixels[i + 2]);
|
||||||
|
return colors.size;
|
||||||
|
});
|
||||||
|
assert.ok(colors > 20);
|
||||||
|
assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1));
|
||||||
|
assert.ok(await page.evaluate(() => document.documentElement.scrollHeight < 5000), "long editor expanded the entire page");
|
||||||
|
await page.screenshot({ path: `/tmp/axis-chips-replacement-${width}.png`, fullPage: true });
|
||||||
|
}
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
assert.deepEqual(await page.evaluate(() => window.workerErrors), []);
|
||||||
|
console.log(JSON.stringify({ schema: "xyzac-second-program-browser.v1", status: "verified", url, first_live_points: first.live, load_retained_live_points: chips.live, new_epoch: explicitlyCleared.epoch, cleared_live_points: explicitlyCleared.live, load_ms: loadMs, preview_segments: chips.preview, static_draw_calls: chips.draws, execution, browser_errors: errors }));
|
||||||
|
} finally { await browser.close(); }
|
||||||
@@ -72,6 +72,7 @@ if(NOT EMSCRIPTEN)
|
|||||||
add_test(NAME sim_api_test COMMAND sim_api_test)
|
add_test(NAME sim_api_test COMMAND sim_api_test)
|
||||||
|
|
||||||
add_executable(sim_protocol_test tests/sim_protocol_test.c)
|
add_executable(sim_protocol_test tests/sim_protocol_test.c)
|
||||||
|
target_compile_options(sim_protocol_test PRIVATE -UNDEBUG)
|
||||||
target_link_libraries(sim_protocol_test PRIVATE sim_core)
|
target_link_libraries(sim_protocol_test PRIVATE sim_core)
|
||||||
target_include_directories(sim_protocol_test PRIVATE include)
|
target_include_directories(sim_protocol_test PRIVATE include)
|
||||||
add_test(NAME sim_protocol_test COMMAND sim_protocol_test)
|
add_test(NAME sim_protocol_test COMMAND sim_protocol_test)
|
||||||
@@ -308,7 +309,7 @@ if(EMSCRIPTEN)
|
|||||||
string(REPLACE "_sim_device_init" "_sim_device_io_size','_sim_device_init" _sim_module_link_flags "${_sim_module_link_flags}")
|
string(REPLACE "_sim_device_init" "_sim_device_io_size','_sim_device_init" _sim_module_link_flags "${_sim_module_link_flags}")
|
||||||
string(REPLACE "_sim_motion_step','_sim_motion_read_state" "_sim_motion_step','_sim_motion_set_profile','_sim_motion_set_lookahead','_sim_motion_set_feed_override','_sim_motion_peek','_sim_motion_read_state" _sim_module_link_flags "${_sim_module_link_flags}")
|
string(REPLACE "_sim_motion_step','_sim_motion_read_state" "_sim_motion_step','_sim_motion_set_profile','_sim_motion_set_lookahead','_sim_motion_set_feed_override','_sim_motion_peek','_sim_motion_read_state" _sim_module_link_flags "${_sim_module_link_flags}")
|
||||||
string(REPLACE "_sim_motion_state_hash','_sim_motion_canonical" "_sim_motion_state_hash','_sim_motion_plan_hash','_sim_motion_canonical" _sim_module_link_flags "${_sim_module_link_flags}")
|
string(REPLACE "_sim_motion_state_hash','_sim_motion_canonical" "_sim_motion_state_hash','_sim_motion_plan_hash','_sim_motion_canonical" _sim_module_link_flags "${_sim_module_link_flags}")
|
||||||
string(REPLACE "_sim_motion_init" "_sim_motion_init','_sim_linuxcnc_tp_size','_sim_linuxcnc_tp_queue_capacity','_sim_linuxcnc_tp_init','_sim_linuxcnc_tp_reset','_sim_linuxcnc_tp_set_profile','_sim_linuxcnc_tp_set_feed_override','_sim_linuxcnc_tp_set_velocity_scale','_sim_linuxcnc_tp_set_jog_scale','_sim_linuxcnc_tp_set_axis_limit','_sim_linuxcnc_tp_jog','_sim_linuxcnc_tp_velocity_scale','_sim_linuxcnc_tp_velocity_limit','_sim_linuxcnc_tp_jog_scale','_sim_linuxcnc_tp_jog_velocity','_sim_linuxcnc_tp_feed_override','_sim_linuxcnc_tp_set_dynamics','_sim_linuxcnc_tp_set_paused','_sim_linuxcnc_tp_abort','_sim_linuxcnc_tp_set_position','_sim_linuxcnc_tp_set_goal_position','_sim_linuxcnc_tp_add_line','_sim_linuxcnc_tp_add_circle','_sim_linuxcnc_tp_step','_sim_linuxcnc_tp_idle','_sim_linuxcnc_tp_read_state','_sim_linuxcnc_tp_queue_depth','_sim_linuxcnc_tp_queue_segment','_sim_linuxcnc_tp_restore_progress','_sim_linuxcnc_tp_state_hash','_sim_linuxcnc_tp_canonical" _sim_module_link_flags "${_sim_module_link_flags}")
|
string(REPLACE "_sim_motion_init" "_sim_motion_init','_sim_linuxcnc_tp_size','_sim_linuxcnc_tp_queue_capacity','_sim_linuxcnc_tp_init','_sim_linuxcnc_tp_reset','_sim_linuxcnc_tp_set_profile','_sim_linuxcnc_tp_set_feed_override','_sim_linuxcnc_tp_set_velocity_scale','_sim_linuxcnc_tp_set_jog_scale','_sim_linuxcnc_tp_set_axis_limit','_sim_linuxcnc_tp_jog','_sim_linuxcnc_tp_velocity_scale','_sim_linuxcnc_tp_velocity_limit','_sim_linuxcnc_tp_jog_scale','_sim_linuxcnc_tp_jog_velocity','_sim_linuxcnc_tp_feed_override','_sim_linuxcnc_tp_set_dynamics','_sim_linuxcnc_tp_axis_limit','_sim_linuxcnc_tp_step_line','_sim_linuxcnc_tp_is_stepping','_sim_linuxcnc_tp_set_paused','_sim_linuxcnc_tp_abort','_sim_linuxcnc_tp_set_position','_sim_linuxcnc_tp_set_goal_position','_sim_linuxcnc_tp_add_line','_sim_linuxcnc_tp_add_circle','_sim_linuxcnc_tp_step','_sim_linuxcnc_tp_idle','_sim_linuxcnc_tp_read_state','_sim_linuxcnc_tp_queue_depth','_sim_linuxcnc_tp_queue_segment','_sim_linuxcnc_tp_restore_progress','_sim_linuxcnc_tp_state_hash','_sim_linuxcnc_tp_canonical" _sim_module_link_flags "${_sim_module_link_flags}")
|
||||||
string(REPLACE "_sim_linuxcnc_tp_velocity_limit" "_sim_linuxcnc_tp_velocity_limit','_sim_linuxcnc_tp_velocity_mm_min" _sim_module_link_flags "${_sim_module_link_flags}")
|
string(REPLACE "_sim_linuxcnc_tp_velocity_limit" "_sim_linuxcnc_tp_velocity_limit','_sim_linuxcnc_tp_velocity_mm_min" _sim_module_link_flags "${_sim_module_link_flags}")
|
||||||
set_target_properties(sim_core_module PROPERTIES LINK_FLAGS "${_sim_module_link_flags}")
|
set_target_properties(sim_core_module PROPERTIES LINK_FLAGS "${_sim_module_link_flags}")
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -64,6 +64,15 @@ typedef struct sim_interpreter {
|
|||||||
char canonical[SIM_INTERPRETER_CANONICAL_CAPACITY];
|
char canonical[SIM_INTERPRETER_CANONICAL_CAPACITY];
|
||||||
} sim_interpreter;
|
} sim_interpreter;
|
||||||
|
|
||||||
|
typedef struct sim_coordinate_context {
|
||||||
|
double offsets[9][9];
|
||||||
|
int system;
|
||||||
|
} sim_coordinate_context;
|
||||||
|
|
||||||
|
int sim_interpreter_get_coordinates(const sim_interpreter *interpreter, sim_coordinate_context *context);
|
||||||
|
int sim_interpreter_set_coordinates(sim_interpreter *interpreter, const sim_coordinate_context *context);
|
||||||
|
int sim_interpreter_sync_position(sim_interpreter *interpreter, const sim_posemath_pose *position);
|
||||||
|
|
||||||
int sim_interpreter_init(sim_interpreter *interpreter);
|
int sim_interpreter_init(sim_interpreter *interpreter);
|
||||||
int sim_interpreter_reset(sim_interpreter *interpreter);
|
int sim_interpreter_reset(sim_interpreter *interpreter);
|
||||||
int sim_interpreter_execute(sim_interpreter *interpreter, const char *block, uint32_t length);
|
int sim_interpreter_execute(sim_interpreter *interpreter, const char *block, uint32_t length);
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ typedef struct sim_io_spindle_state {
|
|||||||
double orient_angle;
|
double orient_angle;
|
||||||
double revs;
|
double revs;
|
||||||
uint32_t tick;
|
uint32_t tick;
|
||||||
|
double requested_rpm;
|
||||||
|
int32_t direction;
|
||||||
} sim_io_spindle_state;
|
} sim_io_spindle_state;
|
||||||
|
|
||||||
typedef struct sim_io_probe_state {
|
typedef struct sim_io_probe_state {
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ double sim_linuxcnc_tp_jog_velocity(const sim_linuxcnc_tp *planner, int axis);
|
|||||||
double sim_linuxcnc_tp_feed_override(const sim_linuxcnc_tp *planner);
|
double sim_linuxcnc_tp_feed_override(const sim_linuxcnc_tp *planner);
|
||||||
int32_t sim_linuxcnc_tp_set_dynamics(sim_linuxcnc_tp *planner, double acceleration_limit);
|
int32_t sim_linuxcnc_tp_set_dynamics(sim_linuxcnc_tp *planner, double acceleration_limit);
|
||||||
int32_t sim_linuxcnc_tp_set_paused(sim_linuxcnc_tp *planner, int paused);
|
int32_t sim_linuxcnc_tp_set_paused(sim_linuxcnc_tp *planner, int paused);
|
||||||
|
int32_t sim_linuxcnc_tp_step_line(sim_linuxcnc_tp *planner);
|
||||||
|
int32_t sim_linuxcnc_tp_is_stepping(const sim_linuxcnc_tp *planner);
|
||||||
|
double sim_linuxcnc_tp_axis_limit(const sim_linuxcnc_tp *planner, int axis, int maximum);
|
||||||
int32_t sim_linuxcnc_tp_abort(sim_linuxcnc_tp *planner);
|
int32_t sim_linuxcnc_tp_abort(sim_linuxcnc_tp *planner);
|
||||||
int32_t sim_linuxcnc_tp_set_position(sim_linuxcnc_tp *planner, const sim_posemath_pose *position);
|
int32_t sim_linuxcnc_tp_set_position(sim_linuxcnc_tp *planner, const sim_posemath_pose *position);
|
||||||
int32_t sim_linuxcnc_tp_set_goal_position(sim_linuxcnc_tp *planner, const sim_posemath_pose *position);
|
int32_t sim_linuxcnc_tp_set_goal_position(sim_linuxcnc_tp *planner, const sim_posemath_pose *position);
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ typedef struct sim_program {
|
|||||||
int motion_control_mode;
|
int motion_control_mode;
|
||||||
double path_tolerance;
|
double path_tolerance;
|
||||||
double naive_cam_tolerance;
|
double naive_cam_tolerance;
|
||||||
|
uint32_t pause_from;
|
||||||
} sim_program;
|
} sim_program;
|
||||||
|
|
||||||
uint32_t sim_program_size(void);
|
uint32_t sim_program_size(void);
|
||||||
|
|||||||
@@ -101,17 +101,12 @@ static int axis_index(char letter) {
|
|||||||
static double *axis_pointer(sim_interpreter_state *state, int index) { return &state->x + index; }
|
static double *axis_pointer(sim_interpreter_state *state, int index) { return &state->x + index; }
|
||||||
|
|
||||||
static void machine_pose_for_state(const interpreter_meta *meta, const sim_interpreter_state *state, int machine_coordinates, sim_posemath_pose *pose) {
|
static void machine_pose_for_state(const interpreter_meta *meta, const sim_interpreter_state *state, int machine_coordinates, sim_posemath_pose *pose) {
|
||||||
int coordinate = meta->coordinate_system > 0 ? meta->coordinate_system - 1 : 0;
|
// The interpreter state already stores machine coordinates. Offsets are
|
||||||
const double *offset = meta->coordinate_offsets[coordinate];
|
// applied exactly once when accepting an absolute coordinate word.
|
||||||
pose->tran.x = state->x + (machine_coordinates ? 0.0 : offset[0]);
|
(void)meta; (void)machine_coordinates;
|
||||||
pose->tran.y = state->y + (machine_coordinates ? 0.0 : offset[1]);
|
pose->tran.x = state->x; pose->tran.y = state->y; pose->tran.z = state->z;
|
||||||
pose->tran.z = state->z + (machine_coordinates ? 0.0 : offset[2]);
|
pose->a = state->a; pose->b = state->b; pose->c = state->c;
|
||||||
pose->a = state->a + (machine_coordinates ? 0.0 : offset[3]);
|
pose->u = state->u; pose->v = state->v; pose->w = state->w;
|
||||||
pose->b = state->b + (machine_coordinates ? 0.0 : offset[4]);
|
|
||||||
pose->c = state->c + (machine_coordinates ? 0.0 : offset[5]);
|
|
||||||
pose->u = state->u + (machine_coordinates ? 0.0 : offset[6]);
|
|
||||||
pose->v = state->v + (machine_coordinates ? 0.0 : offset[7]);
|
|
||||||
pose->w = state->w + (machine_coordinates ? 0.0 : offset[8]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static void set_error(sim_interpreter *interpreter, interpreter_meta *meta, int code, uint32_t column) {
|
static void set_error(sim_interpreter *interpreter, interpreter_meta *meta, int code, uint32_t column) {
|
||||||
@@ -352,6 +347,7 @@ static int apply_g(sim_interpreter_state *state, interpreter_meta *meta, double
|
|||||||
if (code_is(code, 80) || (code >= 81.0 && code <= 89.0 && floor(code) == code)) { if (meta) meta->cycle_mode = (int)code; return SIM_INTERP_OK; }
|
if (code_is(code, 80) || (code >= 81.0 && code <= 89.0 && floor(code) == code)) { if (meta) meta->cycle_mode = (int)code; return SIM_INTERP_OK; }
|
||||||
if (code_is(code, 61) || code_is_value(code, 61.1) || code_is(code, 64)) { if (++groups[7] > 1) return SIM_INTERP_ERR_STATE; return SIM_INTERP_OK; }
|
if (code_is(code, 61) || code_is_value(code, 61.1) || code_is(code, 64)) { if (++groups[7] > 1) return SIM_INTERP_ERR_STATE; return SIM_INTERP_OK; }
|
||||||
if (code_is(code, 54) || code_is(code, 55) || code_is(code, 56) || code_is(code, 57) || code_is(code, 58) || code_is(code, 59)) { if (meta) meta->coordinate_system = (int)code - 53; return SIM_INTERP_OK; }
|
if (code_is(code, 54) || code_is(code, 55) || code_is(code, 56) || code_is(code, 57) || code_is(code, 58) || code_is(code, 59)) { if (meta) meta->coordinate_system = (int)code - 53; return SIM_INTERP_OK; }
|
||||||
|
if (code_is_value(code, 59.1) || code_is_value(code, 59.2) || code_is_value(code, 59.3)) { if (meta) meta->coordinate_system = 6 + (int)lround((code - 59.0) * 10.0); return SIM_INTERP_OK; }
|
||||||
if (code_is(code, 53)) return SIM_INTERP_OK;
|
if (code_is(code, 53)) return SIM_INTERP_OK;
|
||||||
if (code_is(code, 10)) return SIM_INTERP_OK;
|
if (code_is(code, 10)) return SIM_INTERP_OK;
|
||||||
return SIM_INTERP_ERR_UNKNOWN_CODE;
|
return SIM_INTERP_ERR_UNKNOWN_CODE;
|
||||||
@@ -524,16 +520,14 @@ int sim_interpreter_execute(sim_interpreter *interpreter, const char *block, uin
|
|||||||
coordinate = p_value == 0.0 ? (meta->coordinate_system > 0 ? meta->coordinate_system - 1 : 0) : (int)p_value - 1;
|
coordinate = p_value == 0.0 ? (meta->coordinate_system > 0 ? meta->coordinate_system - 1 : 0) : (int)p_value - 1;
|
||||||
if (code_is(l_value, 20)) {
|
if (code_is(l_value, 20)) {
|
||||||
meta->coordinate_offsets[coordinate][axis] = *axis_pointer(&previous, (int)axis) - axis_value;
|
meta->coordinate_offsets[coordinate][axis] = *axis_pointer(&previous, (int)axis) - axis_value;
|
||||||
if (p_value == 0.0) meta->coordinate_l20 = 1;
|
|
||||||
*destination = axis_value;
|
|
||||||
} else {
|
} else {
|
||||||
meta->coordinate_offsets[coordinate][axis] = axis_value;
|
meta->coordinate_offsets[coordinate][axis] = axis_value;
|
||||||
}
|
}
|
||||||
} else if (next.distance_mode == SIM_INTERP_INCREMENTAL) *destination += axis_value;
|
} else if (next.distance_mode == SIM_INTERP_INCREMENTAL) *destination += axis_value;
|
||||||
else *destination = axis_value + (seen_g53 || meta->coordinate_l20 ? 0.0 : meta->coordinate_offsets[meta->coordinate_system > 0 ? meta->coordinate_system - 1 : 0][axis]);
|
else *destination = axis_value + (seen_g53 ? 0.0 : meta->coordinate_offsets[meta->coordinate_system > 0 ? meta->coordinate_system - 1 : 0][axis]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (seen_g10 && !seen_axis[0] && !seen_axis[1] && !seen_axis[2]) { set_error(interpreter, meta, SIM_INTERP_ERR_PARAMETER, 1); return SIM_INTERP_ERR_PARAMETER; }
|
if (seen_g10 && !seen_axis[0] && !seen_axis[1] && !seen_axis[2] && !seen_axis[3] && !seen_axis[4] && !seen_axis[5] && !seen_axis[6] && !seen_axis[7] && !seen_axis[8]) { set_error(interpreter, meta, SIM_INTERP_ERR_PARAMETER, 1); return SIM_INTERP_ERR_PARAMETER; }
|
||||||
if (meta->cycle_mode >= 81 && meta->cycle_mode <= 89 && seen_r && seen_axis[2]) {
|
if (meta->cycle_mode >= 81 && meta->cycle_mode <= 89 && seen_r && seen_axis[2]) {
|
||||||
double return_plane = r_value;
|
double return_plane = r_value;
|
||||||
if (next.units == SIM_INTERP_UNITS_INCH) return_plane *= 25.4;
|
if (next.units == SIM_INTERP_UNITS_INCH) return_plane *= 25.4;
|
||||||
@@ -635,3 +629,30 @@ int sim_interpreter_parameter_set(sim_interpreter *interpreter, uint32_t index,
|
|||||||
int sim_interpreter_parameter_get(const sim_interpreter *interpreter, uint32_t index, double *value) {
|
int sim_interpreter_parameter_get(const sim_interpreter *interpreter, uint32_t index, double *value) {
|
||||||
interpreter_meta *meta = meta_for(interpreter, 0); if (meta == NULL || value == NULL || index < SIM_INTERPRETER_PARAMETER_MIN || index > SIM_INTERPRETER_PARAMETER_MAX) return SIM_INTERP_ERR_PARAMETER; *value = meta->parameters[index]; return SIM_INTERP_OK;
|
interpreter_meta *meta = meta_for(interpreter, 0); if (meta == NULL || value == NULL || index < SIM_INTERPRETER_PARAMETER_MIN || index > SIM_INTERPRETER_PARAMETER_MAX) return SIM_INTERP_ERR_PARAMETER; *value = meta->parameters[index]; return SIM_INTERP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int sim_interpreter_get_coordinates(const sim_interpreter *interpreter, sim_coordinate_context *context) {
|
||||||
|
interpreter_meta *meta = meta_for(interpreter, 0);
|
||||||
|
if (meta == NULL || context == NULL) return SIM_INTERP_ERR_ARGUMENT;
|
||||||
|
memcpy(context->offsets, meta->coordinate_offsets, sizeof(context->offsets));
|
||||||
|
context->system = meta->coordinate_system > 0 ? meta->coordinate_system : 1;
|
||||||
|
return SIM_INTERP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sim_interpreter_set_coordinates(sim_interpreter *interpreter, const sim_coordinate_context *context) {
|
||||||
|
interpreter_meta *meta = meta_for(interpreter, 0);
|
||||||
|
if (meta == NULL || context == NULL || context->system < 1 || context->system > 9) return SIM_INTERP_ERR_ARGUMENT;
|
||||||
|
memcpy(meta->coordinate_offsets, context->offsets, sizeof(meta->coordinate_offsets));
|
||||||
|
meta->coordinate_system = context->system;
|
||||||
|
return SIM_INTERP_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
int sim_interpreter_sync_position(sim_interpreter *interpreter, const sim_posemath_pose *position) {
|
||||||
|
interpreter_meta *meta = meta_for(interpreter, 0);
|
||||||
|
if (meta == NULL || position == NULL) return SIM_INTERP_ERR_ARGUMENT;
|
||||||
|
memcpy(&interpreter->state.x, &position->tran.x, 9 * sizeof(double));
|
||||||
|
meta->last_output_pose = *position;
|
||||||
|
meta->has_output_pose = 1;
|
||||||
|
meta->has_motion = 0;
|
||||||
|
interpreter->state.status = SIM_INTERP_OK;
|
||||||
|
return SIM_INTERP_OK;
|
||||||
|
}
|
||||||
|
|||||||
@@ -257,6 +257,8 @@ int sim_io_spindle_command(sim_io *io, uint32_t spindle, double rpm, uint32_t en
|
|||||||
if (io->state.estop) { set_last(io, SIM_IO_ERR_ESTOP); return SIM_IO_ERR_ESTOP; }
|
if (io->state.estop) { set_last(io, SIM_IO_ERR_ESTOP); return SIM_IO_ERR_ESTOP; }
|
||||||
if (!io->state.ready) { set_last(io, SIM_IO_ERR_NOT_READY); return SIM_IO_ERR_NOT_READY; }
|
if (!io->state.ready) { set_last(io, SIM_IO_ERR_NOT_READY); return SIM_IO_ERR_NOT_READY; }
|
||||||
state = &io->state.spindles[spindle];
|
state = &io->state.spindles[spindle];
|
||||||
|
state->requested_rpm = enabled ? rpm : 0.0;
|
||||||
|
state->direction = enabled ? (rpm > 0 ? 1 : rpm < 0 ? -1 : 0) : 0;
|
||||||
state->command_rpm = enabled && !state->inhibit && !state->amp_fault ? rpm * state->override_scale : 0.0;
|
state->command_rpm = enabled && !state->inhibit && !state->amp_fault ? rpm * state->override_scale : 0.0;
|
||||||
state->enabled = enabled ? 1U : 0U;
|
state->enabled = enabled ? 1U : 0U;
|
||||||
set_last(io, SIM_IO_OK);
|
set_last(io, SIM_IO_OK);
|
||||||
@@ -274,11 +276,12 @@ int sim_io_spindle_adjust(sim_io *io, uint32_t spindle, double delta) {
|
|||||||
state = &io->state.spindles[spindle];
|
state = &io->state.spindles[spindle];
|
||||||
// Match LinuxCNC EMCMOT_SPINDLE_INCREASE/DECREASE: change the signed
|
// Match LinuxCNC EMCMOT_SPINDLE_INCREASE/DECREASE: change the signed
|
||||||
// speed only while the spindle is running and keep its direction.
|
// speed only while the spindle is running and keep its direction.
|
||||||
if (state->command_rpm > 0.0) target = fmax(0.0, state->command_rpm + delta);
|
if (state->requested_rpm > 0.0) target = fmax(0.0, state->requested_rpm + delta);
|
||||||
else if (state->command_rpm < 0.0) target = fmin(0.0, state->command_rpm - delta);
|
else if (state->requested_rpm < 0.0) target = fmin(0.0, state->requested_rpm - delta);
|
||||||
else target = 0.0;
|
else target = 0.0;
|
||||||
target = fmax(-state->max_rpm, fmin(state->max_rpm, target));
|
target = fmax(-state->max_rpm, fmin(state->max_rpm, target));
|
||||||
state->command_rpm = state->enabled && !state->inhibit && !state->amp_fault ? target : 0.0;
|
state->requested_rpm = target;
|
||||||
|
state->command_rpm = state->enabled && !state->inhibit && !state->amp_fault ? target * state->override_scale : 0.0;
|
||||||
set_last(io, SIM_IO_OK);
|
set_last(io, SIM_IO_OK);
|
||||||
mirror_pins(io);
|
mirror_pins(io);
|
||||||
return SIM_IO_OK;
|
return SIM_IO_OK;
|
||||||
@@ -300,12 +303,11 @@ int sim_io_spindle_set_override(sim_io *io, uint32_t spindle, double scale) {
|
|||||||
sim_io_spindle_state *state;
|
sim_io_spindle_state *state;
|
||||||
double base;
|
double base;
|
||||||
if (!valid_spindle(io, spindle) || !isfinite(scale) || scale < 0.0 || scale > 2.0) return SIM_IO_ERR_RANGE;
|
if (!valid_spindle(io, spindle) || !isfinite(scale) || scale < 0.0 || scale > 2.0) return SIM_IO_ERR_RANGE;
|
||||||
if (io->state.estop) { set_last(io, SIM_IO_ERR_ESTOP); return SIM_IO_ERR_ESTOP; }
|
/* EMC_TRAJ_SET_SPINDLE_SCALE remains accepted while OFF/ESTOP. */
|
||||||
if (!io->state.ready) { set_last(io, SIM_IO_ERR_NOT_READY); return SIM_IO_ERR_NOT_READY; }
|
|
||||||
state = &io->state.spindles[spindle];
|
state = &io->state.spindles[spindle];
|
||||||
base = state->override_scale > 0.0 ? state->command_rpm / state->override_scale : 0.0;
|
base = state->requested_rpm;
|
||||||
state->override_scale = scale;
|
state->override_scale = scale;
|
||||||
state->command_rpm = state->enabled && !state->inhibit && !state->amp_fault ? base * scale : 0.0;
|
state->command_rpm = scale > 0 && state->enabled && !state->inhibit && !state->amp_fault ? base * scale : 0.0;
|
||||||
set_last(io, SIM_IO_OK);
|
set_last(io, SIM_IO_OK);
|
||||||
mirror_pins(io);
|
mirror_pins(io);
|
||||||
return SIM_IO_OK;
|
return SIM_IO_OK;
|
||||||
@@ -623,9 +625,9 @@ uint32_t sim_io_canonical(const sim_io *io, char *output, uint32_t capacity) {
|
|||||||
const sim_io_spindle_state *spindle = &io->state.spindles[index];
|
const sim_io_spindle_state *spindle = &io->state.spindles[index];
|
||||||
int part = snprintf(destination + (output && (uint32_t)written < capacity ? written : 0),
|
int part = snprintf(destination + (output && (uint32_t)written < capacity ? written : 0),
|
||||||
output && (uint32_t)written < capacity ? capacity - (uint32_t)written : 0,
|
output && (uint32_t)written < capacity ? capacity - (uint32_t)written : 0,
|
||||||
"%s{\"at_speed\":%u,\"brake\":%u,\"command_rpm\":%.9g,\"enabled\":%u,\"forward\":%u,\"inhibit\":%u,\"override\":%.9g,\"reverse\":%u,\"speed_rpm\":%.9g,\"tick\":%u}",
|
"%s{\"at_speed\":%u,\"brake\":%u,\"command_rpm\":%.9g,\"enabled\":%u,\"forward\":%u,\"inhibit\":%u,\"override\":%.9g,\"reverse\":%u,\"speed_rpm\":%.9g,\"tick\":%u,\"requested_rpm\":%.9g,\"direction\":%d}",
|
||||||
index ? "," : "", spindle->at_speed, spindle->brake, spindle->command_rpm,
|
index ? "," : "", spindle->at_speed, spindle->brake, spindle->command_rpm,
|
||||||
spindle->enabled, spindle->forward, spindle->inhibit, spindle->override_scale, spindle->reverse, spindle->speed_rpm, spindle->tick);
|
spindle->enabled, spindle->forward, spindle->inhibit, spindle->override_scale, spindle->reverse, spindle->speed_rpm, spindle->tick, spindle->requested_rpm, spindle->enabled ? spindle->direction : 0);
|
||||||
if (part < 0) return 0; written += part;
|
if (part < 0) return 0; written += part;
|
||||||
}
|
}
|
||||||
{ int part = snprintf(destination + (output && (uint32_t)written < capacity ? written : 0),
|
{ int part = snprintf(destination + (output && (uint32_t)written < capacity ? written : 0),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ struct sim_linuxcnc_tp {
|
|||||||
uint32_t last_active_id;
|
uint32_t last_active_id;
|
||||||
int just_completed;
|
int just_completed;
|
||||||
int initialized;
|
int initialized;
|
||||||
|
int step_id;
|
||||||
};
|
};
|
||||||
|
|
||||||
static sim_linuxcnc_tp *active_planner;
|
static sim_linuxcnc_tp *active_planner;
|
||||||
@@ -211,11 +212,19 @@ int32_t sim_linuxcnc_tp_jog(sim_linuxcnc_tp *planner, int axis, int direction, d
|
|||||||
double *value;
|
double *value;
|
||||||
double velocity;
|
double velocity;
|
||||||
struct state_tag_t state_tag = {0};
|
struct state_tag_t state_tag = {0};
|
||||||
if (planner == NULL || !planner->initialized || axis < 0 || axis >= 9 || (direction != -1 && direction != 1) || !isfinite(increment) || increment <= 0.0) return -1215;
|
if (planner == NULL || !planner->initialized || axis < 0 || axis >= 9 || (direction != -1 && direction != 1) || !isfinite(increment) || increment < 0.0) return -1215;
|
||||||
active_planner = planner;
|
active_planner = planner;
|
||||||
end = planner->tp.goalPos;
|
end = planner->tp.goalPos;
|
||||||
value = axis == 0 ? &end.tran.x : axis == 1 ? &end.tran.y : axis == 2 ? &end.tran.z : axis == 3 ? &end.a : axis == 4 ? &end.b : axis == 5 ? &end.c : axis == 6 ? &end.u : axis == 7 ? &end.v : &end.w;
|
value = axis == 0 ? &end.tran.x : axis == 1 ? &end.tran.y : axis == 2 ? &end.tran.z : axis == 3 ? &end.a : axis == 4 ? &end.b : axis == 5 ? &end.c : axis == 6 ? &end.u : axis == 7 ? &end.v : &end.w;
|
||||||
*value += (double)direction * increment;
|
// command.c::EMCMOT_JOG_CONT targets the directional jog limit until abort.
|
||||||
|
// Zero is the continuous-mode encoding; positive increments retain their ABI.
|
||||||
|
if (increment == 0.0) {
|
||||||
|
double target = direction > 0 ? planner->axis_max[axis] : planner->axis_min[axis];
|
||||||
|
if ((target - *value) * direction <= 0.0) return -1217;
|
||||||
|
*value = target;
|
||||||
|
} else {
|
||||||
|
*value += (double)direction * increment;
|
||||||
|
}
|
||||||
if (!isfinite(*value) || *value < planner->axis_min[axis] || *value > planner->axis_max[axis]) return -1217;
|
if (!isfinite(*value) || *value < planner->axis_min[axis] || *value > planner->axis_max[axis]) return -1217;
|
||||||
velocity = planner->axis_velocity[axis] * planner->jog_scale;
|
velocity = planner->axis_velocity[axis] * planner->jog_scale;
|
||||||
state_tag.fields_float[GM_FIELD_FLOAT_LINE_NUMBER] = (float)tag;
|
state_tag.fields_float[GM_FIELD_FLOAT_LINE_NUMBER] = (float)tag;
|
||||||
@@ -257,15 +266,36 @@ int32_t sim_linuxcnc_tp_set_dynamics(sim_linuxcnc_tp *planner, double accelerati
|
|||||||
int32_t sim_linuxcnc_tp_set_paused(sim_linuxcnc_tp *planner, int paused) {
|
int32_t sim_linuxcnc_tp_set_paused(sim_linuxcnc_tp *planner, int paused) {
|
||||||
if (planner == NULL || !planner->initialized || (paused != 0 && paused != 1)) return -1219;
|
if (planner == NULL || !planner->initialized || (paused != 0 && paused != 1)) return -1219;
|
||||||
active_planner = planner;
|
active_planner = planner;
|
||||||
|
planner->status.stepping = 0;
|
||||||
if (paused) return tpPause(&planner->tp) == TP_ERR_OK ? 0 : -1220;
|
if (paused) return tpPause(&planner->tp) == TP_ERR_OK ? 0 : -1220;
|
||||||
return tpResume(&planner->tp) == TP_ERR_OK ? 0 : -1221;
|
return tpResume(&planner->tp) == TP_ERR_OK ? 0 : -1221;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int32_t sim_linuxcnc_tp_step_line(sim_linuxcnc_tp *planner) {
|
||||||
|
if (planner == NULL || !planner->initialized) return -1219;
|
||||||
|
TC_STRUCT *active = tcqItem(&planner->tp.queue, 0);
|
||||||
|
if (active == NULL) return -1219;
|
||||||
|
active_planner = planner;
|
||||||
|
planner->step_id = active->id;
|
||||||
|
planner->status.stepping = 1;
|
||||||
|
return tpResume(&planner->tp) == TP_ERR_OK ? 0 : -1221;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32_t sim_linuxcnc_tp_is_stepping(const sim_linuxcnc_tp *planner) {
|
||||||
|
return planner != NULL && planner->initialized && planner->status.stepping;
|
||||||
|
}
|
||||||
|
|
||||||
|
double sim_linuxcnc_tp_axis_limit(const sim_linuxcnc_tp *planner, int axis, int maximum) {
|
||||||
|
if (planner == NULL || !planner->initialized || axis < 0 || axis > 8) return NAN;
|
||||||
|
return maximum ? planner->axis_max[axis] : planner->axis_min[axis];
|
||||||
|
}
|
||||||
|
|
||||||
int32_t sim_linuxcnc_tp_abort(sim_linuxcnc_tp *planner) {
|
int32_t sim_linuxcnc_tp_abort(sim_linuxcnc_tp *planner) {
|
||||||
if (planner == NULL || !planner->initialized) return -1222;
|
if (planner == NULL || !planner->initialized) return -1222;
|
||||||
active_planner = planner;
|
active_planner = planner;
|
||||||
planner->last_active_id = 0;
|
planner->last_active_id = 0;
|
||||||
planner->just_completed = 0;
|
planner->just_completed = 0;
|
||||||
|
planner->status.stepping = 0;
|
||||||
if (tpAbort(&planner->tp) != TP_ERR_OK) return -1223;
|
if (tpAbort(&planner->tp) != TP_ERR_OK) return -1223;
|
||||||
// A browser PROGRAM_ABORT has no background emcmot servo thread after
|
// A browser PROGRAM_ABORT has no background emcmot servo thread after
|
||||||
// the command response. Complete the source TP abort transition here so
|
// the command response. Complete the source TP abort transition here so
|
||||||
@@ -316,9 +346,17 @@ int32_t sim_linuxcnc_tp_step(sim_linuxcnc_tp *planner) {
|
|||||||
planner->just_completed = 0;
|
planner->just_completed = 0;
|
||||||
const int prior_id = tpGetExecId(&planner->tp);
|
const int prior_id = tpGetExecId(&planner->tp);
|
||||||
const int prior_depth = tcqLen(&planner->tp.queue);
|
const int prior_depth = tcqLen(&planner->tp.queue);
|
||||||
|
const int was_stepping = planner->status.stepping;
|
||||||
const int result = tpRunCycle(&planner->tp, (long)llround(planner->tp.cycleTime * 1e9));
|
const int result = tpRunCycle(&planner->tp, (long)llround(planner->tp.cycleTime * 1e9));
|
||||||
|
// motion/control.c stops a step when the execution ID changes. Compare
|
||||||
|
// the next queued ID too, before the following TC can start moving.
|
||||||
|
TC_STRUCT *next = tcqItem(&planner->tp.queue, 0);
|
||||||
|
if (planner->status.stepping && (next == NULL || next->id != planner->step_id)) {
|
||||||
|
tpPause(&planner->tp);
|
||||||
|
planner->status.stepping = 0;
|
||||||
|
}
|
||||||
planner->tick += 1u;
|
planner->tick += 1u;
|
||||||
if (prior_id > 0 && prior_depth > 0 && tcqLen(&planner->tp.queue) == 0) {
|
if (!was_stepping && prior_id > 0 && prior_depth > 0 && tcqLen(&planner->tp.queue) == 0) {
|
||||||
planner->last_active_id = (uint32_t)prior_id;
|
planner->last_active_id = (uint32_t)prior_id;
|
||||||
planner->just_completed = 1;
|
planner->just_completed = 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ static void reset_runtime(sim_program *program) {
|
|||||||
program->motion_control_mode = SIM_PROGRAM_MOTION_CONTINUOUS;
|
program->motion_control_mode = SIM_PROGRAM_MOTION_CONTINUOUS;
|
||||||
program->path_tolerance = 0.0;
|
program->path_tolerance = 0.0;
|
||||||
program->naive_cam_tolerance = 0.0;
|
program->naive_cam_tolerance = 0.0;
|
||||||
|
program->pause_from = SIM_PROGRAM_RUNNING;
|
||||||
memset(&program->motion, 0, sizeof(program->motion));
|
memset(&program->motion, 0, sizeof(program->motion));
|
||||||
memset(program->subroutines, 0, sizeof(program->subroutines));
|
memset(program->subroutines, 0, sizeof(program->subroutines));
|
||||||
memset(program->call_stack, 0, sizeof(program->call_stack));
|
memset(program->call_stack, 0, sizeof(program->call_stack));
|
||||||
@@ -344,9 +345,21 @@ void sim_program_destroy(sim_program *program) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int32_t sim_program_load(sim_program *program, const char *source, uint32_t length) {
|
int32_t sim_program_load(sim_program *program, const char *source, uint32_t length) {
|
||||||
|
sim_coordinate_context coordinates;
|
||||||
|
sim_interpreter_state modal_state;
|
||||||
if (program == NULL || source == NULL) return SIM_PROGRAM_ERR_ARGUMENT;
|
if (program == NULL || source == NULL) return SIM_PROGRAM_ERR_ARGUMENT;
|
||||||
if (length == 0 || length > SIM_PROGRAM_MAX_SOURCE_BYTES) return SIM_PROGRAM_ERR_RANGE;
|
if (length == 0 || length > SIM_PROGRAM_MAX_SOURCE_BYTES) return SIM_PROGRAM_ERR_RANGE;
|
||||||
|
modal_state = program->interpreter.state;
|
||||||
|
if (sim_interpreter_get_coordinates(&program->interpreter, &coordinates) != SIM_INTERP_OK) return SIM_PROGRAM_ERR_INTERPRETER;
|
||||||
if (sim_program_reset(program) != SIM_PROGRAM_OK) return SIM_PROGRAM_ERR_INTERPRETER;
|
if (sim_program_reset(program) != SIM_PROGRAM_OK) return SIM_PROGRAM_ERR_INTERPRETER;
|
||||||
|
if (sim_interpreter_set_coordinates(&program->interpreter, &coordinates) != SIM_INTERP_OK) return SIM_PROGRAM_ERR_INTERPRETER;
|
||||||
|
program->interpreter.state.units = modal_state.units;
|
||||||
|
program->interpreter.state.distance_mode = modal_state.distance_mode;
|
||||||
|
program->interpreter.state.plane = modal_state.plane;
|
||||||
|
program->interpreter.state.motion_mode = modal_state.motion_mode;
|
||||||
|
program->interpreter.state.feed = modal_state.feed;
|
||||||
|
program->interpreter.state.speed = modal_state.speed;
|
||||||
|
program->interpreter.state.spindle_mode = modal_state.spindle_mode;
|
||||||
if (append_source(program, source, length) != SIM_PROGRAM_OK) return SIM_PROGRAM_ERR_RANGE;
|
if (append_source(program, source, length) != SIM_PROGRAM_OK) return SIM_PROGRAM_ERR_RANGE;
|
||||||
program->main_line_count = program->line_count;
|
program->main_line_count = program->line_count;
|
||||||
program->status = SIM_PROGRAM_LOADED;
|
program->status = SIM_PROGRAM_LOADED;
|
||||||
@@ -370,20 +383,10 @@ int32_t sim_program_set_rapid_velocity(sim_program *program, double velocity) {
|
|||||||
int32_t sim_program_set_position(sim_program *program, const sim_posemath_pose *position) {
|
int32_t sim_program_set_position(sim_program *program, const sim_posemath_pose *position) {
|
||||||
const double *values;
|
const double *values;
|
||||||
size_t index;
|
size_t index;
|
||||||
if (program == NULL || position == NULL || program->status == SIM_PROGRAM_IDLE) return SIM_PROGRAM_ERR_ARGUMENT;
|
if (program == NULL || position == NULL) return SIM_PROGRAM_ERR_ARGUMENT;
|
||||||
values = &position->tran.x;
|
values = &position->tran.x;
|
||||||
for (index = 0; index < 9; ++index) if (!isfinite(values[index])) return SIM_PROGRAM_ERR_RANGE;
|
for (index = 0; index < 9; ++index) if (!isfinite(values[index])) return SIM_PROGRAM_ERR_RANGE;
|
||||||
program->interpreter.state.x = position->tran.x;
|
return sim_interpreter_sync_position(&program->interpreter, position) == SIM_INTERP_OK ? SIM_PROGRAM_OK : SIM_PROGRAM_ERR_INTERPRETER;
|
||||||
program->interpreter.state.y = position->tran.y;
|
|
||||||
program->interpreter.state.z = position->tran.z;
|
|
||||||
program->interpreter.state.a = position->a;
|
|
||||||
program->interpreter.state.b = position->b;
|
|
||||||
program->interpreter.state.c = position->c;
|
|
||||||
program->interpreter.state.u = position->u;
|
|
||||||
program->interpreter.state.v = position->v;
|
|
||||||
program->interpreter.state.w = position->w;
|
|
||||||
program->interpreter.state.status = SIM_INTERP_OK;
|
|
||||||
return SIM_PROGRAM_OK;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int32_t sim_program_start(sim_program *program) {
|
int32_t sim_program_start(sim_program *program) {
|
||||||
@@ -412,14 +415,15 @@ int32_t sim_program_start(sim_program *program) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int32_t sim_program_pause(sim_program *program) {
|
int32_t sim_program_pause(sim_program *program) {
|
||||||
if (program == NULL || program->status != SIM_PROGRAM_RUNNING) return SIM_PROGRAM_ERR_STATE;
|
if (program == NULL || (program->status != SIM_PROGRAM_RUNNING && program->status != SIM_PROGRAM_COMPLETED && program->status != SIM_PROGRAM_LOADED)) return SIM_PROGRAM_ERR_STATE;
|
||||||
|
program->pause_from = program->status;
|
||||||
program->status = SIM_PROGRAM_PAUSED;
|
program->status = SIM_PROGRAM_PAUSED;
|
||||||
return SIM_PROGRAM_OK;
|
return SIM_PROGRAM_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
int32_t sim_program_resume(sim_program *program) {
|
int32_t sim_program_resume(sim_program *program) {
|
||||||
if (program == NULL || program->status != SIM_PROGRAM_PAUSED) return SIM_PROGRAM_ERR_STATE;
|
if (program == NULL || program->status != SIM_PROGRAM_PAUSED) return SIM_PROGRAM_ERR_STATE;
|
||||||
program->status = SIM_PROGRAM_RUNNING;
|
program->status = program->pause_from;
|
||||||
return SIM_PROGRAM_OK;
|
return SIM_PROGRAM_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -494,8 +498,11 @@ int32_t sim_program_step(sim_program *program) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int32_t sim_program_seek(sim_program *program, uint32_t line) {
|
int32_t sim_program_seek(sim_program *program, uint32_t line) {
|
||||||
|
sim_coordinate_context coordinates;
|
||||||
if (program == NULL || line > program->line_count) return SIM_PROGRAM_ERR_RANGE;
|
if (program == NULL || line > program->line_count) return SIM_PROGRAM_ERR_RANGE;
|
||||||
|
if (sim_interpreter_get_coordinates(&program->interpreter, &coordinates) != SIM_INTERP_OK) return SIM_PROGRAM_ERR_INTERPRETER;
|
||||||
if (sim_interpreter_reset(&program->interpreter) != SIM_INTERP_OK) return SIM_PROGRAM_ERR_INTERPRETER;
|
if (sim_interpreter_reset(&program->interpreter) != SIM_INTERP_OK) return SIM_PROGRAM_ERR_INTERPRETER;
|
||||||
|
if (sim_interpreter_set_coordinates(&program->interpreter, &coordinates) != SIM_INTERP_OK) return SIM_PROGRAM_ERR_INTERPRETER;
|
||||||
program->current_line = 0; program->status = SIM_PROGRAM_PAUSED; program->has_motion = 0;
|
program->current_line = 0; program->status = SIM_PROGRAM_PAUSED; program->has_motion = 0;
|
||||||
while (program->current_line < line) {
|
while (program->current_line < line) {
|
||||||
int result = sim_program_step(program);
|
int result = sim_program_step(program);
|
||||||
@@ -528,11 +535,22 @@ uint32_t sim_program_canonical(const sim_program *program, char *output, uint32_
|
|||||||
char *destination = output == NULL ? discard : output;
|
char *destination = output == NULL ? discard : output;
|
||||||
uint32_t destination_capacity = output == NULL ? 1 : capacity;
|
uint32_t destination_capacity = output == NULL ? 1 : capacity;
|
||||||
int written;
|
int written;
|
||||||
|
sim_coordinate_context coordinates;
|
||||||
|
char coordinate_json[3072];
|
||||||
|
size_t used = 0;
|
||||||
if (program == NULL) return 0;
|
if (program == NULL) return 0;
|
||||||
|
if (sim_interpreter_get_coordinates(&program->interpreter, &coordinates) != SIM_INTERP_OK) return 0;
|
||||||
|
used += (size_t)snprintf(coordinate_json + used, sizeof(coordinate_json) - used, "{\"system\":%d,\"offsets\":[", coordinates.system);
|
||||||
|
for (int system = 0; system < 9; ++system) {
|
||||||
|
used += (size_t)snprintf(coordinate_json + used, sizeof(coordinate_json) - used, "%s[", system ? "," : "");
|
||||||
|
for (int axis = 0; axis < 9; ++axis) used += (size_t)snprintf(coordinate_json + used, sizeof(coordinate_json) - used, "%s%.17g", axis ? "," : "", coordinates.offsets[system][axis]);
|
||||||
|
used += (size_t)snprintf(coordinate_json + used, sizeof(coordinate_json) - used, "]");
|
||||||
|
}
|
||||||
|
(void)snprintf(coordinate_json + used, sizeof(coordinate_json) - used, "]}");
|
||||||
written = snprintf(destination, destination_capacity,
|
written = snprintf(destination, destination_capacity,
|
||||||
"{\"call_depth\":%u,\"control_code\":%d,\"current_line\":%u,\"error_line\":%u,\"feed\":%.17g,\"has_control\":%u,\"has_motion\":%u,\"last_error\":%d,\"line_count\":%u,\"main_line_count\":%u,\"motion_control_mode\":%d,\"path_tolerance\":%.17g,\"naive_cam_tolerance\":%.17g,\"motion_mode\":%d,\"plane\":%d,\"rapid_velocity\":%.17g,\"schema\":\"sim_program.v1\",\"speed\":%.17g,\"spindle_mode\":%d,\"status\":%u,\"subroutine_count\":%u,\"source_length\":%u,\"units\":%d,\"distance_mode\":%d,\"interpreter_state_hash\":\"%llu\"}",
|
"{\"call_depth\":%u,\"control_code\":%d,\"current_line\":%u,\"error_line\":%u,\"feed\":%.17g,\"has_control\":%u,\"has_motion\":%u,\"last_error\":%d,\"line_count\":%u,\"main_line_count\":%u,\"motion_control_mode\":%d,\"path_tolerance\":%.17g,\"naive_cam_tolerance\":%.17g,\"motion_mode\":%d,\"plane\":%d,\"rapid_velocity\":%.17g,\"schema\":\"sim_program.v1\",\"speed\":%.17g,\"spindle_mode\":%d,\"status\":%u,\"subroutine_count\":%u,\"source_length\":%u,\"units\":%d,\"distance_mode\":%d,\"interpreter_state_hash\":\"%llu\",\"coordinate_context\":%s,\"pause_from\":%u}",
|
||||||
program->call_depth, program->control_code, program->current_line, program->error_line, program->interpreter.state.feed, program->has_control, program->has_motion, program->last_error, program->line_count, program->main_line_count, program->motion_control_mode, program->path_tolerance, program->naive_cam_tolerance, program->interpreter.state.motion_mode, program->interpreter.state.plane, program->rapid_velocity, program->interpreter.state.speed, program->interpreter.state.spindle_mode, program->status, program->subroutine_count, program->source_length, program->interpreter.state.units, program->interpreter.state.distance_mode,
|
program->call_depth, program->control_code, program->current_line, program->error_line, program->interpreter.state.feed, program->has_control, program->has_motion, program->last_error, program->line_count, program->main_line_count, program->motion_control_mode, program->path_tolerance, program->naive_cam_tolerance, program->interpreter.state.motion_mode, program->interpreter.state.plane, program->rapid_velocity, program->interpreter.state.speed, program->interpreter.state.spindle_mode, program->status, program->subroutine_count, program->source_length, program->interpreter.state.units, program->interpreter.state.distance_mode,
|
||||||
(unsigned long long)sim_interpreter_state_hash(&program->interpreter));
|
(unsigned long long)sim_interpreter_state_hash(&program->interpreter), coordinate_json, program->pause_from);
|
||||||
return written < 0 ? 0 : (uint32_t)written + 1u;
|
return written < 0 ? 0 : (uint32_t)written + 1u;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -44,15 +44,10 @@ static int state_accepts_kind(uint32_t current, const char *kind) {
|
|||||||
if (current == SIM_PROTOCOL_NEW) return is_kind(kind, "HELLO");
|
if (current == SIM_PROTOCOL_NEW) return is_kind(kind, "HELLO");
|
||||||
if (current == SIM_PROTOCOL_HELLO) return is_kind(kind, "SESSION_ATTACH") || is_kind(kind, "CAPABILITIES");
|
if (current == SIM_PROTOCOL_HELLO) return is_kind(kind, "SESSION_ATTACH") || is_kind(kind, "CAPABILITIES");
|
||||||
if (current == SIM_PROTOCOL_ATTACHED) return is_kind(kind, "SESSION_READY");
|
if (current == SIM_PROTOCOL_ATTACHED) return is_kind(kind, "SESSION_READY");
|
||||||
if (current == SIM_PROTOCOL_READY) return 1;
|
/* Task pause does not disconnect the command channel. The task gate
|
||||||
if (current == SIM_PROTOCOL_PAUSED) {
|
decides whether a valid frame may execute, and records refusals at
|
||||||
return is_kind(kind, "PROGRAM_RESUME") || is_kind(kind, "RESUME") ||
|
that sequence so Resume/Abort cannot be stranded behind a gap. */
|
||||||
is_kind(kind, "PROGRAM_ABORT") || is_kind(kind, "ESTOP") ||
|
if (current == SIM_PROTOCOL_READY || current == SIM_PROTOCOL_PAUSED) return 1;
|
||||||
is_kind(kind, "POWER") || is_kind(kind, "SET_OVERRIDE") ||
|
|
||||||
is_kind(kind, "SIM_STEP") ||
|
|
||||||
is_kind(kind, "PING") || is_kind(kind, "SNAPSHOT_REQUEST") ||
|
|
||||||
is_kind(kind, "DESTROY");
|
|
||||||
}
|
|
||||||
return is_kind(kind, "RESET");
|
return is_kind(kind, "RESET");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -206,7 +206,7 @@ int32_t sim_task_command_gate(const sim_task *task, uint32_t command,
|
|||||||
(idle || task->state.mode == SIM_TASK_MODE_AUTO)
|
(idle || task->state.mode == SIM_TASK_MODE_AUTO)
|
||||||
? SIM_TASK_GATE_ALLOWED : SIM_TASK_GATE_RESET_STATE;
|
? SIM_TASK_GATE_ALLOWED : SIM_TASK_GATE_RESET_STATE;
|
||||||
case SIM_TASK_GATE_PAUSE:
|
case SIM_TASK_GATE_PAUSE:
|
||||||
return machine_on && task->state.mode == SIM_TASK_MODE_AUTO && task->state.program_status == SIM_TASK_PROGRAM_RUNNING
|
return machine_on && (task->state.mode == SIM_TASK_MODE_AUTO || task->state.mode == SIM_TASK_MODE_MDI) && task->state.state == SIM_TASK_RUNNING
|
||||||
? SIM_TASK_GATE_ALLOWED : SIM_TASK_GATE_PAUSE_STATE;
|
? SIM_TASK_GATE_ALLOWED : SIM_TASK_GATE_PAUSE_STATE;
|
||||||
case SIM_TASK_GATE_RESUME:
|
case SIM_TASK_GATE_RESUME:
|
||||||
return machine_on && (task->state.mode == SIM_TASK_MODE_AUTO || task->state.mode == SIM_TASK_MODE_MDI) && task->state.program_status == SIM_TASK_PROGRAM_PAUSED
|
return machine_on && (task->state.mode == SIM_TASK_MODE_AUTO || task->state.mode == SIM_TASK_MODE_MDI) && task->state.program_status == SIM_TASK_PROGRAM_PAUSED
|
||||||
@@ -225,18 +225,17 @@ int32_t sim_task_command_gate(const sim_task *task, uint32_t command,
|
|||||||
manual tab changes mode before sending the command when required. */
|
manual tab changes mode before sending the command when required. */
|
||||||
return machine_on && idle ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : machine_on ? SIM_TASK_GATE_INTERP_ACTIVE : SIM_TASK_GATE_MACHINE_OFF;
|
return machine_on && idle ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : machine_on ? SIM_TASK_GATE_INTERP_ACTIVE : SIM_TASK_GATE_MACHINE_OFF;
|
||||||
case SIM_TASK_GATE_OVERRIDE:
|
case SIM_TASK_GATE_OVERRIDE:
|
||||||
/* Feed override is an online trajectory control. LinuxCNC accepts it
|
/* AXIS setters are available independently of machine power. */
|
||||||
while AUTO is running or paused, provided the machine is powered. */
|
return SIM_TASK_GATE_ALLOWED;
|
||||||
return io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : machine_on ? SIM_TASK_GATE_ALLOWED : SIM_TASK_GATE_MACHINE_OFF;
|
|
||||||
case SIM_TASK_GATE_SPINDLE:
|
case SIM_TASK_GATE_SPINDLE:
|
||||||
return machine_on && idle && task->state.mode == SIM_TASK_MODE_MANUAL ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : !machine_on ? SIM_TASK_GATE_MACHINE_OFF : SIM_TASK_GATE_MODE_STATE;
|
return machine_on && idle ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : !machine_on ? SIM_TASK_GATE_MACHINE_OFF : SIM_TASK_GATE_INTERP_ACTIVE;
|
||||||
case SIM_TASK_GATE_COOLANT:
|
case SIM_TASK_GATE_COOLANT:
|
||||||
return io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : machine_on ? SIM_TASK_GATE_ALLOWED : SIM_TASK_GATE_MACHINE_OFF;
|
return io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : machine_on ? SIM_TASK_GATE_ALLOWED : SIM_TASK_GATE_MACHINE_OFF;
|
||||||
case SIM_TASK_GATE_TOOL:
|
case SIM_TASK_GATE_TOOL:
|
||||||
case SIM_TASK_GATE_PROBE:
|
case SIM_TASK_GATE_PROBE:
|
||||||
return machine_on && idle && task->state.mode == SIM_TASK_MODE_MANUAL ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : !machine_on ? SIM_TASK_GATE_MACHINE_OFF : SIM_TASK_GATE_MODE_STATE;
|
return machine_on && idle && task->state.mode == SIM_TASK_MODE_MANUAL ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : !machine_on ? SIM_TASK_GATE_MACHINE_OFF : SIM_TASK_GATE_MODE_STATE;
|
||||||
case SIM_TASK_GATE_TOUCH_OFF:
|
case SIM_TASK_GATE_TOUCH_OFF:
|
||||||
return machine_on && idle && homed && task->state.mode == SIM_TASK_MODE_MANUAL ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : !machine_on ? SIM_TASK_GATE_MACHINE_OFF : !homed ? SIM_TASK_GATE_UNHOMED : SIM_TASK_GATE_MODE_STATE;
|
return machine_on && idle && homed ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : !machine_on ? SIM_TASK_GATE_MACHINE_OFF : !homed ? SIM_TASK_GATE_UNHOMED : SIM_TASK_GATE_INTERP_ACTIVE;
|
||||||
case SIM_TASK_GATE_TOOL_TOUCH_OFF:
|
case SIM_TASK_GATE_TOOL_TOUCH_OFF:
|
||||||
return machine_on && idle && homed && requested == 1U && task->state.mode == SIM_TASK_MODE_MANUAL ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : !machine_on ? SIM_TASK_GATE_MACHINE_OFF : !homed ? SIM_TASK_GATE_UNHOMED : SIM_TASK_GATE_MODE_STATE;
|
return machine_on && idle && homed && requested == 1U && task->state.mode == SIM_TASK_MODE_MANUAL ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : !machine_on ? SIM_TASK_GATE_MACHINE_OFF : !homed ? SIM_TASK_GATE_UNHOMED : SIM_TASK_GATE_MODE_STATE;
|
||||||
case SIM_TASK_GATE_MDI:
|
case SIM_TASK_GATE_MDI:
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ int main(void) {
|
|||||||
assert(fabs(interpreter.state.x - 11.0) < 1e-12);
|
assert(fabs(interpreter.state.x - 11.0) < 1e-12);
|
||||||
assert(fabs(interpreter.state.y - 22.0) < 1e-12);
|
assert(fabs(interpreter.state.y - 22.0) < 1e-12);
|
||||||
assert(fabs(interpreter.state.z - 33.0) < 1e-12);
|
assert(fabs(interpreter.state.z - 33.0) < 1e-12);
|
||||||
|
assert(sim_interpreter_read_motion(&interpreter, &motion) == SIM_INTERP_OK);
|
||||||
|
assert(fabs(motion.end.tran.x - 11.0) < 1e-12);
|
||||||
|
assert(fabs(motion.end.tran.y - 22.0) < 1e-12);
|
||||||
assert(sim_interpreter_execute(&interpreter, "G53 G0 X0 Y0 Z10", (uint32_t)(sizeof("G53 G0 X0 Y0 Z10") - 1)) == SIM_INTERP_OK);
|
assert(sim_interpreter_execute(&interpreter, "G53 G0 X0 Y0 Z10", (uint32_t)(sizeof("G53 G0 X0 Y0 Z10") - 1)) == SIM_INTERP_OK);
|
||||||
assert(fabs(interpreter.state.x) < 1e-12 && fabs(interpreter.state.y) < 1e-12 && fabs(interpreter.state.z - 10.0) < 1e-12);
|
assert(fabs(interpreter.state.x) < 1e-12 && fabs(interpreter.state.y) < 1e-12 && fabs(interpreter.state.z - 10.0) < 1e-12);
|
||||||
assert(sim_interpreter_execute(&interpreter, "G10 L20 P0 X0 Y0 Z10", (uint32_t)(sizeof("G10 L20 P0 X0 Y0 Z10") - 1)) == SIM_INTERP_OK);
|
assert(sim_interpreter_execute(&interpreter, "G10 L20 P0 X0 Y0 Z10", (uint32_t)(sizeof("G10 L20 P0 X0 Y0 Z10") - 1)) == SIM_INTERP_OK);
|
||||||
@@ -78,5 +81,17 @@ int main(void) {
|
|||||||
assert(sim_interpreter_execute(&interpreter, "G80 M7", (uint32_t)(sizeof("G80 M7") - 1)) == SIM_INTERP_OK);
|
assert(sim_interpreter_execute(&interpreter, "G80 M7", (uint32_t)(sizeof("G80 M7") - 1)) == SIM_INTERP_OK);
|
||||||
assert(sim_interpreter_execute(&interpreter, "M8", 2) == SIM_INTERP_OK);
|
assert(sim_interpreter_execute(&interpreter, "M8", 2) == SIM_INTERP_OK);
|
||||||
assert(sim_interpreter_execute(&interpreter, "M9", 2) == SIM_INTERP_OK);
|
assert(sim_interpreter_execute(&interpreter, "M9", 2) == SIM_INTERP_OK);
|
||||||
|
assert(sim_interpreter_reset(&interpreter) == SIM_INTERP_OK);
|
||||||
|
assert(sim_interpreter_execute(&interpreter, "G0 X30 A20 C40", sizeof("G0 X30 A20 C40") - 1) == SIM_INTERP_OK);
|
||||||
|
assert(sim_interpreter_execute(&interpreter, "G10 L20 P1 X5", 13) == SIM_INTERP_OK);
|
||||||
|
assert(interpreter.state.x == 30.0);
|
||||||
|
assert(sim_interpreter_execute(&interpreter, "G0 X6", 5) == SIM_INTERP_OK);
|
||||||
|
assert(sim_interpreter_read_motion(&interpreter, &motion) == SIM_INTERP_OK);
|
||||||
|
assert(motion.start.tran.x == 30.0 && motion.end.tran.x == 31.0);
|
||||||
|
assert(sim_interpreter_execute(&interpreter, "G10 L20 P9 A5 C10", 17) == SIM_INTERP_OK);
|
||||||
|
assert(interpreter.state.a == 20.0 && interpreter.state.c == 40.0);
|
||||||
|
assert(sim_interpreter_execute(&interpreter, "G59.3 G0 A6 C11", 15) == SIM_INTERP_OK);
|
||||||
|
assert(sim_interpreter_read_motion(&interpreter, &motion) == SIM_INTERP_OK);
|
||||||
|
assert(motion.end.a == 21.0 && motion.end.c == 41.0);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "sim_linuxcnc_tp.h"
|
#include "sim_linuxcnc_tp.h"
|
||||||
|
|
||||||
#include <assert.h>
|
#include <assert.h>
|
||||||
|
#include <math.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
|
|
||||||
@@ -43,6 +44,44 @@ int main(void) {
|
|||||||
assert(sim_linuxcnc_tp_step(planner) == 0);
|
assert(sim_linuxcnc_tp_step(planner) == 0);
|
||||||
assert(sim_linuxcnc_tp_read_state(planner, &state) == 0);
|
assert(sim_linuxcnc_tp_read_state(planner, &state) == 0);
|
||||||
assert(state.position.tran.x > 0.0 && sim_linuxcnc_tp_jog(planner, 0, 1, 100.0, 22) != 0);
|
assert(state.position.tran.x > 0.0 && sim_linuxcnc_tp_jog(planner, 0, 1, 100.0, 22) != 0);
|
||||||
|
assert(sim_linuxcnc_tp_abort(planner) == 0);
|
||||||
|
assert(sim_linuxcnc_tp_set_position(planner, &origin) == 0);
|
||||||
|
assert(sim_linuxcnc_tp_jog(planner, 0, 1, -1.0, 23) != 0);
|
||||||
|
assert(sim_linuxcnc_tp_jog(planner, 0, 1, NAN, 23) != 0);
|
||||||
|
assert(sim_linuxcnc_tp_jog(planner, 0, 1, 0.0, 23) == 0);
|
||||||
|
for (int index = 0; index < 3000; ++index) {
|
||||||
|
assert(sim_linuxcnc_tp_step(planner) == 0);
|
||||||
|
assert(sim_linuxcnc_tp_read_state(planner, &state) == 0);
|
||||||
|
assert(state.position.tran.x <= 5.0);
|
||||||
|
if (state.queue_depth == 0) break;
|
||||||
|
}
|
||||||
|
assert(state.queue_depth == 0 && fabs(state.position.tran.x - 5.0) < 1e-9);
|
||||||
|
assert(sim_linuxcnc_tp_jog(planner, 0, 1, 0.0, 24) != 0);
|
||||||
|
assert(sim_linuxcnc_tp_jog(planner, 0, -1, 0.0, 25) == 0);
|
||||||
|
for (int index = 0; index < 50; ++index) assert(sim_linuxcnc_tp_step(planner) == 0);
|
||||||
|
assert(sim_linuxcnc_tp_read_state(planner, &state) == 0);
|
||||||
|
assert(state.position.tran.x < 5.0 && state.position.tran.x > -5.0);
|
||||||
|
assert(sim_linuxcnc_tp_abort(planner) == 0);
|
||||||
|
assert(sim_linuxcnc_tp_read_state(planner, &state) == 0 && state.queue_depth == 0);
|
||||||
|
assert(sim_linuxcnc_tp_set_position(planner, &origin) == 0);
|
||||||
|
first = pose(1, 0, 0); second = pose(2, 0, 0);
|
||||||
|
assert(sim_linuxcnc_tp_add_line(planner, &first, 10, 300, 101, 1) == 0);
|
||||||
|
assert(sim_linuxcnc_tp_add_line(planner, &second, 10, 300, 102, 1) == 0);
|
||||||
|
assert(sim_linuxcnc_tp_set_paused(planner, 1) == 0);
|
||||||
|
assert(sim_linuxcnc_tp_step_line(planner) == 0);
|
||||||
|
for (int tick = 0; tick < 3000 && sim_linuxcnc_tp_is_stepping(planner); ++tick) assert(sim_linuxcnc_tp_step(planner) == 0);
|
||||||
|
assert(!sim_linuxcnc_tp_is_stepping(planner));
|
||||||
|
assert(sim_linuxcnc_tp_read_state(planner, &state) == 0);
|
||||||
|
/* motion/control.c detects an ID transition after the servo sample;
|
||||||
|
a transition may advance less than one cycle into the following TC. */
|
||||||
|
assert(state.position.tran.x >= 1.0 && state.position.tran.x < 1.0 + 10.0 * 0.001);
|
||||||
|
assert(sim_linuxcnc_tp_queue_depth(planner) == 1);
|
||||||
|
assert(sim_linuxcnc_tp_step_line(planner) == 0);
|
||||||
|
for (int tick = 0; tick < 3000 && sim_linuxcnc_tp_is_stepping(planner); ++tick) assert(sim_linuxcnc_tp_step(planner) == 0);
|
||||||
|
assert(sim_linuxcnc_tp_read_state(planner, &state) == 0);
|
||||||
|
assert(fabs(state.position.tran.x - 2.0) < 1e-9);
|
||||||
|
assert(sim_linuxcnc_tp_axis_limit(planner, 0, 0) == -5.0);
|
||||||
|
assert(sim_linuxcnc_tp_axis_limit(planner, 0, 1) == 5.0);
|
||||||
puts(canonical);
|
puts(canonical);
|
||||||
free(planner);
|
free(planner);
|
||||||
return 0;
|
return 0;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#include "sim_protocol.h"
|
#include "sim_protocol.h"
|
||||||
|
|
||||||
#include <assert.h>
|
#include <assert.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
sim_protocol_reset();
|
sim_protocol_reset();
|
||||||
@@ -15,17 +16,31 @@ int main(void) {
|
|||||||
assert(sim_protocol_last_seq() == 4);
|
assert(sim_protocol_last_seq() == 4);
|
||||||
assert(sim_protocol_accept(1, 5, "PROGRAM_PAUSE", "op-4", 2) == SIM_PROTOCOL_OK);
|
assert(sim_protocol_accept(1, 5, "PROGRAM_PAUSE", "op-4", 2) == SIM_PROTOCOL_OK);
|
||||||
assert(sim_protocol_accept(1, 6, "SIM_STEP", "op-5-step", 2) == SIM_PROTOCOL_OK);
|
assert(sim_protocol_accept(1, 6, "SIM_STEP", "op-5-step", 2) == SIM_PROTOCOL_OK);
|
||||||
assert(sim_protocol_accept(1, 7, "MDI_EXECUTE", "op-5", 2) == SIM_PROTOCOL_ERR_STATE);
|
/* These are connected-channel frames. MDI can still be rejected by
|
||||||
assert(sim_protocol_accept(1, 7, "PROGRAM_ABORT", "op-5", 2) == SIM_PROTOCOL_OK);
|
the task; the transport must not poison subsequent command IDs. */
|
||||||
|
const char *paused_commands[] = {"VISUAL_CLEAR", "SET_SPINDLE_OVERRIDE", "SET_MAX_VELOCITY", "PROGRAM_STEP", "MDI_EXECUTE", "PROGRAM_PAUSE"};
|
||||||
|
uint32_t seq = 7;
|
||||||
|
for (unsigned int i = 0; i < sizeof(paused_commands) / sizeof(paused_commands[0]); ++i) {
|
||||||
|
assert(sim_protocol_accept(1, seq, paused_commands[i], "paused-op", 2) == SIM_PROTOCOL_OK);
|
||||||
|
assert(sim_protocol_last_seq() == seq++);
|
||||||
|
assert(sim_protocol_state_value() == SIM_PROTOCOL_PAUSED);
|
||||||
|
}
|
||||||
|
assert(sim_protocol_accept(1, seq + 1, "PROGRAM_RESUME", "resume-gap", 2) == SIM_PROTOCOL_ERR_SEQUENCE);
|
||||||
|
assert(sim_protocol_last_seq() == seq - 1);
|
||||||
|
assert(sim_protocol_accept(1, seq, "MDI_EXECUTE", NULL, 2) == SIM_PROTOCOL_ERR_OPERATION_ID);
|
||||||
|
assert(sim_protocol_accept(1, seq++, "PROGRAM_RESUME", "resume", 2) == SIM_PROTOCOL_OK);
|
||||||
assert(sim_protocol_state_value() == SIM_PROTOCOL_READY);
|
assert(sim_protocol_state_value() == SIM_PROTOCOL_READY);
|
||||||
assert(sim_protocol_accept(1, 8, "PROGRAM_PAUSE", "op-6", 2) == SIM_PROTOCOL_OK);
|
assert(sim_protocol_accept(1, seq++, "PROGRAM_PAUSE", "pause-again", 2) == SIM_PROTOCOL_OK);
|
||||||
assert(sim_protocol_accept(1, 9, "PROGRAM_RESUME", "op-7", 2) == SIM_PROTOCOL_OK);
|
assert(sim_protocol_accept(1, seq++, "PROGRAM_ABORT", "abort", 2) == SIM_PROTOCOL_OK);
|
||||||
assert(sim_protocol_accept(2, 10, "PING", NULL, 2) == SIM_PROTOCOL_ERR_SCHEMA);
|
assert(sim_protocol_state_value() == SIM_PROTOCOL_READY);
|
||||||
assert(sim_protocol_accept(1, 10, "PING", NULL, 1) == SIM_PROTOCOL_ERR_TICK);
|
assert(sim_protocol_accept(2, seq, "PING", NULL, 2) == SIM_PROTOCOL_ERR_SCHEMA);
|
||||||
assert(sim_protocol_accept(1, 10, "DESTROY", NULL, 2) == SIM_PROTOCOL_OK);
|
assert(sim_protocol_accept(1, seq, "PING", NULL, 1) == SIM_PROTOCOL_ERR_TICK);
|
||||||
assert(sim_protocol_accept(1, 11, "PING", NULL, 2) == SIM_PROTOCOL_ERR_STATE);
|
assert(sim_protocol_accept(1, seq++, "DESTROY", NULL, 2) == SIM_PROTOCOL_OK);
|
||||||
assert(sim_protocol_accept(1, 12, "RESET", "reset-1", 0) == SIM_PROTOCOL_OK);
|
assert(sim_protocol_accept(1, seq, "PING", NULL, 2) == SIM_PROTOCOL_ERR_STATE);
|
||||||
|
assert(sim_protocol_accept(1, seq, "RESET", "reset-1", 2) == SIM_PROTOCOL_OK);
|
||||||
assert(sim_protocol_state_value() == SIM_PROTOCOL_NEW);
|
assert(sim_protocol_state_value() == SIM_PROTOCOL_NEW);
|
||||||
assert(sim_protocol_set_tick(4) == SIM_PROTOCOL_ERR_TICK);
|
assert(sim_protocol_last_seq() == 0);
|
||||||
|
assert(sim_protocol_set_tick(4) == SIM_PROTOCOL_OK);
|
||||||
|
assert(sim_protocol_set_tick(3) == SIM_PROTOCOL_ERR_TICK);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,9 @@ int main(void) {
|
|||||||
assert(sim_task_set_mode(&task, SIM_TASK_MODE_AUTO) == SIM_TASK_OK);
|
assert(sim_task_set_mode(&task, SIM_TASK_MODE_AUTO) == SIM_TASK_OK);
|
||||||
assert(sim_task_command_gate(&task, SIM_TASK_GATE_PAUSE, 0, 1, 0, 1, 0, 10) == SIM_TASK_GATE_ALLOWED);
|
assert(sim_task_command_gate(&task, SIM_TASK_GATE_PAUSE, 0, 1, 0, 1, 0, 10) == SIM_TASK_GATE_ALLOWED);
|
||||||
assert(sim_task_command_gate(&task, SIM_TASK_GATE_OVERRIDE, 0, 1, 0, 1, 0, 10) == SIM_TASK_GATE_ALLOWED);
|
assert(sim_task_command_gate(&task, SIM_TASK_GATE_OVERRIDE, 0, 1, 0, 1, 0, 10) == SIM_TASK_GATE_ALLOWED);
|
||||||
assert(sim_task_command_gate(&task, SIM_TASK_GATE_OVERRIDE, 0, 0, 0, 1, 0, 10) == SIM_TASK_GATE_MACHINE_OFF);
|
/* axis.tcl sliders are available in OFF and ESTOP as well as AUTO. */
|
||||||
assert(sim_task_command_gate(&task, SIM_TASK_GATE_OVERRIDE, 0, 1, 1, 1, 0, 10) == SIM_TASK_GATE_ESTOP_ACTIVE);
|
assert(sim_task_command_gate(&task, SIM_TASK_GATE_OVERRIDE, 0, 0, 0, 1, 0, 10) == SIM_TASK_GATE_ALLOWED);
|
||||||
|
assert(sim_task_command_gate(&task, SIM_TASK_GATE_OVERRIDE, 0, 1, 1, 1, 0, 10) == SIM_TASK_GATE_ALLOWED);
|
||||||
assert(sim_task_command_gate(&task, SIM_TASK_GATE_POWER, 0, 1, 0, 1, 0, 10) == SIM_TASK_GATE_ALLOWED);
|
assert(sim_task_command_gate(&task, SIM_TASK_GATE_POWER, 0, 1, 0, 1, 0, 10) == SIM_TASK_GATE_ALLOWED);
|
||||||
assert(sim_task_command_gate(&task, SIM_TASK_GATE_SET_MODE, SIM_TASK_MODE_MANUAL, 1, 0, 1, 0, 10) == SIM_TASK_GATE_MODE_AUTO_ACTIVE);
|
assert(sim_task_command_gate(&task, SIM_TASK_GATE_SET_MODE, SIM_TASK_MODE_MANUAL, 1, 0, 1, 0, 10) == SIM_TASK_GATE_MODE_AUTO_ACTIVE);
|
||||||
assert(sim_task_command_gate(&task, SIM_TASK_GATE_ESTOP, 1, 1, 0, 1, 0, 10) == SIM_TASK_GATE_ALLOWED);
|
assert(sim_task_command_gate(&task, SIM_TASK_GATE_ESTOP, 1, 1, 0, 1, 0, 10) == SIM_TASK_GATE_ALLOWED);
|
||||||
|
|||||||
@@ -22,8 +22,9 @@ export class AxisOperationController {
|
|||||||
try {
|
try {
|
||||||
const response = await this.client.send(kind, payload, operationId);
|
const response = await this.client.send(kind, payload, operationId);
|
||||||
const responsePayload = response?.payload && typeof response.payload === "object" ? response.payload : {};
|
const responsePayload = response?.payload && typeof response.payload === "object" ? response.payload : {};
|
||||||
const workerBlocked = response?.kind === "ERROR" || responsePayload.status === "blocked";
|
const workerBlocked = response?.kind === "ERROR" || ["blocked", "error"].includes(responsePayload.status);
|
||||||
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: workerBlocked ? "blocked" : "result", reason: responsePayload.gate_reason ?? responsePayload.message_key ?? responsePayload.code ?? response?.kind ?? null }));
|
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: workerBlocked ? "blocked" : "result", reason: responsePayload.gate_reason ?? responsePayload.message_key ?? responsePayload.code ?? response?.kind ?? null }));
|
||||||
|
if (workerBlocked) throw Object.assign(new Error(responsePayload.gate_reason ?? responsePayload.message_key ?? responsePayload.code ?? "axis_worker_rejected"), { response, operationId });
|
||||||
return Object.freeze({ operationId, response, gate: effectiveGate });
|
return Object.freeze({ operationId, response, gate: effectiveGate });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "error", reason: error?.message ?? String(error) }));
|
this.trace.push(Object.freeze({ operation_id: operationId, kind, action, phase: "error", reason: error?.message ?? String(error) }));
|
||||||
|
|||||||
@@ -14,4 +14,14 @@ assert.equal(jog.operationId, "axis-jog-start-000002");
|
|||||||
const tick = await controller.execute("SIM_STEP", { cycles: 1 }, { state: ready, gate: false });
|
const tick = await controller.execute("SIM_STEP", { cycles: 1 }, { state: ready, gate: false });
|
||||||
assert.equal(tick.operationId, "axis-sim-step-000003");
|
assert.equal(tick.operationId, "axis-sim-step-000003");
|
||||||
assert.deepEqual(controller.exportTrace().map((entry) => entry.phase), ["pending", "result", "pending", "result", "pending", "result"]);
|
assert.deepEqual(controller.exportTrace().map((entry) => entry.phase), ["pending", "result", "pending", "result", "pending", "result"]);
|
||||||
|
for (const response of [
|
||||||
|
{ kind: "ERROR", payload: { gate_reason: "xyzac_machine_off" } },
|
||||||
|
{ kind: "COMMAND_RESULT", payload: { status: "blocked", code: "abi_missing" } },
|
||||||
|
{ kind: "COMMAND_RESULT", payload: { status: "error", message_key: "interpreter_error" } },
|
||||||
|
]) {
|
||||||
|
const rejected = new AxisOperationController({ send: async () => response });
|
||||||
|
await assert.rejects(rejected.execute("PROGRAM_RUN"), (error) => error.response === response);
|
||||||
|
assert.equal(rejected.exportTrace().some((entry) => entry.phase === "result"), false);
|
||||||
|
assert.equal(rejected.exportTrace()[1].phase, "blocked");
|
||||||
|
}
|
||||||
console.log(JSON.stringify({ schema: "axis-operation-controller-sample.v1", status: "verified", calls: calls.length, phases: controller.exportTrace().map((entry) => entry.phase), last_operation: tick.operationId }));
|
console.log(JSON.stringify({ schema: "axis-operation-controller-sample.v1", status: "verified", calls: calls.length, phases: controller.exportTrace().map((entry) => entry.phase), last_operation: tick.operationId }));
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
77
web/react/src/HoldButton.tsx
Normal file
77
web/react/src/HoldButton.tsx
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
controlId: string;
|
||||||
|
label: string;
|
||||||
|
children: ReactNode;
|
||||||
|
disabled: boolean;
|
||||||
|
disabledReason?: string;
|
||||||
|
active: boolean;
|
||||||
|
group: RefObject<boolean>;
|
||||||
|
onPress: () => Promise<void> | void;
|
||||||
|
onRelease?: () => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
// A release owns the callbacks captured at press time, even across rerenders.
|
||||||
|
export function HoldButton({ controlId, label, children, disabled, disabledReason, active, group, onPress, onRelease }: Props) {
|
||||||
|
const held = useRef<{ pointer: number | null; key: string | null; finish: () => void } | null>(null);
|
||||||
|
const release = () => {
|
||||||
|
const gesture = held.current;
|
||||||
|
if (!gesture) return;
|
||||||
|
held.current = null;
|
||||||
|
gesture.finish();
|
||||||
|
};
|
||||||
|
const press = (pointer: number | null, key: string | null) => {
|
||||||
|
if (disabled || !active || held.current || group.current) return;
|
||||||
|
group.current = true;
|
||||||
|
const pending = Promise.resolve().then(onPress);
|
||||||
|
void pending.catch(() => undefined);
|
||||||
|
held.current = { pointer, key, finish: () => {
|
||||||
|
void pending.then(() => onRelease?.()).catch(() => undefined).finally(() => { group.current = false; });
|
||||||
|
} };
|
||||||
|
};
|
||||||
|
useEffect(() => {
|
||||||
|
const visibility = () => { if (document.hidden) release(); };
|
||||||
|
const pointerUp = (event: PointerEvent) => { if (held.current?.pointer === event.pointerId) release(); };
|
||||||
|
const keyUp = (event: KeyboardEvent) => { if (held.current?.key === event.key) release(); };
|
||||||
|
window.addEventListener("blur", release);
|
||||||
|
window.addEventListener("pointerup", pointerUp);
|
||||||
|
window.addEventListener("pointercancel", pointerUp);
|
||||||
|
window.addEventListener("keyup", keyUp);
|
||||||
|
document.addEventListener("visibilitychange", visibility);
|
||||||
|
return () => {
|
||||||
|
release();
|
||||||
|
window.removeEventListener("blur", release);
|
||||||
|
window.removeEventListener("pointerup", pointerUp);
|
||||||
|
window.removeEventListener("pointercancel", pointerUp);
|
||||||
|
window.removeEventListener("keyup", keyUp);
|
||||||
|
document.removeEventListener("visibilitychange", visibility);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
useEffect(() => { if (!active || disabled) release(); }, [active, disabled]);
|
||||||
|
return <button type="button" className="axis-text-button" data-axis-control={controlId}
|
||||||
|
disabled={disabled} title={disabled ? disabledReason : label} aria-label={label}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
press(event.pointerId, null);
|
||||||
|
if (event.currentTarget.hasPointerCapture(event.pointerId)) return;
|
||||||
|
try { event.currentTarget.setPointerCapture(event.pointerId); } catch { /* Synthetic events have no active pointer. */ }
|
||||||
|
}}
|
||||||
|
onPointerMove={(event) => {
|
||||||
|
if (!held.current || held.current.pointer !== event.pointerId) return;
|
||||||
|
const bounds = event.currentTarget.getBoundingClientRect();
|
||||||
|
if (event.clientX < bounds.left || event.clientX > bounds.right || event.clientY < bounds.top || event.clientY > bounds.bottom) release();
|
||||||
|
}}
|
||||||
|
onPointerUp={release} onPointerCancel={release} onPointerLeave={release} onLostPointerCapture={release}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== " " && event.key !== "Enter") return;
|
||||||
|
event.preventDefault();
|
||||||
|
if (!event.repeat) press(null, event.key);
|
||||||
|
}}
|
||||||
|
onKeyUp={(event) => {
|
||||||
|
if (event.key !== " " && event.key !== "Enter") return;
|
||||||
|
event.preventDefault();
|
||||||
|
release();
|
||||||
|
}}
|
||||||
|
onBlur={release}>{children}</button>;
|
||||||
|
}
|
||||||
@@ -27,12 +27,12 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
|
|||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
scene.background = new THREE.Color(0x050a0e);
|
scene.background = new THREE.Color(0x050a0e);
|
||||||
scene.add(new THREE.HemisphereLight(0xdcecff, 0x121820, 1.4));
|
scene.add(new THREE.HemisphereLight(0xdcecff, 0x121820, 1.4));
|
||||||
const camera = new THREE.PerspectiveCamera(40, 1, 0.01, 100);
|
let camera: THREE.PerspectiveCamera | THREE.OrthographicCamera = new THREE.PerspectiveCamera(40, 1, 0.001, 100);
|
||||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
|
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false, preserveDrawingBuffer: true });
|
||||||
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, 2));
|
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio || 1, 2));
|
||||||
renderer.domElement.dataset.engine = "three.js r185 / xyzac-tool-preview";
|
renderer.domElement.dataset.engine = "three.js r185 / xyzac-tool-preview";
|
||||||
element.appendChild(renderer.domElement);
|
element.appendChild(renderer.domElement);
|
||||||
const controls = new OrbitControls(camera, renderer.domElement);
|
let controls: OrbitControls<THREE.PerspectiveCamera | THREE.OrthographicCamera> = new OrbitControls(camera, renderer.domElement);
|
||||||
controls.enableDamping = true;
|
controls.enableDamping = true;
|
||||||
controls.dampingFactor = .12;
|
controls.dampingFactor = .12;
|
||||||
controls.enableZoom = true;
|
controls.enableZoom = true;
|
||||||
@@ -60,12 +60,18 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
|
|||||||
const toolGroup = new THREE.Group(); toolGroup.name = "xyzac-preview-tool"; scene.add(toolGroup);
|
const toolGroup = new THREE.Group(); toolGroup.name = "xyzac-preview-tool"; scene.add(toolGroup);
|
||||||
const overlayGroup = new THREE.Group(); overlayGroup.name = "xyzac-preview-overlays"; scene.add(overlayGroup);
|
const overlayGroup = new THREE.Group(); overlayGroup.name = "xyzac-preview-overlays"; scene.add(overlayGroup);
|
||||||
const offsetAxes = new THREE.AxesHelper(1.5); offsetAxes.name = "xyzac-preview-offset-axes"; overlayGroup.add(offsetAxes);
|
const offsetAxes = new THREE.AxesHelper(1.5); offsetAxes.name = "xyzac-preview-offset-axes"; overlayGroup.add(offsetAxes);
|
||||||
const limitGrid = new THREE.GridHelper(2.5, 10, 0x8b4d47, 0x4f2d2d); limitGrid.name = "xyzac-preview-limits"; overlayGroup.add(limitGrid);
|
const limitGrid = new THREE.Box3Helper(new THREE.Box3(), 0xd8756d); limitGrid.name = "xyzac-preview-limits"; overlayGroup.add(limitGrid);
|
||||||
|
const extents = new THREE.Box3Helper(new THREE.Box3(), 0xf2cf65); extents.name = "xyzac-preview-extents"; overlayGroup.add(extents);
|
||||||
const toolMaterial = new THREE.MeshStandardMaterial({ color: 0xb8bec4, roughness: .45, metalness: .35 });
|
const toolMaterial = new THREE.MeshStandardMaterial({ color: 0xb8bec4, roughness: .45, metalness: .35 });
|
||||||
const tool = new THREE.Mesh(new THREE.CylinderGeometry(.04, .04, .58, 20), toolMaterial); tool.name = "xyzac-preview-tool-shaft"; toolGroup.add(tool);
|
const tool = new THREE.Mesh(new THREE.CylinderGeometry(.04, .04, .58, 20), toolMaterial); tool.name = "xyzac-preview-tool-shaft"; toolGroup.add(tool);
|
||||||
const tipGeometry = new THREE.ConeGeometry(.09, .28, 24); tipGeometry.rotateZ(Math.PI); tipGeometry.translate(0, .14, 0);
|
const tipGeometry = new THREE.ConeGeometry(.09, .28, 24); tipGeometry.rotateZ(Math.PI); tipGeometry.translate(0, .14, 0);
|
||||||
const tip = new THREE.Mesh(tipGeometry, new THREE.MeshBasicMaterial({ color: 0xf2f2f2 })); tip.name = "xyzac-preview-tool-tip"; toolGroup.add(tip);
|
const tip = new THREE.Mesh(tipGeometry, new THREE.MeshBasicMaterial({ color: 0xf2f2f2 })); tip.name = "xyzac-preview-tool-tip"; toolGroup.add(tip);
|
||||||
const resize = () => { const width = Math.max(element.clientWidth, 280); const height = Math.max(element.clientHeight, 260); camera.aspect = width / height; camera.updateProjectionMatrix(); renderer.setSize(width, height, false); };
|
const resize = () => {
|
||||||
|
const width = Math.max(element.clientWidth, 280), height = Math.max(element.clientHeight, 260);
|
||||||
|
if (camera instanceof THREE.PerspectiveCamera) camera.aspect = width / height;
|
||||||
|
else { camera.left = -camera.top * width / height; camera.right = -camera.left; }
|
||||||
|
camera.updateProjectionMatrix(); renderer.setSize(width, height, false);
|
||||||
|
};
|
||||||
const observer = new ResizeObserver(resize); observer.observe(element); resize();
|
const observer = new ResizeObserver(resize); observer.observe(element); resize();
|
||||||
const fit = (segments: Segment[]) => {
|
const fit = (segments: Segment[]) => {
|
||||||
const points = segments.flatMap((segment) => (Array.isArray(segment.samples) && segment.samples.length > 1 ? segment.samples : [segment.start ?? {}, segment.end ?? {}]).map(sourcePoint));
|
const points = segments.flatMap((segment) => (Array.isArray(segment.samples) && segment.samples.length > 1 ? segment.samples : [segment.start ?? {}, segment.end ?? {}]).map(sourcePoint));
|
||||||
@@ -90,6 +96,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
|
|||||||
let liveTick = -1;
|
let liveTick = -1;
|
||||||
let liveTrace: THREE.Vector3[] = [];
|
let liveTrace: THREE.Vector3[] = [];
|
||||||
let pathSegments: Segment[] | null = null;
|
let pathSegments: Segment[] | null = null;
|
||||||
|
let extentsSegments: Segment[] | null = null;
|
||||||
// Native TP active_id is the source line/motion id and may repeat (for
|
// Native TP active_id is the source line/motion id and may repeat (for
|
||||||
// example, each call of the same helix subroutine). Preserve every
|
// example, each call of the same helix subroutine). Preserve every
|
||||||
// occurrence instead of letting a Map overwrite all but one geometry.
|
// occurrence instead of letting a Map overwrite all but one geometry.
|
||||||
@@ -109,6 +116,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
|
|||||||
let paintedActiveSegment = 0;
|
let paintedActiveSegment = 0;
|
||||||
let paintedProgress = 0;
|
let paintedProgress = 0;
|
||||||
let liveHistoryLine: THREE.Line | null = null;
|
let liveHistoryLine: THREE.Line | null = null;
|
||||||
|
let historyVertexCount = 0;
|
||||||
let activeLiveLine: THREE.Line | null = null;
|
let activeLiveLine: THREE.Line | null = null;
|
||||||
let livePathEpoch = -1;
|
let livePathEpoch = -1;
|
||||||
let lastHistoryTick = -1;
|
let lastHistoryTick = -1;
|
||||||
@@ -116,14 +124,25 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
|
|||||||
let lastPreset = "";
|
let lastPreset = "";
|
||||||
const presetCamera = (selected: Props["view"], selectedZoom: number) => {
|
const presetCamera = (selected: Props["view"], selectedZoom: number) => {
|
||||||
const distance = bounds.radius * 2.9 / Math.max(.5, selectedZoom);
|
const distance = bounds.radius * 2.9 / Math.max(.5, selectedZoom);
|
||||||
|
controls.dispose();
|
||||||
|
const aspect = Math.max(element.clientWidth, 280) / Math.max(element.clientHeight, 260);
|
||||||
|
const span = bounds.radius * 1.3 / Math.max(.5, selectedZoom);
|
||||||
|
camera = selected === "p" ? new THREE.PerspectiveCamera(40, aspect, .001, 100)
|
||||||
|
: new THREE.OrthographicCamera(-span * aspect, span * aspect, span, -span, .001, 100);
|
||||||
|
camera.up.set(0, 1, 0);
|
||||||
|
if (selected === "z") camera.up.set(0, 0, 1);
|
||||||
|
if (selected === "z2") camera.up.set(-1, 0, 0);
|
||||||
|
if (selected === "y2") camera.up.set(1, 0, 0);
|
||||||
|
controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true; controls.dampingFactor = .12; controls.screenSpacePanning = true;
|
||||||
controls.target.copy(bounds.center);
|
controls.target.copy(bounds.center);
|
||||||
if (selected === "z") camera.position.copy(bounds.center).add(new THREE.Vector3(0, distance, .01));
|
if (selected === "z" || selected === "z2") camera.position.copy(bounds.center).add(new THREE.Vector3(0, -distance, 0));
|
||||||
else if (selected === "z2") camera.position.copy(bounds.center).add(new THREE.Vector3(distance * .3, distance * .9, distance * .3));
|
else if (selected === "x") camera.position.copy(bounds.center).add(new THREE.Vector3(-distance, 0, 0));
|
||||||
else if (selected === "x") camera.position.copy(bounds.center).add(new THREE.Vector3(distance, .02, .01));
|
else if (selected === "y") camera.position.copy(bounds.center).add(new THREE.Vector3(0, 0, distance));
|
||||||
else if (selected === "y") camera.position.copy(bounds.center).add(new THREE.Vector3(.01, .02, distance));
|
else if (selected === "y2") camera.position.copy(bounds.center).add(new THREE.Vector3(0, 0, -distance));
|
||||||
else if (selected === "y2") camera.position.copy(bounds.center).add(new THREE.Vector3(.01, .02, -distance));
|
|
||||||
else camera.position.copy(bounds.center).add(new THREE.Vector3(distance * .85, distance * .7, distance));
|
else camera.position.copy(bounds.center).add(new THREE.Vector3(distance * .85, distance * .7, distance));
|
||||||
camera.lookAt(bounds.center);
|
camera.lookAt(bounds.center);
|
||||||
|
element.dataset.xyzacProjection = selected === "p" ? "perspective" : "orthographic";
|
||||||
controls.update();
|
controls.update();
|
||||||
};
|
};
|
||||||
const pan = (horizontal: number, vertical: number) => {
|
const pan = (horizontal: number, vertical: number) => {
|
||||||
@@ -246,22 +265,14 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
|
|||||||
// vertices so preview loading does not block the UI on one WebGL
|
// vertices so preview loading does not block the UI on one WebGL
|
||||||
// object and geometry allocation per G-code line.
|
// object and geometry allocation per G-code line.
|
||||||
const batched = { rapid: [] as THREE.Vector3[], completed: [] as THREE.Vector3[] };
|
const batched = { rapid: [] as THREE.Vector3[], completed: [] as THREE.Vector3[] };
|
||||||
const sampledLines: Array<{ points: THREE.Vector3[]; rapid: boolean }> = [];
|
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
const start = segment.start ?? {}; const end = segment.end ?? {};
|
const start = segment.start ?? {}; const end = segment.end ?? {};
|
||||||
const sampled = Array.isArray(segment.samples) && segment.samples.length > 1;
|
const sampled = Array.isArray(segment.samples) && segment.samples.length > 1;
|
||||||
const points = sampled ? segment.samples!.map(sourcePoint) : [sourcePoint(start), sourcePoint(end)];
|
const points = sampled ? segment.samples!.map(sourcePoint) : [sourcePoint(start), sourcePoint(end)];
|
||||||
const rapidPath = segment.kind === "rapid";
|
const rapidPath = segment.kind === "rapid";
|
||||||
if (sampled) sampledLines.push({ points, rapid: rapidPath });
|
const target = rapidPath ? batched.rapid : batched.completed;
|
||||||
else (rapidPath ? batched.rapid : batched.completed).push(...points);
|
for (let index = 1; index < points.length; index++) target.push(points[index - 1], points[index]);
|
||||||
}
|
}
|
||||||
const addLine = (points: THREE.Vector3[], material: THREE.LineBasicMaterial | THREE.LineDashedMaterial, dashed = false) => {
|
|
||||||
if (points.length < 2) return;
|
|
||||||
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints(points), material);
|
|
||||||
if (dashed) line.computeLineDistances();
|
|
||||||
line.renderOrder = 10;
|
|
||||||
pathGroup.add(line);
|
|
||||||
};
|
|
||||||
const addSegments = (points: THREE.Vector3[], material: THREE.LineBasicMaterial | THREE.LineDashedMaterial, dashed = false) => {
|
const addSegments = (points: THREE.Vector3[], material: THREE.LineBasicMaterial | THREE.LineDashedMaterial, dashed = false) => {
|
||||||
if (points.length < 2) return;
|
if (points.length < 2) return;
|
||||||
const line = new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(points), material);
|
const line = new THREE.LineSegments(new THREE.BufferGeometry().setFromPoints(points), material);
|
||||||
@@ -271,7 +282,8 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
|
|||||||
};
|
};
|
||||||
addSegments(batched.rapid, rapid, true);
|
addSegments(batched.rapid, rapid, true);
|
||||||
addSegments(batched.completed, completed);
|
addSegments(batched.completed, completed);
|
||||||
for (const sampled of sampledLines) addLine(sampled.points, sampled.rapid ? rapid : completed, sampled.rapid);
|
element.dataset.xyzacPreviewDrawCalls = String(pathGroup.children.length);
|
||||||
|
element.dataset.xyzacPreviewVertices = String(batched.rapid.length + batched.completed.length);
|
||||||
}
|
}
|
||||||
const presetKey = `${viewRef.current}:${zoomRef.current}:${bounds.radius}:${bounds.center.x}:${bounds.center.y}:${bounds.center.z}`;
|
const presetKey = `${viewRef.current}:${zoomRef.current}:${bounds.radius}:${bounds.center.x}:${bounds.center.y}:${bounds.center.z}`;
|
||||||
if (presetKey !== lastPreset) { presetCamera(viewRef.current, zoomRef.current); lastPreset = presetKey; }
|
if (presetKey !== lastPreset) { presetCamera(viewRef.current, zoomRef.current); lastPreset = presetKey; }
|
||||||
@@ -291,10 +303,30 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
|
|||||||
element.dataset.xyzacRenderedTick = String(renderedTick);
|
element.dataset.xyzacRenderedTick = String(renderedTick);
|
||||||
const historyDue = liveTrace.length > 1 && (lastHistoryTick < 0 || renderedTick - lastHistoryTick >= 4);
|
const historyDue = liveTrace.length > 1 && (lastHistoryTick < 0 || renderedTick - lastHistoryTick >= 4);
|
||||||
if (historyDue) {
|
if (historyDue) {
|
||||||
if (liveHistoryLine) { liveHistoryLine.geometry.dispose(); liveGroup.remove(liveHistoryLine); }
|
if (!liveHistoryLine) {
|
||||||
liveHistoryLine = new THREE.Line(new THREE.BufferGeometry().setFromPoints(liveTrace), liveHistory);
|
liveHistoryLine = new THREE.Line(new THREE.BufferGeometry(), liveHistory);
|
||||||
liveHistoryLine.renderOrder = 19;
|
liveHistoryLine.renderOrder = 19;
|
||||||
liveGroup.add(liveHistoryLine);
|
liveHistoryLine.frustumCulled = false;
|
||||||
|
liveGroup.add(liveHistoryLine);
|
||||||
|
historyVertexCount = 0;
|
||||||
|
}
|
||||||
|
let attribute = liveHistoryLine.geometry.getAttribute("position") as THREE.BufferAttribute | undefined;
|
||||||
|
if (!attribute || attribute.count < liveTrace.length) {
|
||||||
|
const capacity = Math.max(1024, 2 ** Math.ceil(Math.log2(liveTrace.length)));
|
||||||
|
attribute = new THREE.BufferAttribute(new Float32Array(capacity * 3), 3).setUsage(THREE.DynamicDrawUsage);
|
||||||
|
liveHistoryLine.geometry.dispose();
|
||||||
|
liveHistoryLine.geometry = new THREE.BufferGeometry();
|
||||||
|
liveHistoryLine.geometry.setAttribute("position", attribute);
|
||||||
|
historyVertexCount = 0;
|
||||||
|
}
|
||||||
|
for (let index = historyVertexCount; index < liveTrace.length; index++) {
|
||||||
|
const point = liveTrace[index];
|
||||||
|
attribute.setXYZ(index, point.x, point.y, point.z);
|
||||||
|
}
|
||||||
|
attribute.addUpdateRange(historyVertexCount * 3, (liveTrace.length - historyVertexCount) * 3);
|
||||||
|
attribute.needsUpdate = true;
|
||||||
|
historyVertexCount = liveTrace.length;
|
||||||
|
liveHistoryLine.geometry.setDrawRange(0, historyVertexCount);
|
||||||
lastHistoryTick = renderedTick;
|
lastHistoryTick = renderedTick;
|
||||||
}
|
}
|
||||||
const nextActiveKey = `${renderedTick}:${renderedActiveSegment}:${renderedProgress}`;
|
const nextActiveKey = `${renderedTick}:${renderedActiveSegment}:${renderedProgress}`;
|
||||||
@@ -326,17 +358,32 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
|
|||||||
}
|
}
|
||||||
pathGroup.visible = layer.showPath; liveGroup.visible = layer.showLive;
|
pathGroup.visible = layer.showPath; liveGroup.visible = layer.showLive;
|
||||||
grid.scale.setScalar(Math.max(.2, bounds.radius * 2.4));
|
grid.scale.setScalar(Math.max(.2, bounds.radius * 2.4));
|
||||||
originGroup.scale.setScalar(Math.max(.35, bounds.radius * .55));
|
originGroup.scale.setScalar(Math.max(.015, bounds.radius * .35));
|
||||||
const pose = sourcePoint(renderedPose);
|
const pose = sourcePoint(renderedPose);
|
||||||
toolGroup.position.copy(pose);
|
toolGroup.position.copy(pose);
|
||||||
toolGroup.rotation.set(-Number(renderedPose.a ?? 0) * DEG, -Number(renderedPose.c ?? 0) * DEG, 0);
|
toolGroup.rotation.set(-Number(renderedPose.a ?? 0) * DEG, -Number(renderedPose.c ?? 0) * DEG, 0);
|
||||||
const toolScale = Math.min(1, Math.max(0.15, bounds.radius * 0.55));
|
const toolScale = Math.min(1, Math.max(0.015, bounds.radius * 0.35));
|
||||||
toolGroup.scale.setScalar(toolScale);
|
toolGroup.scale.setScalar(toolScale);
|
||||||
tool.position.set(0, .57, 0); tip.position.set(0, 0, 0);
|
tool.position.set(0, .57, 0); tip.position.set(0, 0, 0);
|
||||||
grid.visible = true; limitGrid.visible = layer.showLimits; offsetAxes.visible = layer.showOffsets;
|
grid.visible = true;
|
||||||
offsetAxes.position.copy(sourcePoint({ x: state.offsets.x_rot_point, y: state.offsets.y_offset, z: state.offsets.z_offset }));
|
const limits = state.machineLimits;
|
||||||
if (hudRef.current) hudRef.current.textContent = `TOOL PREVIEW\nX ${Number(renderedPose.x).toFixed(3)} Y ${Number(renderedPose.y).toFixed(3)} Z ${Number(renderedPose.z).toFixed(3)}\nA ${Number(renderedPose.a).toFixed(3)} C ${Number(renderedPose.c).toFixed(3)}\nJ0 ${Number(renderedJoints[0] ?? 0).toFixed(3)} J1 ${Number(renderedJoints[1] ?? 0).toFixed(3)} J2 ${Number(renderedJoints[2] ?? 0).toFixed(3)}\nJ3 ${Number(renderedJoints[3] ?? 0).toFixed(3)} J4 ${Number(renderedJoints[4] ?? 0).toFixed(3)}\nHelix ${segments.filter((segment) => segment.kind === "helix").length} / Samples ${segments.reduce((count, segment) => count + (Array.isArray(segment.samples) ? segment.samples.length : 0), 0)}\nLive ${renderedActiveSegment || "-"} / ${renderedProgress.toFixed(3)}\nTick ${renderedTick}\nHash ${state.stateHash.slice(0, 18)}`;
|
limitGrid.visible = layer.showLimits && Boolean(limits);
|
||||||
controls.update(); scene.rotation.y = rotateRef.current ? (performance.now() / 5000) % (Math.PI * 2) : 0; renderer.render(scene, camera); frame = requestAnimationFrame(render);
|
if (limits?.length === 3) limitGrid.box.setFromPoints([sourcePoint({ x: limits[0][0], y: limits[1][0], z: limits[2][0] }), sourcePoint({ x: limits[0][1], y: limits[1][1], z: limits[2][1] })]);
|
||||||
|
offsetAxes.visible = layer.showOffsets && Boolean(state.coordinateOffset);
|
||||||
|
const offset = state.coordinateOffset ?? [0, 0, 0];
|
||||||
|
offsetAxes.position.copy(sourcePoint({ x: offset[0], y: offset[1], z: offset[2] }));
|
||||||
|
extents.visible = layer.showExtents && segments.length > 0;
|
||||||
|
if (extents.visible && extentsSegments !== segments) {
|
||||||
|
extentsSegments = segments;
|
||||||
|
extents.box.setFromPoints(segments.flatMap((segment) => (segment.samples?.length ? segment.samples : [segment.start, segment.end]).filter(Boolean).map((point) => sourcePoint(point ?? {}))));
|
||||||
|
}
|
||||||
|
element.dataset.xyzacLimitsVisible = String(limitGrid.visible);
|
||||||
|
element.dataset.xyzacExtentsVisible = String(extents.visible);
|
||||||
|
element.dataset.xyzacOffset = JSON.stringify(offset);
|
||||||
|
if (hudRef.current) hudRef.current.textContent = `TOOL PREVIEW\nX ${Number(renderedPose.x).toFixed(3)} Y ${Number(renderedPose.y).toFixed(3)} Z ${Number(renderedPose.z).toFixed(3)}\nA ${Number(renderedPose.a).toFixed(3)} C ${Number(renderedPose.c).toFixed(3)}\nJ0 ${Number(renderedJoints[0] ?? 0).toFixed(3)} J1 ${Number(renderedJoints[1] ?? 0).toFixed(3)} J2 ${Number(renderedJoints[2] ?? 0).toFixed(3)}\nJ3 ${Number(renderedJoints[3] ?? 0).toFixed(3)} J4 ${Number(renderedJoints[4] ?? 0).toFixed(3)}`;
|
||||||
|
if (layer.showVelocity && hudRef.current) hudRef.current.textContent += `\nV ${Number(state.velocityMmMin ?? 0).toFixed(1)} mm/min`;
|
||||||
|
controls.mouseButtons.LEFT = rotateRef.current ? THREE.MOUSE.ROTATE : THREE.MOUSE.PAN;
|
||||||
|
controls.update(); renderer.render(scene, camera); frame = requestAnimationFrame(render);
|
||||||
};
|
};
|
||||||
render();
|
render();
|
||||||
return () => { cancelAnimationFrame(frame); observer.disconnect(); clearLines(pathGroup); clearLines(liveGroup); completed.dispose(); rapid.dispose(); live.dispose(); liveHistory.dispose(); originGroup.traverse((child) => { const mesh = child as THREE.Mesh; mesh.geometry?.dispose?.(); const material = mesh.material as (THREE.Material & { map?: THREE.Texture }) | undefined; material?.map?.dispose?.(); material?.dispose?.(); }); controls.dispose(); delete (element as typeof element & { xyzacController?: unknown }).xyzacController; renderer.dispose(); element.replaceChildren(); };
|
return () => { cancelAnimationFrame(frame); observer.disconnect(); clearLines(pathGroup); clearLines(liveGroup); completed.dispose(); rapid.dispose(); live.dispose(); liveHistory.dispose(); originGroup.traverse((child) => { const mesh = child as THREE.Mesh; mesh.geometry?.dispose?.(); const material = mesh.material as (THREE.Material & { map?: THREE.Texture }) | undefined; material?.map?.dispose?.(); material?.dispose?.(); }); controls.dispose(); delete (element as typeof element & { xyzacController?: unknown }).xyzacController; renderer.dispose(); element.replaceChildren(); };
|
||||||
@@ -346,7 +393,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
|
|||||||
const helixCount = visual.toolpath.filter((segment) => (segment as { kind?: string }).kind === "helix").length;
|
const helixCount = visual.toolpath.filter((segment) => (segment as { kind?: string }).kind === "helix").length;
|
||||||
const sampleCount = visual.toolpath.reduce((count, segment) => count + (Array.isArray((segment as { samples?: unknown[] }).samples) ? (segment as { samples: unknown[] }).samples.length : 0), 0);
|
const sampleCount = visual.toolpath.reduce((count, segment) => count + (Array.isArray((segment as { samples?: unknown[] }).samples) ? (segment as { samples: unknown[] }).samples.length : 0), 0);
|
||||||
const controllerButton = (action: string, label: string, glyph: string) => ["zoomIn", "zoomOut"].includes(action) ? null : <button type="button" data-axis-control={`preview-${action}`} data-xyzac-controller={action} title={label} aria-label={label} onClick={() => { const controller = (host.current as (HTMLDivElement & { xyzacController?: Record<string, () => void> }) | null)?.xyzacController; controller?.[action]?.(); }}>{glyph}</button>;
|
const controllerButton = (action: string, label: string, glyph: string) => ["zoomIn", "zoomOut"].includes(action) ? null : <button type="button" data-axis-control={`preview-${action}`} data-xyzac-controller={action} title={label} aria-label={label} onClick={() => { const controller = (host.current as (HTMLDivElement & { xyzacController?: Record<string, () => void> }) | null)?.xyzacController; controller?.[action]?.(); }}>{glyph}</button>;
|
||||||
return <div className="three-viewer xyzac-tool-preview" ref={host} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-preview="toolpath" data-xyzac-controller="orbit" data-xyzac-origin="0,0,0" data-xyzac-origin-axes="X,Y,Z" data-xyzac-toolpath-count={visual.toolpath.length} data-xyzac-helix-count={helixCount} data-xyzac-sample-count={sampleCount} data-xyzac-samples-source="linuxcnc-wasm-sim_posemath_segment_point" data-xyzac-live-source="linuxcnc-source-tp" data-xyzac-live-progress={visual.activeProgress} data-xyzac-live-pose={JSON.stringify(visual.livePose)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} aria-label="XYZAC authoritative tool preview"><div className="xyzac-view-controller" role="toolbar" aria-label="3D preview controller">{controllerButton("zoomIn", "Zoom in", "+")}{controllerButton("zoomOut", "Zoom out", "-")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("panLeft", "Pan left", "L")}{controllerButton("panRight", "Pan right", "R")}{controllerButton("panUp", "Pan up", "U")}{controllerButton("panDown", "Pan down", "D")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("reset", "Reset 3D view", "0")}</div><pre ref={hudRef} className="axis-preview-hud" aria-label="XYZAC tool preview state">{visual.authoritative ? `TOOL PREVIEW\nX ${Number(pose.x).toFixed(3)} Y ${Number(pose.y).toFixed(3)} Z ${Number(pose.z).toFixed(3)}\nA ${Number(pose.a).toFixed(3)} C ${Number(pose.c).toFixed(3)}\nJ0 ${Number(visual.joints[0]).toFixed(3)} J1 ${Number(visual.joints[1]).toFixed(3)} J2 ${Number(visual.joints[2]).toFixed(3)}\nJ3 ${Number(visual.joints[3]).toFixed(3)} J4 ${Number(visual.joints[4]).toFixed(3)}\nHelix ${helixCount} / Samples ${sampleCount}\nLive ${visual.activeSegmentId ?? "-"} / ${visual.activeProgress.toFixed(3)}\nTick ${visual.tick}\nHash ${visual.stateHash.slice(0, 18)}` : "TOOL PREVIEW\nWASM state pending"}</pre></div>;
|
return <div className="three-viewer xyzac-tool-preview" ref={host} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-preview="toolpath" data-xyzac-controller="orbit" data-xyzac-origin="0,0,0" data-xyzac-origin-axes="X,Y,Z" data-xyzac-toolpath-count={visual.toolpath.length} data-xyzac-helix-count={helixCount} data-xyzac-sample-count={sampleCount} data-xyzac-samples-source="linuxcnc-wasm-sim_posemath_segment_point" data-xyzac-live-source="linuxcnc-source-tp" data-xyzac-live-progress={visual.activeProgress} data-xyzac-live-pose={JSON.stringify(visual.livePose)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} aria-label="XYZAC authoritative tool preview"><div className="xyzac-view-controller" role="toolbar" aria-label="3D preview controller">{controllerButton("zoomIn", "Zoom in", "+")}{controllerButton("zoomOut", "Zoom out", "-")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("panLeft", "Pan left", "L")}{controllerButton("panRight", "Pan right", "R")}{controllerButton("panUp", "Pan up", "U")}{controllerButton("panDown", "Pan down", "D")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("reset", "Reset 3D view", "0")}</div><pre ref={hudRef} className="axis-preview-hud" aria-label="XYZAC tool preview state">{visual.authoritative ? `TOOL PREVIEW\nX ${Number(pose.x).toFixed(3)} Y ${Number(pose.y).toFixed(3)} Z ${Number(pose.z).toFixed(3)}\nA ${Number(pose.a).toFixed(3)} C ${Number(pose.c).toFixed(3)}\nJ0 ${Number(visual.joints[0]).toFixed(3)} J1 ${Number(visual.joints[1]).toFixed(3)} J2 ${Number(visual.joints[2]).toFixed(3)}\nJ3 ${Number(visual.joints[3]).toFixed(3)} J4 ${Number(visual.joints[4]).toFixed(3)}` : "TOOL PREVIEW\nWASM state pending"}</pre></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function XyzacToolPreviewFromSnapshot({ snapshot, ...props }: Omit<Props, "visual"> & { snapshot: XyzacVisualSource | null | undefined }) {
|
export function XyzacToolPreviewFromSnapshot({ snapshot, ...props }: Omit<Props, "visual"> & { snapshot: XyzacVisualSource | null | undefined }) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export type MessageEnvelope<TPayload = unknown> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type MotionSnapshot = {
|
export type MotionSnapshot = {
|
||||||
|
stepping?: boolean;
|
||||||
axes?: number[];
|
axes?: number[];
|
||||||
joints?: number[];
|
joints?: number[];
|
||||||
velocity?: number[];
|
velocity?: number[];
|
||||||
@@ -78,6 +79,8 @@ export type SimSnapshotV1 = {
|
|||||||
kinematics?: XyzacKinematicsSnapshot;
|
kinematics?: XyzacKinematicsSnapshot;
|
||||||
xyzac?: XyzacCapabilitySnapshot;
|
xyzac?: XyzacCapabilitySnapshot;
|
||||||
gates?: Record<string, { action: string; enabled: boolean; reason: string }>;
|
gates?: Record<string, { action: string; enabled: boolean; reason: string }>;
|
||||||
|
ui_gates?: Record<string, { action: string; enabled: boolean; reason: string }>;
|
||||||
|
ui_relief?: Record<string, boolean>;
|
||||||
motion_authority?: string;
|
motion_authority?: string;
|
||||||
task_authority?: string;
|
task_authority?: string;
|
||||||
};
|
};
|
||||||
@@ -129,6 +132,8 @@ export type PanelSnapshotV1 = {
|
|||||||
configuration: Record<string, unknown> | null;
|
configuration: Record<string, unknown> | null;
|
||||||
axis_state: AxisStateView;
|
axis_state: AxisStateView;
|
||||||
gates: Record<string, { action: string; enabled: boolean; reason: string }>;
|
gates: Record<string, { action: string; enabled: boolean; reason: string }>;
|
||||||
|
ui_gates?: Record<string, { action: string; enabled: boolean; reason: string }>;
|
||||||
|
ui_relief?: Record<string, boolean>;
|
||||||
visual: {
|
visual: {
|
||||||
schema: "axis-visual.v1";
|
schema: "axis-visual.v1";
|
||||||
pose: Record<string, number>;
|
pose: Record<string, number>;
|
||||||
|
|||||||
@@ -161,6 +161,8 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
|
|||||||
xyzac: panel.xyzac,
|
xyzac: panel.xyzac,
|
||||||
io: panel.io ?? previous.snapshot?.io,
|
io: panel.io ?? previous.snapshot?.io,
|
||||||
gates: panel.gates,
|
gates: panel.gates,
|
||||||
|
ui_gates: panel.ui_gates,
|
||||||
|
ui_relief: panel.ui_relief,
|
||||||
motion_authority: panel.motion_authority,
|
motion_authority: panel.motion_authority,
|
||||||
task_authority: panel.task_authority,
|
task_authority: panel.task_authority,
|
||||||
} satisfies SimSnapshotV1;
|
} satisfies SimSnapshotV1;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ button { color: inherit; }
|
|||||||
.connection-state.ready, .connection-state.running { color: #185c1d; }
|
.connection-state.ready, .connection-state.running { color: #185c1d; }
|
||||||
.connection-state.error, .connection-state.blocked { color: #8b1010; background: #f3d2d2; }
|
.connection-state.error, .connection-state.blocked { color: #8b1010; background: #f3d2d2; }
|
||||||
|
|
||||||
.axis-menubar { display: flex; align-items: stretch; min-width: 0; min-height: 27px; padding-left: 3px; overflow-x: auto; border-bottom: 1px solid #888; background: #d3d3d3; }
|
.axis-menubar { display: flex; align-items: stretch; min-width: 0; min-height: 27px; padding-left: 3px; position: relative; z-index: 30; overflow: visible; border-bottom: 1px solid #888; background: #d3d3d3; }
|
||||||
.axis-menu-item { position: relative; }
|
.axis-menu-item { position: relative; }
|
||||||
.axis-menu-item summary { padding: 5px 10px; cursor: pointer; list-style: none; user-select: none; }
|
.axis-menu-item summary { padding: 5px 10px; cursor: pointer; list-style: none; user-select: none; }
|
||||||
.axis-menu-item summary::-webkit-details-marker { display: none; }
|
.axis-menu-item summary::-webkit-details-marker { display: none; }
|
||||||
@@ -50,10 +50,10 @@ button { color: inherit; }
|
|||||||
.axis-tool-button:disabled { cursor: not-allowed; opacity: .48; }
|
.axis-tool-button:disabled { cursor: not-allowed; opacity: .48; }
|
||||||
.axis-toolbar-separator { width: 1px; height: 26px; flex: 0 0 1px; margin: 0 5px; border-left: 1px solid #969696; border-right: 1px solid #eee; }
|
.axis-toolbar-separator { width: 1px; height: 26px; flex: 0 0 1px; margin: 0 5px; border-left: 1px solid #969696; border-right: 1px solid #eee; }
|
||||||
|
|
||||||
.axis-case-strip { display: flex; align-items: center; width: 100%; min-width: 0; gap: 7px; min-height: 34px; padding: 4px 8px; border-bottom: 1px solid #999; background: #c7c7c7; }
|
.axis-case-strip { display: flex; flex-wrap: wrap; align-items: center; width: 100%; min-width: 0; gap: 7px; min-height: 34px; padding: 4px 8px; border-bottom: 1px solid #999; background: #c7c7c7; }
|
||||||
.axis-case-strip label { display: inline-flex; align-items: center; min-width: 0; flex: 0 0 auto; gap: 5px; font-size: 12px; }
|
.axis-case-strip label { display: inline-flex; align-items: center; min-width: 0; flex: 0 0 auto; gap: 5px; font-size: 12px; }
|
||||||
.axis-case-strip select { width: min(420px, 46vw); max-width: 100%; min-height: 24px; border: 1px solid var(--border-dark); background: white; }
|
.axis-case-strip select { width: min(420px, 46vw); max-width: 100%; min-height: 24px; border: 1px solid var(--border-dark); background: white; }
|
||||||
.axis-case-strip > .axis-text-button { min-width: 68px; white-space: nowrap; }
|
.axis-case-strip > .axis-text-button { flex: 0 0 auto; min-width: max-content; white-space: nowrap; }
|
||||||
.case-status, .command-status { overflow: hidden; color: #4d4d4d; font: 11px ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
.case-status, .command-status { overflow: hidden; color: #4d4d4d; font: 11px ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.case-status { min-width: 0; flex: 1 1 auto; }
|
.case-status { min-width: 0; flex: 1 1 auto; }
|
||||||
.case-status.loading { color: #805400; font-weight: 700; }
|
.case-status.loading { color: #805400; font-weight: 700; }
|
||||||
@@ -153,6 +153,7 @@ button { color: inherit; }
|
|||||||
.axis-about-dialog p { margin: 8px 0 16px; }
|
.axis-about-dialog p { margin: 8px 0 16px; }
|
||||||
.device-status { color: #555; font: 11px ui-monospace, monospace; }
|
.device-status { color: #555; font: 11px ui-monospace, monospace; }
|
||||||
.manual-coolant-block { grid-column: 1; }
|
.manual-coolant-block { grid-column: 1; }
|
||||||
|
.manual-coolant-block .check-line { margin-right: 10px; }
|
||||||
.spindle-row .axis-tool-button { width: 28px; height: 28px; }
|
.spindle-row .axis-tool-button { width: 28px; height: 28px; }
|
||||||
.spindle-row .axis-tool-button img { width: 16px; height: 16px; }
|
.spindle-row .axis-tool-button img { width: 16px; height: 16px; }
|
||||||
.manual-sliders { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 6px 16px; margin-top: 13px; padding-top: 9px; border-top: 1px solid #b0b0b0; }
|
.manual-sliders { display: grid; grid-template-columns: repeat(2, minmax(180px, 1fr)); gap: 6px 16px; margin-top: 13px; padding-top: 9px; border-top: 1px solid #b0b0b0; }
|
||||||
@@ -191,7 +192,7 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
|
|||||||
|
|
||||||
.axis-metrics { padding: 4px 9px; border-top: 1px solid #999; background: #cecece; }
|
.axis-metrics { padding: 4px 9px; border-top: 1px solid #999; background: #cecece; }
|
||||||
.axis-slider { display: grid; grid-template-columns: repeat(3, minmax(170px, 1fr)); gap: 12px; }
|
.axis-slider { display: grid; grid-template-columns: repeat(3, minmax(170px, 1fr)); gap: 12px; }
|
||||||
.axis-slider label { grid-template-columns: auto 48px; }
|
.axis-slider label { grid-template-columns: minmax(0, 1fr) max-content; }
|
||||||
@media (max-width: 900px) { .xyzac-parameter-grid { grid-template-columns: repeat(3, minmax(120px, 1fr)); } }
|
@media (max-width: 900px) { .xyzac-parameter-grid { grid-template-columns: repeat(3, minmax(120px, 1fr)); } }
|
||||||
@media (max-width: 560px) { .xyzac-parameter-grid { grid-template-columns: repeat(2, minmax(120px, 1fr)); } }
|
@media (max-width: 560px) { .xyzac-parameter-grid { grid-template-columns: repeat(2, minmax(120px, 1fr)); } }
|
||||||
|
|
||||||
@@ -258,8 +259,8 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
|
|||||||
.axis-top-pane { grid-template-columns: 1fr; height: auto; min-height: 0; flex: 0 0 auto; }
|
.axis-top-pane { grid-template-columns: 1fr; height: auto; min-height: 0; flex: 0 0 auto; }
|
||||||
.axis-notebook { min-height: 330px; }
|
.axis-notebook { min-height: 330px; }
|
||||||
.axis-preview { min-height: 320px; }
|
.axis-preview { min-height: 320px; }
|
||||||
.axis-program-pane { flex: 0 0 auto; height: auto; min-height: 700px; grid-template-columns: 1fr; grid-template-rows: minmax(520px, 1fr) auto; }
|
.axis-program-pane { flex: 0 0 auto; height: 740px; min-height: 740px; grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) 140px; }
|
||||||
.program-main-layout { grid-template-columns: 1fr; grid-template-rows: minmax(220px, 1fr) minmax(260px, 1fr); grid-column: 1; grid-row: 1; }
|
.program-main-layout { grid-template-columns: 1fr; grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); grid-column: 1; grid-row: 1; overflow: hidden; }
|
||||||
.program-editor-wrap { grid-column: 1; grid-row: 1; }
|
.program-editor-wrap { grid-column: 1; grid-row: 1; }
|
||||||
.program-execution-panel { grid-column: 1; grid-row: 2; border-top: 1px solid #9aa8b4; border-left: 0; }
|
.program-execution-panel { grid-column: 1; grid-row: 2; border-top: 1px solid #9aa8b4; border-left: 0; }
|
||||||
.program-side { grid-column: 1; grid-row: 2; border-top: 1px solid #aaa; border-left: 0; }
|
.program-side { grid-column: 1; grid-row: 2; border-top: 1px solid #aaa; border-left: 0; }
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ export type XyzacVisualState = {
|
|||||||
livePathEpoch: number;
|
livePathEpoch: number;
|
||||||
motionTrace: Array<{ tick: number; pose: XyzacKinematicsSnapshot["pose"]; joints: number[]; activeSegmentId: number; activeProgress: number }>;
|
motionTrace: Array<{ tick: number; pose: XyzacKinematicsSnapshot["pose"]; joints: number[]; activeSegmentId: number; activeProgress: number }>;
|
||||||
offsets: XyzacKinematicsSnapshot["offsets"];
|
offsets: XyzacKinematicsSnapshot["offsets"];
|
||||||
|
coordinateOffset?: number[];
|
||||||
|
machineLimits?: number[][];
|
||||||
|
velocityMmMin?: number;
|
||||||
activeSegmentId: number | null;
|
activeSegmentId: number | null;
|
||||||
toolpath: Array<Record<string, unknown>>;
|
toolpath: Array<Record<string, unknown>>;
|
||||||
source: "linuxcnc-wasm-worker";
|
source: "linuxcnc-wasm-worker";
|
||||||
@@ -24,6 +27,7 @@ export type XyzacVisualSource = {
|
|||||||
simulation_tick?: number;
|
simulation_tick?: number;
|
||||||
state_hash?: string;
|
state_hash?: string;
|
||||||
kinematics?: XyzacKinematicsSnapshot;
|
kinematics?: XyzacKinematicsSnapshot;
|
||||||
|
motion?: { velocity_mm_min?: number };
|
||||||
visual?: { active_segment_id?: number | null; active_progress?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record<string, number>; joints?: number[]; toolpath?: Array<Record<string, unknown>> };
|
visual?: { active_segment_id?: number | null; active_progress?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record<string, number>; joints?: number[]; toolpath?: Array<Record<string, unknown>> };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -52,6 +56,9 @@ export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefine
|
|||||||
livePathEpoch: Number.isSafeInteger(snapshot?.visual?.live_path_epoch) ? Number(snapshot?.visual?.live_path_epoch) : 0,
|
livePathEpoch: Number.isSafeInteger(snapshot?.visual?.live_path_epoch) ? Number(snapshot?.visual?.live_path_epoch) : 0,
|
||||||
motionTrace,
|
motionTrace,
|
||||||
offsets: kinematics?.offsets ?? ZERO_OFFSETS,
|
offsets: kinematics?.offsets ?? ZERO_OFFSETS,
|
||||||
|
coordinateOffset: (snapshot?.visual as { coordinate_offset?: number[] })?.coordinate_offset,
|
||||||
|
machineLimits: (snapshot?.visual as { machine_limits?: number[][] })?.machine_limits,
|
||||||
|
velocityMmMin: (snapshot?.visual as { velocity_mm_min?: number })?.velocity_mm_min ?? snapshot?.motion?.velocity_mm_min,
|
||||||
activeSegmentId: snapshot?.visual?.active_segment_id ?? null,
|
activeSegmentId: snapshot?.visual?.active_segment_id ?? null,
|
||||||
toolpath: snapshot?.visual?.toolpath ?? [],
|
toolpath: snapshot?.visual?.toolpath ?? [],
|
||||||
source: "linuxcnc-wasm-worker",
|
source: "linuxcnc-wasm-worker",
|
||||||
|
|||||||
22
web/worker/axis-source-ui.mjs
Normal file
22
web/worker/axis-source-ui.mjs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
// UI predicates from axis.tcl::update_state, independent of task acceptance.
|
||||||
|
export function axisSourceUi({ task_state, interp_state, taskfile = false, queued_mdi_commands = 0, max_queued_mdi_commands = 10, motion_mode = "FREE", kinematics_type = "BOTH", tool = 0, spindle_direction = 0, on_any_limit = false, paused = false } = {}) {
|
||||||
|
const on = task_state === "STATE_ON";
|
||||||
|
const idle = interp_state === "INTERP_IDLE";
|
||||||
|
const manual = on && idle;
|
||||||
|
const touch = manual && (motion_mode !== "FREE" || kinematics_type === "IDENTITY");
|
||||||
|
const predicates = {
|
||||||
|
estop: true, power: task_state !== "STATE_ESTOP",
|
||||||
|
load: idle, reload: idle && Boolean(taskfile), run: manual, step: on && Boolean(taskfile),
|
||||||
|
pause: on && ["INTERP_READING", "INTERP_WAITING"].includes(interp_state),
|
||||||
|
resume: on && interp_state === "INTERP_PAUSED", pauseResume: on && !idle,
|
||||||
|
abort: on && !idle, manual, home: manual, jog: manual, spindle: manual, coolant: manual,
|
||||||
|
mdi: on && queued_mdi_commands < max_queued_mdi_commands,
|
||||||
|
touchOff: touch, toolTouchOff: touch && tool > 0,
|
||||||
|
spindleAdjust: manual && spindle_direction !== 0, overrideLimits: Boolean(on_any_limit),
|
||||||
|
override: true, jogScale: true, maxVelocity: true, spindleOverride: true,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
gates: Object.fromEntries(Object.entries(predicates).map(([action, enabled]) => [action, { action, enabled, reason: enabled ? "axis_source_ui_available" : `axis_source_ui_${action}_disabled` }])),
|
||||||
|
relief: { estop: task_state === "STATE_ESTOP", power: on, run: !idle, pauseResume: Boolean(paused), abort: idle },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -37,14 +37,13 @@ export function axisState({ task = {}, program = {}, motion = {}, devices = {},
|
|||||||
const hasIoState = typeof ioState.estop === "boolean" || typeof ioState.estop === "number";
|
const hasIoState = typeof ioState.estop === "boolean" || typeof ioState.estop === "number";
|
||||||
const ioEstop = hasIoState && Boolean(ioState.estop);
|
const ioEstop = hasIoState && Boolean(ioState.estop);
|
||||||
const ioReady = typeof ioState.ready === "boolean" || typeof ioState.ready === "number" ? Boolean(ioState.ready) : null;
|
const ioReady = typeof ioState.ready === "boolean" || typeof ioState.ready === "number" ? Boolean(ioState.ready) : null;
|
||||||
// A powered-off machine is OFF. ESTOP_RESET is distinct and is only
|
// axis.tcl displays STATE_ESTOP_RESET as OFF; diagnostics are not state bits.
|
||||||
// reported after the WASM IO latch has recorded the ESTOP edge.
|
const machineState = hasIoState ? ioEstop ? "ESTOP" : ioReady ? "ON" : "OFF"
|
||||||
const estopReset = hasIoState && !ioEstop && ioReady === false && Number(ioState.last_error) === -904;
|
: taskState === "ESTOP" ? "ESTOP" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(taskState) ? "ON" : "OFF";
|
||||||
let machineState = ioEstop || ["ESTOP", "FAULT"].includes(taskState) ? "ESTOP" : estopReset ? "ESTOP_RESET" : ioReady === false ? "OFF" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(taskState) ? "ON" : "OFF";
|
|
||||||
let operationState = programActivity(task, program);
|
let operationState = programActivity(task, program);
|
||||||
const state = machineState === "ESTOP" || machineState === "ESTOP_RESET" ? machineState : operationState === "HOME" ? "HOME" : operationState === "RUNNING" ? "RUNNING" : operationState === "PAUSED" ? "PAUSED" : machineState === "OFF" ? "OFF" : "IDLE";
|
const state = machineState === "ESTOP" || machineState === "ESTOP_RESET" ? machineState : operationState === "HOME" ? "HOME" : operationState === "RUNNING" ? "RUNNING" : operationState === "PAUSED" ? "PAUSED" : machineState === "OFF" ? "OFF" : "IDLE";
|
||||||
const sourceTaskState = machineState === "ESTOP" ? "STATE_ESTOP" : machineState === "ESTOP_RESET" ? "STATE_ESTOP_RESET" : machineState === "OFF" ? "STATE_OFF" : "STATE_ON";
|
const sourceTaskState = machineState === "ESTOP" ? "STATE_ESTOP" : machineState === "OFF" ? "STATE_ESTOP_RESET" : "STATE_ON";
|
||||||
const sourceInterpState = operationState === "PAUSED" ? "INTERP_PAUSED" : operationState === "RUNNING" ? "INTERP_READING" : "INTERP_IDLE";
|
const sourceInterpState = operationState === "PAUSED" ? "INTERP_PAUSED" : operationState === "RUNNING" ? (program?.program_status === "completed" || String(task.mode).toUpperCase() === "MDI" ? "INTERP_WAITING" : "INTERP_READING") : "INTERP_IDLE";
|
||||||
const sourceMode = String(task.mode ?? "MANUAL").toUpperCase();
|
const sourceMode = String(task.mode ?? "MANUAL").toUpperCase();
|
||||||
return Object.freeze({ state, machine_state: machineState, operation_state: operationState, source_task_state: sourceTaskState, source_interp_state: sourceInterpState, source_mode: sourceMode, states: [...AXIS_STATES] });
|
return Object.freeze({ state, machine_state: machineState, operation_state: operationState, source_task_state: sourceTaskState, source_interp_state: sourceInterpState, source_mode: sourceMode, states: [...AXIS_STATES] });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { createTrace, validateTrace } from "./trace-export.mjs";
|
|||||||
import { ConfigurationBatchRuntime } from "../workspace/configuration-batch.mjs";
|
import { ConfigurationBatchRuntime } from "../workspace/configuration-batch.mjs";
|
||||||
import { AuthoritativeRuntime } from "./authoritative-runtime.mjs";
|
import { AuthoritativeRuntime } from "./authoritative-runtime.mjs";
|
||||||
import { axisState } from "./axis-state.mjs";
|
import { axisState } from "./axis-state.mjs";
|
||||||
|
import { axisSourceUi } from "./axis-source-ui.mjs";
|
||||||
import { WasmMotionQueue } from "./wasm-motion-queue.mjs";
|
import { WasmMotionQueue } from "./wasm-motion-queue.mjs";
|
||||||
import { WasmLinuxcncTp } from "./wasm-linuxcnc-tp.mjs";
|
import { WasmLinuxcncTp } from "./wasm-linuxcnc-tp.mjs";
|
||||||
import { WasmHomeLimit } from "./wasm-home-limit.mjs";
|
import { WasmHomeLimit } from "./wasm-home-limit.mjs";
|
||||||
@@ -301,6 +302,7 @@ export class SimSession {
|
|||||||
on_any_limit: Boolean(home.state?.limit_error),
|
on_any_limit: Boolean(home.state?.limit_error),
|
||||||
following_error: 0,
|
following_error: 0,
|
||||||
jog: null,
|
jog: null,
|
||||||
|
stepping: this.wasmMotion.isStepping?.() ?? false,
|
||||||
estop: Boolean(ioState?.estop),
|
estop: Boolean(ioState?.estop),
|
||||||
fault: ioState?.fault ? (ioState.fault_reason ?? "io_fault") : null,
|
fault: ioState?.fault ? (ioState.fault_reason ?? "io_fault") : null,
|
||||||
};
|
};
|
||||||
@@ -369,6 +371,11 @@ export class SimSession {
|
|||||||
return { status: "verified" };
|
return { status: "verified" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
clearLivePlot() {
|
||||||
|
this.livePathEpoch += 1;
|
||||||
|
return this.livePathEpoch;
|
||||||
|
}
|
||||||
|
|
||||||
panelSnapshot({ task = this.task.export(), program = this.xyzacProgram ? this.xyzacProgram.snapshot() : this.program.loaded ? this.program.snapshot() : null, input = {}, tick = this.tick, motionTrace = null, includeToolpath = input.kind !== "SIM_STEP" || !this.xyzacProgram, includeConfiguration = input.kind !== "SIM_STEP" || !this.xyzacProgram, includeStableRuntime = input.kind !== "SIM_STEP" || !this.xyzacProgram } = {}) {
|
panelSnapshot({ task = this.task.export(), program = this.xyzacProgram ? this.xyzacProgram.snapshot() : this.program.loaded ? this.program.snapshot() : null, input = {}, tick = this.tick, motionTrace = null, includeToolpath = input.kind !== "SIM_STEP" || !this.xyzacProgram, includeConfiguration = input.kind !== "SIM_STEP" || !this.xyzacProgram, includeStableRuntime = input.kind !== "SIM_STEP" || !this.xyzacProgram } = {}) {
|
||||||
const motion = this.xyzacProgram ? this.wasmMotionProjection(program) : this.motion.export();
|
const motion = this.xyzacProgram ? this.wasmMotionProjection(program) : this.motion.export();
|
||||||
const wasmMotion = this.wasmMotion.read();
|
const wasmMotion = this.wasmMotion.read();
|
||||||
@@ -384,9 +391,15 @@ export class SimSession {
|
|||||||
const xyzac = this.xyzacKinematics ? { coordinates: "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true, active_type: this.xyzacKinematics.snapshot().active_type } : null;
|
const xyzac = this.xyzacKinematics ? { coordinates: "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true, active_type: this.xyzacKinematics.snapshot().active_type } : null;
|
||||||
const gateInput = { task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo, xyzac, taskfile: Boolean(this.xyzacProgram ? this.xyzacProgram.snapshot().status !== 0 : this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" };
|
const gateInput = { task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo, xyzac, taskfile: Boolean(this.xyzacProgram ? this.xyzacProgram.snapshot().status !== 0 : this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" };
|
||||||
const gateActions = ["load", "run", "runLine", "step", "pause", "resume", "abort", "restart", "reload", "edit", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "overrideLimits", "reset", "spindle", "coolant", "touchOff", "toolTouchOff"];
|
const gateActions = ["load", "run", "runLine", "step", "pause", "resume", "abort", "restart", "reload", "edit", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "overrideLimits", "reset", "spindle", "coolant", "touchOff", "toolTouchOff"];
|
||||||
|
const ui = axisSourceUi({ task_state: axis.source_task_state, interp_state: axis.source_interp_state,
|
||||||
|
taskfile: Boolean(this.xyzacProgram?.path), queued_mdi_commands: task.queued_mdi_commands ?? 0,
|
||||||
|
motion_mode: homeLimit.state?.homed ? "TELEOP" : "FREE", kinematics_type: "BOTH",
|
||||||
|
spindle_direction: Number(devices.spindles?.[0]?.direction ?? 0), tool: Number(devices.tool?.current ?? 0),
|
||||||
|
on_any_limit: motion.on_any_limit, paused: axis.operation_state === "PAUSED" });
|
||||||
const gates = this.xyzacProgram
|
const gates = this.xyzacProgram
|
||||||
? {
|
? {
|
||||||
load: this.xyzacCommandGate({ kind: "PROGRAM_LOAD", payload: {} }),
|
load: this.xyzacCommandGate({ kind: "PROGRAM_LOAD", payload: {} }),
|
||||||
|
reload: this.xyzacProgram.path ? this.xyzacCommandGate({ kind: "PROGRAM_LOAD", payload: {} }) : { enabled: false, reason: "axis_taskfile_missing" },
|
||||||
run: this.xyzacCommandGate({ kind: "PROGRAM_RUN", payload: {} }),
|
run: this.xyzacCommandGate({ kind: "PROGRAM_RUN", payload: {} }),
|
||||||
runLine: this.xyzacCommandGate({ kind: "PROGRAM_RUN", payload: {} }),
|
runLine: this.xyzacCommandGate({ kind: "PROGRAM_RUN", payload: {} }),
|
||||||
step: this.xyzacCommandGate({ kind: "PROGRAM_STEP", payload: {} }),
|
step: this.xyzacCommandGate({ kind: "PROGRAM_STEP", payload: {} }),
|
||||||
@@ -442,8 +455,14 @@ export class SimSession {
|
|||||||
active_progress: wasmMotion.status === "verified" ? wasmMotion.state.progress : 0,
|
active_progress: wasmMotion.status === "verified" ? wasmMotion.state.progress : 0,
|
||||||
...(Array.isArray(motionTrace) && motionTrace.length ? { motion_trace: motionTrace } : {}),
|
...(Array.isArray(motionTrace) && motionTrace.length ? { motion_trace: motionTrace } : {}),
|
||||||
source: "wasm_canonical_motion",
|
source: "wasm_canonical_motion",
|
||||||
|
velocity_mm_min: motion.velocity_mm_min ?? 0,
|
||||||
|
machine_limits: this.wasmMotion.axisLimits?.() ?? null,
|
||||||
|
coordinate_offset: program?.coordinate_context?.offsets?.[(program?.coordinate_context?.system ?? 1) - 1]?.slice(0, 3) ?? null,
|
||||||
};
|
};
|
||||||
const axisCoordinates = [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0];
|
const axisCoordinates = [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0];
|
||||||
|
const coordinateContext = program?.coordinate_context;
|
||||||
|
const activeOffsets = coordinateContext?.offsets?.[coordinateContext.system - 1] ?? Array(9).fill(0);
|
||||||
|
const displayedOffsets = [0, 1, 2, 3, 5].map((index) => activeOffsets[index]);
|
||||||
const unsigned = {
|
const unsigned = {
|
||||||
schema: "axis-panel-snapshot.v1",
|
schema: "axis-panel-snapshot.v1",
|
||||||
context_id: this.contextId,
|
context_id: this.contextId,
|
||||||
@@ -452,13 +471,14 @@ export class SimSession {
|
|||||||
task,
|
task,
|
||||||
program,
|
program,
|
||||||
motion,
|
motion,
|
||||||
coordinates: { machine: [...axisCoordinates], relative: [...axisCoordinates], commanded: [...axisCoordinates] },
|
coordinates: { machine: [...axisCoordinates], relative: axisCoordinates.map((value, index) => value - displayedOffsets[index]), commanded: [...axisCoordinates], offsets: displayedOffsets, system: coordinateContext?.system ?? 1 },
|
||||||
overrides: { feed: motion.feed_override, spindle: [motion.spindle_override] },
|
overrides: { feed: motion.feed_override, spindle: [motion.spindle_override] },
|
||||||
wasm_motion: wasmMotion,
|
wasm_motion: wasmMotion,
|
||||||
...(includeStableRuntime ? { home_limit: homeLimit, hal: { configuration: this.halState, runtime: halRuntime }, devices, device_io: deviceIo } : {}),
|
...(includeStableRuntime ? { home_limit: homeLimit, hal: { configuration: this.halState, runtime: halRuntime }, devices, device_io: deviceIo } : {}),
|
||||||
...(includeConfiguration ? { configuration: this.configurationState } : {}),
|
...(includeConfiguration ? { configuration: this.configurationState } : {}),
|
||||||
axis_state: axis,
|
axis_state: axis,
|
||||||
gates,
|
gates,
|
||||||
|
...(this.xyzacProgram ? { ui_gates: { ...ui.gates, toolTouchOff: gates.toolTouchOff }, ui_relief: ui.relief } : {}),
|
||||||
visual,
|
visual,
|
||||||
authoritative,
|
authoritative,
|
||||||
motion_authority: this.motionAuthority,
|
motion_authority: this.motionAuthority,
|
||||||
@@ -536,8 +556,8 @@ export class SimSession {
|
|||||||
const loaded = program.status !== 0;
|
const loaded = program.status !== 0;
|
||||||
const action = PANEL_GATE_ACTIONS[input.kind] ?? null;
|
const action = PANEL_GATE_ACTIONS[input.kind] ?? null;
|
||||||
if (input.kind === "TOOL_TOUCH_OFF") return { action: "toolTouchOff", enabled: false, reason: "blocked_by_abi_tool_offsets", taskState: task.state, interpState: "IDLE", wasm: { io_ready: ready, io_estop: estop, program_status: program.program_status } };
|
if (input.kind === "TOOL_TOUCH_OFF") return { action: "toolTouchOff", enabled: false, reason: "blocked_by_abi_tool_offsets", taskState: task.state, interpState: "IDLE", wasm: { io_ready: ready, io_estop: estop, program_status: program.program_status } };
|
||||||
const command = input.kind === "JOG_STOP" || input.kind === "JOG_START" || input.kind === "SET_JOG_SCALE" ? WASM_GATE_COMMANDS.JOG
|
const command = input.kind === "JOG_STOP" || input.kind === "JOG_START" ? WASM_GATE_COMMANDS.JOG
|
||||||
: input.kind === "SET_MAX_VELOCITY" || input.kind === "SET_SPINDLE_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
|
: input.kind === "SET_JOG_SCALE" || input.kind === "SET_MAX_VELOCITY" || input.kind === "SET_SPINDLE_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
|
||||||
: input.kind === "SET_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
|
: input.kind === "SET_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
|
||||||
: input.kind === "SPINDLE_COMMAND" || input.kind === "SPINDLE_CONSTANT" || input.kind === "SPINDLE_BRAKE" ? WASM_GATE_COMMANDS.SPINDLE
|
: input.kind === "SPINDLE_COMMAND" || input.kind === "SPINDLE_CONSTANT" || input.kind === "SPINDLE_BRAKE" ? WASM_GATE_COMMANDS.SPINDLE
|
||||||
: input.kind === "COOLANT_COMMAND" ? WASM_GATE_COMMANDS.COOLANT
|
: input.kind === "COOLANT_COMMAND" ? WASM_GATE_COMMANDS.COOLANT
|
||||||
@@ -607,15 +627,16 @@ export class SimSession {
|
|||||||
const value = Number(payload.value);
|
const value = Number(payload.value);
|
||||||
if (!Number.isFinite(value)) return { status: "error", code: "touch_off_value_invalid" };
|
if (!Number.isFinite(value)) return { status: "error", code: "touch_off_value_invalid" };
|
||||||
const selectedSystem = String(payload.system ?? "P1 G54").trim().toUpperCase();
|
const selectedSystem = String(payload.system ?? "P1 G54").trim().toUpperCase();
|
||||||
const systemMatch = selectedSystem.match(/^(P[0-9]|G5[4-9](?:\.[123])?)(?:\s+.*)?$/);
|
const systemMatch = selectedSystem.match(/^(P[0-9]|G(?:5[4-8]|59(?:\.[123])?))(?:\s+.*)?$/);
|
||||||
if (!systemMatch) return { status: "error", code: "touch_off_system_invalid" };
|
if (!systemMatch) return { status: "error", code: "touch_off_system_invalid" };
|
||||||
const systemToken = systemMatch[1];
|
const systemToken = systemMatch[1];
|
||||||
const systemNumber = systemToken.startsWith("P") ? Number(systemToken.slice(1)) : 1 + (Number(systemToken.slice(1, 3)) - 54) + (systemToken.includes(".") ? Number(systemToken.slice(-1)) : 0);
|
const systemNumber = systemToken.startsWith("P") ? Number(systemToken.slice(1)) : 1 + (Number(systemToken.slice(1, 3)) - 54) + (systemToken.includes(".") ? Number(systemToken.slice(-1)) : 0);
|
||||||
if (!Number.isInteger(systemNumber) || systemNumber < 0 || systemNumber > 9) return { status: "error", code: "touch_off_system_invalid" };
|
if (!Number.isInteger(systemNumber) || systemNumber < 0 || systemNumber > 9) return { status: "error", code: "touch_off_system_invalid" };
|
||||||
const displayUnits = String(payload.units ?? "").toLowerCase();
|
const displayUnits = String(payload.units ?? "").toLowerCase();
|
||||||
const programUnits = Number(this.xyzacProgram.snapshot().units);
|
const programUnits = Number(this.xyzacProgram.snapshot().units);
|
||||||
const converted = displayUnits === "mm" && programUnits === 20 ? value / 25.4
|
const linear = ["X", "Y", "Z"].includes(axis);
|
||||||
: displayUnits === "inch" && programUnits === 21 ? value * 25.4 : value;
|
const converted = linear && displayUnits === "mm" && programUnits === 20 ? value / 25.4
|
||||||
|
: linear && displayUnits === "inch" && programUnits === 21 ? value * 25.4 : value;
|
||||||
const formatted = Number(converted.toFixed(12));
|
const formatted = Number(converted.toFixed(12));
|
||||||
const sourceBlock = `G10 L20 ${systemToken} ${axis}${formatted}`;
|
const sourceBlock = `G10 L20 ${systemToken} ${axis}${formatted}`;
|
||||||
// The LinuxCNC interpreter accepts the G54..G59 word in this command;
|
// The LinuxCNC interpreter accepts the G54..G59 word in this command;
|
||||||
@@ -626,14 +647,19 @@ export class SimSession {
|
|||||||
try {
|
try {
|
||||||
if (savedMode !== "MDI") this.task.setMode("MDI");
|
if (savedMode !== "MDI") this.task.setMode("MDI");
|
||||||
this.wasmMotion.setPaused(false);
|
this.wasmMotion.setPaused(false);
|
||||||
|
const synchronized = this.xyzacProgram.synchronizePosition(this.wasmMotion.read().state.position);
|
||||||
|
if (synchronized.status !== "verified") return synchronized;
|
||||||
const offset = this.xyzacProgram.executeBlock(block);
|
const offset = this.xyzacProgram.executeBlock(block);
|
||||||
if (offset.status !== "verified") return { ...offset, touch_off: { axis, value, system: selectedSystem, source_system: systemToken, source_block: sourceBlock, block } };
|
if (offset.status !== "verified") return { ...offset, touch_off: { axis, value, system: selectedSystem, source_system: systemToken, source_block: sourceBlock, block } };
|
||||||
|
const preview = this.xyzacProgram.preview();
|
||||||
|
this.previewState = preview.status === "verified" ? preview : null;
|
||||||
|
if (preview.status !== "verified") return preview;
|
||||||
this.task.setProgram("loaded", this.xyzacProgram.snapshot().current_line);
|
this.task.setProgram("loaded", this.xyzacProgram.snapshot().current_line);
|
||||||
if (savedMode !== "MDI") this.task.setMode(savedMode);
|
|
||||||
return { status: "verified", command: "TOUCH_OFF", source_block: sourceBlock, block, touch_off: { axis, value, system: selectedSystem, source_system: systemToken, source_block: sourceBlock, block }, program: this.xyzacProgram.snapshot(), motion: offset.motion ?? null };
|
return { status: "verified", command: "TOUCH_OFF", source_block: sourceBlock, block, touch_off: { axis, value, system: selectedSystem, source_system: systemToken, source_block: sourceBlock, block }, program: this.xyzacProgram.snapshot(), motion: offset.motion ?? null };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
try { if (savedMode !== "MDI" && this.task.state.mode !== savedMode) this.task.setMode(savedMode); } catch { /* preserve the original interpreter error */ }
|
|
||||||
return { status: "error", code: String(error?.message ?? error) };
|
return { status: "error", code: String(error?.message ?? error) };
|
||||||
|
} finally {
|
||||||
|
if (this.task.state.mode !== savedMode) this.task.setMode(savedMode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1317,6 +1343,10 @@ export class SimSession {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const deviceResponse = (kind, payload) => {
|
const deviceResponse = (kind, payload) => {
|
||||||
|
if (this.xyzacProgram && ["SPINDLE_COMMAND", "SPINDLE_BRAKE", "SPINDLE_CONSTANT"].includes(kind) && !payload?.source_keyboard && (kind !== "SPINDLE_COMMAND" || payload?.delta === undefined)) {
|
||||||
|
try { if (this.task.state.mode !== "MANUAL") this.task.setMode("MANUAL"); }
|
||||||
|
catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
|
||||||
|
}
|
||||||
const result = this.xyzacIo ? this.xyzacIo.command(kind, payload ?? {}) : this.devices.command(kind, payload ?? {});
|
const result = this.xyzacIo ? this.xyzacIo.command(kind, payload ?? {}) : this.devices.command(kind, payload ?? {});
|
||||||
if (result.status === "verified" && this.xyzacProgram && kind === "ESTOP" && payload?.active === true) {
|
if (result.status === "verified" && this.xyzacProgram && kind === "ESTOP" && payload?.active === true) {
|
||||||
// LinuxCNC emcTaskSetState(ESTOP) calls emcTaskAbort: clear the
|
// LinuxCNC emcTaskSetState(ESTOP) calls emcTaskAbort: clear the
|
||||||
@@ -1390,7 +1420,7 @@ export class SimSession {
|
|||||||
|
|
||||||
const visualClearResponse = () => {
|
const visualClearResponse = () => {
|
||||||
const before = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null };
|
const before = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null };
|
||||||
this.livePathEpoch += 1;
|
this.clearLivePlot();
|
||||||
const after = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null };
|
const after = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null };
|
||||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, visual_clear: true, live_path_epoch: this.livePathEpoch, visual_only: true, before, after, operation_id: input.operation_id ?? null }), "verified");
|
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, visual_clear: true, live_path_epoch: this.livePathEpoch, visual_only: true, before, after, operation_id: input.operation_id ?? null }), "verified");
|
||||||
};
|
};
|
||||||
@@ -1637,7 +1667,7 @@ export class SimSession {
|
|||||||
// worker. LinuxCNC keeps the paused task/TP state stable; accept that
|
// worker. LinuxCNC keeps the paused task/TP state stable; accept that
|
||||||
// transport frame as a no-op instead of rejecting it at the protocol
|
// transport frame as a no-op instead of rejecting it at the protocol
|
||||||
// layer and leaving the next PROGRAM_RESUME with a sequence gap.
|
// layer and leaving the next PROGRAM_RESUME with a sequence gap.
|
||||||
if (this.xyzacProgram && (this.task.state.state === "PAUSED" || this.xyzacProgram.snapshot().status === 3)) {
|
if (this.xyzacProgram && (this.task.state.state === "PAUSED" || this.xyzacProgram.snapshot().status === 3) && !this.wasmMotion.isStepping()) {
|
||||||
const pausedProgram = this.xyzacProgram.snapshot();
|
const pausedProgram = this.xyzacProgram.snapshot();
|
||||||
return finish(this.envelope("COMMAND_RESULT", input, {
|
return finish(this.envelope("COMMAND_RESULT", input, {
|
||||||
status: "verified",
|
status: "verified",
|
||||||
@@ -1661,7 +1691,7 @@ export class SimSession {
|
|||||||
task: (nextTick) => {
|
task: (nextTick) => {
|
||||||
if (this.xyzacProgram) {
|
if (this.xyzacProgram) {
|
||||||
const wasmState = this.xyzacProgram.snapshot();
|
const wasmState = this.xyzacProgram.snapshot();
|
||||||
if (![2, 3].includes(wasmState.status)) return;
|
if (wasmState.status !== 2) return;
|
||||||
if (this.pendingTaskSync !== 0) {
|
if (this.pendingTaskSync !== 0) {
|
||||||
this.pendingTaskSync = 0;
|
this.pendingTaskSync = 0;
|
||||||
return;
|
return;
|
||||||
@@ -1854,7 +1884,9 @@ export class SimSession {
|
|||||||
// cycle, so advance the WASM motion queue by the configured
|
// cycle, so advance the WASM motion queue by the configured
|
||||||
// servo/task ratio while publishing one stable task snapshot.
|
// servo/task ratio while publishing one stable task snapshot.
|
||||||
let wasmMotion = this.wasmMotion.read();
|
let wasmMotion = this.wasmMotion.read();
|
||||||
|
const programPaused = this.xyzacProgram?.snapshot().status === 3;
|
||||||
for (let servo = 0; servo < this.xyzacTaskCycleTicks; servo += 1) {
|
for (let servo = 0; servo < this.xyzacTaskCycleTicks; servo += 1) {
|
||||||
|
if (programPaused && !this.wasmMotion.isStepping()) break;
|
||||||
if (this.planner === "linuxcnc-tp" && typeof this.wasmMotion.setExecutionState === "function") {
|
if (this.planner === "linuxcnc-tp" && typeof this.wasmMotion.setExecutionState === "function") {
|
||||||
this.wasmMotion.setExecutionState(this.xyzacExecutionWaitSamples > 0 ? 2 : 3);
|
this.wasmMotion.setExecutionState(this.xyzacExecutionWaitSamples > 0 ? 2 : 3);
|
||||||
}
|
}
|
||||||
@@ -1971,6 +2003,7 @@ export class SimSession {
|
|||||||
const status = this.xyzacProgram ? (program.status === 4 ? "completed" : program.status === 2 ? "running" : program.status === 3 ? "paused" : "loaded") : (program.program_status === "completed" ? "completed" : program.program_status === "running" ? "running" : program.program_status);
|
const status = this.xyzacProgram ? (program.status === 4 ? "completed" : program.status === 2 ? "running" : program.status === 3 ? "paused" : "loaded") : (program.program_status === "completed" ? "completed" : program.program_status === "running" ? "running" : program.program_status);
|
||||||
this.task.setProgram(status, this.xyzacProgram ? (program.execution_source_line ?? program.current_line) : program.line);
|
this.task.setProgram(status, this.xyzacProgram ? (program.execution_source_line ?? program.current_line) : program.line);
|
||||||
if (status === "completed" && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) this.task.transition("STOPPED");
|
if (status === "completed" && this.task.state.state === "RUNNING" && (this.motionAuthority !== "wasm" || this.wasmMotion.read().state.queue_depth === 0)) this.task.transition("STOPPED");
|
||||||
|
if (this.xyzacProgram && this.task.state.mode === "MDI" && this.task.state.state === "RUNNING" && this.wasmMotion.read().state.queue_depth === 0) this.task.transition("STOPPED");
|
||||||
}
|
}
|
||||||
const snapshotState = { tick: this.tick, cycles, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), plc: this.plc.export(), hal_runtime: this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export(), device_io: this.xyzacDeviceIo ? this.xyzacDeviceIo.export() : this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), xyzac_timing: { servo_cycle_time: this.xyzacMotionCycleTime, task_cycle_ticks: this.xyzacTaskCycleTicks } };
|
const snapshotState = { tick: this.tick, cycles, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), plc: this.plc.export(), hal_runtime: this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export(), device_io: this.xyzacDeviceIo ? this.xyzacDeviceIo.export() : this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), xyzac_timing: { servo_cycle_time: this.xyzacMotionCycleTime, task_cycle_ticks: this.xyzacTaskCycleTicks } };
|
||||||
if (program) snapshotState.program = program;
|
if (program) snapshotState.program = program;
|
||||||
@@ -2047,7 +2080,8 @@ export class SimSession {
|
|||||||
}
|
}
|
||||||
restoreMode();
|
restoreMode();
|
||||||
const finalStatus = result.status === "verified" && preview.status === "verified" ? "verified" : "error";
|
const finalStatus = result.status === "verified" && preview.status === "verified" ? "verified" : "error";
|
||||||
return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), finalStatus);
|
return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null,
|
||||||
|
}), finalStatus);
|
||||||
}
|
}
|
||||||
restoreMode();
|
restoreMode();
|
||||||
const result = this.program.load(source, input.payload?.path ?? null);
|
const result = this.program.load(source, input.payload?.path ?? null);
|
||||||
@@ -2130,7 +2164,7 @@ export class SimSession {
|
|||||||
// false ERR_STATE. The LinuxCNC WASM TP remains authoritative for
|
// false ERR_STATE. The LinuxCNC WASM TP remains authoritative for
|
||||||
// the actual pause/resume motion transition.
|
// the actual pause/resume motion transition.
|
||||||
if (action === "pause") { this.wasmMotion.setPaused(true); this.task.transition("PAUSED"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("paused", program.execution_source_line ?? program.current_line); }
|
if (action === "pause") { this.wasmMotion.setPaused(true); this.task.transition("PAUSED"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("paused", program.execution_source_line ?? program.current_line); }
|
||||||
else if (action === "resume") { this.wasmMotion.setPaused(false); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); }
|
else if (action === "resume") { this.wasmMotion.setPaused(false); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram(program.status === 4 ? "completed" : program.status === 1 ? "loaded" : "running", program.execution_source_line ?? program.current_line); }
|
||||||
else { this.wasmMotion.abort(); this.task.transition("STOPPED"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("aborted", program.execution_source_line ?? program.current_line); }
|
else { this.wasmMotion.abort(); this.task.transition("STOPPED"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("aborted", program.execution_source_line ?? program.current_line); }
|
||||||
}
|
}
|
||||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||||
@@ -2204,25 +2238,47 @@ export class SimSession {
|
|||||||
}
|
}
|
||||||
case "PROGRAM_STEP": {
|
case "PROGRAM_STEP": {
|
||||||
if (this.xyzacProgram) {
|
if (this.xyzacProgram) {
|
||||||
const current = this.xyzacProgram.snapshot();
|
try {
|
||||||
if (current.status === 1 && this.task.state.mode !== "AUTO") this.task.setMode("AUTO");
|
const current = this.xyzacProgram.snapshot();
|
||||||
if (current.status === 1) this.xyzacProgram.start();
|
const idle = !["RUNNING", "PAUSED"].includes(this.task.state.state);
|
||||||
const result = this.xyzacProgram.step();
|
this.task.setMode("AUTO");
|
||||||
if (result.status === "verified") {
|
if (idle) {
|
||||||
// AXIS AUTO_STEP returns to an interpreter-paused state after the
|
const started = this.xyzacProgram.start();
|
||||||
// single canonical block. Keep the WASM interpreter status and
|
if (started.status !== "verified") return finish(this.error(input, "program_step", started.code), "failed");
|
||||||
// task status aligned so the Resume gate is authoritative.
|
} else if (current.status === 3 && this.wasmMotion.queueDepth() > 0) {
|
||||||
if (result.program_status !== 4) {
|
this.wasmMotion.stepLine();
|
||||||
const pauseResult = this.xyzacProgram.pause();
|
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, program: current, task: this.task.export(), motion: this.wasmMotionProjection() }), "verified");
|
||||||
if (pauseResult.status !== "verified") return finish(this.error(input, "program_pause", pauseResult.code ?? "program_pause_failed"), "failed");
|
} else {
|
||||||
|
if (current.status === 3) this.xyzacProgram.resume();
|
||||||
|
const stepped = this.xyzacProgram.step();
|
||||||
|
if (stepped.status !== "verified") return finish(this.error(input, "program_step", stepped.code), "failed");
|
||||||
|
const sync = this.syncXyzacProgramSpindle(stepped.program);
|
||||||
|
if (sync.status !== "verified" && sync.status !== "skipped") return finish(this.error(input, "program_spindle", sync.code), "failed");
|
||||||
|
if (stepped.has_control && [428, 429, 430].includes(stepped.control_code)) {
|
||||||
|
const selected = this.switchXyzacKinematics({ 428: 1, 429: 0, 430: 2 }[stepped.control_code]);
|
||||||
|
if (selected.status !== "verified") return finish(this.error(input, "program_step", selected.code), "failed");
|
||||||
|
}
|
||||||
|
if (stepped.motion) {
|
||||||
|
stepped.motion.kinematics_mode = this.xyzacProgramKinematicsMode;
|
||||||
|
const queued = this.wasmMotion.enqueue(stepped.motion);
|
||||||
|
if (queued.status === "error") return finish(this.error(input, "motion_queue", queued.code), "failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this.task.state.state === "READY") this.task.transition("RUNNING");
|
if (this.xyzacProgram.snapshot().status === 4 && this.wasmMotion.read().state.queue_depth === 0) {
|
||||||
if (result.motion) this.wasmMotion.enqueue(result.motion);
|
this.task.setProgram("completed", this.xyzacProgram.snapshot().current_line);
|
||||||
|
if (this.task.state.state !== "STOPPED") this.task.transition("STOPPED");
|
||||||
|
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion: this.wasmMotionProjection() }), "verified");
|
||||||
|
}
|
||||||
|
const paused = this.xyzacProgram.pause();
|
||||||
|
if (paused.status !== "verified") return finish(this.error(input, "program_pause", paused.code), "failed");
|
||||||
this.wasmMotion.setPaused(true);
|
this.wasmMotion.setPaused(true);
|
||||||
this.task.setProgram(result.program_status === 4 ? "completed" : "paused", result.line);
|
if (["READY", "STOPPED"].includes(this.task.state.state)) this.task.transition("RUNNING");
|
||||||
this.task.transition(result.program_status === 4 ? "STOPPED" : "PAUSED");
|
if (this.task.state.state === "RUNNING") this.task.transition("PAUSED");
|
||||||
|
this.task.setProgram("paused", this.xyzacProgram.snapshot().current_line);
|
||||||
|
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion: this.wasmMotionProjection() }), "verified");
|
||||||
|
} catch (error) {
|
||||||
|
return finish(this.error(input, "program_step", String(error?.message ?? error)), "failed");
|
||||||
}
|
}
|
||||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
|
||||||
}
|
}
|
||||||
if (this.task.state.state === "READY") {
|
if (this.task.state.state === "READY") {
|
||||||
this.task.setMode("AUTO");
|
this.task.setMode("AUTO");
|
||||||
@@ -2280,6 +2336,8 @@ export class SimSession {
|
|||||||
// WASM TP, so clear that transport pause before queuing MDI motion;
|
// WASM TP, so clear that transport pause before queuing MDI motion;
|
||||||
// the queue itself remains authoritative for execution.
|
// the queue itself remains authoritative for execution.
|
||||||
this.wasmMotion.setPaused(false);
|
this.wasmMotion.setPaused(false);
|
||||||
|
const synchronized = this.xyzacProgram.synchronizePosition(this.wasmMotion.read().state.position);
|
||||||
|
if (synchronized.status !== "verified") return finish(this.error(input, "mdi_synch", synchronized.code), "failed");
|
||||||
const result = this.xyzacProgram.executeBlock(block);
|
const result = this.xyzacProgram.executeBlock(block);
|
||||||
if (result.status === "verified") {
|
if (result.status === "verified") {
|
||||||
const controlCode = Number(result.program?.control_code ?? 0);
|
const controlCode = Number(result.program?.control_code ?? 0);
|
||||||
@@ -2295,6 +2353,7 @@ export class SimSession {
|
|||||||
if (result.motion) {
|
if (result.motion) {
|
||||||
const enqueue = this.wasmMotion.enqueue(result.motion);
|
const enqueue = this.wasmMotion.enqueue(result.motion);
|
||||||
if (enqueue.status === "error") return finish(this.error(input, "motion_queue", enqueue.code), "failed");
|
if (enqueue.status === "error") return finish(this.error(input, "motion_queue", enqueue.code), "failed");
|
||||||
|
if (this.wasmMotion.read().state.queue_depth > 0 && this.task.state.state !== "RUNNING") this.task.transition("RUNNING");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
|
||||||
@@ -2334,11 +2393,13 @@ export class SimSession {
|
|||||||
if (this.xyzacProgram) {
|
if (this.xyzacProgram) {
|
||||||
if (this.wasmHomeLimit.read().status !== "verified") return finish(this.error(input, "home_limit", "wasm_home_limit_unavailable"), "failed");
|
if (this.wasmHomeLimit.read().status !== "verified") return finish(this.error(input, "home_limit", "wasm_home_limit_unavailable"), "failed");
|
||||||
const requestedAxis = input.payload?.all ? null : String(input.payload?.axis ?? "").toLowerCase() || null;
|
const requestedAxis = input.payload?.all ? null : String(input.payload?.axis ?? "").toLowerCase() || null;
|
||||||
if (requestedAxis && this.xyzacProgramKinematicsMode !== XYZAC_MODES.identity) return finish(this.error(input, "home_limit", "home_joint_requires_identity"), "failed");
|
if (requestedAxis) return finish(this.error(input, "home_limit", "home_joint_requires_joint_mode"), "failed");
|
||||||
|
const requestedJoint = input.payload?.joint;
|
||||||
|
if (requestedJoint !== undefined && (!Number.isInteger(requestedJoint) || requestedJoint < 0 || requestedJoint >= 5)) return finish(this.error(input, "home_limit", "home_joint_invalid"), "failed");
|
||||||
// AXIS home_all_joints()/home_joint() calls ensure_mode(MANUAL)
|
// AXIS home_all_joints()/home_joint() calls ensure_mode(MANUAL)
|
||||||
// before issuing the homing command.
|
// before issuing the homing command.
|
||||||
try { if (this.task.state.mode !== "MANUAL") this.task.setMode("MANUAL"); } catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
|
try { if (this.task.state.mode !== "MANUAL") this.task.setMode("MANUAL"); } catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
|
||||||
const home = this.wasmHomeLimit.home(input.seq, requestedAxis);
|
const home = this.wasmHomeLimit.home(input.seq, input.payload?.all ? null : requestedJoint ?? null);
|
||||||
if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed");
|
if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed");
|
||||||
this.task.setMode("MANUAL");
|
this.task.setMode("MANUAL");
|
||||||
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), wasm_home_limit: this.wasmHomeLimit.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
|
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), wasm_home_limit: this.wasmHomeLimit.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
|
||||||
@@ -2391,9 +2452,10 @@ export class SimSession {
|
|||||||
if (direction === null) return finish(this.error(input, "motion_jog", "motion_jog_range"), "failed");
|
if (direction === null) return finish(this.error(input, "motion_jog", "motion_jog_range"), "failed");
|
||||||
this.wasmMotion.setPaused(false);
|
this.wasmMotion.setPaused(false);
|
||||||
const requestedIncrement = input.payload?.increment;
|
const requestedIncrement = input.payload?.increment;
|
||||||
const distance = requestedIncrement === null || input.payload?.continuous === true
|
const continuous = requestedIncrement === null || input.payload?.continuous === true;
|
||||||
? Math.max(Number(this.wasmMotion.jogVelocity(axis)) * this.xyzacMotionCycleTime * 5, Number.EPSILON)
|
const displayScale = ["x", "y", "z"].includes(axis) && input.payload?.units === "inch" ? 25.4 : 1;
|
||||||
: requestedIncrement ?? 1;
|
const distance = continuous ? 0 : Number(requestedIncrement ?? 1) * displayScale;
|
||||||
|
if (!continuous && (!Number.isFinite(distance) || distance <= 0)) return finish(this.error(input, "motion_jog", "motion_jog_nonfinite"), "failed");
|
||||||
const jog = this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, c: 5 }[axis], direction, distance, input.seq);
|
const jog = this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, c: 5 }[axis], direction, distance, input.seq);
|
||||||
if (jog.status !== "verified") {
|
if (jog.status !== "verified") {
|
||||||
const code = jog.code === "linuxcnc_tp_jog_-1217" ? "motion_limit_range" : jog.code === "linuxcnc_tp_jog_-1215" ? "motion_jog_nonfinite" : jog.code;
|
const code = jog.code === "linuxcnc_tp_jog_-1217" ? "motion_limit_range" : jog.code === "linuxcnc_tp_jog_-1215" ? "motion_jog_nonfinite" : jog.code;
|
||||||
|
|||||||
81
web/worker/test_axis_source_execution.mjs
Normal file
81
web/worker/test_axis_source_execution.mjs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||||
|
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||||
|
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||||
|
|
||||||
|
const bundle = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", fetchImpl: async (url) => {
|
||||||
|
try { const source = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url), "utf8"); return { ok: true, text: async () => source }; }
|
||||||
|
catch { return { ok: false, text: async () => "" }; }
|
||||||
|
} });
|
||||||
|
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||||
|
const session = new XyzacSession(core, { sessionId: "axis-source-execution" });
|
||||||
|
let seq = 0;
|
||||||
|
const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, seq: ++seq, operation_id: `source-exec-${seq}`, kind, payload });
|
||||||
|
const ok = (kind, payload = {}) => {
|
||||||
|
const response = send(kind, payload);
|
||||||
|
assert.notEqual(response.kind, "ERROR", `${kind}: ${response.payload?.gate_reason ?? response.payload?.message_key ?? response.payload?.code}`);
|
||||||
|
return response.payload;
|
||||||
|
};
|
||||||
|
const panel = () => ok("SNAPSHOT_REQUEST").panel_snapshot;
|
||||||
|
const drain = () => { for (let n = 0; n < 2000 && session.wasmMotion.queueDepth() > 0; n++) ok("SIM_STEP", { cycles: 1, response: "motion_delta" }); };
|
||||||
|
ok("HELLO"); ok("SESSION_ATTACH"); ok("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||||
|
ok("PROGRAM_LOAD", { source: "G21 G90\nG1 X1 F600\nG1 X2\nM2\n", path: "source-step.ngc" });
|
||||||
|
assert.equal(panel().axis_state.source_task_state, "STATE_ESTOP_RESET");
|
||||||
|
ok("SET_OVERRIDE", { feed: 0.9 });
|
||||||
|
assert.equal(panel().axis_state.source_task_state, "STATE_ESTOP_RESET");
|
||||||
|
ok("POWER", { active: true });
|
||||||
|
assert.equal(panel().ui_gates.run.enabled, true);
|
||||||
|
assert.equal(panel().ui_gates.step.enabled, true);
|
||||||
|
assert.equal(send("PROGRAM_RUN").payload.gate_reason, "xyzac_not_all_homed");
|
||||||
|
ok("HOME", { all: true });
|
||||||
|
assert.equal(send("HOME", { axis: "x" }).payload.message_key, "home_joint_requires_joint_mode");
|
||||||
|
|
||||||
|
ok("PROGRAM_STEP");
|
||||||
|
assert.equal(panel().program.current_line, 0, "first native Step starts at line zero and pauses");
|
||||||
|
ok("PROGRAM_STEP");
|
||||||
|
assert.equal(panel().program.current_line, 1);
|
||||||
|
ok("PROGRAM_STEP");
|
||||||
|
assert.equal(panel().program.current_line, 2);
|
||||||
|
assert.ok(session.wasmMotion.queueDepth() > 0);
|
||||||
|
ok("PROGRAM_STEP");
|
||||||
|
assert.equal(panel().motion.stepping, true);
|
||||||
|
drain();
|
||||||
|
assert.ok(Math.abs(panel().visual.pose.x - 1) < 1e-8);
|
||||||
|
assert.equal(panel().program.current_line, 2);
|
||||||
|
assert.equal(panel().axis_state.operation_state, "PAUSED");
|
||||||
|
ok("PROGRAM_STEP"); ok("PROGRAM_STEP"); drain();
|
||||||
|
assert.ok(Math.abs(panel().visual.pose.x - 2) < 1e-8);
|
||||||
|
ok("PROGRAM_ABORT");
|
||||||
|
|
||||||
|
ok("PROGRAM_LOAD", { source: "G21 G90\nG1 X5 F600\nG1 X10\nM2\n", path: "source-waiting.ngc" });
|
||||||
|
ok("PROGRAM_RUN");
|
||||||
|
for (let n = 0; n < 20 && panel().program.program_status !== "completed"; n++) ok("SIM_STEP", { cycles: 1 });
|
||||||
|
assert.equal(panel().axis_state.source_interp_state, "INTERP_WAITING");
|
||||||
|
ok("PROGRAM_PAUSE");
|
||||||
|
const paused = panel().visual.pose;
|
||||||
|
ok("SIM_STEP", { cycles: 2 });
|
||||||
|
assert.deepEqual(panel().visual.pose, paused);
|
||||||
|
ok("PROGRAM_RESUME");
|
||||||
|
assert.equal(panel().program.program_status, "completed", "Resume restores WAITING, not READING");
|
||||||
|
drain(); ok("SIM_STEP", { cycles: 1 });
|
||||||
|
assert.ok(Math.abs(panel().visual.pose.x - 10) < 1e-8);
|
||||||
|
assert.equal(panel().axis_state.source_interp_state, "INTERP_IDLE");
|
||||||
|
|
||||||
|
ok("PROGRAM_LOAD", { source: "G21 G90\nM2\n", path: "source-mdi.ngc" });
|
||||||
|
ok("MDI_EXECUTE", { block: "G1 X12 F600" });
|
||||||
|
assert.equal(panel().axis_state.source_interp_state, "INTERP_WAITING");
|
||||||
|
assert.equal(panel().ui_gates.mdi.enabled, true);
|
||||||
|
assert.equal(panel().ui_gates.jog.enabled, false);
|
||||||
|
assert.equal(send("MDI_EXECUTE", { block: "G0 X0" }).kind, "ERROR");
|
||||||
|
ok("PROGRAM_PAUSE"); ok("PROGRAM_RESUME"); drain(); ok("SIM_STEP", { cycles: 1 });
|
||||||
|
assert.equal(panel().axis_state.source_interp_state, "INTERP_IDLE");
|
||||||
|
assert.ok(Math.abs(panel().visual.pose.x - 12) < 1e-8);
|
||||||
|
ok("SPINDLE_COMMAND", { rpm: -600, enabled: true });
|
||||||
|
ok("SET_SPINDLE_OVERRIDE", { scale: 0 });
|
||||||
|
assert.equal(panel().devices.spindles[0].direction, -1);
|
||||||
|
assert.equal(panel().devices.spindles[0].command_rpm, 0);
|
||||||
|
ok("SET_SPINDLE_OVERRIDE", { scale: 1 });
|
||||||
|
assert.equal(panel().devices.spindles[0].command_rpm, -600);
|
||||||
|
ok("SPINDLE_COMMAND", { rpm: 0, enabled: false });
|
||||||
|
console.log(JSON.stringify({ schema: "axis-source-execution.v1", status: "verified", checks: ["ui_vs_command_gate", "source_off_state", "switchkins_type", "repeated_step", "step_motion", "waiting_pause_resume", "mdi_motion_state", "spindle_zero_override"] }));
|
||||||
@@ -13,8 +13,13 @@ assert.equal(axisState({ task: { state: "READY" }, program: { program_status: "i
|
|||||||
assert.equal(axisState({ task: { state: "RUNNING", mode: "AUTO" }, program: { program_status: "running" } }).source_interp_state, "INTERP_READING");
|
assert.equal(axisState({ task: { state: "RUNNING", mode: "AUTO" }, program: { program_status: "running" } }).source_interp_state, "INTERP_READING");
|
||||||
assert.equal(axisState({ task: { state: "PAUSED", mode: "AUTO" }, program: { program_status: "paused" } }).source_interp_state, "INTERP_PAUSED");
|
assert.equal(axisState({ task: { state: "PAUSED", mode: "AUTO" }, program: { program_status: "paused" } }).source_interp_state, "INTERP_PAUSED");
|
||||||
assert.equal(axisState({ task: { state: "STOPPED" }, io: { state: { estop: false, ready: false, last_error: 0 } } }).machine_state, "OFF");
|
assert.equal(axisState({ task: { state: "STOPPED" }, io: { state: { estop: false, ready: false, last_error: 0 } } }).machine_state, "OFF");
|
||||||
assert.equal(axisState({ task: { state: "STOPPED" }, io: { state: { estop: false, ready: false, last_error: -904 } } }).machine_state, "ESTOP_RESET");
|
for (const last_error of [0, -904, -903]) {
|
||||||
|
const state = axisState({ task: { state: "STOPPED" }, io: { state: { estop: false, ready: false, last_error } } });
|
||||||
|
assert.equal(state.machine_state, "OFF");
|
||||||
|
assert.equal(state.source_task_state, "STATE_ESTOP_RESET");
|
||||||
|
}
|
||||||
assert.equal(validateAxisTransition("ESTOP", "ESTOP_RESET").status, "accepted");
|
assert.equal(validateAxisTransition("ESTOP", "ESTOP_RESET").status, "accepted");
|
||||||
|
assert.equal(axisState({ task: { state: "FAULT" }, io: { state: { estop: false, ready: true } } }).source_task_state, "STATE_ON");
|
||||||
assert.equal(validateAxisTransition("ESTOP_RESET", "ON").status, "accepted");
|
assert.equal(validateAxisTransition("ESTOP_RESET", "ON").status, "accepted");
|
||||||
assert.equal(validateAxisTransition("ON", "RUNNING").status, "accepted");
|
assert.equal(validateAxisTransition("ON", "RUNNING").status, "accepted");
|
||||||
assert.equal(validateAxisTransition("RUNNING", "HOME").status, "blocked");
|
assert.equal(validateAxisTransition("RUNNING", "HOME").status, "blocked");
|
||||||
|
|||||||
@@ -41,10 +41,14 @@ assert.equal(readyGates.tool.enabled, true);
|
|||||||
assert.equal(readyGates.probe.enabled, true);
|
assert.equal(readyGates.probe.enabled, true);
|
||||||
assert.equal(send("KINEMATICS_SELECT", { type: 1 }).payload.kinematics.active_type, 1);
|
assert.equal(send("KINEMATICS_SELECT", { type: 1 }).payload.kinematics.active_type, 1);
|
||||||
const alternateKinematicsGates = send("SNAPSHOT_REQUEST").payload.panel_snapshot.gates;
|
const alternateKinematicsGates = send("SNAPSHOT_REQUEST").payload.panel_snapshot.gates;
|
||||||
assert.equal(alternateKinematicsGates.spindle.reason, "linuxcnc_wasm_mode_unavailable");
|
// Switchkins leaves MDI selected; AXIS spindle() ensures MANUAL itself.
|
||||||
|
assert.equal(alternateKinematicsGates.spindle.enabled, true);
|
||||||
|
assert.equal(alternateKinematicsGates.spindle.reason, "linuxcnc_wasm_authority");
|
||||||
assert.equal(alternateKinematicsGates.tool.reason, "linuxcnc_wasm_mode_unavailable");
|
assert.equal(alternateKinematicsGates.tool.reason, "linuxcnc_wasm_mode_unavailable");
|
||||||
assert.equal(send("RESET").payload.command, "RESET");
|
assert.equal(send("RESET").payload.command, "RESET");
|
||||||
assert.equal(send("MDI_EXECUTE", { block: "G0 X1" }).payload.task.mode, "MDI");
|
assert.equal(send("MDI_EXECUTE", { block: "G0 X1" }).payload.task.mode, "MDI");
|
||||||
|
assert.equal(send("JOG_STOP").kind, "ERROR", "jog stop must not abort a running MDI move");
|
||||||
|
assert.equal(send("SIM_STEP", { cycles: 100 }).kind, "COMMAND_RESULT");
|
||||||
assert.equal(send("JOG_STOP").payload.command, "JOG_STOP");
|
assert.equal(send("JOG_STOP").payload.command, "JOG_STOP");
|
||||||
assert.equal(send("SET_MODE", { mode: "MANUAL" }).payload.mode, "MANUAL");
|
assert.equal(send("SET_MODE", { mode: "MANUAL" }).payload.mode, "MANUAL");
|
||||||
assert.equal(send("POWER", { active: false }).payload.panel_snapshot.axis_state.machine_state, "OFF");
|
assert.equal(send("POWER", { active: false }).payload.panel_snapshot.axis_state.machine_state, "OFF");
|
||||||
@@ -72,7 +76,7 @@ assert.equal(send("PROGRAM_ABORT").payload.task.state, "STOPPED");
|
|||||||
|
|
||||||
assert.equal(send("ESTOP", { active: true }).payload.panel_snapshot.axis_state.machine_state, "ESTOP");
|
assert.equal(send("ESTOP", { active: true }).payload.panel_snapshot.axis_state.machine_state, "ESTOP");
|
||||||
const reset = send("ESTOP", { active: false });
|
const reset = send("ESTOP", { active: false });
|
||||||
assert.equal(reset.payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
|
assert.equal(reset.payload.panel_snapshot.axis_state.source_task_state, "STATE_ESTOP_RESET");
|
||||||
assert.equal(send("POWER", { active: true }).payload.panel_snapshot.axis_state.machine_state, "ON");
|
assert.equal(send("POWER", { active: true }).payload.panel_snapshot.axis_state.machine_state, "ON");
|
||||||
|
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const estopGates = xyzacGateMatrix({ task: { state: "ESTOP", mode: "MANUAL" }, p
|
|||||||
assert.equal(estopGates.switchkinsXyzac.enabled, false);
|
assert.equal(estopGates.switchkinsXyzac.enabled, false);
|
||||||
assert.equal(estopGates.switchkinsXyzac.reason, "xyzac_estop_active");
|
assert.equal(estopGates.switchkinsXyzac.reason, "xyzac_estop_active");
|
||||||
send("ESTOP", "estop-on", { active: true });
|
send("ESTOP", "estop-on", { active: true });
|
||||||
assert.equal(send("ESTOP", "estop-reset", { active: false }).payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
|
assert.equal(send("ESTOP", "estop-reset", { active: false }).payload.panel_snapshot.axis_state.source_task_state, "STATE_ESTOP_RESET");
|
||||||
send("POWER", "power-on-again", { active: true });
|
send("POWER", "power-on-again", { active: true });
|
||||||
const recovered = send("SNAPSHOT_REQUEST", "recovered-snapshot");
|
const recovered = send("SNAPSHOT_REQUEST", "recovered-snapshot");
|
||||||
assert.equal(typeof recovered.payload.panel_snapshot.state_hash, "string");
|
assert.equal(typeof recovered.payload.panel_snapshot.state_hash, "string");
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ assert.equal(released.payload.status, "verified");
|
|||||||
assert.equal(released.payload.device_io.state.estop, 0);
|
assert.equal(released.payload.device_io.state.estop, 0);
|
||||||
assert.equal(released.payload.device_io.state.ready, 0);
|
assert.equal(released.payload.device_io.state.ready, 0);
|
||||||
assert.equal(released.payload.task.state, "STOPPED");
|
assert.equal(released.payload.task.state, "STOPPED");
|
||||||
assert.equal(released.payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
|
assert.equal(released.payload.panel_snapshot.axis_state.source_task_state, "STATE_ESTOP_RESET");
|
||||||
|
|
||||||
const powered = send("POWER", { active: true });
|
const powered = send("POWER", { active: true });
|
||||||
assert.equal(powered.payload.status, "verified");
|
assert.equal(powered.payload.status, "verified");
|
||||||
@@ -97,9 +97,16 @@ const restarted = send("PROGRAM_RESTART");
|
|||||||
assert.equal(restarted.payload.status, "verified");
|
assert.equal(restarted.payload.status, "verified");
|
||||||
const fullRun = send("PROGRAM_RUN");
|
const fullRun = send("PROGRAM_RUN");
|
||||||
assert.equal(fullRun.payload.task.state, "RUNNING");
|
assert.equal(fullRun.payload.task.state, "RUNNING");
|
||||||
const completed = send("SIM_STEP", { cycles: 8000 });
|
let completed = send("SIM_STEP", { cycles: 8000 });
|
||||||
assert.equal(completed.payload.status, "verified");
|
assert.equal(completed.payload.status, "verified");
|
||||||
assert.equal(completed.payload.program.program_status, "completed");
|
assert.equal(completed.payload.program.program_status, "completed");
|
||||||
|
// M2 closes the interpreter before the last trajectory segment drains.
|
||||||
|
let drainTicks = 0;
|
||||||
|
for (; drainTicks < 100 && completed.payload.wasm_motion.state.queue_depth > 0; drainTicks += 1) {
|
||||||
|
assert.equal(completed.payload.panel_snapshot.gates.run.enabled, false);
|
||||||
|
completed = send("SIM_STEP", { cycles: 1 });
|
||||||
|
assert.equal(completed.payload.status, "verified");
|
||||||
|
}
|
||||||
assert.equal(completed.payload.task.state, "STOPPED");
|
assert.equal(completed.payload.task.state, "STOPPED");
|
||||||
assert.equal(completed.payload.wasm_motion.state.queue_depth, 0);
|
assert.equal(completed.payload.wasm_motion.state.queue_depth, 0);
|
||||||
assert.equal((completed.payload.panel_snapshot.visual.toolpath ?? loaded.payload.panel_snapshot.visual.toolpath).length, 29);
|
assert.equal((completed.payload.panel_snapshot.visual.toolpath ?? loaded.payload.panel_snapshot.visual.toolpath).length, 29);
|
||||||
@@ -120,7 +127,7 @@ console.log(JSON.stringify({
|
|||||||
active_progress: stepped.payload.panel_snapshot.visual.active_progress,
|
active_progress: stepped.payload.panel_snapshot.visual.active_progress,
|
||||||
rtcp_active_type: stepped.payload.panel_snapshot.kinematics.active_type,
|
rtcp_active_type: stepped.payload.panel_snapshot.kinematics.active_type,
|
||||||
rtcp_joint_pose_delta: stepped.payload.panel_snapshot.kinematics.joints.map((value, index) => value - [stepped.payload.panel_snapshot.visual.pose.x, stepped.payload.panel_snapshot.visual.pose.y, stepped.payload.panel_snapshot.visual.pose.z, stepped.payload.panel_snapshot.visual.pose.a, stepped.payload.panel_snapshot.visual.pose.c][index]),
|
rtcp_joint_pose_delta: stepped.payload.panel_snapshot.kinematics.joints.map((value, index) => value - [stepped.payload.panel_snapshot.visual.pose.x, stepped.payload.panel_snapshot.visual.pose.y, stepped.payload.panel_snapshot.visual.pose.z, stepped.payload.panel_snapshot.visual.pose.a, stepped.payload.panel_snapshot.visual.pose.c][index]),
|
||||||
full_run: { cycles: 8000, program_status: completed.payload.program.program_status, task_state: completed.payload.task.state, queue_depth: completed.payload.wasm_motion.state.queue_depth, final_switchkins_type: completed.payload.panel_snapshot.kinematics.active_type },
|
full_run: { cycles: 8000 + drainTicks, drain_ticks: drainTicks, program_status: completed.payload.program.program_status, task_state: completed.payload.task.state, queue_depth: completed.payload.wasm_motion.state.queue_depth, final_switchkins_type: completed.payload.panel_snapshot.kinematics.active_type },
|
||||||
js_motion_unchanged: session.motion.export().state_hash === jsMotionHash,
|
js_motion_unchanged: session.motion.export().state_hash === jsMotionHash,
|
||||||
state_hash: stepped.payload.panel_snapshot.state_hash,
|
state_hash: stepped.payload.panel_snapshot.state_hash,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ function setup(id, { homeAll = true } = {}) {
|
|||||||
|
|
||||||
const partialHome = setup("xyzac-manual-partial-home", { homeAll: false });
|
const partialHome = setup("xyzac-manual-partial-home", { homeAll: false });
|
||||||
for (const [index, axis] of ["x", "y", "z", "a", "c"].entries()) {
|
for (const [index, axis] of ["x", "y", "z", "a", "c"].entries()) {
|
||||||
const response = partialHome.send("HOME", `home-${axis}`, { axis });
|
const response = partialHome.send("HOME", `home-${axis}`, { joint: index });
|
||||||
assert.equal(response.payload.wasm_home_limit.state.homed_mask, (1 << (index + 1)) - 1);
|
assert.equal(response.payload.wasm_home_limit.state.homed_mask, (1 << (index + 1)) - 1);
|
||||||
assert.equal(response.payload.wasm_home_limit.state.homed, index === 4);
|
assert.equal(response.payload.wasm_home_limit.state.homed, index === 4);
|
||||||
}
|
}
|
||||||
@@ -60,15 +60,22 @@ assert.equal(ferror.kind, "ERROR");
|
|||||||
assert.equal(ferror.payload.code, "motion_following_error");
|
assert.equal(ferror.payload.code, "motion_following_error");
|
||||||
|
|
||||||
const mdiCase = setup("xyzac-manual-mdi");
|
const mdiCase = setup("xyzac-manual-mdi");
|
||||||
const mdi = mdiCase.send("MDI_EXECUTE", "mdi-xyzac", { block: "G21 G90 G1 X10 Y5 Z180 A30 C45 F100" });
|
// The selected INI limits Z to [-120, 120]; use an in-range completion case.
|
||||||
|
const mdi = mdiCase.send("MDI_EXECUTE", "mdi-xyzac", { block: "G21 G90 G1 X10 Y5 Z18 A30 C45 F600" });
|
||||||
assert.equal(mdi.kind, "COMMAND_RESULT");
|
assert.equal(mdi.kind, "COMMAND_RESULT");
|
||||||
assert.equal(mdi.payload.motion_authority, "wasm");
|
assert.equal(mdi.payload.motion_authority, "wasm");
|
||||||
assert.equal(mdi.payload.program.source, "linuxcnc-wasm-sim_program");
|
assert.equal(mdi.payload.program.source, "linuxcnc-wasm-sim_program");
|
||||||
assert.equal(mdi.payload.panel_snapshot.kinematics.active_type, 0);
|
assert.equal(mdi.payload.panel_snapshot.kinematics.active_type, 0);
|
||||||
const invalidMdi = mdiCase.send("MDI_EXECUTE", "mdi-invalid", { block: "G999" });
|
const invalidMdi = mdiCase.send("MDI_EXECUTE", "mdi-invalid", { block: "G999" });
|
||||||
assert.equal(invalidMdi.kind, "ERROR");
|
assert.equal(invalidMdi.kind, "ERROR");
|
||||||
|
assert.equal(invalidMdi.payload.gate_reason, "mdi_while_program_active");
|
||||||
|
for (let tick = 0; tick < 20000 && mdiCase.session.wasmMotion.read().state.queue_depth > 0; tick += 32) {
|
||||||
|
const sample = mdiCase.send("SIM_STEP", `mdi-drain-${tick}`, { cycles: 32, response: "motion_delta" });
|
||||||
|
assert.equal(sample.kind, "COMMAND_RESULT", sample.payload.message_key);
|
||||||
|
}
|
||||||
|
assert.equal(mdiCase.session.wasmMotion.read().state.queue_depth, 0);
|
||||||
const loaded = mdiCase.send("PROGRAM_LOAD", "mdi-program", { source: "G21 G90\nG1 X20 A10 C20 F100\nM2\n", path: "mdi-run.ngc" });
|
const loaded = mdiCase.send("PROGRAM_LOAD", "mdi-program", { source: "G21 G90\nG1 X20 A10 C20 F100\nM2\n", path: "mdi-run.ngc" });
|
||||||
assert.equal(loaded.kind, "COMMAND_RESULT");
|
assert.equal(loaded.kind, "COMMAND_RESULT", JSON.stringify({ reason: loaded.payload.gate_reason ?? loaded.payload.message_key, code: loaded.payload.code, preview: loaded.payload.preview, program: loaded.payload.program?.program_status }));
|
||||||
mdiCase.send("PROGRAM_RUN", "mdi-run");
|
mdiCase.send("PROGRAM_RUN", "mdi-run");
|
||||||
const mdiWhileRunning = mdiCase.send("MDI_EXECUTE", "mdi-running", { block: "G0 X1" });
|
const mdiWhileRunning = mdiCase.send("MDI_EXECUTE", "mdi-running", { block: "G0 X1" });
|
||||||
assert.equal(mdiWhileRunning.kind, "ERROR");
|
assert.equal(mdiWhileRunning.kind, "ERROR");
|
||||||
|
|||||||
71
web/worker/test_xyzac_pause_resume_consistency.mjs
Normal file
71
web/worker/test_xyzac_pause_resume_consistency.mjs
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||||
|
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||||
|
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||||
|
|
||||||
|
const bundle = await loadSimulationCase({ path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", fetchImpl: async (url) => {
|
||||||
|
try { const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url), "utf8"); return { ok: true, text: async () => body }; }
|
||||||
|
catch { return { ok: false, text: async () => "" }; }
|
||||||
|
} });
|
||||||
|
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||||
|
const session = new XyzacSession(core, { sessionId: "pause-consistency" });
|
||||||
|
let seq = 0;
|
||||||
|
const send = (kind, payload = {}, accepted = true) => {
|
||||||
|
const result = session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, kind, payload, seq: ++seq, operation_id: `pause-consistency-${seq}` });
|
||||||
|
assert.equal(result.kind === "ERROR", !accepted, `${kind}: ${result.payload.gate_reason ?? result.payload.message_key ?? result.payload.code}`);
|
||||||
|
assert.equal(core._sim_protocol_last_seq(), seq, `${kind} stranded the transport`);
|
||||||
|
assert.equal(session.timeline.events.cursor, seq, `${kind} stranded the event log`);
|
||||||
|
if (!accepted) assert.equal(result.payload.code, "axis_gate");
|
||||||
|
return result.payload;
|
||||||
|
};
|
||||||
|
const motion = () => session.wasmMotion.read().state;
|
||||||
|
const snapshot = () => session.xyzacProgram.snapshot();
|
||||||
|
const sample = (cycles = 5) => send("SIM_STEP", { cycles, response: "motion_delta" });
|
||||||
|
send("HELLO"); send("SESSION_ATTACH"); send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
|
||||||
|
send("PROGRAM_LOAD", { path: "pause-reading.ngc", source: "G21 G90\n" + Array.from({ length: 80 }, (_, i) => `G1 X${i % 2 ? 0 : 2} F600`).join("\n") + "\nM2\n" });
|
||||||
|
send("POWER", { active: true }); send("HOME", { all: true }); send("PROGRAM_RUN"); sample();
|
||||||
|
for (let round = 0; round < 3; round++) {
|
||||||
|
send("PROGRAM_PAUSE");
|
||||||
|
const pose = motion().position, line = snapshot().current_line, queue = motion().queue_depth;
|
||||||
|
const epoch = session.livePathEpoch;
|
||||||
|
sample(); assert.deepEqual(motion().position, pose);
|
||||||
|
send("VISUAL_CLEAR"); assert.equal(session.livePathEpoch, epoch + 1);
|
||||||
|
for (const [kind, payload] of [["SET_SPINDLE_OVERRIDE", { scale: 1 }], ["SET_MAX_VELOCITY", { percent: 100 }], ["SET_JOG_SCALE", { percent: 50 }], ["SET_OVERRIDE", { feed: 1 }]]) send(kind, payload);
|
||||||
|
for (const [kind, payload] of [["PROGRAM_PAUSE", {}], ["PROGRAM_RUN", {}], ["PROGRAM_LOAD", { path: "bad.ngc", source: "M2\n" }], ["MDI_EXECUTE", { block: "G0 X100" }], ["HOME", { all: true }], ["JOG_START", { axis: "x", direction: 1, increment: 1 }]]) send(kind, payload, false);
|
||||||
|
assert.equal(snapshot().current_line, line);
|
||||||
|
assert.equal(snapshot().program_status, "paused");
|
||||||
|
assert.deepEqual(motion().position, pose);
|
||||||
|
assert.equal(motion().queue_depth, queue);
|
||||||
|
send("PROGRAM_RESUME");
|
||||||
|
for (let n = 0; n < 3; n++) sample();
|
||||||
|
assert.notDeepEqual(motion().position, pose, "Resume did not advance the preserved TP");
|
||||||
|
send("PROGRAM_RESUME", {}, false);
|
||||||
|
sample();
|
||||||
|
}
|
||||||
|
send("PROGRAM_PAUSE");
|
||||||
|
const beforeStep = motion().position;
|
||||||
|
send("PROGRAM_STEP");
|
||||||
|
for (let tick = 0; tick < 300 && session.wasmMotion.isStepping(); tick++) sample(1);
|
||||||
|
assert.notDeepEqual(motion().position, beforeStep, "Step while paused did not move");
|
||||||
|
assert.equal(snapshot().program_status, "paused");
|
||||||
|
send("VISUAL_CLEAR"); send("PROGRAM_RESUME"); sample(); send("PROGRAM_ABORT");
|
||||||
|
assert.equal(motion().queue_depth, 0);
|
||||||
|
|
||||||
|
send("PROGRAM_LOAD", { path: "pause-waiting.ngc", source: "G21 G90\nG1 X30 F600\nM2\n" });
|
||||||
|
send("PROGRAM_RUN");
|
||||||
|
for (let tick = 0; tick < 30 && snapshot().status !== 4; tick++) sample(1);
|
||||||
|
assert.equal(snapshot().status, 4); assert.ok(motion().queue_depth > 0);
|
||||||
|
send("PROGRAM_PAUSE"); send("VISUAL_CLEAR"); send("PROGRAM_RESUME");
|
||||||
|
assert.equal(snapshot().status, 4, "WAITING resume must not rewind or re-read");
|
||||||
|
for (let tick = 0; tick < 1000 && motion().queue_depth; tick++) sample();
|
||||||
|
assert.ok(Math.abs(motion().position.x - 30) < 1e-8);
|
||||||
|
send("PROGRAM_LOAD", { path: "pause-mdi.ngc", source: "G21 G90\nM2\n" });
|
||||||
|
send("MDI_EXECUTE", { block: "G1 X20 F600" }); sample();
|
||||||
|
send("PROGRAM_PAUSE");
|
||||||
|
const mdiPose = motion().position;
|
||||||
|
send("SET_SPINDLE_OVERRIDE", { scale: .8 }); send("VISUAL_CLEAR"); send("PROGRAM_RESUME"); sample();
|
||||||
|
assert.notDeepEqual(motion().position, mdiPose);
|
||||||
|
assert.equal(session.task.state.mode, "MDI");
|
||||||
|
send("PROGRAM_ABORT");
|
||||||
|
console.log(JSON.stringify({ schema: "xyzac-pause-consistency.v1", status: "verified", commands: seq, transport_seq: core._sim_protocol_last_seq(), checks: ["reading_repeated_pause", "clear_and_overrides_while_paused", "rejected_commands_keep_sequence", "paused_step_then_resume", "waiting_resume_to_completion", "mdi_pause_resume", "abort_after_resume"] }));
|
||||||
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
|
|||||||
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||||
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||||
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
|
||||||
|
import { fnv1a64 } from "./replay.mjs";
|
||||||
|
|
||||||
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
|
||||||
const fetchImpl = async (url) => {
|
const fetchImpl = async (url) => {
|
||||||
@@ -29,6 +30,38 @@ assert.equal(second.payload.program.path, "second.ngc");
|
|||||||
assert.notEqual(second.payload.program.source_hash, first.payload.program.source_hash);
|
assert.notEqual(second.payload.program.source_hash, first.payload.program.source_hash);
|
||||||
assert.equal(second.payload.panel_snapshot.visual.toolpath.length, 1);
|
assert.equal(second.payload.panel_snapshot.visual.toolpath.length, 1);
|
||||||
|
|
||||||
|
const epoch = () => session.livePathEpoch;
|
||||||
|
const before = epoch();
|
||||||
|
const source = "G21 G90\nG1 X2 Y2 Z3 F100\nM2\n";
|
||||||
|
const replaced = send("PROGRAM_LOAD", { source, path: "web-replacement.ngc", clear_live_plot_on_replace: true });
|
||||||
|
assert.equal(replaced.kind, "COMMAND_RESULT");
|
||||||
|
assert.equal(replaced.payload.visual_clear, undefined);
|
||||||
|
assert.equal(epoch(), before, "changed file retains the native trace, even from an old caller");
|
||||||
|
assert.equal(replaced.payload.program.source_hash, fnv1a64(source));
|
||||||
|
assert.equal(session.xyzacProgram.snapshot().source_hash, fnv1a64(source));
|
||||||
|
assert.equal(send("PROGRAM_LOAD", { source, path: "web-replacement.ngc", clear_live_plot_on_replace: true }).kind, "COMMAND_RESULT");
|
||||||
|
assert.equal(epoch(), before, "unchanged reload preserves live history");
|
||||||
|
const failedReplacement = send("PROGRAM_LOAD", { source: "G999\n", path: "bad-web.ngc", clear_live_plot_on_replace: true });
|
||||||
|
assert.equal(failedReplacement.kind, "ERROR");
|
||||||
|
assert.equal(epoch(), before, "failed parse does not clear history");
|
||||||
|
const native = send("PROGRAM_LOAD", { source, path: "native.ngc" });
|
||||||
|
assert.equal(native.kind, "COMMAND_RESULT");
|
||||||
|
assert.equal(epoch(), before, "default source PROGRAM_LOAD preserves history");
|
||||||
|
send("POWER", { active: true }); send("HOME", { all: true }); send("PROGRAM_RUN");
|
||||||
|
const blocked = send("PROGRAM_LOAD", { source, path: "blocked.ngc", clear_live_plot_on_replace: true });
|
||||||
|
assert.equal(blocked.kind, "ERROR");
|
||||||
|
assert.equal(epoch(), before, "gate rejection does not clear history");
|
||||||
|
send("PROGRAM_ABORT");
|
||||||
|
const edited = send("PROGRAM_LOAD", { source: source.replace("X2", "X3"), path: "native.ngc", clear_live_plot_on_replace: true });
|
||||||
|
assert.equal(edited.kind, "COMMAND_RESULT");
|
||||||
|
assert.equal(epoch(), before, "edited text retains the native live trace");
|
||||||
|
assert.equal(edited.payload.program.source_hash, fnv1a64(source.replace("X2", "X3")));
|
||||||
|
const cleared = send("VISUAL_CLEAR");
|
||||||
|
assert.equal(cleared.kind, "COMMAND_RESULT");
|
||||||
|
assert.equal(epoch(), before + 1);
|
||||||
|
assert.deepEqual(cleared.payload.before, cleared.payload.after, "clear must not change CNC state");
|
||||||
|
assert.deepEqual(cleared.payload.panel_snapshot.visual.toolpath, edited.payload.panel_snapshot.visual.toolpath);
|
||||||
|
|
||||||
console.log(JSON.stringify({
|
console.log(JSON.stringify({
|
||||||
schema: "xyzac-program-replacement.v1",
|
schema: "xyzac-program-replacement.v1",
|
||||||
status: "verified",
|
status: "verified",
|
||||||
|
|||||||
49
web/worker/test_xyzac_source_widget_gates.mjs
Normal file
49
web/worker/test_xyzac_source_widget_gates.mjs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
|
||||||
|
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
|
||||||
|
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
|
||||||
|
|
||||||
|
const bundle = await loadSimulationCase({
|
||||||
|
path: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini",
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
try { const text = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url), "utf8"); return { ok: true, text: async () => text }; }
|
||||||
|
catch { return { ok: false, text: async () => "" }; }
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
|
||||||
|
const session = new XyzacSession(core, { sessionId: "source-widget-gates" });
|
||||||
|
let seq = 0;
|
||||||
|
const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId, seq: ++seq, operation_id: `source-widget-${seq}`, kind, payload });
|
||||||
|
const snapshot = () => send("SNAPSHOT_REQUEST").payload.panel_snapshot;
|
||||||
|
send("HELLO"); send("SESSION_ATTACH");
|
||||||
|
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).kind, "COMMAND_RESULT");
|
||||||
|
assert.equal(send("PROGRAM_LOAD", { source: "G21 G90\nG1 X100 F60\nM2\n", path: "source-widget.ngc" }).kind, "COMMAND_RESULT");
|
||||||
|
for (const estop of [false, true]) {
|
||||||
|
send("ESTOP", { active: estop });
|
||||||
|
for (const [kind, payload] of [["SET_OVERRIDE", { feed: 0.8 }], ["SET_JOG_SCALE", { percent: 50 }], ["SET_MAX_VELOCITY", { percent: 60 }], ["SET_SPINDLE_OVERRIDE", { scale: 0.7 }]]) {
|
||||||
|
const response = send(kind, payload);
|
||||||
|
assert.equal(response.kind, "COMMAND_RESULT", `${kind} in ${estop ? "ESTOP" : "OFF"}: ${response.payload.gate_reason ?? response.payload.message_key}`);
|
||||||
|
}
|
||||||
|
assert.equal(snapshot().io.state.spindles[0].enabled, 0);
|
||||||
|
assert.equal(snapshot().io.state.spindles[0].command_rpm, 0);
|
||||||
|
assert.equal(snapshot().ui_gates.abort.enabled, false, "Stop widget must be disabled at IDLE");
|
||||||
|
}
|
||||||
|
send("ESTOP", { active: false }); send("POWER", { active: true }); send("HOME", { all: true });
|
||||||
|
send("SET_MODE", { mode: "MDI" });
|
||||||
|
assert.equal(snapshot().gates.spindle.enabled, true);
|
||||||
|
assert.equal(snapshot().gates.touchOff.enabled, true);
|
||||||
|
assert.equal(send("SPINDLE_COMMAND", { direction: 1, rpm: 100, enabled: true }).kind, "COMMAND_RESULT");
|
||||||
|
assert.equal(snapshot().task.mode, "MANUAL", "source spindle radio handler ensures MANUAL");
|
||||||
|
send("SPINDLE_COMMAND", { direction: 0, rpm: 0, enabled: false });
|
||||||
|
send("PROGRAM_RUN"); send("SIM_STEP", { cycles: 2 });
|
||||||
|
assert.equal(snapshot().ui_gates.abort.enabled, true);
|
||||||
|
assert.equal(snapshot().ui_gates.coolant.enabled, false, "source manual checkbox group requires IDLE");
|
||||||
|
assert.equal(snapshot().gates.load.enabled, false);
|
||||||
|
send("PROGRAM_PAUSE");
|
||||||
|
assert.equal(snapshot().gates.load.enabled, false);
|
||||||
|
assert.equal(snapshot().ui_gates.abort.enabled, true);
|
||||||
|
send("PROGRAM_ABORT");
|
||||||
|
assert.equal(snapshot().ui_gates.abort.enabled, false);
|
||||||
|
assert.equal(snapshot().ui_gates.coolant.enabled, true);
|
||||||
|
console.log(JSON.stringify({ schema: "xyzac-source-widget-gates.v1", status: "verified", cases: ["OFF", "ESTOP", "MDI_IDLE", "AUTO_RUNNING", "PAUSED", "ABORTED_IDLE"] }));
|
||||||
@@ -35,11 +35,38 @@ for (let sample = 0; sample < 5; sample += 1) jogSample = send("SIM_STEP", { cyc
|
|||||||
assert.ok(Number(jogSample.payload.wasm_motion?.state?.position?.x ?? 0) > 0);
|
assert.ok(Number(jogSample.payload.wasm_motion?.state?.position?.x ?? 0) > 0);
|
||||||
assert.equal(send("JOG_STOP", {}).payload.status, "verified");
|
assert.equal(send("JOG_STOP", {}).payload.status, "verified");
|
||||||
|
|
||||||
|
const beforeTouch = session.wasmMotion.read().state.position;
|
||||||
|
const epochBefore = send("SNAPSHOT_REQUEST").payload.panel_snapshot.visual.live_path_epoch;
|
||||||
const systemTouch = send("TOUCH_OFF", { axis: "X", value: 12, system: "P1 G54", units: "mm" });
|
const systemTouch = send("TOUCH_OFF", { axis: "X", value: 12, system: "P1 G54", units: "mm" });
|
||||||
assert.equal(systemTouch.kind, "COMMAND_RESULT");
|
assert.equal(systemTouch.kind, "COMMAND_RESULT");
|
||||||
assert.equal(systemTouch.payload.touch_off.source_block, "G10 L20 P1 X12");
|
assert.equal(systemTouch.payload.touch_off.source_block, "G10 L20 P1 X12");
|
||||||
assert.equal(systemTouch.payload.touch_off.block, "G10 L20 P1 X12");
|
assert.equal(systemTouch.payload.touch_off.block, "G10 L20 P1 X12");
|
||||||
assert.equal(systemTouch.payload.task.mode, "MANUAL");
|
assert.equal(systemTouch.payload.task.mode, "MANUAL");
|
||||||
|
let panel = send("SNAPSHOT_REQUEST").payload.panel_snapshot;
|
||||||
|
assert.deepEqual(session.wasmMotion.read().state.position, beforeTouch);
|
||||||
|
assert.ok(Math.abs(panel.coordinates.relative[0] - 12) < 1e-9);
|
||||||
|
assert.ok(Math.abs(panel.visual.toolpath[0].end.x - (beforeTouch.x - 12 + 10)) < 1e-9);
|
||||||
|
assert.equal(panel.visual.live_path_epoch, epochBefore);
|
||||||
|
for (const axis of ["A", "C"]) {
|
||||||
|
const angular = send("TOUCH_OFF", { axis, value: 15, system: "P1 G54", units: "inch" });
|
||||||
|
assert.equal(angular.kind, "COMMAND_RESULT", JSON.stringify(angular.payload));
|
||||||
|
assert.equal(angular.payload.block, `G10 L20 P1 ${axis}15`);
|
||||||
|
panel = send("SNAPSHOT_REQUEST").payload.panel_snapshot;
|
||||||
|
assert.equal(panel.coordinates.relative[axis === "A" ? 3 : 4], 15);
|
||||||
|
}
|
||||||
|
const savedOffsets = panel.program.coordinate_context;
|
||||||
|
assert.equal(send("PROGRAM_LOAD", { source: "G21 G90\nG0 X13 A15 C15\nM2\n", path: "touch-reload.ngc" }).kind, "COMMAND_RESULT");
|
||||||
|
panel = send("SNAPSHOT_REQUEST").payload.panel_snapshot;
|
||||||
|
assert.deepEqual(panel.program.coordinate_context, savedOffsets);
|
||||||
|
assert.ok(Math.abs(panel.visual.toolpath[0].end.x - beforeTouch.x - 1) < 1e-9);
|
||||||
|
assert.equal(send("TOUCH_OFF", { axis: "X", value: 0, system: "G54.1" }).payload.code, "touch_off_system_invalid");
|
||||||
|
assert.equal(send("TOUCH_OFF", { axis: "X", value: 20, system: "G59.3" }).kind, "COMMAND_RESULT");
|
||||||
|
assert.equal(send("MDI_EXECUTE", { block: "G59.3" }).kind, "COMMAND_RESULT");
|
||||||
|
panel = send("SNAPSHOT_REQUEST").payload.panel_snapshot;
|
||||||
|
assert.equal(panel.coordinates.system, 9);
|
||||||
|
assert.ok(Math.abs(panel.coordinates.relative[0] - 20) < 1e-9);
|
||||||
|
assert.equal(send("MDI_EXECUTE", { block: "G54" }).kind, "COMMAND_RESULT");
|
||||||
|
assert.equal(send("SET_MODE", { mode: "MANUAL" }).kind, "COMMAND_RESULT");
|
||||||
|
|
||||||
assert.equal(send("TOOL_PREPARE", { tool: 1, pocket: 1 }).payload.status, "verified");
|
assert.equal(send("TOOL_PREPARE", { tool: 1, pocket: 1 }).payload.status, "verified");
|
||||||
assert.equal(send("TOOL_PREPARED", { prepared: true }).payload.status, "verified");
|
assert.equal(send("TOOL_PREPARED", { prepared: true }).payload.status, "verified");
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const estop = send("ESTOP", "io-estop", { active: true });
|
|||||||
assert.equal(estop.payload.device_io.source, "wasm_io");
|
assert.equal(estop.payload.device_io.source, "wasm_io");
|
||||||
const blocked = send("SPINDLE_COMMAND", "spindle-blocked", { spindle: 0, rpm: 500, enabled: true });
|
const blocked = send("SPINDLE_COMMAND", "spindle-blocked", { spindle: 0, rpm: 500, enabled: true });
|
||||||
assert.equal(blocked.kind, "ERROR");
|
assert.equal(blocked.kind, "ERROR");
|
||||||
assert.equal(send("ESTOP", "io-estop-reset", { active: false }).payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET");
|
assert.equal(send("ESTOP", "io-estop-reset", { active: false }).payload.panel_snapshot.axis_state.source_task_state, "STATE_ESTOP_RESET");
|
||||||
assert.equal(send("POWER", "power-on-after-estop", { active: true }).payload.device_io.source, "wasm_io");
|
assert.equal(send("POWER", "power-on-after-estop", { active: true }).payload.device_io.source, "wasm_io");
|
||||||
const firstHash = send("SNAPSHOT_REQUEST", "snapshot-a").payload.panel_snapshot.device_io.projection_hash;
|
const firstHash = send("SNAPSHOT_REQUEST", "snapshot-a").payload.panel_snapshot.device_io.projection_hash;
|
||||||
const secondHash = send("SNAPSHOT_REQUEST", "snapshot-b").payload.panel_snapshot.device_io.projection_hash;
|
const secondHash = send("SNAPSHOT_REQUEST", "snapshot-b").payload.panel_snapshot.device_io.projection_hash;
|
||||||
|
|||||||
@@ -98,7 +98,8 @@ export class WasmHomeLimit {
|
|||||||
|
|
||||||
home(sequence = 0, axis = null) {
|
home(sequence = 0, axis = null) {
|
||||||
if (!this.available) return this.read();
|
if (!this.available) return this.read();
|
||||||
const index = axis === null || axis === undefined ? -1 : [...this.coordinates].findIndex((value) => value.toLowerCase() === String(axis).toLowerCase());
|
const index = axis === null || axis === undefined ? -1 : Number.isInteger(axis) ? axis : [...this.coordinates].findIndex((value) => value.toLowerCase() === String(axis).toLowerCase());
|
||||||
|
if (index >= this.jointCount) return { status: "error", code: "wasm_home_joint_invalid", state: this.read().state };
|
||||||
if (axis !== null && index < 0) return { status: "error", code: "wasm_home_axis_invalid", state: this.read().state };
|
if (axis !== null && index < 0) return { status: "error", code: "wasm_home_axis_invalid", state: this.read().state };
|
||||||
const result = index < 0
|
const result = index < 0
|
||||||
? call(this.core, "sim_kinematics_home_limit_home", "number", ["number", "number"], [this.homeLimit, sequence])
|
? call(this.core, "sim_kinematics_home_limit_home", "number", ["number", "number"], [this.homeLimit, sequence])
|
||||||
|
|||||||
@@ -98,6 +98,17 @@ export class WasmLinuxcncTp {
|
|||||||
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)]); return result === 0 ? { status: "verified", command: "JOG_START" } : { status: "error", code: `linuxcnc_tp_jog_${result}` }; }
|
||||||
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])); }
|
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(); }
|
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() {
|
||||||
|
const result = call(this.core, "sim_linuxcnc_tp_step_line", "number", ["number"], [this.planner]);
|
||||||
|
if (result !== 0) throw new Error(`linuxcnc_tp_step_line_${result}`);
|
||||||
|
this.controls.paused = false;
|
||||||
|
return this.read();
|
||||||
|
}
|
||||||
|
isStepping() { return Boolean(call(this.core, "sim_linuxcnc_tp_is_stepping", "number", ["number"], [this.planner])); }
|
||||||
|
axisLimits() {
|
||||||
|
const limits = [0, 1, 2].map((axis) => [0, 1].map((maximum) => Number(call(this.core, "sim_linuxcnc_tp_axis_limit", "number", ["number", "number", "number"], [this.planner, axis, maximum]))));
|
||||||
|
return limits.flat().every(Number.isFinite) ? limits : null;
|
||||||
|
}
|
||||||
|
|
||||||
enqueue(segment) {
|
enqueue(segment) {
|
||||||
if (!this.available || !segment) return { status: "skipped", code: "linuxcnc_tp_unavailable" };
|
if (!this.available || !segment) return { status: "skipped", code: "linuxcnc_tp_unavailable" };
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export class WasmProgramRuntime {
|
|||||||
this.samplePointer = 0;
|
this.samplePointer = 0;
|
||||||
this.samplePosePointer = 0;
|
this.samplePosePointer = 0;
|
||||||
this.source = "";
|
this.source = "";
|
||||||
|
this.hashedSource = null;
|
||||||
|
this.cachedSourceHash = null;
|
||||||
this.path = null;
|
this.path = null;
|
||||||
this.subroutines = [];
|
this.subroutines = [];
|
||||||
this.executionSource = "";
|
this.executionSource = "";
|
||||||
@@ -95,6 +97,16 @@ export class WasmProgramRuntime {
|
|||||||
return result === 0 ? { status: "verified", command: name } : { status: "error", code: `wasm_${errorPrefix}_${result}`, result, reason: PROGRAM_ERROR_REASONS[result] ?? `wasm_${errorPrefix}_${result}`, message_key: PROGRAM_ERROR_REASONS[result] ?? `wasm_${errorPrefix}_${result}`, source: "linuxcnc-wasm-sim_program" };
|
return result === 0 ? { status: "verified", command: name } : { status: "error", code: `wasm_${errorPrefix}_${result}`, result, reason: PROGRAM_ERROR_REASONS[result] ?? `wasm_${errorPrefix}_${result}`, message_key: PROGRAM_ERROR_REASONS[result] ?? `wasm_${errorPrefix}_${result}`, source: "linuxcnc-wasm-sim_program" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get sourceHash() {
|
||||||
|
// Source text is immutable between file replacements. Only its identity
|
||||||
|
// needs checking on a tick; the canonical WASM state is still read fresh.
|
||||||
|
if (this.hashedSource !== this.source) {
|
||||||
|
this.hashedSource = this.source;
|
||||||
|
this.cachedSourceHash = this.source ? fnv1a64(this.source) : null;
|
||||||
|
}
|
||||||
|
return this.cachedSourceHash;
|
||||||
|
}
|
||||||
|
|
||||||
load(source, path = null) {
|
load(source, path = null) {
|
||||||
if (typeof source !== "string" || source.length === 0) return { status: "error", code: "program_source_invalid" };
|
if (typeof source !== "string" || source.length === 0) return { status: "error", code: "program_source_invalid" };
|
||||||
const bytes = new TextEncoder().encode(source);
|
const bytes = new TextEncoder().encode(source);
|
||||||
@@ -140,6 +152,9 @@ export class WasmProgramRuntime {
|
|||||||
if (loaded.status !== "verified") return loaded;
|
if (loaded.status !== "verified") return loaded;
|
||||||
const synchronized = temporary.synchronizePosition(this.synchronizedPosition);
|
const synchronized = temporary.synchronizePosition(this.synchronizedPosition);
|
||||||
if (synchronized.status !== "verified") return synchronized;
|
if (synchronized.status !== "verified") return synchronized;
|
||||||
|
const state = this.snapshot();
|
||||||
|
const initialized = temporary.initializeCoordinates(state.coordinate_context, state);
|
||||||
|
if (initialized.status !== "verified") return initialized;
|
||||||
temporary.setRapidVelocity(this.rapidVelocity);
|
temporary.setRapidVelocity(this.rapidVelocity);
|
||||||
for (const subroutine of this.subroutines) {
|
for (const subroutine of this.subroutines) {
|
||||||
const added = temporary.addSubroutine(subroutine.name, subroutine.source, subroutine.path);
|
const added = temporary.addSubroutine(subroutine.name, subroutine.source, subroutine.path);
|
||||||
@@ -156,11 +171,26 @@ export class WasmProgramRuntime {
|
|||||||
if (step.motion) segments.push({ id: segments.length + 1, kinematics_mode: kinematicsMode, ...step.motion });
|
if (step.motion) segments.push({ id: segments.length + 1, kinematics_mode: kinematicsMode, ...step.motion });
|
||||||
if ([4, 5, 6].includes(step.program.status)) break;
|
if ([4, 5, 6].includes(step.program.status)) break;
|
||||||
}
|
}
|
||||||
return { status: "verified", schema: "wasm-canonical-preview.v1", source: "linuxcnc-wasm-sim_program", path: this.path, source_hash: fnv1a64(this.source), line_count: this.snapshot().line_count, segments, preview_hash: fnv1a64(segments) };
|
return { status: "verified", schema: "wasm-canonical-preview.v1", source: "linuxcnc-wasm-sim_program", path: this.path, source_hash: this.sourceHash, line_count: this.snapshot().line_count, segments, preview_hash: fnv1a64(segments) };
|
||||||
} finally { temporary.destroy(); }
|
} finally { temporary.destroy(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
start() { return this.invoke("sim_program_start", [], "program_start"); }
|
start() { return this.invoke("sim_program_start", [], "program_start"); }
|
||||||
|
initializeCoordinates(context, state = {}) {
|
||||||
|
if (!context) return { status: "verified" };
|
||||||
|
const previous = this.snapshot().coordinate_context;
|
||||||
|
const blocks = ["G21"];
|
||||||
|
for (const [index, offsets] of context.offsets.entries()) {
|
||||||
|
if (offsets.some((value, axis) => value !== (previous?.offsets?.[index]?.[axis] ?? 0))) blocks.push(`G10 L2 P${index + 1} ${AXES.map((axis, a) => `${axis.toUpperCase()}${offsets[a]}`).join(" ")}`);
|
||||||
|
}
|
||||||
|
const system = context.system <= 6 ? `G${53 + context.system}` : `G59.${context.system - 6}`;
|
||||||
|
blocks.push(`${system} G${state.units === 20 ? 20 : 21} G${state.distance_mode === 91 ? 91 : 90}`);
|
||||||
|
for (const block of blocks) {
|
||||||
|
const result = this.executeBlock(block);
|
||||||
|
if (result.status !== "verified") return result;
|
||||||
|
}
|
||||||
|
return { status: "verified" };
|
||||||
|
}
|
||||||
synchronizePosition(value) {
|
synchronizePosition(value) {
|
||||||
const position = Object.fromEntries(AXES.map((axis) => [axis, Number(value?.[axis] ?? 0)]));
|
const position = Object.fromEntries(AXES.map((axis) => [axis, Number(value?.[axis] ?? 0)]));
|
||||||
if (Object.values(position).some((coordinate) => !Number.isFinite(coordinate))) return { status: "error", code: "program_position_nonfinite" };
|
if (Object.values(position).some((coordinate) => !Number.isFinite(coordinate))) return { status: "error", code: "program_position_nonfinite" };
|
||||||
@@ -208,10 +238,12 @@ export class WasmProgramRuntime {
|
|||||||
|
|
||||||
restoreState(value) {
|
restoreState(value) {
|
||||||
if (!value || ![SCHEMA, "sim_program.v1"].includes(value.schema) || !Number.isSafeInteger(value.current_line) || value.current_line < 0 || !this.source) throw new Error("wasm_program_restore_schema");
|
if (!value || ![SCHEMA, "sim_program.v1"].includes(value.schema) || !Number.isSafeInteger(value.current_line) || value.current_line < 0 || !this.source) throw new Error("wasm_program_restore_schema");
|
||||||
if (value.source_hash && value.source_hash !== fnv1a64(this.source)) throw new Error("wasm_program_restore_source_hash");
|
if (value.source_hash && value.source_hash !== this.sourceHash) throw new Error("wasm_program_restore_source_hash");
|
||||||
const subroutines = [...this.subroutines];
|
const subroutines = [...this.subroutines];
|
||||||
const loaded = this.load(this.source, this.path);
|
const loaded = this.load(this.source, this.path);
|
||||||
if (loaded.status !== "verified") throw new Error(loaded.code);
|
if (loaded.status !== "verified") throw new Error(loaded.code);
|
||||||
|
const initialized = this.initializeCoordinates(value.coordinate_context, value);
|
||||||
|
if (initialized.status !== "verified") throw new Error(initialized.code);
|
||||||
const synchronized = this.synchronizePosition(Object.fromEntries(AXES.map((axis) => [axis, Number(value[axis] ?? 0)])));
|
const synchronized = this.synchronizePosition(Object.fromEntries(AXES.map((axis) => [axis, Number(value[axis] ?? 0)])));
|
||||||
if (synchronized.status !== "verified") throw new Error(synchronized.code);
|
if (synchronized.status !== "verified") throw new Error(synchronized.code);
|
||||||
for (const subroutine of subroutines) {
|
for (const subroutine of subroutines) {
|
||||||
@@ -232,6 +264,14 @@ export class WasmProgramRuntime {
|
|||||||
} else if (status === "completed") {
|
} else if (status === "completed") {
|
||||||
this.start();
|
this.start();
|
||||||
for (let index = 0; index < this.snapshot().line_count + 1 && this.snapshot().status === 2; index += 1) this.step();
|
for (let index = 0; index < this.snapshot().line_count + 1 && this.snapshot().status === 2; index += 1) this.step();
|
||||||
|
} else if (status === "paused") {
|
||||||
|
const from = Number(value.pause_from ?? 2);
|
||||||
|
if (from !== 1) {
|
||||||
|
this.start();
|
||||||
|
if (from === 4) for (let index = 0; index < this.snapshot().line_count + 1 && this.snapshot().status === 2; index += 1) this.step();
|
||||||
|
}
|
||||||
|
const paused = this.pause();
|
||||||
|
if (paused.status !== "verified") throw new Error(paused.code);
|
||||||
} else if (status === "aborted") {
|
} else if (status === "aborted") {
|
||||||
const aborted = this.abort();
|
const aborted = this.abort();
|
||||||
if (aborted.status !== "verified") throw new Error(aborted.code);
|
if (aborted.status !== "verified") throw new Error(aborted.code);
|
||||||
@@ -305,7 +345,7 @@ export class WasmProgramRuntime {
|
|||||||
const motionControl = { 0: "continuous", 1: "exact_path", 2: "exact_stop" }[state.motion_control_mode] ?? "continuous";
|
const motionControl = { 0: "continuous", 1: "exact_path", 2: "exact_stop" }[state.motion_control_mode] ?? "continuous";
|
||||||
const executionLineText = state.current_line > 0 ? (this.executionLines[state.current_line - 1] ?? "").trim() : "";
|
const executionLineText = state.current_line > 0 ? (this.executionLines[state.current_line - 1] ?? "").trim() : "";
|
||||||
const executionMeta = state.current_line > 0 ? (this.executionLineMetadata[state.current_line - 1] ?? { path: this.path, line: state.current_line }) : { path: this.path, line: 0 };
|
const executionMeta = state.current_line > 0 ? (this.executionLineMetadata[state.current_line - 1] ?? { path: this.path, line: state.current_line }) : { path: this.path, line: 0 };
|
||||||
return { schema: SCHEMA, source: "linuxcnc-wasm-sim_program", ...state, motion_control: { mode: motionControl, path_tolerance: Number(state.path_tolerance ?? 0), naive_cam_tolerance: Number(state.naive_cam_tolerance ?? 0) }, active_gcodes: activeGcodes(state), loaded: state.status !== 0, line: state.current_line, currentLine: state.current_line, execution_line: state.current_line, execution_line_text: executionLineText, execution_source_file: executionMeta.path, execution_source_line: executionMeta.line, program_status: programStatus, path: this.path, subroutines: this.subroutines.map((subroutine) => subroutine.name), state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, source_hash: this.source ? fnv1a64(this.source) : null };
|
return { schema: SCHEMA, source: "linuxcnc-wasm-sim_program", ...state, motion_control: { mode: motionControl, path_tolerance: Number(state.path_tolerance ?? 0), naive_cam_tolerance: Number(state.naive_cam_tolerance ?? 0) }, active_gcodes: activeGcodes(state), loaded: state.status !== 0, line: state.current_line, currentLine: state.current_line, execution_line: state.current_line, execution_line_text: executionLineText, execution_source_file: executionMeta.path, execution_source_line: executionMeta.line, program_status: programStatus, path: this.path, subroutines: this.subroutines.map((subroutine) => subroutine.name), state_hash: `fnv1a64:${BigInt.asUintN(64, BigInt(hash >>> 0)).toString(16).padStart(16, "0")}`, source_hash: this.sourceHash };
|
||||||
} finally { this.core._free(buffer); }
|
} finally { this.core._free(buffer); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user