Merge pull request #1624 from ruvnet/claude/off-axis-sneaker-ruview-u398g0
Some checks failed
Bench Regression Guard / bench compile-verify (--no-run) (push) Has been cancelled
Continuous Integration / Code Quality & Security (push) Has been cancelled
Continuous Integration / Rust Workspace Tests (push) Has been cancelled
Continuous Integration / UI JavaScript Tests (push) Has been cancelled
Continuous Integration / Tests (push) Has been cancelled
Continuous Integration / Tests-1 (push) Has been cancelled
Continuous Integration / Tests-2 (push) Has been cancelled
wifi-densepose sensing-server → Docker Hub + ghcr.io / build · push · smoke-test (push) Has been cancelled
CSI data policy (ADR-299) / CSI data policy check (push) Has been cancelled
Fix-Marker Regression Guard / Verify fix markers (push) Has been cancelled
Security Scanning / Static Application Security Testing (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Container Security Scan (push) Has been cancelled
Security Scanning / Infrastructure Security Scan (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / License Compliance Scan (push) Has been cancelled
Security Scanning / Security Policy Compliance (push) Has been cancelled
three.js demos → GitHub Pages / build-and-deploy (push) Has been cancelled
Bench Regression Guard / bench fast-run (informational, non-gating) (push) Has been cancelled
Continuous Integration / Performance Tests (push) Has been cancelled
Continuous Integration / Docker Build & Test (push) Has been cancelled
Continuous Integration / API Documentation (push) Has been cancelled
Continuous Integration / Notify (push) Has been cancelled
Security Scanning / Security Report (push) Has been cancelled

ADR-324: off-axis-mode — clean-room Kooima projection in Rust/WASM + demo
This commit is contained in:
rUv 2026-08-16 21:02:12 -04:00 committed by GitHub
commit a76adc3c2f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 2522 additions and 1 deletions

View file

@ -1 +1 @@
{"sessionId":"d80c93c2-51b7-42e8-a0fc-dc47cff1200f","pid":45748,"acquiredAt":1779668018388}
{"sessionId":"905385c4-b13f-5091-96df-5752fb109cf5","pid":509,"procStart":"527","acquiredAt":1786922977672}

4
.gitignore vendored
View file

@ -307,3 +307,7 @@ v2/crates/wifi-densepose-sensing-server/data/
# file-specific so tracked datasets below v2/data remain visible.
/v2/data/session-secret
*.proptest-regressions
# ADR-324: wasm-bindgen output for ruview-offaxis is generated locally
# (see the crate README); never commit generated artifacts.
v2/crates/ruview-offaxis/pkg/

View file

@ -0,0 +1,276 @@
# ADR-324: off-axis-mode — RF-assisted head-coupled perspective for the three.js realtime demo
| Field | Value |
|-------|-------|
| **Status** | Proposed (core implemented — see §2.5) |
| **Date** | 2026-08-16 |
| **Deciders** | ruv |
| **Codename** | **off-axis-mode** |
| **Scope** | New `examples/three.js/demos/07-off-axis-window.html` (client-side only); no server changes |
| **Relates to** | ADR-019 (sensing-only UI), ADR-035 (live sensing UI accuracy), ADR-169 (adam-mode), ADR-170 (yoga-mode), ADR-282 (L0L5 evidence ladder), ADR-295 (source provenance), ADR-306 (spatial ontology), ADR-307 (persistent tracking), ADR-323 (pose refinement) |
| **Prior art** | [`icurtis1/off-axis-sneaker`](https://github.com/icurtis1/off-axis-sneaker) (reference only — see §2.1 licensing) |
| **Numbering note** | ADR-324 is the next free number in the authoring checkout (322 is unused, 323 is the latest on disk). Re-run the ADR index/collision check immediately before merge and rename if needed. |
| **Tracking issue** | none yet |
---
## 1. Context
### 1.1 The question this ADR answers
"Can we use [`icurtis1/off-axis-sneaker`](https://github.com/icurtis1/off-axis-sneaker)
with RuView?" The answer is: **yes for the technique, no for the code, and
only honestly for the RF part.** This ADR records the research behind each of
those three clauses and defines the integration that is actually defensible.
### 1.2 What off-axis-sneaker is
`off-axis-sneaker` is a React + TypeScript + Vite web app that renders a GLB
model (a sneaker) in three.js and creates a *head-coupled perspective*
("fish-tank VR" / "window into the screen") illusion:
- **Tracking input**: MediaPipe Face Mesh (468 facial landmarks) from a
webcam. Head (x, y) comes from the eye midpoint; depth (z) is proxied by
inter-ocular distance. An exponential moving average (default factor 0.3)
smooths jitter; sensitivity multipliers are `strengthX: 4`, `strengthY: 3`,
`strengthZ: 2`.
- **Projection**: `src/utils/offAxisCamera.ts` builds a **true asymmetric
(off-axis) frustum** — `makePerspective(left, right, top, bottom, near, far)`
with `left/right/top/bottom = (screenBound eyePosition) · (near /
viewerToScreenDistance)` — i.e. Kooima's generalized perspective projection,
plus a matching camera translation. Constants: `nearPlane 0.05`,
`farPlane 1000`, `worldScale 0.01` (cm → world units), `movementScale 1.5`.
- **Calibration**: a wizard captures physical screen width/height (cm),
typical viewing distance, and pixel density, stored locally, so eye position
is computed relative to the *physical* display.
The technique descends from Johnny Chung Lee's 2007 Wii-remote desktop VR
demo and the fish-tank VR literature (Ware, Arthur & Booth, CHI '93). The
projection math is Robert Kooima's "Generalized Perspective Projection"
(2008). Both are public, well-documented techniques independent of any one
implementation.
### 1.3 What the illusion physically requires
The head-coupled illusion is only convincing when the tracked eye position is
**accurate to roughly centimeters** and **low-latency**. The VR literature
puts comfortable motion-to-photon latency below ~20 ms for head-mounted
displays; desktop fish-tank VR tolerates more, but visible lag between head
motion and parallax response is exactly what breaks the "window" illusion.
`CLAIMED` (literature values; no RuView measurement exists for this demo yet).
### 1.4 What RuView RF sensing can actually supply today
This is where honesty is mandatory (repo rule: never present WiFi sensing as
camera-grade).
- **Field-peak position, not metric localization.**
`wifi-densepose-sensing-server/src/field_localize.rs` derives a position
from the strongest peak of the 20×20 `signal_field` carried on
`/ws/sensing` `sensing_update` frames. Its own module doc states the
caveat: the subcarrier→angle mapping is a *representation*; "a single ESP32
link cannot resolve a true (x, z) room position." The emitted position is
"strongest field peak in the room model," mapped with `X_SCALE 0.6`,
`Z_SCALE 0.5`, gated by `PEAK_THRESHOLD 0.35` — real, live, motion-tracking,
but **not a calibrated person fix** and nowhere near eye-position precision.
- **RF pose is 2-D, normalized, constant-confidence.** The committed Cog
(ADR-101, restated by ADR-323) emits 17 COCO keypoints as normalized 2-D
coordinates with a constant confidence and no per-joint uncertainty. A
"nose" keypoint exists (COCO index 0), but it is not a metric 3-D head fix.
- **Tracks are coarse and pseudonymous by design.** `ruview-track` (ADR-307)
maintains `person_N` tracks with container-level ("kitchen → hallway")
continuity, coarse non-reversible features, and asserts **no accuracy
number** — outputs default to evidence level `L1`.
- **Update cadence and latency are unmeasured for this purpose.** The demo
pipeline runs at ~30 Hz on the MediaPipe side (ADR-170), but no end-to-end
RF motion-to-photon latency has been measured. Any figure quoted for the RF
path must be tagged `MEASURED` with a reproducer before it appears in docs
or UI.
Conclusion of the capability match: **RF cannot drive a convincing fish-tank
illusion by itself today**, and this ADR does not claim it can. RF *can*
supply things a webcam cannot: camera-free presence, zone-level position,
person count, approach direction, and pseudonymous continuity — including
when the camera is off.
### 1.5 What this ADR is *not*
- Not a vendoring of `off-axis-sneaker` (see §2.1 — the repo has no license).
- Not a claim of camera-grade RF head tracking, at any tier.
- Not a backend change: no new server endpoints, no new auth surface, no
schema changes. Purely additive client-side HTML/JS, like ADR-169/170.
- Not a React/Vite/Tailwind adoption. The `examples/three.js/demos/*` are
dependency-light single-file HTML demos and stay that way.
## 2. Decision
### 2.1 Licensing: adopt the technique, not the code
`off-axis-sneaker` publishes **no license**. Under default copyright, its
source cannot be copied, vendored, or translated into this repository.
Decision:
1. **No code, assets, or models from `off-axis-sneaker` enter this repo.**
The GLB sneaker model is likewise unlicensed for reuse; demos use assets
already present in `examples/`.
2. The off-axis projection is implemented **clean-room from the public
sources**: Kooima's "Generalized Perspective Projection" (2008) — the
`pa/pb/pc` screen-corner formulation — and three.js's documented
`PerspectiveCamera.projectionMatrix` override path. The repository is cited
as prior art in this ADR only.
3. If upstream later adds a permissive license, revisiting reuse requires a
new ADR note, not silent copying.
### 2.2 Tiered integration — each tier labeled by what it really is
**Tier A (ships first): webcam-fine + RF-context hybrid.**
`07-off-axis-window.html` uses MediaPipe Face Landmarker (already the pattern
in demo 05) for fine head tracking and the Kooima frustum for rendering —
functionally what off-axis-sneaker does, reimplemented. RuView RF adds the
camera-free layer around it:
- **Presence-gated camera**: the webcam pipeline starts only when the RF
presence signal (`/ws/sensing` `sensing_update`) says someone is in the
zone, and stops after a configurable RF-vacancy timeout. The privacy
posture improves: the camera is *off* until physics says there is someone
to track.
- **Multi-person arbitration**: when RF reports more than one person, the HUD
says so and the demo holds the last stable perspective instead of jumping
between faces.
- **Pre-warm**: RF approach direction (field-peak trajectory) warms up
MediaPipe and the scene before the person sits down.
**Tier B (demo mode, prominently labeled): RF-only coarse parallax.**
A toggle drives the off-axis eye position from RF alone — field peak (x, z)
plus the pose nose keypoint when present — through a one-euro filter, a
deadband, and a hard gain clamp. The HUD labels it **"RF coarse body
parallax — not head tracking"** and shows the live evidence level (`L1`
heuristic unless a certificate says otherwise, per ADR-282/ADR-318). The
expected experience is a slow, body-scale parallax sway — a demonstrative
"the room model moves because *you* moved, with no camera" — not a stable
fish-tank illusion. The demo must never present Tier B as equivalent to
Tier A.
**Tier C (future, explicitly gated, not promised): metric RF head position.**
Only a calibrated multistatic deployment (ADR-297 multi-node semantics,
ADR-311 fusion, ADR-303 ground-truth sync) with an evidence-engine ledger
entry (ADR-304) and a capability certificate (ADR-318) could justify feeding
RF positions into the fine path. No current data supports this; Tier C exists
in this ADR solely so nobody ships it informally without those gates.
### 2.3 Implementation surface
- New file `examples/three.js/demos/07-off-axis-window.html` (07, not 06 —
ADR-170 reserves `06-yoga-mode.html`). Single-file demo following the 0105
conventions: same CSS custom properties, same HUD/helper-panel pattern,
served from the existing static demo server
(`http://127.0.0.1:8765/examples/three.js/demos/…`).
- A small clean-room module (inline `<script type="module">` or
`examples/three.js/lib/off-axis-camera.js` if shared later) that, given
screen corners `pa, pb, pc` (from calibration) and eye point `pe`, sets
`camera.projectionMatrix` via the Kooima formulation each frame.
- Data inputs are the **existing** streams only: `/ws/sensing`
(`sensing_update``signal_field` → field peak, using the same
`X_SCALE`/`Z_SCALE`/`PEAK_THRESHOLD` mapping as `field_localize.rs`) and,
when available, `/api/v1/stream/pose` for the nose keypoint. WebSocket
access uses the existing ticket flow (`ws_ticket.rs` / `bearer_auth.rs`);
no endpoint is exempted or added.
- Calibration mirrors the sneaker app's concept without its code: screen
width/height in cm, viewing distance, persisted in `localStorage` under a
demo-scoped key. No calibration data leaves the browser.
- Provenance discipline: if the demo is pointed at a synthetic or replayed
source, the ADR-295 provenance state must surface in the HUD exactly as the
Observatory does — synthetic can never present as live.
### 2.4 Honesty and evidence rules binding this feature
1. Every user-visible latency, accuracy, or precision statement in the demo,
README, or docs carries a `MEASURED` (with reproducer), `CLAIMED`, or
`SYNTHETIC` tag. This ADR itself contains no `MEASURED` claims.
2. Tier B is labeled coarse body parallax in the HUD at all times; there is
no configuration that hides the label while RF drives the camera.
3. No PCK or pose-accuracy number may be quoted for the RF path without the
mean-pose baseline and a leakage-free held-out split (repo rule).
4. The webcam feed never leaves the browser; no frames, landmarks, or
embeddings are sent to the server. RF data continues to obey ADR-307's
privacy invariants (pseudonymous, coarse, rotatable).
### 2.5 Implementation status (2026-08-16 amendment)
The projection core shipped as a **Rust crate compiled to WASM** rather than
the inline JS module §2.3 anticipated — a strict upgrade with the same
surface: `v2/crates/ruview-offaxis` (dependency-free native core; wasm-bindgen
only on wasm32) implements the Kooima projection, the one-euro filter, the
field-peak mapping (constants mirroring `field_localize.rs`), and the Tier B
coarse-parallax stage with its deadband/gain/clamp bounds enforced in Rust.
`examples/three.js/demos/07-off-axis-window.html` consumes the wasm-bindgen
output (built locally per the crate README; generated artifacts are not
committed). Validation and `MEASURED` benchmarks live in the crate README.
The demo ships with a `SYNTHETIC`-labeled mouse simulator and the labeled
Tier B RF mode; a Tier A fine tracker connects through
`OffAxisCamera.update_normalized` and remains host-provided.
## 3. Options considered
| Option | Verdict | Why |
|---|---|---|
| Vendor `off-axis-sneaker` (or fork + point at RuView) | **Rejected** | No license ⇒ no redistribution rights. Also React/Vite stack conflicts with the repo's single-file demo convention. |
| Clean-room Kooima off-axis demo, webcam-fine + RF-context (Tier A/B) | **Chosen** | Legally clean, matches demo conventions, uses RF for what it is actually good at, and demonstrates camera-free presence value honestly. |
| RF-only head-coupled perspective as the headline | **Rejected** | Over-claim. Single-link field peaks are a representation, not metric localization (`field_localize.rs` caveat); shipping this as "head tracking" violates the camera-grade rule. Survives only as the labeled Tier B toggle. |
| Wait for multistatic metric localization (Tier C) before any demo | **Rejected** | Blocks a useful, honest demo on a phase-2/3 program (ADR-303/311/318) with no delivery date. The gates are recorded instead. |
| Add a dedicated server endpoint for head position | **Rejected** | Unnecessary — existing `/ws/sensing` + `/api/v1/stream/pose` suffice; a new endpoint would expand the auth surface for no capability gain. |
## 4. Consequences
**Improves**
- A publicly legible demo of RF sensing's actual differentiator: the scene
knows you are there, where you roughly are, and how many of you there are —
before and without any camera.
- Privacy posture of the head-tracking demo class: camera duty-cycle is
bounded by RF presence instead of always-on.
- Canonical, licensed off-axis projection code the Observatory or future UI
can reuse.
**Costs / risks**
- Tier B can underwhelm viewers primed by webcam demos; the mitigation is the
labeling and the side-by-side toggle, not inflated gain.
- MediaPipe CDN dependency (same as demo 05) remains a network-availability
risk for Tier A; the demo must degrade to Tier B with a visible notice.
- Screen-calibration friction (cm measurements) may deter casual users; a
"skip calibration (approximate)" path with degraded-accuracy labeling is
acceptable.
- Upstream `off-axis-sneaker` may change or add a license; tracking that is
manual.
**Follow-ups (not in this ADR's scope)**
- Measure end-to-end RF motion-to-parallax latency with a reproducer and
publish it `MEASURED`.
- If/when ADR-303/311 land, evaluate Tier C against the ADR-318 certificate
gate.
- Consider promoting the off-axis camera module into the Observatory 3D view.
## 5. Validation
- Demo checklist (manual, per ADR-169/170 practice): loads from the static
server; Tier A activates only on RF presence; Tier B label visible whenever
RF drives the camera; provenance badge correct against a synthetic source;
no network requests carrying webcam-derived data (verified in devtools).
- `rg` gate before merge: no file under `examples/` contains code originating
from `icurtis1/off-axis-sneaker`.
- No workspace, harness, or firmware validation rows are triggered — the
change is a static HTML demo plus this document.
## 6. References
- [`icurtis1/off-axis-sneaker`](https://github.com/icurtis1/off-axis-sneaker) — prior-art reference (unlicensed; technique only)
- Robert Kooima, *Generalized Perspective Projection*, 2008 — off-axis frustum math
- Johnny Chung Lee, *Head Tracking for Desktop VR Displays using the Wii Remote*, 2007
- Ware, Arthur & Booth, *Fish Tank Virtual Reality*, CHI '93 — head coupling vs. stereo
- `v2/crates/wifi-densepose-sensing-server/src/field_localize.rs` — field-peak honesty caveat and coordinate mapping
- `v2/crates/wifi-densepose-sensing-server/src/ws_ticket.rs`, `bearer_auth.rs` — WebSocket auth pattern
- `v2/crates/ruview-track/src/lib.rs` — ADR-307 privacy invariants and evidence discipline
- ADR-169, ADR-170 — demo-scoped ADR pattern for `examples/three.js/demos/`
- ADR-282 — L0L5 evidence ladder; ADR-295 — provenance state machine

View file

@ -108,6 +108,7 @@ Statuses: **Proposed** (under discussion), **Accepted** (approved and/or impleme
| [ADR-115](ADR-115-home-assistant-integration.md) | Home Assistant integration via MQTT auto-discovery + Matter bridge (HA-DISCO + HA-FABRIC + HA-MIND) | Accepted (MQTT track) / Proposed (Matter SDK P8b) |
| [ADR-169](ADR-169-adam-mode-light-theme.md) | adam-mode — light theme toggle for the three.js realtime demo | Proposed |
| [ADR-170](ADR-170-yoga-mode-pose-system.md) | yoga-mode — yoga pose detection, classification, and scoring for the three.js realtime demo | Proposed |
| [ADR-324](ADR-324-off-axis-head-coupled-perspective-demo.md) | off-axis-mode — RF-assisted head-coupled perspective demo (clean-room Kooima projection; RF presence gating) | Proposed |
### Architecture and infrastructure

View file

@ -0,0 +1,378 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>RuView · ADR-324 · off-axis window (ruview-offaxis WASM)</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='7' y='9' width='18' height='14' rx='2' fill='none' stroke='%23e8a634' stroke-width='2'/><circle cx='16' cy='16' r='3' fill='%23e8a634'/></svg>">
<style>
:root {
--bg: #0a0a0a;
--bg-panel: rgba(0, 0, 0, 0.88);
--amber: #e8a634;
--amber-dim: #4a3a1a;
--amber-hot: #ffc04d;
--grid-major: #444444;
--grid-minor: #222222;
--green: #4f4;
--blue: #4cf;
--red: #f66;
--text-mute: #888;
--border: #2a2a2a;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--amber);
font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
overflow: hidden;
-webkit-font-smoothing: antialiased;
}
canvas { display: block; }
#info {
position: absolute;
top: 16px;
left: 16px;
padding: 14px 16px;
background: var(--bg-panel);
border: 1px solid var(--amber);
border-radius: 8px;
min-width: 280px;
max-width: 360px;
font-size: 12px;
line-height: 1.55;
z-index: 10;
backdrop-filter: blur(6px);
box-shadow: 0 4px 24px rgba(232, 166, 52, 0.08);
}
#info h1 { margin: 0 0 2px 0; font-size: 14px; letter-spacing: 0.5px; }
#info .sub { font-size: 11px; color: var(--text-mute); margin-bottom: 10px; }
#info .row { display: flex; justify-content: space-between; gap: 12px; margin: 2px 0; }
#info .row .k { color: var(--text-mute); }
#info .row .v { color: var(--amber); font-variant-numeric: tabular-nums; }
#info .row .v.live { color: var(--green); }
#info .row .v.warn { color: var(--red); }
/* The mandatory ADR-324 §2.4 mode label: always visible while RF
drives the camera; there is no configuration that hides it. */
#mode-label {
position: absolute;
top: 16px;
right: 16px;
padding: 8px 14px;
background: var(--bg-panel);
border: 1px solid var(--blue);
border-radius: 8px;
color: var(--blue);
font-size: 12px;
z-index: 11;
}
#mode-label.rf { border-color: var(--red); color: var(--red); }
#controls {
position: absolute;
bottom: 16px;
left: 16px;
padding: 12px 16px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 12px;
z-index: 10;
max-width: 340px;
}
#controls h2 { margin: 0 0 8px 0; font-size: 12px; color: var(--text-mute); }
#controls label { display: flex; justify-content: space-between; gap: 8px; margin: 4px 0; align-items: center; }
#controls input[type="number"] {
width: 70px; background: #111; border: 1px solid var(--border);
color: var(--amber); font-family: inherit; font-size: 12px; padding: 2px 6px; border-radius: 4px;
}
#controls input[type="text"] {
width: 190px; background: #111; border: 1px solid var(--border);
color: var(--amber); font-family: inherit; font-size: 11px; padding: 2px 6px; border-radius: 4px;
}
#controls button {
background: var(--amber-dim); border: 1px solid var(--amber); color: var(--amber-hot);
font-family: inherit; font-size: 12px; padding: 4px 10px; border-radius: 5px; cursor: pointer; margin-top: 6px;
}
#controls button:hover { background: var(--amber); color: #000; }
#wasm-missing {
position: absolute;
inset: 0;
display: none;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.92);
z-index: 50;
}
#wasm-missing .box {
max-width: 560px; border: 1px solid var(--amber); border-radius: 10px;
background: var(--bg-panel); padding: 22px 26px; font-size: 13px; line-height: 1.7;
}
#wasm-missing code { color: var(--amber-hot); background: #151005; padding: 1px 5px; border-radius: 4px; display: block; margin: 4px 0; }
</style>
</head>
<body>
<div id="info">
<h1>OFF-AXIS WINDOW</h1>
<div class="sub">ADR-324 · clean-room Kooima projection · ruview-offaxis (Rust→WASM)</div>
<div class="row"><span class="k">engine</span><span class="v" id="hud-engine">loading…</span></div>
<div class="row"><span class="k">input</span><span class="v" id="hud-input">mouse (SYNTHETIC)</span></div>
<div class="row"><span class="k">eye x/y/z (m)</span><span class="v" id="hud-eye"></span></div>
<div class="row"><span class="k">render fps</span><span class="v live" id="hud-fps"></span></div>
<div class="row"><span class="k">rf socket</span><span class="v" id="hud-ws">not connected</span></div>
<div class="row"><span class="k">rf peak</span><span class="v" id="hud-peak"></span></div>
<div class="row" style="margin-top:8px"><span class="k" style="font-size:10px">
keys: <b>M</b> mouse · <b>R</b> RF Tier B · wheel = distance</span></div>
</div>
<div id="mode-label">MOUSE SIM — SYNTHETIC INPUT</div>
<div id="controls">
<h2>PHYSICAL CALIBRATION (stored locally)</h2>
<label>screen width (cm) <input id="cal-w" type="number" step="0.5" value="60"></label>
<label>screen height (cm) <input id="cal-h" type="number" step="0.5" value="34"></label>
<label>viewing distance (cm) <input id="cal-d" type="number" step="1" value="65"></label>
<h2 style="margin-top:10px">RF SOURCE (Tier B)</h2>
<label>ws url <input id="ws-url" type="text" value="ws://127.0.0.1:8080/ws/sensing"></label>
<button id="apply">apply calibration</button>
</div>
<div id="wasm-missing">
<div class="box">
<b>ruview-offaxis WASM module not found.</b><br><br>
This demo loads the crate's wasm-bindgen output from
<code>v2/crates/ruview-offaxis/pkg/</code>. Generated artifacts are not
committed (repo rule); build them once:
<code>cd v2 && cargo build -p ruview-offaxis --target wasm32-unknown-unknown --release</code>
<code>wasm-bindgen --target web --out-dir crates/ruview-offaxis/pkg \
target/wasm32-unknown-unknown/release/ruview_offaxis.wasm</code>
(install the CLI with <code>cargo install wasm-bindgen-cli --version 0.2.114</code>)<br>
then reload. Full steps: <code>v2/crates/ruview-offaxis/README.md</code>.
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script type="module">
// ADR-324 demo: the projection math lives in Rust/WASM (ruview-offaxis).
// This file only wires inputs (mouse SYNTHETIC sim, or /ws/sensing
// signal_field for the labeled Tier B coarse-parallax mode) into the
// WASM camera and copies its matrices onto a three.js camera.
const $ = (id) => document.getElementById(id);
// ---- Load the WASM module (not committed; user builds it once). ----
let wasm;
try {
wasm = await import('../../../v2/crates/ruview-offaxis/pkg/ruview_offaxis.js');
await wasm.default();
$('hud-engine').textContent = 'ruview-offaxis wasm';
} catch (e) {
console.error('ruview-offaxis pkg not found', e);
$('wasm-missing').style.display = 'flex';
throw e;
}
const { OffAxisCamera, RfParallax } = wasm;
// ---- Calibration (persisted locally; never leaves the browser). ----
const CAL_KEY = 'ruview-offaxis-demo-cal';
const saved = JSON.parse(localStorage.getItem(CAL_KEY) || 'null');
if (saved) { $('cal-w').value = saved.w; $('cal-h').value = saved.h; $('cal-d').value = saved.d; }
const cal = () => ({ w: +$('cal-w').value || 60, h: +$('cal-h').value || 34, d: +$('cal-d').value || 65 });
let cam = new OffAxisCamera(cal().w, cal().h, cal().d, 0.05, 100.0);
cam.set_filter(1.2, 0.4); // interactive: light smoothing, quick catch-up
let rf = new RfParallax(cal().d / 100);
$('apply').onclick = () => {
const c = cal();
localStorage.setItem(CAL_KEY, JSON.stringify(c));
cam = new OffAxisCamera(c.w, c.h, c.d, 0.05, 100.0);
cam.set_filter(1.2, 0.4);
rf = new RfParallax(c.d / 100);
buildRoom(); // room proportions follow the physical screen
};
// ---- three.js scene: a room extending behind the screen plane. ----
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x0a0a0a);
// Camera is fully driven by the WASM matrices.
const camera = new THREE.PerspectiveCamera();
camera.matrixAutoUpdate = false;
let room = new THREE.Group();
function buildRoom() {
scene.remove(room);
room = new THREE.Group();
const c = cal();
const W = c.w / 100, H = c.h / 100, DEPTH = Math.max(W, 0.8) * 2.0;
// Wireframe box behind the screen: the classic "window" cue.
const boxGeo = new THREE.BoxGeometry(W, H, DEPTH);
const edges = new THREE.EdgesGeometry(boxGeo);
const box = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xe8a634 }));
box.position.z = -DEPTH / 2; // screen plane is z = 0
room.add(box);
// Depth rails: rows of columns receding into the box.
const colMat = new THREE.MeshStandardMaterial({ color: 0x4a3a1a, emissive: 0x2a1f08 });
for (let i = 1; i <= 6; i++) {
for (const sx of [-1, 1]) {
const col = new THREE.Mesh(new THREE.CylinderGeometry(0.008, 0.008, H * 0.9, 12), colMat);
col.position.set(sx * W * 0.42, 0, -DEPTH * i / 7);
room.add(col);
}
}
// Floating objects at staggered depths (parallax targets).
const knotMat = new THREE.MeshStandardMaterial({ color: 0xe8a634, metalness: 0.4, roughness: 0.35 });
const knot = new THREE.Mesh(new THREE.TorusKnotGeometry(H * 0.18, H * 0.05, 120, 16), knotMat);
knot.position.set(0, 0, -DEPTH * 0.45);
knot.name = 'knot';
room.add(knot);
const orb = new THREE.Mesh(
new THREE.IcosahedronGeometry(H * 0.08, 1),
new THREE.MeshStandardMaterial({ color: 0x4cf0ff, emissive: 0x0a3540 })
);
orb.position.set(-W * 0.22, H * 0.18, -DEPTH * 0.18);
room.add(orb);
// One object slightly IN FRONT of the screen plane — pops "out".
const pop = new THREE.Mesh(
new THREE.OctahedronGeometry(H * 0.05),
new THREE.MeshStandardMaterial({ color: 0xffc04d, emissive: 0x604010 })
);
pop.position.set(W * 0.28, -H * 0.2, 0.06);
room.add(pop);
room.add(new THREE.AmbientLight(0xffffff, 0.35));
const key = new THREE.PointLight(0xffe0a0, 1.0);
key.position.set(0.3, 0.4, 0.5);
room.add(key);
scene.add(room);
}
buildRoom();
// ---- Input modes. ----
// 'mouse' — SYNTHETIC eye simulator (always available, no hardware).
// 'rf' — Tier B: /ws/sensing signal_field → RfParallax.
// Labeled coarse body parallax, NOT head tracking (ADR-324 §2.4).
let mode = 'mouse';
const modeLabel = $('mode-label');
function setMode(m) {
mode = m;
if (m === 'rf') {
modeLabel.textContent = 'RF COARSE BODY PARALLAX — NOT HEAD TRACKING';
modeLabel.classList.add('rf');
$('hud-input').textContent = 'rf field peak (Tier B)';
connectWs();
} else {
modeLabel.textContent = 'MOUSE SIM — SYNTHETIC INPUT';
modeLabel.classList.remove('rf');
$('hud-input').textContent = 'mouse (SYNTHETIC)';
}
}
window.addEventListener('keydown', (e) => {
if (e.key === 'm' || e.key === 'M') setMode('mouse');
if (e.key === 'r' || e.key === 'R') setMode('rf');
});
// Mouse sim: pointer position maps to a ±0.3 m eye excursion;
// wheel adjusts distance.
let mouseEye = { x: 0, y: 0, d: cal().d / 100 };
window.addEventListener('pointermove', (e) => {
mouseEye.x = (e.clientX / window.innerWidth - 0.5) * 0.6;
mouseEye.y = (0.5 - e.clientY / window.innerHeight) * 0.4;
});
window.addEventListener('wheel', (e) => {
mouseEye.d = Math.min(2.5, Math.max(0.2, mouseEye.d + e.deltaY * 0.0005));
}, { passive: true });
// ---- RF Tier B input: /ws/sensing sensing_update.signal_field. ----
let ws = null;
function connectWs() {
if (ws) { try { ws.close(); } catch (_) {} }
const url = $('ws-url').value;
$('hud-ws').textContent = 'connecting…';
try { ws = new WebSocket(url); } catch (e) {
$('hud-ws').textContent = 'invalid url'; return;
}
ws.onopen = () => { $('hud-ws').textContent = 'connected'; };
ws.onclose = () => { $('hud-ws').textContent = 'closed'; };
ws.onerror = () => { $('hud-ws').textContent = 'error (server up? ticket needed?)'; };
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
const field = msg.signal_field || (msg.data && msg.data.signal_field);
if (!field || !field.values) return;
const nx = field.grid_size || field.nx || 20;
const nz = field.grid_size || field.nz || 20;
const values = Float32Array.from(field.values);
const found = rf.update(values, nx, nz, performance.now() / 1000);
$('hud-peak').textContent = found
? `value ${rf.peak_value().toFixed(2)} (≥ 0.35 gate)`
: 'below 0.35 gate — holding';
// Provenance surfaced verbatim (ADR-295: synthetic never
// presents as live).
if (msg.provenance || msg.source) {
$('hud-ws').textContent = `connected · src: ${msg.provenance || msg.source}`;
}
} catch (_) { /* non-JSON frame */ }
};
}
// ---- Render loop: one WASM call, three matrix copies, render. ----
const tmp = new THREE.Matrix4();
let frames = 0, lastFps = performance.now();
function animate() {
requestAnimationFrame(animate);
const t = performance.now() / 1000;
if (mode === 'mouse') {
cam.update_eye(mouseEye.x, mouseEye.y, mouseEye.d, t);
} else {
const e = rf.eye(); // bounded coarse-parallax eye (metres)
cam.update_eye(e[0], e[1], e[2], t);
}
// Copy the Kooima matrices onto the three.js camera.
camera.projectionMatrix.fromArray(cam.projection());
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
tmp.fromArray(cam.view());
camera.matrixWorld.copy(tmp).invert(); // world = inverse(view)
camera.matrixWorldInverse.copy(tmp);
const knot = room.getObjectByName('knot');
if (knot) { knot.rotation.y += 0.003; knot.rotation.x += 0.001; }
renderer.render(scene, camera);
const eye = cam.eye();
$('hud-eye').textContent = `${eye[0].toFixed(3)} / ${eye[1].toFixed(3)} / ${eye[2].toFixed(3)}`;
frames++;
const now = performance.now();
if (now - lastFps > 1000) {
$('hud-fps').textContent = String(frames);
frames = 0; lastFps = now;
}
}
animate();
window.addEventListener('resize', () => {
renderer.setSize(window.innerWidth, window.innerHeight);
// NOTE: no camera.aspect update — the frustum is fully determined
// by the physical screen calibration, not the browser viewport.
});
</script>
</body>
</html>

