f34817d5b6
FastAPI backend + vanilla JS frontend for LiDAR/camera startup, camera settings, and rosbag recording, unifying the previous scan_gui/scan_gui_dual/ scan_gui_triple desktop tools into one web app.
170 lines
6.5 KiB
JavaScript
170 lines
6.5 KiB
JavaScript
// 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";
|
|
},
|
|
};
|
|
}
|