Skip to content

Quest 3 + WebXR companion (quest_hand device type, /vr, ML loop closed)#1

Open
turfptax wants to merge 47 commits into
mainfrom
feat/quest-vr-v1
Open

Quest 3 + WebXR companion (quest_hand device type, /vr, ML loop closed)#1
turfptax wants to merge 47 commits into
mainfrom
feat/quest-vr-v1

Conversation

@turfptax

@turfptax turfptax commented May 26, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a WebXR client at /vr that turns a Meta Quest 3 into a labeling rig, live demo, and field-capture device for the muscle→finger model. Quest hand-tracking joints become ML ground truth (25 joints × 7 floats ≈ 175-dim label vector, vs LASK5's 4-dim). The training pipeline, recorder, matcher, sessions, and captures system are unchanged — the Quest is integrated as a synthetic device_type: "quest_hand" via one synthesizer method (AppState.ingest_quest_packet).

The loop now closes end-to-end for both training and field capture:

  • Gesture training (deliberate, in VR mode): record paired data, train, see predictions live as an amber "ghost hand" overlaid on your real hand plus per-finger REAL-vs-PRED curl bars
  • Field capture (real activities, in AR passthrough mode): wear the headset while cooking/typing/working, see the real world via passthrough, capture sensor + Quest hand-tracking labels continuously while doing real tasks

Validated on a real Quest 3S. Branch has 30 single-purpose commits — review trail intentionally preserved (per @-team feedback "your messages are good enough that the log is the changelog").

Day-1 requirements (from initial design review)

  • HTTPS via mkcert (--ssl-certfile / --ssl-keyfile + full operator procedure in docs/vr-setup.md)
  • CaptureWriter lazy header-write
  • window_ms defaults per device type (quest_hand=175, lask5=100)
  • Quest first-class in snapshot (rides existing _handle_packet)
  • Per-capture <name>.labels.schema.json sidecar
  • meta.auto.label_source = "quest_hand" tag

Review nit-fixes (addressed before second review round)

  • _joint_to_column_index helper — QUEST_JOINT_CHANNEL_ORDER constant + _flatten_quest_joint + _quest_label_column, both call sites coupled structurally
  • Ghost hand wrist orientation (v1.8) — delta-quaternion alignment, position AND orientation
  • Three pytest cases (and seven bonus ones) — 10 new tests in test_storage.py + test_quest_ingest.py, full suite: 23 passed
  • TODO comment in _write_labels_schema for wrist-relative labels follow-up
  • Handedness future-proofing note in docs/protocol.md

Field-capture mode (added after second-round real-headset testing)

The bigger second round — turning the WebXR client from a deliberate-gesture training rig into a wearable data-capture device for natural activities.

  • v1.9 — Passthrough mode (?mode=ar): immersive-ar session with WebGLRenderer alpha: true so passthrough composites behind our floating panels. URL-toggleable; VR mode preserved for deliberate gesture training. ARButton swap in three.js boot.
  • v1.9 — Sync slate at REC press: 60×20 cm yellow splash with SYNC: capture_X.csv · t = <unix_ms> visible for 2.5s, centered in user's primary view. Frame-accurate video↔CSV pairing point when the operator reviews the headset's screen recording later.
  • v1.9 — Filename + ms clock in header strip: replaces REC · N rows · match X% with REC · 14:32:05.123 · capture_X.csv so any frame where the user happens to look at the heatmap during a real-world session is a self-contained sync anchor.
  • v1.9.1 — AR-specific scaling: panels pushed 1.10m (vs 0.70m VR) and scaled 0.65× so they read as overlays rather than dominating FOV against bright real-world backgrounds. Inter-panel offsets scale proportionally so the cluster stays cohesive.
  • v1.10 — Movable panels + collapse-to-STOP: heatmap+header+compare wrapped in infoGroup, menu+status wrapped in menuRoot. Each gets a drag handle (3 cm cube, top-left of each cluster); off-hand ray + pinch grabs, release drops and re-faces toward user. While recording, the full 3×2 action menu collapses to a single big red STOP button anchored at the menu position. Status strip stays visible in both modes.

Architecture in one line

Browsers can't speak UDP, so the WebXR client posts hand-joint frames to a new /ws/quest WebSocket. The server synthesizes each frame into an OpenMusclePacket(device_type="quest_hand") and hands it to the existing _handle_packet. From the recorder/matcher/snapshot's view, the Quest is just another device. One new endpoint, one synthesizer; the rest of the pipeline rides for free.

What's in the diff (by surface)

Surface Files Notes
Synthesizer + ingest pc/src/openmuscle/web/state.py ingest_quest_packet, _write_labels_schema, Quest-aware start_recording, QUEST_JOINT_CHANNEL_ORDER + helpers (structural coupling between flatten/schema)
HTTP/WS endpoints pc/src/openmuscle/web/app.py /vr route, /ws/quest inbound, /vr added to no-cache middleware (caught Quest Browser cache bug)
Recording writer pc/src/openmuscle/data/storage.py Lazy header, label_count: Optional[int] so 175-dim Quest captures Just Work
CLI / TLS pc/src/openmuscle/cli.py, web/app.py::serve --ssl-certfile / --ssl-keyfile flags plumbed into uvicorn
WebXR client pc/src/openmuscle/web/static/vr/{index.html, app.js, styles.css} v1.0 → v1.10 trail, ~1700 lines of JS, doc-first via web/README.md
Launcher pc/start-vr.bat One-click Windows launcher: ADB sanity, server up, adb reverse, open Quest Browser. Optional arm and mode args. Portable (no hardcoded paths).
Tests pc/tests/test_storage.py, pc/tests/test_quest_ingest.py 10 new tests, 23 total, all passing
Docs docs/protocol.md, docs/vr-setup.md, root README.md, pc/src/openmuscle/web/README.md quest_hand type + versioning policy + handedness future-proofing, operator guide with real Quest mkcert install procedure (the hard-won workaround for Horizon OS hiding the standard cert UI)
.gitignore *.pem so dev TLS certs don't accidentally ship

mkcert procedure (the unexpected gotcha)

Documenting because it was hard-won (full procedure in docs/vr-setup.md):

  • Meta's Horizon OS consumer shell hides the standard Android "Settings → Security → Install a certificate" path. The Settings panel inside VR is Horizon Settings, not AOSP Settings.
  • Workaround: launch the AOSP Security Dashboard via ADB:
    adb shell 'am start -n com.android.settings/.Settings\$SecurityDashboardActivity'
    
  • The $ MUST be backslash-escaped through both PowerShell single-quotes AND the Android shell.
  • 2D panel may not be foregrounded — find it via Meta-button universal-menu app switcher.
  • File picker defaults to "Recent" (empty); hamburger menu → Internal Storage → Download.
  • Full table of working / broken Settings activities enumerated in docs/vr-setup.md so future Quest dev doesn't have to re-discover via dumpsys.

How to test

git fetch && git checkout feat/quest-vr-v1
cd pc && pip install -e .
pytest                                    # 23 tests, all pass

# Generate cert + push to Quest (one-time)
mkcert -install
mkcert -cert-file vr-cert.pem -key-file vr-key.pem <your-LAN-ip> localhost
adb push %LOCALAPPDATA%\mkcert\rootCA.pem /sdcard/Download/openmuscle-vr-rootCA.pem
adb shell 'am start -n com.android.settings/.Settings\$SecurityDashboardActivity'
# (then install the cert via the AOSP UI per docs/vr-setup.md)

# One-click launcher (USB + adb-reverse path, no certs needed for first try)
.\start-vr.bat                           # right arm, VR mode (default)
.\start-vr.bat right ar                  # right arm, AR/passthrough field-capture mode

In VR (gesture training): set both controllers down → 3-checkmark preflight → Enter VR → tap SESSION → REC → curl a finger → STOP → repeat → TRAIN → ghost hand + bars appear.

In AR (field capture): same flow but with passthrough visible. Drag panels via the handles to wherever fits your workspace. Tap REC to start a real-activity session — menu collapses to a single STOP button, sync slate flashes for video pairing, header strip shows running filename + ms clock. Quest's built-in screen recorder captures the video; pair by filename post-hoc.

Full operator walkthrough: docs/vr-setup.md.

Where to start review

  1. pc/src/openmuscle/web/state.py — synthesizer + helpers (~200 LOC of real change)
  2. pc/src/openmuscle/data/storage.py — lazy header refactor (~25 LOC)
  3. pc/src/openmuscle/web/README.md new "VR companion" section — architecture writeup; read before diving into the WebXR client
  4. pc/src/openmuscle/web/static/vr/app.js — WebXR client (~1700 LOC). Sections: scene init, drag/raycast, button factory, frame loop, REST handlers, sync slate, ghost hand, comparison panel. README section names every subsystem.
  5. docs/vr-setup.md — operator guide. Worth reading because the mkcert procedure is non-obvious.

Test plan

  • pytest passes (10 new tests for lazy writer + ingest synthesis)
  • start-vr.bat launches end-to-end on a clean clone
  • VR mode: preflight, Enter VR, joint spheres on both hands, ray pointer, button hit-tests, record/train/predict loop
  • AR mode: passthrough visible, panels scaled and positioned reasonably against real-world background
  • Movable panels: drag handles on infoGroup + menuRoot, grab/release works, panels re-face user after release
  • Collapse-to-STOP: REC press collapses menu to single STOP button, STOP press restores full menu
  • Sync slate: 2.5s yellow splash with filename + Unix-ms timestamp visible during REC start
  • mkcert + Quest cert install: follow docs/vr-setup.md end-to-end on a clean headset
  • Ghost hand: orientation alignment matches real wrist (v1.8); REAL vs PRED bars + amber ghost hand appear when PREDICT is on

Deferred (with owner's blessing)

  • Wrist-relative training labels — TODO comment in _write_labels_schema, GitHub issue to file separately. Right time is when training a portable model that needs to generalize across capture-locations.
  • Desktop UI piston-comparator → 3D hand viewer for quest_hand captures — its own scope, Studio v1.x enhancement.
  • Linux/macOS launcherpc/start-vr.bat is Windows-only. .sh mirror is a 5-min add when needed.
  • In-VR gesture-name labeling during field captures — operator currently watches video post-hoc; in-VR voice-or-button-tagging would tighten the loop.
  • BLE / Wi-Fi-direct bracelet-to-headset path — current architecture requires a PC on the LAN. For true off-grid capture, the Quest needs to receive bracelet data directly. Discussed as v2 territory (native APK or Quest Browser Web Bluetooth if Meta enables it).

Branch state

git log --oneline main..feat/quest-vr-v1   # 30 commits since the WIP-cleanup split

🤖 Generated with Claude Code

turfptax and others added 30 commits May 25, 2026 10:50
The legacy capture .txt converter previously emitted one row per packet
with (device_id, ticks, data) columns -- not directly trainable. Rewrite
it to deque-match the 3 SensorBand banks against LASK5 label packets by
recency, producing the standard 12-sensor + 4-label paired-CSV shape
plus per-stream timestamp columns. Dedup back-to-back identical records.

Also lift combine_csvs() into this module so cli `train` and the web
`/api/train` route can share one row-wise concatenation helper.

dataset.py: detect_columns() now excludes any column with "Timestamp" in
its name so the new Sensor_Timestamp / Label_Timestamp columns don't get
fed to the model as features.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rewrite _forward_to_hand() to match the OpenHand firmware's anatomical
channel layout (FINGER_CHANNELS = [1, 3, 5, 7, 9] -> thumb, index,
middle, ring, pinky). The previous implementation appended joystick X
as the 5th angle, which lined up with channel 9 (pinky) instead of
channel 1 (thumb) -- the hand was getting "thumb=predicted_pinky" and
"pinky=joystick_x", silently inverted.

Also reverse the piston order (P4..P1) on the way out so the PC path
matches the LASK5 ESP-NOW path, whose default 'L5' device config has
reverse=True. Without this reversal the same prediction array drives
different fingers depending on whether the packet came via the model
or via direct ESP-NOW from the LASK5.

Add rate-limited logs (first hit + every 500th) so the operator can
verify forwarding is actually happening without strace.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two additive endpoints for the Captures and Sessions panels:

  POST /api/reveal {name?}
    Opens captures_dir in the OS file manager. With a capture name,
    highlights that specific .csv inside its folder (Explorer /select on
    Windows, open -R on macOS, xdg-open on Linux). Whitelist-guarded
    via state.capture_path().

  POST/DELETE /api/sessions/{id}/captures{/name}
    The "I forgot to start a session before recording" recovery path.
    Bulk-add or remove existing captures from a session after the fact.
    Updates both the session JSON's `captures` list AND the capture's
    .meta.json (tag `session:<id>` + auto.session_id) so the captures
    filter and session expansion stay consistent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Rebrand the web UI from "OpenMuscle Live" to "OpenMuscle Studio" and
restructure the page around the data-pipeline narrative:

  SENSOR -> LABEL -> CAPTURE -> MODEL -> HAND

A topbar pipeline-strip shows live status for each stage with click-
to-scroll anchors. Body splits into numbered stages: (1) Live --
heatmap + GT-vs-Predicted comparator hero, (2) Capture, (3) Models,
(4) Output. The Devices list collapses into a thin left rail.

styles.css gets the matching grid-template + new pipe-pill / stage-*
classes. app.js adds renderPipelinePills() (called every WS tick) and
moves the per-panel renderers into the new stage containers.

No behavior changes -- same WS contract, same REST surface. Pure
restructure + visual rework.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ease

Two ergonomics fixes to the boot.py listener loops:

(1) KeyboardInterrupt handling
    Both espnow_listen() and udp_listen() now catch Ctrl-C from the REPL
    (mpremote / Thonny / serial), release_all() the servos, close the
    socket cleanly, and re-raise so the boot orchestrator can also bail
    out. Previously a Ctrl-C left the servos energized in whatever
    angle they were last commanded -- annoying and a power drain.

(2) UDP idle-sleep
    udp_listen() tracks last_packet_t. After UDP_IDLE_SLEEP_S (30s) with
    no incoming packets, release_all() puts the servos in low-torque
    state and the OLED shows "Sleeping..." so the operator knows the
    hand is intentionally limp rather than crashed. The next incoming
    packet wakes it (apply_packet implicitly re-energizes), so wake is
    just "resume listening".

    Loop yield drops from 500 Hz (2 ms) to 20 Hz (50 ms) while asleep,
    so the ESP32 isn't pegged when nobody's streaming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…cket

The Quest hand-tracking pipeline (coming next) doesn't know how many
label columns it'll have until the first XRHand frame arrives -- joint
count varies by Quest model and XRHand implementation (Quest 3S sends
~26 joints * 7 floats = 182 per hand). Hardcoding 4 (LASK5) in
CaptureWriter's constructor doesn't fit.

Refactor CaptureWriter to defer the header row until the first
write_row call, and let label_count be either an explicit hint (today:
4 for LASK5) or None (infer from len(label_values) of the first row).

Preserve the "empty CSV still has a header" invariant via a check in
close(): if no row was ever paired, emit the header using the hint or
0 label columns. So consumers (combine_csvs, train, pandas readers)
never see a zero-byte file.

state.py: start_recording() detects label_device_type == "quest_hand"
and passes label_count=None so the writer infers. LASK5 callers are
unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…hesis

Browsers can't speak UDP, so the WebXR client in the Quest headset will
open a WebSocket and push XRHand frames as JSON. Rather than introduce
a parallel ingest path, we synthesize each frame into an
OpenMusclePacket(device_type="quest_hand") and route it through the
same _handle_packet that UDP listeners use. From the recorder, matcher,
snapshot, and meta-sidecar code's perspective, the Quest is just
another device.

state.py: new AppState.ingest_quest_packet(payload) builds
    data.values      = flat [px,py,pz, rx,ry,rz,rw] per joint (matches
                       LASK5 convention so the recorder needs no
                       special-case)
    data.handedness  = "left" | "right"
    data.joint_names = canonical OpenXR-ish names for the column layout
    data.hands       = structured per-joint form preserved for JSONL
                       sidecar / offline analysis

Empty payloads (Quest reports tracking lost) drop silently -- we want
gaps in the data, not zero rows that mislead the model.

app.py: new /ws/quest endpoint accepts the inbound socket, calls
ingest_quest_packet per frame, logs connect/disconnect/per-frame errors
to the existing log buffer. Per-frame errors don't kill the socket --
one bad frame happens; the next one is usually fine.

Smoke-tested end-to-end with a 5-frame fake Quest client. Device
appears in /api/devices as type="quest_hand" with values_len=182.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server-side prep for the WebXR client. Recording with the Quest as the
label source now Just Works:

* /vr route serves static/vr/index.html (placeholder for now; the real
  WebXR client lands in the next commit). WebXR needs a secure context
  -- HTTPS via mkcert or localhost via `adb reverse`.

* start_recording's window_ms argument is now Optional[int]. None means
  "pick per-device-type" via a new DEFAULT_WINDOW_MS_BY_TYPE map
  (lask5=100, quest_hand=175, fallback=100). Quest WebXR has higher
  end-to-end latency than LASK5 ESP-NOW, so a tighter window was
  dropping too many sensor frames as unpaired.

* _auto_pick_label now walks an explicit type-preference list
  ("quest_hand", "lask5"). Previously only LASK5 was auto-pickable, so
  starting a recording with only a Quest device connected fell through
  to sensor-only mode silently.

* New per-capture <name>.labels.schema.json sidecar, written lazily on
  the first quest_hand label packet. Maps each CSV column
  (label_0..label_N) back to (joint_name, channel) so consumers can
  deserialize the wide label vector without reverse-engineering the
  joint ordering. LASK5 captures don't write the sidecar (column
  meaning is obvious from device_type).

* Auto-meta gains label_source ("lask5" | "quest_hand" | None) so the
  Captures panel filter and any downstream training pipeline can
  cleanly separate the two label families.

* delete_capture extended to also remove .labels.schema.json.

* stop_recording sidecars response gains labels_schema path.

* StartRecordingBody.window_ms becomes Optional[int] (default None ->
  per-device-type) so the JS client doesn't have to know the Quest
  default value.

End-to-end verified: prime two devices, POST /api/recording with no
overrides, server picks quest-test as label_device_id, window=175,
streams 30 paired frames, schema sidecar lands with 26 joints / 182
label columns, meta.auto.label_source = "quest_hand", CSV has the
expected 243 columns (1 + 60 + 182).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Quest Browser refuses to grant WebXR hand-tracking on plain HTTP --
WebXR requires a secure context. Two paths get you one:

  1. mkcert + LAN HTTPS  (untethered, the real use case)
  2. adb reverse over USB (Quest sees http://localhost, tethered)

This adds path 1. mkcert produces a cert/key pair; install the mkcert
root CA on the headset via Settings -> Security -> Install a
certificate, then:

    openmuscle web --ssl-certfile cert.pem --ssl-keyfile key.pem

uvicorn handles the TLS termination directly -- no nginx etc. Both
flags must be passed together (or neither, for plain HTTP local
development).

The CLI prints the LAN VR URL pattern on startup so the operator
doesn't have to compose it by hand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… record

The /vr page now actually does something. Pure HTML + ES modules from
the three.js CDN, no build step. Companion to the server-side ingestion
that landed in 95556fb / 7b1d2b4.

Architecture in the headset:

  XRSession ('hand-tracking' optional feature requested)
       |
       v
  per XRFrame:
    1. capture XRHand joints (25 per OpenXR spec) for the chosen arm
       -> POST {device_id, ts, handedness, joints[]} to /ws/quest
          (throttled to ~30 Hz so the WS queue stays drained)
    2. update small joint-sphere visualizer so user sees what we see
    3. detect pinch (index-tip <-> thumb-tip < 2.5 cm, hold >= 1 s)
       -> toggle recording. Yellow ring on the index tip fills during hold.
    4. raycast: if the OFF-hand's index tip touches the floating button,
       toggle recording too. Off-hand on purpose -- if you tap with the
       same fingers we're trying to capture, you smear the gesture.
    5. paint heatmap from latest /ws/live snapshot onto a CanvasTexture
       on a 40 x 12 cm world-anchored panel placed at session start.
       The matching desktop UI colour ramp is reproduced inline so we
       don't depend on its CSS.
    6. update header strip: live device Hz when idle, "REC ... rows ...
       match X%" when recording.

The heatmap + button are WORLD-anchored at session-start (not head-locked)
-- locking to the head is the canonical nausea recipe. Pre-VR landing
page has a checklist (HTTPS, WebXR support, server reachable) so failure
modes are obvious before the operator pulls the headset on.

URL params:
  ?arm=right (default) | ?arm=left  -- which Quest hand is the FlexGrid
                                       arm. We capture only this one;
                                       the other stays free for the
                                       record button.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
protocol.md: document the quest_hand payload (flat values + structured
hands form + handedness + joint_names), plus the per-capture
.labels.schema.json sidecar contract. Sibling LASK5 / FlexGrid sections
left untouched.

docs/vr-setup.md (new): operator guide for the Quest 3S companion --
mkcert setup + headset CA install (Settings -> Security -> Install a
certificate), HTTPS server invocation, per-session walkthrough, and a
troubleshooting table for the failure modes we hit during smoke
testing. Ends with the architecture diagram showing how the synthetic
quest_hand packet path collapses into the existing UDP-listener
pipeline (no parallel ingest, no special-casing downstream).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The single record-toggle sphere becomes a row of three labeled buttons
so the user can drive a full record -> train cycle without taking the
headset off. Per first-headset test feedback: "We may want to design
buttons on the UI so i can record and stop and train through the VR app."

Layout (anchored at session start, world-locked):

       [          HEATMAP          ]
       [   25 Hz · 30 Hz · idle    ]     <- header (unchanged)

         ⬤ REC   ⬤ TRAIN   ⬤ SESSION    <- new row, off-hand tap

       [  trained: R²=0.81 ✓        ]     <- new status strip (fades)

Architecture:

* createButton(name, isActive, onActivate) factory builds a small sphere
  plus a billboard text label canvas-textured plane. Each button lives in
  the global `buttons` map keyed by name. The off-hand touch raycast now
  iterates the map, with per-button hover state so adjacent presses don't
  cascade.

* uiState centralizes recording / sessionActive / sessionId / training
  flags previously scattered across recordingState + ad-hoc. Server-side
  state mirrors in from /ws/live snapshots each frame, so the SESSION
  button's "active" color reflects whatever the server thinks, even if
  another client (e.g. the desktop UI) started or ended the session.

* runTrain() decides what to train on with this priority:
    1. active session's captures
    2. fallback: most recent capture only
  Edge cases (no captures, training in flight, recording in flight) all
  surface human-readable messages in the status strip rather than failing
  silently.

* drawStatus()/setStatus() paints the status strip with an alpha-fade
  over STATUS_FADE_MS (6s) so old messages don't pile up but you have
  time to read the result of a training run.

* REC button's label flips between "REC" (idle) and "STOP" (recording),
  so glance-readable from the headset.

Pinch-to-record stays as a hands-free fallback for REC only (TRAIN and
SESSION are deliberately tap-only -- you don't want to accidentally
start a session by clenching your fist).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The v1.1 floating spheres turned out wrong in practice: no visible pointer
to aim with, and the user had to physically reach to where each sphere was
anchored. Real-headset feedback: "usually there is a pointer from the
controller or hand which I can use to navigate, which is gone in our app.
We need a proper menu within the vr app."

This rewrites the in-VR UI to the standard WebXR pattern:

* Flat menu panel (~46 x 20 cm, translucent dark plate) below the heatmap,
  tilted ~18° toward the head so it's readable from a natural viewing angle.
  Holds a 2x2 grid of rectangular labeled buttons:

      REC      SESSION
      TRAIN    EXIT VR

  Plenty of room for more rows later (PREDICT toggle, gesture-label dropdown,
  etc.) without re-layout.

* Ray pointers from each XR controller, via the canonical Three.js pattern:
  renderer.xr.getController(i) returns an Object3D whose transform tracks
  the corresponding XRInputSource's targetRaySpace. We attach a Line geometry
  forward (3 m default, truncated to the hit point on hover) and a
  selectstart listener that fires the hovered button's action.

* Pinch = select. With hand tracking, Quest maps a pinch to OpenXR's "select"
  action, which surfaces as the standard XRInputSource select event. No more
  custom proximity detection for buttons.

* Only the OFF-hand ray is rendered + active. The captured-arm ray would
  clutter the view and risk hovering buttons while you perform a gesture.
  The captured arm's pinch-to-record (1-second hold) still works as the
  hands-free shortcut for REC.

* Hover-glow + active-state coloring drawn into each button's CanvasTexture:
  gray idle, blue when the ray is over it, red when its underlying state
  is "on" (recording for REC, session open for SESSION, training in flight
  for TRAIN). REC's label flips REC <-> STOP per state.

* New EXIT VR button calls session.end() -- clean way out without taking
  the headset off.

Cache-bust: script src is now `/static/vr/app.js?v=2`. Quest Browser
ignores `Cache-Control: no-store` for ES modules in some configurations,
which is why v1.1 didn't show up after a refresh. Bump this counter for
every future app.js change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The no-cache middleware whitelisted "/" and "/static/*" but missed the /vr
route added in 7b1d2b4, so Quest Browser cached the VR HTML and refreshes
kept loading stale JS (this is why v1.2 didn't appear on refresh -- the
cached HTML still pointed at app.js with no version querystring). Add /vr
to the whitelist. Bump app.js?v=3 to force a fresh fetch on whoever still
has the stale HTML.

Also: the off-hand had no visualizer in v1.2, just a ray emanating from
nowhere. Per first-headset feedback ("I can't see the shape of my left
hand to see which i'm grabbing"), add a parallel green-sphere visualizer
for the off-hand alongside the captured-arm's blue spheres. Slightly
larger spheres (8.5 mm vs 6 mm) so the pointing hand reads as more
solid in the user's peripheral vision even when most of the ray is
behind the fingertip.

Refactor: updateArmVisualizer -> updateHandVisualizer(... , meshesMap)
so the same logic drives both hands. Same for the hide path.

Color legend now:
  BLUE  = captured arm (the hand whose pose we're recording for ML)
  GREEN = off-hand (the hand that drives the menu)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…train

Menu grows from a 2x2 to a 3x2 grid so the data->model->system workflow
fits cleanly on one panel:

  Row 0 (toggles): REC      SESSION   PREDICT
  Row 1 (actions): TRAIN    RECENTER  EXIT VR

PREDICT toggles inference at the server (POST /api/inference/enabled)
and reflects whatever the server actually thinks via the inference field
in /ws/live snapshots. Refuses cleanly with "no model loaded -- train
one first" if no model is loaded.

TRAIN now auto-enables inference after a successful activate (which
already hot-swaps the new model into the engine). Server-side default
is paused-on-load (commit bd1b68a) but in VR there's no obvious second
click to do this -- pressing TRAIN already implies "I want this model
running". Status strip surfaces "trained: R²=X · model loaded ✓ ·
predict ON" so the user knows the loop closed.

RECENTER re-anchors the heatmap + menu + status strip to wherever the
head is right now. Setting `placed = false` makes the next XRFrame
re-run placeAnchors with the current viewer pose. Useful when the user
shifts in their chair or moves around the room.

Grid layout math now driven by MENU_COLS / MENU_ROWS constants so adding
or removing buttons stays a one-line change.

Bump app.js?v=4.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per first-headset feedback: "we need to see the inference after training to
predict or infer what the gestures are and to see how big the difference is
between reality and the prediction."

Small panel between heatmap and menu, four columns (INDEX / MIDDLE / RING /
PINKY -- thumb omitted since FlexGrid can't see it) each with a pair of
vertical bars:
  GREEN R = ground-truth curl from XRHand joints on the captured arm
  AMBER P = model-predicted curl from the inference engine

"Curl" is normalized [0..1] derived from wrist <-> finger-tip distance:
when extended the tip is far from the wrist, when closed it's close. The
per-finger max-extended distance is tracked dynamically so the metric
adapts to the user's hand size without calibration -- first time you fully
extend a finger, that becomes that finger's 0.0 reference.

The model's predicted output (inference.piston_values in the WS snapshot)
uses the same 25-joint x 7-float ordering the WebXR client sends in. So we
extract predicted wrist + tip positions at the same indices XRHand uses
and apply the same curl formula. Apples-to-apples.

Panel is hidden when (a) no captured hand is tracked or (b) inference is
paused / no model loaded -- nothing meaningful to compare. The moment
PREDICT goes on the panel pops in, so you can train -> see-error-live
without taking the headset off.

Layout: menu pushed down 5cm (MENU_OFFSET_DOWN 0.23 -> 0.28) to make room
for the 5cm-tall compare panel in the gap. Heatmap unchanged.

Bump app.js?v=5.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small UX wins from observing v1.5 in the headset:

* Ray idle length 3 m -> 0.8 m, and a more muted blue. At 3 m the ray
  shot off into the room and read like a laser pointer; at 0.8 m it
  reads more as "where my finger is aimed." On hover the ray still
  extends to the hit point and turns amber, so the hover affordance
  isn't lost. Rename RAY_DEFAULT_LEN_M -> RAY_IDLE_LEN_M to make the
  intent obvious at the use sites.

* Brief white flash on button activation (BUTTON_FLASH_MS = 180 ms).
  Priority is flash > active > hovered > idle in drawMenuButton so the
  confirmation lands regardless of what state-change the action
  triggers (which may not be visible immediately if the server is
  slow to respond). Label text inverts to dark on the white flash for
  legibility.

* Pinch progress ring grows from 2.5-3.0 cm to 3.8-4.6 cm radius and
  brighter (0xfacc15 vs 0xfbbf24). Easy to spot at arm's length while
  you're focusing on the gesture itself.

Bump app.js?v=6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add a third joint visualizer alongside the existing real-arm (blue) and
off-hand (green) -- amber, semi-transparent spheres rendered at the
model's predicted joint positions. Visible only while PREDICT is on AND
the captured hand is tracked.

The ghost is anchored to the user's real wrist each frame: we offset
every predicted joint by (real_wrist - predicted_wrist) so the
visualization shows predicted SHAPE rather than absolute model output.
This sidesteps the "model trained on absolute world positions, predicts
positions where the recordings happened to be" confound -- the user
sees their real hand and a ghost hand at the same place, the difference
between them = the model's shape error in 3D rather than the abstract
0..1 curl scalar from the comparison bars.

Rotation isn't aligned yet. A future iteration could match wrist
orientations too (compute the quat that takes predicted-wrist-orientation
to real-wrist-orientation, apply to all predicted joints). For now,
shape-only is dramatically more useful than the raw absolute output and
already gives the visceral "see how wrong the model is" feedback the
user asked for.

Bump app.js?v=7.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Windows .bat that takes you from "everything powered down" to "in VR
with /vr loaded" in one click. Useful both for development iteration
and for new contributors who want to try the VR side without learning
the adb-reverse incantation.

What it does, in order:
  1. Locate adb (PATH first, then %LOCALAPPDATA%\Android SDK fallback)
  2. Parse `adb devices` -- distinguishes "device" / "unauthorized" /
     "offline" so the failure message tells you what to fix
  3. Verify `openmuscle` CLI is installed (`pip install -e .`-ready)
  4. Start `openmuscle web` in its own console window
  5. Poll localhost:8000 for up to 20s until the server responds
  6. `adb reverse tcp:8000 tcp:8000` so Quest sees localhost
  7. `adb shell am start -a android.intent.action.VIEW -d <url>`
     to open Quest Browser straight to /vr

Portable: uses %~dp0 for self-location, no hardcoded paths. Works
from any clone path. ADB lookup falls through PATH then Android SDK
default so it works with MQDH installs and chocolatey installs alike.

Optional first arg picks arm: `start-vr.bat right` (default) or
`start-vr.bat left`. The arg flows into `?arm=...` on the /vr URL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The VR/Quest companion shipped across 14 commits but none of them
updated the doc surfaces a contributor checks first. Close that gap:

* README.md (root): add a VR-companion subsection under Quick Start
  pointing at start-vr.bat + the manual paths, add the --ssl flags
  row to the CLI table, add docs/vr-setup.md to the Documentation
  links.

* pc/src/openmuscle/web/README.md: new "VR companion (/vr)" section
  documenting the architecture (why WebSocket-inbound when everything
  else is UDP), the new endpoints (/vr, /ws/quest, /ws/quest payload),
  the quest_hand recording specifics (window_ms default, lazy
  label_count, labels-schema sidecar, label_source meta tag, auto-pick
  preference), the WebXR client file structure, the ?v=N cache-bust
  contract, and the auto-enable-inference-on-train policy. Update
  Known Gotchas with the cache-related lessons learned (/vr was
  missing from middleware; Quest Browser ignores no-cache for ES
  modules; WebXR needs a secure context).

* docs/protocol.md: clarify the versioning policy -- adding a new
  `type` like quest_hand is non-breaking under v1.0, since existing
  parsers ignore unknown types and the envelope is unchanged. Bump
  to "1.1" only when the envelope itself changes.

No code changes in this commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Review nit-fix: the joint-flatten ordering was implicit in two places
(`ingest_quest_packet` did `extend(pos); extend(rot)`, and
`_write_labels_schema` reconstructed the column index as
`ji * n_floats + ci` with its own local `ordering` list). Changing
one would silently break the other -- the sidecar would lie about
what's in the CSV.

Centralize:
* `QUEST_JOINT_CHANNEL_ORDER = ("px","py","pz","rx","ry","rz","rw")`
  at module level, with a docstring naming both call sites.
* `_flatten_quest_joint(pos, rot)` -- used by ingest_quest_packet to
  pack one joint in the canonical order.
* `_quest_label_column(ji, ci)` -- the inverse view, used by
  _write_labels_schema to compute column indices.

Both call sites now route through these helpers, so the coupling is
structural rather than two aspirational comments. Verified the flatten
output is byte-identical to the old direct-extend code with a quick
smoke test (1,2,3 + 4,5,6,7 -> [1..7]).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Team feedback on the v1.7 ghost-hand: "the wrist-position-only ghost
looks broken in demo recordings. Ship in this PR if it's really 3 lines.
If it turns into a rabbit hole, defer to v1.8." This is v1.8 -- about
12 lines of additional Three.js quaternion math, not a rabbit hole.

Transform each predicted joint from "predicted wrist frame" into "real
wrist frame":

  pos_in_real_frame = real_wrist_pos +
                      delta_quat * (pred_pos - pred_wrist_pos)
  delta_quat        = real_wrist_quat * inverse(pred_wrist_quat)

Same convention WebXR uses (XRJointPose.transform.orientation is a
quaternion in (x, y, z, w) order, matching Three.js's THREE.Quaternion
constructor). Allocations are reused per-frame (six module-level
Vector3 / Quaternion temporaries) so the XR loop doesn't churn.

Graceful degradation: if the wrist quat is missing for a frame (tracking
hiccup), falls back to position-only alignment, same as v1.7.

Bump app.js?v=8. Visual validation requires the headset -- the math is
the standard delta-quaternion approach but the ghost may need fine-
tuning if Quest's joint orientation convention is rotated relative to
the world (e.g., wrist forward axis differs from what we expect). If
demo recordings show the ghost mirrored or rotated 90°, swap the
multiply order or apply a fixed correction quat -- but typical Quest
hand-tracking joints follow the OpenXR convention which matches this.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per review: three pytest cases requested before merge.

tests/test_storage.py::TestCaptureWriterLazyHeader
  * test_label_count_inferred_from_first_row -- None hint, write_row with
    175-element label_values, header gains 175 label_N columns.
  * test_explicit_label_count_still_works -- LASK5 path unchanged
    (label_count=4 -> 4 label columns).
  * test_close_on_empty_capture_writes_header -- empty capture still
    produces a header-only file (preserves "consumers never see a
    zero-byte CSV" invariant from before the lazy refactor).
  * test_close_on_empty_capture_with_none_count -- empty + None hint
    falls back to 0 label columns rather than crashing.

tests/test_quest_ingest.py::TestIngestQuestPacket
  * test_full_payload_registers_device -- 25-joint frame -> device in
    AppState.devices with type "quest_hand" and 175-element values.
  * test_data_values_use_canonical_channel_order -- pins the layout
    against QUEST_JOINT_CHANNEL_ORDER + _flatten_quest_joint so a
    future re-order of one immediately breaks the test.
  * test_empty_joints_drops_silently -- tracking-lost frames (empty
    or missing 'joints' key) don't create zombie devices.
  * test_missing_pos_rot_defaults_to_identity -- partial joint dicts
    still produce 7 floats (zero pos + identity quat).
  * test_default_device_id_when_missing -- payload without device_id
    falls back to "quest-01".
  * test_repeated_packets_increment_packet_count -- packet counter
    advances per frame.

Full suite: 23 passed (4 matcher + 9 protocol + 4 storage + 6 ingest).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two forward-looking notes from review:

1. TODO comment in `_write_labels_schema` docstring naming the
   "wrist-relative label coordinates" follow-up explicitly. Right time
   to do that work is when we want a portable model that generalizes
   across capture-locations, not now. Will track as a GitHub issue in
   the OpenMuscle-Software repo separately.

2. `docs/protocol.md` notes that the current single-hand `handedness`
   string can grow to `"both"` in a backward-compatible way: payload
   widens, schema sidecar grows a per-column hand field or parallel
   joint-name lists. Captured here so a future refactor of
   `_flatten_quest_joint` / `_write_labels_schema` doesn't accidentally
   shape itself in a way that prevents the extension.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mkcert-generated certs for the VR /vr page over LAN are per-machine
(they encode your specific LAN IP and hostname) and not useful to
anyone else. They're not secret in the cryptographic sense either --
the mkcert root CA only trusts certs signed by your local install --
but there's no reason to push them. Catch the whole class with *.pem
since nothing in the repo today is a checked-in PEM.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Validated end-to-end on real hardware 2026-05-29. The original docs
assumed the standard Android "Settings -> Security -> Install a
certificate" path was reachable from Horizon OS's consumer Settings
panel. It isn't -- Meta has hidden it. The Settings UI you see when
tapping the gear icon in VR is Horizon Settings, not AOSP Settings.

Document the actual workaround we hard-won:

* Push the cert via `adb push`, then launch the AOSP Security
  Dashboard activity directly via ADB with the right backslash-
  escaped activity name (the $ must survive both PowerShell single
  quotes AND the Android shell, hence \$).
* Activity name is SecurityDashboardActivity, not SecuritySettings-
  Activity (that's the older Android name and doesn't exist on
  current Horizon OS -- enumerated via `dumpsys package
  com.android.settings`).
* The 2D panel often launches but isn't auto-foregrounded -- find
  it in the Meta-button universal-menu app switcher.
* The file picker that opens for cert selection defaults to "Recent"
  which is empty on first install; user has to hit the hamburger
  menu and navigate to Internal Storage -> Download manually.
* Include the table of working / non-working Settings activities so
  future Quest dev (when other AOSP menus are similarly hidden)
  doesn't have to re-enumerate from scratch.

Also a cortex note logged with the same procedure (tags:
openmuscle,quest3,webxr,mkcert,adb,horizon-os,gotchas) so the
knowledge survives outside this repo too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Field-capture mode. Wear the headset while doing real activities (cooking,
typing, woodworking) and capture FlexGrid + Quest hand-tracking + a video
of what you're doing, so the model trains on natural movement rather than
deliberate in-VR gestures.

Three pieces:

1. ?mode=ar|vr URL param toggles between immersive-vr (existing default,
   black background, deliberate-gesture training) and immersive-ar
   (passthrough, real world visible behind our floating panels, field
   capture). XR_SESSION_TYPE flows through preflight check, scene
   background nulling, and the VRButton/ARButton swap. local-floor goes
   from required (VR) to optional (AR -- passthrough gives us the floor
   we need without explicit anchoring).

2. Sync slate: SYNC_SLATE_MS (2.5 s) high-contrast splash that pops up
   when REC starts, centered 55 cm in front of the user. Big yellow plate
   with black text -- visible in passthrough AND in the headset's screen
   recording. Shows the capture filename + Unix-ms timestamp so a
   re-watched video can be paired to a CSV row by timestamp without
   guessing.

3. Header strip filename + ms clock while recording: REC * 14:32:05.123 *
   capture_X.csv (previously was REC * N rows * match X%). The new format
   gives the operator a running sync marker every frame they happen to
   glance at the heatmap during a real-world capture session. Idle header
   text (FlexGrid Hz / Quest Hz) is unchanged.

start-vr.bat gains a second optional arg: `start-vr.bat right ar` for
field capture, `start-vr.bat right` (or no args) for the VR default.
URL is properly quoted in echo so the & between query params doesn't
trip cmd.

Bump app.js?v=9. The "tap REC * see SYNC splash * do your activity * tap
STOP * pull MP4 off headset * pair by filename" loop is now a thing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…posites

v1.9 shipped with renderer alpha disabled, so even when Quest granted
immersive-ar the WebGL framebuffer was opaque and passthrough had
nothing to composite behind. User report: "still shows black background"
after loading ?mode=ar.

* WebGLRenderer({ alpha: true }) unconditionally (cheap in VR mode too,
  we just paint a solid background ourselves)
* renderer.setClearAlpha(0) when MODE === 'ar' as a belt-and-suspenders
  explicit transparent clear
* Log the granted XRSession's environmentBlendMode on sessionstart so
  next time we can tell "Quest fell back to VR" vs "renderer setup wrong"
  from one console line ('opaque' = VR, 'alpha-blend'/'additive' = AR)

Bump app.js?v=10. Refresh ?mode=ar on the headset to pick up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First-headset report on passthrough mode: "the buttons are super big
and in the center" -- which is exactly what you'd expect when the same
panel sizes that read as "comfortably in front of me" in VR (no
real-world reference) are dropped against an actual kitchen / desk /
workshop. In passthrough the user has scale anchors, and 40cm panels at
70cm distance feel enormous.

Two coupled adjustments only when MODE === 'ar':
  HEATMAP_FORWARD_M : 0.70 -> 1.10  (panels further away, halves
                                      angular size in the user's view)
  UI_SCALE          : 1.0  -> 0.65  (uniform scale on heatmap, header,
                                      compare, status, menu groups)

UI_SCALE also multiplies the inter-panel offsets in placeAnchors
(MENU_OFFSET_DOWN, COMPARE_OFFSET_DOWN, STATUS_ROW_DOWN, and the
heatmap's drop-below-eye-height) so gaps shrink proportionally --
without that, small panels would have weirdly large empty space
between them and look broken.

The SYNC slate (centered 55cm in front of head at REC press) stays
full-size deliberately -- it's *supposed* to dominate the 2.5s
video-sync frame so a re-watched screen recording has an obvious
sync marker.

Reminder for the user: the RECENTER button in the menu re-anchors the
panel cluster to wherever the head is pointing right now, which is
useful in AR mode if you've moved around your workspace since session
start.

Bump app.js?v=11.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Field-capture feedback: "we will need to be able to move the menu and
also collapse it when recording... it will just be one button for
stopping the recording. Also we will need to be able to move the
sensor array visualization too. These pieces will be integeral to
being able to do work with the headset on and recording."

Three coupled changes:

1. Group restructuring
   - infoGroup (new Group) wraps heatmapMesh + headerMesh + compareMesh.
     The "data display cluster" moves as one unit. Children positions are
     now local-space offsets from the group origin (= heatmap center).
   - menuRoot (new Group) wraps menuPanel + statusMesh so the action UI
     moves as one. statusMesh's vertical offset captured as a fixed
     local offset; placeAnchors no longer positions it independently.
   - UI_SCALE is now applied to the two groups directly in placeAnchors
     rather than to each leaf mesh. Cleaner; children inherit.

2. Drag handles + drag logic
   - HANDLE_SIZE_M (3 cm) cubes attached to top-left corner of infoGroup
     and menuRoot, exposed as raycast targets. Hover-amber, grabbed-emerald.
   - Off-hand ray + pinch on a handle starts a drag. Pinch release ends
     the drag and re-aims the panel toward the head's current position
     (so a panel dragged behind your back lands facing you, not away).
   - Reusing the existing select / selectend XR events on the controllers;
     dragState is mutually exclusive with button activation -- if you
     pinch while hovering a handle, you drag; if hovering a button, you
     click; raycast picks geometrically nearest hit.

3. Collapse-to-STOP while recording
   - New stopButtonMesh (18 x 10 cm big red button) lives as a child of
     menuRoot at the same world position as menuPanel.
   - setRecordingCollapsedUI(isRecording) called every frame from the XR
     loop: menuPanel.visible = !recording, stopButtonMesh.visible =
     recording. Mutually exclusive.
   - STOP button is a first-class raycast target -- pinch fires
     toggleRecording (= stop). No need to find a small button in a 3x2
     grid mid-activity.
   - Status strip stays visible in both states; useful save/train
     feedback applies regardless.

Behavior of the existing RECENTER button is unchanged but more useful
now: it sets placed=false, which re-runs placeAnchors and resets BOTH
group positions + the slate position to defaults relative to the head's
current pose. Use it if you've dragged things into a corner and want a
clean slate.

Bump app.js?v=12.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
turfptax and others added 11 commits May 29, 2026 23:54
Created Open-Muscle/OpenMuscle-AR as the discoverability anchor +
future home for AR/VR work. The current WebXR client stays here in
pc/src/openmuscle/web/static/vr/ for now (tight coupling to the FastAPI
server), but the new repo gives the AR side a findable URL and a clear
roadmap for v2 (native Quest APK with BLE-direct bracelet) and beyond.

Add it to the Related Repositories section under a new AR/VR heading,
linking to its ROADMAP so contributors looking at this repo see the AR
work exists and where it's headed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
start-vr.bat already covers the USB-tethered + adb-reverse + plain
HTTP path (fast iteration). This new launcher covers the other half:
HTTPS over LAN, no cable, for real field-capture sessions.

What it does:
  1. Verify openmuscle CLI on PATH
  2. Verify vr-cert.pem + vr-key.pem exist next to it (helpful error
     linking to the mkcert wiki page if not)
  3. Sniff a non-loopback LAN IPv4 via PowerShell and print the
     exact headset URLs (both /vr and /vr?mode=ar) so the operator
     doesn't have to look up the IP separately
  4. cd into pc/ and run `openmuscle web --ssl-certfile vr-cert.pem
     --ssl-keyfile vr-key.pem`

No adb-reverse, no Quest Browser auto-open -- the whole appeal of
HTTPS-LAN is being away from the PC, so user types the printed URL
into Quest Browser themselves.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First-headset failure mode report: "I moved from where I was and the
headset wanted to define a new boundary. When this happened it seemed
to glitch our code and mess up."

What was happening: Quest pauses the WebXR session whenever system UI
takes over (boundary redraw, universal menu, notifications). Visibility
state goes 'visible' -> 'visible-blurred'. Our frame loop kept firing
but viewer/joint poses can return null or stale data, so we kept
feeding garbage frames to /ws/quest and the recorder accumulated bad
rows. On resume, our world-anchored panels stayed put even though the
user's actual position in the room had shifted, leaving the menu
floating somewhere the user wasn't.

Three additive fixes:

1. Track xrPaused via session.visibilitychange. While paused, the frame
   loop short-circuits before pose sampling -- just renders the static
   scene + returns. No joints sent, no hand visualizers updated, no
   raycast or button activations. Skipping these is the difference
   between clean pause + resume vs corrupted-recording-then-glitch.

2. On resume to 'visible', force placed=false so the next frame re-runs
   placeAnchors with the current head pose. The panel cluster pops to
   wherever the user is looking now -- exactly what you'd want after
   walking ten feet to redraw a boundary.

3. On sessionend with an active recording, send DELETE /api/recording
   so the server's ActiveCapture closes cleanly. Without this the
   server thinks recording is still going forever (or until the next
   server restart), and a subsequent REC tap would fail "Already
   recording -- stop the current capture first" with no obvious way
   to recover from inside the headset. Fire-and-forget; if the server
   is unreachable simultaneously (Wi-Fi went out at the same time)
   we log and move on.

Status strip surfaces both transitions:
   "session paused (visible-blurred) -- boundary redraw or system UI"
   "session resumed after 12.3s -- re-anchoring UI"
So the operator scrubbing the screen recording later can see exactly
when Quest paused and how long it took, paired against the CSV's
gap in timestamps.

Bump app.js?v=13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Field-capture sessions want a stable workstation layout. Previously
every page reload, every Quest cert reset, every session resume after
a long pause would snap the heatmap + menu back to their default
positions and the user had to re-drag everything into their preferred
workspace arrangement.

Now: every drag-end serializes infoGroup + menuRoot pose to
localStorage under a per-MODE key (openmuscle-vr-layout-vr or
-ar). On next placeAnchors call -- whether that's a fresh session
start or a post-pause re-anchor -- we apply the saved layout if
present, else fall back to default positioning. The user's preferred
field-capture station persists across sessions, browser reloads,
Quest reboots, mkcert renewals, anything.

Per-MODE key because VR and AR have meaningfully different ergonomics:
in VR you might want the menu front-and-center; in AR field capture
you probably want it shoved to the periphery so it doesn't block your
real-world view of the stove / desk / whatever you're capturing.

RECENTER button now clears the saved layout in addition to setting
placed=false, so it's a real "I'm lost, start over" gesture. Without
that, a user who hit RECENTER, then closed the tab, would come back
next session to the same lost layout.

Slate position is computed fresh per-session inside placeAnchors
regardless of saved layout, since the sync slate should always pop
up in front of wherever the user is at REC press, not at some
previously-saved location.

Bump app.js?v=14.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three real edge cases found reviewing the field-capture work, all of
which Tory could plausibly hit during a first real test session:

1. Orphaned drag state. endDrag() was only called from the controller
   selectend event. If the controller disconnected mid-drag (hand
   tracking lost while holding a panel) or the session paused (boundary
   redraw) mid-drag, selectend never fired -- so dragState stayed set,
   the handle stayed stuck GRAB-green, and updateDrag() kept gluing the
   panel to a frozen/stale controller position. Added cancelDrag() and
   call it from both the controller 'disconnected' handler and the
   visibilitychange pause-start path. It releases the panel in place
   (no head re-orientation, since head pose is unreliable during a
   pause) and persists the layout.

2. Dishonest sessionend logging. The auto-stop-recording-on-sessionend
   path (added in v1.11) awaited the DELETE but never checked r.ok, so
   it logged "auto-stopped active recording" even when the server
   returned 400 (state drift -> "not currently recording"). Now checks
   r.ok and logs the HTTP status honestly when it isn't.

3. NaN panel positions from corrupt localStorage. _applyPoseJSON only
   checked that pose.p / pose.q existed, not that they were finite
   numbers of the right length. A corrupt / partially-written / schema-
   drifted layout payload would set NaN positions and fling a panel to
   an unreachable spot the user couldn't even find to drag back. Added
   _allFinite() validation (length + Number.isFinite on every element),
   reject degenerate near-zero quaternions, normalize the quat, and
   require a positive finite scale. Invalid payloads fall through to
   default positioning instead of corrupting the scene.

Also swept the last 3 em-dashes out of app.js string literals / comments
(house style: no em-dashes in shipped files).

Co-Authored-By: turfptax-claude O4.8 <noreply@anthropic.com>
Tory's goal for the VR app is "an easy to use application he can test
with the hardware and testing scenarios." This is the runbook that
serves that directly: a fixed bring-up order, a 10-step 2-minute smoke
test that needs only the headset (no FlexGrid), and six independent
per-feature scenarios (gesture training, AR field capture, pause/
boundary-redraw recovery, collapse-to-STOP, panel persistence, predict
+ ghost hand) each with explicit expected results and what-to-watch-for.

Ends with an acceptance-bar checklist ("what good looks like") and a
problem-reporting template pointing at the right repo + the wiki
troubleshooting page.

Linked from the root README docs index. No em-dashes (house style).

Co-Authored-By: turfptax-claude O4.8 <noreply@anthropic.com>
Self-review (iteration 4) found two canvases re-uploading to the GPU
every frame when their content rarely changes. Each `tex.needsUpdate =
true` forces three.js to re-upload the whole canvas next render. On
Quest's mobile GPU at 72fps this is wasted bandwidth competing with
hand tracking -- and it happens during recording, exactly when smooth
tracking matters most.

1. drawStopButton(): the collapsed STOP button is visible for the
   entire recording but its look only flips on hover enter/leave.
   It was redrawing + re-uploading a 512x280 texture every frame.
   Now guarded by _stopButtonLastHovered -- only redraws when the
   hover state actually changes (so ~0 uploads/sec instead of 72).

2. drawStatus(): called every frame from updateFromSnapshot to
   animate the fade, but it kept clearing + uploading the 800x90
   texture forever, even long after the text fully faded to nothing.
   Now guarded by _statusFadeDone -- once alpha hits 0 we do one
   final cleared upload and then no-op until the text changes.
   setStatus() resets the flag so re-issuing even an identical
   message re-flashes it.

No behavior change, purely fewer GPU uploads. drawHeatmap (signature-
gated) and drawCompare (live data, legitimately per-frame) were
already fine; drawHeader's idle-Hz redraw is lower impact and left
for a later pass.

Bump app.js?v=16.

Co-Authored-By: turfptax-claude O4.8 <noreply@anthropic.com>
Real correctness bug found in iteration 5's state.py review, the kind
that would quietly poison field-capture training data.

Root cause (client): captureAndSend skipped joints whose pose was
momentarily unavailable (hand partly out of camera FOV -- common during
real activities). That made the quest_hand payload a VARIABLE length
frame to frame, and worse: when a middle joint dropped, every later
joint shifted into the wrong slot. The server writes those slots
straight into CSV columns, so this produced (a) ragged CSVs (different
column count per row -> breaks pandas.read_csv) and (b) silently
misaligned ground-truth labels (column N means different joints in
different rows).

Fix, two layers:

* Client (app.js): emit ALL joints in canonical JOINT_NAMES order every
  frame. For an unavailable joint, send zero pos + identity quat with
  valid:false instead of omitting it, so the array length and per-slot
  meaning are stable. Drop the whole frame only when NO joint is valid.
  The validity flag rides in hands.joints (preserved in the JSONL
  sidecar); the flat values / CSV carry only the numbers.

* Server (state.py): defense in depth. Lock the label width at the first
  label packet (same width the labels-schema sidecar describes) and
  pad/truncate every paired row to it, so the CSV stays rectangular even
  if some client sends variable-length frames. Count mismatches
  (label_width_mismatch) and surface them in the stop-recording result +
  a warn log so a partial-tracking session is visible, not silent.

docs/protocol.md: document the fixed-length contract + the valid field.

Tests: new tests/test_quest_recording.py drives interleaved 25/20/25-joint
frames through a real recording and asserts the CSV is rectangular, the
schema sidecar matches the CSV width, and exactly one width-mismatch was
counted. Full suite 25 passed.

Bump app.js?v=17.

Co-Authored-By: turfptax-claude O4.8 <noreply@anthropic.com>
Completes the planned server-side coverage so the quest_hand recording
contract is regression-guarded:

* test_quest_window_defaults_to_175ms / test_lask5_window_defaults_to_100ms
  -- per-device-type match window picked when window_ms is None.
* test_explicit_window_overrides_default -- caller arg wins.
* test_auto_pick_prefers_quest_over_lask5 -- AUTO_LABEL_TYPE_PREFERENCE
  picks the richer Quest label vector when both sources are connected.
* test_label_source_meta_tag_quest / _lask5 -- the capture meta sidecar's
  auto.label_source + auto.window_ms reflect the actual label source, so
  the Captures panel and downstream training can keep Quest-labeled and
  LASK5-labeled datasets separable.

Each test calls stop_recording() before the temp dir is cleaned up --
on Windows an open CSV/JSONL handle blocks TemporaryDirectory deletion.

Full suite now 31 passed (4 matcher + 9 protocol + 6 ingest + 8
recording + 4 storage).

Co-Authored-By: turfptax-claude O4.8 <noreply@anthropic.com>
So the operator can judge data quality mid-session without taking the
headset off or checking the PC -- directly serves the "easy to test"
goal.

While recording, the floating header now shows:
  * a quality DOT colored by the live sensor<->label match rate:
      gray  = warming up (< 10 sensor frames seen)
      green = match rate >= 70%  (good)
      amber = 40-70%             (marginal)
      red   = < 40%              (poor pairing -- something's off)
  * the match percentage appended to the clock + filename
  * a "JOINTS DROPPING" suffix (and amber dot) when the server has had
    to zero-pad joint columns because partial hand tracking sent short
    frames -- the live counterpart to the v1.15 fix, so the operator
    SEES tracking degrade instead of finding zero-filled joints later.

Server: _snapshot's recording dict now carries label_width_mismatch so
the client can surface it live (it was previously only in the stop
result).

Two supporting touches in drawHeader:
  * auto-fit font size (shrinks 32->16px) so long capture filenames no
    longer overflow the canvas edges.
  * a redraw guard keyed on (text, recording, quality) -- the idle
    "X Hz · Y Hz" header was re-uploading its texture every frame even
    though it changes ~1/sec. (The recording header still redraws every
    frame because its ms clock genuinely changes; that's intended.)

Bump app.js?v=18. 31 tests still pass.

Co-Authored-By: turfptax-claude O4.8 <noreply@anthropic.com>
Add a "Reading capture quality" subsection + a note in the smoke-test
step 9, so a tester knows what the recording-header quality dot colors
mean (gray warming-up / green good / amber marginal / red poor) and what
"JOINTS DROPPING" indicates. Keeps the runbook in sync with the v1.16
in-VR quality gauge.

Co-Authored-By: turfptax-claude O4.8 <noreply@anthropic.com>
@turfptax

turfptax commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

Update: robustness + field-capture hardening (v1.11 → v1.16)

Picking up after the second-round real-headset testing, this batch focused on making the app safe to hand to a tester and run real field-capture sessions. All on feat/quest-vr-v1, pushed. Test suite is up from 23 to 31 passing.

Correctness / data integrity

  • v1.15 (the important one): fixed silent label misalignment. The WebXR client skipped joints whose pose was momentarily unavailable (hand partly out of camera FOV, common in real activities). That made quest_hand payloads variable-length and, worse, shifted every later joint into the wrong CSV column when a middle joint dropped, silently misaligning the ground-truth labels and producing ragged CSVs that break pandas.read_csv. Fixed two layers: the client now emits all joints in canonical order every frame with a valid flag (zero+identity for untracked), dropping the frame only when nothing is tracked; the server locks label width at the first label packet and pads/truncates every row so the CSV stays rectangular and consistent with the schema sidecar, counting + logging any mismatch. New tests/test_quest_recording.py proves rectangularity + schema agreement. Heads-up: any captures made before v1.15 during partial hand tracking may have misaligned/ragged label data -- worth spot-checking column counts on those CSVs.
  • v1.13: defensive fixes -- orphaned drag state on controller disconnect/pause (new cancelDrag), honest sessionend auto-stop logging (r.ok check), and a NaN-guard on corrupt localStorage panel layouts.

Robustness / UX for testing

  • v1.11: graceful XR pause/resume. Quest's boundary-redraw / universal-menu pause used to glitch the app. Now we detect visibilitychange, stop sending joint frames while paused (no corrupt timeline), and re-anchor panels on resume. Auto-stops an active recording on sessionend so the CSV closes cleanly.
  • v1.12: panel layout persistence (per-mode localStorage); RECENTER clears it.
  • v1.16: live capture-quality gauge. The recording header now shows a match-rate-colored dot (gray warming-up / green good / amber marginal / red poor) + live % + a JOINTS DROPPING warning, so the operator can judge data quality mid-capture without removing the headset.
  • v1.14: perf -- eliminated two per-frame canvas texture re-uploads (STOP button, faded status strip) that competed with hand tracking on Quest's mobile GPU during recording.

Tests + docs

  • tests/: now 31 passing (added lazy-writer, ingest synthesis, recording rectangularity, window/label_source defaults).
  • New docs/vr-testing-scenarios.md: bring-up order, 2-minute smoke test, six per-feature scenarios with acceptance bar.
  • docs/vr-setup.md: the actual Quest mkcert cert-install procedure (Horizon OS hides the standard Android cert UI; documented the AOSP-Settings-via-ADB workaround).
  • New sibling repo OpenMuscle-AR (discoverability anchor + future native-APK home) with a setup/troubleshooting wiki.
  • start-vr-https.bat added for the untethered/LAN path.

Two notes for review

  1. Co-author trailer: the earlier commits in this PR used Co-Authored-By: Claude Opus 4.7 (1M context). The newer commits (v1.13+) use the repo owner's preferred turfptax-claude O4.8 format. Not rewriting pushed history; flagging in case you want to normalize on squash-merge.
  2. Deferred (unchanged from before): wrist-relative training labels (TODO in _write_labels_schema), desktop comparator 3D-hand viewer for quest_hand captures.

turfptax and others added 4 commits June 7, 2026 16:49
Read-through (iteration 10) of the comparison/ghost-hand math found two
issues that bite exactly when the model is bad -- i.e. early in training,
which is most of the time during testing.

1. Shared curl auto-calibration. curlFromDistance auto-learns each
   finger's "fully extended" wrist->tip distance, but realCurls and
   predictedCurls fed the SAME fingerMaxExtended array. Real distances
   are physical (~0.10 m); a poorly-trained model's predicted distances
   can be wildly off-scale, and one bad predicted frame would pollute
   the shared max and distort the REAL bars too -- right when you need
   the real bars to stay trustworthy to judge the model. Split into
   separate fingerMaxExtendedReal / fingerMaxExtendedPred arrays; each
   curl source now takes its calibration array explicitly.

2. Ghost-hand degenerate quaternion. The wrist-frame alignment inverts
   the model's predicted wrist quaternion. A bad model can output a
   near-zero quaternion, and inverting that divides by ~zero -> NaN ->
   the whole ghost hand jumps to NaN positions. Guard qLenSq > 1e-6:
   below that, fall back to position-only alignment (still useful).
   Also normalize the predicted quat and use a dedicated inverse temp
   (_ghostPredQuatInv) instead of .clone().invert(), dropping a
   per-frame allocation in the XR loop.

No behavior change for a well-trained model; for a bad one the REAL
bars stay accurate and the ghost hand stays on-screen instead of
distorting/vanishing. Bump app.js?v=19. 31 tests still pass.

Co-Authored-By: turfptax-claude O4.8 <noreply@anthropic.com>
Final read-through (iteration 11) found a real leak. The /ws/quest and
/ws/live sockets auto-reconnect 1.5s after any `close` event so a
network blip doesn't kill the session. But sessionend DELIBERATELY
closed both sockets -- which fired their close handlers, which
reconnected them. Net effect: after you exit VR the page keeps a live
/ws/quest + /ws/live connection open forever, reconnecting on a loop,
and each enter/exit-VR cycle stacked ANOTHER reconnect loop on top.
Server-side this shows up as endless quest connect/disconnect log spam
after the headset comes off.

Fix: standard want-open flag. connectQuestWS/connectLiveWS set
_questWsWantOpen/_liveWsWantOpen = true; the close handler only
reconnects while the flag is set. New closeQuestWS/closeLiveWS clear
the flag first, then close + drop the reference, so an intentional
teardown stays torn down. sessionend now calls these helpers instead
of closing the sockets by hand. The error handlers are unchanged
(error -> close -> reconnect is still correct -- a real drop should
reconnect).

Bump app.js?v=20. 31 tests still pass.

Co-Authored-By: turfptax-claude O4.8 <noreply@anthropic.com>
Two finds from iteration 12's review of detectPinchAndToggle + the menu
button rendering.

1. Pinch repeat-toggle (functional bug). The captured-arm pinch-hold
   record toggle tried to "require release before next" by setting
   pinchStart = -Infinity after firing. But that makes `held` =
   now - (-Infinity) = Infinity, so the only thing stopping a re-fire
   was a 1.5s cooldown -- meaning a CONTINUOUS pinch-hold re-toggled
   recording every 1.5 seconds. Replace the -Infinity/cooldown hack
   with a pinchToggled flag that fires exactly once per hold and is
   only cleared by an actual release (a non-pinching frame). Dropped
   the now-unused uiState.lastPinchToggleAt.

2. Menu-button redraw waste (perf). updateButtonVisual calls
   drawMenuButton for all 6 buttons every frame, each doing a full
   canvas redraw + GPU texture upload -- but a button's look only
   changes on active/hover/flash transitions. That's 6 uploads/frame
   at 72fps for nothing. Add a per-button _lastDrawKey guard (same fix
   v1.14 applied to the STOP button + status strip; the menu buttons
   were missed). Flash still works: the flashing bool is time-derived
   so its true->false expiry flips the key and forces the clearing
   redraw.

Bump app.js?v=21. 31 tests still pass.

Co-Authored-By: turfptax-claude O4.8 <noreply@anthropic.com>
…ze it

Iteration 13 review: placeAnchors AR-scaling + slate timing checked out
correct, but onXRFrame had no error guard. A single throwing frame
(malformed /ws/live snapshot, an unexpected null pose, a bad predicted
vector) propagating out of the setAnimationLoop callback can stop the
loop and FREEZE the headset mid-session. On an untethered field-capture
rig that means losing a real-work recording that can't easily be redone
-- the worst failure mode for Tory's use case.

Wrap the frame body (renamed onXRFrameImpl) in onXRFrame: catch, rate-
limit a console.error (first error immediately, then <=1 per 2s, with a
running count so a persistent error is obvious without flooding at
72fps), and ALWAYS render so the view stays live and the user can still
reach EXIT / STOP. The error is logged, not silently swallowed -- it's
just no longer fatal to the session.

Bump app.js?v=22. 31 tests still pass (server unchanged).

Co-Authored-By: turfptax-claude O4.8 <noreply@anthropic.com>
@turfptax

turfptax commented Jun 7, 2026

Copy link
Copy Markdown
Contributor Author

Update 2: review-pass bug fixes (v1.17 → v1.20)

A systematic read-through of the WebXR client (which can't be unit-tested) surfaced several real bugs, all now fixed on feat/quest-vr-v1. Worth knowing before merge since a few would quietly affect data quality or a live session. Still 31 tests passing; the server stack was also re-verified end-to-end (record→stop produces a rectangular CSV with the quality fields).

  • v1.18 -- WebSocket reconnect leak. /ws/quest + /ws/live auto-reconnect on close, but sessionend deliberately closed them, which fired the reconnect handlers. Net effect: after exiting VR the page kept both sockets alive forever, reconnecting on a loop, and each enter/exit-VR cycle stacked another loop (server log spam). Fixed with a want-open flag + explicit close helpers.
  • v1.19 -- pinch repeat-toggle. The captured-arm pinch-hold record toggle used pinchStart = -Infinity to "require release", but that made the elapsed time Infinity, so a continuous hold re-toggled recording every 1.5s. Replaced with a release flag that fires exactly once per hold.
  • v1.17 -- REAL-vs-PRED hardening. (a) realCurls and predictedCurls shared one auto-calibration array, so a poorly-trained model's off-scale prediction polluted the max and distorted the real bars too -- split them. (b) A degenerate predicted wrist quaternion (common for a bad model) inverted to NaN and flung the ghost hand off-screen -- added a guard + fall back to position-only.
  • v1.20 -- frame-loop resilience. onXRFrame had no error guard; a single throwing frame could stop setAnimationLoop and freeze the headset mid-recording. Wrapped it so a bad frame logs (rate-limited) but always renders and keeps the session alive.
  • v1.14 + v1.19 -- perf. Eliminated redundant per-frame canvas texture uploads (status strip, STOP button, and the 6 menu buttons) that competed with hand tracking on Quest's mobile GPU during recording.

Net theme: the client was built fast across many iterations and had accumulated correctness + lifecycle bugs that only show up under real field-capture conditions (partial tracking, exit/re-enter, bad early models). These passes were specifically about making it safe to hand to a tester. Happy to walk through any of them.

turfptax and others added 2 commits June 9, 2026 07:17
Closes the deferred item the team flagged in the first review ("the
4-piston comparator UI will need a future 'if quest_hand, show 3D hand
viewer' mode"). When a quest_hand device is streaming, the Live stage's
LASK5 piston comparator (which shows zeros for a hand source) is swapped
for a live 3D hand skeleton -- the desktop counterpart to the VR app's
ghost hand.

New static/hand-viewer.js (ES module, exposes window.OMHandViewer):
  * Three.js scene (same CDN + version the VR client uses), 25-joint
    skeleton with bones, drawn from the device's flat `values` array.
  * Renders the REAL captured hand (green) and, when a quest-trained
    model is running (inference.piston_values has >= 25*7 floats), the
    PREDICTED hand (amber) overlaid.
  * Both hands transformed into WRIST-LOCAL space (subtract wrist pos,
    rotate by inverse wrist quat, with a degeneracy guard for bad-model
    quaternions) so the hand shows in a canonical palm orientation and
    REAL vs PRED is a direct shape comparison. (Same wrist-relative idea
    tracked for training in issue #2, used here just for viz.)
  * Auto-rotates; drag to orbit, double-click to resume auto-spin.
    Per-frame allocation avoided (reused scratch vec/quat).

Wiring:
  * index.html: importmap + module script + #hand-viewer container in
    the comparator.
  * app.js: renderHandViewer() in handleTick detects the quest_hand
    device, lazy-inits the viewer, toggles .comparator.hand-mode (CSS
    hides the pistons, shows the viewer), feeds real+pred flats, and
    relabels the GT meta line "Quest hand - N joints - X Hz".
  * styles.css: viewer panel + .hand-mode toggle + legend.

No server change -- the /ws/live snapshot already exposes a quest_hand
device's device_type + flat `values`, and inference.piston_values for
predictions.

Verified server-side end-to-end: all assets serve, the page references
the module, and a fake quest_hand device yields a snapshot with a
175-float `values` array that renderHandViewer consumes. The actual 3D
RENDER is unverified by me (no display in this environment) -- needs a
visual check on a real browser.

Co-Authored-By: turfptax-claude O4.8 <turfptax-claude@openmuscle.org>
The desktop Studio 3D hand viewer reads each device's flat `values` from
the /ws/live snapshot. Add a test asserting a quest_hand device surfaces
in _snapshot() as device_type 'quest_hand' with its full 25*7 flat joint
vector, so a future snapshot refactor can't silently break the viewer's
only data source. Suite now 32 passing.

Co-Authored-By: turfptax-claude O4.8 <turfptax-claude@openmuscle.org>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant