// M3 live scan view — consumes /ws/pointcloud (see backend/pointcloud_codec.py // for the exact binary layout) and renders into #pointcloud-canvas with // three.js. One reused BufferGeometry with pre-allocated typed arrays, // overwritten in place per frame (needsUpdate=true) to avoid GC churn at // the stream's 3-5Hz. import * as THREE from "three"; import { OrbitControls } from "/vendor/three/OrbitControls.js"; const MAGIC = "PCF1"; // Must match backend config.py POINTCLOUD_PROFILES max_points — sizes the // pre-allocated buffers, so it can't just read the count off the wire. const PROFILE_MAX_POINTS = { desktop: 20000, phone: 7000 }; function pickProfile() { return window.innerWidth < 768 ? "phone" : "desktop"; } export function initPointcloudView() { const canvas = document.getElementById("pointcloud-canvas"); const emptyState = document.getElementById("scan-empty"); const profile = pickProfile(); const maxPoints = PROFILE_MAX_POINTS[profile]; const renderer = new THREE.WebGLRenderer({ canvas, antialias: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); const scene = new THREE.Scene(); scene.background = new THREE.Color(0x0a0e14); const camera = new THREE.PerspectiveCamera(60, 1, 0.05, 500); // FAST-LIO's "camera_init" world frame is z-up (LiDAR convention), not // three.js's default y-up — set the camera's up vector to match so // OrbitControls orbits around the right axis. camera.up.set(0, 0, 1); camera.position.set(0, -8, 5); const controls = new OrbitControls(camera, canvas); controls.enableDamping = true; controls.dampingFactor = 0.08; const grid = new THREE.GridHelper(40, 40, 0x2a3040, 0x1a1f28); grid.rotation.x = Math.PI / 2; // GridHelper defaults to the XZ plane (y-up); rotate into XY (z-up) scene.add(grid); const positions = new Float32Array(maxPoints * 3); const colors = new Float32Array(maxPoints * 3); const geometry = new THREE.BufferGeometry(); geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3)); geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3)); geometry.setDrawRange(0, 0); const material = new THREE.PointsMaterial({ size: 0.035, vertexColors: true, sizeAttenuation: true }); scene.add(new THREE.Points(geometry, material)); // Small marker at the current sensor pose (position + heading). const poseGizmo = new THREE.Mesh( new THREE.ConeGeometry(0.15, 0.4, 12), new THREE.MeshBasicMaterial({ color: 0x5b8cff }), ); poseGizmo.rotation.x = Math.PI / 2; // ConeGeometry points +y by default; point it along +x (forward) instead poseGizmo.visible = false; scene.add(poseGizmo); let hasCentered = false; let running = true; let paused = false; let viewMode = "orbit"; // or "lidar" const latestPose = { position: new THREE.Vector3(), quaternion: new THREE.Quaternion(), valid: false }; const lookTarget = new THREE.Vector3(); const FORWARD = new THREE.Vector3(1, 0, 0); // body-frame +x — matches the poseGizmo's forward convention below function resize() { const w = canvas.clientWidth, h = canvas.clientHeight; if (w === 0 || h === 0) return; renderer.setSize(w, h, false); camera.aspect = w / h; camera.updateProjectionMatrix(); } new ResizeObserver(resize).observe(canvas); resize(); function updateCameraForLidarView() { if (!latestPose.valid) return; camera.position.copy(latestPose.position); lookTarget.copy(FORWARD).applyQuaternion(latestPose.quaternion).add(latestPose.position); camera.up.set(0, 0, 1); camera.lookAt(lookTarget); } function animate() { if (!running) return; requestAnimationFrame(animate); if (paused) return; if (viewMode === "lidar") { updateCameraForLidarView(); } else { controls.update(); } renderer.render(scene, camera); } animate(); function applyFrame(buf) { const view = new DataView(buf); if (String.fromCharCode(view.getUint8(0), view.getUint8(1), view.getUint8(2), view.getUint8(3)) !== MAGIC) return; let off = 4; off += 4; // frame_seq — unused client-side for now off += 8; // timestamp const poseFlag = view.getUint32(off, true); off += 4; if (poseFlag) { const x = view.getFloat32(off, true); const y = view.getFloat32(off + 4, true); const z = view.getFloat32(off + 8, true); const qx = view.getFloat32(off + 12, true), qy = view.getFloat32(off + 16, true), qz = view.getFloat32(off + 20, true), qw = view.getFloat32(off + 24, true); off += 28; poseGizmo.position.set(x, y, z); poseGizmo.quaternion.set(qx, qy, qz, qw); poseGizmo.visible = true; latestPose.position.set(x, y, z); latestPose.quaternion.set(qx, qy, qz, qw); latestPose.valid = true; if (!hasCentered) { controls.target.set(x, y, z); camera.position.set(x, y - 8, z + 5); hasCentered = true; } } const n = view.getUint32(off, true); off += 4; const count = Math.min(n, maxPoints); positions.set(new Float32Array(buf, off, count * 3)); const intensity = new Uint8Array(buf, off + n * 3 * 4, count); for (let i = 0; i < count; i++) { const t = intensity[i] / 255; // Cool-to-warm ramp by intensity — no camera colour to draw from // (LiDAR-only), so intensity is the only per-point signal worth encoding. colors[i * 3] = 0.15 + 0.65 * t; colors[i * 3 + 1] = 0.35 + 0.35 * (1 - Math.abs(t - 0.5) * 2); colors[i * 3 + 2] = 0.75 - 0.55 * t; } geometry.attributes.position.needsUpdate = true; geometry.attributes.color.needsUpdate = true; geometry.setDrawRange(0, count); emptyState.classList.toggle("hidden", count > 0); } function connect() { const proto = location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(`${proto}//${location.host}/ws/pointcloud?profile=${profile}`); ws.binaryType = "arraybuffer"; ws.onmessage = (ev) => applyFrame(ev.data); ws.onclose = () => { if (running) setTimeout(connect, 1500); }; ws.onerror = () => ws.close(); } connect(); return { setPaused(p) { paused = p; }, // "orbit" — free OrbitControls camera (default). "lidar" — camera locked // to the sensor's live position/heading, first-person. Disabling // OrbitControls in lidar mode (not just skipping controls.update()) stops // it from silently accumulating drag input it never gets to apply, which // would otherwise jump the view on switching back to orbit. setViewMode(mode) { viewMode = mode; controls.enabled = mode === "orbit"; }, }; }