8
v2/Cargo.lock generated
View file

@ -9770,6 +9770,14 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "ruview-offaxis"
version = "0.3.1"
dependencies = [
"criterion",
"wasm-bindgen",
]
[[package]]
name = "ruview-ontology"
version = "0.3.1"

View file

@ -118,6 +118,9 @@ members = [
"crates/ruview-counterfactual",# ADR-313 counterfactual spatial inference
"crates/ruview-infogain", # ADR-314 information-gain scheduler
"crates/ruview-active", # ADR-309 active sensing control
# ADR-324 — clean-room Kooima off-axis (head-coupled perspective) projection.
# Dependency-free native core; wasm-bindgen surface only on wasm32.
"crates/ruview-offaxis",
]
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
# excluded from workspace to avoid breaking `cargo test --workspace`.

View file

@ -0,0 +1,26 @@
[package]
name = "ruview-offaxis"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = "Clean-room generalized off-axis (head-coupled) perspective projection for RuView demos (ADR-324)"
readme = "README.md"
[lib]
# cdylib for the wasm32 build (ADR-324 demo surface); rlib for native reuse.
crate-type = ["cdylib", "rlib"]
# The native core is dependency-free by design (ADR-324 §2.3): the wasm-bindgen
# surface exists only when compiling for wasm32, so the workspace test gate
# (`cargo test --workspace --no-default-features`) never pulls JS-interop deps.
[target.'cfg(target_arch = "wasm32")'.dependencies]
wasm-bindgen = "0.2"
[dev-dependencies]
criterion = { workspace = true }
[[bench]]
name = "offaxis"
harness = false

