mirror of
https://github.com/ruvnet/RuView.git
synced 2026-08-26 02:04:55 +00:00
feat(offaxis): clean-room Kooima off-axis projection in Rust/WASM (ADR-324)
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
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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 |
|
||||
|
||||
378
examples/three.js/demos/07-off-axis-window.html
Normal file
378
examples/three.js/demos/07-off-axis-window.html
Normal file
@@ -0,0 +1,378 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RuView · ADR-324 · off-axis window (ruview-offaxis WASM)</title>
|
||||
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><rect x='7' y='9' width='18' height='14' rx='2' fill='none' stroke='%23e8a634' stroke-width='2'/><circle cx='16' cy='16' r='3' fill='%23e8a634'/></svg>">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0a0a0a;
|
||||
--bg-panel: rgba(0, 0, 0, 0.88);
|
||||
--amber: #e8a634;
|
||||
--amber-dim: #4a3a1a;
|
||||
--amber-hot: #ffc04d;
|
||||
--grid-major: #444444;
|
||||
--grid-minor: #222222;
|
||||
--green: #4f4;
|
||||
--blue: #4cf;
|
||||
--red: #f66;
|
||||
--text-mute: #888;
|
||||
--border: #2a2a2a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg);
|
||||
color: var(--amber);
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
canvas { display: block; }
|
||||
|
||||
#info {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--amber);
|
||||
border-radius: 8px;
|
||||
min-width: 280px;
|
||||
max-width: 360px;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
z-index: 10;
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 4px 24px rgba(232, 166, 52, 0.08);
|
||||
}
|
||||
#info h1 { margin: 0 0 2px 0; font-size: 14px; letter-spacing: 0.5px; }
|
||||
#info .sub { font-size: 11px; color: var(--text-mute); margin-bottom: 10px; }
|
||||
#info .row { display: flex; justify-content: space-between; gap: 12px; margin: 2px 0; }
|
||||
#info .row .k { color: var(--text-mute); }
|
||||
#info .row .v { color: var(--amber); font-variant-numeric: tabular-nums; }
|
||||
#info .row .v.live { color: var(--green); }
|
||||
#info .row .v.warn { color: var(--red); }
|
||||
|
||||
/* The mandatory ADR-324 §2.4 mode label: always visible while RF
|
||||
drives the camera; there is no configuration that hides it. */
|
||||
#mode-label {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--blue);
|
||||
border-radius: 8px;
|
||||
color: var(--blue);
|
||||
font-size: 12px;
|
||||
z-index: 11;
|
||||
}
|
||||
#mode-label.rf { border-color: var(--red); color: var(--red); }
|
||||
|
||||
#controls {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font-size: 12px;
|
||||
z-index: 10;
|
||||
max-width: 340px;
|
||||
}
|
||||
#controls h2 { margin: 0 0 8px 0; font-size: 12px; color: var(--text-mute); }
|
||||
#controls label { display: flex; justify-content: space-between; gap: 8px; margin: 4px 0; align-items: center; }
|
||||
#controls input[type="number"] {
|
||||
width: 70px; background: #111; border: 1px solid var(--border);
|
||||
color: var(--amber); font-family: inherit; font-size: 12px; padding: 2px 6px; border-radius: 4px;
|
||||
}
|
||||
#controls input[type="text"] {
|
||||
width: 190px; background: #111; border: 1px solid var(--border);
|
||||
color: var(--amber); font-family: inherit; font-size: 11px; padding: 2px 6px; border-radius: 4px;
|
||||
}
|
||||
#controls button {
|
||||
background: var(--amber-dim); border: 1px solid var(--amber); color: var(--amber-hot);
|
||||
font-family: inherit; font-size: 12px; padding: 4px 10px; border-radius: 5px; cursor: pointer; margin-top: 6px;
|
||||
}
|
||||
#controls button:hover { background: var(--amber); color: #000; }
|
||||
|
||||
#wasm-missing {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
z-index: 50;
|
||||
}
|
||||
#wasm-missing .box {
|
||||
max-width: 560px; border: 1px solid var(--amber); border-radius: 10px;
|
||||
background: var(--bg-panel); padding: 22px 26px; font-size: 13px; line-height: 1.7;
|
||||
}
|
||||
#wasm-missing code { color: var(--amber-hot); background: #151005; padding: 1px 5px; border-radius: 4px; display: block; margin: 4px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="info">
|
||||
<h1>OFF-AXIS WINDOW</h1>
|
||||
<div class="sub">ADR-324 · clean-room Kooima projection · ruview-offaxis (Rust→WASM)</div>
|
||||
<div class="row"><span class="k">engine</span><span class="v" id="hud-engine">loading…</span></div>
|
||||
<div class="row"><span class="k">input</span><span class="v" id="hud-input">mouse (SYNTHETIC)</span></div>
|
||||
<div class="row"><span class="k">eye x/y/z (m)</span><span class="v" id="hud-eye">—</span></div>
|
||||
<div class="row"><span class="k">render fps</span><span class="v live" id="hud-fps">—</span></div>
|
||||
<div class="row"><span class="k">rf socket</span><span class="v" id="hud-ws">not connected</span></div>
|
||||
<div class="row"><span class="k">rf peak</span><span class="v" id="hud-peak">—</span></div>
|
||||
<div class="row" style="margin-top:8px"><span class="k" style="font-size:10px">
|
||||
keys: <b>M</b> mouse · <b>R</b> RF Tier B · wheel = distance</span></div>
|
||||
</div>
|
||||
|
||||
<div id="mode-label">MOUSE SIM — SYNTHETIC INPUT</div>
|
||||
|
||||
<div id="controls">
|
||||
<h2>PHYSICAL CALIBRATION (stored locally)</h2>
|
||||
<label>screen width (cm) <input id="cal-w" type="number" step="0.5" value="60"></label>
|
||||
<label>screen height (cm) <input id="cal-h" type="number" step="0.5" value="34"></label>
|
||||
<label>viewing distance (cm) <input id="cal-d" type="number" step="1" value="65"></label>
|
||||
<h2 style="margin-top:10px">RF SOURCE (Tier B)</h2>
|
||||
<label>ws url <input id="ws-url" type="text" value="ws://127.0.0.1:8080/ws/sensing"></label>
|
||||
<button id="apply">apply calibration</button>
|
||||
</div>
|
||||
|
||||
<div id="wasm-missing">
|
||||
<div class="box">
|
||||
<b>ruview-offaxis WASM module not found.</b><br><br>
|
||||
This demo loads the crate's wasm-bindgen output from
|
||||
<code>v2/crates/ruview-offaxis/pkg/</code>. Generated artifacts are not
|
||||
committed (repo rule); build them once:
|
||||
<code>cd v2 && cargo build -p ruview-offaxis --target wasm32-unknown-unknown --release</code>
|
||||
<code>wasm-bindgen --target web --out-dir crates/ruview-offaxis/pkg \
|
||||
target/wasm32-unknown-unknown/release/ruview_offaxis.wasm</code>
|
||||
(install the CLI with <code>cargo install wasm-bindgen-cli --version 0.2.114</code>)<br>
|
||||
then reload. Full steps: <code>v2/crates/ruview-offaxis/README.md</code>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
|
||||
<script type="module">
|
||||
// ADR-324 demo: the projection math lives in Rust/WASM (ruview-offaxis).
|
||||
// This file only wires inputs (mouse SYNTHETIC sim, or /ws/sensing
|
||||
// signal_field for the labeled Tier B coarse-parallax mode) into the
|
||||
// WASM camera and copies its matrices onto a three.js camera.
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
// ---- Load the WASM module (not committed; user builds it once). ----
|
||||
let wasm;
|
||||
try {
|
||||
wasm = await import('../../../v2/crates/ruview-offaxis/pkg/ruview_offaxis.js');
|
||||
await wasm.default();
|
||||
$('hud-engine').textContent = 'ruview-offaxis wasm';
|
||||
} catch (e) {
|
||||
console.error('ruview-offaxis pkg not found', e);
|
||||
$('wasm-missing').style.display = 'flex';
|
||||
throw e;
|
||||
}
|
||||
const { OffAxisCamera, RfParallax } = wasm;
|
||||
|
||||
// ---- Calibration (persisted locally; never leaves the browser). ----
|
||||
const CAL_KEY = 'ruview-offaxis-demo-cal';
|
||||
const saved = JSON.parse(localStorage.getItem(CAL_KEY) || 'null');
|
||||
if (saved) { $('cal-w').value = saved.w; $('cal-h').value = saved.h; $('cal-d').value = saved.d; }
|
||||
const cal = () => ({ w: +$('cal-w').value || 60, h: +$('cal-h').value || 34, d: +$('cal-d').value || 65 });
|
||||
|
||||
let cam = new OffAxisCamera(cal().w, cal().h, cal().d, 0.05, 100.0);
|
||||
cam.set_filter(1.2, 0.4); // interactive: light smoothing, quick catch-up
|
||||
let rf = new RfParallax(cal().d / 100);
|
||||
|
||||
$('apply').onclick = () => {
|
||||
const c = cal();
|
||||
localStorage.setItem(CAL_KEY, JSON.stringify(c));
|
||||
cam = new OffAxisCamera(c.w, c.h, c.d, 0.05, 100.0);
|
||||
cam.set_filter(1.2, 0.4);
|
||||
rf = new RfParallax(c.d / 100);
|
||||
buildRoom(); // room proportions follow the physical screen
|
||||
};
|
||||
|
||||
// ---- three.js scene: a room extending behind the screen plane. ----
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
renderer.setPixelRatio(window.devicePixelRatio);
|
||||
document.body.appendChild(renderer.domElement);
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0a0a);
|
||||
|
||||
// Camera is fully driven by the WASM matrices.
|
||||
const camera = new THREE.PerspectiveCamera();
|
||||
camera.matrixAutoUpdate = false;
|
||||
|
||||
let room = new THREE.Group();
|
||||
function buildRoom() {
|
||||
scene.remove(room);
|
||||
room = new THREE.Group();
|
||||
const c = cal();
|
||||
const W = c.w / 100, H = c.h / 100, DEPTH = Math.max(W, 0.8) * 2.0;
|
||||
|
||||
// Wireframe box behind the screen: the classic "window" cue.
|
||||
const boxGeo = new THREE.BoxGeometry(W, H, DEPTH);
|
||||
const edges = new THREE.EdgesGeometry(boxGeo);
|
||||
const box = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xe8a634 }));
|
||||
box.position.z = -DEPTH / 2; // screen plane is z = 0
|
||||
room.add(box);
|
||||
|
||||
// Depth rails: rows of columns receding into the box.
|
||||
const colMat = new THREE.MeshStandardMaterial({ color: 0x4a3a1a, emissive: 0x2a1f08 });
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
for (const sx of [-1, 1]) {
|
||||
const col = new THREE.Mesh(new THREE.CylinderGeometry(0.008, 0.008, H * 0.9, 12), colMat);
|
||||
col.position.set(sx * W * 0.42, 0, -DEPTH * i / 7);
|
||||
room.add(col);
|
||||
}
|
||||
}
|
||||
|
||||
// Floating objects at staggered depths (parallax targets).
|
||||
const knotMat = new THREE.MeshStandardMaterial({ color: 0xe8a634, metalness: 0.4, roughness: 0.35 });
|
||||
const knot = new THREE.Mesh(new THREE.TorusKnotGeometry(H * 0.18, H * 0.05, 120, 16), knotMat);
|
||||
knot.position.set(0, 0, -DEPTH * 0.45);
|
||||
knot.name = 'knot';
|
||||
room.add(knot);
|
||||
|
||||
const orb = new THREE.Mesh(
|
||||
new THREE.IcosahedronGeometry(H * 0.08, 1),
|
||||
new THREE.MeshStandardMaterial({ color: 0x4cf0ff, emissive: 0x0a3540 })
|
||||
);
|
||||
orb.position.set(-W * 0.22, H * 0.18, -DEPTH * 0.18);
|
||||
room.add(orb);
|
||||
|
||||
// One object slightly IN FRONT of the screen plane — pops "out".
|
||||
const pop = new THREE.Mesh(
|
||||
new THREE.OctahedronGeometry(H * 0.05),
|
||||
new THREE.MeshStandardMaterial({ color: 0xffc04d, emissive: 0x604010 })
|
||||
);
|
||||
pop.position.set(W * 0.28, -H * 0.2, 0.06);
|
||||
room.add(pop);
|
||||
|
||||
room.add(new THREE.AmbientLight(0xffffff, 0.35));
|
||||
const key = new THREE.PointLight(0xffe0a0, 1.0);
|
||||
key.position.set(0.3, 0.4, 0.5);
|
||||
room.add(key);
|
||||
scene.add(room);
|
||||
}
|
||||
buildRoom();
|
||||
|
||||
// ---- Input modes. ----
|
||||
// 'mouse' — SYNTHETIC eye simulator (always available, no hardware).
|
||||
// 'rf' — Tier B: /ws/sensing signal_field → RfParallax.
|
||||
// Labeled coarse body parallax, NOT head tracking (ADR-324 §2.4).
|
||||
let mode = 'mouse';
|
||||
const modeLabel = $('mode-label');
|
||||
function setMode(m) {
|
||||
mode = m;
|
||||
if (m === 'rf') {
|
||||
modeLabel.textContent = 'RF COARSE BODY PARALLAX — NOT HEAD TRACKING';
|
||||
modeLabel.classList.add('rf');
|
||||
$('hud-input').textContent = 'rf field peak (Tier B)';
|
||||
connectWs();
|
||||
} else {
|
||||
modeLabel.textContent = 'MOUSE SIM — SYNTHETIC INPUT';
|
||||
modeLabel.classList.remove('rf');
|
||||
$('hud-input').textContent = 'mouse (SYNTHETIC)';
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'm' || e.key === 'M') setMode('mouse');
|
||||
if (e.key === 'r' || e.key === 'R') setMode('rf');
|
||||
});
|
||||
|
||||
// Mouse sim: pointer position maps to a ±0.3 m eye excursion;
|
||||
// wheel adjusts distance.
|
||||
let mouseEye = { x: 0, y: 0, d: cal().d / 100 };
|
||||
window.addEventListener('pointermove', (e) => {
|
||||
mouseEye.x = (e.clientX / window.innerWidth - 0.5) * 0.6;
|
||||
mouseEye.y = (0.5 - e.clientY / window.innerHeight) * 0.4;
|
||||
});
|
||||
window.addEventListener('wheel', (e) => {
|
||||
mouseEye.d = Math.min(2.5, Math.max(0.2, mouseEye.d + e.deltaY * 0.0005));
|
||||
}, { passive: true });
|
||||
|
||||
// ---- RF Tier B input: /ws/sensing sensing_update.signal_field. ----
|
||||
let ws = null;
|
||||
function connectWs() {
|
||||
if (ws) { try { ws.close(); } catch (_) {} }
|
||||
const url = $('ws-url').value;
|
||||
$('hud-ws').textContent = 'connecting…';
|
||||
try { ws = new WebSocket(url); } catch (e) {
|
||||
$('hud-ws').textContent = 'invalid url'; return;
|
||||
}
|
||||
ws.onopen = () => { $('hud-ws').textContent = 'connected'; };
|
||||
ws.onclose = () => { $('hud-ws').textContent = 'closed'; };
|
||||
ws.onerror = () => { $('hud-ws').textContent = 'error (server up? ticket needed?)'; };
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data);
|
||||
const field = msg.signal_field || (msg.data && msg.data.signal_field);
|
||||
if (!field || !field.values) return;
|
||||
const nx = field.grid_size || field.nx || 20;
|
||||
const nz = field.grid_size || field.nz || 20;
|
||||
const values = Float32Array.from(field.values);
|
||||
const found = rf.update(values, nx, nz, performance.now() / 1000);
|
||||
$('hud-peak').textContent = found
|
||||
? `value ${rf.peak_value().toFixed(2)} (≥ 0.35 gate)`
|
||||
: 'below 0.35 gate — holding';
|
||||
// Provenance surfaced verbatim (ADR-295: synthetic never
|
||||
// presents as live).
|
||||
if (msg.provenance || msg.source) {
|
||||
$('hud-ws').textContent = `connected · src: ${msg.provenance || msg.source}`;
|
||||
}
|
||||
} catch (_) { /* non-JSON frame */ }
|
||||
};
|
||||
}
|
||||
|
||||
// ---- Render loop: one WASM call, three matrix copies, render. ----
|
||||
const tmp = new THREE.Matrix4();
|
||||
let frames = 0, lastFps = performance.now();
|
||||
function animate() {
|
||||
requestAnimationFrame(animate);
|
||||
const t = performance.now() / 1000;
|
||||
|
||||
if (mode === 'mouse') {
|
||||
cam.update_eye(mouseEye.x, mouseEye.y, mouseEye.d, t);
|
||||
} else {
|
||||
const e = rf.eye(); // bounded coarse-parallax eye (metres)
|
||||
cam.update_eye(e[0], e[1], e[2], t);
|
||||
}
|
||||
|
||||
// Copy the Kooima matrices onto the three.js camera.
|
||||
camera.projectionMatrix.fromArray(cam.projection());
|
||||
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
|
||||
tmp.fromArray(cam.view());
|
||||
camera.matrixWorld.copy(tmp).invert(); // world = inverse(view)
|
||||
camera.matrixWorldInverse.copy(tmp);
|
||||
|
||||
const knot = room.getObjectByName('knot');
|
||||
if (knot) { knot.rotation.y += 0.003; knot.rotation.x += 0.001; }
|
||||
|
||||
renderer.render(scene, camera);
|
||||
|
||||
const eye = cam.eye();
|
||||
$('hud-eye').textContent = `${eye[0].toFixed(3)} / ${eye[1].toFixed(3)} / ${eye[2].toFixed(3)}`;
|
||||
frames++;
|
||||
const now = performance.now();
|
||||
if (now - lastFps > 1000) {
|
||||
$('hud-fps').textContent = String(frames);
|
||||
frames = 0; lastFps = now;
|
||||
}
|
||||
}
|
||||
animate();
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
renderer.setSize(window.innerWidth, window.innerHeight);
|
||||
// NOTE: no camera.aspect update — the frustum is fully determined
|
||||
// by the physical screen calibration, not the browser viewport.
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
8
v2/Cargo.lock
generated
8
v2/Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
26
v2/crates/ruview-offaxis/Cargo.toml
Normal file
26
v2/crates/ruview-offaxis/Cargo.toml
Normal file
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "ruview-offaxis"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Clean-room generalized off-axis (head-coupled) perspective projection for RuView demos (ADR-324)"
|
||||
readme = "README.md"
|
||||
|
||||
[lib]
|
||||
# cdylib for the wasm32 build (ADR-324 demo surface); rlib for native reuse.
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
# The native core is dependency-free by design (ADR-324 §2.3): the wasm-bindgen
|
||||
# surface exists only when compiling for wasm32, so the workspace test gate
|
||||
# (`cargo test --workspace --no-default-features`) never pulls JS-interop deps.
|
||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = { workspace = true }
|
||||
|
||||
[[bench]]
|
||||
name = "offaxis"
|
||||
harness = false
|
||||
164
v2/crates/ruview-offaxis/README.md
Normal file
164
v2/crates/ruview-offaxis/README.md
Normal file
@@ -0,0 +1,164 @@
|
||||
# ruview-offaxis
|
||||
|
||||
Clean-room **off-axis (head-coupled) perspective projection** in Rust, with a
|
||||
wasm-bindgen surface for browser demos. Implements ADR-324.
|
||||
|
||||
The screen becomes a window: given the physical screen's corners and the
|
||||
viewer's eye position, the crate produces the asymmetric frustum and
|
||||
screen-aligned view matrix that keep the screen plane fixed while everything
|
||||
behind and in front of it moves with true parallax.
|
||||
|
||||
## Clean-room statement
|
||||
|
||||
ADR-324 records that the prior-art repository (`icurtis1/off-axis-sneaker`)
|
||||
publishes **no license**. No code, assets, or derived text from it appear in
|
||||
this crate. The implementation follows the published math only:
|
||||
|
||||
- Robert Kooima, *Generalized Perspective Projection* (2008) — the
|
||||
`pa`/`pb`/`pc` screen-corner frustum formulation.
|
||||
- Casiez, Roussel & Vogel, *1€ Filter* (CHI 2012) — adaptive tracking-noise
|
||||
smoothing.
|
||||
|
||||
## What's in the crate
|
||||
|
||||
| Module | Contents |
|
||||
|---|---|
|
||||
| `projection` | `Screen` (3 corners, any orientation), `off_axis()` → asymmetric `projection` + screen-aligned `view` matrix (column-major `f64`, the three.js `Matrix4.elements` layout). All failure modes are typed errors — never NaN matrices. |
|
||||
| `filter` | `OneEuro` / `OneEuro3` one-euro filter. Timestamps are injected; the crate never reads a clock. |
|
||||
| `rf` | `field_peak()` — strongest-cell extraction for `/ws/sensing` `signal_field` grids, mirroring the sensing server's `field_localize.rs` constants (`X_SCALE 0.6`, `Z_SCALE 0.5`, `PEAK_THRESHOLD 0.35`). `CoarseParallax` — the **Tier B** stage: deadband + gain + hard clamp + one-euro. `ScreenCalibration` — physical screen (cm) + normalized-head → metric eye mapping (**Tier A** input hook). |
|
||||
| `wasm` (wasm32 only) | `OffAxisCamera` and `RfParallax` wasm-bindgen classes. |
|
||||
|
||||
The native core is **dependency-free**; wasm-bindgen is pulled only when
|
||||
compiling for `wasm32`.
|
||||
|
||||
## Honesty contract (repo rule — read before demoing)
|
||||
|
||||
- A single-link CSI field peak is a *representation of field energy*, *not*
|
||||
metric localization and *never* a head position (see the caveat in
|
||||
`wifi-densepose-sensing-server/src/field_localize.rs`). The Tier B path is
|
||||
therefore **coarse body parallax by construction**: deadbanded,
|
||||
gain-limited, hard-clamped. Do not present it as head tracking; the demo
|
||||
labels it on screen at all times.
|
||||
- Numeric defaults (gains, deadbands, filter cutoffs) are interaction-design
|
||||
choices — `CLAIMED`, not measured performance.
|
||||
- The benchmark numbers below are `MEASURED` with the stated reproducer on
|
||||
the stated machine; re-run locally before relying on them.
|
||||
|
||||
## Native quick start
|
||||
|
||||
```rust
|
||||
use ruview_offaxis::{off_axis, Screen, Vec3};
|
||||
|
||||
// 60 cm × 34 cm screen centered at the origin; eye 65 cm out, 10 cm right.
|
||||
let screen = Screen::centered(0.60, 0.34)?;
|
||||
let oa = off_axis(&screen, Vec3::new(0.10, 0.0, 0.65), 0.05, 100.0)?;
|
||||
let mvp: [f64; 16] = oa.view_projection(); // column-major, GL/three.js layout
|
||||
# Ok::<(), ruview_offaxis::OffAxisError>(())
|
||||
```
|
||||
|
||||
Key invariant (unit-tested for a grid of eye positions and for tilted
|
||||
screens): the physical screen corners always project exactly to the NDC
|
||||
corners — `pa→(−1,−1)`, `pb→(1,−1)`, `pc→(−1,1)`, `pd→(1,1)` — and points on
|
||||
the screen plane are eye-invariant. That is the mathematical definition of
|
||||
"the screen is a window".
|
||||
|
||||
## Building the WASM package
|
||||
|
||||
Generated artifacts are not committed (repo rule). Build once:
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
rustup target add wasm32-unknown-unknown
|
||||
cargo build -p ruview-offaxis --target wasm32-unknown-unknown --release
|
||||
|
||||
# Install the matching CLI once: cargo install wasm-bindgen-cli --version 0.2.114
|
||||
wasm-bindgen --target web --out-dir crates/ruview-offaxis/pkg \
|
||||
target/wasm32-unknown-unknown/release/ruview_offaxis.wasm
|
||||
```
|
||||
|
||||
Output: `pkg/ruview_offaxis.js` + `pkg/ruview_offaxis_bg.wasm`
|
||||
(≈54 KB wasm, `MEASURED` for this crate at wasm-bindgen 0.2.114; `wasm-opt -Oz`
|
||||
can shrink it further if you have binaryen). The demo at
|
||||
`examples/three.js/demos/07-off-axis-window.html` loads this path directly —
|
||||
build, then open the demo. A Node smoke test of the same flow lives in the
|
||||
PR's validation notes.
|
||||
|
||||
## three.js integration (the whole wiring)
|
||||
|
||||
```js
|
||||
import init, { OffAxisCamera, RfParallax } from './pkg/ruview_offaxis.js';
|
||||
await init();
|
||||
|
||||
// Physical calibration in cm — measure your actual screen.
|
||||
const cam = new OffAxisCamera(60, 34, 65, 0.05, 100.0);
|
||||
cam.set_filter(1.2, 0.4); // one-euro: min_cutoff Hz, beta
|
||||
|
||||
const camera = new THREE.PerspectiveCamera();
|
||||
camera.matrixAutoUpdate = false; // WASM owns every matrix
|
||||
|
||||
const view = new THREE.Matrix4();
|
||||
function onFrame(eyeX, eyeY, eyeZ) { // metres, screen space
|
||||
cam.update_eye(eyeX, eyeY, eyeZ, performance.now() / 1000);
|
||||
camera.projectionMatrix.fromArray(cam.projection());
|
||||
camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert();
|
||||
view.fromArray(cam.view());
|
||||
camera.matrixWorld.copy(view).invert();
|
||||
camera.matrixWorldInverse.copy(view);
|
||||
}
|
||||
```
|
||||
|
||||
Scene convention: the screen plane is `z = 0`; content behind the screen has
|
||||
`z < 0`; content with `z > 0` "pops out". Do **not** update `camera.aspect`
|
||||
on resize — the frustum is determined by the physical screen, not the
|
||||
viewport.
|
||||
|
||||
### Input tiers (ADR-324)
|
||||
|
||||
- **Tier A (fine tracker)** — feed any head tracker through
|
||||
`cam.update_normalized(nx, ny, depthScale, lateralRangeM, t)`; `nx`/`ny`
|
||||
are normalized image coordinates from whatever fine tracker the host runs
|
||||
(which stays entirely in the browser). RF adds presence gating around it.
|
||||
- **Tier B (RF only, labeled)** — `RfParallax.update(Float32Array, nx, nz, t)`
|
||||
with `/ws/sensing` `signal_field` values, then `rf.eye()` →
|
||||
`cam.update_eye(...)`. Keep the on-screen "coarse body parallax — not head
|
||||
tracking" label; the clamps in the Rust core bound the excursion but the
|
||||
label is what keeps the demo honest.
|
||||
|
||||
## Benchmarks
|
||||
|
||||
`MEASURED` — reproducer: `cd v2 && cargo bench -p ruview-offaxis`.
|
||||
Environment for the numbers below: Linux x86_64 container (shared/virtualized
|
||||
CPU), rustc 1.89.0, criterion 0.5, 2026-08-16. Treat them as order-of-
|
||||
magnitude; re-run on your hardware.
|
||||
|
||||
| Benchmark | Time (median) |
|
||||
|---|---|
|
||||
| `off_axis_projection` (frustum + view build) | ~76 ns |
|
||||
| `view_projection` combined (4×4 multiply) | ~27 ns |
|
||||
| `one_euro3_step` (3-axis filter step) | ~60 ns |
|
||||
| `field_peak_20x20` (live grid size) | ~488 ns |
|
||||
| `field_peak_100x100` | ~12.3 µs |
|
||||
| `tier_b_full_frame_20x20` (scan → parallax → projection) | ~598 ns |
|
||||
|
||||
The full Tier B per-frame path costs well under a microsecond — under 0.01%
|
||||
of a 60 Hz frame budget. The argmax scan was the only hot spot found; it was
|
||||
rewritten branch-light for a measured −18% (20×20) / −33% (100×100) before
|
||||
these numbers were taken. End-to-end *motion-to-photon* latency (RF capture →
|
||||
render) has **not** been measured and is dominated by the sensing pipeline,
|
||||
not this crate; no figure is claimed.
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
cd v2
|
||||
cargo test -p ruview-offaxis # 23 unit tests + doctest
|
||||
cargo clippy -p ruview-offaxis --all-targets # zero warnings
|
||||
cargo build -p ruview-offaxis --target wasm32-unknown-unknown --release
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- ADR-324 — decision record, tier definitions, licensing analysis
|
||||
- `docs/adr/ADR-282-*` — 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
|
||||
84
v2/crates/ruview-offaxis/benches/offaxis.rs
Normal file
84
v2/crates/ruview-offaxis/benches/offaxis.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
//! Criterion benchmarks for the per-frame hot path (ADR-324).
|
||||
//!
|
||||
//! Reproducer: `cd v2 && cargo bench -p ruview-offaxis`
|
||||
//! Any numbers quoted from this bench are MEASURED on the machine that ran
|
||||
//! that command; re-run locally before relying on them.
|
||||
|
||||
use criterion::{black_box, criterion_group, criterion_main, Criterion};
|
||||
use ruview_offaxis::{
|
||||
field_peak, off_axis, CoarseParallax, CoarseParallaxConfig, OneEuro3, OneEuroConfig, Screen,
|
||||
Vec3,
|
||||
};
|
||||
|
||||
fn bench_projection(c: &mut Criterion) {
|
||||
let screen = Screen::centered(0.6, 0.34).unwrap();
|
||||
c.bench_function("off_axis_projection", |b| {
|
||||
let mut t = 0.0_f64;
|
||||
b.iter(|| {
|
||||
t += 0.016;
|
||||
let eye = Vec3::new(0.1 * t.sin(), 0.05 * t.cos(), 0.65);
|
||||
black_box(off_axis(black_box(&screen), black_box(eye), 0.05, 100.0).unwrap())
|
||||
})
|
||||
});
|
||||
|
||||
c.bench_function("off_axis_view_projection_combined", |b| {
|
||||
let eye = Vec3::new(0.1, -0.03, 0.65);
|
||||
let oa = off_axis(&screen, eye, 0.05, 100.0).unwrap();
|
||||
b.iter(|| black_box(black_box(&oa).view_projection()))
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_filter(c: &mut Criterion) {
|
||||
c.bench_function("one_euro3_step", |b| {
|
||||
let mut f = OneEuro3::new(OneEuroConfig::default());
|
||||
let mut t = 0.0_f64;
|
||||
b.iter(|| {
|
||||
t += 0.016;
|
||||
black_box(f.filter(Vec3::new(t.sin(), t.cos(), 0.65), t))
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
fn make_grid(nx: usize, nz: usize) -> Vec<f32> {
|
||||
// Deterministic pseudo-field with one hot cell.
|
||||
let mut v: Vec<f32> = (0..nx * nz).map(|i| 0.05 + (i % 7) as f32 * 0.01).collect();
|
||||
v[(nz / 3) * nx + nx / 4] = 0.9;
|
||||
v
|
||||
}
|
||||
|
||||
fn bench_field_peak(c: &mut Criterion) {
|
||||
let g20 = make_grid(20, 20);
|
||||
c.bench_function("field_peak_20x20", |b| {
|
||||
b.iter(|| black_box(field_peak(black_box(&g20), 20, 20)))
|
||||
});
|
||||
|
||||
let g100 = make_grid(100, 100);
|
||||
c.bench_function("field_peak_100x100", |b| {
|
||||
b.iter(|| black_box(field_peak(black_box(&g100), 100, 100)))
|
||||
});
|
||||
}
|
||||
|
||||
fn bench_full_tier_b_frame(c: &mut Criterion) {
|
||||
// The whole Tier B per-frame path: grid scan → parallax → projection.
|
||||
let screen = Screen::centered(0.6, 0.34).unwrap();
|
||||
let g20 = make_grid(20, 20);
|
||||
c.bench_function("tier_b_full_frame_20x20", |b| {
|
||||
let mut cp = CoarseParallax::new(CoarseParallaxConfig::default());
|
||||
let mut t = 0.0_f64;
|
||||
b.iter(|| {
|
||||
t += 0.016;
|
||||
let peak = field_peak(black_box(&g20), 20, 20);
|
||||
let eye = cp.update(peak, t);
|
||||
black_box(off_axis(&screen, eye, 0.05, 100.0).unwrap())
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
criterion_group!(
|
||||
benches,
|
||||
bench_projection,
|
||||
bench_filter,
|
||||
bench_field_peak,
|
||||
bench_full_tier_b_frame
|
||||
);
|
||||
criterion_main!(benches);
|
||||
240
v2/crates/ruview-offaxis/src/filter.rs
Normal file
240
v2/crates/ruview-offaxis/src/filter.rs
Normal file
@@ -0,0 +1,240 @@
|
||||
//! One-euro filter (Casiez, Roussel & Vogel, CHI 2012) for eye-position
|
||||
//! smoothing.
|
||||
//!
|
||||
//! Implemented from the published algorithm: an exponential low-pass whose
|
||||
//! cutoff adapts to the signal's speed — low cutoff (heavy smoothing) when
|
||||
//! nearly still, higher cutoff (low lag) when moving fast. This is the
|
||||
//! standard jitter/lag trade-off filter for interactive tracking.
|
||||
//!
|
||||
//! Timestamps are injected by the caller in seconds (monotonic). The crate
|
||||
//! never reads a clock — repo discipline, and it keeps the wasm build free
|
||||
//! of `performance.now()` assumptions.
|
||||
|
||||
use crate::math::Vec3;
|
||||
use core::f64::consts::PI;
|
||||
|
||||
/// One-euro filter parameters.
|
||||
///
|
||||
/// - `min_cutoff` (Hz): smoothing floor. Lower = smoother but laggier at rest.
|
||||
/// - `beta`: speed coefficient. Higher = less lag during fast motion.
|
||||
/// - `d_cutoff` (Hz): cutoff for the internal derivative estimate.
|
||||
///
|
||||
/// Defaults are the paper's recommended starting point (1.0, 0.0, 1.0);
|
||||
/// interactive head tracking typically tunes `min_cutoff` down and `beta` up.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct OneEuroConfig {
|
||||
/// Smoothing floor in Hz (lower = smoother but laggier at rest).
|
||||
pub min_cutoff: f64,
|
||||
/// Speed coefficient (higher = less lag during fast motion).
|
||||
pub beta: f64,
|
||||
/// Cutoff in Hz for the internal derivative estimate.
|
||||
pub d_cutoff: f64,
|
||||
}
|
||||
|
||||
impl Default for OneEuroConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
min_cutoff: 1.0,
|
||||
beta: 0.0,
|
||||
d_cutoff: 1.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Smoothing factor for an exponential low-pass at `cutoff` Hz sampled
|
||||
/// `dt` seconds apart.
|
||||
#[inline]
|
||||
fn alpha(cutoff: f64, dt: f64) -> f64 {
|
||||
let tau = 1.0 / (2.0 * PI * cutoff);
|
||||
1.0 / (1.0 + tau / dt)
|
||||
}
|
||||
|
||||
/// Scalar one-euro filter.
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct OneEuro {
|
||||
cfg: OneEuroConfig,
|
||||
/// `(t, x_hat, dx_hat)` from the previous accepted sample.
|
||||
state: Option<(f64, f64, f64)>,
|
||||
}
|
||||
|
||||
impl OneEuro {
|
||||
/// Create with the given parameters.
|
||||
pub fn new(cfg: OneEuroConfig) -> Self {
|
||||
Self { cfg, state: None }
|
||||
}
|
||||
|
||||
/// Replace the parameters, keeping filter state.
|
||||
pub fn set_config(&mut self, cfg: OneEuroConfig) {
|
||||
self.cfg = cfg;
|
||||
}
|
||||
|
||||
/// Clear state; the next sample passes through unfiltered.
|
||||
pub fn reset(&mut self) {
|
||||
self.state = None;
|
||||
}
|
||||
|
||||
/// Filter sample `x` taken at time `t_s` (seconds). Non-monotonic or
|
||||
/// non-finite input returns the previous estimate unchanged (never NaN).
|
||||
pub fn filter(&mut self, x: f64, t_s: f64) -> f64 {
|
||||
if !x.is_finite() || !t_s.is_finite() {
|
||||
return self.state.map_or(0.0, |(_, xh, _)| xh);
|
||||
}
|
||||
match self.state {
|
||||
None => {
|
||||
self.state = Some((t_s, x, 0.0));
|
||||
x
|
||||
}
|
||||
Some((t0, x0, dx0)) => {
|
||||
let dt = t_s - t0;
|
||||
if dt <= 0.0 {
|
||||
return x0;
|
||||
}
|
||||
let dx = (x - x0) / dt;
|
||||
let a_d = alpha(self.cfg.d_cutoff, dt);
|
||||
let dx_hat = a_d * dx + (1.0 - a_d) * dx0;
|
||||
let cutoff = self.cfg.min_cutoff + self.cfg.beta * dx_hat.abs();
|
||||
let a = alpha(cutoff, dt);
|
||||
let x_hat = a * x + (1.0 - a) * x0;
|
||||
self.state = Some((t_s, x_hat, dx_hat));
|
||||
x_hat
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Component-wise one-euro filter over a [`Vec3`].
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
pub struct OneEuro3 {
|
||||
x: OneEuro,
|
||||
y: OneEuro,
|
||||
z: OneEuro,
|
||||
}
|
||||
|
||||
impl OneEuro3 {
|
||||
/// Create with the same parameters on all three axes.
|
||||
pub fn new(cfg: OneEuroConfig) -> Self {
|
||||
Self {
|
||||
x: OneEuro::new(cfg),
|
||||
y: OneEuro::new(cfg),
|
||||
z: OneEuro::new(cfg),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace parameters on all axes, keeping state.
|
||||
pub fn set_config(&mut self, cfg: OneEuroConfig) {
|
||||
self.x.set_config(cfg);
|
||||
self.y.set_config(cfg);
|
||||
self.z.set_config(cfg);
|
||||
}
|
||||
|
||||
/// Clear state on all axes.
|
||||
pub fn reset(&mut self) {
|
||||
self.x.reset();
|
||||
self.y.reset();
|
||||
self.z.reset();
|
||||
}
|
||||
|
||||
/// Filter a 3-D sample taken at time `t_s` (seconds).
|
||||
pub fn filter(&mut self, v: Vec3, t_s: f64) -> Vec3 {
|
||||
Vec3::new(
|
||||
self.x.filter(v.x, t_s),
|
||||
self.y.filter(v.y, t_s),
|
||||
self.z.filter(v.z, t_s),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn first_sample_passes_through() {
|
||||
let mut f = OneEuro::new(OneEuroConfig::default());
|
||||
assert_eq!(f.filter(3.25, 0.0), 3.25);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn constant_input_stays_constant() {
|
||||
let mut f = OneEuro::new(OneEuroConfig::default());
|
||||
for i in 0..100 {
|
||||
let y = f.filter(1.5, i as f64 * 0.016);
|
||||
assert!((y - 1.5).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn step_response_converges_monotonically() {
|
||||
let mut f = OneEuro::new(OneEuroConfig {
|
||||
min_cutoff: 1.0,
|
||||
beta: 0.0,
|
||||
d_cutoff: 1.0,
|
||||
});
|
||||
f.filter(0.0, 0.0);
|
||||
let mut prev = 0.0;
|
||||
for i in 1..200 {
|
||||
let y = f.filter(1.0, i as f64 * 0.016);
|
||||
assert!(y > prev, "monotone rise");
|
||||
assert!(y <= 1.0 + 1e-12, "no overshoot");
|
||||
prev = y;
|
||||
}
|
||||
assert!(prev > 0.99, "converged near the step, got {prev}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn higher_beta_tracks_fast_motion_closer() {
|
||||
// A fast ramp: the adaptive filter (beta > 0) must lag less than the
|
||||
// pure low-pass (beta = 0).
|
||||
let slow_cfg = OneEuroConfig {
|
||||
min_cutoff: 0.5,
|
||||
beta: 0.0,
|
||||
d_cutoff: 1.0,
|
||||
};
|
||||
let fast_cfg = OneEuroConfig {
|
||||
min_cutoff: 0.5,
|
||||
beta: 1.0,
|
||||
d_cutoff: 1.0,
|
||||
};
|
||||
let mut slow = OneEuro::new(slow_cfg);
|
||||
let mut fast = OneEuro::new(fast_cfg);
|
||||
let mut last = (0.0, 0.0, 0.0);
|
||||
for i in 0..120 {
|
||||
let t = i as f64 * 0.016;
|
||||
let x = t * 2.0; // 2 m/s ramp
|
||||
last = (x, slow.filter(x, t), fast.filter(x, t));
|
||||
}
|
||||
let (x, s, f) = last;
|
||||
assert!(
|
||||
(x - f).abs() < (x - s).abs(),
|
||||
"beta reduces lag: |{x}-{f}| < |{x}-{s}|"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_monotonic_and_non_finite_samples() {
|
||||
let mut f = OneEuro::new(OneEuroConfig::default());
|
||||
f.filter(1.0, 1.0);
|
||||
let settled = f.filter(1.0, 2.0);
|
||||
// Time going backwards: hold the estimate.
|
||||
assert_eq!(f.filter(99.0, 0.5), settled);
|
||||
// NaN input: hold the estimate.
|
||||
assert_eq!(f.filter(f64::NAN, 3.0), settled);
|
||||
// NaN never propagates.
|
||||
let y = f.filter(1.0, 4.0);
|
||||
assert!(y.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vec3_filter_is_componentwise() {
|
||||
let mut f3 = OneEuro3::new(OneEuroConfig::default());
|
||||
let mut fx = OneEuro::new(OneEuroConfig::default());
|
||||
for i in 0..10 {
|
||||
let t = i as f64 * 0.02;
|
||||
let v = Vec3::new(i as f64 * 0.1, -1.0, 2.0);
|
||||
let out = f3.filter(v, t);
|
||||
assert_eq!(out.x, fx.filter(v.x, t));
|
||||
assert_eq!(out.y, -1.0);
|
||||
assert_eq!(out.z, 2.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
67
v2/crates/ruview-offaxis/src/lib.rs
Normal file
67
v2/crates/ruview-offaxis/src/lib.rs
Normal file
@@ -0,0 +1,67 @@
|
||||
//! # `ruview-offaxis` — clean-room off-axis (head-coupled) perspective (ADR-324)
|
||||
//!
|
||||
//! Generalized perspective projection for "window into the screen"
|
||||
//! (fish-tank VR / head-coupled perspective) demos, implemented from the
|
||||
//! published math — Robert Kooima, *Generalized Perspective Projection*
|
||||
//! (2008) — plus the supporting input stages the ADR-324 demo needs:
|
||||
//!
|
||||
//! - [`Screen`] / [`off_axis`]: three physical screen corners + a tracked
|
||||
//! eye → asymmetric frustum and screen-aligned view matrix (column-major
|
||||
//! `f64`, the three.js `Matrix4.elements` layout).
|
||||
//! - [`OneEuro`] / [`OneEuro3`]: the one-euro filter (Casiez et al., CHI
|
||||
//! 2012) for tracking-noise smoothing with injected timestamps.
|
||||
//! - [`rf`]: RuView-specific input mapping — `/ws/sensing` signal-field
|
||||
//! peak extraction (constants mirroring the sensing server's
|
||||
//! `field_localize.rs`) and the bounded **Tier B** coarse-parallax stage.
|
||||
//! - A wasm-bindgen surface (wasm32 only) exposing [`wasm::OffAxisCamera`]
|
||||
//! and [`wasm::RfParallax`] to the browser demos.
|
||||
//!
|
||||
//! ## Clean-room statement
|
||||
//!
|
||||
//! ADR-324 records that the prior-art repository (`icurtis1/off-axis-sneaker`)
|
||||
//! is unlicensed: no code, assets, or derived text from it appear here. This
|
||||
//! crate is written solely from the published Kooima and Casiez papers and
|
||||
//! RuView's own source.
|
||||
//!
|
||||
//! ## Honesty contract (repo rule)
|
||||
//!
|
||||
//! Nothing in this crate asserts sensing accuracy. The Tier B RF path is
|
||||
//! **coarse body parallax by construction** — deadbanded, gain-limited,
|
||||
//! hard-clamped — because a single-link CSI field peak is a representation
|
||||
//! of field energy, not metric localization (see
|
||||
//! `wifi-densepose-sensing-server/src/field_localize.rs`). Numeric defaults
|
||||
//! are interaction-design choices (`CLAIMED`); benchmark numbers live in the
|
||||
//! README tagged `MEASURED` with their reproducer.
|
||||
//!
|
||||
//! ## Native quick start
|
||||
//!
|
||||
//! ```
|
||||
//! use ruview_offaxis::{off_axis, Screen, Vec3};
|
||||
//!
|
||||
//! // A 60 cm × 34 cm screen centered at the origin, eye 65 cm away and
|
||||
//! // 10 cm to the right.
|
||||
//! let screen = Screen::centered(0.60, 0.34)?;
|
||||
//! let oa = off_axis(&screen, Vec3::new(0.10, 0.0, 0.65), 0.05, 100.0)?;
|
||||
//! // Column-major, ready for three.js Matrix4.fromArray / any GL pipeline.
|
||||
//! let _m: [f64; 16] = oa.view_projection();
|
||||
//! # Ok::<(), ruview_offaxis::OffAxisError>(())
|
||||
//! ```
|
||||
|
||||
#![forbid(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
|
||||
pub mod filter;
|
||||
pub mod math;
|
||||
pub mod projection;
|
||||
pub mod rf;
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub mod wasm;
|
||||
|
||||
pub use filter::{OneEuro, OneEuro3, OneEuroConfig};
|
||||
pub use math::{Mat4, Vec3};
|
||||
pub use projection::{off_axis, OffAxis, OffAxisError, Screen, MIN_EYE_DISTANCE};
|
||||
pub use rf::{
|
||||
field_peak, CoarseParallax, CoarseParallaxConfig, FieldPeak, ScreenCalibration,
|
||||
FIELD_PEAK_THRESHOLD, FIELD_X_SCALE, FIELD_Z_SCALE,
|
||||
};
|
||||
177
v2/crates/ruview-offaxis/src/math.rs
Normal file
177
v2/crates/ruview-offaxis/src/math.rs
Normal file
@@ -0,0 +1,177 @@
|
||||
//! Minimal 3-vector and column-major 4×4 matrix helpers.
|
||||
//!
|
||||
//! Deliberately dependency-free: this crate targets wasm32 for the ADR-324
|
||||
//! demo surface, and a `nalgebra` pull would dominate the module size for
|
||||
//! what is a handful of fixed-size operations. Matrices use the OpenGL /
|
||||
//! three.js `Matrix4.elements` layout: column-major, `m[col * 4 + row]`.
|
||||
|
||||
/// A 3-component `f64` vector.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Default)]
|
||||
pub struct Vec3 {
|
||||
/// X component.
|
||||
pub x: f64,
|
||||
/// Y component.
|
||||
pub y: f64,
|
||||
/// Z component.
|
||||
pub z: f64,
|
||||
}
|
||||
|
||||
impl Vec3 {
|
||||
/// Construct from components.
|
||||
#[inline]
|
||||
pub const fn new(x: f64, y: f64, z: f64) -> Self {
|
||||
Self { x, y, z }
|
||||
}
|
||||
|
||||
/// Scale by `s`.
|
||||
#[inline]
|
||||
pub fn scale(self, s: f64) -> Vec3 {
|
||||
Vec3::new(self.x * s, self.y * s, self.z * s)
|
||||
}
|
||||
|
||||
/// Dot product.
|
||||
#[inline]
|
||||
pub fn dot(self, rhs: Vec3) -> f64 {
|
||||
self.x * rhs.x + self.y * rhs.y + self.z * rhs.z
|
||||
}
|
||||
|
||||
/// Cross product (right-handed).
|
||||
#[inline]
|
||||
pub fn cross(self, rhs: Vec3) -> Vec3 {
|
||||
Vec3::new(
|
||||
self.y * rhs.z - self.z * rhs.y,
|
||||
self.z * rhs.x - self.x * rhs.z,
|
||||
self.x * rhs.y - self.y * rhs.x,
|
||||
)
|
||||
}
|
||||
|
||||
/// Euclidean length.
|
||||
#[inline]
|
||||
pub fn length(self) -> f64 {
|
||||
self.dot(self).sqrt()
|
||||
}
|
||||
|
||||
/// Unit vector, or `None` when the length is (near) zero.
|
||||
#[inline]
|
||||
pub fn normalize(self) -> Option<Vec3> {
|
||||
let len = self.length();
|
||||
if len <= f64::EPSILON {
|
||||
None
|
||||
} else {
|
||||
Some(self.scale(1.0 / len))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl core::ops::Sub for Vec3 {
|
||||
type Output = Vec3;
|
||||
#[inline]
|
||||
fn sub(self, rhs: Vec3) -> Vec3 {
|
||||
Vec3::new(self.x - rhs.x, self.y - rhs.y, self.z - rhs.z)
|
||||
}
|
||||
}
|
||||
|
||||
impl core::ops::Add for Vec3 {
|
||||
type Output = Vec3;
|
||||
#[inline]
|
||||
fn add(self, rhs: Vec3) -> Vec3 {
|
||||
Vec3::new(self.x + rhs.x, self.y + rhs.y, self.z + rhs.z)
|
||||
}
|
||||
}
|
||||
|
||||
/// Column-major 4×4 matrix, `m[col * 4 + row]` — the exact layout of
|
||||
/// three.js `Matrix4.elements` / OpenGL, so the array can be passed to
|
||||
/// `Matrix4.fromArray` untouched.
|
||||
pub type Mat4 = [f64; 16];
|
||||
|
||||
/// The identity matrix.
|
||||
pub const IDENTITY: Mat4 = [
|
||||
1.0, 0.0, 0.0, 0.0, //
|
||||
0.0, 1.0, 0.0, 0.0, //
|
||||
0.0, 0.0, 1.0, 0.0, //
|
||||
0.0, 0.0, 0.0, 1.0,
|
||||
];
|
||||
|
||||
/// `a * b` (column-major).
|
||||
#[inline]
|
||||
pub fn mul(a: &Mat4, b: &Mat4) -> Mat4 {
|
||||
let mut out = [0.0; 16];
|
||||
for col in 0..4 {
|
||||
for row in 0..4 {
|
||||
let mut acc = 0.0;
|
||||
for k in 0..4 {
|
||||
acc += a[k * 4 + row] * b[col * 4 + k];
|
||||
}
|
||||
out[col * 4 + row] = acc;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Transform a point (`w = 1`); returns the transformed `(x, y, z)` and `w`
|
||||
/// *before* the perspective divide, so callers can check clip-space signs.
|
||||
#[inline]
|
||||
pub fn transform_point(m: &Mat4, p: Vec3) -> (Vec3, f64) {
|
||||
let x = m[0] * p.x + m[4] * p.y + m[8] * p.z + m[12];
|
||||
let y = m[1] * p.x + m[5] * p.y + m[9] * p.z + m[13];
|
||||
let z = m[2] * p.x + m[6] * p.y + m[10] * p.z + m[14];
|
||||
let w = m[3] * p.x + m[7] * p.y + m[11] * p.z + m[15];
|
||||
(Vec3::new(x, y, z), w)
|
||||
}
|
||||
|
||||
/// Transform a point and apply the perspective divide, yielding normalized
|
||||
/// device coordinates. Returns `None` when `w` is (near) zero.
|
||||
#[inline]
|
||||
pub fn project_point(m: &Mat4, p: Vec3) -> Option<Vec3> {
|
||||
let (v, w) = transform_point(m, p);
|
||||
if w.abs() <= f64::EPSILON {
|
||||
None
|
||||
} else {
|
||||
Some(v.scale(1.0 / w))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn identity_transforms_are_noops() {
|
||||
let p = Vec3::new(1.5, -2.0, 3.25);
|
||||
let (q, w) = transform_point(&IDENTITY, p);
|
||||
assert_eq!(q, p);
|
||||
assert_eq!(w, 1.0);
|
||||
assert_eq!(mul(&IDENTITY, &IDENTITY), IDENTITY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cross_is_right_handed() {
|
||||
let x = Vec3::new(1.0, 0.0, 0.0);
|
||||
let y = Vec3::new(0.0, 1.0, 0.0);
|
||||
assert_eq!(x.cross(y), Vec3::new(0.0, 0.0, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_rejects_zero() {
|
||||
assert!(Vec3::new(0.0, 0.0, 0.0).normalize().is_none());
|
||||
let n = Vec3::new(0.0, 3.0, 4.0).normalize().unwrap();
|
||||
assert!((n.length() - 1.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mul_matches_manual_translation_composition() {
|
||||
// T(a) * T(b) == T(a + b) for translations.
|
||||
let mut ta = IDENTITY;
|
||||
ta[12] = 1.0;
|
||||
ta[13] = 2.0;
|
||||
ta[14] = 3.0;
|
||||
let mut tb = IDENTITY;
|
||||
tb[12] = -4.0;
|
||||
tb[13] = 0.5;
|
||||
tb[14] = 7.0;
|
||||
let tab = mul(&ta, &tb);
|
||||
assert_eq!(tab[12], -3.0);
|
||||
assert_eq!(tab[13], 2.5);
|
||||
assert_eq!(tab[14], 10.0);
|
||||
}
|
||||
}
|
||||
449
v2/crates/ruview-offaxis/src/projection.rs
Normal file
449
v2/crates/ruview-offaxis/src/projection.rs
Normal file
@@ -0,0 +1,449 @@
|
||||
//! Clean-room generalized (off-axis) perspective projection.
|
||||
//!
|
||||
//! Implements the screen-corner formulation published by Robert Kooima,
|
||||
//! "Generalized Perspective Projection" (2008): given a physical screen
|
||||
//! described by three of its corners and a tracked eye position in the same
|
||||
//! tracker space, produce the asymmetric frustum and view transform that make
|
||||
//! the screen behave as a window into the virtual scene.
|
||||
//!
|
||||
//! No code from any existing implementation (including the unlicensed
|
||||
//! `icurtis1/off-axis-sneaker` prior art referenced by ADR-324) was copied or
|
||||
//! consulted while writing this module; the derivation follows the published
|
||||
//! math only.
|
||||
//!
|
||||
//! ## Conventions
|
||||
//!
|
||||
//! - Right-handed tracker space, metres.
|
||||
//! - `pa` = screen lower-left, `pb` = lower-right, `pc` = upper-left corner.
|
||||
//! - The screen normal `vn = vr × vu` points toward the viewer's side; the
|
||||
//! eye must be on that side (`EyeBehindScreen` otherwise).
|
||||
//! - Output matrices are column-major `f64` in the three.js / OpenGL layout
|
||||
//! (see [`crate::math::Mat4`]). NDC follows OpenGL: visible x/y/z in
|
||||
//! `[-1, 1]`, camera looking down `-z` in eye space.
|
||||
|
||||
use crate::math::{mul, Mat4, Vec3, IDENTITY};
|
||||
use core::fmt;
|
||||
|
||||
/// Minimum eye-to-screen-plane distance (metres). Below this the frustum
|
||||
/// degenerates (division by ~0); callers get a typed error instead of NaNs.
|
||||
pub const MIN_EYE_DISTANCE: f64 = 1e-6;
|
||||
|
||||
/// Errors from screen construction or projection evaluation.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum OffAxisError {
|
||||
/// The three corners do not span a plane (coincident or collinear).
|
||||
DegenerateScreen,
|
||||
/// The eye is on or behind the screen plane (`distance <= MIN_EYE_DISTANCE`).
|
||||
EyeBehindScreen,
|
||||
/// `near`/`far` are not `0 < near < far`, or not finite.
|
||||
InvalidClipPlanes,
|
||||
/// A non-finite input coordinate was supplied.
|
||||
NonFiniteInput,
|
||||
}
|
||||
|
||||
impl fmt::Display for OffAxisError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
OffAxisError::DegenerateScreen => {
|
||||
write!(f, "screen corners are coincident or collinear")
|
||||
}
|
||||
OffAxisError::EyeBehindScreen => {
|
||||
write!(f, "eye is on or behind the screen plane")
|
||||
}
|
||||
OffAxisError::InvalidClipPlanes => {
|
||||
write!(f, "clip planes must satisfy 0 < near < far and be finite")
|
||||
}
|
||||
OffAxisError::NonFiniteInput => write!(f, "input coordinate is not finite"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for OffAxisError {}
|
||||
|
||||
/// A physical screen described by three corners, with its orthonormal basis
|
||||
/// precomputed at construction (the basis is eye-independent, so caching it
|
||||
/// keeps the per-frame [`off_axis`] call to the eye-dependent work only).
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Screen {
|
||||
pa: Vec3,
|
||||
pb: Vec3,
|
||||
pc: Vec3,
|
||||
vr: Vec3,
|
||||
vu: Vec3,
|
||||
vn: Vec3,
|
||||
}
|
||||
|
||||
impl Screen {
|
||||
/// Build a screen from its lower-left (`pa`), lower-right (`pb`) and
|
||||
/// upper-left (`pc`) corners, in metres, in any orientation.
|
||||
pub fn new(pa: Vec3, pb: Vec3, pc: Vec3) -> Result<Self, OffAxisError> {
|
||||
for v in [pa, pb, pc] {
|
||||
if !(v.x.is_finite() && v.y.is_finite() && v.z.is_finite()) {
|
||||
return Err(OffAxisError::NonFiniteInput);
|
||||
}
|
||||
}
|
||||
let vr = (pb - pa)
|
||||
.normalize()
|
||||
.ok_or(OffAxisError::DegenerateScreen)?;
|
||||
let vu = (pc - pa)
|
||||
.normalize()
|
||||
.ok_or(OffAxisError::DegenerateScreen)?;
|
||||
let vn = vr
|
||||
.cross(vu)
|
||||
.normalize()
|
||||
.ok_or(OffAxisError::DegenerateScreen)?;
|
||||
Ok(Self {
|
||||
pa,
|
||||
pb,
|
||||
pc,
|
||||
vr,
|
||||
vu,
|
||||
vn,
|
||||
})
|
||||
}
|
||||
|
||||
/// Convenience: an axis-aligned screen of `width_m × height_m` metres,
|
||||
/// centered at the origin in the `z = 0` plane, normal facing `+z`
|
||||
/// (the viewer side). This matches the usual desktop-demo setup where
|
||||
/// the tracker origin is the screen center.
|
||||
pub fn centered(width_m: f64, height_m: f64) -> Result<Self, OffAxisError> {
|
||||
if !(width_m.is_finite() && height_m.is_finite()) {
|
||||
return Err(OffAxisError::NonFiniteInput);
|
||||
}
|
||||
if width_m <= 0.0 || height_m <= 0.0 {
|
||||
return Err(OffAxisError::DegenerateScreen);
|
||||
}
|
||||
let hw = width_m / 2.0;
|
||||
let hh = height_m / 2.0;
|
||||
Screen::new(
|
||||
Vec3::new(-hw, -hh, 0.0),
|
||||
Vec3::new(hw, -hh, 0.0),
|
||||
Vec3::new(-hw, hh, 0.0),
|
||||
)
|
||||
}
|
||||
|
||||
/// Lower-left corner.
|
||||
pub fn pa(&self) -> Vec3 {
|
||||
self.pa
|
||||
}
|
||||
|
||||
/// Lower-right corner.
|
||||
pub fn pb(&self) -> Vec3 {
|
||||
self.pb
|
||||
}
|
||||
|
||||
/// Upper-left corner.
|
||||
pub fn pc(&self) -> Vec3 {
|
||||
self.pc
|
||||
}
|
||||
|
||||
/// The implied upper-right corner `pb + (pc - pa)`.
|
||||
pub fn pd(&self) -> Vec3 {
|
||||
self.pb + self.pc - self.pa
|
||||
}
|
||||
|
||||
/// Unit right vector along the screen's bottom edge.
|
||||
pub fn vr(&self) -> Vec3 {
|
||||
self.vr
|
||||
}
|
||||
|
||||
/// Unit up vector along the screen's left edge.
|
||||
pub fn vu(&self) -> Vec3 {
|
||||
self.vu
|
||||
}
|
||||
|
||||
/// Unit normal, pointing toward the viewer side.
|
||||
pub fn vn(&self) -> Vec3 {
|
||||
self.vn
|
||||
}
|
||||
|
||||
/// Signed distance from `eye` to the screen plane along the normal
|
||||
/// (positive when the eye is on the viewer side).
|
||||
pub fn eye_distance(&self, eye: Vec3) -> f64 {
|
||||
// va = pa - pe; d = -(va · vn)
|
||||
-(self.pa - eye).dot(self.vn)
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of a generalized projection evaluation.
|
||||
///
|
||||
/// `projection` is the asymmetric frustum; `view` is the rigid transform
|
||||
/// (screen-basis rotation + eye translation) taking tracker space into eye
|
||||
/// space. For three.js, either:
|
||||
///
|
||||
/// - set `camera.projectionMatrix` from `projection` and position/orient the
|
||||
/// camera from the eye and screen basis yourself, or
|
||||
/// - use [`OffAxis::view_projection`] as a single combined matrix when you
|
||||
/// manage matrices manually.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct OffAxis {
|
||||
/// Asymmetric perspective frustum (column-major).
|
||||
pub projection: Mat4,
|
||||
/// Screen-aligned view matrix: rotation into the screen basis composed
|
||||
/// with translation by the negated eye position (column-major).
|
||||
pub view: Mat4,
|
||||
}
|
||||
|
||||
impl OffAxis {
|
||||
/// `projection * view` — the full tracker-space-to-clip-space matrix.
|
||||
pub fn view_projection(&self) -> Mat4 {
|
||||
mul(&self.projection, &self.view)
|
||||
}
|
||||
}
|
||||
|
||||
/// OpenGL-convention asymmetric frustum matrix (column-major).
|
||||
#[inline]
|
||||
fn frustum(l: f64, r: f64, b: f64, t: f64, n: f64, f: f64) -> Mat4 {
|
||||
let mut m = [0.0; 16];
|
||||
m[0] = 2.0 * n / (r - l);
|
||||
m[5] = 2.0 * n / (t - b);
|
||||
m[8] = (r + l) / (r - l);
|
||||
m[9] = (t + b) / (t - b);
|
||||
m[10] = -(f + n) / (f - n);
|
||||
m[11] = -1.0;
|
||||
m[14] = -2.0 * f * n / (f - n);
|
||||
m
|
||||
}
|
||||
|
||||
/// Evaluate the generalized off-axis projection for `screen` as seen from
|
||||
/// `eye`, with the given clip planes.
|
||||
///
|
||||
/// Errors when the eye is on/behind the screen plane, when clip planes are
|
||||
/// invalid, or when inputs are non-finite. Never returns NaN-bearing
|
||||
/// matrices: every failure mode is a typed error (renderer-facing code must
|
||||
/// hold the last good matrix on error, not draw garbage).
|
||||
pub fn off_axis(screen: &Screen, eye: Vec3, near: f64, far: f64) -> Result<OffAxis, OffAxisError> {
|
||||
if !(eye.x.is_finite() && eye.y.is_finite() && eye.z.is_finite()) {
|
||||
return Err(OffAxisError::NonFiniteInput);
|
||||
}
|
||||
if !(near.is_finite() && far.is_finite()) || near <= 0.0 || far <= near {
|
||||
return Err(OffAxisError::InvalidClipPlanes);
|
||||
}
|
||||
|
||||
let (vr, vu, vn) = (screen.vr, screen.vu, screen.vn);
|
||||
|
||||
// Vectors from the eye to each screen corner.
|
||||
let va = screen.pa - eye;
|
||||
let vb = screen.pb - eye;
|
||||
let vc = screen.pc - eye;
|
||||
|
||||
// Distance from the eye to the screen plane.
|
||||
let d = -va.dot(vn);
|
||||
if d <= MIN_EYE_DISTANCE {
|
||||
return Err(OffAxisError::EyeBehindScreen);
|
||||
}
|
||||
|
||||
// Frustum extents on the near plane.
|
||||
let nd = near / d;
|
||||
let l = vr.dot(va) * nd;
|
||||
let r = vr.dot(vb) * nd;
|
||||
let b = vu.dot(va) * nd;
|
||||
let t = vu.dot(vc) * nd;
|
||||
|
||||
let projection = frustum(l, r, b, t, near, far);
|
||||
|
||||
// View = screen-basis rotation (rows vr/vu/vn) * translation by -eye.
|
||||
// Composed directly: the translation column is -R^T-rotated eye.
|
||||
let mut view = IDENTITY;
|
||||
view[0] = vr.x;
|
||||
view[4] = vr.y;
|
||||
view[8] = vr.z;
|
||||
view[1] = vu.x;
|
||||
view[5] = vu.y;
|
||||
view[9] = vu.z;
|
||||
view[2] = vn.x;
|
||||
view[6] = vn.y;
|
||||
view[10] = vn.z;
|
||||
view[12] = -vr.dot(eye);
|
||||
view[13] = -vu.dot(eye);
|
||||
view[14] = -vn.dot(eye);
|
||||
|
||||
Ok(OffAxis { projection, view })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::math::project_point;
|
||||
|
||||
const EPS: f64 = 1e-9;
|
||||
|
||||
fn assert_close(a: f64, b: f64, eps: f64, what: &str) {
|
||||
assert!((a - b).abs() < eps, "{what}: {a} vs {b}");
|
||||
}
|
||||
|
||||
/// The defining invariant of off-axis projection: for ANY valid eye
|
||||
/// position, the physical screen corners land exactly on the NDC corners
|
||||
/// — pa→(-1,-1), pb→(1,-1), pc→(-1,1), pd→(1,1).
|
||||
#[test]
|
||||
fn screen_corners_map_to_ndc_corners_for_all_eyes() {
|
||||
let screen = Screen::centered(0.6, 0.34).unwrap();
|
||||
let mut checked = 0;
|
||||
for xi in -4..=4 {
|
||||
for yi in -3..=3 {
|
||||
for zi in 1..=6 {
|
||||
let eye = Vec3::new(xi as f64 * 0.25, yi as f64 * 0.2, zi as f64 * 0.35);
|
||||
let oa = off_axis(&screen, eye, 0.05, 100.0).unwrap();
|
||||
let m = oa.view_projection();
|
||||
for (corner, ex, ey) in [
|
||||
(screen.pa(), -1.0, -1.0),
|
||||
(screen.pb(), 1.0, -1.0),
|
||||
(screen.pc(), -1.0, 1.0),
|
||||
(screen.pd(), 1.0, 1.0),
|
||||
] {
|
||||
let ndc = project_point(&m, corner).unwrap();
|
||||
assert_close(ndc.x, ex, EPS, "ndc.x");
|
||||
assert_close(ndc.y, ey, EPS, "ndc.y");
|
||||
}
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(checked, 9 * 7 * 6);
|
||||
}
|
||||
|
||||
/// The invariant survives an arbitrarily rotated + translated screen:
|
||||
/// the math is coordinate-frame independent.
|
||||
#[test]
|
||||
fn corner_invariant_holds_for_tilted_screen() {
|
||||
// A screen rotated ~30° about y and ~15° about x, shifted off origin.
|
||||
let (cy, sy) = (30f64.to_radians().cos(), 30f64.to_radians().sin());
|
||||
let (cx, sx) = (15f64.to_radians().cos(), 15f64.to_radians().sin());
|
||||
let rot = |v: Vec3| {
|
||||
// Ry then Rx.
|
||||
let v = Vec3::new(cy * v.x + sy * v.z, v.y, -sy * v.x + cy * v.z);
|
||||
Vec3::new(v.x, cx * v.y - sx * v.z, sx * v.y + cx * v.z)
|
||||
};
|
||||
let shift = Vec3::new(0.4, -0.2, 1.1);
|
||||
let pa = rot(Vec3::new(-0.3, -0.17, 0.0)) + shift;
|
||||
let pb = rot(Vec3::new(0.3, -0.17, 0.0)) + shift;
|
||||
let pc = rot(Vec3::new(-0.3, 0.17, 0.0)) + shift;
|
||||
let screen = Screen::new(pa, pb, pc).unwrap();
|
||||
|
||||
// An eye on the viewer side of the tilted plane.
|
||||
let eye = shift + rot(Vec3::new(0.1, 0.05, 0.8));
|
||||
let m = off_axis(&screen, eye, 0.01, 50.0)
|
||||
.unwrap()
|
||||
.view_projection();
|
||||
for (corner, ex, ey) in [
|
||||
(screen.pa(), -1.0, -1.0),
|
||||
(screen.pb(), 1.0, -1.0),
|
||||
(screen.pc(), -1.0, 1.0),
|
||||
(screen.pd(), 1.0, 1.0),
|
||||
] {
|
||||
let ndc = project_point(&m, corner).unwrap();
|
||||
assert_close(ndc.x, ex, EPS, "tilted ndc.x");
|
||||
assert_close(ndc.y, ey, EPS, "tilted ndc.y");
|
||||
}
|
||||
}
|
||||
|
||||
/// A centered eye must reduce to the ordinary symmetric perspective
|
||||
/// frustum: l == -r, b == -t, and the projection matrix has no skew
|
||||
/// terms (m[8] == m[9] == 0).
|
||||
#[test]
|
||||
fn centered_eye_is_symmetric_perspective() {
|
||||
let screen = Screen::centered(0.6, 0.34).unwrap();
|
||||
let d = 0.7;
|
||||
let near = 0.05;
|
||||
let oa = off_axis(&screen, Vec3::new(0.0, 0.0, d), near, 100.0).unwrap();
|
||||
assert_close(oa.projection[8], 0.0, EPS, "skew x");
|
||||
assert_close(oa.projection[9], 0.0, EPS, "skew y");
|
||||
// fovy check: m[5] == 1/tan(fovy/2), tan(fovy/2) = (h/2)/d.
|
||||
let expected_m5 = d / (0.34 / 2.0);
|
||||
assert_close(oa.projection[5], expected_m5, 1e-9, "m[5] focal");
|
||||
// aspect: m[0] == m[5]/aspect.
|
||||
let expected_m0 = d / (0.6 / 2.0);
|
||||
assert_close(oa.projection[0], expected_m0, 1e-9, "m[0] focal");
|
||||
}
|
||||
|
||||
/// Depth convention: a point at `near` in front of the eye maps to NDC
|
||||
/// z = -1; a point at `far` maps to +1 (OpenGL convention).
|
||||
#[test]
|
||||
fn near_and_far_map_to_ndc_depth_bounds() {
|
||||
let screen = Screen::centered(0.6, 0.34).unwrap();
|
||||
let eye = Vec3::new(0.12, -0.05, 0.65);
|
||||
let (near, far) = (0.05, 40.0);
|
||||
let m = off_axis(&screen, eye, near, far).unwrap().view_projection();
|
||||
// Looking direction in tracker space is -vn.
|
||||
let toward = screen.vn().scale(-1.0);
|
||||
let p_near = eye + toward.scale(near);
|
||||
let p_far = eye + toward.scale(far);
|
||||
assert_close(project_point(&m, p_near).unwrap().z, -1.0, 1e-7, "near z");
|
||||
assert_close(project_point(&m, p_far).unwrap().z, 1.0, 1e-7, "far z");
|
||||
}
|
||||
|
||||
/// A point exactly on the screen plane keeps the same NDC (x, y) for
|
||||
/// every eye position — this is the "window" property: the screen
|
||||
/// surface itself is the fixed point of the illusion.
|
||||
#[test]
|
||||
fn screen_plane_points_are_eye_invariant() {
|
||||
let screen = Screen::centered(0.5, 0.3).unwrap();
|
||||
// A point 30% right / 20% up from screen center, on the plane.
|
||||
let p = Vec3::new(0.5 * 0.3, 0.3 * 0.2, 0.0);
|
||||
let mut first: Option<(f64, f64)> = None;
|
||||
for eye in [
|
||||
Vec3::new(0.0, 0.0, 0.6),
|
||||
Vec3::new(0.3, 0.1, 0.4),
|
||||
Vec3::new(-0.5, -0.2, 1.2),
|
||||
] {
|
||||
let m = off_axis(&screen, eye, 0.05, 100.0)
|
||||
.unwrap()
|
||||
.view_projection();
|
||||
let ndc = project_point(&m, p).unwrap();
|
||||
match first {
|
||||
None => first = Some((ndc.x, ndc.y)),
|
||||
Some((fx, fy)) => {
|
||||
assert_close(ndc.x, fx, EPS, "plane-point ndc.x eye-invariance");
|
||||
assert_close(ndc.y, fy, EPS, "plane-point ndc.y eye-invariance");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_paths_are_typed() {
|
||||
let screen = Screen::centered(0.6, 0.34).unwrap();
|
||||
let ok_eye = Vec3::new(0.0, 0.0, 0.5);
|
||||
// Degenerate screens.
|
||||
let p = Vec3::new(0.0, 0.0, 0.0);
|
||||
assert_eq!(
|
||||
Screen::new(p, p, Vec3::new(0.0, 1.0, 0.0)).unwrap_err(),
|
||||
OffAxisError::DegenerateScreen
|
||||
);
|
||||
assert_eq!(
|
||||
Screen::new(p, Vec3::new(1.0, 0.0, 0.0), Vec3::new(2.0, 0.0, 0.0)).unwrap_err(),
|
||||
OffAxisError::DegenerateScreen
|
||||
);
|
||||
// Eye behind / on the plane.
|
||||
assert_eq!(
|
||||
off_axis(&screen, Vec3::new(0.0, 0.0, -0.5), 0.05, 10.0).unwrap_err(),
|
||||
OffAxisError::EyeBehindScreen
|
||||
);
|
||||
assert_eq!(
|
||||
off_axis(&screen, Vec3::new(0.2, 0.1, 0.0), 0.05, 10.0).unwrap_err(),
|
||||
OffAxisError::EyeBehindScreen
|
||||
);
|
||||
// Bad clip planes.
|
||||
assert_eq!(
|
||||
off_axis(&screen, ok_eye, 0.0, 10.0).unwrap_err(),
|
||||
OffAxisError::InvalidClipPlanes
|
||||
);
|
||||
assert_eq!(
|
||||
off_axis(&screen, ok_eye, 1.0, 1.0).unwrap_err(),
|
||||
OffAxisError::InvalidClipPlanes
|
||||
);
|
||||
assert_eq!(
|
||||
off_axis(&screen, ok_eye, -1.0, 10.0).unwrap_err(),
|
||||
OffAxisError::InvalidClipPlanes
|
||||
);
|
||||
// Non-finite input.
|
||||
assert_eq!(
|
||||
off_axis(&screen, Vec3::new(f64::NAN, 0.0, 0.5), 0.05, 10.0).unwrap_err(),
|
||||
OffAxisError::NonFiniteInput
|
||||
);
|
||||
assert_eq!(
|
||||
off_axis(&screen, ok_eye, f64::INFINITY, f64::INFINITY).unwrap_err(),
|
||||
OffAxisError::InvalidClipPlanes
|
||||
);
|
||||
}
|
||||
}
|
||||
442
v2/crates/ruview-offaxis/src/rf.rs
Normal file
442
v2/crates/ruview-offaxis/src/rf.rs
Normal file
@@ -0,0 +1,442 @@
|
||||
//! RF-input mapping for the ADR-324 demo tiers.
|
||||
//!
|
||||
//! This module turns RuView's *existing* RF surfaces into a bounded,
|
||||
//! honestly-labeled eye position for the off-axis camera:
|
||||
//!
|
||||
//! - [`field_peak`] extracts the strongest cell of a `/ws/sensing`
|
||||
//! `signal_field` grid using the **same** grid→world mapping as the
|
||||
//! sensing server's `field_localize.rs` (constants mirrored below, with
|
||||
//! the same honesty caveat: a single-link field peak is a representation
|
||||
//! of where field energy concentrates, **not** calibrated metric
|
||||
//! localization, and never a head position).
|
||||
//! - [`CoarseParallax`] converts field-peak motion into a **Tier B** eye
|
||||
//! offset: deadbanded, gain-limited, hard-clamped, one-euro filtered.
|
||||
//! Its output is coarse body parallax by construction — the clamps make
|
||||
//! over-claiming impossible at the API level.
|
||||
//! - [`ScreenCalibration`] holds the physical screen measurements and maps
|
||||
//! a normalized head position (Tier A, from any fine tracker the host
|
||||
//! provides) into metric eye coordinates in screen space.
|
||||
//!
|
||||
//! Evidence discipline: nothing in this module asserts an accuracy number.
|
||||
//! All numeric defaults are interaction-design choices (`CLAIMED`), not
|
||||
//! measured performance.
|
||||
|
||||
use crate::filter::{OneEuro, OneEuroConfig};
|
||||
use crate::math::Vec3;
|
||||
|
||||
/// Grid-cell → world X scale (metres per cell), mirroring
|
||||
/// `wifi-densepose-sensing-server/src/field_localize.rs::X_SCALE`.
|
||||
pub const FIELD_X_SCALE: f64 = 0.6;
|
||||
/// Grid-cell → world Z scale (metres per cell), mirroring
|
||||
/// `field_localize.rs::Z_SCALE`.
|
||||
pub const FIELD_Z_SCALE: f64 = 0.5;
|
||||
/// Minimum normalized field value for a cell to count as a real peak,
|
||||
/// mirroring `field_localize.rs::PEAK_THRESHOLD`.
|
||||
pub const FIELD_PEAK_THRESHOLD: f64 = 0.35;
|
||||
|
||||
/// The strongest cell of a signal-field grid, in the demo's world mapping.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct FieldPeak {
|
||||
/// World X (metres): `(ix - nx/2) * FIELD_X_SCALE`.
|
||||
pub x: f64,
|
||||
/// World Z (metres): `(iz - nz/2) * FIELD_Z_SCALE`.
|
||||
pub z: f64,
|
||||
/// The peak's normalized field value in `[0, 1]`.
|
||||
pub value: f64,
|
||||
/// Grid column of the peak.
|
||||
pub ix: usize,
|
||||
/// Grid row of the peak.
|
||||
pub iz: usize,
|
||||
}
|
||||
|
||||
/// Find the strongest field cell at or above [`FIELD_PEAK_THRESHOLD`].
|
||||
///
|
||||
/// `values` is row-major with the sensing server's layout
|
||||
/// (`idx = iz * nx + ix`). Returns `None` when the slice length doesn't
|
||||
/// match `nx * nz`, when the grid is empty, or when no cell reaches the
|
||||
/// threshold (an honest "no localizable hotspot" outcome, mirroring the
|
||||
/// server's gating).
|
||||
pub fn field_peak(values: &[f32], nx: usize, nz: usize) -> Option<FieldPeak> {
|
||||
field_peak_with_threshold(values, nx, nz, FIELD_PEAK_THRESHOLD)
|
||||
}
|
||||
|
||||
/// [`field_peak`] with a caller-supplied threshold (used by tests and by
|
||||
/// demos that want to visualize sub-threshold energy without moving the
|
||||
/// camera).
|
||||
pub fn field_peak_with_threshold(
|
||||
values: &[f32],
|
||||
nx: usize,
|
||||
nz: usize,
|
||||
threshold: f64,
|
||||
) -> Option<FieldPeak> {
|
||||
if nx == 0 || nz == 0 || values.len() != nx * nz {
|
||||
return None;
|
||||
}
|
||||
// Branch-light argmax: a NaN never satisfies `v > best_v`, so non-finite
|
||||
// cells are skipped without an explicit is_finite() in the hot loop
|
||||
// (+inf is excluded by the finite check on the winner below).
|
||||
let mut best_idx = usize::MAX;
|
||||
let mut best_v = f32::NEG_INFINITY;
|
||||
for (idx, &v) in values.iter().enumerate() {
|
||||
if v > best_v {
|
||||
best_v = v;
|
||||
best_idx = idx;
|
||||
}
|
||||
}
|
||||
if best_idx == usize::MAX || !best_v.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let (idx, v) = (best_idx, best_v);
|
||||
// Compare in f32: grid values arrive as f32 (Float32Array), and casting
|
||||
// 0.35_f32 up to f64 lands a hair below a 0.35_f64 threshold.
|
||||
if v < threshold as f32 {
|
||||
return None;
|
||||
}
|
||||
let ix = idx % nx;
|
||||
let iz = idx / nx;
|
||||
Some(FieldPeak {
|
||||
x: (ix as f64 - nx as f64 / 2.0) * FIELD_X_SCALE,
|
||||
z: (iz as f64 - nz as f64 / 2.0) * FIELD_Z_SCALE,
|
||||
value: v as f64,
|
||||
ix,
|
||||
iz,
|
||||
})
|
||||
}
|
||||
|
||||
/// Tier B parameters. Every default is an interaction-design choice
|
||||
/// (`CLAIMED`), deliberately conservative so the mode reads as what it is:
|
||||
/// slow body-scale parallax, not head tracking.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct CoarseParallaxConfig {
|
||||
/// Eye metres produced per body metre of peak movement (≤ 1 keeps the
|
||||
/// effect visibly sub-physical).
|
||||
pub gain: f64,
|
||||
/// Peak movement below this (metres, from the session origin) is
|
||||
/// ignored entirely — RF field peaks jitter, and the deadband keeps a
|
||||
/// still room visually still.
|
||||
pub deadband_m: f64,
|
||||
/// Hard clamp on the |x| eye offset (metres). The API cannot emit a
|
||||
/// larger excursion regardless of input.
|
||||
pub max_offset_m: f64,
|
||||
/// Nominal viewing distance (metres) used as the eye's Z when no depth
|
||||
/// modulation applies.
|
||||
pub base_distance_m: f64,
|
||||
/// One-euro parameters for the offset filter. Tier B wants heavy
|
||||
/// smoothing: default `min_cutoff` is well below the Tier A default.
|
||||
pub filter: OneEuroConfig,
|
||||
}
|
||||
|
||||
impl Default for CoarseParallaxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
gain: 0.5,
|
||||
deadband_m: 0.15,
|
||||
max_offset_m: 0.35,
|
||||
base_distance_m: 0.65,
|
||||
filter: OneEuroConfig {
|
||||
min_cutoff: 0.4,
|
||||
beta: 0.2,
|
||||
d_cutoff: 1.0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tier B: field-peak motion → bounded, smoothed eye position.
|
||||
///
|
||||
/// The first accepted peak establishes a session origin; subsequent peaks
|
||||
/// move the eye relative to it. Peaks below threshold (i.e. `None` from
|
||||
/// [`field_peak`]) hold the last eye position — the camera never snaps.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct CoarseParallax {
|
||||
cfg: CoarseParallaxConfig,
|
||||
fx: OneEuro,
|
||||
fz: OneEuro,
|
||||
origin: Option<(f64, f64)>,
|
||||
eye: Vec3,
|
||||
}
|
||||
|
||||
impl CoarseParallax {
|
||||
/// Create with the given configuration.
|
||||
pub fn new(cfg: CoarseParallaxConfig) -> Self {
|
||||
Self {
|
||||
cfg,
|
||||
fx: OneEuro::new(cfg.filter),
|
||||
fz: OneEuro::new(cfg.filter),
|
||||
origin: None,
|
||||
eye: Vec3::new(0.0, 0.0, cfg.base_distance_m),
|
||||
}
|
||||
}
|
||||
|
||||
/// The current (last computed) eye position in screen space (metres).
|
||||
pub fn eye(&self) -> Vec3 {
|
||||
self.eye
|
||||
}
|
||||
|
||||
/// Forget the session origin and filter state; the eye returns to the
|
||||
/// centered rest position.
|
||||
pub fn reset(&mut self) {
|
||||
self.origin = None;
|
||||
self.fx.reset();
|
||||
self.fz.reset();
|
||||
self.eye = Vec3::new(0.0, 0.0, self.cfg.base_distance_m);
|
||||
}
|
||||
|
||||
/// Ingest a field peak observed at `t_s` seconds; returns the updated
|
||||
/// eye position. Call with the output of [`field_peak`]; pass `None`
|
||||
/// (below-threshold field) to hold the current position.
|
||||
pub fn update(&mut self, peak: Option<FieldPeak>, t_s: f64) -> Vec3 {
|
||||
let Some(p) = peak else { return self.eye };
|
||||
let (ox, oz) = *self.origin.get_or_insert((p.x, p.z));
|
||||
let dx = deadband(p.x - ox, self.cfg.deadband_m);
|
||||
let dz = deadband(p.z - oz, self.cfg.deadband_m);
|
||||
let raw_x = (dx * self.cfg.gain).clamp(-self.cfg.max_offset_m, self.cfg.max_offset_m);
|
||||
// Peak Z (room depth) modulates viewing distance, same bound.
|
||||
let raw_z = (dz * self.cfg.gain).clamp(-self.cfg.max_offset_m, self.cfg.max_offset_m);
|
||||
let x = self.fx.filter(raw_x, t_s);
|
||||
let z_off = self.fz.filter(raw_z, t_s);
|
||||
// Keep the eye strictly in front of the screen: distance floor at
|
||||
// half the nominal distance.
|
||||
let z = (self.cfg.base_distance_m + z_off).max(self.cfg.base_distance_m * 0.5);
|
||||
self.eye = Vec3::new(x, 0.0, z);
|
||||
self.eye
|
||||
}
|
||||
}
|
||||
|
||||
/// Zero inside `±band`, shifted toward zero outside it (continuous at the
|
||||
/// band edge, so motion doesn't jump when leaving the deadband).
|
||||
#[inline]
|
||||
fn deadband(v: f64, band: f64) -> f64 {
|
||||
if v > band {
|
||||
v - band
|
||||
} else if v < -band {
|
||||
v + band
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Physical screen calibration (Tier A): metric screen size plus the
|
||||
/// viewer's nominal distance. Mirrors the concept of a measured-screen
|
||||
/// calibration wizard; values persist wherever the host keeps them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct ScreenCalibration {
|
||||
/// Physical screen width in metres.
|
||||
pub width_m: f64,
|
||||
/// Physical screen height in metres.
|
||||
pub height_m: f64,
|
||||
/// Nominal eye-to-screen distance in metres.
|
||||
pub base_distance_m: f64,
|
||||
}
|
||||
|
||||
impl ScreenCalibration {
|
||||
/// Construct from centimetre measurements (how humans measure screens).
|
||||
pub fn from_cm(width_cm: f64, height_cm: f64, distance_cm: f64) -> Self {
|
||||
Self {
|
||||
width_m: width_cm / 100.0,
|
||||
height_m: height_cm / 100.0,
|
||||
base_distance_m: distance_cm / 100.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// The screen this calibration describes, centered at the origin
|
||||
/// (see [`crate::Screen::centered`]).
|
||||
pub fn screen(&self) -> Result<crate::Screen, crate::OffAxisError> {
|
||||
crate::Screen::centered(self.width_m, self.height_m)
|
||||
}
|
||||
|
||||
/// Map a normalized head position from a fine tracker into metric eye
|
||||
/// coordinates in screen space.
|
||||
///
|
||||
/// - `nx`, `ny`: head position in normalized image coordinates
|
||||
/// (`[0, 1]`, origin top-left, x rightward — the usual camera-image
|
||||
/// convention). The image is assumed mirrored (selfie view), so a
|
||||
/// viewer moving to *their* left moves the eye left in screen space.
|
||||
/// - `depth_scale`: multiplier on the nominal distance (1.0 = at the
|
||||
/// calibrated distance; a fine tracker derives it from e.g. apparent
|
||||
/// inter-feature distance). Clamped to `[0.25, 4.0]`.
|
||||
/// - `lateral_range_m`: metres of eye travel represented by the full
|
||||
/// image width/height. Deployment-specific; the screen width is a
|
||||
/// reasonable default.
|
||||
pub fn eye_from_normalized(
|
||||
&self,
|
||||
nx: f64,
|
||||
ny: f64,
|
||||
depth_scale: f64,
|
||||
lateral_range_m: f64,
|
||||
) -> Vec3 {
|
||||
let cx = (nx.clamp(0.0, 1.0) - 0.5) * lateral_range_m;
|
||||
let cy = (0.5 - ny.clamp(0.0, 1.0)) * lateral_range_m * (self.height_m / self.width_m);
|
||||
let z = self.base_distance_m * depth_scale.clamp(0.25, 4.0);
|
||||
Vec3::new(cx, cy, z)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn grid(nx: usize, nz: usize, hot: &[(usize, usize, f32)]) -> Vec<f32> {
|
||||
let mut v = vec![0.05_f32; nx * nz];
|
||||
for &(ix, iz, val) in hot {
|
||||
v[iz * nx + ix] = val;
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peak_mapping_matches_field_localize_layout() {
|
||||
// 20×20 grid, hot cell at (ix=15, iz=4).
|
||||
let g = grid(20, 20, &[(15, 4, 0.9)]);
|
||||
let p = field_peak(&g, 20, 20).unwrap();
|
||||
assert_eq!(p.ix, 15);
|
||||
assert_eq!(p.iz, 4);
|
||||
// world_x = (15 - 10) * 0.6, world_z = (4 - 10) * 0.5 — the exact
|
||||
// transform documented in field_localize.rs / the Observatory.
|
||||
assert!((p.x - 3.0).abs() < 1e-12);
|
||||
assert!((p.z - -3.0).abs() < 1e-12);
|
||||
assert!((p.value - 0.9).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn below_threshold_and_malformed_grids_yield_none() {
|
||||
let g = grid(20, 20, &[(3, 3, 0.34)]);
|
||||
assert_eq!(field_peak(&g, 20, 20), None, "0.34 < threshold 0.35");
|
||||
assert!(field_peak(&g, 19, 20).is_none(), "length mismatch");
|
||||
assert!(field_peak(&[], 0, 0).is_none(), "empty grid");
|
||||
// NaN cells are skipped, not propagated.
|
||||
let mut g = grid(4, 4, &[(1, 1, 0.8)]);
|
||||
g[0] = f32::NAN;
|
||||
let p = field_peak(&g, 4, 4).unwrap();
|
||||
assert_eq!((p.ix, p.iz), (1, 1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_threshold_is_accepted() {
|
||||
let g = grid(20, 20, &[(2, 2, 0.35)]);
|
||||
assert!(field_peak(&g, 20, 20).is_some(), "threshold is inclusive");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coarse_parallax_is_deadbanded_gained_and_clamped() {
|
||||
let cfg = CoarseParallaxConfig::default();
|
||||
let mut cp = CoarseParallax::new(cfg);
|
||||
|
||||
// First peak sets the origin: eye stays at rest.
|
||||
let origin = FieldPeak {
|
||||
x: 1.2,
|
||||
z: -0.5,
|
||||
value: 0.8,
|
||||
ix: 12,
|
||||
iz: 9,
|
||||
};
|
||||
let eye0 = cp.update(Some(origin), 0.0);
|
||||
assert_eq!(eye0, Vec3::new(0.0, 0.0, cfg.base_distance_m));
|
||||
|
||||
// Movement inside the deadband: still at rest.
|
||||
let small = FieldPeak {
|
||||
x: 1.2 + 0.1,
|
||||
..origin
|
||||
};
|
||||
let eye1 = cp.update(Some(small), 0.5);
|
||||
assert_eq!(eye1.x, 0.0, "0.1 m < 0.15 m deadband");
|
||||
|
||||
// A huge excursion is clamped to max_offset regardless of gain.
|
||||
let huge = FieldPeak {
|
||||
x: 1.2 + 50.0,
|
||||
..origin
|
||||
};
|
||||
let mut eye = Vec3::default();
|
||||
for i in 0..600 {
|
||||
eye = cp.update(Some(huge), 1.0 + i as f64 * 0.05);
|
||||
}
|
||||
assert!(eye.x <= cfg.max_offset_m + 1e-9, "clamped: {}", eye.x);
|
||||
assert!(
|
||||
eye.x > cfg.max_offset_m * 0.9,
|
||||
"filter converged near clamp"
|
||||
);
|
||||
|
||||
// Below-threshold (None) holds position; never snaps back.
|
||||
let held = cp.update(None, 100.0);
|
||||
assert_eq!(held, eye);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coarse_parallax_depth_never_crosses_the_screen() {
|
||||
let cfg = CoarseParallaxConfig::default();
|
||||
let mut cp = CoarseParallax::new(cfg);
|
||||
let origin = FieldPeak {
|
||||
x: 0.0,
|
||||
z: 0.0,
|
||||
value: 0.9,
|
||||
ix: 10,
|
||||
iz: 10,
|
||||
};
|
||||
cp.update(Some(origin), 0.0);
|
||||
// Walk far toward the screen (negative z offset).
|
||||
let close = FieldPeak { z: -50.0, ..origin };
|
||||
let mut eye = Vec3::default();
|
||||
for i in 0..600 {
|
||||
eye = cp.update(Some(close), 0.1 + i as f64 * 0.05);
|
||||
}
|
||||
assert!(
|
||||
eye.z >= cfg.base_distance_m * 0.5 - 1e-9,
|
||||
"distance floor: {}",
|
||||
eye.z
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_returns_to_rest() {
|
||||
let mut cp = CoarseParallax::new(CoarseParallaxConfig::default());
|
||||
cp.update(
|
||||
Some(FieldPeak {
|
||||
x: 0.0,
|
||||
z: 0.0,
|
||||
value: 0.9,
|
||||
ix: 0,
|
||||
iz: 0,
|
||||
}),
|
||||
0.0,
|
||||
);
|
||||
cp.update(
|
||||
Some(FieldPeak {
|
||||
x: 9.0,
|
||||
z: 0.0,
|
||||
value: 0.9,
|
||||
ix: 0,
|
||||
iz: 0,
|
||||
}),
|
||||
1.0,
|
||||
);
|
||||
cp.reset();
|
||||
assert_eq!(
|
||||
cp.eye(),
|
||||
Vec3::new(0.0, 0.0, CoarseParallaxConfig::default().base_distance_m)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calibration_maps_normalized_head_to_metric_eye() {
|
||||
let cal = ScreenCalibration::from_cm(60.0, 34.0, 65.0);
|
||||
assert!((cal.width_m - 0.6).abs() < 1e-12);
|
||||
|
||||
// Centered head at nominal depth = centered eye at base distance.
|
||||
let c = cal.eye_from_normalized(0.5, 0.5, 1.0, cal.width_m);
|
||||
assert_eq!(c, Vec3::new(0.0, 0.0, 0.65));
|
||||
|
||||
// Head at image right edge → eye half the range to the right;
|
||||
// image y grows downward → ny=0 (top) is +y in screen space.
|
||||
let e = cal.eye_from_normalized(1.0, 0.0, 1.0, 0.6);
|
||||
assert!((e.x - 0.3).abs() < 1e-12);
|
||||
assert!(e.y > 0.0);
|
||||
|
||||
// Depth scale is clamped to a sane band.
|
||||
assert_eq!(cal.eye_from_normalized(0.5, 0.5, 100.0, 0.6).z, 0.65 * 4.0);
|
||||
assert_eq!(cal.eye_from_normalized(0.5, 0.5, 0.0, 0.6).z, 0.65 * 0.25);
|
||||
|
||||
// Out-of-range normalized coords are clamped, not extrapolated.
|
||||
let clamped = cal.eye_from_normalized(7.0, -3.0, 1.0, 0.6);
|
||||
assert!((clamped.x - 0.3).abs() < 1e-12);
|
||||
}
|
||||
}
|
||||
202
v2/crates/ruview-offaxis/src/wasm.rs
Normal file
202
v2/crates/ruview-offaxis/src/wasm.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
//! wasm-bindgen surface (compiled only for `wasm32`).
|
||||
//!
|
||||
//! Exposes two small classes to JavaScript:
|
||||
//!
|
||||
//! - [`OffAxisCamera`] — Tier A/B shared core: screen calibration + one-euro
|
||||
//! filtering + Kooima projection. Feed it an eye position each frame, read
|
||||
//! back column-major matrices ready for `THREE.Matrix4.fromArray`.
|
||||
//! - [`RfParallax`] — Tier B input stage: `/ws/sensing` `signal_field`
|
||||
//! grids in, bounded coarse-parallax eye position out. Its output is
|
||||
//! coarse body parallax by construction (deadband + gain + clamp), so a
|
||||
//! demo cannot accidentally present it as head tracking.
|
||||
//!
|
||||
//! All methods that can fail return `Result<_, JsError>` (thrown as JS
|
||||
//! exceptions); per-frame update methods instead hold the last good state so
|
||||
//! a render loop never has to try/catch.
|
||||
|
||||
use crate::filter::{OneEuro3, OneEuroConfig};
|
||||
use crate::math::Vec3;
|
||||
use crate::projection::{off_axis, OffAxis, Screen};
|
||||
use crate::rf::{field_peak, CoarseParallax, CoarseParallaxConfig, ScreenCalibration};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
/// Head-coupled off-axis camera for a physically calibrated screen.
|
||||
#[wasm_bindgen]
|
||||
pub struct OffAxisCamera {
|
||||
screen: Screen,
|
||||
cal: ScreenCalibration,
|
||||
near: f64,
|
||||
far: f64,
|
||||
filter: OneEuro3,
|
||||
current: OffAxis,
|
||||
eye: Vec3,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl OffAxisCamera {
|
||||
/// Create from physical screen measurements in **centimetres** (the
|
||||
/// units a person measures with) plus clip planes in metres.
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(
|
||||
screen_width_cm: f64,
|
||||
screen_height_cm: f64,
|
||||
viewing_distance_cm: f64,
|
||||
near_m: f64,
|
||||
far_m: f64,
|
||||
) -> Result<OffAxisCamera, JsError> {
|
||||
let cal =
|
||||
ScreenCalibration::from_cm(screen_width_cm, screen_height_cm, viewing_distance_cm);
|
||||
let screen = cal.screen().map_err(|e| JsError::new(&e.to_string()))?;
|
||||
let eye = Vec3::new(0.0, 0.0, cal.base_distance_m);
|
||||
let current =
|
||||
off_axis(&screen, eye, near_m, far_m).map_err(|e| JsError::new(&e.to_string()))?;
|
||||
Ok(Self {
|
||||
screen,
|
||||
cal,
|
||||
near: near_m,
|
||||
far: far_m,
|
||||
filter: OneEuro3::new(OneEuroConfig {
|
||||
min_cutoff: 1.0,
|
||||
beta: 0.3,
|
||||
d_cutoff: 1.0,
|
||||
}),
|
||||
current,
|
||||
eye,
|
||||
})
|
||||
}
|
||||
|
||||
/// Tune the one-euro filter (`min_cutoff` Hz, `beta`). Lower
|
||||
/// `min_cutoff` = smoother at rest; higher `beta` = less lag in motion.
|
||||
pub fn set_filter(&mut self, min_cutoff: f64, beta: f64) {
|
||||
self.filter.set_config(OneEuroConfig {
|
||||
min_cutoff,
|
||||
beta,
|
||||
d_cutoff: 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
/// Reset filter state (e.g. after tracking was lost).
|
||||
pub fn reset_filter(&mut self) {
|
||||
self.filter.reset();
|
||||
}
|
||||
|
||||
/// Update from a **metric eye position** in screen space (metres,
|
||||
/// origin = screen center, +x right, +y up, +z toward the viewer) at
|
||||
/// time `t_s` seconds. Returns `true` when the matrices were updated;
|
||||
/// `false` when the sample was rejected (eye behind screen / non-finite)
|
||||
/// and the previous matrices were held.
|
||||
pub fn update_eye(&mut self, x_m: f64, y_m: f64, z_m: f64, t_s: f64) -> bool {
|
||||
let eye = self.filter.filter(Vec3::new(x_m, y_m, z_m), t_s);
|
||||
match off_axis(&self.screen, eye, self.near, self.far) {
|
||||
Ok(oa) => {
|
||||
self.current = oa;
|
||||
self.eye = eye;
|
||||
true
|
||||
}
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Update from a **normalized head position** (`nx`, `ny` in `[0, 1]`,
|
||||
/// camera-image convention, mirrored/selfie view) with a depth scale
|
||||
/// (1.0 = at the calibrated distance) — the Tier A path fed by any fine
|
||||
/// tracker the host runs. `lateral_range_m` is how many metres of eye
|
||||
/// travel the full image spans (screen width is a reasonable start).
|
||||
pub fn update_normalized(
|
||||
&mut self,
|
||||
nx: f64,
|
||||
ny: f64,
|
||||
depth_scale: f64,
|
||||
lateral_range_m: f64,
|
||||
t_s: f64,
|
||||
) -> bool {
|
||||
let eye = self
|
||||
.cal
|
||||
.eye_from_normalized(nx, ny, depth_scale, lateral_range_m);
|
||||
self.update_eye(eye.x, eye.y, eye.z, t_s)
|
||||
}
|
||||
|
||||
/// The current asymmetric projection matrix, column-major, 16 elements
|
||||
/// (pass straight to `THREE.Matrix4.fromArray`).
|
||||
pub fn projection(&self) -> Vec<f64> {
|
||||
self.current.projection.to_vec()
|
||||
}
|
||||
|
||||
/// The current screen-aligned view matrix, column-major, 16 elements.
|
||||
pub fn view(&self) -> Vec<f64> {
|
||||
self.current.view.to_vec()
|
||||
}
|
||||
|
||||
/// `projection * view` as one matrix, column-major, 16 elements.
|
||||
pub fn view_projection(&self) -> Vec<f64> {
|
||||
self.current.view_projection().to_vec()
|
||||
}
|
||||
|
||||
/// The current (filtered) eye position `[x, y, z]` in metres.
|
||||
pub fn eye(&self) -> Vec<f64> {
|
||||
vec![self.eye.x, self.eye.y, self.eye.z]
|
||||
}
|
||||
}
|
||||
|
||||
/// Tier B input stage: signal-field grids → bounded coarse-parallax eye.
|
||||
#[wasm_bindgen]
|
||||
pub struct RfParallax {
|
||||
parallax: CoarseParallax,
|
||||
last_value: f64,
|
||||
has_peak: bool,
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
impl RfParallax {
|
||||
/// Create with the default conservative Tier B tuning and the given
|
||||
/// nominal viewing distance (metres).
|
||||
#[wasm_bindgen(constructor)]
|
||||
pub fn new(base_distance_m: f64) -> RfParallax {
|
||||
let cfg = CoarseParallaxConfig {
|
||||
base_distance_m,
|
||||
..CoarseParallaxConfig::default()
|
||||
};
|
||||
RfParallax {
|
||||
parallax: CoarseParallax::new(cfg),
|
||||
last_value: 0.0,
|
||||
has_peak: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Ingest one `signal_field` grid (`values.length == nx * nz`,
|
||||
/// `idx = iz * nx + ix` — the `/ws/sensing` layout) observed at `t_s`
|
||||
/// seconds. Returns `true` when a peak at/above the server's threshold
|
||||
/// was found (eye moved), `false` when the field had no localizable
|
||||
/// hotspot (eye held).
|
||||
pub fn update(&mut self, values: &[f32], nx: usize, nz: usize, t_s: f64) -> bool {
|
||||
let peak = field_peak(values, nx, nz);
|
||||
self.has_peak = peak.is_some();
|
||||
self.last_value = peak.map_or(self.last_value, |p| p.value);
|
||||
self.parallax.update(peak, t_s);
|
||||
self.has_peak
|
||||
}
|
||||
|
||||
/// The current eye position `[x, y, z]` in metres (screen space) —
|
||||
/// feed to `OffAxisCamera.update_eye`.
|
||||
pub fn eye(&self) -> Vec<f64> {
|
||||
let e = self.parallax.eye();
|
||||
vec![e.x, e.y, e.z]
|
||||
}
|
||||
|
||||
/// Whether the last grid had an at/above-threshold peak.
|
||||
pub fn has_peak(&self) -> bool {
|
||||
self.has_peak
|
||||
}
|
||||
|
||||
/// The last accepted peak's normalized field value (HUD display).
|
||||
pub fn peak_value(&self) -> f64 {
|
||||
self.last_value
|
||||
}
|
||||
|
||||
/// Forget the session origin (person left / demo reset).
|
||||
pub fn reset(&mut self) {
|
||||
self.parallax.reset();
|
||||
self.has_peak = false;
|
||||
self.last_value = 0.0;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user