Initial commit: scan_web integrated recording UI

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.
This commit is contained in:
gardentech
2026-08-07 14:26:00 +09:00
commit f34817d5b6
38 changed files with 5430 additions and 0 deletions
+169
View File
@@ -0,0 +1,169 @@
// 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";
},
};
}