View file

@ -0,0 +1,164 @@
# ruview-offaxis
Clean-room **off-axis (head-coupled) perspective projection** in Rust, with a
wasm-bindgen surface for browser demos. Implements ADR-324.
The screen becomes a window: given the physical screen's corners and the
viewer's eye position, the crate produces the asymmetric frustum and
screen-aligned view matrix that keep the screen plane fixed while everything
behind and in front of it moves with true parallax.
## Clean-room statement
ADR-324 records that the prior-art repository (`icurtis1/off-axis-sneaker`)
publishes **no license**. No code, assets, or derived text from it appear in
this crate. The implementation follows the published math only:
- Robert Kooima, *Generalized Perspective Projection* (2008) — the
`pa`/`pb`/`pc` screen-corner frustum formulation.
- Casiez, Roussel & Vogel, *1€ Filter* (CHI 2012) — adaptive tracking-noise
smoothing.
## What's in the crate
| Module | Contents |
|---|---|
| `projection` | `Screen` (3 corners, any orientation), `off_axis()` → asymmetric `projection` + screen-aligned `view` matrix (column-major `f64`, the three.js `Matrix4.elements` layout). All failure modes are typed errors — never NaN matrices. |
| `filter` | `OneEuro` / `OneEuro3` one-euro filter. Timestamps are injected; the crate never reads a clock. |
| `rf` | `field_peak()` — strongest-cell extraction for `/ws/sensing` `signal_field` grids, mirroring the sensing server's `field_localize.rs` constants (`X_SCALE 0.6`, `Z_SCALE 0.5`, `PEAK_THRESHOLD 0.35`). `CoarseParallax` — the **Tier B** stage: deadband + gain + hard clamp + one-euro. `ScreenCalibration` — physical screen (cm) + normalized-head → metric eye mapping (**Tier A** input hook). |
| `wasm` (wasm32 only) | `OffAxisCamera` and `RfParallax` wasm-bindgen classes. |
The native core is **dependency-free**; wasm-bindgen is pulled only when
compiling for `wasm32`.
## Honesty contract (repo rule — read before demoing)
- A single-link CSI field peak is a *representation of field energy*, *not*
metric localization and *never* a head position (see the caveat in
`wifi-densepose-sensing-server/src/field_localize.rs`). The Tier B path is
therefore **coarse body parallax by construction**: deadbanded,
gain-limited, hard-clamped. Do not present it as head tracking; the demo
labels it on screen at all times.
- Numeric defaults (gains, deadbands, filter cutoffs) are interaction-design
choices — `CLAIMED`, not measured performance.
- The benchmark numbers below are `MEASURED` with the stated reproducer on
the stated machine; re-run locally before relying on them.
## Native quick start
```rust
use ruview_offaxis::{off_axis, Screen, Vec3};
// 60 cm × 34 cm screen centered at the origin; eye 65 cm out, 10 cm right.
let screen = Screen::centered(0.60, 0.34)?;
let oa = off_axis(&screen, Vec3::new(0.10, 0.0, 0.65), 0.05, 100.0)?;
let mvp: [f64; 16] = oa.view_projection(); // column-major, GL/three.js layout
# Ok::<(), ruview_offaxis::OffAxisError>(())
```
Key invariant (unit-tested for a grid of eye positions and for tilted
screens): the physical screen corners always project exactly to the NDC
corners — `pa→(1,1)`, `pb→(1,1)`, `pc→(1,1)`, `pd→(1,1)` — and points on
the screen plane are eye-invariant. That is the mathematical definition of
"the screen is a window".
## Building the WASM package
Generated artifacts are not committed (repo rule). Build once:
```bash
cd v2
rustup target add wasm32-unknown-unknown
cargo build -p ruview-offaxis --target wasm32-unknown-unknown --release
# Install the matching CLI once: cargo install wasm-bindgen-cli --version 0.2.114
wasm-bindgen --target web --out-dir crates/ruview-offaxis/pkg \
target/wasm32-unknown-unknown/release/ruview_offaxis.wasm
```
Output: `pkg/ruview_offaxis.js` + `pkg/ruview_offaxis_bg.wasm`
(≈54 KB wasm, `MEASURED` for this crate at wasm-bindgen 0.2.114; `wasm-opt -Oz`
can shrink it further if you have binaryen). The demo at
`examples/three.js/demos/07-off-axis-window.html` loads this path directly —
build, then open the demo. A Node smoke test of the same flow lives in the
PR's validation notes.
## three.js integration (the whole wiring)
```js
import init, { OffAxisCamera, RfParallax } from './pkg/ruview_offaxis.js';
await init();
// Physical calibration in cm — measure your actual screen.
const cam = new OffAxisCamera(60, 34, 65, 0.05, 100.0);
cam.set_filter(1.2, 0.4); // one-euro: min_cutoff Hz, beta
const camera = new THREE.PerspectiveCamera();
camera.matrixAutoUpdate = false; // WASM owns every matrix
const view = new THREE.Matrix4();
function onFrame(eyeX, eyeY, eyeZ) { // metres, screen space
cam.update_eye(eyeX, eyeY, eyeZ, performance.now() / 1000);
camera.projectionMatrix.fromArray(cam.projection());
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
view.fromArray(cam.view());
camera.matrixWorld.copy(view).invert();
camera.matrixWorldInverse.copy(view);
}
```
Scene convention: the screen plane is `z = 0`; content behind the screen has
`z < 0`; content with `z > 0` "pops out". Do **not** update `camera.aspect`
on resize — the frustum is determined by the physical screen, not the
viewport.
### Input tiers (ADR-324)
- **Tier A (fine tracker)** — feed any head tracker through
`cam.update_normalized(nx, ny, depthScale, lateralRangeM, t)`; `nx`/`ny`
are normalized image coordinates from whatever fine tracker the host runs
(which stays entirely in the browser). RF adds presence gating around it.
- **Tier B (RF only, labeled)**`RfParallax.update(Float32Array, nx, nz, t)`
with `/ws/sensing` `signal_field` values, then `rf.eye()`
`cam.update_eye(...)`. Keep the on-screen "coarse body parallax — not head
tracking" label; the clamps in the Rust core bound the excursion but the
label is what keeps the demo honest.
## Benchmarks
`MEASURED` — reproducer: `cd v2 && cargo bench -p ruview-offaxis`.
Environment for the numbers below: Linux x86_64 container (shared/virtualized
CPU), rustc 1.89.0, criterion 0.5, 2026-08-16. Treat them as order-of-
magnitude; re-run on your hardware.
| Benchmark | Time (median) |
|---|---|
| `off_axis_projection` (frustum + view build) | ~76 ns |
| `view_projection` combined (4×4 multiply) | ~27 ns |
| `one_euro3_step` (3-axis filter step) | ~60 ns |
| `field_peak_20x20` (live grid size) | ~488 ns |
| `field_peak_100x100` | ~12.3 µs |
| `tier_b_full_frame_20x20` (scan → parallax → projection) | ~598 ns |
The full Tier B per-frame path costs well under a microsecond — under 0.01%
of a 60 Hz frame budget. The argmax scan was the only hot spot found; it was
rewritten branch-light for a measured 18% (20×20) / 33% (100×100) before
these numbers were taken. End-to-end *motion-to-photon* latency (RF capture →
render) has **not** been measured and is dominated by the sensing pipeline,
not this crate; no figure is claimed.
## Validation
```bash
cd v2
cargo test -p ruview-offaxis # 23 unit tests + doctest
cargo clippy -p ruview-offaxis --all-targets # zero warnings
cargo build -p ruview-offaxis --target wasm32-unknown-unknown --release
```
## References
- ADR-324 — decision record, tier definitions, licensing analysis
- `docs/adr/ADR-282-*` — L0L5 evidence ladder (labels used above)
- `v2/crates/wifi-densepose-sensing-server/src/field_localize.rs` — the
field-peak honesty caveat this crate inherits

