diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock index 9a2694bf..5504580e 100644 --- a/.claude/scheduled_tasks.lock +++ b/.claude/scheduled_tasks.lock @@ -1 +1 @@ -{"sessionId":"d80c93c2-51b7-42e8-a0fc-dc47cff1200f","pid":45748,"acquiredAt":1779668018388} \ No newline at end of file +{"sessionId":"905385c4-b13f-5091-96df-5752fb109cf5","pid":509,"procStart":"527","acquiredAt":1786922977672} \ No newline at end of file diff --git a/.gitignore b/.gitignore index 6ae5531d..00b2441a 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/adr/ADR-324-off-axis-head-coupled-perspective-demo.md b/docs/adr/ADR-324-off-axis-head-coupled-perspective-demo.md new file mode 100644 index 00000000..acb88526 --- /dev/null +++ b/docs/adr/ADR-324-off-axis-head-coupled-perspective-demo.md @@ -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 (L0–L5 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 01–05 + 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 ` + + + diff --git a/v2/Cargo.lock b/v2/Cargo.lock index a4cdbe25..94421948 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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" diff --git a/v2/Cargo.toml b/v2/Cargo.toml index 58932c20..35cc6237 100644 --- a/v2/Cargo.toml +++ b/v2/Cargo.toml @@ -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`. diff --git a/v2/crates/ruview-offaxis/Cargo.toml b/v2/crates/ruview-offaxis/Cargo.toml new file mode 100644 index 00000000..d5d3f47d --- /dev/null +++ b/v2/crates/ruview-offaxis/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-offaxis/README.md b/v2/crates/ruview-offaxis/README.md new file mode 100644 index 00000000..391c2dac --- /dev/null +++ b/v2/crates/ruview-offaxis/README.md @@ -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-*` — L0–L5 evidence ladder (labels used above) +- `v2/crates/wifi-densepose-sensing-server/src/field_localize.rs` — the + field-peak honesty caveat this crate inherits diff --git a/v2/crates/ruview-offaxis/benches/offaxis.rs b/v2/crates/ruview-offaxis/benches/offaxis.rs new file mode 100644 index 00000000..b77860f7 --- /dev/null +++ b/v2/crates/ruview-offaxis/benches/offaxis.rs @@ -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 { + // Deterministic pseudo-field with one hot cell. + let mut v: Vec = (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); diff --git a/v2/crates/ruview-offaxis/src/filter.rs b/v2/crates/ruview-offaxis/src/filter.rs new file mode 100644 index 00000000..769e5ae9 --- /dev/null +++ b/v2/crates/ruview-offaxis/src/filter.rs @@ -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); + } + } +} diff --git a/v2/crates/ruview-offaxis/src/lib.rs b/v2/crates/ruview-offaxis/src/lib.rs new file mode 100644 index 00000000..41b11331 --- /dev/null +++ b/v2/crates/ruview-offaxis/src/lib.rs @@ -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, +}; diff --git a/v2/crates/ruview-offaxis/src/math.rs b/v2/crates/ruview-offaxis/src/math.rs new file mode 100644 index 00000000..1665a54f --- /dev/null +++ b/v2/crates/ruview-offaxis/src/math.rs @@ -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 { + 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 { + 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); + } +} diff --git a/v2/crates/ruview-offaxis/src/projection.rs b/v2/crates/ruview-offaxis/src/projection.rs new file mode 100644 index 00000000..8381cc4f --- /dev/null +++ b/v2/crates/ruview-offaxis/src/projection.rs @@ -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 { + 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 { + 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 { + 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 + ); + } +} diff --git a/v2/crates/ruview-offaxis/src/rf.rs b/v2/crates/ruview-offaxis/src/rf.rs new file mode 100644 index 00000000..87b3b4ff --- /dev/null +++ b/v2/crates/ruview-offaxis/src/rf.rs @@ -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 { + 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 { + 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, 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::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 { + 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); + } +} diff --git a/v2/crates/ruview-offaxis/src/wasm.rs b/v2/crates/ruview-offaxis/src/wasm.rs new file mode 100644 index 00000000..bc002e1e --- /dev/null +++ b/v2/crates/ruview-offaxis/src/wasm.rs @@ -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 { + 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 { + self.current.projection.to_vec() + } + + /// The current screen-aligned view matrix, column-major, 16 elements. + pub fn view(&self) -> Vec { + self.current.view.to_vec() + } + + /// `projection * view` as one matrix, column-major, 16 elements. + pub fn view_projection(&self) -> Vec { + self.current.view_projection().to_vec() + } + + /// The current (filtered) eye position `[x, y, z]` in metres. + pub fn eye(&self) -> Vec { + 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 { + 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; + } +}