|
| 1 | +<!DOCTYPE html> |
| 2 | +<html lang="en"> |
| 3 | +<head> |
| 4 | + <meta charset="UTF-8"> |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| 6 | + <title>RuView · ADR-324 · off-axis window (ruview-offaxis WASM)</title> |
| 7 | + <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>"> |
| 8 | + <style> |
| 9 | + :root { |
| 10 | + --bg: #0a0a0a; |
| 11 | + --bg-panel: rgba(0, 0, 0, 0.88); |
| 12 | + --amber: #e8a634; |
| 13 | + --amber-dim: #4a3a1a; |
| 14 | + --amber-hot: #ffc04d; |
| 15 | + --grid-major: #444444; |
| 16 | + --grid-minor: #222222; |
| 17 | + --green: #4f4; |
| 18 | + --blue: #4cf; |
| 19 | + --red: #f66; |
| 20 | + --text-mute: #888; |
| 21 | + --border: #2a2a2a; |
| 22 | + } |
| 23 | + * { box-sizing: border-box; } |
| 24 | + body { |
| 25 | + margin: 0; |
| 26 | + background: var(--bg); |
| 27 | + color: var(--amber); |
| 28 | + font-family: 'SF Mono', Monaco, 'Cascadia Code', Consolas, monospace; |
| 29 | + overflow: hidden; |
| 30 | + -webkit-font-smoothing: antialiased; |
| 31 | + } |
| 32 | + canvas { display: block; } |
| 33 | + |
| 34 | + #info { |
| 35 | + position: absolute; |
| 36 | + top: 16px; |
| 37 | + left: 16px; |
| 38 | + padding: 14px 16px; |
| 39 | + background: var(--bg-panel); |
| 40 | + border: 1px solid var(--amber); |
| 41 | + border-radius: 8px; |
| 42 | + min-width: 280px; |
| 43 | + max-width: 360px; |
| 44 | + font-size: 12px; |
| 45 | + line-height: 1.55; |
| 46 | + z-index: 10; |
| 47 | + backdrop-filter: blur(6px); |
| 48 | + box-shadow: 0 4px 24px rgba(232, 166, 52, 0.08); |
| 49 | + } |
| 50 | + #info h1 { margin: 0 0 2px 0; font-size: 14px; letter-spacing: 0.5px; } |
| 51 | + #info .sub { font-size: 11px; color: var(--text-mute); margin-bottom: 10px; } |
| 52 | + #info .row { display: flex; justify-content: space-between; gap: 12px; margin: 2px 0; } |
| 53 | + #info .row .k { color: var(--text-mute); } |
| 54 | + #info .row .v { color: var(--amber); font-variant-numeric: tabular-nums; } |
| 55 | + #info .row .v.live { color: var(--green); } |
| 56 | + #info .row .v.warn { color: var(--red); } |
| 57 | + |
| 58 | + /* The mandatory ADR-324 §2.4 mode label: always visible while RF |
| 59 | + drives the camera; there is no configuration that hides it. */ |
| 60 | + #mode-label { |
| 61 | + position: absolute; |
| 62 | + top: 16px; |
| 63 | + right: 16px; |
| 64 | + padding: 8px 14px; |
| 65 | + background: var(--bg-panel); |
| 66 | + border: 1px solid var(--blue); |
| 67 | + border-radius: 8px; |
| 68 | + color: var(--blue); |
| 69 | + font-size: 12px; |
| 70 | + z-index: 11; |
| 71 | + } |
| 72 | + #mode-label.rf { border-color: var(--red); color: var(--red); } |
| 73 | + |
| 74 | + #controls { |
| 75 | + position: absolute; |
| 76 | + bottom: 16px; |
| 77 | + left: 16px; |
| 78 | + padding: 12px 16px; |
| 79 | + background: var(--bg-panel); |
| 80 | + border: 1px solid var(--border); |
| 81 | + border-radius: 8px; |
| 82 | + font-size: 12px; |
| 83 | + z-index: 10; |
| 84 | + max-width: 340px; |
| 85 | + } |
| 86 | + #controls h2 { margin: 0 0 8px 0; font-size: 12px; color: var(--text-mute); } |
| 87 | + #controls label { display: flex; justify-content: space-between; gap: 8px; margin: 4px 0; align-items: center; } |
| 88 | + #controls input[type="number"] { |
| 89 | + width: 70px; background: #111; border: 1px solid var(--border); |
| 90 | + color: var(--amber); font-family: inherit; font-size: 12px; padding: 2px 6px; border-radius: 4px; |
| 91 | + } |
| 92 | + #controls input[type="text"] { |
| 93 | + width: 190px; background: #111; border: 1px solid var(--border); |
| 94 | + color: var(--amber); font-family: inherit; font-size: 11px; padding: 2px 6px; border-radius: 4px; |
| 95 | + } |
| 96 | + #controls button { |
| 97 | + background: var(--amber-dim); border: 1px solid var(--amber); color: var(--amber-hot); |
| 98 | + font-family: inherit; font-size: 12px; padding: 4px 10px; border-radius: 5px; cursor: pointer; margin-top: 6px; |
| 99 | + } |
| 100 | + #controls button:hover { background: var(--amber); color: #000; } |
| 101 | + |
| 102 | + #wasm-missing { |
| 103 | + position: absolute; |
| 104 | + inset: 0; |
| 105 | + display: none; |
| 106 | + align-items: center; |
| 107 | + justify-content: center; |
| 108 | + background: rgba(0, 0, 0, 0.92); |
| 109 | + z-index: 50; |
| 110 | + } |
| 111 | + #wasm-missing .box { |
| 112 | + max-width: 560px; border: 1px solid var(--amber); border-radius: 10px; |
| 113 | + background: var(--bg-panel); padding: 22px 26px; font-size: 13px; line-height: 1.7; |
| 114 | + } |
| 115 | + #wasm-missing code { color: var(--amber-hot); background: #151005; padding: 1px 5px; border-radius: 4px; display: block; margin: 4px 0; } |
| 116 | + </style> |
| 117 | +</head> |
| 118 | +<body> |
| 119 | + <div id="info"> |
| 120 | + <h1>OFF-AXIS WINDOW</h1> |
| 121 | + <div class="sub">ADR-324 · clean-room Kooima projection · ruview-offaxis (Rust→WASM)</div> |
| 122 | + <div class="row"><span class="k">engine</span><span class="v" id="hud-engine">loading…</span></div> |
| 123 | + <div class="row"><span class="k">input</span><span class="v" id="hud-input">mouse (SYNTHETIC)</span></div> |
| 124 | + <div class="row"><span class="k">eye x/y/z (m)</span><span class="v" id="hud-eye">—</span></div> |
| 125 | + <div class="row"><span class="k">render fps</span><span class="v live" id="hud-fps">—</span></div> |
| 126 | + <div class="row"><span class="k">rf socket</span><span class="v" id="hud-ws">not connected</span></div> |
| 127 | + <div class="row"><span class="k">rf peak</span><span class="v" id="hud-peak">—</span></div> |
| 128 | + <div class="row" style="margin-top:8px"><span class="k" style="font-size:10px"> |
| 129 | + keys: <b>M</b> mouse · <b>R</b> RF Tier B · wheel = distance</span></div> |
| 130 | + </div> |
| 131 | + |
| 132 | + <div id="mode-label">MOUSE SIM — SYNTHETIC INPUT</div> |
| 133 | + |
| 134 | + <div id="controls"> |
| 135 | + <h2>PHYSICAL CALIBRATION (stored locally)</h2> |
| 136 | + <label>screen width (cm) <input id="cal-w" type="number" step="0.5" value="60"></label> |
| 137 | + <label>screen height (cm) <input id="cal-h" type="number" step="0.5" value="34"></label> |
| 138 | + <label>viewing distance (cm) <input id="cal-d" type="number" step="1" value="65"></label> |
| 139 | + <h2 style="margin-top:10px">RF SOURCE (Tier B)</h2> |
| 140 | + <label>ws url <input id="ws-url" type="text" value="ws://127.0.0.1:8080/ws/sensing"></label> |
| 141 | + <button id="apply">apply calibration</button> |
| 142 | + </div> |
| 143 | + |
| 144 | + <div id="wasm-missing"> |
| 145 | + <div class="box"> |
| 146 | + <b>ruview-offaxis WASM module not found.</b><br><br> |
| 147 | + This demo loads the crate's wasm-bindgen output from |
| 148 | + <code>v2/crates/ruview-offaxis/pkg/</code>. Generated artifacts are not |
| 149 | + committed (repo rule); build them once: |
| 150 | + <code>cd v2 && cargo build -p ruview-offaxis --target wasm32-unknown-unknown --release</code> |
| 151 | + <code>wasm-bindgen --target web --out-dir crates/ruview-offaxis/pkg \ |
| 152 | + target/wasm32-unknown-unknown/release/ruview_offaxis.wasm</code> |
| 153 | + (install the CLI with <code>cargo install wasm-bindgen-cli --version 0.2.114</code>)<br> |
| 154 | + then reload. Full steps: <code>v2/crates/ruview-offaxis/README.md</code>. |
| 155 | + </div> |
| 156 | + </div> |
| 157 | + |
| 158 | + <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> |
| 159 | + <script type="module"> |
| 160 | + // ADR-324 demo: the projection math lives in Rust/WASM (ruview-offaxis). |
| 161 | + // This file only wires inputs (mouse SYNTHETIC sim, or /ws/sensing |
| 162 | + // signal_field for the labeled Tier B coarse-parallax mode) into the |
| 163 | + // WASM camera and copies its matrices onto a three.js camera. |
| 164 | + |
| 165 | + const $ = (id) => document.getElementById(id); |
| 166 | + |
| 167 | + // ---- Load the WASM module (not committed; user builds it once). ---- |
| 168 | + let wasm; |
| 169 | + try { |
| 170 | + wasm = await import('../../../v2/crates/ruview-offaxis/pkg/ruview_offaxis.js'); |
| 171 | + await wasm.default(); |
| 172 | + $('hud-engine').textContent = 'ruview-offaxis wasm'; |
| 173 | + } catch (e) { |
| 174 | + console.error('ruview-offaxis pkg not found', e); |
| 175 | + $('wasm-missing').style.display = 'flex'; |
| 176 | + throw e; |
| 177 | + } |
| 178 | + const { OffAxisCamera, RfParallax } = wasm; |
| 179 | + |
| 180 | + // ---- Calibration (persisted locally; never leaves the browser). ---- |
| 181 | + const CAL_KEY = 'ruview-offaxis-demo-cal'; |
| 182 | + const saved = JSON.parse(localStorage.getItem(CAL_KEY) || 'null'); |
| 183 | + if (saved) { $('cal-w').value = saved.w; $('cal-h').value = saved.h; $('cal-d').value = saved.d; } |
| 184 | + const cal = () => ({ w: +$('cal-w').value || 60, h: +$('cal-h').value || 34, d: +$('cal-d').value || 65 }); |
| 185 | + |
| 186 | + let cam = new OffAxisCamera(cal().w, cal().h, cal().d, 0.05, 100.0); |
| 187 | + cam.set_filter(1.2, 0.4); // interactive: light smoothing, quick catch-up |
| 188 | + let rf = new RfParallax(cal().d / 100); |
| 189 | + |
| 190 | + $('apply').onclick = () => { |
| 191 | + const c = cal(); |
| 192 | + localStorage.setItem(CAL_KEY, JSON.stringify(c)); |
| 193 | + cam = new OffAxisCamera(c.w, c.h, c.d, 0.05, 100.0); |
| 194 | + cam.set_filter(1.2, 0.4); |
| 195 | + rf = new RfParallax(c.d / 100); |
| 196 | + buildRoom(); // room proportions follow the physical screen |
| 197 | + }; |
| 198 | + |
| 199 | + // ---- three.js scene: a room extending behind the screen plane. ---- |
| 200 | + const renderer = new THREE.WebGLRenderer({ antialias: true }); |
| 201 | + renderer.setSize(window.innerWidth, window.innerHeight); |
| 202 | + renderer.setPixelRatio(window.devicePixelRatio); |
| 203 | + document.body.appendChild(renderer.domElement); |
| 204 | + |
| 205 | + const scene = new THREE.Scene(); |
| 206 | + scene.background = new THREE.Color(0x0a0a0a); |
| 207 | + |
| 208 | + // Camera is fully driven by the WASM matrices. |
| 209 | + const camera = new THREE.PerspectiveCamera(); |
| 210 | + camera.matrixAutoUpdate = false; |
| 211 | + |
| 212 | + let room = new THREE.Group(); |
| 213 | + function buildRoom() { |
| 214 | + scene.remove(room); |
| 215 | + room = new THREE.Group(); |
| 216 | + const c = cal(); |
| 217 | + const W = c.w / 100, H = c.h / 100, DEPTH = Math.max(W, 0.8) * 2.0; |
| 218 | + |
| 219 | + // Wireframe box behind the screen: the classic "window" cue. |
| 220 | + const boxGeo = new THREE.BoxGeometry(W, H, DEPTH); |
| 221 | + const edges = new THREE.EdgesGeometry(boxGeo); |
| 222 | + const box = new THREE.LineSegments(edges, new THREE.LineBasicMaterial({ color: 0xe8a634 })); |
| 223 | + box.position.z = -DEPTH / 2; // screen plane is z = 0 |
| 224 | + room.add(box); |
| 225 | + |
| 226 | + // Depth rails: rows of columns receding into the box. |
| 227 | + const colMat = new THREE.MeshStandardMaterial({ color: 0x4a3a1a, emissive: 0x2a1f08 }); |
| 228 | + for (let i = 1; i <= 6; i++) { |
| 229 | + for (const sx of [-1, 1]) { |
| 230 | + const col = new THREE.Mesh(new THREE.CylinderGeometry(0.008, 0.008, H * 0.9, 12), colMat); |
| 231 | + col.position.set(sx * W * 0.42, 0, -DEPTH * i / 7); |
| 232 | + room.add(col); |
| 233 | + } |
| 234 | + } |
| 235 | + |
| 236 | + // Floating objects at staggered depths (parallax targets). |
| 237 | + const knotMat = new THREE.MeshStandardMaterial({ color: 0xe8a634, metalness: 0.4, roughness: 0.35 }); |
| 238 | + const knot = new THREE.Mesh(new THREE.TorusKnotGeometry(H * 0.18, H * 0.05, 120, 16), knotMat); |
| 239 | + knot.position.set(0, 0, -DEPTH * 0.45); |
| 240 | + knot.name = 'knot'; |
| 241 | + room.add(knot); |
| 242 | + |
| 243 | + const orb = new THREE.Mesh( |
| 244 | + new THREE.IcosahedronGeometry(H * 0.08, 1), |
| 245 | + new THREE.MeshStandardMaterial({ color: 0x4cf0ff, emissive: 0x0a3540 }) |
| 246 | + ); |
| 247 | + orb.position.set(-W * 0.22, H * 0.18, -DEPTH * 0.18); |
| 248 | + room.add(orb); |
| 249 | + |
| 250 | + // One object slightly IN FRONT of the screen plane — pops "out". |
| 251 | + const pop = new THREE.Mesh( |
| 252 | + new THREE.OctahedronGeometry(H * 0.05), |
| 253 | + new THREE.MeshStandardMaterial({ color: 0xffc04d, emissive: 0x604010 }) |
| 254 | + ); |
| 255 | + pop.position.set(W * 0.28, -H * 0.2, 0.06); |
| 256 | + room.add(pop); |
| 257 | + |
| 258 | + room.add(new THREE.AmbientLight(0xffffff, 0.35)); |
| 259 | + const key = new THREE.PointLight(0xffe0a0, 1.0); |
| 260 | + key.position.set(0.3, 0.4, 0.5); |
| 261 | + room.add(key); |
| 262 | + scene.add(room); |
| 263 | + } |
| 264 | + buildRoom(); |
| 265 | + |
| 266 | + // ---- Input modes. ---- |
| 267 | + // 'mouse' — SYNTHETIC eye simulator (always available, no hardware). |
| 268 | + // 'rf' — Tier B: /ws/sensing signal_field → RfParallax. |
| 269 | + // Labeled coarse body parallax, NOT head tracking (ADR-324 §2.4). |
| 270 | + let mode = 'mouse'; |
| 271 | + const modeLabel = $('mode-label'); |
| 272 | + function setMode(m) { |
| 273 | + mode = m; |
| 274 | + if (m === 'rf') { |
| 275 | + modeLabel.textContent = 'RF COARSE BODY PARALLAX — NOT HEAD TRACKING'; |
| 276 | + modeLabel.classList.add('rf'); |
| 277 | + $('hud-input').textContent = 'rf field peak (Tier B)'; |
| 278 | + connectWs(); |
| 279 | + } else { |
| 280 | + modeLabel.textContent = 'MOUSE SIM — SYNTHETIC INPUT'; |
| 281 | + modeLabel.classList.remove('rf'); |
| 282 | + $('hud-input').textContent = 'mouse (SYNTHETIC)'; |
| 283 | + } |
| 284 | + } |
| 285 | + window.addEventListener('keydown', (e) => { |
| 286 | + if (e.key === 'm' || e.key === 'M') setMode('mouse'); |
| 287 | + if (e.key === 'r' || e.key === 'R') setMode('rf'); |
| 288 | + }); |
| 289 | + |
| 290 | + // Mouse sim: pointer position maps to a ±0.3 m eye excursion; |
| 291 | + // wheel adjusts distance. |
| 292 | + let mouseEye = { x: 0, y: 0, d: cal().d / 100 }; |
| 293 | + window.addEventListener('pointermove', (e) => { |
| 294 | + mouseEye.x = (e.clientX / window.innerWidth - 0.5) * 0.6; |
| 295 | + mouseEye.y = (0.5 - e.clientY / window.innerHeight) * 0.4; |
| 296 | + }); |
| 297 | + window.addEventListener('wheel', (e) => { |
| 298 | + mouseEye.d = Math.min(2.5, Math.max(0.2, mouseEye.d + e.deltaY * 0.0005)); |
| 299 | + }, { passive: true }); |
| 300 | + |
| 301 | + // ---- RF Tier B input: /ws/sensing sensing_update.signal_field. ---- |
| 302 | + let ws = null; |
| 303 | + function connectWs() { |
| 304 | + if (ws) { try { ws.close(); } catch (_) {} } |
| 305 | + const url = $('ws-url').value; |
| 306 | + $('hud-ws').textContent = 'connecting…'; |
| 307 | + try { ws = new WebSocket(url); } catch (e) { |
| 308 | + $('hud-ws').textContent = 'invalid url'; return; |
| 309 | + } |
| 310 | + ws.onopen = () => { $('hud-ws').textContent = 'connected'; }; |
| 311 | + ws.onclose = () => { $('hud-ws').textContent = 'closed'; }; |
| 312 | + ws.onerror = () => { $('hud-ws').textContent = 'error (server up? ticket needed?)'; }; |
| 313 | + ws.onmessage = (ev) => { |
| 314 | + try { |
| 315 | + const msg = JSON.parse(ev.data); |
| 316 | + const field = msg.signal_field || (msg.data && msg.data.signal_field); |
| 317 | + if (!field || !field.values) return; |
| 318 | + const nx = field.grid_size || field.nx || 20; |
| 319 | + const nz = field.grid_size || field.nz || 20; |
| 320 | + const values = Float32Array.from(field.values); |
| 321 | + const found = rf.update(values, nx, nz, performance.now() / 1000); |
| 322 | + $('hud-peak').textContent = found |
| 323 | + ? `value ${rf.peak_value().toFixed(2)} (≥ 0.35 gate)` |
| 324 | + : 'below 0.35 gate — holding'; |
| 325 | + // Provenance surfaced verbatim (ADR-295: synthetic never |
| 326 | + // presents as live). |
| 327 | + if (msg.provenance || msg.source) { |
| 328 | + $('hud-ws').textContent = `connected · src: ${msg.provenance || msg.source}`; |
| 329 | + } |
| 330 | + } catch (_) { /* non-JSON frame */ } |
| 331 | + }; |
| 332 | + } |
| 333 | + |
| 334 | + // ---- Render loop: one WASM call, three matrix copies, render. ---- |
| 335 | + const tmp = new THREE.Matrix4(); |
| 336 | + let frames = 0, lastFps = performance.now(); |
| 337 | + function animate() { |
| 338 | + requestAnimationFrame(animate); |
| 339 | + const t = performance.now() / 1000; |
| 340 | + |
| 341 | + if (mode === 'mouse') { |
| 342 | + cam.update_eye(mouseEye.x, mouseEye.y, mouseEye.d, t); |
| 343 | + } else { |
| 344 | + const e = rf.eye(); // bounded coarse-parallax eye (metres) |
| 345 | + cam.update_eye(e[0], e[1], e[2], t); |
| 346 | + } |
| 347 | + |
| 348 | + // Copy the Kooima matrices onto the three.js camera. |
| 349 | + camera.projectionMatrix.fromArray(cam.projection()); |
| 350 | + camera.projectionMatrixInverse.copy(camera.projectionMatrix).invert(); |
| 351 | + tmp.fromArray(cam.view()); |
| 352 | + camera.matrixWorld.copy(tmp).invert(); // world = inverse(view) |
| 353 | + camera.matrixWorldInverse.copy(tmp); |
| 354 | + |
| 355 | + const knot = room.getObjectByName('knot'); |
| 356 | + if (knot) { knot.rotation.y += 0.003; knot.rotation.x += 0.001; } |
| 357 | + |
| 358 | + renderer.render(scene, camera); |
| 359 | + |
| 360 | + const eye = cam.eye(); |
| 361 | + $('hud-eye').textContent = `${eye[0].toFixed(3)} / ${eye[1].toFixed(3)} / ${eye[2].toFixed(3)}`; |
| 362 | + frames++; |
| 363 | + const now = performance.now(); |
| 364 | + if (now - lastFps > 1000) { |
| 365 | + $('hud-fps').textContent = String(frames); |
| 366 | + frames = 0; lastFps = now; |
| 367 | + } |
| 368 | + } |
| 369 | + animate(); |
| 370 | + |
| 371 | + window.addEventListener('resize', () => { |
| 372 | + renderer.setSize(window.innerWidth, window.innerHeight); |
| 373 | + // NOTE: no camera.aspect update — the frustum is fully determined |
| 374 | + // by the physical screen calibration, not the browser viewport. |
| 375 | + }); |
| 376 | + </script> |
| 377 | +</body> |
| 378 | +</html> |
0 commit comments