diff --git a/pcd_viewer.html b/pcd_viewer.html
index 8b78e02..4117ec9 100644
--- a/pcd_viewer.html
+++ b/pcd_viewer.html
@@ -541,6 +541,10 @@ scene.background = new THREE.Color(THEME_BG[currentTheme]);
const camera = new THREE.PerspectiveCamera(60, innerWidth / innerHeight, 0.01, 10000);
camera.position.set(0, 0, 10);
+// Z-up everywhere, matching the actual data (ROS/FAST-LIVO2) and WALK mode —
+// orbiting with camera.up misaligned from the cloud's real vertical makes yaw
+// look like a diagonal tumble instead of a clean spin. See also exitFP().
+camera.up.set(0, 0, 1);
const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' });
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
@@ -553,10 +557,57 @@ controls.enableDamping = true;
controls.dampingFactor = 0.08;
controls.zoomToCursor = true; // zoom toward the cursor, not the orbit target — matches CAD/CloudCompare
// two-finger touch: pinch still zooms, but the drag also free-rotates (any direction the
-// fingers move, not locked to one axis) instead of panning — trades away touch-panning,
-// which double-click-to-recenter (below) mostly covers instead.
+// fingers move, not locked to one axis) — OrbitControls only handles 1-2 simultaneous
+// touches (a 3rd pointer makes it go idle), so three-finger pan below covers panning.
controls.touches.TWO = THREE.TOUCH.DOLLY_ROTATE;
+// ─── three-finger pan — replicates OrbitControls' own (private, unexported) screen-
+// space pan math so it feels identical to right-drag/two-finger-pan elsewhere ──
+const panTouches = new Map(); // pointerId -> {x,y}
+function touchCentroid(map) {
+ let x = 0, y = 0;
+ for (const p of map.values()) { x += p.x; y += p.y; }
+ return { x: x / map.size, y: y / map.size };
+}
+function panByScreenDelta(deltaX, deltaY) {
+ const offset = new THREE.Vector3().copy(camera.position).sub(controls.target);
+ const targetDistance = offset.length() * Math.tan((camera.fov / 2) * Math.PI / 180);
+ const panLeftDist = 2 * deltaX * targetDistance / renderer.domElement.clientHeight * controls.panSpeed;
+ const panUpDist = 2 * deltaY * targetDistance / renderer.domElement.clientHeight * controls.panSpeed;
+
+ const panOffset = new THREE.Vector3().setFromMatrixColumn(camera.matrix, 0).multiplyScalar(-panLeftDist);
+ const yCol = new THREE.Vector3();
+ if (controls.screenSpacePanning) yCol.setFromMatrixColumn(camera.matrix, 1);
+ else { yCol.setFromMatrixColumn(camera.matrix, 0); yCol.crossVectors(camera.up, yCol); }
+ panOffset.add(yCol.multiplyScalar(panUpDist));
+
+ camera.position.add(panOffset);
+ controls.target.add(panOffset);
+ controls.update();
+ invalidate();
+}
+let panCentroid = null;
+renderer.domElement.addEventListener('pointerdown', e => {
+ if (e.pointerType !== 'touch') return;
+ panTouches.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ panCentroid = panTouches.size === 3 ? touchCentroid(panTouches) : null;
+});
+renderer.domElement.addEventListener('pointermove', e => {
+ if (e.pointerType !== 'touch' || !panTouches.has(e.pointerId)) return;
+ panTouches.set(e.pointerId, { x: e.clientX, y: e.clientY });
+ if (panTouches.size !== 3) return;
+ const c = touchCentroid(panTouches);
+ if (panCentroid) panByScreenDelta(c.x - panCentroid.x, c.y - panCentroid.y);
+ panCentroid = c;
+});
+function releaseTouchPan(e) {
+ if (e.pointerType !== 'touch' || !panTouches.has(e.pointerId)) return;
+ panTouches.delete(e.pointerId);
+ panCentroid = panTouches.size === 3 ? touchCentroid(panTouches) : null;
+}
+renderer.domElement.addEventListener('pointerup', releaseTouchPan);
+renderer.domElement.addEventListener('pointercancel', releaseTouchPan);
+
// ─── on-demand rendering (perf: idle frames cost nothing) ────────────────
let needsRender = true;
const invalidate = () => { needsRender = true; };
@@ -1023,7 +1074,7 @@ function exitFP() {
const d = new THREE.Vector3(); camera.getWorldDirection(d);
const r = points?.geometry.boundingSphere?.radius || 5;
controls.target.copy(camera.position).addScaledVector(d, Math.max(r, 5));
- camera.up.set(0, 1, 0);
+ camera.up.set(0, 0, 1); // back to the app-wide Z-up default, not three.js's Y-up
controls.enabled = true;
controls.update();
fpStartBtn.disabled = false; fpExitBtn.disabled = true;