Use native LinuxCNC homing and consolidate web panel status

This commit is contained in:
2026-09-09 12:28:02 +08:00
parent eb5c58cdb3
commit 0be2ee8ce1
35 changed files with 881 additions and 350 deletions

View File

@@ -0,0 +1,53 @@
# Native Homing and Status Validation
Date: 2026-09-09. Local URL: http://127.0.0.1:5175/.
This records local execution, not deployment or a full LinuxCNC port.
## Implementation
- The selected XYZAC case compiles the pinned `homing.c` and `simple_tp.c`.
- HAL allocation and ideal motor feedback are adapted; the homing state
machine is not rewritten in JavaScript.
- `task.state` is machine state; interpreter state is `task.interpState`.
- `motion.traj.mode` is FREE/COORD/TELEOP; `motion_type` is separate.
- `home_state` comes from homemod's state underlying `joint.N.home-state`.
- The monitor displays task mode/state, interpreter state, motion mode/type
and Homing. Per-joint status and history remain in the snapshot; their table
and history list were removed from the visible panel at the user's request.
- Numeric Home Joint replaces the world-axis-to-joint alias. Original control
ID `manual-home-axis` is retained; `manual-home-joint-select` selects 0..4.
- Home acceptance is asynchronous. Completion is determined by homemod,
not by the coordinated planner queue.
## Verified
- Native C test: `ctest --test-dir build-native -R sim_native_homing_test`.
- WASM/Worker: `node web/worker/test_native_homing.mjs`.
- All five joints: Jog then native no-search re-home resets joint coordinates
while preserving simulated physical motor position and updating motor offset.
- A test INI with HOME=5 executes the original free_tp final move; intermediate
positions are nonzero, homed stays false until completion, coordinated TP
queue remains empty.
- Partial home remains FREE; the native all-homed edge switches to TELEOP.
- AUTO+PAUSED rejects switching to MANUAL; abort and E-stop cancel homemod
without marking interrupted joints homed; subsequent re-home works.
- Native state save/restore preserves positions and offsets.
- Existing Step, WAITING/MDI Pause/Resume, rerun, touch-off, source widget gates
and Live Plot lifecycle regression tests pass with asynchronous Home setup.
- Source Tcl oracle: 576 scenarios, 7488 assertions. Control inventory: 83 IDs.
- Visible Chrome tests: `scripts/test_xyzac_home_reset_browser.mjs` and
`scripts/test_xyzac_panel_buttons_browser.mjs` with `HEADED=1`.
- Desktop/mobile screenshots and canvas pixel checks pass. General browser
audit covers 51 buttons with no page errors.
- React type checking and production build pass.
Screenshots: `/tmp/axis-native-home-desktop.png`,
`/tmp/axis-native-home-mobile.png`.
## Remaining Boundaries
Task/interpreter status still uses the existing WASM adapter. Physical switch,
encoder and unlock hardware have not been validated; the selected INI uses
zero search/latch velocities. Full Joint/World jogging is not exposed.
General recovery retains the existing planner/HAL endpoint differences and
large RECOVER envelope stack limitation documented in the source-flow contract.

View File

