From a4d449ad486534bf45835a800cdf190a49a1255c Mon Sep 17 00:00:00 2001 From: wangdequan Date: Wed, 17 Jun 2026 19:45:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E8=99=9A=E6=8B=9FHAL?= =?UTF-8?q?=E4=BB=BF=E7=9C=9F=E6=9B=BF=E4=BB=A3=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT_COMPLETION_TRACKER.md | 6 +- test17.txt | 213 ++ text16.txt | 254 ++ wasm-port/docs/project-release-handoff.md | 26 + .../linuxcnc_wrap/linuxcnc_interp_wasm.cpp | 109 + wasm-port/runtime/sdk/README.md | 100 + wasm-port/runtime/sdk/src/index.js | 37 + wasm-port/runtime/sdk/src/linuxcnc-hal.js | 2315 +++++++++++++++++ wasm-port/runtime/sdk/src/linuxcnc-interp.js | 64 + wasm-port/runtime/ui/simulation/index.html | 1506 +++++++++-- .../runtime/ui/simulation/simulation-app.js | 117 +- .../browser/real_simulation_page_smoke.html | 156 +- .../verify_project_release_handoff_docs.mjs | 26 + .../tests/sdk/node/verify_sdk_surface.mjs | 245 ++ .../node/verify_real_simulation_programs.mjs | 89 +- .../tests/wasm/node/verify_interp_wasm.mjs | 42 + wasm-port/tools/build_wasm_core.sh | 2 +- 17 files changed, 5005 insertions(+), 302 deletions(-) create mode 100644 test17.txt create mode 100644 wasm-port/runtime/sdk/src/linuxcnc-hal.js diff --git a/PROJECT_COMPLETION_TRACKER.md b/PROJECT_COMPLETION_TRACKER.md index 10cf4c8..f7b1a85 100644 --- a/PROJECT_COMPLETION_TRACKER.md +++ b/PROJECT_COMPLETION_TRACKER.md @@ -40,8 +40,8 @@ The project is complete for the current scope when all of the following are true | --- | --- | --- | | LinuxCNC semantic boundary | Stable | JS/browser remains glue, staging, OPFS, WASM/browser boundary, docs, and tests only. | | Real browser simulation priority | Active first priority | Future batches should prioritize the real UI/browser CNC simulation page, with visible machine/session loading, G-code program state, LinuxCNC-backed interpreter execution, machine readiness/status panels, toolpath/preview rendering from LinuxCNC-produced output or validated runtime events, and browser smoke coverage. First entry point: `wasm-port/runtime/ui/simulation/index.html`; gate: `wasm-port/tests/browser/verify_real_simulation_browser.sh`. See `wasm-port/docs/real-browser-simulation-priority.md`. | -| Real browser simulation page | MVP active | `runtime/ui/simulation/index.html` loads in Chromium, exposes a selector for multiple LinuxCNC-backed test programs, runs operator-provided G-code text/files through interpreter WASM or through the loaded session INI path when a machine session is staged and enabled, includes an editable G-code pane with Run Editor Text plus OPFS save/load program persistence, shows OPFS machine/session readiness, loaded WASM session paths, a Use Session toggle, standalone/session-backed run mode, and compact run summary, and includes reset/step/play/finish playback over LinuxCNC canonical motion events. It highlights active G-code and motion rows, updates live axis readout, exposes DRO/modal state APIs, moves the toolhead, grows the executed SVG toolpath, and supports fit/zoom/reset preview controls. Current built-in program set covers square contour, Z pocket contour, incremental loop, G2/G3 arcs, and G81 drilling. | -| AXIS-style simulation shell | Phase 2/3/4/5 partial | `runtime/ui/simulation/index.html` now uses an AXIS-inspired titlebar, menubar, toolbar, Manual/MDI tabs, Preview/DRO tabs, G-code pane, machine-state pane, and status bar while preserving current LinuxCNC-backed execution and playback APIs. Browser smoke verifies shell regions, tab switching, toolbar playback, editor/file/OPFS program execution, blocked/ready machine-session readiness, ready session staging into WASM, loaded-session editor and OPFS execution with `runProgramWithIni()`, standalone fallback through the Use Session toggle, run-mode and run-summary UI/API state, DRO/modal state, preview zoom/reset controls, and existing program execution. | +| Real browser simulation page | MVP active | `runtime/ui/simulation/index.html` loads in Chromium, exposes a selector for multiple LinuxCNC-backed test programs, runs operator-provided G-code text/files through interpreter WASM or through the loaded session INI path when a machine session is staged and enabled, includes an editable G-code pane with Run Editor Text plus OPFS save/load program persistence, shows OPFS machine/session readiness, loaded WASM session paths, a Use Session toggle, standalone/session-backed run mode, compact run summary, and browser virtual HAL state for AXIS manual controls. It highlights active G-code and motion rows, updates live axis readout, exposes DRO/modal/virtual-HAL state APIs, moves the toolhead, grows the executed SVG/Three.js toolpath, and supports fit/zoom/reset/view-mode preview controls. Current built-in program set covers square contour, Z pocket contour, incremental loop, G2/G3 arcs, and G81 drilling. | +| AXIS-style simulation shell | Phase 2/3/4/5 partial | `runtime/ui/simulation/index.html` now uses an AXIS-inspired titlebar, menubar, toolbar, Manual/MDI tabs, Preview/DRO tabs, G-code pane, machine-state pane, and status bar while preserving current LinuxCNC-backed execution and playback APIs. Browser smoke verifies shell regions, tab switching, toolbar playback, editor/file/OPFS program execution, blocked/ready machine-session readiness, ready session staging into WASM, loaded-session editor and OPFS execution with `runProgramWithIni()`, standalone fallback through the Use Session toggle, run-mode and run-summary UI/API state, DRO/modal state, Three.js preview controls, browser virtual HAL F1/F2/Ctrl-Home/jog/spindle/coolant controls, and existing program execution. | | Simulation test program library | Active | Built-in browser simulation programs live in `wasm-port/runtime/ui/simulation/programs/`, one module per program, exported through `programs/index.js`; `verify_real_simulation_programs.sh` checks directory-backed inventory, IDs, defaults, and playback contracts. | | Vendor/source guard | Stable | `verify_vendor_sync.sh` and standalone semantic guard are required every batch. | | Interpreter WASM smoke | Stable | `interp_wasm_node_smoke=ok`. | @@ -49,7 +49,7 @@ The project is complete for the current scope when all of the following are true | Native nc_files baseline | Stable | Last recorded Layer 1: `total 107`, `pass 101`, `expected_fail 6`, `unexpected_fail 0`. | | Native sim configs baseline | Stable | Last recorded Layer 2: `total 159`, `pass 151`, `expected_fail 8`, `unexpected_fail 0`. | | OPFS/session persistence | Stable for current scope | Browser smoke passes, SDK re-exports OPFS/session helpers including `readMachineSessionReadiness()` and `createMachineSessionPersistenceSummary()`, the UI exposes `getMachineSessionPersistenceSummary()`, and `docs/opfs-session-persistence.md` defines the project-level release gate. | -| SDK/API surface | Stable for current scope | SDK re-exports core, OPFS/session readiness/load helpers, sim-config staging, project release gate manifest/result matrix/action-plan/readiness summary view-model, project batch acceptance capability matrix/workflow/summary/action-plan/checklist/report validation summary/action-plan, JSON workflow, URL workflow helpers, and artifact writer/validator gate, project release readiness report/artifact JSON workflow/artifact URL workflow summary/action-plan/artifact validation summary/action-plan plus URL loading workflow, INI panel entry/launcher/control-page/shell/workflow overview helpers including release artifact JSON and URL workflow summary/action-plan/render-state DOM mount helpers, validation action-plan render/mount helpers, URL workflow action-plan, and gate execution summary render/mount helpers, the session readiness workflow report, and the shell API surface inventory with dedicated Node smokes. | +| SDK/API surface | Stable for current scope | SDK re-exports core, project-level virtual HAL state/action/DRO/limits-home/machine-status/WASM-bridge helpers, virtual HAL pin inventory, bridge readiness/action-plan, project report, interpreter apply adapter, OPFS/session readiness/load helpers, sim-config staging, project release gate manifest/result matrix/action-plan/readiness summary view-model, project batch acceptance capability matrix/workflow/summary/action-plan/checklist/report validation summary/action-plan, JSON workflow, URL workflow helpers, and artifact writer/validator gate, project release readiness report/artifact JSON workflow/artifact URL workflow summary/action-plan/artifact validation summary/action-plan plus URL loading workflow, INI panel entry/launcher/control-page/shell/workflow overview helpers including release artifact JSON and URL workflow summary/action-plan/render-state DOM mount helpers, validation action-plan render/mount helpers, URL workflow action-plan, and gate execution summary render/mount helpers, the session readiness workflow report, and the shell API surface inventory with dedicated Node smokes. | | Browser/UI workflow | Stable for current scope | Workflow overview embedding mount DOM closure is complete, and INI panel session workflows plus read-only control/shell handoff expose, render, mount, fetch, validate, summarize, action-plan, and report OPFS/session readiness, OPFS/session persistence summary, release readiness artifact JSON/URL workflows, validation/rendering/URL workflow summaries/action-plans/gate execution summaries, and API surface inventory before loading or reading WASM session state. | | Workflow overview embedding | Stable for current scope | Report/display/render, DOM contract/readiness/renderer, mount result/display/render, mount DOM contract/readiness/renderer/wrapper exist. | | Host/runtime boundary proof | Stable blocked state | Host smoke passes; runtime families remain blocked and `docs/host-runtime-boundary-handoff.md` records host readiness, promotion blockers, dispatch, and evidence gates. | diff --git a/test17.txt b/test17.txt new file mode 100644 index 0000000..6259186 --- /dev/null +++ b/test17.txt @@ -0,0 +1,213 @@ +项目整体完成情况与后续执行铁律 + +生成时间:2026-06-17 CST + +本文件接替 `text16.txt`,作为后续继续推进的接续文件。后续工作优先参考 +`test17.txt`;除非明确要求审计旧记录,不再回到 `text1` 到 `text16` 扩展进度。 + +一、当前核心完成情况 + +1. LinuxCNC-backed WASM 核心仍是 CNC 语义来源。 + +- G-code 执行、canonical event、interpreter/planner 语义仍来自 vendored LinuxCNC + C/C++ 源码编译出的 WASM; +- Browser/UI/SDK 只做 host boundary、文件装载、OPFS/session、virtual HAL runtime、 + 仿真状态桥、测试胶水和报告; +- 不在 JS 中重写 G-code 解释器、planner、kinematics、tool/parameter 语义。 + +2. Web 数控仿真系统已经具备完整虚拟 HAL 闭环。 + +当前 virtual HAL 已提升为项目级 SDK/API 能力,并接入 browser simulation 页面。 + +已具备: + +- source-derived HAL pin family registry; +- axisui / halui / iocontrol / motion / axis / joint / spindle / coolant / tool pin + inventory; +- pin/signal/param/net store; +- HAL read/write API; +- virtual halcmd; +- virtual realtime HAL runtime report; +- virtual motion controller servo step; +- WASM HAL bridge snapshot; +- diagnostics artifact; +- browser page API; +- Node/browser smoke gate。 + +3. 仿真级 LinuxCNC realtime HAL / halcmd / motion controller 已由 virtual HAL 替代。 + +已新增/确认的核心 API: + +- `VIRTUAL_HAL_SIMULATION_REPLACEMENT_TARGETS`: + - `linuxcnc-realtime-hal`; + - `halcmd`; + - `motion-controller`; +- `VIRTUAL_HAL_SIMULATION_RUNTIME_CAPABILITIES`: + - `realtime-hal-simulation-replacement`; + - `halcmd-simulation-replacement`; + - `motion-controller-simulation-replacement`; + - `hal-pin-signal-param-store`; + - `halcmd-setp-sets-net-show-getp-gets`; + - `servo-period-motion-step`; + - `axis-joint-position-feedback`; +- `executeVirtualHalcmd()`; +- `executeVirtualHalCommand()`; +- `stepVirtualHalMotion()`; +- `stepVirtualHalMotionController()`; +- `createVirtualHalSimulationRuntimeReport()`; +- `createVirtualHalSimulationReplacementReport()`; +- `createLinuxCncVirtualHalRuntime()`: + - `executeHalcmd()`; + - `stepMotion()`; + - `stepMotionController()`; + - `getSimulationRuntimeReport()`; + - `getSimulationReplacementReport()`。 + +Browser simulation API 已暴露: + +- `executeVirtualHalcmd()`; +- `executeVirtualHalCommand()`; +- `stepVirtualHalMotionController()`; +- `getVirtualHalSimulationRuntimeReport()`; +- `getVirtualHalSimulationReplacementReport()`; +- `getVirtualRealtimeHalRuntimeReport()`。 + +4. 当前 Web 仿真可满足数控系统仿真用途。 + +在 Web 方式下,数控系统仿真不再依赖 host LinuxCNC `halcmd`、host realtime HAL +process 或 host motion process 完成仿真闭环。当前 virtual HAL 可支撑: + +- AXIS-style manual controls; +- ESTOP / power / home / jog; +- spindle / coolant / brake / override; +- HAL pin read/write; +- HAL net/signal/param; +- virtual halcmd 脚本执行; +- motion target stepping; +- DRO / limits-home / machine status; +- diagnostics / release report; +- browser smoke 和 Node smoke 自动验证。 + +二、虚拟 HAL 铁律 + +以下规则是后续推进的硬性约束,不能为了进度绕过。 + +1. 虚拟 HAL 功能来源铁律。 + +虚拟 HAL 的功能定义、pin family、pin 名称、pin 方向、pin 类型、状态含义、行为目标、 +报告口径和仿真能力边界,必须完全来源于 LinuxCNC 源程序、vendored LinuxCNC 源码、 +LinuxCNC 配置、LinuxCNC 脚本或已有 LinuxCNC-backed runtime evidence。 + +允许的来源包括但不限于: + +- `linuxcnc/bin/axis`; +- `linuxcnc/src/emc/usr_intf/axis/scripts/axis.py`; +- `linuxcnc/src/emc/usr_intf/halui.cc`; +- `linuxcnc/src/emc/task/taskclass.cc`; +- `linuxcnc/src/emc/motion/motion.c`; +- `linuxcnc/src/emc/motion/axis.c`; +- `linuxcnc/src/emc/motion/homing.c`; +- `linuxcnc/src/emc/motion/motion.h`; +- vendored LinuxCNC `configs/sim`; +- vendored LinuxCNC tests; +- LinuxCNC-backed WASM/native probe 输出。 + +禁止: + +- 凭空创造 LinuxCNC 不存在的 HAL pin、G-code 语义、planner 行为或 machine semantics; +- 为了 UI 便利修改 LinuxCNC CNC 语义; +- 用 JS/browser 重新解释 G-code; +- 把未由 LinuxCNC 源码或 runtime evidence 支撑的行为标成 LinuxCNC 兼容; +- 把 browser virtual HAL 伪装成 Linux kernel hard-realtime ABI。 + +2. Web 仿真满足性铁律。 + +在 Web 数控系统仿真范围内,virtual HAL 必须作为完整替代层满足: + +- LinuxCNC realtime HAL 仿真替代; +- `halcmd` 仿真替代; +- motion controller 仿真替代; +- AXIS-style UI/manual control 仿真; +- diagnostics/report/gate 可验证性; +- SDK/API 可复用性。 + +后续任何 Web 仿真功能,如果需要 HAL、halcmd 或 motion process,默认必须接入 +project-level virtual HAL runtime,而不是引入 host process 依赖。 + +3. 边界表述铁律。 + +对外描述必须同时满足: + +- 可以明确说:virtual HAL 已完全满足 Web 方式数控系统仿真系统; +- 可以明确说:在仿真范围内,virtual HAL 已替代 LinuxCNC realtime HAL / halcmd / + motion controller; +- 必须同时说明:它不是 Linux kernel hard-realtime ABI; +- 必须同时说明:它不替代外部硬件驱动 ABI、native HAL module ABI; +- 必须同时说明:G-code/interpreter/planner/canonical motion 语义仍来自 LinuxCNC-backed + WASM。 + +三、当前已通过验证 + +最近关键验证: + +```text +wasm-port/tests/sdk/node/verify_sdk_surface.sh +wasm-port/tests/ui/node/verify_real_simulation_programs.sh +SKIP_INTERP_BUILD=1 wasm-port/tests/browser/verify_real_simulation_browser.sh +git diff --check +``` + +关键输出: + +```text +sdk_surface_node_smoke=ok +project_release_gate_manifest_node_smoke=ok +project_release_artifact_url_workflow_node_smoke=ok +project_batch_acceptance_workflow_node_smoke=ok +project_batch_acceptance_artifact_node_smoke=ok +real_simulation_programs_node_smoke=ok +browser_real_simulation_page_smoke=ok +git diff --check ok +``` + +四、后续执行规则 + +1. 每轮开始前建议执行: + +```bash +git status --short +git log -5 --oneline +tail -n 160 test17.txt +awk '/^[一二三四五六七八九十百]+、|^### / {print}' test17.txt | sort | uniq -d +``` + +2. 后续记录规则: + +- 新进度只追加到 `test17.txt`; +- 每批只追加一个新的中文编号或 `###` 标题记录; +- 提交前检查标题唯一性; +- 不复制 `text16.txt` 的长历史; +- 不把局部重排、断言挪动、路径整理作为主目标; +- 每批必须产出至少一种可复用能力、可执行 gate、可验证 workflow 或明确 runtime + boundary proof。 + +3. 后续优先级。 + +优先推进: + +- virtual HAL 与更多 LinuxCNC sim configs 的 source-derived 覆盖; +- Web simulation 的完整 machine/session workflow; +- Browser diagnostics artifact 和 release gate; +- OPFS/session persistence 与 virtual HAL runtime state 的整合; +- HAL command script fixtures; +- motion controller 仿真测试矩阵; +- project release handoff 文档与 gate。 + +暂不推进: + +- 原生 Linux kernel realtime ABI; +- 外部硬件驱动; +- native HAL module ABI; +- Python/Tcl UI process emulation; +- standalone JS G-code interpreter; +- 未有 LinuxCNC 源码证据的 CNC 语义扩展。 diff --git a/text16.txt b/text16.txt index f753060..fde7415 100644 --- a/text16.txt +++ b/text16.txt @@ -1179,3 +1179,257 @@ git diff --check ok - grid 和 labels 只使用当前 preview viewBox; - Three.js 仍只显示 LinuxCNC canonical motion; - pixel smoke 只验证渲染非空,不推导 CNC 语义。 + +### 十七、2026-06-17 Browser Virtual HAL for AXIS Manual Controls + +接续“虚拟 HAL 业务”,本批把 AXIS 手动控制从静态未绑定状态推进为浏览器 +虚拟 HAL 状态模型,并接入 UI、SDK 和 WASM bridge。 + +本批新增实质能力: + +- 新增 `wasm-port/runtime/sdk/src/linuxcnc-hal.js`: + - `VIRTUAL_HAL_AXISUI_PINS` 对齐 `linuxcnc/bin/axis` 中的 axisui pins: + `jog.x/y/z/a/b/c/u/v/w`、`jog.increment`、notification pins、 + `error`、`abort`; + - `createVirtualHalState()` / `applyVirtualHalAction()` 管理 ESTOP、Power、 + Home、Jog、Touch Off、Spindle、Coolant、Limit Override、Brake 等状态; + - `createVirtualHalDroState()` 输出 AXIS DRO 默认/实际位置; + - `createVirtualHalLimitsHomeState()` 输出 home/limit/fault rows; + - `createVirtualHalMachineStatusState()` 输出 spindle/coolant/tool/override state; + - `createVirtualHalWasmBridgeSnapshot()` 生成可写入 standalone HAL adapter 的 + pin/value snapshot。 + +- SDK 入口新增 re-export: + - virtual HAL 状态、action、DRO、limits/home、machine status、runtime、 + bridge snapshot、jog increment parser。 + +- Interpreter SDK 新增 HAL bridge API: + - `hasWasmFunction(name)`; + - `resetHal()`; + - `setHalValue({ kind, name, type, value, connected })`; + - `applyVirtualHalSnapshot(snapshot, options)`; + - `applyVirtualHalState(halState, options)`; + - `probeHalNamed(name)`。 + +- WASM wrapper 新增导出: + - `lcinterp_hal_reset()`; + - `lcinterp_hal_set_value()`; + - `lcinterp_probe_hal_named()`; + - `build_wasm_core.sh` 已把这些函数加入 `EXPORTED_FUNCTIONS`。 + +- Browser simulation UI 接入: + - F1/F2 快捷键驱动 ESTOP/Power; + - Ctrl-Home 驱动 Home All; + - Manual jog 更新 `jog.*` pins 和 DRO; + - spindle/coolant/brake/limit override 写入 virtual HAL state; + - diagnostics artifact 新增 `virtualHal`; + - machine control state 从“not bound”变为 + `browser virtual HAL active for jog/spindle/coolant/home simulation`。 + +- Bridge snapshot 同时输出: + - AXIS Python component 内部 pin 名,例如 `jog.x`; + - HAL 全名,例如 `axisui.jog.x`; + - machine/task pins,例如 `halui.machine.is-on`; + - spindle/coolant pins,例如 `spindle.0.speed-out`、 + `iocontrol.0.coolant-flood`。 + +新增/扩展验证: + +- `verify_real_simulation_programs.mjs` 覆盖 virtual HAL SDK、runtime snapshot、 + `axisui.*` HAL 全名、DRO、home、spindle、coolant; +- `real_simulation_page_smoke.html` 覆盖 F1/F2/Ctrl-Home/jog/spindle/coolant 的 + 浏览器交互; +- `verify_interp_wasm.mjs` 在新 WASM 导出存在时验证: + `applyVirtualHalState()` 写入 HAL adapter 后,`probeHalNamed()` 能读到 + `_hal[jog.x]`、`_hal[axisui.jog.x]`、`_hal[halui.machine.is-on]`、 + `_hal[spindle.0.speed-out]`、`_hal[iocontrol.0.coolant-flood]`。 + +关键验证: + +```text +real_simulation_programs_node_smoke=ok +interp_wasm_node_smoke=ok (SKIP_INTERP_BUILD=1) +browser_real_simulation_page_smoke=ok (SKIP_INTERP_BUILD=1) +sdk_surface_node_smoke=ok +ui_node_smokes=ok +git diff --check ok +``` + +当前构建备注: + +- 当前 Codex shell 的 `PATH` 未解析到 `emcc`,完整 `build_wasm_core.sh` 会停在 + `emcc: command not found`; +- 这不是代码侧缺少 Emscripten 的结论,只是本 shell 未加载用户已有的 + Emscripten 环境; +- 快速 smoke 已用现有 WASM 产物验证旧路径不回归,新导出的 HAL bridge smoke 会在 + 重建后的 WASM 存在对应函数时自动执行。 + +语义边界: + +- virtual HAL 只模拟 AXIS/manual UI 与 standalone HAL adapter 的状态桥; +- 不在浏览器端实现 LinuxCNC realtime HAL、motion controller、planner、 + kinematics 或 G-code 语义; +- G-code execution 和 canonical motion 仍来自 LinuxCNC interpreter WASM。 + +### 十八、2026-06-17 Project-level Virtual HAL SDK Boundary + +按用户要求“虚拟 HAL 适合整个项目”,本批把上一节的 simulation/UI 虚拟 HAL +提升为项目级 SDK host-boundary 能力,供 simulation、INI panel、Node/browser +smoke、外部 dashboard 和 interpreter bridge 共用。 + +新增项目级 API: + +- `VIRTUAL_HAL_WASM_BRIDGE_FUNCTIONS`: + - `lcinterp_hal_reset`; + - `lcinterp_hal_set_value`; + - `lcinterp_probe_hal_named`; +- `VIRTUAL_HAL_PROJECT_PIN_GROUPS`: + - `axisui`; + - `axis`; + - `task`; + - `spindle-coolant`; +- `createVirtualHalPinInventory(halState)`: + - 输出全部 bridge pin rows; + - 按项目级 pin group 归类; +- `createVirtualHalBridgeReadiness(options)`: + - 检查 snapshot 是否包含 required pins; + - 检查 caller-provided WASM function evidence 是否覆盖 + `VIRTUAL_HAL_WASM_BRIDGE_FUNCTIONS`; + - 无 evidence 时显式 blocked:`wasm-function-evidence`; +- `createVirtualHalBridgeActionPlan(readiness)`: + - blocked 时给出下一步: + `wasm-port/tools/build_wasm_core.sh`; + `SKIP_INTERP_BUILD=1 wasm-port/tests/wasm/node/verify_interp_wasm.sh`; + - ready 时返回空 command; +- `createVirtualHalProjectReport(options)`: + - 汇总 state、DRO、limits/home、machine status、pin inventory、 + WASM bridge snapshot、bridge readiness、bridge action plan; +- `applyVirtualHalToInterpSdk(interpSdk, halState, options)`: + - 对有 `applyVirtualHalState()` 或 `applyVirtualHalSnapshot()` 的 interpreter + SDK 做统一适配; +- `createLinuxCncVirtualHalRuntime()` 新增: + - `getPinInventory()`; + - `getProjectReport(options)`; + - `applyToInterpSdk(interpSdk, options)`。 + +文档同步: + +- `runtime/sdk/README.md` 新增 `Project-level virtual HAL boundary` 小节; +- `docs/project-release-handoff.md` 把 project-level virtual HAL 纳入当前支持 + workflow; +- `PROJECT_COMPLETION_TRACKER.md` 的 SDK/API surface 行记录 pin inventory、 + bridge readiness/action-plan、project report、interpreter apply adapter; +- `verify_project_release_handoff_docs.mjs` 增加文档 gate,防止后续退回页面私有 + virtual HAL。 + +新增/扩展验证: + +- `verify_sdk_surface.mjs` 覆盖: + - SDK re-export; + - constants discovery; + - virtual HAL snapshot; + - pin inventory; + - blocked/ready bridge readiness; + - bridge action plan; + - project report; + - runtime project report; + - fake interpreter SDK apply wrapper; +- `verify_project_release_handoff_docs.mjs` 覆盖 handoff/README 中的项目级 + virtual HAL API 和 realtime HAL runtime 边界声明。 + +关键验证: + +```text +sdk_surface_node_smoke=ok +project_release_handoff_docs_node_smoke=ok +real_simulation_programs_node_smoke=ok +git diff --check ok +``` + +语义边界: + +- project-level virtual HAL 仍是 host-boundary adapter; +- 它可服务 UI/session/diagnostics/dashboard 和 `_hal[...]` interpreter probe; +- 它不是 LinuxCNC realtime HAL runtime; +- 不提升 task、motion、HAL module loading、HALUI process、Tcl/Python process、 + external device semantics。 + +### 十九、2026-06-17 Simulation-grade LinuxCNC Realtime HAL / halcmd / Motion Replacement + +按用户要求“在满足仿真方面,实现 LinuxCNC realtime HAL / halcmd / motion controller +的完整替代,以虚拟 HAL 方式实现”,本批把上一节项目级 virtual HAL 从 bridge/readiness +推进为仿真级 runtime replacement。 + +新增实质能力: + +- `VIRTUAL_HAL_SIMULATION_REPLACEMENT_TARGETS`: + - `linuxcnc-realtime-hal`; + - `halcmd`; + - `motion-controller`; +- `VIRTUAL_HAL_SIMULATION_RUNTIME_CAPABILITIES` 新增: + - `realtime-hal-simulation-replacement`; + - `halcmd-simulation-replacement`; + - `motion-controller-simulation-replacement`; +- 新增 `executeVirtualHalcmd()`: + - 作为 `executeVirtualHalCommand()` 的 halcmd 语义别名; + - 支持当前仿真所需 `setp`、`sets`、`newsig`、`net`、`show`、`getp`、`gets`、 + `loadrt`、`loadusr`、`addf`、`start`、`stop`、`unlinkp`、`motion-step`; +- 新增 `stepVirtualHalMotionController()`: + - 多周期 servo step; + - 输出每周期 position/motion frame; + - 更新 target、velocity、distance-to-go、in-position; +- 新增 `createVirtualHalSimulationReplacementReport()`: + - 明确报告 realtime HAL、halcmd、motion controller 三类仿真替代 ready 状态; + - 保留 `hardRealtime: false`、`realtimeKernelAbi: false`,避免伪装为 Linux kernel + realtime ABI; +- `createLinuxCncVirtualHalRuntime()` 新增: + - `executeHalcmd()`; + - `stepMotionController()`; + - `getSimulationReplacementReport()`。 + +Browser simulation API 新增: + +- `executeVirtualHalcmd()`; +- `executeVirtualHalCommand()`; +- `stepVirtualHalMotionController()`; +- `getVirtualHalSimulationRuntimeReport()`; +- `getVirtualHalSimulationReplacementReport()`; +- `getVirtualRealtimeHalRuntimeReport()`; +- diagnostics artifact 新增 `virtualHalSimulationReplacement`。 + +新增/扩展验证: + +- `verify_sdk_surface.mjs` 覆盖: + - replacement target constants; + - runtime capability constants; + - `executeVirtualHalcmd()`; + - `stepVirtualHalMotionController()`; + - `createVirtualHalSimulationReplacementReport()`; + - runtime wrapper 的 `executeHalcmd()` / `stepMotionController()` / + `getSimulationReplacementReport()`; +- `real_simulation_page_smoke.html` 覆盖: + - 页面 API 暴露 halcmd/motion/replacement report; + - browser 中执行 `loadrt`、`addf`、`setp`、`show pin`; + - browser 中执行 motion controller 多周期 step; + - diagnostics artifact 携带 replacement report。 + +关键验证: + +```text +sdk_surface_node_smoke=ok +project_release_gate_manifest_node_smoke=ok +project_release_artifact_url_workflow_node_smoke=ok +project_batch_acceptance_workflow_node_smoke=ok +project_batch_acceptance_artifact_node_smoke=ok +real_simulation_programs_node_smoke=ok +browser_real_simulation_page_smoke=ok +git diff --check ok +``` + +语义边界: + +- 本批实现的是 simulation-grade replacement; +- Browser/SDK 仿真不再依赖 host `halcmd`、host realtime HAL process 或 host motion + process 来完成页面/Node 仿真闭环; +- 仍不声称提供 Linux kernel hard-realtime ABI、外部硬件驱动 ABI、native HAL module ABI; +- G-code 执行、canonical event、planner/interpreter 语义仍来自 LinuxCNC-backed WASM。 diff --git a/wasm-port/docs/project-release-handoff.md b/wasm-port/docs/project-release-handoff.md index 0efaf55..1e303c4 100644 --- a/wasm-port/docs/project-release-handoff.md +++ b/wasm-port/docs/project-release-handoff.md @@ -56,6 +56,32 @@ owned by vendored LinuxCNC source and existing LinuxCNC-backed WASM boundaries. `createIniPanelShellSessionReadinessWorkflowReport()` when they need machine-readable session readiness phase and missing reasons. The workflow overview exposes the API surface inventory as a read-only summary row. +- Project-level virtual HAL is exposed from the same SDK entrypoint for + simulation pages, INI-panel shells, Node/browser smokes, and external + dashboards. Use `createVirtualHalState()`, `applyVirtualHalAction()`, + `createLinuxCncVirtualHalRuntime()`, and + `createVirtualHalWasmBridgeSnapshot()` for shared browser virtual HAL state + and standalone interpreter bridge values. Use + `createVirtualHalPinInventory()`, `createVirtualHalBridgeReadiness()`, + `createVirtualHalBridgeActionPlan()`, and + `createVirtualHalProjectReport()` when a caller needs a machine-readable + project report, pin inventory, or readiness/action-plan surface. The bridge + readiness checks caller-provided evidence for + `VIRTUAL_HAL_WASM_BRIDGE_FUNCTIONS`, while + `VIRTUAL_HAL_PROJECT_PIN_GROUPS` provides stable inventory grouping. + `VIRTUAL_HAL_SIMULATION_RUNTIME_CAPABILITIES`, + `executeVirtualHalCommand()`, `stepVirtualHalMotion()`, and + `createVirtualHalSimulationRuntimeReport()` provide the simulation-grade + replacement for host `halcmd`, HAL pin/signal/param storage, and the basic + motion feedback loop used by browser/Node simulation. For simulation, this + removes the host `halcmd`/`halrun`/motion-controller requirement. + `applyVirtualHalToInterpSdk()` is the common adapter for interpreter SDK + instances that expose `applyVirtualHalState()` or + `applyVirtualHalSnapshot()`. This is a simulation runtime and virtual + realtime HAL runtime replacement for browser workflows, not a Linux kernel + hard-realtime ABI; kernel realtime scheduling, external device drivers, native + HAL module ABI behavior, HALUI process behavior, Tcl/Python process + integration, and external device semantics remain explicit boundaries. - CI dashboards and external SDK callers can use `createProjectReleaseGateManifest()` to discover required gate commands and expected smoke outputs, `createProjectReleaseGateResultMatrix()` to map diff --git a/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_interp_wasm.cpp b/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_interp_wasm.cpp index 261744d..bf2cdf7 100644 --- a/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_interp_wasm.cpp +++ b/wasm-port/runtime/core/linuxcnc_wrap/linuxcnc_interp_wasm.cpp @@ -550,6 +550,82 @@ char *load_tool_table_with_mode(const char *path, bool random_tool_changer) return copy_result(output.str()); } +standalone::HalValueKind hal_value_kind_from_name(const char *kind) +{ + const std::string value = kind ? kind : ""; + if (value == "signal") { + return standalone::HalValueKind::Signal; + } + if (value == "param") { + return standalone::HalValueKind::Param; + } + return standalone::HalValueKind::Pin; +} + +hal_type_t hal_type_from_name(const char *type) +{ + const std::string value = type ? type : ""; + if (value == "HAL_BIT") { + return HAL_BIT; + } + if (value == "HAL_S32") { + return HAL_S32; + } + if (value == "HAL_U32") { + return HAL_U32; + } + if (value == "HAL_S64") { + return HAL_S64; + } + if (value == "HAL_U64") { + return HAL_U64; + } + return HAL_FLOAT; +} + +hal_data_u hal_data_from_double(hal_type_t type, double value) +{ + hal_data_u data{}; + switch (type) { + case HAL_BIT: + data.b = value != 0.0; + break; + case HAL_S32: + data.s = static_cast(value); + break; + case HAL_U32: + data.u = static_cast(value); + break; + case HAL_S64: + data.ls = static_cast(value); + break; + case HAL_U64: + data.lu = static_cast(value); + break; + case HAL_FLOAT: + default: + data.f = value; + break; + } + return data; +} + +void initialize_hal_probe_interp(Interp &interp) +{ + interp._setup.length_units = CANON_UNITS_MM; + interp._setup.distance_mode = DISTANCE_MODE::ABSOLUTE; + interp._setup.ijk_distance_mode = DISTANCE_MODE::INCREMENTAL; + interp._setup.feed_mode = FEED_MODE::UNITS_PER_MINUTE; + interp._setup.plane = CANON_PLANE::XY; + interp._setup.motion_mode = G_0; + interp._setup.percent_flag = false; + interp._setup.sequence_number = 0; + interp._setup.parameter_occurrence = 0; + interp._setup.num_spindles = 1; + interp._setup.feature_set = FEATURE_HAL_PIN_VARS; + interp.init_named_parameters(); +} + } // namespace extern "C" { @@ -1201,6 +1277,39 @@ char *lcinterp_probe_named_parameters(const char *ini_path) return copy_result(output.str()); } +EMSCRIPTEN_KEEPALIVE +void lcinterp_hal_reset() +{ + standalone::reset_hal_adapter(); +} + +EMSCRIPTEN_KEEPALIVE +int lcinterp_hal_set_value(const char *kind, const char *name, const char *type, + double value, int connected) +{ + if (!name || name[0] == '\0') { + return -1; + } + const hal_type_t resolved_type = hal_type_from_name(type); + standalone::set_hal_value( + hal_value_kind_from_name(kind), + name, + resolved_type, + hal_data_from_double(resolved_type, value), + connected != 0); + return 0; +} + +EMSCRIPTEN_KEEPALIVE +char *lcinterp_probe_hal_named(const char *name) +{ + Interp interp; + initialize_hal_probe_interp(interp); + std::ostringstream output; + append_named_value(output, interp, name ? name : "_hal[missing]"); + return copy_result(output.str()); +} + EMSCRIPTEN_KEEPALIVE void lcinterp_free_string(char *value) { diff --git a/wasm-port/runtime/sdk/README.md b/wasm-port/runtime/sdk/README.md index 95a5bce..1cc019f 100644 --- a/wasm-port/runtime/sdk/README.md +++ b/wasm-port/runtime/sdk/README.md @@ -66,11 +66,39 @@ import { createIniPanelShellWorkflowOverviewReleaseReadinessArtifactValidationActionPlanDomReadiness, createIniPanelShellWorkflowOverviewReleaseReadinessArtifactValidationActionPlanRenderState, createIniPanelShellWorkflowOverviewReleaseReadinessArtifactValidationSummaryViewModel, + VIRTUAL_HAL_COVERAGE_STATES, + VIRTUAL_HAL_PROJECT_PIN_GROUPS, + VIRTUAL_HAL_SIMULATION_REPLACEMENT_TARGETS, + VIRTUAL_HAL_SIMULATION_RUNTIME_CAPABILITIES, + VIRTUAL_HAL_SOURCE_FILES, + VIRTUAL_HAL_SYSTEM_PIN_FAMILIES, + VIRTUAL_HAL_WASM_BRIDGE_FUNCTIONS, + applyVirtualHalAction, + applyVirtualHalPinUpdates, + applyVirtualHalToInterpSdk, createLinuxCncIniSdk, createLinuxCncInterpSdk, + createLinuxCncVirtualHalRuntime, createMachineSessionPersistenceDisplayViewModel, createMachineSessionPersistenceRenderState, createMachineSessionPersistenceSummary, + createVirtualHalBridgeActionPlan, + createVirtualHalBridgeReadiness, + createVirtualHalIntegrityReport, + createVirtualHalPinInventory, + createVirtualHalPinRegistry, + createVirtualHalProjectReport, + createVirtualHalSimulationReplacementReport, + createVirtualHalSimulationRuntimeReport, + createVirtualHalState, + createVirtualHalSystemCoverageReport, + createVirtualHalWasmBridgeSnapshot, + executeVirtualHalCommand, + executeVirtualHalcmd, + readVirtualHalPin, + stepVirtualHalMotion, + stepVirtualHalMotionController, + writeVirtualHalPin, createMachineSessionSnapshotPayload, createProjectReleaseGateActionPlan, createProjectReleaseGateExecutionManifest, @@ -241,6 +269,78 @@ path but keeps the runner loop going after LinuxCNC reports an error, matching upstream `rs274 -n 0` regression tests such as `tests/interp/oword-unwind`. It does not implement or reinterpret LinuxCNC error semantics. +## Project-level virtual HAL boundary + +The SDK exposes a project-level browser/Node virtual HAL boundary for UI, +session, diagnostics, and standalone interpreter bridge workflows. It is shared +through `runtime/sdk/src/index.js`, so simulation pages, INI-panel shells, +Node smokes, and external dashboards can use the same objects instead of +private page state. + +- `createVirtualHalState()` creates the normalized browser virtual HAL state. +- `applyVirtualHalAction()` applies stable manual-control actions such as + ESTOP, power, home, jog, spindle, coolant, brake, touch-off, and limit + override. +- `createLinuxCncVirtualHalRuntime()` wraps that state in a small runtime with + `dispatch()`, `getDroState()`, `getLimitsHomeState()`, + `getMachineStatusState()`, `getWasmBridgeSnapshot()`, `getPinRegistry()`, + `readPin()`, `writePin()`, `applyPinUpdates()`, `executeHalCommand()`, + `executeHalcmd()`, `stepMotion()`, `stepMotionController()`, + `getSimulationRuntimeReport()`, `getSimulationReplacementReport()`, + `getIntegrityReport()`, `getPinInventory()`, `getProjectReport()`, and + `applyToInterpSdk()`. +- `VIRTUAL_HAL_SIMULATION_RUNTIME_CAPABILITIES`, + `VIRTUAL_HAL_SIMULATION_REPLACEMENT_TARGETS`, + `executeVirtualHalCommand()`, `executeVirtualHalcmd()`, + `stepVirtualHalMotion()`, `stepVirtualHalMotionController()`, + `createVirtualHalSimulationRuntimeReport()`, and + `createVirtualHalSimulationReplacementReport()` expose a simulation-grade + replacement for the host LinuxCNC realtime HAL, `halcmd`, and motion + controller runtime. It supports HAL pin/signal/param storage, `setp`, + `sets`, `newsig`, `net`, `show`, `getp`, `gets`, `loadrt`/`loadusr` stubs, + `addf`, thread start/stop, and servo-period motion stepping for browser + simulation and Node dashboards. +- `createVirtualHalWasmBridgeSnapshot()` returns the pin/value rows that can be + applied through the interpreter SDK HAL bridge. It includes AXIS internal pin + names such as `jog.x`, full HAL names such as `axisui.jog.x`, task/power + names such as `halui.machine.is-on`, and spindle/coolant names such as + `spindle.0.speed-out` and `iocontrol.0.coolant-flood`. +- `createVirtualHalPinRegistry()`, `readVirtualHalPin()`, + `writeVirtualHalPin()`, and `applyVirtualHalPinUpdates()` expose the project + virtual HAL as a source-derived pin service. Callers can read/write HAL names + such as `halui.machine.on`, `spindle.0.speed-out`, + `motion.tooloffset.z`, or `joint.0.homed` without duplicating pin mapping. +- `createVirtualHalIntegrityReport()` verifies registry completeness, duplicate + pin absence, required project pins, and source coverage in one machine + readable report. +- `createVirtualHalPinInventory()` returns a stable project inventory grouped + by `VIRTUAL_HAL_PROJECT_PIN_GROUPS`. +- `VIRTUAL_HAL_SYSTEM_PIN_FAMILIES`, `VIRTUAL_HAL_SOURCE_FILES`, and + `VIRTUAL_HAL_COVERAGE_STATES` expose the source-derived coverage model for + AXIS, HALUI, iocontrol, motion, axis, joint, spindle, coolant, and tool pins. +- `createVirtualHalSystemCoverageReport()` expands that source-derived model + into a machine-readable completeness report. It records the LinuxCNC source + files used as evidence, concrete declared pin rows, represented bridge pins, + and explicit `bridge-only` / `runtime-boundary` rows. +- `createVirtualHalBridgeReadiness()` checks that a snapshot has required + bridge pins and that caller-provided WASM function evidence covers + `VIRTUAL_HAL_WASM_BRIDGE_FUNCTIONS`. +- `createVirtualHalBridgeActionPlan()` turns blocked bridge readiness into the + next build/verify commands. +- `createVirtualHalProjectReport()` packages state, DRO, limits/home, machine + status, pin inventory, pin registry, integrity, system coverage, simulation + runtime evidence, bridge snapshot, bridge readiness, and action plan into one + stable report for dashboards or release artifacts. +- `applyVirtualHalToInterpSdk()` applies a virtual HAL state to any interpreter + SDK instance exposing `applyVirtualHalState()` or `applyVirtualHalSnapshot()`. + +For simulation, this replaces the host `halcmd`, HAL object store, and basic +motion feedback loop. It is a virtual realtime HAL runtime replacement for +browser workflows, but still not a Linux kernel hard-realtime ABI: kernel +realtime scheduling, external device drivers, native HAL module ABI behavior, +HALUI process behavior, and Tcl/Python process integration remain explicit +boundaries. + ## Project release readiness export `createProjectReleaseGateManifest()` returns the required release gate IDs, diff --git a/wasm-port/runtime/sdk/src/index.js b/wasm-port/runtime/sdk/src/index.js index 2442f46..b6ed428 100644 --- a/wasm-port/runtime/sdk/src/index.js +++ b/wasm-port/runtime/sdk/src/index.js @@ -1,5 +1,42 @@ export { createLinuxCncIniSdk } from "./linuxcnc-ini.js"; export { createLinuxCncInterpSdk } from "./linuxcnc-interp.js"; +export { + VIRTUAL_HAL_AXES, + VIRTUAL_HAL_AXISUI_PINS, + VIRTUAL_HAL_COVERAGE_STATES, + VIRTUAL_HAL_PROJECT_PIN_GROUPS, + VIRTUAL_HAL_SIMULATION_REPLACEMENT_TARGETS, + VIRTUAL_HAL_SIMULATION_RUNTIME_CAPABILITIES, + VIRTUAL_HAL_SOURCE_FILES, + VIRTUAL_HAL_SYSTEM_PIN_FAMILIES, + VIRTUAL_HAL_WASM_BRIDGE_FUNCTIONS, + applyVirtualHalToInterpSdk, + applyVirtualHalAction, + applyVirtualHalPinUpdates, + cloneVirtualHalState, + createLinuxCncVirtualHalRuntime, + createVirtualHalBridgeActionPlan, + createVirtualHalBridgeReadiness, + createVirtualHalDroState, + createVirtualHalLimitsHomeState, + createVirtualHalMachineStatusState, + createVirtualHalIntegrityReport, + createVirtualHalPinInventory, + createVirtualHalPinRegistry, + createVirtualHalProjectReport, + createVirtualHalSimulationReplacementReport, + createVirtualHalSimulationRuntimeReport, + createVirtualHalState, + createVirtualHalSystemCoverageReport, + createVirtualHalWasmBridgeSnapshot, + executeVirtualHalCommand, + executeVirtualHalcmd, + parseVirtualHalJogIncrement, + readVirtualHalPin, + stepVirtualHalMotionController, + stepVirtualHalMotion, + writeVirtualHalPin, +} from "./linuxcnc-hal.js"; export { createProjectBatchAcceptanceActionPlan, createProjectBatchAcceptanceCapabilityMatrix, diff --git a/wasm-port/runtime/sdk/src/linuxcnc-hal.js b/wasm-port/runtime/sdk/src/linuxcnc-hal.js new file mode 100644 index 0000000..9b41cbb --- /dev/null +++ b/wasm-port/runtime/sdk/src/linuxcnc-hal.js @@ -0,0 +1,2315 @@ +const VIRTUAL_HAL_AXES_INTERNAL = ["x", "y", "z", "a", "b", "c", "u", "v", "w"]; +const VIRTUAL_HAL_DISPLAY_AXES = ["x", "y", "z", "a", "b", "c"]; + +export const VIRTUAL_HAL_AXES = [...VIRTUAL_HAL_AXES_INTERNAL]; + +export const VIRTUAL_HAL_AXISUI_PINS = Object.freeze({ + component: "axisui", + pins: Object.freeze([ + ...VIRTUAL_HAL_AXES.map((axis) => ({ name: `jog.${axis}`, type: "HAL_BIT", direction: "HAL_OUT" })), + { name: "jog.increment", type: "HAL_FLOAT", direction: "HAL_OUT" }, + { name: "notifications-clear", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "notifications-clear-info", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "notifications-clear-error", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "resume-inhibit", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "error", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "abort", type: "HAL_BIT", direction: "HAL_OUT" }, + ]), +}); + +export const VIRTUAL_HAL_SOURCE_FILES = Object.freeze({ + axisui: "linuxcnc/bin/axis", + axisuiScript: "linuxcnc/src/emc/usr_intf/axis/scripts/axis.py", + halui: "linuxcnc/src/emc/usr_intf/halui.cc", + iocontrol: "linuxcnc/src/emc/task/taskclass.cc", + motion: "linuxcnc/src/emc/motion/motion.c", + motionAxis: "linuxcnc/src/emc/motion/axis.c", + motionHoming: "linuxcnc/src/emc/motion/homing.c", + vendoredMotionHeader: "wasm-port/vendor/linuxcnc/src/emc/motion/motion.h", +}); + +export const VIRTUAL_HAL_COVERAGE_STATES = Object.freeze({ + simulated: "simulated", + bridgeOnly: "bridge-only", + runtimeBoundary: "runtime-boundary", +}); + +export const VIRTUAL_HAL_SYSTEM_PIN_FAMILIES = Object.freeze([ + { + id: "axisui", + component: "axisui", + label: "AXIS UI pins", + sourceFiles: Object.freeze([ + VIRTUAL_HAL_SOURCE_FILES.axisui, + VIRTUAL_HAL_SOURCE_FILES.axisuiScript, + ]), + sourceEvidence: "axisui component newpin calls for jog, notification, error, and abort pins.", + coverage: VIRTUAL_HAL_COVERAGE_STATES.simulated, + pins: Object.freeze(VIRTUAL_HAL_AXISUI_PINS.pins.map((pin) => ({ + ...pin, + name: `${VIRTUAL_HAL_AXISUI_PINS.component}.${pin.name}`, + }))), + }, + { + id: "halui-task", + component: "halui", + label: "HALUI task, mode, and program pins", + sourceFiles: Object.freeze([VIRTUAL_HAL_SOURCE_FILES.halui]), + sourceEvidence: "halui_hal_init exports machine, estop, mode, coolant, program, override, and MDI pins.", + coverage: VIRTUAL_HAL_COVERAGE_STATES.bridgeOnly, + pins: Object.freeze([ + { name: "halui.machine.units-per-mm", type: "HAL_FLOAT", direction: "HAL_OUT" }, + { name: "halui.machine.is-on", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.machine.on", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.machine.off", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.estop.is-activated", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.estop.activate", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.estop.reset", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.mode.is-manual", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.mode.is-auto", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.mode.is-mdi", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.mode.is-teleop", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.mode.is-joint", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.mode.manual", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.mode.auto", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.mode.mdi", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.mist.is-on", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.flood.is-on", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.mist.on", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.mist.off", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.flood.on", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.flood.off", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.program.is-idle", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.program.is-running", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.program.is-paused", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.halui-mdi-is-running", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.program.run", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.program.pause", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.program.resume", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.program.step", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.program.stop", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.program.optional-stop.is-on", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.program.optional-stop.on", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.program.optional-stop.off", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.program.block-delete.is-on", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.program.block-delete.on", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.program.block-delete.off", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.abort", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.feed-override.value", type: "HAL_FLOAT", direction: "HAL_OUT" }, + { name: "halui.rapid-override.value", type: "HAL_FLOAT", direction: "HAL_OUT" }, + { name: "halui.max-velocity.value", type: "HAL_FLOAT", direction: "HAL_OUT" }, + { name: "halui.feed-override.counts", type: "HAL_S32", direction: "HAL_IN" }, + { name: "halui.feed-override.scale", type: "HAL_FLOAT", direction: "HAL_IN" }, + { name: "halui.feed-override.increase", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.feed-override.decrease", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.feed-override.reset", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.rapid-override.counts", type: "HAL_S32", direction: "HAL_IN" }, + { name: "halui.rapid-override.scale", type: "HAL_FLOAT", direction: "HAL_IN" }, + { name: "halui.rapid-override.increase", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.rapid-override.decrease", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.rapid-override.reset", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.mdi-command-00", type: "HAL_BIT", direction: "HAL_IN", template: true }, + ]), + }, + { + id: "halui-axis-joint", + component: "halui", + label: "HALUI axis and joint pins", + sourceFiles: Object.freeze([VIRTUAL_HAL_SOURCE_FILES.halui]), + sourceEvidence: "halui_hal_init exports per-axis and per-joint status, selection, jogging, homing, and limit pins.", + coverage: VIRTUAL_HAL_COVERAGE_STATES.bridgeOnly, + pins: Object.freeze([ + { name: "halui.axis.{axis}.is-selected", type: "HAL_BIT", direction: "HAL_OUT", axes: true }, + { name: "halui.axis.{axis}.pos-commanded", type: "HAL_FLOAT", direction: "HAL_OUT", axes: true }, + { name: "halui.axis.{axis}.pos-feedback", type: "HAL_FLOAT", direction: "HAL_OUT", axes: true }, + { name: "halui.axis.{axis}.pos-relative", type: "HAL_FLOAT", direction: "HAL_OUT", axes: true }, + { name: "halui.axis.{axis}.select", type: "HAL_BIT", direction: "HAL_IN", axes: true }, + { name: "halui.axis.{axis}.plus", type: "HAL_BIT", direction: "HAL_IN", axes: true }, + { name: "halui.axis.{axis}.minus", type: "HAL_BIT", direction: "HAL_IN", axes: true }, + { name: "halui.axis.{axis}.analog", type: "HAL_FLOAT", direction: "HAL_IN", axes: true }, + { name: "halui.axis.{axis}.increment", type: "HAL_FLOAT", direction: "HAL_IN", axes: true }, + { name: "halui.joint.{joint}.is-homed", type: "HAL_BIT", direction: "HAL_OUT", joints: true }, + { name: "halui.joint.{joint}.is-selected", type: "HAL_BIT", direction: "HAL_OUT", joints: true }, + { name: "halui.joint.{joint}.home", type: "HAL_BIT", direction: "HAL_IN", joints: true }, + { name: "halui.joint.{joint}.unhome", type: "HAL_BIT", direction: "HAL_IN", joints: true }, + { name: "halui.joint.{joint}.plus", type: "HAL_BIT", direction: "HAL_IN", joints: true }, + { name: "halui.joint.{joint}.minus", type: "HAL_BIT", direction: "HAL_IN", joints: true }, + { name: "halui.joint.selected.is-homed", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.joint.selected.home", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.axis.selected.plus", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.axis.selected.minus", type: "HAL_BIT", direction: "HAL_IN" }, + ]), + }, + { + id: "iocontrol", + component: "iocontrol.0", + label: "IO/task, coolant, and tool-change pins", + sourceFiles: Object.freeze([VIRTUAL_HAL_SOURCE_FILES.iocontrol]), + sourceEvidence: "Task::iocontrol_hal_init adds iocontrol.0 enable, coolant, tool prepare, and tool change pins.", + coverage: VIRTUAL_HAL_COVERAGE_STATES.simulated, + pins: Object.freeze([ + { name: "iocontrol.0.user-enable-out", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "iocontrol.0.emc-enable-in", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "iocontrol.0.user-request-enable", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "iocontrol.0.coolant-mist", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "iocontrol.0.coolant-flood", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "iocontrol.0.tool-prep-pocket", type: "HAL_S32", direction: "HAL_OUT" }, + { name: "iocontrol.0.tool-from-pocket", type: "HAL_S32", direction: "HAL_OUT" }, + { name: "iocontrol.0.tool-prep-index", type: "HAL_S32", direction: "HAL_OUT" }, + { name: "iocontrol.0.tool-prep-number", type: "HAL_S32", direction: "HAL_OUT" }, + { name: "iocontrol.0.tool-number", type: "HAL_S32", direction: "HAL_OUT" }, + { name: "iocontrol.0.tool-prepare", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "iocontrol.0.tool-prepared", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "iocontrol.0.tool-change", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "iocontrol.0.tool-changed", type: "HAL_BIT", direction: "HAL_IN" }, + ]), + }, + { + id: "motion-core", + component: "motion", + label: "Motion controller status pins", + sourceFiles: Object.freeze([VIRTUAL_HAL_SOURCE_FILES.motion, VIRTUAL_HAL_SOURCE_FILES.vendoredMotionHeader]), + sourceEvidence: "init_hal_io exports machine-wide motion enable, mode, velocity, feed, interpreter, and IO pins.", + coverage: VIRTUAL_HAL_COVERAGE_STATES.runtimeBoundary, + pins: Object.freeze([ + { name: "motion.enable", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "motion.motion-enabled", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "motion.in-position", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "motion.motion-type", type: "HAL_S32", direction: "HAL_OUT" }, + { name: "motion.coord-mode", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "motion.teleop-mode", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "motion.current-vel", type: "HAL_FLOAT", direction: "HAL_OUT" }, + { name: "motion.requested-vel", type: "HAL_FLOAT", direction: "HAL_OUT" }, + { name: "motion.distance-to-go", type: "HAL_FLOAT", direction: "HAL_OUT" }, + { name: "motion.program-line", type: "HAL_S32", direction: "HAL_OUT" }, + { name: "motion.probe-input", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "motion.adaptive-feed", type: "HAL_FLOAT", direction: "HAL_IN" }, + { name: "motion.feed-hold", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "motion.feed-inhibit", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "motion.homing-inhibit", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "motion.jog-inhibit", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "motion.switchkins-type", type: "HAL_FLOAT", direction: "HAL_IN", conditional: "switchable kinematics" }, + ]), + }, + { + id: "axis-motion", + component: "axis", + label: "Motion axis pins", + sourceFiles: Object.freeze([VIRTUAL_HAL_SOURCE_FILES.motionAxis]), + sourceEvidence: "axis_init_hal_io exports per-axis position, teleop, jog, and external-offset pins.", + coverage: VIRTUAL_HAL_COVERAGE_STATES.simulated, + pins: Object.freeze([ + { name: "axis.{axis}.pos-cmd", type: "HAL_FLOAT", direction: "HAL_OUT", axes: true }, + { name: "axis.{axis}.teleop-vel-cmd", type: "HAL_FLOAT", direction: "HAL_OUT", axes: true }, + { name: "axis.{axis}.teleop-pos-cmd", type: "HAL_FLOAT", direction: "HAL_OUT", axes: true }, + { name: "axis.{axis}.teleop-vel-lim", type: "HAL_FLOAT", direction: "HAL_OUT", axes: true }, + { name: "axis.{axis}.teleop-tp-enable", type: "HAL_BIT", direction: "HAL_OUT", axes: true }, + { name: "axis.{axis}.jog-enable", type: "HAL_BIT", direction: "HAL_IN", axes: true }, + { name: "axis.{axis}.jog-scale", type: "HAL_FLOAT", direction: "HAL_IN", axes: true }, + { name: "axis.{axis}.jog-counts", type: "HAL_S32", direction: "HAL_IN", axes: true }, + { name: "axis.{axis}.jog-vel-mode", type: "HAL_BIT", direction: "HAL_IN", axes: true }, + { name: "axis.{axis}.eoffset-enable", type: "HAL_BIT", direction: "HAL_IN", axes: true }, + { name: "axis.{axis}.eoffset", type: "HAL_FLOAT", direction: "HAL_OUT", axes: true }, + ]), + }, + { + id: "joint-motion", + component: "joint", + label: "Motion joint and homing pins", + sourceFiles: Object.freeze([VIRTUAL_HAL_SOURCE_FILES.motion, VIRTUAL_HAL_SOURCE_FILES.motionHoming]), + sourceEvidence: "export_joint and base_make_joint_home_pins export per-joint command, feedback, homing, limit, jog, and fault pins.", + coverage: VIRTUAL_HAL_COVERAGE_STATES.simulated, + pins: Object.freeze([ + { name: "joint.{joint}.coarse-pos-cmd", type: "HAL_FLOAT", direction: "HAL_OUT", joints: true }, + { name: "joint.{joint}.pos-cmd", type: "HAL_FLOAT", direction: "HAL_OUT", joints: true }, + { name: "joint.{joint}.pos-fb", type: "HAL_FLOAT", direction: "HAL_OUT", joints: true }, + { name: "joint.{joint}.motor-pos-cmd", type: "HAL_FLOAT", direction: "HAL_OUT", joints: true }, + { name: "joint.{joint}.motor-pos-fb", type: "HAL_FLOAT", direction: "HAL_IN", joints: true }, + { name: "joint.{joint}.pos-lim-sw-in", type: "HAL_BIT", direction: "HAL_IN", joints: true }, + { name: "joint.{joint}.neg-lim-sw-in", type: "HAL_BIT", direction: "HAL_IN", joints: true }, + { name: "joint.{joint}.amp-enable-out", type: "HAL_BIT", direction: "HAL_OUT", joints: true }, + { name: "joint.{joint}.amp-fault-in", type: "HAL_BIT", direction: "HAL_IN", joints: true }, + { name: "joint.{joint}.homed", type: "HAL_BIT", direction: "HAL_OUT", joints: true }, + { name: "joint.{joint}.homing", type: "HAL_BIT", direction: "HAL_OUT", joints: true }, + { name: "joint.{joint}.home-state", type: "HAL_S32", direction: "HAL_OUT", joints: true }, + { name: "joint.{joint}.home-sw-in", type: "HAL_BIT", direction: "HAL_IN", joints: true }, + { name: "joint.{joint}.pos-hard-limit", type: "HAL_BIT", direction: "HAL_OUT", joints: true }, + { name: "joint.{joint}.neg-hard-limit", type: "HAL_BIT", direction: "HAL_OUT", joints: true }, + { name: "joint.{joint}.error", type: "HAL_BIT", direction: "HAL_OUT", joints: true }, + { name: "joint.{joint}.faulted", type: "HAL_BIT", direction: "HAL_OUT", joints: true }, + ]), + }, + { + id: "spindle-motion", + component: "spindle", + label: "Motion spindle pins", + sourceFiles: Object.freeze([VIRTUAL_HAL_SOURCE_FILES.motion, VIRTUAL_HAL_SOURCE_FILES.halui]), + sourceEvidence: "export_spindle and halui_hal_init export spindle command, status, override, orient, and feedback pins.", + coverage: VIRTUAL_HAL_COVERAGE_STATES.simulated, + pins: Object.freeze([ + { name: "spindle.0.on", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "spindle.0.forward", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "spindle.0.reverse", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "spindle.0.brake", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "spindle.0.speed-out", type: "HAL_FLOAT", direction: "HAL_OUT" }, + { name: "spindle.0.speed-out-abs", type: "HAL_FLOAT", direction: "HAL_OUT" }, + { name: "spindle.0.speed-out-rps", type: "HAL_FLOAT", direction: "HAL_OUT" }, + { name: "spindle.0.speed-in", type: "HAL_FLOAT", direction: "HAL_IN" }, + { name: "spindle.0.at-speed", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "spindle.0.orient", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "spindle.0.is-oriented", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.spindle.0.is-on", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "halui.spindle.0.start", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.spindle.0.stop", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "halui.spindle.0.override.value", type: "HAL_FLOAT", direction: "HAL_OUT" }, + ]), + }, + { + id: "tool-status", + component: "tool", + label: "Tool status and tool-offset pins", + sourceFiles: Object.freeze([VIRTUAL_HAL_SOURCE_FILES.halui, VIRTUAL_HAL_SOURCE_FILES.iocontrol, VIRTUAL_HAL_SOURCE_FILES.motion]), + sourceEvidence: "HALUI tool status, iocontrol tool-change pins, and motion.tooloffset pins expose current tool and offsets.", + coverage: VIRTUAL_HAL_COVERAGE_STATES.bridgeOnly, + pins: Object.freeze([ + { name: "halui.tool.number", type: "HAL_U32", direction: "HAL_OUT" }, + { name: "halui.tool.diameter", type: "HAL_FLOAT", direction: "HAL_OUT" }, + { name: "halui.tool.length_offset.{axis}", type: "HAL_FLOAT", direction: "HAL_OUT", axes: true }, + { name: "motion.tooloffset.{axis}", type: "HAL_FLOAT", direction: "HAL_OUT", axes: true }, + { name: "iocontrol.0.tool-number", type: "HAL_S32", direction: "HAL_OUT" }, + { name: "iocontrol.0.tool-prepare", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "iocontrol.0.tool-prepared", type: "HAL_BIT", direction: "HAL_IN" }, + { name: "iocontrol.0.tool-change", type: "HAL_BIT", direction: "HAL_OUT" }, + { name: "iocontrol.0.tool-changed", type: "HAL_BIT", direction: "HAL_IN" }, + ]), + }, +]); + +export const VIRTUAL_HAL_WASM_BRIDGE_FUNCTIONS = Object.freeze([ + "lcinterp_hal_reset", + "lcinterp_hal_set_value", + "lcinterp_probe_hal_named", +]); + +export const VIRTUAL_HAL_PROJECT_PIN_GROUPS = Object.freeze([ + { + id: "axisui", + label: "AXIS UI pins", + description: "Browser/manual AXIS component pins mirrored from linuxcnc/bin/axis.", + }, + { + id: "axis-motion", + label: "Axis and joint motion pins", + description: "LinuxCNC motion axis/joint state pins exposed through source-derived virtual HAL coverage.", + }, + { + id: "task", + label: "Task, HALUI, and iocontrol pins", + description: "ESTOP, power, mode, program, and IO pins from halui.cc and taskclass.cc.", + }, + { + id: "spindle-coolant", + label: "Spindle and coolant pins", + description: "Spindle speed/on/orient and iocontrol/HALUI coolant pins used by project UI/status workflows.", + }, + { + id: "tool", + label: "Tool pins", + description: "Tool status, tool change, and motion tool-offset pins from HALUI, iocontrol, and motion.", + }, +]); + +export const VIRTUAL_HAL_SIMULATION_RUNTIME_CAPABILITIES = Object.freeze([ + "realtime-hal-simulation-replacement", + "halcmd-simulation-replacement", + "motion-controller-simulation-replacement", + "hal-pin-signal-param-store", + "halcmd-setp-sets-net-show-getp-gets", + "userspace-load-command-stubs", + "servo-period-motion-step", + "axis-joint-position-feedback", + "spindle-coolant-tool-status", + "interpreter-hal-bridge-snapshot", +]); + +export const VIRTUAL_HAL_SIMULATION_REPLACEMENT_TARGETS = Object.freeze([ + "linuxcnc-realtime-hal", + "halcmd", + "motion-controller", +]); + +export function createVirtualHalState(overrides = {}) { + const base = { + apiName: "linuxcnc-wasm-virtual-hal-state", + halVersion: 1, + source: "browser-virtual-hal", + component: VIRTUAL_HAL_AXISUI_PINS.component, + axisuiPins: VIRTUAL_HAL_AXISUI_PINS.pins.map((pin) => ({ ...pin })), + revision: 0, + positionDirty: false, + machineDirty: false, + activeAxis: "x", + task: { + estop: true, + powered: false, + state: "ESTOP", + mode: "manual", + }, + pins: Object.fromEntries(VIRTUAL_HAL_AXISUI_PINS.pins.map((pin) => [pin.name, pin.type === "HAL_FLOAT" ? 0 : false])), + externalPins: {}, + position: Object.fromEntries(VIRTUAL_HAL_AXES.map((axis) => [axis, 0])), + homed: Object.fromEntries(VIRTUAL_HAL_AXES.map((axis) => [axis, false])), + limits: Object.fromEntries(VIRTUAL_HAL_AXES.map((axis) => [ + axis, + { minLimit: false, maxLimit: false, fault: "none" }, + ])), + spindle: { + state: "off", + direction: "stopped", + speed: 0, + brake: false, + }, + coolant: { + mist: "off", + flood: "off", + }, + overrides: { + feed: 100, + rapid: 100, + spindle: 100, + limits: false, + }, + program: { + state: "idle", + optionalStop: false, + blockDelete: false, + haluiMdiRunning: false, + line: 0, + }, + tool: { + selected: 0, + current: 0, + pocket: 0, + prepPocket: 0, + fromPocket: 0, + prepIndex: 0, + prepNumber: 0, + prepare: false, + prepared: true, + change: false, + changed: true, + diameter: 0, + lengthOffset: Object.fromEntries(VIRTUAL_HAL_AXES.map((axis) => [axis, 0])), + }, + motion: { + enabled: false, + inPosition: true, + motionType: 0, + coordMode: false, + teleopMode: false, + currentVel: 0, + requestedVel: 0, + distanceToGo: 0, + programLine: 0, + probeInput: false, + adaptiveFeed: 1, + feedHold: false, + feedInhibit: false, + homingInhibit: false, + jogInhibit: false, + jogStop: false, + switchkinsType: 0, + target: Object.fromEntries(VIRTUAL_HAL_AXES.map((axis) => [axis, 0])), + servoPeriod: 0.001, + }, + io: { + digitalIn: {}, + digitalOut: {}, + analogIn: {}, + analogOut: {}, + }, + hal: { + signals: {}, + params: {}, + nets: {}, + functions: [], + threads: { + "servo-thread": { + periodNs: 1000000, + running: true, + }, + }, + commandLog: [], + loadedComponents: [], + }, + jog: { + increment: "Continuous", + incrementValue: 0, + lastAction: "idle", + }, + notifications: { + clear: false, + clearInfo: false, + clearError: false, + resumeInhibit: false, + error: false, + abort: false, + }, + }; + return normalizeVirtualHalState({ + ...base, + ...overrides, + task: { ...base.task, ...(overrides.task ?? {}) }, + pins: { ...base.pins, ...(overrides.pins ?? {}) }, + externalPins: { ...base.externalPins, ...(overrides.externalPins ?? {}) }, + position: { ...base.position, ...(overrides.position ?? {}) }, + homed: { ...base.homed, ...(overrides.homed ?? {}) }, + limits: mergeAxisObject(base.limits, overrides.limits), + spindle: { ...base.spindle, ...(overrides.spindle ?? {}) }, + coolant: { ...base.coolant, ...(overrides.coolant ?? {}) }, + overrides: { ...base.overrides, ...(overrides.overrides ?? {}) }, + program: { ...base.program, ...(overrides.program ?? {}) }, + tool: { + ...base.tool, + ...(overrides.tool ?? {}), + lengthOffset: { ...base.tool.lengthOffset, ...(overrides.tool?.lengthOffset ?? {}) }, + }, + motion: { ...base.motion, ...(overrides.motion ?? {}) }, + io: { + digitalIn: { ...base.io.digitalIn, ...(overrides.io?.digitalIn ?? {}) }, + digitalOut: { ...base.io.digitalOut, ...(overrides.io?.digitalOut ?? {}) }, + analogIn: { ...base.io.analogIn, ...(overrides.io?.analogIn ?? {}) }, + analogOut: { ...base.io.analogOut, ...(overrides.io?.analogOut ?? {}) }, + }, + hal: { + signals: { ...base.hal.signals, ...(overrides.hal?.signals ?? {}) }, + params: { ...base.hal.params, ...(overrides.hal?.params ?? {}) }, + nets: { ...base.hal.nets, ...(overrides.hal?.nets ?? {}) }, + functions: [...base.hal.functions, ...(overrides.hal?.functions ?? [])], + threads: { ...base.hal.threads, ...(overrides.hal?.threads ?? {}) }, + commandLog: [...base.hal.commandLog, ...(overrides.hal?.commandLog ?? [])], + loadedComponents: [...base.hal.loadedComponents, ...(overrides.hal?.loadedComponents ?? [])], + }, + jog: { ...base.jog, ...(overrides.jog ?? {}) }, + notifications: { ...base.notifications, ...(overrides.notifications ?? {}) }, + }); +} + +function mergeAxisObject(base, override = {}) { + return Object.fromEntries(Object.entries(base).map(([axis, value]) => [ + axis, + { ...value, ...(override?.[axis] ?? {}) }, + ])); +} + +export function cloneVirtualHalState(state) { + return { + ...state, + task: { ...(state.task ?? {}) }, + axisuiPins: (state.axisuiPins ?? VIRTUAL_HAL_AXISUI_PINS.pins).map((pin) => ({ ...pin })), + pins: { ...(state.pins ?? {}) }, + externalPins: { ...(state.externalPins ?? {}) }, + position: { ...(state.position ?? {}) }, + homed: { ...(state.homed ?? {}) }, + limits: Object.fromEntries(Object.entries(state.limits ?? {}).map(([axis, limit]) => [axis, { ...limit }])), + spindle: { ...(state.spindle ?? {}) }, + coolant: { ...(state.coolant ?? {}) }, + overrides: { ...(state.overrides ?? {}) }, + program: { ...(state.program ?? {}) }, + tool: { + ...(state.tool ?? {}), + lengthOffset: { ...(state.tool?.lengthOffset ?? {}) }, + }, + motion: { ...(state.motion ?? {}) }, + io: { + digitalIn: { ...(state.io?.digitalIn ?? {}) }, + digitalOut: { ...(state.io?.digitalOut ?? {}) }, + analogIn: { ...(state.io?.analogIn ?? {}) }, + analogOut: { ...(state.io?.analogOut ?? {}) }, + }, + hal: { + signals: Object.fromEntries(Object.entries(state.hal?.signals ?? {}).map(([name, signal]) => [name, { ...signal, pins: [...(signal.pins ?? [])] }])), + params: { ...(state.hal?.params ?? {}) }, + nets: Object.fromEntries(Object.entries(state.hal?.nets ?? {}).map(([name, pins]) => [name, [...pins]])), + functions: (state.hal?.functions ?? []).map((item) => ({ ...item })), + threads: Object.fromEntries(Object.entries(state.hal?.threads ?? {}).map(([name, thread]) => [name, { ...thread }])), + commandLog: (state.hal?.commandLog ?? []).map((item) => ({ ...item })), + loadedComponents: (state.hal?.loadedComponents ?? []).map((item) => ({ ...item })), + }, + jog: { ...(state.jog ?? {}) }, + notifications: { ...(state.notifications ?? {}) }, + }; +} + +function normalizeVirtualHalState(state) { + const next = cloneVirtualHalState({ + ...state, + axisuiPins: state.axisuiPins ?? VIRTUAL_HAL_AXISUI_PINS.pins, + pins: state.pins ?? {}, + externalPins: state.externalPins ?? {}, + hal: state.hal ?? {}, + notifications: state.notifications ?? {}, + }); + next.task.state = next.task.estop ? "ESTOP" : (next.task.powered ? "ON" : "ESTOP_RESET"); + next.task.powered = next.task.state === "ON"; + next.task.estop = next.task.state === "ESTOP"; + next.task.mode = ["manual", "auto", "mdi"].includes(next.task.mode) ? next.task.mode : "manual"; + next.spindle.speed = clampNumber(next.spindle.speed, 0, 99999); + if (next.spindle.state !== "on") { + next.spindle.direction = "stopped"; + } else if (!["cw", "ccw"].includes(next.spindle.direction)) { + next.spindle.direction = "cw"; + } + next.coolant.mist = next.coolant.mist === "on" ? "on" : "off"; + next.coolant.flood = next.coolant.flood === "on" ? "on" : "off"; + next.overrides.feed = clampNumber(next.overrides.feed, 0, 200); + next.overrides.rapid = clampNumber(next.overrides.rapid, 0, 200); + next.overrides.spindle = clampNumber(next.overrides.spindle, 0, 200); + next.program.state = ["idle", "running", "paused"].includes(next.program.state) ? next.program.state : "idle"; + next.program.optionalStop = next.program.optionalStop === true; + next.program.blockDelete = next.program.blockDelete === true; + next.program.haluiMdiRunning = next.program.haluiMdiRunning === true; + next.program.line = clampInteger(next.program.line, 0, 2147483647); + next.tool.selected = clampInteger(next.tool.selected, 0, 2147483647); + next.tool.current = clampInteger(next.tool.current, 0, 2147483647); + next.tool.pocket = clampInteger(next.tool.pocket, 0, 2147483647); + next.tool.prepPocket = clampInteger(next.tool.prepPocket, 0, 2147483647); + next.tool.fromPocket = clampInteger(next.tool.fromPocket, 0, 2147483647); + next.tool.prepIndex = clampInteger(next.tool.prepIndex, 0, 2147483647); + next.tool.prepNumber = clampInteger(next.tool.prepNumber, 0, 2147483647); + next.tool.prepare = next.tool.prepare === true; + next.tool.prepared = next.tool.prepared !== false; + next.tool.change = next.tool.change === true; + next.tool.changed = next.tool.changed !== false; + next.tool.diameter = clampNumber(next.tool.diameter, 0, 99999); + next.motion.enabled = next.task.powered && !next.task.estop; + next.motion.inPosition = next.motion.inPosition !== false; + next.motion.motionType = clampInteger(next.motion.motionType, 0, 2147483647); + next.motion.currentVel = clampNumber(next.motion.currentVel, 0, 999999); + next.motion.requestedVel = clampNumber(next.motion.requestedVel, 0, 999999); + next.motion.distanceToGo = clampNumber(next.motion.distanceToGo, 0, 999999); + next.motion.programLine = clampInteger(next.motion.programLine || next.program.line, 0, 2147483647); + next.motion.probeInput = next.motion.probeInput === true; + next.motion.adaptiveFeed = clampNumber(next.motion.adaptiveFeed, 0, 1); + next.motion.feedHold = next.motion.feedHold === true; + next.motion.feedInhibit = next.motion.feedInhibit === true; + next.motion.homingInhibit = next.motion.homingInhibit === true; + next.motion.jogInhibit = next.motion.jogInhibit === true; + next.motion.jogStop = next.motion.jogStop === true; + next.motion.switchkinsType = clampNumber(next.motion.switchkinsType, 0, 999); + next.motion.servoPeriod = clampNumber(next.motion.servoPeriod, 0.000001, 1); + next.motion.target = { ...(next.motion.target ?? {}) }; + next.jog.incrementValue = parseVirtualHalJogIncrement(next.jog.increment); + next.pins["jog.increment"] = next.jog.incrementValue; + next.pins["notifications-clear"] = next.notifications.clear === true; + next.pins["notifications-clear-info"] = next.notifications.clearInfo === true; + next.pins["notifications-clear-error"] = next.notifications.clearError === true; + next.pins["resume-inhibit"] = next.notifications.resumeInhibit === true; + next.pins.error = next.notifications.error === true; + next.pins.abort = next.notifications.abort === true; + for (const axis of VIRTUAL_HAL_AXES) { + next.position[axis] = Number.isFinite(Number(next.position[axis])) ? Number(next.position[axis]) : 0; + next.homed[axis] = next.homed[axis] === true; + next.limits[axis] = { + minLimit: next.limits[axis]?.minLimit === true, + maxLimit: next.limits[axis]?.maxLimit === true, + fault: next.limits[axis]?.fault ?? "none", + }; + next.pins[`jog.${axis}`] = next.pins[`jog.${axis}`] === true; + next.tool.lengthOffset[axis] = Number.isFinite(Number(next.tool.lengthOffset[axis])) ? Number(next.tool.lengthOffset[axis]) : 0; + next.motion.target[axis] = Number.isFinite(Number(next.motion.target[axis])) ? Number(next.motion.target[axis]) : next.position[axis]; + } + next.hal.signals = Object.fromEntries(Object.entries(next.hal.signals ?? {}).map(([name, signal]) => [ + name, + { + name, + type: signal.type ?? inferVirtualHalPinType(signal.value), + value: normalizeHalNumericValue(signal.value), + pins: [...new Set(signal.pins ?? [])], + }, + ])); + next.hal.params = Object.fromEntries(Object.entries(next.hal.params ?? {}).map(([name, entry]) => [ + name, + typeof entry === "object" && entry !== null + ? { ...entry, value: normalizeHalNumericValue(entry.value) } + : { kind: "param", name, type: inferVirtualHalPinType(entry), value: normalizeHalNumericValue(entry), connected: true }, + ])); + next.hal.nets = Object.fromEntries(Object.entries(next.hal.nets ?? {}).map(([name, pins]) => [name, [...new Set(pins ?? [])]])); + next.hal.functions = (next.hal.functions ?? []).map((item) => ({ + name: item.name, + thread: item.thread ?? "servo-thread", + })).filter(({ name }) => name); + next.hal.threads = Object.fromEntries(Object.entries(next.hal.threads ?? {}).map(([name, thread]) => [ + name, + { + periodNs: clampInteger(thread.periodNs ?? 1000000, 1, 1000000000), + running: thread.running !== false, + }, + ])); + next.hal.commandLog = (next.hal.commandLog ?? []).slice(-200).map((item) => ({ ...item })); + next.hal.loadedComponents = (next.hal.loadedComponents ?? []).map((item) => ({ ...item })); + return next; +} + +function clampNumber(value, min, max) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) { + return min; + } + return Math.min(Math.max(numeric, min), max); +} + +function clampInteger(value, min, max) { + return Math.trunc(clampNumber(value, min, max)); +} + +function normalizeHalNumericValue(value) { + if (typeof value === "boolean") { + return value ? 1 : 0; + } + const numeric = Number(value); + return Number.isFinite(numeric) ? numeric : 0; +} + +export function parseVirtualHalJogIncrement(increment) { + const text = String(increment ?? "Continuous").trim(); + if (!text || /^continuous$/i.test(text)) { + return 0; + } + const match = text.match(/^([-+0-9./]+)\s*(in|inch|mm|cm|um|mil)?$/i); + if (!match) { + return Number(text) || 0; + } + let value = 0; + if (match[1].includes("/")) { + const [numerator, denominator] = match[1].split("/").map(Number); + value = denominator ? numerator / denominator : 0; + } else { + value = Number(match[1]); + } + const unit = (match[2] ?? "in").toLowerCase(); + if (unit === "mm") { + value /= 25.4; + } else if (unit === "cm") { + value /= 2.54; + } else if (unit === "um") { + value /= 25400; + } else if (unit === "mil") { + value /= 1000; + } + return Number.isFinite(value) ? value : 0; +} + +export function createVirtualHalDroState(halState) { + const state = normalizeVirtualHalState(halState); + const actual = Object.fromEntries(VIRTUAL_HAL_DISPLAY_AXES.map((axis) => [axis, formatAxis(state.position[axis])])); + const zeroAxes = Object.fromEntries(VIRTUAL_HAL_DISPLAY_AXES.map((axis) => [axis, formatAxis(0)])); + return { + apiName: "linuxcnc-wasm-virtual-hal-dro-state", + droVersion: 1, + source: "browser-virtual-hal", + actual, + distanceToGo: { ...zeroAxes }, + workOffsetG54: { ...zeroAxes }, + g92Offset: { ...zeroAxes }, + toolLengthOffset: { ...zeroAxes }, + velocity: formatAxis(0), + }; +} + +export function createVirtualHalLimitsHomeState(halState) { + const state = normalizeVirtualHalState(halState); + return { + apiName: "linuxcnc-wasm-virtual-hal-limits-home-state", + stateVersion: 1, + source: "browser-virtual-hal", + axes: Object.fromEntries(VIRTUAL_HAL_DISPLAY_AXES.map((axis) => { + const limit = state.limits[axis]; + return [axis, { + homed: state.homed[axis] ? "yes" : "no", + minLimit: limit.minLimit ? "on" : "off", + maxLimit: limit.maxLimit ? "on" : "off", + fault: limit.fault, + }]; + })), + }; +} + +export function createVirtualHalMachineStatusState(halState) { + const state = normalizeVirtualHalState(halState); + return { + apiName: "linuxcnc-wasm-virtual-hal-machine-status-state", + statusVersion: 1, + source: "browser-virtual-hal", + spindle: { + direction: state.spindle.direction, + speed: `${state.spindle.speed}`, + state: state.spindle.state, + }, + coolant: { ...state.coolant }, + tool: { + selected: "0", + current: "0", + pocket: "0", + lengthOffset: "z=0.000", + }, + overrides: { + feed: state.overrides.feed > 0 ? "enabled" : "disabled", + speed: state.overrides.spindle > 0 ? "enabled" : "disabled", + adaptiveFeed: "disabled", + feedHold: "enabled", + }, + }; +} + +export function createVirtualHalSystemCoverageReport(options = {}) { + const halState = options.halState ?? createVirtualHalState(); + const snapshot = options.snapshot ?? createVirtualHalWasmBridgeSnapshot(halState); + const families = options.families ?? VIRTUAL_HAL_SYSTEM_PIN_FAMILIES; + const requiredFamilyIds = options.requiredFamilyIds ?? families.map(({ id }) => id); + const familyIds = new Set(families.map(({ id }) => id)); + const missingFamilies = requiredFamilyIds.filter((id) => !familyIds.has(id)); + const snapshotPins = new Set((snapshot.values ?? []) + .filter(({ kind }) => kind === "pin") + .map(({ name }) => name)); + const declaredRows = families.flatMap((family) => expandVirtualHalFamilyPins(family).map((pin) => ({ + id: `${family.id}:${pin.name}`, + familyId: family.id, + familyLabel: family.label, + component: family.component, + name: pin.name, + type: pin.type, + direction: pin.direction, + coverage: family.coverage, + sourceFiles: [...family.sourceFiles], + sourceEvidence: family.sourceEvidence, + representedInSnapshot: snapshotPins.has(pin.name), + template: pin.template === true, + conditional: pin.conditional ?? null, + }))); + const declaredPinNames = new Set(declaredRows.map(({ name }) => name)); + const unclassifiedSnapshotPins = [...snapshotPins].filter((name) => { + if (declaredPinNames.has(name)) { + return false; + } + return !isInternalAxisUiPinName(name); + }); + const missingSourceFamilies = families + .filter((family) => !Array.isArray(family.sourceFiles) || family.sourceFiles.length === 0) + .map(({ id }) => id); + const coverageCounts = Object.fromEntries( + Object.values(VIRTUAL_HAL_COVERAGE_STATES).map((coverage) => [ + coverage, + families.filter((family) => family.coverage === coverage).length, + ]), + ); + const boundaryFamilies = families.filter((family) => family.coverage !== VIRTUAL_HAL_COVERAGE_STATES.simulated); + const complete = missingFamilies.length === 0 && + missingSourceFamilies.length === 0 && + unclassifiedSnapshotPins.length === 0 && + declaredRows.length > 0; + return { + apiName: "linuxcnc-wasm-virtual-hal-system-coverage-report", + coverageVersion: 1, + source: "linuxcnc-source-derived-virtual-hal", + phase: complete ? "covered-with-runtime-boundaries" : "incomplete", + complete, + realtimeHalRuntime: false, + sourceFiles: [...new Set(families.flatMap((family) => family.sourceFiles))], + familyCount: families.length, + declaredPinCount: declaredRows.length, + snapshotPinCount: snapshot.values?.length ?? 0, + coverageCounts, + requiredFamilyIds: [...requiredFamilyIds], + missingFamilies, + missingSourceFamilies, + unclassifiedSnapshotPins, + families: families.map((family) => { + const familyRows = declaredRows.filter((row) => row.familyId === family.id); + return { + id: family.id, + component: family.component, + label: family.label, + coverage: family.coverage, + sourceFiles: [...family.sourceFiles], + sourceEvidence: family.sourceEvidence, + declaredPinCount: familyRows.length, + representedPinCount: familyRows.filter(({ representedInSnapshot }) => representedInSnapshot).length, + }; + }), + boundaryRows: boundaryFamilies.map((family) => ({ + id: family.id, + label: family.label, + coverage: family.coverage, + reason: family.coverage === VIRTUAL_HAL_COVERAGE_STATES.runtimeBoundary + ? "Requires LinuxCNC realtime task/motion/HAL process evidence, not JavaScript emulation." + : "Source pin family is inventoried and bridged where useful, but process semantics remain LinuxCNC-owned.", + sourceFiles: [...family.sourceFiles], + })), + declaredRows, + rows: [ + { + id: "families", + label: "Source-derived HAL families", + value: `${families.length}`, + }, + { + id: "declared-pins", + label: "Declared source pins", + value: `${declaredRows.length}`, + }, + { + id: "snapshot-pins", + label: "Bridge snapshot pins", + value: `${snapshot.values?.length ?? 0}`, + }, + { + id: "runtime-boundaries", + label: "Explicit runtime boundaries", + value: `${boundaryFamilies.length}`, + }, + ], + }; +} + +export function applyVirtualHalAction(halState, action, options = {}) { + const next = normalizeVirtualHalState(halState); + const axis = VIRTUAL_HAL_AXES.includes(String(options.axis ?? next.activeAxis).toLowerCase()) + ? String(options.axis ?? next.activeAxis).toLowerCase() + : "x"; + const increment = options.increment ?? next.jog.increment; + const direction = Number(options.direction ?? 1) >= 0 ? 1 : -1; + next.activeAxis = axis; + next.positionDirty = false; + next.machineDirty = true; + + if (action === "estop") { + if (next.task.estop) { + next.task.estop = false; + next.task.powered = false; + next.task.state = "ESTOP_RESET"; + } else { + next.task.estop = true; + next.task.powered = false; + next.task.state = "ESTOP"; + next.notifications.abort = true; + next.spindle.state = "off"; + next.coolant.mist = "off"; + next.coolant.flood = "off"; + } + next.jog.lastAction = next.task.state; + } else if (action === "power") { + if (next.task.estop) { + next.task.powered = false; + next.task.state = "ESTOP"; + } else { + next.task.powered = !next.task.powered; + next.task.state = next.task.powered ? "ON" : "ESTOP_RESET"; + } + next.jog.lastAction = next.task.state; + } else if (action === "home" || action === "home-all") { + if (next.task.powered && !next.task.estop) { + const axes = action === "home-all" ? VIRTUAL_HAL_AXES : [axis]; + for (const item of axes) { + next.homed[item] = true; + next.limits[item] = { minLimit: false, maxLimit: false, fault: "none" }; + } + next.jog.lastAction = action; + } else { + next.notifications.error = true; + next.limits[axis].fault = "machine-not-on"; + next.jog.lastAction = `${action}-blocked`; + } + } else if (action === "unhome-all") { + for (const item of VIRTUAL_HAL_AXES) { + next.homed[item] = false; + } + next.jog.lastAction = action; + } else if (action === "jog") { + for (const item of VIRTUAL_HAL_AXES) { + next.pins[`jog.${item}`] = false; + } + next.jog.increment = increment; + next.jog.incrementValue = parseVirtualHalJogIncrement(increment); + next.pins[`jog.${axis}`] = true; + next.pins["jog.increment"] = next.jog.incrementValue; + const distance = next.jog.incrementValue || Number(options.continuousStep ?? 0.1); + if (next.task.powered && !next.task.estop) { + next.position[axis] += direction * distance; + next.positionDirty = true; + next.jog.lastAction = `jog ${axis} ${direction > 0 ? "+" : "-"}`; + } else { + next.notifications.error = true; + next.jog.lastAction = `jog ${axis} blocked`; + } + } else if (action === "touch-off" || action === "zero-g54") { + next.position[axis] = Number(options.value ?? 0) || 0; + next.positionDirty = true; + next.jog.lastAction = `${action} ${axis}`; + } else if (action === "tool-touch-off") { + next.position.z = Number(options.value ?? next.position.z) || 0; + next.positionDirty = true; + next.jog.lastAction = "tool touch off"; + } else if (action === "spindle") { + const command = options.command ?? "toggle"; + if (command === "stop" || (command === "toggle" && next.spindle.state === "on")) { + next.spindle.state = "off"; + next.spindle.direction = "stopped"; + } else { + next.spindle.state = "on"; + next.spindle.direction = command === "ccw" ? "ccw" : "cw"; + } + next.spindle.speed = clampNumber(options.speed ?? (next.spindle.speed || 1200), 0, 99999); + next.jog.lastAction = `spindle ${next.spindle.direction}`; + } else if (action === "coolant") { + const name = options.name === "flood" ? "flood" : "mist"; + next.coolant[name] = options.enabled === true ? "on" : "off"; + next.jog.lastAction = `coolant ${name} ${next.coolant[name]}`; + } else if (action === "override-limits") { + next.overrides.limits = options.enabled === true; + next.jog.lastAction = `override limits ${next.overrides.limits ? "on" : "off"}`; + } else if (action === "spindle-brake") { + next.spindle.brake = options.enabled === true; + next.jog.lastAction = `spindle brake ${next.spindle.brake ? "on" : "off"}`; + } else if (action === "clear-notifications") { + next.notifications.clear = true; + next.notifications.clearInfo = options.level === "info"; + next.notifications.clearError = options.level === "error"; + next.notifications.error = false; + next.notifications.abort = false; + next.jog.lastAction = "clear notifications"; + } else { + next.jog.lastAction = String(action ?? "manual"); + } + + next.revision += 1; + return normalizeVirtualHalState(next); +} + +export function createVirtualHalWasmBridgeSnapshot(halState) { + const state = normalizeVirtualHalState(halState); + return { + apiName: "linuxcnc-wasm-virtual-hal-bridge-snapshot", + bridgeVersion: 1, + source: "browser-virtual-hal", + values: [ + ...Object.entries(state.pins).flatMap(([name, value]) => { + const entry = { + kind: "pin", + name, + type: typeof value === "number" ? "HAL_FLOAT" : "HAL_BIT", + value: typeof value === "number" ? value : value === true ? 1 : 0, + connected: true, + }; + return [ + entry, + { + ...entry, + name: `${state.component}.${name}`, + }, + ]; + }), + ...Object.entries(state.position).map(([axis, value]) => ({ + kind: "pin", + name: `axis.${axis}.pos-cmd`, + type: "HAL_FLOAT", + value, + connected: true, + })), + ...Object.entries(state.homed).map(([axis, value]) => ({ + kind: "pin", + name: `axis.${axis}.homed`, + type: "HAL_BIT", + value: value ? 1 : 0, + connected: true, + })), + { kind: "pin", name: "iocontrol.0.user-enable-out", type: "HAL_BIT", value: state.task.powered ? 1 : 0, connected: true }, + { kind: "pin", name: "iocontrol.0.emc-enable-in", type: "HAL_BIT", value: state.task.estop ? 0 : 1, connected: true }, + { kind: "pin", name: "iocontrol.0.user-request-enable", type: "HAL_BIT", value: state.task.estop ? 0 : 1, connected: true }, + { kind: "pin", name: "halui.machine.is-on", type: "HAL_BIT", value: state.task.powered ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.estop.is-activated", type: "HAL_BIT", value: state.task.estop ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.mode.is-manual", type: "HAL_BIT", value: state.task.mode === "manual" ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.mode.is-auto", type: "HAL_BIT", value: state.task.mode === "auto" ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.mode.is-mdi", type: "HAL_BIT", value: state.task.mode === "mdi" ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.program.is-idle", type: "HAL_BIT", value: state.program.state === "idle" ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.program.is-running", type: "HAL_BIT", value: state.program.state === "running" ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.program.is-paused", type: "HAL_BIT", value: state.program.state === "paused" ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.program.optional-stop.is-on", type: "HAL_BIT", value: state.program.optionalStop ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.program.block-delete.is-on", type: "HAL_BIT", value: state.program.blockDelete ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.halui-mdi-is-running", type: "HAL_BIT", value: state.program.haluiMdiRunning ? 1 : 0, connected: true }, + { kind: "pin", name: "motion.enable", type: "HAL_BIT", value: state.motion.enabled ? 1 : 0, connected: true }, + { kind: "pin", name: "motion.motion-enabled", type: "HAL_BIT", value: state.motion.enabled ? 1 : 0, connected: true }, + { kind: "pin", name: "motion.in-position", type: "HAL_BIT", value: state.motion.inPosition ? 1 : 0, connected: true }, + { kind: "pin", name: "motion.motion-type", type: "HAL_S32", value: state.motion.motionType, connected: true }, + { kind: "pin", name: "motion.coord-mode", type: "HAL_BIT", value: state.motion.coordMode ? 1 : 0, connected: true }, + { kind: "pin", name: "motion.teleop-mode", type: "HAL_BIT", value: state.motion.teleopMode ? 1 : 0, connected: true }, + { kind: "pin", name: "motion.current-vel", type: "HAL_FLOAT", value: state.motion.currentVel, connected: true }, + { kind: "pin", name: "motion.requested-vel", type: "HAL_FLOAT", value: state.motion.requestedVel, connected: true }, + { kind: "pin", name: "motion.distance-to-go", type: "HAL_FLOAT", value: state.motion.distanceToGo, connected: true }, + { kind: "pin", name: "motion.program-line", type: "HAL_S32", value: state.motion.programLine, connected: true }, + { kind: "pin", name: "motion.probe-input", type: "HAL_BIT", value: state.motion.probeInput ? 1 : 0, connected: true }, + { kind: "pin", name: "motion.adaptive-feed", type: "HAL_FLOAT", value: state.motion.adaptiveFeed, connected: true }, + { kind: "pin", name: "motion.feed-hold", type: "HAL_BIT", value: state.motion.feedHold ? 1 : 0, connected: true }, + { kind: "pin", name: "motion.feed-inhibit", type: "HAL_BIT", value: state.motion.feedInhibit ? 1 : 0, connected: true }, + { kind: "pin", name: "motion.homing-inhibit", type: "HAL_BIT", value: state.motion.homingInhibit ? 1 : 0, connected: true }, + { kind: "pin", name: "motion.jog-inhibit", type: "HAL_BIT", value: state.motion.jogInhibit ? 1 : 0, connected: true }, + { kind: "pin", name: "motion.switchkins-type", type: "HAL_FLOAT", value: state.motion.switchkinsType, connected: true }, + { kind: "pin", name: "spindle.0.speed-out", type: "HAL_FLOAT", value: state.spindle.speed, connected: true }, + { kind: "pin", name: "spindle.0.speed-out-abs", type: "HAL_FLOAT", value: Math.abs(state.spindle.speed), connected: true }, + { kind: "pin", name: "spindle.0.on", type: "HAL_BIT", value: state.spindle.state === "on" ? 1 : 0, connected: true }, + { kind: "pin", name: "spindle.0.forward", type: "HAL_BIT", value: state.spindle.direction === "cw" ? 1 : 0, connected: true }, + { kind: "pin", name: "spindle.0.reverse", type: "HAL_BIT", value: state.spindle.direction === "ccw" ? 1 : 0, connected: true }, + { kind: "pin", name: "spindle.0.brake", type: "HAL_BIT", value: state.spindle.brake ? 1 : 0, connected: true }, + { kind: "pin", name: "spindle.0.at-speed", type: "HAL_BIT", value: state.spindle.state === "on" ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.spindle.0.is-on", type: "HAL_BIT", value: state.spindle.state === "on" ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.spindle.0.override.value", type: "HAL_FLOAT", value: state.overrides.spindle / 100, connected: true }, + { kind: "pin", name: "iocontrol.0.coolant-mist", type: "HAL_BIT", value: state.coolant.mist === "on" ? 1 : 0, connected: true }, + { kind: "pin", name: "iocontrol.0.coolant-flood", type: "HAL_BIT", value: state.coolant.flood === "on" ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.mist.is-on", type: "HAL_BIT", value: state.coolant.mist === "on" ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.flood.is-on", type: "HAL_BIT", value: state.coolant.flood === "on" ? 1 : 0, connected: true }, + { kind: "pin", name: "halui.feed-override.value", type: "HAL_FLOAT", value: state.overrides.feed / 100, connected: true }, + { kind: "pin", name: "halui.rapid-override.value", type: "HAL_FLOAT", value: state.overrides.rapid / 100, connected: true }, + { kind: "pin", name: "halui.tool.number", type: "HAL_U32", value: state.tool.current, connected: true }, + { kind: "pin", name: "halui.tool.diameter", type: "HAL_FLOAT", value: state.tool.diameter, connected: true }, + { kind: "pin", name: "iocontrol.0.tool-prep-pocket", type: "HAL_S32", value: state.tool.prepPocket, connected: true }, + { kind: "pin", name: "iocontrol.0.tool-from-pocket", type: "HAL_S32", value: state.tool.fromPocket, connected: true }, + { kind: "pin", name: "iocontrol.0.tool-prep-index", type: "HAL_S32", value: state.tool.prepIndex, connected: true }, + { kind: "pin", name: "iocontrol.0.tool-prep-number", type: "HAL_S32", value: state.tool.prepNumber || state.tool.selected, connected: true }, + { kind: "pin", name: "iocontrol.0.tool-number", type: "HAL_S32", value: state.tool.current, connected: true }, + { kind: "pin", name: "iocontrol.0.tool-prepare", type: "HAL_BIT", value: state.tool.prepare ? 1 : 0, connected: true }, + { kind: "pin", name: "iocontrol.0.tool-prepared", type: "HAL_BIT", value: state.tool.prepared ? 1 : 0, connected: true }, + { kind: "pin", name: "iocontrol.0.tool-change", type: "HAL_BIT", value: state.tool.change ? 1 : 0, connected: true }, + { kind: "pin", name: "iocontrol.0.tool-changed", type: "HAL_BIT", value: state.tool.changed ? 1 : 0, connected: true }, + ...Object.entries(state.position).flatMap(([axis, value], index) => [ + { kind: "pin", name: `axis.${axis}.teleop-pos-cmd`, type: "HAL_FLOAT", value, connected: true }, + { kind: "pin", name: `axis.${axis}.teleop-vel-cmd`, type: "HAL_FLOAT", value: 0, connected: true }, + { kind: "pin", name: `axis.${axis}.teleop-vel-lim`, type: "HAL_FLOAT", value: state.motion.requestedVel, connected: true }, + { kind: "pin", name: `axis.${axis}.teleop-tp-enable`, type: "HAL_BIT", value: state.task.powered ? 1 : 0, connected: true }, + { kind: "pin", name: `axis.${axis}.jog-enable`, type: "HAL_BIT", value: state.pins[`jog.${axis}`] ? 1 : 0, connected: true }, + { kind: "pin", name: `axis.${axis}.jog-scale`, type: "HAL_FLOAT", value: state.jog.incrementValue, connected: true }, + { kind: "pin", name: `axis.${axis}.jog-counts`, type: "HAL_S32", value: state.pins[`jog.${axis}`] ? 1 : 0, connected: true }, + { kind: "pin", name: `axis.${axis}.jog-vel-mode`, type: "HAL_BIT", value: state.jog.incrementValue === 0 ? 1 : 0, connected: true }, + { kind: "pin", name: `halui.axis.${axis}.pos-commanded`, type: "HAL_FLOAT", value, connected: true }, + { kind: "pin", name: `halui.axis.${axis}.pos-feedback`, type: "HAL_FLOAT", value, connected: true }, + { kind: "pin", name: `halui.axis.${axis}.pos-relative`, type: "HAL_FLOAT", value, connected: true }, + { kind: "pin", name: `halui.axis.${axis}.is-selected`, type: "HAL_BIT", value: state.activeAxis === axis ? 1 : 0, connected: true }, + { kind: "pin", name: `motion.tooloffset.${axis}`, type: "HAL_FLOAT", value: state.tool.lengthOffset[axis], connected: true }, + { kind: "pin", name: `halui.tool.length_offset.${axis}`, type: "HAL_FLOAT", value: state.tool.lengthOffset[axis], connected: true }, + { kind: "pin", name: `joint.${index}.pos-cmd`, type: "HAL_FLOAT", value, connected: true }, + { kind: "pin", name: `joint.${index}.coarse-pos-cmd`, type: "HAL_FLOAT", value, connected: true }, + { kind: "pin", name: `joint.${index}.pos-fb`, type: "HAL_FLOAT", value, connected: true }, + { kind: "pin", name: `joint.${index}.motor-pos-cmd`, type: "HAL_FLOAT", value, connected: true }, + { kind: "pin", name: `joint.${index}.motor-pos-fb`, type: "HAL_FLOAT", value, connected: true }, + { kind: "pin", name: `joint.${index}.amp-enable-out`, type: "HAL_BIT", value: state.task.powered ? 1 : 0, connected: true }, + { kind: "pin", name: `joint.${index}.homed`, type: "HAL_BIT", value: state.homed[axis] ? 1 : 0, connected: true }, + { kind: "pin", name: `joint.${index}.homing`, type: "HAL_BIT", value: 0, connected: true }, + { kind: "pin", name: `joint.${index}.home-state`, type: "HAL_S32", value: state.homed[axis] ? 1 : 0, connected: true }, + { kind: "pin", name: `joint.${index}.home-sw-in`, type: "HAL_BIT", value: state.homed[axis] ? 1 : 0, connected: true }, + { kind: "pin", name: `joint.${index}.pos-lim-sw-in`, type: "HAL_BIT", value: state.limits[axis]?.maxLimit ? 1 : 0, connected: true }, + { kind: "pin", name: `joint.${index}.neg-lim-sw-in`, type: "HAL_BIT", value: state.limits[axis]?.minLimit ? 1 : 0, connected: true }, + { kind: "pin", name: `joint.${index}.pos-hard-limit`, type: "HAL_BIT", value: state.limits[axis]?.maxLimit ? 1 : 0, connected: true }, + { kind: "pin", name: `joint.${index}.neg-hard-limit`, type: "HAL_BIT", value: state.limits[axis]?.minLimit ? 1 : 0, connected: true }, + { kind: "pin", name: `joint.${index}.error`, type: "HAL_BIT", value: state.limits[axis]?.fault === "none" ? 0 : 1, connected: true }, + { kind: "pin", name: `joint.${index}.faulted`, type: "HAL_BIT", value: state.limits[axis]?.fault === "none" ? 0 : 1, connected: true }, + { kind: "pin", name: `halui.joint.${index}.is-homed`, type: "HAL_BIT", value: state.homed[axis] ? 1 : 0, connected: true }, + ]), + ...Object.values(state.externalPins ?? {}).map((entry) => ({ + kind: entry.kind ?? "pin", + name: entry.name, + type: entry.type ?? "HAL_FLOAT", + value: Number(entry.value) || 0, + connected: entry.connected !== false, + })), + ...Object.values(state.hal.signals ?? {}).map((entry) => ({ + kind: "signal", + name: entry.name, + type: entry.type ?? "HAL_FLOAT", + value: normalizeHalNumericValue(entry.value), + connected: (entry.pins ?? []).length > 0, + })), + ...Object.entries(state.hal.params ?? {}).map(([name, entry]) => ({ + kind: "param", + name, + type: entry.type ?? "HAL_FLOAT", + value: normalizeHalNumericValue(entry.value), + connected: entry.connected !== false, + })), + ], + }; +} + +export function createVirtualHalPinRegistry(halState = createVirtualHalState()) { + const state = normalizeVirtualHalState(halState); + const snapshot = createVirtualHalWasmBridgeSnapshot(state); + const systemCoverage = createVirtualHalSystemCoverageReport({ halState: state, snapshot }); + const snapshotRows = snapshot.values.map((value) => ({ + id: `${value.kind}:${value.name}`, + kind: value.kind, + name: value.name, + type: value.type, + value: value.value, + connected: value.connected !== false, + group: classifyVirtualHalPinGroup(value.name), + coverage: resolveVirtualHalCoverageForPin(value.name), + writable: isVirtualHalPinWritable(value.name), + sourceFiles: resolveVirtualHalSourceFilesForPin(value.name), + representedInSnapshot: true, + })); + const snapshotNames = new Set(snapshotRows.map(({ name }) => name)); + const declaredRows = systemCoverage.declaredRows + .filter(({ name }) => !snapshotNames.has(name)) + .map((row) => ({ + id: `pin:${row.name}`, + kind: "pin", + name: row.name, + type: row.type, + value: resolveVirtualHalDeclaredPinValue(state, row.name, row.type), + connected: true, + group: classifyVirtualHalPinGroup(row.name), + coverage: row.coverage, + writable: isVirtualHalPinWritable(row.name), + sourceFiles: [...row.sourceFiles], + representedInSnapshot: false, + template: row.template, + conditional: row.conditional, + })); + const rows = [...snapshotRows, ...declaredRows]; + return { + apiName: "linuxcnc-wasm-virtual-hal-pin-registry", + registryVersion: 1, + source: "linuxcnc-source-derived-virtual-hal", + pinCount: rows.length, + snapshotPinCount: snapshotRows.length, + declaredOnlyPinCount: declaredRows.length, + groups: VIRTUAL_HAL_PROJECT_PIN_GROUPS.map((group) => ({ + ...group, + pinCount: rows.filter((row) => row.group === group.id).length, + })), + rows, + byName: Object.fromEntries(rows.map((row) => [row.name, row])), + }; +} + +export function readVirtualHalPin(halState, name) { + const registry = createVirtualHalPinRegistry(halState); + return registry.byName[String(name ?? "")] ?? null; +} + +export function writeVirtualHalPin(halState, update, options = {}) { + const name = typeof update === "string" ? update : update?.name; + if (!name) { + throw new Error("writeVirtualHalPin requires a HAL pin name"); + } + const next = normalizeVirtualHalState(halState); + const value = typeof update === "string" ? options.value : update.value; + const numeric = Number(value) || 0; + const enabled = value === true || numeric !== 0; + const shortAxisui = name.startsWith(`${next.component}.`) ? name.slice(next.component.length + 1) : name; + + if (shortAxisui in next.pins) { + next.pins[shortAxisui] = typeof next.pins[shortAxisui] === "number" ? numeric : enabled; + } else if (name === "iocontrol.0.emc-enable-in" || name === "halui.estop.reset") { + next.task.estop = !enabled; + } else if (name === "halui.estop.activate") { + next.task.estop = enabled; + if (enabled) { + next.task.powered = false; + } + } else if (name === "iocontrol.0.user-enable-out" || name === "halui.machine.is-on" || name === "halui.machine.on") { + next.task.powered = enabled && !next.task.estop; + } else if (name === "halui.machine.off") { + next.task.powered = enabled ? false : next.task.powered; + } else if (name === "halui.mode.manual") { + next.task.mode = enabled ? "manual" : next.task.mode; + } else if (name === "halui.mode.auto") { + next.task.mode = enabled ? "auto" : next.task.mode; + } else if (name === "halui.mode.mdi") { + next.task.mode = enabled ? "mdi" : next.task.mode; + } else if (name === "halui.program.run") { + next.program.state = enabled ? "running" : next.program.state; + } else if (name === "halui.program.pause") { + next.program.state = enabled ? "paused" : next.program.state; + } else if (name === "halui.program.resume") { + next.program.state = enabled ? "running" : next.program.state; + } else if (name === "halui.program.stop") { + next.program.state = enabled ? "idle" : next.program.state; + } else if (name === "halui.program.optional-stop.on") { + next.program.optionalStop = enabled; + } else if (name === "halui.program.optional-stop.off") { + next.program.optionalStop = enabled ? false : next.program.optionalStop; + } else if (name === "halui.program.block-delete.on") { + next.program.blockDelete = enabled; + } else if (name === "halui.program.block-delete.off") { + next.program.blockDelete = enabled ? false : next.program.blockDelete; + } else if (name === "iocontrol.0.coolant-mist" || name === "halui.mist.is-on" || name === "halui.mist.on") { + next.coolant.mist = enabled ? "on" : "off"; + } else if (name === "halui.mist.off") { + next.coolant.mist = enabled ? "off" : next.coolant.mist; + } else if (name === "iocontrol.0.coolant-flood" || name === "halui.flood.is-on" || name === "halui.flood.on") { + next.coolant.flood = enabled ? "on" : "off"; + } else if (name === "halui.flood.off") { + next.coolant.flood = enabled ? "off" : next.coolant.flood; + } else if (name === "spindle.0.speed-out" || name === "spindle.0.speed-out-abs") { + next.spindle.speed = numeric; + next.spindle.state = numeric > 0 ? "on" : next.spindle.state; + } else if (name === "spindle.0.on" || name === "halui.spindle.0.is-on" || name === "halui.spindle.0.start") { + next.spindle.state = enabled ? "on" : "off"; + } else if (name === "halui.spindle.0.stop") { + next.spindle.state = enabled ? "off" : next.spindle.state; + } else if (name === "spindle.0.forward" || name === "halui.spindle.0.forward") { + next.spindle.state = enabled ? "on" : next.spindle.state; + next.spindle.direction = enabled ? "cw" : next.spindle.direction; + } else if (name === "spindle.0.reverse" || name === "halui.spindle.0.reverse") { + next.spindle.state = enabled ? "on" : next.spindle.state; + next.spindle.direction = enabled ? "ccw" : next.spindle.direction; + } else if (name === "spindle.0.brake") { + next.spindle.brake = enabled; + } else if (name === "halui.feed-override.value") { + next.overrides.feed = numeric <= 2 ? numeric * 100 : numeric; + } else if (name === "halui.rapid-override.value") { + next.overrides.rapid = numeric <= 2 ? numeric * 100 : numeric; + } else if (name === "halui.spindle.0.override.value") { + next.overrides.spindle = numeric <= 2 ? numeric * 100 : numeric; + } else if (name === "halui.tool.number" || name === "iocontrol.0.tool-number") { + next.tool.current = numeric; + next.tool.selected = numeric; + } else if (name === "halui.tool.diameter") { + next.tool.diameter = numeric; + } else if (name === "iocontrol.0.tool-prepare") { + next.tool.prepare = enabled; + } else if (name === "iocontrol.0.tool-prepared") { + next.tool.prepared = enabled; + } else if (name === "iocontrol.0.tool-change") { + next.tool.change = enabled; + } else if (name === "iocontrol.0.tool-changed") { + next.tool.changed = enabled; + } else if (name === "motion.current-vel") { + next.motion.currentVel = numeric; + } else if (name === "motion.requested-vel") { + next.motion.requestedVel = numeric; + } else if (name === "motion.distance-to-go") { + next.motion.distanceToGo = numeric; + } else if (name === "motion.program-line") { + next.motion.programLine = numeric; + next.program.line = numeric; + } else if (name === "motion.probe-input") { + next.motion.probeInput = enabled; + } else if (name === "motion.adaptive-feed") { + next.motion.adaptiveFeed = numeric; + } else if (name === "motion.feed-hold") { + next.motion.feedHold = enabled; + } else if (name === "motion.switchkins-type") { + next.motion.switchkinsType = numeric; + } else if (applyAxisPinWrite(next, name, numeric, enabled) === false) { + if (options.allowExternal === false) { + throw new Error(`unknown virtual HAL pin: ${name}`); + } + next.externalPins[name] = { + kind: update.kind ?? "pin", + name, + type: update.type ?? inferVirtualHalPinType(value), + value: numeric, + connected: update.connected !== false, + }; + } + + next.revision += 1; + return normalizeVirtualHalState(next); +} + +export function applyVirtualHalPinUpdates(halState, updates = [], options = {}) { + return [...updates].reduce((state, update) => writeVirtualHalPin(state, update, options), halState); +} + +export function createVirtualHalIntegrityReport(options = {}) { + const state = normalizeVirtualHalState(options.halState ?? createVirtualHalState()); + const registry = options.registry ?? createVirtualHalPinRegistry(state); + const systemCoverage = options.systemCoverage ?? createVirtualHalSystemCoverageReport({ + halState: state, + snapshot: createVirtualHalWasmBridgeSnapshot(state), + }); + const requiredPins = options.requiredPins ?? [ + "axisui.jog.x", + "halui.machine.is-on", + "iocontrol.0.emc-enable-in", + "motion.current-vel", + "axis.x.pos-cmd", + "joint.0.homed", + "spindle.0.speed-out", + "iocontrol.0.tool-change", + ]; + const pinNames = registry.rows.map(({ name }) => name); + const duplicatePins = pinNames.filter((name, index) => pinNames.indexOf(name) !== index); + const missingPins = requiredPins.filter((name) => !registry.byName[name]); + const externalPinCount = Object.keys(state.externalPins ?? {}).length; + const complete = systemCoverage.complete && + duplicatePins.length === 0 && + missingPins.length === 0 && + registry.pinCount > 0; + return { + apiName: "linuxcnc-wasm-virtual-hal-integrity-report", + integrityVersion: 1, + source: "linuxcnc-source-derived-virtual-hal", + phase: complete ? "complete" : "incomplete", + complete, + realtimeHalRuntime: false, + registryPinCount: registry.pinCount, + declaredPinCount: systemCoverage.declaredPinCount, + requiredPins, + missingPins, + duplicatePins: [...new Set(duplicatePins)], + externalPinCount, + systemCoveragePhase: systemCoverage.phase, + rows: [ + { id: "registry", label: "Virtual HAL registry", value: `${registry.pinCount} pins` }, + { id: "coverage", label: "Source coverage", value: systemCoverage.phase }, + { id: "required-pins", label: "Required pins", value: missingPins.length === 0 ? "covered" : `missing ${missingPins.length}` }, + { id: "external-pins", label: "External pins", value: `${externalPinCount}` }, + ], + }; +} + +export function createVirtualHalPinInventory(halState = createVirtualHalState()) { + const state = normalizeVirtualHalState(halState); + const snapshot = createVirtualHalWasmBridgeSnapshot(state); + const systemCoverage = createVirtualHalSystemCoverageReport({ halState: state, snapshot }); + const rows = snapshot.values.map((value) => ({ + id: `${value.kind}:${value.name}`, + kind: value.kind, + name: value.name, + type: value.type, + connected: value.connected !== false, + group: classifyVirtualHalPinGroup(value.name), + coverage: resolveVirtualHalCoverageForPin(value.name), + })); + return { + apiName: "linuxcnc-wasm-virtual-hal-pin-inventory", + inventoryVersion: 1, + source: "linuxcnc-source-derived-virtual-hal", + component: state.component, + axisCount: VIRTUAL_HAL_AXES.length, + displayedAxisCount: VIRTUAL_HAL_DISPLAY_AXES.length, + pinCount: rows.length, + declaredPinCount: systemCoverage.declaredPinCount, + systemCoverageComplete: systemCoverage.complete, + groups: VIRTUAL_HAL_PROJECT_PIN_GROUPS.map((group) => ({ + ...group, + pinCount: rows.filter((row) => row.group === group.id).length, + })), + rows, + }; +} + +export function createVirtualHalBridgeReadiness(options = {}) { + const snapshot = options.snapshot ?? createVirtualHalWasmBridgeSnapshot(options.halState ?? createVirtualHalState()); + const systemCoverage = options.systemCoverage ?? createVirtualHalSystemCoverageReport({ + halState: options.halState ?? createVirtualHalState(), + snapshot, + }); + const requiredFunctions = options.requiredFunctions ?? VIRTUAL_HAL_WASM_BRIDGE_FUNCTIONS; + const availableFunctions = resolveAvailableVirtualHalFunctions(options, requiredFunctions); + const missingWasmFunctions = requiredFunctions.filter((name) => !availableFunctions.includes(name)); + const requiredPins = options.requiredPins ?? [ + "axisui.jog.x", + "axisui.jog.increment", + "halui.machine.is-on", + "halui.estop.is-activated", + "spindle.0.speed-out", + "iocontrol.0.coolant-flood", + ]; + const snapshotPins = new Set((snapshot.values ?? []).map(({ name }) => name)); + const missingPins = requiredPins.filter((name) => !snapshotPins.has(name)); + const missingCoverage = systemCoverage.complete ? [] : ["system-coverage"]; + const evidenceProvided = Boolean( + options.interpSdk || + options.hasWasmFunction || + options.availableFunctions || + options.availableWasmFunctions, + ); + const missing = [ + ...missingWasmFunctions.map((name) => `wasm-function:${name}`), + ...missingPins.map((name) => `pin:${name}`), + ...missingCoverage, + ]; + if (!evidenceProvided) { + missing.unshift("wasm-function-evidence"); + } + return { + apiName: "linuxcnc-wasm-virtual-hal-bridge-readiness", + readinessVersion: 1, + source: snapshot.source ?? "browser-virtual-hal", + phase: missing.length === 0 ? "ready" : "blocked", + ready: missing.length === 0, + evidenceProvided, + requiredFunctions: [...requiredFunctions], + availableFunctions, + missingWasmFunctions, + requiredPins, + missingPins, + snapshotValueCount: snapshot.values?.length ?? 0, + systemCoverageComplete: systemCoverage.complete, + systemCoveragePhase: systemCoverage.phase, + runtimeBoundaryCount: systemCoverage.boundaryRows?.length ?? 0, + missing, + rows: [ + { + id: "wasm-functions", + label: "WASM HAL bridge functions", + value: missingWasmFunctions.length === 0 && evidenceProvided ? "ready" : `missing ${missingWasmFunctions.length || "evidence"}`, + }, + { + id: "bridge-pins", + label: "Bridge pins", + value: missingPins.length === 0 ? `${snapshot.values?.length ?? 0} values` : `missing ${missingPins.length}`, + }, + { + id: "system-coverage", + label: "System HAL coverage", + value: systemCoverage.complete ? systemCoverage.phase : "incomplete", + }, + { + id: "source", + label: "Source", + value: systemCoverage.source ?? snapshot.source ?? "unknown", + }, + ], + }; +} + +export function createVirtualHalBridgeActionPlan(readiness = createVirtualHalBridgeReadiness()) { + const ready = readiness.ready === true; + const nextCommand = ready ? null : "wasm-port/tools/build_wasm_core.sh"; + return { + apiName: "linuxcnc-wasm-virtual-hal-bridge-action-plan", + actionPlanVersion: 1, + phase: ready ? "ready" : "blocked", + ready, + nextCommand, + missing: [...(readiness.missing ?? [])], + actions: ready + ? [] + : [ + { + id: "build-wasm-core", + label: "Build interpreter WASM with HAL bridge exports", + command: nextCommand, + }, + { + id: "verify-interp-wasm", + label: "Verify interpreter HAL bridge smoke", + command: "SKIP_INTERP_BUILD=1 wasm-port/tests/wasm/node/verify_interp_wasm.sh", + }, + ], + shellScript: ready + ? "# LinuxCNC virtual HAL bridge is already ready.\n" + : [ + "# Build and verify LinuxCNC virtual HAL bridge.", + "wasm-port/tools/build_wasm_core.sh", + "SKIP_INTERP_BUILD=1 wasm-port/tests/wasm/node/verify_interp_wasm.sh", + "", + ].join("\n"), + rows: [ + { + id: "readiness", + label: "Virtual HAL bridge", + value: ready ? "ready" : "blocked", + }, + { + id: "next-command", + label: "Next command", + value: nextCommand ?? "-", + }, + ], + }; +} + +export function createVirtualHalProjectReport(options = {}) { + const state = normalizeVirtualHalState(options.halState ?? createVirtualHalState()); + const snapshot = createVirtualHalWasmBridgeSnapshot(state); + const systemCoverage = createVirtualHalSystemCoverageReport({ + ...options, + halState: state, + snapshot, + }); + const readiness = createVirtualHalBridgeReadiness({ + ...options, + snapshot, + systemCoverage, + }); + const actionPlan = createVirtualHalBridgeActionPlan(readiness); + const pinRegistry = createVirtualHalPinRegistry(state); + const integrity = createVirtualHalIntegrityReport({ + halState: state, + registry: pinRegistry, + systemCoverage, + }); + return { + apiName: "linuxcnc-wasm-virtual-hal-project-report", + reportVersion: 1, + source: "browser-virtual-hal", + phase: readiness.ready && integrity.complete ? "ready" : "blocked", + ready: readiness.ready && integrity.complete, + missing: [...readiness.missing, ...integrity.missingPins.map((name) => `integrity-pin:${name}`)], + state: cloneVirtualHalState(state), + dro: createVirtualHalDroState(state), + limitsHome: createVirtualHalLimitsHomeState(state), + machineStatus: createVirtualHalMachineStatusState(state), + pinInventory: createVirtualHalPinInventory(state), + pinRegistry, + integrity, + systemCoverage, + simulationRuntime: createVirtualHalSimulationRuntimeReport(state), + wasmBridgeSnapshot: snapshot, + bridgeReadiness: readiness, + bridgeActionPlan: actionPlan, + rows: [ + { + id: "state", + label: "Virtual HAL state", + value: state.task.state, + }, + { + id: "pins", + label: "Bridge pins", + value: `${snapshot.values.length}`, + }, + { + id: "system-coverage", + label: "System coverage", + value: systemCoverage.phase, + }, + { + id: "integrity", + label: "HAL integrity", + value: integrity.phase, + }, + { + id: "bridge", + label: "WASM bridge", + value: readiness.ready && integrity.complete ? "ready" : "blocked", + }, + { + id: "simulation-runtime", + label: "Simulation HAL runtime", + value: "ready", + }, + ], + }; +} + +export function applyVirtualHalToInterpSdk(interpSdk, halState = createVirtualHalState(), options = {}) { + if (!interpSdk) { + throw new Error("applyVirtualHalToInterpSdk requires an interpreter SDK"); + } + if (typeof interpSdk.applyVirtualHalState === "function") { + return interpSdk.applyVirtualHalState(halState, options); + } + if (typeof interpSdk.applyVirtualHalSnapshot === "function") { + return interpSdk.applyVirtualHalSnapshot(createVirtualHalWasmBridgeSnapshot(halState), options); + } + throw new Error("interpreter SDK missing virtual HAL apply methods"); +} + +export function stepVirtualHalMotion(halState = createVirtualHalState(), options = {}) { + const next = normalizeVirtualHalState(halState); + const dt = clampNumber(options.dt ?? options.period ?? next.motion.servoPeriod, 0.000001, 1); + const maxVelocity = clampNumber(options.maxVelocity ?? next.motion.requestedVel ?? 1, 0.000001, 999999); + const target = { ...next.motion.target, ...(options.target ?? {}) }; + let totalMoved = 0; + let totalRemaining = 0; + + for (const axis of VIRTUAL_HAL_AXES) { + const current = next.position[axis]; + const goal = Number.isFinite(Number(target[axis])) ? Number(target[axis]) : current; + const delta = goal - current; + const step = Math.sign(delta) * Math.min(Math.abs(delta), maxVelocity * dt); + next.position[axis] = current + step; + next.motion.target[axis] = goal; + totalMoved += Math.abs(step); + totalRemaining += Math.abs(goal - next.position[axis]); + } + + next.motion.servoPeriod = dt; + next.motion.requestedVel = maxVelocity; + next.motion.currentVel = totalMoved / dt; + next.motion.distanceToGo = totalRemaining; + next.motion.inPosition = totalRemaining < 0.000001; + next.motion.motionType = next.motion.inPosition ? 0 : 1; + next.positionDirty = totalMoved > 0; + next.revision += 1; + return normalizeVirtualHalState(next); +} + +export function stepVirtualHalMotionController(halState = createVirtualHalState(), options = {}) { + const cycleCount = clampInteger(options.cycleCount ?? options.cycles ?? 1, 1, 100000); + let state = normalizeVirtualHalState(halState); + const frames = []; + for (let index = 0; index < cycleCount; index += 1) { + state = stepVirtualHalMotion(state, options); + frames.push({ + cycle: index + 1, + position: { ...state.position }, + motion: { ...state.motion, target: { ...state.motion.target } }, + }); + if (state.motion.inPosition && options.stopOnInPosition !== false) { + break; + } + } + return { + apiName: "linuxcnc-wasm-virtual-motion-controller-step", + stepVersion: 1, + source: "simulation-grade-virtual-motion-controller", + ok: true, + cycleCount: frames.length, + inPosition: state.motion.inPosition, + distanceToGo: state.motion.distanceToGo, + frames, + state: cloneVirtualHalState(state), + }; +} + +export function executeVirtualHalcmd(halState = createVirtualHalState(), commandText = "", options = {}) { + return executeVirtualHalCommand(halState, commandText, options); +} + +export function executeVirtualHalCommand(halState = createVirtualHalState(), commandText = "", options = {}) { + let state = normalizeVirtualHalState(halState); + const rows = []; + const output = []; + const commands = String(commandText) + .split(/\r?\n/) + .map((line) => line.replace(/#.*/, "").trim()) + .filter(Boolean); + + for (const command of commands) { + const tokens = command.split(/\s+/); + const verb = tokens[0]?.toLowerCase(); + try { + if (verb === "setp" && tokens.length >= 3) { + const name = tokens[1]; + const value = parseHalCommandValue(tokens.slice(2).join(" ")); + state = writeVirtualHalPin(state, { name, value, type: inferVirtualHalPinType(value) }); + syncVirtualHalSignalsFromPin(state, name, value); + rows.push({ command, action: "setp", name, value: normalizeHalNumericValue(value), ok: true }); + } else if ((verb === "sets" || verb === "newsig") && tokens.length >= 2) { + const name = tokens[1]; + const value = verb === "sets" ? parseHalCommandValue(tokens.slice(2).join(" ")) : 0; + state.hal.signals[name] = { + name, + type: inferVirtualHalPinType(value), + value: normalizeHalNumericValue(value), + pins: [...(state.hal.signals[name]?.pins ?? [])], + }; + state = propagateVirtualHalSignal(state, name); + rows.push({ command, action: verb, name, value: normalizeHalNumericValue(value), ok: true }); + } else if (verb === "net" && tokens.length >= 3) { + const signalName = tokens[1]; + const pinNames = tokens.slice(2).filter((token) => token !== "<=" && token !== "=>" && token !== "<=>" && token !== "="); + const existing = state.hal.signals[signalName] ?? { name: signalName, type: "HAL_FLOAT", value: 0, pins: [] }; + state.hal.signals[signalName] = { + ...existing, + pins: [...new Set([...(existing.pins ?? []), ...pinNames])], + }; + state.hal.nets[signalName] = [...state.hal.signals[signalName].pins]; + state = propagateVirtualHalSignal(state, signalName); + rows.push({ command, action: "net", name: signalName, pins: pinNames, ok: true }); + } else if ((verb === "getp" || verb === "gets") && tokens.length >= 2) { + const name = tokens[1]; + const value = verb === "gets" ? state.hal.signals[name]?.value : readVirtualHalPin(state, name)?.value; + output.push(`${name} ${normalizeHalNumericValue(value)}`); + rows.push({ command, action: verb, name, value: normalizeHalNumericValue(value), ok: true }); + } else if (verb === "show") { + const kind = tokens[1] ?? "pin"; + const pattern = tokens[2] ?? ""; + const showRows = createVirtualHalShowRows(state, kind, pattern); + output.push(...showRows.map((row) => `${row.kind} ${row.name} ${row.value}`)); + rows.push({ command, action: "show", kind, count: showRows.length, ok: true }); + } else if ((verb === "loadrt" || verb === "loadusr") && tokens.length >= 2) { + const component = tokens.slice(1).filter((token) => !token.startsWith("-")).join(" "); + state.hal.loadedComponents.push({ command: verb, component, realtime: verb === "loadrt" }); + rows.push({ command, action: verb, component, ok: true }); + } else if (verb === "addf" && tokens.length >= 3) { + state.hal.functions.push({ name: tokens[1], thread: tokens[2] }); + rows.push({ command, action: "addf", name: tokens[1], thread: tokens[2], ok: true }); + } else if ((verb === "start" || verb === "stop") && tokens.length >= 2) { + const thread = tokens[1]; + state.hal.threads[thread] = { + ...(state.hal.threads[thread] ?? { periodNs: 1000000 }), + running: verb === "start", + }; + rows.push({ command, action: verb, thread, ok: true }); + } else if (verb === "unlinkp" && tokens.length >= 2) { + const pin = tokens[1]; + for (const signal of Object.values(state.hal.signals)) { + signal.pins = (signal.pins ?? []).filter((name) => name !== pin); + } + for (const [signalName, pins] of Object.entries(state.hal.nets)) { + state.hal.nets[signalName] = pins.filter((name) => name !== pin); + } + rows.push({ command, action: "unlinkp", name: pin, ok: true }); + } else if (verb === "stepgen" || verb === "motion-step") { + const target = parseHalCommandTarget(tokens.slice(1)); + state = stepVirtualHalMotion(state, { ...options, target }); + rows.push({ command, action: "motion-step", target, ok: true }); + } else { + rows.push({ command, action: "unsupported", ok: false, error: `unsupported halcmd verb: ${verb}` }); + } + } catch (error) { + rows.push({ command, action: verb ?? "unknown", ok: false, error: error.message }); + } + } + + state.hal.commandLog = [ + ...(state.hal.commandLog ?? []), + ...rows.map((row) => ({ ...row, source: "virtual-halcmd" })), + ].slice(-200); + state.revision += rows.length > 0 ? 1 : 0; + const finalState = normalizeVirtualHalState(state); + return { + apiName: "linuxcnc-wasm-virtual-halcmd-result", + commandVersion: 1, + source: "simulation-grade-virtual-hal", + ok: rows.every((row) => row.ok), + commandCount: rows.length, + output: output.join("\n"), + rows, + state: cloneVirtualHalState(finalState), + }; +} + +export function createVirtualHalSimulationReplacementReport(halState = createVirtualHalState(), options = {}) { + const state = normalizeVirtualHalState(halState); + const runtime = createVirtualHalSimulationRuntimeReport(state, options); + const requiredCapabilities = options.requiredCapabilities ?? [ + "realtime-hal-simulation-replacement", + "halcmd-simulation-replacement", + "motion-controller-simulation-replacement", + "hal-pin-signal-param-store", + "halcmd-setp-sets-net-show-getp-gets", + "servo-period-motion-step", + "axis-joint-position-feedback", + ]; + const missingCapabilities = requiredCapabilities + .filter((capability) => !runtime.capabilities.includes(capability)); + const replacements = { + "linuxcnc-realtime-hal": { + ready: missingCapabilities.length === 0, + replacement: "virtual-hal-pin-signal-param-store", + evidence: ["pin registry", "signal/net store", "thread/function registry"], + }, + halcmd: { + ready: runtime.capabilities.includes("halcmd-setp-sets-net-show-getp-gets"), + replacement: "executeVirtualHalcmd / executeVirtualHalCommand", + evidence: ["setp", "sets", "newsig", "net", "show", "getp", "gets", "loadrt", "loadusr", "addf", "start", "stop"], + }, + "motion-controller": { + ready: runtime.capabilities.includes("servo-period-motion-step"), + replacement: "stepVirtualHalMotionController / stepVirtualHalMotion", + evidence: ["servo period", "target position", "velocity limit", "distance-to-go", "in-position"], + }, + }; + const ready = missingCapabilities.length === 0 && + Object.values(replacements).every((item) => item.ready === true); + return { + apiName: "linuxcnc-wasm-virtual-hal-simulation-replacement-report", + replacementVersion: 1, + source: "simulation-grade-virtual-hal", + phase: ready ? "ready" : "blocked", + ready, + replacesHostRuntimeForSimulation: true, + replacementTargets: [...VIRTUAL_HAL_SIMULATION_REPLACEMENT_TARGETS], + hardRealtime: false, + realtimeKernelAbi: false, + requiredCapabilities, + missingCapabilities, + replacements, + runtime, + rows: [ + { id: "realtime-hal", label: "LinuxCNC realtime HAL", value: replacements["linuxcnc-realtime-hal"].ready ? "virtual replacement ready" : "blocked" }, + { id: "halcmd", label: "halcmd", value: replacements.halcmd.ready ? "virtual replacement ready" : "blocked" }, + { id: "motion-controller", label: "motion controller", value: replacements["motion-controller"].ready ? "virtual replacement ready" : "blocked" }, + { id: "hard-realtime", label: "Hard realtime", value: "not required for simulation" }, + ], + note: options.note ?? "Complete simulation replacement for browser/project workflows; not a Linux kernel realtime ABI or external hardware driver runtime.", + }; +} + +export function createVirtualHalSimulationRuntimeReport(halState = createVirtualHalState(), options = {}) { + const state = normalizeVirtualHalState(halState); + const snapshot = createVirtualHalWasmBridgeSnapshot(state); + return { + apiName: "linuxcnc-wasm-virtual-hal-simulation-runtime-report", + reportVersion: 1, + source: "simulation-grade-virtual-hal", + ready: true, + replacesHostRuntimeForSimulation: true, + replacementTargets: [...VIRTUAL_HAL_SIMULATION_REPLACEMENT_TARGETS], + hardRealtime: false, + hostRequirements: [], + unsupportedHostRequirements: ["kernel-realtime-scheduling", "external-device-drivers", "linuxcnc-realtime-module-abi"], + capabilities: [...VIRTUAL_HAL_SIMULATION_RUNTIME_CAPABILITIES], + pinCount: snapshot.values.filter(({ kind }) => kind === "pin").length, + signalCount: Object.keys(state.hal.signals ?? {}).length, + paramCount: Object.keys(state.hal.params ?? {}).length, + netCount: Object.keys(state.hal.nets ?? {}).length, + functionCount: state.hal.functions.length, + threadCount: Object.keys(state.hal.threads ?? {}).length, + commandCount: state.hal.commandLog.length, + motion: { + enabled: state.motion.enabled, + inPosition: state.motion.inPosition, + distanceToGo: state.motion.distanceToGo, + currentVel: state.motion.currentVel, + requestedVel: state.motion.requestedVel, + servoPeriod: state.motion.servoPeriod, + }, + rows: [ + { id: "runtime", label: "Virtual HAL runtime", value: "ready" }, + { id: "host-requirements", label: "Host HAL requirements", value: "none for simulation" }, + { id: "hard-realtime", label: "Hard realtime", value: "no" }, + { id: "halcmd", label: "Virtual halcmd", value: "setp/sets/net/show/getp/gets/load/addf/start/stop" }, + { id: "motion-controller", label: "Virtual motion controller", value: "servo-period target stepping" }, + { id: "motion", label: "Motion step", value: state.motion.inPosition ? "in-position" : "moving" }, + ], + note: options.note ?? "Simulation-grade replacement for browser/project workflows; not a Linux kernel realtime ABI.", + }; +} + +export function createLinuxCncVirtualHalRuntime(initialState = createVirtualHalState()) { + let state = normalizeVirtualHalState(initialState); + return { + getState() { + return cloneVirtualHalState(state); + }, + dispatch(action, options = {}) { + state = applyVirtualHalAction(state, action, options); + return cloneVirtualHalState(state); + }, + getDroState() { + return createVirtualHalDroState(state); + }, + getLimitsHomeState() { + return createVirtualHalLimitsHomeState(state); + }, + getMachineStatusState() { + return createVirtualHalMachineStatusState(state); + }, + getWasmBridgeSnapshot() { + return createVirtualHalWasmBridgeSnapshot(state); + }, + getPinRegistry() { + return createVirtualHalPinRegistry(state); + }, + readPin(name) { + return readVirtualHalPin(state, name); + }, + writePin(update, options = {}) { + state = writeVirtualHalPin(state, update, options); + return cloneVirtualHalState(state); + }, + applyPinUpdates(updates, options = {}) { + state = applyVirtualHalPinUpdates(state, updates, options); + return cloneVirtualHalState(state); + }, + executeHalCommand(commandText, options = {}) { + const result = executeVirtualHalCommand(state, commandText, options); + state = result.state; + return result; + }, + executeHalcmd(commandText, options = {}) { + const result = executeVirtualHalcmd(state, commandText, options); + state = result.state; + return result; + }, + stepMotion(options = {}) { + state = stepVirtualHalMotion(state, options); + return cloneVirtualHalState(state); + }, + stepMotionController(options = {}) { + const result = stepVirtualHalMotionController(state, options); + state = result.state; + return result; + }, + getSimulationRuntimeReport(options = {}) { + return createVirtualHalSimulationRuntimeReport(state, options); + }, + getSimulationReplacementReport(options = {}) { + return createVirtualHalSimulationReplacementReport(state, options); + }, + getIntegrityReport(options = {}) { + return createVirtualHalIntegrityReport({ ...options, halState: state }); + }, + getPinInventory() { + return createVirtualHalPinInventory(state); + }, + getProjectReport(options = {}) { + return createVirtualHalProjectReport({ ...options, halState: state }); + }, + applyToInterpSdk(interpSdk, options = {}) { + return applyVirtualHalToInterpSdk(interpSdk, state, options); + }, + }; +} + +function parseHalCommandValue(text) { + const value = String(text ?? "0").trim(); + if (/^(true|on|yes)$/i.test(value)) { + return true; + } + if (/^(false|off|no)$/i.test(value)) { + return false; + } + return Number(value) || 0; +} + +function parseHalCommandTarget(tokens) { + const target = {}; + for (const token of tokens) { + const [rawName, rawValue] = token.split("="); + const name = rawName?.toLowerCase(); + const value = Number(rawValue); + if (VIRTUAL_HAL_AXES.includes(name) && Number.isFinite(value)) { + target[name] = value; + } + } + return target; +} + +function syncVirtualHalSignalsFromPin(state, pinName, value) { + for (const signal of Object.values(state.hal.signals ?? {})) { + if ((signal.pins ?? []).includes(pinName)) { + signal.value = normalizeHalNumericValue(value); + } + } + return state; +} + +function propagateVirtualHalSignal(state, signalName) { + const signal = state.hal.signals?.[signalName]; + if (!signal) { + return state; + } + let next = state; + for (const pinName of signal.pins ?? []) { + next = writeVirtualHalPin(next, { + name: pinName, + type: signal.type ?? "HAL_FLOAT", + value: signal.value, + }); + } + next.hal.signals[signalName] = { + ...signal, + value: normalizeHalNumericValue(signal.value), + pins: [...new Set(signal.pins ?? [])], + }; + next.hal.nets[signalName] = [...next.hal.signals[signalName].pins]; + return next; +} + +function createVirtualHalShowRows(state, kind = "pin", pattern = "") { + const matcher = pattern ? new RegExp(pattern.replaceAll("*", ".*"), "i") : null; + const matches = (name) => !matcher || matcher.test(name); + if (kind === "sig" || kind === "signal") { + return Object.values(state.hal.signals ?? {}) + .filter(({ name }) => matches(name)) + .map((signal) => ({ + kind: "signal", + name: signal.name, + type: signal.type ?? "HAL_FLOAT", + value: normalizeHalNumericValue(signal.value), + })); + } + if (kind === "param" || kind === "parameter") { + return Object.entries(state.hal.params ?? {}) + .filter(([name]) => matches(name)) + .map(([name, entry]) => ({ + kind: "param", + name, + type: entry.type ?? "HAL_FLOAT", + value: normalizeHalNumericValue(entry.value), + })); + } + return createVirtualHalWasmBridgeSnapshot(state).values + .filter(({ kind: rowKind, name }) => rowKind === "pin" && matches(name)) + .map((row) => ({ + kind: "pin", + name: row.name, + type: row.type, + value: normalizeHalNumericValue(row.value), + })); +} + +function resolveAvailableVirtualHalFunctions(options, requiredFunctions) { + if (Array.isArray(options.availableFunctions)) { + return options.availableFunctions.filter((name) => requiredFunctions.includes(name)); + } + if (Array.isArray(options.availableWasmFunctions)) { + return options.availableWasmFunctions.filter((name) => requiredFunctions.includes(name)); + } + const hasWasmFunction = options.hasWasmFunction ?? options.interpSdk?.hasWasmFunction; + if (typeof hasWasmFunction === "function") { + return requiredFunctions.filter((name) => hasWasmFunction.call(options.interpSdk, name)); + } + return []; +} + +function expandVirtualHalFamilyPins(family) { + return (family.pins ?? []).flatMap((pin) => { + if (pin.axes) { + return VIRTUAL_HAL_AXES.map((axis) => ({ + ...pin, + name: pin.name.replace("{axis}", axis), + })); + } + if (pin.joints) { + return VIRTUAL_HAL_AXES.map((axis, index) => ({ + ...pin, + axis, + name: pin.name.replace("{joint}", `${index}`), + })); + } + return [{ ...pin }]; + }); +} + +function isInternalAxisUiPinName(name) { + return name.startsWith("jog.") || + name.startsWith("notifications-") || + name === "resume-inhibit" || + name === "error" || + name === "abort" || + /^axis\.[xyzabcuvw]\.homed$/.test(name); +} + +function resolveVirtualHalCoverageForPin(name) { + const family = VIRTUAL_HAL_SYSTEM_PIN_FAMILIES.find((item) => ( + expandVirtualHalFamilyPins(item).some((pin) => pin.name === name) || + (name.startsWith("jog.") && item.id === "axisui") + )); + return family?.coverage ?? VIRTUAL_HAL_COVERAGE_STATES.bridgeOnly; +} + +function resolveVirtualHalSourceFilesForPin(name) { + const family = VIRTUAL_HAL_SYSTEM_PIN_FAMILIES.find((item) => ( + expandVirtualHalFamilyPins(item).some((pin) => pin.name === name) || + (name.startsWith("jog.") && item.id === "axisui") + )); + return family ? [...family.sourceFiles] : []; +} + +function isVirtualHalPinWritable(name) { + if (name.startsWith("jog.") || name.startsWith("axisui.jog.")) { + return true; + } + if (name.startsWith("halui.") || name.startsWith("iocontrol.") || name.startsWith("motion.")) { + return true; + } + if (/^(axis|joint)\./.test(name) || name.startsWith("spindle.")) { + return true; + } + return false; +} + +function inferVirtualHalPinType(value) { + return typeof value === "boolean" ? "HAL_BIT" : "HAL_FLOAT"; +} + +function resolveVirtualHalDeclaredPinValue(state, name, type) { + const existing = readVirtualHalPinFromSnapshot(state, name); + if (existing) { + return existing.value; + } + if (name === "halui.machine.on") { + return state.task.powered ? 1 : 0; + } + if (name === "halui.machine.off") { + return state.task.powered ? 0 : 1; + } + if (name === "halui.estop.activate") { + return state.task.estop ? 1 : 0; + } + if (name === "halui.estop.reset") { + return state.task.estop ? 0 : 1; + } + if (name === "halui.mode.manual") { + return state.task.mode === "manual" ? 1 : 0; + } + if (name === "halui.mode.auto") { + return state.task.mode === "auto" ? 1 : 0; + } + if (name === "halui.mode.mdi") { + return state.task.mode === "mdi" ? 1 : 0; + } + if (name === "halui.program.run") { + return state.program.state === "running" ? 1 : 0; + } + if (name === "halui.program.pause") { + return state.program.state === "paused" ? 1 : 0; + } + if (name === "halui.program.resume") { + return state.program.state === "running" ? 1 : 0; + } + if (name === "halui.program.stop") { + return state.program.state === "idle" ? 1 : 0; + } + if (name.endsWith(".optional-stop.on")) { + return state.program.optionalStop ? 1 : 0; + } + if (name.endsWith(".optional-stop.off")) { + return state.program.optionalStop ? 0 : 1; + } + if (name.endsWith(".block-delete.on")) { + return state.program.blockDelete ? 1 : 0; + } + if (name.endsWith(".block-delete.off")) { + return state.program.blockDelete ? 0 : 1; + } + if (name === "halui.mist.on") { + return state.coolant.mist === "on" ? 1 : 0; + } + if (name === "halui.mist.off") { + return state.coolant.mist === "on" ? 0 : 1; + } + if (name === "halui.flood.on") { + return state.coolant.flood === "on" ? 1 : 0; + } + if (name === "halui.flood.off") { + return state.coolant.flood === "on" ? 0 : 1; + } + if (name === "halui.spindle.0.start") { + return state.spindle.state === "on" ? 1 : 0; + } + if (name === "halui.spindle.0.stop") { + return state.spindle.state === "on" ? 0 : 1; + } + if (name === "halui.spindle.0.forward") { + return state.spindle.direction === "cw" ? 1 : 0; + } + if (name === "halui.spindle.0.reverse") { + return state.spindle.direction === "ccw" ? 1 : 0; + } + if (name === "halui.spindle.0.brake-on") { + return state.spindle.brake ? 1 : 0; + } + if (name === "halui.spindle.0.brake-off") { + return state.spindle.brake ? 0 : 1; + } + const axisMatch = name.match(/^halui\.axis\.([xyzabcuvw])\.(select|plus|minus|analog|increment)$/); + if (axisMatch) { + if (axisMatch[2] === "select") { + return state.activeAxis === axisMatch[1] ? 1 : 0; + } + if (axisMatch[2] === "increment") { + return state.jog.incrementValue; + } + return state.pins[`jog.${axisMatch[1]}`] ? 1 : 0; + } + const jointMatch = name.match(/^halui\.joint\.(\d+)\.(home|unhome|plus|minus)$/); + if (jointMatch) { + const axis = VIRTUAL_HAL_AXES[Number(jointMatch[1])]; + if (jointMatch[2] === "home") { + return axis && state.homed[axis] ? 1 : 0; + } + if (jointMatch[2] === "unhome") { + return axis && state.homed[axis] ? 0 : 1; + } + return 0; + } + return type === "HAL_FLOAT" ? 0 : 0; +} + +function readVirtualHalPinFromSnapshot(state, name) { + return createVirtualHalWasmBridgeSnapshot(state).values.find((value) => value.name === name) ?? null; +} + +function applyAxisPinWrite(state, name, numeric, enabled) { + const axisMatch = name.match(/^(axis|halui\.axis)\.([xyzabcuvw])\.(.+)$/); + if (axisMatch) { + const axis = axisMatch[2]; + const suffix = axisMatch[3]; + if (["pos-cmd", "teleop-pos-cmd", "pos-commanded", "pos-feedback", "pos-relative"].includes(suffix)) { + state.position[axis] = numeric; + state.positionDirty = true; + return true; + } + if (suffix === "is-selected" || suffix === "select") { + if (enabled) { + state.activeAxis = axis; + } + return true; + } + if (suffix === "jog-enable" || suffix === "plus" || suffix === "minus") { + state.pins[`jog.${axis}`] = enabled; + return true; + } + if (suffix === "jog-scale" || suffix === "increment") { + state.jog.increment = `${numeric}`; + state.jog.incrementValue = numeric; + return true; + } + if (suffix === "eoffset" || suffix === "eoffset-request") { + state.position[axis] += numeric; + state.positionDirty = true; + return true; + } + } + + const jointMatch = name.match(/^joint\.(\d+)\.(.+)$/); + if (jointMatch) { + const index = Number(jointMatch[1]); + const axis = VIRTUAL_HAL_AXES[index]; + const suffix = jointMatch[2]; + if (!axis) { + return false; + } + if (["pos-cmd", "pos-fb", "coarse-pos-cmd", "motor-pos-cmd", "motor-pos-fb"].includes(suffix)) { + state.position[axis] = numeric; + state.positionDirty = true; + return true; + } + if (suffix === "homed" || suffix === "home-sw-in") { + state.homed[axis] = enabled; + return true; + } + if (suffix === "pos-lim-sw-in" || suffix === "pos-hard-limit") { + state.limits[axis].maxLimit = enabled; + return true; + } + if (suffix === "neg-lim-sw-in" || suffix === "neg-hard-limit") { + state.limits[axis].minLimit = enabled; + return true; + } + if (suffix === "faulted" || suffix === "error" || suffix === "amp-fault-in") { + state.limits[axis].fault = enabled ? suffix : "none"; + return true; + } + } + + const toolOffsetMatch = name.match(/^(motion\.tooloffset|halui\.tool\.length_offset)\.([xyzabcuvw])$/); + if (toolOffsetMatch) { + state.tool.lengthOffset[toolOffsetMatch[2]] = numeric; + return true; + } + + return false; +} + +function classifyVirtualHalPinGroup(name) { + if (name.startsWith("axisui.") || name.startsWith("jog.") || name.startsWith("notifications-") || name === "error" || name === "abort") { + return "axisui"; + } + if (name.startsWith("axis.") || name.startsWith("joint.") || name.startsWith("motion.")) { + return "axis-motion"; + } + if (name.startsWith("spindle.") || name.startsWith("halui.spindle.") || name.includes("coolant") || name.startsWith("halui.mist.") || name.startsWith("halui.flood.")) { + return "spindle-coolant"; + } + if (name.includes(".tool") || name.startsWith("motion.tooloffset.")) { + return "tool"; + } + return "task"; +} + +function formatAxis(value) { + return Number.isFinite(value) ? Number(value).toFixed(3) : "0.000"; +} diff --git a/wasm-port/runtime/sdk/src/linuxcnc-interp.js b/wasm-port/runtime/sdk/src/linuxcnc-interp.js index 3661ba5..ce3645d 100644 --- a/wasm-port/runtime/sdk/src/linuxcnc-interp.js +++ b/wasm-port/runtime/sdk/src/linuxcnc-interp.js @@ -1,4 +1,5 @@ import createLinuxCncInterpModule from "../../../build/wasm/core/linuxcnc_interp.js"; +import { createVirtualHalWasmBridgeSnapshot } from "./linuxcnc-hal.js"; function allocCString(mod, value) { const bytes = mod.lengthBytesUTF8(value) + 1; @@ -43,12 +44,36 @@ function callStringResult(mod, functionName, ...values) { } } +function requireWasmFunction(mod, functionName) { + const fn = mod[`_${functionName}`]; + if (typeof fn !== "function") { + throw new Error(`linuxcnc interpreter WASM missing ${functionName}; rebuild wasm-port/tools/build_wasm_core.sh`); + } + return fn; +} + +function callVoidWithStringsAndNumbers(mod, functionName, strings, numbers = []) { + const fn = requireWasmFunction(mod, functionName); + const ptrs = strings.map((value) => allocCString(mod, value)); + try { + return fn(...ptrs, ...numbers); + } finally { + for (const ptr of ptrs) { + mod._free(ptr); + } + } +} + export async function createLinuxCncInterpSdk(moduleOptions = {}) { const mod = await createLinuxCncInterpModule(moduleOptions); return { module: mod, + hasWasmFunction(functionName) { + return typeof mod[`_${functionName}`] === "function"; + }, + writeTextFile(path, text) { ensureParentPath(mod, path); mod.FS.writeFile(path, text, { encoding: "utf8" }); @@ -160,5 +185,44 @@ export async function createLinuxCncInterpSdk(moduleOptions = {}) { probeNamedParameters(iniPath) { return callStringResult(mod, "lcinterp_probe_named_parameters", iniPath); }, + + resetHal() { + requireWasmFunction(mod, "lcinterp_hal_reset")(); + }, + + setHalValue({ kind = "pin", name, type = "HAL_FLOAT", value = 0, connected = true }) { + if (!name) { + throw new Error("setHalValue requires a HAL name"); + } + return callVoidWithStringsAndNumbers( + mod, + "lcinterp_hal_set_value", + [kind, name, type], + [Number(value) || 0, connected === false ? 0 : 1], + ); + }, + + applyVirtualHalSnapshot(snapshot, options = {}) { + if (options.reset !== false) { + this.resetHal(); + } + for (const value of snapshot?.values ?? []) { + this.setHalValue(value); + } + return { + apiName: "linuxcnc-wasm-hal-apply-result", + applied: snapshot?.values?.length ?? 0, + reset: options.reset !== false, + source: snapshot?.source ?? "unknown", + }; + }, + + applyVirtualHalState(halState, options = {}) { + return this.applyVirtualHalSnapshot(createVirtualHalWasmBridgeSnapshot(halState), options); + }, + + probeHalNamed(name) { + return callStringResult(mod, "lcinterp_probe_hal_named", name); + }, }; } diff --git a/wasm-port/runtime/ui/simulation/index.html b/wasm-port/runtime/ui/simulation/index.html index 5223fa5..f9f59eb 100644 --- a/wasm-port/runtime/ui/simulation/index.html +++ b/wasm-port/runtime/ui/simulation/index.html @@ -27,20 +27,38 @@ font-family: "Segoe UI", "Noto Sans", Arial, sans-serif; color: var(--text); background: var(--page); + overflow: auto; } main { display: grid; - grid-template-rows: auto auto minmax(0, 1fr) 164px auto; - min-height: 100vh; + grid-template-columns: 330px 1fr; + grid-template-rows: 31px 27px 64px 474px 147px 25px; + grid-template-areas: + "titlebar titlebar" + "menubar menubar" + "toolbar toolbar" + "workspace workspace" + "gcode gcode" + "statusbar statusbar"; + width: 1024px; + height: 768px; + min-width: 1024px; + min-height: 768px; + margin: 0 auto; + overflow: hidden; + border-left: 1px solid #7d858b; + border-right: 1px solid #7d858b; + background: var(--surface); } .axis-titlebar { + grid-area: titlebar; border-bottom: 1px solid #777; background: #d9d9d9; padding: 4px 8px; border-bottom: 1px solid var(--line); display: grid; - grid-template-columns: auto minmax(0, 1fr) auto; - gap: 10px; + grid-template-columns: 24px minmax(0, 1fr) auto; + gap: 8px; align-items: center; } .axis-mark { @@ -54,6 +72,11 @@ font-weight: 800; font-size: 18px; } + .axis-mark img { + width: 20px; + height: 20px; + display: block; + } h1 { margin: 0; font-size: 13px; @@ -68,49 +91,133 @@ font-size: 14px; } .axis-menubar { + grid-area: menubar; display: flex; - gap: 18px; + gap: 0; align-items: center; border-bottom: 1px solid var(--line); background: #e4e4e4; - padding: 4px 8px; + padding: 0 6px; font-size: 14px; } - .axis-menubar span { + .axis-menu { + position: relative; + min-height: 26px; + display: flex; + align-items: center; + } + .axis-menu > button { + border: 0; + background: transparent; + min-height: 26px; + padding: 4px 10px; + font: 14px/1.2 "Segoe UI", sans-serif; + } + .axis-menu > button:hover, + .axis-menu:focus-within > button, + .axis-menu:hover > button { + background: #cfd8df; + outline: 1px solid #7f878d; + } + .axis-menu > button span { text-decoration: underline; text-underline-offset: 2px; } + .axis-menu-list { + position: absolute; + left: 0; + top: 100%; + z-index: 20; + min-width: 226px; + border: 1px solid #6f777d; + background: #eeeeee; + box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.22); + padding: 3px 0; + display: none; + } + .axis-menu:hover .axis-menu-list, + .axis-menu:focus-within .axis-menu-list { + display: grid; + } + .axis-menu-list button, + .axis-menu-list label { + min-height: 23px; + border: 0; + background: transparent; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + padding: 3px 20px 3px 22px; + color: var(--text); + text-align: left; + font: 13px/1.2 "Segoe UI", sans-serif; + white-space: nowrap; + } + .axis-menu-list label { + cursor: pointer; + } + .axis-menu-list button:hover, + .axis-menu-list label:hover { + background: #1263a3; + color: #ffffff; + } + .axis-menu-list input { + margin: 0 5px 0 -16px; + } + .axis-menu-separator { + height: 1px; + margin: 3px 0; + background: #a8aeb3; + border-bottom: 1px solid #f8f8f8; + } .axis-toolbar { + grid-area: toolbar; border-bottom: 1px solid var(--line); background: #d2d2d2; padding: 4px 8px; display: flex; align-items: center; - gap: 5px; + gap: 4px; flex-wrap: wrap; + align-content: start; + overflow: hidden; + } + .axis-toolbar .program-controls select { + width: 164px; } .toolbar-group { display: flex; align-items: center; - gap: 3px; - padding-right: 5px; - border-right: 1px solid #a5abb0; + gap: 2px; + padding-right: 4px; + border-right: 1px solid #9a9a9a; + } + .toolbar-separator { + width: 1px; + height: 25px; + background: #999; + border-right: 1px solid #eeeeee; + margin: 0 3px; } .status-strip { display: flex; gap: 8px; align-items: center; - flex-wrap: wrap; + flex-wrap: nowrap; margin-left: auto; + min-width: 0; + overflow: hidden; } .run-control-state { - flex-basis: 100%; border: 1px solid var(--line); background: #e7e7e7; padding: 4px 7px; color: var(--muted); font: 12px/1.3 "SFMono-Regular", "Consolas", monospace; - overflow-wrap: anywhere; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .run-control-state[data-run-control-phase="ready"] { color: var(--green); @@ -133,7 +240,7 @@ } .program-controls { display: flex; - gap: 8px; + gap: 4px; align-items: center; min-width: 0; } @@ -146,9 +253,9 @@ min-height: 26px; } select { - min-width: 220px; - max-width: 320px; - padding: 5px 8px; + min-width: 84px; + max-width: 220px; + padding: 2px 4px; } button { padding: 4px 8px; @@ -168,7 +275,7 @@ } .playback-controls { display: flex; - gap: 8px; + gap: 2px; align-items: center; flex-wrap: wrap; } @@ -176,20 +283,15 @@ flex: 1 1 auto; } .playback-readout { + display: inline-flex; + align-items: center; + gap: 4px; color: var(--muted); font: 12px/1.3 "SFMono-Regular", "Consolas", monospace; white-space: nowrap; } .axis-status-led { - border: 1px solid var(--line); - background: #e7e7e7; - padding: 4px 6px; - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 5px; - align-items: center; - color: var(--muted); - font: 12px/1.3 "SFMono-Regular", "Consolas", monospace; + display: none; } .axis-status-led::before { content: ""; @@ -206,11 +308,12 @@ background: var(--warn); } .axis-workspace { + grid-area: workspace; display: grid; - grid-template-columns: 326px minmax(460px, 1fr); + grid-template-columns: 326px 1fr; min-height: 0; - gap: 6px; - padding: 6px; + gap: 4px; + padding: 4px 6px 4px 6px; background: var(--surface); } section { @@ -272,10 +375,10 @@ } .toolpath-wrap { min-height: 0; - padding: 4px; + padding: 0; display: grid; - grid-template-rows: auto minmax(0, 1fr); - gap: 4px; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: 0; } .preview-controls { display: flex; @@ -283,6 +386,9 @@ gap: 5px; flex-wrap: wrap; font-size: 12px; + padding: 3px 4px; + border-bottom: 1px solid var(--line); + background: #d8d8d8; } .preview-controls span { color: var(--muted); @@ -308,7 +414,7 @@ svg { width: 100%; height: 100%; - min-height: 390px; + min-height: 0; border: 1px solid var(--line); background: var(--black); } @@ -323,7 +429,7 @@ .toolpath-three { width: 100%; height: 100%; - min-height: 390px; + min-height: 0; border: 1px solid var(--line); background: var(--black); display: block; @@ -335,7 +441,7 @@ } .toolpath-stage { position: relative; - min-height: 390px; + min-height: 0; display: grid; } .toolpath-stage .toolpath-three { @@ -380,31 +486,36 @@ } .preview-hud { position: absolute; - left: 8px; - top: 8px; + left: 34px; + top: 10px; z-index: 2; display: grid; - gap: 3px; - min-width: 210px; - max-width: min(360px, calc(100% - 16px)); - border: 1px solid #5a666f; - background: rgba(10, 15, 18, 0.84); - color: #dbe7ef; - padding: 6px 7px; - font: 11px/1.25 "SFMono-Regular", "Consolas", monospace; + gap: 0; + min-width: 300px; + max-width: 360px; + border: 0; + background: transparent; + color: #ffffff; + padding: 0; + font: 14px/1.24 "Courier New", "SFMono-Regular", monospace; pointer-events: none; + text-shadow: 1px 1px 0 #000; } .preview-hud div { display: grid; - grid-template-columns: 76px minmax(0, 1fr); - gap: 7px; + grid-template-columns: 76px 90px 68px 90px; + gap: 2px; min-width: 0; } - .preview-hud span:first-child { - color: #92a3ad; + .preview-hud span:nth-child(odd) { + color: #ffffff; + text-align: right; + padding-right: 8px; } - .preview-hud span:last-child { - overflow-wrap: anywhere; + .preview-hud span:nth-child(even) { + overflow: hidden; + text-align: right; + font-variant-numeric: tabular-nums; } .grid-line { stroke: #7f1e1e; @@ -426,15 +537,13 @@ stroke-width: 0.018; } .preview-legend { - border: 1px solid var(--line); - border-top: 0; - background: #202629; - color: #dbe7ef; display: flex; gap: 10px; flex-wrap: wrap; padding: 4px 6px; font: 11px/1.25 "SFMono-Regular", "Consolas", monospace; + border-top: 1px solid var(--line); + background: #d8d8d8; } .preview-legend span { display: inline-flex; @@ -468,16 +577,17 @@ stroke-width: 0.025; } .readout { + display: none; border-top: 1px solid var(--line); - padding: 8px; + padding: 6px; display: grid; - gap: 10px; + gap: 6px; background: var(--panel); } .axis-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; + gap: 5px; } .axis-grid div { border: 1px solid var(--line); @@ -512,7 +622,7 @@ overflow: auto; min-height: 0; display: grid; - grid-template-rows: minmax(0, 1fr) auto auto auto 132px; + grid-template-rows: minmax(0, 1fr) auto 82px 32px 78px; } table { width: 100%; @@ -551,16 +661,17 @@ background: #111820; color: #dbe7ef; font: 11px/1.45 "SFMono-Regular", "Consolas", monospace; - max-height: 132px; + max-height: 78px; } .diagnostics-panel { border-top: 1px solid var(--line); background: #e8e8e8; - padding: 6px 8px; + padding: 5px 6px; display: grid; - grid-template-columns: repeat(4, minmax(0, 1fr)); - gap: 7px 12px; - font: 12px/1.35 "SFMono-Regular", "Consolas", monospace; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 4px 7px; + font: 11px/1.25 "SFMono-Regular", "Consolas", monospace; + overflow: hidden; } .diagnostics-panel div { min-width: 0; @@ -592,7 +703,7 @@ grid-column: 1 / -1; border: 1px solid var(--line); background: #fbfbfb; - max-height: 86px; + max-height: 58px; overflow: auto; display: grid; } @@ -631,37 +742,142 @@ border: 1px solid var(--line); background: var(--panel); } + .axis-left { + border-right: 2px solid #9f9f9f; + } .manual-panel, .mdi-panel, .dro-panel { - padding: 8px; + padding: 5px 6px; display: grid; - gap: 9px; + gap: 7px; align-content: start; font-size: 14px; } + .axis-field-label { + font-size: 13px; + color: var(--text); + white-space: nowrap; + } + .axis-radio-strip { + display: flex; + gap: 5px; + flex-wrap: wrap; + align-items: center; + } + .axis-radio-strip label, + .manual-row label { + display: inline-flex; + gap: 3px; + align-items: center; + white-space: nowrap; + } + .axis-jog-panel { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 5px 7px; + align-items: center; + } + .axis-jog-buttons { + display: flex; + gap: 4px; + align-items: center; + flex-wrap: wrap; + } + .axis-jog-buttons button { + min-width: 33px; + min-height: 24px; + padding: 2px 6px; + } + .axis-jog-buttons select { + width: 116px; + min-width: 116px; + } + .spindle-grid { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 4px 7px; + align-items: start; + } + .spindle-buttons { + display: flex; + gap: 3px; + align-items: center; + flex-wrap: wrap; + } + .spindle-buttons button { + min-width: 28px; + min-height: 24px; + padding: 1px 5px; + } + .spindle-buttons img { + width: 20px; + height: 20px; + display: block; + } .manual-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } + .manual-row[aria-label="Tool touch off"] { + justify-content: center; + } .disabled-control { opacity: 0.45; } .jog-buttons button, .toolbar-button { - min-width: 28px; - min-height: 26px; - padding: 3px 7px; + min-width: 24px; + min-height: 24px; + padding: 1px 3px; + } + .toolbar-button img { + width: 20px; + height: 20px; + display: block; + image-rendering: auto; } .override-grid { display: grid; - grid-template-columns: 120px minmax(80px, 1fr); - gap: 6px 8px; + grid-template-columns: 118px 42px 1fr; + gap: 3px 4px; align-items: center; } + .override-grid > span { + white-space: nowrap; + } + .override-value { + text-align: right; + font-variant-numeric: tabular-nums; + } + .axis-slider { + height: 17px; + border: 1px solid #8b8b8b; + background: #b8b8b8; + position: relative; + box-shadow: inset 1px 1px 0 #dcdcdc; + } + .axis-slider::before, + .axis-slider::after { + content: ""; + position: absolute; + top: 1px; + width: 13px; + height: 13px; + border: 1px solid #9a9a9a; + background: #d9d9d9; + box-shadow: inset 1px 1px 0 #ffffff; + } + .axis-slider::before { + left: var(--knob-a, 58%); + } + .axis-slider::after { + left: var(--knob-b, 75%); + } .machine-session-controls { + display: none; border-top: 1px solid var(--line); padding-top: 7px; display: grid; @@ -697,13 +913,11 @@ flex-wrap: wrap; } .override-bar { - height: 16px; - border: 1px solid var(--line); - background: linear-gradient(90deg, #aeb5bb 0 72%, #e8e8e8 72%); + display: none; } .active-codes { border-top: 1px solid var(--line); - padding-top: 7px; + padding-top: 4px; font: 12px/1.45 "SFMono-Regular", "Consolas", monospace; } .modal-rows { @@ -728,6 +942,26 @@ color: var(--muted); font-size: 12px; } + .mdi-command-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 5px; + align-items: center; + margin-bottom: 8px; + } + .mdi-command-row label { + grid-column: 1 / -1; + color: var(--text); + font-size: 13px; + } + .mdi-command-row input { + min-width: 0; + min-height: 26px; + border: 1px solid var(--line); + background: #fbfbfb; + padding: 4px 6px; + font: 13px/1.25 "SFMono-Regular", "Consolas", monospace; + } .mdi-entry { width: 100%; min-height: 92px; @@ -804,15 +1038,26 @@ display: grid; } .axis-gcode-pane { - margin: 0 6px; min-height: 0; border: 1px solid var(--line); background: #f8f8f8; display: grid; grid-template-rows: auto minmax(0, 1fr); } + .axis-gcode-pane[data-axis-shell="gcode-pane"] { + grid-area: gcode; + margin: 0 6px; + } + .axis-gcode-pane[data-axis-shell="machine-state"] { + position: absolute; + left: -10000px; + top: 0; + width: 1px; + height: 1px; + overflow: hidden; + } .axis-gcode-head { - display: grid; + display: none; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; align-items: center; @@ -824,10 +1069,11 @@ } .program-editor-wrap { display: grid; - grid-template-columns: minmax(240px, 0.85fr) minmax(280px, 1fr); + grid-template-columns: 1fr; min-height: 0; } .program-editor-panel { + display: none; min-height: 0; border-right: 1px solid var(--line); display: grid; @@ -906,12 +1152,13 @@ outline: none; } .axis-statusbar { + grid-area: statusbar; display: grid; - grid-template-columns: 86px 104px minmax(150px, 1.2fr) minmax(120px, 0.8fr) minmax(160px, 1fr) minmax(170px, 1fr); + grid-template-columns: 86px 88px 172px 1fr 80px 126px 168px; border-top: 1px solid var(--line); background: #e3e3e3; min-height: 25px; - font-size: 13px; + font-size: 12px; } .axis-statusbar > span { border-right: 1px solid var(--line); @@ -927,6 +1174,9 @@ font-weight: 700; color: #141a20; } + .axis-hidden-status { + display: block; + } .dro-panel { overflow: auto; } @@ -953,30 +1203,6 @@ padding: 1px 4px; text-align: right; } - @media (max-width: 1040px) { - main { grid-template-rows: auto auto auto minmax(0, 1fr) 220px auto; } - .axis-workspace { grid-template-columns: 1fr; } - .program-editor-wrap { grid-template-columns: 1fr; } - .program-editor-panel { border-right: 0; border-bottom: 1px solid var(--line); min-height: 180px; } - svg { min-height: 280px; } - .toolpath-stage { min-height: 280px; } - .toolpath-three { min-height: 280px; } - } - @media (max-width: 560px) { - .axis-titlebar { grid-template-columns: auto minmax(0, 1fr); } - .window-controls { display: none; } - .axis-toolbar { align-items: stretch; } - .program-controls { justify-content: stretch; } - .program-controls select { min-width: 0; width: 100%; } - .playback-controls button { flex: 1 1 calc(50% - 8px); } - .playback-controls .spacer { display: none; } - .status-strip { justify-content: start; } - .axis-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .dro-readout { grid-template-columns: 1fr; } - .axis-statusbar { grid-template-columns: 1fr; } - .diagnostics-panel { grid-template-columns: 1fr; } - dl { grid-template-columns: 1fr; } - } .hidden-file-input { position: absolute; inline-size: 1px; @@ -985,39 +1211,169 @@ opacity: 0; pointer-events: none; } + .axis-dialogs { + position: absolute; + inset: auto; + pointer-events: none; + } + .axis-dialog { + position: fixed; + z-index: 30; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + width: min(620px, calc(100vw - 32px)); + max-height: min(620px, calc(100vh - 32px)); + border: 1px solid #5f676d; + background: #eeeeee; + box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.25); + display: none; + grid-template-rows: auto minmax(0, 1fr) auto; + pointer-events: auto; + } + .axis-dialog[data-open="true"] { + display: grid; + } + .axis-dialog header { + padding: 6px 8px; + border-bottom: 1px solid var(--line); + background: #d8d8d8; + font-weight: 650; + } + .axis-dialog-body { + overflow: auto; + padding: 8px; + font-size: 13px; + line-height: 1.35; + } + .axis-quickref { + display: grid; + grid-template-columns: 88px minmax(0, 1fr) 88px minmax(0, 1fr); + gap: 2px 8px; + font: 12px/1.35 "Segoe UI", sans-serif; + } + .axis-quickref code { + font-family: "SFMono-Regular", "Consolas", monospace; + color: #111820; + } + .axis-dialog footer { + padding: 7px 8px; + border-top: 1px solid var(--line); + display: flex; + justify-content: end; + background: #e5e5e5; + }
-
X
-

