Align AXIS Web control lifecycle with LinuxCNC

This commit is contained in:
2026-09-07 13:47:10 +08:00
parent 35adac6471
commit 0a679b5eeb
34 changed files with 2598 additions and 177 deletions

View File

@@ -2,7 +2,7 @@
"schema": "runtime-mapping-report.v1", "schema": "runtime-mapping-report.v1",
"status": "passed", "status": "passed",
"mapped_error_codes": 18, "mapped_error_codes": 18,
"commands": 50, "commands": 52,
"limits": 6, "limits": 6,
"real_exclusions": 3 "real_exclusions": 3
} }

View File

@@ -7,6 +7,7 @@
**原始 AXIS 入口**:[`linuxcnc/share/axis/tcl/axis.tcl`](../linuxcnc/share/axis/tcl/axis.tcl) **原始 AXIS 入口**:[`linuxcnc/share/axis/tcl/axis.tcl`](../linuxcnc/share/axis/tcl/axis.tcl)
**配套通信契约**:[前端与通信详细设计](linuxcnc-web-wasm-frontend-communication-design.md) **配套通信契约**:[前端与通信详细设计](linuxcnc-web-wasm-frontend-communication-design.md)
**配套案例契约**:[案例对标计划](linuxcnc-web-wasm-case-parity-plan.md) **配套案例契约**:[案例对标计划](linuxcnc-web-wasm-case-parity-plan.md)
**控制面板逻辑契约**:[LinuxCNC AXIS Web 控制面板源程序对照逻辑](linuxcnc-axis-web-control-panel-source-logic.md)
本文件是“Web 主界面完全对标 LinuxCNC 原始 AXIS”的唯一执行规范。它把原始 AXIS 的功能、界面结构、状态门控、快捷键、图标、绘图和外部集成拆成可追溯任务;没有来源、命令序列、状态结果和验收证据的功能,不得标记为完成。 本文件是“Web 主界面完全对标 LinuxCNC 原始 AXIS”的唯一执行规范。它把原始 AXIS 的功能、界面结构、状态门控、快捷键、图标、绘图和外部集成拆成可追溯任务;没有来源、命令序列、状态结果和验收证据的功能,不得标记为完成。

View File

@@ -0,0 +1,394 @@
# LinuxCNC AXIS Web Control Panel Source Logic
Status: active implementation contract
Scope: the selected `XYZAC TRT` Web/WASM panel and its LinuxCNC-compatible
Worker command path
Source baseline: LinuxCNC submodule `239dcf4284762ad7e9c65d390bc5524d855c2f8e`
This document is the control-flow authority for the Web panel. A button is
implemented only when its complete path is defined here:
```text
browser gesture
-> React handler
-> AxisOperationController operation
-> SimSession command gate
-> LinuxCNC/WASM task, interpreter, TP or IO authority
-> canonical snapshot
-> React projection
```
The React state is never an authority. A rejected Worker command must remain
rejected in the UI, with the source gate reason visible as the disabled-button
title and in Diagnostics.
The panel does not add a startup/run alias. The source-defined machine sequence
is explicit: release E-stop when needed, power on, home, then press the single
toolbar Run control (or the source `R` shortcut).
On browser startup, the selected INI is the only program-selection authority.
For `xyzac-trt.ini`, `[DISPLAY] OPEN_FILE` selects
`./demos/xyzac_switchkins.ngc`, matching `axis.py` startup around
`initialfile`/`open_file_guts`. Browser UI persistence must not restore a
different G-code source or path and must never schedule a hidden second
`PROGRAM_LOAD` after the configured file appears.
AXIS has both Machine/View menu bindings and toolbar/notebook bindings. The Web
surface keeps the toolbar as the single machine-command entry point and the
notebook tabs as the single view entry point. This removes duplicate buttons;
the command, gate and source symbol for each retained entry are unchanged.
## Source References
The primary GUI source is
`linuxcnc/src/emc/usr_intf/axis/scripts/axis.py`. The accepted command and
post-completion/abort behavior are also checked against
`linuxcnc/src/emc/task/emctaskmain.cc`; the Web panel must not stop at the
Python button handler when the LinuxCNC task layer defines an additional
transition.
| AXIS source symbol | Role |
| --- | --- |
| `manual_ok`, `ensure_mode` | Polling, task-state and mode preconditions |
| `estop_clicked`, `onoff_clicked` | E-stop and machine power edges |
| `open_file_guts` | File replacement transaction and mode restoration |
| `task_run`, `task_run_line`, `task_step`, `task_pause`, `task_resume`, `task_pauseresume`, `task_stop` | Program execution lifecycle |
| `send_mdi`, `send_mdi_command` | MDI history, mode and command submission |
| `home_all_joints`, `home_joint` | Homing and joint/kinematics restrictions |
| `touch_off_system`, `touch_off_tool` | Coordinate/tool offset dialogs and `G10`/`G43` sequence |
| `spindle`, `spindle_forward_toggle`, `spindle_backward_toggle`, `spindle_increase`, `spindle_decrease`, `spindle_constant` | Spindle direction, speed and brake controls |
| `mist_toggle`, `flood_toggle`, `brake_on`, `brake_off` | Auxiliary IO controls |
| `toggle_override_limits`, `cycle_view` | Limit override and preview view selection |
The widget wiring is in `linuxcnc/share/axis/tcl/axis.tcl`: machine menu and
toolbar bindings are around lines 105-170 and 490-669; manual jog, home,
touch-off, spindle and coolant bindings are around lines 893-1087.
## Source Surface Inventory
This inventory separates source controls from the smaller Web surface. A
source-only or host-bound entry is documented explicitly rather than silently
replaced by a different action.
| Source surface | Source entries | Web treatment |
| --- | --- | --- |
| File menu | Open, Recent Files, Edit, Reload, Save As, Properties, Edit Tool Data, Reload Tool Data, Ladder Editor, Quit | Open/Reload/Load are Worker-backed; editor text is the Web file source; save, external editors, Ladder and Quit are host-boundary status/disabled entries. |
| Machine menu | E-stop, Power, Run, Run from selected line, Step, Pause, Resume, Stop, Optional Stop, Block Delete, MDI history, Calibration/HAL tools, Homing, Unhoming, Zero coordinate system, Tool touch-off target | E-stop/Power/Run/Step/Pause/Resume/Stop/Home/Touch Off are represented by documented commands; Run-from-line, MDI clipboard/file actions, calibration/HAL desktop tools and Unhoming are not exposed; Optional Stop, Block Delete, Override Limits and Tool Touch Off remain ABI-blocked. |
| View menu | Six preset views, inches/mm, show program/rapids/live plot/tool/extents/offsets/limits/velocity/DTG, grid, DRO font, PyVCP, commanded/actual, machine/relative, joint/world, clear plot | Preset view/zoom/rotate, units, commanded/actual, machine/relative and supported visual layers are Web projections; unsupported teleop, grid/DTG/font/PyVCP settings are not aliased to motion state. |
| Toolbar | E-stop, Power, Open, Reload, Run, Step, Pause/Resume, Stop, Block Delete, Optional Stop, zoom, six views, rotate, Clear Plot | Same source order and icon assets are retained for supported controls; unsupported controls are disabled with a source/ABI reason. |
| Manual notebook | Axis/joint selector, continuous/increment jog, Home Axis, Touch Off, Tool Touch Off, Override Limits, spindle direction/stop/speed/brake, coolant, tool/probe | XYZAC axis selector, jog, Home Axis, Touch Off, spindle, coolant, tool and probe are represented; joint selector, Tool Touch Off and Override Limits are explicitly bounded by the selected configuration/ABI. |
| Web case strip | no AXIS equivalent | `Load exact XYZAC TRT case` is a Web workspace/bootstrap control, not an AXIS source button. It may only call the same documented `LOAD_WORKSPACE` + `PROGRAM_LOAD` transaction and must not introduce a second Run/Power/Home flow. |
| MDI notebook | MDI entry, Go, history selection/double-click, Up/Down history, Ctrl-M/H/Shift-H clipboard operations | Entry, Go and local history are represented; clipboard/file history operations are host/UI-only and are not sent as machine commands. |
| Browser UI persistence | AXIS preferences do not override `[DISPLAY] OPEN_FILE` with executable source text | OPFS may persist presentation preferences such as view, zoom, tabs, units and selected axis. It must not persist or automatically load G-code source/path. |
The Web surface may remove duplicate menu entries, but it may not change the
meaning of a retained command. This is why Machine/View menus are not rendered
when the same source action already has a toolbar or notebook entry.
## Source Keyboard Contract
The following bindings are copied from `axis.py` lines 3109-3171. Keys that
would require an unavailable command are listed as blocked instead of being
silently mapped to another operation.
| Key | Source handler | Web treatment |
| --- | --- | --- |
| `F1`, `F2`, lowercase `r`, `T`, `P`, `S`, `Esc`, `Ctrl-R`, `O`, `F7`-`F12` | E-stop, power, run, step, pause, resume, stop, reload, open, coolant and spindle handlers | Worker-backed commands with the same gate and ordering rules. |
| uppercase `R`, `F` | `task_reverse`, `task_forward` | Source controls are recorded as blocked because the selected WASM program ABI has no reverse/forward command; they are never mapped to Run. |
| `Home`, `Ctrl-Home`, `End`, `Ctrl-End` | `home_joint`, `home_all_joints`, `touch_off_system`, `touch_off_tool` | Home and system Touch Off are implemented; Tool Touch Off reports the documented ABI blocker. |
| `x`, `y`, `z`, `a`, `c`, `-`, `=`, Shift jog keys | Axis selection and `jog_on`/`jog_off` | Axis selection and pointer jog are implemented; browser key-jog remains limited to the visible selected axes. |
| `c`, `i`, `I` | Continuous/incremental jog mode | Represented by the jog increment selector; no second local mode state is created. |
| `v`, `d`, `!`, `@`, `#`, `$` | Cycle view, rotate, units, display type, coordinate type, teleop mode | View/rotate/units/display/coordinate actions are projected; teleop mode is explicitly unavailable. |
| `Ctrl-K` | `clear_live_plot` | Worker visual-only `VISUAL_CLEAR`. |
| `Ctrl-S`, Ctrl-M/H/Shift-H, `F4` | Save G-code, MDI clipboard/history, next tab | Save/clipboard operations are host/UI-boundary; `F4` notebook cycling is a Web navigation action and does not alter machine state. |
## State Gates
Every motion or interpreter operation is evaluated against the canonical task,
IO and trajectory state before the handler runs.
| State | Meaning in the panel |
| --- | --- |
| `ESTOP` | E-stop input asserted. Only the reset edge and diagnostic-safe actions are available. |
| `ESTOP_RESET` | E-stop released but power is not on. `POWER(active=true)` is the next machine edge. |
| `OFF` | Machine power is off. File inspection/loading is allowed by LinuxCNC, but motion and MDI are not. |
| `ON` | IO is ready. Manual operations still require an idle interpreter and the correct task mode. |
| `RUNNING`/`PAUSED` | AUTO execution owns the interpreter. Manual, MDI, home, spindle and tool operations are blocked. |
| `STOPPED` | A stopped taskfile remains recoverable; the next source-defined transition is `IO_RECOVER`, power or restart as applicable. |
`manual_ok()` is equivalent to: poll, task state ON, not running, and either
an idle interpreter or an MDI queue below its limit. `ensure_mode()` is a
command transaction, not a React state assignment.
## Control Matrix
| Panel control | React command | Source flow and required result |
| --- | --- | --- |
| Emergency Stop / Cancel | `ESTOP {active}` | `estop_clicked` toggles `STATE_ESTOP` and `STATE_ESTOP_RESET`; active E-stop aborts the interpreter and TP, then keeps the loaded taskfile. |
| Power on / off | `POWER {active}` | `onoff_clicked` toggles `STATE_ON` and `STATE_OFF`; power-off aborts motion but preserves the loaded file. |
| Open G-code file | browser file + `PROGRAM_LOAD` | `open_file_guts`: poll, save mode, enter MANUAL, restore the saved tab mode before `PLAN_OPEN/program_open`, set first line, synchronize, parse/preview, then `set_mode_from_tab` after refresh. |
| Load case / Load editor | `PROGRAM_LOAD` | Same replacement transaction as `open_file_guts`; old preview is cleared before parsing and never reused after a failed replacement. |
| Switchkins Identity / TCP: XYZAC / User K | `KINEMATICS_SELECT {type}` | Selected-case remap equivalent for `M429`/`M428`/`M430` from `xyzac-trt.ini`; requires homed, idle, empty TP queue and canonical HAL switchkins state. |
| Run | `PROGRAM_RUN` | `run_warn`, `ensure_mode(AUTO)`, `AUTO_RUN` from the selected start line, then periodic task/TP ticks. Normal AXIS Run uses line 0. In `emctaskmain.cc`, an ended or aborted task plan is closed; the next `EMC_TASK_PLAN_RUN` reopens the current taskfile and runs from line 0. Therefore Run after `completed` or `aborted` must work directly and must not require a separate Reload/Restart click. |
| Run from selected line | not exposed | AXIS `task_run_line` requires an editor highlight and source `AUTO_RUN` start line. The Web editor has no source-line selection command yet, so no partial/fake button is rendered. |
| Reverse / Forward | not exposed | AXIS `task_reverse`/`task_forward` are keyboard-only source actions; the selected WASM program ABI has no corresponding command and Web preserves the distinction from Run. |
| Step | `PROGRAM_STEP` | `ensure_mode(AUTO)` when needed, issue `AUTO_STEP`, and keep the interpreter at the source line boundary. |
| Pause / Resume | `PROGRAM_PAUSE` / `PROGRAM_RESUME` | Pause only while AUTO is reading/waiting; resume only from the paused state. Polling ticks stop around the edge so a servo sample cannot race it. |
| Stop | `PROGRAM_ABORT` | AXIS enables Stop only while the interpreter is non-idle; then it highlights the active line, issues `c.abort()`, waits for completion, and clears the abort edge. The Worker may accept an internal abort edge in more states, but the Web button follows the AXIS UI gate. |
| Block delete / optional pause | disabled | LinuxCNC source has these controls, but the selected WASM ABI does not expose their canonical state. They are visible as disabled, with `blocked_by_abi`, and never emulate a local toggle. |
| Preview view, zoom, rotate, clear plot | local view or `VISUAL_CLEAR` | Visual-only operations do not change task, interpreter, TP or IO state. `Ctrl-K` maps AXIS `clear_live_plot()` and clears only the accumulated execution Live Plot; it must not delete the loaded program's static preview. |
| DRO coordinate/display/units | local projection | Maps AXIS `coord_type`, `display_type` and `metric` variables; values remain Worker snapshot projections. |
| Preview layers | local projection | Maps source `show_live_plot`, `show_machine_limits`, `show_machine_speed`, `show_offsets` and `show_extents` where the selected visual adapter has data; toggling a layer never changes motion. |
| Manual / MDI tabs | `SET_MODE` | The tab gesture sends an explicit Worker mode command. No background effect may change MANUAL after AUTO has started. |
| Joint/World (teleop) mode | not exposed | AXIS View menu calls `set_teleop_mode`/`c.teleop_enable`; the selected WASM motion ABI has no canonical teleop setter, so Web does not pretend its coordinate mode is a joint/world toggle. |
| Jog `-` / `+` | `JOG_START`, repeated `SIM_STEP`, `JOG_STOP` | `jog_on`/`jog_off` semantics. Incremental mode submits one TP segment; Continuous mode samples the source TP jog velocity until pointer release. |
| Home Axis | `HOME {axis}` | `manual_ok`, `ensure_mode(MANUAL)`, then source joint-home command. Individual axis home is rejected outside identity kinematics, matching `home_joint`. |
| Home All / Re-home All | `HOME {all:true}` | `manual_ok`, `ensure_mode(MANUAL)`, then home all joints. Once the canonical homed mask is complete, the Web label changes to `RE-HOME ALL` so loading another program is not mistaken for a requirement to home again. Activating Re-home retains the source `home_all_joints` confirmation. `PROGRAM_LOAD` never clears the homed mask and never invokes HOME. |
| Touch Off | `TOUCH_OFF {axis,value,system,units}` | Prompt for value and one of AXIS's `all_systems` entries (`P0 Current`, `P1 G54` ... `P9 G59.3`), enter MDI, issue `G10 L20 <P-token>`, wait/synchronize, reload the active interpreter state and restore the saved mode. |
| Tool Touch Off | `TOOL_TOUCH_OFF {axis,value,system,units}` | LinuxCNC source requires `G10 L10/L11` plus `G43`, but the selected WASM interpreter does not expose canonical tool-offset storage. The control is therefore disabled with `blocked_by_abi_tool_offsets`; it never sends a fake fixed `G43`. |
| Override limits | disabled | Source requires an active limit and an idle interpreter; the selected WASM limit override ABI is unavailable, so no unsafe local emulation is provided. |
| Spindle CCW / stop / CW | `SPINDLE_COMMAND` | `manual_ok`, `ensure_mode(MANUAL)`, then source direction/stop command. The displayed pressed state is derived from canonical signed RPM. |
| Spindle - / + | `SPINDLE_COMMAND {delta}` → `SPINDLE_CONSTANT` on release | AXIS binds Button-1 to `spindle_decrease`/`spindle_increase` and ButtonRelease to `spindle_constant`; Web preserves this press/release sequence. |
| Brake | `SPINDLE_BRAKE` | Source brake command, gated by `manual_ok`. |
| Mist / Flood | `COOLANT_COMMAND` | Independent source coolant toggles; canonical device IO is returned after every edge. |
| Prepare / Change tool | `TOOL_PREPARE` / `TOOL_CHANGE` | Source tool IO authority and acknowledgement path; no simulated completion is reported before the Worker verifies it. |
| Arm / Disarm probe | `PROBE_CONFIG` | Source probe IO authority; faults and E-stop remain Worker-owned. |
| Jog Speed | `SET_JOG_SCALE` | `update_jog_slider_vel` writes the source linear jog scale. |
| Angular Jog Speed | not exposed | AXIS has a separate `ajogspeed` control for rotary joints; the selected WASM TP exposes one shared jog scale only, so the Web panel does not alias linear Jog Speed to this control. |
| Feed Override | `SET_OVERRIDE` | `set_feedrate` writes the source feed override; updates are debounced but ordered in the Worker queue. |
| Spindle Override | `SET_SPINDLE_OVERRIDE` | `set_spindlerate` writes canonical spindle override. |
| Max Velocity | `SET_MAX_VELOCITY` | AXIS `set_maxvel` calls `c.maxvel` and changes the trajectory velocity limit. The selected WASM TP exposes this as its velocity scale. |
| Rapid Override | disabled | AXIS `set_rapidrate` calls `c.rapidrate` and is distinct from Max Velocity. The selected WASM TP has no rapid-scale setter, so the Web control is disabled with `blocked_by_abi_rapidrate`. |
| MDI command/history | `MDI_EXECUTE` | `send_mdi` validates `manual_ok`, enters MDI, de-duplicates adjacent history entries and submits `c.mdi`. |
| File edit/tool data/quit and desktop diagnostics | disabled or status-only | AXIS source launches external editor, tool editor, Ladder, HAL tools, LinuxCNC status and process quit. These are host-boundary operations and are not represented as browser controls. |
## Button Dependency Matrix
The following matrix describes the side effects that determine the next set of
enabled controls. The right-hand column is an observable Worker state change;
React must derive its button gates from that state on the next snapshot.
| Action | Must be true before the click | State change after acceptance | Controls affected next |
| --- | --- | --- | --- |
| `ESTOP(active=true)` | Always accepted by the task state machine | IO `estop=1`, `ready=0`; interpreter and TP aborted; task `ESTOP`; loaded source retained | Disable power-on, home, jog, MDI, spindle, tool, probe and all program controls except E-stop reset/recovery-safe actions |
| `ESTOP(active=false)` | E-stop input is asserted | IO estop clears; task becomes `STOPPED`/`ESTOP_RESET`; power remains off | Enable Power On; keep all motion/interpreter operations disabled |
| `POWER(active=true)` | E-stop clear, interpreter idle, task not running | IO `ready=1`; task `READY`/machine `ON` | Enable Home and source-gated manual/program controls; Run still requires homed and a taskfile |
| `POWER(active=false)` | Machine on (source accepts the immediate off edge) | Abort motion/interpreter, IO `ready=0`, loaded taskfile retained | Disable motion, MDI, home and Run; allow Power On after the off edge |
| `HOME(all/axis)` | Machine on, interpreter idle, homing gate true | Home-limit authority sets homed mask; individual non-identity home is rejected | Enables Run/MDI/jog only after the canonical homed result is published |
| `PROGRAM_LOAD` | Interpreter idle, TP/MDI motion queue empty, source file/path valid | Enters MANUAL for replacement setup, captures the current canonical TP pose, restores the saved mode before PLAN_OPEN-equivalent parsing, replaces the interpreter source, applies the `task_plan_synch` pose before preview/subroutine processing, then reapplies the selected tab mode | Run/Step/Restart gates are recalculated from the new taskfile; old preview/trace is unavailable during parse and incremental coordinates start from the synchronized machine pose |
| `KINEMATICS_SELECT` | Machine on, homed, idle, TP queue empty, valid type 0/1/2 | M429/M428/M430 selection updates canonical kinematics and the HAL switchkins type | Identity is required for individual coordinate-axis homing; a non-empty queue blocks selection |
| `SET_MODE(MANUAL)` | Machine on, no active AUTO/MDI motion | Task mode becomes `MANUAL` | Enables manual jog/home/spindle/tool gates when idle; disables AUTO-only pause/resume |
| `SET_MODE(MDI)` | Machine on, interpreter idle or MDI queue below limit | Task mode becomes `MDI` | Enables MDI submission; manual controls follow `manual_ok` and queue policy |
| `PROGRAM_RUN` | AXIS toolbar projection: machine ON and interpreter IDLE. Task acceptance additionally requires a current taskfile and, unless `NO_FORCE_HOMING` is configured, all required joints homed. `completed` and `aborted` are valid idle starting states. | If the previous task plan ended or was aborted, reopen/rewind the current taskfile at line 0; set mode `AUTO`, program `RUNNING`, and begin TP samples. A short program may pass through RUNNING and reach `completed` before the next UI paint; that is successful execution, not a blocked Run. | Disable Load, MDI, Home, Jog, spindle and tool operations while active; enable Pause and Stop. On completion, Run becomes available again for another line-0 execution. |
| `PROGRAM_PAUSE` | AUTO program is reading/waiting | Program/task `PAUSED`; run polling is stopped around the edge | Disable Pause and Stop-as-new-run; enable Resume; keep E-stop and Power Off available |
| `PROGRAM_RESUME` | Program/task is paused | Program/task `RUNNING`; TP polling resumes | Re-enable Pause and Stop; keep manual/MDI/home disabled |
| `PROGRAM_ABORT` | AXIS exposes Stop while machine ON and interpreter non-IDLE; the task abort command itself is immediate | Interpreter aborts, TP queue is cleared, current taskfile/path remains selected, and the open task plan is closed | Stop periodic run polling; direct Run is enabled again when the task is idle and reopens the same taskfile from line 0. Reload is not a prerequisite. |
| `JOG_START` | Machine on, idle/manual gate, homed | One incremental segment or repeated continuous TP segments are queued | While held, another jog direction is not started; release/cancel must issue `JOG_STOP` |
| `JOG_STOP` | An active jog gesture exists | Active jog is aborted/stopped; TP remains runnable | Allows a subsequent jog, MDI or source-gated manual command |
| `TOUCH_OFF` | Machine on, homed, idle/manual gate and valid dialog value | MDI `G10 L20` updates the selected coordinate system, then the saved mode is restored | Position/DRO and offsets refresh; program run state is unchanged |
| `SET_MAX_VELOCITY` | Machine powered and the override gate permits the update | Source TP velocity limit is changed | Motion duration/velocity changes; jog and feed overrides remain independent |
| Rapid Override control | Not available in the selected WASM TP | No state change | Rapid Override remains disabled; Max Velocity must not be used as a substitute |
| Spindle/Coolant/Tool/Probe | Machine on, idle, source-specific manual gate | Canonical device IO changes only after Worker verification | Their pressed/checked states update from device IO; no React-only optimistic state is allowed |
### Dependency Flow
```text
ESTOP ──release──> ESTOP_RESET ──POWER ON──> ON
│
├──HOME ALL──> homed ON
│ │
│ ├──RUN──> AUTO/RUNNING
│ │ ├──PAUSE──> PAUSED
│ │ │ └──RESUME──> RUNNING
│ │ └──STOP/ESTOP/POWER OFF──> idle loaded taskfile
│ └──MANUAL/MDI──> jog/home/offset/device gates
└──PROGRAM LOAD──> loaded taskfile (idle only)
```
Any E-stop or power-off edge invalidates the downstream branch immediately.
The UI must not leave a stale enabled button visible while it waits for the
next canonical snapshot; the Worker gate remains the final authority even if a
click races a state transition.
## Required Transactions
### Startup program selection
```text
read selected INI
-> resolve [DISPLAY] OPEN_FILE
-> keep preview/program controls in loading state
-> LOAD_WORKSPACE
-> exactly one PROGRAM_LOAD for the configured OPEN_FILE
-> publish the configured program, source hash and preview atomically
-> enable source-gated controls
```
The UI cache is read only for presentation preferences. A stale cached program
from an earlier Web version is ignored and replaced on the next UI preference
save. No default-preview-then-cached-preview transition is permitted.
### File replacement
```text
poll
-> reject if AUTO is running/paused
-> save task mode
-> ensure MANUAL
-> clear previous preview and pending TP/interpreter edges
-> restore saved task mode before PLAN_OPEN-equivalent parsing
-> capture current canonical TP pose
-> set first line / PROGRAM_LOAD
-> task_plan_synch equivalent: initialize the new interpreter from that TP pose
-> subroutine load + preview from the same synchronized pose
-> reapply selected tab mode after the UI refresh
-> publish one canonical replacement snapshot
```
The replacement is rejected while the TP or an MDI move still has a queued or
active segment. Loading program B after program A has moved the machine must
never reset the interpreter to the origin. For example, if A ends at `X10`, a
new B program beginning with `G91 G1 X5` previews and executes from `X10` to
`X15`, not from `X0` to `X5`.
### E-stop recovery and startup
```text
ESTOP(active=true)
-> abort interpreter and TP
-> task ESTOP, IO estop=1, ready=0
ESTOP(active=false)
-> IO estop=0, task STOPPED (ESTOP_RESET)
POWER(active=true)
-> IO ready=1, task READY/ON
HOME(all=true)
-> MANUAL mode, all joints homed
PROGRAM_RUN
-> if the previous plan is completed/aborted: reopen current taskfile at line 0
-> AUTO mode, RUNNING, periodic SIM_STEP until completion
```
### Run after completion or Stop
```text
RUNNING -> M2/end-of-file -> WAITING -> queue/IO drained -> IDLE/completed
-> press Run
-> reopen current taskfile
-> AUTO_RUN line 0
RUNNING -> Stop/abort -> abort interpreter + TP -> close task plan -> IDLE/aborted
-> press Run
-> reopen current taskfile
-> AUTO_RUN line 0
```
This is the source behavior in `emctaskmain.cc`: closing the task plan avoids
resuming at an aborted line, and `EMC_TASK_PLAN_RUN_TYPE` opens the selected
file again when no plan is open. The Web runtime may expose `PROGRAM_RESTART`
internally for recovery/testing, but it is not a required user step and is not
a second visible Run control. After the reopen/synchronization, a positioning
block whose target already equals the current canonical pose is a zero-motion
block: it advances the interpreter line but must not enqueue an invalid
zero-length TP segment.
### Touch-off command encoding
AXIS's `all_systems` list is `P0 Current`, `P1 G54` through `P9 G59.3`.
`touch_off_system` passes the first token from the selected entry to
`G10 L20`, so the source command is `G10 L20 P1 X...` for G54 (and `P0` for
the current system). The Worker accepts the same P-token or a G54..G59.3
display token, records the normalized `source_system`, and executes the same
canonical P form. This keeps the source command and the interpreter encoding
auditable without changing the UI behavior.
### Continuous jog
AXIS binds Button-1 to continuous jog and ButtonRelease to `jog_stop`.
The Web equivalent keeps a single active `{axis,direction}` gesture, issues
one source-TP jog segment per 50 ms sample using the canonical jog velocity,
advances one servo sample, and sends `JOG_STOP` exactly once on release,
cancel, leave or E-stop.
### Toolpath display lifecycle
The panel renders two different path layers. They must never share an implicit
clear condition:
| Layer | Source owner | Visible/retained | Cleared or replaced |
| --- | --- | --- | --- |
| Static program preview | `open_file_guts` creates `AxisCanon`; Gremlin renders the parsed canon | Remains visible during Run, Pause, Resume, Stop and normal completion | A new `PROGRAM_LOAD` hides the old preview while parsing, then replaces it atomically with the newly parsed program. A failed replacement must not relabel the old path as the new program. |
| Machining Live Plot | `LivePlotter`/`linuxcnc.positionlogger`; `show_live_plot` controls visibility | Accumulates during motion. Pause freezes and retains it. Resume continues the same trace. Stop and normal completion retain the accumulated trace. Hiding the Live layer changes visibility only and does not delete data. | AXIS `clear_live_plot()` explicitly calls `logger.clear()`. The Web panel additionally starts a documented new machining epoch for an accepted `PROGRAM_RUN`, `PROGRAM_RESTART`, or `PROGRAM_LOAD`, as required for this panel: the previous machining trace is cleared without changing the static preview or any task/TP state. |
The Worker publishes a monotonic `visual.live_path_epoch`. React may clear the
machining Live Plot only when this value changes. Array identity, a full versus
delta snapshot, Pause, Resume, Stop, task mode, queue depth and completion are
not clear signals.
```text
PROGRAM_LOAD accepted -> increment live_path_epoch -> clear old live trace
-> hide old static preview while parsing
-> publish new static preview
PROGRAM_RUN accepted -> increment live_path_epoch -> begin a new live trace
PROGRAM_PAUSE -> epoch unchanged -> retain/freeze live trace
PROGRAM_RESUME -> epoch unchanged -> continue live trace
PROGRAM_ABORT/STOP -> epoch unchanged -> retain live trace
program completed -> epoch unchanged -> retain live trace
VISUAL_CLEAR -> increment live_path_epoch -> clear live trace only
PROGRAM_RESTART -> increment live_path_epoch -> clear live trace
```
If Clear Plot is used while motion is still active, history before the clear
edge is removed and `positionlogger` semantics immediately begin accumulating
the subsequent motion in the new epoch. At IDLE, the cleared trace stays empty.
The additional Web epoch at a new Run/load boundary is a presentation
lifecycle rule requested for this panel. It does not pretend that AXIS calls
`positionlogger.clear()` from `task_run` or `open_file_guts`: the native source
does not. Pause/completion retention and explicit Clear Plot behavior remain
direct source matches.
## Implementation Invariants
1. `web/worker/sim-session.mjs` is the command gate and transaction boundary.
2. `web/worker/wasm-linuxcnc-tp.mjs`, `wasm-program-runtime.mjs`,
`wasm-xyzac-io.mjs` and `wasm-xyzac-device-io.mjs` are the only authorities
for selected-case motion, interpreter and device state.
3. `web/react/src/App.tsx` may render a button disabled from a gate snapshot,
but a Worker gate is still mandatory because UI state can be stale.
4. Every accepted operation produces a ledger entry and canonical snapshot;
every rejected operation produces a stable source/gate reason.
5. A feature that cannot be represented by the selected ABI is disabled with
an explicit reason. It must not be approximated with `G92`, a fixed `G43`,
local booleans or a second JavaScript state machine.
6. New controls must be added to this matrix, the source symbol inventory and
a focused Worker/browser regression test before deployment.
7. Executable program source/path is never part of UI preference persistence;
refresh performs one INI-authorized startup load.
8. Live Plot clearing is controlled only by the Worker `live_path_epoch`;
React object/array replacement is never a lifecycle signal.
## Change Procedure
Focused evidence for this contract is kept in
`web/worker/test_xyzac_touch_off_source_flow.mjs`,
`web/worker/test_xyzac_panel_buttons.mjs` and
`scripts/test_xyzac_panel_buttons_browser.mjs`. The completed/aborted direct
rerun contract is covered by `web/worker/test_xyzac_program_rerun.mjs` and the
published-site execution sequence is covered by
`scripts/test_xyzac_program_execution_browser_strict.mjs`.
The source-surface audit is enforced by `scripts/test_axis_control_contract.mjs`.
When LinuxCNC source behavior or the selected machine configuration changes:
1. Update the source baseline and the affected matrix row/transaction above.
2. Update the Worker gate/authority path.
3. Update the React handler only after the Worker contract is verified.
4. Add normal, blocked and recovery assertions to the focused test.
5. Run the panel browser test, build the React bundle, and publish a new
release directory without replacing the previous rollback target.
The document deliberately records ABI blockers and source boundaries. A
`verified` Web slice means the implemented path matches this contract; it does
not claim that an unavailable LinuxCNC host or official native oracle has been
silently reproduced in the browser.

View File

@@ -0,0 +1,18 @@
# LinuxCNC Submodule Local Changes
The root repository pins the `linuxcnc` submodule at
`239dcf4284762ad7e9c65d390bc5524d855c2f8e`.
Local source-oracle and simulation configuration changes that cannot be
represented by the unchanged submodule pointer are archived in
`patches/linuxcnc-working-tree.patch`.
From the repository root, restore them to a clean submodule checkout with:
```sh
git submodule update --init linuxcnc
git -C linuxcnc apply ../patches/linuxcnc-working-tree.patch
```
The patch includes tracked modifications and the untracked configuration files
present when the root project was saved.

View File

@@ -0,0 +1,944 @@
diff --git a/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini b/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini
index cc4ad63..fcee728 100644
--- a/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini
+++ b/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.ini
@@ -11,7 +11,7 @@
# Version of this INI file
-VERSION = 1.0
+VERSION = 1.1
# Name of machine, for use with display, etc.
MACHINE = 3axis-tutorial
@@ -43,6 +43,7 @@ MAX_FEED_OVERRIDE = 1.2
MAX_SPINDLE_OVERRIDE = 1.0
# Prefix to be used
PROGRAM_PREFIX =/home/rolf/linuxcnc/nc_files #need to change
+OPEN_FILE = configs/nc_files/3dtest.ngc
# Introductory graphic
INTRO_GRAPHIC = linuxcnc.gif
diff --git a/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini b/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini
index 957336f..41ebee0 100644
--- a/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini
+++ b/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini
@@ -18,7 +18,7 @@ MAX_ANGULAR_VELOCITY = 360
POSITION_FEEDBACK = ACTUAL
MAX_FEED_OVERRIDE = 2
PROGRAM_PREFIX = ../../nc_files
- INTRO_GRAPHIC = emc2.gif
+ INTRO_GRAPHIC = linuxcnc.gif
INTRO_TIME = 1
#EDITOR = geany
TOOL_EDITOR = tooledit z diam
diff --git a/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini b/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini
index f49430b..29ac1a6 100644
--- a/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini
+++ b/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzbc-trt.ini
@@ -18,7 +18,7 @@ MAX_ANGULAR_VELOCITY = 360
POSITION_FEEDBACK = ACTUAL
MAX_FEED_OVERRIDE = 2
PROGRAM_PREFIX = ../../nc_files
- INTRO_GRAPHIC = emc2.gif
+ INTRO_GRAPHIC = linuxcnc.gif
INTRO_TIME = 1
#EDITOR = geany
TOOL_EDITOR = tooledit z diam
diff --git a/lib/hallib/sim_lib.tcl b/lib/hallib/sim_lib.tcl
index 7d98add..27f1270 100644
--- a/lib/hallib/sim_lib.tcl
+++ b/lib/hallib/sim_lib.tcl
@@ -108,6 +108,8 @@ proc setup_kins {axes} {
# set up axis indices for known kins
switch $kins_module {
trivkins {indices_for_trivkins $axes}
+ xyzac-trt-kins {indices_for_trivkins $axes}
+ xyzbc-trt-kins {indices_for_trivkins $axes}
default {
puts stderr "setup_kins: unknown \[KINS\]KINEMATICS=<$::KINS(KINEMATICS)>"
}
diff --git a/src/emc/motion/control.c b/src/emc/motion/control.c
index 8c9ce66..0ab0f5f 100644
--- a/src/emc/motion/control.c
+++ b/src/emc/motion/control.c
@@ -19,6 +19,9 @@
#ifdef SWITCHKINS_DEBUG
#include <stdio.h> // rtpreempt only, consolidate to stderr
#endif
+#include <stdio.h>
+#include <stdlib.h>
+#include <time.h>
#include "posemath.h"
#include "rtapi.h"
@@ -67,6 +70,46 @@ extern struct emcmot_status_t *emcmotStatus;
// etc.
static double *pcmd_p[EMCMOT_MAX_AXIS];
+/* Optional simulation-only servo oracle. It is disabled unless the caller
+ * provides a file path, so normal LinuxCNC motion timing is unchanged. */
+static FILE *xyzac_trace_stream;
+static void xyzac_trace_tick(void) {
+ const char *path;
+ int joint;
+ struct timespec now;
+ clock_gettime(CLOCK_MONOTONIC, &now);
+ if (xyzac_trace_stream == NULL) {
+ path = getenv("LINUXCNC_XYZAC_TRACE_FILE");
+ if (path == NULL || path[0] == '\0') return;
+ xyzac_trace_stream = fopen(path, "w");
+ if (xyzac_trace_stream == NULL) return;
+ setvbuf(xyzac_trace_stream, NULL, _IOLBF, 0);
+ }
+ fprintf(xyzac_trace_stream,
+ "{\"wall_ns\":%llu,\"motion_heartbeat\":%u,\"position\":[%.17g,%.17g,%.17g,%.17g,%.17g,%.17g,%.17g,%.17g,%.17g],\"actual_position\":[%.17g,%.17g,%.17g,%.17g,%.17g,%.17g,%.17g,%.17g,%.17g],\"velocity\":%.17g,\"acceleration\":%.17g,\"queue_depth\":%d,\"active_depth\":%d,\"motion_id\":%d,\"motion_type\":%d,\"paused\":%d,\"queue_full\":%d,\"joints\":[",
+ (unsigned long long)now.tv_sec * 1000000000ull + (unsigned long long)now.tv_nsec,
+ emcmotStatus->heartbeat,
+ emcmotStatus->carte_pos_cmd.tran.x, emcmotStatus->carte_pos_cmd.tran.y, emcmotStatus->carte_pos_cmd.tran.z,
+ emcmotStatus->carte_pos_cmd.a, emcmotStatus->carte_pos_cmd.b, emcmotStatus->carte_pos_cmd.c,
+ emcmotStatus->carte_pos_cmd.u, emcmotStatus->carte_pos_cmd.v, emcmotStatus->carte_pos_cmd.w,
+ emcmotStatus->carte_pos_fb.tran.x, emcmotStatus->carte_pos_fb.tran.y, emcmotStatus->carte_pos_fb.tran.z,
+ emcmotStatus->carte_pos_fb.a, emcmotStatus->carte_pos_fb.b, emcmotStatus->carte_pos_fb.c,
+ emcmotStatus->carte_pos_fb.u, emcmotStatus->carte_pos_fb.v, emcmotStatus->carte_pos_fb.w,
+ emcmotStatus->current_vel, emcmotStatus->acc, emcmotStatus->depth,
+ emcmotStatus->activeDepth, emcmotStatus->id, emcmotStatus->motionType,
+ emcmotStatus->paused, emcmotStatus->queueFull);
+ for (joint = 0; joint < EMCMOT_MAX_JOINTS; ++joint) {
+ if (joint != 0) fputc(',', xyzac_trace_stream);
+ fprintf(xyzac_trace_stream, "{\"cmd\":%.17g,\"fb\":%.17g,\"vel\":%.17g,\"acc\":%.17g,\"homed\":%d}",
+ emcmotStatus->joint_status[joint].pos_cmd,
+ emcmotStatus->joint_status[joint].pos_fb,
+ emcmotStatus->joint_status[joint].vel_cmd,
+ emcmotStatus->joint_status[joint].acc_cmd,
+ emcmotStatus->joint_status[joint].homed);
+ }
+ fputs("]}\n", xyzac_trace_stream);
+}
+
/***********************************************************************
* LOCAL FUNCTION PROTOTYPES *
************************************************************************/
@@ -271,6 +314,7 @@ void emcmotController(void *arg, long period)
update_status();
/* here ends the core of the controller */
emcmotStatus->heartbeat++;
+ xyzac_trace_tick();
/* set tail to head, to indicate work complete */
emcmotStatus->tail = emcmotStatus->head;
/* end of controller function */
diff --git a/src/emc/nml_intf/emc.cc b/src/emc/nml_intf/emc.cc
index 59fdabe..cdba7ed 100644
--- a/src/emc/nml_intf/emc.cc
+++ b/src/emc/nml_intf/emc.cc
@@ -1838,6 +1838,7 @@ void EMC_TRAJ_STAT::update(CMS * cms)
cms->update(probeval);
cms->update(kinematics_type);
cms->update(motion_type);
+ cms->update(motion_heartbeat);
}
@@ -2261,4 +2262,4 @@ void EMC_MOTION_ADAPTIVE::update(CMS * cms)
void EMC_MOTION_CMD_MSG::update(CMS * cms)
{
-}
\ No newline at end of file
+}
diff --git a/src/emc/nml_intf/emc_nml.hh b/src/emc/nml_intf/emc_nml.hh
index 2d5e61e..8c713b7 100644
--- a/src/emc/nml_intf/emc_nml.hh
+++ b/src/emc/nml_intf/emc_nml.hh
@@ -834,6 +834,7 @@ class EMC_TRAJ_STAT:public EMC_TRAJ_STAT_MSG {
//bool spindle_override_enabled; moved to SPINDLE_STAT
bool adaptive_feed_enabled;
bool feed_hold_enabled;
+ uint32_t motion_heartbeat; // native motion-controller servo-cycle counter
StateTag tag;
};
diff --git a/src/emc/task/emctaskmain.cc b/src/emc/task/emctaskmain.cc
index 6d5ebd5..d215e2e 100644
--- a/src/emc/task/emctaskmain.cc
+++ b/src/emc/task/emctaskmain.cc
@@ -3490,6 +3490,11 @@ int main(int argc, char *argv[])
// since emcStatus was passed to the WM init functions, it
// will be updated in the _update() functions above. There's
// no need to call the individual functions on all WM items.
+ // EMC_TASK_STAT_MSG::heartbeat is the native task-cycle oracle used by
+ // external clients. Keep it monotonic and publish it with this cycle's
+ // status image so a reader can identify every task tick without relying on
+ // wall-clock sampling.
+ emcStatus->task.heartbeat++;
emcStatusBuffer->write(emcStatus);
// wait on timer cycle, if specified, or calculate actual
diff --git a/src/emc/task/taskintf.cc b/src/emc/task/taskintf.cc
index 36fbd97..b3ae2aa 100644
--- a/src/emc/task/taskintf.cc
+++ b/src/emc/task/taskintf.cc
@@ -1612,6 +1612,7 @@ int emcTrajUpdate(EMC_TRAJ_STAT * stat)
stat->feed_override_enabled = enables & FS_ENABLED;
stat->adaptive_feed_enabled = enables & AF_ENABLED;
stat->feed_hold_enabled = enables & FH_ENABLED;
+ stat->motion_heartbeat = emcmotStatus.heartbeat;
if (new_config) {
stat->cycleTime = emcmotConfig.trajCycleTime;
diff --git a/src/emc/usr_intf/axis/extensions/emcmodule.cc b/src/emc/usr_intf/axis/extensions/emcmodule.cc
index b43f041..96f12a2 100644
--- a/src/emc/usr_intf/axis/extensions/emcmodule.cc
+++ b/src/emc/usr_intf/axis/extensions/emcmodule.cc
@@ -409,6 +409,9 @@ static PyMemberDef Stat_members[] = {
{(char*)"echo_serial_number", T_INT, O(echo_serial_number), READONLY},
{(char*)"echo_serial_number", T_INT, O(echo_serial_number), READONLY},
{(char*)"state", T_INT, O(status), READONLY},
+ {(char*)"task_heartbeat", T_UINT, O(task.heartbeat), READONLY,
+ "Native EMC task-loop heartbeat. This counter is incremented by the "
+ "LinuxCNC task thread and is suitable for identifying native status ticks."},
// task
{(char*)"task_mode", T_INT, O(task.mode), READONLY},
@@ -473,6 +476,8 @@ static PyMemberDef Stat_members[] = {
{(char*)"feed_override_enabled", T_BOOL, O(motion.traj.feed_override_enabled), READONLY},
{(char*)"adaptive_feed_enabled", T_BOOL, O(motion.traj.adaptive_feed_enabled), READONLY},
{(char*)"feed_hold_enabled", T_BOOL, O(motion.traj.feed_hold_enabled), READONLY},
+ {(char*)"motion_heartbeat", T_UINT, O(motion.traj.motion_heartbeat), READONLY,
+ "Native motion-controller servo-cycle counter."},
{(char*)"num_extrajoints", T_INT, O(motion.numExtraJoints), READONLY},
diff --git a/configs/sim/axis/corexy/tool.tbl b/configs/sim/axis/corexy/tool.tbl
new file mode 100644
index 0000000..6239879
--- /dev/null
+++ b/configs/sim/axis/corexy/tool.tbl
@@ -0,0 +1,5 @@
+T1 P1 Z0.511 D0.125 ;1/8 end mill
+T2 P2 Z0.1 D0.0625 ;1/16 end mill
+T3 P3 Z1.273 D0.201 ;#7 tap drill
+T99999 P99999 Z0.1 ;big tool number
+T4 P4 Z0 D.005 ;Added 20131029
diff --git a/configs/sim/axis/rotate/rotatekins.hal b/configs/sim/axis/rotate/rotatekins.hal
new file mode 100644
index 0000000..f42bec6
--- /dev/null
+++ b/configs/sim/axis/rotate/rotatekins.hal
@@ -0,0 +1,6 @@
+# Workspace-derived rotatekins candidate. The runtime integration remains a
+# later K2-HAL task; this file is intentionally limited to module registration.
+loadrt [KINS]KINEMATICS
+loadrt [EMCMOT]EMCMOT servo_period_nsec=[EMCMOT]SERVO_PERIOD num_joints=[KINS]JOINTS
+addf motion-command-handler servo-thread
+addf motion-controller servo-thread
diff --git a/configs/sim/axis/rotate/rotatekins.ini b/configs/sim/axis/rotate/rotatekins.ini
new file mode 100644
index 0000000..40e40a8
--- /dev/null
+++ b/configs/sim/axis/rotate/rotatekins.ini
@@ -0,0 +1,27 @@
+# Workspace-derived simulation candidate for the rotatekins source module.
+# LinuxCNC ships the module and man-page example but no dedicated configs/sim
+# entry; this fixture keeps that absence explicit in the coupled report.
+
+[EMC]
+MACHINE = rotatekins_candidate
+VERSION = 1.1
+
+[KINS]
+JOINTS = 6
+KINEMATICS = rotatekins
+
+[TRAJ]
+COORDINATES = XYZABC
+LINEAR_UNITS = mm
+ANGULAR_UNITS = degree
+
+[HAL]
+HALFILE = rotatekins.hal
+
+[EMCMOT]
+EMCMOT = motmod
+SERVO_PERIOD = 1000000
+
+[TASK]
+TASK = milltask
+CYCLE_TIME = 0.010
diff --git a/configs/sim/axis/tool.tbl b/configs/sim/axis/tool.tbl
new file mode 100644
index 0000000..6239879
--- /dev/null
+++ b/configs/sim/axis/tool.tbl
@@ -0,0 +1,5 @@
+T1 P1 Z0.511 D0.125 ;1/8 end mill
+T2 P2 Z0.1 D0.0625 ;1/16 end mill
+T3 P3 Z1.273 D0.201 ;#7 tap drill
+T99999 P99999 Z0.1 ;big tool number
+T4 P4 Z0 D.005 ;Added 20131029
diff --git a/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/3axis-tutorial b/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/3axis-tutorial
new file mode 100755
index 0000000..2b374b9
--- /dev/null
+++ b/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/3axis-tutorial
@@ -0,0 +1,284 @@
+#!/usr/bin/env python3
+
+# Rolf Redford, Nov 2018
+
+#import libraries
+from vismach import *
+import hal
+import math
+import sys
+
+#----------------------------------------------------------------------------------------------------------------------------------
+# Starting and defining
+
+# used for diameter for versions less than 2.8.
+# it gives us way to access variable values from vismach script.
+import linuxcnc
+s = linuxcnc.stat()
+s.poll()
+
+# Here is where we define pins that linuxcnc will send
+# data to, in order to make movements.
+# We will need 5 pins, 3 for motion and 2 for tool stats.
+# tooldiameter isn't really used but if you are using 2.8 you can make couple changes
+# in this file, and uncomment last line in HAL file.
+# add joints. Mill has 3.
+c = hal.component("3axis-tutorial")
+c.newpin("jointX", hal.HAL_FLOAT, hal.HAL_IN)
+c.newpin("jointY", hal.HAL_FLOAT, hal.HAL_IN)
+c.newpin("jointZ", hal.HAL_FLOAT, hal.HAL_IN)
+
+# tool length and diameter pins?
+c.newpin("toollength", hal.HAL_FLOAT, hal.HAL_IN)
+c.newpin("tooldiameter", hal.HAL_FLOAT, hal.HAL_IN)
+
+# tells loadusr pins is ready
+c.ready()
+
+# Used for tool cylinder
+# it will be updated in shape and length by function below.
+toolshape = CylinderZ(0)
+toolshape = Color([1, .5, .5, .5],[toolshape])
+
+# updates tool cylinder shape.
+class HalToolCylinder(CylinderZ):
+ def __init__(self, comp, *args):
+ # get machine access so it can
+ # change itself as it runs
+ # specifically tool cylinder in this case.
+ CylinderZ.__init__(self, *args)
+ self.comp = c
+ def coords(self):
+ # update data - not needed if using 2.8 and self.comp["tooldiameter"]
+ # 2.7 does not have direct pin for diameter so this is workaround. commented out code is direct way to do it.
+ # s.poll() # 2.8 don't need this, comment out if using 2.8.
+ # get diameter and divide by 2 to get radius.
+ # rad = ( s.tool_table[s.tool_in_spindle].diameter ) # 2.7 workaround
+ rad = ( self.comp["tooldiameter"] ) # 2.8 only
+ rad = rad / 2 # change to rad
+ # this instantly updates tool model but tooltip doesn't move till -
+ # tooltip, the drawing point will NOT move till g43h(tool number) is called, however.
+ # Tool will "crash" if h and tool length does not match.
+ leng = s.tool_table[s.tool_in_spindle].zoffset
+ # Update tool length when g43h(toolnumber) is called, otherwise stays at 0 or previous size.
+ # commented out as I prefer machine to show actual tool size right away.
+ #leng = self.comp["toollength"]
+ return (-leng, rad, 0, rad)
+
+
+#----------------------------------------------------------------------------------------------------------------------------------
+# Concept of machine design
+
+# The model follows logical tree design - picture the tree, with branch and smaller branches off it
+# if you move the larger branch, smaller branches will move with it, but if you move smaller branch larger will not.
+#
+# Machine design follows that conceptal design, so for example if you move X, it can move on its own, but if you move Y,
+# it will also move X assembly, as it is attached to Y assembly.
+# so for this machine, tree looks like this:
+
+# model
+# |
+# |---frame
+# | |
+# | |---base
+# | |
+# | |---column
+# | |
+# | |---top
+# |
+# |
+# |---yassembly
+# | |
+# | |
+# | |---xassembly
+# | | |
+# | | |
+# | | |---xbase
+# | | |
+# | | |---work
+# | |
+# | |
+# | |---ybase
+# |
+# |
+# |---zassembly
+# |
+# |
+# |---zframe
+# | |
+# | |---zbody
+# | |
+# | |---spindle
+# |
+# |
+# |---toolassembly
+# |
+# |---cat30
+# |
+# |---tool
+# |
+# |---tooltip
+# |
+# |---(tool cylinder function)
+
+# As you can see, lowest parts must exist first before it can be grouped with others into assembly.
+# So you build upwards from lowest point in tree and assembly them together.
+# Same is applicable for any design of machine. Look at machine arm example and you will see that it starts
+# with tip and adds to larger part of arm then it finally groups with base.
+
+
+#----------------------------------------------------------------------------------------------------------------------------------
+# Starting with fixed frame
+
+# start creating base itself, floor and column for z. box is centered on 0,0,0
+base = BoxCentered(200, 560, 20)
+# translate it so top of base is at zero
+base = Translate([base], 0,0,-10)
+
+# column, attached to base on side.
+# Box() accepts extents
+# ie -100 to 100 is 200 wide, and rightmost is at -100 on coord.
+# Box(x rightmost, y futherest, z lowest, x leftmost, y nearest, z highest)
+column = Box( -60, -260, 0, 60, -200, 400)
+
+# add block on top
+# not really needed, but I like how it looks with it.
+# bare column looks little bit strange for some reason.
+top = Box(-80,-280,400, 80,-160,440)
+
+# now fuse it into "frame"
+frame = Collection([base, column, top])
+# color it grayish
+frame = Color([.8,.8,.8,1],[frame])
+
+
+#----------------------------------------------------------------------------------------------------------------------------------
+# Moving parts section
+
+# Start with X, Y then finally Z with tool and spindle.
+
+# X table addition
+xbase = BoxCentered(1000,200,30)
+# let's color it blue
+xbase = Color([0,0,1,1], [xbase])
+# Move table so top is at zero for now,
+# so work (default 0,0,0) is on top of table.
+xbase = Translate([xbase], 0,0, -15)
+
+# now create work which would be defined by Linuxcnc.
+# I suspect we would need to define shape but not enough is known.
+# for now just create an point that would be bottom center of stock.
+work = Capture()
+
+# group work and xbase together so they move together.
+xassembly = Collection([xbase, work])
+# work is now defined and grouped, and default at 0,0,0, or
+# currently on top of x part table.
+# so we move table group upwards, taking work with it.
+xassembly = Translate([xassembly], 0,0, 35)
+
+# Must define part motion before it becomes part of collection.
+# Must have arguments, object itself, c (defined above), then finally scale from the pin to x y z.
+# since this moves solely on X axis, only x is 1, rest is zero.
+# you could use fractions for say axis that moves in compound like arm for example
+# but this machine is very simple, so all axis will be purely full on axis and zero on other axis.
+xassembly = HalTranslate([xassembly], c, "jointX", 1, 0, 0)
+
+# Y assembly creation
+ybase = BoxCentered(200, 200, 10)
+# colorize it green so we can see it separate from frame.
+ybase = Color([0,1,0,1], [ybase])
+# don't define translation for this one, as y also moves X table.
+# translating this would move itself alone. You want it to move X parts also.
+
+# X table is moved by Y base, so we have to make X child of Y.
+# now define collection of ybase and xassembly.
+yassembly = Collection([ybase, xassembly])
+# define its motion first before translate.
+yassembly = HalTranslate([yassembly], c, "jointY", 0, 1, 0)
+# Now that translate is locked with part,
+# move it upwards so its on frame base.
+yassembly = Translate([yassembly], 0,0,5)
+
+# spindle head
+# define small cylinder where tool will be attached to.
+# It is shallow, basically exposed end of "cat30" toolholder.
+# let's pretend machine uses cat30.
+cat30 = CylinderZ(0, 30, 20, 40) # cone wider top smaller bottom
+# color it red, as in danger, tool!
+cat30 = Color([1,0,0,1], [cat30])
+
+# Define tool and grab such model information from linuxcnc
+# tooltip is initially in vismach "world" 0,0,0.
+# what it does is place where line drawing is in world, so
+# you can see where machine think tip of tool is.
+# first capture it, so we can use it and move it to where
+# defined end of tool is.
+tooltip = Capture()
+
+# Now that we have tooltip, let's attach it to cylinder function (see above)
+# it creates cylinder then translates tooltip to end of it.
+tool = Collection([
+ Translate([HalTranslate([tooltip], c, "toollength", 0, 0, -1)], 0, 0, 0),
+ HalToolCylinder(toolshape)
+ ])
+
+# Since tool is defined, lets attach it to cat30
+# Group cat30 and tooltip
+toolassembly = Collection([cat30, tool])
+# now that tool is properly attached, we can move it
+# and tool will "move" with it now.
+# BUT we need to build rest of head in such way that TOP of head is defined as Z zero.
+# Move it so it attaches to bottom of spindle body.
+toolassembly = Translate([toolassembly],0,0,-120)
+
+# Start building Z assembly head, including spindle and support
+# top is at zero as I want top to be defined as Z home top.
+spindle = CylinderZ(-100, 60, 0, 60) # top is at zero
+# define rest of head using Box
+zbody = Box(-30, -200, 0, 30, 0, -100)
+
+# fuse into z assembly
+zframe = Collection([zbody, spindle])
+# color it yellow
+zframe = Color([1,1,0,1], [zframe])
+
+# Now that all parts are created, let's group it and finally make Z motion
+zassembly = Collection([zframe, toolassembly])
+# define Z motion
+zassembly = HalTranslate([zassembly], c, "jointZ", 0, 0, 1)
+# Now that motion is defined,
+# we can now move it to Z home position.
+zassembly = Translate([zassembly], 0,0, 400)
+
+#----------------------------------------------------------------------------------------------------------------------------------
+# Getting it all together and finishing model
+
+# Assembly everything into single model.
+# xassembly is already included into yassembly so don't need to include it.
+model = Collection([frame, yassembly, zassembly])
+
+# Finally, call main() with parameter to let linuxcnc know.
+# parameter list:
+# final model name must include all parts you want to use
+# tooltip (special for tool tip inclusuion)
+# work (special for work part inclusion)
+# size of screen (bigger means more zoomed out to show more of machine)
+# last 2 is where view point source is.
+main(model, tooltip, work, 600, lat=-75, lon=215)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/3axis-tutorial.hal b/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/3axis-tutorial.hal
new file mode 100644
index 0000000..aa04e3b
--- /dev/null
+++ b/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/3axis-tutorial.hal
@@ -0,0 +1,71 @@
+# core HAL config file for simulation
+
+# first load all the RT modules that will be needed
+# kinematics
+loadrt [KINS]KINEMATICS
+#autoconverted trivkins
+# motion controller, get name and thread periods from ini file
+loadrt [EMCMOT]EMCMOT base_period_nsec=[EMCMOT]BASE_PERIOD servo_period_nsec=[EMCMOT]SERVO_PERIOD num_joints=[KINS]JOINTS
+# load 6 differentiators (for velocity and accel signals
+loadrt ddt names=ddt_x,ddt_xv,ddt_y,ddt_yv,ddt_z,ddt_zv
+# load additional blocks
+loadrt hypot names=vel_xy,vel_xyz
+
+# add motion controller functions to servo thread
+addf motion-command-handler servo-thread
+addf motion-controller servo-thread
+# link the differentiator functions into the code
+addf ddt_x servo-thread
+addf ddt_xv servo-thread
+addf ddt_y servo-thread
+addf ddt_yv servo-thread
+addf ddt_z servo-thread
+addf ddt_zv servo-thread
+addf vel_xy servo-thread
+addf vel_xyz servo-thread
+
+# create HAL signals for position commands from motion module
+# loop position commands back to motion module feedback
+net Xpos joint.0.motor-pos-cmd => joint.0.motor-pos-fb ddt_x.in
+net Ypos joint.1.motor-pos-cmd => joint.1.motor-pos-fb ddt_y.in
+net Zpos joint.2.motor-pos-cmd => joint.2.motor-pos-fb ddt_z.in
+
+# send the position commands thru differentiators to
+# generate velocity and accel signals
+net Xvel ddt_x.out => ddt_xv.in vel_xy.in0
+net Xacc <= ddt_xv.out
+net Yvel ddt_y.out => ddt_yv.in vel_xy.in1
+net Yacc <= ddt_yv.out
+net Zvel ddt_z.out => ddt_zv.in vel_xyz.in0
+net Zacc <= ddt_zv.out
+
+# Cartesian 2- and 3-axis velocities
+net XYvel vel_xy.out => vel_xyz.in1
+net XYZvel <= vel_xyz.out
+
+# estop loopback
+net estop-loop iocontrol.0.user-enable-out iocontrol.0.emc-enable-in
+
+loadusr -W hal_manualtoolchange
+net tool-change iocontrol.0.tool-change => hal_manualtoolchange.change
+net tool-changed iocontrol.0.tool-changed <= hal_manualtoolchange.changed
+net tool-number iocontrol.0.tool-prep-number => hal_manualtoolchange.number
+net tool-prepare-loopback iocontrol.0.tool-prepare => iocontrol.0.tool-prepared
+
+# start 3axis-tutorial vismach addition
+loadusr -W ./3axis-tutorial
+
+# "wire" linuxcnc pins to joints defined in vismach file
+net j0 joint.0.pos-fb => 3axis-tutorial.jointX
+net j1 joint.1.pos-fb => 3axis-tutorial.jointY
+net j2 joint.2.pos-fb => 3axis-tutorial.jointZ
+
+#tool sinput
+net tool_len 3axis-tutorial.toollength <= motion.tooloffset.z
+net tool_rad halui.tool.diameter 3axis-tutorial.tooldiameter # Only in 2.8 or later
+
+
+
+
+
+
diff --git a/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/3axis-tutorial.ini b/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/3axis-tutorial.ini
new file mode 100644
index 0000000..4133b0d
--- /dev/null
+++ b/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/3axis-tutorial.ini
@@ -0,0 +1,224 @@
+# This config file was created 2018-11-21 06:26:44.941844 by the update_ini script
+# The original config files may be found in the /home/john/linuxcnc/configs/rolfmill/rolfmill.old directory
+
+# EMC controller parameters for a simulated machine.
+
+# General note: Comments can either be preceded with a # or ; - either is
+# acceptable, although # is in keeping with most linux config files.
+
+# General section -------------------------------------------------------------
+[EMC]
+
+
+# Version of this INI file
+VERSION = 1.0
+
+# Name of machine, for use with display, etc.
+MACHINE = 3axis-tutorial
+
+# Debug level, 0 means no messages. See src/emc/nml_int/emcglb.h for others
+# DEBUG = 0x7FFFFFFF
+DEBUG = 0
+
+# Sections for display options ------------------------------------------------
+[DISPLAY]
+
+# Name of display program, e.g., xemc
+DISPLAY = axis
+
+# Cycle time, in seconds, that display will sleep between polls
+CYCLE_TIME = 0.100
+
+# Path to help file
+HELP_FILE = doc/help.txt
+
+# Initial display setting for position, RELATIVE or MACHINE
+POSITION_OFFSET = RELATIVE
+
+# Initial display setting for position, COMMANDED or ACTUAL
+POSITION_FEEDBACK = ACTUAL
+
+# Highest value that will be allowed for feed override, 1.0 = 100%
+MAX_FEED_OVERRIDE = 1.2
+MAX_SPINDLE_OVERRIDE = 1.0
+# Prefix to be used
+PROGRAM_PREFIX =/home/rolf/linuxcnc/nc_files #need to change
+OPEN_FILE = configs/nc_files/3dtest.ngc
+
+# Introductory graphic
+INTRO_GRAPHIC = linuxcnc.gif
+INTRO_TIME = 5
+
+#EDITOR = geany
+
+INCREMENTS = 1 mm, .01 in, .1mm, 1 mil, .1 mil, 1/8000 in
+
+[FILTER]
+PROGRAM_EXTENSION = .png,.gif,.jpg Grayscale Depth Image
+PROGRAM_EXTENSION = .py Python Script
+
+png = image-to-gcode
+gif = image-to-gcode
+jpg = image-to-gcode
+py = python3
+
+# Task controller section -----------------------------------------------------
+
+[RS274NGC]
+
+# File containing interpreter variables
+PARAMETER_FILE = 3axis-tutorial_mm.var
+
+# Motion control section ------------------------------------------------------
+[EMCMOT]
+
+
+EMCMOT = motmod
+
+# Timeout for comm to emcmot, in seconds
+COMM_TIMEOUT = 1.0
+
+
+# BASE_PERIOD is unused in this configuration but specified in core_sim.hal
+BASE_PERIOD = 0
+# Servo task period, in nano-seconds
+SERVO_PERIOD = 1000000
+
+# Hardware Abstraction Layer section --------------------------------------------------
+[TASK]
+
+# Name of task controller program, e.g., milltask
+TASK = milltask
+
+# Cycle time, in seconds, that task controller will sleep between polls
+CYCLE_TIME = 0.001
+
+# Part program interpreter section --------------------------------------------
+
+[HAL]
+
+# The run script first uses halcmd to execute any HALFILE
+# files, and then to execute any individual HALCMD commands.
+#
+
+# list of hal config files to run through halcmd
+# files are executed in the order in which they appear
+HALFILE = 3axis-tutorial.hal
+
+# list of halcmd commands to execute
+# commands are executed in the order in which they appear
+#HALCMD = save neta
+
+# Single file that is executed after the GUI has started. Only supported by
+# AXIS at this time (only AXIS creates a HAL component of its own)
+#POSTGUI_HALFILE = test_postgui.hal
+
+HALUI = halui
+
+# Trajectory planner section --------------------------------------------------
+
+[HALUI]
+#No Content
+
+[TRAJ]
+
+
+AXES = 3
+COORDINATES = X Y Z
+HOME = 0 0 0
+LINEAR_UNITS = mm
+ANGULAR_UNITS = degree
+DEFAULT_LINEAR_VELOCITY = 30.48
+MAX_LINEAR_VELOCITY = 53.34
+DEFAULT_ACCELERATION = 508
+MAX_LINEAR_ACCELERATION = 508
+POSITION_FILE = position_mm.txt
+ARC_BLEND_ENABLE = 1
+ARC_BLEND_FALLBACK_ENABLE = 1
+ARC_BLEND_OPTIMIZATION_DEPTH = 50
+#Use this setting for no smoothing (for debugging and stress-testing)
+ARC_BLEND_SMOOTHING_THRESHOLD = .75
+#Use this setting for "normal" smoothing, i.e. if we blend over more than 40% of a segment
+#ARC_BLEND_SMOOTHING_THRESHOLD = 0.40
+
+# Axes sections ---------------------------------------------------------------
+
+[EMCIO]
+
+# Name of IO controller program, e.g., io
+EMCIO = io
+
+# cycle time, in seconds
+CYCLE_TIME = 0.100
+
+# tool table file
+TOOL_TABLE = tool.tbl
+TOOL_CHANGE_POSITION = 0 0 0
+
+
+[KINS]
+KINEMATICS = trivkins
+#This is a best-guess at the number of joints, it should be checked
+JOINTS = 3
+
+[AXIS_X]
+MIN_LIMIT = -500.0
+MAX_LIMIT = 500.0
+MAX_VELOCITY = 25.0
+MAX_ACCELERATION = 750.0
+
+[JOINT_0]
+
+TYPE = LINEAR
+HOME = 0.0
+MAX_VELOCITY = 25.0
+MAX_ACCELERATION = 750.0
+STEPGEN_MAXACCEL = 937.5
+SCALE = 80.0
+FERROR = 1
+MIN_FERROR = .25
+MIN_LIMIT = -500.0
+MAX_LIMIT = 500.0
+HOME_OFFSET = 0.0
+
+[AXIS_Y]
+MIN_LIMIT = -100.0
+MAX_LIMIT = 100.0
+MAX_VELOCITY = 25.0
+MAX_ACCELERATION = 750.0
+
+[JOINT_1]
+
+TYPE = LINEAR
+HOME = 0.0
+MAX_VELOCITY = 25.0
+MAX_ACCELERATION = 750.0
+STEPGEN_MAXACCEL = 937.5
+SCALE = 80.0
+FERROR = 1
+MIN_FERROR = .25
+MIN_LIMIT = -100.0
+MAX_LIMIT = 100.0
+HOME_OFFSET = 0.0
+
+[AXIS_Z]
+MIN_LIMIT = -240.0
+MAX_LIMIT = 0.001
+MAX_VELOCITY = 25.0
+MAX_ACCELERATION = 750.0
+
+[JOINT_2]
+
+TYPE = LINEAR
+HOME = 0.0
+MAX_VELOCITY = 25.0
+MAX_ACCELERATION = 750.0
+STEPGEN_MAXACCEL = 937.5
+SCALE = 80.0
+FERROR = 1
+MIN_FERROR = .25
+MIN_LIMIT = -240.0
+MAX_LIMIT = 0.001
+HOME_OFFSET = 0.0
+
+# section for main IO controller parameters -----------------------------------
diff --git a/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/README b/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/README
new file mode 100644
index 0000000..6522282
--- /dev/null
+++ b/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/README
@@ -0,0 +1,2 @@
+A simple vismach 3 axis mill example built using primitives.
+Comments is in 3axis-tutorial vismach file, and some in 3axis-tutorial.hal explains step by step the process to build.
diff --git a/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/README_es b/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/README_es
new file mode 100644
index 0000000..5193b11
--- /dev/null
+++ b/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/README_es
@@ -0,0 +1,2 @@
+Un ejemplo simple de fresadora vismach de 3 ejes construido usando primitivas.
+Los comentarios se encuentran en el archivo vismach 3axis-tutorial, y algunos en 3axis-tutorial.hal explican paso a paso el proceso de creación.
diff --git a/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/tool.tbl b/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/tool.tbl
new file mode 100644
index 0000000..2e64501
--- /dev/null
+++ b/configs/sim/axis/vismach/3axis-tutorial/3axis-tutorial.old/tool.tbl
@@ -0,0 +1,4 @@
+T1 P1 Z12 D30 ;big
+T2 P2 Z50 D10 ;1/16 end mill
+T3 P3 Z50 D5 ;#7 tap drill
+T99999 P99999 Z0.1 ;big tool number
diff --git a/configs/sim/axis/vismach/scorbot/scorbot.hal b/configs/sim/axis/vismach/scorbot/scorbot.hal
new file mode 100644
index 0000000..d5da59d
--- /dev/null
+++ b/configs/sim/axis/vismach/scorbot/scorbot.hal
@@ -0,0 +1,5 @@
+# Workspace-derived scorbot-kins module registration candidate.
+loadrt [KINS]KINEMATICS
+loadrt [EMCMOT]EMCMOT servo_period_nsec=[EMCMOT]SERVO_PERIOD num_joints=[KINS]JOINTS
+addf motion-command-handler servo-thread
+addf motion-controller servo-thread
diff --git a/configs/sim/axis/vismach/scorbot/scorbot.ini b/configs/sim/axis/vismach/scorbot/scorbot.ini
new file mode 100644
index 0000000..f18fde2
--- /dev/null
+++ b/configs/sim/axis/vismach/scorbot/scorbot.ini
@@ -0,0 +1,26 @@
+# Workspace-derived simulation candidate for scorbot-kins.
+# The locked LinuxCNC source contains scorbot-kins.c but no shipped sim INI.
+
+[EMC]
+MACHINE = scorbot_candidate
+VERSION = 1.1
+
+[KINS]
+JOINTS = 5
+KINEMATICS = scorbot-kins
+
+[TRAJ]
+COORDINATES = XYZAB
+LINEAR_UNITS = mm
+ANGULAR_UNITS = degree
+
+[HAL]
+HALFILE = scorbot.hal
+
+[EMCMOT]
+EMCMOT = motmod
+SERVO_PERIOD = 1000000
+
+[TASK]
+TASK = milltask
+CYCLE_TIME = 0.010

View File

@@ -0,0 +1,40 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
const root = new URL("../", import.meta.url);
const read = (path) => readFile(new URL(path, root), "utf8");
const [axisPy, axisTcl, taskSource, contract, app, worker, programCore, runtime] = await Promise.all([
read("linuxcnc/src/emc/usr_intf/axis/scripts/axis.py"),
read("linuxcnc/share/axis/tcl/axis.tcl"),
read("linuxcnc/src/emc/task/emctaskmain.cc"),
read("docs/linuxcnc-axis-web-control-panel-source-logic.md"),
read("web/react/src/App.tsx"),
read("web/worker/sim-session.mjs"),
read("wasm-core/src/sim_program.c"),
read("web/worker/wasm-program-runtime.mjs"),
]);
for (const symbol of ["manual_ok", "ensure_mode", "open_file_guts", "task_run", "task_step", "task_pause", "task_resume", "task_stop", "send_mdi_command", "home_all_joints", "home_joint", "touch_off_system", "touch_off_tool", "spindle_forward_toggle", "spindle_backward_toggle", "toggle_override_limits", "cycle_view"]) assert.match(axisPy, new RegExp(`(?:def|class) ${symbol}`), `missing source symbol ${symbol}`);
for (const binding of ["machine_estop", "machine_power", "file_open", "reload", "program_run", "program_step", "program_pause", "program_stop", "program_blockdelete", "program_optpause", "view_zoomin", "view_zoomout", "clear_plot", "jogminus", "jogplus", "zerohome.home", "zerohome.zero", "zerohome.tooltouch", "spindlef.ccw", "spindlef.stop", "spindlef.cw"]) assert.match(axisTcl, new RegExp(`(?:toolbar\\.${binding}|manual\\.jogf|manual\\.spindlef|manual\\.jogf\\.zerohome)`), `missing source binding ${binding}`);
for (const taskTerm of ["EMC_TASK_PLAN_RUN_TYPE", "emcTaskPlanOpen", "emcTaskPlanClose", "programStartLine = run_msg->line"]) assert.match(taskSource, new RegExp(taskTerm.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `missing task source ${taskTerm}`);
for (const term of ["manual_ok", "ensure_mode", "open_file_guts", "EMC_TASK_PLAN_RUN", "PROGRAM_LOAD", "PROGRAM_RUN", "PROGRAM_STEP", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "JOG_START", "HOME", "TOUCH_OFF", "Run after completion or Stop", "Toolpath display lifecycle", "live_path_epoch", "positionlogger", "blocked_by_abi_tool_offsets", "Button Dependency Matrix"]) assert.match(contract, new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), `contract missing ${term}`);
assert.match(app, /Begin executing current file \[R\]/);
assert.match(app, /Reload current G-code \[Ctrl-R\]/);
assert.match(app, /Rapid Override/);
assert.doesNotMatch(app, /Reset machine/);
assert.doesNotMatch(app, /Run Startup Sequence/);
assert.doesNotMatch(app, /G92\s+\$\{selectedAxis/);
assert.doesNotMatch(app, /cachedProgram/);
assert.doesNotMatch(app, /program:\s*\{\s*path:\s*programPath/);
assert.match(contract, /exactly one PROGRAM_LOAD for the configured OPEN_FILE/);
assert.match(worker, /blocked_by_abi_tool_offsets/);
assert.doesNotMatch(worker, /xyzac_program_aborted_requires_restart/);
assert.match(worker, /reopened_taskfile/);
assert.match(worker, /livePathEpoch/);
assert.doesNotMatch(worker.match(/const visualClearResponse[\s\S]*?switch \(input\.kind\)/)?.[0] ?? "", /previewState\s*=\s*null/);
assert.match(programCore, /SIM_PROGRAM_COMPLETED \|\| program->status == SIM_PROGRAM_ABORTED/);
assert.match(programCore, /sim_program_set_position/);
assert.match(runtime, /synchronizePosition/);
assert.match(worker, /xyzac_motion_active/);
console.log(JSON.stringify({ schema: "axis-control-source-contract.v1", status: "verified", source: "linuxcnc-axis-web-control-panel-source-logic.md", checked: { axis_py_symbols: 17, axis_tcl_bindings: 21, task_source_edges: 4, app_source_controls: 3, worker_abi_boundaries: 1, direct_rerun_edges: 2 } }));

View File

@@ -0,0 +1,122 @@
import { pathToFileURL } from "node:url";
const modulePath = process.env.PLAYWRIGHT_MODULE;
const url = process.env.XYZAC_PANEL_URL ?? "https://49.232.250.35:58010/";
if (!modulePath) {
console.log(JSON.stringify({ schema: "xyzac-live-path-lifecycle-browser.v1", status: "blocked", code: "playwright_unavailable" }));
process.exit(0);
}
const imported = await import(pathToFileURL(modulePath).href);
const playwright = imported.default ?? imported;
const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--ignore-certificate-errors", "--disable-gpu"] });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true });
await context.addInitScript(() => { localStorage.clear(); sessionStorage.clear(); });
const page = await context.newPage();
const browserErrors = [];
page.on("pageerror", (error) => browserErrors.push(`pageerror:${error.message}`));
page.on("console", (message) => { if (message.type() === "error") browserErrors.push(`console:${message.text()}`); });
const main = page.locator("main.axis-app");
const state = () => main.evaluate((element) => {
const viewer = element.querySelector('[data-xyzac-preview="toolpath"]');
return {
loading: element.dataset.axisProgramLoading,
path: element.dataset.axisProgramPath,
program: element.dataset.axisProgramStatus,
operation: element.querySelector(".operation-state")?.textContent?.trim(),
machine: element.querySelector(".machine-state")?.textContent?.trim(),
homed: element.dataset.axisHomed,
line: element.dataset.axisCurrentLine,
queue: element.dataset.axisTpQueueDepth,
velocity: element.dataset.axisTpVelocity,
epoch: Number(viewer?.getAttribute("data-xyzac-live-path-epoch") ?? -1),
liveCount: Number(viewer?.getAttribute("data-xyzac-live-trace-count") ?? 0),
previewCount: Number(viewer?.getAttribute("data-xyzac-toolpath-count") ?? 0),
error: element.querySelector(".diagnostic-error")?.textContent?.trim() ?? null,
};
});
const wait = async (predicate, label, timeout = 90000) => {
const started = Date.now(); let last;
while (Date.now() - started < timeout) { last = await state(); if (predicate(last)) return last; await page.waitForTimeout(50); }
throw new Error(`${label}:${JSON.stringify(last)}`);
};
const click = async (locator, label, timeout = 30000) => {
const started = Date.now();
while (Date.now() - started < timeout) { if (await locator.count() && !await locator.first().isDisabled()) { await locator.first().click(); return; } await page.waitForTimeout(100); }
throw new Error(`${label}:disabled_or_missing`);
};
const load = async (name, source) => {
const input = main.locator('input[type="file"][aria-label="Open G-code file"]');
await input.setInputFiles({ name, mimeType: "text/plain", buffer: Buffer.from(source) });
return wait((value) => value.loading === "false" && value.path === `browser/${name}` && value.program === "loaded" && value.error === null, `${name}_loaded`);
};
const canvasPixels = async (targetPage) => targetPage.locator('[data-xyzac-preview="toolpath"] canvas').evaluate((canvas) => {
const gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true }) ?? canvas.getContext("webgl", { preserveDrawingBuffer: true });
if (!gl) return { width: canvas.width, height: canvas.height, changed: 0 };
const pixels = new Uint8Array(canvas.width * canvas.height * 4);
gl.readPixels(0, 0, canvas.width, canvas.height, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
let changed = 0;
for (let index = 0; index < pixels.length; index += 4) if (Math.abs(pixels[index] - 5) + Math.abs(pixels[index + 1] - 11) + Math.abs(pixels[index + 2] - 15) > 20) changed += 1;
return { width: canvas.width, height: canvas.height, changed };
});
const longMoves = Array.from({ length: 160 }, (_, index) => `G1 X${index % 2 ? 0 : 6} Y${index % 2 ? 0 : 4} Z${index % 2 ? 10 : 8} A${index % 2 ? 0 : 10} C${index % 2 ? 0 : 15} F300`);
const longSource = ["G21", "G90", ...longMoves, "M2", ""].join("\n");
const shortMoves = Array.from({ length: 12 }, (_, index) => `G1 X${index % 2 ? -0.2 : 0.2} Y${index % 2 ? -0.1 : 0.1} F600`);
const shortSource = ["G21", "G91", ...shortMoves, "M2", ""].join("\n");
const replacementSource = "G21\nG90\nG1 X1 Y1 Z1 F300\nM2\n";
await page.goto(`${url}?live-path-lifecycle=20260907`, { waitUntil: "domcontentloaded", timeout: 30000 });
await wait((value) => value.loading === "false" && value.program === "loaded", "startup");
await click(main.locator('button[aria-label="Power on [F2]"]'), "power_on");
await wait((value) => value.machine === "Machine ON", "power_state");
await click(main.locator('button[aria-label="Home all axes"]'), "home_all");
await wait((value) => value.homed === "[true,true,true,true,true]", "home_state");
const loadedLong = await load("live-long.ngc", longSource);
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run_long");
const running = await wait((value) => value.program === "running" && value.liveCount >= 3, "long_live_trace");
await click(main.locator('button[aria-label="Pause execution [P]"]'), "pause");
const paused = await wait((value) => value.program === "paused", "paused");
await page.waitForTimeout(1000);
const pausedStable = await state();
if (paused.epoch !== running.epoch || pausedStable.epoch !== running.epoch || pausedStable.liveCount < running.liveCount || pausedStable.previewCount !== loadedLong.previewCount) throw new Error(`pause_cleared_path:${JSON.stringify({ loadedLong, running, paused, pausedStable })}`);
await click(main.locator('button[aria-label="Resume execution [S]"]'), "resume");
const resumed = await wait((value) => value.program === "running" && value.liveCount >= pausedStable.liveCount, "resumed");
if (resumed.epoch !== running.epoch || resumed.previewCount !== loadedLong.previewCount) throw new Error(`resume_changed_epoch:${JSON.stringify(resumed)}`);
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "stop");
const stopped = await wait((value) => value.operation === "Operation IDLE" && value.program === "aborted", "stopped");
if (stopped.epoch !== running.epoch || stopped.liveCount < pausedStable.liveCount || stopped.previewCount !== loadedLong.previewCount) throw new Error(`stop_cleared_path:${JSON.stringify(stopped)}`);
const loadedShort = await load("live-short.ngc", shortSource);
if (loadedShort.epoch <= stopped.epoch || loadedShort.liveCount !== 0) throw new Error(`load_did_not_clear_live_path:${JSON.stringify({ stopped, loadedShort })}`);
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run_short");
const completed = await wait((value) => value.program === "completed" && value.operation === "Operation IDLE" && value.liveCount >= 2, "short_completed");
await page.waitForTimeout(1000);
const completedStable = await state();
if (completedStable.epoch !== completed.epoch || completedStable.liveCount < completed.liveCount || completedStable.previewCount !== loadedShort.previewCount) throw new Error(`completion_cleared_path:${JSON.stringify({ completed, completedStable })}`);
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "rerun_short");
const rerun = await wait((value) => value.epoch > completedStable.epoch && value.liveCount < completedStable.liveCount, "rerun_new_epoch");
await wait((value) => value.program === "completed" && value.operation === "Operation IDLE", "rerun_completed");
const replacement = await load("live-replacement.ngc", replacementSource);
if (replacement.epoch <= rerun.epoch || replacement.liveCount !== 0 || replacement.previewCount !== 1) throw new Error(`replacement_lifecycle_invalid:${JSON.stringify({ rerun, replacement })}`);
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run_replacement");
await wait((value) => value.liveCount >= 1 && Number(value.line) > 0, "replacement_trace");
await wait((value) => value.operation === "Operation IDLE", "replacement_idle");
const beforeClear = await state();
await click(main.locator('button[aria-label="Clear live plot [Ctrl-K]"]'), "clear_live_plot");
const cleared = await wait((value) => value.epoch > beforeClear.epoch && value.liveCount <= 1, "cleared_live_plot");
if (cleared.previewCount !== beforeClear.previewCount) throw new Error(`clear_removed_static_preview:${JSON.stringify({ beforeClear, cleared })}`);
const desktopPixels = await canvasPixels(page);
if (desktopPixels.changed < 100) throw new Error(`desktop_canvas_blank:${JSON.stringify(desktopPixels)}`);
await page.screenshot({ path: "/tmp/xyzac-live-path-lifecycle-desktop.png", fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await page.waitForTimeout(500);
const mobilePixels = await canvasPixels(page);
if (mobilePixels.changed < 100) throw new Error(`mobile_canvas_blank:${JSON.stringify(mobilePixels)}`);
await page.screenshot({ path: "/tmp/xyzac-live-path-lifecycle-mobile.png", fullPage: true });
console.log(JSON.stringify({ schema: "xyzac-live-path-lifecycle-browser.v1", status: browserErrors.length ? "failed" : "verified", lifecycle: { loadedLong, running, paused: pausedStable, resumed, stopped, loadedShort, completed: completedStable, rerun, replacement, beforeClear, cleared }, canvas_pixels: { desktop: desktopPixels, mobile: mobilePixels }, browser_errors: browserErrors, screenshots: ["/tmp/xyzac-live-path-lifecycle-desktop.png", "/tmp/xyzac-live-path-lifecycle-mobile.png"] }));
await browser.close();

View File

@@ -0,0 +1,112 @@
import { pathToFileURL } from "node:url";
import { fnv1a64 } from "../web/worker/replay.mjs";
const modulePath = process.env.PLAYWRIGHT_MODULE;
const url = process.env.XYZAC_PANEL_URL ?? "https://49.232.250.35:58010/";
if (!modulePath) {
console.log(JSON.stringify({ schema: "xyzac-multi-program-load-browser.v1", status: "blocked", code: "playwright_unavailable" }));
process.exit(0);
}
const imported = await import(pathToFileURL(modulePath).href);
const playwright = imported.default ?? imported;
const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--ignore-certificate-errors", "--disable-gpu"] });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true });
await context.addInitScript(() => { localStorage.clear(); sessionStorage.clear(); });
const page = await context.newPage();
const failures = [];
const browserErrors = [];
const dialogs = [];
page.on("pageerror", (error) => browserErrors.push(`pageerror:${error.message}`));
page.on("console", (message) => { if (message.type() === "error") browserErrors.push(`console:${message.text()}`); });
page.on("dialog", async (dialog) => { dialogs.push({ type: dialog.type(), message: dialog.message() }); await dialog.dismiss(); });
const main = page.locator("main.axis-app");
const state = () => main.evaluate((element) => ({
source: element.dataset.axisStateSource,
machine: element.querySelector(".machine-state")?.textContent?.trim(),
operation: element.querySelector(".operation-state")?.textContent?.trim(),
program: element.dataset.axisProgramStatus,
line: element.dataset.axisCurrentLine,
queue: element.dataset.axisTpQueueDepth,
velocity: element.dataset.axisTpVelocity,
homed: element.dataset.axisHomed,
loading: element.querySelector(".case-status")?.getAttribute("aria-busy") === "true",
command: element.querySelector(".command-status")?.textContent?.trim(),
error: element.querySelector(".diagnostic-error")?.textContent?.trim() ?? null,
editor: element.querySelector("#axis-program")?.value ?? "",
sourceHash: element.querySelector(".xyzac-program header code")?.textContent?.trim() ?? "",
previewCount: element.querySelector('[data-xyzac-preview="toolpath"]')?.getAttribute("data-xyzac-toolpath-count") ?? "0",
}));
const wait = async (predicate, label, timeout = 60000) => {
const started = Date.now(); let last;
while (Date.now() - started < timeout) { last = await state(); if (predicate(last)) return last; await page.waitForTimeout(200); }
throw new Error(`${label}:${JSON.stringify(last)}`);
};
const click = async (locator, label, timeout = 30000) => {
const started = Date.now();
while (Date.now() - started < timeout) { if (await locator.count() && !await locator.first().isDisabled()) { await locator.first().click(); return; } await page.waitForTimeout(200); }
throw new Error(`${label}:disabled_or_missing`);
};
const record = async (name, action) => { try { return { name, result: "verified", ...(await action() ?? {}) }; } catch (error) { const row = { name, result: "failed", reason: error?.message ?? String(error) }; failures.push(row); return row; } };
const absoluteMoves = Array.from({ length: 12 }, (_, index) => {
const positive = index % 2 === 0;
return `G1 X${positive ? 20 : 0} Y${positive ? 10 : 0} Z${positive ? 5 : 10} A${positive ? 30 : 0} C${positive ? 40 : 0} F600`;
});
const incrementalMoves = (x, y, z, a, c) => Array.from({ length: 12 }, (_, index) => {
const sign = index % 2 === 0 ? 1 : -1;
return `G1 X${sign * x} Y${sign * y} Z${sign * z} A${sign * a} C${sign * c} F600`;
});
const programs = [
{ name: "browser-a.ngc", source: ["G21", "G90", "G0 X0 Y0 Z10 A0 C0", ...absoluteMoves, "M2", ""].join("\n") },
{ name: "browser-b.ngc", source: ["G21", "G91", ...incrementalMoves(1, 0.5, 0, 1, 2), "M2", ""].join("\n") },
{ name: "browser-c.ngc", source: ["G21", "G91", ...incrementalMoves(0.5, 1, 0.5, 2, 1), "M2", ""].join("\n") },
];
const results = [];
const evidence = [];
try {
await page.goto(`${url}?strict-multi-load=20260907`, { waitUntil: "domcontentloaded", timeout: 30000 });
results.push(await record("startup", async () => { await wait((s) => s.source === "linuxcnc-wasm", "startup"); }));
results.push(await record("power_home", async () => {
await click(main.getByRole("button", { name: "Load 3D_Chips.ngc", exact: true }), "initial_load");
await wait((s) => s.program === "loaded" && !s.loading && s.error === null, "initial_loaded");
await click(main.locator('button[aria-label="Power on [F2]"]'), "power");
await wait((s) => s.machine === "Machine ON", "power_state");
await click(main.locator('button[aria-label="Home all axes"]'), "home");
await wait((s) => s.homed === "[true,true,true,true,true]", "home_state");
}));
for (const program of programs) {
results.push(await record(`load_${program.name}`, async () => {
const input = main.locator('input[type="file"][aria-label="Open G-code file"]');
await input.setInputFiles({ name: program.name, mimeType: "text/plain", buffer: Buffer.from(program.source) });
const loaded = await wait((s) => s.program === "loaded" && !s.loading && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, `${program.name}_loaded`);
const hash = fnv1a64(program.source);
if (!loaded.editor.includes(program.source.trim())) throw new Error(`${program.name}:editor_source_mismatch`);
if (!loaded.sourceHash.includes(hash.slice(0, 12))) throw new Error(`${program.name}:source_hash_mismatch:${loaded.sourceHash}:${hash}`);
if (Number(loaded.previewCount) <= 0) throw new Error(`${program.name}:preview_empty`);
evidence.push({ name: program.name, source_hash: hash, preview_count: Number(loaded.previewCount), line_after_load: loaded.line });
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), `${program.name}_run`);
const running = await wait((s) => ["running", "completed"].includes(s.program) && (Number(s.line) > 0 || Number(s.queue) > 0 || Number(s.velocity) > 0), `${program.name}_running`);
await page.waitForTimeout(2000);
const active = await state();
if (Number(active.line) <= 0 && Number(active.queue) <= 0 && Number(active.velocity) <= 0) throw new Error(`${program.name}:motion_not_observed:${JSON.stringify(active)}`);
let stopped = active;
if (active.operation !== "Operation IDLE") {
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), `${program.name}_stop`);
stopped = await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted", "completed"].includes(s.program) && s.error === null, `${program.name}_stopped`);
}
return { source_hash: hash, preview_count: Number(loaded.previewCount), running_line: running.line, stopped_program: stopped.program };
}));
}
results.push(await record("round_trip_A", async () => {
const input = main.locator('input[type="file"][aria-label="Open G-code file"]');
await input.setInputFiles({ name: programs[0].name, mimeType: "text/plain", buffer: Buffer.from(programs[0].source) });
const loaded = await wait((s) => s.program === "loaded" && !s.loading && s.error === null, "roundtrip_loaded");
if (!loaded.sourceHash.includes(evidence[0].source_hash.slice(0, 12))) throw new Error("roundtrip_hash_mismatch");
if (Number(loaded.previewCount) <= 0) throw new Error("roundtrip_preview_empty");
}));
} finally {
await page.screenshot({ path: "/tmp/xyzac-multi-program-load-browser.png", fullPage: true });
if (dialogs.length) failures.push({ name: "automatic_dialog", result: "failed", reason: JSON.stringify(dialogs) });
console.log(JSON.stringify({ schema: "xyzac-multi-program-load-browser.v1", status: failures.length === 0 && browserErrors.length === 0 ? "verified" : "failed", url, results, evidence, dialogs, failures, browser_errors: browserErrors, final_state: await state(), screenshot: "/tmp/xyzac-multi-program-load-browser.png" }));
await browser.close();
}

View File

@@ -75,7 +75,6 @@ try {
})); }));
results.push(await record("POWER ON", async () => { await click(main.locator('button[aria-label="Power on [F2]"]'), "power_on"); await wait((s) => s.machine === "Machine ON", "power_state"); })); results.push(await record("POWER ON", async () => { await click(main.locator('button[aria-label="Power on [F2]"]'), "power_on"); await wait((s) => s.machine === "Machine ON", "power_state"); }));
results.push(await record("HOME ALL", async () => { await click(main.locator('button[aria-label="Home all axes"]'), "home_all"); await wait((s) => s.homed === "[true,true,true,true,true]", "home_state"); })); results.push(await record("HOME ALL", async () => { await click(main.locator('button[aria-label="Home all axes"]'), "home_all"); await wait((s) => s.homed === "[true,true,true,true,true]", "home_state"); }));
results.push(await record("RESET", async () => { await click(main.locator('button[aria-label="Reset machine"]'), "reset"); await wait((s) => s.command?.includes("RESET acknowledged") || s.error === null, "reset_state"); }));
results.push(await record("KINEMATICS IDENTITY/TCP/USERK", async () => { results.push(await record("KINEMATICS IDENTITY/TCP/USERK", async () => {
for (const name of ["Identity", "TCP: XYZAC", "User K", "Identity", "TCP: XYZAC", "User K"]) { for (const name of ["Identity", "TCP: XYZAC", "User K", "Identity", "TCP: XYZAC", "User K"]) {
await click(main.getByRole("button", { name: new RegExp(`^${name}`) }), `kinematics_${name}`); await click(main.getByRole("button", { name: new RegExp(`^${name}`) }), `kinematics_${name}`);
@@ -99,13 +98,13 @@ try {
await axisInput.check(); await axisInput.check();
await click(main.locator(`button[aria-label="Jog ${axis} positive"]`), `jog_${axis}_positive`); await click(main.locator(`button[aria-label="Jog ${axis} positive"]`), `jog_${axis}_positive`);
await wait((s) => s.error === null, `jog_${axis}_ack`); await wait((s) => s.error === null, `jog_${axis}_ack`);
await click(main.getByRole("button", { name: "Stop", exact: true }).first(), `jog_${axis}_stop`); await click(main.getByRole("button", { name: "Stop jog", exact: true }), `jog_${axis}_stop`);
} }
})); }));
results.push(await record("SPINDLE", async () => { results.push(await record("SPINDLE", async () => {
await click(main.locator('button[aria-label="Spindle clockwise"]'), "spindle_cw"); await click(main.locator('button[aria-label="Spindle clockwise"]'), "spindle_cw");
await click(main.locator('button[aria-label="Spindle counter-clockwise"]'), "spindle_ccw"); await click(main.locator('button[aria-label="Spindle counter-clockwise"]'), "spindle_ccw");
await click(main.getByRole("button", { name: "Stop", exact: true }).nth(1), "spindle_stop"); await click(main.getByRole("button", { name: "Stop spindle", exact: true }), "spindle_stop");
const brake = main.getByLabel("Brake", { exact: true }); const brake = main.getByLabel("Brake", { exact: true });
if (await brake.count() && !await brake.isDisabled()) { if (await brake.count() && !await brake.isDisabled()) {
await brake.click(); await brake.click();
@@ -158,7 +157,7 @@ try {
await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program), "program_aborted"); await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program), "program_aborted");
await click(main.locator(".program-actions button").first(), "program_reload_before_step"); await click(main.locator(".program-actions button").first(), "program_reload_before_step");
await wait((s) => s.program === "loaded" && s.error === null, "program_reload_before_step_ack"); await wait((s) => s.program === "loaded" && s.error === null, "program_reload_before_step_ack");
await click(main.locator(".program-actions button").nth(2), "program_step"); await click(main.locator('button[aria-label="Execute next line [T]"]'), "program_step");
await wait((s) => s.program === "paused", "program_stepped"); await wait((s) => s.program === "paused", "program_stepped");
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "program_step_abort"); await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "program_step_abort");
await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program), "program_step_abort_ack"); await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program), "program_step_abort_ack");
@@ -169,28 +168,23 @@ try {
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "program_abort_again"); await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "program_abort_again");
await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program) && s.error === null, "program_abort_again_ack"); await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program) && s.error === null, "program_abort_again_ack");
})); }));
results.push(await record("RUN STARTUP SEQUENCE", async () => { results.push(await record("EDITOR PROGRAM LOAD + RELOAD", async () => {
await click(main.locator(".program-actions button").first(), "startup_sequence_reload");
await wait((s) => s.program === "loaded" && s.queue === "0" && s.runEnabled === "true" && !s.programLoading && s.error === null, "startup_sequence_reload_ack");
await click(main.getByRole("button", { name: "Run startup sequence", exact: true }), "startup_sequence");
await wait((s) => s.program === "running", "startup_sequence_running");
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "startup_sequence_abort");
await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program) && s.error === null, "startup_sequence_abort_ack");
}));
results.push(await record("EDITOR PROGRAM LOAD + REOPEN", async () => {
const editor = main.locator("#axis-program"); const editor = main.locator("#axis-program");
await editor.fill("G21\nG1 X2 Y2 Z2 F100\nM2\n"); await editor.fill("G21\nG1 X2 Y2 Z2 F100\nM2\n");
await click(main.locator(".program-actions button").first(), "editor_program_load"); await click(main.locator(".program-actions button").first(), "editor_program_load");
await wait((s) => s.program === "loaded" && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "editor_program_load_ack"); await wait((s) => s.program === "loaded" && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "editor_program_load_ack");
await click(main.locator('button[aria-label="Reopen editor defaults"]'), "reopen_defaults"); await click(main.locator('button[aria-label="Reload current G-code [Ctrl-R]"]'), "reload_current_program");
await wait((s) => s.program === "loaded" && s.case?.includes("xyzac_switchkins") && s.error === null, "reopen_defaults_ack"); await wait((s) => s.program === "loaded" && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "reload_current_program_ack");
})); }));
results.push(await record("DISABLED unsupported buttons", async () => { results.push(await record("DISABLED unsupported buttons", async () => {
for (const name of ["Toggle skip lines with slash", "Toggle optional pause"]) if (!await main.locator(`button[aria-label="${name}"]`).isDisabled()) throw new Error(`${name}:must_be_disabled`); for (const name of ["Toggle skip lines with slash", "Toggle optional pause"]) if (!await main.locator(`button[aria-label="${name}"]`).isDisabled()) throw new Error(`${name}:must_be_disabled`);
})); }));
} finally { } finally {
const buttons = await main.locator("button").evaluateAll((items) => items.map((button) => ({ text: button.textContent?.trim() ?? "", aria: button.getAttribute("aria-label"), disabled: button.disabled }))); const buttons = await main.locator("button").evaluateAll((items) => items.map((button) => ({ text: button.textContent?.trim() ?? "", aria: button.getAttribute("aria-label"), disabled: button.disabled })));
const visibleLabels = buttons.map((button) => button.aria ?? button.text).filter(Boolean);
const duplicateLabels = Object.entries(visibleLabels.reduce((counts, label) => { counts[label] = (counts[label] ?? 0) + 1; return counts; }, {})).filter(([, count]) => count > 1);
for (const [label, count] of duplicateLabels) if (["Emergency Stop [F1]", "Power on [F2]", "Home all axes", "Run program", "Clear plot", "Stop Jog", "Stop Spindle"].includes(label)) failures.push({ name: "duplicate_visible_control", result: "failed", reason: `${label}:${count}` });
await page.screenshot({ path: "/tmp/xyzac-panel-buttons-browser.png", fullPage: true }); await page.screenshot({ path: "/tmp/xyzac-panel-buttons-browser.png", fullPage: true });
console.log(JSON.stringify({ schema: "xyzac-panel-buttons-browser.v1", status: failures.length === 0 && browserErrors.length === 0 ? "verified" : "failed", url, results, button_count: buttons.length, disabled_button_count: buttons.filter((button) => button.disabled).length, failures, browser_errors: browserErrors, final_state: await state(), screenshot: "/tmp/xyzac-panel-buttons-browser.png" })); console.log(JSON.stringify({ schema: "xyzac-panel-buttons-browser.v1", status: failures.length === 0 && browserErrors.length === 0 ? "verified" : "failed", url, results, button_count: buttons.length, disabled_button_count: buttons.filter((button) => button.disabled).length, duplicate_labels: duplicateLabels, failures, browser_errors: browserErrors, final_state: await state(), screenshot: "/tmp/xyzac-panel-buttons-browser.png" }));
await browser.close(); await browser.close();
} }

View File

@@ -0,0 +1,143 @@
import { pathToFileURL } from "node:url";
const modulePath = process.env.PLAYWRIGHT_MODULE;
const url = process.env.XYZAC_PANEL_URL ?? "https://49.232.250.35:58010/";
if (!modulePath) {
console.log(JSON.stringify({ schema: "xyzac-program-execution-browser-strict.v1", status: "blocked", code: "playwright_unavailable" }));
process.exit(0);
}
const imported = await import(pathToFileURL(modulePath).href);
const playwright = imported.default ?? imported;
const browser = await playwright.chromium.launch({
headless: true,
executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome",
args: ["--ignore-certificate-errors", "--disable-gpu"],
});
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true });
await context.addInitScript(() => { localStorage.clear(); sessionStorage.clear(); });
const page = await context.newPage();
const failures = [];
const browserErrors = [];
page.on("pageerror", (error) => browserErrors.push(`pageerror:${error.message}`));
page.on("console", (message) => { if (message.type() === "error") browserErrors.push(`console:${message.text()}`); });
page.on("dialog", async (dialog) => { await dialog.accept(); });
const main = page.locator("main.axis-app");
const state = () => main.evaluate((element) => ({
source: element.dataset.axisStateSource,
authority: element.dataset.axisAuthority,
motion: element.dataset.axisMotionAuthority,
machine: element.querySelector(".machine-state")?.textContent?.trim(),
operation: element.querySelector(".operation-state")?.textContent?.trim(),
mode: element.dataset.axisTaskMode,
program: element.dataset.axisProgramStatus,
runEnabled: element.dataset.axisRunEnabled,
loading: element.querySelector(".case-status")?.getAttribute("aria-busy") === "true",
homed: element.dataset.axisHomed,
line: element.dataset.axisCurrentLine,
queue: element.dataset.axisTpQueueDepth,
velocity: element.dataset.axisTpVelocity,
command: element.querySelector(".command-status")?.textContent?.trim(),
error: element.querySelector(".diagnostic-error")?.textContent?.trim() ?? null,
}));
const wait = async (predicate, label, timeout = 60000) => {
const started = Date.now(); let last;
while (Date.now() - started < timeout) {
last = await state();
if (predicate(last)) return last;
await page.waitForTimeout(200);
}
throw new Error(`${label}:${JSON.stringify(last)}`);
};
const click = async (locator, label, timeout = 30000) => {
const started = Date.now();
while (Date.now() - started < timeout) {
if (await locator.count() && !await locator.first().isDisabled()) { await locator.first().click(); return; }
await page.waitForTimeout(200);
}
throw new Error(`${label}:disabled_or_missing`);
};
const record = async (name, action) => {
try { const result = await action(); return { name, result: "verified", ...(result ?? {}) }; }
catch (error) { const row = { name, result: "failed", reason: error?.message ?? String(error) }; failures.push(row); return row; }
};
const samples = [];
const runAndObserve = async (name, waitMs = 5000) => record(name, async () => {
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), `${name}_run`);
const started = await wait((s) => s.program === "running" && s.operation === "Operation RUNNING", `${name}_running`);
await page.waitForTimeout(waitMs);
const observed = await state();
samples.push({ name, started, observed });
if (observed.error !== null) throw new Error(`${name}_error:${observed.error}`);
if (Number(observed.line) <= 0 && Number(observed.queue) <= 0 && Number(observed.velocity) <= 0) throw new Error(`${name}_no_motion:${JSON.stringify(observed)}`);
return { observed };
});
const results = [];
try {
await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30000 });
results.push(await record("startup", async () => {
const s = await wait((v) => v.source === "linuxcnc-wasm" && v.motion === "wasm", "wasm_startup");
if (s.authority !== "linuxcnc-wasm-worker") throw new Error("authority_marker_missing");
}));
results.push(await record("initial_program_load", async () => {
await click(main.getByRole("button", { name: "Load 3D_Chips.ngc", exact: true }), "load_initial");
return { state: await wait((s) => s.program === "loaded" && !s.loading && s.error === null, "initial_program_loaded") };
}));
results.push(await record("power_on", async () => {
await click(main.locator('button[aria-label="Power on [F2]"]'), "power_on");
await wait((s) => s.machine === "Machine ON", "power_state");
}));
results.push(await record("home_all", async () => {
await click(main.locator('button[aria-label="Home all axes"]'), "home_all");
await wait((s) => s.homed === "[true,true,true,true,true]", "home_state");
}));
results.push(await runAndObserve("first_run", 5000));
results.push(await record("stop_first_run", async () => {
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "stop_first_run");
return { state: await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program), "first_stop_idle") };
}));
results.push(await runAndObserve("rerun_after_abort_without_reload", 3000));
results.push(await record("stop_direct_rerun", async () => {
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "stop_direct_rerun");
await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program), "direct_rerun_stop_idle");
}));
results.push(await record("reload_after_stop", async () => {
await click(main.getByRole("button", { name: "Load 3D_Chips.ngc", exact: true }), "reload_after_stop");
return { state: await wait((s) => s.program === "loaded" && s.queue === "0" && !s.loading && s.error === null, "reload_after_stop_loaded") };
}));
results.push(await runAndObserve("rerun_after_reload", 3000));
results.push(await record("stop_second_run", async () => {
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "stop_second_run");
await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted"].includes(s.program), "second_stop_idle");
}));
results.push(await record("load_second_gcode", async () => {
const moves = Array.from({ length: 160 }, (_, index) => {
const direction = index % 2 === 0 ? 1 : 0;
return `G1 X${direction ? 20 : 0} Y${direction ? 10 : 0} Z${direction ? 5 : 10} A${direction ? 30 : 0} C${direction ? 40 : 0} F100`;
});
const source = ["G21", "G90", "G0 X0 Y0 Z10 A0 C0", ...moves, "M2", ""].join("\n");
const fileInput = main.locator('input[type="file"][aria-label="Open G-code file"]');
await fileInput.setInputFiles({ name: "strict-second-load.ngc", mimeType: "text/plain", buffer: Buffer.from(source) });
return { state: await wait((s) => s.program === "loaded" && !s.loading && s.command?.includes("PROGRAM_LOAD acknowledged") && s.error === null, "second_gcode_loaded") };
}));
results.push(await runAndObserve("run_second_gcode", 3000));
results.push(await record("stop_second_gcode", async () => {
await click(main.locator('button[aria-label="Stop program execution [ESC]"]'), "stop_second_gcode");
await wait((s) => s.operation === "Operation IDLE" && ["loaded", "aborted", "completed"].includes(s.program), "second_gcode_stop_idle");
}));
} finally {
await page.screenshot({ path: "/tmp/xyzac-program-execution-browser-strict.png", fullPage: true });
console.log(JSON.stringify({
schema: "xyzac-program-execution-browser-strict.v1",
status: failures.length === 0 && browserErrors.length === 0 ? "verified" : "failed",
url,
results,
samples,
failures,
browser_errors: browserErrors,
final_state: await state(),
screenshot: "/tmp/xyzac-program-execution-browser-strict.png",
}));
await browser.close();
}

View File

@@ -0,0 +1,99 @@
import { readFile } from "node:fs/promises";
import { pathToFileURL } from "node:url";
import { fnv1a64 } from "../web/worker/replay.mjs";
const modulePath = process.env.PLAYWRIGHT_MODULE;
const url = process.env.XYZAC_PANEL_URL ?? "https://49.232.250.35:58010/";
if (!modulePath) {
console.log(JSON.stringify({ schema: "xyzac-refresh-single-program-browser.v1", status: "blocked", code: "playwright_unavailable" }));
process.exit(0);
}
const defaultPath = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc";
const legacyPath = "linuxcnc/nc_files/3D_Chips.ngc";
const defaultSource = await readFile(new URL(`../${defaultPath}`, import.meta.url), "utf8");
const legacySource = await readFile(new URL(`../${legacyPath}`, import.meta.url), "utf8");
const defaultHash = fnv1a64(defaultSource.replace(/\r\n/g, "\n").replace(/\r/g, "\n"));
const legacyHash = fnv1a64(legacySource.replace(/\r\n/g, "\n").replace(/\r/g, "\n"));
const imported = await import(pathToFileURL(modulePath).href);
const playwright = imported.default ?? imported;
const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.BROWSER_EXECUTABLE_PATH ?? "/usr/bin/google-chrome", args: ["--ignore-certificate-errors", "--disable-gpu"] });
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 }, ignoreHTTPSErrors: true });
const page = await context.newPage();
const browserErrors = [];
page.on("pageerror", (error) => browserErrors.push(`pageerror:${error.message}`));
page.on("console", (message) => { if (message.type() === "error") browserErrors.push(`console:${message.text()}`); });
page.on("dialog", async (dialog) => { await dialog.accept(); });
const main = page.locator("main.axis-app");
const state = () => main.evaluate((element) => ({
loading: element.dataset.axisProgramLoading,
path: element.dataset.axisProgramPath,
sourceHash: element.dataset.axisProgramSourceHash,
program: element.dataset.axisProgramStatus,
runEnabled: element.dataset.axisRunEnabled,
machine: element.querySelector(".machine-state")?.textContent?.trim(),
operation: element.querySelector(".operation-state")?.textContent?.trim(),
homed: element.dataset.axisHomed,
line: element.dataset.axisCurrentLine,
queue: element.dataset.axisTpQueueDepth,
velocity: element.dataset.axisTpVelocity,
case: element.querySelector(".case-status")?.textContent?.trim(),
previewCount: element.querySelector('[data-xyzac-preview="toolpath"]')?.getAttribute("data-xyzac-toolpath-count") ?? "0",
powerDisabled: element.querySelector('button[aria-label="Power on [F2]"]')?.disabled,
runDisabled: element.querySelector('button[aria-label="Begin executing current file [R]"]')?.disabled,
error: element.querySelector(".diagnostic-error")?.textContent?.trim() ?? null,
}));
const wait = async (predicate, label, timeout = 60000) => {
const started = Date.now(); let last;
while (Date.now() - started < timeout) { last = await state(); if (predicate(last)) return last; await page.waitForTimeout(200); }
throw new Error(`${label}:${JSON.stringify(last)}`);
};
const click = async (locator, label, timeout = 30000) => {
const started = Date.now();
while (Date.now() - started < timeout) { if (await locator.count() && !await locator.first().isDisabled()) { await locator.first().click(); return; } await page.waitForTimeout(200); }
throw new Error(`${label}:disabled_or_missing`);
};
await page.goto(`${url}?refresh-single-program-seed=20260907`, { waitUntil: "domcontentloaded", timeout: 30000 });
await wait((s) => s.loading === "false" && s.path === defaultPath && s.sourceHash === defaultHash, "initial_default_program");
const legacyState = { version: 1, selectedAxis: "x", leftTab: "manual", rightTab: "preview", view: "p", zoom: 1, rotateMode: false, coordType: "machine", displayType: "actual", metric: true, layers: { grid: true, tool: true, path: true, live: true, limits: false, velocity: false, offsets: false, extents: false }, casePath: "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini", touchOffSystem: "P1 G54", program: { path: legacyPath, source: legacySource } };
const savedAt = Date.now();
const legacyRecordUnsigned = { schema: "linuxcnc-ui-temp-json.v1", context_id: "linuxcnc-2.10.0-pre0-wasm-m1-py-c1", key: "xyzac-trt", generation: 1000, saved_at_ms: savedAt, state: legacyState };
const legacyRecord = { ...legacyRecordUnsigned, state_hash: fnv1a64(legacyState), record_hash: fnv1a64(legacyRecordUnsigned) };
await page.evaluate(async (record) => {
const root = await navigator.storage.getDirectory();
const app = await root.getDirectoryHandle("linuxcnc-web-wasm", { create: true });
const states = await app.getDirectoryHandle("ui-state", { create: true });
const project = await states.getDirectoryHandle("xyzac-trt", { create: true });
for (const name of ["state-a.json", "state-b.json", "manifest.json"]) { try { await project.removeEntry(name); } catch { /* empty cache */ } }
const handle = await project.getFileHandle("state-a.json", { create: true });
const writable = await handle.createWritable();
await writable.write(JSON.stringify(record));
await writable.close();
}, legacyRecord);
await page.reload({ waitUntil: "domcontentloaded", timeout: 30000 });
const transitions = [];
for (let index = 0; index < 140; index += 1) {
const sample = await state();
transitions.push(sample);
if (sample.path === legacyPath || sample.sourceHash === legacyHash || sample.case?.includes("3D_Chips.ngc")) throw new Error(`legacy_program_auto_loaded:${JSON.stringify(sample)}`);
if (sample.loading === "true" && (sample.powerDisabled !== true || sample.runDisabled !== true || sample.previewCount !== "0")) throw new Error(`startup_loading_gate_invalid:${JSON.stringify(sample)}`);
await page.waitForTimeout(250);
}
const stable = await state();
if (stable.loading !== "false" || stable.path !== defaultPath || stable.sourceHash !== defaultHash || stable.previewCount !== "29" || stable.error !== null) throw new Error(`configured_program_not_stable:${JSON.stringify(stable)}`);
await click(main.locator('button[aria-label="Power on [F2]"]'), "power_on");
await wait((s) => s.machine === "Machine ON", "power_on_state");
await click(main.locator('button[aria-label="Home all axes"]'), "home_all");
await wait((s) => s.homed === "[true,true,true,true,true]", "home_state");
await click(main.locator('button[aria-label="Begin executing current file [R]"]'), "run");
const running = await wait((s) => s.program === "running" && s.operation === "Operation RUNNING", "program_running");
await page.waitForTimeout(3000);
const moving = await state();
if (Number(moving.line) <= 0 && Number(moving.queue) <= 0 && Number(moving.velocity) <= 0) throw new Error(`program_not_moving:${JSON.stringify(moving)}`);
await page.screenshot({ path: "/tmp/xyzac-refresh-single-program-browser.png", fullPage: true });
console.log(JSON.stringify({ schema: "xyzac-refresh-single-program-browser.v1", status: browserErrors.length ? "failed" : "verified", configured_program: { path: defaultPath, source_hash: defaultHash, preview_count: Number(stable.previewCount) }, ignored_legacy_program: { path: legacyPath, source_hash: legacyHash }, sampled_ms: transitions.length * 250, running, moving, browser_errors: browserErrors, screenshot: "/tmp/xyzac-refresh-single-program-browser.png" }));
await browser.close();

View File

@@ -295,7 +295,7 @@ if(EMSCRIPTEN)
) )
get_target_property(_sim_module_link_flags sim_core_module LINK_FLAGS) get_target_property(_sim_module_link_flags sim_core_module LINK_FLAGS)
string(REPLACE "_sim_task_recover','_sim_task_restore" "_sim_task_recover','_sim_task_command_gate','_sim_task_restore" _sim_module_link_flags "${_sim_module_link_flags}") string(REPLACE "_sim_task_recover','_sim_task_restore" "_sim_task_recover','_sim_task_command_gate','_sim_task_restore" _sim_module_link_flags "${_sim_module_link_flags}")
string(REPLACE "_sim_interpreter_size" "_sim_program_size','_sim_program_requires_subroutines','_sim_program_init','_sim_program_reset','_sim_program_destroy','_sim_program_load','_sim_program_add_subroutine','_sim_program_set_rapid_velocity','_sim_program_start','_sim_program_pause','_sim_program_resume','_sim_program_abort','_sim_program_step','_sim_program_execute_block','_sim_program_seek','_sim_program_read_motion','_sim_program_clear_motion','_sim_program_canonical','_sim_program_state_hash','_sim_interpreter_size" _sim_module_link_flags "${_sim_module_link_flags}") string(REPLACE "_sim_interpreter_size" "_sim_program_size','_sim_program_requires_subroutines','_sim_program_init','_sim_program_reset','_sim_program_destroy','_sim_program_load','_sim_program_add_subroutine','_sim_program_set_rapid_velocity','_sim_program_set_position','_sim_program_start','_sim_program_pause','_sim_program_resume','_sim_program_abort','_sim_program_step','_sim_program_execute_block','_sim_program_seek','_sim_program_read_motion','_sim_program_clear_motion','_sim_program_canonical','_sim_program_state_hash','_sim_interpreter_size" _sim_module_link_flags "${_sim_module_link_flags}")
string(REPLACE "_sim_kinematics_switch_select','_sim_kinematics_switch_forward" "_sim_kinematics_switch_select','_sim_kinematics_switch_select_physical','_sim_kinematics_switch_forward" _sim_module_link_flags "${_sim_module_link_flags}") string(REPLACE "_sim_kinematics_switch_select','_sim_kinematics_switch_forward" "_sim_kinematics_switch_select','_sim_kinematics_switch_select_physical','_sim_kinematics_switch_forward" _sim_module_link_flags "${_sim_module_link_flags}")
string(REPLACE "_sim_kinematics_switch_inverse','_sim_kinematics_switch_active" "_sim_kinematics_switch_inverse','_sim_kinematics_switch_inverse_mode','_sim_kinematics_switch_active" _sim_module_link_flags "${_sim_module_link_flags}") string(REPLACE "_sim_kinematics_switch_inverse','_sim_kinematics_switch_active" "_sim_kinematics_switch_inverse','_sim_kinematics_switch_inverse_mode','_sim_kinematics_switch_active" _sim_module_link_flags "${_sim_module_link_flags}")
string(REPLACE "_sim_motion_init" "_sim_motion_set_profile','_sim_motion_set_lookahead','_sim_motion_set_dynamics','_sim_motion_set_feed_override','_sim_motion_set_paused','_sim_motion_peek','_sim_motion_init" _sim_module_link_flags "${_sim_module_link_flags}") string(REPLACE "_sim_motion_init" "_sim_motion_set_profile','_sim_motion_set_lookahead','_sim_motion_set_dynamics','_sim_motion_set_feed_override','_sim_motion_set_paused','_sim_motion_peek','_sim_motion_init" _sim_module_link_flags "${_sim_module_link_flags}")

View File

@@ -89,6 +89,7 @@ void sim_program_destroy(sim_program *program);
int32_t sim_program_load(sim_program *program, const char *source, uint32_t length); int32_t sim_program_load(sim_program *program, const char *source, uint32_t length);
int32_t sim_program_add_subroutine(sim_program *program, const char *source, uint32_t length); int32_t sim_program_add_subroutine(sim_program *program, const char *source, uint32_t length);
int32_t sim_program_set_rapid_velocity(sim_program *program, double velocity); int32_t sim_program_set_rapid_velocity(sim_program *program, double velocity);
int32_t sim_program_set_position(sim_program *program, const sim_posemath_pose *position);
int32_t sim_program_start(sim_program *program); int32_t sim_program_start(sim_program *program);
int32_t sim_program_pause(sim_program *program); int32_t sim_program_pause(sim_program *program);
int32_t sim_program_resume(sim_program *program); int32_t sim_program_resume(sim_program *program);

View File

@@ -367,9 +367,46 @@ int32_t sim_program_set_rapid_velocity(sim_program *program, double velocity) {
return SIM_PROGRAM_OK; return SIM_PROGRAM_OK;
} }
int32_t sim_program_set_position(sim_program *program, const sim_posemath_pose *position) {
const double *values;
size_t index;
if (program == NULL || position == NULL || program->status == SIM_PROGRAM_IDLE) return SIM_PROGRAM_ERR_ARGUMENT;
values = &position->tran.x;
for (index = 0; index < 9; ++index) if (!isfinite(values[index])) return SIM_PROGRAM_ERR_RANGE;
program->interpreter.state.x = position->tran.x;
program->interpreter.state.y = position->tran.y;
program->interpreter.state.z = position->tran.z;
program->interpreter.state.a = position->a;
program->interpreter.state.b = position->b;
program->interpreter.state.c = position->c;
program->interpreter.state.u = position->u;
program->interpreter.state.v = position->v;
program->interpreter.state.w = position->w;
program->interpreter.state.status = SIM_INTERP_OK;
return SIM_PROGRAM_OK;
}
int32_t sim_program_start(sim_program *program) { int32_t sim_program_start(sim_program *program) {
if (program == NULL || program->status == SIM_PROGRAM_IDLE) return SIM_PROGRAM_ERR_STATE; if (program == NULL || program->status == SIM_PROGRAM_IDLE) return SIM_PROGRAM_ERR_STATE;
if (program->status == SIM_PROGRAM_COMPLETED || program->status == SIM_PROGRAM_ABORTED) return SIM_PROGRAM_ERR_STATE; /* LinuxCNC EMC_TASK_PLAN_RUN reopens the current task file after an M2/
end-of-file or task abort, then starts again at the requested line
(AXIS uses line zero for the normal Run button). Preserve the current
interpreter position/offset state as the task-plan synchronization
point, but rewind file/control execution before accepting AUTO_RUN. */
if (program->status == SIM_PROGRAM_COMPLETED || program->status == SIM_PROGRAM_ABORTED) {
program->current_line = 0;
program->last_error = 0;
program->error_line = 0;
program->has_motion = 0;
program->call_depth = 0;
program->has_control = 0;
program->control_code = 0;
program->interpreter.state.line = 0;
program->interpreter.state.program_end = 0;
program->interpreter.state.status = SIM_INTERP_OK;
memset(&program->motion, 0, sizeof(program->motion));
memset(program->call_stack, 0, sizeof(program->call_stack));
}
program->status = SIM_PROGRAM_RUNNING; program->status = SIM_PROGRAM_RUNNING;
return SIM_PROGRAM_OK; return SIM_PROGRAM_OK;
} }

View File

@@ -95,6 +95,14 @@ int main(void) {
} }
} }
assert(have_arc && arc.turn == -3); assert(have_arc && arc.turn == -3);
assert(program.status == SIM_PROGRAM_COMPLETED);
assert(sim_program_start(&program) == SIM_PROGRAM_OK);
assert(program.status == SIM_PROGRAM_RUNNING && program.current_line == 0);
assert(program.interpreter.state.program_end == 0);
assert(sim_program_step(&program) == SIM_PROGRAM_OK);
assert(sim_program_abort(&program) == SIM_PROGRAM_OK);
assert(sim_program_start(&program) == SIM_PROGRAM_OK);
assert(program.status == SIM_PROGRAM_RUNNING && program.current_line == 0);
saved_switcher = switcher; saved_switcher = switcher;
saved_pose = pose; saved_pose = pose;

View File

@@ -12,59 +12,58 @@ import { XYZAC_CASE, projectXyzacPanel, type XyzacOperationTrace } from "./xyzac
export const SELECTED_XYZAC_CASE = XYZAC_CASE; export const SELECTED_XYZAC_CASE = XYZAC_CASE;
const DEFAULT_CASE = SELECTED_XYZAC_CASE; const DEFAULT_CASE = SELECTED_XYZAC_CASE;
const PROGRAM_PATH = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc"; const PROGRAM_PATH = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/demos/xyzac_switchkins.ngc";
const DEFAULT_PROGRAM = "";
const normalizeProgramSource = (source: string) => source.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const normalizeProgramSource = (source: string) => source.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
// Button-to-command sequencing is defined in
// docs/linuxcnc-axis-web-control-panel-source-logic.md; React only projects
// Worker gates and emits the documented operation.
type Action = (kind: string, payload?: Record<string, unknown>) => Promise<void> | void; type Action = (kind: string, payload?: Record<string, unknown>) => Promise<void> | void;
type IconButtonProps = { label: string; asset: string; onClick?: () => void; disabled?: boolean; danger?: boolean; pressed?: boolean; feature?: string }; type IconButtonProps = { label: string; asset: string; onClick?: () => void; disabled?: boolean; disabledReason?: string; danger?: boolean; pressed?: boolean; feature?: string };
function IconButton({ label, asset, onClick, disabled = false, danger = false, pressed = false, feature = "AXIS-UI-05" }: IconButtonProps) { function IconButton({ label, asset, onClick, disabled = false, disabledReason, danger = false, pressed = false, feature = "AXIS-UI-05" }: IconButtonProps) {
return <button type="button" data-axis-feature={feature} className={`axis-tool-button${danger ? " danger" : ""}${pressed ? " pressed" : ""}`} onClick={onClick} disabled={disabled} title={label} aria-label={label} aria-pressed={pressed}><img src={axisIcon(asset)} alt="" aria-hidden="true" /></button>; return <button type="button" data-axis-feature={feature} className={`axis-tool-button${danger ? " danger" : ""}${pressed ? " pressed" : ""}`} onClick={onClick} disabled={disabled} title={disabled && disabledReason ? disabledReason : label} aria-label={label} aria-pressed={pressed}><img src={axisIcon(asset)} alt="" aria-hidden="true" /></button>;
} }
function TextButton({ children, onClick, onPointerDown, onPointerUp, onPointerCancel, onPointerLeave, ariaLabel, disabled = false, danger = false, feature = "AXIS-UI-07" }: { children: React.ReactNode; onClick?: () => void; onPointerDown?: React.PointerEventHandler<HTMLButtonElement>; onPointerUp?: React.PointerEventHandler<HTMLButtonElement>; onPointerCancel?: React.PointerEventHandler<HTMLButtonElement>; onPointerLeave?: React.PointerEventHandler<HTMLButtonElement>; ariaLabel?: string; disabled?: boolean; danger?: boolean; feature?: string }) { function TextButton({ children, onClick, onPointerDown, onPointerUp, onPointerCancel, onPointerLeave, ariaLabel, disabled = false, disabledReason, danger = false, feature = "AXIS-UI-07" }: { children: React.ReactNode; onClick?: () => void; onPointerDown?: React.PointerEventHandler<HTMLButtonElement>; onPointerUp?: React.PointerEventHandler<HTMLButtonElement>; onPointerCancel?: React.PointerEventHandler<HTMLButtonElement>; onPointerLeave?: React.PointerEventHandler<HTMLButtonElement>; ariaLabel?: string; disabled?: boolean; disabledReason?: string; danger?: boolean; feature?: string }) {
return <button type="button" data-axis-feature={feature} className={`axis-text-button${danger ? " danger" : ""}`} onClick={onClick} onPointerDown={onPointerDown} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} onPointerLeave={onPointerLeave} aria-label={ariaLabel} disabled={disabled}>{children}</button>; return <button type="button" data-axis-feature={feature} className={`axis-text-button${danger ? " danger" : ""}`} onClick={onClick} onPointerDown={onPointerDown} onPointerUp={onPointerUp} onPointerCancel={onPointerCancel} onPointerLeave={onPointerLeave} aria-label={ariaLabel} disabled={disabled} title={disabled && disabledReason ? disabledReason : undefined}>{children}</button>;
} }
function Separator() { return <span className="axis-toolbar-separator" aria-hidden="true" />; } function Separator() { return <span className="axis-toolbar-separator" aria-hidden="true" />; }
function Menu({ label, children }: { label: string; children: React.ReactNode }) { function Menu({ label, children }: { label: string; children: React.ReactNode }) {
// AXIS defines Machine/View menu bindings, but this Web surface already
// exposes the same commands through the toolbar and notebook tabs. Keep one
// visible entry point per action while retaining the source command flow.
if (label === "Machine" || label === "View") return null;
return <details className="axis-menu-item" data-axis-feature="AXIS-UI-02"><summary>{label}</summary><div className="axis-menu-popup">{children}</div></details>; return <details className="axis-menu-item" data-axis-feature="AXIS-UI-02"><summary>{label}</summary><div className="axis-menu-popup">{children}</div></details>;
} }
function MenuAction({ children, onClick, shortcut, disabled = false }: { children: React.ReactNode; onClick?: () => void; shortcut?: string; disabled?: boolean }) { function MenuAction({ children, onClick, shortcut, disabled = false }: { children: React.ReactNode; onClick?: () => void; shortcut?: string; disabled?: boolean }) { return <button type="button" onClick={onClick} disabled={disabled}><span>{children}</span>{shortcut && <kbd>{shortcut}</kbd>}</button>; }
return <button type="button" onClick={onClick} disabled={disabled}><span>{children}</span>{shortcut && <kbd>{shortcut}</kbd>}</button>;
}
function AxisRadio({ axis, selected, onChange }: { axis: string; selected: string; onChange: (axis: string) => void }) { function AxisRadio({ axis, selected, onChange }: { axis: string; selected: string; onChange: (axis: string) => void }) {
return <label className={`axis-radio${selected === axis ? " selected" : ""}`}><input type="radio" name="axis" value={axis} checked={selected === axis} onChange={() => onChange(axis)} /><span>{axis.toUpperCase()}</span></label>; return <label className={`axis-radio${selected === axis ? " selected" : ""}`}><input type="radio" name="axis" value={axis} checked={selected === axis} onChange={() => onChange(axis)} /><span>{axis.toUpperCase()}</span></label>;
} }
function MachineSequence({ wasmReady, isEstop, machineOn, allHomed, programLoaded, programRunning, programLoading, canPower, canHome, canRun, onEstop, onRelease, onPower, onHomeAll, onRun, onSequence }: { wasmReady: boolean; isEstop: boolean; machineOn: boolean; allHomed: boolean; programLoaded: boolean; programRunning: boolean; programLoading: boolean; canPower: boolean; canHome: boolean; canRun: boolean; onEstop: () => void; onRelease: () => void; onPower: () => void; onHomeAll: () => void; onRun: () => void; onSequence: () => void }) { function MachineSequence({ wasmReady, allHomed, canHome, onHomeAll }: { wasmReady: boolean; allHomed: boolean; canHome: boolean; onHomeAll: () => void }) {
return <section className="machine-sequence" data-axis-feature="AXIS-MACHINE-SEQUENCE" aria-label="Machine sequence"> return <section className="machine-sequence" data-axis-feature="AXIS-MACHINE-SEQUENCE" aria-label="Machine sequence">
<div className="machine-sequence-heading"><strong>Machine sequence</strong><span>{wasmReady ? (isEstop ? "ESTOP" : machineOn ? "ON" : "OFF") : "WASM PENDING"} / {wasmReady ? (allHomed ? "HOME ALL" : "NOT HOMED") : "WASM PENDING"}</span></div> <div className="machine-sequence-heading"><strong>Machine sequence</strong><span>{wasmReady ? (allHomed ? "HOMED" : "NOT HOMED") : "WASM PENDING"}</span></div>
<div className="machine-sequence-actions" role="group" aria-label="Emergency stop, power, homing and program controls"> <div className="machine-sequence-actions" role="group" aria-label="Homing controls">
<TextButton danger disabled={isEstop} onClick={onEstop} ariaLabel="Emergency stop">Emergency Stop</TextButton> <TextButton disabled={!canHome} onClick={onHomeAll} ariaLabel={allHomed ? "Re-home all axes" : "Home all axes"}>{allHomed ? "RE-HOME ALL" : "HOME ALL"}</TextButton>
<TextButton disabled={!isEstop} onClick={onRelease} ariaLabel="Release emergency stop">Release E-stop</TextButton>
<TextButton disabled={!canPower} onClick={onPower} ariaLabel={machineOn ? "Power off" : "Power on"}>{machineOn ? "Power Off" : "Power On"}</TextButton>
<TextButton disabled={!canHome} onClick={onHomeAll} ariaLabel="Home all axes">HOME ALL</TextButton>
<TextButton disabled={!canRun || programLoading} onClick={onRun} ariaLabel="Run program">Run Program</TextButton>
<TextButton feature="AXIS-MACHINE-SEQUENCE" disabled={!programLoaded || !canRun || programRunning || programLoading} onClick={onSequence} ariaLabel="Run startup sequence">Run Startup Sequence</TextButton>
</div> </div>
</section>; </section>;
} }
function ManualControl({ action, canJog, canHome, canSpindle, canCoolant, canTool, canProbe, canTouchOff, canToolTouchOff, onHomeAll, selectedAxis, setSelectedAxis, spindleOn, spindleBrake, coolant, onSpindle, onCoolant, jogScale, onJogScale, devices }: { action: Action; canJog: boolean; canHome: boolean; canSpindle: boolean; canCoolant: boolean; canTool: boolean; canProbe: boolean; canTouchOff: boolean; canToolTouchOff: boolean; onHomeAll: () => void; selectedAxis: string; setSelectedAxis: (axis: string) => void; spindleOn: boolean; spindleBrake: boolean; coolant: { mist: boolean; flood: boolean }; onSpindle: (direction: number) => void; onCoolant: (key: "mist" | "flood") => void; jogScale: number | null; onJogScale: (event: React.ChangeEvent<HTMLInputElement>) => void; devices: Record<string, any> }) { function ManualControl({ action, canJog, canHome, canSpindle, canCoolant, canTool, canProbe, canTouchOff, canToolTouchOff, selectedAxis, setSelectedAxis, spindleDirection, spindleBrake, coolant, onSpindle, onCoolant, onHomeAxis, onTouchOff, onToolTouchOff, jogScale, onJogScale, devices }: { action: Action; canJog: boolean; canHome: boolean; canSpindle: boolean; canCoolant: boolean; canTool: boolean; canProbe: boolean; canTouchOff: boolean; canToolTouchOff: boolean; selectedAxis: string; setSelectedAxis: (axis: string) => void; spindleDirection: number; spindleBrake: boolean; coolant: { mist: boolean; flood: boolean }; onSpindle: (direction: number) => void; onCoolant: (key: "mist" | "flood") => void; onHomeAxis: () => void; onTouchOff: () => void; onToolTouchOff: () => void; jogScale: number | null; onJogScale: (event: React.ChangeEvent<HTMLInputElement>) => void; devices: Record<string, any> }) {
const [increment, setIncrement] = useState("Continuous"); const [increment, setIncrement] = useState("Continuous");
const [jogging, setJogging] = useState(false); const [jogging, setJogging] = useState(false);
const jog = (direction: number) => { setJogging(true); const request = action("JOG_START", { axis: selectedAxis, direction, increment: increment === "Continuous" ? null : Number(increment) }); if (increment !== "Continuous" && request && typeof (request as Promise<void>).then === "function") void (request as Promise<void>).then(() => action("SIM_STEP", { cycles: 1 })); }; const jog = (direction: number) => { setJogging(true); const continuous = increment === "Continuous"; const request = action("JOG_START", { axis: selectedAxis, direction, increment: continuous ? null : Number(increment), continuous }); if (!continuous && request && typeof (request as Promise<void>).then === "function") void (request as Promise<void>).then(() => action("SIM_STEP", { cycles: 1 })); };
const stopJog = () => { if (!jogging) return; setJogging(false); void action("JOG_STOP"); }; const stopJog = () => { if (!jogging) return; setJogging(false); void action("JOG_STOP"); };
return <section className="manual-control" data-axis-feature="AXIS-MANUAL-01" aria-label="Manual Control"><div className="manual-grid"> return <section className="manual-control" data-axis-feature="AXIS-MANUAL-01" aria-label="Manual Control"><div className="manual-grid">
<div className="manual-axis-block"><h3>Axis:</h3><div className="axis-radio-grid">{["x", "y", "z", "a", "c"].map((axis) => <AxisRadio key={axis} axis={axis} selected={selectedAxis} onChange={setSelectedAxis} />)}</div></div> <div className="manual-axis-block"><h3>Axis:</h3><div className="axis-radio-grid">{["x", "y", "z", "a", "c"].map((axis) => <AxisRadio key={axis} axis={axis} selected={selectedAxis} onChange={setSelectedAxis} />)}</div></div>
<div className="manual-jog-block"><h3>Jog:</h3><div className="jog-row"><TextButton disabled={!canJog} onPointerDown={() => jog(-1)} onPointerUp={stopJog} onPointerCancel={stopJog} onPointerLeave={stopJog} ariaLabel={`Jog ${selectedAxis.toUpperCase()} negative`}>-</TextButton><TextButton disabled={!canJog} onPointerDown={() => jog(1)} onPointerUp={stopJog} onPointerCancel={stopJog} onPointerLeave={stopJog} ariaLabel={`Jog ${selectedAxis.toUpperCase()} positive`}>+</TextButton><select value={increment} onChange={(event) => setIncrement(event.target.value)} aria-label="Jog increment"><option>Continuous</option><option value="0.1000">0.1000</option><option value="0.0100">0.0100</option><option value="0.0010">0.0010</option><option value="0.0001">0.0001</option></select><TextButton disabled={!canJog} onClick={stopJog}>Stop</TextButton></div><div className="manual-action-row"><TextButton disabled={!canHome} onClick={() => action("HOME", { axis: selectedAxis })}>Home Axis</TextButton><TextButton disabled={!canHome} onClick={onHomeAll}>Home All</TextButton><TextButton disabled={!canTouchOff} onClick={() => action("MDI_EXECUTE", { block: `G92 ${selectedAxis.toUpperCase()}0` })}>Touch Off</TextButton><TextButton disabled={!canToolTouchOff} onClick={() => action("MDI_EXECUTE", { block: "G43" })}>Tool Touch Off</TextButton></div><label className="check-line"><input type="checkbox" disabled title="LinuxCNC WASM override-limits command unavailable" /> Override Limits (WASM unavailable)</label></div> <div className="manual-jog-block"><h3>Jog:</h3><div className="jog-row"><TextButton disabled={!canJog} onPointerDown={() => jog(-1)} onPointerUp={stopJog} onPointerCancel={stopJog} onPointerLeave={stopJog} ariaLabel={`Jog ${selectedAxis.toUpperCase()} negative`}>-</TextButton><TextButton disabled={!canJog} onPointerDown={() => jog(1)} onPointerUp={stopJog} onPointerCancel={stopJog} onPointerLeave={stopJog} ariaLabel={`Jog ${selectedAxis.toUpperCase()} positive`}>+</TextButton><select value={increment} onChange={(event) => setIncrement(event.target.value)} aria-label="Jog increment"><option>Continuous</option><option value="0.1000">0.1000</option><option value="0.0100">0.0100</option><option value="0.0010">0.0010</option><option value="0.0001">0.0001</option></select><TextButton disabled={!canJog} onClick={stopJog} ariaLabel="Stop jog">Stop Jog</TextButton></div><div className="manual-action-row"><TextButton disabled={!canHome} onClick={onHomeAxis}>Home Axis</TextButton><TextButton disabled={!canTouchOff} onClick={onTouchOff}>Touch Off</TextButton><TextButton disabled={!canToolTouchOff} disabledReason="blocked_by_abi_tool_offsets" onClick={onToolTouchOff}>Tool Touch Off</TextButton></div><label className="check-line"><input type="checkbox" disabled title="LinuxCNC WASM override-limits command unavailable" /> Override Limits (WASM unavailable)</label></div>
<div className="manual-spindle-block"><h3>Spindle:</h3><div className="spindle-row"><IconButton label="Spindle counter-clockwise" asset="spindle_ccw.gif" disabled={!canSpindle} pressed={spindleOn === false} onClick={() => onSpindle(-1)} /><TextButton disabled={!canSpindle} onClick={() => onSpindle(0)}>Stop</TextButton><IconButton label="Spindle clockwise" asset="spindle_cw.gif" disabled={!canSpindle} pressed={spindleOn} onClick={() => onSpindle(1)} /></div><div className="spindle-row"><TextButton disabled={!canSpindle} onClick={() => action("SPINDLE_COMMAND", { spindle: 0, delta: -100 })}>-</TextButton><TextButton disabled={!canSpindle} onClick={() => action("SPINDLE_COMMAND", { spindle: 0, delta: 100 })}>+</TextButton><label className="check-line"><input type="checkbox" checked={spindleBrake} disabled={!canSpindle} onChange={(event) => void action("SPINDLE_BRAKE", { spindle: 0, engaged: event.target.checked })} /> Brake</label></div></div> <div className="manual-spindle-block"><h3>Spindle:</h3><div className="spindle-row"><IconButton label="Spindle counter-clockwise" asset="spindle_ccw.gif" disabled={!canSpindle} pressed={spindleDirection === -1} onClick={() => onSpindle(-1)} /><TextButton disabled={!canSpindle} onClick={() => onSpindle(0)} ariaLabel="Stop spindle">Stop Spindle</TextButton><IconButton label="Spindle clockwise" asset="spindle_cw.gif" disabled={!canSpindle} pressed={spindleDirection === 1} onClick={() => onSpindle(1)} /></div><div className="spindle-row"><TextButton disabled={!canSpindle} onPointerDown={() => action("SPINDLE_COMMAND", { spindle: 0, delta: -100 })} onPointerUp={() => action("SPINDLE_CONSTANT", { spindle: 0 })} onPointerCancel={() => action("SPINDLE_CONSTANT", { spindle: 0 })} ariaLabel="Decrease spindle speed">-</TextButton><TextButton disabled={!canSpindle} onPointerDown={() => action("SPINDLE_COMMAND", { spindle: 0, delta: 100 })} onPointerUp={() => action("SPINDLE_CONSTANT", { spindle: 0 })} onPointerCancel={() => action("SPINDLE_CONSTANT", { spindle: 0 })} ariaLabel="Increase spindle speed">+</TextButton><label className="check-line"><input type="checkbox" checked={spindleBrake} disabled={!canSpindle} onChange={(event) => void action("SPINDLE_BRAKE", { spindle: 0, engaged: event.target.checked })} /> Brake</label></div></div>
<div className="manual-coolant-block"><h3>Coolant:</h3><label className="check-line"><input type="checkbox" checked={coolant.mist} disabled={!canCoolant} onChange={() => onCoolant("mist")} /> Mist</label><label className="check-line"><input type="checkbox" checked={coolant.flood} disabled={!canCoolant} onChange={() => onCoolant("flood")} /> Flood</label></div> <div className="manual-coolant-block"><h3>Coolant:</h3><label className="check-line"><input type="checkbox" checked={coolant.mist} disabled={!canCoolant} onChange={() => onCoolant("mist")} /> Mist</label><label className="check-line"><input type="checkbox" checked={coolant.flood} disabled={!canCoolant} onChange={() => onCoolant("flood")} /> Flood</label></div>
</div><div className="manual-device-block"><h3>Tool / Probe:</h3><div className="manual-action-row"><TextButton disabled={!canTool} onClick={() => action("TOOL_PREPARE", { tool: 1, pocket: 1 })}>Prepare T1</TextButton><TextButton disabled={!canTool} onClick={() => action("TOOL_CHANGE", {})}>Change</TextButton><TextButton disabled={!canProbe} onClick={() => action("PROBE_CONFIG", { action: "arm", probe_type: 0 })}>Arm Probe</TextButton><TextButton disabled={!canProbe} onClick={() => action("PROBE_CONFIG", { action: "disarm" })}>Disarm</TextButton></div><span className="device-status">{devices.tool ? `Tool ${devices.tool.current ?? 0} / ${devices.tool.state ?? "idle"}; Probe ${devices.probe?.state ?? "idle"}` : "WASM state pending"}</span></div><div className="manual-sliders"><label>Jog Speed:<output>{jogScale === null ? "-" : `${Math.round(jogScale * 100)}%`}</output><input type="range" min="6" max="100" value={jogScale === null ? 6 : Math.round(jogScale * 100)} onChange={onJogScale} disabled={!canJog || jogScale === null} /></label></div></section>; </div><div className="manual-device-block"><h3>Tool / Probe:</h3><div className="manual-action-row"><TextButton disabled={!canTool} onClick={() => action("TOOL_PREPARE", { tool: 1, pocket: 1 })}>Prepare T1</TextButton><TextButton disabled={!canTool} onClick={() => action("TOOL_CHANGE", {})}>Change</TextButton><TextButton disabled={!canProbe} onClick={() => action("PROBE_CONFIG", { action: "arm", probe_type: 0 })}>Arm Probe</TextButton><TextButton disabled={!canProbe} onClick={() => action("PROBE_CONFIG", { action: "disarm" })}>Disarm</TextButton></div><span className="device-status">{devices.tool ? `Tool ${devices.tool.current ?? 0} / ${devices.tool.state ?? "idle"}; Probe ${devices.probe?.state ?? "idle"}` : "WASM state pending"}</span></div></section>;
} }
function MdiControl({ action, ready }: { action: Action; ready: boolean }) { function MdiControl({ action, ready }: { action: Action; ready: boolean }) {
@@ -113,7 +112,7 @@ function VirtualizedExecutionTrace({ entries, executing }: { entries: Array<{ li
return <div ref={traceRef} className="program-trace-scroll" onScroll={(event) => { const node = event.currentTarget; followRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < TRACE_ROW_HEIGHT * 1.5; updateWindow(); }}><ol style={{ height: `${Math.max(1, entries.length * TRACE_ROW_HEIGHT)}px` }}>{visible.map((entry, offset) => { const index = windowRange.start + offset; const isCurrent = executing && index === entries.length - 1; return <li key={`${entry.line}-${index}`} className={isCurrent ? "executing-line" : "completed-line"} style={{ position: "absolute", top: `${index * TRACE_ROW_HEIGHT}px`, left: 0, height: `${TRACE_ROW_HEIGHT}px` }} aria-current={isCurrent ? "true" : undefined}><span>L{entry.line}</span><code title={entry.text}>{entry.text || "-"}</code></li>; })}</ol></div>; return <div ref={traceRef} className="program-trace-scroll" onScroll={(event) => { const node = event.currentTarget; followRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < TRACE_ROW_HEIGHT * 1.5; updateWindow(); }}><ol style={{ height: `${Math.max(1, entries.length * TRACE_ROW_HEIGHT)}px` }}>{visible.map((entry, offset) => { const index = windowRange.start + offset; const isCurrent = executing && index === entries.length - 1; return <li key={`${entry.line}-${index}`} className={isCurrent ? "executing-line" : "completed-line"} style={{ position: "absolute", top: `${index * TRACE_ROW_HEIGHT}px`, left: 0, height: `${TRACE_ROW_HEIGHT}px` }} aria-current={isCurrent ? "true" : undefined}><span>L{entry.line}</span><code title={entry.text}>{entry.text || "-"}</code></li>; })}</ol></div>;
} }
function ProgramPane({ text, setText, activeGcodes, executionTrace, programStatus, onLoad, onRun, onStep, canLoad, canRun, canStep, currentLine, executionLine, executionLineText, diagnostic }: { text: string; setText: (text: string) => void; activeGcodes: string; executionTrace: Array<{ line: number; text: string; program_status?: string | null }>; programStatus: string; onLoad: () => void; onRun: () => void; onStep: () => void; canLoad: boolean; canRun: boolean; canStep: boolean; currentLine: number | null; executionLine: number | null; executionLineText: string; diagnostic: string | null }) { function ProgramPane({ text, setText, activeGcodes, executionTrace, programStatus, onLoad, canLoad, currentLine, executionLine, executionLineText, diagnostic }: { text: string; setText: (text: string) => void; activeGcodes: string; executionTrace: Array<{ line: number; text: string; program_status?: string | null }>; programStatus: string; onLoad: () => void; onRun?: () => void; onStep?: () => void; canLoad: boolean; canRun?: boolean; canStep?: boolean; currentLine: number | null; executionLine: number | null; executionLineText: string; diagnostic: string | null }) {
const lines = text.split("\n"); const lines = text.split("\n");
const executing = ["running", "paused"].includes(programStatus.toLowerCase()); const executing = ["running", "paused"].includes(programStatus.toLowerCase());
const mainLine = executing && currentLine && currentLine > 0 && currentLine <= lines.length ? currentLine : null; const mainLine = executing && currentLine && currentLine > 0 && currentLine <= lines.length ? currentLine : null;
@@ -141,7 +140,7 @@ function ProgramPane({ text, setText, activeGcodes, executionTrace, programStatu
}, [mainLine]); }, [mainLine]);
const visibleLines = lines.slice(editorWindow.start, editorWindow.end); const visibleLines = lines.slice(editorWindow.start, editorWindow.end);
const lineClass = (line: number) => line === mainLine ? "current-line" : executedLines.has(line) ? "completed-line" : ""; const lineClass = (line: number) => line === mainLine ? "current-line" : executedLines.has(line) ? "completed-line" : "";
return <section className="axis-program-pane" data-axis-feature="AXIS-UI-07" aria-label="G-code program"><div className="program-main-layout"><div className="program-editor-wrap"><div ref={gutterRef} className="program-gutter" aria-label="G-code line numbers"><div className="program-gutter-content" style={{ height: `${Math.max(PROGRAM_ROW_HEIGHT, lines.length * PROGRAM_ROW_HEIGHT)}px` }}>{visibleLines.map((_, offset) => { const index = editorWindow.start + offset; const line = index + 1; return <span key={index} className={lineClass(line)} style={{ top: `${index * PROGRAM_ROW_HEIGHT}px` }} aria-current={line === mainLine ? "true" : undefined}>{line}</span>; })}</div></div><div className="program-editor-input"><div ref={highlightsRef} className="program-line-highlights" style={{ height: `${Math.max(PROGRAM_ROW_HEIGHT, lines.length * PROGRAM_ROW_HEIGHT)}px` }} aria-hidden="true"><span className="program-highlights-spacer" />{visibleLines.map((_, offset) => { const index = editorWindow.start + offset; return <span key={index} className={lineClass(index + 1)} style={{ top: `${index * PROGRAM_ROW_HEIGHT}px` }} />; })}</div><textarea ref={editorRef} id="axis-program" data-axis-feature="AXIS-EDIT-01" value={text} onChange={(event) => setText(event.target.value)} onScroll={() => { if (editorRef.current) { if (gutterRef.current) gutterRef.current.scrollTop = editorRef.current.scrollTop; if (highlightsRef.current) { highlightsRef.current.scrollTop = editorRef.current.scrollTop; highlightsRef.current.scrollLeft = editorRef.current.scrollLeft; } updateEditorWindow(); } }} spellCheck={false} aria-invalid={Boolean(diagnostic)} aria-label="G-code program" /></div></div><div className="program-execution-panel"><div className="program-execution-line" role="status" aria-label="Currently executing G-code"><span>{executionLine ? `Executing source line ${executionLine}` : "Execution idle"}</span><code title={executionLineText}>{executionLineText || "-"}</code></div><div className="program-execution-trace" role="log" aria-label="G-code execution trace" aria-live="polite"><strong>Subprogram / execution trace <span>({executionTrace.length} cached)</span></strong><VirtualizedExecutionTrace entries={executionTrace} executing={executing} /></div></div></div><aside className="program-side"><div className="program-actions"><TextButton feature="AXIS-EDIT-01" onClick={onLoad} disabled={!canLoad}>Load</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onRun} disabled={!canRun}>Run</TextButton><TextButton feature="AXIS-CTRL-02" onClick={onStep} disabled={!canStep}>Step</TextButton></div><label className="active-gcodes-label">Active G-Codes:<textarea data-axis-feature="AXIS-UI-07" readOnly value={activeGcodes} aria-label="Active G-Codes" /></label>{diagnostic && <output className="program-diagnostic" role="alert" data-axis-feature="AXIS-EDIT-01">{diagnostic}</output>}</aside></section>; return <section className="axis-program-pane" data-axis-feature="AXIS-UI-07" aria-label="G-code program"><div className="program-main-layout"><div className="program-editor-wrap"><div ref={gutterRef} className="program-gutter" aria-label="G-code line numbers"><div className="program-gutter-content" style={{ height: `${Math.max(PROGRAM_ROW_HEIGHT, lines.length * PROGRAM_ROW_HEIGHT)}px` }}>{visibleLines.map((_, offset) => { const index = editorWindow.start + offset; const line = index + 1; return <span key={index} className={lineClass(line)} style={{ top: `${index * PROGRAM_ROW_HEIGHT}px` }} aria-current={line === mainLine ? "true" : undefined}>{line}</span>; })}</div></div><div className="program-editor-input"><div ref={highlightsRef} className="program-line-highlights" style={{ height: `${Math.max(PROGRAM_ROW_HEIGHT, lines.length * PROGRAM_ROW_HEIGHT)}px` }} aria-hidden="true"><span className="program-highlights-spacer" />{visibleLines.map((_, offset) => { const index = editorWindow.start + offset; return <span key={index} className={lineClass(index + 1)} style={{ top: `${index * PROGRAM_ROW_HEIGHT}px` }} />; })}</div><textarea ref={editorRef} id="axis-program" data-axis-feature="AXIS-EDIT-01" value={text} onChange={(event) => setText(event.target.value)} onScroll={() => { if (editorRef.current) { if (gutterRef.current) gutterRef.current.scrollTop = editorRef.current.scrollTop; if (highlightsRef.current) { highlightsRef.current.scrollTop = editorRef.current.scrollTop; highlightsRef.current.scrollLeft = editorRef.current.scrollLeft; } updateEditorWindow(); } }} spellCheck={false} aria-invalid={Boolean(diagnostic)} aria-label="G-code program" /></div></div><div className="program-execution-panel"><div className="program-execution-line" role="status" aria-label="Currently executing G-code"><span>{executionLine ? `Executing source line ${executionLine}` : "Execution idle"}</span><code title={executionLineText}>{executionLineText || "-"}</code></div><div className="program-execution-trace" role="log" aria-label="G-code execution trace" aria-live="polite"><strong>Subprogram / execution trace <span>({executionTrace.length} cached)</span></strong><VirtualizedExecutionTrace entries={executionTrace} executing={executing} /></div></div></div><aside className="program-side"><div className="program-actions"><TextButton feature="AXIS-EDIT-01" onClick={onLoad} disabled={!canLoad}>Load</TextButton></div><label className="active-gcodes-label">Active G-Codes:<textarea data-axis-feature="AXIS-UI-07" readOnly value={activeGcodes} aria-label="Active G-Codes" /></label>{diagnostic && <output className="program-diagnostic" role="alert" data-axis-feature="AXIS-EDIT-01">{diagnostic}</output>}</aside></section>;
} }
function DiagnosticsPanel({ state, gates }: { state: typeof INITIAL_STATE; gates: Record<string, { enabled: boolean; reason: string }> }) { function DiagnosticsPanel({ state, gates }: { state: typeof INITIAL_STATE; gates: Record<string, { enabled: boolean; reason: string }> }) {
@@ -162,11 +161,11 @@ export function App() {
const operationController = useMemo(() => new AxisOperationController(client), [client]); const operationController = useMemo(() => new AxisOperationController(client), [client]);
const [state, setState] = useState(INITIAL_STATE); const [state, setState] = useState(INITIAL_STATE);
const [casePath, setCasePath] = useState<string>(DEFAULT_CASE); const [casePath, setCasePath] = useState<string>(DEFAULT_CASE);
const [caseStatus, setCaseStatus] = useState("ready to load"); const [caseStatus, setCaseStatus] = useState("loading configured program...");
const [commandStatus, setCommandStatus] = useState("Idle"); const [commandStatus, setCommandStatus] = useState("Idle");
const [programText, setProgramTextState] = useState(DEFAULT_PROGRAM); const [programText, setProgramTextState] = useState("");
const [programPath, setProgramPath] = useState(PROGRAM_PATH); const [programPath, setProgramPath] = useState(PROGRAM_PATH);
const [programLoading, setProgramLoading] = useState(false); const [programLoading, setProgramLoading] = useState(true);
const [selectedAxis, setSelectedAxis] = useState("x"); const [selectedAxis, setSelectedAxis] = useState("x");
const [leftTab, setLeftTab] = useState<"manual" | "mdi" | "diagnostics">("manual"); const [leftTab, setLeftTab] = useState<"manual" | "mdi" | "diagnostics">("manual");
const [rightTab, setRightTab] = useState<"preview" | "dro">("preview"); const [rightTab, setRightTab] = useState<"preview" | "dro">("preview");
@@ -176,11 +175,13 @@ export function App() {
const [coordType, setCoordType] = useState<"machine" | "relative">("machine"); const [coordType, setCoordType] = useState<"machine" | "relative">("machine");
const [displayType, setDisplayType] = useState<"actual" | "commanded">("actual"); const [displayType, setDisplayType] = useState<"actual" | "commanded">("actual");
const [metric, setMetric] = useState(true); const [metric, setMetric] = useState(true);
const [touchOffSystem, setTouchOffSystem] = useState("P1 G54");
const [layers, setLayers] = useState({ grid: true, tool: true, path: true, live: true, limits: false, velocity: false, offsets: false, extents: false }); const [layers, setLayers] = useState({ grid: true, tool: true, path: true, live: true, limits: false, velocity: false, offsets: false, extents: false });
const runTimer = useRef<ReturnType<typeof setInterval> | null>(null); const runTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const jogTimer = useRef<ReturnType<typeof setInterval> | null>(null); const jogTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const runStepPending = useRef(false); const runStepPending = useRef(false);
const jogStepPending = useRef(false); const jogStepPending = useRef(false);
const continuousJog = useRef<{ axis: string; direction: number } | null>(null);
const programFileInput = useRef<HTMLInputElement>(null); const programFileInput = useRef<HTMLInputElement>(null);
const [plotCleared, setPlotCleared] = useState(false); const [plotCleared, setPlotCleared] = useState(false);
const [operationTrace, setOperationTrace] = useState<XyzacOperationTrace[]>([]); const [operationTrace, setOperationTrace] = useState<XyzacOperationTrace[]>([]);
@@ -197,7 +198,6 @@ export function App() {
const cachedResponse = await client.loadUiState(UI_CACHE_KEY).catch(() => null); const cachedResponse = await client.loadUiState(UI_CACHE_KEY).catch(() => null);
const cachedPayload = cachedResponse?.payload && typeof cachedResponse.payload === "object" ? cachedResponse.payload as Record<string, unknown> : null; const cachedPayload = cachedResponse?.payload && typeof cachedResponse.payload === "object" ? cachedResponse.payload as Record<string, unknown> : null;
const cached = cachedPayload?.status === "verified" && cachedPayload.state && typeof cachedPayload.state === "object" ? cachedPayload.state as Record<string, unknown> : null; const cached = cachedPayload?.status === "verified" && cachedPayload.state && typeof cachedPayload.state === "object" ? cachedPayload.state as Record<string, unknown> : null;
const cachedProgram = cached?.program && typeof cached.program === "object" ? cached.program as Record<string, unknown> : null;
if (cached) { if (cached) {
if (cached.casePath === DEFAULT_CASE) setCasePath(DEFAULT_CASE); if (cached.casePath === DEFAULT_CASE) setCasePath(DEFAULT_CASE);
if (["x", "y", "z", "a", "c"].includes(String(cached.selectedAxis))) setSelectedAxis(String(cached.selectedAxis)); if (["x", "y", "z", "a", "c"].includes(String(cached.selectedAxis))) setSelectedAxis(String(cached.selectedAxis));
@@ -209,6 +209,7 @@ export function App() {
if (["machine", "relative"].includes(String(cached.coordType))) setCoordType(cached.coordType as "machine" | "relative"); if (["machine", "relative"].includes(String(cached.coordType))) setCoordType(cached.coordType as "machine" | "relative");
if (["actual", "commanded"].includes(String(cached.displayType))) setDisplayType(cached.displayType as "actual" | "commanded"); if (["actual", "commanded"].includes(String(cached.displayType))) setDisplayType(cached.displayType as "actual" | "commanded");
if (typeof cached.metric === "boolean") setMetric(cached.metric); if (typeof cached.metric === "boolean") setMetric(cached.metric);
if (typeof cached.touchOffSystem === "string" && /^(?:P[0-9]|G5[4-9](?:\.[123])?)(?:\s+.*)?$/i.test(cached.touchOffSystem)) setTouchOffSystem(cached.touchOffSystem);
if (cached.layers && typeof cached.layers === "object") { if (cached.layers && typeof cached.layers === "object") {
const savedLayers = cached.layers as Record<string, unknown>; const savedLayers = cached.layers as Record<string, unknown>;
setLayers((current) => { setLayers((current) => {
@@ -227,15 +228,11 @@ export function App() {
const configuration = toWorkerConfiguration(bundle); const configuration = toWorkerConfiguration(bundle);
await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false }); await operationController.execute("LOAD_WORKSPACE", { root_path: "/workspace", configuration }, { state: {}, gate: false });
await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: {}, gate: false }); await operationController.execute("PROGRAM_LOAD", { source: normalizedSource, path: PROGRAM_PATH }, { state: {}, gate: false });
if (typeof cachedProgram?.source === "string" && cachedProgram.source.trim() && cachedProgram.source.length <= 900000 && typeof cachedProgram.path === "string" && cachedProgram.path.length <= 512 && (cachedProgram.source !== normalizedSource || cachedProgram.path !== PROGRAM_PATH)) {
const cachedSource = normalizeProgramSource(cachedProgram.source);
setProgramTextState(cachedSource); setProgramPath(cachedProgram.path);
await operationController.execute("PROGRAM_LOAD", { source: cachedSource, path: cachedProgram.path }, { state: {}, gate: false });
}
uiCacheHydrated.current = true; uiCacheHydrated.current = true;
setOperationTrace(operationController.exportTrace()); setOperationTrace(operationController.exportTrace());
setCaseStatus(`${DEFAULT_CASE} + ${cachedProgram?.path ?? PROGRAM_PATH} loaded`); setCommandStatus("PROGRAM_LOAD acknowledged");
}).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }); setCaseStatus(`${DEFAULT_CASE} + ${PROGRAM_PATH} loaded`);
}).catch((error: Error) => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`Error: ${error.message}`); setCaseStatus(`blocked: ${error.message}`); }).finally(() => setProgramLoading(false));
return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); client.close(); }; return () => { unsubscribe(); if (runTimer.current) clearInterval(runTimer.current); if (jogTimer.current) clearInterval(jogTimer.current); if (previewProgressTimer.current) clearInterval(previewProgressTimer.current); if (feedOverrideTimer.current) clearTimeout(feedOverrideTimer.current); if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); client.close(); };
}, [client, operationController]); }, [client, operationController]);
const axes = state.snapshot?.motion?.axes ?? []; const axes = state.snapshot?.motion?.axes ?? [];
@@ -252,15 +249,26 @@ export function App() {
const isEstop = wasmSnapshotReady && machineState === "ESTOP"; const isEstop = wasmSnapshotReady && machineState === "ESTOP";
const machineOn = wasmSnapshotReady && machineState === "ON"; const machineOn = wasmSnapshotReady && machineState === "ON";
const ready = wasmSnapshotReady && machineOn && !["error", "recovering"].includes(state.status); const ready = wasmSnapshotReady && machineOn && !["error", "recovering"].includes(state.status);
const programLoaded = Boolean(state.program?.source_hash || state.program?.path || state.program?.loaded || state.program?.program_status && state.program.program_status !== "idle");
const programRunning = operationState === "RUNNING"; const programRunning = operationState === "RUNNING";
const programPaused = operationState === "PAUSED"; const programPaused = operationState === "PAUSED";
const idle = operationState === "IDLE"; const idle = operationState === "IDLE";
const wasmGate = (action: string) => state.gates[action] ?? { action, enabled: false, reason: "linuxcnc_wasm_snapshot_pending" }; const wasmGate = (action: string) => state.gates[action] ?? { action, enabled: false, reason: "linuxcnc_wasm_snapshot_pending" };
const gates = { load: wasmGate("load"), run: wasmGate("run"), step: wasmGate("step"), pause: wasmGate("pause"), resume: wasmGate("resume"), abort: wasmGate("abort"), mdi: wasmGate("mdi"), jog: wasmGate("jog"), home: wasmGate("home"), power: wasmGate("power"), reset: wasmGate("reset"), override: wasmGate("override"), spindle: wasmGate("spindle"), coolant: wasmGate("coolant"), tool: wasmGate("tool"), probe: wasmGate("probe"), touchOff: wasmGate("touchOff"), toolTouchOff: wasmGate("toolTouchOff") }; const gates = { load: wasmGate("load"), run: wasmGate("run"), step: wasmGate("step"), pause: wasmGate("pause"), resume: wasmGate("resume"), abort: wasmGate("abort"), mdi: wasmGate("mdi"), jog: wasmGate("jog"), home: wasmGate("home"), power: wasmGate("power"), reset: wasmGate("reset"), override: wasmGate("override"), spindle: wasmGate("spindle"), coolant: wasmGate("coolant"), tool: wasmGate("tool"), probe: wasmGate("probe"), touchOff: wasmGate("touchOff"), toolTouchOff: wasmGate("toolTouchOff") };
const stopRunTimer = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } }; const stopRunTimer = () => { if (runTimer.current) { clearInterval(runTimer.current); runTimer.current = null; } };
const stopJogTimer = () => { if (jogTimer.current) { clearInterval(jogTimer.current); jogTimer.current = null; } }; const stopJogTimer = () => { continuousJog.current = null; if (jogTimer.current) { clearInterval(jogTimer.current); jogTimer.current = null; } };
const startJogTimer = () => { if (jogTimer.current) return; jogTimer.current = setInterval(() => { if (jogStepPending.current) return; jogStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 1, response: "motion_delta" }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { jogStepPending.current = false; }); }, 50); }; const startJogTimer = (axis: string, direction: number) => {
continuousJog.current = { axis, direction };
if (jogTimer.current) return;
jogTimer.current = setInterval(() => {
const jog = continuousJog.current;
if (!jog || jogStepPending.current) return;
jogStepPending.current = true;
void operationController.execute("JOG_START", { axis: jog.axis, direction: jog.direction, increment: null, continuous: true, continuousSample: true }, { state: axisState, gate: false })
.then(() => operationController.execute("SIM_STEP", { cycles: 1, response: "motion_delta" }, { state: axisState, gate: false }))
.catch(() => undefined)
.finally(() => { jogStepPending.current = false; });
}, 50);
};
const startRunTimer = () => { if (runTimer.current) return; runTimer.current = setInterval(() => { if (runStepPending.current) return; runStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 5, response: "motion_delta" }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { runStepPending.current = false; }); }, 50); }; const startRunTimer = () => { if (runTimer.current) return; runTimer.current = setInterval(() => { if (runStepPending.current) return; runStepPending.current = true; operationController.execute("SIM_STEP", { cycles: 5, response: "motion_delta" }, { state: axisState, gate: false }).catch(() => undefined).finally(() => { runStepPending.current = false; }); }, 50); };
const dispatch = async (kind: string, payload: Record<string, unknown> = {}, gate = true, action: string | null = null): Promise<void> => { const dispatch = async (kind: string, payload: Record<string, unknown> = {}, gate = true, action: string | null = null): Promise<void> => {
setCommandStatus(`${kind} pending`); setCommandStatus(`${kind} pending`);
@@ -279,7 +287,7 @@ export function App() {
} }
const pending = operationController.execute(kind, payload, { state: axisState, gate, ...(action ? { action } : {}) }); const pending = operationController.execute(kind, payload, { state: axisState, gate, ...(action ? { action } : {}) });
setOperationTrace(operationController.exportTrace()); setOperationTrace(operationController.exportTrace());
return pending.then(() => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`); if (kind === "PROGRAM_RUN" || kind === "PROGRAM_RESUME") startRunTimer(); if (["PROGRAM_PAUSE", "PROGRAM_ABORT", "PROGRAM_RESTART"].includes(kind)) stopRunTimer(); if (kind === "JOG_START") startJogTimer(); if (kind === "JOG_STOP") stopJogTimer(); }).catch((error: Error) => { return pending.then(() => { setOperationTrace(operationController.exportTrace()); setCommandStatus(`${kind} acknowledged`); if (kind === "PROGRAM_RUN" || kind === "PROGRAM_RESUME") startRunTimer(); if (["PROGRAM_PAUSE", "PROGRAM_ABORT", "PROGRAM_RESTART"].includes(kind)) stopRunTimer(); if (kind === "JOG_START" && payload.continuous === true && payload.continuousSample !== true) startJogTimer(String(payload.axis ?? "x"), Number(payload.direction)); if (kind === "JOG_STOP") stopJogTimer(); }).catch((error: Error) => {
setOperationTrace(operationController.exportTrace()); setOperationTrace(operationController.exportTrace());
const code = error.message; const code = error.message;
setCommandStatus(`${kind} blocked: ${code}`); setCommandStatus(`${kind} blocked: ${code}`);
@@ -288,21 +296,21 @@ export function App() {
}; };
const execute: Action = (kind, payload = {}) => dispatch(kind, payload).catch(() => undefined); const execute: Action = (kind, payload = {}) => dispatch(kind, payload).catch(() => undefined);
const runProgram = () => { if (programLoading) return; void dispatch("PROGRAM_RUN").catch(() => undefined); }; const runProgram = () => { if (programLoading) return; void dispatch("PROGRAM_RUN").catch(() => undefined); };
const stopProgram = () => { const wasRunning = Boolean(runTimer.current) || gates.abort.enabled; stopRunTimer(); if (wasRunning) void dispatch("PROGRAM_ABORT", {}, gates.abort.enabled).catch(() => undefined); }; const stopProgram = () => { const wasRunning = programRunning || programPaused; stopRunTimer(); if (wasRunning) void dispatch("PROGRAM_ABORT", {}, true).catch(() => undefined); };
const emergencyStop = () => { stopRunTimer(); stopJogTimer(); void dispatch("ESTOP", { active: true }, false).catch(() => undefined); }; const emergencyStop = () => { stopRunTimer(); stopJogTimer(); void dispatch("ESTOP", { active: true }, false).catch(() => undefined); };
const releaseEstop = () => dispatch("ESTOP", { active: false }, false); const releaseEstop = () => dispatch("ESTOP", { active: false }, false);
const toggleEstop = () => { if (!isEstop) { emergencyStop(); return; } void releaseEstop().catch(() => undefined); }; const toggleEstop = () => { if (!isEstop) { emergencyStop(); return; } void releaseEstop().catch(() => undefined); };
const powerOn = () => { if (!gates.power.enabled) return; void dispatch("POWER", { active: isEstop ? false : !machineOn }, false).catch(() => undefined); }; const powerOn = () => { if (!gates.power.enabled) return; void dispatch("POWER", { active: isEstop ? false : !machineOn }, false).catch(() => undefined); };
const resetMachine = () => { stopRunTimer(); stopJogTimer(); if (!gates.reset.enabled) return; void dispatch("RESET").catch(() => undefined); }; const homeAll = () => {
const homeAll = () => { void dispatch("HOME", { all: true }).catch(() => undefined); }; const confirm = globalThis.confirm;
const runStartupSequence = () => { if (allHomed && typeof confirm === "function" && !confirm("All joints are already homed. Re-home all joints?")) return;
if (programLoading) return; void dispatch("HOME", { all: true }).catch(() => undefined);
const release = isEstop ? releaseEstop() : Promise.resolve(); };
const power = () => machineOn ? Promise.resolve() : dispatch("POWER", { active: true }, false); const homeAxis = () => {
void release.then(power) const index = ["x", "y", "z", "a", "c"].indexOf(selectedAxis);
.then(() => dispatch("HOME", { all: true }, false)) const confirm = globalThis.confirm;
.then(() => dispatch("PROGRAM_RUN", {}, false)) if (index >= 0 && homedAxes[index] && typeof confirm === "function" && !confirm(`${selectedAxis.toUpperCase()} is already homed. Re-home this joint?`)) return;
.catch(() => undefined); void dispatch("HOME", { axis: selectedAxis }).catch(() => undefined);
}; };
const clearLoadedPreview = async () => { const clearLoadedPreview = async () => {
await operationController.execute("VISUAL_CLEAR", {}, { state: axisState, gate: false }); await operationController.execute("VISUAL_CLEAR", {}, { state: axisState, gate: false });
@@ -370,17 +378,7 @@ export function App() {
setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`); setCaseStatus(`blocked: ${error instanceof Error ? error.message : "case_load_failed"}`);
} finally { setProgramLoading(false); } } finally { setProgramLoading(false); }
}; };
const reopenDefaults = () => { const setProgramText = (value: string) => { setProgramTextState(value); };
if (programLoading || programRunning || !gates.load.enabled) return;
void loadCase();
};
// The legacy menu/toolbar handlers call setProgramText(DEFAULT_PROGRAM).
// Route that action through the same WASM-backed reopen transaction instead
// of changing only a local editor string.
const setProgramText = (value: string) => {
if (value === DEFAULT_PROGRAM) { reopenDefaults(); return; }
setProgramTextState(value);
};
const loadPackagedProgram = async () => { const loadPackagedProgram = async () => {
if (programLoading || programRunning) return; if (programLoading || programRunning) return;
const path = "linuxcnc/nc_files/3D_Chips.ngc"; const path = "linuxcnc/nc_files/3D_Chips.ngc";
@@ -463,24 +461,52 @@ export function App() {
setProgramLoading(false); setProgramLoading(false);
} }
}; };
const enterManualMode = () => { setLeftTab("manual"); if (String(axisState.task.mode).toUpperCase() !== "MANUAL") void dispatch("SET_MODE", { mode: "MANUAL" }, false).catch(() => undefined); }; const enterManualMode = () => {
const enterMdiMode = () => { setLeftTab("mdi"); if (String(axisState.task.mode).toUpperCase() !== "MDI") void dispatch("SET_MODE", { mode: "MDI" }, false).catch(() => undefined); }; setLeftTab("manual");
useEffect(() => { if (!wasmSnapshotReady || programRunning || programPaused || programLoading) return;
if (!wasmSnapshotReady || programRunning || programPaused || programLoading || leftTab === "diagnostics") return; if (String(axisState.task.mode).toUpperCase() === "MANUAL") return;
const desiredMode = leftTab === "mdi" ? "MDI" : "MANUAL"; // Send the explicit tab gesture in the same Worker queue as any following
if (String(axisState.task.mode).toUpperCase() === desiredMode) return; // Run command. A state-sync effect can otherwise arrive after AUTO starts
void dispatch("SET_MODE", { mode: desiredMode }, false).catch(() => undefined); // and be rejected as an invalid AUTO -> MANUAL transition.
}, [leftTab, axisState.task.mode, wasmSnapshotReady, programRunning, programPaused, programLoading]); void dispatch("SET_MODE", { mode: "MANUAL" }, false).catch(() => undefined);
};
const enterMdiMode = () => {
setLeftTab("mdi");
if (!wasmSnapshotReady || programRunning || programPaused || programLoading) return;
if (String(axisState.task.mode).toUpperCase() === "MDI") return;
void dispatch("SET_MODE", { mode: "MDI" }, false).catch(() => undefined);
};
useEffect(() => { useEffect(() => {
const onKeyDown = (event: KeyboardEvent) => { const onKeyDown = (event: KeyboardEvent) => {
const target = event.target as HTMLElement | null; const target = event.target as HTMLElement | null;
if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return; if (target && ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)) return;
if (!event.ctrlKey && event.key === "R") { event.preventDefault(); setCommandStatus("PROGRAM_REVERSE blocked: LinuxCNC reverse command is not exposed by the selected WASM program ABI"); return; }
if (!event.ctrlKey && event.key === "F") { event.preventDefault(); setCommandStatus("PROGRAM_FORWARD blocked: LinuxCNC forward command is not exposed by the selected WASM program ABI"); return; }
if (!event.ctrlKey && event.key === "B") { event.preventDefault(); execute("SPINDLE_BRAKE", { spindle: 0, engaged: true }); return; }
if (!event.ctrlKey && event.key === "b") { event.preventDefault(); execute("SPINDLE_BRAKE", { spindle: 0, engaged: false }); return; }
const key = event.key.toLowerCase(); const key = event.key.toLowerCase();
if (event.ctrlKey && key === "k") { event.preventDefault(); void dispatch("VISUAL_CLEAR", {}, true, "vismachClear").then(() => setPlotCleared(true)).catch(() => undefined); return; } if (event.ctrlKey && key === "k") { event.preventDefault(); void dispatch("VISUAL_CLEAR", {}, true, "vismachClear").then(() => setPlotCleared(true)).catch(() => undefined); return; }
if (event.ctrlKey && key === "r") { event.preventDefault(); loadProgram(); return; }
if (event.ctrlKey && event.key === "Home") { event.preventDefault(); homeAll(); return; }
if (event.ctrlKey && event.key === "End") { event.preventDefault(); promptTouchOff(true); return; }
if (key === "f3") { event.preventDefault(); enterManualMode(); return; } if (key === "f3") { event.preventDefault(); enterManualMode(); return; }
if (key === "f5") { event.preventDefault(); setLeftTab("mdi"); return; } if (key === "f5") { event.preventDefault(); enterMdiMode(); return; }
if (key === "f1") { event.preventDefault(); toggleEstop(); return; } if (key === "f1") { event.preventDefault(); toggleEstop(); return; }
if (key === "f2") { event.preventDefault(); powerOn(); return; } if (key === "f2") { event.preventDefault(); powerOn(); return; }
if (key === "f7") { event.preventDefault(); onCoolant("mist"); return; }
if (key === "f8") { event.preventDefault(); onCoolant("flood"); return; }
if (key === "f9") { event.preventDefault(); onSpindle(spindleDirection === 1 ? 0 : 1); return; }
if (key === "f10") { event.preventDefault(); onSpindle(spindleDirection === -1 ? 0 : -1); return; }
if (key === "f11") { event.preventDefault(); execute("SPINDLE_COMMAND", { spindle: 0, delta: -100 }); return; }
if (key === "f12") { event.preventDefault(); execute("SPINDLE_COMMAND", { spindle: 0, delta: 100 }); return; }
if (key === "v") { event.preventDefault(); setView((current) => current === "x" ? "y" : current === "y" ? "p" : current === "z" ? "z2" : current === "z2" ? "x" : "z"); setRightTab("preview"); return; }
if (key === "d") { event.preventDefault(); setRotateMode((current) => !current); return; }
if (key === "!") { event.preventDefault(); setMetric((current) => !current); return; }
if (key === "@") { event.preventDefault(); setDisplayType((current) => current === "actual" ? "commanded" : "actual"); return; }
if (key === "#") { event.preventDefault(); setCoordType((current) => current === "machine" ? "relative" : "machine"); return; }
if (key === "f4") { event.preventDefault(); setRightTab((current) => current === "preview" ? "dro" : "preview"); return; }
if (event.key === "Home") { event.preventDefault(); homeAxis(); return; }
if (event.key === "End") { event.preventDefault(); promptTouchOff(false); return; }
if (key === "r") { event.preventDefault(); runProgram(); return; } if (key === "r") { event.preventDefault(); runProgram(); return; }
if (key === "t") { event.preventDefault(); execute("PROGRAM_STEP"); return; } if (key === "t") { event.preventDefault(); execute("PROGRAM_STEP"); return; }
if (key === "p") { event.preventDefault(); execute("PROGRAM_PAUSE"); return; } if (key === "p") { event.preventDefault(); execute("PROGRAM_PAUSE"); return; }
@@ -498,15 +524,30 @@ export function App() {
if (!ready) { stopRunTimer(); stopJogTimer(); } if (!ready) { stopRunTimer(); stopJogTimer(); }
}, [programRunning, ready, state.program?.program_status, state.task?.mode, state.snapshot?.wasm_motion]); }, [programRunning, ready, state.program?.program_status, state.task?.mode, state.snapshot?.wasm_motion]);
const spindleState = (state.devices as Record<string, any> | null)?.spindles?.[0] as Record<string, any> | undefined; const spindleState = (state.devices as Record<string, any> | null)?.spindles?.[0] as Record<string, any> | undefined;
const spindleOn = spindleState ? Boolean(spindleState.enabled) : false; const spindleDirection = spindleState?.enabled ? Number(spindleState.command_rpm ?? 0) < 0 ? -1 : 1 : 0;
const spindleBrake = spindleState ? Boolean(spindleState.brake) : false; const spindleBrake = spindleState ? Boolean(spindleState.brake) : false;
const ioState = (state.snapshot?.io as Record<string, any> | undefined)?.state as Record<string, any> | undefined; const ioState = (state.snapshot?.io as Record<string, any> | undefined)?.state as Record<string, any> | undefined;
const manualReady = wasmSnapshotReady && Boolean(ioState?.ready) && !Boolean(ioState?.estop); const manualReady = wasmSnapshotReady && Boolean(ioState?.ready) && !Boolean(ioState?.estop);
const deviceIoEnvelope = state.deviceIo as Record<string, any> | null; const deviceIoEnvelope = state.deviceIo as Record<string, any> | null;
const deviceIoCanonical = (deviceIoEnvelope?.state as Record<string, any> | undefined) ?? deviceIoEnvelope; const deviceIoCanonical = (deviceIoEnvelope?.state as Record<string, any> | undefined) ?? deviceIoEnvelope;
const coolant = { mist: Boolean(deviceIoCanonical?.coolant?.mist ?? deviceIoCanonical?.coolant?.mist_requested), flood: Boolean(deviceIoCanonical?.coolant?.flood ?? deviceIoCanonical?.coolant?.flood_requested) }; const coolant = { mist: Boolean(deviceIoCanonical?.coolant?.mist ?? deviceIoCanonical?.coolant?.mist_requested), flood: Boolean(deviceIoCanonical?.coolant?.flood ?? deviceIoCanonical?.coolant?.flood_requested) };
const onSpindle = (direction: number) => { execute("SPINDLE_COMMAND", { spindle: 0, direction, rpm: direction === 0 ? 0 : 600, enabled: direction !== 0 }); }; const onSpindle = (direction: number) => { execute("SPINDLE_COMMAND", { spindle: 0, direction, rpm: direction === 0 ? 0 : 1, enabled: direction !== 0 }); };
const onCoolant = (key: "mist" | "flood") => { const next = { ...coolant, [key]: !coolant[key] }; execute("COOLANT_COMMAND", { mist: next.mist, flood: next.flood, lube: false }); }; const onCoolant = (key: "mist" | "flood") => { const next = { ...coolant, [key]: !coolant[key] }; execute("COOLANT_COMMAND", { mist: next.mist, flood: next.flood, lube: false }); };
const promptTouchOff = (toolOnly: boolean) => {
if (toolOnly) { setCommandStatus("TOOL_TOUCH_OFF blocked: blocked_by_abi_tool_offsets"); return; }
const prompt = globalThis.prompt;
if (typeof prompt !== "function") return;
const entered = prompt(toolOnly ? `Tool ${toolNumber} Touch Off: enter ${selectedAxis.toUpperCase()} coordinate` : `Touch Off: enter ${selectedAxis.toUpperCase()} coordinate`, "0");
if (entered === null) return;
const value = Number(entered);
if (!Number.isFinite(value)) { setCommandStatus("TOUCH_OFF blocked: touch_off_value_invalid"); return; }
const system = prompt("Coordinate system (P0 Current, P1 G54 ... P9 G59.3)", touchOffSystem);
if (system === null) return;
const normalizedSystem = system.trim().toUpperCase();
if (!/^(?:P[0-9]|G5[4-9](?:\.[123])?)(?:\s+.*)?$/.test(normalizedSystem)) { setCommandStatus("TOUCH_OFF blocked: touch_off_system_invalid"); return; }
setTouchOffSystem(normalizedSystem);
void dispatch(toolOnly ? "TOOL_TOUCH_OFF" : "TOUCH_OFF", { axis: selectedAxis, value, system: normalizedSystem, units: metric ? "mm" : "inch" }).catch(() => undefined);
};
const wasmMotion = (state.snapshot?.wasm_motion as { state?: Record<string, unknown> } | undefined)?.state ?? {}; const wasmMotion = (state.snapshot?.wasm_motion as { state?: Record<string, unknown> } | undefined)?.state ?? {};
const sourceFeedOverride = Number(state.snapshot?.overrides?.feed ?? wasmMotion.feed_override); const sourceFeedOverride = Number(state.snapshot?.overrides?.feed ?? wasmMotion.feed_override);
const feedOverridePercent = Number.isFinite(sourceFeedOverride) ? Math.max(0, Math.min(200, Math.round(sourceFeedOverride * 100))) : null; const feedOverridePercent = Number.isFinite(sourceFeedOverride) ? Math.max(0, Math.min(200, Math.round(sourceFeedOverride * 100))) : null;
@@ -546,7 +587,7 @@ export function App() {
if (!uiCacheHydrated.current || !wasmSnapshotReady) return; if (!uiCacheHydrated.current || !wasmSnapshotReady) return;
if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current); if (uiCacheSaveTimer.current) clearTimeout(uiCacheSaveTimer.current);
const cacheState = { const cacheState = {
version: 1, version: 2,
selectedAxis, selectedAxis,
leftTab, leftTab,
rightTab, rightTab,
@@ -558,24 +599,24 @@ export function App() {
metric, metric,
layers, layers,
casePath, casePath,
program: { path: programPath, source: programText }, touchOffSystem,
}; };
uiCacheSaveTimer.current = setTimeout(() => { uiCacheSaveTimer.current = setTimeout(() => {
uiCacheSaveTimer.current = null; uiCacheSaveTimer.current = null;
void client.saveUiState(cacheState, UI_CACHE_KEY).catch(() => undefined); void client.saveUiState(cacheState, UI_CACHE_KEY).catch(() => undefined);
}, 500); }, 500);
return () => { if (uiCacheSaveTimer.current) { clearTimeout(uiCacheSaveTimer.current); uiCacheSaveTimer.current = null; } }; return () => { if (uiCacheSaveTimer.current) { clearTimeout(uiCacheSaveTimer.current); uiCacheSaveTimer.current = null; } };
}, [client, wasmSnapshotReady, selectedAxis, leftTab, rightTab, view, zoom, rotateMode, coordType, displayType, metric, layers, casePath, programPath, programText]); }, [client, wasmSnapshotReady, selectedAxis, leftTab, rightTab, view, zoom, rotateMode, coordType, displayType, metric, touchOffSystem, layers, casePath]);
return <main className="axis-app" data-axis-feature="AXIS-UI-01" data-axis-authority="linuxcnc-wasm-worker" data-axis-state-source={wasmSnapshotReady ? "linuxcnc-wasm" : "pending"} data-axis-motion-authority={String(state.snapshot?.motion_authority ?? "pending")} data-axis-task-authority={String(state.snapshot?.task_authority ?? "pending")} data-axis-task-mode={String(taskRecord.mode ?? "pending")} data-axis-ui-cache={uiCacheHydrated.current ? "opfs" : "pending"} data-axis-view={view} data-axis-zoom={String(zoom)} data-axis-tp-queue-depth={String((wasmMotion as { queue_depth?: number }).queue_depth ?? "")} data-axis-tp-velocity={String((wasmMotion as { velocity?: number }).velocity ?? "")} data-axis-tp-active-id={String((wasmMotion as { active_id?: number }).active_id ?? "")} data-axis-current-line={String(visibleCurrentLine ?? "")} data-axis-run-enabled={String(gates.run.enabled)} data-axis-homed={JSON.stringify(state.snapshot?.motion?.homed_axes ?? state.snapshot?.motion?.homed ?? null)} data-axis-program-status={String(state.program?.program_status ?? "idle")}> return <main className="axis-app" data-axis-feature="AXIS-UI-01" data-axis-authority="linuxcnc-wasm-worker" data-axis-state-source={wasmSnapshotReady ? "linuxcnc-wasm" : "pending"} data-axis-motion-authority={String(state.snapshot?.motion_authority ?? "pending")} data-axis-task-authority={String(state.snapshot?.task_authority ?? "pending")} data-axis-task-mode={String(taskRecord.mode ?? "pending")} data-axis-ui-cache={uiCacheHydrated.current ? "opfs" : "pending"} data-axis-view={view} data-axis-zoom={String(zoom)} data-axis-tp-queue-depth={String((wasmMotion as { queue_depth?: number }).queue_depth ?? "")} data-axis-tp-velocity={String((wasmMotion as { velocity?: number }).velocity ?? "")} data-axis-tp-active-id={String((wasmMotion as { active_id?: number }).active_id ?? "")} data-axis-current-line={String(visibleCurrentLine ?? "")} data-axis-run-enabled={String(gates.run.enabled && !programLoading)} data-axis-homed={JSON.stringify(state.snapshot?.motion?.homed_axes ?? state.snapshot?.motion?.homed ?? null)} data-axis-program-status={String(state.program?.program_status ?? "idle")} data-axis-program-loading={String(programLoading)} data-axis-program-path={String(state.program?.path ?? "")} data-axis-program-source-hash={String(state.program?.source_hash ?? "")}>
<header className="axis-window-header" data-axis-feature="AXIS-SHELL-01"><div className="axis-brand"><img src={axisIcon("axis-24x24.png")} alt="AXIS" /> <strong>AXIS</strong><span>LinuxCNC Web/WASM</span></div><span className={`connection-state ${state.status}`} data-axis-feature="AXIS-SHELL-01" role="status">{state.status.toUpperCase()}</span><span className="axis-state-badge" data-axis-state={taskState} aria-label={`Machine ${machineState}, operation ${operationState}`}>{taskState}</span></header> <header className="axis-window-header" data-axis-feature="AXIS-SHELL-01"><div className="axis-brand"><img src={axisIcon("axis-24x24.png")} alt="AXIS" /> <strong>AXIS</strong><span>LinuxCNC Web/WASM</span></div><span className={`connection-state ${state.status}`} data-axis-feature="AXIS-SHELL-01" role="status">{state.status.toUpperCase()}</span><span className="axis-state-badge" data-axis-state={taskState} aria-label={`Machine ${machineState}, operation ${operationState}`}>{taskState}</span></header>
<nav className="axis-menubar" data-axis-feature="AXIS-UI-02" aria-label="AXIS menu"><Menu label="File"><MenuAction onClick={() => { setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); }} disabled={programRunning || programLoading}>Reload editor</MenuAction><MenuAction onClick={() => setCommandStatus("Tool data editor is browser-backed")}>Edit tool data...</MenuAction><MenuAction onClick={() => setCommandStatus("Quit is unavailable in a browser tab")}>Quit</MenuAction></Menu><Menu label="Machine"><MenuAction shortcut="F1" onClick={toggleEstop}>{isEstop ? "Cancel Emergency Stop" : "Emergency Stop"}</MenuAction><MenuAction shortcut="F2" onClick={powerOn} disabled={!gates.power.enabled}>{machineOn ? "Power off" : "Power on"}</MenuAction><MenuAction onClick={resetMachine} disabled={!gates.reset.enabled}>Reset machine</MenuAction><MenuAction shortcut="R" onClick={runProgram} disabled={!gates.run.enabled || programLoading}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")} disabled={!gates.step.enabled || programLoading}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")} disabled={!gates.pause.enabled || programLoading}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")} disabled={!gates.resume.enabled || programLoading}>Resume</MenuAction><MenuAction shortcut="Esc" onClick={stopProgram} disabled={!gates.abort.enabled}>Stop</MenuAction></Menu><Menu label="View"><MenuAction onClick={() => setRightTab("preview")}>Preview</MenuAction><MenuAction onClick={() => setRightTab("dro")}>DRO</MenuAction><MenuAction onClick={enterManualMode} disabled={programRunning || programLoading}>Manual Control</MenuAction><MenuAction onClick={enterMdiMode} disabled={programRunning || programLoading}>MDI</MenuAction></Menu><Menu label="Help"><MenuAction onClick={() => setCommandStatus("LinuxCNC AXIS source-compatible controls")}>About AXIS</MenuAction></Menu></nav> <nav className="axis-menubar" data-axis-feature="AXIS-UI-02" aria-label="AXIS menu"><Menu label="File"><MenuAction onClick={loadProgram} disabled={programRunning || programLoading}>Reload editor</MenuAction><MenuAction onClick={() => setCommandStatus("Tool data editor is browser-backed")}>Edit tool data...</MenuAction><MenuAction onClick={() => setCommandStatus("Quit is unavailable in a browser tab")}>Quit</MenuAction></Menu><Menu label="Machine"><MenuAction shortcut="F1" onClick={toggleEstop}>{isEstop ? "Cancel Emergency Stop" : "Emergency Stop"}</MenuAction><MenuAction shortcut="F2" onClick={powerOn} disabled={!gates.power.enabled}>{machineOn ? "Power off" : "Power on"}</MenuAction><MenuAction shortcut="R" onClick={runProgram} disabled={!gates.run.enabled || programLoading}>Run program</MenuAction><MenuAction shortcut="T" onClick={() => execute("PROGRAM_STEP")} disabled={!gates.step.enabled || programLoading}>Step</MenuAction><MenuAction shortcut="P" onClick={() => execute("PROGRAM_PAUSE")} disabled={!gates.pause.enabled || programLoading}>Pause</MenuAction><MenuAction shortcut="S" onClick={() => execute("PROGRAM_RESUME")} disabled={!gates.resume.enabled || programLoading}>Resume</MenuAction><MenuAction shortcut="Esc" onClick={stopProgram} disabled={!programRunning && !programPaused}>Stop</MenuAction></Menu><Menu label="View"><MenuAction onClick={() => setRightTab("preview")}>Preview</MenuAction><MenuAction onClick={() => setRightTab("dro")}>DRO</MenuAction><MenuAction onClick={enterManualMode} disabled={programRunning || programLoading}>Manual Control</MenuAction><MenuAction onClick={enterMdiMode} disabled={programRunning || programLoading}>MDI</MenuAction></Menu><Menu label="Help"><MenuAction onClick={() => setCommandStatus("LinuxCNC AXIS source-compatible controls")}>About AXIS</MenuAction></Menu></nav>
<section className="axis-toolbar" data-axis-feature="AXIS-UI-05" aria-label="AXIS toolbar"><IconButton feature="AXIS-MACHINE-01" label={isEstop ? "Cancel Emergency Stop [F1]" : "Emergency Stop [F1]"} asset="tool_estop.gif" danger onClick={toggleEstop} pressed={isEstop} /><IconButton feature="AXIS-MACHINE-02" label={machineOn ? "Power off [F2]" : "Power on [F2]"} asset="tool_power.gif" disabled={!gates.power.enabled} onClick={powerOn} /><IconButton feature="AXIS-MACHINE-03" label="Reset machine" asset="tool_reload.gif" disabled={!gates.reset.enabled} onClick={resetMachine} /><Separator /><IconButton feature="AXIS-FILE-01" label="Open G-Code file [O]" asset="tool_open.gif" disabled={programRunning || programLoading} onClick={openProgramFile} /><IconButton feature="AXIS-FILE-02" label="Load exact XYZAC TRT case" asset="tool_verify.gif" disabled={!gates.load.enabled} onClick={loadCase} /><IconButton feature="AXIS-FILE-04" label="Reopen editor defaults" asset="tool_reload.gif" onClick={() => { setProgramText(DEFAULT_PROGRAM); setProgramPath(PROGRAM_PATH); setPlotCleared(false); }} /><Separator /><IconButton feature="AXIS-MACHINE-03" label="Begin executing current file [R]" asset="tool_run.gif" disabled={!gates.run.enabled} onClick={runProgram} /><IconButton feature="AXIS-MACHINE-05" label="Execute next line [T]" asset="tool_step.gif" disabled={!gates.step.enabled} onClick={() => execute("PROGRAM_STEP")} /><IconButton feature="AXIS-MACHINE-06" label={programPaused ? "Resume execution [S]" : "Pause execution [P]"} asset={programPaused ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={programPaused ? !gates.resume.enabled : !gates.pause.enabled} onClick={() => execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton feature="AXIS-MACHINE-07" label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!gates.abort.enabled} onClick={stopProgram} /><Separator /><IconButton feature="AXIS-MACHINE-09" label="Toggle skip lines with slash" asset="tool_blockdelete.gif" disabled /><IconButton feature="AXIS-MACHINE-08" label="Toggle optional pause" asset="tool_optpause.gif" disabled /><Separator /><IconButton feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-15" label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} /></section> <section className="axis-toolbar" data-axis-feature="AXIS-UI-05" aria-label="AXIS toolbar"><IconButton feature="AXIS-MACHINE-01" label={isEstop ? "Cancel Emergency Stop [F1]" : "Emergency Stop [F1]"} asset="tool_estop.gif" danger onClick={toggleEstop} pressed={isEstop} /><IconButton feature="AXIS-MACHINE-02" label={machineOn ? "Power off [F2]" : "Power on [F2]"} asset="tool_power.gif" disabled={!gates.power.enabled || programLoading} disabledReason={programLoading ? "axis_program_load_in_progress" : gates.power.reason} onClick={powerOn} /><Separator /><IconButton feature="AXIS-FILE-01" label="Open G-Code file [O]" asset="tool_open.gif" disabled={programRunning || programLoading} onClick={openProgramFile} /><IconButton feature="AXIS-FILE-02" label="Load exact XYZAC TRT case" asset="tool_verify.gif" disabled={!gates.load.enabled || programLoading} disabledReason={programLoading ? "axis_program_load_in_progress" : gates.load.reason} onClick={loadCase} /><IconButton feature="AXIS-FILE-04" label="Reload current G-code [Ctrl-R]" asset="tool_reload.gif" disabled={!gates.load.enabled || programLoading} disabledReason={programLoading ? "axis_program_load_in_progress" : gates.load.reason} onClick={loadProgram} /><Separator /><IconButton feature="AXIS-MACHINE-03" label="Begin executing current file [R]" asset="tool_run.gif" disabled={!gates.run.enabled || programLoading} disabledReason={programLoading ? "axis_program_load_in_progress" : gates.run.reason} onClick={runProgram} /><IconButton feature="AXIS-MACHINE-05" label="Execute next line [T]" asset="tool_step.gif" disabled={!gates.step.enabled || programLoading} disabledReason={programLoading ? "axis_program_load_in_progress" : gates.step.reason} onClick={() => execute("PROGRAM_STEP")} /><IconButton feature="AXIS-MACHINE-06" label={programPaused ? "Resume execution [S]" : "Pause execution [P]"} asset={programPaused ? "resume_inhibit.gif" : "tool_pause.gif"} disabled={programLoading || (programPaused ? !gates.resume.enabled : !gates.pause.enabled)} disabledReason={programLoading ? "axis_program_load_in_progress" : programPaused ? gates.resume.reason : gates.pause.reason} onClick={() => execute(programPaused ? "PROGRAM_RESUME" : "PROGRAM_PAUSE")} /><IconButton feature="AXIS-MACHINE-07" label="Stop program execution [ESC]" asset="tool_stop.gif" disabled={!programRunning && !programPaused} disabledReason={!programRunning && !programPaused ? "axis_stop_state_gate" : gates.abort.reason} onClick={stopProgram} /><Separator /><IconButton feature="AXIS-MACHINE-09" label="Toggle skip lines with slash" asset="tool_blockdelete.gif" disabled disabledReason="blocked_by_abi: LinuxCNC block-delete state is unavailable" /><IconButton feature="AXIS-MACHINE-08" label="Toggle optional pause" asset="tool_optpause.gif" disabled disabledReason="blocked_by_abi: LinuxCNC optional-stop state is unavailable" /><Separator /><IconButton feature="AXIS-VIEW-01" label="Zoom in" asset="tool_zoomin.gif" onClick={() => setZoom((value) => Math.min(2, value + .2))} /><IconButton feature="AXIS-VIEW-01" label="Zoom out" asset="tool_zoomout.gif" onClick={() => setZoom((value) => Math.max(.5, value - .2))} /><IconButton feature="AXIS-VIEW-01" label="Top view" asset="tool_axis_z.gif" pressed={view === "z"} onClick={() => { setView("z"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Rotated top view" asset="tool_axis_z2.gif" pressed={view === "z2"} onClick={() => { setView("z2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Side view" asset="tool_axis_x.gif" pressed={view === "x"} onClick={() => { setView("x"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Front view" asset="tool_axis_y.gif" pressed={view === "y"} onClick={() => { setView("y"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Inverted front view" asset="tool_axis_y2.png" pressed={view === "y2"} onClick={() => { setView("y2"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-01" label="Perspective view" asset="tool_axis_p.gif" pressed={view === "p"} onClick={() => { setView("p"); setRightTab("preview"); }} /><IconButton feature="AXIS-VIEW-02" label="Drag / rotate mode" asset="tool_rotate.gif" pressed={rotateMode} onClick={() => setRotateMode((value) => !value)} /><Separator /><IconButton feature="AXIS-VIEW-15" label="Clear live plot [Ctrl-K]" asset="tool_clear.gif" onClick={() => xyzacCommand("VISUAL_CLEAR", {}, "vismachClear")} /></section>
<section className="axis-case-strip" data-axis-feature="AXIS-EDIT-03"><label>Simulation case<select data-axis-feature="AXIS-EDIT-03" value={casePath} onChange={(event) => setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path === SELECTED_XYZAC_CASE).map((entry) => <option key={entry.path} value={entry.path}>{entry.label} | XYZAC TRT</option>)}</select></label><TextButton feature="AXIS-EDIT-03" onClick={loadCase} disabled={!gates.load.enabled || programLoading}>Load case</TextButton><TextButton feature="AXIS-EDIT-03" onClick={loadPackagedProgram} disabled={!gates.load.enabled || programLoading}>Load 3D_Chips.ngc</TextButton><span className={`case-status${programLoading ? " loading" : ""}`} role="status" aria-live="polite" aria-busy={programLoading}>{programLoading ? "PROGRAM_LOAD pending: LinuxCNC WASM preview in progress..." : caseStatus}</span>{previewProgress && <div className={`preview-load-indicator ${previewProgress.phase}`} role="status" aria-live="polite" aria-label="Toolpath preview loading progress"><span title={previewProgress.detail}>{previewProgress.detail}</span><progress max="100" value={previewProgress.percent}>{previewProgress.percent}%</progress><output>{previewProgress.percent}%</output></div>}<span className="command-status" role="status">{commandStatus}</span></section> <section className="axis-case-strip" data-axis-feature="AXIS-EDIT-03"><label>Simulation case<select data-axis-feature="AXIS-EDIT-03" value={casePath} onChange={(event) => setCasePath(event.target.value)}>{SIM_CASE_CATALOG.filter((entry) => entry.path === SELECTED_XYZAC_CASE).map((entry) => <option key={entry.path} value={entry.path}>{entry.label} | XYZAC TRT</option>)}</select></label><TextButton feature="AXIS-EDIT-03" onClick={loadPackagedProgram} disabled={!gates.load.enabled || programLoading}>Load 3D_Chips.ngc</TextButton><span className={`case-status${programLoading ? " loading" : ""}`} role="status" aria-live="polite" aria-busy={programLoading}>{programLoading ? "PROGRAM_LOAD pending: LinuxCNC WASM preview in progress..." : caseStatus}</span>{previewProgress && <div className={`preview-load-indicator ${previewProgress.phase}`} role="status" aria-live="polite" aria-label="Toolpath preview loading progress"><span title={previewProgress.detail}>{previewProgress.detail}</span><progress max="100" value={previewProgress.percent}>{previewProgress.percent}%</progress><output>{previewProgress.percent}%</output></div>}<span className="command-status" role="status">{commandStatus}</span></section>
<MachineSequence wasmReady={wasmSnapshotReady} isEstop={isEstop} machineOn={machineOn} allHomed={allHomed} programLoaded={programLoaded} programRunning={programRunning} programLoading={programLoading} canPower={gates.power.enabled} canHome={gates.home.enabled} canRun={gates.run.enabled} onEstop={emergencyStop} onRelease={() => { void releaseEstop().catch(() => undefined); }} onPower={powerOn} onHomeAll={homeAll} onRun={runProgram} onSequence={runStartupSequence} /> <MachineSequence wasmReady={wasmSnapshotReady} allHomed={allHomed} canHome={gates.home.enabled && !programLoading} onHomeAll={homeAll} />
<XyzacTrtPanel view={xyzacView} command={xyzacCommand} /> <XyzacTrtPanel view={xyzacView} command={xyzacCommand} loading={programLoading} />
<XyzacLiveParameters snapshot={state.snapshot} /> <XyzacLiveParameters snapshot={state.snapshot} />
<section className="axis-top-pane" data-axis-feature="AXIS-UI-06"><div className="axis-notebook left-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Manual, MDI and diagnostics controls"><button id="axis-tab-manual" type="button" role="tab" aria-controls="axis-panel-manual" aria-selected={leftTab === "manual"} className={leftTab === "manual" ? "active" : ""} onClick={enterManualMode}>Manual Control [F3]</button><button id="axis-tab-mdi" type="button" role="tab" aria-controls="axis-panel-mdi" aria-selected={leftTab === "mdi"} className={leftTab === "mdi" ? "active" : ""} onClick={enterMdiMode}>MDI [F5]</button><button id="axis-tab-diagnostics" type="button" role="tab" aria-controls="axis-panel-diagnostics" aria-selected={leftTab === "diagnostics"} className={leftTab === "diagnostics" ? "active" : ""} onClick={() => setLeftTab("diagnostics")}>Diagnostics</button></div><div id="axis-panel-manual" role="tabpanel" aria-labelledby="axis-tab-manual" hidden={leftTab !== "manual"}><ManualControl action={execute} canJog={manualReady && gates.jog.enabled} canHome={gates.home.enabled} canSpindle={manualReady && Boolean(gates.spindle?.enabled)} canCoolant={wasmSnapshotReady && Boolean(gates.coolant?.enabled)} canTool={manualReady && Boolean(gates.tool?.enabled)} canProbe={manualReady && Boolean(gates.probe?.enabled)} canTouchOff={manualReady && Boolean(gates.touchOff?.enabled)} canToolTouchOff={manualReady && Boolean(gates.toolTouchOff?.enabled)} onHomeAll={homeAll} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleOn={spindleOn} spindleBrake={spindleBrake} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} jogScale={Number.isFinite(sourceJogScale) ? sourceJogScale : null} onJogScale={(event) => void dispatch("SET_JOG_SCALE", { percent: Number(event.target.value) })} devices={state.devices ?? {}} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={manualReady && gates.mdi.enabled} /></div><div id="axis-panel-diagnostics" role="tabpanel" aria-labelledby="axis-tab-diagnostics" hidden={leftTab !== "diagnostics"}><DiagnosticsPanel state={state} gates={gates} /><XyzacDiagnostics view={xyzacView} operations={operationTrace} alarms={state.alarms} error={state.error} /></div></div><div className="axis-notebook right-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Preview and DRO"><button id="axis-tab-preview" type="button" role="tab" aria-controls="axis-panel-preview" aria-selected={rightTab === "preview"} className={rightTab === "preview" ? "active" : ""} onClick={() => setRightTab("preview")}>Preview</button><button id="axis-tab-dro" type="button" role="tab" aria-controls="axis-panel-dro" aria-selected={rightTab === "dro"} className={rightTab === "dro" ? "active" : ""} onClick={() => setRightTab("dro")}>DRO</button></div><div id="axis-panel-preview" role="tabpanel" aria-labelledby="axis-tab-preview" className="axis-preview" hidden={rightTab !== "preview"}><XyzacToolPreviewFromSnapshot snapshot={programLoading ? null : state.snapshot} view={view} zoom={zoom} rotateMode={rotateMode} showPath={layers.path} showLive={layers.live} showLimits={layers.limits} showVelocity={layers.velocity} showOffsets={layers.offsets} showExtents={layers.extents} /></div><div id="axis-panel-dro" role="tabpanel" aria-labelledby="axis-panel-dro" hidden={rightTab !== "dro"}><DroView axes={axes} state={state} coordType={coordType} displayType={displayType} metric={metric} setCoordType={setCoordType} setDisplayType={setDisplayType} setMetric={setMetric} /></div></div></section> <section className="axis-top-pane" data-axis-feature="AXIS-UI-06"><div className="axis-notebook left-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Manual, MDI and diagnostics controls"><button id="axis-tab-manual" type="button" role="tab" aria-controls="axis-panel-manual" aria-selected={leftTab === "manual"} className={leftTab === "manual" ? "active" : ""} onClick={enterManualMode}>Manual Control [F3]</button><button id="axis-tab-mdi" type="button" role="tab" aria-controls="axis-panel-mdi" aria-selected={leftTab === "mdi"} className={leftTab === "mdi" ? "active" : ""} onClick={enterMdiMode}>MDI [F5]</button><button id="axis-tab-diagnostics" type="button" role="tab" aria-controls="axis-panel-diagnostics" aria-selected={leftTab === "diagnostics"} className={leftTab === "diagnostics" ? "active" : ""} onClick={() => setLeftTab("diagnostics")}>Diagnostics</button></div><div id="axis-panel-manual" role="tabpanel" aria-labelledby="axis-tab-manual" hidden={leftTab !== "manual"}><ManualControl action={execute} canJog={!programLoading && manualReady && gates.jog.enabled} canHome={!programLoading && gates.home.enabled} canSpindle={!programLoading && manualReady && Boolean(gates.spindle?.enabled)} canCoolant={!programLoading && wasmSnapshotReady && Boolean(gates.coolant?.enabled)} canTool={!programLoading && manualReady && Boolean(gates.tool?.enabled)} canProbe={!programLoading && manualReady && Boolean(gates.probe?.enabled)} canTouchOff={!programLoading && manualReady && Boolean(gates.touchOff?.enabled)} canToolTouchOff={!programLoading && manualReady && Boolean(gates.toolTouchOff?.enabled)} selectedAxis={selectedAxis} setSelectedAxis={setSelectedAxis} spindleDirection={spindleDirection} spindleBrake={spindleBrake} coolant={coolant} onSpindle={onSpindle} onCoolant={onCoolant} onHomeAxis={homeAxis} onTouchOff={() => promptTouchOff(false)} onToolTouchOff={() => promptTouchOff(true)} jogScale={Number.isFinite(sourceJogScale) ? sourceJogScale : null} onJogScale={(event) => void dispatch("SET_JOG_SCALE", { percent: Number(event.target.value) })} devices={state.devices ?? {}} /></div><div id="axis-panel-mdi" role="tabpanel" aria-labelledby="axis-tab-mdi" hidden={leftTab !== "mdi"}><MdiControl action={execute} ready={!programLoading && manualReady && gates.mdi.enabled} /></div><div id="axis-panel-diagnostics" role="tabpanel" aria-labelledby="axis-tab-diagnostics" hidden={leftTab !== "diagnostics"}><DiagnosticsPanel state={state} gates={gates} /><XyzacDiagnostics view={xyzacView} operations={operationTrace} alarms={state.alarms} error={state.error} /></div></div><div className="axis-notebook right-notebook" data-axis-feature="AXIS-UI-06"><div className="notebook-tabs" role="tablist" aria-label="Preview and DRO"><button id="axis-tab-preview" type="button" role="tab" aria-controls="axis-panel-preview" aria-selected={rightTab === "preview"} className={rightTab === "preview" ? "active" : ""} onClick={() => setRightTab("preview")}>Preview</button><button id="axis-tab-dro" type="button" role="tab" aria-controls="axis-panel-dro" aria-selected={rightTab === "dro"} className={rightTab === "dro" ? "active" : ""} onClick={() => setRightTab("dro")}>DRO</button></div><div id="axis-panel-preview" role="tabpanel" aria-labelledby="axis-tab-preview" className="axis-preview" hidden={rightTab !== "preview"}><XyzacToolPreviewFromSnapshot snapshot={programLoading ? null : state.snapshot} view={view} zoom={zoom} rotateMode={rotateMode} showPath={layers.path} showLive={layers.live} showLimits={layers.limits} showVelocity={layers.velocity} showOffsets={layers.offsets} showExtents={layers.extents} /></div><div id="axis-panel-dro" role="tabpanel" aria-labelledby="axis-tab-dro" hidden={rightTab !== "dro"}><DroView axes={axes} state={state} coordType={coordType} displayType={displayType} metric={metric} setCoordType={setCoordType} setDisplayType={setDisplayType} setMetric={setMetric} /></div></div></section>
<section className="axis-metrics" data-axis-feature="AXIS-VIEW-03"><div className="axis-slider"><label>Jog Speed:<output>{jogScalePercent === null ? "-" : `${jogScalePercent}%`}</output><input type="range" min="6" max="100" value={jogScalePercent ?? 6} onChange={(event) => void dispatch("SET_JOG_SCALE", { percent: Number(event.target.value) })} disabled={!gates.jog.enabled || jogScalePercent === null} aria-label="Jog speed" /></label><label>Feed Override:<output>{feedOverridePercent === null ? "-" : `${feedOverridePercent}%`}</output><input type="range" min="0" max="200" step="1" value={feedOverridePercent ?? 0} onChange={requestFeedOverride} disabled={!gates.override.enabled || programLoading || feedOverridePercent === null} aria-label="Feed override" aria-valuetext={feedOverridePercent === null ? "WASM state pending" : `${feedOverridePercent}%`} data-axis-feed-override={feedOverridePercent ?? ""} /></label><label>Spindle Override:<output>{spindleOverridePercent === null ? "-" : `${spindleOverridePercent}%`}</output><input type="range" min="0" max="200" value={spindleOverridePercent ?? 0} onChange={(event) => void dispatch("SET_SPINDLE_OVERRIDE", { spindle: 0, scale: Number(event.target.value) / 100 })} disabled={!gates.override.enabled || spindleOverridePercent === null} aria-label="Spindle override" /></label><label>Max Velocity:<output>{maxVelocityPercent === null ? "-" : `${maxVelocityPercent}%`}</output><input type="range" min="6" max="100" value={maxVelocityPercent ?? 6} onChange={(event) => void dispatch("SET_MAX_VELOCITY", { percent: Number(event.target.value) })} disabled={!gates.override.enabled || maxVelocityPercent === null} aria-label="Max velocity" /></label></div><div className="axis-layer-controls" aria-label="Preview layers">{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => <label key={layer}><input type="checkbox" data-axis-feature="AXIS-VIEW-03" checked={layers[layer]} onChange={(event) => setLayers((current) => ({ ...current, [layer]: event.target.checked }))} /> {layer}</label>)}</div></section> <section className="axis-metrics" data-axis-feature="AXIS-VIEW-03"><div className="axis-slider"><label>Jog Speed:<output>{jogScalePercent === null ? "-" : `${jogScalePercent}%`}</output><input type="range" min="6" max="100" value={jogScalePercent ?? 6} onChange={(event) => void dispatch("SET_JOG_SCALE", { percent: Number(event.target.value) })} disabled={programLoading || !gates.jog.enabled || jogScalePercent === null} aria-label="Jog speed" /></label><label>Feed Override:<output>{feedOverridePercent === null ? "-" : `${feedOverridePercent}%`}</output><input type="range" min="0" max="200" step="1" value={feedOverridePercent ?? 0} onChange={requestFeedOverride} disabled={!gates.override.enabled || programLoading || feedOverridePercent === null} aria-label="Feed override" aria-valuetext={feedOverridePercent === null ? "WASM state pending" : `${feedOverridePercent}%`} data-axis-feed-override={feedOverridePercent ?? ""} /></label><label>Spindle Override:<output>{spindleOverridePercent === null ? "-" : `${spindleOverridePercent}%`}</output><input type="range" min="0" max="200" value={spindleOverridePercent ?? 0} onChange={(event) => void dispatch("SET_SPINDLE_OVERRIDE", { spindle: 0, scale: Number(event.target.value) / 100 })} disabled={programLoading || !gates.override.enabled || spindleOverridePercent === null} aria-label="Spindle override" /></label><label>Max Velocity:<output>{maxVelocityPercent === null ? "-" : `${maxVelocityPercent}%`}</output><input type="range" min="6" max="100" value={maxVelocityPercent ?? 6} onChange={(event) => void dispatch("SET_MAX_VELOCITY", { percent: Number(event.target.value) })} disabled={programLoading || !gates.override.enabled || maxVelocityPercent === null} aria-label="Max velocity" /></label><label title="blocked_by_abi: LinuxCNC rapidrate is not exposed by the selected WASM TP"><span>Rapid Override</span><output>Unavailable</output><input type="range" min="0" max="120" value="0" disabled aria-label="Rapid override" /></label></div><div className="axis-layer-controls" aria-label="Preview layers">{(["path", "live", "limits", "velocity", "offsets", "extents"] as const).map((layer) => <label key={layer}><input type="checkbox" data-axis-feature="AXIS-VIEW-03" checked={layers[layer]} onChange={(event) => setLayers((current) => ({ ...current, [layer]: event.target.checked }))} /> {layer}</label>)}</div></section>
<XyzacProgramPane status={visibleProgramStatus} currentLine={visibleCurrentLine} sourceHash={programLoading ? "" : String(state.program?.source_hash ?? "")}><ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} executionTrace={visibleExecutionTrace} programStatus={visibleProgramStatus} onLoad={loadProgram} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} canLoad={gates.load.enabled && !programLoading} canRun={gates.run.enabled && !programLoading} canStep={gates.step.enabled && !programLoading} currentLine={visibleCurrentLine} executionLine={visibleExecutionLine} executionLineText={visibleExecutionLineText} diagnostic={state.error?.code ?? null} /></XyzacProgramPane> <XyzacProgramPane status={visibleProgramStatus} currentLine={visibleCurrentLine} sourceHash={programLoading ? "" : String(state.program?.source_hash ?? "")}><ProgramPane text={programText} setText={setProgramText} activeGcodes={activeGcodes} executionTrace={visibleExecutionTrace} programStatus={visibleProgramStatus} onLoad={loadProgram} onRun={runProgram} onStep={() => execute("PROGRAM_STEP")} canLoad={gates.load.enabled && !programLoading} canRun={gates.run.enabled && !programLoading} canStep={gates.step.enabled && !programLoading} currentLine={visibleCurrentLine} executionLine={visibleExecutionLine} executionLineText={visibleExecutionLineText} diagnostic={state.error?.code ?? null} /></XyzacProgramPane>
<input ref={programFileInput} className="visually-hidden-file-input" type="file" accept=".ngc,.nc,.gcode,.tap,text/plain" onChange={onProgramFile} aria-label="Open G-code file" /> <input ref={programFileInput} className="visually-hidden-file-input" type="file" accept=".ngc,.nc,.gcode,.tap,text/plain" onChange={onProgramFile} aria-label="Open G-code file" />
<footer className="axis-info-bar" data-axis-feature="AXIS-UI-07"><span className={`task-state ${state.status}`} data-axis-feature="AXIS-SHELL-01">{taskState}</span><span className="machine-state" data-axis-state-machine={machineState}>Machine {machineState}</span><span className="operation-state" data-axis-state-operation={operationState}>Operation {operationState}</span><span className="tool-state" data-axis-feature="AXIS-UI-07">Tool {toolNumber}</span><span className="position-state" data-axis-feature="AXIS-VIEW-17">{position}</span><span className="footer-status" role="status">{state.error ? `Error: ${state.error.code}` : commandStatus}</span></footer> <footer className="axis-info-bar" data-axis-feature="AXIS-UI-07"><span className={`task-state ${state.status}`} data-axis-feature="AXIS-SHELL-01">{taskState}</span><span className="machine-state" data-axis-state-machine={machineState}>Machine {machineState}</span><span className="operation-state" data-axis-state-operation={operationState}>Operation {operationState}</span><span className="tool-state" data-axis-feature="AXIS-UI-07">Tool {toolNumber}</span><span className="position-state" data-axis-feature="AXIS-VIEW-17">{position}</span><span className="footer-status" role="status">{state.error ? `Error: ${state.error.code}` : commandStatus}</span></footer>

View File

@@ -110,6 +110,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
let paintedProgress = 0; let paintedProgress = 0;
let liveHistoryLine: THREE.Line | null = null; let liveHistoryLine: THREE.Line | null = null;
let activeLiveLine: THREE.Line | null = null; let activeLiveLine: THREE.Line | null = null;
let livePathEpoch = -1;
let lastHistoryTick = -1; let lastHistoryTick = -1;
let lastActiveKey = ""; let lastActiveKey = "";
let lastPreset = ""; let lastPreset = "";
@@ -145,6 +146,29 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
const segments = state.toolpath as Segment[]; const segments = state.toolpath as Segment[];
const layer = layersRef.current; const layer = layersRef.current;
const now = performance.now(); const now = performance.now();
if (state.livePathEpoch !== livePathEpoch) {
livePathEpoch = state.livePathEpoch;
clearLines(liveGroup);
liveTrace = [];
playbackQueue = [];
playbackStart = 0;
queuedLastTick = state.tick;
renderedTick = state.tick;
renderedPose = state.livePose;
renderedJoints = state.joints;
renderedActiveSegment = state.activeSegmentId ?? 0;
renderedProgress = state.activeProgress;
paintedTick = renderedTick;
paintedPose = renderedPose;
paintedJoints = renderedJoints;
paintedActiveSegment = renderedActiveSegment;
paintedProgress = renderedProgress;
liveTick = renderedTick;
liveHistoryLine = null;
activeLiveLine = null;
lastHistoryTick = -1;
lastActiveKey = "";
}
// React can deliver a full snapshot just behind an already queued // React can deliver a full snapshot just behind an already queued
// motion-delta response. Keep the rendered pose monotonic until the // motion-delta response. Keep the rendered pose monotonic until the
// newer samples arrive; otherwise the tool visibly jumps backward. // newer samples arrive; otherwise the tool visibly jumps backward.
@@ -217,28 +241,6 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
segmentCandidatesBySourceLine.set(sourceLine, candidates); segmentCandidatesBySourceLine.set(sourceLine, candidates);
} }
clearLines(pathGroup); clearLines(pathGroup);
clearLines(liveGroup);
// A new PROGRAM_LOAD, including the transient empty snapshot while
// loading, starts a new live-trace epoch. Clear internal playback and
// history buffers as well as rendered geometry so the previous
// program's yellow/orange path cannot be painted again.
liveTrace = [];
playbackQueue = [];
playbackStart = 0;
queuedLastTick = -1;
renderedTick = state.tick;
renderedPose = state.livePose;
renderedJoints = state.joints;
renderedActiveSegment = state.activeSegmentId ?? 0;
renderedProgress = state.activeProgress;
paintedTick = renderedTick;
paintedPose = renderedPose;
paintedJoints = renderedJoints;
paintedActiveSegment = renderedActiveSegment;
paintedProgress = renderedProgress;
// Seed the new epoch without adding a visible history point. The
// first post-load motion delta will create the first live segment.
liveTick = renderedTick;
bounds = fit(segments); bounds = fit(segments);
// Long programs can contain thousands of linear blocks. Batch their // Long programs can contain thousands of linear blocks. Batch their
// vertices so preview loading does not block the UI on one WebGL // vertices so preview loading does not block the UI on one WebGL
@@ -270,10 +272,6 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
addSegments(batched.rapid, rapid, true); addSegments(batched.rapid, rapid, true);
addSegments(batched.completed, completed); addSegments(batched.completed, completed);
for (const sampled of sampledLines) addLine(sampled.points, sampled.rapid ? rapid : completed, sampled.rapid); for (const sampled of sampledLines) addLine(sampled.points, sampled.rapid ? rapid : completed, sampled.rapid);
liveHistoryLine = null;
activeLiveLine = null;
lastHistoryTick = -1;
lastActiveKey = "";
} }
const presetKey = `${viewRef.current}:${zoomRef.current}:${bounds.radius}:${bounds.center.x}:${bounds.center.y}:${bounds.center.z}`; const presetKey = `${viewRef.current}:${zoomRef.current}:${bounds.radius}:${bounds.center.x}:${bounds.center.y}:${bounds.center.z}`;
if (presetKey !== lastPreset) { presetCamera(viewRef.current, zoomRef.current); lastPreset = presetKey; } if (presetKey !== lastPreset) { presetCamera(viewRef.current, zoomRef.current); lastPreset = presetKey; }
@@ -289,6 +287,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
liveTick = renderedTick; liveTick = renderedTick;
} }
element.dataset.xyzacLiveTraceCount = String(liveTrace.length); element.dataset.xyzacLiveTraceCount = String(liveTrace.length);
element.dataset.xyzacLivePathEpoch = String(livePathEpoch);
element.dataset.xyzacRenderedTick = String(renderedTick); element.dataset.xyzacRenderedTick = String(renderedTick);
const historyDue = liveTrace.length > 1 && (lastHistoryTick < 0 || renderedTick - lastHistoryTick >= 4); const historyDue = liveTrace.length > 1 && (lastHistoryTick < 0 || renderedTick - lastHistoryTick >= 4);
if (historyDue) { if (historyDue) {
@@ -346,7 +345,7 @@ export function XyzacToolPreview({ visual, view = "p", zoom = 1, rotateMode = fa
const pose = visual.pose; const pose = visual.pose;
const helixCount = visual.toolpath.filter((segment) => (segment as { kind?: string }).kind === "helix").length; const helixCount = visual.toolpath.filter((segment) => (segment as { kind?: string }).kind === "helix").length;
const sampleCount = visual.toolpath.reduce((count, segment) => count + (Array.isArray((segment as { samples?: unknown[] }).samples) ? (segment as { samples: unknown[] }).samples.length : 0), 0); const sampleCount = visual.toolpath.reduce((count, segment) => count + (Array.isArray((segment as { samples?: unknown[] }).samples) ? (segment as { samples: unknown[] }).samples.length : 0), 0);
const controllerButton = (action: string, label: string, glyph: string) => <button type="button" data-xyzac-controller={action} title={label} aria-label={label} onClick={() => { const controller = (host.current as (HTMLDivElement & { xyzacController?: Record<string, () => void> }) | null)?.xyzacController; controller?.[action]?.(); }}>{glyph}</button>; const controllerButton = (action: string, label: string, glyph: string) => ["zoomIn", "zoomOut"].includes(action) ? null : <button type="button" data-xyzac-controller={action} title={label} aria-label={label} onClick={() => { const controller = (host.current as (HTMLDivElement & { xyzacController?: Record<string, () => void> }) | null)?.xyzacController; controller?.[action]?.(); }}>{glyph}</button>;
return <div className="three-viewer xyzac-tool-preview" ref={host} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-preview="toolpath" data-xyzac-controller="orbit" data-xyzac-origin="0,0,0" data-xyzac-origin-axes="X,Y,Z" data-xyzac-toolpath-count={visual.toolpath.length} data-xyzac-helix-count={helixCount} data-xyzac-sample-count={sampleCount} data-xyzac-samples-source="linuxcnc-wasm-sim_posemath_segment_point" data-xyzac-live-source="linuxcnc-source-tp" data-xyzac-live-progress={visual.activeProgress} data-xyzac-live-pose={JSON.stringify(visual.livePose)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} aria-label="XYZAC authoritative tool preview"><div className="xyzac-view-controller" role="toolbar" aria-label="3D preview controller">{controllerButton("zoomIn", "Zoom in", "+")}{controllerButton("zoomOut", "Zoom out", "-")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("panLeft", "Pan left", "L")}{controllerButton("panRight", "Pan right", "R")}{controllerButton("panUp", "Pan up", "U")}{controllerButton("panDown", "Pan down", "D")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("reset", "Reset 3D view", "0")}</div><pre ref={hudRef} className="axis-preview-hud" aria-label="XYZAC tool preview state">{visual.authoritative ? `TOOL PREVIEW\nX ${Number(pose.x).toFixed(3)} Y ${Number(pose.y).toFixed(3)} Z ${Number(pose.z).toFixed(3)}\nA ${Number(pose.a).toFixed(3)} C ${Number(pose.c).toFixed(3)}\nJ0 ${Number(visual.joints[0]).toFixed(3)} J1 ${Number(visual.joints[1]).toFixed(3)} J2 ${Number(visual.joints[2]).toFixed(3)}\nJ3 ${Number(visual.joints[3]).toFixed(3)} J4 ${Number(visual.joints[4]).toFixed(3)}\nHelix ${helixCount} / Samples ${sampleCount}\nLive ${visual.activeSegmentId ?? "-"} / ${visual.activeProgress.toFixed(3)}\nTick ${visual.tick}\nHash ${visual.stateHash.slice(0, 18)}` : "TOOL PREVIEW\nWASM state pending"}</pre></div>; return <div className="three-viewer xyzac-tool-preview" ref={host} data-xyzac-authoritative={String(visual.authoritative)} data-xyzac-preview="toolpath" data-xyzac-controller="orbit" data-xyzac-origin="0,0,0" data-xyzac-origin-axes="X,Y,Z" data-xyzac-toolpath-count={visual.toolpath.length} data-xyzac-helix-count={helixCount} data-xyzac-sample-count={sampleCount} data-xyzac-samples-source="linuxcnc-wasm-sim_posemath_segment_point" data-xyzac-live-source="linuxcnc-source-tp" data-xyzac-live-progress={visual.activeProgress} data-xyzac-live-pose={JSON.stringify(visual.livePose)} data-xyzac-tick={visual.tick} data-xyzac-state-hash={visual.stateHash} data-xyzac-active-segment={visual.activeSegmentId ?? ""} aria-label="XYZAC authoritative tool preview"><div className="xyzac-view-controller" role="toolbar" aria-label="3D preview controller">{controllerButton("zoomIn", "Zoom in", "+")}{controllerButton("zoomOut", "Zoom out", "-")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("panLeft", "Pan left", "L")}{controllerButton("panRight", "Pan right", "R")}{controllerButton("panUp", "Pan up", "U")}{controllerButton("panDown", "Pan down", "D")}<span className="xyzac-controller-divider" aria-hidden="true" />{controllerButton("reset", "Reset 3D view", "0")}</div><pre ref={hudRef} className="axis-preview-hud" aria-label="XYZAC tool preview state">{visual.authoritative ? `TOOL PREVIEW\nX ${Number(pose.x).toFixed(3)} Y ${Number(pose.y).toFixed(3)} Z ${Number(pose.z).toFixed(3)}\nA ${Number(pose.a).toFixed(3)} C ${Number(pose.c).toFixed(3)}\nJ0 ${Number(visual.joints[0]).toFixed(3)} J1 ${Number(visual.joints[1]).toFixed(3)} J2 ${Number(visual.joints[2]).toFixed(3)}\nJ3 ${Number(visual.joints[3]).toFixed(3)} J4 ${Number(visual.joints[4]).toFixed(3)}\nHelix ${helixCount} / Samples ${sampleCount}\nLive ${visual.activeSegmentId ?? "-"} / ${visual.activeProgress.toFixed(3)}\nTick ${visual.tick}\nHash ${visual.stateHash.slice(0, 18)}` : "TOOL PREVIEW\nWASM state pending"}</pre></div>;
} }

View File

@@ -4,18 +4,17 @@ import type { SimSnapshotV1 } from "./protocol";
type Command = (kind: string, payload?: Record<string, unknown>, action?: string) => void; type Command = (kind: string, payload?: Record<string, unknown>, action?: string) => void;
export function XyzacKinematicsControl({ view, command }: { view: XyzacPanelView; command: Command }) { export function XyzacKinematicsControl({ view, command, loading = false }: { view: XyzacPanelView; command: Command; loading?: boolean }) {
const active = view.kinematics?.active_type; const active = view.kinematics?.active_type;
return <section className="xyzac-kinematics" aria-label="XYZAC kinematics"> return <section className="xyzac-kinematics" aria-label="XYZAC kinematics">
<div className="xyzac-section-title"><strong>Switchkins</strong><span>{view.kinematics?.native_type ?? "Unavailable"}</span></div> <div className="xyzac-section-title"><strong>Switchkins</strong><span>{view.kinematics?.native_type ?? "Unavailable"}</span></div>
<div className="xyzac-segments" role="group" aria-label="Kinematics mode"> <div className="xyzac-segments" role="group" aria-label="Kinematics mode">
{XYZAC_KINEMATICS.map((item) => { {XYZAC_KINEMATICS.map((item) => {
const gate = view.gates[item.action]; const gate = view.gates[item.action];
const disabled = view.status !== "ready" || gate?.enabled === false; const disabled = loading || view.status !== "ready" || gate?.enabled === false;
return <button key={item.type} type="button" className={active === item.type ? "active" : ""} aria-pressed={active === item.type} disabled={disabled} title={gate?.reason ?? item.mcode} onClick={() => command("KINEMATICS_SELECT", { type: item.type }, item.action)}><span>{item.label}</span><small>{item.mcode}</small></button>; return <button key={item.type} type="button" className={active === item.type ? "active" : ""} aria-pressed={active === item.type} disabled={disabled} title={gate?.reason ?? item.mcode} onClick={() => command("KINEMATICS_SELECT", { type: item.type }, item.action)}><span>{item.label}</span><small>{item.mcode}</small></button>;
})} })}
</div> </div>
<button type="button" className="xyzac-clear" title={view.gates.vismachClear?.reason ?? "Clear visual plot"} disabled={view.gates.vismachClear?.enabled === false} onClick={() => command("VISUAL_CLEAR", {}, "vismachClear")}>Clear plot</button>
</section>; </section>;
} }
@@ -66,6 +65,6 @@ export function XyzacDiagnostics({ view, operations, alarms, error }: { view: Xy
</section>; </section>;
} }
export function XyzacTrtPanel({ view, command }: { view: XyzacPanelView; command: Command }) { export function XyzacTrtPanel({ view, command, loading = false }: { view: XyzacPanelView; command: Command; loading?: boolean }) {
return <section className="xyzac-control-band" aria-label="XYZAC TRT controls"><XyzacKinematicsControl view={view} command={command} /><XyzacManualPanel view={view} /></section>; return <section className="xyzac-control-band" aria-label="XYZAC TRT controls"><XyzacKinematicsControl view={view} command={command} loading={loading} /><XyzacManualPanel view={view} /></section>;
} }

View File

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

View File

@@ -118,9 +118,8 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
}; };
} }
if (panel?.schema === "axis-panel-snapshot.v1") { if (panel?.schema === "axis-panel-snapshot.v1") {
const visualSegments = (Array.isArray(panel.visual.toolpath) ? panel.visual.toolpath : previous.toolpath) const visualSegments = Array.isArray(panel.visual.toolpath)
.filter((segment) => segment && typeof segment === "object" && Number.isSafeInteger((segment as { id?: unknown }).id ?? 0)) ? panel.visual.toolpath.filter((segment) => segment && typeof segment === "object" && Number.isSafeInteger((segment as { id?: unknown }).id ?? 0)).map((segment, index) => {
.map((segment, index) => {
const value = segment as Record<string, unknown>; const value = segment as Record<string, unknown>;
return { return {
id: Number(value.id ?? index + 1), id: Number(value.id ?? index + 1),
@@ -142,7 +141,8 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
...(typeof value.turn === "number" ? { turn: value.turn } : {}), ...(typeof value.turn === "number" ? { turn: value.turn } : {}),
active: value.id === panel.visual.active_segment_id, active: value.id === panel.visual.active_segment_id,
}; };
}); })
: previous.toolpath;
const pose = panel.visual.pose; const pose = panel.visual.pose;
const axes = [pose.x, pose.y, pose.z, pose.a, pose.c].map((value) => Number.isFinite(value) ? value : 0); const axes = [pose.x, pose.y, pose.z, pose.a, pose.c].map((value) => Number.isFinite(value) ? value : 0);
const snapshot = { const snapshot = {
@@ -203,10 +203,10 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
].filter((segment): segment is NonNullable<typeof segment> & { id: number; start: Record<string, number>; end: Record<string, number> } => Number.isSafeInteger(segment.id) && Boolean(segment.start) && Boolean(segment.end)); ].filter((segment): segment is NonNullable<typeof segment> & { id: number; start: Record<string, number>; end: Record<string, number> } => Number.isSafeInteger(segment.id) && Boolean(segment.start) && Boolean(segment.end));
const command = String(result.command ?? ""); const command = String(result.command ?? "");
const visualClear = command === "VISUAL_CLEAR"; const visualClear = command === "VISUAL_CLEAR";
const resetPath = visualClear || command === "PROGRAM_LOAD" || command === "PROGRAM_RESTART"; const resetPath = command === "PROGRAM_LOAD" || command === "PROGRAM_RESTART";
const known = resetPath ? new Set<number>() : new Set(previous.toolpath.map((segment) => segment.id)); const known = resetPath ? new Set<number>() : new Set(previous.toolpath.map((segment) => segment.id));
const nextToolpath = resetPath const nextToolpath = resetPath
? visualClear ? [] : previewSegments.map((segment, index) => ({ ...segment, id: index + 1, source_line: typeof segment.source_line === "number" ? segment.source_line : null, start: segment.start as Record<string, number>, end: segment.end as Record<string, number>, active: false })) ? previewSegments.map((segment, index) => ({ ...segment, id: index + 1, source_line: typeof segment.source_line === "number" ? segment.source_line : null, start: segment.start as Record<string, number>, end: segment.end as Record<string, number>, active: false }))
: previous.toolpath.map((segment) => ({ ...segment, start: { ...segment.start }, end: { ...segment.end } })); : previous.toolpath.map((segment) => ({ ...segment, start: { ...segment.start }, end: { ...segment.end } }));
for (const segment of plannerSegments) { for (const segment of plannerSegments) {
if (!known.has(segment.id)) nextToolpath.push({ id: segment.id, source_line: segment.source_line, start: segment.start, end: segment.end, active: segment.id === planner?.active?.id }); if (!known.has(segment.id)) nextToolpath.push({ id: segment.id, source_line: segment.source_line, start: segment.start, end: segment.end, active: segment.id === planner?.active?.id });
@@ -224,7 +224,14 @@ function projection(result: Record<string, unknown>, message: MessageEnvelope, p
active_progress: 0, active_progress: 0,
motion_trace: [], motion_trace: [],
} }
: previous.snapshot?.visual; : visualClear && previous.snapshot?.visual
? {
...previous.snapshot.visual,
toolpath: nextToolpath,
live_path_epoch: Number(result.live_path_epoch ?? previous.snapshot.visual.live_path_epoch ?? 0),
motion_trace: [],
}
: previous.snapshot?.visual;
const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [], homed: typeof motionState.homed === "boolean" ? motionState.homed : undefined, homed_axes: Array.isArray(motionState.homed_axes) ? motionState.homed_axes as boolean[] : undefined }, overrides: (result.overrides as SimSnapshotV1["overrides"]) ?? previous.snapshot?.overrides, authoritative, ...(visual ? { visual } : {}) } satisfies SimSnapshotV1; const snapshot = { simulation_tick: message.simulation_tick ?? previous.tick, state_hash: String(result.state_hash ?? previous.snapshot?.state_hash ?? ""), task: result.task as SimSnapshotV1["task"], motion: { axes, joints, velocity: Array.isArray(motionState.velocity) ? motionState.velocity as number[] : [], homed: typeof motionState.homed === "boolean" ? motionState.homed : undefined, homed_axes: Array.isArray(motionState.homed_axes) ? motionState.homed_axes as boolean[] : undefined }, overrides: (result.overrides as SimSnapshotV1["overrides"]) ?? previous.snapshot?.overrides, authoritative, ...(visual ? { visual } : {}) } satisfies SimSnapshotV1;
return { snapshot, toolpath: nextToolpath, task: (result.task as Record<string, unknown>) ?? previous.task, program: programResult as Record<string, unknown> ?? previous.program, devices: (result.devices as Record<string, unknown>) ?? previous.devices, deviceIo: (result.device_io as Record<string, unknown>) ?? previous.deviceIo, hal: (result.hal_runtime as Record<string, unknown>) ?? previous.hal, plc: (result.plc as Record<string, unknown>) ?? previous.plc, python: (result.python as Record<string, unknown>) ?? previous.python, ioAck: (result.io_ack as Record<string, unknown>) ?? previous.ioAck, axisState: (result.axis_state as AxisStateView) ?? previous.axisState, gates: (result.gates as FrontendState["gates"]) ?? previous.gates, executionTrace }; return { snapshot, toolpath: nextToolpath, task: (result.task as Record<string, unknown>) ?? previous.task, program: programResult as Record<string, unknown> ?? previous.program, devices: (result.devices as Record<string, unknown>) ?? previous.devices, deviceIo: (result.device_io as Record<string, unknown>) ?? previous.deviceIo, hal: (result.hal_runtime as Record<string, unknown>) ?? previous.hal, plc: (result.plc as Record<string, unknown>) ?? previous.plc, python: (result.python as Record<string, unknown>) ?? previous.python, ioAck: (result.io_ack as Record<string, unknown>) ?? previous.ioAck, axisState: (result.axis_state as AxisStateView) ?? previous.axisState, gates: (result.gates as FrontendState["gates"]) ?? previous.gates, executionTrace };
} }

View File

@@ -8,15 +8,32 @@ const base = {
simulation_tick: 1, simulation_tick: 1,
state_hash: "fnv1a64:base", state_hash: "fnv1a64:base",
task: {}, motion: {}, wasm_motion: {}, gates: {}, task: {}, motion: {}, wasm_motion: {}, gates: {},
visual: { schema: "axis-visual.v1", pose: { x: 0 }, joints: [0, 0, 0, 0, 0], toolpath: [{ id: 1, start: { x: 0 }, end: { x: 1 } }], source: "wasm_canonical_motion" }, visual: { schema: "axis-visual.v1", pose: { x: 0 }, joints: [0, 0, 0, 0, 0], live_path_epoch: 3, toolpath: [{ id: 1, start: { x: 0 }, end: { x: 1 } }], source: "wasm_canonical_motion" },
authoritative: {}, motion_authority: "wasm", task_authority: "wasm_task_canonical", authoritative: {}, motion_authority: "wasm", task_authority: "wasm_task_canonical",
}, },
toolpath: [{ id: 1, start: { x: 0 }, end: { x: 1 } }], toolpath: [{ id: 1, start: { x: 0 }, end: { x: 1 } }],
}; };
const result = reduceFrontendState(base, { const result = reduceFrontendState(base, {
schema_version: 1, kind: "COMMAND_RESULT", context_id: base.contextId, session_id: base.sessionId, seq: 1, schema_version: 1, kind: "COMMAND_RESULT", context_id: base.contextId, session_id: base.sessionId, seq: 1,
payload: { status: "verified", command: "VISUAL_CLEAR", visual_clear: true, panel_snapshot: undefined }, payload: { status: "verified", command: "VISUAL_CLEAR", visual_clear: true, live_path_epoch: 4, panel_snapshot: undefined },
}); });
assert.deepEqual(result.toolpath, []); assert.equal(result.toolpath.length, 1);
assert.deepEqual(result.snapshot?.visual?.toolpath, []); assert.equal(result.toolpath[0].id, 1);
console.log(JSON.stringify({ schema: "store-visual-clear.v1", status: "verified", stale_toolpath_removed: true })); assert.deepEqual(result.toolpath[0].start, { x: 0 });
assert.deepEqual(result.toolpath[0].end, { x: 1 });
assert.equal(result.snapshot?.visual?.toolpath?.length, 1);
assert.equal(result.snapshot?.visual?.live_path_epoch, 4);
const paused = reduceFrontendState(base, {
schema_version: 1, kind: "COMMAND_RESULT", context_id: base.contextId, session_id: base.sessionId, seq: 2,
payload: {
status: "verified", command: "PROGRAM_PAUSE",
panel_snapshot: {
...base.snapshot,
visual: { schema: "axis-visual.v1", pose: { x: 0 }, joints: [0, 0, 0, 0, 0], live_path_epoch: 3, source: "wasm_canonical_motion" },
},
},
});
assert.equal(paused.toolpath, base.toolpath);
assert.equal(paused.snapshot?.visual?.toolpath, base.toolpath);
assert.equal(paused.snapshot?.visual?.live_path_epoch, 3);
console.log(JSON.stringify({ schema: "store-visual-clear.v1", status: "verified", static_toolpath_retained: true, pause_toolpath_reference_retained: true, live_path_epoch: 4 }));

View File

@@ -7,7 +7,7 @@ const visual = projectXyzacVisual({
simulation_tick: 12, simulation_tick: 12,
state_hash: "fnv1a64:tool-preview", state_hash: "fnv1a64:tool-preview",
kinematics: { schema: "wasm-xyzac-kinematics.v1", status: "verified", active_type: 1, native_type: "xyzac-trt-kins", coordinates: "XYZAC", joint_count: 5, linear_units: "mm", angular_units: "deg", pose: { x: 10, y: -5, z: 20, a: 30, b: 0, c: 45, u: 0, v: 0, w: 0 }, joints: [10, -5, 20, 30, 45], offsets: { x_rot_point: 0, y_offset: 20, z_offset: 10, tool_offset: 5 }, generation: 1, switch_count: 1, state_hash: "fnv1a64:k", wasm_state_hash: "fnv1a64:w" }, kinematics: { schema: "wasm-xyzac-kinematics.v1", status: "verified", active_type: 1, native_type: "xyzac-trt-kins", coordinates: "XYZAC", joint_count: 5, linear_units: "mm", angular_units: "deg", pose: { x: 10, y: -5, z: 20, a: 30, b: 0, c: 45, u: 0, v: 0, w: 0 }, joints: [10, -5, 20, 30, 45], offsets: { x_rot_point: 0, y_offset: 20, z_offset: 10, tool_offset: 5 }, generation: 1, switch_count: 1, state_hash: "fnv1a64:k", wasm_state_hash: "fnv1a64:w" },
visual: { active_segment_id: 3, active_progress: 0.375, pose: { x: 11, y: -4, z: 19, a: 31, c: 44 }, joints: [11, -4, 19, 31, 44], toolpath: [{ id: 3, active: true, motion_type: 1, start: { x: 0, y: 0, z: 0 }, end: { x: 10, y: -5, z: 20 } }] }, visual: { active_segment_id: 3, active_progress: 0.375, live_path_epoch: 7, pose: { x: 11, y: -4, z: 19, a: 31, c: 44 }, joints: [11, -4, 19, 31, 44], toolpath: [{ id: 3, active: true, motion_type: 1, start: { x: 0, y: 0, z: 0 }, end: { x: 10, y: -5, z: 20 } }] },
}); });
assert.equal(visual.tick, 12); assert.equal(visual.tick, 12);
assert.equal(visual.stateHash, "fnv1a64:tool-preview"); assert.equal(visual.stateHash, "fnv1a64:tool-preview");
@@ -16,8 +16,9 @@ assert.deepEqual(visual.pose, { x: 11, y: -4, z: 19, a: 31, b: 0, c: 44, u: 0, v
assert.deepEqual(visual.livePose, visual.pose); assert.deepEqual(visual.livePose, visual.pose);
assert.equal(visual.activeProgress, 0.375); assert.equal(visual.activeProgress, 0.375);
assert.equal(visual.activeSegmentId, 3); assert.equal(visual.activeSegmentId, 3);
assert.equal(visual.livePathEpoch, 7);
for (const token of ["liveTrace = []", "playbackQueue = []", "queuedLastTick = -1", "liveTick = -1"]) assert.ok(source.includes(token), token); for (const token of ["liveTrace = []", "playbackQueue = []", "queuedLastTick = -1", "liveTick = -1"]) assert.ok(source.includes(token), token);
for (const token of ["renderedTick = state.tick", "paintedTick = renderedTick", "A new PROGRAM_LOAD"]) assert.ok(source.includes(token), token); for (const token of ["renderedTick = state.tick", "paintedTick = renderedTick", "state.livePathEpoch !== livePathEpoch", "xyzacLivePathEpoch"]) assert.ok(source.includes(token), token);
for (const token of ["xyzac-tool-preview", "xyzac-preview-toolpath", "xyzac-preview-live-toolpath", "xyzac-preview-tool", "xyzac-preview-tool-shaft", "xyzac-preview-tool-tip", "xyzac-origin-coordinate-system", "xyzac-origin-axis-${axis.label.toLowerCase()}", "xyzac-origin-label-${axis.label.toLowerCase()}", "OrbitControls", "enablePan", "enableRotate", "zoomIn", "panRight", "data-xyzac-controller=\"orbit\"", "data-xyzac-origin=\"0,0,0\"", "sourcePoint", "toolGroup.position.copy(pose)", "toolGroup.rotation.set(-Number(renderedPose.a ?? 0) * DEG, -Number(renderedPose.c ?? 0) * DEG, 0)", "const segments = state.toolpath as Segment[]", "activeSegment.samples.map(sourcePoint)", "segmentCandidatesBySourceLine", "candidates.length === 1", "state.activeProgress", "state.livePose", "motionTrace", "playbackQueue", "data-xyzac-preview=\"toolpath\"", "data-xyzac-helix-count", "data-xyzac-sample-count", "data-xyzac-samples-source", "data-xyzac-live-source", "data-xyzac-live-pose", "preserveDrawingBuffer: true", "authoritative tool preview"]) assert.ok(source.includes(token), token); for (const token of ["xyzac-tool-preview", "xyzac-preview-toolpath", "xyzac-preview-live-toolpath", "xyzac-preview-tool", "xyzac-preview-tool-shaft", "xyzac-preview-tool-tip", "xyzac-origin-coordinate-system", "xyzac-origin-axis-${axis.label.toLowerCase()}", "xyzac-origin-label-${axis.label.toLowerCase()}", "OrbitControls", "enablePan", "enableRotate", "zoomIn", "panRight", "data-xyzac-controller=\"orbit\"", "data-xyzac-origin=\"0,0,0\"", "sourcePoint", "toolGroup.position.copy(pose)", "toolGroup.rotation.set(-Number(renderedPose.a ?? 0) * DEG, -Number(renderedPose.c ?? 0) * DEG, 0)", "const segments = state.toolpath as Segment[]", "activeSegment.samples.map(sourcePoint)", "segmentCandidatesBySourceLine", "candidates.length === 1", "state.activeProgress", "state.livePose", "motionTrace", "playbackQueue", "data-xyzac-preview=\"toolpath\"", "data-xyzac-helix-count", "data-xyzac-sample-count", "data-xyzac-samples-source", "data-xyzac-live-source", "data-xyzac-live-pose", "preserveDrawingBuffer: true", "authoritative tool preview"]) assert.ok(source.includes(token), token);
for (const forbidden of [/arcPoints/, /Math\.atan2/, /Quaternion/, /cross\(/]) assert.equal(forbidden.test(source), false, String(forbidden)); for (const forbidden of [/arcPoints/, /Math\.atan2/, /Quaternion/, /cross\(/]) assert.equal(forbidden.test(source), false, String(forbidden));
for (const forbidden of [/xyzac-machine/, /table-x/, /saddle-y/, /spindle-z/, /accept\s*\(/, /postMessage\s*\(/, /_sim_/, /_malloc/, /navigator\.storage/]) assert.equal(forbidden.test(source), false, String(forbidden)); for (const forbidden of [/xyzac-machine/, /table-x/, /saddle-y/, /spindle-z/, /accept\s*\(/, /postMessage\s*\(/, /_sim_/, /_malloc/, /navigator\.storage/]) assert.equal(forbidden.test(source), false, String(forbidden));

View File

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

View File

@@ -37,14 +37,14 @@ const OPERATION_KINDS = new Set([
"SESSION_ATTACH", "LOAD_WORKSPACE", "SIM_STEP", "DESTROY", "CHECKPOINT", "RECOVER", "ABORT", "SESSION_ATTACH", "LOAD_WORKSPACE", "SIM_STEP", "DESTROY", "CHECKPOINT", "RECOVER", "ABORT",
"COMMAND", "START", "STOP", "RESET", "PROGRAM_LOAD", "PROGRAM_RUN", "COMMAND", "START", "STOP", "RESET", "PROGRAM_LOAD", "PROGRAM_RUN",
"PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_RESTART", "JOG_START", "JOG_STOP", "PROGRAM_PAUSE", "PROGRAM_RESUME", "PROGRAM_ABORT", "PROGRAM_RESTART", "JOG_START", "JOG_STOP",
"HOME", "SET_MODE", "SET_OVERRIDE", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "SPINDLE_COMMAND", "SPINDLE_BRAKE", "TOOL_PREPARE", "HOME", "SET_MODE", "SET_OVERRIDE", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "SPINDLE_COMMAND", "SPINDLE_CONSTANT", "SPINDLE_BRAKE", "TOOL_PREPARE",
"TOOL_CHANGED", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT", "TOOL_CHANGED", "TOOL_PREPARED", "TOOL_CHANGE", "TOOL_ABORT", "TOOL_FAULT", "TOOL_CLEAR_FAULT",
"SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "IO_STEP", "IO_RECOVER", "SPINDLE_FAULT", "SPINDLE_INHIBIT", "SPINDLE_ORIENT", "SPINDLE_ORIENTED", "IO_STEP", "IO_RECOVER",
"PROBE_CONFIG", "LIMIT_SET", "ESTOP", "HAL_COMMAND", "HAL_SET", "PROBE_CONFIG", "LIMIT_SET", "ESTOP", "HAL_COMMAND", "HAL_SET",
"ENCODER_SET", "COOLANT_COMMAND", "COOLANT_FAULT", "DOOR_SET", "INTERLOCK_SET", "INTERLOCK_RESET", "DEVICE_ESTOP", "DEVICE_ACK_ISSUE", "DEVICE_ACK", "DEVICE_ACK_ABORT", "ENCODER_SET", "COOLANT_COMMAND", "COOLANT_FAULT", "DOOR_SET", "INTERLOCK_SET", "INTERLOCK_RESET", "DEVICE_ESTOP", "DEVICE_ACK_ISSUE", "DEVICE_ACK", "DEVICE_ACK_ABORT",
"CONFIGURATION_BATCH", "CONFIGURATION_BATCH",
"PYTHON_IMPORT", "PYTHON_CALL", "PYTHON_CAPABILITY", "PYTHON_IMPORT", "PYTHON_CALL", "PYTHON_CAPABILITY",
"PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", "PLC_CONFIG", "PLC_SET", "PLC_TRIGGER", "FOLLOWING_ERROR", "IO_COMMAND", "IO_ACK", "IO_TIMEOUT", "PROGRAM_STEP", "PROGRAM_SEEK", "MDI_EXECUTE", "TOUCH_OFF", "TOOL_TOUCH_OFF", "PLC_CONFIG", "PLC_SET", "PLC_TRIGGER", "FOLLOWING_ERROR", "IO_COMMAND", "IO_ACK", "IO_TIMEOUT",
"REMAP_REGISTER", "REMAP_CALL", "VCP_REGISTER", "VCP_UPDATE", "FILTER_EXEC", "REMAP_REGISTER", "REMAP_CALL", "VCP_REGISTER", "VCP_UPDATE", "FILTER_EXEC",
"POWER", "KINEMATICS_SELECT", "VISUAL_CLEAR", "POWER", "KINEMATICS_SELECT", "VISUAL_CLEAR",
]); ]);
@@ -53,7 +53,7 @@ const PANEL_GATE_ACTIONS = Object.freeze({
PROGRAM_RUN: "run", PROGRAM_STEP: "step", PROGRAM_PAUSE: "pause", PROGRAM_RUN: "run", PROGRAM_STEP: "step", PROGRAM_PAUSE: "pause",
PROGRAM_RESUME: "resume", PROGRAM_ABORT: "abort", PROGRAM_RESTART: "restart", PROGRAM_RESUME: "resume", PROGRAM_ABORT: "abort", PROGRAM_RESTART: "restart",
JOG_START: "jogStart", JOG_STOP: "jogStop", SET_JOG_SCALE: "jogStart", SET_MAX_VELOCITY: "override", SET_SPINDLE_OVERRIDE: "override", HOME: "home", SET_OVERRIDE: "override", JOG_START: "jogStart", JOG_STOP: "jogStop", SET_JOG_SCALE: "jogStart", SET_MAX_VELOCITY: "override", SET_SPINDLE_OVERRIDE: "override", HOME: "home", SET_OVERRIDE: "override",
SPINDLE_COMMAND: "spindle", SPINDLE_BRAKE: "spindle", COOLANT_COMMAND: "coolant", TOOL_PREPARE: "tool", TOOL_CHANGE: "tool", PROBE_CONFIG: "probe", SPINDLE_COMMAND: "spindle", SPINDLE_CONSTANT: "spindle", SPINDLE_BRAKE: "spindle", COOLANT_COMMAND: "coolant", TOOL_PREPARE: "tool", TOOL_CHANGE: "tool", PROBE_CONFIG: "probe",
ESTOP: "estop", IO_RECOVER: "recover", ESTOP: "estop", IO_RECOVER: "recover",
POWER: "power", POWER: "power",
}); });
@@ -103,6 +103,8 @@ function canonicalPayload(value) {
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalPayload(value[key])}`).join(",")}}`; return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalPayload(value[key])}`).join(",")}}`;
} }
// Control-flow contract: docs/linuxcnc-axis-web-control-panel-source-logic.md
// maps each browser operation to its LinuxCNC AXIS source symbol and gate.
export class SimSession { export class SimSession {
constructor(core, { contextId = CONTEXT_ID, sessionId = "", planner = "queue" } = {}) { constructor(core, { contextId = CONTEXT_ID, sessionId = "", planner = "queue" } = {}) {
this.core = core; this.core = core;
@@ -161,6 +163,7 @@ export class SimSession {
this.configurationState = null; this.configurationState = null;
this.halState = null; this.halState = null;
this.previewState = null; this.previewState = null;
this.livePathEpoch = 0;
this.workspaceFiles = {}; this.workspaceFiles = {};
call(core, "sim_protocol_reset"); call(core, "sim_protocol_reset");
if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []); if (typeof core.ccall === "function") core.ccall("sim_event_reset", null, [], []);
@@ -173,7 +176,8 @@ export class SimSession {
// The loaded toolpath is immutable during execution. Avoid cloning all // The loaded toolpath is immutable during execution. Avoid cloning all
// WASM pose samples into every 10 ms step response; the frontend retains // WASM pose samples into every 10 ms step response; the frontend retains
// the canonical path from PROGRAM_LOAD/PROGRAM_RESTART. // the canonical path from PROGRAM_LOAD/PROGRAM_RESTART.
const lightweightXyzacStep = input.kind === "SIM_STEP" && Boolean(this.xyzacProgram); const xyzacProgramActive = Boolean(this.xyzacProgram);
const lightweightXyzacStep = input.kind === "SIM_STEP" && xyzacProgramActive;
const traceOnlyStep = lightweightXyzacStep && input.payload?.trace_only === true; const traceOnlyStep = lightweightXyzacStep && input.payload?.trace_only === true;
const currentMotion = traceOnlyStep && statePayload.wasm_motion ? statePayload.wasm_motion : this.wasmMotion.read(); const currentMotion = traceOnlyStep && statePayload.wasm_motion ? statePayload.wasm_motion : this.wasmMotion.read();
const completedMotion = program.status === 4 && currentMotion.state?.queue_depth === 0; const completedMotion = program.status === 4 && currentMotion.state?.queue_depth === 0;
@@ -181,7 +185,21 @@ export class SimSession {
// responses must retain their code/reason instead of being rewritten into // responses must retain their code/reason instead of being rewritten into
// a delta with undefined fields. // a delta with undefined fields.
const motionDelta = kind === "COMMAND_RESULT" && lightweightXyzacStep && input.payload?.response === "motion_delta" && !completedMotion; const motionDelta = kind === "COMMAND_RESULT" && lightweightXyzacStep && input.payload?.response === "motion_delta" && !completedMotion;
const panelSnapshot = motionDelta || traceOnlyStep ? null : this.panelSnapshot({ task, program, input, tick, motionTrace: statePayload.motion_trace, includeToolpath: !lightweightXyzacStep, includeConfiguration: !lightweightXyzacStep, includeStableRuntime: !lightweightXyzacStep }); // The frontend retains the canonical preview from PROGRAM_LOAD. Repeating
// thousands of immutable segments on unrelated control responses makes a
// loaded program delay abort/power/home commands behind structured-clone
// work. Explicit preview transactions still publish the full replacement.
const publishXyzacToolpath = ["PROGRAM_LOAD", "PROGRAM_RESTART", "RECOVER", "SNAPSHOT_REQUEST", "VISUAL_CLEAR"].includes(input.kind);
const panelSnapshot = motionDelta || traceOnlyStep ? null : this.panelSnapshot({
task,
program,
input,
tick,
motionTrace: statePayload.motion_trace,
includeToolpath: xyzacProgramActive ? publishXyzacToolpath : !lightweightXyzacStep,
includeConfiguration: !lightweightXyzacStep,
includeStableRuntime: !lightweightXyzacStep,
});
const compactProgram = motionDelta ? { const compactProgram = motionDelta ? {
schema: program.schema, schema: program.schema,
source: program.source, source: program.source,
@@ -337,8 +355,11 @@ export class SimSession {
if (!this.xyzacProgram?.source) return { status: "verified" }; if (!this.xyzacProgram?.source) return { status: "verified" };
const source = this.xyzacProgram.source; const source = this.xyzacProgram.source;
const path = this.xyzacProgram.path; const path = this.xyzacProgram.path;
const synchronizedPosition = this.wasmMotion.read().state.position;
const loaded = this.xyzacProgram.load(source, path); const loaded = this.xyzacProgram.load(source, path);
if (loaded.status !== "verified") return loaded; if (loaded.status !== "verified") return loaded;
const synchronized = this.xyzacProgram.synchronizePosition(synchronizedPosition);
if (synchronized.status !== "verified") return synchronized;
this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity); this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
const subroutines = this.loadXyzacSubroutines(path); const subroutines = this.loadXyzacSubroutines(path);
if (subroutines.status !== "verified") return subroutines; if (subroutines.status !== "verified") return subroutines;
@@ -415,6 +436,7 @@ export class SimSession {
schema: "axis-visual.v1", schema: "axis-visual.v1",
pose: { ...position }, pose: { ...position },
joints: Array.isArray(joints) ? [...joints] : [], joints: Array.isArray(joints) ? [...joints] : [],
live_path_epoch: this.livePathEpoch,
...(includeToolpath ? { toolpath: (preview?.segments ?? []).map((segment, index) => ({ id: index + 1, ...segment })) } : {}), ...(includeToolpath ? { toolpath: (preview?.segments ?? []).map((segment, index) => ({ id: index + 1, ...segment })) } : {}),
active_segment_id: wasmMotion.status === "verified" ? wasmMotion.state.active_id : authoritative.planner.active_id, active_segment_id: wasmMotion.status === "verified" ? wasmMotion.state.active_id : authoritative.planner.active_id,
active_progress: wasmMotion.status === "verified" ? wasmMotion.state.progress : 0, active_progress: wasmMotion.status === "verified" ? wasmMotion.state.progress : 0,
@@ -502,6 +524,9 @@ export class SimSession {
const program = this.xyzacProgram.snapshot(); const program = this.xyzacProgram.snapshot();
const io = this.xyzacIo?.canonical?.() ?? {}; const io = this.xyzacIo?.canonical?.() ?? {};
const wasmMotion = this.wasmMotion.read(); const wasmMotion = this.wasmMotion.read();
if (input.kind === "PROGRAM_LOAD" && wasmMotion.status === "verified" && Number(wasmMotion.state.queue_depth ?? 0) > 0) {
return { action: null, enabled: false, reason: "xyzac_motion_active", taskState: task.state, interpState: "READING", wasm: { queue_depth: wasmMotion.state.queue_depth, active_id: wasmMotion.state.active_id } };
}
if (input.kind === "KINEMATICS_SELECT" && wasmMotion.status === "verified" && Number(wasmMotion.state.queue_depth ?? 0) > 0) { if (input.kind === "KINEMATICS_SELECT" && wasmMotion.status === "verified" && Number(wasmMotion.state.queue_depth ?? 0) > 0) {
return { action: input.payload?.type === 0 ? "switchkinsIdentity" : input.payload?.type === 1 ? "switchkinsXyzac" : "switchkinsUserk", enabled: false, reason: "xyzac_motion_active", taskState: task.state, interpState: "READING", wasm: { queue_depth: wasmMotion.state.queue_depth, active_id: wasmMotion.state.active_id } }; return { action: input.payload?.type === 0 ? "switchkinsIdentity" : input.payload?.type === 1 ? "switchkinsXyzac" : "switchkinsUserk", enabled: false, reason: "xyzac_motion_active", taskState: task.state, interpState: "READING", wasm: { queue_depth: wasmMotion.state.queue_depth, active_id: wasmMotion.state.active_id } };
} }
@@ -510,10 +535,11 @@ export class SimSession {
const ready = Boolean(io.ready) && !estop; const ready = Boolean(io.ready) && !estop;
const loaded = program.status !== 0; const loaded = program.status !== 0;
const action = PANEL_GATE_ACTIONS[input.kind] ?? null; const action = PANEL_GATE_ACTIONS[input.kind] ?? null;
if (input.kind === "TOOL_TOUCH_OFF") return { action: "toolTouchOff", enabled: false, reason: "blocked_by_abi_tool_offsets", taskState: task.state, interpState: "IDLE", wasm: { io_ready: ready, io_estop: estop, program_status: program.program_status } };
const command = input.kind === "JOG_STOP" || input.kind === "JOG_START" || input.kind === "SET_JOG_SCALE" ? WASM_GATE_COMMANDS.JOG const command = input.kind === "JOG_STOP" || input.kind === "JOG_START" || input.kind === "SET_JOG_SCALE" ? WASM_GATE_COMMANDS.JOG
: input.kind === "SET_MAX_VELOCITY" || input.kind === "SET_SPINDLE_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE : input.kind === "SET_MAX_VELOCITY" || input.kind === "SET_SPINDLE_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
: input.kind === "SET_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE : input.kind === "SET_OVERRIDE" ? WASM_GATE_COMMANDS.OVERRIDE
: input.kind === "SPINDLE_COMMAND" || input.kind === "SPINDLE_BRAKE" ? WASM_GATE_COMMANDS.SPINDLE : input.kind === "SPINDLE_COMMAND" || input.kind === "SPINDLE_CONSTANT" || input.kind === "SPINDLE_BRAKE" ? WASM_GATE_COMMANDS.SPINDLE
: input.kind === "COOLANT_COMMAND" ? WASM_GATE_COMMANDS.COOLANT : input.kind === "COOLANT_COMMAND" ? WASM_GATE_COMMANDS.COOLANT
: input.kind === "TOOL_PREPARE" || input.kind === "TOOL_CHANGE" ? WASM_GATE_COMMANDS.TOOL : input.kind === "TOOL_PREPARE" || input.kind === "TOOL_CHANGE" ? WASM_GATE_COMMANDS.TOOL
: input.kind === "PROBE_CONFIG" ? WASM_GATE_COMMANDS.PROBE : input.kind === "PROBE_CONFIG" ? WASM_GATE_COMMANDS.PROBE
@@ -549,9 +575,6 @@ export class SimSession {
}); });
if (result === null) return { action, enabled: false, reason: "linuxcnc_wasm_gate_unavailable", taskState: task.state, interpState: "UNKNOWN" }; if (result === null) return { action, enabled: false, reason: "linuxcnc_wasm_gate_unavailable", taskState: task.state, interpState: "UNKNOWN" };
let reason = result === 0 ? "linuxcnc_wasm_authority" : WASM_GATE_REASONS[result] ?? "linuxcnc_wasm_gate_unknown_command"; let reason = result === 0 ? "linuxcnc_wasm_authority" : WASM_GATE_REASONS[result] ?? "linuxcnc_wasm_gate_unknown_command";
if (input.kind === "PROGRAM_RUN" && Number(program.status) === 5) {
return { action, enabled: false, reason: "xyzac_program_aborted_requires_restart", taskState: task.state, interpState: "IDLE", wasm: { io_ready: ready, io_estop: estop, program_status: program.program_status, queue_depth: wasmMotion.state.queue_depth, gate_result: result } };
}
if (result === 2 && input.kind === "POWER" && input.payload?.active !== true) reason = "xyzac_power_already_off"; if (result === 2 && input.kind === "POWER" && input.payload?.active !== true) reason = "xyzac_power_already_off";
if (result === 3 && input.kind === "POWER") reason = "xyzac_power_already_on"; if (result === 3 && input.kind === "POWER") reason = "xyzac_power_already_on";
if (result === 4 && input.kind === "MDI_EXECUTE") reason = "mdi_while_program_active"; if (result === 4 && input.kind === "MDI_EXECUTE") reason = "mdi_while_program_active";
@@ -576,6 +599,44 @@ export class SimSession {
return { ...selected, kinematics: this.xyzacKinematics.snapshot(), wasm_motion: this.wasmMotion.read() }; return { ...selected, kinematics: this.xyzacKinematics.snapshot(), wasm_motion: this.wasmMotion.read() };
} }
xyzacTouchOff(input) {
if (!this.xyzacProgram) return { status: "error", code: "xyzac_program_unavailable" };
const payload = input.payload ?? {};
const axis = String(payload.axis ?? "").trim().toUpperCase();
if (!["X", "Y", "Z", "A", "C"].includes(axis)) return { status: "error", code: "touch_off_axis_invalid" };
const value = Number(payload.value);
if (!Number.isFinite(value)) return { status: "error", code: "touch_off_value_invalid" };
const selectedSystem = String(payload.system ?? "P1 G54").trim().toUpperCase();
const systemMatch = selectedSystem.match(/^(P[0-9]|G5[4-9](?:\.[123])?)(?:\s+.*)?$/);
if (!systemMatch) return { status: "error", code: "touch_off_system_invalid" };
const systemToken = systemMatch[1];
const systemNumber = systemToken.startsWith("P") ? Number(systemToken.slice(1)) : 1 + (Number(systemToken.slice(1, 3)) - 54) + (systemToken.includes(".") ? Number(systemToken.slice(-1)) : 0);
if (!Number.isInteger(systemNumber) || systemNumber < 0 || systemNumber > 9) return { status: "error", code: "touch_off_system_invalid" };
const displayUnits = String(payload.units ?? "").toLowerCase();
const programUnits = Number(this.xyzacProgram.snapshot().units);
const converted = displayUnits === "mm" && programUnits === 20 ? value / 25.4
: displayUnits === "inch" && programUnits === 21 ? value * 25.4 : value;
const formatted = Number(converted.toFixed(12));
const sourceBlock = `G10 L20 ${systemToken} ${axis}${formatted}`;
// The LinuxCNC interpreter accepts the G54..G59 word in this command;
// the selected WASM interpreter exposes the same operation through its
// canonical P-number form (G54=P1, ..., G59.3=P9).
const block = `G10 L20 P${systemNumber} ${axis}${formatted}`;
const savedMode = this.task.state.mode;
try {
if (savedMode !== "MDI") this.task.setMode("MDI");
this.wasmMotion.setPaused(false);
const offset = this.xyzacProgram.executeBlock(block);
if (offset.status !== "verified") return { ...offset, touch_off: { axis, value, system: selectedSystem, source_system: systemToken, source_block: sourceBlock, block } };
this.task.setProgram("loaded", this.xyzacProgram.snapshot().current_line);
if (savedMode !== "MDI") this.task.setMode(savedMode);
return { status: "verified", command: "TOUCH_OFF", source_block: sourceBlock, block, touch_off: { axis, value, system: selectedSystem, source_system: systemToken, source_block: sourceBlock, block }, program: this.xyzacProgram.snapshot(), motion: offset.motion ?? null };
} catch (error) {
try { if (savedMode !== "MDI" && this.task.state.mode !== savedMode) this.task.setMode(savedMode); } catch { /* preserve the original interpreter error */ }
return { status: "error", code: String(error?.message ?? error) };
}
}
stateForCheckpoint() { stateForCheckpoint() {
const state = { const state = {
context_id: this.contextId, context_id: this.contextId,
@@ -600,6 +661,7 @@ export class SimSession {
wasm_home_limit: this.wasmHomeLimit.exportState(), wasm_home_limit: this.wasmHomeLimit.exportState(),
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}), ...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
preview: this.previewState, preview: this.previewState,
live_path_epoch: this.livePathEpoch,
task: this.task.export(), task: this.task.export(),
scheduler: this.scheduler.export(), scheduler: this.scheduler.export(),
authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(), authoritative: this.xyzacProgram ? { schema: "wasm_authority_boundary.v1", source: "linuxcnc-wasm-worker" } : this.authoritative.export(),
@@ -634,6 +696,7 @@ export class SimSession {
wasm_motion: this.wasmMotion.exportState(), wasm_motion: this.wasmMotion.exportState(),
task_authority: this.task.authority(), task_authority: this.task.authority(),
wasm_home_limit: this.wasmHomeLimit.exportState(), wasm_home_limit: this.wasmHomeLimit.exportState(),
live_path_epoch: this.livePathEpoch,
...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}), ...(this.xyzacKinematics ? { kinematics: this.xyzacKinematics.snapshot() } : {}),
}; };
if (this.xyzacProgram) value.program = { ...this.xyzacProgram.snapshot(), source_text: this.xyzacProgram.source }; if (this.xyzacProgram) value.program = { ...this.xyzacProgram.snapshot(), source_text: this.xyzacProgram.source };
@@ -711,6 +774,7 @@ export class SimSession {
this.configurationState = state.configuration ?? this.configurationState; this.configurationState = state.configuration ?? this.configurationState;
this.halState = state.hal ?? this.halState; this.halState = state.hal ?? this.halState;
this.previewState = state.preview ?? this.previewState; this.previewState = state.preview ?? this.previewState;
this.livePathEpoch = Number.isSafeInteger(state.live_path_epoch) ? state.live_path_epoch : this.livePathEpoch;
this.python = new PythonBridge(); if (state.python) this.python.restoreState(state.python); this.python = new PythonBridge(); if (state.python) this.python.restoreState(state.python);
const liveEvents = liveTimeline.events.events; const liveEvents = liveTimeline.events.events;
const rebasedEvents = liveEvents.map((event, index) => index === liveEvents.length - 1 ? { ...event, tick: record.simulation_tick } : event); const rebasedEvents = liveEvents.map((event, index) => index === liveEvents.length - 1 ? { ...event, tick: record.simulation_tick } : event);
@@ -806,6 +870,7 @@ export class SimSession {
if (trace.state?.configuration !== undefined) this.configurationState = trace.state.configuration; if (trace.state?.configuration !== undefined) this.configurationState = trace.state.configuration;
if (trace.state?.hal !== undefined) this.halState = trace.state.hal; if (trace.state?.hal !== undefined) this.halState = trace.state.hal;
if (trace.state?.preview !== undefined) this.previewState = trace.state.preview; if (trace.state?.preview !== undefined) this.previewState = trace.state.preview;
if (Number.isSafeInteger(trace.state?.live_path_epoch)) this.livePathEpoch = trace.state.live_path_epoch;
if (trace.state?.plc) this.plc.restore(trace.state.plc); if (trace.state?.plc) this.plc.restore(trace.state.plc);
if (trace.state?.hal_runtime) this.hal.restore(trace.state.hal_runtime); if (trace.state?.hal_runtime) this.hal.restore(trace.state.hal_runtime);
if (trace.state?.device_io) this.deviceIo.restore(trace.state.device_io); if (trace.state?.device_io) this.deviceIo.restore(trace.state.device_io);
@@ -856,6 +921,7 @@ export class SimSession {
this.configurationState = checkpoint.state.configuration ?? this.configurationState; this.configurationState = checkpoint.state.configuration ?? this.configurationState;
this.halState = checkpoint.state.hal ?? this.halState; this.halState = checkpoint.state.hal ?? this.halState;
this.previewState = checkpoint.state.preview ?? this.previewState; this.previewState = checkpoint.state.preview ?? this.previewState;
this.livePathEpoch = Number.isSafeInteger(checkpoint.state.live_path_epoch) ? checkpoint.state.live_path_epoch : this.livePathEpoch;
this.python = new PythonBridge(); if (checkpoint.state.python) this.python.restoreState(checkpoint.state.python); this.python = new PythonBridge(); if (checkpoint.state.python) this.python.restoreState(checkpoint.state.python);
this.timeline = new ReplayTimeline(checkpoint.timeline); this.timeline = new ReplayTimeline(checkpoint.timeline);
if (checkpoint.state.hal_runtime?.schema === "wasm_xyzac_hal.v1" && this.xyzacIo) this.xyzacIo.halCommand({ action: "step", ticks: checkpoint.state.hal_runtime.tick ?? 0 }); if (checkpoint.state.hal_runtime?.schema === "wasm_xyzac_hal.v1" && this.xyzacIo) this.xyzacIo.halCommand({ action: "step", ticks: checkpoint.state.hal_runtime.tick ?? 0 });
@@ -1104,6 +1170,8 @@ export class SimSession {
this.configurationState = result.state?.configuration ?? checkpoint?.state?.configuration ?? null; this.configurationState = result.state?.configuration ?? checkpoint?.state?.configuration ?? null;
this.halState = result.state?.hal ?? checkpoint?.state?.hal ?? null; this.halState = result.state?.hal ?? checkpoint?.state?.hal ?? null;
this.previewState = result.state?.preview ?? checkpoint?.state?.preview ?? null; this.previewState = result.state?.preview ?? checkpoint?.state?.preview ?? null;
const recoveredLivePathEpoch = result.state?.live_path_epoch ?? checkpoint?.state?.live_path_epoch;
if (Number.isSafeInteger(recoveredLivePathEpoch)) this.livePathEpoch = recoveredLivePathEpoch;
this.python = new PythonBridge(); this.python = new PythonBridge();
if (result.state?.python) this.python.restoreState(result.state.python); if (result.state?.python) this.python.restoreState(result.state.python);
this.program = new ProgramRunner(this.core); this.program = new ProgramRunner(this.core);
@@ -1322,9 +1390,9 @@ export class SimSession {
const visualClearResponse = () => { const visualClearResponse = () => {
const before = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null }; const before = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null };
this.previewState = null; this.livePathEpoch += 1;
const after = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null }; const after = { task: this.task.hash(), motion: this.wasmMotion.exportState().state_hash, program: this.xyzacProgram?.snapshot().state_hash ?? null };
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, visual_clear: true, visual_only: true, before, after, operation_id: input.operation_id ?? null }), "verified"); return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, visual_clear: true, live_path_epoch: this.livePathEpoch, visual_only: true, before, after, operation_id: input.operation_id ?? null }), "verified");
}; };
switch (input.kind) { switch (input.kind) {
@@ -1929,6 +1997,17 @@ export class SimSession {
case "PROGRAM_LOAD": { case "PROGRAM_LOAD": {
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_load_while_active"), "failed"); if (["RUNNING", "PAUSED"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_load_while_active"), "failed");
const source = input.payload?.source ?? input.payload?.text ?? input.payload?.program_source; const source = input.payload?.source ?? input.payload?.text ?? input.payload?.program_source;
this.livePathEpoch += 1;
const savedMode = this.task.state.mode;
const restoreMode = () => {
if (savedMode !== "MANUAL" && this.task.state.mode !== savedMode) this.task.setMode(savedMode);
};
// AXIS open_file_guts() enters MANUAL before the replacement setup,
// then restores the tab-selected task mode before PLAN_OPEN. The
// final restore below mirrors set_mode_from_tab after the UI refresh.
try { if (savedMode !== "MANUAL") this.task.setMode("MANUAL"); } catch (error) {
return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed");
}
// PROGRAM_LOAD is a replacement transaction. Clear the previous // PROGRAM_LOAD is a replacement transaction. Clear the previous
// canonical preview before asking the WASM interpreter to parse the // canonical preview before asking the WASM interpreter to parse the
// new source, so a parse/subroutine error cannot re-publish the old // new source, so a parse/subroutine error cannot re-publish the old
@@ -1952,9 +2031,12 @@ export class SimSession {
this.xyzacDeferredPhysicalPose = null; this.xyzacDeferredPhysicalPose = null;
this.xyzacDeferredPhysicalPoseApplied = false; this.xyzacDeferredPhysicalPoseApplied = false;
this.xyzacAppendDeferredOnActive = false; this.xyzacAppendDeferredOnActive = false;
restoreMode();
const synchronizedPosition = this.wasmMotion.read().state.position;
const result = this.xyzacProgram.load(source, input.payload?.path ?? null); const result = this.xyzacProgram.load(source, input.payload?.path ?? null);
if (result.status === "verified") { const synchronized = this.xyzacProgram.synchronizePosition(synchronizedPosition); if (synchronized.status !== "verified") { restoreMode(); return finish(this.error(input, "program_synch", synchronized.code), "failed"); } }
if (result.status === "verified") this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity); if (result.status === "verified") this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
if (result.status === "verified") { const subroutines = this.loadXyzacSubroutines(input.payload?.path); if (subroutines.status !== "verified") return finish(this.error(input, "program_subroutine", subroutines.code), "failed"); } if (result.status === "verified") { const subroutines = this.loadXyzacSubroutines(input.payload?.path); if (subroutines.status !== "verified") { restoreMode(); return finish(this.error(input, "program_subroutine", subroutines.code), "failed"); } }
const preview = result.status === "verified" ? this.xyzacProgram.preview() : result; const preview = result.status === "verified" ? this.xyzacProgram.preview() : result;
if (result.status === "verified" && preview.status === "verified") { if (result.status === "verified" && preview.status === "verified") {
this.previewState = preview; this.previewState = preview;
@@ -1964,9 +2046,11 @@ export class SimSession {
if (this.task.state.state === "STOPPED") this.task.recover(); if (this.task.state.state === "STOPPED") this.task.recover();
this.task.setProgram("loaded", 0); this.task.setProgram("loaded", 0);
} }
restoreMode();
const finalStatus = result.status === "verified" && preview.status === "verified" ? "verified" : "error"; const finalStatus = result.status === "verified" && preview.status === "verified" ? "verified" : "error";
return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), finalStatus); return finish(this.envelope(finalStatus === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, preview, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), finalStatus);
} }
restoreMode();
const result = this.program.load(source, input.payload?.path ?? null); const result = this.program.load(source, input.payload?.path ?? null);
if (result.status === "verified") { if (result.status === "verified") {
this.authoritative.reset(); this.authoritative.reset();
@@ -1978,6 +2062,7 @@ export class SimSession {
result.preview = preview.preview ?? { status: "error", code: preview.code ?? "program_preview_failed" }; result.preview = preview.preview ?? { status: "error", code: preview.code ?? "program_preview_failed" };
this.previewState = result.preview; this.previewState = result.preview;
} }
restoreMode();
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
...result, ...result,
task: this.task.export(), task: this.task.export(),
@@ -1990,12 +2075,14 @@ export class SimSession {
} }
case "PROGRAM_RUN": { case "PROGRAM_RUN": {
if (this.xyzacProgram) { if (this.xyzacProgram) {
const previousProgramStatus = this.xyzacProgram.snapshot().program_status;
const result = this.xyzacProgram.start(); const result = this.xyzacProgram.start();
if (result.status === "verified") { this.wasmMotion.setPaused(false); this.task.setMode("AUTO"); if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); } if (result.status === "verified") { this.livePathEpoch += 1; this.wasmMotion.setPaused(false); this.task.setMode("AUTO"); if (this.task.state.state === "STOPPED") this.task.recover(); this.task.transition("RUNNING"); const program = this.xyzacProgram.snapshot(); this.task.setProgram("running", program.execution_source_line ?? program.current_line); }
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed"); return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, reopened_taskfile: ["completed", "aborted"].includes(previousProgramStatus), start_line: 0, program: this.xyzacProgram.snapshot(), task: this.task.export(), wasm_motion: this.wasmMotion.read(), motion: this.wasmMotionProjection(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
} }
const result = this.program.start(); const result = this.program.start();
if (result.status === "verified") { if (result.status === "verified") {
this.livePathEpoch += 1;
this.task.setMode("AUTO"); this.task.setMode("AUTO");
if (this.task.state.state === "STOPPED") this.task.recover(); if (this.task.state.state === "STOPPED") this.task.recover();
if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false); if (this.motionAuthority === "wasm") this.wasmMotion.setPaused(false);
@@ -2094,14 +2181,13 @@ export class SimSession {
this.xyzacDeferredPhysicalPose = null; this.xyzacDeferredPhysicalPose = null;
this.xyzacDeferredPhysicalPoseApplied = false; this.xyzacDeferredPhysicalPoseApplied = false;
this.xyzacAppendDeferredOnActive = false; this.xyzacAppendDeferredOnActive = false;
const source = this.xyzacProgram.source; const result = this.resetXyzacProgramToLoaded();
const result = this.xyzacProgram.load(source, this.xyzacProgram.path); if (result.status === "verified") { this.livePathEpoch += 1; this.wasmMotion.abort(); this.wasmMotion.setPaused(false); this.task.recover(); this.task.setMode("AUTO"); this.task.setProgram("loaded", 0); }
if (result.status === "verified") this.xyzacProgram.setRapidVelocity(this.xyzacRapidVelocity);
if (result.status === "verified") { const subroutines = this.loadXyzacSubroutines(this.xyzacProgram.path); if (subroutines.status !== "verified") return finish(this.error(input, "program_subroutine", subroutines.code), "failed"); const preview = this.xyzacProgram.preview(); if (preview.status === "verified") this.previewState = preview; this.wasmMotion.reset(); this.wasmMotion.setPaused(false); this.task.recover(); this.task.setMode("AUTO"); this.task.setProgram("loaded", 0); }
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed"); return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, program: this.xyzacProgram.snapshot(), task: this.task.export(), motion_authority: "wasm", command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
} }
const result = this.program.restart(); const result = this.program.restart();
if (result.status === "verified") { if (result.status === "verified") {
this.livePathEpoch += 1;
if (["ESTOP", "FAULT"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_restart_faulted"), "failed"); if (["ESTOP", "FAULT"].includes(this.task.state.state)) return finish(this.error(input, "task_state", "program_restart_faulted"), "failed");
if (["RUNNING", "PAUSED"].includes(this.task.state.state)) this.task.transition("STOPPED"); if (["RUNNING", "PAUSED"].includes(this.task.state.state)) this.task.transition("STOPPED");
if (this.task.state.state === "STOPPED") this.task.recover(); if (this.task.state.state === "STOPPED") this.task.recover();
@@ -2225,6 +2311,17 @@ export class SimSession {
operation_id: input.operation_id ?? null, operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed"); }), result.status === "verified" ? "verified" : "failed");
} }
case "TOUCH_OFF": case "TOOL_TOUCH_OFF": {
if (input.kind === "TOOL_TOUCH_OFF") return finish(this.error(input, "abi_capability", "blocked_by_abi_tool_offsets"), "failed");
const result = this.xyzacTouchOff(input);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, {
...result,
task: this.task.export(),
motion_authority: this.xyzacProgram ? "wasm" : this.motionAuthority,
command: input.kind,
operation_id: input.operation_id ?? null,
}), result.status === "verified" ? "verified" : "failed");
}
case "KINEMATICS_SELECT": { case "KINEMATICS_SELECT": {
if (!this.xyzacKinematics) return finish(this.error(input, "kinematics", "xyzac_kinematics_unavailable"), "failed"); if (!this.xyzacKinematics) return finish(this.error(input, "kinematics", "xyzac_kinematics_unavailable"), "failed");
const mode = Number(input.payload?.type); const mode = Number(input.payload?.type);
@@ -2239,7 +2336,12 @@ export class SimSession {
case "HOME": { case "HOME": {
if (this.xyzacProgram) { if (this.xyzacProgram) {
if (this.wasmHomeLimit.read().status !== "verified") return finish(this.error(input, "home_limit", "wasm_home_limit_unavailable"), "failed"); if (this.wasmHomeLimit.read().status !== "verified") return finish(this.error(input, "home_limit", "wasm_home_limit_unavailable"), "failed");
const home = this.wasmHomeLimit.home(input.seq, input.payload?.all ? null : input.payload?.axis ?? null); const requestedAxis = input.payload?.all ? null : String(input.payload?.axis ?? "").toLowerCase() || null;
if (requestedAxis && this.xyzacProgramKinematicsMode !== XYZAC_MODES.identity) return finish(this.error(input, "home_limit", "home_joint_requires_identity"), "failed");
// AXIS home_all_joints()/home_joint() calls ensure_mode(MANUAL)
// before issuing the homing command.
try { if (this.task.state.mode !== "MANUAL") this.task.setMode("MANUAL"); } catch (error) { return finish(this.error(input, "task_mode", String(error?.message ?? error)), "failed"); }
const home = this.wasmHomeLimit.home(input.seq, requestedAxis);
if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed"); if (home.status === "error") return finish(this.error(input, "home_limit", home.code), "failed");
this.task.setMode("MANUAL"); this.task.setMode("MANUAL");
return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), wasm_home_limit: this.wasmHomeLimit.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified"); return finish(this.envelope("COMMAND_RESULT", input, { status: "verified", command: input.kind, motion: this.wasmMotionProjection(), wasm_home_limit: this.wasmHomeLimit.read(), motion_authority: "wasm", operation_id: input.operation_id ?? null }), "verified");
@@ -2291,7 +2393,10 @@ export class SimSession {
const direction = input.payload?.direction === -1 ? -1 : input.payload?.direction === 1 ? 1 : null; const direction = input.payload?.direction === -1 ? -1 : input.payload?.direction === 1 ? 1 : null;
if (direction === null) return finish(this.error(input, "motion_jog", "motion_jog_range"), "failed"); if (direction === null) return finish(this.error(input, "motion_jog", "motion_jog_range"), "failed");
this.wasmMotion.setPaused(false); this.wasmMotion.setPaused(false);
const distance = input.payload?.increment ?? 1; const requestedIncrement = input.payload?.increment;
const distance = requestedIncrement === null || input.payload?.continuous === true
? Math.max(Number(this.wasmMotion.jogVelocity(axis)) * this.xyzacMotionCycleTime * 5, Number.EPSILON)
: requestedIncrement ?? 1;
const jog = this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, c: 5 }[axis], direction, distance, input.seq); const jog = this.wasmMotion.jog({ x: 0, y: 1, z: 2, a: 3, c: 5 }[axis], direction, distance, input.seq);
if (jog.status !== "verified") { if (jog.status !== "verified") {
const code = jog.code === "linuxcnc_tp_jog_-1217" ? "motion_limit_range" : jog.code === "linuxcnc_tp_jog_-1215" ? "motion_jog_nonfinite" : jog.code; const code = jog.code === "linuxcnc_tp_jog_-1217" ? "motion_limit_range" : jog.code === "linuxcnc_tp_jog_-1215" ? "motion_jog_nonfinite" : jog.code;
@@ -2308,7 +2413,7 @@ export class SimSession {
if (result.status === "error" && result.state?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205); if (result.status === "error" && result.state?.fault && this.task.state.state === "RUNNING") this.task.fault(-1205);
return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motionProjection(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed"); return finish(this.envelope(result.status === "verified" ? "COMMAND_RESULT" : "ERROR", input, { ...result, motion: this.motionProjection(), task: this.task.export(), command: input.kind, operation_id: input.operation_id ?? null }), result.status === "verified" ? "verified" : "failed");
} }
case "SET_SPINDLE_OVERRIDE": case "SPINDLE_COMMAND": case "SPINDLE_BRAKE": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT": case "SET_SPINDLE_OVERRIDE": case "SPINDLE_COMMAND": case "SPINDLE_CONSTANT": case "SPINDLE_BRAKE": case "SPINDLE_FAULT": case "SPINDLE_INHIBIT":
case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP": case "SPINDLE_ORIENT": case "SPINDLE_ORIENTED": case "IO_STEP":
case "TOOL_PREPARE": case "TOOL_PREPARED": case "TOOL_CHANGE": case "TOOL_PREPARE": case "TOOL_PREPARED": case "TOOL_CHANGE":
case "TOOL_CHANGED": case "TOOL_ABORT": case "TOOL_FAULT": case "TOOL_CLEAR_FAULT": case "TOOL_CHANGED": case "TOOL_ABORT": case "TOOL_FAULT": case "TOOL_CLEAR_FAULT":

View File

@@ -40,10 +40,16 @@ assert.equal(loaded.payload.preview.source, "linuxcnc-wasm-sim_program");
assert.equal(loaded.payload.preview.line_count, 4711); assert.equal(loaded.payload.preview.line_count, 4711);
assert.equal(loaded.payload.preview.segments.length, 4684); assert.equal(loaded.payload.preview.segments.length, 4684);
assert.equal(send("POWER", { active: true }).kind, "COMMAND_RESULT"); const power = send("POWER", { active: true });
assert.equal(send("HOME", { all: true }).kind, "COMMAND_RESULT"); assert.equal(power.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(power.payload.panel_snapshot.visual, "toolpath"), false);
const home = send("HOME", { all: true });
assert.equal(home.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(home.payload.panel_snapshot.visual, "toolpath"), false);
const run = send("PROGRAM_RUN"); const run = send("PROGRAM_RUN");
assert.equal(run.kind, "COMMAND_RESULT"); assert.equal(run.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(run.payload.panel_snapshot.visual, "toolpath"), false);
assert.equal(run.payload.xyzac_ledger.entries.some((entry) => Object.hasOwn(entry, "response")), false);
let stepped = null; let stepped = null;
let executionTraceEntries = 0; let executionTraceEntries = 0;
let runningQueueGaps = 0; let runningQueueGaps = 0;
@@ -69,6 +75,18 @@ assert.equal(session.timeline.snapshots.snapshots.size, 0);
assert.equal(session.ledger.audit({ maxEntries: 32 }).entries.length, 32); assert.equal(session.ledger.audit({ maxEntries: 32 }).entries.length, 32);
assert.equal(runningQueueGaps, 0); assert.equal(runningQueueGaps, 0);
// Replacing a large preview must leave the worker responsive for the next
// control transaction and must publish only the new program's path.
const aborted = send("PROGRAM_ABORT");
assert.equal(aborted.kind, "COMMAND_RESULT");
const replacement = send("PROGRAM_LOAD", { source: "G21 G90\nG1 X1 Y2 F100\nM2\n", path: "replacement.ngc" });
assert.equal(replacement.kind, "COMMAND_RESULT");
assert.equal(replacement.payload.program.path, "replacement.ngc");
assert.equal(replacement.payload.panel_snapshot.visual.toolpath.length, 1);
const replacementPower = send("POWER", { active: false });
assert.equal(replacementPower.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(replacementPower.payload.panel_snapshot.visual, "toolpath"), false);
console.log(JSON.stringify({ console.log(JSON.stringify({
schema: "xyzac-3d-chips-program.v1", schema: "xyzac-3d-chips-program.v1",
status: "verified", status: "verified",
@@ -84,4 +102,5 @@ console.log(JSON.stringify({
motion_authority: stepped.payload.motion_authority, motion_authority: stepped.payload.motion_authority,
motion_delta_snapshots: session.timeline.snapshots.snapshots.size, motion_delta_snapshots: session.timeline.snapshots.snapshots.size,
ledger_audit_window: session.ledger.audit({ maxEntries: 32 }).entries.length, ledger_audit_window: session.ledger.audit({ maxEntries: 32 }).entries.length,
replacement_segments: replacement.payload.panel_snapshot.visual.toolpath.length,
})); }));

View File

@@ -0,0 +1,76 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { CONTEXT_ID, SimSession } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
assert.equal(bundle.status, "ready");
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "xyzac-live-path-lifecycle" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${kind.toLowerCase()}-${seq}`, payload });
const epoch = (response) => response.payload.panel_snapshot.visual.live_path_epoch;
send("HELLO");
send("SESSION_ATTACH");
send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) });
const source = ["G21", "G90", ...Array.from({ length: 80 }, (_, index) => `G1 X${index % 2 ? 0 : 5} Y${index % 2 ? 0 : 3} Z${index % 2 ? 10 : 8} F300`), "M2", ""].join("\n");
const loaded = send("PROGRAM_LOAD", { source, path: "live-path.ngc" });
assert.equal(loaded.kind, "COMMAND_RESULT");
const loadEpoch = epoch(loaded);
const previewCount = loaded.payload.panel_snapshot.visual.toolpath.length;
assert.ok(loadEpoch > 0 && previewCount > 0);
send("POWER", { active: true });
send("HOME", { all: true });
const run = send("PROGRAM_RUN");
const runEpoch = epoch(run);
assert.equal(runEpoch, loadEpoch + 1);
send("SIM_STEP", { cycles: 5, response: "motion_delta" });
const pause = send("PROGRAM_PAUSE");
assert.equal(epoch(pause), runEpoch);
assert.equal(Object.hasOwn(pause.payload.panel_snapshot.visual, "toolpath"), false);
const resume = send("PROGRAM_RESUME");
assert.equal(epoch(resume), runEpoch);
assert.equal(Object.hasOwn(resume.payload.panel_snapshot.visual, "toolpath"), false);
let completed = resume;
for (let index = 0; index < 5000; index += 1) {
completed = send("SIM_STEP", { cycles: 20, response: "motion_delta" });
if (completed.payload.program?.program_status === "completed" && Number(completed.payload.wasm_motion?.state?.queue_depth ?? -1) === 0) break;
}
assert.equal(completed.payload.program.program_status, "completed");
assert.equal(epoch(completed), runEpoch);
assert.equal(Object.hasOwn(completed.payload.panel_snapshot.visual, "toolpath"), false);
const rerun = send("PROGRAM_RUN");
assert.equal(epoch(rerun), runEpoch + 1);
const abort = send("PROGRAM_ABORT");
assert.equal(epoch(abort), runEpoch + 1);
const replacement = send("PROGRAM_LOAD", { source: "G21\nG90\nG1 X1 Y1 Z1 F300\nM2\n", path: "replacement-live-path.ngc" });
assert.equal(epoch(replacement), runEpoch + 2);
assert.equal(replacement.payload.panel_snapshot.visual.toolpath.length, 1);
const clear = send("VISUAL_CLEAR");
assert.equal(epoch(clear), runEpoch + 3);
assert.equal(clear.payload.panel_snapshot.visual.toolpath.length, 1);
assert.equal(session.previewState.segments.length, 1);
console.log(JSON.stringify({
schema: "xyzac-live-path-lifecycle.v1",
status: "verified",
source: "axis.py:LivePlotter/clear_live_plot/task_pause/task_resume/task_stop",
epochs: { load: loadEpoch, run: runEpoch, pause: epoch(pause), resume: epoch(resume), completed: epoch(completed), rerun: epoch(rerun), abort: epoch(abort), replacement: epoch(replacement), clear: epoch(clear) },
static_preview: { initial_segments: previewCount, clear_plot_segments: clear.payload.panel_snapshot.visual.toolpath.length },
}));

View File

@@ -0,0 +1,86 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { fnv1a64 } from "./replay.mjs";
import { CONTEXT_ID, SimSession } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const programs = [
{ name: "program-a.ngc", source: "G21\nG90\nG0 X0 Y0 Z10 A0 C0\nG1 X10 Y0 Z10 A0 C0 F600\nM2\n", expected: { x: 10, y: 0, z: 10 } },
{ name: "program-b.ngc", source: "G21\nG91\nG1 X5 F600\nG1 Y4 F600\nM2\n", delta: { x: 5, y: 4, z: 0 } },
{ name: "program-c.ngc", source: "G21\nG91\nG1 X-3 Y-2 Z2 F600\nM2\n", delta: { x: -3, y: -2, z: 2 } },
];
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
assert.equal(bundle.status, "ready");
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: "xyzac-multi-program-load" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${kind.toLowerCase()}-${seq}`, payload });
const close = (actual, expected, label) => assert.ok(Math.abs(Number(actual) - Number(expected)) <= 1e-7, `${label}: ${actual} != ${expected}`);
const runToCompletion = () => {
const started = send("PROGRAM_RUN");
assert.equal(started.kind, "COMMAND_RESULT", JSON.stringify(started));
let response = started;
for (let index = 0; index < 2000; index += 1) {
response = send("SIM_STEP", { cycles: 20, response: "motion_delta" });
assert.equal(response.kind, "COMMAND_RESULT", JSON.stringify(response));
if (response.payload.program?.program_status === "completed" && Number(response.payload.wasm_motion?.state?.queue_depth ?? -1) === 0) return response;
}
throw new Error(`program_completion_timeout:${JSON.stringify(response.payload?.program)}`);
};
send("HELLO");
send("SESSION_ATTACH");
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.configuration.state, "TASK_READY");
assert.equal(send("POWER", { active: true }).payload.status, "verified");
assert.equal(send("HOME", { all: true }).payload.status, "verified");
const evidence = [];
let expectedPose = { x: 0, y: 0, z: 0 };
for (const program of programs) {
const before = session.wasmMotion.read().state.position;
close(before.x, expectedPose.x, `${program.name}:before.x`);
close(before.y, expectedPose.y, `${program.name}:before.y`);
close(before.z, expectedPose.z, `${program.name}:before.z`);
const loaded = send("PROGRAM_LOAD", { source: program.source, path: `browser/${program.name}` });
assert.equal(loaded.kind, "COMMAND_RESULT", JSON.stringify(loaded));
assert.equal(loaded.payload.program.source_hash, fnv1a64(program.source));
assert.equal(loaded.payload.program.current_line, 0);
const firstMotion = loaded.payload.preview.segments[0];
close(firstMotion.start.x, before.x, `${program.name}:preview.start.x`);
close(firstMotion.start.y, before.y, `${program.name}:preview.start.y`);
close(firstMotion.start.z, before.z, `${program.name}:preview.start.z`);
const completed = runToCompletion();
expectedPose = program.expected ?? {
x: before.x + program.delta.x,
y: before.y + program.delta.y,
z: before.z + program.delta.z,
};
close(completed.payload.wasm_motion.state.position.x, expectedPose.x, `${program.name}:final.x`);
close(completed.payload.wasm_motion.state.position.y, expectedPose.y, `${program.name}:final.y`);
close(completed.payload.wasm_motion.state.position.z, expectedPose.z, `${program.name}:final.z`);
evidence.push({ name: program.name, source_hash: loaded.payload.program.source_hash, preview_segments: loaded.payload.preview.segments.length, synchronized_start: { x: firstMotion.start.x, y: firstMotion.start.y, z: firstMotion.start.z }, final: expectedPose });
}
const beforeRoundTrip = session.wasmMotion.read().state.position;
const roundTrip = send("PROGRAM_LOAD", { source: programs[0].source, path: `browser/${programs[0].name}` });
assert.equal(roundTrip.payload.program.source_hash, evidence[0].source_hash);
assert.notEqual(roundTrip.payload.program.source_hash, evidence[1].source_hash);
assert.notEqual(roundTrip.payload.program.source_hash, evidence[2].source_hash);
close(roundTrip.payload.preview.segments[0].start.x, beforeRoundTrip.x, "roundtrip:start.x");
close(roundTrip.payload.preview.segments[0].start.y, beforeRoundTrip.y, "roundtrip:start.y");
close(roundTrip.payload.preview.segments[0].start.z, beforeRoundTrip.z, "roundtrip:start.z");
console.log(JSON.stringify({ schema: "xyzac-multi-program-load.v1", status: "verified", source: "axis.py:open_file_guts/task_plan_synch", programs: evidence, round_trip_hash: roundTrip.payload.program.source_hash }));

View File

@@ -136,8 +136,10 @@ record("machine", "Power after ESTOP", "POWER", "axis.py:commands.power", io.sen
const unsupported = [ const unsupported = [
{ button: "Override Limits", reason: "selected XYZAC WASM configuration owns limits; no LinuxCNC override_limits command ABI" }, { button: "Override Limits", reason: "selected XYZAC WASM configuration owns limits; no LinuxCNC override_limits command ABI" },
{ button: "Rapid Override", reason: "blocked_by_abi_rapidrate: selected WASM TP exposes max velocity but not c.rapidrate" },
{ button: "Angular Jog Speed", reason: "blocked_by_abi_angular_jog_scale: selected WASM TP exposes one shared jog scale" },
{ button: "Block delete / Optional pause", reason: "UI toggle is not forwarded to sim_program interpreter state" }, { button: "Block delete / Optional pause", reason: "UI toggle is not forwarded to sim_program interpreter state" },
{ button: "Tool Touch Off", reason: "UI emits fixed G43 and does not expose LinuxCNC G10 prompt/value flow" }, { button: "Tool Touch Off", reason: "blocked_by_abi_tool_offsets: selected WASM interpreter has no canonical G10 L10/L11 tool-offset storage" },
{ button: "Edit tool data / Quit", reason: "desktop process boundary; browser intentionally host-excluded" }, { button: "Edit tool data / Quit", reason: "desktop process boundary; browser intentionally host-excluded" },
]; ];
for (const item of unsupported) rows.push({ group: "scope", button: item.button, command: null, source: "axis.py source mapped", result: "blocked_by_abi", reason: item.reason }); for (const item of unsupported) rows.push({ group: "scope", button: item.button, command: null, source: "axis.py source mapped", result: "blocked_by_abi", reason: item.reason });

View File

@@ -0,0 +1,66 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { CONTEXT_ID, SimSession } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
assert.equal(bundle.status, "ready");
const wasmBinary = await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url));
const core = await createModule({ wasmBinary });
const session = new SimSession(core, { sessionId: "xyzac-program-rerun" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `${kind.toLowerCase()}-${seq}`, payload });
send("HELLO");
send("SESSION_ATTACH");
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.configuration.state, "TASK_READY");
const source = "G21\nG90\nG0 X0 Y0 Z10 A0 C0\nG1 X10 Y5 Z5 A15 C20 F600\nG1 X0 Y0 Z10 A0 C0 F600\nM2\n";
assert.equal(send("PROGRAM_LOAD", { source, path: "rerun.ngc" }).payload.status, "verified");
assert.equal(send("POWER", { active: true }).payload.status, "verified");
assert.equal(send("HOME", { all: true }).payload.status, "verified");
assert.equal(send("PROGRAM_RUN").payload.task.state, "RUNNING");
let completed;
for (let index = 0; index < 1000; index += 1) {
completed = send("SIM_STEP", { cycles: 20, response: "motion_delta" });
if (completed.payload.program.program_status === "completed" && completed.payload.wasm_motion.state.queue_depth === 0) break;
}
assert.equal(completed.payload.program.program_status, "completed");
assert.equal(completed.payload.task.state, "STOPPED");
const rerunCompleted = send("PROGRAM_RUN");
assert.equal(rerunCompleted.kind, "COMMAND_RESULT");
assert.equal(rerunCompleted.payload.status, "verified");
assert.equal(rerunCompleted.payload.reopened_taskfile, true);
assert.equal(rerunCompleted.payload.start_line, 0);
assert.equal(rerunCompleted.payload.program.current_line, 0);
assert.equal(rerunCompleted.payload.task.state, "RUNNING");
const rerunStep = send("SIM_STEP", { cycles: 2, response: "motion_delta" });
assert.equal(rerunStep.kind, "COMMAND_RESULT", JSON.stringify(rerunStep));
assert.equal(rerunStep.payload.program?.program_status, "running", JSON.stringify(rerunStep));
assert.equal(send("PROGRAM_ABORT").payload.task.state, "STOPPED");
const rerunAborted = send("PROGRAM_RUN");
assert.equal(rerunAborted.kind, "COMMAND_RESULT");
assert.equal(rerunAborted.payload.status, "verified");
assert.equal(rerunAborted.payload.reopened_taskfile, true);
assert.equal(rerunAborted.payload.program.current_line, 0);
assert.equal(rerunAborted.payload.task.state, "RUNNING");
console.log(JSON.stringify({
schema: "xyzac-program-rerun.v1",
status: "verified",
source: "linuxcnc-emctaskmain-auto-run",
completed_rerun: { reopened_taskfile: rerunCompleted.payload.reopened_taskfile, start_line: rerunCompleted.payload.start_line },
aborted_rerun: { reopened_taskfile: rerunAborted.payload.reopened_taskfile, start_line: rerunAborted.payload.start_line },
}));

View File

@@ -48,7 +48,10 @@ const clear = send("VISUAL_CLEAR", "visual-clear");
assert.equal(clear.kind, "COMMAND_RESULT"); assert.equal(clear.kind, "COMMAND_RESULT");
assert.equal(clear.payload.visual_only, true); assert.equal(clear.payload.visual_only, true);
assert.deepEqual(clear.payload.before, clear.payload.after); assert.deepEqual(clear.payload.before, clear.payload.after);
assert.equal(send("PROGRAM_RUN", "program-run").kind, "COMMAND_RESULT"); const run = send("PROGRAM_RUN", "program-run");
assert.equal(run.kind, "COMMAND_RESULT");
assert.equal(Object.hasOwn(run.payload.panel_snapshot.visual, "toolpath"), false);
assert.equal(run.payload.xyzac_ledger.entries.some((entry) => Object.hasOwn(entry, "response")), false);
const runningSwitch = send("KINEMATICS_SELECT", "select-running", { type: 0 }); const runningSwitch = send("KINEMATICS_SELECT", "select-running", { type: 0 });
assert.equal(runningSwitch.kind, "ERROR"); assert.equal(runningSwitch.kind, "ERROR");
assert.equal(runningSwitch.payload.gate_reason, "xyzac_interp_running"); assert.equal(runningSwitch.payload.gate_reason, "xyzac_interp_running");
@@ -78,7 +81,7 @@ const sessionSource = await readFile(new URL("./sim-session.mjs", import.meta.ur
const tpSource = await readFile(new URL("./wasm-linuxcnc-tp.mjs", import.meta.url), "utf8"); const tpSource = await readFile(new URL("./wasm-linuxcnc-tp.mjs", import.meta.url), "utf8");
const combined = `${appSource}\n${panelSource}\n${contractSource}`; const combined = `${appSource}\n${panelSource}\n${contractSource}`;
for (const symbol of ["XyzacTrtPanel", "XyzacProgramPane", "XyzacKinematicsControl", "XyzacManualPanel", "XyzacLiveParameters", "XyzacDiagnostics", "Spindle", "Feed actual", "TP velocity", "Live parameters", "KINEMATICS_SELECT", "VISUAL_CLEAR", "POWER", "switchkinsXyzac", "Operation trace", "Alarms / recovery"]) assert.ok(combined.includes(symbol), symbol); for (const symbol of ["XyzacTrtPanel", "XyzacProgramPane", "XyzacKinematicsControl", "XyzacManualPanel", "XyzacLiveParameters", "XyzacDiagnostics", "Spindle", "Feed actual", "TP velocity", "Live parameters", "KINEMATICS_SELECT", "VISUAL_CLEAR", "POWER", "switchkinsXyzac", "Operation trace", "Alarms / recovery"]) assert.ok(combined.includes(symbol), symbol);
for (const symbol of ["wasmSnapshotReady", "manualReady", "sourceJogScale", "sourceVelocityScale", "spindleOverrideSource", "reopenDefaults", "gates.reset.enabled", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "WASM PENDING", "LinuxCNC WASM override-limits command unavailable"]) assert.ok(appSource.includes(symbol), symbol); for (const symbol of ["wasmSnapshotReady", "manualReady", "sourceJogScale", "sourceVelocityScale", "spindleOverrideSource", "SET_JOG_SCALE", "SET_MAX_VELOCITY", "SET_SPINDLE_OVERRIDE", "WASM PENDING", "LinuxCNC WASM override-limits command unavailable"]) assert.ok(appSource.includes(symbol), symbol);
for (const symbol of ["loadUiState", "saveUiState", "UI_STATE_SAVE", "UI_STATE_LOAD"]) assert.ok(`${appSource}\n${workerClientSource}\n${opfsBridgeSource}`.includes(symbol), symbol); for (const symbol of ["loadUiState", "saveUiState", "UI_STATE_SAVE", "UI_STATE_LOAD"]) assert.ok(`${appSource}\n${workerClientSource}\n${opfsBridgeSource}`.includes(symbol), symbol);
for (const forbidden of [/defaultValue=\"50\"/, /defaultValue=\"100\"/, /setBlockDelete/, /setOptionalStop/, /state\.status === \"running\" \? \"READING\"/]) assert.equal(forbidden.test(appSource), false, String(forbidden)); for (const forbidden of [/defaultValue=\"50\"/, /defaultValue=\"100\"/, /setBlockDelete/, /setOptionalStop/, /state\.status === \"running\" \? \"READING\"/]) assert.equal(forbidden.test(appSource), false, String(forbidden));
for (const forbidden of [/_sim_/, /_malloc/, /\bHEAP(?:U|F|I)?\d*\b/, /navigator\.storage/, /localStorage/, /postMessage\s*\(/]) assert.equal(forbidden.test(combined), false, String(forbidden)); for (const forbidden of [/_sim_/, /_malloc/, /\bHEAP(?:U|F|I)?\d*\b/, /navigator\.storage/, /localStorage/, /postMessage\s*\(/]) assert.equal(forbidden.test(combined), false, String(forbidden));

View File

@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import createModule from "../../build-wasm/linuxcnc-sim-core.js";
import { loadSimulationCase, toWorkerConfiguration } from "../workspace/sim-case-loader.mjs";
import { SimSession, CONTEXT_ID } from "./sim-session.mjs";
const CASE = "linuxcnc/configs/sim/axis/vismach/5axis/table-rotary-tilting/xyzac-trt.ini";
const fetchImpl = async (url) => {
try {
const body = await readFile(new URL(`../../${String(url).replace(/^\//, "")}`, import.meta.url));
return { ok: true, text: async () => body.toString("utf8") };
} catch {
return { ok: false, text: async () => "" };
}
};
const bundle = await loadSimulationCase({ path: CASE, fetchImpl });
const core = await createModule({ wasmBinary: await readFile(new URL("../../build-wasm/linuxcnc-sim-core.wasm", import.meta.url)) });
const session = new SimSession(core, { sessionId: "xyzac-touch-off-source-flow" });
const base = { schema_version: 1, context_id: CONTEXT_ID, session_id: session.sessionId };
let seq = 0;
const send = (kind, payload = {}) => session.accept({ ...base, kind, seq: ++seq, operation_id: `touch-${seq}`, payload });
send("HELLO");
send("SESSION_ATTACH");
assert.equal(send("LOAD_WORKSPACE", { root_path: "/workspace", configuration: toWorkerConfiguration(bundle) }).payload.status, "verified");
assert.equal(send("PROGRAM_LOAD", { source: "G21 G90\nG0 X10 Y5 Z200 A30 C45\nG1 X20 Y10 Z180 A35 C50 F100\nM2\n", path: "touch.ngc" }).payload.status, "verified");
assert.equal(send("POWER", { active: true }).payload.status, "verified");
assert.equal(send("HOME", { all: true }).payload.status, "verified");
const jogStart = send("JOG_START", { axis: "X", direction: 1, increment: null, continuous: true });
assert.equal(jogStart.payload.status, "verified");
let jogSample = jogStart;
for (let sample = 0; sample < 5; sample += 1) jogSample = send("SIM_STEP", { cycles: 1, response: "motion_delta" });
assert.ok(Number(jogSample.payload.wasm_motion?.state?.position?.x ?? 0) > 0);
assert.equal(send("JOG_STOP", {}).payload.status, "verified");
const systemTouch = send("TOUCH_OFF", { axis: "X", value: 12, system: "P1 G54", units: "mm" });
assert.equal(systemTouch.kind, "COMMAND_RESULT");
assert.equal(systemTouch.payload.touch_off.source_block, "G10 L20 P1 X12");
assert.equal(systemTouch.payload.touch_off.block, "G10 L20 P1 X12");
assert.equal(systemTouch.payload.task.mode, "MANUAL");
assert.equal(send("TOOL_PREPARE", { tool: 1, pocket: 1 }).payload.status, "verified");
assert.equal(send("TOOL_PREPARED", { prepared: true }).payload.status, "verified");
assert.equal(send("TOOL_CHANGE", {}).payload.status, "verified");
assert.equal(send("TOOL_CHANGED", { changed: true }).payload.status, "verified");
const toolTouch = send("TOOL_TOUCH_OFF", { axis: "Z", value: 3, system: "G54", units: "mm" });
assert.equal(toolTouch.kind, "ERROR");
assert.equal(toolTouch.payload.gate_reason, "blocked_by_abi_tool_offsets");
const malformed = send("TOUCH_OFF", { axis: "Q", value: 0, system: "G54", units: "mm" });
assert.equal(malformed.kind, "ERROR");
assert.equal(malformed.payload.code, "touch_off_axis_invalid");
console.log(JSON.stringify({ schema: "xyzac-touch-off-source-flow.v1", status: "verified", source_block: systemTouch.payload.touch_off.source_block, wasm_block: systemTouch.payload.touch_off.block, tool_touch_off: toolTouch.payload.gate_reason, invalid: malformed.payload.code }));

View File

@@ -78,6 +78,7 @@ export class WasmProgramRuntime {
this.executionLines = []; this.executionLines = [];
this.executionLineMetadata = []; this.executionLineMetadata = [];
this.rapidVelocity = 35; this.rapidVelocity = 35;
this.synchronizedPosition = Object.fromEntries(AXES.map((axis) => [axis, 0]));
if (!this.available || typeof core?._malloc !== "function") return; if (!this.available || typeof core?._malloc !== "function") return;
this.pointer = core._malloc(call(core, "sim_program_size", "number", [], [])); this.pointer = core._malloc(call(core, "sim_program_size", "number", [], []));
this.motionPointer = core._malloc(SEGMENT_BYTES); this.motionPointer = core._malloc(SEGMENT_BYTES);
@@ -137,6 +138,8 @@ export class WasmProgramRuntime {
try { try {
const loaded = temporary.load(this.source, this.path); const loaded = temporary.load(this.source, this.path);
if (loaded.status !== "verified") return loaded; if (loaded.status !== "verified") return loaded;
const synchronized = temporary.synchronizePosition(this.synchronizedPosition);
if (synchronized.status !== "verified") return synchronized;
temporary.setRapidVelocity(this.rapidVelocity); temporary.setRapidVelocity(this.rapidVelocity);
for (const subroutine of this.subroutines) { for (const subroutine of this.subroutines) {
const added = temporary.addSubroutine(subroutine.name, subroutine.source, subroutine.path); const added = temporary.addSubroutine(subroutine.name, subroutine.source, subroutine.path);
@@ -158,6 +161,15 @@ export class WasmProgramRuntime {
} }
start() { return this.invoke("sim_program_start", [], "program_start"); } start() { return this.invoke("sim_program_start", [], "program_start"); }
synchronizePosition(value) {
const position = Object.fromEntries(AXES.map((axis) => [axis, Number(value?.[axis] ?? 0)]));
if (Object.values(position).some((coordinate) => !Number.isFinite(coordinate))) return { status: "error", code: "program_position_nonfinite" };
this.core.HEAPF64.set(AXES.map((axis) => position[axis]), this.samplePosePointer / 8);
const result = call(this.core, "sim_program_set_position", "number", ["number", "number"], [this.pointer, this.samplePosePointer]);
if (result !== 0) return programError("program_set_position", result);
this.synchronizedPosition = position;
return { status: "verified", command: "sim_program_set_position", position: { ...position } };
}
setRapidVelocity(velocity) { setRapidVelocity(velocity) {
if (!Number.isFinite(Number(velocity)) || Number(velocity) <= 0) return { status: "error", code: "program_rapid_velocity_invalid" }; if (!Number.isFinite(Number(velocity)) || Number(velocity) <= 0) return { status: "error", code: "program_rapid_velocity_invalid" };
this.rapidVelocity = Number(velocity); this.rapidVelocity = Number(velocity);
@@ -200,6 +212,8 @@ export class WasmProgramRuntime {
const subroutines = [...this.subroutines]; const subroutines = [...this.subroutines];
const loaded = this.load(this.source, this.path); const loaded = this.load(this.source, this.path);
if (loaded.status !== "verified") throw new Error(loaded.code); if (loaded.status !== "verified") throw new Error(loaded.code);
const synchronized = this.synchronizePosition(Object.fromEntries(AXES.map((axis) => [axis, Number(value[axis] ?? 0)])));
if (synchronized.status !== "verified") throw new Error(synchronized.code);
for (const subroutine of subroutines) { for (const subroutine of subroutines) {
const added = this.addSubroutine(subroutine.name, subroutine.source); const added = this.addSubroutine(subroutine.name, subroutine.source);
if (added.status !== "verified") throw new Error(added.code); if (added.status !== "verified") throw new Error(added.code);
@@ -243,6 +257,15 @@ export class WasmProgramRuntime {
const result = call(this.core, "sim_program_read_motion", "number", ["number", "number"], [this.pointer, this.motionPointer]); const result = call(this.core, "sim_program_read_motion", "number", ["number", "number"], [this.pointer, this.motionPointer]);
if (result !== 0) return null; if (result !== 0) return null;
const value = segment(this.core, this.motionPointer); const value = segment(this.core, this.motionPointer);
// LinuxCNC does not enqueue a trajectory segment when a linear/rapid
// block resolves to the current synchronized pose. This occurs naturally
// when AUTO_RUN reopens a completed/aborted file and its first positioning
// block names the position where the previous run stopped.
const linearNoop = value.motion_type === 1 && AXES.every((axis) => Math.abs(Number(value.start?.[axis] ?? 0) - Number(value.end?.[axis] ?? 0)) <= 1e-12);
if (linearNoop) {
call(this.core, "sim_program_clear_motion", "number", ["number"], [this.pointer]);
return null;
}
// LinuxCNC rapid blocks do not carry an F word. The trajectory queue // LinuxCNC rapid blocks do not carry an F word. The trajectory queue
// requires a positive velocity, so use the simulation's configured rapid // requires a positive velocity, so use the simulation's configured rapid
// rate for that WASM-produced linear segment. // rate for that WASM-produced linear segment.

View File

@@ -139,6 +139,7 @@ export class WasmXyzacIo {
command(kind, payload = {}) { command(kind, payload = {}) {
if (kind === "RESET") return this.invoke("sim_io_reset"); if (kind === "RESET") return this.invoke("sim_io_reset");
if (kind === "SPINDLE_CONSTANT") return { status: "verified", command: "SPINDLE_CONSTANT", source: "linuxcnc_spindle_constant" };
if (kind === "SPINDLE_COMMAND") { if (kind === "SPINDLE_COMMAND") {
if (payload.delta !== undefined) return this.invoke("sim_io_spindle_adjust", [payload.spindle ?? 0, Number(payload.delta)]); if (payload.delta !== undefined) return this.invoke("sim_io_spindle_adjust", [payload.spindle ?? 0, Number(payload.delta)]);
const requestedRpm = Number(payload.rpm ?? payload.speed ?? 0); const requestedRpm = Number(payload.rpm ?? payload.speed ?? 0);

View File

@@ -34,7 +34,11 @@ export class XyzacSession extends SimSession {
// every previous response inside the stored response object. // every previous response inside the stored response object.
const payload = { ...response.payload }; const payload = { ...response.payload };
payload.xyzac_session = this.authority(); payload.xyzac_session = this.authority();
payload.xyzac_ledger = this.ledger.audit({ includeResponses: input.kind !== "SIM_STEP", maxEntries: input.kind === "SIM_STEP" ? 32 : 2048 }); // The operation ledger remains complete inside the worker for recovery.
// Browser responses need only an audit summary: including historical
// PROGRAM_LOAD responses repeatedly clones the full toolpath after every
// control click, which can starve an abort behind a large G-code preview.
payload.xyzac_ledger = this.ledger.audit({ includeResponses: false, maxEntries: input.kind === "SIM_STEP" ? 32 : 128 });
payload.xyzac_scheduler = this.scheduler.export(); payload.xyzac_scheduler = this.scheduler.export();
return { ...response, payload }; return { ...response, payload };
} }