From c6d50b07a460edec30a39d11030296a2fa5849a2 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 21:05:48 +0000 Subject: [PATCH 1/6] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- .lovable/plan.md | 136 +++++++++++++++++++++++------------------------ 1 file changed, 67 insertions(+), 69 deletions(-) diff --git a/.lovable/plan.md b/.lovable/plan.md index f82e664..15f5a1c 100644 --- a/.lovable/plan.md +++ b/.lovable/plan.md @@ -1,83 +1,81 @@ +## Objetivo -# Seleção de elementos IFC — Esconder, Isolar e Exportar GLB +Alinhar o comportamento dos controles Touch Plus (Meta Quest 3) ao padrão pedido: -Adicionar um modo de **seleção por elemento** (viga, chapa, etc.) que permite ao usuário escolher partes do modelo e então **esconder**, **isolar** ou **exportar como GLB reduzido** para reuso. Funciona no desktop e no AR/XR (Quest 3) em uma única fase. +- **Grip** = único responsável por mover a peça (pan, zoom, orbit). +- **Trigger** = apenas seleção, medição e ações pontuais (nunca move/rotaciona a peça). +- **Thumbstick** = navegação do usuário (teletransporte/recuar/girar a câmera), nunca mais ajuste fino do modelo. +- **Botão Menu** = mantém comportamento nativo (não tocar). -## Fluxo do usuário +## Mapeamento alvo -1. Usuário ativa botão **"Selecionar"** no ViewerControls (e no XRHud). -2. Clica em elementos do modelo — cada clique alterna seleção (highlight laranja/emissive). -3. Contador mostra "N elementos selecionados". -4. Três ações disponíveis: - - **Esconder selecionados** → some com eles - - **Isolar selecionados** → esconde todo o resto - - **Mostrar tudo** → restaura -5. Botão extra **"Exportar GLB"** salva arquivo `.glb` contendo apenas os elementos selecionados, baixável pelo navegador. Esse GLB pode ser recarregado no app pelo fluxo normal de import. -6. Estado de visibilidade é **persistido por `fileName`** no `localStorage`, igual ao `PLACEMENT_KEY`, sobrevivendo a recarregar a página e a sair/entrar do AR. - -## Granularidade - -Seleção opera no **elemento IFC inteiro** (uma viga, uma chapa). O converter de IFC preserva a hierarquia: cada `IfcProduct` vira um `Group` ou `Mesh` nomeado no GLB. O picking sobe na árvore até encontrar o ancestral marcado como "elemento IFC raiz" (campo `userData.ifcElement = true` adicionado durante a conversão), garantindo que clicar em qualquer face seleciona a viga toda. - -## Detalhes técnicos - -### 1. `src/lib/convertIFC.ts` -- Durante a conversão IFC→GLB, marcar cada `IfcProduct` resultante com `obj.userData.ifcElement = true` e `userData.ifcId = expressId`. Sem isso, a árvore three.js fica plana e não dá pra distinguir "elemento" de "submesh". - -### 2. `src/stores/useModelStore.ts` -Novo slice de seleção: -```ts -selectionMode: boolean -selectedElementIds: Set // chave: `${modelId}:${ifcId|uuid}` -hiddenElementIds: Set -isolatedElementIds: Set | null // null = sem isolamento -toggleSelection(key), clearSelection(), -hideSelected(), isolateSelected(), showAll() +```text +Mão Botão Ação +───────────────────────────────────────────────────────── +Esquerda/Direita Grip (1 mão) Pan da peça (translação 6DOF, sem rotação) +Ambas as mãos Grip + afastar Zoom uniforme (escala) +Ambas as mãos Grip + giro oposto Orbitar/rotacionar a peça +Direita Trigger Selecionar componente / adicionar ponto de medida +Esquerda Trigger Alternar snap de vértice (já existe) +Direita Botão A Desfazer última medida (já existe) +Direita Botão B Limpar medidas (já existe) +Thumbstick Frente + soltar Teletransporte para o ponto apontado +Thumbstick Lados Snap-rotate da câmera (±30°) +Thumbstick Trás Recuar o usuário (passo) ``` -Persistência de `hiddenElementIds`/`isolatedElementIds` por `fileName` em `localStorage` (chave nova `tsxr_visibility_v1`), aplicada ao carregar o modelo. -### 3. `src/components/three/ModelViewer.tsx` -- Novo componente `SelectionHandler`: ativo quando `selectionMode === true`. Raycaster no `onPointerDown`, sobe a árvore via `object.parent` até achar `userData.ifcElement === true`, faz toggle no store. Desabilita OrbitControls durante o clique para não conflitar. -- Aplicar visibilidade: `useEffect` percorre `scene.traverse`, define `obj.visible` conforme `hiddenElementIds`/`isolatedElementIds`. -- Highlight de seleção: material `emissive` laranja (`#f59e0b`, mesma cor de holes) com `emissiveIntensity = 0.4`, restaurado ao deselecionar. Guardar material original em `userData.originalMaterial`. -- `selectionMode` é mutuamente exclusivo com `measureMode` e `positionMode` (mesma lógica que esses dois já têm entre si). +## Mudanças no código -### 4. `src/components/three/XRControllerMeasure.tsx` (ou novo `XRControllerSelect.tsx`) -- Quando `selectionMode === true` em XR: usar o mesmo ray do controller, no `selectstart` (trigger) faz toggle de seleção em vez de pontos de medição. -- Tip do laser muda para laranja durante o modo seleção. +### 1. `src/components/three/XRGrabbable.tsx` -### 5. `src/components/ViewerControls.tsx` + `src/components/XRHud.tsx` -Novos botões: -- `Selecionar` (toggle, ícone `MousePointerClick`) -- `Esconder` / `Isolar` / `Mostrar tudo` (visíveis quando `selectionMode` ou há ocultos) -- `Exportar GLB` (visível quando há seleção) -- Badge com contador "N selecionados" +- **One-hand grip**: passar a aplicar **apenas translação** (pan). Hoje aplica matriz completa do grip → leva rotação junto. Vou capturar o offset de posição e atualizar só `group.position`, mantendo `quaternion` e `scale`. +- **Two-hand grip**: manter rotação pelo eixo entre as mãos + **forçar `allowScale = true` sempre** (zoom passa a vir do grip, não depende mais do toggle do HUD). +- Remover a prop `allowScale` da assinatura (ou ignorar e tratar como `true`). -### 6. `src/lib/exportSelectionGLB.ts` (novo) -```ts -import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'; -// Clona apenas os elementos selecionados em uma nova Scene, -// preserva transformações mundiais, exporta como .glb binário, -// dispara download via Blob + . -``` -Nome do arquivo: `{original}_selecao_{N}elem.glb`. O GLB resultante pode ser aberto pelo próprio app via fluxo normal de import (já aceita `.glb`). +### 2. `src/pages/XRSession.tsx` -### 7. Performance -- Aplicar visibilidade via `obj.visible = false` (não remove geometria — reversível e barato). -- Frustum culling do three.js já ignora invisíveis no render → ganho real de FPS no Quest mesmo sem exportar. +- Remover o estado `allowScale` e os toggles correspondentes nas duas instâncias de ``. +- Substituir `` por um novo `` (ver item 4) — o joystick não deve mais editar `fineTuning` do modelo. +- Garantir que `XRHitTestPlacement` só consome `onSelect` (trigger) quando `placementMode === true`; fora disso o trigger é roteado integralmente para `XRControllerMeasure`. -## Arquivos tocados +### 3. `src/components/XRHud.tsx` e `src/components/three/XRHudInWorld.tsx` -- `src/lib/convertIFC.ts` — marcar `userData.ifcElement` -- `src/lib/exportSelectionGLB.ts` — **novo** -- `src/stores/useModelStore.ts` — slice de seleção + persistência -- `src/components/three/ModelViewer.tsx` — `SelectionHandler`, highlight, aplicar visibilidade -- `src/components/three/XRControllerMeasure.tsx` — handler de seleção em XR -- `src/components/ViewerControls.tsx` — botões desktop -- `src/components/XRHud.tsx` — botões XR +- Remover botão/toggle “Permitir escala” (`allowScale`) — agora é sempre via grip duplo. +- Remover/relabelar o toggle “Movimento livre” (`freeMove`) já que o joystick deixa de ajustar o modelo; manter apenas se ainda fizer sentido para outra função (caso não, remover prop também de `XRSession.tsx`). -## Fora do escopo (decidido) +### 4. Novo `src/components/three/ControllerLocomotion.tsx` -- **Não** exportar IFC reduzido (decidido: arquivo é só pra este app, GLB resolve e é trivial). -- **Não** suportar seleção por face/sub-mesh (decidido: elemento inteiro). -- **Não** salvar conjuntos nomeados de seleção ("layout A", "layout B") — pode virar Fase 2 se você pedir. +Componente que substitui `ControllerFineTuning`. Lê `useXR().session` e implementa: + +- **Joystick esquerdo (ou ambos, configurável)**: + - `axes[1] < -0.7` mantido por ~200 ms → dispara teleport: raycast a partir do controle direito contra `scene.children` no plano horizontal mais próximo; aplica `xrOrigin.position` para reposicionar. + - `axes[1] > 0.7` → recua o `XROrigin` em passos de 0.3 m por trigger. + - `axes[0]` cruzando ±0.7 (com histerese) → snap-rotate de ±30° no `XROrigin.rotation.y`. +- Usar a API `XROrigin` já montada em `XRSession.tsx` (referência via `useRef` ou store) para escrever pose. +- Desligar tudo se `lockedActive` (modelo travado) — locomoção do usuário ainda funciona, só não move o modelo. + +### 5. `src/components/three/XRControllerMeasure.tsx` + +- Sem mudanças funcionais — já usa apenas trigger e botões A/B. Apenas confirmar que o early-return `(!measureMode && !selectionMode)` continua: quando nenhum dos modos está ativo, o trigger não dispara nada relacionado à medição (e não interfere em `XRHitTestPlacement`, que respeita `placementMode`). + +### 6. `src/components/three/XRHitTestPlacement.tsx` + +- Garantir que o handler `onSelect` (trigger) verifica `placementMode` antes de colocar o modelo. Hoje o tap-plane invisível pode estar capturando o select mesmo fora do placement — adicionar guarda explícita e `visible={placementMode}`. + +## Validação + +1. **Desktop SimXR** (devkit): pressionar Q/W (grip simulado) move a peça; pressionar A/S (trigger simulado) não muda pose nenhuma. +2. **Quest 3 real (passthrough)**: + - Grip único arrasta a peça sem rotacionar. + - Dois grips afastando = zoom; dois grips em rotação oposta = orbit. + - Trigger direito fora dos modos medir/selecionar/colocar = nada acontece. + - Trigger direito em modo Selecionar = seleciona componente IFC. + - Trigger direito em modo Medir = adiciona ponto com snap. + - Joystick esquerdo para frente = teletransporte; lados = snap-rotate; trás = recua. +3. Logs `[XR][grab]` continuam mostrando início/fim de one-hand e two-hand. + +## Fora do escopo + +- Não mexer no botão Menu (Universal Menu do Quest é nativo). +- Não alterar handlers DOM/mouse do modo desktop (Viewer). +- Não tocar em RLS, store de dados ou backend. From b1beb90c32c47d1ceafa3d38f330db5dd3388e3a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 21:07:29 +0000 Subject: [PATCH 2/6] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/three/ControllerLocomotion.tsx | 157 ++++++++++++++++++ src/components/three/XRGrabbable.tsx | 72 ++++---- 2 files changed, 195 insertions(+), 34 deletions(-) create mode 100644 src/components/three/ControllerLocomotion.tsx diff --git a/src/components/three/ControllerLocomotion.tsx b/src/components/three/ControllerLocomotion.tsx new file mode 100644 index 0000000..2a9c1cf --- /dev/null +++ b/src/components/three/ControllerLocomotion.tsx @@ -0,0 +1,157 @@ +import { useRef, RefObject } from 'react'; +import { useFrame, useThree } from '@react-three/fiber'; +import { useXR } from '@react-three/xr'; +import * as THREE from 'three'; + +/** + * Quest 3 thumbstick locomotion (rig-relative). + * + * Mapeamento: + * • Joystick para frente (axes[1] < -0.7) e soltar (volta a 0) + * → teletransporta para o ponto apontado pelo controle direito + * (raycast contra `scene.children`). Sem alvo → passo de 0.5 m + * na direção do controle. + * • Joystick para trás (axes[1] > 0.7) → passo de 0.3 m para trás + * (em relação à orientação do XROrigin / yaw da câmera). + * • Joystick lateral (|axes[0]| > 0.7) → snap-rotate ±30° em torno + * do eixo Y do XROrigin. + * + * Lê apenas o thumbstick (axes[2]/axes[3] em xr-standard) — não toca em + * grip nem trigger. + */ +interface Props { + /** Ref para o que envolve ; sua posição/rotação move o usuário. */ + rigRef: RefObject; +} + +const DEAD = 0.7; +const SNAP_DEG = 30; +const STEP_BACK = 0.3; +const STEP_FWD_NO_HIT = 0.5; + +export function ControllerLocomotion({ rigRef }: Props) { + const { scene, camera } = useThree(); + const session = useXR((s) => s.session); + + // Edge states per direction (need to release to fire again) + const fwdArmed = useRef(false); + const fwdLatched = useRef(false); // armed once axis exceeded threshold; fires on release + const backLatched = useRef(false); + const rotLatched = useRef<0 | -1 | 1>(0); + + const raycaster = useRef(new THREE.Raycaster()); + + useFrame((_state, _dt, frame: XRFrame | undefined) => { + const rig = rigRef.current; + if (!rig || !session || !frame) return; + + const refSpace = (camera as unknown as { matrixWorldInverse: THREE.Matrix4 }) && (frame as XRFrame).session + ? (frame as XRFrame).session + : null; + if (!refSpace) return; + + // Find thumbstick + right controller pose + let stickX = 0; + let stickY = 0; + let rightSource: XRInputSource | null = null; + for (const src of session.inputSources) { + const gp = src.gamepad; + if (!gp) continue; + // xr-standard: axes[2]=thumbstick X, axes[3]=thumbstick Y + if (gp.axes.length >= 4) { + // Prefer left for locomotion; if no left stick, use right + if (src.handedness === 'left') { + stickX = gp.axes[2] ?? 0; + stickY = gp.axes[3] ?? 0; + } else if (src.handedness === 'right' && stickX === 0 && stickY === 0) { + stickX = gp.axes[2] ?? 0; + stickY = gp.axes[3] ?? 0; + } + } + if (src.handedness === 'right') rightSource = src; + } + + // ── 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) < 0.3) { + rotLatched.current = 0; + } + + // ── Back step ────────────────────────────────────────────────── + if (stickY > DEAD) { + if (!backLatched.current) { + backLatched.current = true; + // Move oposto à direção em que o usuário olha (xz) + const headDir = new THREE.Vector3(); + camera.getWorldDirection(headDir); + headDir.y = 0; headDir.normalize(); + rig.position.addScaledVector(headDir, -STEP_BACK); + } + } else if (stickY < 0.3) { + backLatched.current = false; + } + + // ── Forward → teleport (arm on push, fire on release) ───────── + if (stickY < -DEAD) { + fwdArmed.current = true; + fwdLatched.current = true; + } else if (fwdLatched.current && stickY > -0.2) { + fwdLatched.current = false; + // Fire teleport + const refSpaceObj = (frame.session as XRSession).requestReferenceSpace; // not used; we use camera world + void refSpaceObj; + let targetWorld: THREE.Vector3 | null = null; + if (rightSource && rightSource.targetRaySpace) { + const pose = frame.getPose(rightSource.targetRaySpace, (camera as unknown as { parent: THREE.Object3D }).parent + ? (frame as XRFrame).session as unknown as XRReferenceSpace + : null as unknown as XRReferenceSpace); + // Fallback path: build ray from controller via WebXR reference + void pose; + } + // Simpler & robust: get controller pose from its object via three's renderer + const gl = (rigRef.current as unknown as { __gl?: unknown }).__gl as unknown; + void gl; + + // We can't get the controller's three.js Object3D here without the XR + // input wrapper — use the camera as a fallback ray origin: shoot from + // camera forward until it hits something. + if (!targetWorld) { + 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; + const hits = raycaster.current.intersectObjects(scene.children, 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; + }); + if (hit) { + targetWorld = hit.point.clone(); + } else { + // No hit: step forward STEP_FWD_NO_HIT m on the floor plane + targetWorld = origin.clone().add(dir.multiplyScalar(STEP_FWD_NO_HIT)); + } + } + if (targetWorld) { + // Move the rig so the camera ends up at targetWorld (keep camera Y). + const camWorld = new THREE.Vector3(); + camera.getWorldPosition(camWorld); + const delta = new THREE.Vector3().subVectors(targetWorld, camWorld); + delta.y = 0; // keep user height + rig.position.add(delta); + } + fwdArmed.current = false; + } + }); + + return null; +} diff --git a/src/components/three/XRGrabbable.tsx b/src/components/three/XRGrabbable.tsx index 6404ef8..0cb8774 100644 --- a/src/components/three/XRGrabbable.tsx +++ b/src/components/three/XRGrabbable.tsx @@ -5,7 +5,10 @@ import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useController import { useModelStore } from '@/stores/useModelStore'; interface XRGrabbableProps { - /** Allow uniform scaling via two-hand grab (distance between hands) */ + /** + * @deprecated Mantido por compatibilidade — agora o zoom por duas mãos + * está SEMPRE ativo (faz parte do mapeamento padrão do grip). + */ allowScale?: boolean; /** When true, the active model is locked — grab is ignored entirely */ lockedActive?: boolean; @@ -16,8 +19,8 @@ interface XRGrabbableProps { interface SingleGrabRecord { hand: 'left' | 'right'; - /** Offset = gripWorld^-1 * groupWorld at grab start */ - offset: THREE.Matrix4; + /** Offset (world) = groupWorldPos - controllerWorldPos at grab start. */ + offsetWorld: THREE.Vector3; } interface DualGrabRecord { @@ -33,22 +36,22 @@ interface DualGrabRecord { startScale: number; } -const _tmpMat = new THREE.Matrix4(); const _tmpMat2 = new THREE.Matrix4(); const _tmpVecL = new THREE.Vector3(); const _tmpVecR = new THREE.Vector3(); const _tmpQuat = new THREE.Quaternion(); /** - * Wraps children in a group whose pose is driven by Touch Plus grip: - * • One-hand grab: model becomes an extension of that hand (6DOF). - * • Two-hand grab: midpoint = pivot, axis between hands = rotation, - * distance ratio = uniform scale (if allowScale). + * Touch Plus grip mapping (Meta Quest 3): * - * On release the group keeps its last pose (we don't write to the store — - * the manual fine-tuning sliders remain available as additional offsets). + * • One-hand grip → **PAN ONLY** (translação). A rotação e a escala + * da peça NÃO mudam — o grip simples só arrasta. + * • 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). */ -export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabStart, children }: XRGrabbableProps) { +export function XRGrabbable({ lockedActive = false, onGrabStart, children }: XRGrabbableProps) { const groupRef = useRef(null); const grab = useControllerGrab(); @@ -63,7 +66,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt const g = groupRef.current; if (!g) return; g.scale.set(1, 1, 1); - // Abort any in-progress two-hand scaling so it doesn't snap back. dual.current = null; single.current = null; }, [scaleResetNonce]); @@ -75,7 +77,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt const L = snap.left; const R = snap.right; - // Locked: ignore all grab input — release any in-progress grabs and bail. if (lockedActive) { if (single.current) single.current = null; if (dual.current) dual.current = null; @@ -94,7 +95,7 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt haloRef.current.visible = maxGrip > 0.05; } - // ─── Two-hand mode ─────────────────────────────────────────── + // ─── Two-hand mode (orbit + zoom, sempre) ──────────────────── if (lActive && rActive) { _tmpVecL.setFromMatrixPosition(L.gripWorld); _tmpVecR.setFromMatrixPosition(R.gripWorld); @@ -105,7 +106,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt axisNow.divideScalar(distNow); if (!dual.current) { - // Capture group.updateMatrixWorld(); dual.current = { startGroupWorld: group.matrixWorld.clone(), @@ -114,26 +114,21 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt startDist: distNow, startScale: group.scale.x, }; - single.current = null; // dual takes over + single.current = null; if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); } - console.log('[XR][grab] ◆ TWO-HAND start'); + console.log('[XR][grab] ◆ TWO-HAND start (orbit+zoom)'); } const d = dual.current; - // Rotation delta: from startAxis to axisNow const deltaQuat = new THREE.Quaternion().setFromUnitVectors(d.startAxis, axisNow); - // Scale ratio - const scaleRatio = allowScale - ? THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10) - : 1; + // ZOOM sempre ativo no grip duplo + const scaleRatio = THREE.MathUtils.clamp(distNow / d.startDist, 0.1, 10); - // Build target world: T(midNow) * R(delta) * S(ratio) * T(-startMid) * startGroupWorld - const m = new THREE.Matrix4(); 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); - m.identity() + const m = new THREE.Matrix4().identity() .multiply(tBack) .multiply(rot) .multiply(scl) @@ -147,21 +142,31 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt dual.current = null; } - // ─── One-hand mode ─────────────────────────────────────────── + // ─── One-hand mode (PAN ONLY) ──────────────────────────────── const activeHand: 'left' | 'right' | null = lActive ? 'left' : rActive ? 'right' : null; if (activeHand) { const slot = activeHand === 'left' ? L : R; + const ctrlPos = new THREE.Vector3().setFromMatrixPosition(slot.gripWorld); + if (!single.current || single.current.hand !== activeHand) { group.updateMatrixWorld(); - const offset = new THREE.Matrix4() - .copy(slot.gripWorld).invert() - .multiply(group.matrixWorld); - single.current = { hand: activeHand, offset }; + const groupWorldPos = new THREE.Vector3().setFromMatrixPosition(group.matrixWorld); + const offsetWorld = new THREE.Vector3().subVectors(groupWorldPos, ctrlPos); + single.current = { hand: activeHand, offsetWorld }; if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); } - console.log(`[XR][grab] ● ONE-HAND start (${activeHand})`); + console.log(`[XR][grab] ● ONE-HAND start (${activeHand}) — pan only`); } - _tmpMat.multiplyMatrices(slot.gripWorld, single.current.offset); - applyWorldMatrixToLocal(group, _tmpMat); + + // Target world position = controller + initial offset + const targetWorldPos = new THREE.Vector3().addVectors(ctrlPos, single.current.offsetWorld); + + // Convert to parent-local position (preserve current rotation/scale) + if (group.parent) { + group.parent.updateMatrixWorld(); + const invParent = new THREE.Matrix4().copy(group.parent.matrixWorld).invert(); + targetWorldPos.applyMatrix4(invParent); + } + group.position.copy(targetWorldPos); } else if (single.current) { single.current = null; } @@ -169,7 +174,6 @@ export function XRGrabbable({ allowScale = false, lockedActive = false, onGrabSt return ( - {/* Subtle halo proportional to analog grip — invisible by default */} From 705d7b171bd8874e0c53259f004f0813561569d6 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 21:07:52 +0000 Subject: [PATCH 3/6] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/three/ControllerLocomotion.tsx | 149 +++++++----------- 1 file changed, 54 insertions(+), 95 deletions(-) diff --git a/src/components/three/ControllerLocomotion.tsx b/src/components/three/ControllerLocomotion.tsx index 2a9c1cf..bc8f9a1 100644 --- a/src/components/three/ControllerLocomotion.tsx +++ b/src/components/three/ControllerLocomotion.tsx @@ -6,25 +6,23 @@ import * as THREE from 'three'; /** * Quest 3 thumbstick locomotion (rig-relative). * - * Mapeamento: - * • Joystick para frente (axes[1] < -0.7) e soltar (volta a 0) - * → teletransporta para o ponto apontado pelo controle direito - * (raycast contra `scene.children`). Sem alvo → passo de 0.5 m - * na direção do controle. - * • Joystick para trás (axes[1] > 0.7) → passo de 0.3 m para trás - * (em relação à orientação do XROrigin / yaw da câmera). - * • Joystick lateral (|axes[0]| > 0.7) → snap-rotate ±30° em torno - * do eixo Y do XROrigin. + * Mapeamento (thumbstick = axes[2]/axes[3] em xr-standard): + * • Para frente (axes[1] < -0.7), libera (axis volta a ~0) → teletransporta + * para o ponto onde o usuário está olhando (raycast da câmera). Se não + * houver geometria atingida, dá um passo de 0.5 m na direção do olhar. + * • Para trás (axes[1] > 0.7) → passo de 0.3 m oposto ao olhar. + * • Lateral (|axes[0]| > 0.7) → snap-rotate ±30° no eixo Y do rig. * - * Lê apenas o thumbstick (axes[2]/axes[3] em xr-standard) — não toca em - * grip nem trigger. + * NÃO lê grip ou trigger — esses são responsabilidade exclusiva de + * XRGrabbable e XRControllerMeasure, respectivamente. */ interface Props { - /** Ref para o que envolve ; sua posição/rotação move o usuário. */ + /** Ref para o que envolve ; sua pose move o usuário. */ rigRef: 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; @@ -33,42 +31,30 @@ export function ControllerLocomotion({ rigRef }: Props) { const { scene, camera } = useThree(); const session = useXR((s) => s.session); - // Edge states per direction (need to release to fire again) - const fwdArmed = useRef(false); - const fwdLatched = useRef(false); // armed once axis exceeded threshold; fires on release + const fwdPushed = useRef(false); const backLatched = useRef(false); const rotLatched = useRef<0 | -1 | 1>(0); - const raycaster = useRef(new THREE.Raycaster()); - useFrame((_state, _dt, frame: XRFrame | undefined) => { + useFrame(() => { const rig = rigRef.current; - if (!rig || !session || !frame) return; + if (!rig || !session) return; - const refSpace = (camera as unknown as { matrixWorldInverse: THREE.Matrix4 }) && (frame as XRFrame).session - ? (frame as XRFrame).session - : null; - if (!refSpace) return; - - // Find thumbstick + right controller pose let stickX = 0; let stickY = 0; - let rightSource: XRInputSource | null = null; for (const src of session.inputSources) { const gp = src.gamepad; - if (!gp) continue; - // xr-standard: axes[2]=thumbstick X, axes[3]=thumbstick Y - if (gp.axes.length >= 4) { - // Prefer left for locomotion; if no left stick, use right - if (src.handedness === 'left') { - stickX = gp.axes[2] ?? 0; - stickY = gp.axes[3] ?? 0; - } else if (src.handedness === 'right' && stickX === 0 && stickY === 0) { - stickX = gp.axes[2] ?? 0; - stickY = gp.axes[3] ?? 0; - } + if (!gp || gp.axes.length < 4) continue; + // Prefer left stick for locomotion + 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; } - if (src.handedness === 'right') rightSource = src; } // ── Snap-rotate (lateral) ────────────────────────────────────── @@ -78,7 +64,7 @@ export function ControllerLocomotion({ rigRef }: Props) { rotLatched.current = dir; rig.rotation.y -= (dir * SNAP_DEG * Math.PI) / 180; } - } else if (Math.abs(stickX) < 0.3) { + } else if (Math.abs(stickX) < RELEASE) { rotLatched.current = 0; } @@ -86,70 +72,43 @@ export function ControllerLocomotion({ rigRef }: Props) { if (stickY > DEAD) { if (!backLatched.current) { backLatched.current = true; - // Move oposto à direção em que o usuário olha (xz) const headDir = new THREE.Vector3(); camera.getWorldDirection(headDir); - headDir.y = 0; headDir.normalize(); - rig.position.addScaledVector(headDir, -STEP_BACK); + headDir.y = 0; + if (headDir.lengthSq() > 1e-6) { + headDir.normalize(); + rig.position.addScaledVector(headDir, -STEP_BACK); + } } - } else if (stickY < 0.3) { + } else if (stickY < RELEASE) { backLatched.current = false; } - // ── Forward → teleport (arm on push, fire on release) ───────── + // ── Forward → teleport on release ───────────────────────────── if (stickY < -DEAD) { - fwdArmed.current = true; - fwdLatched.current = true; - } else if (fwdLatched.current && stickY > -0.2) { - fwdLatched.current = false; - // Fire teleport - const refSpaceObj = (frame.session as XRSession).requestReferenceSpace; // not used; we use camera world - void refSpaceObj; - let targetWorld: THREE.Vector3 | null = null; - if (rightSource && rightSource.targetRaySpace) { - const pose = frame.getPose(rightSource.targetRaySpace, (camera as unknown as { parent: THREE.Object3D }).parent - ? (frame as XRFrame).session as unknown as XRReferenceSpace - : null as unknown as XRReferenceSpace); - // Fallback path: build ray from controller via WebXR reference - void pose; - } - // Simpler & robust: get controller pose from its object via three's renderer - const gl = (rigRef.current as unknown as { __gl?: unknown }).__gl as unknown; - void gl; - - // We can't get the controller's three.js Object3D here without the XR - // input wrapper — use the camera as a fallback ray origin: shoot from - // camera forward until it hits something. - if (!targetWorld) { - 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; - const hits = raycaster.current.intersectObjects(scene.children, 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; - }); - if (hit) { - targetWorld = hit.point.clone(); - } else { - // No hit: step forward STEP_FWD_NO_HIT m on the floor plane - targetWorld = origin.clone().add(dir.multiplyScalar(STEP_FWD_NO_HIT)); - } - } - if (targetWorld) { - // Move the rig so the camera ends up at targetWorld (keep camera Y). - const camWorld = new THREE.Vector3(); - camera.getWorldPosition(camWorld); - const delta = new THREE.Vector3().subVectors(targetWorld, camWorld); - delta.y = 0; // keep user height - rig.position.add(delta); - } - fwdArmed.current = false; + fwdPushed.current = true; + } else if (fwdPushed.current && stickY > -RELEASE) { + fwdPushed.current = false; + // Cast from camera forward to find target on real/virtual geometry + 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; + const hits = raycaster.current.intersectObjects(scene.children, 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)); + const camWorld = new THREE.Vector3(); + camera.getWorldPosition(camWorld); + const delta = new THREE.Vector3().subVectors(target, camWorld); + delta.y = 0; // preserva altura do usuário + rig.position.add(delta); } }); From 83777ec63088e958279663d1eae0bb3590b2fef2 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 21:08:08 +0000 Subject: [PATCH 4/6] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/pages/XRSession.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx index f937d12..e79550e 100644 --- a/src/pages/XRSession.tsx +++ b/src/pages/XRSession.tsx @@ -17,6 +17,7 @@ import { findNearestVertex } from '@/components/three/SmartMeasure'; import { XRHitTestPlacement } from '@/components/three/XRHitTestPlacement'; import { XRGrabbable } from '@/components/three/XRGrabbable'; import { XRControllerMeasure } from '@/components/three/XRControllerMeasure'; +import { ControllerLocomotion } from '@/components/three/ControllerLocomotion'; // DEVKIT: remove these imports + all `// DEVKIT:` blocks below to strip devkit import { useDevKit } from '@/devkit/useDevKit'; import { DevPanel } from '@/devkit/DevPanel'; @@ -470,9 +471,13 @@ const XRSession = () => { const [freeMove, setFreeMove] = useState(true); const [placementMode, setPlacementMode] = useState(true); // start in placement mode const [snapToPlanes, setSnapToPlanes] = useState(true); - const [allowScale, setAllowScale] = useState(false); + // Zoom por grip duplo é SEMPRE ligado (parte do mapeamento padrão). + const allowScale = true; + const setAllowScale = () => {}; const [liveCode, setLiveCode] = useState(null); const [liveViewers, setLiveViewers] = useState(0); + // Rig que envolve — locomoção por joystick escreve aqui. + const rigRef = useRef(null); // DEVKIT: simXR forces in-XR rendering on desktop without a real WebXR session const devkit = useDevKit(); const [simXR, setSimXR] = useState(false); @@ -569,7 +574,9 @@ const XRSession = () => { - + + + {effectiveInXR ? ( <> From 05ef15c9b399bd0333ebbd87fa97d575d2ff73a5 Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 21:08:19 +0000 Subject: [PATCH 5/6] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/pages/XRSession.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx index e79550e..9164e34 100644 --- a/src/pages/XRSession.tsx +++ b/src/pages/XRSession.tsx @@ -473,7 +473,7 @@ const XRSession = () => { const [snapToPlanes, setSnapToPlanes] = useState(true); // Zoom por grip duplo é SEMPRE ligado (parte do mapeamento padrão). const allowScale = true; - const setAllowScale = () => {}; + const setAllowScale = (_v?: boolean) => {}; const [liveCode, setLiveCode] = useState(null); const [liveViewers, setLiveViewers] = useState(0); // Rig que envolve — locomoção por joystick escreve aqui. @@ -616,7 +616,7 @@ const XRSession = () => { - + From d14b5bf89ff2e3b5fb10e75148dd648d36f6c80a Mon Sep 17 00:00:00 2001 From: "gpt-engineer-app[bot]" <159125892+gpt-engineer-app[bot]@users.noreply.github.com> Date: Thu, 21 May 2026 21:08:39 +0000 Subject: [PATCH 6/6] Changes Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com> --- src/components/XRHud.tsx | 52 ++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/src/components/XRHud.tsx b/src/components/XRHud.tsx index 73d297e..113cd36 100644 --- a/src/components/XRHud.tsx +++ b/src/components/XRHud.tsx @@ -32,74 +32,74 @@ interface XRHudProps { const helpItems = [ { - title: 'Agarrar a peça (Grip)', + title: 'Grip (1 mão) — Pan', icon: '✊', - desc: 'Aproxime o controle e APERTE O GRIP LATERAL — a peça vira extensão da sua mão (mover + girar em 6 eixos). Solte o grip para fixar na posição.', + desc: 'Aperte UM grip lateral e mova o controle: a peça é arrastada (translação) sem rotacionar. Solte para fixar.', }, { - title: 'Two-Hand (girar/escalar)', + title: 'Grip (2 mãos) — Zoom + Orbit', icon: '🤲', - desc: 'Aperte o grip dos DOIS controles ao mesmo tempo. Gire os punhos para rotacionar a peça pelo eixo entre as mãos. Aproxime/afaste para escalar (apenas se o toggle "Escala" estiver ON).', + desc: 'Aperte o grip dos DOIS controles. AFASTE/APROXIME as mãos para dar ZOOM. Gire os punhos em direções opostas para ORBITAR a peça pelo eixo entre as mãos.', + }, + { + title: 'Trigger — Selecionar / Medir', + icon: '🔫', + desc: 'O gatilho NÃO move a peça. Ele só age dentro dos modos Selecionar, Medir ou Posicionar — escolhendo elementos, marcando pontos ou colocando o modelo na superfície.', + }, + { + title: 'Thumbstick — Locomoção', + icon: '🕹️', + desc: 'Para FRENTE e solte: teletransporta para o ponto apontado. Para os LADOS: snap-rotate de ±30° na câmera. Para TRÁS: passo de 0,3 m no sentido oposto ao olhar.', }, { title: 'Posicionar (Hit-Test)', icon: '🎯', - desc: 'Aponte para uma superfície real e toque para posicionar o modelo. O retículo verde indica onde o modelo será colocado.', + desc: 'Ative para reposicionar o modelo. Aponte o controle para uma superfície real e aperte o TRIGGER no retículo para fixar o modelo ali.', }, { title: 'Snap (Planos)', icon: '🧲', - desc: 'Quando ativado, o modelo se alinha automaticamente a planos detectados (chão, parede, mesa). O retículo fica azul quando snap está ativo.', - }, - { - title: 'Joystick (ajuste fino)', - icon: '🕹️', - desc: 'Esquerdo: X (lateral) / Z (frente-trás). Direito: rotação Y / altura Y. Aperte o TRIGGER para acelerar (leve = passo fino, forte = passo rápido). O joystick é desativado enquanto você segura o grip (que tem prioridade para agarrar).', + desc: 'Quando ON, o modelo se alinha automaticamente aos planos detectados (chão, parede, mesa). Retículo azul indica snap ativo.', }, { title: 'Grid', icon: '📐', - desc: 'Exibe/oculta a grade de referência para auxiliar no alinhamento visual do modelo com o ambiente.', + desc: 'Exibe/oculta a grade de referência para alinhar o modelo com o ambiente.', }, { title: 'Sólido / Wire / Bordas', icon: '🔲', - desc: 'Alterna entre modos de renderização: Sólido (preenchido), Wireframe (malha de arame), Bordas (apenas contornos).', + desc: 'Alterna modos de renderização: Sólido (preenchido), Wireframe (malha), Bordas (apenas contornos).', }, { title: 'Medir', icon: '📏', - desc: 'Ative e toque em dois pontos no modelo para medir a distância entre eles em milímetros. O snap de vértice auxilia na precisão.', + desc: 'Ative e toque o TRIGGER em dois pontos da peça para medir a distância em milímetros. O snap de vértice auxilia a precisão.', }, { title: 'Screenshot', icon: '📸', - desc: 'Captura uma imagem da visão AR atual. As capturas ficam salvas na galeria do viewer.', + desc: 'Captura uma imagem da visão AR atual. As capturas ficam na galeria do viewer.', }, { title: 'Inspeção', icon: '📋', - desc: 'Abre o checklist de inspeção para aprovar ou rejeitar itens como dimensões, furos, soldas e posição das placas.', + desc: 'Abre o checklist para aprovar ou rejeitar itens (dimensões, furos, soldas, posição das chapas).', }, { title: 'Opacidade', icon: '👁️', - desc: 'Ajusta a transparência do modelo de 0% (invisível) a 100% (opaco) para visualizar através da peça.', + desc: 'Ajusta a transparência do modelo de 0% a 100% para ver através da peça.', }, { - title: 'Posição (mm)', + title: 'Ajuste fino (Posição/Rotação)', icon: '↔️', - desc: 'Ajuste fino da posição do modelo nos eixos X, Y e Z com incrementos de 1mm usando os botões +/−.', + desc: 'Use os botões +/− do painel para ajustes finos em milímetros (posição) e décimos de grau (rotação). O joystick não faz mais ajuste fino — ele agora é só locomoção.', }, { - title: 'Rotação X° / Y° / Z°', - icon: '🔄', - desc: 'Rotaciona o modelo ao redor de cada eixo com incrementos de 0.1°. X = inclinação frontal, Y = giro lateral, Z = tombamento.', - }, - { - title: 'Botões A/B (Touch Plus)', + title: 'Botões A/B (Touch Plus direito)', icon: '🎮', - desc: 'Atualmente reservados para o sistema (menu Meta). O fluxo natural usa Grip para agarrar e Trigger para acelerar o joystick.', + desc: 'No modo Medir: A = desfazer último ponto; B = limpar todas as medições. O botão Menu (esquerdo) abre o menu Universal do Quest.', }, ];