@@ -45,12 +45,12 @@ digraph LinuxCncAxisWebControlLogic {
source [label="LinuxCNC 源程序\naxis.py · axis.tcl\nemctaskmain.cc · HAL/INI", fillcolor="#f7f1df", color="#b08a2e"]; source [label="LinuxCNC 源程序\naxis.py · axis.tcl\nemctaskmain.cc · HAL/INI", fillcolor="#f7f1df", color="#b08a2e"];
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\n83 个文档控制 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\noperation_id · ledger\nWeb 通道暂停仍接收合法连续 seq", 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 · ui_gates / gates · motion\nprogram · devices · visual", fillcolor="#e9f5e8", color="#43844b"]; snapshot [label="Canonical snapshot\naxis_state · ui_gates / gates · motion\nprogram · devices · visual · source_status", 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; }
@@ -106,7 +106,10 @@ digraph LinuxCncAxisWebControlLogic {
estop -> off [label="ESTOP active=false"]; estop -> off [label="ESTOP active=false"];
off -> on_unhomed [label="POWER active=true"]; off -> on_unhomed [label="POWER active=true"];
on_unhomed -> idle [label="HOME all=true"]; homing [label="MANUAL · HOMING\\ntraj.mode=FREE · interpState=IDLE\\nhoming.c + simple_tp.c\\njoint homing / homed / home-state", fillcolor="#e9f5e8"];
on_unhomed -> homing [label="HOME all/joint"];
homing -> idle [label="全部 homed -> TELEOP"];
homing -> on_unhomed [label="部分回零 / 中止", style=dashed];
idle -> running [label="PROGRAM_RUN"]; idle -> running [label="PROGRAM_RUN"];
running -> paused [label="PROGRAM_PAUSE"]; running -> paused [label="PROGRAM_PAUSE"];
paused -> running [label="PROGRAM_RESUME"]; paused -> running [label="PROGRAM_RESUME"];
@@ -135,7 +138,7 @@ 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 (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)", label="手动控制\n\nmanual-axis-x / y / z / a / c (本地选择)\nmanual-jog-increment (本地选择)\nmanual-jog-negative / positive (JOG_START -> release JOG_STOP)\nmanual-home-joint-select (数字关节选择)\nmanual-home-axis (HOME joint; 原生 homemod)\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"

View File

@@ -91,6 +91,8 @@ fingerprinted so the audit fails on an unreviewed source drift:
| `lib/python/glnav.py` | `b2d811a21954966655a40f107c2af217cc7d57bae0dc308dc4698f5859ed3f11` | Orthographic/perspective presets, rotation, camera framing | | `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 |
| `src/emc/motion/homing.c` | `ee73d38f2feb8d5523b9a733cb17d635d3904667aaa219f8a02d7fbffee1ba39` | Native joint homing and sequence state machines, compiled into WASM |
| `src/emc/motion/simple_tp.c` | `5a0026f4d5bdd9d1bc55290c6ea9c399abd664633b69413c307f7353953d2603` | Native free-mode joint motion during homing |
| `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 |
The current local LinuxCNC tree contains unrelated heartbeat instrumentation The current local LinuxCNC tree contains unrelated heartbeat instrumentation
@@ -155,7 +157,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: `SOURCE_ADAPTER`; Web Home Axis maps the fixed XYZAC axis selection to a numeric joint and retains the source KINEMATICS_BOTH restriction; individual joint selection and Unhome remain `SOURCE_BLOCKED` | | Home all/joint and Unhome all/joint | `home_all_joints`, `home_joint_number`, `unhome_all_joints`, `unhome_joint_number` | Home All and numeric Home Joint: `SOURCE_ADAPTER`, native homemod execution. World-axis requests reject for BOTH. Unhome remains unavailable. |
| 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 |
@@ -163,9 +165,9 @@ families because their cardinality comes from the selected INI.
| Show program, rapids, alpha, live plot, tool, extents, offsets, limits, velocity, DTG | `toggle_show_*`, `toggle_program_alpha` | Program/live/extents/offsets/limits/velocity have adapters; rapids, alpha, tool toggle and DTG are not rendered | | Show program, rapids, alpha, live plot, tool, extents, offsets, limits, velocity, DTG | `toggle_show_*`, `toggle_program_alpha` | Program/live/extents/offsets/limits/velocity have adapters; rapids, alpha, tool toggle and DTG are not rendered |
| Grid Off/custom/INI increments, large DRO font, PyVCP, Joint/World | `set_grid_size`, `set_grid_size_custom`, `toggle_dro_large_font`, `toggle_show_pyvcppanel`, `set_teleop_mode` | Not rendered; Joint/World is `SOURCE_BLOCKED`, presentation-only settings are not aliased | | Grid Off/custom/INI increments, large DRO font, PyVCP, Joint/World | `set_grid_size`, `set_grid_size_custom`, `toggle_dro_large_font`, `toggle_show_pyvcppanel`, `set_teleop_mode` | Not rendered; Joint/World is `SOURCE_BLOCKED`, presentation-only settings are not aliased |
| 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/increment selection and dedicated numeric homing-joint selection: `SOURCE_ADAPTER`; full joint-mode jogging remains unavailable |
| 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 Axis: fixed XYZAC axis-to-joint `SOURCE_ADAPTER` with source mode transaction; Touch Off: selected-coordinate adapter; Tool Touch Off/Override Limits: visible `SOURCE_BLOCKED` | | Home Axis, Touch Off, Tool Touch Off, Override Limits | `home_joint`, `home_joint_number`, touch-off/override widgets | Home Joint uses explicit numeric selection and native homemod. Touch Off is a coordinate adapter; Tool Touch Off/Override Limits remain 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 |
@@ -184,7 +186,7 @@ command are blocked instead of being silently mapped to another operation.
| --- | --- | --- | | --- | --- | --- |
| `F1`, `F2`, lowercase `o`, `r`, `t`, `p`, `s`, `Escape`, `Ctrl-r`, `F7`-`F12` | E-stop, power, open, run, step, pause, resume, stop, reload, coolant and spindle handlers | Worker-backed commands where the corresponding control is supported. Display labels remain `O/R/T/P/S`. | | `F1`, `F2`, lowercase `o`, `r`, `t`, `p`, `s`, `Escape`, `Ctrl-r`, `F7`-`F12` | E-stop, power, open, run, step, pause, resume, stop, reload, coolant and spindle handlers | Worker-backed commands where the corresponding control is supported. Display labels remain `O/R/T/P/S`. |
| uppercase `R`, `F` | `task_reverse`, `task_forward` | Source controls are recorded as blocked because the selected WASM program ABI has no reverse/forward command; they are never mapped to Run. | | uppercase `R`, `F` | `task_reverse`, `task_forward` | Source controls are recorded as blocked because the selected WASM program ABI has no reverse/forward command; they are never mapped to Run. |
| `Home`, `Ctrl-Home`, `Shift-Home`, `End`, `Ctrl-End` | selected joint home, home all, zero selected G54 axis, system touch off, tool touch off | Home uses the fixed XYZAC axis-to-joint adapter (and Home All uses the all-joint transaction); system Touch Off has an adapter; zero-G54 and Tool Touch Off are blocked. | | `Home`, `Ctrl-Home`, `Shift-Home`, `End`, `Ctrl-End` | selected joint home, home all, zero selected G54 axis, system touch off, tool touch off | Home uses numeric joint selection (and Home All uses the native configured joint sequences); system Touch Off has an adapter; zero-G54 and Tool Touch Off are blocked. |
| lowercase `x`, `y`, `z`, `a` | Select the first four source axes | Axis selection adapter. Native AXIS does **not** bind lowercase `c` to C-axis selection; `c` selects continuous jog. C remains selectable only through its radio control. | | lowercase `x`, `y`, `z`, `a` | Select the first four source axes | Axis selection adapter. Native AXIS does **not** bind lowercase `c` to C-axis selection; `c` selects continuous jog. C remains selectable only through its radio control. |
| `-`, `=`, arrows/Page keys/`[`/`]` and their Shift variants | `jog_on_map`/`jog_off_map` or selected-axis `jog_minus`/`jog_plus` | Jog key semantics require keydown start and keyup stop. Pointer controls are supported; unavailable key bindings are not reassigned. | | `-`, `=`, arrows/Page keys/`[`/`]` and their Shift variants | `jog_on_map`/`jog_off_map` or selected-axis `jog_minus`/`jog_plus` | Jog key semantics require keydown start and keyup stop. Pointer controls are supported; unavailable key bindings are not reassigned. |
| lowercase `c`, lowercase `i`, uppercase `I` | Continuous, next incremental and previous incremental jog modes | Represented by the jog increment selector; no second machine-mode state is created. | | lowercase `c`, lowercase `i`, uppercase `I` | Continuous, next incremental and previous incremental jog modes | Represented by the jog increment selector; no second machine-mode state is created. |
@@ -219,15 +221,12 @@ command are blocked instead of being silently mapped to another operation.
`motion/control.c:2186` 在执行 ID 改变后暂停 TP,不能“解析一行后永久 `motion/control.c:2186` 在执行 ID 改变后暂停 TP,不能“解析一行后永久
冻结”,也不能一次 Step 把全部已排队运动执行完。Web 单步的源码行解析 冻结”,也不能一次 Step 把全部已排队运动执行完。Web 单步的源码行解析
与原生 NML 指令细分仍是需单独验证的适配边界,不能用行数相同替代运动断言。 与原生 NML 指令细分仍是需单独验证的适配边界,不能用行数相同替代运动断言。
5. **回零类型不能混同。** `switchkins.c::kinematicsType()` 始终返回 5. **回零采用原生 homemod。** `switchkins.c::kinematicsType()` 始终返回
`KINEMATICS_BOTH`;M429 的 `active_type=0` 不代表 KINEMATICS_IDENTITY。 `KINEMATICS_BOTH`,M429 不改变这一类型。世界轴字母不能作为回零目标;
原生 `home_joint` 在这种配置下只接受数字 joint;直接把世界坐标字母传给 `manual-home-joint-select` 明确选择数字关节,`manual-home-axis` 保留 ID,
`home_joint` 会被源码拒绝。固定的 XYZAC TRT Web 面板没有原生 joint 按钮显示 Home Joint,提交 `HOME {joint:0..4}`。Home All 提交
单选组,因此 `Home Axis` 使用明确记录的 Web 适配: `HOME {all:true}`。WASM 直接编译源 `homing.c` 和 `simple_tp.c`,
`X/Y/Z/A/C -> joint 0/1/2/3/4`,Worker 只把这个映射后的数字 joint 不再预览 home-limit 后排一条协调 TP 回零直线。完整语义见下方 Native Homing。
交给 WASM home-limit authority,并在结果中回显 `source_axis`、
`joint` 和 `motion_mode`。它不是把 `KINEMATICS_BOTH` 冒充成
`KINEMATICS_IDENTITY`,也不是用坐标轴字母绕过 joint 约束。
6. **自动恢复标签模式。** `axis.tcl::update_state` 在 ON+IDLE 的状态边沿, 6. **自动恢复标签模式。** `axis.tcl::update_state` 在 ON+IDLE 的状态边沿,
当前 task_mode 为 AUTO 时调用 `set_mode_from_tab`;显式 Open/Reload 完成后 当前 task_mode 为 AUTO 时调用 `set_mode_from_tab`;显式 Open/Reload 完成后
也调用它。Web 必须通过排队的 `SET_MODE` 恢复所选 Manual/MDI 页,不能只 也调用它。Web 必须通过排队的 `SET_MODE` 恢复所选 Manual/MDI 页,不能只
@@ -254,7 +253,7 @@ command are blocked instead of being silently mapped to another operation.
| 功能 | 原生行为 / 当前边界 | 禁止的错误结论 | | 功能 | 原生行为 / 当前边界 | 禁止的错误结论 |
| --- | --- | --- | | --- | --- | --- |
| Run 警告 | `run_warn` 对 XYZ 预览边界给出确认;当前 WASM 没有完整含刀偏 extents 警告事务 | 不得声称已复制 run_warn 的确认/取消交互 | | Run 警告 | `run_warn` 对 XYZ 预览边界给出确认;当前 WASM 没有完整含刀偏 extents 警告事务 | 不得声称已复制 run_warn 的确认/取消交互 |
| Joint/World、Home Axis | KINEMATICS_BOTH 需要 joint 数字选择与 FREE/TELEOP 状态;固定 XYZAC Web 面板以 X/Y/Z/A/C 的明确一对一映射提供 Home Axis 适配,执行时仍切入 FREE/joint 并由 WASM 返回数字 joint 结果 | 不得用 switchkins 编号判断 kinematics 类型,不得把世界轴字母直接交给 `home_joint`,不得把本地坐标归零冒充 joint home | | Joint/World、Home Axis | 回零执行使用原生 FREE/homemod;数字关节选择用于 Home Joint,轴选择仍用于世界坐标 Jog/Touch Off | 不得用 X/Y/Z/A/C 偷换 joint,也不得把 M429 当作 KINEMATICS_IDENTITY。完整 Joint/World 点动切换尚未提供。 |
| 速度滑块 | 原生线性/角速度独立,显示物理单位;当前 Jog/Max 的百分比与 6% 下限属于已公开的 Web 适配 | 不得把单一比例视为独立 angular jog 控制 | | 速度滑块 | 原生线性/角速度独立,显示物理单位;当前 Jog/Max 的百分比与 6% 下限属于已公开的 Web 适配 | 不得把单一比例视为独立 angular jog 控制 |
| 预览图层 | limits 现从 WASM XYZ 软限位 getter 读取;offsets 使用已实现的 G5x 平移;extents 使用 canonical 采样包围框 | G92/旋转/刀具偏置范围与原生文字标注仍未全量覆盖,不能称完整原生图层复刻 | | 预览图层 | limits 现从 WASM XYZ 软限位 getter 读取;offsets 使用已实现的 G5x 平移;extents 使用 canonical 采样包围框 | G92/旋转/刀具偏置范围与原生文字标注仍未全量覆盖,不能称完整原生图层复刻 |
| commanded/actual | 仿真当前两者相同,需要分别存在的 canonical 字段才可声明跟随误差显示 | 不得从 React 标签推导真实反馈差异 | | commanded/actual | 仿真当前两者相同,需要分别存在的 canonical 字段才可声明跟随误差显示 | 不得从 React 标签推导真实反馈差异 |
@@ -354,10 +353,10 @@ safety adaptation, not attributed to AXIS.
| DRO coordinate/display/units | local projection | Maps AXIS `coord_type`, `display_type` and `metric` variables; values remain Worker snapshot projections. | | DRO coordinate/display/units | local projection | Maps AXIS `coord_type`, `display_type` and `metric` variables; values remain Worker snapshot projections. |
| Preview layers | local projection | `SOURCE_ADAPTER` only where the source variable exists: path=`show_program`, live=`show_live_plot`, limits=`show_machine_limits`, velocity=`show_machine_speed`, offsets=`show_offsets`, extents=`show_extents`. AXIS also has rapids, alpha, tool and DTG controls that the Web does not expose. Toggling visibility never deletes layer data. | | Preview layers | local projection | `SOURCE_ADAPTER` only where the source variable exists: path=`show_program`, live=`show_live_plot`, limits=`show_machine_limits`, velocity=`show_machine_speed`, offsets=`show_offsets`, extents=`show_extents`. AXIS also has rapids, alpha, tool and DTG controls that the Web does not expose. Toggling visibility never deletes layer data. |
| 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`; WASM now exposes motion mode for homing and task transitions; the full joint/world jog toggle is still not rendered. |
| 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,joint}` | `SOURCE_ADAPTER`: UI uses the fixed map `x/y/z/a/c -> 0/1/2/3/4`; Worker validates the pair, applies the source `manual_ok` gate, enters MANUAL, switches to FREE/joint for the `c.home(joint)` equivalent, waits for the WASM home-limit result, then publishes the canonical joint mask. An unmapped axis, mismatched joint or invalid numeric joint is rejected before any mode/home mutation. | | Home Axis / Home Joint | `HOME {joint}` | `SOURCE_ADAPTER`: select numeric joint, confirm re-home if applicable, ensure MANUAL, native FREE + `do_home_joint(j)`, then servo-driven `do_homing`/`simple_tp_update`. Letter-axis requests reject for BOTH. |
| Home All / Re-home All | `HOME {all:true}` | `SOURCE_ADAPTER`: `manual_ok`, ensure MANUAL, if `all_homed()` ask the native re-home confirmation, then switch to FREE/joint, call the WASM equivalent of `c.home(-1)`, wait, and publish all five homed bits. The fixed Web axis adapter restores its prior world-axis presentation mode after the completed transaction; the source mode used for homing is still recorded. 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, re-home confirmation, native FREE + `do_home_joint(-1)`. ACK means accepted; only native homing/homed/home_state establish completion. HOME_SEQUENCE comes from INI. |
| 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`. |
| Override limits | disabled | `SOURCE_BLOCKED`: widget enable requires `on_any_limit`; handler separately requires interpreter IDLE. If already overridden it ensures AUTO; otherwise it ensures MANUAL and calls `c.override_limits()`. The selected WASM ABI cannot represent this canonical path. | | Override limits | disabled | `SOURCE_BLOCKED`: widget enable requires `on_any_limit`; handler separately requires interpreter IDLE. If already overridden it ensures AUTO; otherwise it ensures MANUAL and calls `c.override_limits()`. The selected WASM ABI cannot represent this canonical path. |
@@ -404,15 +403,16 @@ history rows intentionally share the family ID `mdi-history-select`.
| `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 replacement path as Open; retains Live Plot, 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`, MANUAL, FREE/joint home, wait, canonical snapshot | Repeat asks source confirmation; Web Re-home label is non-authoritative presentation only; response records the mode phase and five-joint homed mask | | `manual-home-all` | `home_all_joints` | `HOME {all:true}`; ON+IDLE, no concurrent homing/jog | Native homemod selects the configured sequences. No artificial coordinated return-to-zero motion. |
| `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 under `manual_ok` | Acknowledged MANUAL/MDI task mode. The Web does not expose a separate Joint/World toggle; Home transactions use an internal FREE/joint phase and restore the selected fixed XYZAC world-axis presentation mode after completion. | | `tab-manual`, `tab-mdi` | `ensure_manual` / `ensure_mdi` | `SET_MODE`; manual_ok and motion exclusion | Task mode acknowledgement and WASM motion mode are monitored separately; no local tab is mode authority. |
| `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; source manualgroup ON+IDLE | 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 world axis to Jog/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` | UI ON+IDLE; `HOME {axis,joint}` validates the fixed XYZAC mapping, then follows the source MANUAL -> FREE/joint -> home transaction | Selected axis is homed through its mapped numeric joint; already-homed joint asks for explicit re-home confirmation; invalid mapping is rejected without mode/home mutation | | `manual-home-joint-select` | Native numeric joint Homing selection | Local integer 0..4; same homing gate | Selects only the target joint; no motion command. |
| `manual-home-axis` | `home_joint` / numeric Homing menu | `HOME {joint}`; ON+IDLE and no concurrent motion | Label Home Joint; numeric target only. Native state and motor offset determine the result. |
| `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 |
@@ -449,10 +449,10 @@ React must derive its button gates from that state on the next snapshot.
| `ESTOP(active=false)` | Source toggle requires current status ESTOP | IO E-stop clears; observed source task state becomes `ESTOP_RESET`; Web may display `STOPPED` only as an adapter label | Power is enabled; motion/manual/MDI remain disabled until ON | | `ESTOP(active=false)` | Source toggle requires current status ESTOP | IO E-stop clears; observed source task state becomes `ESTOP_RESET`; Web may display `STOPPED` only as an adapter label | Power is enabled; motion/manual/MDI remain disabled until ON |
| `POWER(active=true)` | Source ON edge is valid only from `ESTOP_RESET`; it does not require interpreter IDLE | Trajectory enables and observed task state becomes ON | Manual group requires ON+IDLE; program Run still meets UI/taskfile adapter and task-layer homing rules | | `POWER(active=true)` | Source ON edge is valid only from `ESTOP_RESET`; it does not require interpreter IDLE | Trajectory enables and observed task state becomes ON | Manual group requires ON+IDLE; program Run still meets UI/taskfile adapter and task-layer homing rules |
| `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; `go_home()` selects FREE/joint before `c.home()` | Home authority updates the canonical five-joint `homed[]`; Web axis selection is validated and mapped to one numeric joint for this fixed one-to-one XYZAC case | AUTO's task layer may run only when all required joints are homed unless `NO_FORCE_HOMING=1`; an invalid axis/joint pair never changes mode or home state | | `HOME(all/joint)` | ON+IDLE, no concurrent homing or jog; ensure MANUAL | FREE mode, original homing sequence and joint state machines; `homing`, `homed`, `home_state`, motor offset and position from WASM | Home is not AUTO execution. INTERP_IDLE does not mean homing is inactive. |
| `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 | KINEMATICS_BOTH remains unchanged; 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 acknowledged; explicit joint/teleop switching remains the stated boundary | 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; motion mode selects TELEOP when homed, FREE otherwise | 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 |
@@ -628,7 +628,7 @@ Run 仍需用户单独操作,不能因加载文件而自动上电、回零或
| Open、Reload、案例和打包程序 | 四条入口共用 `gates.load`,包含 PAUSED 与活动 TP 队列;Reload 还要求已有 taskfile | 文件读取完成后再次检查门控,再提交一次 `PROGRAM_LOAD` | 用户取消文件选择不发命令;门控已变化则不替换编辑器文本、路径或预览;解析失败属于已选新文件的源码错误 | | 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 顺序处理 | | 点动和主轴调速按住按钮 | 主指针按下或聚焦按钮的 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 不得删除轨迹;旋转、平移和缩放须验证实际画布变化 | | 坐标轴、步长、DRO、视图、图层、选项卡 | 使用表内本地显示状态;切换 Manual/MDI 另走 `SET_MODE` | 更新选择或投影,不能变更 canonical machine state | 每个选项均验证显示变化;隐藏 Live Plot 不得删除轨迹;旋转、平移和缩放须验证实际画布变化 |
| 系统对刀、重复回零 | 源码门控通过后显示参数/确认对话框 | 确认后只发送一次 `TOUCH_OFF` 或 `HOME` | 取消不发运动/对刀命令;Home All 的原生 ensure MANUAL 在确认之前,Web 确认前模式处理属于适配差异;空白/非有限数字/非法 P 或 G 系统标识不提交 | | 系统对刀、重复回零 | 源码门控通过后显示参数/确认对话框 | 确认后只发送一次 `TOUCH_OFF` 或 `HOME` | 取消不发运动/对刀命令;Home All 在确认前 ensure MANUAL,单关节重复回零在确认后 ensure MANUAL;空白/非有限数字/非法 P 或 G 系统标识不提交 |
| About 和菜单 | 本地打开、关闭、Escape;About 打开时焦点进入,关闭后返回入口 | 显示/隐藏相应内容 | 弹窗打开期间不触发普通机器快捷键;F1 急停仍可用;关闭动作不触发 Stop | | About 和菜单 | 本地打开、关闭、Escape;About 打开时焦点进入,关闭后返回入口 | 显示/隐藏相应内容 | 弹窗打开期间不触发普通机器快捷键;F1 急停仍可用;关闭动作不触发 Stop |
| ABI/桌面边界控件 | 原生 disabled 属性与明确原因 | 无成功路径 | `SOURCE_BLOCKED` 不得出现机器命令或本地假开关;全部在浏览器清单中检查 | | ABI/桌面边界控件 | 原生 disabled 属性与明确原因 | 无成功路径 | `SOURCE_BLOCKED` 不得出现机器命令或本地假开关;全部在浏览器清单中检查 |
@@ -792,38 +792,83 @@ block whose target already equals the current canonical pose is a zero-motion
block: it advances the interpreter line but must not enqueue an invalid block: it advances the interpreter line but must not enqueue an invalid
zero-length TP segment. zero-length TP segment.
### Homing must execute through the WASM trajectory ### Native Homing and State Monitoring (2026-09-09)
`sim_kinematics_home_limit_home()` and This section supersedes the former axis-to-joint alias and coordinated-TP
`sim_kinematics_home_limit_home_joint()` are home-state authorities. They return-to-zero implementation. The reference is the pinned native source,
calculate and commit the canonical homed mask, joint values and forward pose; including the selected INI, not the previous browser tests.
they are not a substitute for the LinuxCNC `c.home()` motion command. The Web
must therefore use a two-phase transaction whenever the current pose differs | Requested status | Correct native field / source | Web snapshot |
from the home target: | --- | --- | --- |
| Task mode | `emcStatus->task.mode` | `source_status.task.mode`: MANUAL/MDI/AUTO |
| Machine state | `emcStatus->task.state` | `source_status.task.state`: STATE_ESTOP/STATE_ESTOP_RESET/STATE_ON |
| Interpreter state | `emcStatus->task.interpState` | `source_status.task.interpState`: INTERP_IDLE/READING/WAITING/PAUSED |
| Motion mode | `emcStatus->motion.traj.mode` | `source_status.traj.mode`: FREE/COORD/TELEOP |
| Motion type | `emcStatus->motion.traj.motion_type` | `source_status.traj.motion_type`: motion classification, not mode |
| Joint homing/homed | `get_homing(j)` / `get_homed(j)`, reflected in joint status | `source_status.joint[j].homing/homed` |
| Joint home state | `homing.c::H[j].home_state`, HAL `joint.N.home-state` | `source_status.joint[j].home_state/home_state_name` |
The native NML joint status does not directly expose a `home_state` member.
WASM reads the original homemod state that drives the HAL pin. Task status
remains the existing WASM task/interpreter adapter; this is not a claim that
the entire NML task process has been ported. Motion mode is stored in WASM.
The visible monitor retains only Task mode, Task state, Interpreter, Motion
mode, Motion type and Homing. Per-joint status, positions, motor offsets and
bounded servo transition history remain in the Worker snapshot for logic and
diagnostics, but their table and history list are not rendered on the panel.
The detailed status strip is the single visible machine-state summary. The
duplicate footer and header operation badge are removed. Connection status
remains separately labelled. The case strip shows the current program basename
with its full path in a tooltip, and renders the command result once. Existing
machine/operation values remain on the main element as data attributes for
browser diagnostics; no control command or gate changes with this layout.
Home Joint, the numeric joint selector and Home All are rendered in the Manual
tab's action area; the standalone Machine sequence heading is not rendered.
```text ```text
HOME click HOME(all/joint)
-> validate ON+IDLE/manual_ok and axis-to-joint mapping -> ON+IDLE and no active motion; ensure MANUAL
-> ensure MANUAL -> select FREE/joint homing phase -> native FREE, do_home_joint(-1 or numeric joint)
-> WASM kinematics computes target pose without committing home state -> ACK: accepted; does not mean homed
-> enqueue one LinuxCNC TP homing move to that target -> servo cycle: original do_homing() then simple_tp_update()
-> return HOME {homing.phase:"moving"}; keep current pose and homed state -> forward kinematics of resulting joint coordinates
-> browser scheduler sends SIM_STEP task/servo ticks -> synchronize coordinated planner coordinate origin to native result
-> TP reaches target and queue becomes empty -> publish homing/homed/home_state and motor position
-> commit sim_kinematics_home(_joint) -> native all-homed edge: control.c switches to TELEOP; partial home remains FREE
-> synchronize interpreter/TP canonical position -> native homing inactive: synchronize interpreter
-> restore the prior Web presentation mode
-> publish HOME {homing.phase:"complete", wasm_home_limit, pose}
``` ```
When the current pose already equals the target, the transaction may commit in `HOME_SEARCH_VEL=0` and default `HOME_LATCH_VEL=0` select home-at-current-
the same command because there is no motion segment to execute. A Home request position (`homing.c:805`). `HOME_SET_SWITCH_POSITION` adjusts joint coordinates
must never call `setPosition()` to jump the TP to zero, must never change the and motor offset without moving the motor. The final move targets INI HOME
React pose locally, and must never advance `home_sequence` before the queued through the joint `free_tp` (`homing.c:1248`). In the selected XYZAC INI,
motion has completed. `HOME ALL` targets all five configured joint home values; HOME and HOME_OFFSET are zero; re-home after Jog can therefore reset displayed
`Home Axis` targets only the mapped joint and lets the selected XYZAC joint coordinates with unchanged physical motor position. This is native
kinematics calculate the resulting world pose. A pending home blocks another behavior. It must not be replaced by G0, G53 or a coordinated TP line to zero.
Home until its queue has completed or an ESTOP/Power Off aborts it.
A nonzero HOME relative to HOME_OFFSET executes a real native free_tp final
move. Search/index inputs use homemod inputs; the selected browser fixture
has no physical switches. Unlock hardware is unsupported and configurations
requiring it are rejected, rather than faking an unlocked device.
Home completion never follows coordinated queue depth. Abort, ESTOP and power
off cancel homemod; aborted joints do not become homed. Power off additionally
applies VOLATILE_HOME. During homing, conflicting Run/MDI/Jog/Load/mode/recovery
requests reject, while status sampling and abort remain available. These extra
Web UI exclusions are explicit adapters around the native button formulas.
`visual.pose_epoch` follows native home command sequence. It invalidates stale
display playback without clearing Live Plot. Neither this sequence nor the
displayed zero value is evidence of homing completion.
Validation boundary: the native homing state round-trip preserves joint/motor
positions and offsets. The existing general recovery audit still reports
`endpoint_at_checkpoint_equal=false` for planner/HAL fields; a roughly 7.7 MB
full-history RECOVER envelope also exceeds the existing ccall-array stack
boundary. These are not closed by this homing change and must not be included
in claims of full recovery parity. Native homing checkpoint bytes are stored
sparsely to avoid copying unused joint compensation tables into JSON.
### Touch-off command encoding ### Touch-off command encoding
@@ -902,7 +947,7 @@ caller with its obsolete flag. Clear Live Plot is an explicit user operation.
1. `web/worker/sim-session.mjs` is the command gate and transaction boundary. 1. `web/worker/sim-session.mjs` is the command gate and transaction boundary.
2. `web/worker/wasm-linuxcnc-tp.mjs`, `wasm-program-runtime.mjs`, 2. `web/worker/wasm-linuxcnc-tp.mjs`, `wasm-program-runtime.mjs`,
`wasm-xyzac-io.mjs` and `wasm-xyzac-device-io.mjs` are the only authorities `wasm-native-homing.mjs`, `wasm-xyzac-io.mjs` and `wasm-xyzac-device-io.mjs` are the only authorities
for selected-case motion, interpreter and device state. for selected-case motion, interpreter and device state.
3. `web/react/src/App.tsx` may render a button disabled from a gate snapshot, 3. `web/react/src/App.tsx` may render a button disabled from a gate snapshot,
but a Worker gate is still mandatory because UI state can be stale. but a Worker gate is still mandatory because UI state can be stale.
@@ -930,14 +975,14 @@ caller with its obsolete flag. Clear Live Plot is an explicit user operation.
Focused evidence for this contract is kept in Focused evidence for this contract is kept in
`web/worker/test_xyzac_touch_off_source_flow.mjs`, `web/worker/test_xyzac_touch_off_source_flow.mjs`,
`web/worker/test_xyzac_home_source_flow.mjs`, `web/worker/test_native_homing.mjs`,
`web/worker/test_xyzac_panel_buttons.mjs` and `web/worker/test_xyzac_panel_buttons.mjs` and
`scripts/test_xyzac_panel_buttons_browser.mjs`. The completed/aborted direct `scripts/test_xyzac_panel_buttons_browser.mjs`. The completed/aborted direct
rerun contract is covered by `web/worker/test_xyzac_program_rerun.mjs` and the rerun contract is covered by `web/worker/test_xyzac_program_rerun.mjs`. The
direct visible Jog -> Home Axis/Home All sequence at direct visible Jog -> Home Joint/Home All sequence at
`scripts/test_xyzac_home_reset_browser.mjs` records the in-flight TP pose, `scripts/test_xyzac_home_reset_browser.mjs` records native homing transitions,
queue depth and final zero pose on the local `:5175` panel. joint coordinates and unchanged motor positions for no-search homing on the local `:5175` panel.
published-site execution sequence is covered by The 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 The press/release, focus-loss, MDI history, modal keyboard and desktop/mobile
@@ -948,8 +993,8 @@ OFF/ESTOP overrides and the source mode transitions against real WASM.
`update_state` procedure across 576 combinations and compares 13 widget `update_state` procedure across 576 combinations and compares 13 widget
predicates (7488 assertions), rather than comparing two rewritten formulas. predicates (7488 assertions), rather than comparing two rewritten formulas.
`web/worker/test_axis_source_execution.mjs` verifies repeated Step, queued `web/worker/test_axis_source_execution.mjs` verifies repeated Step, queued
motion Step, WAITING/MDI Pause-Resume, source OFF state, fixed XYZAC motion Step, WAITING/MDI Pause-Resume, source OFF state, KINEMATICS_BOTH
axis-to-joint Home Axis mapping and spindle override 0-to-100 restoration using world-axis rejection and spindle override 0-to-100 restoration using
the actual WASM runtime. 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

After

Width:  |  Height:  |  Size: 2.6 MiB

View File

@@ -13,7 +13,7 @@ const [contract, app, panel, preview] = await Promise.all([
const controlIds = [ const controlIds = [
"menu-file", "menu-file-edit-tool-data", "menu-file-quit", "menu-help", "menu-help-about", "about-close", "menu-file", "menu-file-edit-tool-data", "menu-file-quit", "menu-help", "menu-help-about", "about-close",
"toolbar-estop", "toolbar-power", "toolbar-open", "toolbar-load-case", "toolbar-reload", "toolbar-run", "toolbar-step", "toolbar-pause-resume", "toolbar-stop", "toolbar-block-delete", "toolbar-optional-pause", "toolbar-zoom-in", "toolbar-zoom-out", "toolbar-view-z", "toolbar-view-z2", "toolbar-view-x", "toolbar-view-y", "toolbar-view-y2", "toolbar-view-p", "toolbar-rotate", "toolbar-clear-live-plot", "toolbar-estop", "toolbar-power", "toolbar-open", "toolbar-load-case", "toolbar-reload", "toolbar-run", "toolbar-step", "toolbar-pause-resume", "toolbar-stop", "toolbar-block-delete", "toolbar-optional-pause", "toolbar-zoom-in", "toolbar-zoom-out", "toolbar-view-z", "toolbar-view-z2", "toolbar-view-x", "toolbar-view-y", "toolbar-view-y2", "toolbar-view-p", "toolbar-rotate", "toolbar-clear-live-plot",
"workspace-case-select", "workspace-load-3d-chips", "manual-home-all", "workspace-case-select", "workspace-load-3d-chips", "manual-home-all", "manual-home-joint-select",
"switchkins-0", "switchkins-1", "switchkins-2", "switchkins-0", "switchkins-1", "switchkins-2",
"tab-manual", "tab-mdi", "tab-diagnostics", "tab-preview", "tab-dro", "tab-manual", "tab-mdi", "tab-diagnostics", "tab-preview", "tab-dro",
"manual-axis-x", "manual-axis-y", "manual-axis-z", "manual-axis-a", "manual-axis-c", "manual-axis-x", "manual-axis-y", "manual-axis-z", "manual-axis-a", "manual-axis-c",

View File

@@ -1,79 +1,107 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
const modulePath = process.env.PLAYWRIGHT_MODULE; const imported = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href);
assert.ok(modulePath, "PLAYWRIGHT_MODULE is required"); const { chromium } = imported.default ?? imported;
const imported = await import(pathToFileURL(modulePath).href); const url = process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/";
const playwright = imported.default ?? imported; const browser = await chromium.launch({ headless: false, slowMo: 100, executablePath: "/usr/bin/google-chrome", args: ["--disable-gpu"] });
const browser = await playwright.chromium.launch({ const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } });
headless: false, const errors = [];
executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", let confirmHome = true;
args: ["--disable-gpu", "--ignore-certificate-errors", "--window-size=1440,1000"], page.on("pageerror", (e) => errors.push(e.message));
}); page.on("dialog", async (d) => { await page.waitForTimeout(600); if (d.type() === "confirm" && confirmHome) await d.accept(); else await d.dismiss(); });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); await page.addInitScript(() => {
const main = page.locator("main.axis-app"); window.homeResults = []; window.homeRequests = [];
page.on("dialog", async (dialog) => { if (dialog.type() === "confirm") await dialog.accept(); else await dialog.dismiss(); }); const Original = window.Worker;
const state = () => main.evaluate((element) => { window.Worker = class extends Original {
const preview = element.querySelector("[data-xyzac-preview]"); constructor(...args) {
return { super(...args);
loading: element.dataset.axisProgramLoading, this.addEventListener("message", ({ data }) => {
homed: element.dataset.axisHomed, const p = data.payload;
pose: preview?.dataset.xyzacLivePose, window.homeResults.push({ seq: data.seq, kind: data.kind, command: p?.command, home: p?.homing,
hud: element.querySelector(".axis-preview-hud")?.textContent ?? "", status: p?.source_status ?? p?.panel_snapshot?.source_status,
queue: element.dataset.axisTpQueueDepth, pose: p?.panel_snapshot?.visual?.pose, epoch: p?.panel_snapshot?.visual?.live_path_epoch });
command: element.querySelector(".command-status")?.textContent ?? "", if (window.homeResults.length > 500) window.homeResults.shift();
error: element.querySelector(".diagnostic-error")?.textContent ?? null, });
}
postMessage(data, ...args) { window.homeRequests.push({ seq: data.seq, kind: data.kind, payload: data.payload }); super.postMessage(data, ...args); }
}; };
}); });
const wait = async (predicate, label) => { const control = (id) => page.locator(`[data-axis-control="${id}"]`);
const started = Date.now(); let latest; const status = () => page.evaluate(() => window.homeResults.filter((r) => r.status).at(-1).status);
while (Date.now() - started < 45000) { latest = await state(); if (predicate(latest)) return latest; await page.waitForTimeout(100); } const home = async (id) => {
throw new Error(`${label}:${JSON.stringify(latest)}`); const seq = await page.evaluate(() => window.homeRequests.at(-1)?.seq ?? 0);
await control(id).click();
await page.waitForFunction((seq) => window.homeResults.some((r) => r.seq > seq && r.home?.phase === "accepted"), seq);
await page.waitForFunction((seq) => window.homeResults.some((r) => r.seq > seq && r.status?.homing_history?.length > 1 && !r.status.homing_active), seq);
const result = await status();
assert.equal(result.task.mode, "MANUAL"); assert.equal(result.task.interpState, "INTERP_IDLE"); assert.equal(result.traj.mode, result.joint.every((j) => j.homed) ? "TELEOP" : "FREE");
assert.ok(result.homing_history.some((event) => event.joint.some((j) => j.home_state === 21 && j.homing)));
return result;
};
const jog = async (axis) => {
await control(`manual-axis-${axis}`).check();
const button = control("manual-jog-positive");
await button.scrollIntoViewIfNeeded();
const box = await button.boundingBox();
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down(); await page.waitForTimeout(900); await page.mouse.up();
await page.waitForFunction(() => document.querySelector("main").dataset.axisTpQueueDepth === "0");
await page.waitForTimeout(250);
return status();
}; };
const control = (id) => main.locator(`[data-axis-control="${id}"]`);
const click = async (id) => { const button = control(id); await wait(async () => await button.count() > 0 && !await button.isDisabled(), `${id}_enabled`); await button.click(); };
const pose = async () => JSON.parse((await state()).pose ?? "{}");
const screenshot = async (name) => { await page.screenshot({ path: `/tmp/axis-home-reset-${name}.png` }); console.log(name, JSON.stringify(await state())); };
try { try {
await page.goto(process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/", { waitUntil: "domcontentloaded" }); await page.goto(url);
await wait((value) => value.loading === "false", "startup"); await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramLoading === "false");
await click("toolbar-power"); await wait((value) => value.command.includes("POWER acknowledged"), "power"); await control("toolbar-power").click();
await click("manual-home-all"); await wait((value) => value.command.includes("HOME acknowledged") && value.homed === "[true,true,true,true,true]", "home_all"); await home("manual-home-all");
await screenshot("01-home-all-zero"); for (const [joint, axis] of ["x", "y", "z", "a", "c"].entries()) {
await control("manual-axis-x").check(); process.stderr.write(`Visible UI: Jog ${axis.toUpperCase()}, then Home Joint ${joint}\n`);
const jog = control("manual-jog-positive"); const before = await jog(axis);
await jog.dispatchEvent("pointerdown", { pointerId: 41, button: 0 }); assert.ok(before.joint[joint].position > 0);
await wait((value) => value.command.includes("JOG_START acknowledged"), "jog_start"); await control("manual-home-joint-select").selectOption(String(joint));
await page.waitForTimeout(450); const after = await home("manual-home-axis");
await jog.dispatchEvent("pointerup", { pointerId: 41, button: 0 }); for (let j = 0; j < 5; j++) {
await wait((value) => value.command.includes("JOG_STOP acknowledged") && value.queue === "0", "jog_stop"); assert.ok(Math.abs(after.joint[j].motor_position - before.joint[j].motor_position) < 1e-8);
const jogged = await pose(); assert.ok(Math.abs(Number(jogged.x)) > 1e-6, JSON.stringify(jogged)); assert.ok(Math.abs(after.joint[j].position - (j === joint ? 0 : before.joint[j].position)) < 1e-8);
await screenshot("02-jogged-x"); }
await click("manual-home-axis");
const movingAxis = await wait((value) => value.command.includes("HOME acknowledged") && value.queue !== "0", "home_axis_motion");
const movingAxisPose = JSON.parse(movingAxis.pose); assert.ok(Math.abs(Number(movingAxisPose.x)) > 1e-6, JSON.stringify(movingAxisPose));
await screenshot("03-home-axis-moving");
const homedAxis = await wait((value) => value.command.includes("HOME acknowledged") && value.queue === "0", "home_axis");
const homeAxisPose = JSON.parse(homedAxis.pose); assert.ok(Math.abs(Number(homeAxisPose.x)) < 1e-8, JSON.stringify(homeAxisPose));
await screenshot("04-home-axis-zero");
for (const [axis, pointerId] of [["y", 42], ["z", 43], ["a", 44], ["c", 45]]) {
await control(`manual-axis-${axis}`).check();
const button = control("manual-jog-negative");
await button.dispatchEvent("pointerdown", { pointerId, button: 0 });
await wait((value) => value.command.includes("JOG_START acknowledged"), `${axis}_jog_start`);
await page.waitForTimeout(300);
await button.dispatchEvent("pointerup", { pointerId, button: 0 });
await wait((value) => value.command.includes("JOG_STOP acknowledged") && value.queue === "0", `${axis}_jog_stop`);
} }
await screenshot("05-multi-axis-jogged"); const before = await jog("x");
await click("manual-home-all"); confirmHome = false;
const movingAll = await wait((value) => value.command.includes("HOME acknowledged") && value.queue !== "0", "home_all_motion"); const count = await page.evaluate(() => window.homeRequests.filter((r) => r.kind === "HOME").length);
const movingAllPose = JSON.parse(movingAll.pose); assert.ok(Object.entries(movingAllPose).some(([axis, value]) => ["x", "y", "z", "a", "c"].includes(axis) && Math.abs(Number(value)) > 1e-6), JSON.stringify(movingAllPose)); await control("manual-home-all").click(); await page.waitForTimeout(800);
await screenshot("06-home-all-moving"); assert.equal(await page.evaluate(() => window.homeRequests.filter((r) => r.kind === "HOME").length), count);
const final = await wait((value) => value.command.includes("HOME acknowledged") && value.queue === "0", "home_all_again"); confirmHome = true;
const finalPose = JSON.parse(final.pose); const after = await home("manual-home-all");
for (const axis of ["x", "y", "z", "a", "c"]) assert.ok(Math.abs(Number(finalPose[axis])) < 1e-8, `${axis}:${JSON.stringify(finalPose)}`); assert.deepEqual(after.joint.map((j) => j.motor_position), before.joint.map((j) => j.motor_position));
await screenshot("07-home-all-zero"); assert.ok(after.joint.every((j) => j.homed && Math.abs(j.position) < 1e-8));
console.log(JSON.stringify({ schema: "xyzac-home-reset-browser.v1", status: "verified", url: process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/", jogged, home_axis: homeAxisPose, home_all: finalPose, screenshots: ["/tmp/axis-home-reset-01-home-all-zero.png", "/tmp/axis-home-reset-02-jogged-x.png", "/tmp/axis-home-reset-03-home-axis-moving.png", "/tmp/axis-home-reset-04-home-axis-zero.png", "/tmp/axis-home-reset-05-multi-axis-jogged.png", "/tmp/axis-home-reset-06-home-all-moving.png", "/tmp/axis-home-reset-07-home-all-zero.png"] })); await control("tab-manual").click();
await page.waitForFunction(() => window.homeResults.filter((r) => r.status).at(-1)?.status.traj.mode === "TELEOP");
await page.locator('[aria-label="LinuxCNC status"]').scrollIntoViewIfNeeded();
await page.screenshot({ path: "/tmp/axis-native-home-desktop.png", fullPage: true });
const pixelCheck = async () => page.locator("canvas[data-engine]").evaluate((canvas) => {
const gl = canvas.getContext("webgl2") ?? canvas.getContext("webgl");
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(); for (let i=0;i<data.length;i+=16) colors.add(`${data[i]},${data[i+1]},${data[i+2]}`);
return colors.size;
});
assert.ok(await pixelCheck() > 5);
await control("toolbar-run").click();
await page.waitForFunction(() => window.homeResults.filter((r) => r.status).at(-1)?.status.task.mode === "AUTO");
assert.equal((await status()).traj.mode, "COORD");
await control("toolbar-pause-resume").click();
await page.waitForFunction(() => window.homeResults.filter((r) => r.status).at(-1)?.status.task.interpState === "INTERP_PAUSED");
await control("tab-manual").click(); assert.equal((await status()).task.mode, "AUTO");
assert.equal(await control("manual-home-all").isDisabled(), true);
await control("toolbar-stop").click();
await page.waitForFunction(() => document.querySelector("main").dataset.axisTpQueueDepth === "0");
await page.setViewportSize({ width: 390, height: 844 });
await home("manual-home-all");
await page.locator('[aria-label="LinuxCNC status"]').scrollIntoViewIfNeeded();
await page.screenshot({ path: "/tmp/axis-native-home-mobile.png", fullPage: true });
assert.ok(await pixelCheck() > 5);
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
assert.deepEqual(errors, []);
console.log(JSON.stringify({ status: "verified", url, checks: ["visible-five-joint-home", "motor-position-preserved", "native-state-history", "cancel", "auto-paused-mode", "desktop-mobile-canvas"] }));
} finally { await browser.close(); } } finally { await browser.close(); }

View File

@@ -22,8 +22,8 @@ const state = () => main.evaluate((element) => {
loading: element.dataset.axisProgramLoading, loading: element.dataset.axisProgramLoading,
path: element.dataset.axisProgramPath, path: element.dataset.axisProgramPath,
program: element.dataset.axisProgramStatus, program: element.dataset.axisProgramStatus,
operation: element.querySelector(".operation-state")?.textContent?.trim(), operation: `Operation ${element.dataset.axisStateOperation}`,
machine: element.querySelector(".machine-state")?.textContent?.trim(), machine: `Machine ${element.dataset.axisStateMachine}`,
homed: element.dataset.axisHomed, homed: element.dataset.axisHomed,
line: element.dataset.axisCurrentLine, line: element.dataset.axisCurrentLine,
queue: element.dataset.axisTpQueueDepth, queue: element.dataset.axisTpQueueDepth,

View File

@@ -11,7 +11,7 @@ const errors = [];
page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`));
page.on("console", (message) => { if (message.type() === "error") errors.push(`console:${message.text()}`); }); page.on("console", (message) => { if (message.type() === "error") errors.push(`console:${message.text()}`); });
const main = page.locator("main.axis-app"); const main = page.locator("main.axis-app");
const state = () => main.evaluate((element) => ({ source: element.dataset.axisStateSource, machine: element.querySelector(".machine-state")?.textContent?.trim(), program: element.dataset.axisProgramStatus, line: element.dataset.axisCurrentLine, path: element.querySelector(".xyzac-tool-preview")?.dataset.xyzacToolpathCount, live: element.querySelector(".xyzac-tool-preview")?.dataset.xyzacLiveTraceCount, loading: element.querySelector(".case-status")?.getAttribute("aria-busy") === "true", command: element.querySelector(".command-status")?.textContent, error: element.querySelector(".diagnostic-error")?.textContent ?? null })); const state = () => main.evaluate((element) => ({ source: element.dataset.axisStateSource, machine: `Machine ${element.dataset.axisStateMachine}`, program: element.dataset.axisProgramStatus, line: element.dataset.axisCurrentLine, path: element.querySelector(".xyzac-tool-preview")?.dataset.xyzacToolpathCount, live: element.querySelector(".xyzac-tool-preview")?.dataset.xyzacLiveTraceCount, loading: element.querySelector(".case-status")?.getAttribute("aria-busy") === "true", command: element.querySelector(".command-status")?.textContent, error: element.querySelector(".diagnostic-error")?.textContent ?? null }));
const wait = async (predicate, label, timeout = 45000) => { const started = Date.now(); let last; while (Date.now() - started < timeout) { last = await state(); if (predicate(last)) return last; await page.waitForTimeout(100); } throw new Error(`${label}:${JSON.stringify(last)}`); }; const wait = async (predicate, label, timeout = 45000) => { const started = Date.now(); let last; while (Date.now() - started < timeout) { last = await state(); if (predicate(last)) return last; await page.waitForTimeout(100); } throw new Error(`${label}:${JSON.stringify(last)}`); };
const click = async (locator, label) => { for (let index = 0; index < 300; index += 1) { if (await locator.count() && !await locator.first().isDisabled()) { await locator.first().click(); return; } await page.waitForTimeout(100); } throw new Error(`${label}:disabled_or_missing`); }; const click = async (locator, label) => { for (let index = 0; index < 300; index += 1) { if (await locator.count() && !await locator.first().isDisabled()) { await locator.first().click(); return; } await page.waitForTimeout(100); } throw new Error(`${label}:disabled_or_missing`); };
const loadFile = async (name, source) => { const input = main.locator('input[type="file"][aria-label="Open G-code file"]'); await input.setInputFiles({ name, mimeType: "text/plain", buffer: Buffer.from(source) }); return wait((value) => value.program === "loaded" && !value.loading && value.error === null, `${name}_loaded`); }; const loadFile = async (name, source) => { const input = main.locator('input[type="file"][aria-label="Open G-code file"]'); await input.setInputFiles({ name, mimeType: "text/plain", buffer: Buffer.from(source) }); return wait((value) => value.program === "loaded" && !value.loading && value.error === null, `${name}_loaded`); };

