Compare commits

26 Commits

Author SHA1 Message Date
c168cdf0c4 docs: rename esp-a-axis branch to private-mods in AGENTS.md 2026-05-03 15:14:17 +02:00
214de86bdf Z-A coupling: drop active jog/MDI auto-coordination, keep refuse-only check
The active rewriter for jogs/MDI didn't help anyway because the
continuous-jog buttons send rate-based /api/jog commands to the AVR
and bypass the planner+MDI path entirely. Rather than build out
continuous-jog coupling on the ESP firmware or fake it with browser
ticks, simplify back to:

  * Runtime check (Planner.__encode + ExternalAxis motion entry
    points) refuses any move that would worsen the Z-A gap. Already
    improvement-aware so X/Y jogs and Z-up/A-down recoveries pass.
  * File preprocessor (AuxPreprocessor) injects pre-position A
    moves into uploaded gcode so well-formed programs run without
    operator intervention.

Operator workflow: jog freely down to the safe band; if you need to
go deeper, lower A first (aux jog mm) or use a step-jog MDI like
'G91 G0 Z-10 A-10' that includes the A delta. Programs do the right
thing on their own.
2026-05-03 15:10:26 +02:00
be3a53e518 AuxAxis: push home_preclear_mm via HOMECFG
Tells the auxcnc ESP how far (in steps) to back off if HOME is
invoked while the limit switch is already tripped. The ESP now
hard-fails instead of zeroing blindly when the switch stays active
after the preclear move. Default 10 mm; set home_preclear_mm=0 to
disable the preclear and revert to immediate failure.
2026-05-03 15:10:26 +02:00
b9f7bbeec9 Z-A coupling: auto-coordinate A on jogs and MDI
Match the file-preprocessor behaviour for live operator input. When a
Z-down jog or MDI line would push (A-Z) above the safe band, append
the matching A delta to the same line so the planner runs Z and A
together. Same direction-aware refusal: only error when the operator
explicitly asks A to move *up* (delta > 0) past the bound, or when
the required A would violate A's soft minimum.

Implementation:
  * ExternalAxis.coordinate_mdi rewrites a multi-line MDI burst,
    tracking G90/G91 modal across lines (jogs always emit
    M70/G91/G0/M72; standard MDI defaults to G90). Z and A targets
    are computed in machine coords using offset_z and offset_a so
    the work-coord A token we emit is consistent with the operator's
    frame.
  * The 'A0' the jog UI emits for axes that aren't moving is treated
    as 'no A intent' (G91 delta of zero) and freely overridden.
  * Hooked into Mach.mdi after the existing ATC rewrite. On
    ExternalAxisError the burst is dropped with a user message; the
    planner check downstream still fires as defense in depth.
  * Planner.__encode also catches ExternalAxisError now (vs
    bricking on uncaught) - logs to the operator messages list and
    halts the cycle cleanly so subsequent jogs work.
  * check_coupling itself is now improvement-aware: only refuses
    moves that worsen an existing violation. Pure XY jogs and
    Z-up/A-down recovery moves pass even when (A-Z) is currently
    above the bound.

Tested locally with synthetic MDI: small Z jog within band, Z jog
across the boundary (auto-injects A delta), G90 MDI G0 Z-50
(appends A106), explicit A-lift while Z deep (refuses), pure XY
jog (unchanged), G91 A-down (unchanged), G90 G0 A0 with
offset_a=134 (refuses as lift to home).
2026-05-03 15:10:26 +02:00
ec40429dec Z-A coupling interlock: prevent collision between Z and A tools
The auxiliary A axis carries a tool that hangs below the Z spindle.
Beyond a small Z descent the two physically collide unless A drops
with Z. Enforce in machine coords:

    A_machine - Z_machine <= K
    K = (A_home_mm - z_home_mm) + couple_z_clearance_mm

With our setup K = (134 - 0) + 22 = 156. At rest A=134 Z=0, A-Z=134
which is fine. Z can descend 22mm before the rule starts forcing A
down with it.

Two complementary layers:

(1) AuxPreprocessor injection (auto-fix uploaded files)
    Tracks modal Z, A and distance mode (G90/G91) while scanning the
    file. When a line would put A above Z by more than the clearance
    we emit a 'G0 A<safe>' BEFORE the line so A is already at the
    safe position when Z descends. Endpoint check is sufficient
    because Z moves monotonically along a single line.

    Errors are raised (not silently auto-fixed) when:
      - the line lifts A above the safe band while Z stays put
        (would require auto-injecting a Z-up which could swing
        through a fixture)
      - the line endpoint targets an A above the safe band

    G91 disables injection with a one-shot warning; the runtime
    check still applies.

(2) Runtime check (ExternalAxis.check_coupling)
    Single source of truth for live motion. Hooked into:
      * Planner.__encode for every line block (covers MDI and
        running programs - gplan emits machine-coord targets)
      * ExternalAxis.execute_to_mm/enqueue_target_mm/enqueue_line
        for direct A motion (covers UI jog/move and planner-A
        dispatch)
    Raises ExternalAxisError on violation; gplan and the API both
    surface the message. Skipped when coupling is disabled or the
    axis isn't homed (mirrors the soft-limit gate).

    Continuous Z jog from the AVR is not gated - it's an active
    operator action without a pre-known endpoint. Operator-driven
    over-travel during continuous jog will be caught by the next
    MDI/file-load attempt.

Configuration in aux.json:
    couple_z_enabled        bool   default true (per agreed setup)
    couple_z_clearance_mm   float  default 22.0
    z_home_mm               float  default 0.0

Surfaced in the new Z-A Coupling section of the A Axis settings
page with a description of the rule. Existing aux.json files get
the new keys via the merged-defaults path on read.

Tested locally with synthetic gcode covering Z descent, combined
moves, A lift while Z deep, G92 reset, G91 mode, and combined
Z+A target violations.
2026-05-03 15:10:26 +02:00
789ca4871b docs: A axis architecture (renamed from W) + README section
- Move docs/AUX_W_AXIS.md to docs/AUX_A_AXIS.md and rebadge W -> A
  throughout, with a header note pointing at ExternalAxis as the
  current implementation.
- README: A-axis fork heading, link to AUX_A_AXIS.md, /api/aux/status
  in verify-flash, small comment in scripts/deploy/local.sh.
2026-05-03 15:10:26 +02:00
d65abac0d5 Config: idempotent macro file rename W -> A
The auxiliary stepper used to be exposed as a W axis. After the
gplan integration it is exposed as A. Migrate persisted macro
config on every load:
  w_down.nc -> a_down.nc
  w_up.nc   -> a_up.nc
  'W Down'  -> 'A Down'
  'W Up'    -> 'A Up'

Idempotent so a stale in-memory copy can never reintroduce the old
names.
2026-05-03 15:10:26 +02:00
6bc1e4feea UI: A axis surface (DRO row, jog, Home A, settings page)
Front-end side of the gplan-integrated A axis (B3).

- a-axis-view.{js,pug}: dedicated settings page that mounts the
  AAxisSettings Svelte component and lives at #a-axis in the V09
  settings rail.
- AAxisSettings.svelte: aux.json-backed form (axis letter, port,
  homing direction, soft limits, ATC pin map, etc.) with master
  Save integration via 'onefin:save-all'.
- main.ts + SettingsView.svelte: register AAxisSettings in the
  Svelte component map; SettingsView no longer embeds the W axis
  fieldset.
- settings-shell-view: 'A Axis' rail entry; route to a-axis-view.
- app.js: extend settings family to include 'a-axis'; broadcast
  onefin:save-all from the master Save button.
- control-view: Home All button waits for the gantry cycle to
  finish before firing Home A on a non-virtual setup; A jog
  buttons; aux_jog/aux_home/aux_jog_incr methods.
- control-view.pug: A row in the DRO (with set-position + zero +
  home actions), A- / A+ tiles in the jog grid (gated on
  w.enabled || a.enabled), legacy W row kept for installs that
  haven't migrated to the gplan integration.
- style.styl: dro-axis.axis-w color.
2026-05-03 15:10:26 +02:00
3cab165d80 ATC: M100..M103 preprocessor + Mach MDI rewrite + hook handlers
ATC pneumatics in g-code (drop tool / grab tool / release clamp /
engage clamp) are expressed as M100..M103. AuxPreprocessor rewrites
those into (MSG,HOOK:droptool:) etc on file upload + on planner
load + on MDI input, so the Hooks layer (B1) can dispatch them via
registered ATC handlers in Ctrl.

- AuxPreprocessor.py: regex-based file rewriter, idempotent.
- FileHandler: invoke preprocessor on every upload.
- Planner.init: also re-preprocess on load (catches files written
  before this version).
- Mach.mdi: same rewrite for ad-hoc MDI input so M101 typed at the
  console produces a HOOK message.
- Ctrl: register the four ATC hooks (droptool/grabtool/release/clamp)
  with block_unpause + auto_resume so programs using them pause at
  the right point and resume cleanly. aux_home retained as a legacy
  alias for older preprocessed files.
