diff --git a/src/components/three/ControllerLocomotion.tsx b/src/components/three/ControllerLocomotion.tsx index b8b7a80..3f06aba 100644 --- a/src/components/three/ControllerLocomotion.tsx +++ b/src/components/three/ControllerLocomotion.tsx @@ -1,171 +1,223 @@ -import { useRef, RefObject } from 'react'; +import { useRef, useState } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; import { useXR } from '@react-three/xr'; import * as THREE from 'three'; import { getAllModelLocalGroups } from '@/lib/modelTransforms'; +import { useModelStore } from '@/stores/useModelStore'; +import { toast } from 'sonner'; /** - * Quest 3 thumbstick locomotion (rig-relative). - * - * Mapeamento (thumbstick = axes[2]/axes[3] em xr-standard): - * • Para frente (axes[1] < -0.7) → exibe mira no modelo em tempo real. - * Ao soltar (axis volta a ~0) → teletransporta para a mira. - * Se não houver modelo atingido pelo olhar, projeta mira a 0.5m. - * • Para trás (axes[1] > 0.7) → passo de 0.3 m oposto ao olhar. - * Mapeado diretamente para facilitar pequenos ajustes. - * • Lateral (|axes[0]| > 0.7) → snap-rotate ±30° no eixo Y do rig. - * - * NÃO lê grip ou trigger — esses são responsabilidade exclusiva de - * XRGrabbable e XRControllerMeasure, respectivamente. + * Mapeamento de Locomoção por Teletransporte Parabólico via Controle XR: + * + * - Incline e segure o Thumbstick (analógico) de qualquer controle para frente. + * Um arco parabólico surgirá projetado a partir da mira física do controle. + * - Ao soltar o direcional, você se teletransporta para o local. + * - Para entrar em escala real dentro do modelo, aponte diretamente para ele. A escala + * será automaticamente definida para 1:1 ao pousar. */ interface Props { /** Ref para o que envolve ; sua pose move o usuário. */ - rigRef: RefObject; + rigRef: React.RefObject; } const DEAD = 0.7; const RELEASE = 0.3; -const SNAP_DEG = 30; -const STEP_BACK = 0.3; -const STEP_FWD_NO_HIT = 0.5; export function ControllerLocomotion({ rigRef }: Props) { - const { camera } = useThree(); + const { camera, gl } = useThree(); const session = useXR((s) => s.session); const fwdPushed = useRef(false); - const backLatched = useRef(false); - const rotLatched = useRef<0 | -1 | 1>(0); const raycaster = useRef(new THREE.Raycaster()); + + // Refs visuais + const arcLineRef = useRef(null); + const lineGeomRef = useRef(null); const reticleRef = useRef(null); - useFrame(() => { + // Estados temporários do teletransporte + const lastTeleportTarget = useRef(null); + const lastTeleportHitModel = useRef(false); + + useFrame((_state, _dt, frame: XRFrame | undefined) => { const rig = rigRef.current; - const reticle = reticleRef.current; - if (!rig || !session) { - if (reticle && reticle.visible) reticle.visible = false; + if (!rig || !session || !frame) { + if (arcLineRef.current) arcLineRef.current.visible = false; + if (reticleRef.current) reticleRef.current.visible = false; return; } - let stickX = 0; + const referenceSpace = gl.xr.getReferenceSpace(); + if (!referenceSpace) return; + + let activeSource: XRInputSource | null = null; let stickY = 0; + + // Procura qualquer controle com analógico inclinado para a frente for (const src of session.inputSources) { const gp = src.gamepad; - if (!gp || gp.axes.length < 4) continue; - // Preferencia pelo stick esquerdo para locomoção - if (src.handedness === 'left') { - stickX = gp.axes[2] ?? 0; - stickY = gp.axes[3] ?? 0; - break; - } - if (src.handedness === 'right' && stickX === 0 && stickY === 0) { - stickX = gp.axes[2] ?? 0; - stickY = gp.axes[3] ?? 0; - } - } - - // ── Snap-rotate (lateral) ────────────────────────────────────── - if (Math.abs(stickX) > DEAD) { - const dir: -1 | 1 = stickX > 0 ? 1 : -1; - if (rotLatched.current !== dir) { - rotLatched.current = dir; - rig.rotation.y -= (dir * SNAP_DEG * Math.PI) / 180; - } - } else if (Math.abs(stickX) < RELEASE) { - rotLatched.current = 0; - } - - // ── Back step ────────────────────────────────────────────────── - if (stickY > DEAD) { - if (!backLatched.current) { - backLatched.current = true; - const headDir = new THREE.Vector3(); - camera.getWorldDirection(headDir); - headDir.y = 0; - if (headDir.lengthSq() > 1e-6) { - headDir.normalize(); - rig.position.addScaledVector(headDir, -STEP_BACK); + if (gp && gp.axes.length >= 4) { + const sy = gp.axes[3] ?? 0; // Eixo Y vertical do joystick + if (sy < -DEAD) { + activeSource = src; + stickY = sy; + break; } } - } else if (stickY < RELEASE) { - backLatched.current = false; } - // ── Forward → teleport on release ───────────────────────────── - if (stickY < -DEAD) { + // Se encontramos um controle ativo mirando/inclinado para frente + if (activeSource && stickY < -DEAD) { fwdPushed.current = true; - // Realiza raycast a partir da direção que o usuário está olhando (câmera) const origin = new THREE.Vector3(); - camera.getWorldPosition(origin); const dir = new THREE.Vector3(); - camera.getWorldDirection(dir); - raycaster.current.set(origin, dir); - raycaster.current.far = 30; - // Filtra o raycast para atingir exclusivamente as peças 3D carregadas - const modelsObjects = getAllModelLocalGroups(); - const hits = raycaster.current.intersectObjects(modelsObjects, true); - const hit = hits.find((h) => { - const o = h.object; - if (!(o instanceof THREE.Mesh)) return false; - if (o.userData.__edgeLine) return false; - return true; - }); - - const target = hit ? hit.point.clone() : origin.clone().add(dir.multiplyScalar(STEP_FWD_NO_HIT)); - - if (reticle) { - // Converte o ponto de mundo para o espaço local do rig - const localTarget = target.clone(); - rig.worldToLocal(localTarget); - reticle.position.copy(localTarget); - - if (hit && hit.face) { - const normalWorld = hit.face.normal.clone(); - const nm = new THREE.Matrix3().getNormalMatrix(hit.object.matrixWorld); - normalWorld.applyMatrix3(nm).normalize(); - - // Converte normal do mundo para o espaço local do rig para orientar o retículo - const rigWorldQuat = new THREE.Quaternion(); - rig.getWorldQuaternion(rigWorldQuat); - const normalLocal = normalWorld.clone().applyQuaternion(rigWorldQuat.invert()); - - const quaternion = new THREE.Quaternion().setFromUnitVectors(new THREE.Vector3(0, 1, 0), normalLocal); - reticle.quaternion.copy(quaternion); - } else { - reticle.rotation.set(-Math.PI / 2, 0, 0); - } - reticle.visible = true; + // Obtém pose do controle usando o targetRaySpace (mira física do controle) + const ctrlPose = frame.getPose(activeSource.targetRaySpace, referenceSpace); + if (ctrlPose) { + const m = new THREE.Matrix4().fromArray(ctrlPose.transform.matrix); + origin.setFromMatrixPosition(m); + const q = new THREE.Quaternion().setFromRotationMatrix(m); + dir.set(0, 0, -1).applyQuaternion(q).normalize(); + } else { + // Fallback para direção do olhar se pose falhar + camera.getWorldPosition(origin); + camera.getWorldDirection(dir); } - } else if (fwdPushed.current && stickY > -RELEASE) { - fwdPushed.current = false; - if (reticle && reticle.visible) { - reticle.visible = false; - // Recupera o ponto projetado e calcula o deslocamento em relação à câmera - const localTarget = reticle.position.clone(); - const targetWorld = localTarget.clone(); - rig.localToWorld(targetWorld); + // Parâmetros da simulação do arco parabólico + const points: THREE.Vector3[] = []; + const currentPos = origin.clone(); + const velocity = dir.clone().multiplyScalar(7.5); // velocidade do tiro do arco + const gravity = new THREE.Vector3(0, -9.8, 0); + const dt = 0.025; // passo da física + let hitPoint: THREE.Vector3 | null = null; + let hitModel = false; + const modelsObjects = getAllModelLocalGroups(); - const camWorld = new THREE.Vector3(); - camera.getWorldPosition(camWorld); + points.push(currentPos.clone()); - const delta = new THREE.Vector3().subVectors(targetWorld, camWorld); - delta.y = 0; // preserva altura do rig do usuário - rig.position.add(delta); + // Executa a trajetória balística + for (let i = 0; i < 35; i++) { + const nextPos = currentPos.clone().addScaledVector(velocity, dt); + velocity.addScaledVector(gravity, dt); + + const segDir = new THREE.Vector3().subVectors(nextPos, currentPos); + const segLen = segDir.length(); + + if (segLen > 0.001) { + segDir.normalize(); + raycaster.current.set(currentPos, segDir); + raycaster.current.far = segLen; + + // 1. Verifica colisão com modelos 3D + const hits = raycaster.current.intersectObjects(modelsObjects, true); + const firstHit = hits.find( + (h) => h.object instanceof THREE.Mesh && !h.object.userData.__edgeLine + ); + + if (firstHit) { + hitPoint = firstHit.point.clone(); + hitModel = true; + points.push(hitPoint); + break; + } + + // 2. Verifica colisão com o chão/grid de calibração plano infinito em Y = gridY + const gridY = useModelStore.getState().gridY; + if ( + (currentPos.y >= gridY && nextPos.y <= gridY) || + (currentPos.y <= gridY && nextPos.y >= gridY) + ) { + const t = (gridY - currentPos.y) / (nextPos.y - currentPos.y); + hitPoint = new THREE.Vector3().lerpVectors(currentPos, nextPos, t); + points.push(hitPoint); + break; + } + } + + currentPos.copy(nextPos); + points.push(currentPos.clone()); + } + + const finalTarget = hitPoint ?? points[points.length - 1]; + lastTeleportTarget.current = finalTarget.clone(); + lastTeleportHitModel.current = hitModel; + + // Atualiza a geometria do arco + if (lineGeomRef.current && arcLineRef.current) { + lineGeomRef.current.setFromPoints(points); + lineGeomRef.current.attributes.position.needsUpdate = true; + lineGeomRef.current.computeBoundingSphere(); + arcLineRef.current.visible = true; + } + + // Atualiza o retículo + if (reticleRef.current) { + const localTarget = finalTarget.clone(); + rig.worldToLocal(localTarget); + reticleRef.current.position.copy(localTarget); + reticleRef.current.visible = true; + + // Visual premium: Rosa/Fúcsia para escala real (modelo), Ciano para o grid/chão + const mat = reticleRef.current.material as THREE.MeshBasicMaterial; + if (hitModel) { + mat.color.set('#d946ef'); + reticleRef.current.scale.setScalar(1.2); + } else { + mat.color.set('#06b6d4'); + reticleRef.current.scale.setScalar(1.0); + } } } else { - if (reticle && reticle.visible) { - reticle.visible = false; + // Quando soltar o analógico + if (fwdPushed.current) { + fwdPushed.current = false; + + const targetWorld = lastTeleportTarget.current; + const hitModel = lastTeleportHitModel.current; + + if (targetWorld) { + const camWorld = new THREE.Vector3(); + camera.getWorldPosition(camWorld); + + const delta = new THREE.Vector3().subVectors(targetWorld, camWorld); + + if (hitModel) { + // "Para entrar em escala real dentro do modelo, aponte para ele" + useModelStore.getState().resetScale(); + // Ajusta o rig para que o pé fique no Y do modelo, mantendo a altura da câmera + rig.position.y = targetWorld.y; + delta.y = 0; + toast.success("Entrando na peça em Escala Real (1:1)!"); + } else { + delta.y = 0; // preserva a altura vertical do usuário + } + + rig.position.add(delta); + } } + + // Oculta feixe e retículo + if (arcLineRef.current) arcLineRef.current.visible = false; + if (reticleRef.current) reticleRef.current.visible = false; } }); return ( - - - - + <> + {/* Arco parabólico premium */} + + + + + + {/* Retículo de pouso */} + + + + + ); } diff --git a/src/components/three/XRControllerMeasure.tsx b/src/components/three/XRControllerMeasure.tsx index 41d1bcd..cd62bd7 100644 --- a/src/components/three/XRControllerMeasure.tsx +++ b/src/components/three/XRControllerMeasure.tsx @@ -330,25 +330,7 @@ export function XRControllerMeasure() { trigState.current = false; } - // ── Button A (undo) ────────────────────────────────────────────── - const aBtn = gp?.buttons?.[4]; - const aVal = aBtn ? (aBtn.value || (aBtn.pressed ? 1 : 0)) : 0; - if (!aState.current && aVal > BTN_ON) { - aState.current = true; - useModelStore.getState().undoLastMeasurement(); - } else if (aState.current && aVal < TRIG_OFF) { - aState.current = false; - } - - // ── Button B (clear) ───────────────────────────────────────────── - const bBtn = gp?.buttons?.[5]; - const bVal = bBtn ? (bBtn.value || (bBtn.pressed ? 1 : 0)) : 0; - if (!bState.current && bVal > BTN_ON) { - bState.current = true; - useModelStore.getState().clearMeasurements(); - } else if (bState.current && bVal < TRIG_OFF) { - bState.current = false; - } + // A and B physical buttons are now handled globally in XRHudInWorld.tsx for the Summon Menu. }); // Construct the laser Line object once so we can attach via diff --git a/src/components/three/XRGrabbable.tsx b/src/components/three/XRGrabbable.tsx index 5fa624c..6895e05 100644 --- a/src/components/three/XRGrabbable.tsx +++ b/src/components/three/XRGrabbable.tsx @@ -23,7 +23,7 @@ interface SingleGrabRecord { hand: 'left' | 'right'; /** Offset (world) = groupWorldPos - controllerWorldPos at grab start. */ offsetWorld: THREE.Vector3; - /** Altura Y de mundo da peça no momento em que o grab iniciou, para travar horizontalmente */ + /** Altura Y de mundo da peça no momento em que o grab iniciou */ startWorldY: number; } @@ -46,14 +46,13 @@ const _tmpVecR = new THREE.Vector3(); const _tmpQuat = new THREE.Quaternion(); /** - * Touch Plus grip mapping (Meta Quest 3): + * Mapeamento Geral do Quest 3 para Manipulação do Modelo em AR: * - * • One-hand grip → **PAN ONLY (RESTRICTED HORIZONTALLY)** (translação). A rotação e a escala - * da peça NÃO mudam — o grip simples só arrasta no plano horizontal X/Z para preservar a altura Y calibrada. - * • Two-hand grip → ORBIT (eixo entre as mãos) + ZOOM uniforme - * (distância entre os controles). O zoom está sempre ligado. - * - * Trigger não interfere aqui (reservado para seleção/medição). + * • Panorâmica (Pan) -> Segurar o botão Grip (lateral) em qualquer um dos controles + * e mover a mão na direção que deseja arrastar (movimentação livre em 3D). + * • Giro (Rotação) e Zoom (Escala) -> Apontar ambos os controles para a bounding box do modelo, + * segurar ambos os botões de Grip (L+R) e mover as mãos em direções opostas (giro) ou + * afastar/aproximar as mãos (zoom). */ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabStart, children }: XRGrabbableProps) { const groupRef = useRef(null); @@ -143,7 +142,7 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta useFrame((_state, dt) => { const group = groupRef.current; if (!group) return; - const snap: ControllerGrabSnapshot = grab.current; + const snap = grab.current; const L = snap.left; const R = snap.right; @@ -157,7 +156,7 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta const lActive = L.isGrabbing && L.hasPose; const rActive = R.isGrabbing && R.hasPose; - // ─── Halo intensity (visual feedback for analog grip) ──────── + // Visual feedback para a pressão analógica do grip (halo) if (haloRef.current) { const maxGrip = Math.max(L.gripValue, R.gripValue); const mat = haloRef.current.material as THREE.MeshBasicMaterial; @@ -165,7 +164,7 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta haloRef.current.visible = maxGrip > 0.05; } - // ─── Two-hand mode (orbit + zoom, sempre) ──────────────────── + // ─── Modo de Duas Mãos: Giro (Órbita) + Zoom ──────────────────── if (lActive && rActive) { _tmpVecL.setFromMatrixPosition(L.gripWorld); _tmpVecR.setFromMatrixPosition(R.gripWorld); @@ -177,45 +176,64 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta if (!dual.current) { group.updateMatrixWorld(); - dual.current = { - startGroupWorld: group.matrixWorld.clone(), - startMid: midNow.clone(), - startAxis: axisNow.clone(), - startDist: distNow, - startScale: group.scale.x, + + // Restrição: Apontar ambos os controles para a caixa delimitadora do modelo + const box = new THREE.Box3().setFromObject(group); + const checkPointerCollision = (slot: typeof L) => { + const origin = new THREE.Vector3().setFromMatrixPosition(slot.gripWorld); + const q = new THREE.Quaternion().setFromRotationMatrix(slot.gripWorld); + const dir = new THREE.Vector3(0, 0, -1).applyQuaternion(q).normalize(); + const ray = new THREE.Ray(origin, dir); + return ray.intersectsBox(box); }; - single.current = null; - if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); } - console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom)'); + + if (checkPointerCollision(L) && checkPointerCollision(R)) { + dual.current = { + startGroupWorld: group.matrixWorld.clone(), + startMid: midNow.clone(), + startAxis: axisNow.clone(), + startDist: distNow, + startScale: group.scale.x, + }; + single.current = null; + if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); } + console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom) - bounding box hit'); + } else { + // Se não apontavam para o modelo, ignora e permite o pan de uma mão (da primeira mão a engajar) + return; + } } - const d = dual.current; - const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow); - // ZOOM só quando allowScale=true. Caso contrário, mantém escala 1× (só orbita). - const scaleRatio = allowScale - ? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10) - : 1; + if (dual.current) { + const d = dual.current; + const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow); + + // ZOOM: afasta as mãos para zoom in, aproxima para zoom out + const scaleRatio = allowScale + ? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10) + : 1; - const tToOrigin = new THREE.Matrix4().makeTranslation(-d.startMid.x, -d.startMid.y, -d.startMid.z); - const tBack = new THREE.Matrix4().makeTranslation(midNow.x, midNow.y, midNow.z); - const rot = new THREE.Matrix4().makeRotationFromQuaternion(deltaQuat); - const scl = new THREE.Matrix4().makeScale(scaleRatio, scaleRatio, scaleRatio); - const m = new THREE.Matrix4().identity() - .multiply(tBack) - .multiply(rot) - .multiply(scl) - .multiply(tToOrigin) - .multiply(d.startGroupWorld); + const tToOrigin = new THREE.Matrix4().makeTranslation(-d.startMid.x, -d.startMid.y, -d.startMid.z); + const tBack = new THREE.Matrix4().makeTranslation(midNow.x, midNow.y, midNow.z); + const rot = new THREE.Matrix4().makeRotationFromQuaternion(deltaQuat); + const scl = new THREE.Matrix4().makeScale(scaleRatio, scaleRatio, scaleRatio); + const m = new THREE.Matrix4().identity() + .multiply(tBack) + .multiply(rot) + .multiply(scl) + .multiply(tToOrigin) + .multiply(d.startGroupWorld); - applyWorldMatrixToLocal(group, m); - return; + applyWorldMatrixToLocal(group, m); + return; + } } else if (dual.current) { console.log('[XR][grab] ◆ TWO-HAND end'); dual.current = null; syncGrabToStore(); } - // ─── One-hand mode (PAN ONLY - HORIZONTAL X/Z) ──────────────── + // ─── Modo de Uma Mão: Panorâmica Livre (Arrastar em 3D) ──────── const activeHand: 'left' | 'right' | null = lActive ? 'left' : rActive ? 'right' : null; if (activeHand) { const slot = activeHand === 'left' ? L : R; @@ -226,38 +244,32 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta const groupWorldPos = new THREE.Vector3().setFromMatrixPosition(group.matrixWorld); const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos); - // Salva a altura Y original de mundo - const startWorldY = groupWorldPos.y; - - single.current = { hand: activeHand, offsetWorld, startWorldY }; + single.current = { hand: activeHand, offsetWorld, startWorldY: groupWorldPos.y }; if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); } - console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — horizontal pan & rotation`); + console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — free 3D pan`); } - // Rotação Y via analógico do controle ativo + // Função particular improvisada: rotacionar com o analógico horizontal durante o pan const inputSource = session?.inputSources ? Array.from(session.inputSources).find(s => s.handedness === activeHand) : null; const gp = inputSource?.gamepad; if (gp && gp.axes.length >= 4) { - const stickX = gp.axes[2]; // horizontal do thumbstick + const stickX = gp.axes[2]; // thumbstick horizontal const deadzone = 0.15; if (Math.abs(stickX) > deadzone) { - const rotationSpeed = 1.5; // rad por segundo + const rotationSpeed = 1.5; const angle = Math.sign(stickX) * (Math.abs(stickX) - deadzone) * rotationSpeed * dt; const q = new THREE.Quaternion().setFromAxisAngle(new THREE.Vector3(0, 1, 0), -angle); group.quaternion.multiplyQuaternions(q, group.quaternion); - // Rotaciona o offset para girar ao redor da mão + // Gira o offset em torno da mão single.current.offsetWorld.applyQuaternion(q); } } - // Target world position = controller + initial/rotated offset + // Calcula a nova posição no espaço de mundo (movimentação 3D livre) const targetWorldPos = new THREE.Vector3().addVectors(ctrlPos, single.current.offsetWorld); - // Trava a translação no plano horizontal (XZ de mundo), preservando a altura Y original de calibração! - targetWorldPos.y = single.current.startWorldY; - - // Convert to parent-local position (preserve current rotation/scale) + // Converte para coordenadas locais do pai if (group.parent) { group.parent.updateMatrixWorld(); const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert(); @@ -282,7 +294,6 @@ export function XRGrabbable({ allowScale = true, lockedActive = false, onGrabSta ); } -/** Convert a desired world matrix to the group's local matrix (given its parent). */ function applyWorldMatrixToLocal(group: THREE.Group, worldMatrix: THREE.Matrix4) { if (group.parent) { group.parent.updateMatrixWorld(); diff --git a/src/components/three/XRHudInWorld.tsx b/src/components/three/XRHudInWorld.tsx index 9161d22..e4830d3 100644 --- a/src/components/three/XRHudInWorld.tsx +++ b/src/components/three/XRHudInWorld.tsx @@ -84,20 +84,72 @@ export function XRHudInWorld(props: XRHudInWorldProps) { const headLockRef = useRef(null); const targetPos = useRef(new THREE.Vector3()); const targetQuat = useRef(new THREE.Quaternion()); - const lastABtn = useRef(false); + const lastButtonsState = useRef({ + left4: false, // X + left5: false, // Y + left3: false, // Menu (☰) + right4: false, // A + right5: false, // B + }); /** Cached offset from controller to panel at drag start. */ const dragOffsetPos = useRef(new THREE.Vector3()); const dragOffsetQuat = useRef(new THREE.Quaternion()); const dragInitialized = useRef(false); useFrame(() => { - // Toggle the head-locked 3-button group via left X button (Quest Touch buttons[4]) - const gp = leftCtrl?.inputSource?.gamepad; - if (gp) { - const xBtn = gp.buttons[4]; - const pressed = !!xBtn?.pressed; - if (pressed && !lastABtn.current) setHudVisible((v) => !v); - lastABtn.current = pressed; + // Escuta botões A, B, X, Y e Menu do esquerdo + const gpLeft = leftCtrl?.inputSource?.gamepad; + const gpRight = rightCtrl?.inputSource?.gamepad; + + let xPressed = false; + let yPressed = false; + let menuPressed = false; + let aPressed = false; + let bPressed = false; + + if (gpLeft) { + // Esquerdo: Menu (3), X (4), Y (5) + const btnMenu = gpLeft.buttons[3]; + const btnX = gpLeft.buttons[4]; + const btnY = gpLeft.buttons[5]; + + if (btnX?.pressed && !lastButtonsState.current.left4) xPressed = true; + if (btnY?.pressed && !lastButtonsState.current.left5) yPressed = true; + if (btnMenu?.pressed && !lastButtonsState.current.left3) menuPressed = true; + + lastButtonsState.current.left4 = !!btnX?.pressed; + lastButtonsState.current.left5 = !!btnY?.pressed; + lastButtonsState.current.left3 = !!btnMenu?.pressed; + } + + if (gpRight) { + // Direito: A (4), B (5) + const btnA = gpRight.buttons[4]; + const btnB = gpRight.buttons[5]; + + if (btnA?.pressed && !lastButtonsState.current.right4) aPressed = true; + if (btnB?.pressed && !lastButtonsState.current.right5) bPressed = true; + + lastButtonsState.current.right4 = !!btnA?.pressed; + lastButtonsState.current.right5 = !!btnB?.pressed; + } + + // Se o menu estiver aberto, pressionar X físico fecha o projeto (encerra sessão AR) + if (xPressed && open) { + try { xrSession?.end(); } catch (e) { console.warn('[XR] session.end failed', e); } + setTimeout(() => navigate('/viewer'), 50); + toast.success("Projeto fechado!"); + } else if (aPressed || bPressed || xPressed || yPressed) { + // Se qualquer um dos botões A, B, X, Y for pressionado, abre/traz o menu + setOpen(true); + reanchorRequested.current = true; + } + + // Pressionar o botão de Menu (☰) do controle esquerdo abre o menu de ferramentas diretamente + if (menuPressed) { + setOpen(true); + setTab('tools'); + reanchorRequested.current = true; } // Floating panel: @@ -527,6 +579,7 @@ function ToolsTab(p: XRHudInWorldProps) { const setScaleRatio = useModelStore((s) => s.setScaleRatio); const resetScale = useModelStore((s) => s.resetScale); const clearMeasurements = useModelStore((s) => s.clearMeasurements); + const undoLastMeasurement = useModelStore((s) => s.undoLastMeasurement); return ( @@ -585,21 +638,24 @@ function ToolsTab(p: XRHudInWorldProps) { const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!; return ( setScaleRatio(preset)} /> ); })} - - + onClick={resetScale} fontSize={0.0075} /> + + {measureMode && ( - Gatilho D: marcar · A: desfazer · B: limpar · Gatilho E: alternar snap + Gatilho D: marcar ponto · Gatilho E: alternar snap · A/B/X/Y: abrir Menu )}