mirror of
https://github.com/ruvnet/RuView.git
synced 2026-08-31 20:45:58 +00:00
New leaf crate v2/crates/ruview-offaxis implementing ADR-324's projection
core from the published math only (Kooima 2008 generalized perspective
projection; Casiez 2012 one-euro filter) — no code from the unlicensed
prior-art repo. Dependency-free native core; wasm-bindgen surface gated to
wasm32 (cdylib+rlib, ~54 KB wasm after bindgen).
- projection: Screen (3 corners, any orientation) + off_axis() -> typed
errors, never NaN matrices; column-major f64 (three.js Matrix4 layout).
Tests pin the defining invariants: screen corners -> NDC corners for a
grid of eye positions and tilted screens; screen-plane points are
eye-invariant; centered eye reduces to the symmetric frustum; near/far
map to NDC -1/+1.
- filter: one-euro with injected timestamps (no clock in crate);
monotonicity/convergence/NaN-rejection tests.
- rf: field-peak extraction mirroring field_localize.rs constants
(X_SCALE 0.6, Z_SCALE 0.5, PEAK_THRESHOLD 0.35) and the Tier B
coarse-parallax stage (deadband, gain, hard clamp) so over-claiming is
impossible at the API level. No accuracy numbers asserted.
- wasm: OffAxisCamera + RfParallax bindgen classes; per-frame updates hold
last good state instead of throwing.
- benches (criterion): full Tier B frame ~598 ns; argmax scan optimized
-18%/-33% (20x20/100x100). MEASURED table + reproducer in README.
- examples/three.js/demos/07-off-axis-window.html: demo with SYNTHETIC
mouse simulator and labeled RF Tier B mode ('coarse body parallax - not
head tracking'), physical calibration panel, /ws/sensing input, and a
build-instructions overlay when the local pkg/ output is missing
(generated artifacts stay uncommitted; .gitignore entry added).
- Validated 23 unit tests + doctest, clippy clean, wasm32 release build,
Node smoke test of the bindgen output, and a headless-Chromium run of
the demo (engine load, eye response, mode labels, ws failure path).
- ADR-324: header + section 2.5 amendment recording the Rust/WASM core.
Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_01BU3NcEgTpAVvu5QGtw4czT
165 lines
7.3 KiB
Markdown
165 lines
7.3 KiB
Markdown
# 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
|