From edd900d29bbb08c0a7751fb96bbd74991a371a43 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 8 May 2026 21:02:05 -0700 Subject: [PATCH] ui-pagescene: snapshot+restore camera quat across pointer-lock unlock to stop rotation snap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user left/middle-click drags to rotate the camera and releases, Chromium synthesizes one final `mousemove` while exiting pointer lock so it can reposition the cursor back to where the user grabbed it. At that exact moment, Three's PointerLockControls still has `isLocked === true` (the flag flips after the unlock event dispatches), so its mousemove handler reads the synthesized `movementX/Y` as user input and rotates the camera by it — producing a visible lurch back by roughly the inverse of the user's last drag delta. Capture the camera quaternion in `MouseControls.onMouseUp` immediately before `lockControls.unlock()`. In a real (non-debug) `unlock` listener attached at editor init, copy the snapshot back into the camera and call `updateMatrixWorld()`. By the time `unlock` fires, the synthetic move has already been applied, so overwriting is the surgical fix at the boundaries we actually own — no Three.js subclassing, no per-frame state machine. Trace that confirmed the cause: [mc] up button=1 locked=true quat=(-0.2684, 0.1394, 0.0393, 0.9524) [cam-frame] quat=(-0.2684, 0.1394, 0.0393, 0.9524) [plc] unlock quat=(-0.2180, 0.5112, 0.1358, 0.8202) --- .../pagescene/src/lib/engine/editor.ts | 19 +++++++++++++++++++ .../src/lib/engine/keybinds_controls.ts | 15 +++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/frontend/libs/components/pagescene/src/lib/engine/editor.ts b/frontend/libs/components/pagescene/src/lib/engine/editor.ts index 99fa787757..5b8c1d1b8f 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/editor.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/editor.ts @@ -425,6 +425,25 @@ class Editor { this.cameraController.camera, this.renderer.domElement, ); + // Restore the quaternion captured at mouseup. Chromium synthesizes + // one final `mousemove` while exiting pointer lock so it can + // reposition the cursor; PLC reads that synthesized delta and + // rotates the camera by it because `isLocked` is still true at + // that moment. By the time the `unlock` event fires here, that + // synthetic move has already been applied — overwriting it with + // the pre-unlock snapshot keeps the camera at the orientation the + // user actually released on. + this.cameraController.lockControls.addEventListener("unlock", () => { + const snap = this.mouse_controls?.quatAtUnlockRequest; + const cam = this.cameraController.camera; + if (snap && cam) { + cam.quaternion.copy(snap); + cam.updateMatrixWorld(); + } + if (this.mouse_controls) { + this.mouse_controls.quatAtUnlockRequest = null; + } + }); } // FreeCam math + listeners now live in hooks/useFreeCam.ts; the // editor reads `cameraController.freeCamState` (set by that hook) on diff --git a/frontend/libs/components/pagescene/src/lib/engine/keybinds_controls.ts b/frontend/libs/components/pagescene/src/lib/engine/keybinds_controls.ts index 9fd42d73d3..afc00988ed 100644 --- a/frontend/libs/components/pagescene/src/lib/engine/keybinds_controls.ts +++ b/frontend/libs/components/pagescene/src/lib/engine/keybinds_controls.ts @@ -130,6 +130,14 @@ export class MouseControls { private isBoneDragged: boolean = false; private ignoreNextClick: boolean = false; + // Snapshot of the camera quaternion taken in onMouseUp right before + // lockControls.unlock(). The unlock listener in editor.ts restores + // this value to neutralize the synthetic mousemove Chromium fires + // while pointer-lock is releasing — that synthetic move would + // otherwise be processed by PointerLockControls and lurch the camera + // back by the cursor's "trip home" delta, producing a visible snap. + quatAtUnlockRequest: THREE.Quaternion | null = null; + // Deps held for read callbacks + bus emissions throughout the class. // Constructor still copies primitive fields onto `this.x` for the // existing internals; new code reads through `this.deps` directly. @@ -273,6 +281,13 @@ export class MouseControls { onMouseUp(event: any) { if (event.button === 0 || event.button === 1) { + // Snapshot the quaternion the user actually ended on. The unlock + // listener in editor.ts will restore this once Chromium's + // synthetic-mousemove window has closed, so the camera doesn't + // lurch by the cursor's trip-home delta. + if (this.camera) { + this.quatAtUnlockRequest = this.camera.quaternion.clone(); + } this.lockControls?.unlock(); this.isMouseClicked = false; }