diff --git a/src/components/ViewerControls.tsx b/src/components/ViewerControls.tsx index 07563c0..80d9c32 100644 --- a/src/components/ViewerControls.tsx +++ b/src/components/ViewerControls.tsx @@ -1,4 +1,4 @@ -import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download } from 'lucide-react'; +import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download, Footprints, Compass, Map } from 'lucide-react'; import { Slider } from '@/components/ui/slider'; import { Button } from '@/components/ui/button'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; @@ -44,6 +44,9 @@ export function ViewerControls() { selectionMode, setSelectionMode, selectedElementKeys, hiddenElementKeys, isolatedElementKeys, hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection, + desktopCameraMode, setDesktopCameraMode, + xrEnvironment, setXrEnvironment, + xrScaleMode, setXrScaleMode, } = useModelStore(); const activeModel = models.find(m => m.id === activeModelId); const hasSelection = selectedElementKeys.size > 0; @@ -290,6 +293,74 @@ export function ViewerControls() { )} + {/* Seletor de Câmera (Orbit vs Walk) */} + { + const next = desktopCameraMode === 'orbit' ? 'walk' : 'orbit'; + setDesktopCameraMode(next); + if (next === 'walk') { + toast.info("Passeio 3D Ativo: clique no visualizador para prender o mouse. WASD = andar, Q/E = subir/descer, ESC = soltar mouse."); + } + }} + title="Alternar entre câmera orbital e passeio em primeira pessoa" + > + {desktopCameraMode === 'walk' ? : } + + {desktopCameraMode === 'walk' ? 'Passeio 3D' : 'Câmera'} + + + + {/* Seletor de Cenário 2D */} + + + + + + {xrEnvironment === 'passthrough' ? 'Estúdio' : xrEnvironment === 'office' ? 'Escritório' : 'Campo Aberto'} + + + + + + + Cenário de Fundo + + setXrEnvironment('passthrough')} + > + 🌌 Estúdio (Padrão) + + { + setXrEnvironment('office'); + setXrScaleMode('tabletop'); + }} + > + 🏢 Escritório (VR Mesa) + + { + setXrEnvironment('field'); + setXrScaleMode('realscale'); + }} + > + 🌳 Campo Aberto (VR Grama) + + + + {/* Screenshot */} s.desktopCameraMode); + const xrEnvironment = useModelStore((s) => s.xrEnvironment); + + // Armazena o estado atual das teclas de locomoção + const keys = useRef({ + w: false, + a: false, + s: false, + d: false, + arrowup: false, + arrowleft: false, + arrowdown: false, + arrowright: false, + q: false, // voar para baixo + e: false, // voar para cima + }); + + useEffect(() => { + if (activeMode !== 'walk') return; + + const handleKeyDown = (e: KeyboardEvent) => { + const k = e.key.toLowerCase(); + if (k in keys.current) { + keys.current[k as keyof typeof keys.current] = true; + } + }; + + const handleKeyUp = (e: KeyboardEvent) => { + const k = e.key.toLowerCase(); + if (k in keys.current) { + keys.current[k as keyof typeof keys.current] = false; + } + }; + + window.addEventListener('keydown', handleKeyDown); + window.addEventListener('keyup', handleKeyUp); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('keyup', handleKeyUp); + }; + }, [activeMode]); + + // Define posições iniciais amigáveis ao alternar para o modo passeio + const lastMode = useRef(null); + useEffect(() => { + if (activeMode === 'walk' && lastMode.current !== 'walk') { + if (xrEnvironment === 'office') { + // Posiciona o operador em pé na ponta da mesa + camera.position.set(0, 1.4, 1.3); + camera.lookAt(0, 0.85, 0); + } else { + // Posiciona no campo aberto em escala real + camera.position.set(0, 1.65, 3); + camera.lookAt(0, 1.0, 0); + } + } + lastMode.current = activeMode; + }, [activeMode, xrEnvironment, camera]); + + useFrame((state, delta) => { + if (activeMode !== 'walk') return; + + const speed = 2.0 * delta; // Metros por frame adaptativo + const rotSpeed = 1.5 * delta; + + const direction = new THREE.Vector3(); + const side = new THREE.Vector3(); + + // Obtém vetor de olhar da câmera + camera.getWorldDirection(direction); + direction.y = 0; // Trava o vetor na horizontal para não voar para cima/baixo ao olhar + direction.normalize(); + + // Obtém vetor lateral + side.crossVectors(camera.up, direction).normalize(); + + // 1. Leituras do teclado + let moveFwd = 0; + let moveSide = 0; + let moveUp = 0; + + if (keys.current.w || keys.current.arrowup) moveFwd += 1; + if (keys.current.s || keys.current.arrowdown) moveFwd -= 1; + if (keys.current.d || keys.current.arrowright) moveSide -= 1; + if (keys.current.a || keys.current.arrowleft) moveSide += 1; + if (keys.current.e) moveUp += 1; + if (keys.current.q) moveUp -= 1; + + camera.position.addScaledVector(direction, moveFwd * speed); + camera.position.addScaledVector(side, moveSide * speed); + camera.position.y += moveUp * speed; + + // 2. Leituras da Gamepad API (Xbox / PlayStation) + const gamepads = navigator.getGamepads(); + const gp = gamepads[0] || gamepads[1]; + if (gp) { + const deadzone = 0.15; + + // Analógico Esquerdo (Locomoção) + const stickLX = gp.axes[0]; // Eixo lateral + const stickLY = gp.axes[1]; // Eixo frente/trás + + if (Math.abs(stickLY) > deadzone) { + camera.position.addScaledVector(direction, -stickLY * speed); + } + if (Math.abs(stickLX) > deadzone) { + camera.position.addScaledVector(side, -stickLX * speed); + } + + // Analógico Direito (Rotação da Câmera) + const stickRX = gp.axes[2]; // Rotação Y + const stickRY = gp.axes[3]; // Olhar X cima/baixo + + if (Math.abs(stickRX) > deadzone) { + camera.rotation.y -= stickRX * rotSpeed * 1.6; + } + if (Math.abs(stickRY) > deadzone) { + // Rotaciona verticalmente + camera.rotation.x -= stickRY * rotSpeed * 1.6; + // Limita ângulo vertical para evitar inverter a visão (entre -80° e 80°) + camera.rotation.x = Math.max(-1.4, Math.min(1.4, camera.rotation.x)); + } + + // Gatilhos para controle de altura (RT sobe, LT desce) + const triggerLT = gp.buttons[6]; + const triggerRT = gp.buttons[7]; + if (triggerRT && triggerRT.value > 0.1) { + camera.position.y += triggerRT.value * speed; + } + if (triggerLT && triggerLT.value > 0.1) { + camera.position.y -= triggerLT.value * speed; + } + } + + // Trava de colisão com o chão/mesa para manter realismo + const minHeight = xrEnvironment === 'office' ? 0.9 : 0.1; + if (camera.position.y < minHeight) { + camera.position.y = minHeight; + } + }); + + if (activeMode !== 'walk') return null; + + return ; +} diff --git a/src/components/three/ModelViewer.tsx b/src/components/three/ModelViewer.tsx index f4edd9d..e3a3437 100644 --- a/src/components/three/ModelViewer.tsx +++ b/src/components/three/ModelViewer.tsx @@ -23,6 +23,7 @@ import { XREnvironmentSelector, } from './XRSceneComponents'; import { XRGrabbable } from './XRGrabbable'; +import { DesktopFirstPersonControls } from './DesktopFirstPersonControls'; import { XRHitTestPlacement } from './XRHitTestPlacement'; import { XRControllerMeasure } from './XRControllerMeasure'; import { ControllerLocomotion } from './ControllerLocomotion'; @@ -1205,6 +1206,7 @@ function SceneInsideXR({ const activeModel = models.find(m => m.id === activeId); const isActiveLocked = !!activeModel?.locked; const xrEnvironment = useModelStore((s) => s.xrEnvironment); + const desktopCameraMode = useModelStore((s) => s.desktopCameraMode); const rigRef = useRef(null); const devkit = useDevKit(); @@ -1225,9 +1227,15 @@ function SceneInsideXR({ // Modo 2D normal (não imersivo) return ( <> - - - + {xrEnvironment === 'passthrough' && ( + <> + + + + > + )} + + }> @@ -1246,14 +1254,18 @@ function SceneInsideXR({ - + {desktopCameraMode === 'orbit' ? ( + + ) : ( + + )} > ); } diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts index 6ca1312..437a677 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -311,6 +311,8 @@ interface ModelStore { setXrEnvironment: (env: 'passthrough' | 'office' | 'field') => void; xrScaleMode: 'tabletop' | 'realscale'; setXrScaleMode: (mode: 'tabletop' | 'realscale') => void; + desktopCameraMode: 'orbit' | 'walk'; + setDesktopCameraMode: (mode: 'orbit' | 'walk') => void; } /** Sync top-level legacy `model` / `fineTuning` from active SceneModel. */ @@ -695,4 +697,6 @@ export const useModelStore = create((set, get) => ({ setXrEnvironment: (env) => set({ xrEnvironment: env }), xrScaleMode: 'realscale', setXrScaleMode: (mode) => set({ xrScaleMode: mode }), + desktopCameraMode: 'orbit', + setDesktopCameraMode: (mode) => set({ desktopCameraMode: mode }), }));