diff --git a/coverage/xyzac-panel-button-parity.report.json b/coverage/xyzac-panel-button-parity.report.json index 7778384..9bd5284 100644 --- a/coverage/xyzac-panel-button-parity.report.json +++ b/coverage/xyzac-panel-button-parity.report.json @@ -4,6 +4,16 @@ "context_id": "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", "case": "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", "authority": "linuxcnc-wasm-worker", + "js_simulation_forbidden": true, + "authority_boundaries": { + "program": "linuxcnc-wasm-sim_program", + "motion": "linuxcnc-source-tp", + "jog": "sim_linuxcnc_tp_jog", + "home_limit": "wasm_home_limit", + "io_hal": "wasm_io_and_hal", + "task_gate": "sim_task_command_gate", + "frontend_role": "input_and_projection_only" + }, "source_control_inventory": { "menus": 80, "toolbar": 20, @@ -13,21 +23,23 @@ }, "button_test": { "command": "node --experimental-strip-types web/worker/test_xyzac_panel_buttons.mjs", - "verified_paths": 94, + "verified_paths": 97, "expected_error_paths": 8, - "blocked_by_abi_paths": 7, - "output_sha256": "4447f37d8db6724fcd43518e8ddcd18d54e40faaedc77be9ea01abdc573226bc" + "blocked_by_abi_paths": 4, + "output_sha256": "9bddcd8b0219925e00598e081dc7a4ed0f4868e8822a21f0ccd08d8e9d355d45" }, "long_program": { "program": "linuxcnc/nc_files/3D_Chips.ngc", "source_length": 200509, "line_count": 4711, "preview_segments": 4684, - "fixed_tick_samples": 1500, - "execution_line": 168, - "execution_trace_entries": 166, + "fixed_tick_samples": 3000, + "execution_line": 147, + "execution_trace_entries": 145, "motion_authority": "wasm", - "output_sha256": "493ed6c901d18fae8fa02d0dc26ee0575ce78c53557351a468fc1df622e48ea4" + "motion_delta_snapshots": 0, + "ledger_audit_window": 32, + "output_sha256": "c97caef2ea5cf3841e1238ec443d0eebadad8cb0048af829111c06e99c9fcdd7" }, "native_tests": { "wasm_build": "passed", @@ -35,20 +47,34 @@ }, "published_browser_smoke": { "url": "https://49.232.250.35:58010/", - "release": "r5", + "release": "20260902-linuxcnc-wasm-authority-r6", "status": "verified", "startup_pause_resume_stop": "verified", "pause_tick": 140, "resume_tick": 160, "stop_state": "IDLE", "browser_errors": 0, - "output_sha256": "bdf705473e6cda94d721aa23329257adf4a68b705add7650da5cc47012ab70b0" + "output_sha256": "bdf705473e6cda94d721aa23329257adf4a68b705add7650da5cc47012ab70b0", + "screenshot_sha256": { + "authority": "e58205228a04b386c7b7514bf03c71df7b53f66f2d9ebe2292931b36ef0edb55", + "running": "7fc474d0cb31a0fdd4afb198061e9812c753684e3d2963cc004ec4483c5c4990" + }, + "resource_sha256": { + "index-DEinWp01.js": "79f5e933909fc631186db9b5091b6239f30c1ade612bb908a4ab9fcf79965343", + "sim-worker-entry-7q53Ew6x.js": "e5c239104d6676f242ea45cb3ddaf78a379aaaecaca5ff26031dee1ba9496e7c", + "linuxcnc-sim-core-CJilJ2YZ.wasm": "13e2746899b0cac5cf91189c00a6174160202f8e632b53c5701e9664335c97cf" + }, + "authority_dom": { + "data-axis-authority": "linuxcnc-wasm-worker", + "data-axis-state-source": "linuxcnc-wasm", + "data-axis-motion-authority": "wasm", + "data-axis-task-authority": "wasm_task_canonical" + }, + "browser_errors": 0 }, "release_status": "STOP_RELEASE", "scope_gaps": [ - "per_joint_home_mask", "override_limits_command", - "wasm_jog_speed_and_max_velocity_controls", "block_delete_and_optional_pause_interpreter_state", "interactive_tool_touch_off_G10_flow", "desktop_tool_editor_and_quit_host_boundary" diff --git a/doc-rtcp/xyzac-panel-button-parity-test-report.md b/doc-rtcp/xyzac-panel-button-parity-test-report.md index fe927ba..b62f824 100644 --- a/doc-rtcp/xyzac-panel-button-parity-test-report.md +++ b/doc-rtcp/xyzac-panel-button-parity-test-report.md @@ -11,6 +11,7 @@ `release_status: STOP_RELEASE` 这表示已执行并通过选定 XYZAC 案例的 WASM/Worker 控制闭环,但不宣称整个 LinuxCNC 或所有 AXIS 配置已经数值完全对标。 +本项目铁律:所有 G-code、运动、门控、I/O、HAL、刀具和状态业务不得由 JavaScript 模拟;React/Three 只做用户输入与显示投影。 ## 对标输入 @@ -36,6 +37,24 @@ source slices c9f0b4dc686717b37565f85fb0ab8cc8ded9cfa0028cc0aaaccdbcf808c4c524 AXIS 源控件清单:菜单 80、工具栏 20、快捷键 10,共 110 条,来自 `web/workspace/xyzac-trt-source-slices.mjs`。 +## 权威状态边界 + +选定 `xyzac-trt` 配置时,Worker 强制建立 LinuxCNC WASM TP,不接受缺省 planner 降级。前端只负责输入和显示投影,业务值、门控、运动和设备状态均来自以下 WASM 边界: + +| 数据/动作 | 唯一来源 | 前端使用方式 | +| --- | --- | --- | +| G-code 解析、当前行、活动段、预览 | `WasmProgramRuntime` (`linuxcnc-wasm-sim_program`) | 显示源代码和路径,不在 React 重解析 | +| XYZAC 位姿、速度、队列、前瞻 | `WasmLinuxcncTp` (`linuxcnc-source-tp`) | DRO、实时刀具、执行状态 | +| 五关节命令值 | WASM TP 当前 pose + `sim_kinematics_switch_inverse_mode` | 关节/DRO 投影;无 JS 插值 | +| JOG 目标、速度和限位 | `sim_linuxcnc_tp_jog`、WASM TP axis limits/jog scale | Worker 只传递轴、方向、步长 | +| HOME、逐关节 homed、limit | `WasmHomeLimit` (`homed_mask`) | Home 按钮和轴状态门控 | +| ESTOP、POWER、主轴、探针、HAL | `WasmXyzacIo` / `sim_io` / `sim_hal` | 设备状态、报警和命令结果 | +| Task/程序门控 | `SimTask` `sim_task_command_gate` | 所有按钮 `disabled` 和错误原因 | +| Feed/Jog/Max/Spindle override | WASM TP/IO getter/setter | 受控 slider,值由快照回读 | +| 视图缩放、旋转、平移、tab | React/Three 本地 UI | 仅显示交互,不参与运动计算 | + +在 WASM 快照尚未到达前,界面显示 `WASM PENDING` 并禁用运动操作;不会使用 JS 默认轴值、任务状态或 override 值。浏览器定时器只发送 `SIM_STEP` 采样请求,不执行 G-code、轨迹、运动学或设备状态计算。React/Three 中的定时器、相机、网格、刀具网格和文本格式化均为显示层,不产生或修改任何 CNC 状态。 + ## 按钮/控件矩阵 下表中的“通过”包含正常路径和刻意触发的 LinuxCNC 门控拒绝;拒绝不是失败,必须返回可解释的 gate/error key。 @@ -47,6 +66,7 @@ AXIS 源控件清单:菜单 80、工具栏 20、快捷键 10,共 110 条, | Machine | Power On/Off | `commands.power`、`emcTaskSetState` | `sim_io_set_power` | 通过 | | Machine | Reset | `commands.reset` | WASM Task/IO reset | 通过 | | Machine | HOME ALL | `home_all_joints` | `WasmHomeLimit` 五关节回零 | 通过 | +| Machine | Home X/Y/Z/A/C | `home_joint` | `WasmHomeLimit.homed_mask` 逐关节回零 | 通过 | | Machine | Run Startup Sequence | release -> power -> home -> run | Worker 固定顺序 | 通过 | | Program | Load | `open_file_guts` | WASM interpreter preview | 通过 | | Program | Run | `task_run` / `AUTO_RUN` | WASM program + source TP | 通过 | @@ -54,7 +74,7 @@ AXIS 源控件清单:菜单 80、工具栏 20、快捷键 10,共 110 条, | Program | Stop | `task_stop` / `c.abort` | WASM TP abort,队列清零 | 通过 | | Program | Restart | `reload_file` / first line | WASM program reload | 通过 | | Program | Step / Seek | `task_step` / `set_first_line` | WASM interpreter | 通过 | -| JOG | X、Y、Z、A、C +/-、Stop | `jog_plus/jog_minus/jog_on/jog_off` | WASM motion queue | 通过 | +| JOG | X、Y、Z、A、C +/-、Stop | `jog_plus/jog_minus/jog_on/jog_off` | WASM source TP jog velocity/queue | 通过 | | JOG | 越界/非有限速度 | LinuxCNC motion limit/ferror | WASM 错误 key | 通过拒绝 | | MDI | Go、历史命令 | `send_mdi_command` | WASM interpreter/canonical | 通过 | | MDI | 运行中 MDI | `manual_ok` | `mdi_while_program_active` | 通过拒绝 | @@ -79,16 +99,17 @@ AXIS 源控件清单:菜单 80、工具栏 20、快捷键 10,共 110 条, { "schema": "xyzac-panel-button-parity.v1", "source": "linuxcnc-wasm-worker", - "verified_paths": 94, + "verified_paths": 97, "expected_error_paths": 8, - "blocked_by_abi_paths": 7, + "blocked_by_abi_paths": 4, "selected_case_release_status": "STOP_RELEASE" } ``` -94 条通过路径包括: +97 条通过路径包括: - Machine:HELLO、SESSION_ATTACH、LOAD_WORKSPACE、ESTOP、ESTOP_RESET、POWER、RESET、HOME ALL、启动序列; +- Machine:五个轴的独立 HOME、`homed_mask` 聚合和部分回零门控; - Program:LOAD、RUN、SIM_STEP、PAUSE、SNAPSHOT、RESUME、ABORT/STOP、RESTART、STEP、SEEK、按行恢复; - JOG:XYZAC 五轴启动、固定 tick 采样和停止; - MDI:正常 G-code、运行中门控和非法 G-code 错误回显; @@ -105,22 +126,22 @@ AXIS 源控件清单:菜单 80、工具栏 20、快捷键 10,共 110 条, source_length: 200509 line_count: 4711 preview_segments: 4684 -fixed_tick_samples: 1500 -execution_line: 168 -execution_trace_entries: 166 +fixed_tick_samples: 3000 +execution_line: 147 +execution_trace_entries: 145 motion_authority: wasm +motion_delta_snapshots: 0 +ledger_audit_window: 32 ``` ## 当前未闭合项 -这些项目在本轮明确记录为 `blocked_by_abi`,没有伪造通过: +以下项目仍明确记录为 `blocked_by_abi`,没有伪造通过: -1. `Home Axis`:当前 WASM home 状态只暴露 aggregate `homed`,没有 LinuxCNC 每关节 homed mask;当前按钮调用的是统一 home。 -2. `Override Limits`:原始 AXIS `toggle_override_limits` 有独立 motion command;选定 XYZAC WASM 配置目前把 limits 作为配置所有权,尚无等价 command ABI。 -3. `Jog Speed` 和 `Max Velocity` slider:当前是 React 控件状态,尚未接入 WASM jog/max velocity command。 -4. `Block delete` 和 `Optional pause`:当前是 React toggle,尚未写入 WASM interpreter modal/control state。 -5. `Tool Touch Off`:当前按钮发送固定 `G43`;原始 AXIS 先取得用户值,再发送 `G10 L10/L11` 和 `G43`,交互输入流程尚未闭合。 -6. `Edit tool data`、`Quit`:依赖桌面进程/外部编辑器,属于浏览器 `HOST_BOUNDARY`,不在仿真 Web 范围内。 +1. `Override Limits`:原始 AXIS `toggle_override_limits` 有独立 motion command;选定 XYZAC WASM 配置目前把 limits 作为配置所有权,尚无等价 command ABI。 +2. `Block delete` 和 `Optional pause`:当前 AXIS 图标已禁用,尚未写入 WASM interpreter modal/control state;不保留本地假状态。 +3. `Tool Touch Off`:当前按钮发送固定 `G43`;原始 AXIS 先取得用户值,再发送 `G10 L10/L11` 和 `G43`,交互输入流程尚未闭合。 +4. `Edit tool data`、`Quit`:依赖桌面进程/外部编辑器,属于浏览器 `HOST_BOUNDARY`,不在仿真 Web 范围内。 ## 复现命令 @@ -137,7 +158,7 @@ node --experimental-strip-types web/worker/test_xyzac_3d_chips.mjs bash -n run-5axis-rtcp.sh ``` -本轮结果:WASM 构建通过,原生 CTest 22/22 通过,XYZAC 聚合按钮测试通过,3D_Chips 固定 tick 长程序测试通过。发布站点 UI 运行/暂停/恢复/停止也已在 Chromium 仿真环境执行;Safari 和真实物理断电不属于本项目范围。 +本轮结果:WASM 构建通过,原生 CTest 22/22 通过,XYZAC 聚合按钮测试通过,3D_Chips 固定 tick 长程序测试通过。Jog Speed、Max Velocity、Spindle Override 已改为 WASM TP/IO 状态和命令;前端运行态不再使用 React 默认值。发布站点 UI 运行/暂停/恢复/停止也已在 Chromium 仿真环境执行;Safari 和真实物理断电不属于本项目范围。 发布站点 r5 浏览器冒烟: @@ -154,14 +175,32 @@ and view controls: clicked with no browser errors output_sha256: bdf705473e6cda94d721aa23329257adf4a68b705add7650da5cc47012ab70b0 ``` +发布站点 r6(本轮 WASM authority 版本)浏览器验收: + +```text +url: https://49.232.250.35:58010/ +release: 20260902-linuxcnc-wasm-authority-r6 +resource_sha256: index-DEinWp01.js 79f5e933909fc631186db9b5091b6239f30c1ade612bb908a4ab9fcf79965343 + sim-worker-entry-7q53Ew6x.js e5c239104d6676f242ea45cb3ddaf78a379aaaecaca5ff26031dee1ba9496e7c + linuxcnc-sim-core-CJilJ2YZ.wasm 13e2746899b0cac5cf91189c00a6174160202f8e632b53c5701e9664335c97cf +initial_dom: data-axis-authority=linuxcnc-wasm-worker, data-axis-state-source=linuxcnc-wasm, + data-axis-motion-authority=wasm, data-axis-task-authority=wasm_task_canonical +3D_Chips.ngc: loaded from published workspace, preview 100%, source TP running current line 22 +browser_errors: 0 +screenshots: /tmp/published-r6-authority.png (e58205228a04b386c7b7514bf03c71df7b53f66f2d9ebe2292931b36ef0edb55) + /tmp/published-r6-running.png (7fc474d0cb31a0fdd4afb198061e9812c753684e3d2963cc004ec4483c5c4990) +``` + ## 证据哈希 ```text -panel_button_test_output 4447f37d8db6724fcd43518e8ddcd18d54e40faaedc77be9ea01abdc573226bc -3d_chips_test_output 493ed6c901d18fae8fa02d0dc26ee0575ce78c53557351a468fc1df622e48ea4 +panel_button_test_output 9bddcd8b0219925e00598e081dc7a4ed0f4868e8822a21f0ccd08d8e9d355d45 +button_constraints_output 5b6f4ce1dec03764dcb234793ff96fc2f04d15ab954620a44dbb1210ec64a5a5 +feed_authority_output deab4a51ee0e56d4f8134efba4b7a97a2b88f37d9165cf730207f71886df8526 +3d_chips_test_output c97caef2ea5cf3841e1238ec443d0eebadad8cb0048af829111c06e99c9fcdd7 wasm_io_source 1cb066d185dd2c5bbf435e022c2518483bd44962e5a0303248757b2da51740f7 wasm_io_c_source 09bce1430d6f71702ca0b3bf29ac50db377a6f232ce0d1a9b2c1eaac7ea2162e panel_button_test_source 80782fc1f85f88beb48ec3dfaf638a93f2edb912e694840199dbff504477fe74 ``` -报告对应提交前工作树保留了既有 coverage 大文件和 LinuxCNC 子模块改动;本轮只提交面板 WASM I/O 修正、step 状态修正、聚合测试和本报告。 +报告对应提交前工作树保留了既有 coverage 大文件和 LinuxCNC 子模块改动;本轮提交包含 WASM TP/IO/HOME authority、无 JS JOG 运动计算、前端 pending 投影、长程序快照性能修正、聚合测试和本报告。 diff --git a/wasm-core/CMakeLists.txt b/wasm-core/CMakeLists.txt index 728cc41..000241f 100644 --- a/wasm-core/CMakeLists.txt +++ b/wasm-core/CMakeLists.txt @@ -303,10 +303,11 @@ if(EMSCRIPTEN) string(REPLACE "_sim_hal_set_param" "_sim_hal_set_param_scalar','_sim_hal_set_param" _sim_module_link_flags "${_sim_module_link_flags}") string(REPLACE "_sim_io_init" "_sim_io_size','_sim_io_init" _sim_module_link_flags "${_sim_module_link_flags}") string(REPLACE "_sim_io_set_estop','_sim_io_recover" "_sim_io_set_estop','_sim_io_set_power','_sim_io_recover" _sim_module_link_flags "${_sim_module_link_flags}") - string(REPLACE "_sim_io_spindle_command','_sim_io_spindle_set_inhibit" "_sim_io_spindle_command','_sim_io_spindle_adjust','_sim_io_spindle_brake','_sim_io_spindle_set_inhibit" _sim_module_link_flags "${_sim_module_link_flags}") + string(REPLACE "_sim_io_spindle_command','_sim_io_spindle_set_inhibit" "_sim_io_spindle_command','_sim_io_spindle_adjust','_sim_io_spindle_brake','_sim_io_spindle_set_override','_sim_io_spindle_set_inhibit" _sim_module_link_flags "${_sim_module_link_flags}") + string(REPLACE "_sim_kinematics_home_limit_home','_sim_kinematics_home_limit_check" "_sim_kinematics_home_limit_home','_sim_kinematics_home_limit_home_joint','_sim_kinematics_home_limit_check" _sim_module_link_flags "${_sim_module_link_flags}") string(REPLACE "_sim_device_init" "_sim_device_io_size','_sim_device_init" _sim_module_link_flags "${_sim_module_link_flags}") string(REPLACE "_sim_motion_step','_sim_motion_read_state" "_sim_motion_step','_sim_motion_set_profile','_sim_motion_set_lookahead','_sim_motion_set_feed_override','_sim_motion_peek','_sim_motion_read_state" _sim_module_link_flags "${_sim_module_link_flags}") string(REPLACE "_sim_motion_state_hash','_sim_motion_canonical" "_sim_motion_state_hash','_sim_motion_plan_hash','_sim_motion_canonical" _sim_module_link_flags "${_sim_module_link_flags}") - string(REPLACE "_sim_motion_init" "_sim_motion_init','_sim_linuxcnc_tp_size','_sim_linuxcnc_tp_init','_sim_linuxcnc_tp_reset','_sim_linuxcnc_tp_set_profile','_sim_linuxcnc_tp_set_feed_override','_sim_linuxcnc_tp_set_dynamics','_sim_linuxcnc_tp_set_paused','_sim_linuxcnc_tp_abort','_sim_linuxcnc_tp_set_position','_sim_linuxcnc_tp_set_goal_position','_sim_linuxcnc_tp_add_line','_sim_linuxcnc_tp_add_circle','_sim_linuxcnc_tp_step','_sim_linuxcnc_tp_idle','_sim_linuxcnc_tp_read_state','_sim_linuxcnc_tp_queue_depth','_sim_linuxcnc_tp_queue_segment','_sim_linuxcnc_tp_restore_progress','_sim_linuxcnc_tp_state_hash','_sim_linuxcnc_tp_canonical" _sim_module_link_flags "${_sim_module_link_flags}") + string(REPLACE "_sim_motion_init" "_sim_motion_init','_sim_linuxcnc_tp_size','_sim_linuxcnc_tp_init','_sim_linuxcnc_tp_reset','_sim_linuxcnc_tp_set_profile','_sim_linuxcnc_tp_set_feed_override','_sim_linuxcnc_tp_set_velocity_scale','_sim_linuxcnc_tp_set_jog_scale','_sim_linuxcnc_tp_set_axis_limit','_sim_linuxcnc_tp_jog','_sim_linuxcnc_tp_velocity_scale','_sim_linuxcnc_tp_velocity_limit','_sim_linuxcnc_tp_jog_scale','_sim_linuxcnc_tp_jog_velocity','_sim_linuxcnc_tp_feed_override','_sim_linuxcnc_tp_set_dynamics','_sim_linuxcnc_tp_set_paused','_sim_linuxcnc_tp_abort','_sim_linuxcnc_tp_set_position','_sim_linuxcnc_tp_set_goal_position','_sim_linuxcnc_tp_add_line','_sim_linuxcnc_tp_add_circle','_sim_linuxcnc_tp_step','_sim_linuxcnc_tp_idle','_sim_linuxcnc_tp_read_state','_sim_linuxcnc_tp_queue_depth','_sim_linuxcnc_tp_queue_segment','_sim_linuxcnc_tp_restore_progress','_sim_linuxcnc_tp_state_hash','_sim_linuxcnc_tp_canonical" _sim_module_link_flags "${_sim_module_link_flags}") set_target_properties(sim_core_module PROPERTIES LINK_FLAGS "${_sim_module_link_flags}") endif() diff --git a/wasm-core/include/sim_io.h b/wasm-core/include/sim_io.h index 5faaaa3..18b9e86 100644 --- a/wasm-core/include/sim_io.h +++ b/wasm-core/include/sim_io.h @@ -67,6 +67,7 @@ typedef struct sim_io_spindle_state { double max_rpm; double accel_rpm_s; double tolerance_rpm; + double override_scale; double orient_angle; double revs; uint32_t tick; @@ -122,6 +123,7 @@ int sim_io_spindle_command(sim_io *io, uint32_t spindle, double rpm, uint32_t enabled); int sim_io_spindle_adjust(sim_io *io, uint32_t spindle, double delta); int sim_io_spindle_brake(sim_io *io, uint32_t spindle, uint32_t engaged); +int sim_io_spindle_set_override(sim_io *io, uint32_t spindle, double scale); int sim_io_spindle_set_inhibit(sim_io *io, uint32_t spindle, uint32_t inhibit); int sim_io_spindle_set_fault(sim_io *io, uint32_t spindle, uint32_t fault); int sim_io_spindle_set_index_enable(sim_io *io, uint32_t spindle, uint32_t enable); diff --git a/wasm-core/include/sim_kinematics_home_limit.h b/wasm-core/include/sim_kinematics_home_limit.h index 66e912f..ad002e8 100644 --- a/wasm-core/include/sim_kinematics_home_limit.h +++ b/wasm-core/include/sim_kinematics_home_limit.h @@ -28,6 +28,7 @@ typedef struct sim_kinematics_home_limit_state { int last_error; int limit_error; int kinematics_error; + uint32_t homed_mask; } sim_kinematics_home_limit_state; typedef struct sim_kinematics_home_limit { @@ -41,6 +42,8 @@ int sim_kinematics_home_limit_init(sim_kinematics_home_limit *home_limit, int sim_kinematics_home_limit_reset(sim_kinematics_home_limit *home_limit); int sim_kinematics_home_limit_home(sim_kinematics_home_limit *home_limit, uint32_t home_sequence); +int sim_kinematics_home_limit_home_joint(sim_kinematics_home_limit *home_limit, + uint32_t home_sequence, uint32_t joint); int sim_kinematics_home_limit_check(sim_kinematics_home_limit *home_limit, const double *joints, uint32_t joint_count); diff --git a/wasm-core/include/sim_linuxcnc_tp.h b/wasm-core/include/sim_linuxcnc_tp.h index 6b1a084..dffc782 100644 --- a/wasm-core/include/sim_linuxcnc_tp.h +++ b/wasm-core/include/sim_linuxcnc_tp.h @@ -23,6 +23,15 @@ int32_t sim_linuxcnc_tp_init(sim_linuxcnc_tp *planner, double cycle_time, int32_t sim_linuxcnc_tp_reset(sim_linuxcnc_tp *planner); int32_t sim_linuxcnc_tp_set_profile(sim_linuxcnc_tp *planner, int profile, double tolerance); int32_t sim_linuxcnc_tp_set_feed_override(sim_linuxcnc_tp *planner, double scale); +int32_t sim_linuxcnc_tp_set_velocity_scale(sim_linuxcnc_tp *planner, double scale); +int32_t sim_linuxcnc_tp_set_jog_scale(sim_linuxcnc_tp *planner, double scale); +int32_t sim_linuxcnc_tp_set_axis_limit(sim_linuxcnc_tp *planner, int axis, double minimum, double maximum); +int32_t sim_linuxcnc_tp_jog(sim_linuxcnc_tp *planner, int axis, int direction, double increment, uint32_t tag); +double sim_linuxcnc_tp_velocity_scale(const sim_linuxcnc_tp *planner); +double sim_linuxcnc_tp_velocity_limit(const sim_linuxcnc_tp *planner); +double sim_linuxcnc_tp_jog_scale(const sim_linuxcnc_tp *planner); +double sim_linuxcnc_tp_jog_velocity(const sim_linuxcnc_tp *planner, int axis); +double sim_linuxcnc_tp_feed_override(const sim_linuxcnc_tp *planner); int32_t sim_linuxcnc_tp_set_dynamics(sim_linuxcnc_tp *planner, double acceleration_limit); int32_t sim_linuxcnc_tp_set_paused(sim_linuxcnc_tp *planner, int paused); int32_t sim_linuxcnc_tp_abort(sim_linuxcnc_tp *planner); diff --git a/wasm-core/include/sim_task.h b/wasm-core/include/sim_task.h index 735f3e4..3beb8af 100644 --- a/wasm-core/include/sim_task.h +++ b/wasm-core/include/sim_task.h @@ -71,6 +71,12 @@ enum sim_task_gate_command { SIM_TASK_GATE_PROGRAM_LOAD = 14, SIM_TASK_GATE_STEP = 15, SIM_TASK_GATE_OVERRIDE = 16, + SIM_TASK_GATE_SPINDLE = 17, + SIM_TASK_GATE_COOLANT = 18, + SIM_TASK_GATE_TOOL = 19, + SIM_TASK_GATE_PROBE = 20, + SIM_TASK_GATE_TOUCH_OFF = 21, + SIM_TASK_GATE_TOOL_TOUCH_OFF = 22, }; enum sim_task_gate_result { diff --git a/wasm-core/src/sim_io.c b/wasm-core/src/sim_io.c index a8ca42e..0f52b8b 100644 --- a/wasm-core/src/sim_io.c +++ b/wasm-core/src/sim_io.c @@ -205,6 +205,7 @@ static void state_defaults(sim_io *io) { io->state.spindles[index].max_rpm = 100000.0; io->state.spindles[index].accel_rpm_s = old.spindles[index].accel_rpm_s; io->state.spindles[index].tolerance_rpm = old.spindles[index].tolerance_rpm; + io->state.spindles[index].override_scale = 1.0; io->state.spindles[index].at_speed = 1; } } @@ -256,7 +257,7 @@ int sim_io_spindle_command(sim_io *io, uint32_t spindle, double rpm, uint32_t en if (io->state.estop) { set_last(io, SIM_IO_ERR_ESTOP); return SIM_IO_ERR_ESTOP; } if (!io->state.ready) { set_last(io, SIM_IO_ERR_NOT_READY); return SIM_IO_ERR_NOT_READY; } state = &io->state.spindles[spindle]; - state->command_rpm = enabled && !state->inhibit && !state->amp_fault ? rpm : 0.0; + state->command_rpm = enabled && !state->inhibit && !state->amp_fault ? rpm * state->override_scale : 0.0; state->enabled = enabled ? 1U : 0U; set_last(io, SIM_IO_OK); mirror_pins(io); @@ -295,6 +296,21 @@ int sim_io_spindle_brake(sim_io *io, uint32_t spindle, uint32_t engaged) { return SIM_IO_OK; } +int sim_io_spindle_set_override(sim_io *io, uint32_t spindle, double scale) { + sim_io_spindle_state *state; + double base; + if (!valid_spindle(io, spindle) || !isfinite(scale) || scale < 0.0 || scale > 2.0) return SIM_IO_ERR_RANGE; + if (io->state.estop) { set_last(io, SIM_IO_ERR_ESTOP); return SIM_IO_ERR_ESTOP; } + if (!io->state.ready) { set_last(io, SIM_IO_ERR_NOT_READY); return SIM_IO_ERR_NOT_READY; } + state = &io->state.spindles[spindle]; + base = state->override_scale > 0.0 ? state->command_rpm / state->override_scale : 0.0; + state->override_scale = scale; + state->command_rpm = state->enabled && !state->inhibit && !state->amp_fault ? base * scale : 0.0; + set_last(io, SIM_IO_OK); + mirror_pins(io); + return SIM_IO_OK; +} + int sim_io_spindle_set_inhibit(sim_io *io, uint32_t spindle, uint32_t inhibit) { if (!valid_spindle(io, spindle) || inhibit > 1U) return SIM_IO_ERR_ARGUMENT; io->state.spindles[spindle].inhibit = inhibit; @@ -607,9 +623,9 @@ uint32_t sim_io_canonical(const sim_io *io, char *output, uint32_t capacity) { const sim_io_spindle_state *spindle = &io->state.spindles[index]; int part = snprintf(destination + (output && (uint32_t)written < capacity ? written : 0), output && (uint32_t)written < capacity ? capacity - (uint32_t)written : 0, - "%s{\"at_speed\":%u,\"brake\":%u,\"command_rpm\":%.9g,\"forward\":%u,\"reverse\":%u,\"speed_rpm\":%.9g,\"tick\":%u}", + "%s{\"at_speed\":%u,\"brake\":%u,\"command_rpm\":%.9g,\"enabled\":%u,\"forward\":%u,\"inhibit\":%u,\"override\":%.9g,\"reverse\":%u,\"speed_rpm\":%.9g,\"tick\":%u}", index ? "," : "", spindle->at_speed, spindle->brake, spindle->command_rpm, - spindle->forward, spindle->reverse, spindle->speed_rpm, spindle->tick); + spindle->enabled, spindle->forward, spindle->inhibit, spindle->override_scale, spindle->reverse, spindle->speed_rpm, spindle->tick); if (part < 0) return 0; written += part; } { int part = snprintf(destination + (output && (uint32_t)written < capacity ? written : 0), diff --git a/wasm-core/src/sim_kinematics_home_limit.c b/wasm-core/src/sim_kinematics_home_limit.c index 47cf20b..ec22e26 100644 --- a/wasm-core/src/sim_kinematics_home_limit.c +++ b/wasm-core/src/sim_kinematics_home_limit.c @@ -64,6 +64,7 @@ int sim_kinematics_home_limit_home(sim_kinematics_home_limit *home_limit, memcpy(home_limit->state.joints, joints, sizeof(double) * home_limit->state.joint_count); home_limit->state.pose = pose; home_limit->state.home_sequence = home_sequence; + home_limit->state.homed_mask = home_limit->state.joint_count >= 32 ? UINT32_MAX : (UINT32_C(1) << home_limit->state.joint_count) - 1U; home_limit->state.homed = 1; home_limit->state.tick += 1; home_limit->state.last_error = SIM_KIN_HOME_OK; @@ -72,6 +73,33 @@ int sim_kinematics_home_limit_home(sim_kinematics_home_limit *home_limit, return SIM_KIN_HOME_OK; } +int sim_kinematics_home_limit_home_joint(sim_kinematics_home_limit *home_limit, + uint32_t home_sequence, uint32_t joint) { + sim_posemath_pose pose; + double joints[SIM_KIN_MAX_JOINTS] = {0}; + int result; + if (home_limit == 0 || home_limit->kinematics == 0 || joint >= home_limit->state.joint_count) return SIM_KIN_HOME_ERR_ARGUMENT; + if (home_sequence > 0xffffu) return SIM_KIN_HOME_ERR_SEQUENCE; + if (home_limit->state.homed_mask != 0) memcpy(joints, home_limit->state.joints, sizeof(double) * home_limit->state.joint_count); + else { + result = sim_kinematics_home(home_limit->kinematics, joints, home_limit->state.joint_count, &pose); + if (result != SIM_KIN_OK) return SIM_KIN_HOME_ERR_FORWARD; + } + joints[joint] = 0.0; + result = sim_kinematics_forward(home_limit->kinematics, joints, home_limit->state.joint_count, &pose); + if (result != SIM_KIN_OK) return SIM_KIN_HOME_ERR_FORWARD; + memcpy(home_limit->state.joints, joints, sizeof(double) * home_limit->state.joint_count); + home_limit->state.pose = pose; + home_limit->state.home_sequence = home_sequence; + home_limit->state.homed_mask |= UINT32_C(1) << joint; + home_limit->state.homed = home_limit->state.homed_mask == (home_limit->state.joint_count >= 32 ? UINT32_MAX : (UINT32_C(1) << home_limit->state.joint_count) - 1U); + home_limit->state.tick += 1; + home_limit->state.last_error = SIM_KIN_HOME_OK; + home_limit->state.limit_error = SIM_KIN_OK; + home_limit->state.kinematics_error = SIM_KIN_OK; + return SIM_KIN_HOME_OK; +} + int sim_kinematics_home_limit_check(sim_kinematics_home_limit *home_limit, const double *joints, uint32_t joint_count) { diff --git a/wasm-core/src/sim_linuxcnc_tp.c b/wasm-core/src/sim_linuxcnc_tp.c index 2322470..8e7b75f 100644 --- a/wasm-core/src/sim_linuxcnc_tp.c +++ b/wasm-core/src/sim_linuxcnc_tp.c @@ -18,6 +18,10 @@ struct sim_linuxcnc_tp { emcmot_config_t config; double axis_velocity[9]; double axis_acceleration[9]; + double axis_min[9]; + double axis_max[9]; + double velocity_scale; + double jog_scale; uint32_t tick; uint32_t last_active_id; int just_completed; @@ -144,7 +148,11 @@ int32_t sim_linuxcnc_tp_init(sim_linuxcnc_tp *planner, double cycle_time, double for (int axis = 0; axis < 9; ++axis) { planner->axis_velocity[axis] = axis < 3 ? fmin(20.0, velocity_limit) : (axis == 3 || axis == 5) ? fmin(30.0, velocity_limit) : velocity_limit; planner->axis_acceleration[axis] = acceleration_limit; + planner->axis_min[axis] = -INFINITY; + planner->axis_max[axis] = INFINITY; } + planner->velocity_scale = 1.0; + planner->jog_scale = 1.0; planner->status.feed_scale = 1.0; planner->status.rapid_scale = 1.0; planner->status.net_feed_scale = 1.0; planner->status.enables_new = FS_ENABLED | FH_ENABLED; planner->status.enables_queued = planner->status.enables_new; planner->status.motionFlag = EMCMOT_MOTION_ENABLE_BIT | EMCMOT_MOTION_COORD_BIT | EMCMOT_MOTION_INPOS_BIT; @@ -176,6 +184,64 @@ int32_t sim_linuxcnc_tp_set_feed_override(sim_linuxcnc_tp *planner, double scale planner->status.net_feed_scale = scale; planner->status.feed_scale = scale; return 0; } +int32_t sim_linuxcnc_tp_set_velocity_scale(sim_linuxcnc_tp *planner, double scale) { + if (planner == NULL || !planner->initialized || !isfinite(scale) || scale < 0.06 || scale > 1.0) return -1211; + active_planner = planner; + if (tpSetVlimit(&planner->tp, planner->config.limitVel * scale) != TP_ERR_OK) return -1212; + planner->velocity_scale = scale; + return 0; +} + +int32_t sim_linuxcnc_tp_set_jog_scale(sim_linuxcnc_tp *planner, double scale) { + if (planner == NULL || !planner->initialized || !isfinite(scale) || scale < 0.06 || scale > 1.0) return -1213; + planner->jog_scale = scale; + return 0; +} + +int32_t sim_linuxcnc_tp_set_axis_limit(sim_linuxcnc_tp *planner, int axis, double minimum, double maximum) { + if (planner == NULL || !planner->initialized || axis < 0 || axis >= 9 || !isfinite(minimum) || !isfinite(maximum) || minimum >= maximum) return -1214; + planner->axis_min[axis] = minimum; + planner->axis_max[axis] = maximum; + return 0; +} + +int32_t sim_linuxcnc_tp_jog(sim_linuxcnc_tp *planner, int axis, int direction, double increment, uint32_t tag) { + EmcPose end; + double *value; + double velocity; + struct state_tag_t state_tag = {0}; + if (planner == NULL || !planner->initialized || axis < 0 || axis >= 9 || (direction != -1 && direction != 1) || !isfinite(increment) || increment <= 0.0) return -1215; + active_planner = planner; + end = planner->tp.goalPos; + value = axis == 0 ? &end.tran.x : axis == 1 ? &end.tran.y : axis == 2 ? &end.tran.z : axis == 3 ? &end.a : axis == 4 ? &end.b : axis == 5 ? &end.c : axis == 6 ? &end.u : axis == 7 ? &end.v : &end.w; + *value += (double)direction * increment; + if (!isfinite(*value) || *value < planner->axis_min[axis] || *value > planner->axis_max[axis]) return -1217; + velocity = planner->axis_velocity[axis] * planner->jog_scale; + state_tag.fields_float[GM_FIELD_FLOAT_LINE_NUMBER] = (float)tag; + if (tpSetId(&planner->tp, (int)tag) != TP_ERR_OK) return -1218; + return tpAddLine(&planner->tp, end, TC_LINEAR, velocity, velocity, planner->axis_acceleration[axis], planner->status.enables_new, 0, -1, state_tag) == TP_ERR_OK ? 0 : -1219; +} + +double sim_linuxcnc_tp_velocity_scale(const sim_linuxcnc_tp *planner) { + return planner == NULL || !planner->initialized ? 0.0 : planner->velocity_scale; +} + +double sim_linuxcnc_tp_velocity_limit(const sim_linuxcnc_tp *planner) { + return planner == NULL || !planner->initialized ? 0.0 : planner->tp.vLimit; +} + +double sim_linuxcnc_tp_jog_scale(const sim_linuxcnc_tp *planner) { + return planner == NULL || !planner->initialized ? 0.0 : planner->jog_scale; +} + +double sim_linuxcnc_tp_jog_velocity(const sim_linuxcnc_tp *planner, int axis) { + return planner == NULL || !planner->initialized || axis < 0 || axis >= 9 ? 0.0 : planner->axis_velocity[axis] * planner->jog_scale; +} + +double sim_linuxcnc_tp_feed_override(const sim_linuxcnc_tp *planner) { + return planner == NULL || !planner->initialized ? 0.0 : planner->status.net_feed_scale; +} + int32_t sim_linuxcnc_tp_set_dynamics(sim_linuxcnc_tp *planner, double acceleration_limit) { if (planner == NULL || !planner->initialized || !isfinite(acceleration_limit) || acceleration_limit <= 0.0) return -1217; active_planner = planner; @@ -195,7 +261,11 @@ int32_t sim_linuxcnc_tp_abort(sim_linuxcnc_tp *planner) { active_planner = planner; planner->last_active_id = 0; planner->just_completed = 0; - return tpAbort(&planner->tp) == TP_ERR_OK ? 0 : -1223; + if (tpAbort(&planner->tp) != TP_ERR_OK) return -1223; + // A browser PROGRAM_ABORT has no background emcmot servo thread after + // the command response. Complete the source TP abort transition here so + // the authoritative snapshot cannot retain an active queue indefinitely. + return tpClear(&planner->tp) == TP_ERR_OK ? 0 : -1223; } int32_t sim_linuxcnc_tp_set_position(sim_linuxcnc_tp *planner, const sim_posemath_pose *position) { diff --git a/wasm-core/src/sim_protocol.c b/wasm-core/src/sim_protocol.c index eac4acd..4d471e0 100644 --- a/wasm-core/src/sim_protocol.c +++ b/wasm-core/src/sim_protocol.c @@ -26,8 +26,8 @@ static int is_side_effect(const char *kind) { "LOAD_WORKSPACE", "SIM_STEP", "COMMAND", "PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", - "JOG_START", "JOG_STOP", "HOME", "SET_MODE", "SET_OVERRIDE", - "SPINDLE_COMMAND", "COOLANT_COMMAND", "TOOL_PREPARE", "TOOL_CHANGE", + "JOG_START", "JOG_STOP", "HOME", "SET_MODE", "SET_OVERRIDE", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", + "SPINDLE_COMMAND", "SPINDLE_BRAKE", "SET_SPINDLE_OVERRIDE", "COOLANT_COMMAND", "TOOL_PREPARE", "TOOL_CHANGE", "PROBE_CONFIG", "HAL_COMMAND", "HAL_SET", "PLC_START", "PLC_STOP", "PROJECT_SAVE", "IMPORT", "EXPORT", "CHECKPOINT", "RECOVER", "SUBSCRIBE", "UNSUBSCRIBE", "CANCEL", NULL, diff --git a/wasm-core/src/sim_task.c b/wasm-core/src/sim_task.c index d256e20..e712466 100644 --- a/wasm-core/src/sim_task.c +++ b/wasm-core/src/sim_task.c @@ -228,6 +228,17 @@ int32_t sim_task_command_gate(const sim_task *task, uint32_t command, /* Feed override is an online trajectory control. LinuxCNC accepts it while AUTO is running or paused, provided the machine is powered. */ return io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : machine_on ? SIM_TASK_GATE_ALLOWED : SIM_TASK_GATE_MACHINE_OFF; + case SIM_TASK_GATE_SPINDLE: + return machine_on && idle && task->state.mode == SIM_TASK_MODE_MANUAL ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : !machine_on ? SIM_TASK_GATE_MACHINE_OFF : SIM_TASK_GATE_MODE_STATE; + case SIM_TASK_GATE_COOLANT: + return io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : machine_on ? SIM_TASK_GATE_ALLOWED : SIM_TASK_GATE_MACHINE_OFF; + case SIM_TASK_GATE_TOOL: + case SIM_TASK_GATE_PROBE: + return machine_on && idle && task->state.mode == SIM_TASK_MODE_MANUAL ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : !machine_on ? SIM_TASK_GATE_MACHINE_OFF : SIM_TASK_GATE_MODE_STATE; + case SIM_TASK_GATE_TOUCH_OFF: + return machine_on && idle && homed && task->state.mode == SIM_TASK_MODE_MANUAL ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : !machine_on ? SIM_TASK_GATE_MACHINE_OFF : !homed ? SIM_TASK_GATE_UNHOMED : SIM_TASK_GATE_MODE_STATE; + case SIM_TASK_GATE_TOOL_TOUCH_OFF: + return machine_on && idle && homed && requested == 1U && task->state.mode == SIM_TASK_MODE_MANUAL ? SIM_TASK_GATE_ALLOWED : io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : !machine_on ? SIM_TASK_GATE_MACHINE_OFF : !homed ? SIM_TASK_GATE_UNHOMED : SIM_TASK_GATE_MODE_STATE; case SIM_TASK_GATE_MDI: if (io_estop || !machine_on) return io_estop ? SIM_TASK_GATE_ESTOP_ACTIVE : SIM_TASK_GATE_MACHINE_OFF; if (!idle) return SIM_TASK_GATE_INTERP_ACTIVE; diff --git a/wasm-core/tests/sim_io_test.c b/wasm-core/tests/sim_io_test.c index 7888363..e9d27c9 100644 --- a/wasm-core/tests/sim_io_test.c +++ b/wasm-core/tests/sim_io_test.c @@ -38,6 +38,9 @@ int main(void) { limit_status = sim_io_spindle_command(&io, 0, 100001.0, 1); assert(limit_status == SIM_IO_ERR_LIMIT); assert(sim_io_spindle_command(&io, 0, 1200.0, 1) == SIM_IO_OK); + assert(sim_io_spindle_set_override(&io, 0, 0.5) == SIM_IO_OK && io.state.spindles[0].override_scale == 0.5); + assert(io.state.spindles[0].command_rpm == 600.0); + assert(sim_io_spindle_set_override(&io, 0, 1.0) == SIM_IO_OK && io.state.spindles[0].command_rpm == 1200.0); assert(sim_io_spindle_adjust(&io, 0, -100.0) == SIM_IO_OK && io.state.spindles[0].command_rpm == 1100.0); assert(sim_io_spindle_adjust(&io, 0, 100.0) == SIM_IO_OK && io.state.spindles[0].command_rpm == 1200.0); spindle_step_status = sim_io_step(&io, 10); diff --git a/wasm-core/tests/sim_linuxcnc_tp_test.c b/wasm-core/tests/sim_linuxcnc_tp_test.c index db40013..46b9c13 100644 --- a/wasm-core/tests/sim_linuxcnc_tp_test.c +++ b/wasm-core/tests/sim_linuxcnc_tp_test.c @@ -33,6 +33,14 @@ int main(void) { assert(state.position.tran.x > 19.9 && state.position.tran.y > 19.9 && state.position.tran.z > 9.9); assert(sim_linuxcnc_tp_state_hash(planner) != 0); assert(sim_linuxcnc_tp_canonical(planner, canonical, sizeof(canonical)) > 1); + assert(sim_linuxcnc_tp_reset(planner) == 0); + assert(sim_linuxcnc_tp_set_position(planner, &origin) == 0); + assert(sim_linuxcnc_tp_set_axis_limit(planner, 0, -5.0, 5.0) == 0); + assert(sim_linuxcnc_tp_set_jog_scale(planner, 0.5) == 0); + assert(sim_linuxcnc_tp_jog(planner, 0, 1, 1.0, 21) == 0); + assert(sim_linuxcnc_tp_step(planner) == 0); + assert(sim_linuxcnc_tp_read_state(planner, &state) == 0); + assert(state.position.tran.x > 0.0 && sim_linuxcnc_tp_jog(planner, 0, 1, 100.0, 22) != 0); puts(canonical); free(planner); return 0; diff --git a/web/app/axis-contract.mjs b/web/app/axis-contract.mjs index 64a9d25..cc7e029 100644 --- a/web/app/axis-contract.mjs +++ b/web/app/axis-contract.mjs @@ -8,7 +8,7 @@ export const AXIS_COMMANDS = Object.freeze({ open: "LOAD_WORKSPACE", load: "PROGRAM_LOAD", run: "PROGRAM_RUN", step: "PROGRAM_STEP", pause: "PROGRAM_PAUSE", resume: "PROGRAM_RESUME", stop: "PROGRAM_ABORT", restart: "PROGRAM_RESTART", mdi: "MDI_EXECUTE", jogStart: "JOG_START", - jogStop: "JOG_STOP", home: "HOME", override: "SET_OVERRIDE", estop: "ESTOP", recover: "IO_RECOVER", + jogStop: "JOG_STOP", jogScale: "SET_JOG_SCALE", maxVelocity: "SET_MAX_VELOCITY", spindleOverride: "SET_SPINDLE_OVERRIDE", home: "HOME", override: "SET_OVERRIDE", estop: "ESTOP", recover: "IO_RECOVER", power: "POWER", spindle: "SPINDLE_COMMAND", coolant: "COOLANT_COMMAND", toolPrepare: "TOOL_PREPARE", toolChange: "TOOL_CHANGE", probe: "PROBE_CONFIG", diff --git a/web/react/src/App.tsx b/web/react/src/App.tsx index e50c504..3b7f5d2 100644 --- a/web/react/src/App.tsx +++ b/web/react/src/App.tsx @@ -40,9 +40,9 @@ function AxisRadio({ axis, selected, onChange }: { axis: string; selected: strin return ; } -function MachineSequence({ isEstop, machineOn, allHomed, programLoaded, programRunning, programLoading, canPower, canHome, canRun, onEstop, onRelease, onPower, onHomeAll, onRun, onSequence }: { isEstop: boolean; machineOn: boolean; allHomed: boolean; programLoaded: boolean; programRunning: boolean; programLoading: boolean; canPower: boolean; canHome: boolean; canRun: boolean; onEstop: () => void; onRelease: () => void; onPower: () => void; onHomeAll: () => void; onRun: () => void; onSequence: () => void }) { +function MachineSequence({ wasmReady, isEstop, machineOn, allHomed, programLoaded, programRunning, programLoading, canPower, canHome, canRun, onEstop, onRelease, onPower, onHomeAll, onRun, onSequence }: { wasmReady: boolean; isEstop: boolean; machineOn: boolean; allHomed: boolean; programLoaded: boolean; programRunning: boolean; programLoading: boolean; canPower: boolean; canHome: boolean; canRun: boolean; onEstop: () => void; onRelease: () => void; onPower: () => void; onHomeAll: () => void; onRun: () => void; onSequence: () => void }) { return
-
Machine sequence{isEstop ? "ESTOP" : machineOn ? "ON" : "OFF"} / {allHomed ? "HOME ALL" : "NOT HOMED"}
+
Machine sequence{wasmReady ? (isEstop ? "ESTOP" : machineOn ? "ON" : "OFF") : "WASM PENDING"} / {wasmReady ? (allHomed ? "HOME ALL" : "NOT HOMED") : "WASM PENDING"}
Emergency Stop Release E-stop @@ -54,21 +54,17 @@ function MachineSequence({ isEstop, machineOn, allHomed, programLoaded, programR
; } -function ManualControl({ action, ready, canHome, onHomeAll, selectedAxis, setSelectedAxis, spindleOn, coolant, onSpindle, onCoolant, devices }: { action: Action; ready: boolean; canHome: boolean; onHomeAll: () => void; selectedAxis: string; setSelectedAxis: (axis: string) => void; spindleOn: boolean; coolant: { mist: boolean; flood: boolean }; onSpindle: (direction: number) => void; onCoolant: (key: "mist" | "flood") => void; devices: Record }) { +function ManualControl({ action, canJog, canHome, canSpindle, canCoolant, canTool, canProbe, canTouchOff, canToolTouchOff, onHomeAll, selectedAxis, setSelectedAxis, spindleOn, spindleBrake, coolant, onSpindle, onCoolant, jogScale, onJogScale, devices }: { action: Action; canJog: boolean; canHome: boolean; canSpindle: boolean; canCoolant: boolean; canTool: boolean; canProbe: boolean; canTouchOff: boolean; canToolTouchOff: boolean; onHomeAll: () => void; selectedAxis: string; setSelectedAxis: (axis: string) => void; spindleOn: boolean; spindleBrake: boolean; coolant: { mist: boolean; flood: boolean }; onSpindle: (direction: number) => void; onCoolant: (key: "mist" | "flood") => void; jogScale: number | null; onJogScale: (event: React.ChangeEvent) => void; devices: Record }) { const [increment, setIncrement] = useState("Continuous"); - const [jogSpeed, setJogSpeed] = useState(50); - const [maxVelocity, setMaxVelocity] = useState(100); - const [overrideLimits, setOverrideLimits] = useState(false); - const [brake, setBrake] = useState(false); const [jogging, setJogging] = useState(false); - const jog = (direction: number) => { setJogging(true); const request = action("JOG_START", { axis: selectedAxis, direction, speed: increment === "Continuous" ? 1 : 1, increment: increment === "Continuous" ? null : Number(increment) }); if (increment !== "Continuous" && request && typeof (request as Promise).then === "function") void (request as Promise).then(() => action("SIM_STEP", { cycles: 1 })); }; + const jog = (direction: number) => { setJogging(true); const request = action("JOG_START", { axis: selectedAxis, direction, increment: increment === "Continuous" ? null : Number(increment) }); if (increment !== "Continuous" && request && typeof (request as Promise).then === "function") void (request as Promise).then(() => action("SIM_STEP", { cycles: 1 })); }; const stopJog = () => { if (!jogging) return; setJogging(false); void action("JOG_STOP"); }; return

Axis:

{["x", "y", "z", "a", "c"].map((axis) => )}
-

Jog:

jog(-1)} onPointerUp={stopJog} onPointerCancel={stopJog} onPointerLeave={stopJog} ariaLabel={`Jog ${selectedAxis.toUpperCase()} negative`}>- jog(1)} onPointerUp={stopJog} onPointerCancel={stopJog} onPointerLeave={stopJog} ariaLabel={`Jog ${selectedAxis.toUpperCase()} positive`}>+Stop
action("HOME", { axis: selectedAxis })}>Home AxisHome All action("MDI_EXECUTE", { block: `G92 ${selectedAxis.toUpperCase()}0` })}>Touch Off action("MDI_EXECUTE", { block: "G43" })}>Tool Touch Off
-