2026-05-03 15:10:26 +02:00
6d4c51bd49 ExternalAxis: virtual A axis through gplan, mirrored on the ESP
ExternalAxis exposes the auxcnc-driven ESP stepper as motor 4 (a
synthetic, host-only motor that gplan sees but the AVR doesn't). The
result is a virtual A axis that is fully integrated with the planner:
G1 A25 F1500 schedules a coordinated S-curve and the ESP runs the
exact same 7-segment trajectory the AVR would have run if A were a
real motor.

- ExternalAxis.py: synthetic-motor state, S-curve LINE block forward
  to the ESP, soft-limit enforcement, option-(b) homing (user A=0
  at the home limit).
- State: walk motors 0..4 in find_motor; clear both homed and h on
  reset; expose synthetic motor vars.
- axis-vars.js: motor-4 guard so the JS computed axis bindings don't
  throw when motor 4 has no entry in config.motors; resolve motor_id
  for the synthetic axis by scanning state['4an'].
- Ctrl: instantiate ExternalAxis after AuxAxis, share the axis_letter
  setting, wire AuxAxis state observer.
- Web: route /api/aux/{home,jog,move} through ExternalAxis when it
  is enabled so the DRO and synthetic-motor flags stay in sync.
2026-05-03 15:10:26 +02:00
32bf65934b AuxAxis: ESP32-driven external stepper (auxcnc)
bbctrl.AuxAxis manages a stepper driven by an auxcnc-style ESP32
over /dev/ttyUSB0 (or whichever serial port). Persistent config in
aux.json; UI talks to it via /api/aux/* endpoints.

- AuxAxis: serial framing, position tracking, soft-limit enforcement,
  homing state machine, ATC pneumatic control (M100..M103 wrappers).
- Ctrl: instantiate self.aux alongside the other subsystems and
  close it during shutdown.
- Web: handlers for /api/aux/{config,status,home,abort,jog,move,set-zero}.
2026-05-03 15:10:26 +02:00
6185c65afa Hooks: ATC IPC layer between gcode preprocessor and runtime
Adds bbctrl.Hooks: a small dispatch layer for HOOK:<event>:<data>
messages embedded in g-code as (MSG,HOOK:droptool:) etc. Hooks can
block the unpause until the registered callback completes and
auto-resume after.

- bbctrl.Hooks: registry, fire, dispatch_hook_message, persistent
  config in hooks.json, REST surface (/api/hooks, /api/hooks/save,
  /api/hooks/status, /api/hooks/fire/<event>).
- Ctrl: instantiate self.hooks alongside the other subsystems.
- Planner._add_message: when a (MSG,...) line is HOOK:<event>:<data>,
  route it through ctrl.hooks instead of state.messages so it never
  surfaces as a UI popup and dispatch is immediate (state.messages
  has a 250ms debounce).
- Web: handlers for the /api/hooks routes.
2026-05-03 15:10:26 +02:00
2b949c4f00 docs: AGENTS.md - branch model and where-does-it-go guide 2026-05-03 15:10:25 +02:00
72c69d3000 docs: CHANGELOG entry summarising the community-fork additions 2026-05-03 14:12:18 +02:00
94072253d4 ui: V09 redesign - Control/Program/Console/Settings shell
Replaces the legacy side-menu chrome with a 4-tab top header.

- index.pug: tablet/kiosk fit-to-viewport script, header tab nav,
  estop/state badges in header.
- app.js: route hash to (control|program|console|<settings-family>),
  multi-section settings shell.
- control-view: header DRO, jog grid, MDI/probe/macros panels.
- program-view + program-mixin: file browser + toolpath preview +
  run/pause/stop, replaces the legacy 'macros' tab content.
- console-view: MDI shell, message log, indicators.
- settings-shell-view: rail-driven inner pages (Display & Units,
  Probing, G-code & Motion, Macros, Network, etc.).
- settings-view: filter Svelte SettingsView to one rail section.
- SettingsView.svelte: tag every section with data-sec=… so the
  filter above can hide non-matching ones.
- style.styl: ~2700 lines of V09 layout, DRO, jog grid, status
  strip, and tablet/kiosk variants.

No A-axis / auxiliary-axis content lives on this branch.
2026-05-03 14:11:29 +02:00
c10f5c053a ui: assorted polish - Vue async fix, OrbitControls passive listeners, path-viewer + motor-view + indicators
- main.js: disable Vue async batching so reactive writes from
  hashchange listeners propagate synchronously (matches Vue 1's older
  default; avoids dropped DRO updates).
- orbit.js: pass {passive:false} to wheel/touch listeners so
  OrbitControls.preventDefault() actually suppresses page panning.
- path-viewer: opaque dark canvas (no flash from page background),
  zero-size guard, ResizeObserver cleanup on destroy.
- motor-view: stop clobbering user edits with controller state.
- estop/indicators/tool-view/path-viewer pug: rename FA4 icons to FA6,
  add viewBox to estop SVG, fix tool-view trailing newline.
2026-05-03 14:07:35 +02:00
b9e880448e ui: upgrade to FontAwesome 6, harden burger-menu shim
- Drop FA4 font files and font-awesome.min.css.
- Ship FA6 webfonts (solid, regular, brands) and fa6.min.css.
- io-indicator: use FA6 names (fa-circle-plus / -minus / -exclamation).
- static/js/ui.js: no-op the legacy side-menu click handler when menu
  links are not present (V09 chrome removes them) so the Settings tab
  no longer logs 'cannot set properties of null'.
2026-05-03 14:07:06 +02:00
8224ab8f97 boot: cold-boot optimisations cutting bbctrl listen by ~8s on the Pi
- scripts/rc.local.fast: minimal rc.local that defers the heavy bits.
- scripts/bbserial-rebind.service: oneshot unit that unbinds ttyAMA0
  from pl011 and (re)loads bbserial before bbctrl.service.
- scripts/bbctrl.service: declare the After/Wants on bbserial-rebind
  so we can rely on it rather than racing rc.local.
- scripts/install.sh: ship the cold-boot bits with firmware updates
  (mask sysstat, replace dphys-swapfile with an fstab swap entry).
- scripts/rc.local + setup_rpi.sh + setup.py: wire updated paths.
2026-05-03 14:06:44 +02:00
0b5ab2ff3b diag: add startup-timing trace and /api/diag/timing endpoint
bbctrl.Trace records monotonic-anchored events from process start.
Ctrl, Comm, the Web layer and __init__ are instrumented so a single
GET /api/diag/timing returns a full timeline of import, controller
init, AVR connection, first websocket, and first GET /. The
restart-timing.js client posts performance.now() marks back so the
browser side can be aligned in the same view.

Used to drive the cold-boot optimisations that reduce listen latency
on the Pi by ~8s.
2026-05-03 14:06:17 +02:00
94270e7725 Planner: lazy-load camotics.gplan so HTTP listener comes up first
Importing camotics.gplan pulls in a C++ extension (libstdc++,
boost::python, etc.) which adds several seconds to bbctrl startup
on the Pi. Defer it to Planner.init() — bbctrl can serve the UI
and accept connections without ever touching the planner, and the
penalty is paid only the first time motion is queued.
2026-05-03 14:04:30 +02:00
7a6e2cd00b Camera: replace deprecated @web.asynchronous with async def
Tornado removed @web.asynchronous in 6.x; bbctrl on the Pi runs an
older but compatible async-aware build. Switching to coroutine syntax
keeps the streaming endpoint working across Tornado 5/6.
2026-05-03 14:04:03 +02:00
785dafc3bc Log: tolerate missing rotated log files on startup
Recursive _rotate() may have already moved or unlinked the source path
by the time we try to rename it (also tolerates concurrent logrotate
runs from /etc/cron.reboot). Catch FileNotFoundError instead of
crashing bbctrl on startup.
2026-05-03 14:03:58 +02:00
0d5370a724 deploy: add macOS-friendly deploy scripts (local / hardware / prod)
- deploy.sh dispatcher + thin shims (deploy-local.sh / -hardware.sh / -prod.sh).
- scripts/deploy/local.sh: build UI bundle and serve via tmux session on :8770 for offline iteration.
- scripts/deploy/hardware.sh: rsync-based push to a Pi over SSH and restart bbctrl.service.
- scripts/deploy/prod.sh: bundle release tarball.
- scripts/deploy/patch_font_mime.py: hot-patches Chromium 72's broken WOFF2 mime handling on the kiosk Pi.
2026-05-03 14:03:50 +02:00
f170002c8b tools: SD card backup/restore script
backup/onefinity-backup.sh: dd-based whole-card backup/restore with
shrink/expand support so a Pi image can be moved between SD cards
of different sizes.
2026-05-03 14:03:24 +02:00
24215a8b36 build: document Pi firmware build/flash + gplan.so cross-build via Stretch Docker
- .pi/BUILD.md: end-to-end macOS dev workflow, deploy paths, dphys-swapfile vs fstab, troubleshooting.
- .pi/Dockerfile.gplan + build-gplan.sh: rebuild gplan.so from source on Raspbian Stretch (Bullseye is too new for the toolchain).
- Makefile: ensure trailing newline between concatenated pug templates so Pug doesn't glue file boundaries together.
2026-05-03 14:03:20 +02:00
3ca19ea875 chore: ignore build/log/scratch artefacts and dev-container helper 2026-05-03 14:03:08 +02:00
7 changed files with 182 additions and 1100 deletions

77
AGENTS.md Normal file
View File

@@ -0,0 +1,77 @@
# Onefinity firmware — agent guidelines
## Branch model
This fork lives on **two long-lived branches**:
- **`master`** — public-facing fork. General-use upgrades on top of
upstream OneFinity firmware: V09 UX redesign, Font Awesome 6, faster
cold boot, macOS dev/deploy tooling, build & flash docs, SD-card
backup, `/api/diag/timing`, kiosk/tablet polish, and assorted
bug-fixes. **No A-axis, ATC, hooks, or auxcnc/ESP content.** Aim for
changes that benefit any Onefinity owner.
- **`private-mods`** — bespoke shop branch. Stacks on top of `master`
and adds everything specific to the auxcnc-ESP-driven A axis and
the ATC: `Hooks` (ATC IPC), `AuxAxis` (ESP serial driver),
`ExternalAxis` (virtual A through gplan), `AuxPreprocessor` (M100-M103),
Z-A coupling interlock, the A-axis UI surface, and the
`/api/aux/*` endpoints.
Upstream:
- `upstream``https://github.com/OneFinityCNC/onefinity-firmware.git`
- `origin` → Gitea (`https://gitea.home.muehe.org/muehe/onefinity-firmware.git`)
`origin/pre-split-backup` is a tag preserving the pre-split master
tip. Keep it indefinitely until further notice.
## Where does a change go?
| Change | Branch |
|---|---|
| UI polish, theme, layout that any user benefits from | `master` |
| Build / install / boot performance | `master` |
| Diagnostics, logging, generic Python / Tornado fixes | `master` |
| Anything that touches `AuxAxis`, `ExternalAxis`, `Hooks`, `AuxPreprocessor` | `private-mods` |
| Anything mentioning the auxcnc ESP, `/dev/ttyUSB0`, the M100-M103 ATC pneumatics, or motor index 4 | `private-mods` |
| Z-A coupling interlock, ATC tool change sequencing | `private-mods` |
| A-axis UI (DRO row, jog tile, settings page, A-axis routes) | `private-mods` |
| W → A renames or aux.json migrations | `private-mods` |
When in doubt: ask "would this be useful on a stock Onefinity with no
ESP attached?" If yes → `master`. If no → `private-mods`.
## Workflow
```bash
# Day-to-day shop / hardware work (default)
git checkout private-mods
# … do work, commit …
git push origin private-mods
# Generic improvement to master
git checkout master
# … do work, commit …
git push origin master
# After landing on master, replay private-mods on top
git checkout private-mods
git rebase master
git push --force-with-lease origin private-mods
```
If a change accidentally lands on `master` but is bespoke (touches
the file table above), move it: `git reset --hard <prev>` on master,
cherry-pick onto `private-mods`, force-push master.
## Deploy
- `./deploy.sh local` — UI bundle on `localhost:8770` (tmux session
`onefin-local`). No controller backend; A-axis row stays hidden.
- `./deploy.sh hardware` — rsync to the Pi over SSH, restart
`bbctrl.service`. Use the `private-mods` branch on the shop Pi.
- `./deploy.sh prod` — bundle a release tarball.
See `.pi/BUILD.md` for the full build / flash / cross-compile flow.
## Commit before ending a turn; push after significant changes.

View File

@@ -1,6 +1,67 @@
OneFinity CNC Controller Firmware Changelog
===========================================
## Unreleased (community fork)
General-use additions on top of upstream OneFinity firmware.
### UI
- V09 redesign: 4-tab top header (Control / Program / Console /
Settings) replaces the legacy side menu.
- Control: redesigned DRO with per-axis offset + zero + home
actions, jog grid with consistent button sizing across kiosk
and tablet, status strip with live state / velocity / spindle.
- Program: dedicated tab for run / pause / stop, file browser,
toolpath preview.
- Console: MDI shell, message log, indicators.
- Settings: rail-driven inner pages so each section is its own
focused panel rather than one long scroll.
- Tablet mode (`?tablet=1`) pins the UI to 1920x1080 and scales
it to fit the actual viewport.
- Kiosk mode (`?kiosk=1`, auto on localhost): tighter layout for
the controller's onboard 1366x768 screen.
- Font Awesome 6 throughout (replaces FA4).
- Fix: stop clobbering motor settings while the user is editing
them.
- Fix: keep jog grid visible during jog/home/probe/MDI activity.
- Fix: opaque dark canvas for path-viewer (no flash through page
background).
- Fix: OrbitControls now uses non-passive wheel/touch listeners so
it can suppress page panning while interacting with the 3D
viewer.
- Fix: macros tab no longer renders placeholder color stripes for
`#dedede`/`#fff`-only macros.
- Fix: hide the X cursor in kiosk mode (touchscreen).
- Fix: chromium 72 mime + flex-gap fallbacks (some kiosk Pis ship
with that older browser build).
- Fix: Vue 1 async batching disabled so reactive writes from
`hashchange` listeners propagate synchronously.
### Boot / install
- Cold-boot optimisations cutting bbctrl listen latency by ~8s on
the Pi (mask sysstat, replace dphys-swapfile with an fstab swap
entry, lazy-load `camotics.gplan`, `bbserial-rebind.service`
with explicit `Before=bbctrl.service`).
- `install.sh` now ships these with firmware updates.
- `bbctrl.Trace` + `/api/diag/timing` for measuring startup, with
a UI-side `restart-timing.js` client that POSTs browser marks.
- `Camera.py` switched from deprecated `@web.asynchronous` to
`async def` so the streaming endpoint works on newer Tornado.
- `Log.py` tolerates missing rotated log files on startup
(concurrent logrotate runs from `/etc/cron.reboot` no longer
crash bbctrl).
### Build / tooling
- `.pi/BUILD.md`: end-to-end macOS dev workflow, deploy paths,
troubleshooting.
- `.pi/Dockerfile.gplan` + `build-gplan.sh`: rebuild `gplan.so`
from source on Raspbian Stretch (Bullseye is too new).
- `deploy.sh` dispatcher with `local`, `hardware`, `prod` modes.
- `backup/onefinity-backup.sh`: dd-based whole-card backup/restore
with shrink/expand support.
- `Makefile`: ensure trailing newlines between concatenated pug
templates so Pug doesn't glue file boundaries together.
## v1.0.8
- Fixed chatter and lost steps issues (most commonly seen by Fusion users), re-enabled support for G61, G61.1, G64.
- Fixed 3d preview on Safari-based web browsers (MacOS & iOS)

View File

@@ -1,8 +1,9 @@
# OneFinity CNC Controller Firmware (W-axis fork)
# OneFinity CNC Controller Firmware (A-axis fork)
This is the OneFinity / Buildbotics bbctrl firmware with a virtual W
axis driven by an auxcnc ESP32 over USB serial. See
[docs/AUX_W_AXIS.md](docs/AUX_W_AXIS.md) for the design and config.
This is the community-fork firmware (V09 UI, FA6, cold-boot work,
macOS dev tooling) with a virtual A axis driven by an auxcnc ESP32
over USB serial. See [docs/AUX_A_AXIS.md](docs/AUX_A_AXIS.md) for the
design and config.
## Layout
@@ -16,7 +17,7 @@ src/svelte-components/ Newer Svelte UI for dialogs and settings
src/pug/ Pug templates compiled into build/http/index.html
src/resources/ Static assets and config templates
scripts/ Install / update / RPi build helpers
docs/ Architecture, dev setup, W-axis docs
docs/ Architecture, dev setup, A-axis docs
```
## Build & flash (quick path, macOS or Linux)
@@ -100,7 +101,8 @@ bbctrl restarts, then the new UI).
```bash
curl -s http://onefinity.local/ | grep -c "OneFinity"
curl -s http://onefinity.local/api/aux/status # if W axis is enabled
curl -s http://onefinity.local/api/diag/timing | head
curl -s http://onefinity.local/api/aux/status # if A axis is enabled
```
## Build & flash (full path, Debian/Linux)
@@ -109,12 +111,12 @@ For AVR + GPlan rebuilds, see [docs/development.md](docs/development.md).
That path uses qemu + chroot to cross-compile gplan for ARM and needs
the `gcc-avr` / `avr-libc` toolchain.
## W axis (auxcnc)
## A axis (auxcnc)
This fork adds a virtual W axis. See
[docs/AUX_W_AXIS.md](docs/AUX_W_AXIS.md) for:
This fork adds a virtual A axis. See
[docs/AUX_A_AXIS.md](docs/AUX_A_AXIS.md) for:
- G-code surface (`G28 W0`, `G1 W25`, etc.)
- G-code surface (`G28 A0`, `G1 A25`, etc.)
- The G-code preprocessor and hook architecture
- aux.json keys
- REST API (`/api/aux/*`)

View File

@@ -1,6 +1,17 @@
# W axis (auxcnc) integration
# A axis (auxcnc) integration
This adds a virtual `W` axis to the bbctrl controller, driven by the
> **Note:** This document describes the original out-of-band W-axis
> architecture (gcode preprocessor rewriting W tokens into HOOK
> messages dispatched between blocks). The current implementation
> integrates the auxcnc-driven stepper as a *virtual A axis* through
> gplan via a synthetic motor (`bbctrl/ExternalAxis.py`), so A is
> blended with XYZ in the same S-curve plan and the gcode surface
> below applies as plain `A` words.
>
> The HOOK pipeline still exists for ATC pneumatics (M100..M103),
> see `bbctrl/AuxPreprocessor.py`.
This adds a virtual `A` axis to the bbctrl controller, driven by the
auxcnc ESP32 over USB serial (`/dev/ttyUSB0`). The ESP owns step-pulse
generation, real-time limit-switch monitoring, and the homing dance.
The Pi owns units (mm), soft limits, sequencing inside G-code jobs, and
@@ -10,15 +21,15 @@ a small REST API for jogging / homing from the UI.
The bbctrl planner (gplan) only understands `xyzabc`, so adding a true
7th axis would require rebuilding gplan + the AVR firmware. We avoid
that by treating W as a synchronous out-of-band axis: W moves run
that by treating W as a synchronous out-of-band axis: A moves run
*between* G-code blocks, not blended with XYZ.
Pipeline:
1. User uploads a G-code file containing `W` words.
1. User uploads a G-code file containing `A` words.
2. `FileHandler` runs `AuxPreprocessor` on the upload, rewriting W
tokens in place into `(MSG,HOOK:aux:<mm>)` etc. The original line
minus the W word continues to drive XYZ.
minus the A word continues to drive XYZ.
3. The planner sees only XYZ + message comments. When it reaches a
message line, the message goes through `state.add_message` which
`Hooks._on_state_change` watches for the `HOOK:` prefix.
@@ -26,25 +37,25 @@ Pipeline:
for the event name (`aux`, `aux_rel`, `aux_home`, `aux_setzero`).
5. The handler runs in a hook thread, gating `Mach.unpause` until done.
While the handler is busy the machine is in HOLDING - no XYZ motion
can resume until W finishes.
can resume until A finishes.
6. The handler talks to the ESP over `/dev/ttyUSB0` via `AuxAxis`,
blocking on a deterministic reply token (`[step] done`, `[home]
done`, etc).
MDI commands containing `W` words are rewritten the same way at the
MDI commands containing `A` words are rewritten the same way at the
`Mach.mdi()` boundary so manual jog and macros work too.
## G-code surface
```gcode
G21 G90
G28 W0 ; home W axis
G1 W25 F300 ; move W to 25 mm absolute
G1 X100 W12.5 ; mixed: W moves first, then XYZ (configurable)
G28 A0 ; home A axis
G1 A25 F300 ; move A to 25 mm absolute
G1 X100 W12.5 ; mixed: A moves first, then XYZ (configurable)
G91
G1 W-2.5 ; relative W move
G1 A-2.5 ; relative A move
G90
G92 W0 ; set current W as zero (G92-style)
G92 A0 ; set current A as zero (G92-style)
```
Rules:
@@ -105,18 +116,18 @@ limit switch when the axis isn't homed yet).
**Control view**
- A jog row appears under the XYZ jog grid when `aux_enabled` is true,
with three buttons: `W-`, `W+`, and a wide `Home W`. There is
with three buttons: `A-`, `A+`, and a wide `Home W`. There is
intentionally no separate "set zero" or "W origin" button - homing
lands the axis at `home_position_mm` (0 by default), so home and
zero are the same point.
- The DRO table shows a W axis row with position, status (OFFLINE /
- The DRO table shows a A axis row with position, status (OFFLINE /
UNHOMED / HOMED), and a single Home button in the actions column
(the cog and map-marker columns are placeholders for layout).
**Settings view**
A "W Axis (auxcnc)" section exposes every aux.json field except
`enabled` (which stays read-only - flipping the W axis on/off requires
`enabled` (which stays read-only - flipping the A axis on/off requires
editingaux.json on the controller, so a fresh install can't surprise
the user with hardware that isn't there). Saving PUTs the merged
config to `/api/aux/config/save`, which writes aux.json and pushes
@@ -135,19 +146,19 @@ These are pushed via `state.set` and visible in the websocket stream:
## Edge cases
- **ESP reboots mid-job**: `[boot] auxcnc v=N` banner -> `aux_homed`
cleared, message added: "W axis controller restarted - re-home
before use". Subsequent W moves still run; if you want a hard fail
cleared, message added: "A axis controller restarted - re-home
before use". Subsequent A moves still run; if you want a hard fail
instead, that's a one-line change in `_require_present`.
- **Limit switch closed at boot of HOME**: `[home] failed
reason=already_at_limit` -> hook raises -> Mach surfaces error.
- **Pause mid-W-move**: the hook is blocking, so feed-hold takes
effect *after* the W move completes. For an immediate stop hit
effect *after* the A move completes. For an immediate stop hit
estop; the Hooks listener will call `aux.abort()` which sends
`ABORT\n` to the ESP and the step-pulse loop exits.
- **Connection loss**: if `/dev/ttyUSB0` can't be opened at startup,
`aux_present=False` and any G-code with W will fail-fast at the
hook handler with "Aux axis not connected".
- **No home enforcement**: per design, manual jogs and W moves are
- **No home enforcement**: per design, manual jogs and A moves are
allowed even without a successful home. Soft limits still apply
unless you use the raw step jog endpoint.

View File

@@ -1,900 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Onefinity · V09 · Full UX</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fortawesome/fontawesome-free@6.5.2/css/all.min.css" />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box}
html,body{margin:0;font-family:'Inter',system-ui,sans-serif;background:#0f172a;color:#e5e7eb}
.mono{font-family:'JetBrains Mono',monospace}
/* ---------- HOST CHROME ---------- */
.host{min-height:100vh;display:flex;flex-direction:column;background:radial-gradient(circle at 30% 0%,#374151,#0f172a 60%);}
.topbar{display:flex;align-items:center;gap:.6rem;flex-wrap:wrap;padding:.7rem 1rem;background:rgba(255,255,255,.04);border-bottom:1px solid rgba(255,255,255,.08);position:sticky;top:0;z-index:50;backdrop-filter:blur(10px);}
.topbar .brand{display:flex;align-items:center;gap:.5rem;font-weight:800;color:#fff}
.stripe-logo-sm{background:repeating-linear-gradient(135deg,#a7c7a3 0 6px,transparent 6px 14px);width:26px;height:26px;border-radius:6px}
.pill{padding:.3rem .65rem;border-radius:9999px;font-size:.75rem;font-weight:700;background:rgba(255,255,255,.08);color:#cbd5e1}
.seg-host{display:inline-flex;background:rgba(255,255,255,.05);border-radius:9999px;padding:3px;gap:3px}
.seg-host button{padding:.4rem .85rem;border-radius:9999px;font-size:.78rem;font-weight:700;color:#cbd5e1}
.seg-host button.on{background:#fde047;color:#0f172a}
.toggle{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .7rem;border-radius:8px;background:rgba(255,255,255,.08);font-size:.75rem;font-weight:600;color:#e5e7eb;cursor:pointer}
.toggle.on{background:#22c55e;color:#0b1220}
.stage{flex:1;display:flex;align-items:flex-start;justify-content:center;padding:1rem;overflow:auto}
.scaler-viewport{position:relative;flex:0 0 auto}
.scaler{position:absolute;top:0;left:0;width:1920px;height:auto;transform-origin:top left;transition:transform .2s}
/* ---------- KIOSK (1920x1080) ---------- */
.kiosk{
width:1920px;height:1080px;overflow:hidden;border-radius:14px;position:relative;
box-shadow:0 30px 60px rgba(0,0,0,.5);
display:flex;flex-direction:column;
background:#ffffff;color:#0f172a;
}
/* Header */
.head{
flex:0 0 96px;height:96px;
display:flex;align-items:center;gap:18px;
padding:0 24px;background:#ffffff;border-bottom:1px solid #e5e7eb;
}
.brand-blk{display:flex;align-items:center;gap:14px}
.menu-btn{width:54px;height:54px;border-radius:12px;background:#f1f5f9;border:1px solid #e2e8f0;color:#0f172a;display:inline-flex;align-items:center;justify-content:center;font-size:1.1rem}
.menu-btn:hover{background:#e2e8f0}
.brand-logo{width:42px;height:42px;border-radius:8px;background:repeating-linear-gradient(135deg,#a7c7a3 0 6px,transparent 6px 14px)}
.brand-name{font-weight:900;font-size:22px;letter-spacing:-.01em}
/* Underline-ribbon tab style (V02) */
.kiosk-tabs{display:inline-flex;gap:0;margin-right:auto;padding-left:18px;align-items:stretch;height:96px}
.ktab{
position:relative;
height:96px;padding:0 26px;
background:transparent;border:none;border-radius:0;
color:#475569;font-size:1.05rem;font-weight:700;
display:inline-flex;align-items:center;gap:.55rem;cursor:pointer;
transition:color .15s;
}
.ktab i{font-size:1.1rem;color:#94a3b8;transition:color .15s}
.ktab:hover{color:#0f172a}
.ktab:hover i{color:#475569}
.ktab.active{color:#0f172a}
.ktab.active i{color:#0f172a}
.ktab.active::after{
content:"";position:absolute;left:14px;right:14px;bottom:0;
height:5px;background:#fde047;border-radius:5px 5px 0 0;
}
.ktab .ktab-badge{background:#fee2e2;color:#991b1b;font-size:.7rem;padding:3px 8px;border-radius:9999px;font-weight:800;line-height:1}
.ktab.active .ktab-badge{background:#fde047;color:#0f172a}
.sys-btn{display:inline-flex;align-items:center;gap:.55rem;height:54px;padding:0 1.1rem;border-radius:14px;background:#f1f5f9;border:1px solid #e2e8f0;color:#0f172a;font-size:.9rem;font-weight:600}
.sys-btn .pip{width:9px;height:9px;border-radius:9999px;background:#22c55e}
.state-badge{display:inline-flex;align-items:center;gap:.6rem;height:54px;padding:0 1.1rem;border-radius:14px;background:#dcfce7;color:#166534;font-weight:800;font-size:1rem;letter-spacing:.04em}
.state-badge .dot{width:10px;height:10px;border-radius:9999px;background:currentColor;position:relative}
.state-badge .dot::after{content:"";position:absolute;inset:-3px;border-radius:9999px;border:2px solid currentColor;opacity:.5;animation:pls 1.6s ease-out infinite}
@keyframes pls{0%{transform:scale(.7);opacity:.6}100%{transform:scale(2.2);opacity:0}}
.estop{
width:88px;height:88px;background:#dc2626;color:#fff;font-weight:900;
clip-path:polygon(30% 0,70% 0,100% 30%,100% 70%,70% 100%,30% 100%,0 70%,0 30%);
display:flex;align-items:center;justify-content:center;
border:3px solid #fff;box-shadow:0 0 0 3px #b91c1c, 0 8px 20px rgba(220,38,38,.35);font-size:1rem;letter-spacing:.05em
}
/* Body */
.body{flex:1;display:flex;flex-direction:column;background:#f1f5f9;min-height:0}
.panel{display:none;flex:1;min-height:0;flex-direction:column;padding:18px;gap:14px}
.panel.active{display:flex}
/* ----------------------- V09 jog/macro palette ----------------------- */
/* Flat soft slate, no shadow */
:root{
--jog-bg:#3f4b63;
--jog-hover:#4a5777;
--jog-dir-bg:#5b6885;
--jog-dir-hover:#6a779a;
--jog-ghost-bg:#8c97ad;
--jog-ghost-hover:#9ba6bb;
--jog-ink:#fff;
--jog-ghost-ink:#0f172a;
}
/* JOG */
.jog-card{background:#fff;border:1px solid #e5e7eb;border-radius:18px;display:flex;flex-direction:column;padding:18px;min-height:0}
.jog-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:14px}
.jog-title{font-size:18px;font-weight:700;color:#0f172a}
.jog-title .step{color:#0ea5e9;font-family:'JetBrains Mono',monospace}
.step-seg{display:inline-flex;background:#f1f5f9;border:1px solid #e2e8f0;border-radius:14px;padding:4px}
.step-seg button{height:48px;min-width:64px;padding:0 1rem;border-radius:11px;font-size:1rem;font-weight:800;color:#475569;cursor:pointer}
.step-seg button.active{background:#0f172a;color:#fde047}
.jog-grid{display:grid;grid-template-columns:repeat(4,1fr);grid-template-rows:repeat(4,1fr);gap:10px;flex:1;min-height:0}
.jbtn{
border-radius:16px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;
user-select:none;-webkit-tap-highlight-color:transparent;cursor:pointer;
font-weight:700;font-size:1.05rem;border:none;
transition:transform .06s, background .15s;
background:var(--jog-bg);color:var(--jog-ink);
}
.jbtn:hover{background:var(--jog-hover)}
.jbtn:active{transform:scale(.97)}
.jbtn .ico{font-size:1.6rem}
.jbtn .lbl{font-size:.8rem;color:inherit;opacity:.85;font-weight:600}
.jbtn.dir{background:var(--jog-dir-bg)} .jbtn.dir:hover{background:var(--jog-dir-hover)}
.jbtn.ghost{background:var(--jog-ghost-bg);color:var(--jog-ghost-ink)} .jbtn.ghost:hover{background:var(--jog-ghost-hover)}
/* DRO + STATUS */
.control-grid{display:grid;grid-template-columns:720px 1fr;gap:18px;flex:1;min-height:0}
.right-col{display:grid;grid-template-rows:1fr 158px;gap:18px;min-height:0}
.dro-card{background:#fff;border:1px solid #e5e7eb;border-radius:18px;overflow:hidden;display:flex;flex-direction:column}
.dro-head{display:grid;grid-template-columns:84px 1.4fr 1fr 1fr 170px 170px 280px;column-gap:.75rem;align-items:center;padding:14px 22px;background:#f8fafc;border-bottom:1px solid #e5e7eb;font-size:.78rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;color:#94a3b8}
.dro-row{display:grid;grid-template-columns:84px 1.4fr 1fr 1fr 170px 170px 280px;column-gap:.75rem;align-items:center;padding:14px 22px;border-bottom:1px solid #f1f5f9;flex:1;min-height:0}
.dro-row:last-child{border-bottom:none}
.dro-axis{font-weight:900;font-size:46px;line-height:1}
.dro-pos{font-family:'JetBrains Mono',monospace;font-size:36px;font-weight:800}
.dro-pos .u{font-size:14px;color:#94a3b8;font-weight:500;margin-left:6px}
.dro-sec{font-family:'JetBrains Mono',monospace;font-size:18px;color:#64748b;font-weight:600}
.axis-x{color:#dc2626} .axis-y{color:#16a34a} .axis-z{color:#2563eb} .axis-w{color:#7c3aed}
.chip{display:inline-flex;align-items:center;gap:.4rem;padding:.4rem .7rem;border-radius:9999px;font-size:.78rem;font-weight:700}
.chip-green{background:#dcfce7;color:#166534}
.chip-amber{background:#fef3c7;color:#92400e}
.chip-red{background:#fee2e2;color:#991b1b}
.chip-slate{background:#e2e8f0;color:#334155}
.chip-blue{background:#dbeafe;color:#1e40af}
.icon-btn{
width:72px;height:72px;border-radius:14px;cursor:pointer;
display:inline-flex;align-items:center;justify-content:center;
color:#334155;background:#f1f5f9;border:1px solid #e2e8f0;
font-size:1.45rem
}
.icon-btn:hover{background:#e2e8f0}
.actions-cell{display:flex;justify-content:flex-end;gap:10px}
.z-highlight{background:rgba(254,243,199,.4)}
.status-strip{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;min-height:0}
.stat-card{background:#fff;border:1px solid #e5e7eb;border-radius:18px;padding:18px 22px;display:flex;flex-direction:column;justify-content:center}
.stat-label{font-size:11px;font-weight:800;text-transform:uppercase;letter-spacing:.14em;color:#94a3b8}
.stat-val{font-family:'JetBrains Mono',monospace;font-size:30px;font-weight:800;margin-top:6px}
.stat-val.ok{color:#166534}
.stat-sub{font-size:13px;color:#64748b;margin-top:2px}
/* MACROS */
.macro-row{display:grid;grid-template-columns:repeat(8,1fr);gap:12px;flex:0 0 auto}
.macro-btn{
height:84px;border-radius:14px;border:none;cursor:pointer;
color:#fff;background:#3f4b63;
font-weight:800;font-size:1rem;
display:flex;align-items:center;justify-content:center;gap:.6rem;
transition:transform .06s, background .15s
}
.macro-btn:hover{background:#4a5777}
.macro-btn:active{transform:translateY(2px)}
.macro-btn .mnum{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:8px;background:#fde047;color:#0f172a;font-size:.85rem;font-weight:900}
.macro-btn .micon{font-size:1.1rem;opacity:.75}
/* =============================================================
PROGRAM PANEL
============================================================= */
.program-card{background:#fff;border:1px solid #e5e7eb;border-radius:18px;display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden}
.ptab-bar{display:flex;align-items:center;gap:6px;border-bottom:1px solid #e5e7eb;flex:0 0 auto;background:#fff;padding:0 18px}
.ptab{height:60px;padding:0 22px;font-weight:700;color:#64748b;border-bottom:3px solid transparent;font-size:1rem;display:inline-flex;align-items:center;gap:.5rem;cursor:pointer}
.ptab:hover{color:#0f172a}
.ptab.active{color:#0f172a;border-bottom-color:#0f172a}
.ptab .ptab-badge{background:#fde047;color:#0f172a;font-size:.7rem;padding:2px 7px;border-radius:9999px;font-weight:900}
.action-bar{display:flex;align-items:center;gap:12px;padding:18px;flex-wrap:wrap;border-bottom:1px solid #f1f5f9}
.action-btn{height:84px;padding:0 24px;border-radius:14px;background:#3f4b63;color:#fff;border:none;cursor:pointer;display:inline-flex;flex-direction:column;align-items:center;justify-content:center;gap:4px;font-weight:800;font-size:.9rem;letter-spacing:.04em;transition:background .15s}
.action-btn:hover{background:#4a5777}
.action-btn .ico{font-size:1.4rem}
.action-btn.run{background:#16a34a}
.action-btn.run:hover{background:#15803d}
.action-btn.stop{background:#0f172a}
.action-btn.stop:hover{background:#1e293b}
.action-btn.danger{background:#fee2e2;color:#7f1d1d}
.action-btn.danger:hover{background:#fecaca}
.action-btn.danger .ico{color:#dc2626}
.file-bar{display:flex;align-items:center;gap:10px;padding:14px 18px;flex-wrap:wrap;border-bottom:1px solid #f1f5f9}
.file-btn{height:54px;padding:0 18px;border-radius:12px;background:#f1f5f9;border:1px solid #e2e8f0;font-weight:700;color:#0f172a;font-size:.9rem;display:inline-flex;align-items:center;gap:.5rem;cursor:pointer}
.file-btn:hover{background:#e2e8f0}
.file-select{height:54px;padding:0 16px;border-radius:12px;background:#fff;border:1px solid #e2e8f0;font-weight:600;color:#0f172a;font-size:.9rem;display:inline-flex;align-items:center;gap:.5rem;cursor:pointer}
.file-select .caret{color:#94a3b8;margin-left:.5rem}
.file-select.primary{background:#fff;border:2px solid #0ea5e9;flex:1;min-width:300px}
.program-body{flex:1;display:grid;grid-template-columns:1fr 600px;min-height:0}
.gcode{font-family:'JetBrains Mono',monospace;font-size:14px;line-height:1.6;background:#fafafa;border-right:1px solid #f1f5f9;padding:14px 0;overflow:auto;color:#1e293b}
.gline{display:grid;grid-template-columns:60px 1fr;gap:14px;padding:1px 18px 1px 0}
.gline:nth-child(odd){background:#f4f4f5}
.gline .gn{color:#f59e0b;text-align:right;font-weight:700}
.gline.cur{background:#dbeafe !important}
.gline.cur .gn{color:#1e40af}
.gcomment{color:#64748b}
.gword{color:#0f172a}
.gnum{color:#16a34a}
.viewer{display:flex;flex-direction:column;min-height:0}
.viewer-3d{flex:1;background:#0b1220;position:relative;overflow:hidden;display:flex;align-items:center;justify-content:center}
.viewer-tools{display:flex;gap:8px;padding:14px;border-top:1px solid #f1f5f9;background:#fff;flex-wrap:wrap}
.vtool{height:60px;width:60px;border-radius:12px;background:#f1f5f9;border:1px solid #e2e8f0;color:#475569;display:inline-flex;align-items:center;justify-content:center;font-size:1.2rem;cursor:pointer}
.vtool:hover{background:#e2e8f0}
.vtool.on{background:#0f172a;color:#fff;border-color:#0f172a}
.vinfo{padding:14px 18px;background:#fff;font-size:13px;color:#64748b;border-top:1px solid #f1f5f9;display:flex;justify-content:space-between;align-items:center}
.vinfo .ext{color:#0f172a;font-weight:600}
/* =============================================================
MESSAGES PANEL
============================================================= */
.messages{display:none;flex-direction:column;flex:1;min-height:0;padding:18px;gap:12px;overflow:auto}
.messages.active{display:flex}
.msg{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:18px 22px;display:grid;grid-template-columns:54px 1fr auto;gap:18px;align-items:flex-start}
.msg .mi{width:54px;height:54px;border-radius:12px;display:inline-flex;align-items:center;justify-content:center;font-size:1.4rem}
.msg.error{border-left:6px solid #dc2626}
.msg.error .mi{background:#fee2e2;color:#991b1b}
.msg.warn{border-left:6px solid #f59e0b}
.msg.warn .mi{background:#fef3c7;color:#92400e}
.msg.info{border-left:6px solid #0ea5e9}
.msg.info .mi{background:#dbeafe;color:#1e40af}
.msg.ok{border-left:6px solid #16a34a}
.msg.ok .mi{background:#dcfce7;color:#166534}
.msg .mtitle{font-weight:800;font-size:1.05rem;color:#0f172a}
.msg .mtime{font-size:.8rem;color:#94a3b8;margin-top:2px}
.msg .mbody{margin-top:6px;color:#475569;font-size:.95rem;line-height:1.5}
.msg .mbody .mono{background:#f1f5f9;padding:2px 6px;border-radius:4px;font-size:.85rem}
.msg .mactions{display:flex;gap:8px}
.mbtn{height:48px;padding:0 16px;border-radius:10px;background:#f1f5f9;border:1px solid #e2e8f0;font-weight:700;color:#0f172a;font-size:.85rem;cursor:pointer}
.mbtn:hover{background:#e2e8f0}
.mbtn.primary{background:#0f172a;color:#fff;border-color:#0f172a}
.mbtn.primary:hover{background:#1e293b}
/* =============================================================
INDICATORS PANEL
============================================================= */
.indicators{display:none;flex:1;min-height:0;padding:18px;gap:14px;overflow:auto;grid-template-columns:repeat(4,1fr);grid-auto-rows:min-content}
.indicators.active{display:grid}
.ind{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:16px 18px;display:flex;flex-direction:column;gap:6px}
.ind-label{font-size:.8rem;font-weight:800;text-transform:uppercase;letter-spacing:.1em;color:#94a3b8}
.ind-val{font-family:'JetBrains Mono',monospace;font-size:1.6rem;font-weight:800;color:#0f172a}
.ind-state{display:inline-flex;align-items:center;gap:.4rem;font-size:.8rem;font-weight:700;color:#475569}
.ind-state .dot{width:10px;height:10px;border-radius:9999px}
.ind .progress{height:8px;background:#f1f5f9;border-radius:9999px;overflow:hidden;margin-top:4px}
.ind .progress > div{height:100%;background:#0ea5e9}
.ind.full{grid-column:span 2}
/* =============================================================
MDI PANEL
============================================================= */
.mdi{display:none;flex-direction:column;flex:1;min-height:0;padding:18px;gap:14px}
.mdi.active{display:flex}
.mdi-input{
background:#0b1220;color:#86efac;border:1px solid #1e293b;border-radius:14px;
padding:22px 24px;font-family:'JetBrains Mono',monospace;font-size:1.4rem;font-weight:600;
display:flex;align-items:center;gap:.6rem;
}
.mdi-input .prompt{color:#475569}
.mdi-input .cursor{display:inline-block;width:14px;height:1.4rem;background:#86efac;animation:blink 1s steps(2,end) infinite;vertical-align:middle}
@keyframes blink{50%{opacity:0}}
.mdi-keys{display:grid;grid-template-columns:repeat(8,1fr);gap:8px;flex:0 0 auto}
.mkey{height:64px;border-radius:12px;background:#fff;border:1px solid #e2e8f0;font-weight:800;font-size:1.05rem;color:#0f172a;cursor:pointer;font-family:'JetBrains Mono',monospace}
.mkey:hover{background:#f1f5f9}
.mkey.send{background:#16a34a;color:#fff;border-color:#15803d;grid-column:span 2;font-family:'Inter',sans-serif;font-size:.95rem;letter-spacing:.04em}
.mkey.send:hover{background:#15803d}
.mkey.clear{background:#fee2e2;color:#7f1d1d;border-color:#fca5a5;font-family:'Inter',sans-serif;font-size:.95rem;letter-spacing:.04em}
.mdi-history{flex:1;background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:14px 18px;overflow:auto;font-family:'JetBrains Mono',monospace;font-size:.95rem}
.mdi-history .h-row{display:grid;grid-template-columns:80px 1fr auto;gap:14px;padding:6px 0;border-bottom:1px solid #f1f5f9;align-items:center}
.mdi-history .h-time{color:#94a3b8;font-size:.8rem}
.mdi-history .h-cmd{color:#0f172a;font-weight:700}
.mdi-history .h-status{color:#16a34a;font-weight:700;font-size:.8rem}
.mdi-history .h-status.err{color:#dc2626}
/* =============================================================
SETTINGS PANEL
============================================================= */
.settings{display:none;flex:1;min-height:0;padding:18px;gap:14px;overflow:auto;grid-template-columns:280px 1fr}
.settings.active{display:grid}
.set-side{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:10px;display:flex;flex-direction:column;gap:4px;height:fit-content}
.set-item{height:56px;padding:0 16px;border-radius:10px;display:flex;align-items:center;gap:.6rem;color:#475569;font-weight:700;cursor:pointer}
.set-item:hover{background:#f1f5f9}
.set-item.active{background:#0f172a;color:#fff}
.set-content{display:flex;flex-direction:column;gap:14px}
.set-card{background:#fff;border:1px solid #e5e7eb;border-radius:14px;padding:22px}
.set-title{font-weight:800;font-size:1.1rem;color:#0f172a;margin-bottom:14px}
.set-row{display:grid;grid-template-columns:280px 1fr auto;gap:14px;align-items:center;padding:14px 0;border-bottom:1px solid #f1f5f9}
.set-row:last-child{border-bottom:none}
.set-row .label{font-weight:700;color:#0f172a;font-size:.95rem}
.set-row .desc{color:#64748b;font-size:.85rem;margin-top:2px}
.set-row .val{font-family:'JetBrains Mono',monospace;color:#475569}
.set-input{height:48px;padding:0 14px;border-radius:10px;border:1px solid #e2e8f0;background:#fff;font-family:'JetBrains Mono',monospace;font-size:.95rem;color:#0f172a;min-width:200px}
.set-toggle{width:54px;height:30px;border-radius:9999px;background:#cbd5e1;position:relative;cursor:pointer;transition:background .15s}
.set-toggle::after{content:"";position:absolute;left:3px;top:3px;width:24px;height:24px;border-radius:9999px;background:#fff;transition:transform .15s}
.set-toggle.on{background:#16a34a}
.set-toggle.on::after{transform:translateX(24px)}
</style>
</head>
<body>
<div class="host">
<div class="topbar">
<div class="brand">
<div class="stripe-logo-sm"></div>
ONEFINITY · V09 · Full UX preview
</div>
<span class="pill">Click the inner tabs to navigate</span>
<div style="margin-left:auto"></div>
<button id="oneToOne" class="toggle">1:1</button>
<button id="fitBtn" class="toggle on">Fit</button>
<span id="scaleInfo" class="pill mono">100%</span>
</div>
<div class="stage" id="stage">
<div class="scaler-viewport" id="viewport">
<div class="scaler" id="scaler">
<!-- ============= KIOSK ============= -->
<div class="kiosk">
<header class="head">
<div class="brand-blk">
<div class="brand-logo"></div>
<div class="brand-name">ONEFINITY</div>
</div>
<div class="kiosk-tabs">
<button class="ktab active" data-target="control"><i class="fa-solid fa-gamepad"></i> Control</button>
<button class="ktab" data-target="program"><i class="fa-solid fa-list-ol"></i> Program</button>
<button class="ktab" data-target="console"><i class="fa-solid fa-terminal"></i> Console <span class="ktab-badge">2</span></button>
<button class="ktab" data-target="settings"><i class="fa-solid fa-sliders"></i> Settings</button>
</div>
<button class="sys-btn"><span class="pip"></span> All systems · view <i class="fa-solid fa-chevron-down" style="font-size:10px;opacity:.6"></i></button>
<span class="state-badge"><span class="dot"></span> READY</span>
<button class="estop">STOP</button>
</header>
<div class="body">
<!-- ============= CONTROL ============= -->
<div class="panel active" data-panel="control">
<div class="control-grid">
<!-- jog -->
<div class="jog-card">
<div class="jog-head">
<div class="jog-title">Jog · step <span class="step">10mm</span></div>
<div class="step-seg">
<button>0.1</button><button>1</button><button class="active">10</button><button>100</button>
</div>
</div>
<div class="jog-grid">
<button class="jbtn dir"><i class="fa-solid fa-arrow-up ico" style="transform:rotate(-45deg)"></i></button>
<button class="jbtn">Y+</button>
<button class="jbtn dir"><i class="fa-solid fa-arrow-up ico" style="transform:rotate(45deg)"></i></button>
<button class="jbtn">Z+</button>
<button class="jbtn">X</button>
<button class="jbtn ghost"><span class="lbl">XY</span><span style="font-size:1rem;font-weight:700">Origin</span></button>
<button class="jbtn">X+</button>
<button class="jbtn ghost"><span class="lbl">Z</span><span style="font-size:1rem;font-weight:700">Origin</span></button>
<button class="jbtn dir"><i class="fa-solid fa-arrow-down ico" style="transform:rotate(45deg)"></i></button>
<button class="jbtn">Y</button>
<button class="jbtn dir"><i class="fa-solid fa-arrow-down ico" style="transform:rotate(-45deg)"></i></button>
<button class="jbtn">Z</button>
<button class="jbtn"><i class="fa-solid fa-arrow-down ico"></i><span class="lbl">W</span></button>
<button class="jbtn ghost"><span class="lbl">W</span><span style="font-size:1rem;font-weight:700">Origin</span></button>
<button class="jbtn"><i class="fa-solid fa-arrow-up ico"></i><span class="lbl">W+</span></button>
<button class="jbtn"><i class="fa-solid fa-house ico"></i><span class="lbl">Home</span></button>
</div>
</div>
<!-- DRO + status -->
<div class="right-col">
<div class="dro-card">
<div class="dro-head">
<div>Axis</div><div>Position</div><div>Absolute</div><div>Offset</div><div>State</div><div>Toolpath</div><div style="text-align:right">Actions</div>
</div>
<div class="dro-row">
<div class="dro-axis axis-x">X</div>
<div class="dro-pos">0.000<span class="u">mm</span></div>
<div class="dro-sec">0.000</div>
<div class="dro-sec">0.000</div>
<div><span class="chip chip-amber"><i class="fa-solid fa-question"></i> Unhomed</span></div>
<div><span class="chip chip-green"><i class="fa-solid fa-check"></i> OK</span></div>
<div class="actions-cell">
<button class="icon-btn"><i class="fa-solid fa-gear"></i></button>
<button class="icon-btn"><i class="fa-solid fa-location-dot"></i></button>
<button class="icon-btn"><i class="fa-solid fa-house"></i></button>
</div>
</div>
<div class="dro-row">
<div class="dro-axis axis-y">Y</div>
<div class="dro-pos">0.000<span class="u">mm</span></div>
<div class="dro-sec">0.000</div>
<div class="dro-sec">0.000</div>
<div><span class="chip chip-amber"><i class="fa-solid fa-question"></i> Unhomed</span></div>
<div><span class="chip chip-green"><i class="fa-solid fa-check"></i> OK</span></div>
<div class="actions-cell">
<button class="icon-btn"><i class="fa-solid fa-gear"></i></button>
<button class="icon-btn"><i class="fa-solid fa-location-dot"></i></button>
<button class="icon-btn"><i class="fa-solid fa-house"></i></button>
</div>
</div>
<div class="dro-row z-highlight">
<div class="dro-axis axis-z">Z</div>
<div class="dro-pos">0.000<span class="u">mm</span></div>
<div class="dro-sec">0.000</div>
<div class="dro-sec">0.000</div>
<div><span class="chip chip-amber"><i class="fa-solid fa-question"></i> Unhomed</span></div>
<div><span class="chip chip-amber"><i class="fa-solid fa-triangle-exclamation"></i> Over</span></div>
<div class="actions-cell">
<button class="icon-btn"><i class="fa-solid fa-gear"></i></button>
<button class="icon-btn"><i class="fa-solid fa-location-dot"></i></button>
<button class="icon-btn"><i class="fa-solid fa-house"></i></button>
</div>
</div>
<div class="dro-row">
<div class="dro-axis axis-w">W</div>
<div class="dro-pos">0.000<span class="u">mm</span></div>
<div class="dro-sec">0.000</div>
<div class="dro-sec" style="opacity:.4"></div>
<div><span class="chip chip-amber"><i class="fa-solid fa-question"></i> Unhomed</span></div>
<div><span class="chip chip-green"><i class="fa-solid fa-check"></i> OK</span></div>
<div class="actions-cell">
<button class="icon-btn"><i class="fa-solid fa-location-dot"></i></button>
<button class="icon-btn"><i class="fa-solid fa-house"></i></button>
</div>
</div>
</div>
<div class="status-strip">
<div class="stat-card"><div class="stat-label">State</div><div class="stat-val ok">READY</div><div class="stat-sub">No alerts</div></div>
<div class="stat-card"><div class="stat-label">Velocity / Feed</div><div class="stat-val">0 · 0</div><div class="stat-sub">m/min · mm/min</div></div>
<div class="stat-card"><div class="stat-label">Spindle</div><div class="stat-val">0 (0)</div><div class="stat-sub">RPM (commanded / actual)</div></div>
<div class="stat-card"><div class="stat-label">Job</div><div class="stat-val">0 / 1,785</div><div class="stat-sub">Line · 19:07 remaining</div></div>
</div>
</div>
</div>
<!-- macros -->
<div class="macro-row">
<button class="macro-btn"><span class="mnum">1</span><i class="fa-solid fa-circle-play micon"></i> Macro 1</button>
<button class="macro-btn"><span class="mnum">2</span><i class="fa-solid fa-circle-play micon"></i> Macro 2</button>
<button class="macro-btn"><span class="mnum">3</span><i class="fa-solid fa-circle-play micon"></i> Macro 3</button>
<button class="macro-btn"><span class="mnum">4</span><i class="fa-solid fa-circle-play micon"></i> Macro 4</button>
<button class="macro-btn"><span class="mnum">5</span><i class="fa-solid fa-circle-play micon"></i> Macro 5</button>
<button class="macro-btn"><span class="mnum">6</span><i class="fa-solid fa-circle-play micon"></i> Macro 6</button>
<button class="macro-btn"><span class="mnum">7</span><i class="fa-solid fa-circle-play micon"></i> Macro 7</button>
<button class="macro-btn"><span class="mnum">8</span><i class="fa-solid fa-circle-play micon"></i> Macro 8</button>
</div>
</div>
<!-- ============= PROGRAM ============= -->
<div class="panel" data-panel="program" style="padding:0;gap:0">
<div class="program-card" style="margin:18px;border-radius:18px">
<!-- Auto sub-panel -->
<div class="auto-sub" data-sub="auto" style="display:flex;flex-direction:column;flex:1;min-height:0">
<div class="action-bar">
<button class="action-btn run"><i class="fa-solid fa-play ico"></i><span>RUN</span></button>
<button class="action-btn stop"><i class="fa-solid fa-stop ico"></i><span>STOP</span></button>
<button class="action-btn"><i class="fa-solid fa-folder-arrow-up ico"></i><span>UPLOAD FOLDER</span></button>
<button class="action-btn"><i class="fa-solid fa-file-arrow-up ico"></i><span>UPLOAD FILE</span></button>
<button class="action-btn"><i class="fa-solid fa-file-arrow-down ico"></i><span>DOWNLOAD FILE</span></button>
<button class="action-btn danger"><i class="fa-solid fa-trash ico"></i><span>DELETE</span></button>
</div>
<div class="file-bar">
<button class="file-btn"><i class="fa-solid fa-folder-plus"></i> Create Folder</button>
<button class="file-btn"><i class="fa-solid fa-folder-minus"></i> Delete Folder</button>
<span class="file-select"><i class="fa-solid fa-folder-open" style="color:#64748b"></i> Default folder <i class="fa-solid fa-chevron-down caret"></i></span>
<span class="file-select primary"><i class="fa-solid fa-file-code" style="color:#0ea5e9"></i> thin-rough.nc <i class="fa-solid fa-chevron-down caret" style="margin-left:auto"></i></span>
<span class="file-select"><i class="fa-solid fa-arrow-down-wide-short" style="color:#64748b"></i> By Upload Date <i class="fa-solid fa-chevron-down caret"></i></span>
</div>
<div class="program-body">
<div class="gcode" id="gcode-list"></div>
<div class="viewer">
<div class="viewer-3d">
<svg viewBox="0 0 400 220" style="width:100%;height:100%">
<defs>
<pattern id="gridv" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#1e293b" stroke-width="1"/>
</pattern>
</defs>
<rect width="400" height="220" fill="url(#gridv)"/>
<rect x="40" y="80" width="320" height="60" stroke="#475569" stroke-width="1" fill="none" stroke-dasharray="3 3"/>
<text x="40" y="74" fill="#64748b" font-size="9" font-family="monospace">Stock: 250 × 25 × 16 mm</text>
<!-- toolpath -->
<path d="M40,110 L360,110 M40,100 L360,100 M40,120 L360,120 M40,90 L360,90 M40,130 L360,130" stroke="#22c55e" stroke-width="1.4" fill="none" opacity=".8"/>
<path d="M40,110 L40,80 L60,80 L60,110 M80,110 L80,80 L100,80 L100,110 M120,110 L120,80 L140,80 L140,110" stroke="#ef4444" stroke-width="1.4" fill="none" opacity=".8"/>
<circle cx="40" cy="110" r="3" fill="#22c55e"/>
<circle cx="360" cy="110" r="3" fill="#ef4444"/>
<text x="46" y="108" fill="#22c55e" font-size="8" font-family="monospace">START</text>
<text x="332" y="108" fill="#ef4444" font-size="8" font-family="monospace">END</text>
<!-- axes gizmo -->
<g transform="translate(28,196)">
<line x1="0" y1="0" x2="22" y2="0" stroke="#ef4444" stroke-width="2"/>
<line x1="0" y1="0" x2="0" y2="-22" stroke="#3b82f6" stroke-width="2"/>
<line x1="0" y1="0" x2="-12" y2="12" stroke="#22c55e" stroke-width="2"/>
<text x="24" y="4" fill="#ef4444" font-size="9" font-family="monospace">X</text>
<text x="-4" y="-26" fill="#3b82f6" font-size="9" font-family="monospace">Z</text>
<text x="-22" y="22" fill="#22c55e" font-size="9" font-family="monospace">Y</text>
</g>
</svg>
</div>
<div class="viewer-tools">
<button class="vtool" title="Fit"><i class="fa-solid fa-expand"></i></button>
<button class="vtool on" title="Tool"><i class="fa-solid fa-screwdriver-wrench"></i></button>
<button class="vtool" title="Stock"><i class="fa-solid fa-cube"></i></button>
<button class="vtool" title="Origin"><i class="fa-solid fa-up-right-and-down-left-from-center"></i></button>
<button class="vtool" title="Top"><i class="fa-solid fa-square"></i></button>
<button class="vtool" title="Front"><i class="fa-solid fa-square-full"></i></button>
<button class="vtool" title="Iso"><i class="fa-solid fa-cubes"></i></button>
</div>
<div class="vinfo">
<span><span class="ext">thin-rough.nc</span> · 1,785 lines · 12.4 KB</span>
<span class="mono">est. 19:07</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ============= CONSOLE ============= -->
<div class="panel" data-panel="console" style="padding:0;gap:0">
<div class="program-card" style="margin:18px;border-radius:18px">
<div class="ptab-bar">
<button class="ptab active" data-ptab="mdi"><i class="fa-solid fa-keyboard"></i> MDI</button>
<button class="ptab" data-ptab="messages"><i class="fa-solid fa-comment-dots"></i> Messages <span class="ptab-badge">2</span></button>
<button class="ptab" data-ptab="indicators"><i class="fa-solid fa-bell"></i> Indicators</button>
</div>
<!-- MDI sub-panel -->
<div class="mdi active" data-sub="mdi">
<div class="mdi-input">
<span class="prompt">G&gt;</span>
<span class="mono">G0 X100 Y50 F2000</span>
<span class="cursor"></span>
</div>
<div class="mdi-keys">
<button class="mkey">G0</button>
<button class="mkey">G1</button>
<button class="mkey">G2</button>
<button class="mkey">G3</button>
<button class="mkey">G28</button>
<button class="mkey">G92</button>
<button class="mkey">M3</button>
<button class="mkey">M5</button>
<button class="mkey">X</button>
<button class="mkey">Y</button>
<button class="mkey">Z</button>
<button class="mkey">W</button>
<button class="mkey">F</button>
<button class="mkey">S</button>
<button class="mkey clear">CLEAR</button>
<button class="mkey send">SEND ↵</button>
</div>
<div class="mdi-history">
<div class="h-row"><span class="h-time">19:42:11</span><span class="h-cmd">G21</span><span class="h-status">✓ ok</span></div>
<div class="h-row"><span class="h-time">19:42:14</span><span class="h-cmd">G90</span><span class="h-status">✓ ok</span></div>
<div class="h-row"><span class="h-time">19:43:02</span><span class="h-cmd">G0 Y12.800</span><span class="h-status">✓ ok</span></div>
<div class="h-row"><span class="h-time">19:43:08</span><span class="h-cmd">G0 Z19.040</span><span class="h-status">✓ ok</span></div>
<div class="h-row"><span class="h-time">19:43:30</span><span class="h-cmd">G1 Z-20 F800</span><span class="h-status err">✗ blocked: Z over travel</span></div>
<div class="h-row"><span class="h-time">19:44:01</span><span class="h-cmd">G0 Z5</span><span class="h-status">✓ ok</span></div>
</div>
</div>
<!-- Messages sub-panel -->
<div class="messages" data-sub="messages">
<div class="msg warn">
<div class="mi"><i class="fa-solid fa-triangle-exclamation"></i></div>
<div>
<div style="display:flex;align-items:baseline;gap:.6rem">
<div class="mtitle">Z toolpath exceeds soft-limit</div>
<div class="mtime">2 min ago · sticky</div>
</div>
<div class="mbody">Loaded program reaches <span class="mono">Z = -16.500</span>. Configured soft-limit is <span class="mono">Z = -15.000</span>. Adjust the Z origin or set a deeper soft-limit before running.</div>
</div>
<div class="mactions">
<button class="mbtn">Open settings</button>
<button class="mbtn primary">Acknowledge</button>
</div>
</div>
<div class="msg info">
<div class="mi"><i class="fa-solid fa-circle-info"></i></div>
<div>
<div style="display:flex;align-items:baseline;gap:.6rem">
<div class="mtitle">Camera offline</div>
<div class="mtime">12 min ago</div>
</div>
<div class="mbody">Camera at <span class="mono">10.1.10.55:8554</span> did not respond on last poll. Live preview disabled.</div>
</div>
<div class="mactions">
<button class="mbtn">Retry</button>
<button class="mbtn">Dismiss</button>
</div>
</div>
<div class="msg ok">
<div class="mi"><i class="fa-solid fa-check"></i></div>
<div>
<div style="display:flex;align-items:baseline;gap:.6rem">
<div class="mtitle">File uploaded · thin-rough.nc</div>
<div class="mtime">21 min ago</div>
</div>
<div class="mbody">1,785 lines · 12.4 KB · checksum verified.</div>
</div>
<div class="mactions">
<button class="mbtn">Open</button>
</div>
</div>
<div class="msg error">
<div class="mi"><i class="fa-solid fa-circle-xmark"></i></div>
<div>
<div style="display:flex;align-items:baseline;gap:.6rem">
<div class="mtitle">WiFi: not connected</div>
<div class="mtime">1 h ago</div>
</div>
<div class="mbody">Falling back to wired ethernet. SSID <span class="mono">workshop-2g</span> last seen 53 min ago.</div>
</div>
<div class="mactions">
<button class="mbtn">Network…</button>
<button class="mbtn">Mute</button>
</div>
</div>
</div>
<!-- Indicators sub-panel -->
<div class="indicators" data-sub="indicators">
<div class="ind">
<div class="ind-label">Spindle Load</div>
<div class="ind-val">0 %</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> idle</div>
<div class="progress"><div style="width:0%"></div></div>
</div>
<div class="ind">
<div class="ind-label">Spindle Temp</div>
<div class="ind-val">24 °C</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> nominal</div>
<div class="progress"><div style="width:24%"></div></div>
</div>
<div class="ind">
<div class="ind-label">Driver Voltage</div>
<div class="ind-val">48.1 V</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
</div>
<div class="ind">
<div class="ind-label">Coolant</div>
<div class="ind-val">OFF</div>
<div class="ind-state"><span class="dot" style="background:#94a3b8"></span> standby</div>
</div>
<div class="ind">
<div class="ind-label">Limit X</div>
<div class="ind-val" style="color:#16a34a">CLEAR</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
</div>
<div class="ind">
<div class="ind-label">Limit Y</div>
<div class="ind-val" style="color:#16a34a">CLEAR</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
</div>
<div class="ind">
<div class="ind-label">Limit Z</div>
<div class="ind-val" style="color:#dc2626">BLOCKED</div>
<div class="ind-state"><span class="dot" style="background:#dc2626"></span> over-travel</div>
</div>
<div class="ind">
<div class="ind-label">Probe</div>
<div class="ind-val">OPEN</div>
<div class="ind-state"><span class="dot" style="background:#94a3b8"></span> not contacted</div>
</div>
<div class="ind">
<div class="ind-label">E-Stop</div>
<div class="ind-val" style="color:#16a34a">RELEASED</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> safe</div>
</div>
<div class="ind">
<div class="ind-label">Door</div>
<div class="ind-val">CLOSED</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
</div>
<div class="ind">
<div class="ind-label">Air Pressure</div>
<div class="ind-val">6.2 bar</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> ok</div>
<div class="progress"><div style="width:62%"></div></div>
</div>
<div class="ind">
<div class="ind-label">Vacuum</div>
<div class="ind-val">0.81 bar</div>
<div class="ind-state"><span class="dot" style="background:#16a34a"></span> hold</div>
<div class="progress"><div style="width:81%"></div></div>
</div>
</div>
</div>
</div>
<!-- ============= SETTINGS ============= -->
<div class="panel" data-panel="settings" style="padding:0;gap:0">
<div class="settings active" style="padding:18px">
<div class="set-side">
<div class="set-item active"><i class="fa-solid fa-display"></i> Display & Units</div>
<div class="set-item"><i class="fa-solid fa-arrows-up-down-left-right"></i> Motion</div>
<div class="set-item"><i class="fa-solid fa-bolt"></i> Spindle</div>
<div class="set-item"><i class="fa-solid fa-shield-halved"></i> Safety / Soft-limits</div>
<div class="set-item"><i class="fa-solid fa-network-wired"></i> Network</div>
<div class="set-item"><i class="fa-solid fa-video"></i> Camera</div>
<div class="set-item"><i class="fa-solid fa-keyboard"></i> Macros</div>
<div class="set-item"><i class="fa-solid fa-circle-info"></i> About</div>
</div>
<div class="set-content">
<div class="set-card">
<div class="set-title">Display & Units</div>
<div class="set-row">
<div>
<div class="label">Display Units</div>
<div class="desc">Position, feed and dimensions throughout the UI.</div>
</div>
<div><div class="step-seg" style="display:inline-flex"><button class="active">METRIC</button><button>IMPERIAL</button></div></div>
<div></div>
</div>
<div class="set-row">
<div>
<div class="label">Decimal places</div>
<div class="desc">Position readout precision.</div>
</div>
<div><input class="set-input" value="3" /></div>
<div class="val">04</div>
</div>
<div class="set-row">
<div>
<div class="label">Pulse-dot animation</div>
<div class="desc">Animate status badges (ready, idle, alarm).</div>
</div>
<div><div class="set-toggle on"></div></div>
<div></div>
</div>
<div class="set-row">
<div>
<div class="label">Theme</div>
<div class="desc">Pick a tile finish.</div>
</div>
<div><span class="file-select"><i class="fa-solid fa-palette" style="color:#64748b"></i> V09 · Flat soft slate <i class="fa-solid fa-chevron-down caret"></i></span></div>
<div></div>
</div>
</div>
<div class="set-card">
<div class="set-title">Network</div>
<div class="set-row">
<div>
<div class="label">IP Address</div>
<div class="desc">Wired ethernet, DHCP.</div>
</div>
<div><span class="mono" style="font-size:1.05rem;font-weight:700">10.1.10.55</span></div>
<div><button class="mbtn">Edit</button></div>
</div>
<div class="set-row">
<div>
<div class="label">WiFi</div>
<div class="desc">Wireless network connection.</div>
</div>
<div><span class="chip chip-red"><i class="fa-solid fa-wifi"></i> Not connected</span></div>
<div><button class="mbtn primary">Configure</button></div>
</div>
<div class="set-row">
<div>
<div class="label">Hostname</div>
<div class="desc">Used in mDNS / Bonjour discovery.</div>
</div>
<div><input class="set-input" value="onefinity-shop.local" style="width:300px" /></div>
<div></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// ----- Build G-code list -----
const gcodeLines = [
[1,'G21','word'],[2,'; X = along blank, Z = tool entry from top, Y fixed','c'],
[3,'; Y fixed to blank center: 12.800','c'],[4,'; nominal rapid: 3200.0 mm/min','c'],
[5,'; stock top Z: -0.960','c'],[6,'; deepest allowed cut Z: -16.500','c'],
[7,'G21','word'],[8,'G90','word'],[9,'G0 Y12.800','word'],
[10,'G0 Z19.040','word'],[11,'; rough pass 1 radius=18.540','c'],
[12,'G0 X0.000','word'],[13,'G1 Z-0.710 F800.000','word cur'],
[14,'G1 Z-0.960 F200.000','word'],[15,'G4 P0.250','word'],
[16,'G1 X249.500 F200.000','word'],[17,'G4 P0.250','word'],
[18,'G0 Z19.040','word'],[19,'; rough pass 2 radius=17.540','c'],
[20,'G0 X0.000','word'],[21,'G1 Z-1.710 F800.000','word'],
[22,'G1 Z-1.960 F200.000','word'],[23,'G4 P0.250','word'],
[24,'G1 X249.500 F200.000','word'],[25,'G4 P0.250','word'],
[26,'G0 Z19.040','word'],[27,'; rough pass 3 radius=16.540','c'],
[28,'G0 X0.000','word'],[29,'G1 Z-2.710 F800.000','word'],
[30,'G1 Z-2.960 F200.000','word'],[31,'G4 P0.250','word'],
[32,'G1 X249.500 F200.000','word'],[33,'G4 P0.250','word'],
[34,'G0 Z19.040','word'],[35,'; rough pass 4 radius=15.540','c'],
];
document.getElementById('gcode-list').innerHTML = gcodeLines.map(([n,t,cls])=>{
const isComment = cls.includes('c');
const isCur = cls.includes('cur');
const cls2 = 'gline' + (isCur?' cur':'');
const inner = isComment ? `<span class="gcomment">${t}</span>` : `<span class="gword">${t}</span>`;
return `<div class="${cls2}"><span class="gn">${n}</span><span>${inner}</span></div>`;
}).join('');
// ----- Top tab switching (Control / Program / Settings) -----
document.querySelectorAll('.ktab').forEach(b=>{
b.addEventListener('click', ()=>{
const target = b.dataset.target;
document.querySelectorAll('.ktab').forEach(x=>x.classList.remove('active'));
b.classList.add('active');
document.querySelectorAll('.panel').forEach(p=>p.classList.remove('active'));
document.querySelector(`.panel[data-panel="${target}"]`).classList.add('active');
applyScale();
});
});
// ----- Console sub-tab switching (MDI / Messages / Indicators) -----
function showSub(name){
document.querySelectorAll('.ptab').forEach(x=>x.classList.toggle('active', x.dataset.ptab===name));
document.querySelectorAll('[data-sub]').forEach(s=>{
const on = s.dataset.sub===name;
if(s.classList.contains('messages') || s.classList.contains('indicators') || s.classList.contains('mdi')){
s.classList.toggle('active', on);
}
});
}
document.querySelectorAll('.ptab').forEach(b=>{
b.addEventListener('click', ()=>{ showSub(b.dataset.ptab); });
});
// Default Console sub: MDI active
document.querySelectorAll('.messages[data-sub], .indicators[data-sub]').forEach(s=>s.classList.remove('active'));
// ----- Scaling -----
const stage = document.getElementById('stage');
const scaler = document.getElementById('scaler');
const viewport = document.getElementById('viewport');
const fitBtn = document.getElementById('fitBtn');
const oneToOne = document.getElementById('oneToOne');
const scaleInfo = document.getElementById('scaleInfo');
let mode = 'fit';
function activeKioskHeight(){
const m = document.querySelector('.kiosk');
return m ? Math.max(1080, m.offsetHeight) : 1080;
}
function applyScale(){
let s;
if(mode==='1:1'){
s = 1; scaleInfo.textContent = '100% · 1920px wide';
} else {
const sw = stage.clientWidth - 32;
s = Math.min(sw/1920, 1);
scaleInfo.textContent = Math.round(s*100) + '% · 1920px wide';
}
const h = activeKioskHeight();
scaler.style.transform = `scale(${s})`;
viewport.style.width = (1920 * s) + 'px';
viewport.style.height = (h * s) + 'px';
}
window.addEventListener('resize', applyScale);
fitBtn.addEventListener('click', ()=>{ mode='fit'; fitBtn.classList.add('on'); oneToOne.classList.remove('on'); applyScale(); });
oneToOne.addEventListener('click', ()=>{ mode='1:1'; oneToOne.classList.add('on'); fitBtn.classList.remove('on'); applyScale(); });
applyScale();
</script>
</body>
</html>

View File

@@ -1,169 +0,0 @@
# UX Redesign — Implementation Plan
Reference mock: `docs/mocks/v09_full_ux.html`
Target hardware: 10.8" portable monitor, 1920×1080, capacitive touch, Chrome fullscreen.
## 1. Goals
The redesign keeps every existing feature but reorganizes the page into a single-screen control surface for finger-touch use:
- A slim 96 px header replaces the 140 px nav-header. Only logo + ONEFINITY wordmark + tab bar + system pill + READY badge + octagonal STOP.
- 4 top-level sections accessed via underline-ribbon tabs in the header:
1. **Control** — jog pad, DRO table, status strip, macro row.
2. **Program** — Auto run controls, file actions, G-code listing, 3D viewer.
3. **Console** — MDI, Messages, Indicators (sub-tabs).
4. **Settings** — paged settings (replaces the Pure left rail).
- Touch targets ≥ 64 px (jog tiles 72 px, axis action icons 72 px, macro buttons 84 px).
- All action chip-soup (WiFi/Camera/Rotary/IP/Version) collapses into one "All systems · view" pill that opens a popover. Burger menu removed (Settings tab supersedes it).
- V09 jog/macro palette: flat soft slate (#3f4b63), no drop shadow; yellow (#fde047) accent for active states (step seg, tab underline, macro number badge).
- Spindle override / feed override sliders live in a bottom-edge drawer triggered by tapping the Spindle KPI tile (no permanent screen real estate).
- Hard cut: no `config.ui.layout` flag; the new shell replaces the old in a single release.
## 2. Scope of code change
The build is Pug + Stylus + Browserify Vue (Vue 1.x). `index.pug` defines the chrome; `src/pug/templates/*.pug` defines each view; `src/js/*.js` mirrors them as Vue components routed by `currentView` from the URL hash.
Files we will touch:
- `src/pug/index.pug` — replace `#layout / #menu / #main / .nav-header` with the new header + tab bar + body. Drop the burger and the side-menu include.
- `src/pug/templates/control-view.pug` — restructure into the new Control panel (jog grid + DRO table + status strip + macro row). MDI/Messages/Indicators move out.
- New `src/pug/templates/program-view.pug` — Auto sub-panel content (action bar, file bar, gcode-viewer, path-viewer).
- New `src/pug/templates/console-view.pug` — MDI / Messages / Indicators sub-tabs hosting existing `console.pug` and `indicators.pug` partials.
- `src/js/app.js` — extend `parse_hash` so `#program`, `#console`, `#settings` resolve; expose tab state for the header to highlight.
- `src/js/control-view.js` — keep jog/DRO logic, drop the Auto/MDI/Messages/Indicators internal `tab` state and template hooks.
- New `src/js/program-view.js`, `src/js/console-view.js` — extracted Vue components.
- `src/stylus/style.styl` — add `.app-shell`, `.head`, `.tabs-host`, `.ktab`, panel styles, V09 jog tokens. Keep legacy classes alive until templates fully migrated.
- `src/static/css/side-menu.css` — stop including in `index.pug`.
- Settings: keep `settings-view.pug`, `admin-general-view.pug`, `admin-network-view.pug`, `motor-view.pug`, `tool-view.pug`, `io-view.pug`, etc., and surface them through a left-rail navigator inside the Settings panel rather than the sidebar.
- Settings → Macros owns the full macro list (1…N). Control's macro row is a slice of the first 8; reordering happens in Settings.
## 3. Routing model
We keep the existing URL hash routing because everything in `src/js/app.js#parse_hash` and the deep-linked menu items (`#motor:0`, `#admin-network`, etc.) depend on it.
| URL hash | Top tab | Notes |
|-------------------------|------------|-------------------------------------------------------|
| `#control` | Control | Default |
| `#program` / `#program:auto` | Program | Auto sub-view (only sub-view for now) |
| `#console` / `#console:mdi` | Console | MDI default, also `:messages` and `:indicators` |
| `#settings` | Settings | Settings home (Display & Units) |
| `#admin-general`, `#admin-network`, `#motor:N`, `#tool`, `#io`, `#help`, `#cheat-sheet` | Settings | Existing routes remain, surfaced in the Settings left rail |
The header tab bar maps URL prefix → active tab. A tiny helper `topTabFromHash(hash)` lives in `app.js` and is reused by the header template.
## 4. Step-by-step
### Phase 1 — Mock parity (12 days)
1. Add `docs/mocks/v09_full_ux.html` (done) so anyone can preview the target.
2. Move the V09 palette into Stylus tokens at the top of `style.styl`:
```styl
$jog-bg = #3f4b63
$jog-hover = #4a5777
$jog-dir = #5b6885
$jog-ghost = #8c97ad
$accent = #fde047
$accent-ink = #0f172a
```
3. Build the header in `index.pug`:
```pug
.app-shell
header.head
.brand-blk
.brand-logo
.brand-name ONEFINITY
nav.tabs-host(role="tablist")
a.ktab(:class="{active: topTab === 'control'}", href="#control")
.fa.fa-gamepad
| Control
a.ktab(:class="{active: topTab === 'program'}", href="#program") …
a.ktab(:class="{active: topTab === 'console'}", href="#console") …
a.ktab(:class="{active: topTab === 'settings'}", href="#settings") …
button.sys-btn(@click="toggle_sys_popover") …
span.state-badge(:class="state_class")
estop(@click="estop")
```
4. Style the header tabs as **underline ribbon** (V02): transparent fills, slate-gray text, dark text + 5 px yellow underline on active. CSS already proven in the mock.
5. Move the rotary toggle and pi-temp warning into the system pill popover.
### Phase 2 — Control panel (2 days)
1. Rewrite the outer markup of `control-view.pug` to a CSS grid:
```
.control-grid → 720px jog-card | 1fr right-col(dro-card + status-strip)
```
Drop the `<table>`-based outer layout (axes table stays — it's a real data table).
2. Replace the legacy `<button>` elements in the jog table with `.jbtn` markup that pulls colors from `$jog-*` tokens. Keep the `@click="jog_fn(...)"` bindings unchanged.
3. Build the new `.step-seg` with the existing `jog_incr` model. The four buttons stay wired to `jog_incr = 'fine' | 'small' | 'medium' | 'large'`.
4. Build `.dro-card` from the existing `table.axes` markup. Each row gets the new 7-column grid; axis cells just need `.dro-axis`, `.dro-pos`, `.dro-sec` classes.
5. Move the four KPI tiles (`State / Velocity-Feed / Spindle / Job`) into `.status-strip`. Existing `state.v`, `state.feed`, `state.s`, `state.line` bindings are unchanged.
6. Move `.macros-div` into a `.macro-row` 8-column grid. The row binds to `config.macros.slice(0, 8)`; macros 9…N are editable and runnable only from Settings → Macros (no drawer in Control). Reordering in Settings changes which macros appear in the visible 8.
7. Drop the legacy `.tabs / #tab1` block from `control-view.pug` entirely.
### Phase 3 — Program panel (1.5 days)
1. New file `src/pug/templates/program-view.pug` with `.program-card` and the action / file bars.
2. Move the Auto bar (RUN, STOP, UPLOAD FOLDER, UPLOAD FILE, DOWNLOAD FILE, DELETE) and the file-select strip (Create Folder, Delete Folder, folder picker, file picker, sort) out of `control-view.pug` into here. Use the V09 button styles (`.action-btn`, `.action-btn.run`, `.action-btn.danger`, `.file-btn`, `.file-select`).
3. Embed `path-viewer` and `gcode-viewer` in `.program-body { 1fr 600px }`. Both Vue components render unchanged.
4. New `src/js/program-view.js` exporting the same data model the existing `Auto` tab uses (`gcode_files`, `state.selected`, `start_pause`, etc.). The fastest path: move the relevant computed/methods into a mixin `gcode-program-mixin.js` consumed by both old and new components during the migration.
5. Wire `<component :is="currentView + '-view'">` in `index.pug` to pick up `program-view`.
### Phase 4 — Console panel (1 day)
1. New `src/pug/templates/console-view.pug` with the inner `.ptab-bar` (MDI / Messages / Indicators) and `data-sub` panels.
2. The MDI panel reuses the existing `<input v-model="mdi" @keyup.enter="submit_mdi">` plus the on-screen keypad (G0/G1/G2/G3/G28/G92/M3/M5 + axis letters + CLEAR/SEND).
3. The Messages panel pulls from the existing `popupMessages` array + a new `messages_log` state we will accumulate from `app.js`'s `error` and `popupMessages` channels (no protocol change).
4. The Indicators panel mounts the existing `<indicators :state="state" :template="template">` component.
5. Sub-tab state is local Vue state (`activeSub: 'mdi' | 'messages' | 'indicators'`) plus URL fragment after `:` so deep links keep working.
### Phase 5 — Settings panel (1 day)
1. New `src/pug/templates/settings-view.pug` with a left rail and a content slot.
2. The left rail is data-driven from a list of existing settings views: General, Network, Motion (settings-view), Spindle (tool-view), Safety (admin-general subset), Camera, Macros (settings-view subset), I/O, Motors, Help, About.
3. The content slot uses `<component :is="settingsSub + '-view'">` so each existing pug template renders unchanged (`admin-general-view.pug`, `admin-network-view.pug`, `motor-view.pug`, `tool-view.pug`, `io-view.pug`, `settings-view.pug`, `help-view.pug`, `cheat-sheet-view.pug`).
4. Existing routes (`#admin-network`, `#motor:0`, …) resolve to Settings + the matching left-rail item. We lose nothing.
5. Decommission the side menu in `index.pug` and stop including `side-menu.css`.
### Phase 6 — Polish & rollout (0.5 days)
1. Pulse-dot animation for the READY badge (CSS keyframes already in the mock).
2. System pill popover content: WiFi state + button, Camera state + retry, Rotary toggle, IP address, firmware version, "Open Settings".
3. Disabled states: jog buttons + macro buttons honor `is_ready` like before; gray them out instead of hiding.
4. Decimal-places setting from the existing `display_units` plumbing — wire to a new `precision` config the DRO reads.
5. Build the **Spindle override drawer**: clicking the `.stat-card` for Spindle toggles `.override-drawer.open` anchored to the bottom edge of the body. The drawer hosts the two existing `<input type="range">` controls for `feed_override` and `speed_override` plus `Reset` buttons. Bind to the existing `override_feed` / `override_speed` methods.
6. **Hard cut cleanup:** delete the legacy `.nav-header`, side-menu markup, and the inline `.tabs / #tab1#tab4` block from `control-view.pug`. Remove `src/static/css/side-menu.css` from `index.pug` includes. Sweep `style.styl` for orphan rules (`.nav-header`, `.brand`, `.menu-link`, `.pure-menu*` overrides, `.tabs > input` selectors) and delete them in the same commit so we don't ship dead CSS.
## 5. Migration risks & mitigations
| Risk | Mitigation |
|----------------------------------------------|---------------------------------------------------------------------------------------------|
| Existing deep links from PDFs / forum posts (`#admin-network`) break | Keep the same hashes; only their visual shell changes. `parse_hash` resolves them. |
| Vue 1.x doesn't support modern slot syntax we used in the mock | The mock is plain HTML for visual review; production code uses the existing Vue 1 patterns. No new Vue features required. |
| Touch monitor with HDMI vs USB-C may report different DPI | The new layout is fluid inside 1920 × 1080 only when fullscreen Chrome. Provide a CSS `@media (max-width: 1820px)` fallback that scales the macro row to 4 columns and stacks the right column under the jog. |
| Existing customers rely on muscle memory of the side menu | Settings tab opens directly to the same left-rail navigator. First-launch toast: "Side menu moved to Settings." |
| `path-viewer` / `gcode-viewer` are heavy three.js components | They live in the Program tab now; we lazy-mount with `v-if="currentView === 'program'"` so Control stays light. |
| MDI input could lose focus when the inner `.ptab` is switched | Keep the input mounted, just hide non-active subs with `display:none`. |
## 6. Testing checklist
- Chrome on the 10.8" 1920 × 1080 monitor, fullscreen — every panel fits without scrolling at 100 %.
- Chrome at 1366 × 768 — fallback layout works (Control collapses jog above DRO).
- Touch hit-tests: every interactive target ≥ 48 px on its shortest side, primary jog tiles ≥ 72 px.
- Existing flows still work end-to-end: home all axes, run a small program, MDI a `G0 X10`, switch to Imperial, upload a folder, delete a file.
- Hash routing: hand-type `#motor:1` and confirm Settings tab activates with Motor 1 selected.
- Spindle override drawer: tap Spindle KPI tile, sliders move feed/speed override, `Reset` returns both to 100 %, tile tap closes drawer.
- Macro row shows macros 18 only; reordering in Settings → Macros changes which 8 appear on Control.
- Pulse-dot animation respects `prefers-reduced-motion`.
- Hard-cut cleanup verified: `git grep` finds no references to the old `.nav-header`, `side-menu.css`, or the `#tab1#tab4` selectors after the rename.
## 7. Estimated effort
About 67 working days for one developer:
1. Mock parity & header — 1.5 days
2. Control panel (incl. macro slice + DRO grid) — 2 days
3. Program panel — 1.5 days
4. Console panel — 1 day
5. Settings shell — 1 day
6. Override drawer, polish, hard-cut cleanup, regression tests — 0.51 day
## 8. Resolved decisions
- **Rollout: hard cut.** No `config.ui.layout` feature flag, no parallel legacy shell. The new `index.pug` tree replaces the old one in a single release; the old `.nav-header`, side menu, and embedded `.tabs` block are deleted (not gated). One pre-release internal QA pass on real hardware before tagging.
- **Macros above 8: Settings owns the master list; Control surfaces the first 8 (configurable).** The Control macro row reads from `config.macros[0..7]`; everything beyond index 7 is editable / runnable only from Settings → Macros. Users can reorder which macros land in the visible 8 there.
- **"Pin to Control" indicator slot: defer.** Not in this redesign. Tracked as a follow-up; current status strip stays fixed at State / Velocity·Feed / Spindle / Job.
- **Feed & spindle override: drawer triggered by the Spindle KPI tile.** The Spindle card in the status strip becomes tappable. Tap opens a bottom-edge drawer (≈ 220 px tall) containing the two existing range inputs (`feed_override`, `speed_override`) at touch-friendly size with `Reset to 100 %` buttons. Closes by tapping the tile again or the drawer chevron. No protocol change; reuses the existing `override_feed` / `override_speed` handlers.

View File

@@ -9,8 +9,8 @@
# * The full V09 chrome (header tabs, settings rail, jog grid, DRO
# skeleton, status strip).
# * A "DISCONNECTED" overlay because there's no controller backend.
# * The W axis row in jog/DRO is hidden (correct: it appears only when
# the controller reports `aux_enabled = true`). To exercise the W
# * The A axis row in jog/DRO is hidden (correct: it appears only when
# the controller reports `aux_enabled = true`). To exercise the A
# axis end-to-end, deploy to the Pi (`./deploy.sh hardware`).
set -euo pipefail