diff --git a/.lovable/plan.md b/.lovable/plan.md index 1c8b568..6632ae3 100644 --- a/.lovable/plan.md +++ b/.lovable/plan.md @@ -1,64 +1,111 @@ ## 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. +Construir uma **"giga de teste"** (DevKit) que vista o app com simulação completa de XR + instrumentação, permitindo: -## O que muda +1. **Você (sem Quest):** validar quase 100% do fluxo direto no navegador, incluindo grab, posicionamento e medição. +2. **Testador (com Quest):** receber um build com painel de diagnóstico visível e log persistente, devolvendo feedback objetivo. +3. **Remoção limpa:** quando o app estiver maduro, apagar 1 pasta + remover 3 imports + apagar 1 flag → app puro restaurado. -### 1. Reparent on grab/release (causa-raiz) +## Princípios de isolamento -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. +- Todo código do DevKit vive em **`src/devkit/`** (pasta nova). Nada de código de simulação espalhado pelo app. +- Ativação por **flag única** `?devkit=1` na URL **OU** `localStorage.setItem('devkit', '1')`. Sem flag → app roda exatamente como hoje. +- Componentes do DevKit são montados condicionalmente em **3 pontos de injeção** apenas (todos em `XRSession.tsx`). Esses pontos ficam marcados com comentário `// DEVKIT: remove this block` para facilitar a limpeza futura. +- Zero alteração de lógica de negócio. Tudo que o DevKit faz é **observar** (logger), **simular** (fake inputs) ou **adicionar UI extra** (painel). -- **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. +## Componentes do DevKit -### 2. Lock / Unlock (fixar peça) +### 1. `devkit/DevKitFlag.ts` — detector de modo +Hook `useDevKit()` retorna `boolean`. Lê `URLSearchParams` + `localStorage`. Se `true`, ativa todo o resto. -Nova flag global `isLocked` no `useModelStore`: +### 2. `devkit/SimXRSession.tsx` — modo "Faux-XR" pra desktop +**Resolve seu problema #1:** validar tudo sem headset. -- 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). +Quando ativado (botão "🧪 Simular XR" no header, só aparece com `?devkit=1`): +- Não chama `store.enterAR()` (que falha sem HTTPS/headset compatível). +- Em vez disso, força `inXR=true` num estado paralelo `simXR`. +- Renderiza a árvore `XRGrabbable + XRModel` **sem** `XRHitTestPlacement` (auto-posiciona em `[0,0,-1.5]`). +- Substitui leitura de `session.inputSources` por uma fonte **fake** (ver #3). +- Câmera fica controlável por `OrbitControls` (você navega como quiser). -### 3. Logs DevKit +### 3. `devkit/FakeControllers.tsx` — controles virtuais com mouse/teclado +**Resolve seu problema #2:** testar grab sem Touch Plus. -- `[grab] attach→scene (hand=left|right|dual)` -- `[grab] release→` -- `[lock] ON` / `[lock] OFF` +- Desenha 2 esferas coloridas (L=azul, R=vermelho) na cena, posicionáveis por **arrastar com mouse**. +- Atalhos teclado: + - `Q` / `W` → segurar grip esquerdo / direito (analógico simulado em 1.0) + - `A` / `S` → segurar trigger esquerdo / direito + - `Setas` → mover controle ativo + - `Shift + Setas` → mover controle no eixo Z +- Expõe um `FakeInputProvider` (Context) que `useControllerGrab` consome **quando em modo DevKit**. Sem DevKit, hook continua lendo `session.inputSources` real. +- **Mudança mínima em `useControllerGrab.ts`:** uma linha checando `if (devkit) return useFakeInputs()`. Removível. -Visíveis na aba **Logs** do `DevPanel`, úteis tanto no desktop quanto refletidos no overlay quando o testador usar o Quest. +### 4. `devkit/DevPanel.tsx` — painel de diagnóstico flutuante +Painel HTML overlay (canto direito), só visível com `?devkit=1`: +- **Aba "Inputs":** valores ao vivo de cada controle (real ou fake) — grip, trigger, axes, hasPose +- **Aba "Cena":** posição/rotação/escala do grupo grabável, contagem de meshes, bbox +- **Aba "XR":** `session?.mode`, hit-test ativo, planos detectados (count + labels), FPS +- **Aba "Logs":** últimos 100 console.logs com timestamp, filtro por tag (`[XR]`, `[grab]`, `[HitTest]`) +- Botão **"Exportar relatório"** → baixa JSON com snapshot atual + screenshot do canvas. **Você pede pro testador clicar isso e te enviar o arquivo.** -### 4. Smoke test +### 5. `devkit/XRDevHud.tsx` — HUD in-world expandido (substitui o `XRDebugHud` atual quando ativo) +Painel 3D maior, ancorado no pulso esquerdo (não na câmera, pra não atrapalhar). Mostra mesmas infos da aba "Inputs" + "XR" do DevPanel. Visível **dentro do headset**, essencial pro testador. -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`. +### 6. `devkit/SmokeTest.tsx` — roteiro automático +Rota nova `/devkit/smoke` (só roteada com `?devkit=1`): +- Sequência scriptada: carrega demo → simula grip esquerdo → move 50cm → solta → mede 2 pontos → screenshot → checklist. +- Cada passo mostra ✅/❌ + tempo. +- Você roda antes de mandar build pro testador. -## Arquivos afetados +### 7. `devkit/RecordSession.tsx` — gravador de "macros" +Botão "Gravar" no DevPanel: captura por 30s todos inputs + posições da cena num JSON. Botão "Reproduzir": replay do macro. Útil pra reproduzir um bug que o testador relatou ("clico aqui, viro assim, peça desaparece"). -- `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). +## Pontos de injeção em `XRSession.tsx` + +Apenas 3 blocos curtos, todos cercados por `{devkit && <... />}`: + +```tsx +// DEVKIT: remove this block +{devkit && } + +// DEVKIT: remove this block +{devkit && simXR && } + +// DEVKIT: remove this block +{devkit && } +``` + +E uma linha em `useControllerGrab.ts`: +```ts +// DEVKIT: remove this conditional +if (typeof window !== 'undefined' && useDevKitFake()) return fakeRef; +``` + +## Como remover o DevKit no futuro (1 minuto) + +1. `rm -rf src/devkit/` +2. Em `XRSession.tsx`: deletar 3 blocos marcados `// DEVKIT:` + import. +3. Em `useControllerGrab.ts`: deletar linha marcada `// DEVKIT:`. +4. Pronto — app puro, zero resíduo. + +## Entregas desta primeira leva (priorizadas) + +Pra não fazer tudo de uma vez, **proponho entregar nesta ordem**, validando cada etapa antes da próxima: + +**Fase 1 (essencial — destrava você hoje):** +- DevKitFlag + SimXRSession + FakeControllers + DevPanel (aba Inputs + Logs) + +**Fase 2 (essencial — instrumenta o testador):** +- XRDevHud expandido + DevPanel aba Cena/XR + Exportar relatório + +**Fase 3 (qualidade de vida):** +- SmokeTest + RecordSession + +Posso entregar a **Fase 1 inteira** agora numa única leva, e as fases 2 e 3 sob demanda quando você validar que a 1 te serve. Cabe? ## 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. +- **Sem novas dependências.** Tudo com Three.js, R3F, Zustand já presentes. +- **TypeScript rigoroso.** Tipos compartilhados entre real e fake (mesma interface `ControllerGrabSnapshot`). +- **Zero impacto em produção:** sem flag, bundle do DevKit nem é importado se usarmos `React.lazy()` na entrada. +- **Hot-swappable:** ligar/desligar o DevKit não recarrega a página (estado da cena preservado). diff --git a/src/App.tsx b/src/App.tsx index 9875793..00a16eb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,8 +7,6 @@ import Index from "./pages/Index"; import Viewer from "./pages/Viewer"; import XRSession from "./pages/XRSession"; import NotFound from "./pages/NotFound"; -// DEVKIT: remove this import + route to strip devkit -import SmokeTest from "./devkit/SmokeTest"; const queryClient = new QueryClient(); @@ -22,8 +20,6 @@ const App = () => ( } /> } /> } /> - {/* DEVKIT: remove this route to strip devkit */} - } /> } /> diff --git a/src/components/XRHud.tsx b/src/components/XRHud.tsx index 8817686..9bb8147 100644 --- a/src/components/XRHud.tsx +++ b/src/components/XRHud.tsx @@ -3,7 +3,6 @@ import { Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera, Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip, ClipboardCheck, X, Crosshair, Magnet, HelpCircle, Maximize2, - Lock, Unlock, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Slider } from '@/components/ui/slider'; @@ -113,7 +112,6 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog fineTuning, setFineTuning, resetFineTuning, addScreenshot, checklist, - isLocked, setLocked, } = useModelStore(); const approved = checklist.filter(i => i.status === 'approved').length; @@ -312,22 +310,6 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog )} - {/* Lock / Unlock — fixa a peça no espaço */} - - ))} -
+ {/* Content */} +
{tab === 'inputs' && (
-
fake: {isFakeActive() ? 'ATIVO' : 'inativo'}
+
+ fake: {isFakeActive() ? 'ATIVO' : 'inativo'} +
{(['left', 'right'] as const).map((h) => { const s = fakeInput[h]; const grabbing = s.gripValue > 0.7; @@ -161,95 +130,9 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) {
Q/W = grip L/R · A/S = trigger L/R
1/2 = ativar mão · setas = mover
Shift+↑/↓ = Z (frente/trás) · R = reset
-
L = fixar/liberar peça (lock)
)} - - {tab === 'cena' && ( -
- - - n.toFixed(3)).join(' × ')} /> - n.toFixed(3)).join(', ')} /> - n.toFixed(1)).join(', ')} /> - -
- )} - - {tab === 'xr' && ( -
- - - - - - - -
- )} - - {tab === 'macro' && ( -
-
fase: {macroState.phase}
-
- {macroState.phase !== 'recording' ? ( - - ) : ( - - )} - {macroState.phase !== 'playing' ? ( - - ) : ( - - )} - - - { - const f = e.target.files?.[0]; - if (!f) return; - try { - const macro = await loadMacroFromFile(f); - macroState.current = macro; - tick((n) => n + 1); - } catch { /* noop */ } - e.target.value = ''; - }} - /> -
- {macroState.current && ( -
-
{macroState.current.name}
-
frames: {macroState.current.frames.length}
-
duração: {(macroState.current.duration / 1000).toFixed(1)}s
-
- )} - {macroState.phase === 'recording' && ( -
gravando… {macroState.rec.length} frames
- )} -
- Use os atalhos do teclado para encenar o bug; depois clique em Parar e Salvar. - No próximo round, Carregar o JSON e clicar em Play reproduz exatamente. -
-
- )} - {tab === 'logs' && (
{logBuffer .filter((l) => !logFilter || l.msg.toLowerCase().includes(logFilter.toLowerCase())) - .slice(-80).reverse() + .slice(-80) + .reverse() .map((l, i) => (
); } - -function Row({ k, v }: { k: string; v: string }) { - return ( -
- {k} - {v} -
- ); -} diff --git a/src/devkit/FakeControllers.tsx b/src/devkit/FakeControllers.tsx index aca4fbb..58e421f 100644 --- a/src/devkit/FakeControllers.tsx +++ b/src/devkit/FakeControllers.tsx @@ -17,8 +17,6 @@ import { useEffect, useRef, useState } from 'react'; import { useFrame } from '@react-three/fiber'; import * as THREE from 'three'; import { fakeInput, setFakeActive } from './fakeInputStore'; -import { isPlaybackActive } from './macroStore'; -import { useModelStore } from '@/stores/useModelStore'; const STEP = 0.03; // m per keypress @@ -37,7 +35,6 @@ export function FakeControllers() { const keys = new Set(); const apply = () => { - if (isPlaybackActive()) return; // macro playback owns inputs const pos = new THREE.Vector3(); const quat = new THREE.Quaternion(); const scl = new THREE.Vector3(); @@ -72,12 +69,6 @@ export function FakeControllers() { fakeInput.right.gripWorld.makeTranslation(0.25, 1.2, -0.5); force((n) => n + 1); return; - } else if (k === 'l' || k === 'L') { - const s = useModelStore.getState(); - const next = !s.isLocked; - s.setLocked(next); - console.log(`[lock] ${next ? 'ON' : 'OFF'} (keyboard)`); - return; } apply(); }; diff --git a/src/devkit/MacroDriver.tsx b/src/devkit/MacroDriver.tsx deleted file mode 100644 index 88505a4..0000000 --- a/src/devkit/MacroDriver.tsx +++ /dev/null @@ -1,11 +0,0 @@ -/** - * DEVKIT — Drives macro recording/playback inside the R3F frame loop. - * Mounted inside . No visual output. - */ -import { useFrame } from '@react-three/fiber'; -import { tickMacro } from './macroStore'; - -export function MacroDriver() { - useFrame(() => { tickMacro(); }); - return null; -} diff --git a/src/devkit/SceneStatsProbe.tsx b/src/devkit/SceneStatsProbe.tsx deleted file mode 100644 index 294c6cc..0000000 --- a/src/devkit/SceneStatsProbe.tsx +++ /dev/null @@ -1,47 +0,0 @@ -/** - * DEVKIT — samples scene + XR session every frame, writes to sceneStatsStore. - * Mounted inside . No visual output. - */ -import { useEffect, useRef } from 'react'; -import { useFrame, useThree } from '@react-three/fiber'; -import { useXR } from '@react-three/xr'; -import { sceneStats, updateModelStats } from './sceneStatsStore'; - -export function SceneStatsProbe() { - const { scene } = useThree(); - const session = useXR((s) => s.session); - const frameCount = useRef(0); - const lastFpsTime = useRef(performance.now()); - - useEffect(() => { - sceneStats.xrMode = session ? 'immersive-ar' : null; - }, [session]); - - useFrame((_state, _dt, frame: XRFrame | undefined) => { - frameCount.current++; - const now = performance.now(); - if (now - lastFpsTime.current >= 500) { - sceneStats.fps = Math.round((frameCount.current * 1000) / (now - lastFpsTime.current)); - frameCount.current = 0; - lastFpsTime.current = now; - } - - // Find the grabbable model group by traversing for first userData marker, fallback: scan - let modelGroup: import('three').Object3D | null = null; - scene.traverse((o) => { - if (!modelGroup && o.userData?.__xrModelRoot) modelGroup = o; - }); - updateModelStats(modelGroup); - - if (frame) { - try { - const planes = (frame as unknown as { detectedPlanes?: { size?: number } }).detectedPlanes; - sceneStats.xrPlanesCount = planes?.size ?? 0; - } catch { sceneStats.xrPlanesCount = 0; } - sceneStats.xrInputCount = frame.session?.inputSources?.length ?? 0; - } - sceneStats.lastUpdate = now; - }); - - return null; -} diff --git a/src/devkit/SmokeTest.tsx b/src/devkit/SmokeTest.tsx deleted file mode 100644 index 69dd4e3..0000000 --- a/src/devkit/SmokeTest.tsx +++ /dev/null @@ -1,235 +0,0 @@ -/** - * DEVKIT — `/devkit/smoke` smoke test runner. - * - * Scripted suite that exercises the store + demo pipeline without a headset. - * Run before sending a build to the Quest tester. All steps must be ✅. - */ -import { useState } from 'react'; -import { Link, Navigate } from 'react-router-dom'; -import * as THREE from 'three'; -import { useModelStore } from '@/stores/useModelStore'; -import { generateDemoBeamGLB } from '@/lib/generateDemoBeam'; -import { useDevKit } from './useDevKit'; -import { fakeInput } from './fakeInputStore'; -import { startRecording, stopRecording, macroState } from './macroStore'; - -interface StepResult { name: string; ok: boolean; detail: string; ms: number; } - -type Step = { name: string; run: () => Promise }; - -function buildSteps(): Step[] { - const store = useModelStore.getState; - return [ - { - name: 'gerar demo beam (IPE 200)', - run: async () => { - const { blob, fileName, fileSize } = await generateDemoBeamGLB(); - const url = URL.createObjectURL(blob); - store().setModel({ url, fileName, fileSize }); - return `${fileName} • ${(fileSize / 1024).toFixed(1)} KB`; - }, - }, - { - name: 'modelo no store', - run: async () => { - const m = store().model; - if (!m?.url) throw new Error('model.url ausente'); - return m.fileName; - }, - }, - { - name: 'fineTuning reset', - run: async () => { - store().resetFineTuning(); - const ft = store().fineTuning; - if (ft.posX !== 0 || ft.scale !== 1) throw new Error('reset falhou'); - return 'ok'; - }, - }, - { - name: 'medição cria distância correta', - run: async () => { - store().clearMeasurements(); - store().addMeasurePoint({ x: 0, y: 0, z: 0 }); - store().addMeasurePoint({ x: 0.1, y: 0, z: 0 }); - const ms = store().measurements; - if (ms.length !== 1) throw new Error(`esperado 1 medição, obteve ${ms.length}`); - const d = ms[0].distanceMM; - if (Math.abs(d - 100) > 0.01) throw new Error(`esperado 100mm, obteve ${d.toFixed(2)}`); - return `${d.toFixed(2)} mm`; - }, - }, - { - name: 'checklist defaults', - run: async () => { - store().resetChecklist(); - const cl = store().checklist; - if (cl.length !== 4) throw new Error(`esperado 4 itens, obteve ${cl.length}`); - if (!cl.every(i => i.status === 'pending')) throw new Error('itens não-pending'); - return `${cl.length} itens`; - }, - }, - { - name: 'fake input grip simulado', - run: async () => { - fakeInput.left.gripValue = 1; - await new Promise(r => setTimeout(r, 50)); - if (fakeInput.left.gripValue !== 1) throw new Error('grip não foi setado'); - fakeInput.left.gripValue = 0; - return 'ok'; - }, - }, - { - name: 'macro recorder ciclo curto', - run: async () => { - startRecording(); - await new Promise(r => setTimeout(r, 200)); - const m = stopRecording(); - if (!m) throw new Error('macro vazia'); - if (macroState.phase !== 'idle') throw new Error('fase não voltou a idle'); - return `${m.frames.length} frames • ${m.duration.toFixed(0)}ms`; - }, - }, - { - name: 'navigator.xr disponível', - run: async () => { - if (!('xr' in navigator)) throw new Error('navigator.xr ausente'); - return 'ok'; - }, - }, - { - name: 'HTTPS ativo', - run: async () => { - if (location.protocol !== 'https:' && location.hostname !== 'localhost') { - throw new Error(`protocolo ${location.protocol}`); - } - return location.protocol; - }, - }, - { - name: 'reparent preserva world-transform (Object3D.attach)', - run: async () => { - const scene = new THREE.Scene(); - const parent = new THREE.Group(); - parent.position.set(1, 2, 3); - parent.rotation.set(0, Math.PI / 4, 0); - scene.add(parent); - const child = new THREE.Group(); - child.position.set(0.5, 0, 0); - parent.add(child); - scene.updateMatrixWorld(true); - const beforeWorld = child.matrixWorld.clone(); - scene.attach(child); - scene.updateMatrixWorld(true); - const afterWorld = child.matrixWorld.clone(); - for (let i = 0; i < 16; i++) { - if (Math.abs(beforeWorld.elements[i] - afterWorld.elements[i]) > 1e-5) { - throw new Error(`world drift no índice ${i}`); - } - } - // re-attach back - parent.attach(child); - scene.updateMatrixWorld(true); - const restored = child.matrixWorld.clone(); - for (let i = 0; i < 16; i++) { - if (Math.abs(beforeWorld.elements[i] - restored.elements[i]) > 1e-5) { - throw new Error(`world drift restore ${i}`); - } - } - return 'world preserved'; - }, - }, - { - name: 'lock toggle persiste no store', - run: async () => { - store().setLocked(false); - if (store().isLocked) throw new Error('isLocked deveria ser false'); - store().setLocked(true); - if (!store().isLocked) throw new Error('setLocked(true) falhou'); - store().setLocked(false); - return 'ok'; - }, - }, - ]; -} - -export default function SmokeTest() { - const devkit = useDevKit(); - const [results, setResults] = useState([]); - const [running, setRunning] = useState(false); - - if (!devkit) return ; - - const run = async () => { - setRunning(true); - setResults([]); - const steps = buildSteps(); - const out: StepResult[] = []; - for (const s of steps) { - const t0 = performance.now(); - try { - const detail = await s.run(); - out.push({ name: s.name, ok: true, detail, ms: performance.now() - t0 }); - } catch (e) { - out.push({ name: s.name, ok: false, detail: (e as Error).message, ms: performance.now() - t0 }); - } - setResults([...out]); - } - setRunning(false); - }; - - const passed = results.filter(r => r.ok).length; - const failed = results.filter(r => !r.ok).length; - - return ( -
-
-
-

🧪 DevKit · Smoke Test

- ← Home -
-

- Roteiro automático que exercita store, demo, medição, checklist, fake input e gravador. - Execute antes de enviar build para o testador com Quest. -

-
- - {results.length > 0 && ( - - ✓ {passed} - {' · '} - ✕ {failed} - - )} -
- -
- {results.length === 0 && ( -
aguardando execução…
- )} - {results.map((r, i) => ( -
-
- {r.ok ? '✓' : '✕'} - {r.name} -
{r.detail}
-
- {r.ms.toFixed(0)}ms -
- ))} -
-
-
- ); -} diff --git a/src/devkit/XRDevHud.tsx b/src/devkit/XRDevHud.tsx deleted file mode 100644 index fbe72c8..0000000 --- a/src/devkit/XRDevHud.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * DEVKIT — In-world expanded HUD anchored to left controller (or fake left). - * - * Replaces the production XRDebugHud while devkit is on. Shows inputs, FPS, - * model transform, plane count — readable inside the headset. - */ -import { useRef } from 'react'; -import { useFrame, useThree } from '@react-three/fiber'; -import { Text } from '@react-three/drei'; -import * as THREE from 'three'; -import { sceneStats } from './sceneStatsStore'; -import { fakeInput, isFakeActive } from './fakeInputStore'; - -const _m = new THREE.Matrix4(); -const _p = new THREE.Vector3(); -const _q = new THREE.Quaternion(); -const _s = new THREE.Vector3(); -const _off = new THREE.Vector3(0.10, 0.05, -0.05); // above wrist, away from face - -export function XRDevHud() { - const groupRef = useRef(null); - const textRef = useRef<{ text: string } & THREE.Object3D>(null); - const { camera, gl } = useThree(); - - useFrame((_state, _dt, frame: XRFrame | undefined) => { - if (!groupRef.current) return; - - let anchored = false; - - if (isFakeActive()) { - _m.copy(fakeInput.left.gripWorld); - anchored = true; - } else if (frame) { - const ref = gl.xr.getReferenceSpace(); - if (ref) { - for (const src of frame.session.inputSources) { - if (src.handedness === 'left' && src.gripSpace) { - const pose = frame.getPose(src.gripSpace, ref); - if (pose) { _m.fromArray(pose.transform.matrix); anchored = true; break; } - } - } - } - } - - if (anchored) { - _m.decompose(_p, _q, _s); - const off = _off.clone().applyQuaternion(_q); - groupRef.current.position.copy(_p).add(off); - groupRef.current.quaternion.copy(_q); - } else { - // fallback: HUD floats in front of camera - const off = new THREE.Vector3(-0.18, -0.10, -0.5).applyQuaternion(camera.quaternion); - groupRef.current.position.copy(camera.position).add(off); - groupRef.current.quaternion.copy(camera.quaternion); - } - - if (textRef.current) { - const lines = [ - `FPS ${sceneStats.fps} XR ${sceneStats.xrMode ? 'on' : 'off'} fake:${isFakeActive() ? '1' : '0'}`, - `inputs:${sceneStats.xrInputCount} planes:${sceneStats.xrPlanesCount}`, - `meshes:${sceneStats.meshCount} tris:${sceneStats.triCount}`, - `pos ${sceneStats.modelGroupPos.map((v) => v.toFixed(2)).join(' ')}`, - `rot ${sceneStats.modelGroupRotDeg.map((v) => v.toFixed(0)).join(' ')}`, - `bbox ${sceneStats.modelBboxSize.map((v) => v.toFixed(2)).join(' ')}`, - `L g${fakeInput.left.gripValue.toFixed(2)} t${fakeInput.left.triggerValue.toFixed(2)}`, - `R g${fakeInput.right.gripValue.toFixed(2)} t${fakeInput.right.triggerValue.toFixed(2)}`, - ]; - (textRef.current as unknown as { text: string }).text = lines.join('\n'); - } - }); - - return ( - - - - - - } - fontSize={0.0075} - color="#22c55e" - anchorX="left" - anchorY="top" - position={[-0.018, 0.045, 0.001]} - maxWidth={0.17} - lineHeight={1.3} - > - {' '} - - - ); -} diff --git a/src/devkit/macroStore.ts b/src/devkit/macroStore.ts deleted file mode 100644 index e8b3241..0000000 --- a/src/devkit/macroStore.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * DEVKIT — Macro recorder/player for fake controller inputs. - * - * Records fakeInput poses + grip/trigger at ~30Hz to a JSON-serializable - * timeline. Replays by writing values back into fakeInput. Used to reproduce - * bugs reported by the headset tester. - */ -import * as THREE from 'three'; -import { fakeInput } from './fakeInputStore'; - -export interface MacroFrame { - t: number; // ms since start - l: { m: number[]; g: number; t: number }; - r: { m: number[]; g: number; t: number }; -} - -export interface Macro { - name: string; - duration: number; - frames: MacroFrame[]; -} - -type Phase = 'idle' | 'recording' | 'playing'; - -interface State { - phase: Phase; - recStart: number; - rec: MacroFrame[]; - play: { macro: Macro; start: number; idx: number } | null; - current: Macro | null; - listeners: Set<() => void>; -} - -export const macroState: State = { - phase: 'idle', - recStart: 0, - rec: [], - play: null, - current: null, - listeners: new Set(), -}; - -function notify() { for (const l of macroState.listeners) l(); } - -export function subscribeMacro(cb: () => void) { - macroState.listeners.add(cb); - return () => macroState.listeners.delete(cb); -} - -export function startRecording() { - macroState.phase = 'recording'; - macroState.recStart = performance.now(); - macroState.rec = []; - notify(); -} - -export function stopRecording(): Macro { - const macro: Macro = { - name: `macro-${new Date().toISOString().slice(11, 19)}`, - duration: performance.now() - macroState.recStart, - frames: macroState.rec.slice(), - }; - macroState.phase = 'idle'; - macroState.current = macro; - notify(); - return macro; -} - -export function startPlaying(macro?: Macro) { - const m = macro ?? macroState.current; - if (!m || m.frames.length === 0) return; - macroState.play = { macro: m, start: performance.now(), idx: 0 }; - macroState.phase = 'playing'; - notify(); -} - -export function stopPlaying() { - macroState.play = null; - macroState.phase = 'idle'; - notify(); -} - -export function tickMacro() { - if (macroState.phase === 'recording') { - const t = performance.now() - macroState.recStart; - // throttle to ~30Hz - const last = macroState.rec[macroState.rec.length - 1]; - if (!last || t - last.t > 33) { - macroState.rec.push({ - t, - l: { m: matrixToArray(fakeInput.left.gripWorld), g: fakeInput.left.gripValue, t: fakeInput.left.triggerValue }, - r: { m: matrixToArray(fakeInput.right.gripWorld), g: fakeInput.right.gripValue, t: fakeInput.right.triggerValue }, - }); - } - } else if (macroState.phase === 'playing' && macroState.play) { - const t = performance.now() - macroState.play.start; - const frames = macroState.play.macro.frames; - while ( - macroState.play.idx < frames.length - 1 && - frames[macroState.play.idx + 1].t <= t - ) { - macroState.play.idx++; - } - const f = frames[macroState.play.idx]; - if (f) { - arrayToMatrix(f.l.m, fakeInput.left.gripWorld); - fakeInput.left.gripValue = f.l.g; - fakeInput.left.triggerValue = f.l.t; - arrayToMatrix(f.r.m, fakeInput.right.gripWorld); - fakeInput.right.gripValue = f.r.g; - fakeInput.right.triggerValue = f.r.t; - } - if (t > macroState.play.macro.duration) stopPlaying(); - } -} - -export function isPlaybackActive() { - return macroState.phase === 'playing'; -} - -function matrixToArray(m: THREE.Matrix4): number[] { - return Array.from(m.elements); -} -function arrayToMatrix(arr: number[], target: THREE.Matrix4) { - target.fromArray(arr); -} - -export function downloadMacro(macro: Macro) { - const blob = new Blob([JSON.stringify(macro, null, 2)], { type: 'application/json' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${macro.name}.json`; - a.click(); - URL.revokeObjectURL(url); -} - -export function loadMacroFromFile(file: File): Promise { - return new Promise((resolve, reject) => { - const r = new FileReader(); - r.onload = () => { - try { resolve(JSON.parse(String(r.result)) as Macro); } catch (e) { reject(e); } - }; - r.onerror = reject; - r.readAsText(file); - }); -} diff --git a/src/devkit/sceneStatsStore.ts b/src/devkit/sceneStatsStore.ts deleted file mode 100644 index ae9290f..0000000 --- a/src/devkit/sceneStatsStore.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * DEVKIT — global mutable snapshot of scene + XR session diagnostics. - * - * Written every frame by SceneStatsProbe (inside Canvas), read by DevPanel - * (HTML overlay) and XRDevHud (in-world). Plain object to avoid re-renders. - */ -import * as THREE from 'three'; - -export interface SceneStats { - fps: number; - meshCount: number; - triCount: number; - modelBboxSize: [number, number, number]; - modelGroupPos: [number, number, number]; - modelGroupRotDeg: [number, number, number]; - modelGroupScale: number; - xrMode: string | null; - xrPlanesCount: number; - xrHitTestActive: boolean; - xrInputCount: number; - lastUpdate: number; -} - -export const sceneStats: SceneStats = { - fps: 0, - meshCount: 0, - triCount: 0, - modelBboxSize: [0, 0, 0], - modelGroupPos: [0, 0, 0], - modelGroupRotDeg: [0, 0, 0], - modelGroupScale: 1, - xrMode: null, - xrPlanesCount: 0, - xrHitTestActive: false, - xrInputCount: 0, - lastUpdate: 0, -}; - -export function snapshotStats(): SceneStats { - return { ...sceneStats }; -} - -const _v = new THREE.Vector3(); -const _q = new THREE.Quaternion(); -const _e = new THREE.Euler(); -const _box = new THREE.Box3(); - -export function updateModelStats(group: THREE.Object3D | null) { - if (!group) return; - group.updateWorldMatrix(true, true); - group.getWorldPosition(_v); - sceneStats.modelGroupPos = [_v.x, _v.y, _v.z]; - group.getWorldQuaternion(_q); - _e.setFromQuaternion(_q); - sceneStats.modelGroupRotDeg = [ - THREE.MathUtils.radToDeg(_e.x), - THREE.MathUtils.radToDeg(_e.y), - THREE.MathUtils.radToDeg(_e.z), - ]; - group.getWorldScale(_v); - sceneStats.modelGroupScale = _v.x; - - let meshes = 0; - let tris = 0; - group.traverse((o) => { - if (o instanceof THREE.Mesh && o.geometry) { - meshes++; - const idx = o.geometry.index; - const pos = o.geometry.attributes.position; - if (idx) tris += idx.count / 3; - else if (pos) tris += pos.count / 3; - } - }); - sceneStats.meshCount = meshes; - sceneStats.triCount = Math.round(tris); - - _box.setFromObject(group); - _box.getSize(_v); - sceneStats.modelBboxSize = [_v.x, _v.y, _v.z]; -} diff --git a/src/hooks/useControllerGrab.ts b/src/hooks/useControllerGrab.ts index ddd34eb..b41f2de 100644 --- a/src/hooks/useControllerGrab.ts +++ b/src/hooks/useControllerGrab.ts @@ -1,7 +1,6 @@ import { useRef } from 'react'; import { useFrame, useThree } from '@react-three/fiber'; import * as THREE from 'three'; -import { useModelStore } from '@/stores/useModelStore'; // DEVKIT: remove this import + the fake-input block in useFrame to strip devkit import { fakeInput, isFakeActive } from '@/devkit/fakeInputStore'; @@ -53,8 +52,6 @@ export function useControllerGrab() { const { gl } = useThree(); useFrame((_state, _delta, frame: XRFrame | undefined) => { - const locked = useModelStore.getState().isLocked; - // DEVKIT: fake-input branch — bypasses real WebXR when SimXR is active. if (isFakeActive()) { for (const hand of ['left', 'right'] as const) { @@ -64,13 +61,6 @@ export function useControllerGrab() { slot.triggerValue = fake.triggerValue; slot.hasPose = fake.hasPose; slot.gripWorld.copy(fake.gripWorld); - if (locked) { - if (slot.isGrabbing) { - slot.isGrabbing = false; - console.log(`[XR][grab] ◼ ${hand} END (locked)`); - } - continue; - } if (!slot.isGrabbing && fake.gripValue > GRAB_ON) { slot.isGrabbing = true; console.log(`[XR][grab] ▶ ${hand} START (fake v=${fake.gripValue.toFixed(2)})`); @@ -103,12 +93,8 @@ export function useControllerGrab() { const trigValue = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0; slot.gripValue = gripValue; slot.triggerValue = trigValue; - if (locked) { - if (slot.isGrabbing) { - slot.isGrabbing = false; - console.log(`[XR][grab] ◼ ${source.handedness} END (locked)`); - } - } else if (!slot.isGrabbing && gripValue > GRAB_ON) { + // Hysteresis + if (!slot.isGrabbing && gripValue > GRAB_ON) { slot.isGrabbing = true; console.log(`[XR][grab] ▶ ${source.handedness} START (v=${gripValue.toFixed(2)})`); } else if (slot.isGrabbing && gripValue < GRAB_OFF) { diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index 34c3e60..f428a0f 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -211,7 +211,7 @@ const Index = () => { {/* Footer */}

- TrackSteelXR v1.1 — Q.C. Inspection + TrackSteelXR v1.0 — Q.C. Inspection

); diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx index 09d53c6..d2df4a7 100644 --- a/src/pages/XRSession.tsx +++ b/src/pages/XRSession.tsx @@ -17,9 +17,6 @@ import { XRGrabbable } from '@/components/three/XRGrabbable'; import { useDevKit } from '@/devkit/useDevKit'; import { DevPanel } from '@/devkit/DevPanel'; import { FakeControllers } from '@/devkit/FakeControllers'; -import { SceneStatsProbe } from '@/devkit/SceneStatsProbe'; -import { MacroDriver } from '@/devkit/MacroDriver'; -import { XRDevHud } from '@/devkit/XRDevHud'; // --- Diagnóstico XR --- console.log('[XR] Inicializando store...'); @@ -124,7 +121,6 @@ function XRModel({ url }: { url: string }) { return ( { - {devkit ? : } + {/* DEVKIT: fake controllers visible in scene + keyboard driver */} {devkit && simXR && } {/* DEVKIT: orbit camera in SimXR so you can navigate around the model */} @@ -570,9 +566,6 @@ const XRSession = () => { - {/* DEVKIT: scene/XR stats probe + macro recorder driver */} - {devkit && } - {devkit && } diff --git a/src/stores/useModelStore.ts b/src/stores/useModelStore.ts index 0c81b9a..2a4b883 100644 --- a/src/stores/useModelStore.ts +++ b/src/stores/useModelStore.ts @@ -118,10 +118,6 @@ interface ModelStore { addScreenshot: (dataUrl: string) => void; removeScreenshot: (index: number) => void; clearScreenshots: () => void; - - /** When true, the grabbed model stays frozen in world space (grip is ignored). */ - isLocked: boolean; - setLocked: (locked: boolean) => void; } const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 }; @@ -223,7 +219,4 @@ export const useModelStore = create((set) => ({ screenshots: state.screenshots.filter((_, i) => i !== index), })), clearScreenshots: () => set({ screenshots: [] }), - - isLocked: false, - setLocked: (isLocked) => set({ isLocked }), }));