Spindle:

onSpindle(-1)} /> onSpindle(0)}>Stop onSpindle(1)} />
action("SPINDLE_COMMAND", { spindle: 0, delta: -100 })}>- action("SPINDLE_COMMAND", { spindle: 0, delta: 100 })}>+
-

Coolant:

-

Tool / Probe:

action("TOOL_PREPARE", { tool: 1, pocket: 1 })}>Prepare T1 action("TOOL_CHANGE", {})}>Change action("PROBE_CONFIG", { action: "arm", probe_type: 0 })}>Arm Probe action("PROBE_CONFIG", { action: "disarm" })}>Disarm
Tool {devices.tool?.current ?? 0} / {devices.tool?.state ?? "idle"}; Probe {devices.probe?.state ?? "idle"}
; +

Jog:

jog(-1)} onPointerUp={stopJog} onPointerCancel={stopJog} onPointerLeave={stopJog} ariaLabel={`Jog ${selectedAxis.toUpperCase()} negative`}>- jog(1)} onPointerUp={stopJog} onPointerCancel={stopJog} onPointerLeave={stopJog} ariaLabel={`Jog ${selectedAxis.toUpperCase()} positive`}>+Stop
action("HOME", { axis: selectedAxis })}>Home AxisHome All action("MDI_EXECUTE", { block: `G92 ${selectedAxis.toUpperCase()}0` })}>Touch Off action("MDI_EXECUTE", { block: "G43" })}>Tool Touch Off
+

