diff --git a/doc-rtcp/xyzac-native-homing-status-validation.md b/doc-rtcp/xyzac-native-homing-status-validation.md new file mode 100644 index 0000000..3f46c17 --- /dev/null +++ b/doc-rtcp/xyzac-native-homing-status-validation.md @@ -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. diff --git a/docs/linuxcnc-axis-web-control-panel-source-logic.dot b/docs/linuxcnc-axis-web-control-panel-source-logic.dot index c28bd42..3f2d91b 100644 --- a/docs/linuxcnc-axis-web-control-panel-source-logic.dot +++ b/docs/linuxcnc-axis-web-control-panel-source-logic.dot @@ -45,12 +45,12 @@ digraph LinuxCncAxisWebControlLogic { 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]; - 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"]; 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]; 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"]; { rank=same; source; contract; gesture; react; operation; } @@ -106,7 +106,10 @@ digraph LinuxCncAxisWebControlLogic { estop -> off [label="ESTOP active=false"]; 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"]; running -> paused [label="PROGRAM_PAUSE"]; paused -> running [label="PROGRAM_RESUME"]; @@ -135,7 +138,7 @@ digraph LinuxCncAxisWebControlLogic { ]; 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", fillcolor="#e9f5e8", color="#43844b" diff --git a/docs/linuxcnc-axis-web-control-panel-source-logic.md b/docs/linuxcnc-axis-web-control-panel-source-logic.md index c83216f..bc6976e 100644 --- a/docs/linuxcnc-axis-web-control-panel-source-logic.md +++ b/docs/linuxcnc-axis-web-control-panel-source-logic.md @@ -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 | | `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/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 | 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 | | 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 | -| 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 | | 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 | @@ -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 | | 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 | -| 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 | -| 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 | | 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 | @@ -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`. | | 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. | | `-`, `=`, 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. | @@ -219,15 +221,12 @@ command are blocked instead of being silently mapped to another operation. `motion/control.c:2186` 在执行 ID 改变后暂停 TP,不能“解析一行后永久 冻结”,也不能一次 Step 把全部已排队运动执行完。Web 单步的源码行解析 与原生 NML 指令细分仍是需单独验证的适配边界,不能用行数相同替代运动断言。 -5. **回零类型不能混同。** `switchkins.c::kinematicsType()` 始终返回 - `KINEMATICS_BOTH`;M429 的 `active_type=0` 不代表 KINEMATICS_IDENTITY。 - 原生 `home_joint` 在这种配置下只接受数字 joint;直接把世界坐标字母传给 - `home_joint` 会被源码拒绝。固定的 XYZAC TRT Web 面板没有原生 joint - 单选组,因此 `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 约束。 +5. **回零采用原生 homemod。** `switchkins.c::kinematicsType()` 始终返回 + `KINEMATICS_BOTH`,M429 不改变这一类型。世界轴字母不能作为回零目标; + `manual-home-joint-select` 明确选择数字关节,`manual-home-axis` 保留 ID, + 按钮显示 Home Joint,提交 `HOME {joint:0..4}`。Home All 提交 + `HOME {all:true}`。WASM 直接编译源 `homing.c` 和 `simple_tp.c`, + 不再预览 home-limit 后排一条协调 TP 回零直线。完整语义见下方 Native Homing。 6. **自动恢复标签模式。** `axis.tcl::update_state` 在 ON+IDLE 的状态边沿, 当前 task_mode 为 AUTO 时调用 `set_mode_from_tab`;显式 Open/Reload 完成后 也调用它。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 的确认/取消交互 | -| 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 控制 | | 预览图层 | limits 现从 WASM XYZ 软限位 getter 读取;offsets 使用已实现的 G5x 平移;extents 使用 canonical 采样包围框 | G92/旋转/刀具偏置范围与原生文字标注仍未全量覆盖,不能称完整原生图层复刻 | | 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. | | 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. | -| 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. | -| 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 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 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, 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 [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`. | | 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 | | `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 | -| `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 | -| `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-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-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-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 | @@ -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 | | `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 | -| `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. | | `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` | | `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 | @@ -628,7 +628,7 @@ Run 仍需用户单独操作,不能因加载文件而自动上电、回零或 | Open、Reload、案例和打包程序 | 四条入口共用 `gates.load`,包含 PAUSED 与活动 TP 队列;Reload 还要求已有 taskfile | 文件读取完成后再次检查门控,再提交一次 `PROGRAM_LOAD` | 用户取消文件选择不发命令;门控已变化则不替换编辑器文本、路径或预览;解析失败属于已选新文件的源码错误 | | 点动和主轴调速按住按钮 | 主指针按下或聚焦按钮的 Space/Enter 按下;重复 keydown/第二指针不重复提交 | 记录本次手势的 axis/direction/increment 与 release 操作;按下和释放有固定命令顺序 | pointerup/cancel/leave/lost capture、keyup、窗口失焦、页面隐藏、离开 Manual 页只结束同一手势一次;release 早于 ACK 时仍按 start→stop 顺序处理 | | 坐标轴、步长、DRO、视图、图层、选项卡 | 使用表内本地显示状态;切换 Manual/MDI 另走 `SET_MODE` | 更新选择或投影,不能变更 canonical machine state | 每个选项均验证显示变化;隐藏 Live Plot 不得删除轨迹;旋转、平移和缩放须验证实际画布变化 | -| 系统对刀、重复回零 | 源码门控通过后显示参数/确认对话框 | 确认后只发送一次 `TOUCH_OFF` 或 `HOME` | 取消不发运动/对刀命令;Home All 的原生 ensure MANUAL 在确认之前,Web 确认前模式处理属于适配差异;空白/非有限数字/非法 P 或 G 系统标识不提交 | +| 系统对刀、重复回零 | 源码门控通过后显示参数/确认对话框 | 确认后只发送一次 `TOUCH_OFF` 或 `HOME` | 取消不发运动/对刀命令;Home All 在确认前 ensure MANUAL,单关节重复回零在确认后 ensure MANUAL;空白/非有限数字/非法 P 或 G 系统标识不提交 | | About 和菜单 | 本地打开、关闭、Escape;About 打开时焦点进入,关闭后返回入口 | 显示/隐藏相应内容 | 弹窗打开期间不触发普通机器快捷键;F1 急停仍可用;关闭动作不触发 Stop | | 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 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 -`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: +This section supersedes the former axis-to-joint alias and coordinated-TP +return-to-zero implementation. The reference is the pinned native source, +including the selected INI, not the previous browser tests. + +| Requested status | Correct native field / source | Web snapshot | +| --- | --- | --- | +| 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 -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} +HOME(all/joint) + -> ON+IDLE and no active motion; ensure MANUAL + -> native FREE, do_home_joint(-1 or numeric joint) + -> ACK: accepted; does not mean homed + -> servo cycle: original do_homing() then simple_tp_update() + -> forward kinematics of resulting joint coordinates + -> synchronize coordinated planner coordinate origin to native result + -> publish homing/homed/home_state and motor position + -> native all-homed edge: control.c switches to TELEOP; partial home remains FREE + -> native homing inactive: synchronize interpreter ``` -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. +`HOME_SEARCH_VEL=0` and default `HOME_LATCH_VEL=0` select home-at-current- +position (`homing.c:805`). `HOME_SET_SWITCH_POSITION` adjusts joint coordinates +and motor offset without moving the motor. The final move targets INI HOME +through the joint `free_tp` (`homing.c:1248`). In the selected XYZAC INI, +HOME and HOME_OFFSET are zero; re-home after Jog can therefore reset displayed +joint coordinates with unchanged physical motor position. This is native +behavior. It must not be replaced by G0, G53 or a coordinated TP line to zero. + +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 @@ -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. 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. 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. @@ -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 `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 `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 -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 +rerun contract is covered by `web/worker/test_xyzac_program_rerun.mjs`. The +direct visible Jog -> Home Joint/Home All sequence at +`scripts/test_xyzac_home_reset_browser.mjs` records native homing transitions, +joint coordinates and unchanged motor positions for no-search homing on the local `:5175` panel. +The published-site execution sequence is covered by `scripts/test_xyzac_program_execution_browser_strict.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 @@ -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 predicates (7488 assertions), rather than comparing two rewritten formulas. `web/worker/test_axis_source_execution.mjs` verifies repeated Step, queued -motion Step, WAITING/MDI Pause-Resume, source OFF state, fixed XYZAC -axis-to-joint Home Axis mapping and spindle override 0-to-100 restoration using +motion Step, WAITING/MDI Pause-Resume, source OFF state, KINEMATICS_BOTH +world-axis rejection and spindle override 0-to-100 restoration using the actual WASM runtime. The exhaustive control-ID audit is enforced by `scripts/test_axis_control_inventory.mjs` and diff --git a/docs/linuxcnc-axis-web-control-panel-source-logic.png b/docs/linuxcnc-axis-web-control-panel-source-logic.png index ff27e6a..4b9e505 100644 Binary files a/docs/linuxcnc-axis-web-control-panel-source-logic.png and b/docs/linuxcnc-axis-web-control-panel-source-logic.png differ diff --git a/scripts/test_axis_control_inventory.mjs b/scripts/test_axis_control_inventory.mjs index 40d69c4..fc40bc1 100644 --- a/scripts/test_axis_control_inventory.mjs +++ b/scripts/test_axis_control_inventory.mjs @@ -13,7 +13,7 @@ const [contract, app, panel, preview] = await Promise.all([ const controlIds = [ "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", - "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", "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", diff --git a/scripts/test_xyzac_home_reset_browser.mjs b/scripts/test_xyzac_home_reset_browser.mjs index 74b3bcc..5230872 100644 --- a/scripts/test_xyzac_home_reset_browser.mjs +++ b/scripts/test_xyzac_home_reset_browser.mjs @@ -1,79 +1,107 @@ 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 imported = await import(pathToFileURL(process.env.PLAYWRIGHT_MODULE).href); +const { chromium } = imported.default ?? imported; +const url = process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/"; +const browser = await chromium.launch({ headless: false, slowMo: 100, executablePath: "/usr/bin/google-chrome", args: ["--disable-gpu"] }); +const page = await browser.newPage({ viewport: { width: 1440, height: 1100 } }); +const errors = []; +let confirmHome = true; +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(); }); +await page.addInitScript(() => { + window.homeResults = []; window.homeRequests = []; + const Original = window.Worker; + window.Worker = class extends Original { + constructor(...args) { + super(...args); + this.addEventListener("message", ({ data }) => { + const p = data.payload; + window.homeResults.push({ seq: data.seq, kind: data.kind, command: p?.command, home: p?.homing, + status: p?.source_status ?? p?.panel_snapshot?.source_status, + pose: p?.panel_snapshot?.visual?.pose, epoch: p?.panel_snapshot?.visual?.live_path_epoch }); + if (window.homeResults.length > 500) window.homeResults.shift(); + }); + } + 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 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) => page.locator(`[data-axis-control="${id}"]`); +const status = () => page.evaluate(() => window.homeResults.filter((r) => r.status).at(-1).status); +const home = async (id) => { + 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 { - 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 page.goto(url); + await page.waitForFunction(() => document.querySelector("main")?.dataset.axisProgramLoading === "false"); + await control("toolbar-power").click(); + await home("manual-home-all"); + for (const [joint, axis] of ["x", "y", "z", "a", "c"].entries()) { + process.stderr.write(`Visible UI: Jog ${axis.toUpperCase()}, then Home Joint ${joint}\n`); + const before = await jog(axis); + assert.ok(before.joint[joint].position > 0); + await control("manual-home-joint-select").selectOption(String(joint)); + const after = await home("manual-home-axis"); + for (let j = 0; j < 5; j++) { + assert.ok(Math.abs(after.joint[j].motor_position - before.joint[j].motor_position) < 1e-8); + assert.ok(Math.abs(after.joint[j].position - (j === joint ? 0 : before.joint[j].position)) < 1e-8); + } } - 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"] })); + const before = await jog("x"); + confirmHome = false; + const count = await page.evaluate(() => window.homeRequests.filter((r) => r.kind === "HOME").length); + await control("manual-home-all").click(); await page.waitForTimeout(800); + assert.equal(await page.evaluate(() => window.homeRequests.filter((r) => r.kind === "HOME").length), count); + confirmHome = true; + const after = await home("manual-home-all"); + assert.deepEqual(after.joint.map((j) => j.motor_position), before.joint.map((j) => j.motor_position)); + assert.ok(after.joint.every((j) => j.homed && Math.abs(j.position) < 1e-8)); + 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 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(); } diff --git a/scripts/test_xyzac_live_path_lifecycle_browser.mjs b/scripts/test_xyzac_live_path_lifecycle_browser.mjs index 4db4f3c..d976b87 100644 --- a/scripts/test_xyzac_live_path_lifecycle_browser.mjs +++ b/scripts/test_xyzac_live_path_lifecycle_browser.mjs @@ -22,8 +22,8 @@ const state = () => main.evaluate((element) => { loading: element.dataset.axisProgramLoading, path: element.dataset.axisProgramPath, program: element.dataset.axisProgramStatus, - operation: element.querySelector(".operation-state")?.textContent?.trim(), - machine: element.querySelector(".machine-state")?.textContent?.trim(), + operation: `Operation ${element.dataset.axisStateOperation}`, + machine: `Machine ${element.dataset.axisStateMachine}`, homed: element.dataset.axisHomed, line: element.dataset.axisCurrentLine, queue: element.dataset.axisTpQueueDepth, diff --git a/scripts/test_xyzac_live_trace_replacement_browser.mjs b/scripts/test_xyzac_live_trace_replacement_browser.mjs index b1e05af..d15e8fa 100644 --- a/scripts/test_xyzac_live_trace_replacement_browser.mjs +++ b/scripts/test_xyzac_live_trace_replacement_browser.mjs @@ -11,7 +11,7 @@ const errors = []; page.on("pageerror", (error) => errors.push(`pageerror:${error.message}`)); page.on("console", (message) => { if (message.type() === "error") errors.push(`console:${message.text()}`); }); 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 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`); }; diff --git a/scripts/test_xyzac_multi_program_load_browser.mjs b/scripts/test_xyzac_multi_program_load_browser.mjs index 04c50b6..76ba2a4 100644 --- a/scripts/test_xyzac_multi_program_load_browser.mjs +++ b/scripts/test_xyzac_multi_program_load_browser.mjs @@ -22,8 +22,8 @@ page.on("dialog", async (dialog) => { dialogs.push({ type: dialog.type(), messag const main = page.locator("main.axis-app"); const state = () => main.evaluate((element) => ({ source: element.dataset.axisStateSource, - machine: element.querySelector(".machine-state")?.textContent?.trim(), - operation: element.querySelector(".operation-state")?.textContent?.trim(), + machine: `Machine ${element.dataset.axisStateMachine}`, + operation: `Operation ${element.dataset.axisStateOperation}`, program: element.dataset.axisProgramStatus, line: element.dataset.axisCurrentLine, queue: element.dataset.axisTpQueueDepth, diff --git a/scripts/test_xyzac_panel_buttons_browser.mjs b/scripts/test_xyzac_panel_buttons_browser.mjs index 8fd4bc1..ff3a27f 100644 --- a/scripts/test_xyzac_panel_buttons_browser.mjs +++ b/scripts/test_xyzac_panel_buttons_browser.mjs @@ -9,7 +9,7 @@ if (!modulePath) { const imported = await import(pathToFileURL(modulePath).href); 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 failures = []; const browserErrors = []; @@ -29,8 +29,8 @@ const state = () => main.evaluate((element) => ({ source: element.dataset.axisStateSource, motion: element.dataset.axisMotionAuthority, task: element.dataset.axisTaskAuthority, - machine: element.querySelector(".machine-state")?.textContent?.trim(), - operation: element.querySelector(".operation-state")?.textContent?.trim(), + machine: `Machine ${element.dataset.axisStateMachine}`, + operation: `Operation ${element.dataset.axisStateOperation}`, case: element.querySelector(".case-status")?.textContent?.trim(), mode: element.dataset.axisTaskMode, 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("HOME/TOUCH OFF boundaries", async () => { 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 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"); diff --git a/scripts/test_xyzac_pause_resume_browser.mjs b/scripts/test_xyzac_pause_resume_browser.mjs index 5c1e22d..4991f4b 100644 --- a/scripts/test_xyzac_pause_resume_browser.mjs +++ b/scripts/test_xyzac_pause_resume_browser.mjs @@ -26,7 +26,7 @@ const control = (id) => page.locator(`[data-axis-control="${id}"]`); const state = () => page.locator("main.axis-app").evaluate((main) => { const viewer = main.querySelector("[data-xyzac-preview]"); return { loading: main.dataset.axisProgramLoading, program: main.dataset.axisProgramStatus, path: main.dataset.axisProgramPath, - mode: main.dataset.axisTaskMode, operation: main.querySelector("[data-axis-state-operation]")?.dataset.axisStateOperation, + mode: main.dataset.axisTaskMode, operation: main.dataset.axisStateOperation, pose: viewer?.dataset.xyzacLivePose, tick: Number(viewer?.dataset.xyzacTick), epoch: viewer?.dataset.xyzacLivePathEpoch, queue: Number(main.dataset.axisTpQueueDepth), command: main.querySelector(".command-status")?.textContent, error: main.querySelector(".diagnostic-error")?.textContent }; diff --git a/scripts/test_xyzac_program_execution_browser_strict.mjs b/scripts/test_xyzac_program_execution_browser_strict.mjs index a79b847..bd5f856 100644 --- a/scripts/test_xyzac_program_execution_browser_strict.mjs +++ b/scripts/test_xyzac_program_execution_browser_strict.mjs @@ -27,8 +27,8 @@ const state = () => main.evaluate((element) => ({ source: element.dataset.axisStateSource, authority: element.dataset.axisAuthority, motion: element.dataset.axisMotionAuthority, - machine: element.querySelector(".machine-state")?.textContent?.trim(), - operation: element.querySelector(".operation-state")?.textContent?.trim(), + machine: `Machine ${element.dataset.axisStateMachine}`, + operation: `Operation ${element.dataset.axisStateOperation}`, mode: element.dataset.axisTaskMode, program: element.dataset.axisProgramStatus, runEnabled: element.dataset.axisRunEnabled, diff --git a/scripts/test_xyzac_refresh_single_program_browser.mjs b/scripts/test_xyzac_refresh_single_program_browser.mjs index 4b2d917..d60cecb 100644 --- a/scripts/test_xyzac_refresh_single_program_browser.mjs +++ b/scripts/test_xyzac_refresh_single_program_browser.mjs @@ -31,8 +31,8 @@ const state = () => main.evaluate((element) => ({ sourceHash: element.dataset.axisProgramSourceHash, program: element.dataset.axisProgramStatus, runEnabled: element.dataset.axisRunEnabled, - machine: element.querySelector(".machine-state")?.textContent?.trim(), - operation: element.querySelector(".operation-state")?.textContent?.trim(), + machine: `Machine ${element.dataset.axisStateMachine}`, + operation: `Operation ${element.dataset.axisStateOperation}`, homed: element.dataset.axisHomed, line: element.dataset.axisCurrentLine, queue: element.dataset.axisTpQueueDepth, diff --git a/wasm-core/CMakeLists.txt b/wasm-core/CMakeLists.txt index 944eb25..990d7e7 100644 --- a/wasm-core/CMakeLists.txt +++ b/wasm-core/CMakeLists.txt @@ -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/nml_intf/emcpose.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 ${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) 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) target_link_libraries(sim_api_test PRIVATE sim_core) target_include_directories(sim_api_test PRIVATE include) 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_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_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 OUTPUT_NAME "linuxcnc-sim-core" SUFFIX ".js" diff --git a/wasm-core/include/sim_native_homing.h b/wasm-core/include/sim_native_homing.h new file mode 100644 index 0000000..b3a022d --- /dev/null +++ b/wasm-core/include/sim_native_homing.h @@ -0,0 +1,15 @@ +#ifndef SIM_NATIVE_HOMING_H +#define SIM_NATIVE_HOMING_H +#include +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 diff --git a/wasm-core/src/sim_native_homing.c b/wasm-core/src/sim_native_homing.c new file mode 100644 index 0000000..68f43c4 --- /dev/null +++ b/wasm-core/src/sim_native_homing.c @@ -0,0 +1,168 @@ +#include +#include +#include +#include "../include/sim_native_homing.h" +#ifdef __EMSCRIPTEN__ +#include +#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; +} diff --git a/wasm-core/tests/sim_native_homing_test.c b/wasm-core/tests/sim_native_homing_test.c new file mode 100644 index 0000000..d1ce21e --- /dev/null +++ b/wasm-core/tests/sim_native_homing_test.c @@ -0,0 +1,43 @@ +#include "sim_native_homing.h" +#include +#include +#include + +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; +} diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx index da792ca..e2a0eca 100644 --- a/web/react/src/App.tsx +++ b/web/react/src/App.tsx @@ -6,6 +6,7 @@ import { SIM_CASE_CATALOG } from "../../workspace/sim-case-catalog.mjs"; import { loadSimulationCase, toWorkerConfiguration } from "../../workspace/sim-case-loader.mjs"; import { axisIcon } from "./sourceAssets"; import { HoldButton } from "./HoldButton"; +import { MachineStatus } from "./MachineStatus"; import { AxisOperationController } from "../../app/axis-operation-controller.mjs"; import { XyzacDiagnostics, XyzacLiveParameters, XyzacProgramPane, XyzacTrtPanel } from "./XyzacTrtPanel"; import { XYZAC_CASE, projectXyzacPanel, type XyzacOperationTrace } from "./xyzac-panel-contract"; @@ -13,7 +14,6 @@ import { XYZAC_CASE, projectXyzacPanel, type XyzacOperationTrace } from "./xyzac export const SELECTED_XYZAC_CASE = 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 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"); // Button-to-command sequencing is defined in // docs/linuxcnc-axis-web-control-panel-source-logic.md; React only projects @@ -54,13 +54,11 @@ function AxisRadio({ axis, selected, onChange, disabled, reason }: { axis: strin return ; } -function MachineSequence({ wasmReady, allHomed, canHome, homeReason, onHomeAll }: { wasmReady: boolean; allHomed: boolean; canHome: boolean; homeReason: string; onHomeAll: () => void }) { - return
-
Machine sequence{wasmReady ? (allHomed ? "HOMED" : "NOT HOMED") : "WASM PENDING"}
-
+function MachineSequence({ wasmReady, allHomed, canHome, homeReason, onHomeAll, children }: { wasmReady: boolean; allHomed: boolean; canHome: boolean; homeReason: string; onHomeAll: () => void; children?: React.ReactNode }) { + return
+ {children} {allHomed ? "RE-HOME ALL" : "HOME ALL"} -
-
; + ; } function ManualControl({ action, canJog, canHome, canSpindle, canCoolant, canTouchOff, canToolTouchOff, selectedAxis, setSelectedAxis, spindleDirection, spindleBrake, coolant, onSpindle, onCoolant, onHomeAxis, onTouchOff, onToolTouchOff, active, metric, reasons, devices }: { action: Action; canJog: boolean; canHome: boolean; canSpindle: boolean; canCoolant: boolean; canTouchOff: boolean; canToolTouchOff: boolean; selectedAxis: string; setSelectedAxis: (axis: string) => void; spindleDirection: number; spindleBrake: boolean; coolant: { mist: boolean; flood: boolean }; onSpindle: (direction: number) => void; onCoolant: (key: "mist" | "flood") => void; onHomeAxis: () => void; onTouchOff: () => void; onToolTouchOff: () => void; active: boolean; metric: boolean; reasons: Record; devices: Record }) { @@ -94,7 +92,7 @@ function ManualControl({ action, canJog, canHome, canSpindle, canCoolant, canTou jog(-1)} onRelease={jogRelease} label={`Jog ${selectedAxis.toUpperCase()} negative`}>- jog(1)} onRelease={jogRelease} label={`Jog ${selectedAxis.toUpperCase()} positive`}>+ -
Home AxisTouch OffTool Touch Off
+
Touch OffTool Touch Off

Spindle:

onSpindle(-1)} /> onSpindle(0)} ariaLabel="Stop spindle">Stop Spindle onSpindle(1)} />
action("SPINDLE_COMMAND", { spindle: 0, delta: -100 })} onRelease={() => action("SPINDLE_CONSTANT", { spindle: 0 })} label="Decrease spindle speed">- action("SPINDLE_COMMAND", { spindle: 0, delta: 100 })} onRelease={() => action("SPINDLE_CONSTANT", { spindle: 0 })} label="Increase spindle speed">+ @@ -227,6 +225,7 @@ export function App() { const [programPath, setProgramPath] = useState(PROGRAM_PATH); const [programLoading, setProgramLoading] = useState(true); const [selectedAxis, setSelectedAxis] = useState("x"); + const [selectedHomeJoint, setSelectedHomeJoint] = useState(0); const [leftTab, setLeftTab] = useState<"manual" | "mdi" | "diagnostics">("manual"); const [rightTab, setRightTab] = useState<"preview" | "dro">("preview"); const [view, setView] = useState<"z" | "z2" | "x" | "y" | "y2" | "p">("p"); @@ -357,7 +356,11 @@ export function App() { homeTimer.current = setInterval(() => { if (homeStepPending.current) return; homeStepPending.current = true; - void operationController.execute("SIM_STEP", { cycles: 5, response: "motion_delta" }, { state: axisState, gate: false }) + void operationController.execute("SIM_STEP", { cycles: 5 }, { state: axisState, gate: false }) + .then((operation) => { + const panel = (operation.response as { payload?: { panel_snapshot?: { motion?: { homing?: boolean; homed?: boolean } } } }).payload?.panel_snapshot; + if (panel?.motion?.homing === false) { stopHomeTimer(); setCommandStatus(panel.motion.homed ? "HOME complete" : "HOME finished; check joint status"); } + }) .catch(() => stopHomeTimer()) .finally(() => { homeStepPending.current = false; }); }, 50); @@ -387,7 +390,18 @@ export function App() { } const pending = operationController.execute(kind, payload, { state: axisState, gate, ...(action ? { action } : {}) }); setOperationTrace(operationController.exportTrace()); - 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) => { + 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(); + const result = (operation.response as { payload?: { homing?: { phase?: string }; panel_snapshot?: { motion?: { homing?: boolean } } } }).payload; + if (kind === "HOME" && result?.homing?.phase === "accepted") startHomeTimer(); + // Ordinary abort keeps sampling the native free_tp deceleration. + if (kind === "PROGRAM_ABORT") { if (result?.panel_snapshot?.motion?.homing) startHomeTimer(); else stopHomeTimer(); } + if (["ESTOP", "POWER"].includes(kind)) stopHomeTimer(); + if (kind === "JOG_START") startJogTimer(String(payload.axis ?? "x"), Number(payload.direction)); + if (kind === "JOG_STOP") stopJogTimer(); + }).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); const code = error.message; setCommandStatus(`${kind} blocked: ${code}`); @@ -401,22 +415,21 @@ export function App() { const releaseEstop = () => dispatch("ESTOP", { active: false }, false); const toggleEstop = () => { if (!isEstop) { emergencyStop(); return; } void releaseEstop().catch(() => undefined); }; const powerOn = () => { if (programLoading || !gates.power.enabled) return; void dispatch("POWER", { active: isEstop ? false : !machineOn }, false).catch(() => undefined); }; - const homeAll = () => { + const homeAll = async () => { if (programLoading || !gates.home.enabled) return; + if (axisState.task.mode !== "MANUAL") { + try { await dispatch("SET_MODE", { mode: "MANUAL" }); } catch { return; } + } const confirm = globalThis.confirm; if (allHomed && typeof confirm === "function" && !confirm("All joints are already homed. Re-home all joints?")) return; void dispatch("HOME", { all: true }).catch(() => undefined); }; const homeAxis = () => { if (programLoading || !gates.home.enabled) return; - // switchkins.c reports KINEMATICS_BOTH even when its active type is 0. - // 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 joint = selectedHomeJoint; 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); + void dispatch("HOME", { joint }).catch(() => undefined); }; const stopPreviewProgress = () => { if (previewProgressTimer.current) { @@ -645,7 +658,7 @@ export function App() { const mdiMotionActive = !programRunning && String(state.task?.mode ?? "").toUpperCase() === "MDI" && wasmQueueDepth > 0; if ((programRunning && !programComplete) || mdiMotionActive || Boolean(state.snapshot?.motion?.stepping)) startRunTimer(); else stopRunTimer(); if (wasmQueueDepth === 0 && !jogStepPending.current) stopJogTimer(); - if (wasmQueueDepth === 0 && !homeStepPending.current) stopHomeTimer(); + if (!state.snapshot?.motion?.homing && !homeStepPending.current) stopHomeTimer(); if (!ready) { stopRunTimer(); stopHomeTimer(); stopJogTimer(); } }, [programRunning, ready, state.program?.program_status, state.task?.mode, state.snapshot?.wasm_motion]); const spindleState = (state.devices as Record | null)?.spindles?.[0] as Record | undefined; @@ -708,7 +721,8 @@ export function App() { const homedAxes = Array.isArray(state.snapshot?.motion?.homed_axes) ? state.snapshot.motion.homed_axes : []; const allHomed = homedAxes.length >= 5 && homedAxes.slice(0, 5).every(Boolean); const xyzacCommand = (kind: string, payload: Record = {}, action?: string) => { void dispatch(kind, payload, true, action ?? null).catch(() => undefined); }; - const position = xyzacView.pose ? `X${Number(xyzacView.pose.x).toFixed(3)} Y${Number(xyzacView.pose.y).toFixed(3)} Z${Number(xyzacView.pose.z).toFixed(3)} A${Number(xyzacView.pose.a).toFixed(3)} C${Number(xyzacView.pose.c).toFixed(3)}` : "WASM state pending"; + const loadedProgramPath = String(state.program?.path ?? programPath); + const loadedProgramName = loadedProgramPath.split("/").at(-1) ?? loadedProgramPath; useEffect(() => { if (!uiCacheHydrated.current || !wasmSnapshotReady) return; if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); @@ -733,19 +747,18 @@ export function App() { }, 500); return () => { if (uiCacheSaveTimer.current) { clearTimeout(uiCacheSaveTimer.current); uiCacheSaveTimer.current = null; } }; }, [client, wasmSnapshotReady, selectedAxis, leftTab, rightTab, view, zoom, rotateMode, coordType, displayType, metric, touchOffSystem, layers, casePath]); - return
-
AXIS AXISLinuxCNC Web/WASM
{state.status.toUpperCase()}{taskState}
+ return
+
AXIS AXISLinuxCNC Web/WASM
Connection: {state.status.toUpperCase()}
execute("PROGRAM_STEP")} /> execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} pressed={relief.pauseResume} /> stopProgram()} pressed={relief.abort} /> setZoom((value) => Math.min(2, value + .2))} /> setZoom((value) => Math.max(.5, value - .2))} /> { setView("z"); setRightTab("preview"); }} /> { setView("z2"); setRightTab("preview"); }} /> { setView("x"); setRightTab("preview"); }} /> { setView("y"); setRightTab("preview"); }} /> { setView("y2"); setRightTab("preview"); }} /> { setView("p"); setRightTab("preview"); }} /> setRotateMode((value) => !value)} /> xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} />
-
Load 3D_Chips.ngc{programLoading ? "PROGRAM_LOAD pending: LinuxCNC WASM preview in progress..." : caseStatus}{previewProgress &&
{previewProgress.detail}{previewProgress.percent}%{previewProgress.percent}%
}{commandStatus}
- +
Load 3D_Chips.ngc{programLoading ? "Loading program..." : caseStatus.startsWith("blocked:") ? caseStatus : `Program: ${loadedProgramName}`}{previewProgress &&
{previewProgress.detail}{previewProgress.percent}%{previewProgress.percent}%
}
+ -
+
{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => )}
{aboutOpen &&
{ if (event.target === event.currentTarget) setAboutOpen(false); }}>