View file

@ -0,0 +1,84 @@
//! Criterion benchmarks for the per-frame hot path (ADR-324).
//!
//! Reproducer: `cd v2 && cargo bench -p ruview-offaxis`
//! Any numbers quoted from this bench are MEASURED on the machine that ran
//! that command; re-run locally before relying on them.
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use ruview_offaxis::{
field_peak, off_axis, CoarseParallax, CoarseParallaxConfig, OneEuro3, OneEuroConfig, Screen,
Vec3,
};
fn bench_projection(c: &mut Criterion) {
let screen = Screen::centered(0.6, 0.34).unwrap();
c.bench_function("off_axis_projection", |b| {
let mut t = 0.0_f64;
b.iter(|| {
t += 0.016;
let eye = Vec3::new(0.1 * t.sin(), 0.05 * t.cos(), 0.65);
black_box(off_axis(black_box(&screen), black_box(eye), 0.05, 100.0).unwrap())
})
});
c.bench_function("off_axis_view_projection_combined", |b| {
let eye = Vec3::new(0.1, -0.03, 0.65);
let oa = off_axis(&screen, eye, 0.05, 100.0).unwrap();
b.iter(|| black_box(black_box(&oa).view_projection()))
});
}
fn bench_filter(c: &mut Criterion) {
c.bench_function("one_euro3_step", |b| {
let mut f = OneEuro3::new(OneEuroConfig::default());
let mut t = 0.0_f64;
b.iter(|| {
t += 0.016;
black_box(f.filter(Vec3::new(t.sin(), t.cos(), 0.65), t))
})
});
}
fn make_grid(nx: usize, nz: usize) -> Vec<f32> {
// Deterministic pseudo-field with one hot cell.
let mut v: Vec<f32> = (0..nx * nz).map(|i| 0.05 + (i % 7) as f32 * 0.01).collect();
v[(nz / 3) * nx + nx / 4] = 0.9;
v
}
fn bench_field_peak(c: &mut Criterion) {
let g20 = make_grid(20, 20);
c.bench_function("field_peak_20x20", |b| {
b.iter(|| black_box(field_peak(black_box(&g20), 20, 20)))
});
let g100 = make_grid(100, 100);
c.bench_function("field_peak_100x100", |b| {
b.iter(|| black_box(field_peak(black_box(&g100), 100, 100)))
});
}
fn bench_full_tier_b_frame(c: &mut Criterion) {
// The whole Tier B per-frame path: grid scan → parallax → projection.
let screen = Screen::centered(0.6, 0.34).unwrap();
let g20 = make_grid(20, 20);
c.bench_function("tier_b_full_frame_20x20", |b| {
let mut cp = CoarseParallax::new(CoarseParallaxConfig::default());
let mut t = 0.0_f64;
b.iter(|| {
t += 0.016;
let peak = field_peak(black_box(&g20), 20, 20);
let eye = cp.update(peak, t);
black_box(off_axis(&screen, eye, 0.05, 100.0).unwrap())
})
});
}
criterion_group!(
benches,
bench_projection,
bench_filter,
bench_field_peak,
bench_full_tier_b_frame
);
criterion_main!(benches);

View file