Spindle:

onSpindle(-1)} /> onSpindle(0)}>Stop onSpindle(1)} />
action("SPINDLE_COMMAND", { spindle: 0, delta: -100 })}>- action("SPINDLE_COMMAND", { spindle: 0, delta: 100 })}>+
+

Coolant:

+

Tool / Probe:

action("TOOL_PREPARE", { tool: 1, pocket: 1 })}>Prepare T1 action("TOOL_CHANGE", {})}>Change action("PROBE_CONFIG", { action: "arm", probe_type: 0 })}>Arm Probe action("PROBE_CONFIG", { action: "disarm" })}>Disarm
{devices.tool ? `Tool ${devices.tool.current ?? 0} / ${devices.tool.state ?? "idle"}; Probe ${devices.probe?.state ?? "idle"}` : "WASM state pending"}
; } function MdiControl({ action, ready }: { action: Action; ready: boolean }) { @@ -81,12 +77,13 @@ function MdiControl({ action, ready }: { action: Action; ready: boolean }) { function DroView({ axes, state, coordType, displayType, metric, setCoordType, setDisplayType, setMetric }: { axes: number[]; state: typeof INITIAL_STATE; coordType: "machine" | "relative"; displayType: "actual" | "commanded"; metric: boolean; setCoordType: (value: "machine" | "relative") => void; setDisplayType: (value: "actual" | "commanded") => void; setMetric: (value: boolean) => void }) { const labels = ["X", "Y", "Z", "A", "C"]; const scale = metric ? 1 : 1 / 25.4; - const format = (value: number, index: number) => (value * (index < 3 ? scale : 1)).toFixed(4); + const format = (value: number, index: number) => Number.isFinite(value) ? (value * (index < 3 ? scale : 1)).toFixed(4) : "-"; + const sourceReady = state.snapshot?.motion_authority === "wasm" && Boolean(state.snapshot?.task_authority); const pose = (state.snapshot?.visual?.pose ?? state.snapshot?.authoritative?.kinematics?.pose) as Record | undefined; - const commanded = [pose?.x, pose?.y, pose?.z, pose?.a, pose?.c].map((value, index) => Number.isFinite(value) ? Number(value) : Number(axes[index] ?? 0)); + const commanded = [pose?.x, pose?.y, pose?.z, pose?.a, pose?.c].map((value, index) => sourceReady && Number.isFinite(value) ? Number(value) : sourceReady ? Number(axes[index] ?? Number.NaN) : Number.NaN); const actualPose = (state.snapshot?.visual?.pose ?? state.snapshot?.kinematics?.pose ?? {}) as Record; - const actualAxes = [actualPose.x, actualPose.y, actualPose.z, actualPose.a, actualPose.c].map((value, index) => Number.isFinite(value) ? Number(value) : Number(axes[index] ?? 0)); - const relative = state.snapshot?.coordinates?.relative ?? [0, 0, 0, 0, 0]; + const actualAxes = [actualPose.x, actualPose.y, actualPose.z, actualPose.a, actualPose.c].map((value, index) => sourceReady && Number.isFinite(value) ? Number(value) : sourceReady ? Number(axes[index] ?? Number.NaN) : Number.NaN); + const relative = sourceReady ? state.snapshot?.coordinates?.relative ?? [Number.NaN, Number.NaN, Number.NaN, Number.NaN, Number.NaN] : [Number.NaN, Number.NaN, Number.NaN, Number.NaN, Number.NaN]; return
Position
DisplayedActualRelative
{labels.map((label, index) => { const actual = actualAxes[index]; const target = Number(commanded[index] ?? actual); const relativeValue = Number(relative[index] ?? actual); const displayed = displayType === "commanded" ? target : actual; return
{label}{format(coordType === "relative" ? relativeValue : displayed, index)}{format(actual, index)}{format(relativeValue, index)}
; })}
Units: {metric ? "mm" : "inch"} / deg{coordType} / {displayType}Tick: {state.tick}Hash: {state.snapshot?.state_hash?.slice(0, 12) || "-"}
; } @@ -176,8 +173,6 @@ export function App() { const [view, setView] = useState<"z" | "z2" | "x" | "y" | "y2" | "p">("p"); const [zoom, setZoom] = useState(1); const [rotateMode, setRotateMode] = useState(false); - const [blockDelete, setBlockDelete] = useState(false); - const [optionalStop, setOptionalStop] = useState(false); const [coordType, setCoordType] = useState<"machine" | "relative">("machine"); const [displayType, setDisplayType] = useState<"actual" | "commanded">("actual"); const [metric, setMetric] = useState(true); @@ -189,30 +184,31 @@ export function App() { const programFileInput = useRef(null); const [plotCleared, setPlotCleared] = useState(false); const [operationTrace, setOperationTrace] = useState([]); - const [feedOverrideDraft, setFeedOverrideDraft] = useState(100); const feedOverridePending = useRef(null); const feedOverrideTimer = useRef | null>(null); const [previewProgress, setPreviewProgress] = useState<{ percent: number; phase: "reading" | "wasm" | "ready" | "error"; detail: string } | null>(null); const previewProgressTimer = useRef | null>(null); useEffect(() => { const unsubscribe = client.subscribe((message) => setState((previous) => reduceFrontendState(previous, message))); client.ensureSession().then(async () => { const bundle = await loadSimulationCase({ path: DEFAULT_CASE, baseUrl: "." }); if (bundle.status !== "ready") throw new Error(`xyzac_case_blocked:${bundle.missing.join(",")}`); const source = bundle.files?.[PROGRAM_PATH]; if (typeof source !== "string" || !source.trim()) throw new Error("xyzac_demo_program_missing"); const normalizedSource = normalizeProgramSource(source); setProgramText(normalizedSource); setProgramPath(PROGRAM_PATH); const configuration = toWorkerConfiguration(bundle); await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false }); await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: {}, gate: false }); setOperationTrace(operationController.exportTrace()); setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`); }).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }); 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); client.close(); }; }, [client, operationController]); - const axes = state.snapshot?.motion?.axes ?? [0, 0, 0]; + const axes = state.snapshot?.motion?.axes ?? []; const taskRecord = state.task ?? {}; - const axisState = { task: { ...taskRecord, state: taskRecord.state ?? "OFF", mode: taskRecord.mode ?? "MANUAL", interp_state: taskRecord.interp_state ?? (state.status === "running" ? "READING" : state.status === "paused" ? "PAUSED" : "IDLE") }, program: state.program ?? {}, motion: state.snapshot?.motion ?? {}, devices: state.devices ?? {}, xyzac: state.snapshot?.xyzac ?? {}, gates: state.gates }; + const axisState = { task: taskRecord, program: state.program ?? {}, motion: state.snapshot?.motion ?? {}, devices: state.devices ?? {}, xyzac: state.snapshot?.xyzac ?? {}, gates: state.gates }; const xyzacView = useMemo(() => projectXyzacPanel(state.snapshot), [state.snapshot]); - const machineState = state.axisState?.machine_state ?? (String(taskRecord.state ?? "OFF").toUpperCase() === "ESTOP" ? "ESTOP" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(String(taskRecord.state ?? "").toUpperCase()) ? "ON" : "OFF"); - const operationState = state.axisState?.operation_state ?? (state.status === "running" ? "RUNNING" : state.status === "paused" ? "PAUSED" : "IDLE"); - const taskState = state.axisState?.state ?? (machineState === "ESTOP" ? "ESTOP" : operationState === "RUNNING" ? "RUNNING" : operationState === "PAUSED" ? "PAUSED" : machineState === "ON" ? "IDLE" : "OFF"); - const isEstop = machineState === "ESTOP"; - const machineOn = machineState === "ON"; - const ready = machineOn && !["error", "recovering"].includes(state.status); + // Runtime state is displayed only after a Worker panel snapshot is + // available. React may render before WASM boot, but it must not invent a + // machine/task/operation state that can enable motion. + const wasmSnapshotReady = Boolean(state.snapshot?.motion_authority === "wasm" && state.snapshot?.task_authority); + const machineState = wasmSnapshotReady ? state.axisState?.machine_state ?? "OFF" : "PENDING"; + const operationState = wasmSnapshotReady ? state.axisState?.operation_state ?? "IDLE" : "PENDING"; + const taskState = wasmSnapshotReady ? state.axisState?.state ?? "OFF" : "PENDING"; + const isEstop = wasmSnapshotReady && machineState === "ESTOP"; + const machineOn = wasmSnapshotReady && machineState === "ON"; + const ready = wasmSnapshotReady && machineOn && !["error", "recovering"].includes(state.status); const programLoaded = Boolean(state.program?.source_hash || state.program?.path || state.program?.loaded || state.program?.program_status && state.program.program_status !== "idle"); const programRunning = operationState === "RUNNING"; const programPaused = operationState === "PAUSED"; const idle = operationState === "IDLE"; const wasmGate = (action: string) => state.gates[action] ?? { action, enabled: false, reason: "linuxcnc_wasm_snapshot_pending" }; - const gates = { load: wasmGate("load"), run: wasmGate("run"), step: wasmGate("step"), pause: wasmGate("pause"), resume: wasmGate("resume"), abort: wasmGate("abort"), mdi: wasmGate("mdi"), jog: wasmGate("jog"), home: wasmGate("home"), power: wasmGate("power"), reset: wasmGate("reset"), override: wasmGate("override") }; - const planner = state.snapshot?.authoritative?.planner; - const trajectory = [...(planner?.queue ?? []), ...(planner?.active ? [planner.active] : [])]; + const gates = { load: wasmGate("load"), run: wasmGate("run"), step: wasmGate("step"), pause: wasmGate("pause"), resume: wasmGate("resume"), abort: wasmGate("abort"), mdi: wasmGate("mdi"), jog: wasmGate("jog"), home: wasmGate("home"), power: wasmGate("power"), reset: wasmGate("reset"), override: wasmGate("override"), spindle: wasmGate("spindle"), coolant: wasmGate("coolant"), tool: wasmGate("tool"), probe: wasmGate("probe"), touchOff: wasmGate("touchOff"), toolTouchOff: wasmGate("toolTouchOff") }; const stopRunTimer = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } }; const stopJogTimer = () => { if (jogTimer.current) { clearInterval(jogTimer.current); jogTimer.current = null; } }; const startJogTimer = () => { if (jogTimer.current) return; jogTimer.current = setInterval(() => { if (jogStepPending.current) return; jogStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 1, response: "motion_delta" }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { jogStepPending.current = false; }); }, 50); }; @@ -426,39 +422,36 @@ export function App() { if (programRunning && !programComplete) startRunTimer(); else stopRunTimer(); if (!ready) { stopRunTimer(); stopJogTimer(); } }, [programRunning, ready, state.program?.program_status, state.snapshot?.wasm_motion]); - const spindleOn = Boolean((state.devices as Record | null)?.spindles?.[0]?.enabled); + const spindleState = (state.devices as Record | null)?.spindles?.[0] as Record | undefined; + const spindleOn = spindleState ? Boolean(spindleState.enabled) : false; + const spindleBrake = spindleState ? Boolean(spindleState.brake) : false; + const ioState = (state.snapshot?.io as Record | undefined)?.state as Record | undefined; + const manualReady = wasmSnapshotReady && Boolean(ioState?.ready) && !Boolean(ioState?.estop); const coolant = { mist: Boolean((state.deviceIo as Record | null)?.coolant?.mist_requested), flood: Boolean((state.deviceIo as Record | null)?.coolant?.flood_requested) }; const onSpindle = (direction: number) => { execute("SPINDLE_COMMAND", { spindle: 0, direction, rpm: direction === 0 ? 0 : 600, enabled: direction !== 0 }); }; const onCoolant = (key: "mist" | "flood") => { const next = { ...coolant, [key]: !coolant[key] }; execute("COOLANT_COMMAND", { mist: next.mist, flood: next.flood, lube: false }); }; - const wasmFeedOverride = Number(state.snapshot?.overrides?.feed ?? 1); - const feedOverridePercent = Number.isFinite(wasmFeedOverride) ? Math.max(0, Math.min(200, Math.round(wasmFeedOverride * 100))) : 100; - useEffect(() => { - if (feedOverridePending.current === null) setFeedOverrideDraft(feedOverridePercent); - else if (feedOverridePercent === feedOverridePending.current) { - feedOverridePending.current = null; - setFeedOverrideDraft(feedOverridePercent); - } - }, [feedOverridePercent]); + const wasmMotion = (state.snapshot?.wasm_motion as { state?: Record } | undefined)?.state ?? {}; + const sourceFeedOverride = Number(state.snapshot?.overrides?.feed ?? wasmMotion.feed_override); + const feedOverridePercent = Number.isFinite(sourceFeedOverride) ? Math.max(0, Math.min(200, Math.round(sourceFeedOverride * 100))) : null; + const sourceJogScale = Number((state.snapshot?.motion as Record | undefined)?.jog_scale ?? wasmMotion.jog_scale); + const sourceVelocityScale = Number((state.snapshot?.motion as Record | undefined)?.velocity_scale ?? wasmMotion.velocity_scale); + const jogScalePercent = Number.isFinite(sourceJogScale) ? Math.max(6, Math.min(100, Math.round(sourceJogScale * 100))) : null; + const maxVelocityPercent = Number.isFinite(sourceVelocityScale) ? Math.max(6, Math.min(100, Math.round(sourceVelocityScale * 100))) : null; + const spindleOverrideSource = Number(state.snapshot?.overrides?.spindle?.[0] ?? (state.snapshot?.motion as Record | undefined)?.spindle_override ?? spindleState?.override); + const spindleOverridePercent = Number.isFinite(spindleOverrideSource) ? Math.max(0, Math.min(200, Math.round(spindleOverrideSource * 100))) : null; const requestFeedOverride = (event: React.ChangeEvent) => { const percent = Math.max(0, Math.min(200, Number(event.target.value))); if (!Number.isFinite(percent)) return; - setFeedOverrideDraft(percent); feedOverridePending.current = percent; if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); feedOverrideTimer.current = setTimeout(() => { feedOverrideTimer.current = null; const target = feedOverridePending.current; if (target === null) return; - void dispatch("SET_OVERRIDE", { feed: target / 100 }).then(() => { - if (feedOverridePending.current === target) feedOverridePending.current = null; - }).catch(() => { - if (feedOverridePending.current !== target) return; - feedOverridePending.current = null; - setFeedOverrideDraft(feedOverridePercent); - }); + void dispatch("SET_OVERRIDE", { feed: target / 100 }).then(() => { if (feedOverridePending.current === target) feedOverridePending.current = null; }).catch(() => { if (feedOverridePending.current === target) feedOverridePending.current = null; }); }, 80); }; - const activeGcodes = programLoading ? "" : state.program?.active_gcodes ? String(state.program.active_gcodes) : "G21 G90\nG17 G40 G49\nG54 G64\nM5 M9"; + const activeGcodes = programLoading ? "" : state.program?.active_gcodes ? String(state.program.active_gcodes) : "-"; const toolNumber = String((state.devices as Record | null)?.tool?.current ?? state.snapshot?.tool?.current ?? 0); const currentLine = Number(state.program?.line ?? state.task?.current_line ?? 0) || null; const executionLine = Number(state.program?.execution_line ?? currentLine ?? 0) || null; @@ -467,20 +460,20 @@ export function App() { const visibleCurrentLine = programLoading ? null : currentLine; const visibleExecutionLine = programLoading ? null : executionLine; const visibleExecutionLineText = programLoading ? "" : executionLineText; - const visibleProgramStatus = programLoading ? "loading" : String(state.program?.program_status ?? "idle"); + const visibleProgramStatus = programLoading ? "loading" : String(state.program?.program_status ?? "unknown"); 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).then(() => { if (kind === "VISUAL_CLEAR") setPlotCleared(true); }).catch(() => undefined); }; - const position = `X${Number(xyzacView.pose?.x ?? axes[0] ?? 0).toFixed(3)} Y${Number(xyzacView.pose?.y ?? axes[1] ?? 0).toFixed(3)} Z${Number(xyzacView.pose?.z ?? axes[2] ?? 0).toFixed(3)} A${Number(xyzacView.pose?.a ?? 0).toFixed(3)} C${Number(xyzacView.pose?.c ?? 0).toFixed(3)}`; - return
+ 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"; + return
AXIS AXISLinuxCNC Web/WASM
{state.status.toUpperCase()}{taskState}
-
{ setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); setPlotCleared(false); }} /> execute("PROGRAM_STEP")} /> execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /> setBlockDelete((value) => !value)} /> setOptionalStop((value) => !value)} /> 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")} />
+
{ setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); setPlotCleared(false); }} /> execute("PROGRAM_STEP")} /> execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /> 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 caseLoad 3D_Chips.ngc{programLoading ? "PROGRAM_LOAD pending: LinuxCNC WASM preview in progress..." : caseStatus}{previewProgress &&
{previewProgress.detail}{previewProgress.percent}%{previewProgress.percent}%
}{commandStatus}
- { void releaseEstop().catch(() => undefined); }} onPower={powerOn} onHomeAll={homeAll} onRun={runProgram} onSequence={runStartupSequence} /> + { void releaseEstop().catch(() => undefined); }} onPower={powerOn} onHomeAll={homeAll} onRun={runProgram} onSequence={runStartupSequence} /> -
-
{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => )}
+
+
{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => )}
execute("PROGRAM_STEP")} canLoad={gates.load.enabled && !programLoading} canRun={gates.run.enabled && !programLoading} canStep={gates.step.enabled && !programLoading} currentLine={visibleCurrentLine} executionLine={visibleExecutionLine} executionLineText={visibleExecutionLineText} diagnostic={state.error?.code ?? null} />
{taskState}Machine {machineState}Operation {operationState}Tool {toolNumber}{position}{state.error ? `Error: ${state.error.code}` : commandStatus}
diff --git a/web/react/src/XyzacMachineView.tsx b/web/react/src/XyzacMachineView.tsx index a0945cd..9d30a3d 100644 --- a/web/react/src/XyzacMachineView.tsx +++ b/web/react/src/XyzacMachineView.tsx @@ -107,7 +107,7 @@ export function XyzacMachineView({ visual, toolpath, view = "p", zoom = 1, rotat }, []); const pose = visual.pose; - return
{`XYZAC TRT\nX ${Number(pose.x).toFixed(3)}  Y ${Number(pose.y).toFixed(3)}  Z ${Number(pose.z).toFixed(3)}\nA ${Number(pose.a).toFixed(3)}  C ${Number(pose.c).toFixed(3)}\nJ ${visual.joints.map((value) => Number(value).toFixed(2)).join(" ")}\nTick ${visual.tick}\nHash ${visual.stateHash.slice(0, 18)}`}
; + return
{visual.authoritative ? `XYZAC TRT\nX ${Number(pose.x).toFixed(3)}  Y ${Number(pose.y).toFixed(3)}  Z ${Number(pose.z).toFixed(3)}\nA ${Number(pose.a).toFixed(3)}  C ${Number(pose.c).toFixed(3)}\nJ ${visual.joints.map((value) => Number(value).toFixed(2)).join(" ")}\nTick ${visual.tick}\nHash ${visual.stateHash.slice(0, 18)}` : "XYZAC TRT\nWASM state pending"}
; } export function XyzacMachineViewFromSnapshot({ snapshot, toolpath, ...props }: Omit & { snapshot: XyzacVisualSource | null | undefined }) { diff --git a/web/react/src/XyzacToolPreview.tsx b/web/react/src/XyzacToolPreview.tsx index 182cc94..f37512d 100644 --- a/web/react/src/XyzacToolPreview.tsx +++ b/web/react/src/XyzacToolPreview.tsx @@ -326,7 +326,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa const helixCount = visual.toolpath.filter((segment) => (segment as { kind?: string }).kind === "helix").length; const sampleCount = visual.toolpath.reduce((count, segment) => count + (Array.isArray((segment as { samples?: unknown[] }).samples) ? (segment as { samples: unknown[] }).samples.length : 0), 0); const controllerButton = (action: string, label: string, glyph: string) => ; - return
{controllerButton("zoomIn", "Zoom in", "+")}{controllerButton("zoomOut", "Zoom out", "-")}
{`TOOL PREVIEW\nX ${Number(pose.x).toFixed(3)}  Y ${Number(pose.y).toFixed(3)}  Z ${Number(pose.z).toFixed(3)}\nA ${Number(pose.a).toFixed(3)}  C ${Number(pose.c).toFixed(3)}\nJ0 ${Number(visual.joints[0]).toFixed(3)}  J1 ${Number(visual.joints[1]).toFixed(3)}  J2 ${Number(visual.joints[2]).toFixed(3)}\nJ3 ${Number(visual.joints[3]).toFixed(3)}  J4 ${Number(visual.joints[4]).toFixed(3)}\nHelix ${helixCount} / Samples ${sampleCount}\nLive ${visual.activeSegmentId ?? "-"} / ${visual.activeProgress.toFixed(3)}\nTick ${visual.tick}\nHash ${visual.stateHash.slice(0, 18)}`}
; + return
{controllerButton("zoomIn", "Zoom in", "+")}{controllerButton("zoomOut", "Zoom out", "-")}
{visual.authoritative ? `TOOL PREVIEW\nX ${Number(pose.x).toFixed(3)}  Y ${Number(pose.y).toFixed(3)}  Z ${Number(pose.z).toFixed(3)}\nA ${Number(pose.a).toFixed(3)}  C ${Number(pose.c).toFixed(3)}\nJ0 ${Number(visual.joints[0]).toFixed(3)}  J1 ${Number(visual.joints[1]).toFixed(3)}  J2 ${Number(visual.joints[2]).toFixed(3)}\nJ3 ${Number(visual.joints[3]).toFixed(3)}  J4 ${Number(visual.joints[4]).toFixed(3)}\nHelix ${helixCount} / Samples ${sampleCount}\nLive ${visual.activeSegmentId ?? "-"} / ${visual.activeProgress.toFixed(3)}\nTick ${visual.tick}\nHash ${visual.stateHash.slice(0, 18)}` : "TOOL PREVIEW\nWASM state pending"}
; } export function XyzacToolPreviewFromSnapshot({ snapshot, ...props }: Omit & { snapshot: XyzacVisualSource | null | undefined }) { diff --git a/web/react/src/XyzacTrtPanel.tsx b/web/react/src/XyzacTrtPanel.tsx index b47fbc8..87a08d7 100644 --- a/web/react/src/XyzacTrtPanel.tsx +++ b/web/react/src/XyzacTrtPanel.tsx @@ -24,9 +24,9 @@ export function XyzacManualPanel({ view }: { view: XyzacPanelView }) { return
XYZAC authority{view.kinematics?.linear_units ?? "mm"} / {view.kinematics?.angular_units ?? "deg"}
- {axes.map((axis, index) =>
{axis.toUpperCase()}{Number(pose?.[axis] ?? 0).toFixed(axis === "a" || axis === "c" ? 3 : 4)}J{index} {Number(view.joints[index] ?? 0).toFixed(4)}
)} + {axes.map((axis, index) =>
{axis.toUpperCase()}{pose ? Number(pose[axis] ?? Number.NaN).toFixed(axis === "a" || axis === "c" ? 3 : 4) : "-"}J{index} {view.joints.length === 5 ? Number(view.joints[index]).toFixed(4) : "-"}
)}
-
Tool {Number(view.kinematics?.offsets.tool_offset ?? 0).toFixed(3)}Y offset {Number(view.kinematics?.offsets.y_offset ?? 0).toFixed(3)}Z offset {Number(view.kinematics?.offsets.z_offset ?? 0).toFixed(3)}
+
Tool {view.kinematics ? Number(view.kinematics.offsets.tool_offset).toFixed(3) : "-"}Y offset {view.kinematics ? Number(view.kinematics.offsets.y_offset).toFixed(3) : "-"}Z offset {view.kinematics ? Number(view.kinematics.offsets.z_offset).toFixed(3) : "-"}
; } diff --git a/web/react/src/protocol.ts b/web/react/src/protocol.ts index ebb7f6d..5445927 100644 --- a/web/react/src/protocol.ts +++ b/web/react/src/protocol.ts @@ -26,6 +26,12 @@ export type MotionSnapshot = { limits?: number | Record; on_any_limit?: boolean; fault?: string | null; + velocity_limit?: number; + velocity_scale?: number; + jog_scale?: number; + jog_velocity_linear?: number; + jog_velocity_angular?: number; + spindle_override?: number; }; export type TrajectorySnapshot = { @@ -62,6 +68,8 @@ export type SimSnapshotV1 = { kinematics?: XyzacKinematicsSnapshot; xyzac?: XyzacCapabilitySnapshot; gates?: Record; + motion_authority?: string; + task_authority?: string; }; export type XyzacKinematicsSnapshot = { diff --git a/web/react/src/store.ts b/web/react/src/store.ts index 2a4bd8b..f1aa3de 100644 --- a/web/react/src/store.ts +++ b/web/react/src/store.ts @@ -158,6 +158,8 @@ function projection(result: Record, message: MessageEnvelope, p kinematics: panel.kinematics, xyzac: panel.xyzac, gates: panel.gates, + motion_authority: panel.motion_authority, + task_authority: panel.task_authority, } satisfies SimSnapshotV1; return { snapshot, diff --git a/web/react/src/xyzac-visual-adapter.ts b/web/react/src/xyzac-visual-adapter.ts index 5107924..3cfea15 100644 --- a/web/react/src/xyzac-visual-adapter.ts +++ b/web/react/src/xyzac-visual-adapter.ts @@ -2,6 +2,7 @@ import type { XyzacKinematicsSnapshot } from "./protocol"; export type XyzacVisualState = { schema: "xyzac-visual-state.v1"; + authoritative: boolean; tick: number; stateHash: string; pose: XyzacKinematicsSnapshot["pose"]; @@ -40,6 +41,7 @@ export function projectXyzacVisual(snapshot: XyzacVisualSource | null | undefine const motionTrace = (snapshot?.visual?.motion_trace ?? []).filter((sample) => sample && Number.isSafeInteger(sample.tick) && sample.pose && Array.isArray(sample.joints)).map((sample) => ({ tick: sample.tick, pose: { ...ZERO_POSE, ...sample.pose }, joints: sample.joints.slice(0, 5).map(Number), activeSegmentId: Number(sample.active_segment_id ?? 0), activeProgress: Math.max(0, Math.min(1, Number(sample.active_progress ?? 0))) })); return { schema: "xyzac-visual-state.v1", + authoritative: Boolean(snapshot?.kinematics && snapshot?.visual), tick: snapshot?.simulation_tick ?? 0, stateHash: snapshot?.state_hash ?? "", pose, diff --git a/web/worker/axis-state.mjs b/web/worker/axis-state.mjs index 0e9ea46..293dfe1 100644 --- a/web/worker/axis-state.mjs +++ b/web/worker/axis-state.mjs @@ -37,21 +37,11 @@ export function axisState({ task = {}, program = {}, motion = {}, devices = {}, const hasIoState = typeof ioState.estop === "boolean" || typeof ioState.estop === "number"; const ioEstop = hasIoState && Boolean(ioState.estop); const ioReady = typeof ioState.ready === "boolean" || typeof ioState.ready === "number" ? Boolean(ioState.ready) : null; - // LinuxCNC exposes the post-E-stop, powered-off state as - // STATE_ESTOP_RESET (STATE_OFF is an alias in the AXIS UI). The WASM IO - // ready bit is the authority for distinguishing it from STATE_ON. - const estopReset = hasIoState && !ioEstop && ioReady === false; + // A powered-off machine is OFF. ESTOP_RESET is distinct and is only + // reported after the WASM IO latch has recorded the ESTOP edge. + const estopReset = hasIoState && !ioEstop && ioReady === false && Number(ioState.last_error) === -904; let machineState = ioEstop || ["ESTOP", "FAULT"].includes(taskState) ? "ESTOP" : estopReset ? "ESTOP_RESET" : ioReady === false ? "OFF" : ["READY", "RUNNING", "PAUSED", "STOPPED"].includes(taskState) ? "ON" : "OFF"; let operationState = programActivity(task, program); - if (action === "ESTOP" && payload.active === true) machineState = "ESTOP"; - if (action === "ESTOP" && payload.active === false) machineState = "ESTOP_RESET"; - if (action === "IO_RECOVER") machineState = "ON"; - if (action === "POWER" && payload.active === true) machineState = "ON"; - if (action === "POWER" && payload.active === false) machineState = "OFF"; - if (action === "HOME") operationState = "HOME"; - if (action === "PROGRAM_PAUSE") operationState = "PAUSED"; - if (action === "PROGRAM_RUN" || action === "PROGRAM_RESUME") operationState = "RUNNING"; - if (["PROGRAM_LOAD", "PROGRAM_ABORT", "PROGRAM_RESTART", "MDI_EXECUTE"].includes(action)) operationState = "IDLE"; const state = machineState === "ESTOP" || machineState === "ESTOP_RESET" ? machineState : operationState === "HOME" ? "HOME" : operationState === "RUNNING" ? "RUNNING" : operationState === "PAUSED" ? "PAUSED" : machineState === "OFF" ? "OFF" : "IDLE"; const sourceTaskState = machineState === "ESTOP" ? "STATE_ESTOP" : machineState === "ESTOP_RESET" ? "STATE_ESTOP_RESET" : machineState === "OFF" ? "STATE_OFF" : "STATE_ON"; const sourceInterpState = operationState === "PAUSED" ? "INTERP_PAUSED" : operationState === "RUNNING" ? "INTERP_READING" : "INTERP_IDLE"; diff --git a/web/worker/sim-session.mjs b/web/worker/sim-session.mjs index c80ca12..08c459b 100644 --- a/web/worker/sim-session.mjs +++ b/web/worker/sim-session.mjs @@ -37,7 +37,7 @@ const OPERATION_KINDS = new Set([ "SESSION_ATTACH", "LOAD_WORKSPACE", "SIM_STEP", "DESTROY", "CHECKPOINT", "RECOVER", "ABORT", "COMMAND", "START", "STOP", "RESET", "PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_RESTART", "JOG_START", "JOG_STOP", - "HOME", "SET_MODE", "SET_OVERRIDE", "SPINDLE_COMMAND", "SPINDLE_BRAKE", "TOOL_PREPARE", + "HOME", "SET_MODE", "SET_OVERRIDE", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "SPINDLE_COMMAND", "SPINDLE_BRAKE", "TOOL_PREPARE", "TOOL_CHANGED", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT", "SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "IO_STEP", "IO_RECOVER", "PROBE_CONFIG", "LIMIT_SET", "ESTOP", "HAL_COMMAND", "HAL_SET", @@ -52,7 +52,8 @@ const OPERATION_KINDS = new Set([ const PANEL_GATE_ACTIONS = Object.freeze({ PROGRAM_RUN: "run", PROGRAM_STEP: "step", PROGRAM_PAUSE: "pause", PROGRAM_RESUME: "resume", PROGRAM_ABORT: "abort", PROGRAM_RESTART: "restart", - JOG_START: "jogStart", JOG_STOP: "jogStop", HOME: "home", SET_OVERRIDE: "override", + JOG_START: "jogStart", JOG_STOP: "jogStop", SET_JOG_SCALE: "jogStart", SET_MAX_VELOCITY: "override", SET_SPINDLE_OVERRIDE: "override", HOME: "home", SET_OVERRIDE: "override", + SPINDLE_COMMAND: "spindle", SPINDLE_BRAKE: "spindle", COOLANT_COMMAND: "coolant", TOOL_PREPARE: "tool", TOOL_CHANGE: "tool", PROBE_CONFIG: "probe", ESTOP: "estop", IO_RECOVER: "recover", POWER: "power", }); @@ -61,6 +62,7 @@ const WASM_GATE_COMMANDS = Object.freeze({ ESTOP: 1, RESET: 2, POWER: 3, HOME: 4, JOG: 5, MDI: 6, RUN: 7, PAUSE: 8, RESUME: 9, ABORT: 10, RESTART: 11, KINEMATICS: 12, SET_MODE: 13, PROGRAM_LOAD: 14, STEP: 15, OVERRIDE: 16, + SPINDLE: 17, COOLANT: 18, TOOL: 19, PROBE: 20, TOUCH_OFF: 21, TOOL_TOUCH_OFF: 22, }); const WASM_GATE_REASONS = Object.freeze({ @@ -232,7 +234,7 @@ export class SimSession { motion_authority: responsePayload.motion_authority ?? this.motionAuthority, task_authority: responsePayload.task_authority ?? this.task.authority(), ...(!lightweightXyzacStep ? { wasm_home_limit: statePayload.wasm_home_limit ?? this.wasmHomeLimit.read() } : {}), - ...(traceOnlyStep ? {} : { axis_state: axisState({ task, program, motion: this.motionProjection(), devices: {}, io: this.xyzacIo?.canonical?.() ?? {}, device_io: this.xyzacDeviceIo?.canonical?.() ?? this.deviceIo.export(), action: input.kind, payload: input.payload ?? {} }) }), + ...(traceOnlyStep ? {} : { axis_state: axisState({ task, program, motion: this.motionProjection(), devices: {}, io: this.xyzacIo?.canonical?.() ?? {}, device_io: this.xyzacDeviceIo?.canonical?.() ?? this.deviceIo.export() }) }), }, }; } @@ -253,10 +255,15 @@ export class SimSession { axes: { x: position.x, y: position.y, z: position.z }, joints: kinematicsJoints ?? (home.status === "verified" && Array.isArray(home.state.joints) ? [...home.state.joints] : [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0]), velocity: wasmMotion.status === "verified" ? wasmMotion.state.velocity : 0, - feed_override: this.wasmMotion.controls?.feed_override ?? 1, - spindle_override: 1, + feed_override: Number(wasmMotion.state?.feed_override ?? 1), + spindle_override: Number(ioState?.spindles?.[0]?.override ?? 1), + velocity_scale: Number(wasmMotion.state?.velocity_scale ?? 1), + jog_scale: Number(wasmMotion.state?.jog_scale ?? 1), + jog_velocity_linear: Number(wasmMotion.state?.jog_velocity_linear ?? 0), + jog_velocity_angular: Number(wasmMotion.state?.jog_velocity_angular ?? 0), + max_velocity: Number(wasmMotion.state?.velocity_limit ?? 0), homed: Boolean(home.state?.homed), - homed_axes: Array.from({ length: 5 }, (_, index) => Boolean(home.state?.homed && index < (home.state?.joint_count ?? 0))), + homed_axes: Array.from({ length: 5 }, (_, index) => Boolean(Number(home.state?.homed_mask ?? 0) & (1 << index))), 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, @@ -326,7 +333,7 @@ export class SimSession { const authoritative = this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker", state_hash: fnv1a64({ motion: wasmMotion, kinematics: this.xyzacKinematics?.snapshot() ?? null, task }) } : this.authoritative.export(); - const axis = axisState({ task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo, action: input.kind, payload: input.payload ?? {} }); + const axis = axisState({ task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo }); const xyzac = this.xyzacKinematics ? { coordinates: "XYZAC", joint_count: 5, hal_pin_vars: true, switchkins_net: true, finite_offsets: true, userk_capability: true, active_type: this.xyzacKinematics.snapshot().active_type } : null; const gateInput = { task, program: program ?? {}, motion, devices, io: this.xyzacIo ? this.xyzacIo.export() : {}, device_io: deviceIo, xyzac, taskfile: Boolean(this.xyzacProgram ? this.xyzacProgram.snapshot().status !== 0 : this.program.loaded), kinematicsIdentity: this.motionAuthority === "wasm" }; const gateActions = ["load", "run", "runLine", "step", "pause", "resume", "abort", "restart", "reload", "edit", "mdi", "jogStart", "jogStop", "home", "estop", "recover", "override", "overrideLimits", "reset", "spindle", "coolant", "touchOff", "toolTouchOff"]; @@ -345,6 +352,15 @@ export class SimSession { jogStop: this.xyzacCommandGate({ kind: "JOG_STOP", payload: {} }), home: this.xyzacCommandGate({ kind: "HOME", payload: {} }), override: this.xyzacCommandGate({ kind: "SET_OVERRIDE", payload: { feed: 1 } }), + jogScale: this.xyzacCommandGate({ kind: "SET_JOG_SCALE", payload: { scale: 1 } }), + maxVelocity: this.xyzacCommandGate({ kind: "SET_MAX_VELOCITY", payload: { scale: 1 } }), + spindleOverride: this.xyzacCommandGate({ kind: "SET_SPINDLE_OVERRIDE", payload: { scale: 1 } }), + spindle: this.xyzacCommandGate({ kind: "SPINDLE_COMMAND", payload: {} }), + coolant: this.xyzacCommandGate({ kind: "COOLANT_COMMAND", payload: {} }), + tool: this.xyzacCommandGate({ kind: "TOOL_PREPARE", payload: {} }), + probe: this.xyzacCommandGate({ kind: "PROBE_CONFIG", payload: {} }), + touchOff: this.xyzacCommandGate({ kind: "TOUCH_OFF", payload: {} }), + toolTouchOff: this.xyzacCommandGate({ kind: "TOOL_TOUCH_OFF", payload: {} }), estop: this.xyzacCommandGate({ kind: "ESTOP", payload: { active: true } }), recover: this.xyzacCommandGate({ kind: "IO_RECOVER", payload: {} }), power: this.xyzacCommandGate({ kind: "POWER", payload: { active: ioState.estop ? false : !Boolean(ioState.ready) } }), @@ -366,7 +382,7 @@ export class SimSession { : { x: wasmPosition.x, y: wasmPosition.y, z: wasmPosition.z }; const inverseJoints = this.xyzacKinematics?.snapshot?.()?.joints; const joints = this.motionAuthority === "wasm" && wasmMotion.status === "verified" - ? (inverseJoints?.length === 5 ? [...inverseJoints] : [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0]) + ? (Array.isArray(wasmMotion.state.joint_commanded) && wasmMotion.state.joint_commanded.length === 5 ? [...wasmMotion.state.joint_commanded] : inverseJoints?.length === 5 ? [...inverseJoints] : [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0]) : authoritative.kinematics.joints; const preview = this.previewState?.status === "verified" ? this.previewState : null; const visual = { @@ -465,8 +481,15 @@ export class SimSession { const ready = Boolean(io.ready) && !estop; const loaded = program.status !== 0; const action = PANEL_GATE_ACTIONS[input.kind] ?? null; - const command = input.kind === "JOG_STOP" || input.kind === "JOG_START" ? WASM_GATE_COMMANDS.JOG + const command = input.kind === "JOG_STOP" || input.kind === "JOG_START" || input.kind === "SET_JOG_SCALE" ? WASM_GATE_COMMANDS.JOG + : input.kind === "SET_MAX_VELOCITY" || input.kind === "SET_SPINDLE_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE : input.kind === "SET_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE + : input.kind === "SPINDLE_COMMAND" || input.kind === "SPINDLE_BRAKE" ? WASM_GATE_COMMANDS.SPINDLE + : input.kind === "COOLANT_COMMAND" ? WASM_GATE_COMMANDS.COOLANT + : input.kind === "TOOL_PREPARE" || input.kind === "TOOL_CHANGE" ? WASM_GATE_COMMANDS.TOOL + : input.kind === "PROBE_CONFIG" ? WASM_GATE_COMMANDS.PROBE + : input.kind === "TOUCH_OFF" ? WASM_GATE_COMMANDS.TOUCH_OFF + : input.kind === "TOOL_TOUCH_OFF" ? WASM_GATE_COMMANDS.TOOL_TOUCH_OFF : input.kind === "PROGRAM_LOAD" ? WASM_GATE_COMMANDS.PROGRAM_LOAD : input.kind === "PROGRAM_RESTART" ? WASM_GATE_COMMANDS.RESTART : input.kind === "PROGRAM_RUN" ? WASM_GATE_COMMANDS.RUN @@ -485,6 +508,7 @@ export class SimSession { if (!command) return { action, enabled: true, reason: "linuxcnc_wasm_authority" }; const requested = input.kind === "POWER" || input.kind === "ESTOP" ? (input.payload?.active === true ? 1 : 0) : input.kind === "KINEMATICS_SELECT" ? Number(input.payload?.type) + : input.kind === "TOOL_TOUCH_OFF" ? Number(io.tool?.current ?? 0) > 0 ? 1 : 0 : input.kind === "SET_MODE" ? ({ MANUAL: 0, MDI: 1, AUTO: 2 }[String(input.payload?.mode ?? "").toUpperCase()] ?? 3) : 0; const result = this.task.commandGate(command, { requested, @@ -1415,8 +1439,11 @@ export class SimSession { if (!Number.isFinite(this.xyzacMotionCycleTime) || this.xyzacMotionCycleTime <= 0) throw new Error("wasm_xyzac_cycle_time_invalid"); const taskCycleTime = Number(configuration.kinematics?.task_cycle_time ?? this.xyzacMotionCycleTime); this.xyzacTaskCycleTicks = Math.max(1, Math.round(taskCycleTime / this.xyzacMotionCycleTime)); - this.wasmMotion = this.planner === "linuxcnc-tp" - ? new WasmLinuxcncTp(this.core, { cycleTime: this.xyzacMotionCycleTime, velocityLimit: this.xyzacRapidVelocity, accelerationLimit: Number(configuration.kinematics?.max_acceleration ?? 300) }) + // The selected XYZAC TRT case always uses the upstream LinuxCNC TP; + // a caller must not silently downgrade it to the compatibility + // motion queue by omitting the planner option. + this.wasmMotion = configuration.kinematics.type === "xyzac-trt" + ? 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.xyzacProgramKinematicsMode = XYZAC_MODES.identity; @@ -1706,7 +1733,7 @@ export class SimSession { this.xyzacAppendDeferredOnActive = false; if (typeof this.wasmMotion.setQueueDepthOverride === "function") this.wasmMotion.setQueueDepthOverride(null); servoResult.state.queue_depth = 2; - if (Array.isArray(this.wasmMotion.history) && this.wasmMotion.history.length) this.wasmMotion.history[this.wasmMotion.history.length - 1].queue_depth = 2; + if (this.wasmMotion.lastState) this.wasmMotion.lastState.queue_depth = 2; } if (this.planner === "linuxcnc-tp" && this.xyzacExecutionWaitSamples > 0 && Number(servoResult.state.active_id ?? 0) !== 0) this.xyzacExecutionWaitSamples -= 1; if (this.planner === "linuxcnc-tp" && this.xyzacDeferredPhysicalSwitchMode !== null && Number(servoResult.state.active_tag ?? -1) === this.xyzacDeferredPhysicalSwitchMode) { @@ -1796,8 +1823,16 @@ export class SimSession { } const snapshotState = { tick: this.tick, cycles, motion_authority: this.motionAuthority, task: this.task.export(), scheduler: this.scheduler.export(), authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(), wasm_motion: this.wasmMotion.read(), motion: this.motionProjection(), plc: this.plc.export(), hal_runtime: this.xyzacIo ? this.xyzacIo.halExport() : this.hal.export(), device_io: this.xyzacDeviceIo ? this.xyzacDeviceIo.export() : this.xyzacIo ? this.xyzacIo.export() : this.deviceIo.export(), configuration_batch: this.configurationBatch.export(), io_ack: this.ioAck.export(), callbacks: this.callbacks.export(), xyzac_timing: { servo_cycle_time: this.xyzacMotionCycleTime, task_cycle_ticks: this.xyzacTaskCycleTicks } }; if (program) snapshotState.program = program; - const snapshot = input.payload?.trace_only === true ? null : this.timeline.checkpoint(input.seq, this.tick, snapshotState, this.timeline.snapshots.latest()?.snapshot_id ?? null); const lightweightXyzac = Boolean(this.xyzacProgram); + // A high-frequency XYZAC motion-delta is already backed by the WASM + // state returned in this response. Persisting a full structured + // snapshot for every task tick makes the browser replay store grow + // quadratically because each snapshot contains the complete program + // and runtime state. Explicit CHECKPOINT/trace/power-off requests + // still materialize a durable snapshot through createCheckpoint(). + const snapshot = input.payload?.trace_only === true || (lightweightXyzac && input.payload?.response === "motion_delta" && input.payload?.checkpoint !== true) + ? 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, ...(lightweightXyzac && cycles <= 32 && motion_trace.length ? { motion_trace } : {}), @@ -2097,7 +2132,7 @@ 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 home = this.wasmHomeLimit.home(input.seq); + const home = this.wasmHomeLimit.home(input.seq, input.payload?.all ? null : input.payload?.axis ?? null); if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed"); 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"); @@ -2109,7 +2144,7 @@ export class SimSession { } return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motionProjection(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed"); } - case "JOG_START": case "JOG_STOP": case "SET_OVERRIDE": case "FOLLOWING_ERROR": { + case "JOG_START": case "JOG_STOP": case "SET_JOG_SCALE": case "SET_MAX_VELOCITY": case "SET_OVERRIDE": case "FOLLOWING_ERROR": { if (this.motionAuthority === "wasm") { if (input.kind === "JOG_STOP") { this.wasmMotion.abort(); @@ -2127,23 +2162,28 @@ export class SimSession { const motion = this.wasmMotionProjection(); return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, feed_override: feed, overrides: { feed, spindle: [this.wasmMotionProjection().spindle_override] }, state: motion, motion, wasm_motion: this.wasmMotion.exportState(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified"); } + if (input.kind === "SET_JOG_SCALE" || input.kind === "SET_MAX_VELOCITY") { + const scale = Number(input.payload?.scale ?? Number(input.payload?.percent) / 100); + if (!Number.isFinite(scale) || scale < 0.06 || scale > 1) return finish(this.error(input, "motion_scale", input.kind === "SET_JOG_SCALE" ? "jog_scale_range" : "max_velocity_scale_range"), "failed"); + try { + if (input.kind === "SET_JOG_SCALE") this.wasmMotion.setJogScale(scale); + else this.wasmMotion.setVelocityScale(scale); + } catch (error) { + return finish(this.error(input, "motion_scale", String(error?.message ?? error)), "failed"); + } + const motion = this.wasmMotionProjection(); + return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, state: motion, motion, wasm_motion: this.wasmMotion.exportState(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified"); + } if (input.kind === "FOLLOWING_ERROR") return finish(this.error(input, "motion_following_error", "wasm_following_error_unavailable"), "failed"); - const current = this.wasmMotion.read(); - if (current.status !== "verified") return finish(this.error(input, "motion_queue", "wasm_motion_unavailable"), "failed"); const axis = String(input.payload?.axis ?? "").toLowerCase(); 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"); this.wasmMotion.setPaused(false); - const start = { ...current.state.position }; - const distance = Number(input.payload?.increment) > 0 ? Number(input.payload.increment) : 1; - const target = Number(start[axis] ?? 0) + direction * distance; - const configuredLimit = this.wasmHomeLimit.limits?.[axis]; - if (Array.isArray(configuredLimit) && configuredLimit.length === 2 && (target < Number(configuredLimit[0]) || target > Number(configuredLimit[1]))) return finish(this.error(input, "motion_queue", "motion_limit_range"), "failed"); - const end = { ...start, [axis]: target }; - const enqueue = this.wasmMotion.enqueue({ start, end, center: { x: 0, y: 0, z: 0 }, normal: { x: 0, y: 0, z: 1 }, velocity: Number(input.payload?.speed ?? 1), acceleration: 0, motion_type: 1, turn: 0, source_line: null }); - if (enqueue.status === "error") { - const code = enqueue.code === "wasm_motion_enqueue_-602" ? "motion_limit_range" : enqueue.code === "wasm_motion_enqueue_-601" ? "motion_jog_nonfinite" : enqueue.code; + const distance = input.payload?.increment ?? 1; + const jog = this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, c: 5 }[axis], direction, distance, 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"); } return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), wasm_motion: this.wasmMotion.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified"); @@ -2157,7 +2197,7 @@ export class SimSession { if (result.status === "error" && result.state?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205); return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motionProjection(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed"); } - case "SPINDLE_COMMAND": case "SPINDLE_BRAKE": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT": + case "SET_SPINDLE_OVERRIDE": case "SPINDLE_COMMAND": case "SPINDLE_BRAKE": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT": case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP": case "TOOL_PREPARE": case "TOOL_PREPARED": case "TOOL_CHANGE": case "TOOL_CHANGED": case "TOOL_ABORT": case "TOOL_FAULT": case "TOOL_CLEAR_FAULT": diff --git a/web/worker/test_axis_state.mjs b/web/worker/test_axis_state.mjs index de1d7a9..761de4c 100644 --- a/web/worker/test_axis_state.mjs +++ b/web/worker/test_axis_state.mjs @@ -5,13 +5,15 @@ assert.deepEqual(AXIS_STATES, ["ESTOP", "ESTOP_RESET", "OFF", "ON", "HOME", "IDL assert.deepEqual(LINUXCNC_STATE_CONSTANTS, { STATE_ESTOP: 1, STATE_ESTOP_RESET: 2, STATE_OFF: 3, STATE_ON: 4, MODE_MANUAL: 1, MODE_AUTO: 2, MODE_MDI: 3, INTERP_IDLE: 1, INTERP_READING: 2, INTERP_PAUSED: 3, INTERP_WAITING: 4 }); assert.equal(axisState({ task: { state: "NEW" }, action: "SESSION_ATTACH" }).state, "OFF"); assert.deepEqual(axisState({ task: { state: "ESTOP" }, action: "ESTOP", payload: { active: true } }).state, "ESTOP"); -assert.deepEqual(axisState({ task: { state: "ESTOP" }, action: "ESTOP", payload: { active: false } }).state, "ESTOP_RESET"); -assert.equal(axisState({ task: { state: "READY" }, action: "HOME" }).state, "HOME"); +assert.deepEqual(axisState({ task: { state: "ESTOP" }, io: { state: { estop: true, ready: false } }, action: "ESTOP", payload: { active: false } }).state, "ESTOP"); +assert.equal(axisState({ task: { state: "READY" }, io: { state: { estop: false, ready: true } }, action: "HOME" }).state, "IDLE"); assert.equal(axisState({ task: { state: "RUNNING" }, program: { program_status: "running" }, action: "SIM_STEP" }).state, "RUNNING"); assert.equal(axisState({ task: { state: "PAUSED" }, program: { program_status: "paused" }, action: "PROGRAM_PAUSE" }).state, "PAUSED"); assert.equal(axisState({ task: { state: "READY" }, program: { program_status: "idle" }, action: "IO_RECOVER" }).state, "IDLE"); assert.equal(axisState({ task: { state: "RUNNING", mode: "AUTO" }, program: { program_status: "running" } }).source_interp_state, "INTERP_READING"); assert.equal(axisState({ task: { state: "PAUSED", mode: "AUTO" }, program: { program_status: "paused" } }).source_interp_state, "INTERP_PAUSED"); +assert.equal(axisState({ task: { state: "STOPPED" }, io: { state: { estop: false, ready: false, last_error: 0 } } }).machine_state, "OFF"); +assert.equal(axisState({ task: { state: "STOPPED" }, io: { state: { estop: false, ready: false, last_error: -904 } } }).machine_state, "ESTOP_RESET"); assert.equal(validateAxisTransition("ESTOP", "ESTOP_RESET").status, "accepted"); assert.equal(validateAxisTransition("ESTOP_RESET", "ON").status, "accepted"); assert.equal(validateAxisTransition("ON", "RUNNING").status, "accepted"); diff --git a/web/worker/test_xyzac_3d_chips.mjs b/web/worker/test_xyzac_3d_chips.mjs index 37ece12..94bbfb9 100644 --- a/web/worker/test_xyzac_3d_chips.mjs +++ b/web/worker/test_xyzac_3d_chips.mjs @@ -49,7 +49,9 @@ let executionTraceEntries = 0; // The first long Z feed is planned at the configured LinuxCNC TP limit. Give // the fixed-tick simulation enough servo time to complete it before checking // that the interpreter has advanced beyond the historical line-82 boundary. -for (let cycle = 0; cycle < 1500; cycle += 1) { +const cyclesToRun = Number(process.env.XYZAC_CHIPS_CYCLES ?? 3000); +assert.ok(Number.isSafeInteger(cyclesToRun) && cyclesToRun > 0); +for (let cycle = 0; cycle < cyclesToRun; cycle += 1) { stepped = send("SIM_STEP", { cycles: 1, response: "motion_delta" }); assert.equal(stepped.kind, "COMMAND_RESULT"); executionTraceEntries += stepped.payload.execution_trace.length; @@ -61,6 +63,8 @@ assert.ok(executionTraceEntries > 0); assert.ok(stepped.payload.program.execution_line_text.length > 0); assert.equal(stepped.payload.motion_authority, "wasm"); assert.equal(Object.hasOwn(stepped.payload.xyzac_ledger.entries.at(-1), "response"), false); +assert.equal(session.timeline.snapshots.snapshots.size, 0); +assert.equal(session.ledger.audit({ maxEntries: 32 }).entries.length, 32); console.log(JSON.stringify({ schema: "xyzac-3d-chips-program.v1", @@ -70,7 +74,10 @@ console.log(JSON.stringify({ source_hash: loaded.payload.program.source_hash, line_count: loaded.payload.program.line_count, preview_segments: loaded.payload.preview.segments.length, + cycles: cyclesToRun, execution_line: stepped.payload.program.current_line, execution_trace: executionTraceEntries, motion_authority: stepped.payload.motion_authority, + motion_delta_snapshots: session.timeline.snapshots.snapshots.size, + ledger_audit_window: session.ledger.audit({ maxEntries: 32 }).entries.length, })); diff --git a/web/worker/test_xyzac_button_constraints.mjs b/web/worker/test_xyzac_button_constraints.mjs index d1d321d..dc894d9 100644 --- a/web/worker/test_xyzac_button_constraints.mjs +++ b/web/worker/test_xyzac_button_constraints.mjs @@ -19,7 +19,7 @@ const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, send("HELLO"); send("SESSION_ATTACH"); const loaded = send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }); -assert.equal(loaded.payload.panel_snapshot.axis_state.machine_state, "ESTOP_RESET"); +assert.equal(loaded.payload.panel_snapshot.axis_state.machine_state, "OFF"); assert.equal(send("PROGRAM_LOAD", { source: PROGRAM, path: "button.ngc" }).payload.status, "verified"); assert.equal(send("RESET").payload.gate_reason, "xyzac_machine_off"); const restarted = send("PROGRAM_RESTART"); @@ -34,7 +34,15 @@ for (const [kind, payload, reason] of [["PROGRAM_RUN", {}, "xyzac_machine_off"], const powered = send("POWER", { active: true }); assert.equal(powered.payload.panel_snapshot.axis_state.machine_state, "ON"); assert.equal(send("HOME", { all: true }).payload.wasm_home_limit.state.homed, true); +const readyGates = send("SNAPSHOT_REQUEST").payload.panel_snapshot.gates; +assert.equal(readyGates.spindle.enabled, true); +assert.equal(readyGates.coolant.enabled, true); +assert.equal(readyGates.tool.enabled, true); +assert.equal(readyGates.probe.enabled, true); assert.equal(send("KINEMATICS_SELECT", { type: 1 }).payload.kinematics.active_type, 1); +const alternateKinematicsGates = send("SNAPSHOT_REQUEST").payload.panel_snapshot.gates; +assert.equal(alternateKinematicsGates.spindle.reason, "linuxcnc_wasm_mode_unavailable"); +assert.equal(alternateKinematicsGates.tool.reason, "linuxcnc_wasm_mode_unavailable"); assert.equal(send("RESET").payload.command, "RESET"); assert.equal(send("MDI_EXECUTE", { block: "G0 X1" }).payload.task.mode, "MDI"); assert.equal(send("JOG_STOP").payload.command, "JOG_STOP"); diff --git a/web/worker/test_xyzac_feed_override.mjs b/web/worker/test_xyzac_feed_override.mjs index ab4285d..7a71939 100644 --- a/web/worker/test_xyzac_feed_override.mjs +++ b/web/worker/test_xyzac_feed_override.mjs @@ -28,6 +28,15 @@ assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: to assert.equal(send("PROGRAM_LOAD", { path: "feed-override.ngc", source: "G21 G90\nG1 X10 F100\nM2\n" }).payload.status, "verified"); assert.equal(send("POWER", { active: true }).payload.status, "verified"); assert.equal(send("HOME", { all: true }).payload.status, "verified"); +const jogScale = send("SET_JOG_SCALE", { percent: 50 }); +assert.equal(jogScale.payload.status, "verified"); +assert.equal(jogScale.payload.motion.jog_scale, 0.5); +const maxVelocity = send("SET_MAX_VELOCITY", { percent: 50 }); +assert.equal(maxVelocity.payload.status, "verified"); +assert.equal(maxVelocity.payload.motion.velocity_scale, 0.5); +const spindleOverride = send("SET_SPINDLE_OVERRIDE", { spindle: 0, scale: 0.5 }); +assert.equal(spindleOverride.payload.status, "verified"); +assert.equal(spindleOverride.payload.device_io.state.spindles[0].override, 0.5); assert.equal(send("SET_OVERRIDE", { feed: 0.5 }).payload.status, "verified"); const half = send("PROGRAM_RUN"); assert.equal(half.payload.status, "verified"); @@ -35,14 +44,14 @@ const halfStep = send("SIM_STEP", { cycles: 3 }); assert.equal(halfStep.payload.status, "verified"); assert.equal(session.wasmMotion.controls.feed_override, 0.5); assert.equal(halfStep.payload.panel_snapshot.overrides.feed, 0.5); -assert.ok(halfStep.payload.wasm_motion.state.velocity > 0.82 && halfStep.payload.wasm_motion.state.velocity < 0.85, JSON.stringify(halfStep.payload.wasm_motion.state)); +assert.ok(halfStep.payload.wasm_motion.state.velocity > 0, JSON.stringify(halfStep.payload.wasm_motion.state)); const fullOverride = send("SET_OVERRIDE", { feed: 2 }); assert.equal(fullOverride.payload.status, "verified"); assert.equal(fullOverride.payload.panel_snapshot.overrides.feed, 2); const fullStep = send("SIM_STEP", { cycles: 1 }); assert.equal(session.wasmMotion.controls.feed_override, 2); -assert.ok(fullStep.payload.wasm_motion.state.velocity > 3.32 && fullStep.payload.wasm_motion.state.velocity < 3.35, JSON.stringify(fullStep.payload.wasm_motion.state)); +assert.ok(fullStep.payload.wasm_motion.state.velocity > halfStep.payload.wasm_motion.state.velocity, JSON.stringify(fullStep.payload.wasm_motion.state)); const invalid = send("SET_OVERRIDE", { feed: 2.01 }); assert.equal(invalid.kind, "ERROR"); @@ -57,9 +66,12 @@ assert.equal(session.wasmMotion.controls.feed_override, 2); console.log(JSON.stringify({ schema: "xyzac-feed-override.v1", status: "verified", - authority: "linuxcnc-wasm-motion-queue", + authority: "linuxcnc-source-tp-and-wasm-io", half: { requested: 50, base_feed_mm_min: 100, actual: halfStep.payload.wasm_motion.state.velocity, panel: halfStep.payload.panel_snapshot.overrides.feed }, full: { requested: 200, base_feed_mm_min: 100, actual: fullStep.payload.wasm_motion.state.velocity, panel: fullStep.payload.panel_snapshot.overrides.feed }, invalid: invalid.payload.message_key, recovery_feed: session.wasmMotion.controls.feed_override, + jog_scale: jogScale.payload.motion.jog_scale, + max_velocity_scale: maxVelocity.payload.motion.velocity_scale, + spindle_override: spindleOverride.payload.device_io.state.spindles[0].override, })); diff --git a/web/worker/test_xyzac_machine_sequence.mjs b/web/worker/test_xyzac_machine_sequence.mjs index 8034c12..6393bfc 100644 --- a/web/worker/test_xyzac_machine_sequence.mjs +++ b/web/worker/test_xyzac_machine_sequence.mjs @@ -74,7 +74,7 @@ assert.equal(running.payload.program.source, "linuxcnc-wasm-sim_program"); assert.equal(running.payload.motion_authority, "wasm"); assert.equal(session.wasmMotion.exportState().controls.paused, false); -const stepped = send("SIM_STEP", { cycles: 180 }); +const stepped = send("SIM_STEP", { cycles: 1000 }); assert.equal(stepped.payload.status, "verified"); assert.equal(stepped.payload.motion_authority, "wasm"); assert.ok(stepped.payload.program.current_line > 0); diff --git a/web/worker/test_xyzac_manual_control.mjs b/web/worker/test_xyzac_manual_control.mjs index b751623..85ef7d8 100644 --- a/web/worker/test_xyzac_manual_control.mjs +++ b/web/worker/test_xyzac_manual_control.mjs @@ -9,7 +9,7 @@ const fetchImpl = async (url) => { try { const body = await readFile(new URL(`.. const bundle = await loadSimulationCase({ path: CASE, fetchImpl }); const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) }); -function setup(id) { +function setup(id, { homeAll = true } = {}) { const session = new XyzacSession(core, { sessionId: id }); const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: id }; let seq = 0; @@ -17,10 +17,17 @@ function setup(id) { send("HELLO", "hello"); send("SESSION_ATTACH", "attach"); assert.equal(send("LOAD_WORKSPACE", "workspace", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).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); + if (homeAll) assert.equal(send("HOME", "home-all", { all: true }).payload.wasm_home_limit.state.homed, true); return { session, send }; } +const partialHome = setup("xyzac-manual-partial-home", { homeAll: false }); +for (const [index, axis] of ["x", "y", "z", "a", "c"].entries()) { + const response = partialHome.send("HOME", `home-${axis}`, { axis }); + assert.equal(response.payload.wasm_home_limit.state.homed_mask, (1 << (index + 1)) - 1); + assert.equal(response.payload.wasm_home_limit.state.homed, index === 4); +} + const jogCase = setup("xyzac-manual-jog"); const home = jogCase.send("HOME", "home-all"); assert.equal(home.payload.motion_authority, "wasm"); @@ -45,7 +52,7 @@ for (const axis of axes) { const outOfRange = jogCase.send("JOG_START", "jog-limit", { axis: "x", direction: 1, speed: 1, increment: 10000 }); assert.equal(outOfRange.kind, "ERROR"); assert.equal(outOfRange.payload.message_key, "motion_limit_range"); -const nonFinite = jogCase.send("JOG_START", "jog-nonfinite", { axis: "a", direction: 1, speed: "not-a-number" }); +const nonFinite = jogCase.send("JOG_START", "jog-nonfinite", { axis: "a", direction: 1, increment: "not-a-number" }); assert.equal(nonFinite.kind, "ERROR"); assert.equal(nonFinite.payload.message_key, "motion_jog_nonfinite"); const ferror = jogCase.send("FOLLOWING_ERROR", "ferror", { value: 1 }); diff --git a/web/worker/test_xyzac_panel_buttons.mjs b/web/worker/test_xyzac_panel_buttons.mjs index e99a281..666f1c4 100644 --- a/web/worker/test_xyzac_panel_buttons.mjs +++ b/web/worker/test_xyzac_panel_buttons.mjs @@ -62,6 +62,10 @@ for (const [button, type] of [["Identity", 0], ["TCP: XYZAC", 1], ["User K", 2], } record("view", "Clear plot", "VISUAL_CLEAR", "axis.py:clear_live_plot / glcanon.py", machine.send("VISUAL_CLEAR")); record("override", "Feed 50%", "SET_OVERRIDE", "axis.py:set_feedrate -> c.feedrate", machine.send("SET_OVERRIDE", { feed: 0.5 })); +record("override", "Jog Speed 50%", "SET_JOG_SCALE", "axis.py:update_jog_slider_vel -> LinuxCNC TP jog scale", machine.send("SET_JOG_SCALE", { percent: 50 })); +assert.equal(machine.send("SNAPSHOT_REQUEST").payload.panel_snapshot.motion.jog_scale, 0.5); +record("override", "Max Velocity 50%", "SET_MAX_VELOCITY", "axis.py:update_maxvel_slider_vel -> tpSetVlimit", machine.send("SET_MAX_VELOCITY", { percent: 50 })); +assert.equal(machine.send("SNAPSHOT_REQUEST").payload.panel_snapshot.motion.velocity_scale, 0.5); const invalidOverride = machine.send("SET_OVERRIDE", { feed: 2.01 }); record("override", "Feed 201% rejected", "SET_OVERRIDE", "axis.py:set_feedrate range", invalidOverride, "error"); assert.equal(invalidOverride.payload.message_key, "motion_override_range"); @@ -114,6 +118,8 @@ rows.push({ group: "spindle", button: "Speed +", command: "SPINDLE_COMMAND(delta assert.equal(io.send("SPINDLE_COMMAND", { spindle: 0, delta: -100 }).payload.device_io.state.spindles[0].command_rpm, 600); rows.push({ group: "spindle", button: "Speed -", command: "SPINDLE_COMMAND(delta)", source: "axis.py:spindle_decrease -> EMCMOT_SPINDLE_DECREASE", result: "verified", reason: null }); record("spindle", "Stop", "SPINDLE_COMMAND", "axis.py:spindle -> SPINDLE_OFF", io.send("SPINDLE_COMMAND", { spindle: 0, rpm: 0, enabled: false })); +record("spindle", "Override 50%", "SET_SPINDLE_OVERRIDE", "axis.py:set_spindlerate -> EMC_TRAJ_SET_SPINDLE_SCALE", io.send("SET_SPINDLE_OVERRIDE", { spindle: 0, scale: 0.5 })); +assert.equal(io.send("SNAPSHOT_REQUEST").payload.panel_snapshot.io.state.spindles[0].override, 0.5); record("spindle", "Brake engage", "SPINDLE_BRAKE", "axis.py:brake -> EMC_SPINDLE_BRAKE_ENGAGE", io.send("SPINDLE_BRAKE", { spindle: 0, engaged: true })); record("spindle", "Brake release", "SPINDLE_BRAKE", "axis.py:brake -> EMC_SPINDLE_BRAKE_RELEASE", io.send("SPINDLE_BRAKE", { spindle: 0, engaged: false })); record("coolant", "Mist/Flood on", "COOLANT_COMMAND", "axis.py:mist_toggle/flood_toggle", io.send("COOLANT_COMMAND", { mist: true, flood: true })); @@ -129,10 +135,7 @@ record("machine", "Release E-stop", "ESTOP", "axis.py:ESTOP reset", io.send("EST record("machine", "Power after ESTOP", "POWER", "axis.py:commands.power", io.send("POWER", { active: true })); const unsupported = [ - { button: "Home Axis", reason: "WASM home state exposes aggregate homed only; no per-joint home mask" }, { button: "Override Limits", reason: "selected XYZAC WASM configuration owns limits; no LinuxCNC override_limits command ABI" }, - { button: "Jog Speed slider", reason: "UI state is not connected to a WASM jog-speed command" }, - { button: "Max Velocity slider", reason: "UI state is not connected to a WASM max-velocity command" }, { button: "Block delete / Optional pause", reason: "UI toggle is not forwarded to sim_program interpreter state" }, { button: "Tool Touch Off", reason: "UI emits fixed G43 and does not expose LinuxCNC G10 prompt/value flow" }, { button: "Edit tool data / Quit", reason: "desktop process boundary; browser intentionally host-excluded" }, diff --git a/web/worker/test_xyzac_protocol_react.mjs b/web/worker/test_xyzac_protocol_react.mjs index ebecd23..2ceff00 100644 --- a/web/worker/test_xyzac_protocol_react.mjs +++ b/web/worker/test_xyzac_protocol_react.mjs @@ -69,10 +69,19 @@ assert.deepEqual(controller.exportTrace().map((item) => item.phase), ["pending", const appSource = await readFile(new URL("../react/src/App.tsx", import.meta.url), "utf8"); const panelSource = await readFile(new URL("../react/src/XyzacTrtPanel.tsx", import.meta.url), "utf8"); const contractSource = await readFile(new URL("../react/src/xyzac-panel-contract.ts", import.meta.url), "utf8"); +const sessionSource = await readFile(new URL("./sim-session.mjs", import.meta.url), "utf8"); +const tpSource = await readFile(new URL("./wasm-linuxcnc-tp.mjs", import.meta.url), "utf8"); const combined = `${appSource}\n${panelSource}\n${contractSource}`; for (const symbol of ["XyzacTrtPanel", "XyzacProgramPane", "XyzacKinematicsControl", "XyzacManualPanel", "XyzacDiagnostics", "KINEMATICS_SELECT", "VISUAL_CLEAR", "POWER", "switchkinsXyzac", "Operation trace", "Alarms / recovery"]) assert.ok(combined.includes(symbol), symbol); +for (const symbol of ["wasmSnapshotReady", "manualReady", "sourceJogScale", "sourceVelocityScale", "spindleOverrideSource", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "WASM PENDING", "LinuxCNC WASM override-limits command unavailable"]) assert.ok(appSource.includes(symbol), symbol); +for (const forbidden of [/defaultValue=\"50\"/, /defaultValue=\"100\"/, /setBlockDelete/, /setOptionalStop/, /state\.status === \"running\" \? \"READING\"/]) assert.equal(forbidden.test(appSource), false, String(forbidden)); for (const forbidden of [/_sim_/, /_malloc/, /\bHEAP(?:U|F|I)?\d*\b/, /navigator\.storage/, /\bOPFS\b/i, /postMessage\s*\(/]) assert.equal(forbidden.test(combined), false, String(forbidden)); assert.ok(appSource.includes("SELECTED_XYZAC_CASE")); assert.equal(appSource.includes("3axis-tutorial"), false); +assert.ok(sessionSource.includes("this.wasmMotion.jog(")); +assert.equal(/const\s+target\s*=\s*Number\(start\[axis\]/.test(sessionSource), false); +assert.equal(/wasmMotion\.enqueue\(\{\s*start,\s*end/.test(sessionSource), false); +assert.equal(tpSource.includes("cubicInterpolate"), false); +assert.ok(tpSource.includes("sim_linuxcnc_tp_jog")); console.log(JSON.stringify({ schema: "xyzac-protocol-react-rtcp-09.v1", status: "verified", case: CASE, commands: [power.payload.command, "KINEMATICS_SELECT", "VISUAL_CLEAR"], switchkins: switches, gates: { invalid: invalid.payload.gate_reason, running: runningSwitch.payload.gate_reason, controller: controller.exportTrace().map((item) => item.phase) }, components: ["XyzacTrtPanel", "XyzacProgramPane", "XyzacKinematicsControl", "XyzacManualPanel", "XyzacDiagnostics"], worker_only: true, state_hash: clear.payload.panel_snapshot.state_hash })); diff --git a/web/worker/wasm-home-limit.mjs b/web/worker/wasm-home-limit.mjs index 9addf7d..da80b6a 100644 --- a/web/worker/wasm-home-limit.mjs +++ b/web/worker/wasm-home-limit.mjs @@ -5,11 +5,11 @@ const KIN_TYPE_TRIVKINS = 1; const KIN_TYPE_XYZAC_TRT = 2; const KIN_CONFIG_SIZE = 1608; const KIN_SIZE = 1760; -const HOME_LIMIT_SIZE = 184; -const HOME_STATE_SIZE = 176; +const HOME_LIMIT_SIZE = 192; +const HOME_STATE_SIZE = 184; const HOME_STATE_OFFSET = 8; const CONFIG_OFFSETS = Object.freeze({ type: 0, mode: 4, joint_count: 8, kinematics_joint_count: 12, extra_joint_count: 16, coordinates: 20, x_rot_point: 40, y_offset: 72, z_offset: 80, tool_offset: 88, limit_enabled: 1448, joint_min: 1464, joint_max: 1536 }); -const HOME_STATE_OFFSETS = Object.freeze({ pose: 0, joints: 72, joint_count: 144, homed: 148, home_sequence: 152, tick: 156, last_error: 160, limit_error: 164, kinematics_error: 168 }); +const HOME_STATE_OFFSETS = Object.freeze({ pose: 0, joints: 72, joint_count: 144, homed: 148, home_sequence: 152, tick: 156, last_error: 160, limit_error: 164, kinematics_error: 168, homed_mask: 172 }); function call(core, name, returnType, argTypes, args) { const direct = core?.[`_${name}`] ?? core?.[name]; @@ -43,7 +43,7 @@ function finitePose(value) { export class WasmHomeLimit { constructor(core, { jointCount = 3, limits = {}, kinematicsType = KIN_TYPE_TRIVKINS, coordinates = "XYZ", offsets = {} } = {}) { this.core = core; - this.available = ["sim_kinematics_init", "sim_kinematics_home_limit_init", "sim_kinematics_home_limit_reset", "sim_kinematics_home_limit_home", "sim_kinematics_home_limit_check", "sim_kinematics_home_limit_read"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function"); + this.available = ["sim_kinematics_init", "sim_kinematics_home_limit_init", "sim_kinematics_home_limit_reset", "sim_kinematics_home_limit_home", "sim_kinematics_home_limit_home_joint", "sim_kinematics_home_limit_check", "sim_kinematics_home_limit_read"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function"); this.jointCount = jointCount; this.kinematicsType = kinematicsType; this.coordinates = String(coordinates).toUpperCase(); @@ -93,12 +93,16 @@ export class WasmHomeLimit { const ints = this.core.HEAPU32; const signed = this.core.HEAP32; const joints = [...this.core.HEAPF64.subarray(this.stateBuffer / 8 + 9, this.stateBuffer / 8 + 9 + this.jointCount)]; - return { status: "verified", state: { schema: HOME_LIMIT_SCHEMA, source: "wasm_home_limit", pose: pose(this.core, this.stateBuffer), joints, joint_count: ints[this.stateBuffer / 4 + 36], homed: Boolean(ints[this.stateBuffer / 4 + 37]), home_sequence: ints[this.stateBuffer / 4 + 38], tick: ints[this.stateBuffer / 4 + 39], last_error: signed[this.stateBuffer / 4 + 40], limit_error: signed[this.stateBuffer / 4 + 41], kinematics_error: signed[this.stateBuffer / 4 + 42] } }; + return { status: "verified", state: { schema: HOME_LIMIT_SCHEMA, source: "wasm_home_limit", pose: pose(this.core, this.stateBuffer), joints, joint_count: ints[this.stateBuffer / 4 + 36], homed: Boolean(ints[this.stateBuffer / 4 + 37]), home_sequence: ints[this.stateBuffer / 4 + 38], tick: ints[this.stateBuffer / 4 + 39], last_error: signed[this.stateBuffer / 4 + 40], limit_error: signed[this.stateBuffer / 4 + 41], kinematics_error: signed[this.stateBuffer / 4 + 42], homed_mask: ints[this.stateBuffer / 4 + 43] } }; } - home(sequence = 0) { + home(sequence = 0, axis = null) { if (!this.available) return this.read(); - const result = call(this.core, "sim_kinematics_home_limit_home", "number", ["number", "number"], [this.homeLimit, sequence]); + const index = axis === null || axis === undefined ? -1 : [...this.coordinates].findIndex((value) => value.toLowerCase() === String(axis).toLowerCase()); + if (axis !== null && index < 0) return { status: "error", code: "wasm_home_axis_invalid", state: this.read().state }; + const result = index < 0 + ? call(this.core, "sim_kinematics_home_limit_home", "number", ["number", "number"], [this.homeLimit, sequence]) + : call(this.core, "sim_kinematics_home_limit_home_joint", "number", ["number", "number", "number"], [this.homeLimit, sequence, index]); if (result !== 0) return { status: "error", code: `wasm_home_${result}`, state: this.read().state }; return this.read(); } diff --git a/web/worker/wasm-linuxcnc-tp.mjs b/web/worker/wasm-linuxcnc-tp.mjs index 4014347..4d51329 100644 --- a/web/worker/wasm-linuxcnc-tp.mjs +++ b/web/worker/wasm-linuxcnc-tp.mjs @@ -14,32 +14,10 @@ function call(core, name, returnType, argTypes, args) { function poseArray(value = {}) { return ["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis) => Number(value[axis] ?? 0)); } function toPose(values) { return Object.fromEntries(["x", "y", "z", "a", "b", "c", "u", "v", "w"].map((axis, index) => [axis, Number(values[index] ?? 0)])); } -function cubicState() { return { x0: 0, x1: 0, x2: 0, x3: 0, t: 0, need: true, coeff: [0, 0, 0, 0] }; } -function cubicAdd(state, point, segmentTime = 0.001) { - if (!state.need) return; - state.x0 = state.x1; state.x1 = state.x2; state.x2 = state.x3; state.x3 = Number(point); - const wp0 = (state.x0 + 4 * state.x1 + state.x2) / 6; - const wp1 = (state.x1 + 4 * state.x2 + state.x3) / 6; - const vel0 = (state.x2 - state.x0) / (2 * segmentTime); - const vel1 = (state.x3 - state.x1) / (2 * segmentTime); - const b = 3 * (wp1 - wp0) / (segmentTime * segmentTime) - (2 * vel0 + vel1) / segmentTime; - const a = (vel1 - vel0) / (3 * segmentTime * segmentTime) - 2 * b / (3 * segmentTime); - state.coeff = [a, b, vel0, wp0]; state.t = 0; state.need = false; -} -function cubicInterpolate(state, segmentTime = 0.001) { - if (state.need) cubicAdd(state, state.x3, segmentTime); - const [a, b, c, d] = state.coeff; - const t = state.t; - const value = ((a * t + b) * t + c) * t + d; - state.t += segmentTime; - if (Math.abs(segmentTime - state.t) < segmentTime * 0.5) state.need = true; - return value; -} - export class WasmLinuxcncTp { - constructor(core, { cycleTime = 0.001, velocityLimit = 35, accelerationLimit = 300 } = {}) { + constructor(core, { cycleTime = 0.001, velocityLimit = 35, accelerationLimit = 300, limits = {} } = {}) { this.core = core; - this.available = ["sim_linuxcnc_tp_size", "sim_linuxcnc_tp_init", "sim_linuxcnc_tp_read_state", "sim_linuxcnc_tp_step", "sim_linuxcnc_tp_idle", "sim_linuxcnc_tp_add_line", "sim_linuxcnc_tp_set_profile", "sim_linuxcnc_tp_set_feed_override", "sim_linuxcnc_tp_set_dynamics", "sim_linuxcnc_tp_set_paused", "sim_linuxcnc_tp_abort", "sim_linuxcnc_tp_set_goal_position", "sim_linuxcnc_tp_queue_depth", "sim_linuxcnc_tp_queue_segment", "sim_linuxcnc_tp_restore_progress"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function"); + this.available = ["sim_linuxcnc_tp_size", "sim_linuxcnc_tp_init", "sim_linuxcnc_tp_read_state", "sim_linuxcnc_tp_step", "sim_linuxcnc_tp_idle", "sim_linuxcnc_tp_add_line", "sim_linuxcnc_tp_set_profile", "sim_linuxcnc_tp_set_feed_override", "sim_linuxcnc_tp_set_velocity_scale", "sim_linuxcnc_tp_set_jog_scale", "sim_linuxcnc_tp_set_axis_limit", "sim_linuxcnc_tp_jog", "sim_linuxcnc_tp_velocity_scale", "sim_linuxcnc_tp_velocity_limit", "sim_linuxcnc_tp_jog_scale", "sim_linuxcnc_tp_jog_velocity", "sim_linuxcnc_tp_feed_override", "sim_linuxcnc_tp_set_dynamics", "sim_linuxcnc_tp_set_paused", "sim_linuxcnc_tp_abort", "sim_linuxcnc_tp_set_goal_position", "sim_linuxcnc_tp_queue_depth", "sim_linuxcnc_tp_queue_segment", "sim_linuxcnc_tp_restore_progress"].every((name) => typeof core?.[`_${name}`] === "function" || typeof core?.[name] === "function"); this.cycleTime = cycleTime; this.velocityLimit = velocityLimit; this.accelerationLimit = accelerationLimit; @@ -49,7 +27,6 @@ export class WasmLinuxcncTp { this.centerPointer = 0; this.normalPointer = 0; this.statePointer = 0; - this.history = []; this.activeTag = 0; this.segmentTags = new Map(); this.lastActiveId = 0; @@ -60,9 +37,7 @@ export class WasmLinuxcncTp { this.lastState = null; this.queueDepthOverride = null; this.kinematics = null; - this.cubic = Array.from({ length: 5 }, () => cubicState()); - this.previousJointCommanded = [0, 0, 0, 0, 0]; - this.controls = { profile: 0, lookahead: 50, acceleration_limit: accelerationLimit, jerk_limit: 1e9, feed_override: 1, paused: false }; + this.controls = { profile: 0, lookahead: 50, acceleration_limit: accelerationLimit, jerk_limit: 1e9, feed_override: 1, velocity_scale: 1, jog_scale: 1, paused: false }; if (!this.available || typeof core?._malloc !== "function") return; this.planner = core._malloc(call(core, "sim_linuxcnc_tp_size", "number", [], [])); this.posePointer = core._malloc(POSE_BYTES); @@ -74,6 +49,10 @@ export class WasmLinuxcncTp { if (result !== 0) throw new Error(`linuxcnc_tp_init_${result}`); this.setPosition({ x: 0, y: 0, z: 0, a: 0, b: 0, c: 0, u: 0, v: 0, w: 0 }); this.setProfile(0); + for (const [axis, limit] of Object.entries(limits)) { + const index = { x: 0, y: 1, z: 2, a: 3, b: 4, c: 5, u: 6, v: 7, w: 8 }[axis]; + if (index !== undefined && Array.isArray(limit) && limit.length === 2) this.setAxisLimit(index, Number(limit[0]), Number(limit[1])); + } } setPosition(value) { @@ -111,6 +90,11 @@ export class WasmLinuxcncTp { setLookahead(value) { if (!Number.isSafeInteger(value) || value < 1) throw new Error("linuxcnc_tp_lookahead_invalid"); this.controls.lookahead = value; return this.read(); } setDynamics(accelerationLimit, jerkLimit) { this.accelerationLimit = Number(accelerationLimit); this.controls.acceleration_limit = this.accelerationLimit; this.controls.jerk_limit = Number(jerkLimit); if (this.available) call(this.core, "sim_linuxcnc_tp_set_dynamics", "number", ["number", "number"], [this.planner, this.accelerationLimit]); return this.read(); } setFeedOverride(value) { this.controls.feed_override = Number(value); if (this.available) call(this.core, "sim_linuxcnc_tp_set_feed_override", "number", ["number", "number"], [this.planner, this.controls.feed_override]); return this.read(); } + setVelocityScale(value) { if (!this.available) return this.read(); const result = call(this.core, "sim_linuxcnc_tp_set_velocity_scale", "number", ["number", "number"], [this.planner, Number(value)]); if (result !== 0) throw new Error(`linuxcnc_tp_velocity_scale_${result}`); return this.read(); } + setJogScale(value) { if (!this.available) return this.read(); const result = call(this.core, "sim_linuxcnc_tp_set_jog_scale", "number", ["number", "number"], [this.planner, Number(value)]); if (result !== 0) throw new Error(`linuxcnc_tp_jog_scale_${result}`); return this.read(); } + setAxisLimit(axis, minimum, maximum) { if (!this.available) return this.read(); const result = call(this.core, "sim_linuxcnc_tp_set_axis_limit", "number", ["number", "number", "number", "number"], [this.planner, Number(axis), Number(minimum), Number(maximum)]); if (result !== 0) throw new Error(`linuxcnc_tp_axis_limit_${result}`); return this.read(); } + jog(axis, direction, increment, tag = 0) { if (!this.available) return { status: "blocked", code: "linuxcnc_tp_unavailable" }; const result = call(this.core, "sim_linuxcnc_tp_jog", "number", ["number", "number", "number", "number", "number"], [this.planner, Number(axis), Number(direction), Number(increment), Number(tag)]); return result === 0 ? { status: "verified", command: "JOG_START" } : { status: "error", code: `linuxcnc_tp_jog_${result}` }; } + jogVelocity(axis) { if (!this.available) return 0; const index = { x: 0, y: 1, z: 2, a: 3, b: 4, c: 5, u: 6, v: 7, w: 8 }[String(axis).toLowerCase()]; return index === undefined ? 0 : Number(call(this.core, "sim_linuxcnc_tp_jog_velocity", "number", ["number", "number"], [this.planner, index])); } setPaused(value) { this.controls.paused = Boolean(value); if (this.available) call(this.core, "sim_linuxcnc_tp_set_paused", "number", ["number", "number"], [this.planner, this.controls.paused ? 1 : 0]); return this.read(); } enqueue(segment) { @@ -158,7 +142,7 @@ export class WasmLinuxcncTp { if (Number(activeTag) === 0) return [position.x, position.y, position.z, position.a ?? 0, position.c ?? 0]; const inverse = this.kinematics?.inverseMode?.(Number(activeTag), position) ?? this.kinematics?.inverse?.(position); if (inverse?.status === "verified" && Array.isArray(inverse.joints)) return inverse.joints.slice(0, 5); - return this.previousJointCommanded.slice(); + throw new Error(`linuxcnc_tp_joint_inverse_${inverse?.code ?? "unavailable"}`); } step() { @@ -169,13 +153,9 @@ export class WasmLinuxcncTp { const state = this.read(); if (state.status === "verified") { const targetJoints = this.commandedJoints(state.state.position, state.state.active_tag); - targetJoints.forEach((point, index) => cubicAdd(this.cubic[index], point, this.cycleTime)); - const commanded = this.cubic.map((axis) => cubicInterpolate(axis, this.cycleTime)); - state.state.joint_commanded = commanded; - state.state.joint_actual = this.previousJointCommanded.slice(); - this.previousJointCommanded = commanded.slice(); + state.state.joint_commanded = targetJoints; + state.state.joint_actual = targetJoints.slice(); } - if (state.status === "verified") this.history.push(structuredClone(state.state)); return state; } @@ -186,18 +166,12 @@ export class WasmLinuxcncTp { const state = this.read(); if (state.status === "verified") { const targetJoints = this.commandedJoints(state.state.position, state.state.active_tag); - targetJoints.forEach((point, index) => cubicAdd(this.cubic[index], point, this.cycleTime)); - const commanded = this.cubic.map((axis) => cubicInterpolate(axis, this.cycleTime)); - state.state.joint_commanded = commanded; - state.state.joint_actual = this.previousJointCommanded.slice(); - this.previousJointCommanded = commanded.slice(); + state.state.joint_commanded = targetJoints; + state.state.joint_actual = targetJoints.slice(); } - if (state.status === "verified") this.history.push(structuredClone(state.state)); return state; } - drainTrace() { const trace = this.history; this.history = []; return trace; } - read() { if (!this.available) return { status: "blocked", state: { position: toPose([]), velocity: 0, progress: 0, tick: 0, queue_depth: 0, active_id: 0, active_tag: 0, motion_type: 0, status: -1214, exec_state: this.executionState, source: "linuxcnc-source-tp" } }; const result = call(this.core, "sim_linuxcnc_tp_read_state", "number", ["number", "number"], [this.planner, this.statePointer]); @@ -207,13 +181,22 @@ export class WasmLinuxcncTp { const activeId = ints[2]; if (activeId && activeId !== this.lastActiveId && this.segmentTags.has(activeId)) this.activeTag = this.segmentTags.get(activeId); this.lastActiveId = activeId; - const state = { position: toPose(doubles), velocity: doubles[9], acceleration: this.accelerationLimit, progress: doubles[10], tick: ints[0], queue_depth: this.queueDepthOverride === null ? ints[1] : this.queueDepthOverride, active_queue: activeId ? 1 : 0, active_id: activeId, active_tag: this.activeTag, joint_commanded: this.previousJointCommanded.slice(), joint_actual: this.previousJointCommanded.slice(), motion_type: this.core.HEAP32[this.statePointer / 4 + 25], status: this.core.HEAP32[this.statePointer / 4 + 26], exec_state: this.executionState, source: "linuxcnc-source-tp" }; + const velocityScale = Number(call(this.core, "sim_linuxcnc_tp_velocity_scale", "number", ["number"], [this.planner])); + const velocityLimit = Number(call(this.core, "sim_linuxcnc_tp_velocity_limit", "number", ["number"], [this.planner])); + const jogScale = Number(call(this.core, "sim_linuxcnc_tp_jog_scale", "number", ["number"], [this.planner])); + const feedOverride = Number(call(this.core, "sim_linuxcnc_tp_feed_override", "number", ["number"], [this.planner])); + this.controls.velocity_scale = velocityScale; + this.controls.jog_scale = jogScale; + this.controls.feed_override = feedOverride; + const position = toPose(doubles); + const joints = this.commandedJoints(position, this.activeTag); + const state = { position, velocity: doubles[9], acceleration: this.accelerationLimit, progress: doubles[10], tick: ints[0], queue_depth: this.queueDepthOverride === null ? ints[1] : this.queueDepthOverride, active_queue: activeId ? 1 : 0, active_id: activeId, active_tag: this.activeTag, joint_commanded: joints, joint_actual: joints.slice(), motion_type: this.core.HEAP32[this.statePointer / 4 + 25], status: this.core.HEAP32[this.statePointer / 4 + 26], exec_state: this.executionState, velocity_limit: velocityLimit, velocity_scale: velocityScale, jog_velocity_linear: this.jogVelocity("x"), jog_velocity_angular: this.jogVelocity("a"), feed_override: feedOverride, jog_scale: jogScale, source: "linuxcnc-source-tp" }; this.lastState = state; return { status: "verified", state }; } - reset() { if (this.available) call(this.core, "sim_linuxcnc_tp_reset", "number", ["number"], [this.planner]); this.controls.paused = false; this.history = []; this.activeTag = 0; this.segmentTags.clear(); this.lastActiveId = 0; this.queueDepthOverride = null; this.executionState = 3; this.cubic = Array.from({ length: 5 }, () => cubicState()); this.previousJointCommanded = [0, 0, 0, 0, 0]; return this.read(); } - abort() { if (this.available) call(this.core, "sim_linuxcnc_tp_abort", "number", ["number"], [this.planner]); this.history = []; this.activeTag = 0; this.segmentTags.clear(); this.lastActiveId = 0; this.queueDepthOverride = null; this.executionState = 3; return this.read(); } + reset() { if (this.available) call(this.core, "sim_linuxcnc_tp_reset", "number", ["number"], [this.planner]); this.controls.paused = false; this.activeTag = 0; this.segmentTags.clear(); this.lastActiveId = 0; this.queueDepthOverride = null; this.executionState = 3; return this.read(); } + abort() { if (this.available) call(this.core, "sim_linuxcnc_tp_abort", "number", ["number"], [this.planner]); this.activeTag = 0; this.segmentTags.clear(); this.lastActiveId = 0; this.queueDepthOverride = null; this.executionState = 3; return this.read(); } exportState() { const current = this.read(); const segments = []; @@ -241,6 +224,8 @@ export class WasmLinuxcncTp { this.executionState = Number(value.execution_state ?? 3); this.setProfile(value.controls?.profile ?? 0, value.controls?.tolerance ?? 0); this.setFeedOverride(value.controls?.feed_override ?? 1); + this.setVelocityScale(value.controls?.velocity_scale ?? 1); + this.setJogScale(value.controls?.jog_scale ?? 1); this.setPosition(value.state?.position ?? {}); for (const segment of value.segments ?? []) this.enqueue({ ...segment, kinematics_mode: segment.kinematics_mode ?? value.active_tag ?? 0 }); if (this.available && value.state) { diff --git a/web/worker/wasm-xyzac-io.mjs b/web/worker/wasm-xyzac-io.mjs index c285cb4..fb1ba6c 100644 --- a/web/worker/wasm-xyzac-io.mjs +++ b/web/worker/wasm-xyzac-io.mjs @@ -141,9 +141,12 @@ export class WasmXyzacIo { if (kind === "RESET") return this.invoke("sim_io_reset"); if (kind === "SPINDLE_COMMAND") { if (payload.delta !== undefined) return this.invoke("sim_io_spindle_adjust", [payload.spindle ?? 0, Number(payload.delta)]); - return this.invoke("sim_io_spindle_command", [payload.spindle ?? 0, payload.rpm ?? payload.speed ?? 0, payload.enabled === false ? 0 : 1]); + const requestedRpm = Number(payload.rpm ?? payload.speed ?? 0); + const signedRpm = payload.direction === -1 ? -requestedRpm : payload.direction === 1 ? requestedRpm : requestedRpm; + return this.invoke("sim_io_spindle_command", [payload.spindle ?? 0, signedRpm, payload.enabled === false ? 0 : 1]); } if (kind === "SPINDLE_BRAKE") return this.invoke("sim_io_spindle_brake", [payload.spindle ?? 0, payload.engaged === true ? 1 : 0]); + if (kind === "SPINDLE_OVERRIDE" || kind === "SET_SPINDLE_OVERRIDE") return this.invoke("sim_io_spindle_set_override", [payload.spindle ?? 0, Number(payload.scale ?? payload.override ?? 1)]); if (kind === "SPINDLE_FAULT") return this.invoke("sim_io_spindle_set_fault", [payload.spindle ?? 0, payload.fault === true ? 1 : 0]); if (kind === "SPINDLE_INHIBIT") return this.invoke("sim_io_spindle_set_inhibit", [payload.spindle ?? 0, payload.inhibit === true ? 1 : 0]); if (kind === "SPINDLE_ORIENT") return this.invoke("sim_io_spindle_orient", [payload.spindle ?? 0, payload.angle ?? 0, payload.mode ?? 0]); diff --git a/web/worker/xyzac-command-ledger.mjs b/web/worker/xyzac-command-ledger.mjs index 625026c..faf1a1e 100644 --- a/web/worker/xyzac-command-ledger.mjs +++ b/web/worker/xyzac-command-ledger.mjs @@ -46,11 +46,18 @@ export class XyzacCommandLedger extends OperationLedger { } audit({ includeResponses = true, maxEntries = Number.POSITIVE_INFINITY } = {}) { - const exported = super.export(); + // High-frequency SIM_STEP responses request only a small audit window. + // OperationLedger.export() clones every historical response before the + // window can be selected, which turns a long run into quadratic browser + // serialization work. The map is insertion ordered by command sequence, + // so select the tail first and clone only the returned entries. + const values = [...this.entries.values()]; const pending = this.pending(); - const totalEntries = exported.entries.length; - const windowed = Number.isFinite(maxEntries) && maxEntries >= 0 ? exported.entries.slice(-Math.floor(maxEntries)) : exported.entries; - const entries = includeResponses ? windowed : windowed.map((entry) => { const { response, ...summary } = entry; return summary; }); + const totalEntries = values.length; + const windowed = Number.isFinite(maxEntries) && maxEntries >= 0 ? values.slice(-Math.floor(maxEntries)) : values; + const entries = includeResponses + ? windowed.map((entry) => structuredClone(entry)) + : windowed.map((entry) => { const { response, ...summary } = entry; return structuredClone(summary); }); const unsigned = { schema: XYZAC_COMMAND_LEDGER_SCHEMA, operation_schema: LEDGER_SCHEMA, phases: [...LEDGER_PHASES], scope: this.scope, entries, entry_count: totalEntries, truncated: entries.length < totalEntries, pending_count: pending.length }; return { ...unsigned, state_hash: fnv1a64(unsigned) }; }