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
index af3ba16e..acb88526 100644
--- a/docs/adr/ADR-324-off-axis-head-coupled-perspective-demo.md
+++ b/docs/adr/ADR-324-off-axis-head-coupled-perspective-demo.md
@@ -2,7 +2,7 @@
| Field | Value |
|-------|-------|
-| **Status** | Proposed |
+| **Status** | Proposed (core implemented — see §2.5) |
| **Date** | 2026-08-16 |
| **Deciders** | ruv |
| **Codename** | **off-axis-mode** |
@@ -195,6 +195,21 @@ in this ADR solely so nobody ships it informally without those gates.
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 |
diff --git a/examples/three.js/demos/07-off-axis-window.html b/examples/three.js/demos/07-off-axis-window.html
new file mode 100644
index 00000000..cdb4c4f4
--- /dev/null
+++ b/examples/three.js/demos/07-off-axis-window.html
@@ -0,0 +1,378 @@
+
+
+
+ This demo loads the crate's wasm-bindgen output from
+ v2/crates/ruview-offaxis/pkg/. Generated artifacts are not
+ committed (repo rule); build them once:
+ cd v2 && cargo build -p ruview-offaxis --target wasm32-unknown-unknown --release
+ wasm-bindgen --target web --out-dir crates/ruview-offaxis/pkg \
+ target/wasm32-unknown-unknown/release/ruview_offaxis.wasm
+ (install the CLI with cargo install wasm-bindgen-cli --version 0.2.114)
+ then reload. Full steps: v2/crates/ruview-offaxis/README.md.
+
+
+
+
+
+
+
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;
+ }
+}