LinuxCNC AXIS Web/WASM

XYZAC TRT

setAboutOpen(false)}>OK
} -
{taskState}Machine {machineState}Operation {operationState}Tool {toolNumber}{position}{state.error ? `Error: ${state.error.code}` : commandStatus}
; } diff --git a/web/react/src/MachineStatus.tsx b/web/react/src/MachineStatus.tsx new file mode 100644 index 0000000..ea0dd63 --- /dev/null +++ b/web/react/src/MachineStatus.tsx @@ -0,0 +1,16 @@ +import type { SourceStatus } from "./protocol"; + +export function MachineStatus({ status, commandStatus }: { status?: SourceStatus; commandStatus: string }) { + return
+
+ Task mode {status?.task.mode ?? "PENDING"} + Task state {status?.task.state ?? "PENDING"} + Interpreter {status?.task.interpState ?? "PENDING"} + Motion mode {status?.traj.mode ?? "PENDING"} + Motion type {status?.traj.motion_type ?? "-"} + Homing {status ? status.homing_active ? "ACTIVE" : "IDLE" : "PENDING"} + Machine sequence {status ? status.joint.length > 0 && status.joint.every((joint) => joint.homed) ? "HOMED" : "NOT HOMED" : "WASM PENDING"} +
+ {commandStatus} +
; +} diff --git a/web/react/src/protocol.ts b/web/react/src/protocol.ts index a5d5a21..79db93e 100644 --- a/web/react/src/protocol.ts +++ b/web/react/src/protocol.ts @@ -18,6 +18,7 @@ export type MessageEnvelope = { }; export type MotionSnapshot = { + homing?: boolean; stepping?: boolean; axes?: number[]; joints?: number[]; @@ -42,6 +43,18 @@ export type MotionSnapshot = { 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 = { queue?: Array<{ id?: number; source_line?: number | null; start?: Record; end?: Record }>; active?: { id?: number; source_line?: number | null; start?: Record; end?: Record } | null; @@ -49,6 +62,7 @@ export type TrajectorySnapshot = { }; export type SimSnapshotV1 = { + source_status?: SourceStatus; schema?: string; snapshot_id?: string; base_snapshot_id?: string; @@ -114,6 +128,7 @@ export type XyzacCapabilitySnapshot = { }; export type PanelSnapshotV1 = { + source_status?: SourceStatus; schema: "axis-panel-snapshot.v1"; context_id: string; session_id: string; diff --git a/web/react/src/store.ts b/web/react/src/store.ts index 21400e2..4ac0c55 100644 --- a/web/react/src/store.ts +++ b/web/react/src/store.ts @@ -83,6 +83,12 @@ function projection(result: Record, message: MessageEnvelope, p } : previous.snapshot.visual; const nextSnapshot = { ...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)?.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, state_hash: String(result.state_hash ?? previous.snapshot.state_hash), task: (result.task as SimSnapshotV1["task"]) ?? previous.snapshot.task, @@ -147,6 +153,7 @@ function projection(result: Record, message: MessageEnvelope, p const axes = [pose.x, pose.y, pose.z, pose.a, pose.c].map((value) => Number.isFinite(value) ? value : 0); const snapshot = { schema: panel.schema, + source_status: panel.source_status, simulation_tick: panel.simulation_tick, state_hash: panel.state_hash, task: panel.task as SimSnapshotV1["task"], diff --git a/web/react/src/styles.css b/web/react/src/styles.css index aed04c3..77f82e7 100644 --- a/web/react/src/styles.css +++ b/web/react/src/styles.css @@ -73,7 +73,7 @@ button { color: inherit; } .machine-sequence-heading { display: grid; gap: 2px; min-width: 148px; } .machine-sequence-heading strong { font-size: 12px; } .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: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; } .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-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) { .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-row output { font-size: 13px; } .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; } diff --git a/web/worker/axis-state.mjs b/web/worker/axis-state.mjs index b2ff72d..06482e4 100644 --- a/web/worker/axis-state.mjs +++ b/web/worker/axis-state.mjs @@ -41,6 +41,7 @@ export function axisState({ task = {}, program = {}, motion = {}, devices = {}, const machineState = hasIoState ? ioEstop ? "ESTOP" : ioReady ? "ON" : "OFF" : taskState === "ESTOP" ? "ESTOP" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(taskState) ? "ON" : "OFF"; 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 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"; diff --git a/web/worker/sim-session.mjs b/web/worker/sim-session.mjs index f18974b..ae709d9 100644 --- a/web/worker/sim-session.mjs +++ b/web/worker/sim-session.mjs @@ -25,6 +25,7 @@ import { axisSourceUi } from "./axis-source-ui.mjs"; import { WasmMotionQueue } from "./wasm-motion-queue.mjs"; import { WasmLinuxcncTp } from "./wasm-linuxcnc-tp.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 { WasmXyzacIo } from "./wasm-xyzac-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 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([ "SESSION_ATTACH", "LOAD_WORKSPACE", "SIM_STEP", "DESTROY", "CHECKPOINT", "RECOVER", "ABORT", @@ -135,13 +135,8 @@ export class SimSession { this.xyzacIo = null; this.xyzacDeviceIo = null; this.xyzacProgram = null; + this.homingHistory = []; 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.xyzacMotionCycleTime = 0.01; this.xyzacTaskCycleTicks = 1; @@ -192,7 +187,7 @@ export class SimSession { // Compact payloads are only valid for successful motion samples. Error // responses must retain their code/reason instead of being rewritten into // 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 // thousands of immutable segments on unrelated control responses makes a // loaded program delay abort/power/home commands behind structured-clone @@ -263,6 +258,13 @@ export class SimSession { simulation_tick: tick, payload: { ...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 } : {}), motion_authority: responsePayload.motion_authority ?? this.motionAuthority, task_authority: responsePayload.task_authority ?? this.task.authority(), @@ -275,6 +277,7 @@ export class SimSession { wasmMotionProjection(program = null) { 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 }; + if (this.xyzacProgram && !this.wasmHomeLimit.active) this.wasmHomeLimit.sync(wasmMotion.state.joint_commanded); const home = this.wasmHomeLimit.read(); const io = this.xyzacIo?.canonical?.(); 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) }, on_any_limit: Boolean(home.state?.limit_error), 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, stepping: this.wasmMotion.isStepping?.() ?? false, 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 ui = axisSourceUi({ task_state: axis.source_task_state, interp_state: axis.source_interp_state, taskfile: Boolean(this.xyzacProgram?.path), queued_mdi_commands: task.queued_mdi_commands ?? 0, - motion_mode: 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), 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 ? { load: this.xyzacCommandGate({ kind: "PROGRAM_LOAD", payload: {} }), @@ -483,6 +490,14 @@ export class SimSession { task, program, 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 }, overrides: { feed: motion.feed_override, spindle: [motion.spindle_override] }, wasm_motion: wasmMotion, @@ -556,6 +571,8 @@ export class SimSession { const program = this.xyzacProgram.snapshot(); const io = this.xyzacIo?.canonical?.() ?? {}; 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) { 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 } }; } 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" }; } const estop = Boolean(io.estop); @@ -659,8 +676,10 @@ export class SimSession { // canonical P-number form (G54=P1, ..., G59.3=P9). const block = `G10 L20 P${systemNumber} ${axis}${formatted}`; const savedMode = this.task.state.mode; + const savedMotionMode = this.wasmHomeLimit.mode; try { if (savedMode !== "MDI") this.task.setMode("MDI"); + this.wasmHomeLimit.setMode("COORD"); this.wasmMotion.setPaused(false); const synchronized = this.xyzacProgram.synchronizePosition(this.wasmMotion.read().state.position); if (synchronized.status !== "verified") return synchronized; @@ -675,6 +694,7 @@ export class SimSession { return { status: "error", code: String(error?.message ?? error) }; } finally { 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) => { 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"); } } 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.wasmMotion.abort(); this.wasmMotion.setPaused(true); - this.pendingHome = null; - this.xyzacMotionMode = "TELEOP"; + this.wasmHomeLimit.cancel(true); const resetProgram = this.resetXyzacProgramToLoaded(); if (resetProgram.status !== "verified") return finish(this.error(input, "program_restart", resetProgram.code ?? "program_reload_failed"), "failed"); this.task.setProgram("loaded", 0); @@ -1460,7 +1479,11 @@ export class SimSession { } const mode = String(input.payload?.mode ?? "").toUpperCase(); 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 })); } case "POWER": { @@ -1492,8 +1515,7 @@ export class SimSession { this.xyzacAppendDeferredOnActive = false; this.wasmMotion.abort(); this.wasmMotion.setPaused(true); - this.pendingHome = null; - this.xyzacMotionMode = "TELEOP"; + this.wasmHomeLimit.cancel(true); const resetProgram = this.resetXyzacProgramToLoaded(); if (resetProgram.status !== "verified") return finish(this.error(input, "program_restart", resetProgram.code ?? "program_reload_failed"), "failed"); 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 WasmMotionQueue(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: 1e9 }); this.xyzacProgram = new WasmProgramRuntime(this.core); + this.homingHistory = []; this.xyzacProgramKinematicsMode = XYZAC_MODES.identity; - this.xyzacMotionMode = "TELEOP"; - this.pendingHome = null; if (typeof this.wasmMotion.setKinematics === "function") this.wasmMotion.setKinematics(this.xyzacKinematics); this.xyzacRapidVelocity = Number(configuration.kinematics?.max_linear_velocity ?? 35); const linearLimit = Number(configuration.kinematics?.axis_linear_velocity_limit ?? 20); @@ -1627,7 +1648,7 @@ export class SimSession { } if (configuration?.home_limits && this.motionAuthority === "wasm") { 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, kinematicsType: configuration.home_limits.kinematics_type ?? 1, coordinates: configuration.home_limits.coordinates ?? "XYZ", @@ -1681,6 +1702,7 @@ export class SimSession { })) : finish(this.coreError(input, loadResult, "sim_load_workspace"), "failed"); } case "SIM_STEP": { + const homingSample = Boolean(this.xyzacProgram && this.wasmHomeLimit.active); const cycles = input.payload?.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"); @@ -1711,6 +1733,7 @@ export class SimSession { this.scheduler.step(cycles, { task: (nextTick) => { if (this.xyzacProgram) { + if (this.wasmHomeLimit.active) return; const wasmState = this.xyzacProgram.snapshot(); if (wasmState.status !== 2) return; if (this.pendingTaskSync !== 0) { @@ -1907,7 +1930,24 @@ export class SimSession { let wasmMotion = this.wasmMotion.read(); const programPaused = this.xyzacProgram?.snapshot().status === 3; 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 (this.wasmMotion.controls.paused && this.wasmMotion.read().state.queue_depth === 0) continue; if (this.planner === "linuxcnc-tp" && typeof this.wasmMotion.setExecutionState === "function") { 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 (this.motionAuthority === "wasm") { if (wasmMotion.status === "verified") { @@ -2051,7 +2079,7 @@ export class SimSession { ? null : this.timeline.checkpoint(input.seq, this.tick, snapshotState, this.timeline.snapshots.latest()?.snapshot_id ?? null); 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 } : {}), motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: snapshotState.authoritative, @@ -2143,7 +2171,7 @@ export class SimSession { if (this.xyzacProgram) { const previousProgramStatus = this.xyzacProgram.snapshot().program_status; 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"); } const result = this.program.start(); @@ -2165,6 +2193,7 @@ export class SimSession { }), result.status === "verified" ? "verified" : "failed"); } 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"; if (this.xyzacProgram) { // PAUSE/RESUME are interpreter and TP state transitions. They must @@ -2275,6 +2304,7 @@ export class SimSession { const current = this.xyzacProgram.snapshot(); const idle = !["RUNNING", "PAUSED"].includes(this.task.state.state); this.task.setMode("AUTO"); + this.wasmHomeLimit.setMode("COORD"); if (idle) { const started = this.xyzacProgram.start(); 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; if (this.xyzacProgram) { this.task.setMode("MDI"); + this.wasmHomeLimit.setMode("COORD"); // AXIS send_mdi_command() leaves the interpreter/TP runnable after // a previous manual jog stop. JOG_STOP intentionally pauses the // WASM TP, so clear that transport pause before queuing MDI motion; @@ -2416,6 +2447,7 @@ export class SimSession { 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"); this.task.setMode("MDI"); + this.wasmHomeLimit.setMode("COORD"); const result = this.switchXyzacKinematics(mode); 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"); @@ -2430,49 +2462,22 @@ export class SimSession { // that compatible while making the explicit Web contract // 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 && payload.joint !== undefined) return finish(this.error(input, "home_limit", "home_target_ambiguous"), "failed"); + if (payload.axis !== undefined) return finish(this.error(input, "home_limit", "home_joint_requires_joint_mode"), "failed"); + const requestedJoint = payload.joint; 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) // 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"); } - const modeBefore = this.xyzacMotionMode; - 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"); - 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"); + try { + this.wasmHomeLimit.sync(this.wasmMotion.read().state.joint_commanded); + const home = this.wasmHomeLimit.home(input.seq, all ? null : requestedJoint); + this.homingHistory = [{ tick: home.state.tick, transition: "command", joint: home.state.joint }]; + return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, + homing: { phase: "accepted", joint: all ? -1 : requestedJoint, source: "linuxcnc-homing.c" }, + wasm_home_limit: home, motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified"); + } catch (error) { return finish(this.error(input, "home_limit", error.message), "failed"); } } const result = this.motion.home(input.payload?.axis ?? null); 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"); 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"); - // The Web jog buttons are world-axis controls. Mirror AXIS's - // teleop selection for this adapter before submitting the TP jog. - this.xyzacMotionMode = "TELEOP"; + if (this.xyzacProgram) this.wasmHomeLimit.setMode("TELEOP"); this.wasmMotion.setPaused(false); const requestedIncrement = input.payload?.increment; const continuous = requestedIncrement === null || input.payload?.continuous === true; diff --git a/web/worker/test_axis_source_execution.mjs b/web/worker/test_axis_source_execution.mjs index 4bdf053..7b440e2 100644 --- a/web/worker/test_axis_source_execution.mjs +++ b/web/worker/test_axis_source_execution.mjs @@ -29,9 +29,8 @@ assert.equal(panel().ui_gates.run.enabled, true); assert.equal(panel().ui_gates.step.enabled, true); assert.equal(send("PROGRAM_RUN").payload.gate_reason, "xyzac_not_all_homed"); ok("HOME", { all: true }); -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("SIM_STEP", { cycles: 1 }); +assert.equal(send("HOME", { axis: "x" }).payload.message_key, "home_joint_requires_joint_mode"); ok("PROGRAM_STEP"); assert.equal(panel().program.current_line, 0, "first native Step starts at line zero and pauses"); diff --git a/web/worker/test_native_homing.mjs b/web/worker/test_native_homing.mjs new file mode 100644 index 0000000..4878ff7 --- /dev/null +++ b/web/worker/test_native_homing.mjs @@ -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"] })); diff --git a/web/worker/test_xyzac_home_source_flow.mjs b/web/worker/test_xyzac_home_source_flow.mjs index 7c1ad93..d506460 100644 --- a/web/worker/test_xyzac_home_source_flow.mjs +++ b/web/worker/test_xyzac_home_source_flow.mjs @@ -1,74 +1,3 @@ -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"], -})); +// Preserve the documented test entry point; the assertions now verify the +// original homemod rather than the retired coordinated return-to-zero adapter. +import "./test_native_homing.mjs"; diff --git a/web/worker/test_xyzac_live_path_lifecycle.mjs b/web/worker/test_xyzac_live_path_lifecycle.mjs index ffbab49..54f2959 100644 --- a/web/worker/test_xyzac_live_path_lifecycle.mjs +++ b/web/worker/test_xyzac_live_path_lifecycle.mjs @@ -34,6 +34,7 @@ assert.equal(loadEpoch, 0); assert.ok(previewCount > 0); send("POWER", { active: true }); send("HOME", { all: true }); +send("SIM_STEP", { cycles: 1 }); const run = send("PROGRAM_RUN"); const runEpoch = epoch(run); diff --git a/web/worker/test_xyzac_pause_resume_consistency.mjs b/web/worker/test_xyzac_pause_resume_consistency.mjs index 6c1712b..e322db5 100644 --- a/web/worker/test_xyzac_pause_resume_consistency.mjs +++ b/web/worker/test_xyzac_pause_resume_consistency.mjs @@ -24,7 +24,7 @@ const snapshot = () => session.xyzacProgram.snapshot(); const sample = (cycles = 5) => send("SIM_STEP", { cycles, response: "motion_delta" }); send("HELLO"); send("SESSION_ATTACH"); send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }); send("PROGRAM_LOAD", { path: "pause-reading.ngc", source: "G21 G90\n" + Array.from({ length: 80 }, (_, i) => `G1 X${i % 2 ? 0 : 2} F600`).join("\n") + "\nM2\n" }); -send("POWER", { active: true }); send("HOME", { all: true }); send("PROGRAM_RUN"); sample(); +send("POWER", { active: true }); send("HOME", { all: true }); sample(1); send("PROGRAM_RUN"); sample(); for (let round = 0; round < 3; round++) { send("PROGRAM_PAUSE"); const pose = motion().position, line = snapshot().current_line, queue = motion().queue_depth; diff --git a/web/worker/test_xyzac_program_rerun.mjs b/web/worker/test_xyzac_program_rerun.mjs index a313dca..c187e32 100644 --- a/web/worker/test_xyzac_program_rerun.mjs +++ b/web/worker/test_xyzac_program_rerun.mjs @@ -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("POWER", { active: 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"); let completed; diff --git a/web/worker/test_xyzac_recovery.mjs b/web/worker/test_xyzac_recovery.mjs index 3697a74..fd4acb5 100644 --- a/web/worker/test_xyzac_recovery.mjs +++ b/web/worker/test_xyzac_recovery.mjs @@ -28,7 +28,8 @@ async function create(id) { const loaded = send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }); assert.equal(loaded.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 }; } diff --git a/web/worker/test_xyzac_source_widget_gates.mjs b/web/worker/test_xyzac_source_widget_gates.mjs index ce6220b..e90435b 100644 --- a/web/worker/test_xyzac_source_widget_gates.mjs +++ b/web/worker/test_xyzac_source_widget_gates.mjs @@ -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"); } send("ESTOP", { active: false }); send("POWER", { active: true }); send("HOME", { all: true }); +send("SIM_STEP", { cycles: 1 }); send("SET_MODE", { mode: "MDI" }); assert.equal(snapshot().gates.spindle.enabled, true); assert.equal(snapshot().gates.touchOff.enabled, true); diff --git a/web/worker/test_xyzac_touch_off_source_flow.mjs b/web/worker/test_xyzac_touch_off_source_flow.mjs index bf57d20..26158cb 100644 --- a/web/worker/test_xyzac_touch_off_source_flow.mjs +++ b/web/worker/test_xyzac_touch_off_source_flow.mjs @@ -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("POWER", { active: 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 }); assert.equal(jogStart.payload.status, "verified"); diff --git a/web/worker/wasm-home-limit.mjs b/web/worker/wasm-home-limit.mjs index a958bd8..7301b1b 100644 --- a/web/worker/wasm-home-limit.mjs +++ b/web/worker/wasm-home-limit.mjs @@ -108,17 +108,6 @@ export class WasmHomeLimit { 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) { 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" }; diff --git a/web/worker/wasm-native-homing.mjs b/web/worker/wasm-native-homing.mjs new file mode 100644 index 0000000..545f9cb --- /dev/null +++ b/web/worker/wasm-native-homing.mjs @@ -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); } +}