From 2725281775aef585ab06e2cfd3870b64c43fe019 Mon Sep 17 00:00:00 2001 From: Tanish Manku Date: Fri, 1 May 2026 06:28:15 -0700 Subject: [PATCH] 3D: Fix broken freecam regression after refactor (#1468) --- .../ControlPanelSceneObject.tsx | 8 +- .../app/src/pages/PageScene/engine/editor.ts | 8 +- .../PageScene/engine/keybinds_controls.ts | 12 +++ .../app/src/pages/PageScene/engine/keymap.ts | 6 ++ .../src/pages/PageScene/hooks/useFreeCam.ts | 83 +++++++++++-------- 5 files changed, 81 insertions(+), 36 deletions(-) diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx b/frontend/apps/artcraft/app/src/pages/PageScene/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx index 0c630831b8..cf81476f4f 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx +++ b/frontend/apps/artcraft/app/src/pages/PageScene/comps/ControlPanelSceneObject/ControlPanelSceneObject.tsx @@ -184,7 +184,13 @@ export const ControlPanelSceneObject = () => { // - Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z (capture phase): flush before the // engine's keymap handler runs the undo, so the pending entry is // the one being reverted. + // + // Scoped to `isShowing` so the listeners aren't attached when the + // panel is hidden (notably during CAMERA_VIEW, where stray + // document mouseup/keydown listeners can interact poorly with the + // freecam pointer-capture and canvas keydown handlers). useEffect(() => { + if (!isShowing) return; const flush = () => { transformSessionRef.current?.commit(); transformSessionRef.current = null; @@ -204,7 +210,7 @@ export const ControlPanelSceneObject = () => { document.removeEventListener("mouseup", flush); document.removeEventListener("keydown", onKeyDown, true); }; - }, []); + }, [isShowing]); if (!currentSceneObject) { return null; diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor.ts index 917e58b350..df7713d7b4 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/editor.ts @@ -103,7 +103,13 @@ class Editor { sceneManager: SceneManager | undefined; - focused: boolean = false; + // True when the user is allowed to drive the camera with the mouse + // (left/middle-click look-around). Flipped to false WHILE a gizmo + // handle is being dragged, then back to true on dragend so the + // gizmo drag doesn't accidentally pan the camera. Defaults to true + // so camera controls are available before the user has touched the + // gizmo for the first time. + focused: boolean = true; renderIndex: number; stats: Stats; diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/keybinds_controls.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/keybinds_controls.ts index 218bcc01c3..bcf4d520ee 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/keybinds_controls.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/keybinds_controls.ts @@ -224,6 +224,18 @@ export class MouseControls { !this.isBoneDragged ) { this.isMouseClicked = true; + // Request pointer lock here (a real primary-gesture event) + // rather than from onMouseMove. Browsers reliably honor lock + // requests from mousedown but may reject ones from mousemove + // — the latter started failing intermittently after the + // useFreeCam listeners became always-attached. + if ( + isPointerLockSupported() && + this.lockControls && + !this.lockControls.isLocked + ) { + this.lockControls.lock(); + } } } diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/engine/keymap.ts b/frontend/apps/artcraft/app/src/pages/PageScene/engine/keymap.ts index 00eb252c12..8d39b5d080 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/engine/keymap.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/engine/keymap.ts @@ -72,6 +72,10 @@ const openAssetModal = () => { store.setAssetModalVisibleDuringDrag(true); }; +const toggleCameraView = (editor: Editor) => { + editor.cameraController.switchCameraView(); +}; + const undo = async (editor: Editor) => { await editor.history.undo(); }; @@ -109,6 +113,8 @@ export const buildKeymap = (): KeyBinding[] => [ run: focusSelected }, { code: "KeyB", label: "Open asset menu", group: "View", run: openAssetModal }, + { code: "Space", label: "Toggle camera view", group: "View", + run: toggleCameraView, preventDefault: true }, { code: "Escape", label: "Clear selection / exit pose", group: "Selection", run: onEscape }, diff --git a/frontend/apps/artcraft/app/src/pages/PageScene/hooks/useFreeCam.ts b/frontend/apps/artcraft/app/src/pages/PageScene/hooks/useFreeCam.ts index e8897444cd..9ed279f51a 100644 --- a/frontend/apps/artcraft/app/src/pages/PageScene/hooks/useFreeCam.ts +++ b/frontend/apps/artcraft/app/src/pages/PageScene/hooks/useFreeCam.ts @@ -11,19 +11,41 @@ import { type FreeCamControlState, } from "~/pages/PageScene/engine/cameraMath"; import { usePageSceneStore } from "~/pages/PageScene/PageSceneStore"; -import { EditorStates } from "~/pages/PageScene/enums"; import type Editor from "~/pages/PageScene/engine/editor"; -// Drives the 3D viewport's camera while the editor is in CAMERA_VIEW. -// Owns: -// - Listener attachment to the canvas (no window/document handlers). -// - Drag state + held-key state in refs. -// - The shared FreeCamControlState handed to the editor's render -// loop via setFreeCamState. +// Drives the 3D viewport's "fly" camera controls (right-click pan, +// wheel zoom, WASD/QE forward/back/strafe/up/down, arrow-key roll/yaw) +// for navigating the scene in EDIT mode. CAMERA_VIEW (the virtual- +// camera POV preview) re-uses the same input pipeline. // -// The editor reads this state on every frame (see editor.ts render -// loop) and integrates it via freeCamFrameTick. There is no FreeCam -// class instance — the math lives in cameraMath.ts. +// Pointer + wheel listeners go on the canvas (events naturally route +// to canvas regardless of focus). WASD/QE/arrow keys go on document +// with an editable-element guard, so the user can fly without +// clicking the canvas first to give it focus. + +const EDITABLE_INPUT_TYPES = new Set([ + "text", + "search", + "email", + "password", + "number", + "url", + "tel", +]); + +const isEventFromEditableElement = (event: KeyboardEvent): boolean => { + const target = event.target; + if (!(target instanceof HTMLElement)) return false; + if (target instanceof HTMLInputElement) { + if (target.disabled || target.readOnly) return false; + const type = target.type?.toLowerCase() ?? ""; + return type === "" || EDITABLE_INPUT_TYPES.has(type); + } + if (target instanceof HTMLTextAreaElement) { + return !(target.disabled || target.readOnly); + } + return target.isContentEditable; +}; export const useFreeCam = ( canvas: HTMLCanvasElement | null, @@ -33,9 +55,6 @@ export const useFreeCam = ( const dragRef = useRef<{ x: number; y: number; pointerId: number } | null>( null, ); - const enabled = usePageSceneStore( - (s) => s.editorState === EditorStates.CAMERA_VIEW, - ); // Hand the state to the editor so its render loop can integrate it. useEffect(() => { @@ -44,27 +63,17 @@ export const useFreeCam = ( return () => editor.cameraController.setFreeCamState(null); }, [editor]); - // Toggle enabled and clear motion when leaving CAMERA_VIEW so the - // camera doesn't drift on a stale held key. + // Attach listeners. Pointer/wheel on canvas (so they only fire + // inside the viewport); keys on document so the user doesn't need + // to click canvas first for WASD to work. useEffect(() => { + if (!canvas || !editor) return; const state = stateRef.current; - state.enabled = enabled; - if (!enabled) { - state.moveKeys = emptyMoveKeys(); - state.rotateKeys = emptyRotateKeys(); - state.velocity.set(0, 0, 0); - dragRef.current = null; - } - }, [enabled]); - - // Attach listeners to the canvas. Canvas-scoped means no listener - // leaks beyond the viewport, and no need for mouseover gating. - useEffect(() => { - if (!canvas || !enabled || !editor) return; - const state = stateRef.current; + state.enabled = true; const onKeyDown = (e: KeyboardEvent) => { if (usePageSceneStore.getState().isPromptBoxFocused) return; + if (isEventFromEditableElement(e)) return; const moveSlot = moveSlotForKeyCode(e.code); if (moveSlot) state.moveKeys[moveSlot] = 1; const rotateSlot = rotateSlotForKeyCode(e.code); @@ -73,6 +82,7 @@ export const useFreeCam = ( const onKeyUp = (e: KeyboardEvent) => { if (usePageSceneStore.getState().isPromptBoxFocused) return; + if (isEventFromEditableElement(e)) return; const moveSlot = moveSlotForKeyCode(e.code); if (moveSlot) state.moveKeys[moveSlot] = 0; const rotateSlot = rotateSlotForKeyCode(e.code); @@ -128,22 +138,27 @@ export const useFreeCam = ( const onContextMenu = (e: Event) => e.preventDefault(); - canvas.addEventListener("keydown", onKeyDown); - canvas.addEventListener("keyup", onKeyUp); canvas.addEventListener("pointerdown", onPointerDown); canvas.addEventListener("pointerup", onPointerUp); canvas.addEventListener("pointermove", onPointerMove); canvas.addEventListener("wheel", onWheel, { passive: true }); canvas.addEventListener("contextmenu", onContextMenu); + document.addEventListener("keydown", onKeyDown); + document.addEventListener("keyup", onKeyUp); return () => { - canvas.removeEventListener("keydown", onKeyDown); - canvas.removeEventListener("keyup", onKeyUp); + state.enabled = false; + state.moveKeys = emptyMoveKeys(); + state.rotateKeys = emptyRotateKeys(); + state.velocity.set(0, 0, 0); + dragRef.current = null; canvas.removeEventListener("pointerdown", onPointerDown); canvas.removeEventListener("pointerup", onPointerUp); canvas.removeEventListener("pointermove", onPointerMove); canvas.removeEventListener("wheel", onWheel); canvas.removeEventListener("contextmenu", onContextMenu); + document.removeEventListener("keydown", onKeyDown); + document.removeEventListener("keyup", onKeyUp); }; - }, [canvas, editor, enabled]); + }, [canvas, editor]); };