From eb5c58cdb316569632cf239046c36a58b6d03685 Mon Sep 17 00:00:00 2001 From: wangdequan Date: Wed, 9 Sep 2026 09:31:32 +0800 Subject: [PATCH] Fix web homing motion and synchronize preview state --- ...cnc-axis-web-control-panel-source-logic.md | 74 +++++++++--- scripts/test_xyzac_home_reset_browser.mjs | 79 +++++++++++++ scripts/test_xyzac_panel_buttons_browser.mjs | 3 +- web/react/src/App.tsx | 29 ++++- web/react/src/XyzacToolPreview.tsx | 24 ++++ web/react/src/protocol.ts | 1 + web/react/src/xyzac-visual-adapter.ts | 4 +- web/worker/sim-session.mjs | 105 ++++++++++++++++-- web/worker/test_axis_source_execution.mjs | 4 +- web/worker/test_xyzac_home_source_flow.mjs | 74 ++++++++++++ web/worker/wasm-home-limit.mjs | 11 ++ 11 files changed, 375 insertions(+), 33 deletions(-) create mode 100644 scripts/test_xyzac_home_reset_browser.mjs create mode 100644 web/worker/test_xyzac_home_source_flow.mjs diff --git a/docs/linuxcnc-axis-web-control-panel-source-logic.md b/docs/linuxcnc-axis-web-control-panel-source-logic.md index 236fbb9..c83216f 100644 --- a/docs/linuxcnc-axis-web-control-panel-source-logic.md +++ b/docs/linuxcnc-axis-web-control-panel-source-logic.md @@ -155,7 +155,7 @@ families because their cardinality comes from the selected INI. | Optional Stop and Block Delete | `toggle_optional_stop`, `toggle_block_delete` | Visible `SOURCE_BLOCKED`; block delete's source wait, MANUAL switch and preview reload must not be approximated by a local checkbox | | 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`; world-axis Home rejects for KINEMATICS_BOTH; individual joint selection and Unhome remain `SOURCE_BLOCKED` | +| Home all/joint and Unhome all/joint | `home_all_joints`, `home_joint_number`, `unhome_all_joints`, `unhome_joint_number` | Home all: `SOURCE_ADAPTER`; Web Home Axis maps the fixed XYZAC axis selection to a numeric joint and retains the source KINEMATICS_BOTH restriction; individual joint selection and Unhome remain `SOURCE_BLOCKED` | | Zero P1..P9 and G92 coordinate systems | `clear_offset` | `SOURCE_BLOCKED`; Touch Off is not an alias for clearing a coordinate system | | 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 | @@ -165,7 +165,7 @@ families because their cardinality comes from the selected INI. | Clear Live Plot | `clear_live_plot` -> `LivePlotter.clear` -> `positionlogger.clear` | `SOURCE_ADAPTER`; this is the only native live-trace deletion action | | Axis/joint selection and jog increment | `ja_rbutton`, `jogincrement`, `activate_ja_widget` | XYZAC axis and increment selection: `SOURCE_ADAPTER`; native joint selection is `SOURCE_BLOCKED` | | Jog negative/positive and key jog | `jog_minus`, `jog_plus`, `jog_on`, `jog_off` | `SOURCE_ADAPTER`; press/release semantics and Manual-tab guard apply | -| Home Axis, Touch Off, Tool Touch Off, Override Limits | corresponding `zerohome`/`override` widgets | World-axis Home reproduces source rejection for KINEMATICS_BOTH; Touch Off: selected-coordinate adapter; Tool Touch Off/Override Limits: visible `SOURCE_BLOCKED` | +| Home Axis, Touch Off, Tool Touch Off, Override Limits | corresponding `zerohome`/`override` widgets | Home Axis: fixed XYZAC axis-to-joint `SOURCE_ADAPTER` with source mode transaction; Touch Off: selected-coordinate adapter; Tool Touch Off/Override Limits: visible `SOURCE_BLOCKED` | | Spindle CCW/Stop/CW, slower/faster, brake | `spindle`, `spindle_decrease`, `spindle_increase`, `spindle_constant`, `brake` | `SOURCE_ADAPTER`; button-state formulas and press/release ordering apply | | 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 +184,7 @@ command are blocked instead of being silently mapped to another operation. | --- | --- | --- | | `F1`, `F2`, lowercase `o`, `r`, `t`, `p`, `s`, `Escape`, `Ctrl-r`, `F7`-`F12` | E-stop, power, open, run, step, pause, resume, stop, reload, coolant and spindle handlers | Worker-backed commands where the corresponding control is supported. Display labels remain `O/R/T/P/S`. | | uppercase `R`, `F` | `task_reverse`, `task_forward` | Source controls are recorded as blocked because the selected WASM program ABI has no reverse/forward command; they are never mapped to Run. | -| `Home`, `Ctrl-Home`, `Shift-Home`, `End`, `Ctrl-End` | selected joint home, home all, zero selected G54 axis, system touch off, tool touch off | Home and system Touch Off have adapters; zero-G54 and Tool Touch Off are blocked. | +| `Home`, `Ctrl-Home`, `Shift-Home`, `End`, `Ctrl-End` | selected joint home, home all, zero selected G54 axis, system touch off, tool touch off | Home uses the fixed XYZAC axis-to-joint adapter (and Home All uses the all-joint transaction); system Touch Off has an adapter; zero-G54 and Tool Touch Off are blocked. | | lowercase `x`, `y`, `z`, `a` | Select the first four source axes | Axis selection adapter. Native AXIS does **not** bind lowercase `c` to C-axis selection; `c` selects continuous jog. C remains selectable only through its radio control. | | `-`, `=`, 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. | @@ -221,9 +221,13 @@ command are blocked instead of being silently mapped to another operation. 与原生 NML 指令细分仍是需单独验证的适配边界,不能用行数相同替代运动断言。 5. **回零类型不能混同。** `switchkins.c::kinematicsType()` 始终返回 `KINEMATICS_BOTH`;M429 的 `active_type=0` 不代表 KINEMATICS_IDENTITY。 - `home_joint` 对世界坐标字母会拒绝,而数字 joint 允许回零。 - 当前面板的轴字母按钮不能冒充 joint 模式;Home Axis 保留原 UI 公式, - 点击返回 `home_joint_requires_joint_mode`。Home All 仍可用。 + 原生 `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 约束。 6. **自动恢复标签模式。** `axis.tcl::update_state` 在 ON+IDLE 的状态边沿, 当前 task_mode 为 AUTO 时调用 `set_mode_from_tab`;显式 Open/Reload 完成后 也调用它。Web 必须通过排队的 `SET_MODE` 恢复所选 Manual/MDI 页,不能只 @@ -250,7 +254,7 @@ command are blocked instead of being silently mapped to another operation. | 功能 | 原生行为 / 当前边界 | 禁止的错误结论 | | --- | --- | --- | | Run 警告 | `run_warn` 对 XYZ 预览边界给出确认;当前 WASM 没有完整含刀偏 extents 警告事务 | 不得声称已复制 run_warn 的确认/取消交互 | -| Joint/World、Home Axis | KINEMATICS_BOTH 需要 joint 数字选择与 FREE/TELEOP 状态;当前世界轴面板只支持 Home All | 不得用 switchkins 编号判断是否允许坐标轴回零 | +| Joint/World、Home Axis | KINEMATICS_BOTH 需要 joint 数字选择与 FREE/TELEOP 状态;固定 XYZAC Web 面板以 X/Y/Z/A/C 的明确一对一映射提供 Home Axis 适配,执行时仍切入 FREE/joint 并由 WASM 返回数字 joint 结果 | 不得用 switchkins 编号判断 kinematics 类型,不得把世界轴字母直接交给 `home_joint`,不得把本地坐标归零冒充 joint home | | 速度滑块 | 原生线性/角速度独立,显示物理单位;当前 Jog/Max 的百分比与 6% 下限属于已公开的 Web 适配 | 不得把单一比例视为独立 angular jog 控制 | | 预览图层 | limits 现从 WASM XYZ 软限位 getter 读取;offsets 使用已实现的 G5x 平移;extents 使用 canonical 采样包围框 | G92/旋转/刀具偏置范围与原生文字标注仍未全量覆盖,不能称完整原生图层复刻 | | commanded/actual | 仿真当前两者相同,需要分别存在的 canonical 字段才可声明跟随误差显示 | 不得从 React 标签推导真实反馈差异 | @@ -352,8 +356,8 @@ safety adaptation, not attributed to AXIS. | Manual / MDI tabs | `SET_MODE` | `SOURCE_ADAPTER`: native tab callbacks are `ensure_manual` and `ensure_mdi`, including `set_motion_teleop` transitions. `axis.tcl::update_state` calls `set_mode_from_tab` only when returning to ON+IDLE from a non-idle/task-state transition while task mode is AUTO. No unrelated React effect may force a mode. | | Joint/World (teleop) mode | not exposed | AXIS View menu calls `set_teleop_mode`/`c.teleop_enable`; the selected WASM motion ABI has no canonical teleop setter, so Web does not pretend its coordinate mode is a joint/world toggle. | | Jog `-` / `+` | `JOG_START`, `SIM_STEP`, `JOG_STOP` | `SOURCE_ADAPTER`: source requires `manual_ok` and the visible Manual tab. Incremental sends one `JOG_INCREMENT`; Continuous sends one `JOG_CONTINUOUS` and motion continues until the deferred release issues `JOG_STOP`. Web servo sampling is transport/runtime work, not repeated user jog commands. | -| Home Axis | `HOME {axis}` | `manual_ok`, then `home_joint` checks axis versus joint selection. The selected switchkins module is always KINEMATICS_BOTH, so a world-axis letter is rejected with `home_joint_requires_joint_mode`; M429 does not change this type. | -| Home All / Re-home All | `HOME {all:true}` | `SOURCE_ADAPTER`: `manual_ok`, ensure MANUAL, if `all_homed()` ask the native re-home confirmation, then `set_motion_teleop(0)`, `c.home(-1)`, wait. Native text remains Home All; the Web `RE-HOME ALL` label is presentation-only and cannot trigger a hidden home during load. | +| Home 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. | | 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. | @@ -400,15 +404,15 @@ history rows intentionally share the family ID `mdi-history-select`. | `toolbar-clear-live-plot` | `clear_live_plot` -> `positionlogger.clear()` | Worker `VISUAL_CLEAR`; always visual-only | Advances `live_path_epoch`, clears Live Plot only and retains static preview | | `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`, then MANUAL | Repeat asks source confirmation; Web Re-home label is non-authoritative presentation only | +| `manual-home-all` | Machine Homing menu -> `home_all_joints` | `HOME {all:true}`; `manual_ok`, MANUAL, FREE/joint home, wait, canonical snapshot | Repeat asks source confirmation; Web Re-home label is non-authoritative presentation only; response records the mode phase and five-joint homed mask | | `switchkins-0`, `switchkins-1`, `switchkins-2` | `M429`, `M428`, `M430` remaps and `motion.switchkins-type` | `KINEMATICS_SELECT`; ON, homed, IDLE, empty TP | Identity/XYZAC/User K canonical type, or stable gate reason | -| `tab-manual`, `tab-mdi` | AXIS notebook F3/F5 `ensure_manual`/`ensure_mdi` | `SET_MODE` adapter under `manual_ok` | Acknowledged MANUAL/MDI task mode. Explicit FREE/TELEOP/joint switching is an unimplemented ABI boundary; the selected panel uses its homed world-axis motion adapter. | +| `tab-manual`, `tab-mdi` | AXIS notebook F3/F5 `ensure_manual`/`ensure_mdi` | `SET_MODE` adapter under `manual_ok` | Acknowledged MANUAL/MDI task mode. The Web does not expose a separate Joint/World toggle; Home transactions use an internal FREE/joint phase and restore the selected fixed XYZAC world-axis presentation mode after completion. | | `tab-diagnostics` | Web diagnostics boundary | Local tab only | Reads Worker state/gates; sends no command | | `tab-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-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}` checks KINEMATICS_BOTH | World-axis letter rejected before confirmation/mode mutation; individual numeric joint input remains unavailable | +| `manual-home-axis` | `home_joint` | UI ON+IDLE; `HOME {axis,joint}` validates the fixed XYZAC mapping, then follows the source MANUAL -> FREE/joint -> home transaction | Selected axis is homed through its mapped numeric joint; already-homed joint asks for explicit re-home confirmation; invalid mapping is rejected without mode/home mutation | | `manual-touch-off` | `touch_off_system` | `TOUCH_OFF`; exact Touch Off UI formula, `manual_ok`, valid axis/value/system, plus task-layer homing policy | MDI `G10 L20`, wait, reload and restore saved task/motion mode | | `manual-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 | @@ -445,7 +449,7 @@ React must derive its button gates from that state on the next snapshot. | `ESTOP(active=false)` | Source toggle requires current status ESTOP | IO E-stop clears; observed source task state becomes `ESTOP_RESET`; Web may display `STOPPED` only as an adapter label | Power is enabled; motion/manual/MDI remain disabled until ON | | `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 | Home authority updates `homed[]`; individual coordinate-axis home is rejected for non-identity/duplicate coordinates | AUTO's task layer may run only when all required joints are homed unless `NO_FORCE_HOMING=1` | +| `HOME(all/axis)` | AXIS homing UI: ON+IDLE; handler `manual_ok`, then MANUAL; `go_home()` selects FREE/joint before `c.home()` | Home authority updates the canonical five-joint `homed[]`; Web axis selection is validated and mapped to one numeric joint for this fixed one-to-one XYZAC case | AUTO's task layer may run only when all required joints are homed unless `NO_FORCE_HOMING=1`; an invalid axis/joint pair never changes mode or home state | | `PROGRAM_LOAD` | Native Open/Reload requires interpreter IDLE; Web additionally excludes active TP/MDI queue and concurrent parse | Save mode, ensure MANUAL, restore mode, set line 0, synchronize, open/replace taskfile and canon, parse status-derived preview, then apply tab mode | New taskfile/preview replace the old selection. Static preview replacement is independent of Live Plot data. | | `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 | @@ -759,7 +763,7 @@ ESTOP(active=false) POWER(active=true) -> valid from ESTOP_RESET; trajectory enable; observed task_state ON HOME(all=true) - -> manual_ok -> MANUAL -> joint mode -> c.home(-1) -> wait + -> manual_ok -> MANUAL -> joint/FREE -> c.home(-1) -> wait PROGRAM_RUN -> if the previous plan is completed/aborted: reopen current taskfile at line 0 -> AUTO mode, interpreter READING; browser scheduler drives WASM samples @@ -788,6 +792,39 @@ block whose target already equals the current canonical pose is a zero-motion block: it advances the interpreter line but must not enqueue an invalid zero-length TP segment. +### Homing must execute through the WASM trajectory + +`sim_kinematics_home_limit_home()` and +`sim_kinematics_home_limit_home_joint()` are home-state authorities. They +calculate and commit the canonical homed mask, joint values and forward pose; +they are not a substitute for the LinuxCNC `c.home()` motion command. The Web +must therefore use a two-phase transaction whenever the current pose differs +from the home target: + +```text +HOME click + -> validate ON+IDLE/manual_ok and axis-to-joint mapping + -> ensure MANUAL -> select FREE/joint homing phase + -> WASM kinematics computes target pose without committing home state + -> enqueue one LinuxCNC TP homing move to that target + -> return HOME {homing.phase:"moving"}; keep current pose and homed state + -> browser scheduler sends SIM_STEP task/servo ticks + -> TP reaches target and queue becomes empty + -> commit sim_kinematics_home(_joint) + -> synchronize interpreter/TP canonical position + -> restore the prior Web presentation mode + -> publish HOME {homing.phase:"complete", wasm_home_limit, pose} +``` + +When the current pose already equals the target, the transaction may commit in +the same command because there is no motion segment to execute. A Home request +must never call `setPosition()` to jump the TP to zero, must never change the +React pose locally, and must never advance `home_sequence` before the queued +motion has completed. `HOME ALL` targets all five configured joint home values; +`Home Axis` targets only the mapped joint and lets the selected XYZAC +kinematics calculate the resulting world pose. A pending home blocks another +Home until its queue has completed or an ESTOP/Power Off aborts it. + ### Touch-off command encoding AXIS's `all_systems` list is `P0 Current`, `P1 G54` through `P9 G59.3`. @@ -893,9 +930,13 @@ caller with its obsolete flag. Clear Live Plot is an explicit user operation. Focused evidence for this contract is kept in `web/worker/test_xyzac_touch_off_source_flow.mjs`, +`web/worker/test_xyzac_home_source_flow.mjs`, `web/worker/test_xyzac_panel_buttons.mjs` and `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 `scripts/test_xyzac_program_execution_browser_strict.mjs`. The source-surface audit is enforced by `scripts/test_axis_control_contract.mjs`. @@ -907,8 +948,9 @@ OFF/ESTOP overrides and the source mode transitions against real WASM. `update_state` procedure across 576 combinations and compares 13 widget predicates (7488 assertions), rather than comparing two rewritten formulas. `web/worker/test_axis_source_execution.mjs` verifies repeated Step, queued -motion Step, WAITING/MDI Pause-Resume, source OFF state, world-axis home rejection -and spindle override 0-to-100 restoration using the actual WASM runtime. +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 +the actual WASM runtime. The exhaustive control-ID audit is enforced by `scripts/test_axis_control_inventory.mjs` and `scripts/test_axis_control_inventory_browser.mjs`; the latter rejects any diff --git a/scripts/test_xyzac_home_reset_browser.mjs b/scripts/test_xyzac_home_reset_browser.mjs new file mode 100644 index 0000000..74b3bcc --- /dev/null +++ b/scripts/test_xyzac_home_reset_browser.mjs @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { pathToFileURL } from "node:url"; + +const modulePath = process.env.PLAYWRIGHT_MODULE; +assert.ok(modulePath, "PLAYWRIGHT_MODULE is required"); +const imported = await import(pathToFileURL(modulePath).href); +const playwright = imported.default ?? imported; +const browser = await playwright.chromium.launch({ + headless: false, + executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", + args: ["--disable-gpu", "--ignore-certificate-errors", "--window-size=1440,1000"], +}); +const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); +const main = page.locator("main.axis-app"); +page.on("dialog", async (dialog) => { if (dialog.type() === "confirm") await dialog.accept(); else await dialog.dismiss(); }); +const state = () => main.evaluate((element) => { + const preview = element.querySelector("[data-xyzac-preview]"); + return { + loading: element.dataset.axisProgramLoading, + homed: element.dataset.axisHomed, + pose: preview?.dataset.xyzacLivePose, + hud: element.querySelector(".axis-preview-hud")?.textContent ?? "", + queue: element.dataset.axisTpQueueDepth, + command: element.querySelector(".command-status")?.textContent ?? "", + error: element.querySelector(".diagnostic-error")?.textContent ?? null, + }; +}); +const wait = async (predicate, label) => { + const started = Date.now(); let latest; + while (Date.now() - started < 45000) { latest = await state(); if (predicate(latest)) return latest; await page.waitForTimeout(100); } + throw new Error(`${label}:${JSON.stringify(latest)}`); +}; +const control = (id) => main.locator(`[data-axis-control="${id}"]`); +const click = async (id) => { const button = control(id); await wait(async () => await button.count() > 0 && !await button.isDisabled(), `${id}_enabled`); await button.click(); }; +const pose = async () => JSON.parse((await state()).pose ?? "{}"); +const screenshot = async (name) => { await page.screenshot({ path: `/tmp/axis-home-reset-${name}.png` }); console.log(name, JSON.stringify(await state())); }; + +try { + await page.goto(process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/", { waitUntil: "domcontentloaded" }); + await wait((value) => value.loading === "false", "startup"); + await click("toolbar-power"); await wait((value) => value.command.includes("POWER acknowledged"), "power"); + await click("manual-home-all"); await wait((value) => value.command.includes("HOME acknowledged") && value.homed === "[true,true,true,true,true]", "home_all"); + await screenshot("01-home-all-zero"); + await control("manual-axis-x").check(); + const jog = control("manual-jog-positive"); + await jog.dispatchEvent("pointerdown", { pointerId: 41, button: 0 }); + await wait((value) => value.command.includes("JOG_START acknowledged"), "jog_start"); + await page.waitForTimeout(450); + await jog.dispatchEvent("pointerup", { pointerId: 41, button: 0 }); + await wait((value) => value.command.includes("JOG_STOP acknowledged") && value.queue === "0", "jog_stop"); + const jogged = await pose(); assert.ok(Math.abs(Number(jogged.x)) > 1e-6, JSON.stringify(jogged)); + await screenshot("02-jogged-x"); + await click("manual-home-axis"); + const movingAxis = await wait((value) => value.command.includes("HOME acknowledged") && value.queue !== "0", "home_axis_motion"); + const movingAxisPose = JSON.parse(movingAxis.pose); assert.ok(Math.abs(Number(movingAxisPose.x)) > 1e-6, JSON.stringify(movingAxisPose)); + await screenshot("03-home-axis-moving"); + const homedAxis = await wait((value) => value.command.includes("HOME acknowledged") && value.queue === "0", "home_axis"); + const homeAxisPose = JSON.parse(homedAxis.pose); assert.ok(Math.abs(Number(homeAxisPose.x)) < 1e-8, JSON.stringify(homeAxisPose)); + await screenshot("04-home-axis-zero"); + for (const [axis, pointerId] of [["y", 42], ["z", 43], ["a", 44], ["c", 45]]) { + await control(`manual-axis-${axis}`).check(); + const button = control("manual-jog-negative"); + await button.dispatchEvent("pointerdown", { pointerId, button: 0 }); + await wait((value) => value.command.includes("JOG_START acknowledged"), `${axis}_jog_start`); + await page.waitForTimeout(300); + await button.dispatchEvent("pointerup", { pointerId, button: 0 }); + await wait((value) => value.command.includes("JOG_STOP acknowledged") && value.queue === "0", `${axis}_jog_stop`); + } + await screenshot("05-multi-axis-jogged"); + await click("manual-home-all"); + const movingAll = await wait((value) => value.command.includes("HOME acknowledged") && value.queue !== "0", "home_all_motion"); + const movingAllPose = JSON.parse(movingAll.pose); assert.ok(Object.entries(movingAllPose).some(([axis, value]) => ["x", "y", "z", "a", "c"].includes(axis) && Math.abs(Number(value)) > 1e-6), JSON.stringify(movingAllPose)); + await screenshot("06-home-all-moving"); + const final = await wait((value) => value.command.includes("HOME acknowledged") && value.queue === "0", "home_all_again"); + const finalPose = JSON.parse(final.pose); + for (const axis of ["x", "y", "z", "a", "c"]) assert.ok(Math.abs(Number(finalPose[axis])) < 1e-8, `${axis}:${JSON.stringify(finalPose)}`); + await screenshot("07-home-all-zero"); + console.log(JSON.stringify({ schema: "xyzac-home-reset-browser.v1", status: "verified", url: process.env.XYZAC_PANEL_URL ?? "http://127.0.0.1:5175/", jogged, home_axis: homeAxisPose, home_all: finalPose, screenshots: ["/tmp/axis-home-reset-01-home-all-zero.png", "/tmp/axis-home-reset-02-jogged-x.png", "/tmp/axis-home-reset-03-home-axis-moving.png", "/tmp/axis-home-reset-04-home-axis-zero.png", "/tmp/axis-home-reset-05-multi-axis-jogged.png", "/tmp/axis-home-reset-06-home-all-moving.png", "/tmp/axis-home-reset-07-home-all-zero.png"] })); +} finally { await browser.close(); } diff --git a/scripts/test_xyzac_panel_buttons_browser.mjs b/scripts/test_xyzac_panel_buttons_browser.mjs index 86a7638..8fd4bc1 100644 --- a/scripts/test_xyzac_panel_buttons_browser.mjs +++ b/scripts/test_xyzac_panel_buttons_browser.mjs @@ -20,6 +20,7 @@ page.on("dialog", async (dialog) => { dialogs.push({ type: dialog.type(), message: dialog.message() }); if (dialog.type() === "prompt" && dialog.message().startsWith("Touch Off:")) await dialog.accept("0"); else if (dialog.type() === "prompt" && dialog.message().startsWith("Coordinate system")) await dialog.accept("P1 G54"); + else if (dialog.type() === "confirm" && dialog.message().includes("already homed")) await dialog.accept(); else await dialog.dismiss(); }); const main = page.locator("main.axis-app"); @@ -114,7 +115,7 @@ try { results.push(await record("MANUAL MODE", async () => { await main.getByRole("tab", { name: "Manual Control [F3]" }).click(); await wait((s) => s.mode === "MANUAL" || s.command?.includes("SET_MODE acknowledged"), "manual_mode_ack"); })); results.push(await record("HOME/TOUCH OFF boundaries", async () => { await click(main.locator('button[data-axis-control="manual-home-axis"]'), "home_axis_repeat"); - await wait((s) => s.command?.includes("home_joint_requires_joint_mode"), "world_axis_home_source_rejection"); + await wait((s) => s.error === null && s.command?.includes("HOME acknowledged"), "home_axis_ack"); await click(main.locator('button[data-axis-control="manual-touch-off"]'), "touch_off"); await 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/web/react/src/App.tsx b/web/react/src/App.tsx index ebdcf5f..da792ca 100644 --- a/web/react/src/App.tsx +++ b/web/react/src/App.tsx @@ -13,6 +13,7 @@ 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 @@ -239,8 +240,10 @@ export function App() { const aboutDialog = useRef(null); const [layers, setLayers] = useState({ grid: true, tool: true, path: true, live: true, limits: false, velocity: false, offsets: false, extents: false }); const runTimer = useRef | null>(null); + const homeTimer = useRef | null>(null); const jogTimer = useRef | null>(null); const runStepPending = useRef(false); + const homeStepPending = useRef(false); const jogStepPending = useRef(false); const continuousJog = useRef<{ axis: string; direction: number } | null>(null); const programFileInput = useRef(null); @@ -315,7 +318,7 @@ export function App() { setCommandStatus("PROGRAM_LOAD acknowledged"); setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`); }).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }).finally(() => setProgramLoading(false)); - return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); client.close(); }; + return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (homeTimer.current) clearInterval(homeTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); client.close(); }; }, [client, operationController]); const axes = state.snapshot?.motion?.axes ?? []; const taskRecord = state.task ?? {}; @@ -348,6 +351,17 @@ export function App() { const relief = state.snapshot?.ui_relief ?? {}; const canLoad = !programLoading && gates.load.enabled; const stopRunTimer = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } }; + const stopHomeTimer = () => { if (homeTimer.current) { clearInterval(homeTimer.current); homeTimer.current = null; } }; + const startHomeTimer = () => { + if (homeTimer.current) return; + homeTimer.current = setInterval(() => { + if (homeStepPending.current) return; + homeStepPending.current = true; + void operationController.execute("SIM_STEP", { cycles: 5, response: "motion_delta" }, { state: axisState, gate: false }) + .catch(() => stopHomeTimer()) + .finally(() => { homeStepPending.current = false; }); + }, 50); + }; const stopJogTimer = () => { continuousJog.current = null; if (jogTimer.current) { clearInterval(jogTimer.current); jogTimer.current = null; } }; const startJogTimer = (axis: string, direction: number) => { continuousJog.current = { axis, direction }; @@ -373,7 +387,7 @@ export function App() { } const pending = operationController.execute(kind, payload, { state: axisState, gate, ...(action ? { action } : {}) }); setOperationTrace(operationController.exportTrace()); - return pending.then(() => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`); if (kind === "PROGRAM_RUN" || kind === "PROGRAM_RESUME") startRunTimer(); if (["PROGRAM_PAUSE", "PROGRAM_ABORT", "PROGRAM_RESTART"].includes(kind)) stopRunTimer(); if (kind === "JOG_START") startJogTimer(String(payload.axis ?? "x"), Number(payload.direction)); if (kind === "JOG_STOP") stopJogTimer(); }).catch((error: Error) => { + return pending.then((operation) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`); if (kind === "PROGRAM_RUN" || kind === "PROGRAM_RESUME") startRunTimer(); if (["PROGRAM_PAUSE", "PROGRAM_ABORT", "PROGRAM_RESTART"].includes(kind)) stopRunTimer(); if (kind === "HOME" && (operation.response as { payload?: { homing?: { phase?: string } } })?.payload?.homing?.phase === "moving") startHomeTimer(); if (kind === "JOG_START") startJogTimer(String(payload.axis ?? "x"), Number(payload.direction)); if (kind === "JOG_STOP") stopJogTimer(); }).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); const code = error.message; setCommandStatus(`${kind} blocked: ${code}`); @@ -396,7 +410,13 @@ export function App() { const homeAxis = () => { if (programLoading || !gates.home.enabled) return; // switchkins.c reports KINEMATICS_BOTH even when its active type is 0. - void dispatch("HOME", { axis: selectedAxis }).catch(() => undefined); + // AXIS therefore homes a numeric joint; this fixed XYZAC Web surface + // maps its visible one-to-one axis selector to that joint explicitly. + const joint = XYZAC_AXIS_TO_JOINT[selectedAxis as keyof typeof XYZAC_AXIS_TO_JOINT]; + if (joint === undefined) { setCommandStatus("HOME blocked: home_axis_invalid"); return; } + const confirm = globalThis.confirm; + if (Boolean(homedAxes[joint]) && typeof confirm === "function" && !confirm(`Joint ${joint} is already homed. Re-home it?`)) return; + void dispatch("HOME", { axis: selectedAxis, joint }).catch(() => undefined); }; const stopPreviewProgress = () => { if (previewProgressTimer.current) { @@ -625,7 +645,8 @@ 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 (!ready) { stopRunTimer(); stopJogTimer(); } + if (wasmQueueDepth === 0 && !homeStepPending.current) stopHomeTimer(); + if (!ready) { stopRunTimer(); stopHomeTimer(); stopJogTimer(); } }, [programRunning, ready, state.program?.program_status, state.task?.mode, state.snapshot?.wasm_motion]); const spindleState = (state.devices as Record | null)?.spindles?.[0] as Record | undefined; const spindleDirection = Number(spindleState?.direction ?? 0); diff --git a/web/react/src/XyzacToolPreview.tsx b/web/react/src/XyzacToolPreview.tsx index 3e7c78b..a25deee 100644 --- a/web/react/src/XyzacToolPreview.tsx +++ b/web/react/src/XyzacToolPreview.tsx @@ -119,6 +119,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa let historyVertexCount = 0; let activeLiveLine: THREE.Line | null = null; let livePathEpoch = -1; + let poseEpoch = -1; let lastHistoryTick = -1; let lastActiveKey = ""; let lastPreset = ""; @@ -165,6 +166,29 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa const segments = state.toolpath as Segment[]; const layer = layersRef.current; const now = performance.now(); + // A HOME transaction can reset the canonical pose without advancing the + // simulation tick. Treat the source home sequence as an explicit pose + // barrier so stale queued jog samples cannot keep the old value on the + // HUD or tool marker. Keep the Live Plot history intact. + if (state.poseEpoch !== poseEpoch) { + poseEpoch = state.poseEpoch; + playbackQueue = []; + playbackStart = 0; + queuedLastTick = state.tick; + renderedTick = state.tick; + renderedPose = state.livePose; + renderedJoints = state.joints; + renderedActiveSegment = state.activeSegmentId ?? 0; + renderedProgress = state.activeProgress; + paintedTick = renderedTick; + paintedPose = renderedPose; + paintedJoints = renderedJoints; + paintedActiveSegment = renderedActiveSegment; + paintedProgress = renderedProgress; + liveTick = renderedTick; + if (activeLiveLine) { activeLiveLine.geometry.dispose(); liveGroup.remove(activeLiveLine); activeLiveLine = null; } + lastActiveKey = ""; + } if (state.livePathEpoch !== livePathEpoch) { livePathEpoch = state.livePathEpoch; clearLines(liveGroup); diff --git a/web/react/src/protocol.ts b/web/react/src/protocol.ts index ad5995d..a5d5a21 100644 --- a/web/react/src/protocol.ts +++ b/web/react/src/protocol.ts @@ -138,6 +138,7 @@ export type PanelSnapshotV1 = { schema: "axis-visual.v1"; pose: Record; joints: number[]; + pose_epoch?: number; live_path_epoch?: number; toolpath?: Array>; active_segment_id?: number | null; diff --git a/web/react/src/xyzac-visual-adapter.ts b/web/react/src/xyzac-visual-adapter.ts index ac26300..97ad010 100644 --- a/web/react/src/xyzac-visual-adapter.ts +++ b/web/react/src/xyzac-visual-adapter.ts @@ -9,6 +9,7 @@ export type XyzacVisualState = { joints: [number, number, number, number, number]; livePose: XyzacKinematicsSnapshot["pose"]; activeProgress: number; + poseEpoch: number; livePathEpoch: number; motionTrace: Array<{ tick: number; pose: XyzacKinematicsSnapshot["pose"]; joints: number[]; activeSegmentId: number; activeProgress: number }>; offsets: XyzacKinematicsSnapshot["offsets"]; @@ -28,7 +29,7 @@ export type XyzacVisualSource = { state_hash?: string; kinematics?: XyzacKinematicsSnapshot; motion?: { velocity_mm_min?: number }; - visual?: { active_segment_id?: number | null; active_progress?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record; joints?: number[]; toolpath?: Array> }; + visual?: { active_segment_id?: number | null; active_progress?: number; pose_epoch?: number; live_path_epoch?: number; motion_trace?: Array<{ tick: number; pose: Record; joints: number[]; active_segment_id: number; active_progress: number }>; pose?: Record; joints?: number[]; toolpath?: Array> }; }; export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefined): XyzacVisualState { @@ -53,6 +54,7 @@ export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefine joints, livePose: pose, activeProgress, + poseEpoch: Number.isSafeInteger(snapshot?.visual?.pose_epoch) ? Number(snapshot?.visual?.pose_epoch) : 0, livePathEpoch: Number.isSafeInteger(snapshot?.visual?.live_path_epoch) ? Number(snapshot?.visual?.live_path_epoch) : 0, motionTrace, offsets: kinematics?.offsets ?? ZERO_OFFSETS, diff --git a/web/worker/sim-session.mjs b/web/worker/sim-session.mjs index 0aa3887..f18974b 100644 --- a/web/worker/sim-session.mjs +++ b/web/worker/sim-session.mjs @@ -33,6 +33,7 @@ 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,6 +136,12 @@ export class SimSession { this.xyzacDeviceIo = null; this.xyzacProgram = null; 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; @@ -301,6 +308,7 @@ export class SimSession { limits: { x: Boolean(home.state?.limit_error), y: Boolean(home.state?.limit_error), z: Boolean(home.state?.limit_error), a: Boolean(home.state?.limit_error), c: Boolean(home.state?.limit_error) }, on_any_limit: Boolean(home.state?.limit_error), following_error: 0, + motion_mode: this.xyzacProgram ? this.xyzacMotionMode : (home.state?.homed ? "TELEOP" : "FREE"), jog: null, stepping: this.wasmMotion.isStepping?.() ?? false, estop: Boolean(ioState?.estop), @@ -393,7 +401,7 @@ export class SimSession { const gateActions = ["load", "run", "runLine", "step", "pause", "resume", "abort", "restart", "reload", "edit", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "overrideLimits", "reset", "spindle", "coolant", "touchOff", "toolTouchOff"]; const ui = axisSourceUi({ task_state: axis.source_task_state, interp_state: axis.source_interp_state, taskfile: Boolean(this.xyzacProgram?.path), queued_mdi_commands: task.queued_mdi_commands ?? 0, - motion_mode: homeLimit.state?.homed ? "TELEOP" : "FREE", kinematics_type: "BOTH", + motion_mode: this.xyzacProgram ? this.xyzacMotionMode : (homeLimit.state?.homed ? "TELEOP" : "FREE"), kinematics_type: "BOTH", spindle_direction: Number(devices.spindles?.[0]?.direction ?? 0), tool: Number(devices.tool?.current ?? 0), on_any_limit: motion.on_any_limit, paused: axis.operation_state === "PAUSED" }); const gates = this.xyzacProgram @@ -449,6 +457,10 @@ export class SimSession { schema: "axis-visual.v1", pose: { ...position }, joints: Array.isArray(joints) ? [...joints] : [], + // Home changes the canonical current pose even when the simulation + // tick does not advance. The renderer uses this monotonic source + // sequence to reset its displayed pose without clearing Live Plot. + pose_epoch: Number(homeLimit.state?.home_sequence ?? 0), live_path_epoch: this.livePathEpoch, ...(includeToolpath ? { toolpath: (preview?.segments ?? []).map((segment, index) => ({ id: index + 1, ...segment })) } : {}), active_segment_id: wasmMotion.status === "verified" ? wasmMotion.state.active_id : authoritative.planner.active_id, @@ -551,6 +563,9 @@ export class SimSession { return { action: input.payload?.type === 0 ? "switchkinsIdentity" : input.payload?.type === 1 ? "switchkinsXyzac" : "switchkinsUserk", enabled: false, reason: "xyzac_motion_active", taskState: task.state, interpState: "READING", wasm: { queue_depth: wasmMotion.state.queue_depth, active_id: wasmMotion.state.active_id } }; } const homeLimit = this.wasmHomeLimit.read(); + if (this.pendingHome && ["HOME", "JOG_START", "MDI_EXECUTE", "PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_STEP", "KINEMATICS_SELECT", "SET_MODE", "TOUCH_OFF", "SPINDLE_COMMAND", "SPINDLE_CONSTANT", "SPINDLE_BRAKE", "COOLANT_COMMAND"].includes(input.kind)) { + return { action: PANEL_GATE_ACTIONS[input.kind] ?? "home", enabled: false, reason: "xyzac_home_in_progress", taskState: task.state, interpState: "READING" }; + } const estop = Boolean(io.estop); const ready = Boolean(io.ready) && !estop; const loaded = program.status !== 0; @@ -1372,6 +1387,8 @@ export class SimSession { this.xyzacAppendDeferredOnActive = false; this.wasmMotion.abort(); this.wasmMotion.setPaused(true); + this.pendingHome = null; + this.xyzacMotionMode = "TELEOP"; 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); @@ -1475,6 +1492,8 @@ export class SimSession { this.xyzacAppendDeferredOnActive = false; this.wasmMotion.abort(); this.wasmMotion.setPaused(true); + this.pendingHome = null; + this.xyzacMotionMode = "TELEOP"; 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); @@ -1594,6 +1613,8 @@ export class SimSession { : new WasmMotionQueue(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: 1e9 }); this.xyzacProgram = new WasmProgramRuntime(this.core); 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); @@ -1941,6 +1962,18 @@ export class SimSession { } } } + if (this.pendingHome && wasmMotion.status === "verified" && Number(wasmMotion.state.queue_depth ?? 0) === 0) { + const pendingHome = this.pendingHome; + const home = this.wasmHomeLimit.home(pendingHome.seq, pendingHome.all ? null : pendingHome.joint); + if (home.status !== "verified") throw new Error(home.code ?? "wasm_home_commit_failed"); + if (this.xyzacProgram && typeof this.xyzacProgram.synchronizePosition === "function") { + const synchronized = this.xyzacProgram.synchronizePosition(home.state.pose); + if (synchronized.status !== "verified") throw new Error(synchronized.code ?? "home_position_sync_failed"); + } + this.xyzacMotionMode = pendingHome.modeBefore; + this.task.setMode("MANUAL"); + this.pendingHome = null; + } if (motionState?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205); if (this.motionAuthority === "wasm") { if (wasmMotion.status === "verified") { @@ -2392,17 +2425,54 @@ export class SimSession { case "HOME": { if (this.xyzacProgram) { if (this.wasmHomeLimit.read().status !== "verified") return finish(this.error(input, "home_limit", "wasm_home_limit_unavailable"), "failed"); - const requestedAxis = input.payload?.all ? null : String(input.payload?.axis ?? "").toLowerCase() || null; - if (requestedAxis) return finish(this.error(input, "home_limit", "home_joint_requires_joint_mode"), "failed"); - const requestedJoint = input.payload?.joint; - if (requestedJoint !== undefined && (!Number.isInteger(requestedJoint) || requestedJoint < 0 || requestedJoint >= 5)) return finish(this.error(input, "home_limit", "home_joint_invalid"), "failed"); + const payload = input.payload ?? {}; + // Older callers used HOME with an empty payload for Home All. Keep + // 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 && 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 issuing the homing command. + // before go_home() selects FREE/joint and issues c.home(). try { if (this.task.state.mode !== "MANUAL") this.task.setMode("MANUAL"); } catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); } - const home = this.wasmHomeLimit.home(input.seq, input.payload?.all ? null : requestedJoint ?? null); - if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed"); + 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, motion: this.wasmMotionProjection(), wasm_home_limit: this.wasmHomeLimit.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified"); + return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, homing: { phase: "complete", target: all ? "all" : "joint", source_axis: requestedAxis, joint: all ? null : requestedJoint, motion_mode_before: modeBefore, motion_mode_during: "FREE", motion_mode_after: this.xyzacMotionMode }, motion: this.wasmMotionProjection(), wasm_home_limit: home, motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified"); } const result = this.motion.home(input.payload?.axis ?? null); if (result.status === "verified" && this.motionAuthority === "wasm") { @@ -2450,13 +2520,28 @@ export class SimSession { if (!["x", "y", "z", "a", "c"].includes(axis)) return finish(this.error(input, "motion_axis", "motion_axis_invalid"), "failed"); 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"; this.wasmMotion.setPaused(false); const requestedIncrement = input.payload?.increment; const continuous = requestedIncrement === null || input.payload?.continuous === true; const displayScale = ["x", "y", "z"].includes(axis) && input.payload?.units === "inch" ? 25.4 : 1; const distance = continuous ? 0 : Number(requestedIncrement ?? 1) * displayScale; if (!continuous && (!Number.isFinite(distance) || distance <= 0)) return finish(this.error(input, "motion_jog", "motion_jog_nonfinite"), "failed"); - const jog = this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, c: 5 }[axis], direction, distance, input.seq); + // The selected XYZAC case uses the LinuxCNC TP jog ABI. Keep the + // existing queue-backed WASM cases working as well: they have no + // native jog entry point, so represent one incremental/continuous + // sample as a normal queued line and retain the same JOG_STOP abort + // path. This compatibility branch is never used by XYZAC TRT. + const jog = typeof this.wasmMotion.jog === "function" + ? this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, c: 5 }[axis], direction, distance, input.seq) + : (() => { + const current = this.wasmMotion.read(); + const start = current.state?.position ?? {}; + const end = { ...start, [axis]: Number(start[axis] ?? 0) + direction * (continuous ? Math.max(Number(input.payload?.speed ?? 1) * this.xyzacMotionCycleTime * 5, Number.EPSILON) : distance) }; + return this.wasmMotion.enqueue({ start, end, velocity: Math.max(Number(input.payload?.speed ?? 1), Number.EPSILON), acceleration: 1, motion_type: 1, source_line: input.seq }); + })(); if (jog.status !== "verified") { const code = jog.code === "linuxcnc_tp_jog_-1217" ? "motion_limit_range" : jog.code === "linuxcnc_tp_jog_-1215" ? "motion_jog_nonfinite" : jog.code; return finish(this.error(input, "motion_queue", code), "failed"); diff --git a/web/worker/test_axis_source_execution.mjs b/web/worker/test_axis_source_execution.mjs index cb650ef..4bdf053 100644 --- a/web/worker/test_axis_source_execution.mjs +++ b/web/worker/test_axis_source_execution.mjs @@ -29,7 +29,9 @@ assert.equal(panel().ui_gates.run.enabled, true); assert.equal(panel().ui_gates.step.enabled, true); assert.equal(send("PROGRAM_RUN").payload.gate_reason, "xyzac_not_all_homed"); ok("HOME", { all: true }); -assert.equal(send("HOME", { axis: "x" }).payload.message_key, "home_joint_requires_joint_mode"); +const homeAxis = send("HOME", { axis: "x", joint: 0 }); +assert.equal(homeAxis.payload.status, "verified"); +assert.deepEqual(homeAxis.payload.homing, { phase: "complete", target: "joint", source_axis: "x", joint: 0, motion_mode_before: "TELEOP", motion_mode_during: "FREE", motion_mode_after: "TELEOP" }); ok("PROGRAM_STEP"); assert.equal(panel().program.current_line, 0, "first native Step starts at line zero and pauses"); diff --git a/web/worker/test_xyzac_home_source_flow.mjs b/web/worker/test_xyzac_home_source_flow.mjs new file mode 100644 index 0000000..7c1ad93 --- /dev/null +++ b/web/worker/test_xyzac_home_source_flow.mjs @@ -0,0 +1,74 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import createModule from "../../build-wasm/linuxcnc-sim-core.js"; +import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs"; +import { XyzacSession, CONTEXT_ID } from "./xyzac-session.mjs"; + +const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini"; +const fetchImpl = async (url) => { + try { + const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url)); + return { ok: true, text: async () => body.toString("utf8") }; + } catch { + return { ok: false, text: async () => "" }; + } +}; + +const bundle = await loadSimulationCase({ path: CASE, fetchImpl }); +assert.equal(bundle.status, "ready"); +const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)); +const core = await createModule({ wasmBinary }); + +function open(sessionId) { + const session = new XyzacSession(core, { sessionId }); + const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: sessionId }; + let seq = 0; + const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${sessionId}-${seq}`, payload }); + assert.equal(send("HELLO").kind, "CAPABILITIES"); + assert.equal(send("SESSION_ATTACH").kind, "SESSION_READY"); + assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified"); + assert.equal(send("POWER", { active: true }).payload.status, "verified"); + return { session, send }; +} + +const partial = open("xyzac-home-axis-source"); +for (const [axis, joint] of [["x", 0], ["y", 1], ["z", 2], ["a", 3], ["c", 4]]) { + const response = partial.send("HOME", { axis, joint }); + assert.equal(response.kind, "COMMAND_RESULT", `${axis}: ${JSON.stringify(response.payload)}`); + assert.deepEqual(response.payload.homing, { + phase: "complete", target: "joint", source_axis: axis, joint, + motion_mode_before: "TELEOP", motion_mode_during: "FREE", motion_mode_after: "TELEOP", + }); + assert.equal(response.payload.wasm_home_limit.state.homed_mask, (1 << (joint + 1)) - 1); +} +assert.equal(partial.send("HOME", { axis: "x", joint: 1 }).payload.message_key, "home_axis_joint_mismatch"); +assert.equal(partial.session.wasmHomeLimit.read().state.homed_mask, 31); + +const all = open("xyzac-home-all-source"); +assert.equal(all.send("HOME", { all: true }).payload.wasm_home_limit.state.homed, true); +const move = all.send("MDI_EXECUTE", { block: "G21 G90 G0 X10 Y5 Z20 A30 C45" }); +assert.equal(move.kind, "COMMAND_RESULT"); +for (let tick = 0; tick < 20000 && all.session.wasmMotion.read().state.queue_depth > 0; tick += 1) all.send("SIM_STEP", { cycles: 1 }); +assert.ok(Math.abs(all.session.wasmMotion.read().state.position.x - 10) < 1e-8); +const rehome = all.send("HOME", { all: true }); +assert.equal(rehome.kind, "COMMAND_RESULT"); +assert.equal(rehome.payload.homing.phase, "moving"); +assert.notDeepEqual(rehome.payload.panel_snapshot.visual.pose, { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 }); +for (let tick = 0; tick < 2000 && all.session.wasmMotion.read().state.queue_depth > 0; tick += 1) all.send("SIM_STEP", { cycles: 1 }); +const completed = all.send("SNAPSHOT_REQUEST"); +assert.deepEqual(completed.payload.panel_snapshot.visual.pose, { x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 }); +assert.deepEqual(completed.payload.wasm_home_limit.state.joints, [0, 0, 0, 0, 0]); +assert.ok(completed.payload.wasm_home_limit.state.home_sequence > rehome.payload.wasm_home_limit.state.home_sequence); + +const running = open("xyzac-home-gate-source"); +assert.equal(running.send("PROGRAM_LOAD", { source: "G21 G90\nG1 X10 F60\nM2\n", path: "home-gate.ngc" }).kind, "COMMAND_RESULT"); +assert.equal(running.send("HOME", { all: true }).kind, "COMMAND_RESULT"); +assert.equal(running.send("PROGRAM_RUN").kind, "COMMAND_RESULT"); +assert.equal(running.send("HOME", { axis: "x", joint: 0 }).payload.gate_reason, "xyzac_interp_running"); + +console.log(JSON.stringify({ + schema: "xyzac-home-source-flow.v1", + status: "verified", + source: ["axis.py:home_all_joints", "axis.py:home_joint", "axis.py:go_home", "axis.tcl:update_state"], + checks: ["axis_to_joint_map", "free_joint_transaction", "pose_sync", "invalid_pair_rejection", "running_gate"], +})); diff --git a/web/worker/wasm-home-limit.mjs b/web/worker/wasm-home-limit.mjs index 7301b1b..a958bd8 100644 --- a/web/worker/wasm-home-limit.mjs +++ b/web/worker/wasm-home-limit.mjs @@ -108,6 +108,17 @@ 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" };