View File

@@ -22,8 +22,8 @@ page.on("dialog", async (dialog) => { dialogs.push({ type: dialog.type(), messag
const main = page.locator("main.axis-app"); const main = page.locator("main.axis-app");
const state = () => main.evaluate((element) => ({ const state = () => main.evaluate((element) => ({
source: element.dataset.axisStateSource, source: element.dataset.axisStateSource,
machine: element.querySelector(".machine-state")?.textContent?.trim(), machine: `Machine ${element.dataset.axisStateMachine}`,
operation: element.querySelector(".operation-state")?.textContent?.trim(), operation: `Operation ${element.dataset.axisStateOperation}`,
program: element.dataset.axisProgramStatus, program: element.dataset.axisProgramStatus,
line: element.dataset.axisCurrentLine, line: element.dataset.axisCurrentLine,
queue: element.dataset.axisTpQueueDepth, queue: element.dataset.axisTpQueueDepth,

View File

@@ -9,7 +9,7 @@ if (!modulePath) {
const imported = await import(pathToFileURL(modulePath).href); const imported = await import(pathToFileURL(modulePath).href);
const playwright = imported.default ?? imported; const playwright = imported.default ?? imported;
const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--ignore-certificate-errors", "--disable-gpu"] }); const browser = await playwright.chromium.launch({ headless: process.env.HEADED !== "1", executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--ignore-certificate-errors", "--disable-gpu"] });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true }); const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true });
const failures = []; const failures = [];
const browserErrors = []; const browserErrors = [];
@@ -29,8 +29,8 @@ const state = () => main.evaluate((element) => ({
source: element.dataset.axisStateSource, source: element.dataset.axisStateSource,
motion: element.dataset.axisMotionAuthority, motion: element.dataset.axisMotionAuthority,
task: element.dataset.axisTaskAuthority, task: element.dataset.axisTaskAuthority,
machine: element.querySelector(".machine-state")?.textContent?.trim(), machine: `Machine ${element.dataset.axisStateMachine}`,
operation: element.querySelector(".operation-state")?.textContent?.trim(), operation: `Operation ${element.dataset.axisStateOperation}`,
case: element.querySelector(".case-status")?.textContent?.trim(), case: element.querySelector(".case-status")?.textContent?.trim(),
mode: element.dataset.axisTaskMode, mode: element.dataset.axisTaskMode,
program: element.dataset.axisProgramStatus, program: element.dataset.axisProgramStatus,
@@ -115,7 +115,7 @@ try {
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 () => {
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 wait((s) => s.error === null && s.command?.includes("HOME acknowledged"), "home_axis_ack"); await wait((s) => s.error === null && s.command?.includes("HOME complete"), "home_joint_complete");
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");

View File

@@ -26,7 +26,7 @@ const control = (id) => page.locator(`[data-axis-control="${id}"]`);
const state = () => page.locator("main.axis-app").evaluate((main) => { const state = () => page.locator("main.axis-app").evaluate((main) => {
const viewer = main.querySelector("[data-xyzac-preview]"); const viewer = main.querySelector("[data-xyzac-preview]");
return { loading: main.dataset.axisProgramLoading, program: main.dataset.axisProgramStatus, path: main.dataset.axisProgramPath, 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, mode: main.dataset.axisTaskMode, operation: main.dataset.axisStateOperation,
pose: viewer?.dataset.xyzacLivePose, tick: Number(viewer?.dataset.xyzacTick), epoch: viewer?.dataset.xyzacLivePathEpoch, pose: viewer?.dataset.xyzacLivePose, tick: Number(viewer?.dataset.xyzacTick), epoch: viewer?.dataset.xyzacLivePathEpoch,
queue: Number(main.dataset.axisTpQueueDepth), command: main.querySelector(".command-status")?.textContent, queue: Number(main.dataset.axisTpQueueDepth), command: main.querySelector(".command-status")?.textContent,
error: main.querySelector(".diagnostic-error")?.textContent }; error: main.querySelector(".diagnostic-error")?.textContent };

View File

@@ -27,8 +27,8 @@ const state = () => main.evaluate((element) => ({
source: element.dataset.axisStateSource, source: element.dataset.axisStateSource,
authority: element.dataset.axisAuthority, authority: element.dataset.axisAuthority,
motion: element.dataset.axisMotionAuthority, motion: element.dataset.axisMotionAuthority,
machine: element.querySelector(".machine-state")?.textContent?.trim(), machine: `Machine ${element.dataset.axisStateMachine}`,
operation: element.querySelector(".operation-state")?.textContent?.trim(), operation: `Operation ${element.dataset.axisStateOperation}`,
mode: element.dataset.axisTaskMode, mode: element.dataset.axisTaskMode,
program: element.dataset.axisProgramStatus, program: element.dataset.axisProgramStatus,
runEnabled: element.dataset.axisRunEnabled, runEnabled: element.dataset.axisRunEnabled,

View File

@@ -31,8 +31,8 @@ const state = () => main.evaluate((element) => ({
sourceHash: element.dataset.axisProgramSourceHash, sourceHash: element.dataset.axisProgramSourceHash,
program: element.dataset.axisProgramStatus, program: element.dataset.axisProgramStatus,
runEnabled: element.dataset.axisRunEnabled, runEnabled: element.dataset.axisRunEnabled,
machine: element.querySelector(".machine-state")?.textContent?.trim(), machine: `Machine ${element.dataset.axisStateMachine}`,
operation: element.querySelector(".operation-state")?.textContent?.trim(), operation: `Operation ${element.dataset.axisStateOperation}`,
homed: element.dataset.axisHomed, homed: element.dataset.axisHomed,
line: element.dataset.axisCurrentLine, line: element.dataset.axisCurrentLine,
queue: element.dataset.axisTpQueueDepth, queue: element.dataset.axisTpQueueDepth,

View File

@@ -37,6 +37,8 @@ add_library(linuxcnc_tp_source STATIC
${CMAKE_CURRENT_SOURCE_DIR}/../linuxcnc/src/emc/tp/spherical_arc.c ${CMAKE_CURRENT_SOURCE_DIR}/../linuxcnc/src/emc/tp/spherical_arc.c
${CMAKE_CURRENT_SOURCE_DIR}/../linuxcnc/src/emc/nml_intf/emcpose.c ${CMAKE_CURRENT_SOURCE_DIR}/../linuxcnc/src/emc/nml_intf/emcpose.c
src/linuxcnc_tp_compat.c src/linuxcnc_tp_compat.c
src/sim_native_homing.c
${CMAKE_CURRENT_SOURCE_DIR}/../linuxcnc/src/emc/motion/simple_tp.c
) )
target_include_directories(linuxcnc_tp_source PRIVATE target_include_directories(linuxcnc_tp_source PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../linuxcnc/src ${CMAKE_CURRENT_SOURCE_DIR}/../linuxcnc/src
@@ -64,11 +66,16 @@ target_include_directories(sim_core PRIVATE
target_link_libraries(sim_core PUBLIC linuxcnc_tp_source) target_link_libraries(sim_core PUBLIC linuxcnc_tp_source)
if(NOT EMSCRIPTEN) if(NOT EMSCRIPTEN)
add_executable(sim_native_homing_test tests/sim_native_homing_test.c)
target_include_directories(sim_native_homing_test PRIVATE include)
target_compile_options(sim_native_homing_test PRIVATE -UNDEBUG)
target_link_libraries(sim_native_homing_test PRIVATE sim_core)
add_executable(sim_api_test tests/sim_api_test.c) add_executable(sim_api_test tests/sim_api_test.c)
target_link_libraries(sim_api_test PRIVATE sim_core) target_link_libraries(sim_api_test PRIVATE sim_core)
target_include_directories(sim_api_test PRIVATE include) target_include_directories(sim_api_test PRIVATE include)
enable_testing() enable_testing()
add_test(NAME sim_native_homing_test COMMAND sim_native_homing_test)
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)
@@ -289,6 +296,7 @@ if(EMSCRIPTEN)
) )
target_compile_options(sim_core_module PRIVATE -ffp-contract=off -fno-fast-math) target_compile_options(sim_core_module PRIVATE -ffp-contract=off -fno-fast-math)
target_link_libraries(sim_core_module PRIVATE sim_posemath linuxcnc_tp_source) target_link_libraries(sim_core_module PRIVATE sim_posemath linuxcnc_tp_source)
target_link_options(sim_core_module PRIVATE "-Wl,-u,sim_native_home_init")
set_target_properties(sim_core_module PROPERTIES set_target_properties(sim_core_module PROPERTIES
OUTPUT_NAME "linuxcnc-sim-core" OUTPUT_NAME "linuxcnc-sim-core"
SUFFIX ".js" SUFFIX ".js"

View File

@@ -0,0 +1,15 @@
#ifndef SIM_NATIVE_HOMING_H
#define SIM_NATIVE_HOMING_H
#include <stdint.h>
typedef struct native_home native_home;
uint32_t sim_native_home_size(void);
int sim_native_home_init(native_home *, int count, double period);
int sim_native_home_config(native_home *, int joint, const double *parameters);
int sim_native_home_sync(native_home *, const double *joints);
int sim_native_home_mode(native_home *, int mode);
int sim_native_home_start(native_home *, int joint, uint32_t seq);
int sim_native_home_step(native_home *);
int sim_native_home_cancel(native_home *, int volatile_only);
int sim_native_home_input(native_home *, int joint, int home_switch, int index_enable, int negative_limit, int positive_limit);
double sim_native_home_get(native_home *, int joint, int field);
#endif

View File

@@ -0,0 +1,168 @@
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include "../include/sim_native_homing.h"
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#define API EMSCRIPTEN_KEEPALIVE
#else
#define API
#endif
/* Only HAL allocation is adapted. The state machine below is the original
* homemod source, including sequence, switch, index and final-move rules. */
#define hal_malloc native_home_hal_malloc
#define hal_pin_bit_newf native_home_hal_bit
#define hal_pin_s32_newf native_home_hal_s32
#include "../../linuxcnc/src/emc/motion/homing.c"
#include "simple_tp.h"
static union { double align; unsigned char bytes[8192]; } pin_arena;
static size_t pin_used;
void *native_home_hal_malloc(long size) {
size_t start = (pin_used + 7) & ~(size_t)7;
if (size < 0 || start + (size_t)size > sizeof(pin_arena)) return NULL;
pin_used = start + size;
return pin_arena.bytes + start;
}
int native_home_hal_bit(hal_pin_dir_t dir, hal_bit_t **pin, int id, const char *fmt, ...) {
(void)dir; (void)id; (void)fmt;
*pin = native_home_hal_malloc(sizeof(**pin)); return *pin ? 0 : -1;
}
int native_home_hal_s32(hal_pin_dir_t dir, hal_s32_t **pin, int id, const char *fmt, ...) {
(void)dir; (void)id; (void)fmt;
*pin = native_home_hal_malloc(sizeof(**pin)); return *pin ? 0 : -1;
}
struct native_home {
emcmot_joint_t joint[EMCMOT_MAX_JOINTS];
home_local_data local[EMCMOT_MAX_JOINTS];
double period;
int count, sequence, current, active, sync, mode;
uint32_t tick, command_seq;
};
/* homemod is a singleton in LinuxCNC. Swap its private data at the ABI
* boundary to isolate multiple simulation sessions in one WASM module. */
static void load(native_home *h) {
joints = h->joint; all_joints = h->count; extra_joints = 0;
servo_freq = 1 / h->period;
memcpy(H, h->local, sizeof(H));
sequence_state = h->sequence; current_sequence = h->current;
homing_active = h->active; sync_now = h->sync;
}
static void save(native_home *h) {
memcpy(h->local, H, sizeof(H));
h->sequence = sequence_state; h->current = current_sequence;
h->active = homing_active; h->sync = sync_now;
}
API uint32_t sim_native_home_size(void) { return sizeof(native_home); }
API int sim_native_home_init(native_home *h, int count, double period) {
if (!h || count < 1 || count > EMCMOT_MAX_JOINTS || !isfinite(period) || period <= 0) return -1;
memset(h, 0, sizeof(*h)); memset(H, 0, sizeof(H));
pin_used = 0; memset(&pin_arena, 0, sizeof(pin_arena));
h->count = count; h->period = period; h->mode = 1; /* FREE */
sequence_state = HOME_SEQUENCE_IDLE; current_sequence = 0; sync_now = 0;
if (homing_init(0, period, count, 0, h->joint)) return -1;
for (int j = 0; j < count; j++) {
h->joint[j].flag |= EMCMOT_JOINT_ACTIVE_BIT | EMCMOT_JOINT_INPOS_BIT;
}
save(h); return 0;
}
/* p: offset, home, final velocity, search velocity, latch velocity, flags,
* sequence, volatile, min limit, max limit, max velocity, max acceleration. */
API int sim_native_home_config(native_home *h, int j, const double *p) {
if (!h || !p || j < 0 || j >= h->count) return -1;
for (int i = 0; i < 12; i++) if (!isfinite(p[i])) return -1;
if (p[8] > p[9] || p[10] <= 0 || p[11] <= 0 || ((int)p[5] & HOME_UNLOCK_FIRST)) return -1;
load(h);
set_joint_homing_params(j, p[0], p[1], p[2], p[3], p[4], p[5], p[6], p[7] != 0);
joints[j].min_pos_limit = p[8]; joints[j].max_pos_limit = p[9];
joints[j].vel_limit = p[10]; joints[j].acc_limit = p[11];
joints[j].free_tp.max_acc = p[11]; save(h); return 0;
}
API int sim_native_home_sync(native_home *h, const double *position) {
if (!h || !position || h->active || h->sequence != HOME_SEQUENCE_IDLE) return -1;
for (int j = 0; j < h->count; j++) if (!isfinite(position[j])) return -1;
for (int j = 0; j < h->count; j++) {
emcmot_joint_t *p = &h->joint[j];
p->pos_cmd = p->pos_fb = p->free_tp.curr_pos = position[j];
p->motor_pos_fb = position[j] + p->motor_offset;
}
return 0;
}
API int sim_native_home_mode(native_home *h, int mode) {
if (!h || mode < 1 || mode > 3 || h->active || h->sequence != HOME_SEQUENCE_IDLE) return -1;
load(h);
if (mode != 1 && !get_allhomed()) return -2;
h->mode = mode; return 0;
}
API int sim_native_home_start(native_home *h, int j, uint32_t seq) {
if (!h || j < -1 || j >= h->count || h->active || h->sequence != HOME_SEQUENCE_IDLE) return -1;
load(h); h->mode = 1; h->command_seq = seq;
do_home_joint(j); save(h); return 0;
}
API int sim_native_home_step(native_home *h) {
if (!h) return -1;
load(h);
/* control.c switches to TELEOP on the native all-homed edge. */
if (do_homing()) h->mode = 3;
/* control.c: homing runs before free-mode position generation. Ideal
* feedback follows the commanded motor position in this simulation. */
for (int j = 0; j < h->count; j++) {
simple_tp_update(&joints[j].free_tp, h->period);
joints[j].pos_cmd = joints[j].free_tp.curr_pos;
joints[j].pos_fb = joints[j].pos_cmd;
joints[j].motor_pos_fb = joints[j].pos_cmd + joints[j].motor_offset;
if (joints[j].free_tp.active) joints[j].flag &= ~EMCMOT_JOINT_INPOS_BIT;
else joints[j].flag |= EMCMOT_JOINT_INPOS_BIT;
}
h->tick++; save(h); return 0;
}
API int sim_native_home_cancel(native_home *h, int volatile_only) {
if (!h) return -1;
load(h);
for (int j = 0; j < h->count; j++) {
if (H[j].homing || H[j].home_state != HOME_IDLE || (homing_active && H[j].joint_in_sequence)) {
H[j].home_state = HOME_ABORT;
base_1joint_state_machine(j);
}
joints[j].free_tp.enable = 0;
if (volatile_only) {
joints[j].free_tp.curr_vel = 0;
joints[j].free_tp.active = 0;
joints[j].flag |= EMCMOT_JOINT_INPOS_BIT;
}
}
sequence_state = HOME_SEQUENCE_IDLE; homing_active = 0;
if (volatile_only) set_unhomed(-2, EMCMOT_MOTION_DISABLED);
h->mode = 1; save(h); return 0;
}
API int sim_native_home_input(native_home *h, int j, int sw, int index, int neg, int pos) {
if (!h || j < 0 || j >= h->count) return -1;
h->local[j].home_sw = sw != 0; h->local[j].index_enable = index != 0;
h->joint[j].on_neg_limit = neg != 0; h->joint[j].on_pos_limit = pos != 0; return 0;
}
API double sim_native_home_get(native_home *h, int j, int field) {
if (!h) return -1;
if (field == 0) {
for (int i = 0; i < h->count; i++) if (h->joint[i].free_tp.active) return 1;
return h->active || h->sequence != HOME_SEQUENCE_IDLE;
}
if (field == 1) return h->mode;
if (field == 2) return h->tick;
if (field == 3) return h->command_seq;
if (j < 0 || j >= h->count) return -1;
switch (field) {
case 4: return h->local[j].homing;
case 5: return h->local[j].homed;
case 6: return h->local[j].home_state;
case 7: return h->joint[j].pos_fb;
case 8: return h->joint[j].motor_pos_fb;
case 9: return h->joint[j].motor_offset;
case 10: return h->local[j].home_sequence;
case 11: return h->joint[j].free_tp.curr_vel;
case 12: return h->local[j].index_enable;
}
return -1;
}

View File

@@ -0,0 +1,43 @@
#include "sim_native_homing.h"
#include <assert.h>
#include <math.h>
#include <stdlib.h>
int main(void) {
native_home *h = malloc(sim_native_home_size());
native_home *other = malloc(sim_native_home_size());
double p[12] = {0,0,-1,0,0,0,0,0,-200,200,20,300};
double position[5] = {10,20,30,15,45};
assert(sim_native_home_init(h,5,.001) == 0);
assert(sim_native_home_init(other,5,.001) == 0);
for (int j=0;j<5;j++) assert(sim_native_home_config(h,j,p) == 0);
assert(sim_native_home_sync(h,position) == 0);
assert(sim_native_home_start(h,0,70000) == 0);
assert(sim_native_home_get(h,0,7) == 10);
assert(sim_native_home_step(h) == 0);
assert(sim_native_home_get(h,0,6) == 21);
assert(sim_native_home_get(h,0,4) == 1);
assert(sim_native_home_get(h,0,5) == 0);
assert(sim_native_home_get(h,0,7) == 0);
assert(sim_native_home_get(h,0,8) == 10);
assert(sim_native_home_get(h,1,7) == 20);
for(int i=0;i<10;i++) assert(sim_native_home_step(h) == 0);
assert(sim_native_home_get(h,0,5) == 1);
assert(sim_native_home_get(other,0,5) == 0);
p[1]=5;
assert(sim_native_home_config(h,1,p) == 0);
assert(sim_native_home_start(h,1,70001) == 0);
assert(sim_native_home_step(h) == 0);
assert(sim_native_home_get(h,1,7)>0 && sim_native_home_get(h,1,7)<5);
for(int i=0;i<2000 && sim_native_home_get(h,0,0);i++) sim_native_home_step(h);
assert(fabs(sim_native_home_get(h,1,7)-5)<1e-8);
assert(fabs(sim_native_home_get(h,1,8)-25)<1e-8);
assert(sim_native_home_start(h,1,70002) == 0);
sim_native_home_step(h);
assert(sim_native_home_cancel(h,0) == 0);
assert(sim_native_home_get(h,1,5) == 0);
for (int i=0;i<1000 && sim_native_home_get(h,0,0);i++) sim_native_home_step(h);
assert(sim_native_home_get(h,0,0) == 0);
free(h); free(other);
return 0;
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,16 @@
import type { SourceStatus } from "./protocol";
export function MachineStatus({ status, commandStatus }: { status?: SourceStatus; commandStatus: string }) {
return <section className="machine-status" aria-label="LinuxCNC status">
<div className="machine-status-dimensions">
<span>Task mode <output>{status?.task.mode ?? "PENDING"}</output></span>
<span>Task state <output>{status?.task.state ?? "PENDING"}</output></span>
<span>Interpreter <output>{status?.task.interpState ?? "PENDING"}</output></span>
<span>Motion mode <output>{status?.traj.mode ?? "PENDING"}</output></span>
<span>Motion type <output>{status?.traj.motion_type ?? "-"}</output></span>
<span>Homing <output>{status ? status.homing_active ? "ACTIVE" : "IDLE" : "PENDING"}</output></span>
<span>Machine sequence <output>{status ? status.joint.length > 0 && status.joint.every((joint) => joint.homed) ? "HOMED" : "NOT HOMED" : "WASM PENDING"}</output></span>
</div>
<span className="command-status" role="status" aria-live="polite" title={commandStatus}>{commandStatus}</span>
</section>;
}

View File

@@ -18,6 +18,7 @@ export type MessageEnvelope<TPayload = unknown> = {
}; };
export type MotionSnapshot = { export type MotionSnapshot = {
homing?: boolean;
stepping?: boolean; stepping?: boolean;
axes?: number[]; axes?: number[];
joints?: number[]; joints?: number[];
@@ -42,6 +43,18 @@ export type MotionSnapshot = {
max_velocity?: number; max_velocity?: number;
}; };
export type HomeJointStatus = {
joint: number; homing: boolean; homed: boolean; home_state: number; home_state_name: string;
position: number; motor_position: number; motor_offset: number; velocity: number; home_sequence: number;
};
export type SourceStatus = {
task: { mode: string; state: string; interpState: string };
traj: { mode: string; motion_type: number };
homing_active: boolean;
joint: HomeJointStatus[];
homing_history: Array<{ tick: number; joint: HomeJointStatus[] }>;
};
export type TrajectorySnapshot = { export type TrajectorySnapshot = {
queue?: Array<{ id?: number; source_line?: number | null; start?: Record<string, number>; end?: Record<string, number> }>; queue?: Array<{ id?: number; source_line?: number | null; start?: Record<string, number>; end?: Record<string, number> }>;
active?: { id?: number; source_line?: number | null; start?: Record<string, number>; end?: Record<string, number> } | null; active?: { id?: number; source_line?: number | null; start?: Record<string, number>; end?: Record<string, number> } | null;
@@ -49,6 +62,7 @@ export type TrajectorySnapshot = {
}; };
export type SimSnapshotV1 = { export type SimSnapshotV1 = {
source_status?: SourceStatus;
schema?: string; schema?: string;
snapshot_id?: string; snapshot_id?: string;
base_snapshot_id?: string; base_snapshot_id?: string;
@@ -114,6 +128,7 @@ export type XyzacCapabilitySnapshot = {
}; };
export type PanelSnapshotV1 = { export type PanelSnapshotV1 = {
source_status?: SourceStatus;
schema: "axis-panel-snapshot.v1"; schema: "axis-panel-snapshot.v1";
context_id: string; context_id: string;
session_id: string; session_id: string;

View File

@@ -83,6 +83,12 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
} : previous.snapshot.visual; } : previous.snapshot.visual;
const nextSnapshot = { const nextSnapshot = {
...previous.snapshot, ...previous.snapshot,
source_status: (result.source_status as SimSnapshotV1["source_status"]) ?? (previous.snapshot.source_status ? {
...previous.snapshot.source_status,
task: { ...previous.snapshot.source_status.task,
mode: String((result.task as Record<string, unknown>)?.mode ?? previous.snapshot.source_status.task.mode),
interpState: (result.axis_state as AxisStateView)?.source_interp_state ?? previous.snapshot.source_status.task.interpState },
} : undefined),
simulation_tick: message.simulation_tick ?? delta.tick ?? previous.snapshot.simulation_tick, simulation_tick: message.simulation_tick ?? delta.tick ?? previous.snapshot.simulation_tick,
state_hash: String(result.state_hash ?? previous.snapshot.state_hash), state_hash: String(result.state_hash ?? previous.snapshot.state_hash),
task: (result.task as SimSnapshotV1["task"]) ?? previous.snapshot.task, task: (result.task as SimSnapshotV1["task"]) ?? previous.snapshot.task,
@@ -147,6 +153,7 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
const axes = [pose.x, pose.y, pose.z, pose.a, pose.c].map((value) => Number.isFinite(value) ? value : 0); const axes = [pose.x, pose.y, pose.z, pose.a, pose.c].map((value) => Number.isFinite(value) ? value : 0);
const snapshot = { const snapshot = {
schema: panel.schema, schema: panel.schema,
source_status: panel.source_status,
simulation_tick: panel.simulation_tick, simulation_tick: panel.simulation_tick,
state_hash: panel.state_hash, state_hash: panel.state_hash,
task: panel.task as SimSnapshotV1["task"], task: panel.task as SimSnapshotV1["task"],

View File

@@ -73,7 +73,7 @@ button { color: inherit; }
.machine-sequence-heading { display: grid; gap: 2px; min-width: 148px; } .machine-sequence-heading { display: grid; gap: 2px; min-width: 148px; }
.machine-sequence-heading strong { font-size: 12px; } .machine-sequence-heading strong { font-size: 12px; }
.machine-sequence-heading span { color: #555; font: 10px ui-monospace, monospace; } .machine-sequence-heading span { color: #555; font: 10px ui-monospace, monospace; }
.machine-sequence-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; min-width: 0; } .machine-sequence-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; min-width: 0; padding: 8px; border-bottom: 1px solid #aaa; }
.machine-sequence-actions .axis-text-button { min-height: 28px; } .machine-sequence-actions .axis-text-button { min-height: 28px; }
.machine-sequence-actions .axis-text-button:first-child { border-color: #9b4a4a; } .machine-sequence-actions .axis-text-button:first-child { border-color: #9b4a4a; }
@@ -245,14 +245,6 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.visually-hidden-file-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; } .visually-hidden-file-input { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
.axis-layer-controls { display: flex; flex-wrap: wrap; gap: 8px 12px; margin-top: 4px; color: #4d4d4d; font-size: 11px; } .axis-layer-controls { display: flex; flex-wrap: wrap; gap: 8px 12px; margin-top: 4px; color: #4d4d4d; font-size: 11px; }
.axis-layer-controls input { margin: 0; } .axis-layer-controls input { margin: 0; }
.axis-info-bar { display: flex; align-items: stretch; width: 100%; min-width: 0; min-height: 27px; margin-top: auto; border-top: 1px solid #777; background: #c4c4c4; }
.axis-info-bar > span { display: flex; align-items: center; min-width: 0; min-height: 25px; padding: 2px 9px; overflow: hidden; border-right: 1px solid #999; font: 12px ui-monospace, monospace; text-overflow: ellipsis; white-space: nowrap; }
.task-state { min-width: 92px; background: #ececec; font-weight: 700 !important; }
.task-state.running { color: #145d1c; }
.task-state.blocked, .task-state.error { color: #9b111e; background: #f2d6d6; }
.tool-state { min-width: 0; flex: 1; }
.position-state { min-width: 230px; }
.footer-status { min-width: 180px; color: #505050; }
@media (max-width: 900px) { @media (max-width: 900px) {
.xyzac-control-band { grid-template-columns: 1fr; } .xyzac-control-band { grid-template-columns: 1fr; }
@@ -287,6 +279,7 @@ output { font: 12px ui-monospace, monospace; text-align: right; }
.dro-mode-row { font-size: 9px; } .dro-mode-row { font-size: 9px; }
.dro-row output { font-size: 13px; } .dro-row output { font-size: 13px; }
.dro-footer { flex-direction: column; gap: 3px; } .dro-footer { flex-direction: column; gap: 3px; }
.axis-info-bar { flex-wrap: wrap; }
.position-state, .footer-status { min-width: 0; flex: 1; }
} }
.machine-status { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 18px; padding: 6px 8px; border-bottom: 1px solid #999; min-width: 0; }
.machine-status-dimensions { display: flex; flex: 1 1 auto; flex-wrap: wrap; gap: 6px 18px; }
.machine-status-dimensions output { font-weight: bold; margin-left: 5px; }

View File

@@ -41,6 +41,7 @@ export function axisState({ task = {}, program = {}, motion = {}, devices = {},
const machineState = hasIoState ? ioEstop ? "ESTOP" : ioReady ? "ON" : "OFF" const machineState = hasIoState ? ioEstop ? "ESTOP" : ioReady ? "ON" : "OFF"
: taskState === "ESTOP" ? "ESTOP" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(taskState) ? "ON" : "OFF"; : taskState === "ESTOP" ? "ESTOP" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(taskState) ? "ON" : "OFF";
let operationState = programActivity(task, program); let operationState = programActivity(task, program);
if (motion.homing) operationState = "HOME";
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 === "OFF" ? "STATE_ESTOP_RESET" : "STATE_ON"; const sourceTaskState = machineState === "ESTOP" ? "STATE_ESTOP" : machineState === "OFF" ? "STATE_ESTOP_RESET" : "STATE_ON";
const sourceInterpState = operationState === "PAUSED" ? "INTERP_PAUSED" : operationState === "RUNNING" ? (program?.program_status === "completed" || String(task.mode).toUpperCase() === "MDI" ? "INTERP_WAITING" : "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";

View File

@@ -25,6 +25,7 @@ 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";
import { WasmNativeHoming } from "./wasm-native-homing.mjs";
import { WasmXyzacKinematics, XYZAC_MODES } from "./wasm-xyzac-kinematics.mjs"; import { WasmXyzacKinematics, XYZAC_MODES } from "./wasm-xyzac-kinematics.mjs";
import { WasmXyzacIo } from "./wasm-xyzac-io.mjs"; import { WasmXyzacIo } from "./wasm-xyzac-io.mjs";
import { WasmXyzacDeviceIo } from "./wasm-xyzac-device-io.mjs"; import { WasmXyzacDeviceIo } from "./wasm-xyzac-device-io.mjs";
@@ -33,7 +34,6 @@ import { axisGate, xyzacGate, xyzacGateMatrix } from "../app/axis-contract.mjs";
const PROTOCOL_SCHEMA = 1; const PROTOCOL_SCHEMA = 1;
const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1"; const CONTEXT_ID = "linuxcnc-2.10.0-pre0-wasm-m1-py-c1";
const XYZAC_AXIS_TO_JOINT = Object.freeze({ x: 0, y: 1, z: 2, a: 3, c: 4 });
const OPERATION_KINDS = new Set([ const OPERATION_KINDS = new Set([
"SESSION_ATTACH", "LOAD_WORKSPACE", "SIM_STEP", "DESTROY", "CHECKPOINT", "RECOVER", "ABORT", "SESSION_ATTACH", "LOAD_WORKSPACE", "SIM_STEP", "DESTROY", "CHECKPOINT", "RECOVER", "ABORT",
@@ -135,13 +135,8 @@ export class SimSession {
this.xyzacIo = null; this.xyzacIo = null;
this.xyzacDeviceIo = null; this.xyzacDeviceIo = null;
this.xyzacProgram = null; this.xyzacProgram = null;
this.homingHistory = [];
this.xyzacProgramKinematicsMode = XYZAC_MODES.identity; this.xyzacProgramKinematicsMode = XYZAC_MODES.identity;
// The Web panel presents fixed XYZAC world-axis controls. Homing still
// follows AXIS's KINEMATICS_BOTH transaction and uses a numeric joint;
// keep the presentation mode separate so the adapter can restore it
// after the accepted home transaction.
this.xyzacMotionMode = "TELEOP";
this.pendingHome = null;
this.xyzacRapidVelocity = 35; this.xyzacRapidVelocity = 35;
this.xyzacMotionCycleTime = 0.01; this.xyzacMotionCycleTime = 0.01;
this.xyzacTaskCycleTicks = 1; this.xyzacTaskCycleTicks = 1;
@@ -192,7 +187,7 @@ export class SimSession {
// Compact payloads are only valid for successful motion samples. Error // Compact payloads are only valid for successful motion samples. Error
// responses must retain their code/reason instead of being rewritten into // responses must retain their code/reason instead of being rewritten into
// a delta with undefined fields. // a delta with undefined fields.
const motionDelta = kind === "COMMAND_RESULT" && lightweightXyzacStep && input.payload?.response === "motion_delta" && !completedMotion; const motionDelta = kind === "COMMAND_RESULT" && lightweightXyzacStep && input.payload?.response === "motion_delta" && !completedMotion && !statePayload.homing_sample && !this.wasmHomeLimit.active;
// The frontend retains the canonical preview from PROGRAM_LOAD. Repeating // The frontend retains the canonical preview from PROGRAM_LOAD. Repeating
// thousands of immutable segments on unrelated control responses makes a // thousands of immutable segments on unrelated control responses makes a
// loaded program delay abort/power/home commands behind structured-clone // loaded program delay abort/power/home commands behind structured-clone
@@ -263,6 +258,13 @@ export class SimSession {
simulation_tick: tick, simulation_tick: tick,
payload: { payload: {
...responsePayload, ...responsePayload,
...(motionDelta ? { source_status: {
task: { mode: task.mode, state: axisState({ task, program, io: this.xyzacIo.export() }).source_task_state,
interpState: axisState({ task, program, io: this.xyzacIo.export() }).source_interp_state },
traj: { mode: this.wasmHomeLimit.mode, motion_type: currentMotion.state.motion_type },
homing_active: this.wasmHomeLimit.active, joint: this.wasmHomeLimit.read().state.joint,
homing_history: this.homingHistory,
} } : {}),
...(panelSnapshot ? { panel_snapshot: panelSnapshot } : {}), ...(panelSnapshot ? { panel_snapshot: panelSnapshot } : {}),
motion_authority: responsePayload.motion_authority ?? this.motionAuthority, motion_authority: responsePayload.motion_authority ?? this.motionAuthority,
task_authority: responsePayload.task_authority ?? this.task.authority(), task_authority: responsePayload.task_authority ?? this.task.authority(),
@@ -275,6 +277,7 @@ export class SimSession {
wasmMotionProjection(program = null) { wasmMotionProjection(program = null) {
const wasmMotion = this.wasmMotion.read(); const wasmMotion = this.wasmMotion.read();
const position = wasmMotion.status === "verified" ? wasmMotion.state.position : { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 }; const position = wasmMotion.status === "verified" ? wasmMotion.state.position : { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 };
if (this.xyzacProgram && !this.wasmHomeLimit.active) this.wasmHomeLimit.sync(wasmMotion.state.joint_commanded);
const home = this.wasmHomeLimit.read(); const home = this.wasmHomeLimit.read();
const io = this.xyzacIo?.canonical?.(); const io = this.xyzacIo?.canonical?.();
const ioState = io && typeof io === "object" && io.status !== "blocked" ? io : null; const ioState = io && typeof io === "object" && io.status !== "blocked" ? io : null;
@@ -308,7 +311,8 @@ export class SimSession {
limits: { x: Boolean(home.state?.limit_error), y: Boolean(home.state?.limit_error), z: Boolean(home.state?.limit_error), a: Boolean(home.state?.limit_error), c: Boolean(home.state?.limit_error) }, limits: { x: Boolean(home.state?.limit_error), y: Boolean(home.state?.limit_error), z: Boolean(home.state?.limit_error), a: Boolean(home.state?.limit_error), c: Boolean(home.state?.limit_error) },
on_any_limit: Boolean(home.state?.limit_error), on_any_limit: Boolean(home.state?.limit_error),
following_error: 0, following_error: 0,
motion_mode: this.xyzacProgram ? this.xyzacMotionMode : (home.state?.homed ? "TELEOP" : "FREE"), motion_mode: this.xyzacProgram ? this.wasmHomeLimit.mode : (home.state?.homed ? "TELEOP" : "FREE"),
homing: Boolean(home.state?.homing),
jog: null, jog: null,
stepping: this.wasmMotion.isStepping?.() ?? false, stepping: this.wasmMotion.isStepping?.() ?? false,
estop: Boolean(ioState?.estop), estop: Boolean(ioState?.estop),
@@ -401,9 +405,12 @@ export class SimSession {
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, 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, taskfile: Boolean(this.xyzacProgram?.path), queued_mdi_commands: task.queued_mdi_commands ?? 0,
motion_mode: this.xyzacProgram ? this.xyzacMotionMode : (homeLimit.state?.homed ? "TELEOP" : "FREE"), kinematics_type: "BOTH", motion_mode: this.xyzacProgram ? this.wasmHomeLimit.mode : (homeLimit.state?.homed ? "TELEOP" : "FREE"), kinematics_type: "BOTH",
spindle_direction: Number(devices.spindles?.[0]?.direction ?? 0), tool: Number(devices.tool?.current ?? 0), 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" }); on_any_limit: motion.on_any_limit, paused: axis.operation_state === "PAUSED" });
if (homeLimit.state?.homing) {
for (const name of ["run", "step", "load", "reload", "home", "jog", "mdi", "touchOff"]) ui.gates[name] = { action: name, enabled: false, reason: "linuxcnc_homing_active" };
}
const gates = this.xyzacProgram const gates = this.xyzacProgram
? { ? {
load: this.xyzacCommandGate({ kind: "PROGRAM_LOAD", payload: {} }), load: this.xyzacCommandGate({ kind: "PROGRAM_LOAD", payload: {} }),
@@ -483,6 +490,14 @@ export class SimSession {
task, task,
program, program,
motion, motion,
...(this.xyzacProgram ? { source_status: {
task: { mode: task.mode, state: axis.source_task_state, interpState: axis.source_interp_state },
traj: { mode: this.wasmHomeLimit.mode, motion_type: wasmMotion.state.motion_type },
homing_active: Boolean(homeLimit.state.homing),
homing_history: this.homingHistory,
joint: homeLimit.state.joint,
authority: { task: "wasm-task-adapter", homing: "linuxcnc-homing.c", home_state: "joint.N.home-state" },
} } : {}),
coordinates: { machine: [...axisCoordinates], relative: axisCoordinates.map((value, index) => value - displayedOffsets[index]), commanded: [...axisCoordinates], offsets: displayedOffsets, system: coordinateContext?.system ?? 1 }, 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,
@@ -556,6 +571,8 @@ export class SimSession {
const program = this.xyzacProgram.snapshot(); const program = this.xyzacProgram.snapshot();
const io = this.xyzacIo?.canonical?.() ?? {}; const io = this.xyzacIo?.canonical?.() ?? {};
const wasmMotion = this.wasmMotion.read(); const wasmMotion = this.wasmMotion.read();
if (["HOME", "SET_MODE"].includes(input.kind) && Number(wasmMotion.state?.queue_depth) > 0) return { enabled: false, reason: "xyzac_motion_active" };
if (input.kind === "JOG_START" && !this.wasmHomeLimit.read().state.homed) return { enabled: false, reason: "xyzac_not_all_homed" };
if (input.kind === "PROGRAM_LOAD" && wasmMotion.status === "verified" && Number(wasmMotion.state.queue_depth ?? 0) > 0) { if (input.kind === "PROGRAM_LOAD" && wasmMotion.status === "verified" && Number(wasmMotion.state.queue_depth ?? 0) > 0) {
return { action: null, enabled: false, reason: "xyzac_motion_active", taskState: task.state, interpState: "READING", wasm: { queue_depth: wasmMotion.state.queue_depth, active_id: wasmMotion.state.active_id } }; return { action: null, enabled: false, reason: "xyzac_motion_active", taskState: task.state, interpState: "READING", wasm: { queue_depth: wasmMotion.state.queue_depth, active_id: wasmMotion.state.active_id } };
} }
@@ -563,7 +580,7 @@ export class SimSession {
return { action: input.payload?.type === 0 ? "switchkinsIdentity" : input.payload?.type === 1 ? "switchkinsXyzac" : "switchkinsUserk", enabled: false, reason: "xyzac_motion_active", taskState: task.state, interpState: "READING", wasm: { queue_depth: wasmMotion.state.queue_depth, active_id: wasmMotion.state.active_id } }; return { action: input.payload?.type === 0 ? "switchkinsIdentity" : input.payload?.type === 1 ? "switchkinsXyzac" : "switchkinsUserk", enabled: false, reason: "xyzac_motion_active", taskState: task.state, interpState: "READING", wasm: { queue_depth: wasmMotion.state.queue_depth, active_id: wasmMotion.state.active_id } };
} }
const homeLimit = this.wasmHomeLimit.read(); const homeLimit = this.wasmHomeLimit.read();
if (this.pendingHome && ["HOME", "JOG_START", "MDI_EXECUTE", "PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_STEP", "KINEMATICS_SELECT", "SET_MODE", "TOUCH_OFF", "SPINDLE_COMMAND", "SPINDLE_CONSTANT", "SPINDLE_BRAKE", "COOLANT_COMMAND"].includes(input.kind)) { if (this.wasmHomeLimit.active && ["HOME", "JOG_START", "JOG_STOP", "MDI_EXECUTE", "PROGRAM_LOAD", "LOAD_WORKSPACE", "PROGRAM_RUN", "PROGRAM_STEP", "PROGRAM_RESTART", "PROGRAM_SEEK", "KINEMATICS_SELECT", "SET_MODE", "TOUCH_OFF", "RECOVER", "CHECKPOINT"].includes(input.kind)) {
return { action: PANEL_GATE_ACTIONS[input.kind] ?? "home", enabled: false, reason: "xyzac_home_in_progress", taskState: task.state, interpState: "READING" }; return { action: PANEL_GATE_ACTIONS[input.kind] ?? "home", enabled: false, reason: "xyzac_home_in_progress", taskState: task.state, interpState: "READING" };
} }
const estop = Boolean(io.estop); const estop = Boolean(io.estop);
@@ -659,8 +676,10 @@ export class SimSession {
// canonical P-number form (G54=P1, ..., G59.3=P9). // canonical P-number form (G54=P1, ..., G59.3=P9).
const block = `G10 L20 P${systemNumber} ${axis}${formatted}`; const block = `G10 L20 P${systemNumber} ${axis}${formatted}`;
const savedMode = this.task.state.mode; const savedMode = this.task.state.mode;
const savedMotionMode = this.wasmHomeLimit.mode;
try { try {
if (savedMode !== "MDI") this.task.setMode("MDI"); if (savedMode !== "MDI") this.task.setMode("MDI");
this.wasmHomeLimit.setMode("COORD");
this.wasmMotion.setPaused(false); this.wasmMotion.setPaused(false);
const synchronized = this.xyzacProgram.synchronizePosition(this.wasmMotion.read().state.position); const synchronized = this.xyzacProgram.synchronizePosition(this.wasmMotion.read().state.position);
if (synchronized.status !== "verified") return synchronized; if (synchronized.status !== "verified") return synchronized;
@@ -675,6 +694,7 @@ export class SimSession {
return { status: "error", code: String(error?.message ?? error) }; return { status: "error", code: String(error?.message ?? error) };
} finally { } finally {
if (this.task.state.mode !== savedMode) this.task.setMode(savedMode); if (this.task.state.mode !== savedMode) this.task.setMode(savedMode);
this.wasmHomeLimit.setMode(savedMotionMode);
} }
} }
@@ -1359,7 +1379,7 @@ 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)) { 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"); } try { if (this.task.state.mode !== "MANUAL") { this.task.setMode("MANUAL"); this.wasmHomeLimit.setMode(this.wasmHomeLimit.read().state.homed ? "TELEOP" : "FREE"); } }
catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); } 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 ?? {});
@@ -1387,8 +1407,7 @@ export class SimSession {
this.xyzacAppendDeferredOnActive = false; this.xyzacAppendDeferredOnActive = false;
this.wasmMotion.abort(); this.wasmMotion.abort();
this.wasmMotion.setPaused(true); this.wasmMotion.setPaused(true);
this.pendingHome = null; this.wasmHomeLimit.cancel(true);
this.xyzacMotionMode = "TELEOP";
const resetProgram = this.resetXyzacProgramToLoaded(); const resetProgram = this.resetXyzacProgramToLoaded();
if (resetProgram.status !== "verified") return finish(this.error(input, "program_restart", resetProgram.code ?? "program_reload_failed"), "failed"); if (resetProgram.status !== "verified") return finish(this.error(input, "program_restart", resetProgram.code ?? "program_reload_failed"), "failed");
this.task.setProgram("loaded", 0); this.task.setProgram("loaded", 0);
@@ -1460,7 +1479,11 @@ export class SimSession {
} }
const mode = String(input.payload?.mode ?? "").toUpperCase(); const mode = String(input.payload?.mode ?? "").toUpperCase();
if (!["MANUAL", "MDI", "AUTO"].includes(mode)) return finish(this.error(input, "task_mode", "task_mode_invalid"), "failed"); if (!["MANUAL", "MDI", "AUTO"].includes(mode)) return finish(this.error(input, "task_mode", "task_mode_invalid"), "failed");
try { this.task.setMode(mode); } catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); } try {
this.task.setMode(mode);
if (this.xyzacProgram && mode === "MANUAL") this.wasmHomeLimit.setMode(this.wasmHomeLimit.read().state.homed ? "TELEOP" : "FREE");
if (this.xyzacProgram && mode !== "MANUAL" && this.wasmHomeLimit.read().state.homed) this.wasmHomeLimit.setMode("COORD");
} catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, mode, task: this.task.export(), operation_id: input.operation_id ?? null })); return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, mode, task: this.task.export(), operation_id: input.operation_id ?? null }));
} }
case "POWER": { case "POWER": {
@@ -1492,8 +1515,7 @@ export class SimSession {
this.xyzacAppendDeferredOnActive = false; this.xyzacAppendDeferredOnActive = false;
this.wasmMotion.abort(); this.wasmMotion.abort();
this.wasmMotion.setPaused(true); this.wasmMotion.setPaused(true);
this.pendingHome = null; this.wasmHomeLimit.cancel(true);
this.xyzacMotionMode = "TELEOP";
const resetProgram = this.resetXyzacProgramToLoaded(); const resetProgram = this.resetXyzacProgramToLoaded();
if (resetProgram.status !== "verified") return finish(this.error(input, "program_restart", resetProgram.code ?? "program_reload_failed"), "failed"); if (resetProgram.status !== "verified") return finish(this.error(input, "program_restart", resetProgram.code ?? "program_reload_failed"), "failed");
this.task.setProgram("loaded", 0); this.task.setProgram("loaded", 0);
@@ -1612,9 +1634,8 @@ export class SimSession {
? new WasmLinuxcncTp(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: this.xyzacRapidVelocity, accelerationLimit: Number(configuration.kinematics?.max_acceleration ?? 300), limits: configuration.home_limits ?? {} }) ? new WasmLinuxcncTp(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: this.xyzacRapidVelocity, accelerationLimit: Number(configuration.kinematics?.max_acceleration ?? 300), limits: configuration.home_limits ?? {} })
: new WasmMotionQueue(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: 1e9 }); : new WasmMotionQueue(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: 1e9 });
this.xyzacProgram = new WasmProgramRuntime(this.core); this.xyzacProgram = new WasmProgramRuntime(this.core);
this.homingHistory = [];
this.xyzacProgramKinematicsMode = XYZAC_MODES.identity; this.xyzacProgramKinematicsMode = XYZAC_MODES.identity;
this.xyzacMotionMode = "TELEOP";
this.pendingHome = null;
if (typeof this.wasmMotion.setKinematics === "function") this.wasmMotion.setKinematics(this.xyzacKinematics); if (typeof this.wasmMotion.setKinematics === "function") this.wasmMotion.setKinematics(this.xyzacKinematics);
this.xyzacRapidVelocity = Number(configuration.kinematics?.max_linear_velocity ?? 35); this.xyzacRapidVelocity = Number(configuration.kinematics?.max_linear_velocity ?? 35);
const linearLimit = Number(configuration.kinematics?.axis_linear_velocity_limit ?? 20); const linearLimit = Number(configuration.kinematics?.axis_linear_velocity_limit ?? 20);
@@ -1627,7 +1648,7 @@ export class SimSession {
} }
if (configuration?.home_limits && this.motionAuthority === "wasm") { if (configuration?.home_limits && this.motionAuthority === "wasm") {
this.wasmHomeLimit.destroy(); this.wasmHomeLimit.destroy();
this.wasmHomeLimit = new WasmHomeLimit(this.core, { this.wasmHomeLimit = this.xyzacProgram ? new WasmNativeHoming(this.core, configuration, this.xyzacKinematics) : new WasmHomeLimit(this.core, {
jointCount: configuration.home_limits.joint_count ?? 3, jointCount: configuration.home_limits.joint_count ?? 3,
kinematicsType: configuration.home_limits.kinematics_type ?? 1, kinematicsType: configuration.home_limits.kinematics_type ?? 1,
coordinates: configuration.home_limits.coordinates ?? "XYZ", coordinates: configuration.home_limits.coordinates ?? "XYZ",
@@ -1681,6 +1702,7 @@ export class SimSession {
})) : finish(this.coreError(input, loadResult, "sim_load_workspace"), "failed"); })) : finish(this.coreError(input, loadResult, "sim_load_workspace"), "failed");
} }
case "SIM_STEP": { case "SIM_STEP": {
const homingSample = Boolean(this.xyzacProgram && this.wasmHomeLimit.active);
const cycles = input.payload?.cycles; const cycles = input.payload?.cycles;
const limit = validateLimit("max_tick_step", cycles); const limit = validateLimit("max_tick_step", cycles);
if (limit.status !== "accepted") return finish(this.envelope("ERROR", input, { ...limit, operation_id: input.operation_id ?? null, simulation_tick: this.tick, retryable: false }), "failed"); if (limit.status !== "accepted") return finish(this.envelope("ERROR", input, { ...limit, operation_id: input.operation_id ?? null, simulation_tick: this.tick, retryable: false }), "failed");
@@ -1711,6 +1733,7 @@ export class SimSession {
this.scheduler.step(cycles, { this.scheduler.step(cycles, {
task: (nextTick) => { task: (nextTick) => {
if (this.xyzacProgram) { if (this.xyzacProgram) {
if (this.wasmHomeLimit.active) return;
const wasmState = this.xyzacProgram.snapshot(); const wasmState = this.xyzacProgram.snapshot();
if (wasmState.status !== 2) return; if (wasmState.status !== 2) return;
if (this.pendingTaskSync !== 0) { if (this.pendingTaskSync !== 0) {
@@ -1907,7 +1930,24 @@ export class SimSession {
let wasmMotion = this.wasmMotion.read(); let wasmMotion = this.wasmMotion.read();
const programPaused = this.xyzacProgram?.snapshot().status === 3; 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 (this.xyzacProgram && this.wasmHomeLimit.active) {
const home = this.wasmHomeLimit.step();
if (home.status !== "verified") throw new Error(home.code);
const transition = home.state.joint.map((j) => `${j.home_state}:${Number(j.homing)}:${Number(j.homed)}`).join(",");
if (this.homingHistory.at(-1)?.transition !== transition) this.homingHistory = [...this.homingHistory.slice(-63), { tick: home.state.tick, transition, joint: home.state.joint }];
// Coordinate synchronization after native motor-offset /
// free_tp updates, not an artificial move to machine zero.
this.wasmMotion.setActiveKinematicsTag(this.xyzacKinematics.activeMode);
this.wasmMotion.setPosition(home.state.pose);
wasmMotion = this.wasmMotion.read();
if (!home.state.homing) {
const sync = this.xyzacProgram.synchronizePosition(home.state.pose);
if (sync.status !== "verified") throw new Error(sync.code);
}
continue;
}
if (programPaused && !this.wasmMotion.isStepping()) break; if (programPaused && !this.wasmMotion.isStepping()) break;
if (this.wasmMotion.controls.paused && this.wasmMotion.read().state.queue_depth === 0) continue;
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);
} }
@@ -1962,18 +2002,6 @@ export class SimSession {
} }
} }
} }
if (this.pendingHome && wasmMotion.status === "verified" && Number(wasmMotion.state.queue_depth ?? 0) === 0) {
const pendingHome = this.pendingHome;
const home = this.wasmHomeLimit.home(pendingHome.seq, pendingHome.all ? null : pendingHome.joint);
if (home.status !== "verified") throw new Error(home.code ?? "wasm_home_commit_failed");
if (this.xyzacProgram && typeof this.xyzacProgram.synchronizePosition === "function") {
const synchronized = this.xyzacProgram.synchronizePosition(home.state.pose);
if (synchronized.status !== "verified") throw new Error(synchronized.code ?? "home_position_sync_failed");
}
this.xyzacMotionMode = pendingHome.modeBefore;
this.task.setMode("MANUAL");
this.pendingHome = null;
}
if (motionState?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205); if (motionState?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
if (this.motionAuthority === "wasm") { if (this.motionAuthority === "wasm") {
if (wasmMotion.status === "verified") { if (wasmMotion.status === "verified") {
@@ -2051,7 +2079,7 @@ export class SimSession {
? null ? null
: this.timeline.checkpoint(input.seq, this.tick, snapshotState, this.timeline.snapshots.latest()?.snapshot_id ?? null); : this.timeline.checkpoint(input.seq, this.tick, snapshotState, this.timeline.snapshots.latest()?.snapshot_id ?? null);
return finish(this.envelope("COMMAND_RESULT", input, { return finish(this.envelope("COMMAND_RESULT", input, {
status: "verified", cycles, program_steps, execution_trace, status: "verified", cycles, program_steps, execution_trace, homing_sample: homingSample,
...(lightweightXyzac && cycles <= 32 && motion_trace.length ? { motion_trace } : {}), ...(lightweightXyzac && cycles <= 32 && motion_trace.length ? { motion_trace } : {}),
motion_authority: this.motionAuthority, motion_authority: this.motionAuthority,
task: this.task.export(), scheduler: this.scheduler.export(), authoritative: snapshotState.authoritative, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: snapshotState.authoritative,
@@ -2143,7 +2171,7 @@ export class SimSession {
if (this.xyzacProgram) { if (this.xyzacProgram) {
const previousProgramStatus = this.xyzacProgram.snapshot().program_status; const previousProgramStatus = this.xyzacProgram.snapshot().program_status;
const result = this.xyzacProgram.start(); const result = this.xyzacProgram.start();
if (result.status === "verified") { this.wasmMotion.setPaused(false); this.task.setMode("AUTO"); if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); } if (result.status === "verified") { this.wasmMotion.setPaused(false); this.task.setMode("AUTO"); this.wasmHomeLimit.setMode("COORD"); if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); }
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, reopened_taskfile: ["completed", "aborted"].includes(previousProgramStatus), start_line: 0, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed"); return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, reopened_taskfile: ["completed", "aborted"].includes(previousProgramStatus), start_line: 0, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
} }
const result = this.program.start(); const result = this.program.start();
@@ -2165,6 +2193,7 @@ export class SimSession {
}), result.status === "verified" ? "verified" : "failed"); }), result.status === "verified" ? "verified" : "failed");
} }
case "PROGRAM_PAUSE": case "PROGRAM_RESUME": case "PROGRAM_ABORT": { case "PROGRAM_PAUSE": case "PROGRAM_RESUME": case "PROGRAM_ABORT": {
if (this.xyzacProgram && input.kind === "PROGRAM_ABORT" && this.wasmHomeLimit.active) this.wasmHomeLimit.cancel();
const action = input.kind === "PROGRAM_PAUSE" ? "pause" : input.kind === "PROGRAM_RESUME" ? "resume" : "abort"; const action = input.kind === "PROGRAM_PAUSE" ? "pause" : input.kind === "PROGRAM_RESUME" ? "resume" : "abort";
if (this.xyzacProgram) { if (this.xyzacProgram) {
// PAUSE/RESUME are interpreter and TP state transitions. They must // PAUSE/RESUME are interpreter and TP state transitions. They must
@@ -2275,6 +2304,7 @@ export class SimSession {
const current = this.xyzacProgram.snapshot(); const current = this.xyzacProgram.snapshot();
const idle = !["RUNNING", "PAUSED"].includes(this.task.state.state); const idle = !["RUNNING", "PAUSED"].includes(this.task.state.state);
this.task.setMode("AUTO"); this.task.setMode("AUTO");
this.wasmHomeLimit.setMode("COORD");
if (idle) { if (idle) {
const started = this.xyzacProgram.start(); const started = this.xyzacProgram.start();
if (started.status !== "verified") return finish(this.error(input, "program_step", started.code), "failed"); if (started.status !== "verified") return finish(this.error(input, "program_step", started.code), "failed");
@@ -2364,6 +2394,7 @@ export class SimSession {
const block = input.payload?.block ?? input.payload?.command; const block = input.payload?.block ?? input.payload?.command;
if (this.xyzacProgram) { if (this.xyzacProgram) {
this.task.setMode("MDI"); this.task.setMode("MDI");
this.wasmHomeLimit.setMode("COORD");
// AXIS send_mdi_command() leaves the interpreter/TP runnable after // AXIS send_mdi_command() leaves the interpreter/TP runnable after
// a previous manual jog stop. JOG_STOP intentionally pauses the // a previous manual jog stop. JOG_STOP intentionally pauses the
// WASM TP, so clear that transport pause before queuing MDI motion; // WASM TP, so clear that transport pause before queuing MDI motion;
@@ -2416,6 +2447,7 @@ export class SimSession {
const mode = Number(input.payload?.type); const mode = Number(input.payload?.type);
if (![XYZAC_MODES.identity, XYZAC_MODES.xyzac, XYZAC_MODES.userk].includes(mode)) return finish(this.error(input, "kinematics", "xyzac_kinematics_type_invalid"), "failed"); if (![XYZAC_MODES.identity, XYZAC_MODES.xyzac, XYZAC_MODES.userk].includes(mode)) return finish(this.error(input, "kinematics", "xyzac_kinematics_type_invalid"), "failed");
this.task.setMode("MDI"); this.task.setMode("MDI");
this.wasmHomeLimit.setMode("COORD");
const result = this.switchXyzacKinematics(mode); const result = this.switchXyzacKinematics(mode);
const code = mode === XYZAC_MODES.xyzac ? "M428" : mode === XYZAC_MODES.identity ? "M429" : "M430"; const code = mode === XYZAC_MODES.xyzac ? "M428" : mode === XYZAC_MODES.identity ? "M429" : "M430";
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, command: input.kind, remap: { code, hal_pin: "motion.switchkins-type", hal_pin_vars: true, sync: "M66 E0 L0" }, kinematics: result.kinematics ?? this.xyzacKinematics.snapshot(), task: this.task.export(), operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed"); return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, command: input.kind, remap: { code, hal_pin: "motion.switchkins-type", hal_pin_vars: true, sync: "M66 E0 L0" }, kinematics: result.kinematics ?? this.xyzacKinematics.snapshot(), task: this.task.export(), operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
@@ -2430,49 +2462,22 @@ export class SimSession {
// that compatible while making the explicit Web contract // that compatible while making the explicit Web contract
// HOME {all:true}. // HOME {all:true}.
const all = payload.all === true || (payload.all === undefined && payload.axis === undefined && payload.joint === undefined); const all = payload.all === true || (payload.all === undefined && payload.axis === undefined && payload.joint === undefined);
const requestedAxis = all ? null : String(payload.axis ?? "").trim().toLowerCase() || null; if (all && payload.joint !== undefined) return finish(this.error(input, "home_limit", "home_target_ambiguous"), "failed");
let requestedJoint = payload.joint; if (payload.axis !== undefined) return finish(this.error(input, "home_limit", "home_joint_requires_joint_mode"), "failed");
if (requestedAxis !== null) { const requestedJoint = payload.joint;
const mappedJoint = XYZAC_AXIS_TO_JOINT[requestedAxis];
if (mappedJoint === undefined) return finish(this.error(input, "home_limit", "home_axis_invalid"), "failed");
if (requestedJoint !== undefined && requestedJoint !== mappedJoint) return finish(this.error(input, "home_limit", "home_axis_joint_mismatch"), "failed");
requestedJoint = mappedJoint;
}
if (!all && requestedJoint === undefined) return finish(this.error(input, "home_limit", "home_joint_required"), "failed"); if (!all && requestedJoint === undefined) return finish(this.error(input, "home_limit", "home_joint_required"), "failed");
if (!all && (!Number.isInteger(requestedJoint) || requestedJoint < 0 || requestedJoint >= 5)) return finish(this.error(input, "home_limit", "home_joint_invalid"), "failed"); if (!all && (!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 go_home() selects FREE/joint and issues c.home(). // before go_home() selects FREE/joint and issues c.home().
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 modeBefore = this.xyzacMotionMode; try {
const currentMotion = this.wasmMotion.read(); this.wasmHomeLimit.sync(this.wasmMotion.read().state.joint_commanded);
const currentPose = currentMotion.state?.position; const home = this.wasmHomeLimit.home(input.seq, all ? null : requestedJoint);
const currentJoints = Array.isArray(currentMotion.state?.joint_commanded) && currentMotion.state.joint_commanded.length === 5 this.homingHistory = [{ tick: home.state.tick, transition: "command", joint: home.state.joint }];
? [...currentMotion.state.joint_commanded] return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind,
: [...(this.xyzacKinematics?.snapshot()?.joints ?? [currentPose.x, currentPose.y, currentPose.z, currentPose.a ?? 0, currentPose.c ?? 0])]; homing: { phase: "accepted", joint: all ? -1 : requestedJoint, source: "linuxcnc-homing.c" },
const targetHome = this.wasmHomeLimit.previewHome(all ? null : requestedJoint); wasm_home_limit: home, motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
if (targetHome.status !== "verified") return finish(this.error(input, "home_limit", targetHome.code ?? "wasm_home_target_failed"), "failed"); } catch (error) { return finish(this.error(input, "home_limit", error.message), "failed"); }
const targetPose = targetHome.state.pose;
const needsMove = ["x", "y", "z", "a", "b", "c", "u", "v", "w"].some((axis) => Math.abs(Number(currentPose?.[axis] ?? 0) - Number(targetPose?.[axis] ?? 0)) > 1e-9);
this.xyzacMotionMode = "FREE";
if (needsMove) {
const enqueue = this.wasmMotion.enqueue({
start: currentPose,
end: targetPose,
velocity: Math.max(Number(this.xyzacRapidVelocity ?? 35), Number.EPSILON),
acceleration: Math.max(Number(this.xyzacAxisAccelerationLimits?.x ?? 300), Number.EPSILON),
motion_type: 1,
source_line: 0,
kinematics_mode: this.xyzacProgramKinematicsMode,
});
if (enqueue.status !== "verified") { this.xyzacMotionMode = modeBefore; return finish(this.error(input, "home_limit", enqueue.code ?? "home_motion_enqueue_failed"), "failed"); }
this.pendingHome = { seq: input.seq, all, axis: requestedAxis, joint: all ? null : requestedJoint, modeBefore, currentJoints, targetPose };
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, homing: { phase: "moving", target: all ? "all" : "joint", source_axis: requestedAxis, joint: all ? null : requestedJoint, motion_mode_before: modeBefore, motion_mode_during: "FREE", motion_mode_after: "FREE", target_pose: targetPose }, motion: this.wasmMotionProjection(), wasm_home_limit: this.wasmHomeLimit.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
}
const home = this.wasmHomeLimit.home(input.seq, all ? null : requestedJoint);
if (home.status !== "verified") { this.xyzacMotionMode = modeBefore; return finish(this.error(input, "home_limit", home.code), "failed"); }
this.xyzacMotionMode = modeBefore;
this.task.setMode("MANUAL");
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, homing: { phase: "complete", target: all ? "all" : "joint", source_axis: requestedAxis, joint: all ? null : requestedJoint, motion_mode_before: modeBefore, motion_mode_during: "FREE", motion_mode_after: this.xyzacMotionMode }, motion: this.wasmMotionProjection(), wasm_home_limit: home, motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
} }
const result = this.motion.home(input.payload?.axis ?? null); const result = this.motion.home(input.payload?.axis ?? null);
if (result.status === "verified" && this.motionAuthority === "wasm") { if (result.status === "verified" && this.motionAuthority === "wasm") {
@@ -2520,9 +2525,7 @@ export class SimSession {
if (!["x", "y", "z", "a", "c"].includes(axis)) return finish(this.error(input, "motion_axis", "motion_axis_invalid"), "failed"); if (!["x", "y", "z", "a", "c"].includes(axis)) return finish(this.error(input, "motion_axis", "motion_axis_invalid"), "failed");
const direction = input.payload?.direction === -1 ? -1 : input.payload?.direction === 1 ? 1 : null; const direction = input.payload?.direction === -1 ? -1 : input.payload?.direction === 1 ? 1 : null;
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");
// The Web jog buttons are world-axis controls. Mirror AXIS's if (this.xyzacProgram) this.wasmHomeLimit.setMode("TELEOP");
// teleop selection for this adapter before submitting the TP jog.
this.xyzacMotionMode = "TELEOP";
this.wasmMotion.setPaused(false); this.wasmMotion.setPaused(false);
const requestedIncrement = input.payload?.increment; const requestedIncrement = input.payload?.increment;
const continuous = requestedIncrement === null || input.payload?.continuous === true; const continuous = requestedIncrement === null || input.payload?.continuous === true;

View File

@@ -29,9 +29,8 @@ assert.equal(panel().ui_gates.run.enabled, true);
assert.equal(panel().ui_gates.step.enabled, true); assert.equal(panel().ui_gates.step.enabled, true);
assert.equal(send("PROGRAM_RUN").payload.gate_reason, "xyzac_not_all_homed"); assert.equal(send("PROGRAM_RUN").payload.gate_reason, "xyzac_not_all_homed");
ok("HOME", { all: true }); ok("HOME", { all: true });
const homeAxis = send("HOME", { axis: "x", joint: 0 }); ok("SIM_STEP", { cycles: 1 });
assert.equal(homeAxis.payload.status, "verified"); assert.equal(send("HOME", { axis: "x" }).payload.message_key, "home_joint_requires_joint_mode");
assert.deepEqual(homeAxis.payload.homing, { phase: "complete", target: "joint", source_axis: "x", joint: 0, motion_mode_before: "TELEOP", motion_mode_during: "FREE", motion_mode_after: "TELEOP" });
ok("PROGRAM_STEP"); ok("PROGRAM_STEP");
assert.equal(panel().program.current_line, 0, "first native Step starts at line zero and pauses"); assert.equal(panel().program.current_line, 0, "first native Step starts at line zero and pauses");

View File

@@ -0,0 +1,110 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import { createHash } from "node:crypto";
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 path = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const bundle = await loadSimulationCase({ path, 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 binary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
for (const [file, hash] of [["homing.c", "ee73d38f2feb8d5523b9a733cb17d635d3904667aaa219f8a02d7fbffee1ba39"], ["simple_tp.c", "5a0026f4d5bdd9d1bc55290c6ea9c399abd664633b69413c307f7353953d2603"]]) {
assert.equal(createHash("sha256").update(await readFile(new URL(`../../linuxcnc/src/emc/motion/${file}`, import.meta.url))).digest("hex"), hash, `source drift: ${file}`);
}
async function setup(id, finalHome = 0) {
const core = await createModule({ wasmBinary: binary });
const session = new XyzacSession(core, { sessionId: id });
let seq = 0;
const send = (kind, payload = {}) => session.accept({ schema_version: 1, context_id: CONTEXT_ID, session_id: id, seq: ++seq, operation_id: `${id}-${seq}`, kind, payload });
const ok = (kind, payload) => { const r = send(kind, payload); assert.notEqual(r.kind, "ERROR", `${kind}: ${r.payload.message_key}`); return r.payload; };
ok("HELLO"); ok("SESSION_ATTACH");
const configuration = toWorkerConfiguration(bundle);
if (finalHome) configuration.files[path] = configuration.files[path].replace(/HOME =\s+0/g, `HOME = ${finalHome}`);
ok("LOAD_WORKSPACE", { root_path: "/workspace", configuration });
ok("PROGRAM_LOAD", { source: "G21 G90\nG1 X10 F600\nM2\n", path: "native-home.ngc" });
ok("POWER", { active: true });
const snapshot = () => ok("SNAPSHOT_REQUEST").panel_snapshot;
const drainHome = () => {
let ticks = 0;
while (session.wasmHomeLimit.active && ticks++ < 1000) ok("SIM_STEP", { cycles: 1, response: "motion_delta" });
assert.equal(session.wasmHomeLimit.active, false);
return snapshot();
};
const jog = (axis, increment) => {
ok("JOG_START", { axis, direction: 1, increment });
let ticks = 0;
while (session.wasmMotion.read().state.queue_depth > 0 && ticks++ < 5000) ok("SIM_STEP", { cycles: 1, response: "motion_delta" });
assert.equal(session.wasmMotion.read().state.queue_depth, 0);
};
return { session, send, ok, snapshot, drainHome, jog };
}
const s = await setup("native-homing");
assert.equal(s.snapshot().source_status.traj.mode, "FREE");
assert.equal(s.send("HOME", { axis: "x" }).payload.message_key, "home_joint_requires_joint_mode");
for (const joint of [0, 1, 2, 3, 4]) {
const ack = s.ok("HOME", { joint });
assert.equal(ack.homing.phase, "accepted");
assert.equal(ack.panel_snapshot.source_status.homing_active, true);
assert.equal(ack.panel_snapshot.source_status.task.interpState, "INTERP_IDLE");
assert.equal(ack.panel_snapshot.axis_state.operation_state, "HOME");
assert.equal(ack.panel_snapshot.motion.homed_axes[joint], false);
const done = s.drainHome();
assert.equal(done.motion.homed_axes[joint], true);
assert.equal(done.source_status.traj.mode, joint === 4 ? "TELEOP" : "FREE");
assert.ok(done.source_status.homing_history.some((e) => e.joint[joint].home_state === 21 && e.joint[joint].homing));
}
for (const [joint, axis] of ["x", "y", "z", "a", "c"].entries()) {
s.jog(axis, 3);
const before = s.snapshot();
s.ok("HOME", { joint });
const after = s.drainHome();
after.source_status.joint.forEach((j, index) => {
assert.ok(Math.abs(j.motor_position - before.source_status.joint[index].motor_position) < 1e-8, "no-search home does not move a motor");
assert.ok(Math.abs(j.position - (index === joint ? 0 : before.source_status.joint[index].position)) < 1e-8);
});
assert.equal(after.visual.live_path_epoch, before.visual.live_path_epoch);
}
s.jog("x", 4); s.jog("y", 2);
const beforeAll = s.snapshot();
s.ok("HOME", { all: true });
for (const [kind, payload] of [["PROGRAM_RUN", {}], ["MDI_EXECUTE", { block: "G0 X1" }], ["SET_MODE", { mode: "AUTO" }]]) assert.equal(s.send(kind, payload).kind, "ERROR");
const afterAll = s.drainHome();
assert.deepEqual(afterAll.source_status.joint.map((j) => j.position), [0, 0, 0, 0, 0]);
assert.deepEqual(afterAll.source_status.joint.map((j) => j.motor_position), beforeAll.source_status.joint.map((j) => j.motor_position));
const nativeCheckpoint = s.session.wasmHomeLimit.exportState();
s.session.wasmHomeLimit.reset();
s.session.wasmHomeLimit.restoreState(nativeCheckpoint);
assert.deepEqual(s.session.wasmHomeLimit.read().state.joint, afterAll.source_status.joint);
s.ok("PROGRAM_RUN");
assert.equal(s.snapshot().source_status.task.mode, "AUTO");
assert.equal(s.snapshot().source_status.traj.mode, "COORD");
s.ok("PROGRAM_PAUSE");
assert.equal(s.snapshot().source_status.task.interpState, "INTERP_PAUSED");
assert.equal(s.send("SET_MODE", { mode: "MANUAL" }).kind, "ERROR");
s.ok("PROGRAM_ABORT"); s.ok("SET_MODE", { mode: "MANUAL" });
assert.equal(s.snapshot().source_status.traj.mode, "TELEOP");
s.ok("HOME", { joint: 0 }); s.ok("ESTOP", { active: true }); s.ok("SIM_STEP", { cycles: 1 });
assert.equal(s.snapshot().source_status.homing_active, false);
assert.equal(s.snapshot().source_status.joint[0].homed, false);
s.ok("ESTOP", { active: false }); s.ok("POWER", { active: true }); s.ok("HOME", { joint: 0 }); s.drainHome();
// A configured final move uses the original joint free_tp, not coordinated TP.
const moving = await setup("native-final-move", 5);
moving.ok("HOME", { joint: 0 });
const mid = moving.ok("SIM_STEP", { cycles: 1 }).panel_snapshot;
assert.equal(mid.source_status.joint[0].home_state, 21);
assert.ok(mid.source_status.joint[0].position > 0 && mid.source_status.joint[0].position < 5);
assert.equal(mid.wasm_motion.state.queue_depth, 0);
assert.equal(mid.source_status.joint[0].homed, false);
const done = moving.drainHome();
assert.ok(Math.abs(done.source_status.joint[0].position - 5) < 1e-8);
assert.ok(Math.abs(done.source_status.joint[0].motor_position - 5) < 1e-8);
moving.ok("HOME", { joint: 1 }); moving.ok("SIM_STEP", { cycles: 1 }); moving.ok("PROGRAM_ABORT");
moving.drainHome();
assert.equal(moving.snapshot().source_status.joint[1].homed, false);
assert.equal(moving.snapshot().source_status.homing_active, false);
console.log(JSON.stringify({ status: "verified", source: "homing.c + simple_tp.c", checks: ["five-joint-home", "no-search-coordinate-rebase", "motor-position-preserved", "native-final-move", "mode-interlocks", "abort-and-recovery", "servo-transition-history"] }));

View File

@@ -1,74 +1,3 @@
import assert from "node:assert/strict"; // Preserve the documented test entry point; the assertions now verify the
import { readFile } from "node:fs/promises"; // original homemod rather than the retired coordinated return-to-zero adapter.
import createModule from "../../build-wasm/linuxcnc-sim-core.js"; import "./test_native_homing.mjs";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
assert.equal(bundle.status, "ready");
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const core = await createModule({ wasmBinary });
function open(sessionId) {
const session = new XyzacSession(core, { sessionId });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${sessionId}-${seq}`, payload });
assert.equal(send("HELLO").kind, "CAPABILITIES");
assert.equal(send("SESSION_ATTACH").kind, "SESSION_READY");
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
assert.equal(send("POWER", { active: true }).payload.status, "verified");
return { session, send };
}
const partial = open("xyzac-home-axis-source");
for (const [axis, joint] of [["x", 0], ["y", 1], ["z", 2], ["a", 3], ["c", 4]]) {
const response = partial.send("HOME", { axis, joint });
assert.equal(response.kind, "COMMAND_RESULT", `${axis}: ${JSON.stringify(response.payload)}`);
assert.deepEqual(response.payload.homing, {
phase: "complete", target: "joint", source_axis: axis, joint,
motion_mode_before: "TELEOP", motion_mode_during: "FREE", motion_mode_after: "TELEOP",
});
assert.equal(response.payload.wasm_home_limit.state.homed_mask, (1 << (joint + 1)) - 1);
}
assert.equal(partial.send("HOME", { axis: "x", joint: 1 }).payload.message_key, "home_axis_joint_mismatch");
assert.equal(partial.session.wasmHomeLimit.read().state.homed_mask, 31);
const all = open("xyzac-home-all-source");
assert.equal(all.send("HOME", { all: true }).payload.wasm_home_limit.state.homed, true);
const move = all.send("MDI_EXECUTE", { block: "G21 G90 G0 X10 Y5 Z20 A30 C45" });
assert.equal(move.kind, "COMMAND_RESULT");
for (let tick = 0; tick < 20000 && all.session.wasmMotion.read().state.queue_depth > 0; tick += 1) all.send("SIM_STEP", { cycles: 1 });
assert.ok(Math.abs(all.session.wasmMotion.read().state.position.x - 10) < 1e-8);
const rehome = all.send("HOME", { all: true });
assert.equal(rehome.kind, "COMMAND_RESULT");
assert.equal(rehome.payload.homing.phase, "moving");
assert.notDeepEqual(rehome.payload.panel_snapshot.visual.pose, { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 });
for (let tick = 0; tick < 2000 && all.session.wasmMotion.read().state.queue_depth > 0; tick += 1) all.send("SIM_STEP", { cycles: 1 });
const completed = all.send("SNAPSHOT_REQUEST");
assert.deepEqual(completed.payload.panel_snapshot.visual.pose, { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 });
assert.deepEqual(completed.payload.wasm_home_limit.state.joints, [0, 0, 0, 0, 0]);
assert.ok(completed.payload.wasm_home_limit.state.home_sequence > rehome.payload.wasm_home_limit.state.home_sequence);
const running = open("xyzac-home-gate-source");
assert.equal(running.send("PROGRAM_LOAD", { source: "G21 G90\nG1 X10 F60\nM2\n", path: "home-gate.ngc" }).kind, "COMMAND_RESULT");
assert.equal(running.send("HOME", { all: true }).kind, "COMMAND_RESULT");
assert.equal(running.send("PROGRAM_RUN").kind, "COMMAND_RESULT");
assert.equal(running.send("HOME", { axis: "x", joint: 0 }).payload.gate_reason, "xyzac_interp_running");
console.log(JSON.stringify({
schema: "xyzac-home-source-flow.v1",
status: "verified",
source: ["axis.py:home_all_joints", "axis.py:home_joint", "axis.py:go_home", "axis.tcl:update_state"],
checks: ["axis_to_joint_map", "free_joint_transaction", "pose_sync", "invalid_pair_rejection", "running_gate"],
}));

View File

@@ -34,6 +34,7 @@ assert.equal(loadEpoch, 0);
assert.ok(previewCount > 0); assert.ok(previewCount > 0);
send("POWER", { active: true }); send("POWER", { active: true });
send("HOME", { all: true }); send("HOME", { all: true });
send("SIM_STEP", { cycles: 1 });
const run = send("PROGRAM_RUN"); const run = send("PROGRAM_RUN");
const runEpoch = epoch(run); const runEpoch = epoch(run);

View File

@@ -24,7 +24,7 @@ const snapshot = () => session.xyzacProgram.snapshot();
const sample = (cycles = 5) => send("SIM_STEP", { cycles, response: "motion_delta" }); 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("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("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(); send("POWER", { active: true }); send("HOME", { all: true }); sample(1); send("PROGRAM_RUN"); sample();
for (let round = 0; round < 3; round++) { for (let round = 0; round < 3; round++) {
send("PROGRAM_PAUSE"); send("PROGRAM_PAUSE");
const pose = motion().position, line = snapshot().current_line, queue = motion().queue_depth; const pose = motion().position, line = snapshot().current_line, queue = motion().queue_depth;

View File

@@ -29,6 +29,7 @@ const source = "G21\nG90\nG0 X0 Y0 Z10 A0 C0\nG1 X10 Y5 Z5 A15 C20 F600\nG1 X0 Y
assert.equal(send("PROGRAM_LOAD", { source, path: "rerun.ngc" }).payload.status, "verified"); assert.equal(send("PROGRAM_LOAD", { source, path: "rerun.ngc" }).payload.status, "verified");
assert.equal(send("POWER", { active: true }).payload.status, "verified"); assert.equal(send("POWER", { active: true }).payload.status, "verified");
assert.equal(send("HOME", { all: true }).payload.status, "verified"); assert.equal(send("HOME", { all: true }).payload.status, "verified");
assert.equal(send("SIM_STEP", { cycles: 1 }).payload.status, "verified");
assert.equal(send("PROGRAM_RUN").payload.task.state, "RUNNING"); assert.equal(send("PROGRAM_RUN").payload.task.state, "RUNNING");
let completed; let completed;

View File

@@ -28,7 +28,8 @@ async function create(id) {
const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }); const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
assert.equal(loaded.payload.status, "verified"); assert.equal(loaded.payload.status, "verified");
assert.equal(send("POWER", "power-on", { active: true }).payload.status, "verified"); assert.equal(send("POWER", "power-on", { active: true }).payload.status, "verified");
assert.equal(send("HOME", "home-all", { all: true }).payload.wasm_home_limit.state.homed, true); assert.equal(send("HOME", "home-all", { all: true }).payload.homing.phase, "accepted");
assert.equal(send("SIM_STEP", "home-complete", { cycles: 1 }).payload.panel_snapshot.motion.homed, true);
return { core, session, send }; return { core, session, send };
} }

View File

@@ -30,6 +30,7 @@ for (const estop of [false, true]) {
assert.equal(snapshot().ui_gates.abort.enabled, false, "Stop widget must be disabled at IDLE"); 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("ESTOP", { active: false }); send("POWER", { active: true }); send("HOME", { all: true });
send("SIM_STEP", { cycles: 1 });
send("SET_MODE", { mode: "MDI" }); send("SET_MODE", { mode: "MDI" });
assert.equal(snapshot().gates.spindle.enabled, true); assert.equal(snapshot().gates.spindle.enabled, true);
assert.equal(snapshot().gates.touchOff.enabled, true); assert.equal(snapshot().gates.touchOff.enabled, true);

View File

@@ -27,6 +27,7 @@ assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: to
assert.equal(send("PROGRAM_LOAD", { source: "G21 G90\nG0 X10 Y5 Z200 A30 C45\nG1 X20 Y10 Z180 A35 C50 F100\nM2\n", path: "touch.ngc" }).payload.status, "verified"); assert.equal(send("PROGRAM_LOAD", { source: "G21 G90\nG0 X10 Y5 Z200 A30 C45\nG1 X20 Y10 Z180 A35 C50 F100\nM2\n", path: "touch.ngc" }).payload.status, "verified");
assert.equal(send("POWER", { active: true }).payload.status, "verified"); assert.equal(send("POWER", { active: true }).payload.status, "verified");
assert.equal(send("HOME", { all: true }).payload.status, "verified"); assert.equal(send("HOME", { all: true }).payload.status, "verified");
assert.equal(send("SIM_STEP", { cycles: 1 }).payload.status, "verified");
const jogStart = send("JOG_START", { axis: "X", direction: 1, increment: null, continuous: true }); const jogStart = send("JOG_START", { axis: "X", direction: 1, increment: null, continuous: true });
assert.equal(jogStart.payload.status, "verified"); assert.equal(jogStart.payload.status, "verified");

View File

@@ -108,17 +108,6 @@ export class WasmHomeLimit {
return this.read(); return this.read();
} }
// Resolve the canonical target of a home command without committing the
// homed mask or pose. The actual commit is performed only after the LinuxCNC
// TP has executed the queued move to this target.
previewHome(axis = null) {
const saved = this.exportState();
const target = this.home(0, axis);
try { this.restoreState(saved); } catch (error) { return { status: "error", code: String(error?.message ?? error) }; }
if (target.status !== "verified") return target;
return { status: "verified", state: target.state };
}
check(joints) { check(joints) {
if (!this.available) return this.read(); if (!this.available) return this.read();
if (!Array.isArray(joints) || joints.length < this.jointCount || joints.some((value) => typeof value !== "number" || !Number.isFinite(value))) return { status: "error", code: "wasm_home_joints_invalid" }; if (!Array.isArray(joints) || joints.length < this.jointCount || joints.some((value) => typeof value !== "number" || !Number.isFinite(value))) return { status: "error", code: "wasm_home_joints_invalid" };

View File

@@ -0,0 +1,88 @@
import { fnv1a64 } from "./replay.mjs";
import { parseIni } from "../workspace/ini-parser.mjs";
const STATES = ["IDLE", "START", "UNLOCK", "UNLOCK_WAIT", "INITIAL_BACKOFF_START", "INITIAL_BACKOFF_WAIT", "INITIAL_SEARCH_START", "INITIAL_SEARCH_WAIT", "SET_COARSE_POSITION", "FINAL_BACKOFF_START", "FINAL_BACKOFF_WAIT", "RISE_SEARCH_START", "RISE_SEARCH_WAIT", "FALL_SEARCH_START", "FALL_SEARCH_WAIT", "SET_SWITCH_POSITION", "INDEX_ONLY_START", "INDEX_SEARCH_START", "INDEX_SEARCH_WAIT", "SET_INDEX_POSITION", "FINAL_MOVE_START", "FINAL_MOVE_WAIT", "LOCK", "LOCK_WAIT", "FINISHED", "ABORT"];
export class WasmNativeHoming {
constructor(core, configuration, kinematics) {
this.core = core;
this.kinematics = kinematics;
this.jointCount = 5;
this.size = core._sim_native_home_size();
this.pointer = core._malloc(this.size);
this.buffer = core._malloc(12 * 8);
if (!this.pointer || !this.buffer) throw new Error("native_home_allocation_failed");
this.available = true;
const parsed = parseIni({ entrypoint: configuration.entrypoint, workspace: { read: (path) => {
const value = configuration.files[path.replace(/^\/workspace\//, "")];
if (typeof value !== "string") throw new Error(`home_ini_missing:${path}`);
return value;
} } });
this.parameters = Array.from({ length: 5 }, (_, j) => {
const section = parsed.sections[`JOINT_${j}`] ?? {};
const n = (key, fallback) => section[key] === undefined ? fallback : Number(section[key]);
const flags = [["HOME_IGNORE_LIMITS", 1], ["HOME_USE_INDEX", 2], ["HOME_IS_SHARED", 4], ["LOCKING_INDEXER", 8], ["HOME_INDEX_NO_ENCODER_RESET", 128]].reduce((mask, [key, bit]) => mask | (n(key, 0) ? bit : 0), 0);
const encoder = n("HOME_ABSOLUTE_ENCODER", 0);
if (![0, 1, 2].includes(encoder)) throw new Error("native_home_absolute_encoder_invalid");
return [n("HOME_OFFSET", 0), n("HOME", 0), n("HOME_FINAL_VEL", -1), n("HOME_SEARCH_VEL", 0), n("HOME_LATCH_VEL", 0), flags | (encoder ? 16 | 32 | (encoder === 2 ? 64 : 0) : 0), n("HOME_SEQUENCE", 999), n("VOLATILE_HOME", 0), n("MIN_LIMIT", -1e9), n("MAX_LIMIT", 1e9), n("MAX_VELOCITY", 1), n("MAX_ACCELERATION", 1)];
});
this.period = Number(parsed.sections.EMCMOT?.SERVO_PERIOD ?? 1000000) / 1e9;
this.reset();
}
invoke(name, ...args) {
const rc = this.core[`_sim_native_home_${name}`](this.pointer, ...args);
if (rc !== 0) throw new Error(`native_home_${name}_${rc}`);
}
get(j, field) { return this.core._sim_native_home_get(this.pointer, j, field); }
get active() { return Boolean(this.get(0, 0)); }
get mode() { return [null, "FREE", "COORD", "TELEOP"][this.get(0, 1)]; }
setMode(mode) { this.invoke("mode", { FREE: 1, COORD: 2, TELEOP: 3 }[mode]); }
sync(joints) { this.core.HEAPF64.set(joints, this.buffer / 8); this.invoke("sync", this.buffer); }
home(seq, joint = null) { this.invoke("start", joint ?? -1, seq); return this.read(); }
step() { this.invoke("step"); return this.read(); }
cancel(volatile = false) { this.invoke("cancel", Number(volatile)); return this.read(); }
reset() {
this.invoke("init", 5, this.period);
this.parameters.forEach((p, j) => { this.core.HEAPF64.set(p, this.buffer / 8); this.invoke("config", j, this.buffer); });
return this.read();
}
check(joints) { if (!this.active) this.sync(joints); return this.read(); }
read() {
const joint = Array.from({ length: 5 }, (_, j) => ({
joint: j, homing: Boolean(this.get(j, 4)), homed: Boolean(this.get(j, 5)),
home_state: this.get(j, 6), home_state_name: `HOME_${STATES[this.get(j, 6)]}`,
position: this.get(j, 7), motor_position: this.get(j, 8), motor_offset: this.get(j, 9),
home_sequence: this.get(j, 10), velocity: this.get(j, 11), index_enable: Boolean(this.get(j, 12)),
}));
const joints = joint.map((j) => j.position);
const result = this.kinematics.forward(joints);
return { status: result.status, ...(result.code ? { code: result.code } : {}), state: {
schema: "sim_home_limit_state.v1", source: "linuxcnc-homing.c", joint_count: 5,
homed: joint.every((j) => j.homed), homed_mask: joint.reduce((mask, j) => mask | (j.homed ? 1 << j.joint : 0), 0),
homing: this.active, motion_mode: this.mode, joint, joints, pose: result.pose ?? null,
home_sequence: this.get(0, 3), tick: this.get(0, 2), last_error: result.result ?? 0, limit_error: result.result === -702 ? -702 : 0, kinematics_error: result.result ?? 0,
} };
}
exportState() {
// emcmot_joint_t includes large unused compensation tables. Preserve the
// complete native state while avoiding repeated zero bytes in checkpoints.
const bytes = this.core.HEAPU8.subarray(this.pointer, this.pointer + this.size);
const nonzero = [];
for (let i = 0; i < bytes.length; i++) if (bytes[i] !== 0) nonzero.push([i, bytes[i]]);
const value = { schema: "native-homing.v1", parameters: this.parameters, period: this.period,
byte_length: this.size, nonzero_bytes: nonzero };
return { ...value, state_hash: fnv1a64(value) };
}
restoreState(value) {
const { state_hash, ...unsigned } = value;
if (value.schema !== "native-homing.v1" || state_hash !== fnv1a64(unsigned) || value.byte_length !== this.size || value.period !== this.period || fnv1a64(value.parameters) !== fnv1a64(this.parameters) || !Array.isArray(value.nonzero_bytes)) throw new Error("native_home_restore_invalid");
let previous = -1;
const bytes = new Uint8Array(this.size);
for (const [offset, byte] of value.nonzero_bytes) {
if (!Number.isInteger(offset) || offset <= previous || offset >= this.size || !Number.isInteger(byte) || byte < 1 || byte > 255) throw new Error("native_home_restore_bytes_invalid");
bytes[offset] = byte; previous = offset;
}
this.core.HEAPU8.set(bytes, this.pointer);
return this.read();
}
destroy() { this.core._free(this.pointer); this.core._free(this.buffer); }
}