Skip to content

Commit 7743b85

Browse files
three.js demos: feat: add native iPhone LiDAR sensor and web viewer (ruvnet#1684)
* feat(ios): add RuView LiDAR frame protocol * feat(ios): capture ARKit scene depth for RuView * feat(ios): stream compact LiDAR frames over websocket * feat(ios): add native LiDAR capture UI * feat(ios): add RuView LiDAR app entrypoint * feat(web): add LiDAR bridge web package * feat(web): decode RuView LiDAR wire frames * feat(web): add local LiDAR websocket relay * feat(web): add LiDAR browser viewer * feat(web): render live LiDAR point cloud * fix(ios): use wall clock time for LiDAR provenance * feat(web): style LiDAR viewer * test(web): add LiDAR codec tests * docs: add iPhone LiDAR integration guide * docs(adr): define iPhone LiDAR sensor bridge * fix(ios): harden and validate LiDAR bridge * fix(ios): qualify LiDAR wire depth type 0df48df
1 parent baffa9c commit 7743b85

1 file changed

Lines changed: 378 additions & 0 deletions

File tree

Lines changed: 378 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,378 @@
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

Comments
 (0)