Fix web homing motion and synchronize preview state

This commit is contained in:
2026-09-09 09:31:32 +08:00
parent bade560372
commit eb5c58cdb3
11 changed files with 375 additions and 33 deletions

View File

@@ -155,7 +155,7 @@ families because their cardinality comes from the selected INI.
| Optional Stop and Block Delete | `toggle_optional_stop`, `toggle_block_delete` | Visible `SOURCE_BLOCKED`; block delete's source wait, MANUAL switch and preview reload must not be approximated by a local checkbox | | Optional Stop and Block Delete | `toggle_optional_stop`, `toggle_block_delete` | Visible `SOURCE_BLOCKED`; block delete's source wait, MANUAL switch and preview reload must not be approximated by a local checkbox |
| MDI history clear/copy/paste/select/double-click/Up/Down | `clear_mdi_history`, `mdi_history_hist2clip`, `mdi_history_clip2hist`, `mdi_history_butt_1`, `mdi_history_double_butt_1`, `mdi_up_cmd`, `mdi_down_cmd` | Select/Up/Down/local history: `SOURCE_ADAPTER`; clipboard/file persistence variants are browser/host boundaries | | MDI history clear/copy/paste/select/double-click/Up/Down | `clear_mdi_history`, `mdi_history_hist2clip`, `mdi_history_clip2hist`, `mdi_history_butt_1`, `mdi_history_double_butt_1`, `mdi_up_cmd`, `mdi_down_cmd` | Select/Up/Down/local history: `SOURCE_ADAPTER`; clipboard/file persistence variants are browser/host boundaries |
| Calibration, HAL Configuration, HAL Meter, HAL Scope, LinuxCNC Status, Debug Level | external `exec` commands in Machine menu | Desktop-process boundary; not rendered and never mapped to Diagnostics as though it were the same action | | Calibration, HAL Configuration, HAL Meter, HAL Scope, LinuxCNC Status, Debug Level | external `exec` commands in Machine menu | Desktop-process boundary; not rendered and never mapped to Diagnostics as though it were the same action |
| Home all/joint and Unhome all/joint | `home_all_joints`, `home_joint_number`, `unhome_all_joints`, `unhome_joint_number` | Home all: `SOURCE_ADAPTER`; world-axis Home rejects for KINEMATICS_BOTH; 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: `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` |
| 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 |
@@ -165,7 +165,7 @@ families because their cardinality comes from the selected INI.
| Clear Live Plot | `clear_live_plot` -> `LivePlotter.clear` -> `positionlogger.clear` | `SOURCE_ADAPTER`; this is the only native live-trace deletion action | | Clear Live Plot | `clear_live_plot` -> `LivePlotter.clear` -> `positionlogger.clear` | `SOURCE_ADAPTER`; this is the only native live-trace deletion action |
| Axis/joint selection and jog increment | `ja_rbutton`, `jogincrement`, `activate_ja_widget` | XYZAC axis and increment selection: `SOURCE_ADAPTER`; native joint selection is `SOURCE_BLOCKED` | | Axis/joint selection and jog increment | `ja_rbutton`, `jogincrement`, `activate_ja_widget` | XYZAC axis and increment selection: `SOURCE_ADAPTER`; native joint selection is `SOURCE_BLOCKED` |
| Jog negative/positive and key jog | `jog_minus`, `jog_plus`, `jog_on`, `jog_off` | `SOURCE_ADAPTER`; press/release semantics and Manual-tab guard apply | | Jog negative/positive and key jog | `jog_minus`, `jog_plus`, `jog_on`, `jog_off` | `SOURCE_ADAPTER`; press/release semantics and Manual-tab guard apply |
| Home Axis, Touch Off, Tool Touch Off, Override Limits | corresponding `zerohome`/`override` widgets | World-axis Home reproduces source rejection for KINEMATICS_BOTH; Touch Off: selected-coordinate adapter; Tool Touch Off/Override Limits: visible `SOURCE_BLOCKED` | | 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` |
| 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 +184,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 and system Touch Off have adapters; 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 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. |
| 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. |
@@ -221,9 +221,13 @@ command are blocked instead of being silently mapped to another operation.
与原生 NML 指令细分仍是需单独验证的适配边界,不能用行数相同替代运动断言。 与原生 NML 指令细分仍是需单独验证的适配边界,不能用行数相同替代运动断言。
5. **回零类型不能混同。** `switchkins.c::kinematicsType()` 始终返回 5. **回零类型不能混同。** `switchkins.c::kinematicsType()` 始终返回
`KINEMATICS_BOTH`;M429 的 `active_type=0` 不代表 KINEMATICS_IDENTITY。 `KINEMATICS_BOTH`;M429 的 `active_type=0` 不代表 KINEMATICS_IDENTITY。
`home_joint` 对世界坐标字母会拒绝,而数字 joint 允许回零。 原生 `home_joint` 在这种配置下只接受数字 joint;直接把世界坐标字母传给
当前面板的轴字母按钮不能冒充 joint 模式;Home Axis 保留原 UI 公式, `home_joint` 会被源码拒绝。固定的 XYZAC TRT Web 面板没有原生 joint
点击返回 `home_joint_requires_joint_mode`。Home All 仍可用。 单选组,因此 `Home Axis` 使用明确记录的 Web 适配:
`X/Y/Z/A/C -> joint 0/1/2/3/4`,Worker 只把这个映射后的数字 joint
交给 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 页,不能只
@@ -250,7 +254,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 状态;当前世界轴面板只支持 Home All | 不得用 switchkins 编号判断是否允许坐标轴回零 | | 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 |
| 速度滑块 | 原生线性/角速度独立,显示物理单位;当前 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 标签推导真实反馈差异 |
@@ -352,8 +356,8 @@ safety adaptation, not attributed to AXIS.
| Manual / MDI tabs | `SET_MODE` | `SOURCE_ADAPTER`: native tab callbacks are `ensure_manual` and `ensure_mdi`, including `set_motion_teleop` transitions. `axis.tcl::update_state` calls `set_mode_from_tab` only when returning to ON+IDLE from a non-idle/task-state transition while task mode is AUTO. No unrelated React effect may force a mode. | | Manual / MDI tabs | `SET_MODE` | `SOURCE_ADAPTER`: native tab callbacks are `ensure_manual` and `ensure_mdi`, including `set_motion_teleop` transitions. `axis.tcl::update_state` calls `set_mode_from_tab` only when returning to ON+IDLE from a non-idle/task-state transition while task mode is AUTO. No unrelated React effect may force a mode. |
| Joint/World (teleop) mode | not exposed | AXIS View menu calls `set_teleop_mode`/`c.teleop_enable`; the selected WASM motion ABI has no canonical teleop setter, so Web does not pretend its coordinate mode is a joint/world toggle. | | Joint/World (teleop) mode | not exposed | AXIS View menu calls `set_teleop_mode`/`c.teleop_enable`; the selected WASM motion ABI has no canonical teleop setter, so Web does not pretend its coordinate mode is a joint/world toggle. |
| Jog `-` / `+` | `JOG_START`, `SIM_STEP`, `JOG_STOP` | `SOURCE_ADAPTER`: source requires `manual_ok` and the visible Manual tab. Incremental sends one `JOG_INCREMENT`; Continuous sends one `JOG_CONTINUOUS` and motion continues until the deferred release issues `JOG_STOP`. Web servo sampling is transport/runtime work, not repeated user jog commands. | | Jog `-` / `+` | `JOG_START`, `SIM_STEP`, `JOG_STOP` | `SOURCE_ADAPTER`: source requires `manual_ok` and the visible Manual tab. Incremental sends one `JOG_INCREMENT`; Continuous sends one `JOG_CONTINUOUS` and motion continues until the deferred release issues `JOG_STOP`. Web servo sampling is transport/runtime work, not repeated user jog commands. |
| Home Axis | `HOME {axis}` | `manual_ok`, then `home_joint` checks axis versus joint selection. The selected switchkins module is always KINEMATICS_BOTH, so a world-axis letter is rejected with `home_joint_requires_joint_mode`; M429 does not change this type. | | Home 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 All / Re-home All | `HOME {all:true}` | `SOURCE_ADAPTER`: `manual_ok`, ensure MANUAL, if `all_homed()` ask the native re-home confirmation, then `set_motion_teleop(0)`, `c.home(-1)`, wait. Native text remains Home All; the Web `RE-HOME ALL` label is presentation-only and cannot trigger a hidden home during load. | | Home All / Re-home All | `HOME {all:true}` | `SOURCE_ADAPTER`: `manual_ok`, ensure MANUAL, if `all_homed()` ask the native re-home confirmation, then 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. |
| 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. |
@@ -400,15 +404,15 @@ 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`, then MANUAL | Repeat asks source confirmation; Web Re-home label is non-authoritative presentation only | | `manual-home-all` | Machine Homing menu -> `home_all_joints` | `HOME {all:true}`; `manual_ok`, 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 |
| `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. Explicit FREE/TELEOP/joint switching is an unimplemented ABI boundary; the selected panel uses its homed world-axis motion adapter. | | `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-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 axis to Jog/Home/Touch Off; sends no motion by itself |
| `manual-jog-increment` | `jogincrement` combobox | Local choice: Continuous or configured increment | Determines `JOG_CONTINUOUS` versus `JOG_INCREMENT` payload only | | `manual-jog-increment` | `jogincrement` combobox | Local choice: Continuous or configured increment | Determines `JOG_CONTINUOUS` versus `JOG_INCREMENT` payload only |
| `manual-jog-negative`, `manual-jog-positive` | `jogminus`, `jogplus` -> `jog_on` | `JOG_START`; visible Manual tab and `manual_ok`; source chooses joint/teleop mode from kinematics+homing | Incremental submits once; Continuous remains active until one release/cancel `JOG_STOP` | | `manual-jog-negative`, `manual-jog-positive` | `jogminus`, `jogplus` -> `jog_on` | `JOG_START`; visible Manual tab and `manual_ok`; source chooses joint/teleop mode from kinematics+homing | Incremental submits once; Continuous remains active until one release/cancel `JOG_STOP` |
| `manual-home-axis` | `home_joint` | UI ON+IDLE; `HOME {axis}` checks KINEMATICS_BOTH | World-axis letter rejected before confirmation/mode mutation; individual numeric joint input remains unavailable | | `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-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 |
@@ -445,7 +449,7 @@ 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 | Home authority updates `homed[]`; individual coordinate-axis home is rejected for non-identity/duplicate coordinates | AUTO's task layer may run only when all required joints are homed unless `NO_FORCE_HOMING=1` | | `HOME(all/axis)` | AXIS homing UI: ON+IDLE; handler `manual_ok`, then MANUAL; `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 |
| `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; explicit joint/teleop switching remains the stated boundary | Manual widgets are still controlled by ON+IDLE, not mode alone |
@@ -759,7 +763,7 @@ ESTOP(active=false)
POWER(active=true) POWER(active=true)
-> valid from ESTOP_RESET; trajectory enable; observed task_state ON -> valid from ESTOP_RESET; trajectory enable; observed task_state ON
HOME(all=true) HOME(all=true)
-> manual_ok -> MANUAL -> joint mode -> c.home(-1) -> wait -> manual_ok -> MANUAL -> joint/FREE -> c.home(-1) -> wait
PROGRAM_RUN PROGRAM_RUN
-> if the previous plan is completed/aborted: reopen current taskfile at line 0 -> if the previous plan is completed/aborted: reopen current taskfile at line 0
-> AUTO mode, interpreter READING; browser scheduler drives WASM samples -> AUTO mode, interpreter READING; browser scheduler drives WASM samples
@@ -788,6 +792,39 @@ 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
`sim_kinematics_home_limit_home()` and
`sim_kinematics_home_limit_home_joint()` are home-state authorities. They
calculate and commit the canonical homed mask, joint values and forward pose;
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
from the home target:
```text
HOME click
-> validate ON+IDLE/manual_ok and axis-to-joint mapping
-> ensure MANUAL -> select FREE/joint homing phase
-> WASM kinematics computes target pose without committing home state
-> enqueue one LinuxCNC TP homing move to that target
-> return HOME {homing.phase:"moving"}; keep current pose and homed state
-> browser scheduler sends SIM_STEP task/servo ticks
-> TP reaches target and queue becomes empty
-> commit sim_kinematics_home(_joint)
-> synchronize interpreter/TP canonical position
-> 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
the same command because there is no motion segment to execute. A Home request
must never call `setPosition()` to jump the TP to zero, must never change the
React pose locally, and must never advance `home_sequence` before the queued
motion has completed. `HOME ALL` targets all five configured joint home values;
`Home Axis` targets only the mapped joint and lets the selected XYZAC
kinematics calculate the resulting world pose. A pending home blocks another
Home until its queue has completed or an ESTOP/Power Off aborts it.
### Touch-off command encoding ### Touch-off command encoding
AXIS's `all_systems` list is `P0 Current`, `P1 G54` through `P9 G59.3`. AXIS's `all_systems` list is `P0 Current`, `P1 G54` through `P9 G59.3`.
@@ -893,9 +930,13 @@ 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_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` and the
direct visible Jog -> Home Axis/Home All sequence at
`scripts/test_xyzac_home_reset_browser.mjs` records the in-flight TP pose,
queue depth and final zero pose on the local `:5175` panel.
published-site execution sequence is covered by published-site execution sequence is covered by
`scripts/test_xyzac_program_execution_browser_strict.mjs`. `scripts/test_xyzac_program_execution_browser_strict.mjs`.
The source-surface audit is enforced by `scripts/test_axis_control_contract.mjs`. The source-surface audit is enforced by `scripts/test_axis_control_contract.mjs`.
@@ -907,8 +948,9 @@ 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, world-axis home rejection motion Step, WAITING/MDI Pause-Resume, source OFF state, fixed XYZAC
and spindle override 0-to-100 restoration using the actual WASM runtime. axis-to-joint Home Axis mapping and spindle override 0-to-100 restoration using
the actual WASM runtime.
The exhaustive control-ID audit is enforced by The exhaustive control-ID audit is enforced by
`scripts/test_axis_control_inventory.mjs` and `scripts/test_axis_control_inventory.mjs` and
`scripts/test_axis_control_inventory_browser.mjs`; the latter rejects any `scripts/test_axis_control_inventory_browser.mjs`; the latter rejects any

View File

@@ -0,0 +1,79 @@
import assert from "node:assert/strict";
import { pathToFileURL } from "node:url";
const modulePath = process.env.PLAYWRIGHT_MODULE;
assert.ok(modulePath, "PLAYWRIGHT_MODULE is required");
const imported = await import(pathToFileURL(modulePath).href);
const playwright = imported.default ?? imported;
const browser = await playwright.chromium.launch({
headless: false,
executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome",
args: ["--disable-gpu", "--ignore-certificate-errors", "--window-size=1440,1000"],
});
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
const main = page.locator("main.axis-app");
page.on("dialog", async (dialog) => { if (dialog.type() === "confirm") await dialog.accept(); else await dialog.dismiss(); });
const state = () => main.evaluate((element) => {
const preview = element.querySelector("[data-xyzac-preview]");
return {
loading: element.dataset.axisProgramLoading,
homed: element.dataset.axisHomed,
pose: preview?.dataset.xyzacLivePose,
hud: element.querySelector(".axis-preview-hud")?.textContent ?? "",
queue: element.dataset.axisTpQueueDepth,
command: element.querySelector(".command-status")?.textContent ?? "",
error: element.querySelector(".diagnostic-error")?.textContent ?? null,
};
});
const wait = async (predicate, label) => {
const started = Date.now(); let latest;
while (Date.now() - started < 45000) { latest = await state(); if (predicate(latest)) return latest; await page.waitForTimeout(100); }
throw new Error(`${label}:${JSON.stringify(latest)}`);
};
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 {
await page.goto(process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/", { waitUntil: "domcontentloaded" });
await wait((value) => value.loading === "false", "startup");
await click("toolbar-power"); await wait((value) => value.command.includes("POWER acknowledged"), "power");
await click("manual-home-all"); await wait((value) => value.command.includes("HOME acknowledged") && value.homed === "[true,true,true,true,true]", "home_all");
await screenshot("01-home-all-zero");
await control("manual-axis-x").check();
const jog = control("manual-jog-positive");
await jog.dispatchEvent("pointerdown", { pointerId: 41, button: 0 });
await wait((value) => value.command.includes("JOG_START acknowledged"), "jog_start");
await page.waitForTimeout(450);
await jog.dispatchEvent("pointerup", { pointerId: 41, button: 0 });
await wait((value) => value.command.includes("JOG_STOP acknowledged") && value.queue === "0", "jog_stop");
const jogged = await pose(); assert.ok(Math.abs(Number(jogged.x)) > 1e-6, JSON.stringify(jogged));
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");
await click("manual-home-all");
const movingAll = await wait((value) => value.command.includes("HOME acknowledged") && value.queue !== "0", "home_all_motion");
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 screenshot("06-home-all-moving");
const final = await wait((value) => value.command.includes("HOME acknowledged") && value.queue === "0", "home_all_again");
const finalPose = JSON.parse(final.pose);
for (const axis of ["x", "y", "z", "a", "c"]) assert.ok(Math.abs(Number(finalPose[axis])) < 1e-8, `${axis}:${JSON.stringify(finalPose)}`);
await screenshot("07-home-all-zero");
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"] }));
} finally { await browser.close(); }

View File

@@ -20,6 +20,7 @@ page.on("dialog", async (dialog) => {
dialogs.push({ type: dialog.type(), message: dialog.message() }); dialogs.push({ type: dialog.type(), message: dialog.message() });
if (dialog.type() === "prompt" && dialog.message().startsWith("Touch Off:")) await dialog.accept("0"); if (dialog.type() === "prompt" && dialog.message().startsWith("Touch Off:")) await dialog.accept("0");
else if (dialog.type() === "prompt" && dialog.message().startsWith("Coordinate system")) await dialog.accept("P1 G54"); else if (dialog.type() === "prompt" && dialog.message().startsWith("Coordinate system")) await dialog.accept("P1 G54");
else if (dialog.type() === "confirm" && dialog.message().includes("already homed")) await dialog.accept();
else await dialog.dismiss(); else await dialog.dismiss();
}); });
const main = page.locator("main.axis-app"); const main = page.locator("main.axis-app");
@@ -114,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.command?.includes("home_joint_requires_joint_mode"), "world_axis_home_source_rejection"); await wait((s) => s.error === null && s.command?.includes("HOME acknowledged"), "home_axis_ack");
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

@@ -13,6 +13,7 @@ import { XYZAC_CASE, projectXyzacPanel, type XyzacOperationTrace } from "./xyzac
export const SELECTED_XYZAC_CASE = XYZAC_CASE; export const SELECTED_XYZAC_CASE = XYZAC_CASE;
const DEFAULT_CASE = SELECTED_XYZAC_CASE; const DEFAULT_CASE = SELECTED_XYZAC_CASE;
const PROGRAM_PATH = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc"; const PROGRAM_PATH = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc";
const XYZAC_AXIS_TO_JOINT = Object.freeze({ x: 0, y: 1, z: 2, a: 3, c: 4 } as const);
const normalizeProgramSource = (source: string) => source.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const normalizeProgramSource = (source: string) => source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Button-to-command sequencing is defined in // Button-to-command sequencing is defined in
// docs/linuxcnc-axis-web-control-panel-source-logic.md; React only projects // docs/linuxcnc-axis-web-control-panel-source-logic.md; React only projects
@@ -239,8 +240,10 @@ export function App() {
const aboutDialog = useRef<HTMLDivElement>(null); const aboutDialog = useRef<HTMLDivElement>(null);
const [layers, setLayers] = useState({ grid: true, tool: true, path: true, live: true, limits: false, velocity: false, offsets: false, extents: false }); const [layers, setLayers] = useState({ grid: true, tool: true, path: true, live: true, limits: false, velocity: false, offsets: false, extents: false });
const runTimer = useRef<ReturnType<typeof setInterval> | null>(null); const runTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const homeTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const jogTimer = useRef<ReturnType<typeof setInterval> | null>(null); const jogTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const runStepPending = useRef(false); const runStepPending = useRef(false);
const homeStepPending = useRef(false);
const jogStepPending = useRef(false); const jogStepPending = useRef(false);
const continuousJog = useRef<{ axis: string; direction: number } | null>(null); const continuousJog = useRef<{ axis: string; direction: number } | null>(null);
const programFileInput = useRef<HTMLInputElement>(null); const programFileInput = useRef<HTMLInputElement>(null);
@@ -315,7 +318,7 @@ export function App() {
setCommandStatus("PROGRAM_LOAD acknowledged"); setCommandStatus("PROGRAM_LOAD acknowledged");
setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`); setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`);
}).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }).finally(() => setProgramLoading(false)); }).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }).finally(() => setProgramLoading(false));
return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); client.close(); }; return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (homeTimer.current) clearInterval(homeTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); client.close(); };
}, [client, operationController]); }, [client, operationController]);
const axes = state.snapshot?.motion?.axes ?? []; const axes = state.snapshot?.motion?.axes ?? [];
const taskRecord = state.task ?? {}; const taskRecord = state.task ?? {};
@@ -348,6 +351,17 @@ export function App() {
const relief = state.snapshot?.ui_relief ?? {}; const relief = state.snapshot?.ui_relief ?? {};
const canLoad = !programLoading && gates.load.enabled; const canLoad = !programLoading && gates.load.enabled;
const stopRunTimer = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } }; const stopRunTimer = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } };
const stopHomeTimer = () => { if (homeTimer.current) { clearInterval(homeTimer.current); homeTimer.current = null; } };
const startHomeTimer = () => {
if (homeTimer.current) return;
homeTimer.current = setInterval(() => {
if (homeStepPending.current) return;
homeStepPending.current = true;
void operationController.execute("SIM_STEP", { cycles: 5, response: "motion_delta" }, { state: axisState, gate: false })
.catch(() => stopHomeTimer())
.finally(() => { homeStepPending.current = false; });
}, 50);
};
const stopJogTimer = () => { continuousJog.current = null; if (jogTimer.current) { clearInterval(jogTimer.current); jogTimer.current = null; } }; const stopJogTimer = () => { continuousJog.current = null; if (jogTimer.current) { clearInterval(jogTimer.current); jogTimer.current = null; } };
const startJogTimer = (axis: string, direction: number) => { const startJogTimer = (axis: string, direction: number) => {
continuousJog.current = { axis, direction }; continuousJog.current = { axis, direction };
@@ -373,7 +387,7 @@ export function App() {
} }
const pending = operationController.execute(kind, payload, { state: axisState, gate, ...(action ? { action } : {}) }); const pending = operationController.execute(kind, payload, { state: axisState, gate, ...(action ? { action } : {}) });
setOperationTrace(operationController.exportTrace()); setOperationTrace(operationController.exportTrace());
return pending.then(() => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`); if (kind === "PROGRAM_RUN" || kind === "PROGRAM_RESUME") startRunTimer(); if (["PROGRAM_PAUSE", "PROGRAM_ABORT", "PROGRAM_RESTART"].includes(kind)) stopRunTimer(); if (kind === "JOG_START") startJogTimer(String(payload.axis ?? "x"), Number(payload.direction)); if (kind === "JOG_STOP") stopJogTimer(); }).catch((error: Error) => { return pending.then((operation) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`); if (kind === "PROGRAM_RUN" || kind === "PROGRAM_RESUME") startRunTimer(); if (["PROGRAM_PAUSE", "PROGRAM_ABORT", "PROGRAM_RESTART"].includes(kind)) stopRunTimer(); if (kind === "HOME" && (operation.response as { payload?: { homing?: { phase?: string } } })?.payload?.homing?.phase === "moving") startHomeTimer(); if (kind === "JOG_START") startJogTimer(String(payload.axis ?? "x"), Number(payload.direction)); if (kind === "JOG_STOP") stopJogTimer(); }).catch((error: Error) => {
setOperationTrace(operationController.exportTrace()); setOperationTrace(operationController.exportTrace());
const code = error.message; const code = error.message;
setCommandStatus(`${kind} blocked: ${code}`); setCommandStatus(`${kind} blocked: ${code}`);
@@ -396,7 +410,13 @@ export function App() {
const homeAxis = () => { const homeAxis = () => {
if (programLoading || !gates.home.enabled) return; if (programLoading || !gates.home.enabled) return;
// switchkins.c reports KINEMATICS_BOTH even when its active type is 0. // switchkins.c reports KINEMATICS_BOTH even when its active type is 0.
void dispatch("HOME", { axis: selectedAxis }).catch(() => undefined); // AXIS therefore homes a numeric joint; this fixed XYZAC Web surface
// maps its visible one-to-one axis selector to that joint explicitly.
const joint = XYZAC_AXIS_TO_JOINT[selectedAxis as keyof typeof XYZAC_AXIS_TO_JOINT];
if (joint === undefined) { setCommandStatus("HOME blocked: home_axis_invalid"); return; }
const confirm = globalThis.confirm;
if (Boolean(homedAxes[joint]) && typeof confirm === "function" && !confirm(`Joint ${joint} is already homed. Re-home it?`)) return;
void dispatch("HOME", { axis: selectedAxis, joint }).catch(() => undefined);
}; };
const stopPreviewProgress = () => { const stopPreviewProgress = () => {
if (previewProgressTimer.current) { if (previewProgressTimer.current) {
@@ -625,7 +645,8 @@ export function App() {
const mdiMotionActive = !programRunning && String(state.task?.mode ?? "").toUpperCase() === "MDI" && wasmQueueDepth > 0; const mdiMotionActive = !programRunning && String(state.task?.mode ?? "").toUpperCase() === "MDI" && wasmQueueDepth > 0;
if ((programRunning && !programComplete) || mdiMotionActive || Boolean(state.snapshot?.motion?.stepping)) startRunTimer(); else stopRunTimer(); if ((programRunning && !programComplete) || mdiMotionActive || Boolean(state.snapshot?.motion?.stepping)) startRunTimer(); else stopRunTimer();
if (wasmQueueDepth === 0 && !jogStepPending.current) stopJogTimer(); if (wasmQueueDepth === 0 && !jogStepPending.current) stopJogTimer();
if (!ready) { stopRunTimer(); stopJogTimer(); } if (wasmQueueDepth === 0 && !homeStepPending.current) stopHomeTimer();
if (!ready) { stopRunTimer(); stopHomeTimer(); stopJogTimer(); }
}, [programRunning, ready, state.program?.program_status, state.task?.mode, state.snapshot?.wasm_motion]); }, [programRunning, ready, state.program?.program_status, state.task?.mode, state.snapshot?.wasm_motion]);
const spindleState = (state.devices as Record<string, any> | null)?.spindles?.[0] as Record<string, any> | undefined; const spindleState = (state.devices as Record<string, any> | null)?.spindles?.[0] as Record<string, any> | undefined;
const spindleDirection = Number(spindleState?.direction ?? 0); const spindleDirection = Number(spindleState?.direction ?? 0);

View File

@@ -119,6 +119,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
let historyVertexCount = 0; let historyVertexCount = 0;
let activeLiveLine: THREE.Line | null = null; let activeLiveLine: THREE.Line | null = null;
let livePathEpoch = -1; let livePathEpoch = -1;
let poseEpoch = -1;
let lastHistoryTick = -1; let lastHistoryTick = -1;
let lastActiveKey = ""; let lastActiveKey = "";
let lastPreset = ""; let lastPreset = "";
@@ -165,6 +166,29 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
const segments = state.toolpath as Segment[]; const segments = state.toolpath as Segment[];
const layer = layersRef.current; const layer = layersRef.current;
const now = performance.now(); const now = performance.now();
// A HOME transaction can reset the canonical pose without advancing the
// simulation tick. Treat the source home sequence as an explicit pose
// barrier so stale queued jog samples cannot keep the old value on the
// HUD or tool marker. Keep the Live Plot history intact.
if (state.poseEpoch !== poseEpoch) {
poseEpoch = state.poseEpoch;
playbackQueue = [];
playbackStart = 0;
queuedLastTick = state.tick;
renderedTick = state.tick;
renderedPose = state.livePose;
renderedJoints = state.joints;
renderedActiveSegment = state.activeSegmentId ?? 0;
renderedProgress = state.activeProgress;
paintedTick = renderedTick;
paintedPose = renderedPose;
paintedJoints = renderedJoints;
paintedActiveSegment = renderedActiveSegment;
paintedProgress = renderedProgress;
liveTick = renderedTick;
if (activeLiveLine) { activeLiveLine.geometry.dispose(); liveGroup.remove(activeLiveLine); activeLiveLine = null; }
lastActiveKey = "";
}
if (state.livePathEpoch !== livePathEpoch) { if (state.livePathEpoch !== livePathEpoch) {
livePathEpoch = state.livePathEpoch; livePathEpoch = state.livePathEpoch;
clearLines(liveGroup); clearLines(liveGroup);

View File

@@ -138,6 +138,7 @@ export type PanelSnapshotV1 = {
schema: "axis-visual.v1"; schema: "axis-visual.v1";
pose: Record<string, number>; pose: Record<string, number>;
joints: number[]; joints: number[];
pose_epoch?: number;
live_path_epoch?: number; live_path_epoch?: number;
toolpath?: Array<Record<string, unknown>>; toolpath?: Array<Record<string, unknown>>;
active_segment_id?: number | null; active_segment_id?: number | null;

View File

@@ -9,6 +9,7 @@ export type XyzacVisualState = {
joints: [number, number, number, number, number]; joints: [number, number, number, number, number];
livePose: XyzacKinematicsSnapshot["pose"]; livePose: XyzacKinematicsSnapshot["pose"];
activeProgress: number; activeProgress: number;
poseEpoch: number;
livePathEpoch: number; livePathEpoch: number;
motionTrace: Array<{ tick: number; pose: XyzacKinematicsSnapshot["pose"]; joints: number[]; activeSegmentId: number; activeProgress: number }>; motionTrace: Array<{ tick: number; pose: XyzacKinematicsSnapshot["pose"]; joints: number[]; activeSegmentId: number; activeProgress: number }>;
offsets: XyzacKinematicsSnapshot["offsets"]; offsets: XyzacKinematicsSnapshot["offsets"];
@@ -28,7 +29,7 @@ export type XyzacVisualSource = {
state_hash?: string; state_hash?: string;
kinematics?: XyzacKinematicsSnapshot; kinematics?: XyzacKinematicsSnapshot;
motion?: { velocity_mm_min?: number }; motion?: { velocity_mm_min?: number };
visual?: { active_segment_id?: number | null; active_progress?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record<string, number>; joints?: number[]; toolpath?: Array<Record<string, unknown>> }; visual?: { active_segment_id?: number | null; active_progress?: number; pose_epoch?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record<string, number>; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record<string, number>; joints?: number[]; toolpath?: Array<Record<string, unknown>> };
}; };
export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefined): XyzacVisualState { export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefined): XyzacVisualState {
@@ -53,6 +54,7 @@ export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefine
joints, joints,
livePose: pose, livePose: pose,
activeProgress, activeProgress,
poseEpoch: Number.isSafeInteger(snapshot?.visual?.pose_epoch) ? Number(snapshot?.visual?.pose_epoch) : 0,
livePathEpoch: Number.isSafeInteger(snapshot?.visual?.live_path_epoch) ? Number(snapshot?.visual?.live_path_epoch) : 0, livePathEpoch: Number.isSafeInteger(snapshot?.visual?.live_path_epoch) ? Number(snapshot?.visual?.live_path_epoch) : 0,
motionTrace, motionTrace,
offsets: kinematics?.offsets ?? ZERO_OFFSETS, offsets: kinematics?.offsets ?? ZERO_OFFSETS,

View File

@@ -33,6 +33,7 @@ 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,6 +136,12 @@ export class SimSession {
this.xyzacDeviceIo = null; this.xyzacDeviceIo = null;
this.xyzacProgram = null; this.xyzacProgram = null;
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;
@@ -301,6 +308,7 @@ 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"),
jog: null, jog: null,
stepping: this.wasmMotion.isStepping?.() ?? false, stepping: this.wasmMotion.isStepping?.() ?? false,
estop: Boolean(ioState?.estop), estop: Boolean(ioState?.estop),
@@ -393,7 +401,7 @@ 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: homeLimit.state?.homed ? "TELEOP" : "FREE", kinematics_type: "BOTH", motion_mode: this.xyzacProgram ? this.xyzacMotionMode : (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" });
const gates = this.xyzacProgram const gates = this.xyzacProgram
@@ -449,6 +457,10 @@ export class SimSession {
schema: "axis-visual.v1", schema: "axis-visual.v1",
pose: { ...position }, pose: { ...position },
joints: Array.isArray(joints) ? [...joints] : [], joints: Array.isArray(joints) ? [...joints] : [],
// Home changes the canonical current pose even when the simulation
// tick does not advance. The renderer uses this monotonic source
// sequence to reset its displayed pose without clearing Live Plot.
pose_epoch: Number(homeLimit.state?.home_sequence ?? 0),
live_path_epoch: this.livePathEpoch, live_path_epoch: this.livePathEpoch,
...(includeToolpath ? { toolpath: (preview?.segments ?? []).map((segment, index) => ({ id: index + 1, ...segment })) } : {}), ...(includeToolpath ? { toolpath: (preview?.segments ?? []).map((segment, index) => ({ id: index + 1, ...segment })) } : {}),
active_segment_id: wasmMotion.status === "verified" ? wasmMotion.state.active_id : authoritative.planner.active_id, active_segment_id: wasmMotion.status === "verified" ? wasmMotion.state.active_id : authoritative.planner.active_id,
@@ -551,6 +563,9 @@ 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)) {
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);
const ready = Boolean(io.ready) && !estop; const ready = Boolean(io.ready) && !estop;
const loaded = program.status !== 0; const loaded = program.status !== 0;
@@ -1372,6 +1387,8 @@ 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.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);
@@ -1475,6 +1492,8 @@ 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.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);
@@ -1594,6 +1613,8 @@ export class SimSession {
: 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.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);
@@ -1941,6 +1962,18 @@ 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") {
@@ -2392,17 +2425,54 @@ export class SimSession {
case "HOME": { case "HOME": {
if (this.xyzacProgram) { if (this.xyzacProgram) {
if (this.wasmHomeLimit.read().status !== "verified") return finish(this.error(input, "home_limit", "wasm_home_limit_unavailable"), "failed"); if (this.wasmHomeLimit.read().status !== "verified") return finish(this.error(input, "home_limit", "wasm_home_limit_unavailable"), "failed");
const requestedAxis = input.payload?.all ? null : String(input.payload?.axis ?? "").toLowerCase() || null; const payload = input.payload ?? {};
if (requestedAxis) return finish(this.error(input, "home_limit", "home_joint_requires_joint_mode"), "failed"); // Older callers used HOME with an empty payload for Home All. Keep
const requestedJoint = input.payload?.joint; // that compatible while making the explicit Web contract
if (requestedJoint !== undefined && (!Number.isInteger(requestedJoint) || requestedJoint < 0 || requestedJoint >= 5)) return finish(this.error(input, "home_limit", "home_joint_invalid"), "failed"); // HOME {all:true}.
const all = payload.all === true || (payload.all === undefined && payload.axis === undefined && payload.joint === undefined);
const requestedAxis = all ? null : String(payload.axis ?? "").trim().toLowerCase() || null;
let requestedJoint = payload.joint;
if (requestedAxis !== null) {
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 && (!Number.isInteger(requestedJoint) || requestedJoint < 0 || requestedJoint >= 5)) return finish(this.error(input, "home_limit", "home_joint_invalid"), "failed");
// AXIS home_all_joints()/home_joint() calls ensure_mode(MANUAL) // AXIS home_all_joints()/home_joint() calls ensure_mode(MANUAL)
// before issuing the homing command. // before 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 home = this.wasmHomeLimit.home(input.seq, input.payload?.all ? null : requestedJoint ?? null); const modeBefore = this.xyzacMotionMode;
if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed"); const currentMotion = this.wasmMotion.read();
const currentPose = currentMotion.state?.position;
const currentJoints = Array.isArray(currentMotion.state?.joint_commanded) && currentMotion.state.joint_commanded.length === 5
? [...currentMotion.state.joint_commanded]
: [...(this.xyzacKinematics?.snapshot()?.joints ?? [currentPose.x, currentPose.y, currentPose.z, currentPose.a ?? 0, currentPose.c ?? 0])];
const targetHome = this.wasmHomeLimit.previewHome(all ? null : requestedJoint);
if (targetHome.status !== "verified") return finish(this.error(input, "home_limit", targetHome.code ?? "wasm_home_target_failed"), "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"); this.task.setMode("MANUAL");
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), wasm_home_limit: this.wasmHomeLimit.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified"); return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, 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") {
@@ -2450,13 +2520,28 @@ 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
// 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;
const displayScale = ["x", "y", "z"].includes(axis) && input.payload?.units === "inch" ? 25.4 : 1; const displayScale = ["x", "y", "z"].includes(axis) && input.payload?.units === "inch" ? 25.4 : 1;
const distance = continuous ? 0 : Number(requestedIncrement ?? 1) * displayScale; const distance = continuous ? 0 : Number(requestedIncrement ?? 1) * displayScale;
if (!continuous && (!Number.isFinite(distance) || distance <= 0)) return finish(this.error(input, "motion_jog", "motion_jog_nonfinite"), "failed"); if (!continuous && (!Number.isFinite(distance) || distance <= 0)) return finish(this.error(input, "motion_jog", "motion_jog_nonfinite"), "failed");
const jog = this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, c: 5 }[axis], direction, distance, input.seq); // The selected XYZAC case uses the LinuxCNC TP jog ABI. Keep the
// existing queue-backed WASM cases working as well: they have no
// native jog entry point, so represent one incremental/continuous
// sample as a normal queued line and retain the same JOG_STOP abort
// path. This compatibility branch is never used by XYZAC TRT.
const jog = typeof this.wasmMotion.jog === "function"
? this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, c: 5 }[axis], direction, distance, input.seq)
: (() => {
const current = this.wasmMotion.read();
const start = current.state?.position ?? {};
const end = { ...start, [axis]: Number(start[axis] ?? 0) + direction * (continuous ? Math.max(Number(input.payload?.speed ?? 1) * this.xyzacMotionCycleTime * 5, Number.EPSILON) : distance) };
return this.wasmMotion.enqueue({ start, end, velocity: Math.max(Number(input.payload?.speed ?? 1), Number.EPSILON), acceleration: 1, motion_type: 1, source_line: input.seq });
})();
if (jog.status !== "verified") { if (jog.status !== "verified") {
const code = jog.code === "linuxcnc_tp_jog_-1217" ? "motion_limit_range" : jog.code === "linuxcnc_tp_jog_-1215" ? "motion_jog_nonfinite" : jog.code; const code = jog.code === "linuxcnc_tp_jog_-1217" ? "motion_limit_range" : jog.code === "linuxcnc_tp_jog_-1215" ? "motion_jog_nonfinite" : jog.code;
return finish(this.error(input, "motion_queue", code), "failed"); return finish(this.error(input, "motion_queue", code), "failed");

View File

@@ -29,7 +29,9 @@ 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 });
assert.equal(send("HOME", { axis: "x" }).payload.message_key, "home_joint_requires_joint_mode"); const homeAxis = send("HOME", { axis: "x", joint: 0 });
assert.equal(homeAxis.payload.status, "verified");
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,74 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs";
const 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

@@ -108,6 +108,17 @@ 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" };