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:
2026-09-08 10:46:27 +08:00
parent 845075f4a3
commit bade560372
55 changed files with 2495 additions and 286 deletions

View File

@@ -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
```
## 历史记录(不作为当前验收结论)
## 结论 ## 结论
本报告针对当前选定案例 本报告针对当前选定案例

View File

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

View File

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

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

View File

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

View File

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

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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