@ -0,0 +1,240 @@
//! One-euro filter (Casiez, Roussel & Vogel, CHI 2012) for eye-position
//! smoothing.
//!
//! Implemented from the published algorithm: an exponential low-pass whose
//! cutoff adapts to the signal's speed — low cutoff (heavy smoothing) when
//! nearly still, higher cutoff (low lag) when moving fast. This is the
//! standard jitter/lag trade-off filter for interactive tracking.
//!
//! Timestamps are injected by the caller in seconds (monotonic). The crate
//! never reads a clock — repo discipline, and it keeps the wasm build free
//! of `performance.now()` assumptions.
use crate::math::Vec3;
use core::f64::consts::PI;
/// One-euro filter parameters.
///
/// - `min_cutoff` (Hz): smoothing floor. Lower = smoother but laggier at rest.
/// - `beta`: speed coefficient. Higher = less lag during fast motion.
/// - `d_cutoff` (Hz): cutoff for the internal derivative estimate.
///
/// Defaults are the paper's recommended starting point (1.0, 0.0, 1.0);
/// interactive head tracking typically tunes `min_cutoff` down and `beta` up.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OneEuroConfig {
/// Smoothing floor in Hz (lower = smoother but laggier at rest).
pub min_cutoff: f64,
/// Speed coefficient (higher = less lag during fast motion).
pub beta: f64,
/// Cutoff in Hz for the internal derivative estimate.
pub d_cutoff: f64,
}
impl Default for OneEuroConfig {
fn default() -> Self {
Self {
min_cutoff: 1.0,
beta: 0.0,
d_cutoff: 1.0,
}
}
}
/// Smoothing factor for an exponential low-pass at `cutoff` Hz sampled
/// `dt` seconds apart.
#[inline]
fn alpha(cutoff: f64, dt: f64) -> f64 {
let tau = 1.0 / (2.0 * PI * cutoff);
1.0 / (1.0 + tau / dt)
}
/// Scalar one-euro filter.
#[derive(Clone, Copy, Debug, Default)]
pub struct OneEuro {
cfg: OneEuroConfig,
/// `(t, x_hat, dx_hat)` from the previous accepted sample.
state: Option<(f64, f64, f64)>,
}
impl OneEuro {
/// Create with the given parameters.
pub fn new(cfg: OneEuroConfig) -> Self {
Self { cfg, state: None }
}
/// Replace the parameters, keeping filter state.
pub fn set_config(&mut self, cfg: OneEuroConfig) {
self.cfg = cfg;
}
/// Clear state; the next sample passes through unfiltered.
pub fn reset(&mut self) {
self.state = None;
}
/// Filter sample `x` taken at time `t_s` (seconds). Non-monotonic or
/// non-finite input returns the previous estimate unchanged (never NaN).
pub fn filter(&mut self, x: f64, t_s: f64) -> f64 {
if !x.is_finite() || !t_s.is_finite() {
return self.state.map_or(0.0, |(_, xh, _)| xh);
}
match self.state {
None => {
self.state = Some((t_s, x, 0.0));
x
}
Some((t0, x0, dx0)) => {
let dt = t_s - t0;
if dt <= 0.0 {
return x0;
}
let dx = (x - x0) / dt;
let a_d = alpha(self.cfg.d_cutoff, dt);
let dx_hat = a_d * dx + (1.0 - a_d) * dx0;
let cutoff = self.cfg.min_cutoff + self.cfg.beta * dx_hat.abs();
let a = alpha(cutoff, dt);
let x_hat = a * x + (1.0 - a) * x0;
self.state = Some((t_s, x_hat, dx_hat));
x_hat
}
}
}
}
/// Component-wise one-euro filter over a [`Vec3`].
#[derive(Clone, Copy, Debug, Default)]
pub struct OneEuro3 {
x: OneEuro,
y: OneEuro,
z: OneEuro,
}
impl OneEuro3 {
/// Create with the same parameters on all three axes.
pub fn new(cfg: OneEuroConfig) -> Self {
Self {
x: OneEuro::new(cfg),
y: OneEuro::new(cfg),
z: OneEuro::new(cfg),
}
}
/// Replace parameters on all axes, keeping state.
pub fn set_config(&mut self, cfg: OneEuroConfig) {
self.x.set_config(cfg);
self.y.set_config(cfg);
self.z.set_config(cfg);
}
/// Clear state on all axes.
pub fn reset(&mut self) {
self.x.reset();
self.y.reset();
self.z.reset();
}
/// Filter a 3-D sample taken at time `t_s` (seconds).
pub fn filter(&mut self, v: Vec3, t_s: f64) -> Vec3 {
Vec3::new(
self.x.filter(v.x, t_s),
self.y.filter(v.y, t_s),
self.z.filter(v.z, t_s),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_sample_passes_through() {
let mut f = OneEuro::new(OneEuroConfig::default());
assert_eq!(f.filter(3.25, 0.0), 3.25);
}
#[test]
fn constant_input_stays_constant() {
let mut f = OneEuro::new(OneEuroConfig::default());
for i in 0..100 {
let y = f.filter(1.5, i as f64 * 0.016);
assert!((y - 1.5).abs() < 1e-12);
}
}
#[test]
fn step_response_converges_monotonically() {
let mut f = OneEuro::new(OneEuroConfig {
min_cutoff: 1.0,
beta: 0.0,
d_cutoff: 1.0,
});
f.filter(0.0, 0.0);
let mut prev = 0.0;
for i in 1..200 {
let y = f.filter(1.0, i as f64 * 0.016);
assert!(y > prev, "monotone rise");
assert!(y <= 1.0 + 1e-12, "no overshoot");
prev = y;
}
assert!(prev > 0.99, "converged near the step, got {prev}");
}
#[test]
fn higher_beta_tracks_fast_motion_closer() {
// A fast ramp: the adaptive filter (beta > 0) must lag less than the
// pure low-pass (beta = 0).
let slow_cfg = OneEuroConfig {
min_cutoff: 0.5,
beta: 0.0,
d_cutoff: 1.0,
};
let fast_cfg = OneEuroConfig {
min_cutoff: 0.5,
beta: 1.0,
d_cutoff: 1.0,
};
let mut slow = OneEuro::new(slow_cfg);
let mut fast = OneEuro::new(fast_cfg);
let mut last = (0.0, 0.0, 0.0);
for i in 0..120 {
let t = i as f64 * 0.016;
let x = t * 2.0; // 2 m/s ramp
last = (x, slow.filter(x, t), fast.filter(x, t));
}
let (x, s, f) = last;
assert!(
(x - f).abs() < (x - s).abs(),
"beta reduces lag: |{x}-{f}| < |{x}-{s}|"
);
}
#[test]
fn rejects_non_monotonic_and_non_finite_samples() {
let mut f = OneEuro::new(OneEuroConfig::default());
f.filter(1.0, 1.0);
let settled = f.filter(1.0, 2.0);
// Time going backwards: hold the estimate.
assert_eq!(f.filter(99.0, 0.5), settled);
// NaN input: hold the estimate.
assert_eq!(f.filter(f64::NAN, 3.0), settled);
// NaN never propagates.
let y = f.filter(1.0, 4.0);
assert!(y.is_finite());
}
#[test]
fn vec3_filter_is_componentwise() {
let mut f3 = OneEuro3::new(OneEuroConfig::default());
let mut fx = OneEuro::new(OneEuroConfig::default());
for i in 0..10 {
let t = i as f64 * 0.02;
let v = Vec3::new(i as f64 * 0.1, -1.0, 2.0);
let out = f3.filter(v, t);
assert_eq!(out.x, fx.filter(v.x, t));
assert_eq!(out.y, -1.0);
assert_eq!(out.z, 2.0);
}
}
}

View file

@ -0,0 +1,67 @@
//! # `ruview-offaxis` — clean-room off-axis (head-coupled) perspective (ADR-324)
//!
//! Generalized perspective projection for "window into the screen"
//! (fish-tank VR / head-coupled perspective) demos, implemented from the
//! published math — Robert Kooima, *Generalized Perspective Projection*
//! (2008) — plus the supporting input stages the ADR-324 demo needs:
//!
//! - [`Screen`] / [`off_axis`]: three physical screen corners + a tracked
//! eye → asymmetric frustum and screen-aligned view matrix (column-major
//! `f64`, the three.js `Matrix4.elements` layout).
//! - [`OneEuro`] / [`OneEuro3`]: the one-euro filter (Casiez et al., CHI
//! 2012) for tracking-noise smoothing with injected timestamps.
//! - [`rf`]: RuView-specific input mapping — `/ws/sensing` signal-field
//! peak extraction (constants mirroring the sensing server's
//! `field_localize.rs`) and the bounded **Tier B** coarse-parallax stage.
//! - A wasm-bindgen surface (wasm32 only) exposing [`wasm::OffAxisCamera`]
//! and [`wasm::RfParallax`] to the browser demos.
//!
//! ## Clean-room statement
//!
//! ADR-324 records that the prior-art repository (`icurtis1/off-axis-sneaker`)
//! is unlicensed: no code, assets, or derived text from it appear here. This
//! crate is written solely from the published Kooima and Casiez papers and
//! RuView's own source.
//!
//! ## Honesty contract (repo rule)
//!
//! Nothing in this crate asserts sensing accuracy. The Tier B RF path is
//! **coarse body parallax by construction** — deadbanded, gain-limited,
//! hard-clamped — because a single-link CSI field peak is a representation
//! of field energy, not metric localization (see
//! `wifi-densepose-sensing-server/src/field_localize.rs`). Numeric defaults
//! are interaction-design choices (`CLAIMED`); benchmark numbers live in the
//! README tagged `MEASURED` with their reproducer.
//!
//! ## Native quick start
//!
//! ```
//! use ruview_offaxis::{off_axis, Screen, Vec3};
//!
//! // A 60 cm × 34 cm screen centered at the origin, eye 65 cm away and
//! // 10 cm to the right.
//! let screen = Screen::centered(0.60, 0.34)?;
//! let oa = off_axis(&screen, Vec3::new(0.10, 0.0, 0.65), 0.05, 100.0)?;
//! // Column-major, ready for three.js Matrix4.fromArray / any GL pipeline.
//! let _m: [f64; 16] = oa.view_projection();
//! # Ok::<(), ruview_offaxis::OffAxisError>(())
//! ```
#![forbid(unsafe_code)]
#![warn(missing_docs)]
pub mod filter;
pub mod math;
pub mod projection;
pub mod rf;
#[cfg(target_arch = "wasm32")]
pub mod wasm;
pub use filter::{OneEuro, OneEuro3, OneEuroConfig};
pub use math::{Mat4, Vec3};
pub use projection::{off_axis, OffAxis, OffAxisError, Screen, MIN_EYE_DISTANCE};
pub use rf::{
field_peak, CoarseParallax, CoarseParallaxConfig, FieldPeak, ScreenCalibration,
FIELD_PEAK_THRESHOLD, FIELD_X_SCALE, FIELD_Z_SCALE,
};

View file

@ -0,0 +1,177 @@
//! Minimal 3-vector and column-major 4×4 matrix helpers.
//!
//! Deliberately dependency-free: this crate targets wasm32 for the ADR-324
//! demo surface, and a `nalgebra` pull would dominate the module size for
//! what is a handful of fixed-size operations. Matrices use the OpenGL /
//! three.js `Matrix4.elements` layout: column-major, `m[col * 4 + row]`.
/// A 3-component `f64` vector.
#[derive(Clone, Copy, Debug, PartialEq, Default)]
pub struct Vec3 {
/// X component.
pub x: f64,
/// Y component.
pub y: f64,
/// Z component.
pub z: f64,
}
impl Vec3 {
/// Construct from components.
#[inline]
pub const fn new(x: f64, y: f64, z: f64) -> Self {
Self { x, y, z }
}
/// Scale by `s`.
#[inline]
pub fn scale(self, s: f64) -> Vec3 {
Vec3::new(self.x * s, self.y * s, self.z * s)
}
/// Dot product.
#[inline]
pub fn dot(self, rhs: Vec3) -> f64 {
self.x * rhs.x + self.y * rhs.y + self.z * rhs.z
}
/// Cross product (right-handed).
#[inline]
pub fn cross(self, rhs: Vec3) -> Vec3 {
Vec3::new(
self.y * rhs.z - self.z * rhs.y,
self.z * rhs.x - self.x * rhs.z,
self.x * rhs.y - self.y * rhs.x,
)
}
/// Euclidean length.
#[inline]
pub fn length(self) -> f64 {
self.dot(self).sqrt()
}
/// Unit vector, or `None` when the length is (near) zero.
#[inline]
pub fn normalize(self) -> Option<Vec3> {
let len = self.length();
if len <= f64::EPSILON {
None
} else {
Some(self.scale(1.0 / len))
}
}
}
impl core::ops::Sub for Vec3 {
type Output = Vec3;
#[inline]
fn sub(self, rhs: Vec3) -> Vec3 {
Vec3::new(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z)
}
}
impl core::ops::Add for Vec3 {
type Output = Vec3;
#[inline]
fn add(self, rhs: Vec3) -> Vec3 {
Vec3::new(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z)
}
}
/// Column-major 4×4 matrix, `m[col * 4 + row]` — the exact layout of
/// three.js `Matrix4.elements` / OpenGL, so the array can be passed to
/// `Matrix4.fromArray` untouched.
pub type Mat4 = [f64; 16];
/// The identity matrix.
pub const IDENTITY: Mat4 = [
1.0, 0.0, 0.0, 0.0, //
0.0, 1.0, 0.0, 0.0, //
0.0, 0.0, 1.0, 0.0, //
0.0, 0.0, 0.0, 1.0,
];
/// `a * b` (column-major).
#[inline]
pub fn mul(a: &Mat4, b: &Mat4) -> Mat4 {
let mut out = [0.0; 16];
for col in 0..4 {
for row in 0..4 {
let mut acc = 0.0;
for k in 0..4 {
acc += a[k * 4 + row] * b[col * 4 + k];
}
out[col * 4 + row] = acc;
}
}
out
}
/// Transform a point (`w = 1`); returns the transformed `(x, y, z)` and `w`
/// *before* the perspective divide, so callers can check clip-space signs.
#[inline]
pub fn transform_point(m: &Mat4, p: Vec3) -> (Vec3, f64) {
let x = m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12];
let y = m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13];
let z = m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14];
let w = m[3] * p.x + m[7] * p.y + m[11] * p.z + m[15];
(Vec3::new(x, y, z), w)
}
/// Transform a point and apply the perspective divide, yielding normalized
/// device coordinates. Returns `None` when `w` is (near) zero.
#[inline]
pub fn project_point(m: &Mat4, p: Vec3) -> Option<Vec3> {
let (v, w) = transform_point(m, p);
if w.abs() <= f64::EPSILON {
None
} else {
Some(v.scale(1.0 / w))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn identity_transforms_are_noops() {
let p = Vec3::new(1.5, -2.0, 3.25);
let (q, w) = transform_point(&IDENTITY, p);
assert_eq!(q, p);
assert_eq!(w, 1.0);
assert_eq!(mul(&IDENTITY, &IDENTITY), IDENTITY);
}
#[test]
fn cross_is_right_handed() {
let x = Vec3::new(1.0, 0.0, 0.0);
let y = Vec3::new(0.0, 1.0, 0.0);
assert_eq!(x.cross(y), Vec3::new(0.0, 0.0, 1.0));
}
#[test]
fn normalize_rejects_zero() {
assert!(Vec3::new(0.0, 0.0, 0.0).normalize().is_none());
let n = Vec3::new(0.0, 3.0, 4.0).normalize().unwrap();
assert!((n.length() - 1.0).abs() < 1e-12);
}
#[test]
fn mul_matches_manual_translation_composition() {
// T(a) * T(b) == T(a + b) for translations.
let mut ta = IDENTITY;
ta[12] = 1.0;
ta[13] = 2.0;
ta[14] = 3.0;
let mut tb = IDENTITY;
tb[12] = -4.0;
tb[13] = 0.5;
tb[14] = 7.0;
let tab = mul(&ta, &tb);
assert_eq!(tab[12], -3.0);
assert_eq!(tab[13], 2.5);
assert_eq!(tab[14], 10.0);
}
}

View file

@ -0,0 +1,449 @@
//! Clean-room generalized (off-axis) perspective projection.
//!
//! Implements the screen-corner formulation published by Robert Kooima,
//! "Generalized Perspective Projection" (2008): given a physical screen
//! described by three of its corners and a tracked eye position in the same
//! tracker space, produce the asymmetric frustum and view transform that make
//! the screen behave as a window into the virtual scene.
//!
//! No code from any existing implementation (including the unlicensed
//! `icurtis1/off-axis-sneaker` prior art referenced by ADR-324) was copied or
//! consulted while writing this module; the derivation follows the published
//! math only.
//!
//! ## Conventions
//!
//! - Right-handed tracker space, metres.
//! - `pa` = screen lower-left, `pb` = lower-right, `pc` = upper-left corner.
//! - The screen normal `vn = vr × vu` points toward the viewer's side; the
//! eye must be on that side (`EyeBehindScreen` otherwise).
//! - Output matrices are column-major `f64` in the three.js / OpenGL layout
//! (see [`crate::math::Mat4`]). NDC follows OpenGL: visible x/y/z in
//! `[-1, 1]`, camera looking down `-z` in eye space.
use crate::math::{mul, Mat4, Vec3, IDENTITY};
use core::fmt;
/// Minimum eye-to-screen-plane distance (metres). Below this the frustum
/// degenerates (division by ~0); callers get a typed error instead of NaNs.
pub const MIN_EYE_DISTANCE: f64 = 1e-6;
/// Errors from screen construction or projection evaluation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OffAxisError {
/// The three corners do not span a plane (coincident or collinear).
DegenerateScreen,
/// The eye is on or behind the screen plane (`distance <= MIN_EYE_DISTANCE`).
EyeBehindScreen,
/// `near`/`far` are not `0 < near < far`, or not finite.
InvalidClipPlanes,
/// A non-finite input coordinate was supplied.
NonFiniteInput,
}
impl fmt::Display for OffAxisError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OffAxisError::DegenerateScreen => {
write!(f, "screen corners are coincident or collinear")
}
OffAxisError::EyeBehindScreen => {
write!(f, "eye is on or behind the screen plane")
}
OffAxisError::InvalidClipPlanes => {
write!(f, "clip planes must satisfy 0 < near < far and be finite")
}
OffAxisError::NonFiniteInput => write!(f, "input coordinate is not finite"),
}
}
}
impl std::error::Error for OffAxisError {}
/// A physical screen described by three corners, with its orthonormal basis
/// precomputed at construction (the basis is eye-independent, so caching it
/// keeps the per-frame [`off_axis`] call to the eye-dependent work only).
#[derive(Clone, Copy, Debug)]
pub struct Screen {
pa: Vec3,
pb: Vec3,
pc: Vec3,
vr: Vec3,
vu: Vec3,
vn: Vec3,
}
impl Screen {
/// Build a screen from its lower-left (`pa`), lower-right (`pb`) and
/// upper-left (`pc`) corners, in metres, in any orientation.
pub fn new(pa: Vec3, pb: Vec3, pc: Vec3) -> Result<Self, OffAxisError> {
for v in [pa, pb, pc] {
if !(v.x.is_finite() && v.y.is_finite() && v.z.is_finite()) {
return Err(OffAxisError::NonFiniteInput);
}
}
let vr = (pb - pa)
.normalize()
.ok_or(OffAxisError::DegenerateScreen)?;
let vu = (pc - pa)
.normalize()
.ok_or(OffAxisError::DegenerateScreen)?;
let vn = vr
.cross(vu)
.normalize()
.ok_or(OffAxisError::DegenerateScreen)?;
Ok(Self {
pa,
pb,
pc,
vr,
vu,
vn,
})
}
/// Convenience: an axis-aligned screen of `width_m × height_m` metres,
/// centered at the origin in the `z = 0` plane, normal facing `+z`
/// (the viewer side). This matches the usual desktop-demo setup where
/// the tracker origin is the screen center.
pub fn centered(width_m: f64, height_m: f64) -> Result<Self, OffAxisError> {
if !(width_m.is_finite() && height_m.is_finite()) {
return Err(OffAxisError::NonFiniteInput);
}
if width_m <= 0.0 || height_m <= 0.0 {
return Err(OffAxisError::DegenerateScreen);
}
let hw = width_m / 2.0;
let hh = height_m / 2.0;
Screen::new(
Vec3::new(-hw, -hh, 0.0),
Vec3::new(hw, -hh, 0.0),
Vec3::new(-hw, hh, 0.0),
)
}
/// Lower-left corner.
pub fn pa(&self) -> Vec3 {
self.pa
}
/// Lower-right corner.
pub fn pb(&self) -> Vec3 {
self.pb
}
/// Upper-left corner.
pub fn pc(&self) -> Vec3 {
self.pc
}
/// The implied upper-right corner `pb + (pc - pa)`.
pub fn pd(&self) -> Vec3 {
self.pb + self.pc - self.pa
}
/// Unit right vector along the screen's bottom edge.
pub fn vr(&self) -> Vec3 {
self.vr
}
/// Unit up vector along the screen's left edge.
pub fn vu(&self) -> Vec3 {
self.vu
}
/// Unit normal, pointing toward the viewer side.
pub fn vn(&self) -> Vec3 {
self.vn
}
/// Signed distance from `eye` to the screen plane along the normal
/// (positive when the eye is on the viewer side).
pub fn eye_distance(&self, eye: Vec3) -> f64 {
// va = pa - pe; d = -(va · vn)
-(self.pa - eye).dot(self.vn)
}
}
/// The result of a generalized projection evaluation.
///
/// `projection` is the asymmetric frustum; `view` is the rigid transform
/// (screen-basis rotation + eye translation) taking tracker space into eye
/// space. For three.js, either:
///
/// - set `camera.projectionMatrix` from `projection` and position/orient the
/// camera from the eye and screen basis yourself, or
/// - use [`OffAxis::view_projection`] as a single combined matrix when you
/// manage matrices manually.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OffAxis {
/// Asymmetric perspective frustum (column-major).
pub projection: Mat4,
/// Screen-aligned view matrix: rotation into the screen basis composed
/// with translation by the negated eye position (column-major).
pub view: Mat4,
}
impl OffAxis {
/// `projection * view` — the full tracker-space-to-clip-space matrix.
pub fn view_projection(&self) -> Mat4 {
mul(&self.projection, &self.view)
}
}
/// OpenGL-convention asymmetric frustum matrix (column-major).
#[inline]
fn frustum(l: f64, r: f64, b: f64, t: f64, n: f64, f: f64) -> Mat4 {
let mut m = [0.0; 16];
m[0] = 2.0 * n / (r - l);
m[5] = 2.0 * n / (t - b);
m[8] = (r + l) / (r - l);
m[9] = (t + b) / (t - b);
m[10] = -(f + n) / (f - n);
m[11] = -1.0;
m[14] = -2.0 * f * n / (f - n);
m
}
/// Evaluate the generalized off-axis projection for `screen` as seen from
/// `eye`, with the given clip planes.
///
/// Errors when the eye is on/behind the screen plane, when clip planes are
/// invalid, or when inputs are non-finite. Never returns NaN-bearing
/// matrices: every failure mode is a typed error (renderer-facing code must
/// hold the last good matrix on error, not draw garbage).
pub fn off_axis(screen: &Screen, eye: Vec3, near: f64, far: f64) -> Result<OffAxis, OffAxisError> {
if !(eye.x.is_finite() && eye.y.is_finite() && eye.z.is_finite()) {
return Err(OffAxisError::NonFiniteInput);
}
if !(near.is_finite() && far.is_finite()) || near <= 0.0 || far <= near {
return Err(OffAxisError::InvalidClipPlanes);
}
let (vr, vu, vn) = (screen.vr, screen.vu, screen.vn);
// Vectors from the eye to each screen corner.
let va = screen.pa - eye;
let vb = screen.pb - eye;
let vc = screen.pc - eye;
// Distance from the eye to the screen plane.
let d = -va.dot(vn);
if d <= MIN_EYE_DISTANCE {
return Err(OffAxisError::EyeBehindScreen);
}
// Frustum extents on the near plane.
let nd = near / d;
let l = vr.dot(va) * nd;
let r = vr.dot(vb) * nd;
let b = vu.dot(va) * nd;
let t = vu.dot(vc) * nd;
let projection = frustum(l, r, b, t, near, far);
// View = screen-basis rotation (rows vr/vu/vn) * translation by -eye.
// Composed directly: the translation column is -R^T-rotated eye.
let mut view = IDENTITY;
view[0] = vr.x;
view[4] = vr.y;
view[8] = vr.z;
view[1] = vu.x;
view[5] = vu.y;
view[9] = vu.z;
view[2] = vn.x;
view[6] = vn.y;
view[10] = vn.z;
view[12] = -vr.dot(eye);
view[13] = -vu.dot(eye);
view[14] = -vn.dot(eye);
Ok(OffAxis { projection, view })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::project_point;
const EPS: f64 = 1e-9;
fn assert_close(a: f64, b: f64, eps: f64, what: &str) {
assert!((a - b).abs() < eps, "{what}: {a} vs {b}");
}
/// The defining invariant of off-axis projection: for ANY valid eye
/// position, the physical screen corners land exactly on the NDC corners
/// — pa→(-1,-1), pb→(1,-1), pc→(-1,1), pd→(1,1).
#[test]
fn screen_corners_map_to_ndc_corners_for_all_eyes() {
let screen = Screen::centered(0.6, 0.34).unwrap();
let mut checked = 0;
for xi in -4..=4 {
for yi in -3..=3 {
for zi in 1..=6 {
let eye = Vec3::new(xi as f64 * 0.25, yi as f64 * 0.2, zi as f64 * 0.35);
let oa = off_axis(&screen, eye, 0.05, 100.0).unwrap();
let m = oa.view_projection();
for (corner, ex, ey) in [
(screen.pa(), -1.0, -1.0),
(screen.pb(), 1.0, -1.0),
(screen.pc(), -1.0, 1.0),
(screen.pd(), 1.0, 1.0),
] {
let ndc = project_point(&m, corner).unwrap();
assert_close(ndc.x, ex, EPS, "ndc.x");
assert_close(ndc.y, ey, EPS, "ndc.y");
}
checked += 1;
}
}
}
assert_eq!(checked, 9 * 7 * 6);
}
/// The invariant survives an arbitrarily rotated + translated screen:
/// the math is coordinate-frame independent.
#[test]
fn corner_invariant_holds_for_tilted_screen() {
// A screen rotated ~30° about y and ~15° about x, shifted off origin.
let (cy, sy) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
let (cx, sx) = (15f64.to_radians().cos(), 15f64.to_radians().sin());
let rot = |v: Vec3| {
// Ry then Rx.
let v = Vec3::new(cy * v.x + sy * v.z, v.y, -sy * v.x + cy * v.z);
Vec3::new(v.x, cx * v.y - sx * v.z, sx * v.y + cx * v.z)
};
let shift = Vec3::new(0.4, -0.2, 1.1);
let pa = rot(Vec3::new(-0.3, -0.17, 0.0)) + shift;
let pb = rot(Vec3::new(0.3, -0.17, 0.0)) + shift;
let pc = rot(Vec3::new(-0.3, 0.17, 0.0)) + shift;
let screen = Screen::new(pa, pb, pc).unwrap();
// An eye on the viewer side of the tilted plane.
let eye = shift + rot(Vec3::new(0.1, 0.05, 0.8));
let m = off_axis(&screen, eye, 0.01, 50.0)
.unwrap()
.view_projection();
for (corner, ex, ey) in [
(screen.pa(), -1.0, -1.0),
(screen.pb(), 1.0, -1.0),
(screen.pc(), -1.0, 1.0),
(screen.pd(), 1.0, 1.0),
] {
let ndc = project_point(&m, corner).unwrap();
assert_close(ndc.x, ex, EPS, "tilted ndc.x");
assert_close(ndc.y, ey, EPS, "tilted ndc.y");
}
}
/// A centered eye must reduce to the ordinary symmetric perspective
/// frustum: l == -r, b == -t, and the projection matrix has no skew
/// terms (m[8] == m[9] == 0).
#[test]
fn centered_eye_is_symmetric_perspective() {
let screen = Screen::centered(0.6, 0.34).unwrap();
let d = 0.7;
let near = 0.05;
let oa = off_axis(&screen, Vec3::new(0.0, 0.0, d), near, 100.0).unwrap();
assert_close(oa.projection[8], 0.0, EPS, "skew x");
assert_close(oa.projection[9], 0.0, EPS, "skew y");
// fovy check: m[5] == 1/tan(fovy/2), tan(fovy/2) = (h/2)/d.
let expected_m5 = d / (0.34 / 2.0);
assert_close(oa.projection[5], expected_m5, 1e-9, "m[5] focal");
// aspect: m[0] == m[5]/aspect.
let expected_m0 = d / (0.6 / 2.0);
assert_close(oa.projection[0], expected_m0, 1e-9, "m[0] focal");
}
/// Depth convention: a point at `near` in front of the eye maps to NDC
/// z = -1; a point at `far` maps to +1 (OpenGL convention).
#[test]
fn near_and_far_map_to_ndc_depth_bounds() {
let screen = Screen::centered(0.6, 0.34).unwrap();
let eye = Vec3::new(0.12, -0.05, 0.65);
let (near, far) = (0.05, 40.0);
let m = off_axis(&screen, eye, near, far).unwrap().view_projection();
// Looking direction in tracker space is -vn.
let toward = screen.vn().scale(-1.0);
let p_near = eye + toward.scale(near);
let p_far = eye + toward.scale(far);
assert_close(project_point(&m, p_near).unwrap().z, -1.0, 1e-7, "near z");
assert_close(project_point(&m, p_far).unwrap().z, 1.0, 1e-7, "far z");
}
/// A point exactly on the screen plane keeps the same NDC (x, y) for
/// every eye position — this is the "window" property: the screen
/// surface itself is the fixed point of the illusion.
#[test]
fn screen_plane_points_are_eye_invariant() {
let screen = Screen::centered(0.5, 0.3).unwrap();
// A point 30% right / 20% up from screen center, on the plane.
let p = Vec3::new(0.5 * 0.3, 0.3 * 0.2, 0.0);
let mut first: Option<(f64, f64)> = None;
for eye in [
Vec3::new(0.0, 0.0, 0.6),
Vec3::new(0.3, 0.1, 0.4),
Vec3::new(-0.5, -0.2, 1.2),
] {
let m = off_axis(&screen, eye, 0.05, 100.0)
.unwrap()
.view_projection();
let ndc = project_point(&m, p).unwrap();
match first {
None => first = Some((ndc.x, ndc.y)),
Some((fx, fy)) => {
assert_close(ndc.x, fx, EPS, "plane-point ndc.x eye-invariance");
assert_close(ndc.y, fy, EPS, "plane-point ndc.y eye-invariance");
}
}
}
}
#[test]
fn error_paths_are_typed() {
let screen = Screen::centered(0.6, 0.34).unwrap();
let ok_eye = Vec3::new(0.0, 0.0, 0.5);
// Degenerate screens.
let p = Vec3::new(0.0, 0.0, 0.0);
assert_eq!(
Screen::new(p, p, Vec3::new(0.0, 1.0, 0.0)).unwrap_err(),
OffAxisError::DegenerateScreen
);
assert_eq!(
Screen::new(p, Vec3::new(1.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0)).unwrap_err(),
OffAxisError::DegenerateScreen
);
// Eye behind / on the plane.
assert_eq!(
off_axis(&screen, Vec3::new(0.0, 0.0, -0.5), 0.05, 10.0).unwrap_err(),
OffAxisError::EyeBehindScreen
);
assert_eq!(
off_axis(&screen, Vec3::new(0.2, 0.1, 0.0), 0.05, 10.0).unwrap_err(),
OffAxisError::EyeBehindScreen
);
// Bad clip planes.
assert_eq!(
off_axis(&screen, ok_eye, 0.0, 10.0).unwrap_err(),
OffAxisError::InvalidClipPlanes
);
assert_eq!(
off_axis(&screen, ok_eye, 1.0, 1.0).unwrap_err(),
OffAxisError::InvalidClipPlanes
);
assert_eq!(
off_axis(&screen, ok_eye, -1.0, 10.0).unwrap_err(),
OffAxisError::InvalidClipPlanes
);
// Non-finite input.
assert_eq!(
off_axis(&screen, Vec3::new(f64::NAN, 0.0, 0.5), 0.05, 10.0).unwrap_err(),
OffAxisError::NonFiniteInput
);
assert_eq!(
off_axis(&screen, ok_eye, f64::INFINITY, f64::INFINITY).unwrap_err(),
OffAxisError::InvalidClipPlanes
);
}
}