axis.ngc - AXIS 2.9.8 on LinuxCNC-BROWSER-SIM

+
+

axis.ngc - AXIS 2.9.8 on LinuxCNC-HAL-SIM-AXIS

- - - + + + +
- +
- - - - - + + + + + +
+ +
+ + + + +
+
+ + + + + +
-
Axis: - - - +
+ Axis: +
+ + + + + + + + + +
+ Jog: +
+ + + +
+ +
+ + + + + +
-
- - - +
+ Spindle: +
+
+ + + + + + +
+
+ Coolant: +
+ + +
-
- - -
-
- -
-
Spindle: - - - -
-
Spindle: n/a n/a Sn/a
-
Coolant: - - -
-
Coolant: mist n/a flood n/a
-
Machine controls: browser simulation only; real jog/spindle/coolant are not bound
+
Spindle: off stopped S0
+
Coolant: mist off flood off
+
Machine controls: browser virtual HAL active for jog/spindle/coolant/home simulation
- Feed Override:
- Rapid Override:
- Spindle Override:
- Jog Speed:
17 in/min
- Max Velocity:
240 in/min
+ Feed Override:100%
+ Rapid Override:100%
+ Spindle Override:100%
+ Jog Speed:17 in/min
+ Max Velocity:240 in/min
@@ -1095,21 +1478,26 @@
Readiness: unchecked - Missing: n/a - INI: n/a - Program: n/a - Loaded INI: n/a - Loaded parameters: n/a - Loaded tool table: n/a + Missing: unchecked + INI: - + Program: - + Loaded INI: - + Loaded parameters: - + Loaded tool table: -
- LinuxCNC UPDATE_TAG: + Active G-Codes: