/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Keyboard controls hook for the 3D viewport * Handles keyboard shortcuts, walk mode, continuous movement */ import { useEffect, type MutableRefObject } from 'react'; import type { Renderer } from '@ifc-lite/renderer'; import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry'; import type { SectionPlane } from '@/store'; import { goHomeFromStore } from '@/store/homeView'; import { getEntityBounds } from '../../utils/viewportUtils.js'; export interface UseKeyboardControlsParams { rendererRef: MutableRefObject; isInitialized: boolean; keyboardHandlersRef: MutableRefObject<{ handleKeyDown: ((e: KeyboardEvent) => void) | null; handleKeyUp: ((e: KeyboardEvent) => void) | null; }>; firstPersonModeRef: MutableRefObject; geometryBoundsRef: MutableRefObject<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }>; coordinateInfoRef: MutableRefObject; geometryRef: MutableRefObject; selectedEntityIdRef: MutableRefObject; hiddenEntitiesRef: MutableRefObject>; isolatedEntitiesRef: MutableRefObject | null>; selectedModelIndexRef: MutableRefObject; clearColorRef: MutableRefObject<[number, number, number, number]>; activeToolRef: MutableRefObject; sectionPlaneRef: MutableRefObject; sectionRangeRef: MutableRefObject<{ min: number; max: number } | null>; updateCameraRotationRealtime: (rotation: { azimuth: number; elevation: number }) => void; calculateScale: () => void; } /** Keys that trigger continuous movement (arrow keys + WASD + shift for sprint) */ const MOVEMENT_KEYS = new Set([ 'arrowup', 'arrowdown', 'arrowleft', 'arrowright', 'w', 's', 'a', 'd', 'shift', ]); export function useKeyboardControls(params: UseKeyboardControlsParams): void { const { rendererRef, isInitialized, keyboardHandlersRef, firstPersonModeRef, geometryBoundsRef, coordinateInfoRef, geometryRef, selectedEntityIdRef, hiddenEntitiesRef, isolatedEntitiesRef, selectedModelIndexRef, clearColorRef, activeToolRef, sectionPlaneRef, sectionRangeRef, updateCameraRotationRealtime, calculateScale, } = params; useEffect(() => { const renderer = rendererRef.current; if (!renderer || !isInitialized) return; const camera = renderer.getCamera(); let aborted = false; const keyState: { [key: string]: boolean } = {}; let moveLoopRunning = false; let moveFrameId: number | null = null; const renderScene = () => { renderer.requestRender(); }; const handleKeyDown = (e: KeyboardEvent) => { const target = e.target as HTMLElement; if ( target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable ) { return; } keyState[e.key.toLowerCase()] = true; // Start movement loop when a movement key is pressed if (MOVEMENT_KEYS.has(e.key.toLowerCase()) && !moveLoopRunning) { moveLoopRunning = true; keyboardMove(); } // Preset views - set view and re-render const setViewAndRender = (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right') => { const rotation = coordinateInfoRef.current?.buildingRotation; camera.setPresetView(view, geometryBoundsRef.current, rotation); renderScene(); updateCameraRotationRealtime(camera.getRotation()); calculateScale(); }; if (e.key === '1') setViewAndRender('top'); if (e.key === '2') setViewAndRender('bottom'); if (e.key === '3') setViewAndRender('front'); if (e.key === '4') setViewAndRender('back'); if (e.key === '5') setViewAndRender('left'); if (e.key === '6') setViewAndRender('right'); // Frame selection (F) - zoom to fit selection, or fit all if nothing selected if (e.key === 'f' || e.key === 'F') { const selectedId = selectedEntityIdRef.current; if (selectedId !== null) { const bounds = getEntityBounds(geometryRef.current, selectedId); if (bounds) { camera.frameBounds(bounds.min, bounds.max, 300); } } else { camera.zoomExtent(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 300); } calculateScale(); } // Home view (H) - reset to isometric if (e.key === 'h' || e.key === 'H') { goHomeFromStore(); } // Fit all / Zoom extents (Z) if (e.key === 'z' || e.key === 'Z') { camera.zoomExtent(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 300); calculateScale(); } }; const handleKeyUp = (e: KeyboardEvent) => { keyState[e.key.toLowerCase()] = false; // Stop movement loop when no movement keys are held const anyHeld = Array.from(MOVEMENT_KEYS).some(k => keyState[k]); if (!anyHeld && moveLoopRunning) { moveLoopRunning = false; if (moveFrameId !== null) { cancelAnimationFrame(moveFrameId); moveFrameId = null; } } }; keyboardHandlersRef.current.handleKeyDown = handleKeyDown; keyboardHandlersRef.current.handleKeyUp = handleKeyUp; const keyboardMove = () => { if (aborted || !moveLoopRunning) return; let moved = false; const isWalkMode = activeToolRef.current === 'walk'; if (isWalkMode) { // Walk mode: arrow keys + WASD move on horizontal plane // Up/W = forward, Down/S = backward, Left/A = strafe left, Right/D = strafe right const fwd = (keyState['arrowup'] || keyState['w'] ? 1 : 0) + (keyState['arrowdown'] || keyState['s'] ? -1 : 0); const strafe = (keyState['arrowleft'] || keyState['a'] ? -1 : 0) + (keyState['arrowright'] || keyState['d'] ? 1 : 0); if (fwd !== 0 || strafe !== 0) { const sprint = keyState['shift'] ? 2 : 1; camera.moveFirstPerson(fwd * sprint, strafe * sprint, 0); moved = true; } } else { // Normal mode: arrow keys pan the view const panSpeed = 5; if (keyState['arrowup']) { camera.pan(0, -panSpeed, false); moved = true; } if (keyState['arrowdown']) { camera.pan(0, panSpeed, false); moved = true; } if (keyState['arrowleft']) { camera.pan(panSpeed, 0, false); moved = true; } if (keyState['arrowright']) { camera.pan(-panSpeed, 0, false); moved = true; } } if (moved) { renderScene(); } moveFrameId = requestAnimationFrame(keyboardMove); }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); return () => { aborted = true; moveLoopRunning = false; if (moveFrameId !== null) { cancelAnimationFrame(moveFrameId); } window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; }, [isInitialized]); } export default useKeyboardControls;