View file

@ -0,0 +1,442 @@
//! RF-input mapping for the ADR-324 demo tiers.
//!
//! This module turns RuView's *existing* RF surfaces into a bounded,
//! honestly-labeled eye position for the off-axis camera:
//!
//! - [`field_peak`] extracts the strongest cell of a `/ws/sensing`
//! `signal_field` grid using the **same** grid→world mapping as the
//! sensing server's `field_localize.rs` (constants mirrored below, with
//! the same honesty caveat: a single-link field peak is a representation
//! of where field energy concentrates, **not** calibrated metric
//! localization, and never a head position).
//! - [`CoarseParallax`] converts field-peak motion into a **Tier B** eye
//! offset: deadbanded, gain-limited, hard-clamped, one-euro filtered.
//! Its output is coarse body parallax by construction — the clamps make
//! over-claiming impossible at the API level.
//! - [`ScreenCalibration`] holds the physical screen measurements and maps
//! a normalized head position (Tier A, from any fine tracker the host
//! provides) into metric eye coordinates in screen space.
//!
//! Evidence discipline: nothing in this module asserts an accuracy number.
//! All numeric defaults are interaction-design choices (`CLAIMED`), not
//! measured performance.
use crate::filter::{OneEuro, OneEuroConfig};
use crate::math::Vec3;
/// Grid-cell → world X scale (metres per cell), mirroring
/// `wifi-densepose-sensing-server/src/field_localize.rs::X_SCALE`.
pub const FIELD_X_SCALE: f64 = 0.6;
/// Grid-cell → world Z scale (metres per cell), mirroring
/// `field_localize.rs::Z_SCALE`.
pub const FIELD_Z_SCALE: f64 = 0.5;
/// Minimum normalized field value for a cell to count as a real peak,
/// mirroring `field_localize.rs::PEAK_THRESHOLD`.
pub const FIELD_PEAK_THRESHOLD: f64 = 0.35;
/// The strongest cell of a signal-field grid, in the demo's world mapping.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FieldPeak {
/// World X (metres): `(ix - nx/2) * FIELD_X_SCALE`.
pub x: f64,
/// World Z (metres): `(iz - nz/2) * FIELD_Z_SCALE`.
pub z: f64,
/// The peak's normalized field value in `[0, 1]`.
pub value: f64,
/// Grid column of the peak.
pub ix: usize,
/// Grid row of the peak.
pub iz: usize,
}
/// Find the strongest field cell at or above [`FIELD_PEAK_THRESHOLD`].
///
/// `values` is row-major with the sensing server's layout
/// (`idx = iz * nx + ix`). Returns `None` when the slice length doesn't
/// match `nx * nz`, when the grid is empty, or when no cell reaches the
/// threshold (an honest "no localizable hotspot" outcome, mirroring the
/// server's gating).
pub fn field_peak(values: &[f32], nx: usize, nz: usize) -> Option<FieldPeak> {
field_peak_with_threshold(values, nx, nz, FIELD_PEAK_THRESHOLD)
}
/// [`field_peak`] with a caller-supplied threshold (used by tests and by
/// demos that want to visualize sub-threshold energy without moving the
/// camera).
pub fn field_peak_with_threshold(
values: &[f32],
nx: usize,
nz: usize,
threshold: f64,
) -> Option<FieldPeak> {
if nx == 0 || nz == 0 || values.len() != nx * nz {
return None;
}
// Branch-light argmax: a NaN never satisfies `v > best_v`, so non-finite
// cells are skipped without an explicit is_finite() in the hot loop
// (+inf is excluded by the finite check on the winner below).
let mut best_idx = usize::MAX;
let mut best_v = f32::NEG_INFINITY;
for (idx, &v) in values.iter().enumerate() {
if v > best_v {
best_v = v;
best_idx = idx;
}
}
if best_idx == usize::MAX || !best_v.is_finite() {
return None;
}
let (idx, v) = (best_idx, best_v);
// Compare in f32: grid values arrive as f32 (Float32Array), and casting
// 0.35_f32 up to f64 lands a hair below a 0.35_f64 threshold.
if v < threshold as f32 {
return None;
}
let ix = idx % nx;
let iz = idx / nx;
Some(FieldPeak {
x: (ix as f64 - nx as f64 / 2.0) * FIELD_X_SCALE,
z: (iz as f64 - nz as f64 / 2.0) * FIELD_Z_SCALE,
value: v as f64,
ix,
iz,
})
}
/// Tier B parameters. Every default is an interaction-design choice
/// (`CLAIMED`), deliberately conservative so the mode reads as what it is:
/// slow body-scale parallax, not head tracking.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CoarseParallaxConfig {
/// Eye metres produced per body metre of peak movement (≤ 1 keeps the
/// effect visibly sub-physical).
pub gain: f64,
/// Peak movement below this (metres, from the session origin) is
/// ignored entirely — RF field peaks jitter, and the deadband keeps a
/// still room visually still.
pub deadband_m: f64,
/// Hard clamp on the |x| eye offset (metres). The API cannot emit a
/// larger excursion regardless of input.
pub max_offset_m: f64,
/// Nominal viewing distance (metres) used as the eye's Z when no depth
/// modulation applies.
pub base_distance_m: f64,
/// One-euro parameters for the offset filter. Tier B wants heavy
/// smoothing: default `min_cutoff` is well below the Tier A default.
pub filter: OneEuroConfig,
}
impl Default for CoarseParallaxConfig {
fn default() -> Self {
Self {
gain: 0.5,
deadband_m: 0.15,
max_offset_m: 0.35,
base_distance_m: 0.65,
filter: OneEuroConfig {
min_cutoff: 0.4,
beta: 0.2,
d_cutoff: 1.0,
},
}
}
}
/// Tier B: field-peak motion → bounded, smoothed eye position.
///
/// The first accepted peak establishes a session origin; subsequent peaks
/// move the eye relative to it. Peaks below threshold (i.e. `None` from
/// [`field_peak`]) hold the last eye position — the camera never snaps.
#[derive(Clone, Copy, Debug)]
pub struct CoarseParallax {
cfg: CoarseParallaxConfig,
fx: OneEuro,
fz: OneEuro,
origin: Option<(f64, f64)>,
eye: Vec3,
}
impl CoarseParallax {
/// Create with the given configuration.
pub fn new(cfg: CoarseParallaxConfig) -> Self {
Self {
cfg,
fx: OneEuro::new(cfg.filter),
fz: OneEuro::new(cfg.filter),
origin: None,
eye: Vec3::new(0.0, 0.0, cfg.base_distance_m),
}
}
/// The current (last computed) eye position in screen space (metres).
pub fn eye(&self) -> Vec3 {
self.eye
}
/// Forget the session origin and filter state; the eye returns to the
/// centered rest position.
pub fn reset(&mut self) {
self.origin = None;
self.fx.reset();
self.fz.reset();
self.eye = Vec3::new(0.0, 0.0, self.cfg.base_distance_m);
}
/// Ingest a field peak observed at `t_s` seconds; returns the updated
/// eye position. Call with the output of [`field_peak`]; pass `None`
/// (below-threshold field) to hold the current position.
pub fn update(&mut self, peak: Option<FieldPeak>, t_s: f64) -> Vec3 {
let Some(p) = peak else { return self.eye };
let (ox, oz) = *self.origin.get_or_insert((p.x, p.z));
let dx = deadband(p.x - ox, self.cfg.deadband_m);
let dz = deadband(p.z - oz, self.cfg.deadband_m);
let raw_x = (dx * self.cfg.gain).clamp(-self.cfg.max_offset_m, self.cfg.max_offset_m);
// Peak Z (room depth) modulates viewing distance, same bound.
let raw_z = (dz * self.cfg.gain).clamp(-self.cfg.max_offset_m, self.cfg.max_offset_m);
let x = self.fx.filter(raw_x, t_s);
let z_off = self.fz.filter(raw_z, t_s);
// Keep the eye strictly in front of the screen: distance floor at
// half the nominal distance.
let z = (self.cfg.base_distance_m + z_off).max(self.cfg.base_distance_m * 0.5);
self.eye = Vec3::new(x, 0.0, z);
self.eye
}
}
/// Zero inside `±band`, shifted toward zero outside it (continuous at the
/// band edge, so motion doesn't jump when leaving the deadband).
#[inline]
fn deadband(v: f64, band: f64) -> f64 {
if v > band {
v - band
} else if v < -band {
v + band
} else {
0.0
}
}
/// Physical screen calibration (Tier A): metric screen size plus the
/// viewer's nominal distance. Mirrors the concept of a measured-screen
/// calibration wizard; values persist wherever the host keeps them.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct ScreenCalibration {
/// Physical screen width in metres.
pub width_m: f64,
/// Physical screen height in metres.
pub height_m: f64,
/// Nominal eye-to-screen distance in metres.
pub base_distance_m: f64,
}
impl ScreenCalibration {
/// Construct from centimetre measurements (how humans measure screens).
pub fn from_cm(width_cm: f64, height_cm: f64, distance_cm: f64) -> Self {
Self {
width_m: width_cm / 100.0,
height_m: height_cm / 100.0,
base_distance_m: distance_cm / 100.0,
}
}
/// The screen this calibration describes, centered at the origin
/// (see [`crate::Screen::centered`]).
pub fn screen(&self) -> Result<crate::Screen, crate::OffAxisError> {
crate::Screen::centered(self.width_m, self.height_m)
}
/// Map a normalized head position from a fine tracker into metric eye
/// coordinates in screen space.
///
/// - `nx`, `ny`: head position in normalized image coordinates
/// (`[0, 1]`, origin top-left, x rightward — the usual camera-image
/// convention). The image is assumed mirrored (selfie view), so a
/// viewer moving to *their* left moves the eye left in screen space.
/// - `depth_scale`: multiplier on the nominal distance (1.0 = at the
/// calibrated distance; a fine tracker derives it from e.g. apparent
/// inter-feature distance). Clamped to `[0.25, 4.0]`.
/// - `lateral_range_m`: metres of eye travel represented by the full
/// image width/height. Deployment-specific; the screen width is a
/// reasonable default.
pub fn eye_from_normalized(
&self,
nx: f64,
ny: f64,
depth_scale: f64,
lateral_range_m: f64,
) -> Vec3 {
let cx = (nx.clamp(0.0, 1.0) - 0.5) * lateral_range_m;
let cy = (0.5 - ny.clamp(0.0, 1.0)) * lateral_range_m * (self.height_m / self.width_m);
let z = self.base_distance_m * depth_scale.clamp(0.25, 4.0);
Vec3::new(cx, cy, z)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn grid(nx: usize, nz: usize, hot: &[(usize, usize, f32)]) -> Vec<f32> {
let mut v = vec![0.05_f32; nx * nz];
for &(ix, iz, val) in hot {
v[iz * nx + ix] = val;
}
v
}
#[test]
fn peak_mapping_matches_field_localize_layout() {
// 20×20 grid, hot cell at (ix=15, iz=4).
let g = grid(20, 20, &[(15, 4, 0.9)]);
let p = field_peak(&g, 20, 20).unwrap();
assert_eq!(p.ix, 15);
assert_eq!(p.iz, 4);
// world_x = (15 - 10) * 0.6, world_z = (4 - 10) * 0.5 — the exact
// transform documented in field_localize.rs / the Observatory.
assert!((p.x - 3.0).abs() < 1e-12);
assert!((p.z - -3.0).abs() < 1e-12);
assert!((p.value - 0.9).abs() < 1e-6);
}
#[test]
fn below_threshold_and_malformed_grids_yield_none() {
let g = grid(20, 20, &[(3, 3, 0.34)]);
assert_eq!(field_peak(&g, 20, 20), None, "0.34 < threshold 0.35");
assert!(field_peak(&g, 19, 20).is_none(), "length mismatch");
assert!(field_peak(&[], 0, 0).is_none(), "empty grid");
// NaN cells are skipped, not propagated.
let mut g = grid(4, 4, &[(1, 1, 0.8)]);
g[0] = f32::NAN;
let p = field_peak(&g, 4, 4).unwrap();
assert_eq!((p.ix, p.iz), (1, 1));
}
#[test]
fn exact_threshold_is_accepted() {
let g = grid(20, 20, &[(2, 2, 0.35)]);
assert!(field_peak(&g, 20, 20).is_some(), "threshold is inclusive");
}
#[test]
fn coarse_parallax_is_deadbanded_gained_and_clamped() {
let cfg = CoarseParallaxConfig::default();
let mut cp = CoarseParallax::new(cfg);
// First peak sets the origin: eye stays at rest.
let origin = FieldPeak {
x: 1.2,
z: -0.5,
value: 0.8,
ix: 12,
iz: 9,
};
let eye0 = cp.update(Some(origin), 0.0);
assert_eq!(eye0, Vec3::new(0.0, 0.0, cfg.base_distance_m));
// Movement inside the deadband: still at rest.
let small = FieldPeak {
x: 1.2 + 0.1,
..origin
};
let eye1 = cp.update(Some(small), 0.5);
assert_eq!(eye1.x, 0.0, "0.1 m < 0.15 m deadband");
// A huge excursion is clamped to max_offset regardless of gain.
let huge = FieldPeak {
x: 1.2 + 50.0,
..origin
};
let mut eye = Vec3::default();
for i in 0..600 {
eye = cp.update(Some(huge), 1.0 + i as f64 * 0.05);
}
assert!(eye.x <= cfg.max_offset_m + 1e-9, "clamped: {}", eye.x);
assert!(
eye.x > cfg.max_offset_m * 0.9,
"filter converged near clamp"
);
// Below-threshold (None) holds position; never snaps back.
let held = cp.update(None, 100.0);
assert_eq!(held, eye);
}
#[test]
fn coarse_parallax_depth_never_crosses_the_screen() {
let cfg = CoarseParallaxConfig::default();
let mut cp = CoarseParallax::new(cfg);
let origin = FieldPeak {
x: 0.0,
z: 0.0,
value: 0.9,
ix: 10,
iz: 10,
};
cp.update(Some(origin), 0.0);
// Walk far toward the screen (negative z offset).
let close = FieldPeak { z: -50.0, ..origin };
let mut eye = Vec3::default();
for i in 0..600 {
eye = cp.update(Some(close), 0.1 + i as f64 * 0.05);
}
assert!(
eye.z >= cfg.base_distance_m * 0.5 - 1e-9,
"distance floor: {}",
eye.z
);
}
#[test]
fn reset_returns_to_rest() {
let mut cp = CoarseParallax::new(CoarseParallaxConfig::default());
cp.update(
Some(FieldPeak {
x: 0.0,
z: 0.0,
value: 0.9,
ix: 0,
iz: 0,
}),
0.0,
);
cp.update(
Some(FieldPeak {
x: 9.0,
z: 0.0,
value: 0.9,
ix: 0,
iz: 0,
}),
1.0,
);
cp.reset();
assert_eq!(
cp.eye(),
Vec3::new(0.0, 0.0, CoarseParallaxConfig::default().base_distance_m)
);
}
#[test]
fn calibration_maps_normalized_head_to_metric_eye() {
let cal = ScreenCalibration::from_cm(60.0, 34.0, 65.0);
assert!((cal.width_m - 0.6).abs() < 1e-12);
// Centered head at nominal depth = centered eye at base distance.
let c = cal.eye_from_normalized(0.5, 0.5, 1.0, cal.width_m);
assert_eq!(c, Vec3::new(0.0, 0.0, 0.65));
// Head at image right edge → eye half the range to the right;
// image y grows downward → ny=0 (top) is +y in screen space.
let e = cal.eye_from_normalized(1.0, 0.0, 1.0, 0.6);
assert!((e.x - 0.3).abs() < 1e-12);
assert!(e.y > 0.0);
// Depth scale is clamped to a sane band.
assert_eq!(cal.eye_from_normalized(0.5, 0.5, 100.0, 0.6).z, 0.65 * 4.0);
assert_eq!(cal.eye_from_normalized(0.5, 0.5, 0.0, 0.6).z, 0.65 * 0.25);
// Out-of-range normalized coords are clamped, not extrapolated.
let clamped = cal.eye_from_normalized(7.0, -3.0, 1.0, 0.6);
assert!((clamped.x - 0.3).abs() < 1e-12);
}
}

View file

@ -0,0 +1,202 @@
//! wasm-bindgen surface (compiled only for `wasm32`).
//!
//! Exposes two small classes to JavaScript:
//!
//! - [`OffAxisCamera`] — Tier A/B shared core: screen calibration + one-euro
//! filtering + Kooima projection. Feed it an eye position each frame, read
//! back column-major matrices ready for `THREE.Matrix4.fromArray`.
//! - [`RfParallax`] — Tier B input stage: `/ws/sensing` `signal_field`
//! grids in, bounded coarse-parallax eye position out. Its output is
//! coarse body parallax by construction (deadband + gain + clamp), so a
//! demo cannot accidentally present it as head tracking.
//!
//! All methods that can fail return `Result<_, JsError>` (thrown as JS
//! exceptions); per-frame update methods instead hold the last good state so
//! a render loop never has to try/catch.
use crate::filter::{OneEuro3, OneEuroConfig};
use crate::math::Vec3;
use crate::projection::{off_axis, OffAxis, Screen};
use crate::rf::{field_peak, CoarseParallax, CoarseParallaxConfig, ScreenCalibration};
use wasm_bindgen::prelude::*;
/// Head-coupled off-axis camera for a physically calibrated screen.
#[wasm_bindgen]
pub struct OffAxisCamera {
screen: Screen,
cal: ScreenCalibration,
near: f64,
far: f64,
filter: OneEuro3,
current: OffAxis,
eye: Vec3,
}
#[wasm_bindgen]
impl OffAxisCamera {
/// Create from physical screen measurements in **centimetres** (the
/// units a person measures with) plus clip planes in metres.
#[wasm_bindgen(constructor)]
pub fn new(
screen_width_cm: f64,
screen_height_cm: f64,
viewing_distance_cm: f64,
near_m: f64,
far_m: f64,
) -> Result<OffAxisCamera, JsError> {
let cal =
ScreenCalibration::from_cm(screen_width_cm, screen_height_cm, viewing_distance_cm);
let screen = cal.screen().map_err(|e| JsError::new(&e.to_string()))?;
let eye = Vec3::new(0.0, 0.0, cal.base_distance_m);
let current =
off_axis(&screen, eye, near_m, far_m).map_err(|e| JsError::new(&e.to_string()))?;
Ok(Self {
screen,
cal,
near: near_m,
far: far_m,
filter: OneEuro3::new(OneEuroConfig {
min_cutoff: 1.0,
beta: 0.3,
d_cutoff: 1.0,
}),
current,
eye,
})
}
/// Tune the one-euro filter (`min_cutoff` Hz, `beta`). Lower
/// `min_cutoff` = smoother at rest; higher `beta` = less lag in motion.
pub fn set_filter(&mut self, min_cutoff: f64, beta: f64) {
self.filter.set_config(OneEuroConfig {
min_cutoff,
beta,
d_cutoff: 1.0,
});
}
/// Reset filter state (e.g. after tracking was lost).
pub fn reset_filter(&mut self) {
self.filter.reset();
}
/// Update from a **metric eye position** in screen space (metres,
/// origin = screen center, +x right, +y up, +z toward the viewer) at
/// time `t_s` seconds. Returns `true` when the matrices were updated;
/// `false` when the sample was rejected (eye behind screen / non-finite)
/// and the previous matrices were held.
pub fn update_eye(&mut self, x_m: f64, y_m: f64, z_m: f64, t_s: f64) -> bool {
let eye = self.filter.filter(Vec3::new(x_m, y_m, z_m), t_s);
match off_axis(&self.screen, eye, self.near, self.far) {
Ok(oa) => {
self.current = oa;
self.eye = eye;
true
}
Err(_) => false,
}
}
/// Update from a **normalized head position** (`nx`, `ny` in `[0, 1]`,
/// camera-image convention, mirrored/selfie view) with a depth scale
/// (1.0 = at the calibrated distance) — the Tier A path fed by any fine
/// tracker the host runs. `lateral_range_m` is how many metres of eye
/// travel the full image spans (screen width is a reasonable start).
pub fn update_normalized(
&mut self,
nx: f64,
ny: f64,
depth_scale: f64,
lateral_range_m: f64,
t_s: f64,
) -> bool {
let eye = self
.cal
.eye_from_normalized(nx, ny, depth_scale, lateral_range_m);
self.update_eye(eye.x, eye.y, eye.z, t_s)
}
/// The current asymmetric projection matrix, column-major, 16 elements
/// (pass straight to `THREE.Matrix4.fromArray`).
pub fn projection(&self) -> Vec<f64> {
self.current.projection.to_vec()
}
/// The current screen-aligned view matrix, column-major, 16 elements.
pub fn view(&self) -> Vec<f64> {
self.current.view.to_vec()
}
/// `projection * view` as one matrix, column-major, 16 elements.
pub fn view_projection(&self) -> Vec<f64> {
self.current.view_projection().to_vec()
}
/// The current (filtered) eye position `[x, y, z]` in metres.
pub fn eye(&self) -> Vec<f64> {
vec![self.eye.x, self.eye.y, self.eye.z]
}
}
/// Tier B input stage: signal-field grids → bounded coarse-parallax eye.
#[wasm_bindgen]
pub struct RfParallax {
parallax: CoarseParallax,
last_value: f64,
has_peak: bool,
}
#[wasm_bindgen]
impl RfParallax {
/// Create with the default conservative Tier B tuning and the given
/// nominal viewing distance (metres).
#[wasm_bindgen(constructor)]
pub fn new(base_distance_m: f64) -> RfParallax {
let cfg = CoarseParallaxConfig {
base_distance_m,
..CoarseParallaxConfig::default()
};
RfParallax {
parallax: CoarseParallax::new(cfg),
last_value: 0.0,
has_peak: false,
}
}
/// Ingest one `signal_field` grid (`values.length == nx * nz`,
/// `idx = iz * nx + ix` — the `/ws/sensing` layout) observed at `t_s`
/// seconds. Returns `true` when a peak at/above the server's threshold
/// was found (eye moved), `false` when the field had no localizable
/// hotspot (eye held).
pub fn update(&mut self, values: &[f32], nx: usize, nz: usize, t_s: f64) -> bool {
let peak = field_peak(values, nx, nz);
self.has_peak = peak.is_some();
self.last_value = peak.map_or(self.last_value, |p| p.value);
self.parallax.update(peak, t_s);
self.has_peak
}
/// The current eye position `[x, y, z]` in metres (screen space) —
/// feed to `OffAxisCamera.update_eye`.
pub fn eye(&self) -> Vec<f64> {
let e = self.parallax.eye();
vec![e.x, e.y, e.z]
}
/// Whether the last grid had an at/above-threshold peak.
pub fn has_peak(&self) -> bool {
self.has_peak
}
/// The last accepted peak's normalized field value (HUD display).
pub fn peak_value(&self) -> f64 {
self.last_value
}
/// Forget the session origin (person left / demo reset).
pub fn reset(&mut self) {
self.parallax.reset();
self.has_peak = false;
self.last_value = 0.0;
}
}