## Objetivo Eliminar a "briga de matrizes" entre `XRHitTestPlacement` (pai) e `XRGrabbable` (filho) e adicionar **Fixar/Desfixar** a peça no espaço, para o fluxo de alinhar quina-com-quina sobre o objeto real. ## O que muda ### 1. Reparent on grab/release (causa-raiz) No `XRGrabbable`, no instante em que o grab começa, o grupo do modelo é **transferido para a Scene** preservando a transform de mundo. Enquanto está "na mão", o pai (`XRHitTestPlacement`) não consegue mais influenciar a matriz — fim do drift/salto. - **Início do grab** (one-hand ou two-hand): guardar `originalParent` e chamar `scene.attach(group)`. - **Fim do grab** (todas as mãos soltaram): `originalParent.attach(group)` para devolver, mantendo a pose final. - Vale para os dois modos (single e dual). A captura de offset/startGroupWorld passa a acontecer **depois** do attach, garantindo math consistente. ### 2. Lock / Unlock (fixar peça) Nova flag global `isLocked` no `useModelStore`: - Quando `true`: `useControllerGrab` ignora o grip (não inicia grab) e `XRGrabbable` mostra um halo dourado discreto sinalizando "travado". - Quando travado, a peça **permanece reparentada na Scene** com a pose congelada — não volta para o `XRHitTestPlacement` (impede que o reticle reposicione). - Botão "🔒 Fixar / 🔓 Liberar" no XR HUD (ao lado dos toggles existentes) e atalho `L` no DevKit (desktop). ### 3. Logs DevKit - `[grab] attach→scene (hand=left|right|dual)` - `[grab] release→` - `[lock] ON` / `[lock] OFF` Visíveis na aba **Logs** do `DevPanel`, úteis tanto no desktop quanto refletidos no overlay quando o testador usar o Quest. ### 4. Smoke test Estender `src/devkit/SmokeTest.tsx` com um caso que: 1. Cria um group filho de outro group, simula attach→scene, valida que `matrixWorld` antes/depois é igual (preserve transform). 2. Toggle de lock impede `useControllerGrab` de marcar `isGrabbing=true`. ## Arquivos afetados - `src/components/three/XRGrabbable.tsx` — reparent on start/end, halo de lock. - `src/hooks/useControllerGrab.ts` — respeitar `isLocked` (early return). - `src/stores/useModelStore.ts` — `isLocked: boolean` + `setLocked`. - `src/components/XRHud.tsx` — botão Fixar/Liberar. - `src/devkit/FakeControllers.tsx` — atalho `L`. - `src/devkit/SmokeTest.tsx` — novos asserts. - `src/pages/XRSession.tsx` — passar a `Scene` para o `XRGrabbable` (via `useThree` interno; sem mudanças na árvore JSX). ## Detalhes técnicos - **`THREE.Object3D.attach(child)`** preserva world-transform automaticamente (decompõe parent.matrixWorld⁻¹ × child.matrixWorld). É a primitiva certa, não `add`. - O re-attach final usa o **parent original capturado no grab start**, não o parent atual — protege contra mudanças de árvore React entre frames. - O `applyWorldMatrixToLocal` continua igual, mas como o parent passa a ser a `Scene` (matrixWorld = identidade), o cálculo fica trivial e estável. - `isLocked` não persiste em localStorage por enquanto (decisão de sessão). ## Fora de escopo (fica para depois) - Snap visual quina-a-quina (destacar vértice mais próximo do controlador durante o grab). - Migração para `RayGrab` nativo. - Persistir lock + pose entre sessões. ## Como validar 1. **Desktop + DevKit**: `?devkit=1` → SimXR ON → `Q` segura com a esquerda → mover → soltar. Não pode haver salto. Apertar `L` → tentar segurar → não deve agarrar. 2. **Quest 3 (testador)**: pegar peça, mover, soltar — pose final sem drift. Apertar Fixar no HUD → tentar pegar → não move. 3. Aba Logs do DevPanel mostra a sequência `attach→scene` / `release→` em todo grab.