Reverted to commit ceb9b8c1a1
This commit is contained in:
+92
-45
@@ -1,64 +1,111 @@
|
|||||||
## Objetivo
|
## 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)`.
|
## Componentes do DevKit
|
||||||
- **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)
|
### 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 ativado (botão "🧪 Simular XR" no header, só aparece com `?devkit=1`):
|
||||||
- Quando travado, a peça **permanece reparentada na Scene** com a pose congelada — não volta para o `XRHitTestPlacement` (impede que o reticle reposicione).
|
- Não chama `store.enterAR()` (que falha sem HTTPS/headset compatível).
|
||||||
- Botão "🔒 Fixar / 🔓 Liberar" no XR HUD (ao lado dos toggles existentes) e atalho `L` no DevKit (desktop).
|
- 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)`
|
- Desenha 2 esferas coloridas (L=azul, R=vermelho) na cena, posicionáveis por **arrastar com mouse**.
|
||||||
- `[grab] release→<parentName>`
|
- Atalhos teclado:
|
||||||
- `[lock] ON` / `[lock] OFF`
|
- `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:
|
### 6. `devkit/SmokeTest.tsx` — roteiro automático
|
||||||
1. Cria um group filho de outro group, simula attach→scene, valida que `matrixWorld` antes/depois é igual (preserve transform).
|
Rota nova `/devkit/smoke` (só roteada com `?devkit=1`):
|
||||||
2. Toggle de lock impede `useControllerGrab` de marcar `isGrabbing=true`.
|
- 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.
|
## Pontos de injeção em `XRSession.tsx`
|
||||||
- `src/hooks/useControllerGrab.ts` — respeitar `isLocked` (early return).
|
|
||||||
- `src/stores/useModelStore.ts` — `isLocked: boolean` + `setLocked`.
|
Apenas 3 blocos curtos, todos cercados por `{devkit && <... />}`:
|
||||||
- `src/components/XRHud.tsx` — botão Fixar/Liberar.
|
|
||||||
- `src/devkit/FakeControllers.tsx` — atalho `L`.
|
```tsx
|
||||||
- `src/devkit/SmokeTest.tsx` — novos asserts.
|
// DEVKIT: remove this block
|
||||||
- `src/pages/XRSession.tsx` — passar a `Scene` para o `XRGrabbable` (via `useThree` interno; sem mudanças na árvore JSX).
|
{devkit && <SimXRButton onActivate={setSimXR} />}
|
||||||
|
|
||||||
|
// DEVKIT: remove this block
|
||||||
|
{devkit && simXR && <FakeControllers />}
|
||||||
|
|
||||||
|
// DEVKIT: remove this block
|
||||||
|
{devkit && <DevPanel />}
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## Detalhes técnicos
|
||||||
|
|
||||||
- **`THREE.Object3D.attach(child)`** preserva world-transform automaticamente (decompõe parent.matrixWorld⁻¹ × child.matrixWorld). É a primitiva certa, não `add`.
|
- **Sem novas dependências.** Tudo com Three.js, R3F, Zustand já presentes.
|
||||||
- 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.
|
- **TypeScript rigoroso.** Tipos compartilhados entre real e fake (mesma interface `ControllerGrabSnapshot`).
|
||||||
- O `applyWorldMatrixToLocal` continua igual, mas como o parent passa a ser a `Scene` (matrixWorld = identidade), o cálculo fica trivial e estável.
|
- **Zero impacto em produção:** sem flag, bundle do DevKit nem é importado se usarmos `React.lazy()` na entrada.
|
||||||
- `isLocked` não persiste em localStorage por enquanto (decisão de sessão).
|
- **Hot-swappable:** ligar/desligar o DevKit não recarrega a página (estado da cena preservado).
|
||||||
|
|
||||||
## 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→<parent>` em todo grab.
|
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import Index from "./pages/Index";
|
|||||||
import Viewer from "./pages/Viewer";
|
import Viewer from "./pages/Viewer";
|
||||||
import XRSession from "./pages/XRSession";
|
import XRSession from "./pages/XRSession";
|
||||||
import NotFound from "./pages/NotFound";
|
import NotFound from "./pages/NotFound";
|
||||||
// DEVKIT: remove this import + route to strip devkit
|
|
||||||
import SmokeTest from "./devkit/SmokeTest";
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
@@ -22,8 +20,6 @@ const App = () => (
|
|||||||
<Route path="/" element={<Index />} />
|
<Route path="/" element={<Index />} />
|
||||||
<Route path="/viewer" element={<Viewer />} />
|
<Route path="/viewer" element={<Viewer />} />
|
||||||
<Route path="/xr" element={<XRSession />} />
|
<Route path="/xr" element={<XRSession />} />
|
||||||
{/* DEVKIT: remove this route to strip devkit */}
|
|
||||||
<Route path="/devkit/smoke" element={<SmokeTest />} />
|
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera,
|
Eye, LayoutGrid, Grid3X3, Box, Ruler, Trash2, Camera,
|
||||||
Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip,
|
Move, RotateCw, RefreshCw, ChevronUp, ChevronDown, Grip,
|
||||||
ClipboardCheck, X, Crosshair, Magnet, HelpCircle, Maximize2,
|
ClipboardCheck, X, Crosshair, Magnet, HelpCircle, Maximize2,
|
||||||
Lock, Unlock,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Slider } from '@/components/ui/slider';
|
import { Slider } from '@/components/ui/slider';
|
||||||
@@ -113,7 +112,6 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
|
|||||||
fineTuning, setFineTuning, resetFineTuning,
|
fineTuning, setFineTuning, resetFineTuning,
|
||||||
addScreenshot,
|
addScreenshot,
|
||||||
checklist,
|
checklist,
|
||||||
isLocked, setLocked,
|
|
||||||
} = useModelStore();
|
} = useModelStore();
|
||||||
|
|
||||||
const approved = checklist.filter(i => i.status === 'approved').length;
|
const approved = checklist.filter(i => i.status === 'approved').length;
|
||||||
@@ -312,22 +310,6 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Lock / Unlock — fixa a peça no espaço */}
|
|
||||||
<Button
|
|
||||||
variant={isLocked ? 'default' : 'outline'}
|
|
||||||
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
|
||||||
onClick={() => {
|
|
||||||
const next = !isLocked;
|
|
||||||
setLocked(next);
|
|
||||||
console.log(`[lock] ${next ? 'ON' : 'OFF'}`);
|
|
||||||
toast[next ? 'success' : 'message'](next ? 'Peça fixada no espaço 🔒' : 'Peça liberada 🔓');
|
|
||||||
}}
|
|
||||||
title="Fixa/libera a peça no espaço (ignora Grip enquanto travada)"
|
|
||||||
>
|
|
||||||
{isLocked ? <Lock className="h-3.5 w-3.5" /> : <Unlock className="h-3.5 w-3.5" />}
|
|
||||||
{isLocked ? 'Fixada' : 'Fixar'}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant={freeMove ? 'default' : 'outline'}
|
variant={freeMove ? 'default' : 'outline'}
|
||||||
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
size="sm" className="h-8 gap-1.5 text-[10px] font-mono"
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { useRef, ReactNode } from 'react';
|
import { useRef, ReactNode } from 'react';
|
||||||
import { useFrame, useThree } from '@react-three/fiber';
|
import { useFrame } from '@react-three/fiber';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab';
|
import { useControllerGrab, ControllerGrabSnapshot } from '@/hooks/useControllerGrab';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
|
||||||
|
|
||||||
interface XRGrabbableProps {
|
interface XRGrabbableProps {
|
||||||
/** Allow uniform scaling via two-hand grab (distance between hands) */
|
/** Allow uniform scaling via two-hand grab (distance between hands) */
|
||||||
@@ -43,57 +42,18 @@ const _tmpQuat = new THREE.Quaternion();
|
|||||||
* • Two-hand grab: midpoint = pivot, axis between hands = rotation,
|
* • Two-hand grab: midpoint = pivot, axis between hands = rotation,
|
||||||
* distance ratio = uniform scale (if allowScale).
|
* distance ratio = uniform scale (if allowScale).
|
||||||
*
|
*
|
||||||
* Parent re-attach strategy:
|
* On release the group keeps its last pose (we don't write to the store —
|
||||||
* At grab start the group is attached to the Scene root (preserving
|
* the manual fine-tuning sliders remain available as additional offsets).
|
||||||
* world-transform). At grab end it is re-attached to its original parent
|
|
||||||
* (typically XRHitTestPlacement), unless `isLocked` is true — in which case
|
|
||||||
* it stays anchored in world space and the original parent can no longer
|
|
||||||
* influence its matrix.
|
|
||||||
*/
|
*/
|
||||||
export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGrabbableProps) {
|
export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGrabbableProps) {
|
||||||
const groupRef = useRef<THREE.Group>(null);
|
const groupRef = useRef<THREE.Group>(null);
|
||||||
const grab = useControllerGrab();
|
const grab = useControllerGrab();
|
||||||
const { scene } = useThree();
|
|
||||||
|
|
||||||
const single = useRef<SingleGrabRecord | null>(null);
|
const single = useRef<SingleGrabRecord | null>(null);
|
||||||
const dual = useRef<DualGrabRecord | null>(null);
|
const dual = useRef<DualGrabRecord | null>(null);
|
||||||
/** Parent the group belonged to before the active grab started. */
|
|
||||||
const originalParent = useRef<THREE.Object3D | null>(null);
|
|
||||||
const haloRef = useRef<THREE.Mesh>(null);
|
const haloRef = useRef<THREE.Mesh>(null);
|
||||||
const lockHaloRef = useRef<THREE.Mesh>(null);
|
|
||||||
const everGrabbed = useRef(false);
|
const everGrabbed = useRef(false);
|
||||||
|
|
||||||
const beginGrab = (mode: 'single' | 'dual', hand: 'left' | 'right' | 'dual') => {
|
|
||||||
const group = groupRef.current;
|
|
||||||
if (!group) return;
|
|
||||||
// Reparent to scene root preserving world-transform.
|
|
||||||
if (group.parent && group.parent !== scene) {
|
|
||||||
originalParent.current = group.parent;
|
|
||||||
scene.attach(group);
|
|
||||||
console.log(`[XR][grab] attach→scene (mode=${mode} hand=${hand})`);
|
|
||||||
} else if (!originalParent.current) {
|
|
||||||
originalParent.current = group.parent ?? scene;
|
|
||||||
}
|
|
||||||
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
|
||||||
};
|
|
||||||
|
|
||||||
const endGrab = () => {
|
|
||||||
const group = groupRef.current;
|
|
||||||
if (!group) return;
|
|
||||||
const locked = useModelStore.getState().isLocked;
|
|
||||||
if (locked) {
|
|
||||||
console.log('[XR][grab] release→scene (locked, staying anchored)');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = originalParent.current;
|
|
||||||
originalParent.current = null;
|
|
||||||
if (target && target !== group.parent) {
|
|
||||||
const name = target === scene ? 'scene' : (target.name || target.type);
|
|
||||||
target.attach(group);
|
|
||||||
console.log(`[XR][grab] release→${name}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useFrame(() => {
|
useFrame(() => {
|
||||||
const group = groupRef.current;
|
const group = groupRef.current;
|
||||||
if (!group) return;
|
if (!group) return;
|
||||||
@@ -103,20 +63,14 @@ export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGra
|
|||||||
|
|
||||||
const lActive = L.isGrabbing && L.hasPose;
|
const lActive = L.isGrabbing && L.hasPose;
|
||||||
const rActive = R.isGrabbing && R.hasPose;
|
const rActive = R.isGrabbing && R.hasPose;
|
||||||
const isLocked = useModelStore.getState().isLocked;
|
|
||||||
|
|
||||||
// ─── Halo intensity (visual feedback for analog grip) ────────
|
// ─── Halo intensity (visual feedback for analog grip) ────────
|
||||||
if (haloRef.current) {
|
if (haloRef.current) {
|
||||||
const maxGrip = isLocked ? 0 : Math.max(L.gripValue, R.gripValue);
|
const maxGrip = Math.max(L.gripValue, R.gripValue);
|
||||||
const mat = haloRef.current.material as THREE.MeshBasicMaterial;
|
const mat = haloRef.current.material as THREE.MeshBasicMaterial;
|
||||||
mat.opacity = maxGrip * 0.25;
|
mat.opacity = maxGrip * 0.25;
|
||||||
haloRef.current.visible = maxGrip > 0.05;
|
haloRef.current.visible = maxGrip > 0.05;
|
||||||
}
|
}
|
||||||
if (lockHaloRef.current) {
|
|
||||||
const mat = lockHaloRef.current.material as THREE.MeshBasicMaterial;
|
|
||||||
mat.opacity = 0.18;
|
|
||||||
lockHaloRef.current.visible = isLocked;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Two-hand mode ───────────────────────────────────────────
|
// ─── Two-hand mode ───────────────────────────────────────────
|
||||||
if (lActive && rActive) {
|
if (lActive && rActive) {
|
||||||
@@ -129,9 +83,7 @@ export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGra
|
|||||||
axisNow.divideScalar(distNow);
|
axisNow.divideScalar(distNow);
|
||||||
|
|
||||||
if (!dual.current) {
|
if (!dual.current) {
|
||||||
// Capture — reparent BEFORE snapshotting matrixWorld so subsequent
|
// Capture
|
||||||
// applyWorldMatrixToLocal math is consistent.
|
|
||||||
beginGrab('dual', 'dual');
|
|
||||||
group.updateMatrixWorld();
|
group.updateMatrixWorld();
|
||||||
dual.current = {
|
dual.current = {
|
||||||
startGroupWorld: group.matrixWorld.clone(),
|
startGroupWorld: group.matrixWorld.clone(),
|
||||||
@@ -141,6 +93,7 @@ export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGra
|
|||||||
startScale: group.scale.x,
|
startScale: group.scale.x,
|
||||||
};
|
};
|
||||||
single.current = null; // dual takes over
|
single.current = null; // dual takes over
|
||||||
|
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||||
console.log('[XR][grab] ◆ TWO-HAND start');
|
console.log('[XR][grab] ◆ TWO-HAND start');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +123,6 @@ export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGra
|
|||||||
} else if (dual.current) {
|
} else if (dual.current) {
|
||||||
console.log('[XR][grab] ◆ TWO-HAND end');
|
console.log('[XR][grab] ◆ TWO-HAND end');
|
||||||
dual.current = null;
|
dual.current = null;
|
||||||
if (!lActive && !rActive) endGrab();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── One-hand mode ───────────────────────────────────────────
|
// ─── One-hand mode ───────────────────────────────────────────
|
||||||
@@ -178,19 +130,18 @@ export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGra
|
|||||||
if (activeHand) {
|
if (activeHand) {
|
||||||
const slot = activeHand === 'left' ? L : R;
|
const slot = activeHand === 'left' ? L : R;
|
||||||
if (!single.current || single.current.hand !== activeHand) {
|
if (!single.current || single.current.hand !== activeHand) {
|
||||||
beginGrab('single', activeHand);
|
|
||||||
group.updateMatrixWorld();
|
group.updateMatrixWorld();
|
||||||
const offset = new THREE.Matrix4()
|
const offset = new THREE.Matrix4()
|
||||||
.copy(slot.gripWorld).invert()
|
.copy(slot.gripWorld).invert()
|
||||||
.multiply(group.matrixWorld);
|
.multiply(group.matrixWorld);
|
||||||
single.current = { hand: activeHand, offset };
|
single.current = { hand: activeHand, offset };
|
||||||
|
if (!everGrabbed.current) { everGrabbed.current = true; onGrabStart?.(); }
|
||||||
console.log(`[XR][grab] ● ONE-HAND start (${activeHand})`);
|
console.log(`[XR][grab] ● ONE-HAND start (${activeHand})`);
|
||||||
}
|
}
|
||||||
_tmpMat.multiplyMatrices(slot.gripWorld, single.current.offset);
|
_tmpMat.multiplyMatrices(slot.gripWorld, single.current.offset);
|
||||||
applyWorldMatrixToLocal(group, _tmpMat);
|
applyWorldMatrixToLocal(group, _tmpMat);
|
||||||
} else if (single.current) {
|
} else if (single.current) {
|
||||||
single.current = null;
|
single.current = null;
|
||||||
endGrab();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -201,11 +152,6 @@ export function XRGrabbable({ allowScale = false, onGrabStart, children }: XRGra
|
|||||||
<sphereGeometry args={[0.5, 16, 12]} />
|
<sphereGeometry args={[0.5, 16, 12]} />
|
||||||
<meshBasicMaterial color="#22c55e" transparent opacity={0} depthWrite={false} />
|
<meshBasicMaterial color="#22c55e" transparent opacity={0} depthWrite={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
{/* Lock halo — golden, visible while isLocked is true */}
|
|
||||||
<mesh ref={lockHaloRef} visible={false}>
|
|
||||||
<sphereGeometry args={[0.55, 16, 12]} />
|
|
||||||
<meshBasicMaterial color="#f59e0b" transparent opacity={0} depthWrite={false} wireframe />
|
|
||||||
</mesh>
|
|
||||||
{children}
|
{children}
|
||||||
</group>
|
</group>
|
||||||
);
|
);
|
||||||
|
|||||||
+25
-150
@@ -1,21 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* DEVKIT — HTML overlay panel with diagnostics + macro recorder + export.
|
* DEVKIT — HTML overlay panel with diagnostics.
|
||||||
*
|
*
|
||||||
* Tabs: Inputs · Cena · XR · Macro · Logs
|
* Tabs:
|
||||||
* Export: snapshot JSON + canvas screenshot, both downloaded.
|
* - Inputs: live grip/trigger/pose values for both controllers (real or fake)
|
||||||
|
* - Logs: captures last 100 console.log/warn/error entries with filter
|
||||||
|
*
|
||||||
|
* Always visible when ?devkit=1, top-right corner.
|
||||||
*/
|
*/
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { fakeInput, isFakeActive } from './fakeInputStore';
|
import { fakeInput, isFakeActive } from './fakeInputStore';
|
||||||
import { sceneStats } from './sceneStatsStore';
|
|
||||||
import {
|
|
||||||
macroState, subscribeMacro, startRecording, stopRecording,
|
|
||||||
startPlaying, stopPlaying, downloadMacro, loadMacroFromFile,
|
|
||||||
} from './macroStore';
|
|
||||||
|
|
||||||
interface LogEntry { ts: number; level: 'log' | 'warn' | 'error'; msg: string; }
|
interface LogEntry {
|
||||||
|
ts: number;
|
||||||
|
level: 'log' | 'warn' | 'error';
|
||||||
|
msg: string;
|
||||||
|
}
|
||||||
|
|
||||||
const logBuffer: LogEntry[] = [];
|
const logBuffer: LogEntry[] = [];
|
||||||
let installed = false;
|
let installed = false;
|
||||||
|
|
||||||
function installLogger() {
|
function installLogger() {
|
||||||
if (installed) return;
|
if (installed) return;
|
||||||
installed = true;
|
installed = true;
|
||||||
@@ -35,7 +38,7 @@ function installLogger() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'inputs' | 'cena' | 'xr' | 'macro' | 'logs';
|
type Tab = 'inputs' | 'logs';
|
||||||
|
|
||||||
interface DevPanelProps {
|
interface DevPanelProps {
|
||||||
simXR: boolean;
|
simXR: boolean;
|
||||||
@@ -47,14 +50,9 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) {
|
|||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [, tick] = useState(0);
|
const [, tick] = useState(0);
|
||||||
const [logFilter, setLogFilter] = useState('');
|
const [logFilter, setLogFilter] = useState('');
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
|
||||||
const rafRef = useRef<number | null>(null);
|
const rafRef = useRef<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => { installLogger(); }, []);
|
useEffect(() => { installLogger(); }, []);
|
||||||
useEffect(() => {
|
|
||||||
const unsub = subscribeMacro(() => tick((n) => n + 1));
|
|
||||||
return () => { unsub; };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let last = 0;
|
let last = 0;
|
||||||
@@ -66,33 +64,6 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) {
|
|||||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const exportReport = async () => {
|
|
||||||
const canvas = document.querySelector('canvas');
|
|
||||||
const screenshot = canvas ? canvas.toDataURL('image/png') : null;
|
|
||||||
const report = {
|
|
||||||
ts: new Date().toISOString(),
|
|
||||||
url: window.location.href,
|
|
||||||
userAgent: navigator.userAgent,
|
|
||||||
simXR,
|
|
||||||
fakeActive: isFakeActive(),
|
|
||||||
stats: { ...sceneStats },
|
|
||||||
inputs: {
|
|
||||||
left: { grip: fakeInput.left.gripValue, trigger: fakeInput.left.triggerValue },
|
|
||||||
right: { grip: fakeInput.right.gripValue, trigger: fakeInput.right.triggerValue },
|
|
||||||
},
|
|
||||||
logs: logBuffer.slice(-100),
|
|
||||||
};
|
|
||||||
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url; a.download = `devkit-report-${Date.now()}.json`; a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
if (screenshot) {
|
|
||||||
const a2 = document.createElement('a');
|
|
||||||
a2.href = screenshot; a2.download = `devkit-screenshot-${Date.now()}.png`; a2.click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (collapsed) {
|
if (collapsed) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -105,17 +76,11 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed top-20 right-2 z-50 w-80 rounded-lg border border-green-500/40 bg-black/90 font-mono text-[10px] text-green-300 shadow-2xl backdrop-blur">
|
<div className="fixed top-20 right-2 z-50 w-72 rounded-lg border border-green-500/40 bg-black/90 font-mono text-[10px] text-green-300 shadow-2xl backdrop-blur">
|
||||||
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between border-b border-green-500/30 px-2 py-1.5">
|
<div className="flex items-center justify-between border-b border-green-500/30 px-2 py-1.5">
|
||||||
<span className="font-bold text-green-400">🧪 DEVKIT</span>
|
<span className="font-bold text-green-400">🧪 DEVKIT</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
|
||||||
onClick={exportReport}
|
|
||||||
className="rounded border border-green-500/40 px-1.5 py-0.5 text-[9px] text-green-300 hover:bg-green-500/20"
|
|
||||||
title="Baixa JSON + screenshot"
|
|
||||||
>
|
|
||||||
⬇ Relatório
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
onClick={onToggleSimXR}
|
onClick={onToggleSimXR}
|
||||||
className={`rounded px-1.5 py-0.5 text-[9px] ${simXR ? 'bg-green-500 text-black' : 'border border-green-500/40 text-green-300 hover:bg-green-500/20'}`}
|
className={`rounded px-1.5 py-0.5 text-[9px] ${simXR ? 'bg-green-500 text-black' : 'border border-green-500/40 text-green-300 hover:bg-green-500/20'}`}
|
||||||
@@ -126,22 +91,26 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
<div className="flex border-b border-green-500/20">
|
<div className="flex border-b border-green-500/20">
|
||||||
{(['inputs', 'cena', 'xr', 'macro', 'logs'] as Tab[]).map((t) => (
|
{(['inputs', 'logs'] as Tab[]).map((t) => (
|
||||||
<button
|
<button
|
||||||
key={t}
|
key={t}
|
||||||
onClick={() => setTab(t)}
|
onClick={() => setTab(t)}
|
||||||
className={`flex-1 px-1 py-1 text-[10px] uppercase ${tab === t ? 'bg-green-500/20 text-green-200' : 'text-green-500/70 hover:text-green-300'}`}
|
className={`flex-1 px-2 py-1 text-[10px] uppercase ${tab === t ? 'bg-green-500/20 text-green-200' : 'text-green-500/70 hover:text-green-300'}`}
|
||||||
>
|
>
|
||||||
{t}
|
{t}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-[60vh] overflow-y-auto p-2">
|
{/* Content */}
|
||||||
|
<div className="max-h-[55vh] overflow-y-auto p-2">
|
||||||
{tab === 'inputs' && (
|
{tab === 'inputs' && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-green-500/70">fake: {isFakeActive() ? 'ATIVO' : 'inativo'}</div>
|
<div className="text-green-500/70">
|
||||||
|
fake: {isFakeActive() ? 'ATIVO' : 'inativo'}
|
||||||
|
</div>
|
||||||
{(['left', 'right'] as const).map((h) => {
|
{(['left', 'right'] as const).map((h) => {
|
||||||
const s = fakeInput[h];
|
const s = fakeInput[h];
|
||||||
const grabbing = s.gripValue > 0.7;
|
const grabbing = s.gripValue > 0.7;
|
||||||
@@ -161,95 +130,9 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) {
|
|||||||
<div>Q/W = grip L/R · A/S = trigger L/R</div>
|
<div>Q/W = grip L/R · A/S = trigger L/R</div>
|
||||||
<div>1/2 = ativar mão · setas = mover</div>
|
<div>1/2 = ativar mão · setas = mover</div>
|
||||||
<div>Shift+↑/↓ = Z (frente/trás) · R = reset</div>
|
<div>Shift+↑/↓ = Z (frente/trás) · R = reset</div>
|
||||||
<div>L = fixar/liberar peça (lock)</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 'cena' && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Row k="meshes" v={String(sceneStats.meshCount)} />
|
|
||||||
<Row k="triângulos" v={sceneStats.triCount.toLocaleString()} />
|
|
||||||
<Row k="bbox (m)" v={sceneStats.modelBboxSize.map(n => n.toFixed(3)).join(' × ')} />
|
|
||||||
<Row k="pos (m)" v={sceneStats.modelGroupPos.map(n => n.toFixed(3)).join(', ')} />
|
|
||||||
<Row k="rot (°)" v={sceneStats.modelGroupRotDeg.map(n => n.toFixed(1)).join(', ')} />
|
|
||||||
<Row k="escala" v={sceneStats.modelGroupScale.toFixed(3)} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'xr' && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Row k="modo" v={sceneStats.xrMode ?? '—'} />
|
|
||||||
<Row k="FPS" v={String(sceneStats.fps)} />
|
|
||||||
<Row k="inputs" v={String(sceneStats.xrInputCount)} />
|
|
||||||
<Row k="planos" v={String(sceneStats.xrPlanesCount)} />
|
|
||||||
<Row k="hit-test" v={sceneStats.xrHitTestActive ? 'sim' : 'não'} />
|
|
||||||
<Row k="navigator.xr" v={typeof navigator !== 'undefined' && 'xr' in navigator ? 'ok' : 'ausente'} />
|
|
||||||
<Row k="HTTPS" v={location.protocol === 'https:' ? 'sim' : 'NÃO'} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'macro' && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="text-green-500/70">fase: <span className="text-green-300">{macroState.phase}</span></div>
|
|
||||||
<div className="flex gap-1 flex-wrap">
|
|
||||||
{macroState.phase !== 'recording' ? (
|
|
||||||
<button onClick={startRecording} className="rounded border border-red-500/50 bg-red-500/10 px-2 py-0.5 text-red-300 hover:bg-red-500/20">● Gravar</button>
|
|
||||||
) : (
|
|
||||||
<button onClick={stopRecording} className="rounded border border-yellow-500/50 bg-yellow-500/10 px-2 py-0.5 text-yellow-300">■ Parar</button>
|
|
||||||
)}
|
|
||||||
{macroState.phase !== 'playing' ? (
|
|
||||||
<button
|
|
||||||
onClick={() => startPlaying()}
|
|
||||||
disabled={!macroState.current}
|
|
||||||
className="rounded border border-green-500/50 bg-green-500/10 px-2 py-0.5 text-green-300 hover:bg-green-500/20 disabled:opacity-40"
|
|
||||||
>▶ Play</button>
|
|
||||||
) : (
|
|
||||||
<button onClick={stopPlaying} className="rounded border border-yellow-500/50 bg-yellow-500/10 px-2 py-0.5 text-yellow-300">⏹ Stop</button>
|
|
||||||
)}
|
|
||||||
<button
|
|
||||||
onClick={() => macroState.current && downloadMacro(macroState.current)}
|
|
||||||
disabled={!macroState.current}
|
|
||||||
className="rounded border border-green-500/40 px-2 py-0.5 hover:bg-green-500/20 disabled:opacity-40"
|
|
||||||
>⬇ Salvar</button>
|
|
||||||
<button
|
|
||||||
onClick={() => fileRef.current?.click()}
|
|
||||||
className="rounded border border-green-500/40 px-2 py-0.5 hover:bg-green-500/20"
|
|
||||||
>⬆ Carregar</button>
|
|
||||||
<input
|
|
||||||
ref={fileRef}
|
|
||||||
type="file"
|
|
||||||
accept="application/json"
|
|
||||||
className="hidden"
|
|
||||||
onChange={async (e) => {
|
|
||||||
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 = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{macroState.current && (
|
|
||||||
<div className="rounded border border-green-500/20 p-1.5">
|
|
||||||
<div className="font-bold text-green-300">{macroState.current.name}</div>
|
|
||||||
<div>frames: {macroState.current.frames.length}</div>
|
|
||||||
<div>duração: {(macroState.current.duration / 1000).toFixed(1)}s</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{macroState.phase === 'recording' && (
|
|
||||||
<div className="text-yellow-300">gravando… {macroState.rec.length} frames</div>
|
|
||||||
)}
|
|
||||||
<div className="text-green-500/60 leading-tight border-t border-green-500/20 pt-1">
|
|
||||||
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.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{tab === 'logs' && (
|
{tab === 'logs' && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<input
|
<input
|
||||||
@@ -261,7 +144,8 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) {
|
|||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
{logBuffer
|
{logBuffer
|
||||||
.filter((l) => !logFilter || l.msg.toLowerCase().includes(logFilter.toLowerCase()))
|
.filter((l) => !logFilter || l.msg.toLowerCase().includes(logFilter.toLowerCase()))
|
||||||
.slice(-80).reverse()
|
.slice(-80)
|
||||||
|
.reverse()
|
||||||
.map((l, i) => (
|
.map((l, i) => (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
@@ -280,12 +164,3 @@ export function DevPanel({ simXR, onToggleSimXR }: DevPanelProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Row({ k, v }: { k: string; v: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex justify-between gap-2 border-b border-green-500/10 py-0.5">
|
|
||||||
<span className="text-green-500/70">{k}</span>
|
|
||||||
<span className="text-green-200 break-all text-right">{v}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ import { useEffect, useRef, useState } from 'react';
|
|||||||
import { useFrame } from '@react-three/fiber';
|
import { useFrame } from '@react-three/fiber';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { fakeInput, setFakeActive } from './fakeInputStore';
|
import { fakeInput, setFakeActive } from './fakeInputStore';
|
||||||
import { isPlaybackActive } from './macroStore';
|
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
|
||||||
|
|
||||||
const STEP = 0.03; // m per keypress
|
const STEP = 0.03; // m per keypress
|
||||||
|
|
||||||
@@ -37,7 +35,6 @@ export function FakeControllers() {
|
|||||||
const keys = new Set<string>();
|
const keys = new Set<string>();
|
||||||
|
|
||||||
const apply = () => {
|
const apply = () => {
|
||||||
if (isPlaybackActive()) return; // macro playback owns inputs
|
|
||||||
const pos = new THREE.Vector3();
|
const pos = new THREE.Vector3();
|
||||||
const quat = new THREE.Quaternion();
|
const quat = new THREE.Quaternion();
|
||||||
const scl = new THREE.Vector3();
|
const scl = new THREE.Vector3();
|
||||||
@@ -72,12 +69,6 @@ export function FakeControllers() {
|
|||||||
fakeInput.right.gripWorld.makeTranslation(0.25, 1.2, -0.5);
|
fakeInput.right.gripWorld.makeTranslation(0.25, 1.2, -0.5);
|
||||||
force((n) => n + 1);
|
force((n) => n + 1);
|
||||||
return;
|
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();
|
apply();
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
/**
|
|
||||||
* DEVKIT — Drives macro recording/playback inside the R3F frame loop.
|
|
||||||
* Mounted inside <Canvas>. No visual output.
|
|
||||||
*/
|
|
||||||
import { useFrame } from '@react-three/fiber';
|
|
||||||
import { tickMacro } from './macroStore';
|
|
||||||
|
|
||||||
export function MacroDriver() {
|
|
||||||
useFrame(() => { tickMacro(); });
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
/**
|
|
||||||
* DEVKIT — samples scene + XR session every frame, writes to sceneStatsStore.
|
|
||||||
* Mounted inside <Canvas>. 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;
|
|
||||||
}
|
|
||||||
@@ -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<string> };
|
|
||||||
|
|
||||||
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<StepResult[]>([]);
|
|
||||||
const [running, setRunning] = useState(false);
|
|
||||||
|
|
||||||
if (!devkit) return <Navigate to="/" replace />;
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className="min-h-screen bg-background p-6 font-mono text-foreground">
|
|
||||||
<div className="mx-auto max-w-2xl space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h1 className="text-xl font-bold">🧪 DevKit · Smoke Test</h1>
|
|
||||||
<Link to="/" className="text-xs text-primary hover:underline">← Home</Link>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Roteiro automático que exercita store, demo, medição, checklist, fake input e gravador.
|
|
||||||
Execute antes de enviar build para o testador com Quest.
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={run}
|
|
||||||
disabled={running}
|
|
||||||
className="rounded bg-primary px-4 py-2 text-sm text-primary-foreground hover:opacity-90 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{running ? 'Executando…' : '▶ Rodar suite'}
|
|
||||||
</button>
|
|
||||||
{results.length > 0 && (
|
|
||||||
<span className="text-sm">
|
|
||||||
<span className="text-green-400">✓ {passed}</span>
|
|
||||||
{' · '}
|
|
||||||
<span className={failed ? 'text-red-400' : 'text-muted-foreground'}>✕ {failed}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1 rounded border border-border bg-card p-3">
|
|
||||||
{results.length === 0 && (
|
|
||||||
<div className="text-xs text-muted-foreground italic">aguardando execução…</div>
|
|
||||||
)}
|
|
||||||
{results.map((r, i) => (
|
|
||||||
<div
|
|
||||||
key={i}
|
|
||||||
className={`flex items-start justify-between gap-3 border-b border-border/50 py-1 text-xs ${
|
|
||||||
r.ok ? 'text-green-400' : 'text-red-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<div className="flex-1">
|
|
||||||
<span className="mr-2">{r.ok ? '✓' : '✕'}</span>
|
|
||||||
{r.name}
|
|
||||||
<div className="ml-5 text-muted-foreground">{r.detail}</div>
|
|
||||||
</div>
|
|
||||||
<span className="text-muted-foreground">{r.ms.toFixed(0)}ms</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<THREE.Group>(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 (
|
|
||||||
<group ref={groupRef}>
|
|
||||||
<mesh position={[0.07, 0, 0]}>
|
|
||||||
<planeGeometry args={[0.18, 0.10]} />
|
|
||||||
<meshBasicMaterial color="#000000" transparent opacity={0.65} depthWrite={false} />
|
|
||||||
</mesh>
|
|
||||||
<Text
|
|
||||||
ref={textRef as unknown as React.Ref<THREE.Mesh>}
|
|
||||||
fontSize={0.0075}
|
|
||||||
color="#22c55e"
|
|
||||||
anchorX="left"
|
|
||||||
anchorY="top"
|
|
||||||
position={[-0.018, 0.045, 0.001]}
|
|
||||||
maxWidth={0.17}
|
|
||||||
lineHeight={1.3}
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
</Text>
|
|
||||||
</group>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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<Macro> {
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useFrame, useThree } from '@react-three/fiber';
|
import { useFrame, useThree } from '@react-three/fiber';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
|
||||||
// DEVKIT: remove this import + the fake-input block in useFrame to strip devkit
|
// DEVKIT: remove this import + the fake-input block in useFrame to strip devkit
|
||||||
import { fakeInput, isFakeActive } from '@/devkit/fakeInputStore';
|
import { fakeInput, isFakeActive } from '@/devkit/fakeInputStore';
|
||||||
|
|
||||||
@@ -53,8 +52,6 @@ export function useControllerGrab() {
|
|||||||
const { gl } = useThree();
|
const { gl } = useThree();
|
||||||
|
|
||||||
useFrame((_state, _delta, frame: XRFrame | undefined) => {
|
useFrame((_state, _delta, frame: XRFrame | undefined) => {
|
||||||
const locked = useModelStore.getState().isLocked;
|
|
||||||
|
|
||||||
// DEVKIT: fake-input branch — bypasses real WebXR when SimXR is active.
|
// DEVKIT: fake-input branch — bypasses real WebXR when SimXR is active.
|
||||||
if (isFakeActive()) {
|
if (isFakeActive()) {
|
||||||
for (const hand of ['left', 'right'] as const) {
|
for (const hand of ['left', 'right'] as const) {
|
||||||
@@ -64,13 +61,6 @@ export function useControllerGrab() {
|
|||||||
slot.triggerValue = fake.triggerValue;
|
slot.triggerValue = fake.triggerValue;
|
||||||
slot.hasPose = fake.hasPose;
|
slot.hasPose = fake.hasPose;
|
||||||
slot.gripWorld.copy(fake.gripWorld);
|
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) {
|
if (!slot.isGrabbing && fake.gripValue > GRAB_ON) {
|
||||||
slot.isGrabbing = true;
|
slot.isGrabbing = true;
|
||||||
console.log(`[XR][grab] ▶ ${hand} START (fake v=${fake.gripValue.toFixed(2)})`);
|
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;
|
const trigValue = trigBtn ? (trigBtn.value || (trigBtn.pressed ? 1 : 0)) : 0;
|
||||||
slot.gripValue = gripValue;
|
slot.gripValue = gripValue;
|
||||||
slot.triggerValue = trigValue;
|
slot.triggerValue = trigValue;
|
||||||
if (locked) {
|
// Hysteresis
|
||||||
if (slot.isGrabbing) {
|
if (!slot.isGrabbing && gripValue > GRAB_ON) {
|
||||||
slot.isGrabbing = false;
|
|
||||||
console.log(`[XR][grab] ◼ ${source.handedness} END (locked)`);
|
|
||||||
}
|
|
||||||
} else if (!slot.isGrabbing && gripValue > GRAB_ON) {
|
|
||||||
slot.isGrabbing = true;
|
slot.isGrabbing = true;
|
||||||
console.log(`[XR][grab] ▶ ${source.handedness} START (v=${gripValue.toFixed(2)})`);
|
console.log(`[XR][grab] ▶ ${source.handedness} START (v=${gripValue.toFixed(2)})`);
|
||||||
} else if (slot.isGrabbing && gripValue < GRAB_OFF) {
|
} else if (slot.isGrabbing && gripValue < GRAB_OFF) {
|
||||||
|
|||||||
+1
-1
@@ -211,7 +211,7 @@ const Index = () => {
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<p className="mt-16 text-center font-mono text-xs text-muted-foreground/50">
|
<p className="mt-16 text-center font-mono text-xs text-muted-foreground/50">
|
||||||
TrackSteelXR v1.1 — Q.C. Inspection
|
TrackSteelXR v1.0 — Q.C. Inspection
|
||||||
</p>
|
</p>
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,6 @@ import { XRGrabbable } from '@/components/three/XRGrabbable';
|
|||||||
import { useDevKit } from '@/devkit/useDevKit';
|
import { useDevKit } from '@/devkit/useDevKit';
|
||||||
import { DevPanel } from '@/devkit/DevPanel';
|
import { DevPanel } from '@/devkit/DevPanel';
|
||||||
import { FakeControllers } from '@/devkit/FakeControllers';
|
import { FakeControllers } from '@/devkit/FakeControllers';
|
||||||
import { SceneStatsProbe } from '@/devkit/SceneStatsProbe';
|
|
||||||
import { MacroDriver } from '@/devkit/MacroDriver';
|
|
||||||
import { XRDevHud } from '@/devkit/XRDevHud';
|
|
||||||
|
|
||||||
// --- Diagnóstico XR ---
|
// --- Diagnóstico XR ---
|
||||||
console.log('[XR] Inicializando store...');
|
console.log('[XR] Inicializando store...');
|
||||||
@@ -124,7 +121,6 @@ function XRModel({ url }: { url: string }) {
|
|||||||
return (
|
return (
|
||||||
<group
|
<group
|
||||||
ref={ref}
|
ref={ref}
|
||||||
userData={{ __xrModelRoot: true }}
|
|
||||||
position={[
|
position={[
|
||||||
-modelInfo.center.x + fineTuning.posX,
|
-modelInfo.center.x + fineTuning.posX,
|
||||||
-modelInfo.center.y + fineTuning.posY,
|
-modelInfo.center.y + fineTuning.posY,
|
||||||
@@ -535,7 +531,7 @@ const XRSession = () => {
|
|||||||
|
|
||||||
<XRMeasurementOverlay />
|
<XRMeasurementOverlay />
|
||||||
<ControllerFineTuning freeMove={freeMove} />
|
<ControllerFineTuning freeMove={freeMove} />
|
||||||
{devkit ? <XRDevHud /> : <XRDebugHud />}
|
<XRDebugHud />
|
||||||
{/* DEVKIT: fake controllers visible in scene + keyboard driver */}
|
{/* DEVKIT: fake controllers visible in scene + keyboard driver */}
|
||||||
{devkit && simXR && <FakeControllers />}
|
{devkit && simXR && <FakeControllers />}
|
||||||
{/* DEVKIT: orbit camera in SimXR so you can navigate around the model */}
|
{/* DEVKIT: orbit camera in SimXR so you can navigate around the model */}
|
||||||
@@ -570,9 +566,6 @@ const XRSession = () => {
|
|||||||
|
|
||||||
<XRSnapHandler />
|
<XRSnapHandler />
|
||||||
<XRGrid />
|
<XRGrid />
|
||||||
{/* DEVKIT: scene/XR stats probe + macro recorder driver */}
|
|
||||||
{devkit && <SceneStatsProbe />}
|
|
||||||
{devkit && <MacroDriver />}
|
|
||||||
</XR>
|
</XR>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
|
|
||||||
|
|||||||
@@ -118,10 +118,6 @@ interface ModelStore {
|
|||||||
addScreenshot: (dataUrl: string) => void;
|
addScreenshot: (dataUrl: string) => void;
|
||||||
removeScreenshot: (index: number) => void;
|
removeScreenshot: (index: number) => void;
|
||||||
clearScreenshots: () => 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 };
|
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<ModelStore>((set) => ({
|
|||||||
screenshots: state.screenshots.filter((_, i) => i !== index),
|
screenshots: state.screenshots.filter((_, i) => i !== index),
|
||||||
})),
|
})),
|
||||||
clearScreenshots: () => set({ screenshots: [] }),
|
clearScreenshots: () => set({ screenshots: [] }),
|
||||||
|
|
||||||
isLocked: false,
|
|
||||||
setLocked: (isLocked) => set({ isLocked }),
|
|
||||||
}));
|
}));
|
||||||
|
|||||||
Reference in New Issue
Block a user