Files
rUv 0df48df7b2 feat: add native iPhone LiDAR sensor and web viewer (#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
2026-08-22 18:06:16 -04:00

109 lines
3.3 KiB
JavaScript

import { decodeLiDARPacket, depthToPointCloud } from './codec.mjs';
const canvas = document.querySelector('#view');
const ctx = canvas.getContext('2d');
const status = document.querySelector('#status');
const fpsEl = document.querySelector('#fps');
const pointsEl = document.querySelector('#points');
const seqEl = document.querySelector('#seq');
const latencyEl = document.querySelector('#latency');
const sensorEl = document.querySelector('#sensor');
let lastFrameAt = performance.now();
let yaw = 0.3;
let pitch = -0.15;
let scale = 120;
function connect() {
const token = new URLSearchParams(location.search).get('token');
if (!token) {
status.textContent = 'TOKEN REQUIRED';
status.dataset.state = 'warn';
return;
}
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${protocol}//${location.host}/ws/lidar?token=${encodeURIComponent(token)}`);
socket.addEventListener('open', () => {
status.textContent = 'LIVE';
status.dataset.state = 'live';
});
socket.addEventListener('close', () => {
status.textContent = 'RECONNECTING';
status.dataset.state = 'warn';
setTimeout(connect, 1000);
});
socket.addEventListener('message', (event) => {
try {
const raw = JSON.parse(event.data);
const frame = decodeLiDARPacket(raw);
const points = depthToPointCloud(frame, 1);
render(points);
const now = performance.now();
const delta = now - lastFrameAt;
lastFrameAt = now;
fpsEl.textContent = delta > 0 ? (1000 / delta).toFixed(1) : '0.0';
pointsEl.textContent = points.length.toLocaleString();
seqEl.textContent = frame.provenance.sequence;
latencyEl.textContent = Math.max(0, Date.now() - Number(frame.provenance.timestampNs / 1_000_000)).toFixed(0);
sensorEl.textContent = frame.provenance.sensor;
} catch (error) {
console.error(error);
status.textContent = 'FRAME ERROR';
status.dataset.state = 'warn';
}
});
}
function resize() {
const dpr = Math.min(devicePixelRatio || 1, 2);
const rect = canvas.getBoundingClientRect();
canvas.width = Math.max(1, Math.floor(rect.width * dpr));
canvas.height = Math.max(1, Math.floor(rect.height * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
function render(points) {
resize();
const w = canvas.clientWidth;
const h = canvas.clientHeight;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = '#091018';
ctx.fillRect(0, 0, w, h);
const cy = Math.cos(yaw);
const sy = Math.sin(yaw);
const cp = Math.cos(pitch);
const sp = Math.sin(pitch);
ctx.fillStyle = '#58e0d2';
for (let i = 0; i < points.length; i += 1) {
let [x, y, z] = points[i];
const rx = x * cy - z * sy;
const rz = x * sy + z * cy;
const ry = y * cp - rz * sp;
const rz2 = y * sp + rz * cp;
const perspective = 1 / Math.max(0.35, 1.8 - rz2 * 0.12);
const px = w / 2 + rx * scale * perspective;
const py = h / 2 + ry * scale * perspective;
if (px >= 0 && px < w && py >= 0 && py < h) ctx.fillRect(px, py, 1.4, 1.4);
}
}
canvas.addEventListener('pointermove', (event) => {
if (!event.buttons) return;
yaw += event.movementX * 0.006;
pitch += event.movementY * 0.006;
});
canvas.addEventListener('wheel', (event) => {
event.preventDefault();
scale = Math.max(40, Math.min(400, scale - event.deltaY * 0.2));
}, { passive: false });
connect();