diff --git a/.lovable/plan.md b/.lovable/plan.md index 649cb92..a5ca2e4 100644 --- a/.lovable/plan.md +++ b/.lovable/plan.md @@ -1,62 +1,115 @@ -## Resposta curta +# Menus dentro do passthrough + Compartilhamento que funciona em AR -Sim, é totalmente possível — hoje o app só carrega 1 modelo por vez (`model: ModelInfo | null` no store), mas a arquitetura (Three.js + R3F) suporta naturalmente múltiplas peças em uma mesma cena. Precisa migrar de "modelo único" para "lista de modelos", cada um com seu próprio transform (posição/rotação/escala) e seleção ativa. +Dois problemas distintos, duas soluções: -## O que muda +--- -### 1. Store (`useModelStore.ts`) -- Substituir `model: ModelInfo | null` por `models: SceneModel[]` (até 5 simultâneos para manter 72 FPS no Quest 3). -- Cada `SceneModel` tem: `id`, `fileName`, `url`, `fineTuning` próprio, `visible`, `color` (cor de identificação), `locked`. -- Adicionar `activeModelId: string | null` — o "modelo selecionado" recebe os controles de fine-tuning, escala e medição. -- Ações: `addModel`, `removeModel`, `duplicateModel`, `setActiveModel`, `updateModelTransform`, `toggleVisibility`. -- Compatibilidade: `model` (singular) continua exposto como getter derivado do `activeModelId` para não quebrar telas legadas. +## Problema 1 — Menus DOM somem no passthrough -### 2. Cena 3D (`ModelViewer.tsx`) -- Renderizar `models.map(m => )`. -- Cada `` aplica seu próprio transform (já existe a lógica, só vira por-instância). -- Outline/halo sutil no modelo ativo para feedback visual. -- Click em um modelo → torna-o ativo (raycaster identifica qual instância foi clicada). +**Causa:** `XRHud` é HTML/CSS sobreposto ao ``. Quando o Quest entra em `immersive-ar`, o headset para de renderizar o DOM da página — só renderiza o conteúdo WebGL do ``. Por isso lista de peças, botões, slider de opacidade etc. desaparecem dentro do óculos. -### 3. UI Desktop (`Viewer.tsx`) -- Novo painel **"Cena"** lateral listando os modelos carregados: - - Nome do arquivo, cor, ícones de visibilidade (olho), trava (cadeado), duplicar, remover. - - Item destacado = ativo. Click seleciona. -- Botão **"+ Adicionar modelo"** abre o mesmo fluxo de upload/cloud já existente, mas em modo "append" em vez de "replace". -- `FineTuningControls`, `ScaleSelector` passam a operar sobre o modelo ativo. +**Solução:** criar um HUD 3D nativo (Three.js) que vive **dentro** da cena XR, ancorado ao controlador esquerdo (estilo "smartwatch de pulso") e/ou em frente ao usuário. Toda interação acontece via raycaster do controlador direito (gatilho = clique). -### 4. UI XR/AR (`XRHud.tsx`) -- Painel flutuante com a lista de peças (mesmos botões compactos). -- Botão "Adicionar" abre o `CloudLoader` já existente em modo append. -- Gestos do controle: o grab (já implementado em `XRGrabbable`) move só o modelo ativo / o que foi pinçado diretamente. +### Componentes a criar -### 5. Funcionalidades dependentes -- **Medições**: passam a ter `modelId` opcional (de qual peça partiu o ponto). Continuam globais na cena — útil para medir GAP entre peças. -- **Checklist**: por enquanto continua global (uma inspeção por sessão). Pode-se evoluir depois para checklist por peça. -- **Screenshot/PDF**: capturam a cena inteira (todas as peças visíveis), sem mudança. -- **Compare mode**: opera sobre a cena toda, sem mudança. +**`src/components/three/XRPanel3D.tsx`** — painel 3D reutilizável: +- Quad com `Text` (drei) e fundo translúcido escuro +- Suporta hover/click via `onPointerOver` / `onClick` do react-three-fiber (já funciona com controllers XR) +- Variantes: `button`, `toggle`, `slider`, `list-item` -## Limitações conhecidas +**`src/components/three/XRWristMenu.tsx`** — menu radial preso ao controlador esquerdo: +- Usa `useXRInputSourceState('controller', 'left')` para obter a pose +- Renderiza painel ~15cm acima do controle quando o usuário gira o pulso para cima (detecta orientação) +- Conteúdo: ícones grandes para cada categoria (Cena, Ferramentas, Inspeção, Compartilhar, Ajuda) -- **Performance**: GLB grandes somados podem derrubar FPS no Quest 3. Vou impor limite de **5 modelos simultâneos** com aviso. -- **Conversão IFC**: cada arquivo passa pelo conversor cliente (`convertIFC.ts`) já existente — sem mudança. -- **Colisão física**: as peças podem se sobrepor visualmente (não há detecção de colisão). Para "empilhar" o usuário posiciona manualmente via grab/fine-tuning. Snap entre peças seria uma evolução futura. -- **Anotações em tela**: ficam vinculadas a coordenadas de mundo, não ao modelo — se você mover o modelo depois, a anotação não acompanha (limitação aceitável v1). +**`src/components/three/XRToolPanel.tsx`** — painel grande flutuante (40×30cm) que abre quando o usuário escolhe uma categoria no wrist menu: +- Posicionado a ~70cm em frente ao usuário, segue o head (com inércia leve) +- Botão "fechar" (X) no canto +- Conteúdo varia por aba aberta: + - **Cena**: lista de modelos carregados (selecionar ativo, visibilidade, lock, duplicar, remover) + botão "+ Adicionar" que dispara o picker de arquivo (file input fica oculto, ativado via `` com `download` ou via mensagem para sair do XR e abrir loader) + - **Ferramentas**: opacidade (slider 3D), modo render (Sólido/Wire/Bordas), grid on/off, medir on/off, snap on/off, escala on/off + - **Posição/Rotação**: 6 pares de botões −/+ (já existem como joystick, mas duplicar no painel ajuda quem prefere clicar) + - **Inspeção**: lista de itens do checklist, cada um com ✓/✗ + - **Compartilhar**: estado da live (ON/OFF), código da sala, contagem de viewers, botão encerrar -## Fora do escopo desta v1 +### Carregar arquivo dentro do XR +Carregar arquivos do disco local exige fileinput DOM, que não existe no passthrough. Duas alternativas: +1. **Cloud only no XR**: o painel "Cena → Adicionar" lista os modelos do Supabase Storage / cache local que já foram pré-carregados antes de entrar no AR. Para arquivo novo, mostra mensagem "saia do AR para fazer upload". +2. **Pré-carregar tudo antes**: usuário carrega todos os IFCs no Viewer 2D, marca quais quer levar, e dentro do AR só alterna entre eles. -- Snap automático/encaixe entre peças (próxima iteração) -- Detecção de colisão / física -- Agrupar peças (parent/child) -- Checklist independente por peça -- Persistência da cena multi-modelo (salvar/carregar layout) +Vou implementar (1) — mais flexível. -## Arquivos afetados +### Edits em `XRSession.tsx` +Dentro do bloco `effectiveInXR`, montar `` e ``. O `` DOM atual continua existindo para uso fora do AR (desktop preview e antes de entrar em sessão). -- `src/stores/useModelStore.ts` — refactor central -- `src/components/three/ModelViewer.tsx` — render por instância -- `src/pages/Viewer.tsx` — painel de cena + botão "Adicionar" -- `src/components/XRHud.tsx` — lista de peças no AR -- `src/components/FineTuningControls.tsx` — opera no ativo -- `src/components/ScaleSelector.tsx` — opera no ativo -- `src/components/three/XRGrabbable.tsx` — grab por instância -- novo `src/components/SceneModelList.tsx` — componente reutilizável da lista +### Edits em `XRHud.tsx` +Sem mudanças funcionais — só passa a ser ocultado via CSS quando `inXR === true` (já é, por causa do passthrough, mas garantir `display:none` evita confusão). + +--- + +## Problema 2 — Viewers veem tela preta no passthrough + +**Causa:** `canvas.captureStream()` captura o `` WebGL com `alpha: true` e `clearColor (0,0,0,0)` — ou seja, **transparente**. No headset, o compositor do sistema operacional do Quest mistura esse canvas transparente com o feed da câmera (passthrough). Mas o canvas em si, quando capturado e enviado via WebRTC, é só os modelos 3D sobre fundo transparente — viewers veem preto/transparente. + +**Pior ainda:** durante uma sessão `immersive-ar`, a cada frame WebXR o WebGL renderiza diretamente para a XR layer; o `` da página pode ficar congelado no último frame de fora do XR (o "captureStream" para de ter conteúdo novo). + +**Solução:** criar um **segundo renderer offscreen opaco** que reproduz a cena XR a cada frame, e capturar o stream desse canvas. + +### Componente: `src/components/three/XRBroadcastMirror.tsx` + +```text +useFrame((state) => { + if (!broadcasting) return; + // 1. Pega câmera XR atual (state.gl.xr.getCamera()) + // 2. Renderiza a mesma scene num WebGLRenderer offscreen + // com alpha:false, clearColor preto/cinza + // 3. O canvas do mirror tem captureStream() ativo +}); +``` + +Detalhes: +- Cria `THREE.WebGLRenderer({ canvas: offscreenCanvas, alpha: false, antialias: true })` +- Tamanho fixo: 1280×720 (boa relação qualidade/banda para WebRTC) +- Usa a câmera XR ativa (`state.gl.xr.getCamera()`) — que é a câmera estéreo combinada do Quest; pegamos a sub-câmera esquerda para gerar mono feed +- Renderiza a mesma `state.scene` +- Opcionalmente, sobrepõe um plano com cor de fundo (cinza escuro #1a1a1a) representando o que seria o passthrough — viewers entendem que é o ponto de vista do operador, sem ver a câmera real (privacidade do ambiente) + +### Mudanças em `ShareButton.tsx` / `broadcaster.ts` +Em vez de capturar `document.querySelector('canvas')` direto, usar: +1. Se não estiver em XR → comportamento atual (canvas principal). +2. Se estiver em XR → o `XRBroadcastMirror` registra seu canvas num contexto/ref global; `ShareButton.start()` lê esse canvas e chama `captureStream()` nele. + +Adicionar `src/lib/webrtc/broadcastSource.ts`: +```text +let activeCanvas: HTMLCanvasElement | null = null; +export function setBroadcastSource(c: HTMLCanvasElement | null) { activeCanvas = c; } +export function getBroadcastSource() { return activeCanvas; } +``` + +`XRBroadcastMirror` chama `setBroadcastSource` no mount/unmount. + +### Sobre câmera real do Quest +Acessar o feed real da câmera passthrough **não é possível via WebXR** (Meta bloqueia por privacidade — só apps nativos com WebView de sistema têm acesso). Então o melhor que conseguimos mostrar para os viewers é a **cena 3D do ponto de vista do operador, em fundo opaco**. Isso já é útil: o supervisor vê a peça, os ajustes, as medições, o checklist sendo marcado. + +--- + +## Limitações e fora de escopo + +- Sem captura real da câmera passthrough (limite WebXR/Meta). +- Mirror renderiza só olho esquerdo (mono) — estéreo dobraria custo de GPU e banda. +- O FPS do mirror pode cair se o headset estiver no limite — adicionar throttle (`captureStream(20)` em vez de 30). +- Wrist menu funciona com controllers; mãos (hand tracking) ficam para v2. + +## Arquivos tocados + +**Criar:** +- `src/components/three/XRPanel3D.tsx` +- `src/components/three/XRWristMenu.tsx` +- `src/components/three/XRToolPanel.tsx` +- `src/components/three/XRBroadcastMirror.tsx` +- `src/lib/webrtc/broadcastSource.ts` + +**Editar:** +- `src/pages/XRSession.tsx` — montar wrist menu, tool panel e broadcast mirror dentro do `` +- `src/components/ShareButton.tsx` — preferir `getBroadcastSource()` quando disponível +- `src/components/XRHud.tsx` — esconder via CSS quando `inXR` diff --git a/src/components/ShareButton.tsx b/src/components/ShareButton.tsx index 1e13caa..5a5d648 100644 --- a/src/components/ShareButton.tsx +++ b/src/components/ShareButton.tsx @@ -4,13 +4,18 @@ import { QRCodeSVG } from 'qrcode.react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { startBroadcast, type BroadcasterHandle } from '@/lib/webrtc/broadcaster'; +import { getBroadcastSource } from '@/lib/webrtc/broadcastSource'; import { toast } from 'sonner'; interface ShareButtonProps { variant?: 'default' | 'compact'; + /** Opens the dialog automatically when toggled. Used by the in-XR HUD. */ + autoOpen?: boolean; + onHandleChange?: (h: BroadcasterHandle | null) => void; + onViewerCountChange?: (n: number) => void; } -export function ShareButton({ variant = 'default' }: ShareButtonProps) { +export function ShareButton({ variant = 'default', autoOpen, onHandleChange, onViewerCountChange }: ShareButtonProps) { const [open, setOpen] = useState(false); const [starting, setStarting] = useState(false); const [handle, setHandle] = useState(null); @@ -22,15 +27,22 @@ export function ShareButton({ variant = 'default' }: ShareButtonProps) { const start = async () => { setStarting(true); try { - // Try canvas.captureStream first (works inside the page, no permission prompt) - const canvas = document.querySelector('canvas') as HTMLCanvasElement | null; + // Prefer the XR broadcast mirror (opaque) if active — required during AR passthrough. let stream: MediaStream | null = null; - - if (canvas && typeof canvas.captureStream === 'function') { - stream = canvas.captureStream(30); + const mirror = getBroadcastSource(); + if (mirror && typeof mirror.captureStream === 'function') { + stream = mirror.captureStream(24); } - // Fallback: getDisplayMedia (asks user permission, captures the browser tab) + // Fallback: main page canvas + if (!stream || stream.getVideoTracks().length === 0) { + const canvas = document.querySelector('canvas') as HTMLCanvasElement | null; + if (canvas && typeof canvas.captureStream === 'function') { + stream = canvas.captureStream(30); + } + } + + // Last resort: getDisplayMedia (asks user permission) if (!stream || stream.getVideoTracks().length === 0) { stream = await navigator.mediaDevices.getDisplayMedia({ video: { frameRate: 30 }, @@ -41,7 +53,8 @@ export function ShareButton({ variant = 'default' }: ShareButtonProps) { streamRef.current = stream; const h = await startBroadcast(stream); setHandle(h); - h.onViewerCountChange(setViewerCount); + onHandleChange?.(h); + h.onViewerCountChange((n) => { setViewerCount(n); onViewerCountChange?.(n); }); toast.success(`Sala criada: ${h.code}`); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -57,9 +70,27 @@ export function ShareButton({ variant = 'default' }: ShareButtonProps) { streamRef.current = null; setHandle(null); setViewerCount(0); + onHandleChange?.(null); + onViewerCountChange?.(0); toast.info('Transmissão encerrada'); }; + // Auto-open dialog when requested by parent (in-XR HUD) + useEffect(() => { + if (autoOpen) setOpen(true); + }, [autoOpen]); + + // Expose start/stop via window so XR HUD can trigger from inside the scene + useEffect(() => { + (window as unknown as { __trackSteelStartLive?: () => void; __trackSteelStopLive?: () => void }).__trackSteelStartLive = () => { if (!handle) start(); }; + (window as unknown as { __trackSteelStopLive?: () => void }).__trackSteelStopLive = () => { if (handle) stop(); }; + return () => { + delete (window as unknown as { __trackSteelStartLive?: () => void }).__trackSteelStartLive; + delete (window as unknown as { __trackSteelStopLive?: () => void }).__trackSteelStopLive; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [handle]); + // Cleanup on unmount useEffect(() => { return () => { diff --git a/src/components/three/XRBroadcastMirror.tsx b/src/components/three/XRBroadcastMirror.tsx new file mode 100644 index 0000000..142cca3 --- /dev/null +++ b/src/components/three/XRBroadcastMirror.tsx @@ -0,0 +1,119 @@ +import { useEffect, useRef } from 'react'; +import { useFrame, useThree } from '@react-three/fiber'; +import * as THREE from 'three'; +import { setBroadcastSource } from '@/lib/webrtc/broadcastSource'; + +interface XRBroadcastMirrorProps { + enabled: boolean; + width?: number; + height?: number; + /** Solid background color used in place of real passthrough (privacy + clarity). */ + backgroundColor?: string; +} + +/** + * Renders the current scene to an OFFSCREEN opaque canvas every frame so a + * MediaStream can capture it. The main XR canvas uses alpha:true for + * passthrough — viewers receiving captureStream() of that would see only + * the 3D content over transparency. This mirror gives them a usable picture. + */ +export function XRBroadcastMirror({ + enabled, + width = 1280, + height = 720, + backgroundColor = '#1a1a1a', +}: XRBroadcastMirrorProps) { + const { gl, scene } = useThree(); + const canvasRef = useRef(null); + const rendererRef = useRef(null); + const mirrorCamRef = useRef(null); + + // Create / dispose the offscreen renderer + useEffect(() => { + if (!enabled) return; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + canvasRef.current = canvas; + + const renderer = new THREE.WebGLRenderer({ + canvas, + alpha: false, + antialias: true, + powerPreference: 'high-performance', + preserveDrawingBuffer: false, + }); + renderer.setPixelRatio(1); + renderer.setSize(width, height, false); + renderer.setClearColor(new THREE.Color(backgroundColor), 1); + renderer.outputColorSpace = THREE.SRGBColorSpace; + rendererRef.current = renderer; + + mirrorCamRef.current = new THREE.PerspectiveCamera(70, width / height, 0.01, 100); + + setBroadcastSource(canvas); + + return () => { + setBroadcastSource(null); + renderer.dispose(); + rendererRef.current = null; + canvasRef.current = null; + mirrorCamRef.current = null; + }; + }, [enabled, width, height, backgroundColor]); + + // Per-frame mirror render (throttled to ~24 fps to save GPU) + const lastTime = useRef(0); + useFrame(() => { + if (!enabled) return; + const renderer = rendererRef.current; + const mirrorCam = mirrorCamRef.current; + if (!renderer || !mirrorCam) return; + + const now = performance.now(); + if (now - lastTime.current < 42) return; // ~24 fps cap + lastTime.current = now; + + // Pick the best available camera: XR camera if in session, else main camera + let srcCam: THREE.Camera | null = null; + const xrCam = (gl.xr as unknown as { isPresenting?: boolean; getCamera?: () => THREE.ArrayCamera }).getCamera?.(); + const isPresenting = (gl.xr as unknown as { isPresenting?: boolean }).isPresenting; + if (isPresenting && xrCam && (xrCam as THREE.ArrayCamera).cameras?.length) { + srcCam = (xrCam as THREE.ArrayCamera).cameras[0]; // left eye + } + + if (srcCam instanceof THREE.PerspectiveCamera) { + mirrorCam.fov = srcCam.fov; + mirrorCam.near = srcCam.near; + mirrorCam.far = srcCam.far; + } + if (srcCam) { + srcCam.updateMatrixWorld(true); + mirrorCam.position.setFromMatrixPosition(srcCam.matrixWorld); + mirrorCam.quaternion.setFromRotationMatrix(srcCam.matrixWorld); + } else { + // Fallback: use the main R3F camera (desktop preview) + const main = (gl as unknown as { __mainCam?: THREE.Camera }).__mainCam; + if (main) { + main.updateMatrixWorld(true); + mirrorCam.position.setFromMatrixPosition(main.matrixWorld); + mirrorCam.quaternion.setFromRotationMatrix(main.matrixWorld); + if (main instanceof THREE.PerspectiveCamera) { + mirrorCam.fov = main.fov; + mirrorCam.near = main.near; + mirrorCam.far = main.far; + } + } + } + mirrorCam.aspect = width / height; + mirrorCam.updateProjectionMatrix(); + + try { + renderer.render(scene, mirrorCam); + } catch (e) { + console.warn('[XRBroadcastMirror] render failed', e); + } + }); + + return null; +} diff --git a/src/components/three/XRHudInWorld.tsx b/src/components/three/XRHudInWorld.tsx new file mode 100644 index 0000000..a202b7a --- /dev/null +++ b/src/components/three/XRHudInWorld.tsx @@ -0,0 +1,399 @@ +import { useRef, useState, forwardRef } from 'react'; +import { useFrame, useThree } from '@react-three/fiber'; +import { useXRInputSourceState } from '@react-three/xr'; +import * as THREE from 'three'; +import { Text } from '@react-three/drei'; +import { XR3DButton, XR3DPanel } from './XRPanel3D'; +import { useModelStore, SCALE_PRESETS } from '@/stores/useModelStore'; +import { getBroadcastSource } from '@/lib/webrtc/broadcastSource'; + +type Tab = 'scene' | 'tools' | 'transform' | 'inspection' | 'share'; + +interface XRHudInWorldProps { + freeMove: boolean; + onToggleFreeMove: () => void; + placementMode: boolean; + onTogglePlacement: () => void; + snapToPlanes: boolean; + onToggleSnap: () => void; + allowScale: boolean; + onToggleAllowScale: () => void; + liveCode?: string | null; + liveViewers?: number; + onStartLive?: () => void; + onStopLive?: () => void; +} + +const POS_STEP = 0.001; +const ROT_STEP = 1; +const SCALE_LABELS = ['1:50', '1:20', '1:10', '1:1']; + +const WristToggle = forwardRef void }>( + function WristToggle({ open, onToggle }, ref) { + return ( + + + + + + { e.stopPropagation(); onToggle(); }}> + + + + + {open ? '✕ Fechar' : '☰ Menu'} + + + ); + }, +); + +export function XRHudInWorld(props: XRHudInWorldProps) { + const { camera } = useThree(); + const leftCtrl = useXRInputSourceState('controller', 'left'); + const [open, setOpen] = useState(true); + const [tab, setTab] = useState('scene'); + + const panelRef = useRef(null); + const wristRef = useRef(null); + const targetPos = useRef(new THREE.Vector3()); + const targetQuat = useRef(new THREE.Quaternion()); + const lastABtn = useRef(false); + + useFrame(() => { + // Toggle via left A button (Quest Touch buttons[4]) + const gp = leftCtrl?.inputSource?.gamepad; + if (gp) { + const aBtn = gp.buttons[4]; + const pressed = !!aBtn?.pressed; + if (pressed && !lastABtn.current) setOpen((v) => !v); + lastABtn.current = pressed; + } + + // Floating panel ~70cm in front of head, yaw-locked, damped + if (panelRef.current && open) { + const fwd = new THREE.Vector3(0, -0.05, -0.7).applyQuaternion(camera.quaternion); + targetPos.current.copy(camera.position).add(fwd); + const euler = new THREE.Euler().setFromQuaternion(camera.quaternion, 'YXZ'); + targetQuat.current.setFromEuler(new THREE.Euler(0, euler.y, 0, 'YXZ')); + panelRef.current.position.lerp(targetPos.current, 0.12); + panelRef.current.quaternion.slerp(targetQuat.current, 0.18); + } + + // Wrist toggle follows left controller + if (wristRef.current) { + const ctrlObj = leftCtrl?.object; + if (ctrlObj) { + ctrlObj.updateMatrixWorld(); + const p = new THREE.Vector3().setFromMatrixPosition(ctrlObj.matrixWorld); + const q = new THREE.Quaternion().setFromRotationMatrix(ctrlObj.matrixWorld); + const offset = new THREE.Vector3(0, 0.06, 0.02).applyQuaternion(q); + wristRef.current.position.copy(p).add(offset); + wristRef.current.quaternion.copy(q); + wristRef.current.visible = true; + } else { + wristRef.current.visible = false; + } + } + }); + + return ( + <> + setOpen((v) => !v)} /> + {open && ( + + + + )} + + ); +} + +function FloatingPanel({ tab, setTab, ...p }: { tab: Tab; setTab: (t: Tab) => void } & XRHudInWorldProps) { + const tabs: { id: Tab; label: string; icon: string }[] = [ + { id: 'scene', label: 'Cena', icon: '🧩' }, + { id: 'tools', label: 'Ferram.', icon: '🛠' }, + { id: 'transform', label: 'Mover', icon: '↔' }, + { id: 'inspection', label: 'Inspeção', icon: '✓' }, + { id: 'share', label: 'Compart.', icon: '📡' }, + ]; + const W = 0.5, H = 0.36; + return ( + + + TrackSteelXR · HUD AR + + + Botão A (esq) abre/fecha + + + + {tabs.map((t, i) => ( + setTab(t.id)} fontSize={0.0075} /> + ))} + + + + {tab === 'scene' && } + {tab === 'tools' && } + {tab === 'transform' && } + {tab === 'inspection' && } + {tab === 'share' && } + + + ); +} + +function SceneTab() { + const models = useModelStore((s) => s.models); + const activeId = useModelStore((s) => s.activeModelId); + const setActive = useModelStore((s) => s.setActiveModel); + const toggleVisible = useModelStore((s) => s.toggleModelVisible); + const toggleLocked = useModelStore((s) => s.toggleModelLocked); + const remove = useModelStore((s) => s.removeModel); + const duplicate = useModelStore((s) => s.duplicateModel); + + if (models.length === 0) { + return ( + + Nenhuma peça na cena. Saia do AR para carregar arquivos no Viewer. + + ); + } + + return ( + + + Toque no nome para tornar a peça ativa + + {models.slice(0, 5).map((m, i) => { + const y = 0.085 - i * 0.04; + const isActive = m.id === activeId; + return ( + + + + + + { e.stopPropagation(); setActive(m.id); }}> + + + + + + {m.fileName} + + + toggleVisible(m.id)} /> + toggleLocked(m.id)} /> + duplicate(m.id)} /> + remove(m.id)} /> + + ); + })} + + ); +} + +function ToolsTab(p: XRHudInWorldProps) { + const opacity = useModelStore((s) => s.opacity); + const setOpacity = useModelStore((s) => s.setOpacity); + const renderMode = useModelStore((s) => s.renderMode); + const setRenderMode = useModelStore((s) => s.setRenderMode); + const showGrid = useModelStore((s) => s.showGrid); + const setShowGrid = useModelStore((s) => s.setShowGrid); + const measureMode = useModelStore((s) => s.measureMode); + const setMeasureMode = useModelStore((s) => s.setMeasureMode); + const scaleRatio = useModelStore((s) => s.scaleRatio); + const setScaleRatio = useModelStore((s) => s.setScaleRatio); + + return ( + + Render + setRenderMode('solid')} /> + setRenderMode('wireframe')} /> + setRenderMode('edges')} /> + + Toggles + setShowGrid(!showGrid)} /> + setMeasureMode(!measureMode)} /> + + + + + + Opacidade {Math.round(opacity * 100)}% + + setOpacity(Math.max(0, opacity - 0.25))} /> + setOpacity(Math.max(0, opacity - 0.05))} /> + setOpacity(Math.min(1, opacity + 0.05))} /> + setOpacity(Math.min(1, opacity + 0.25))} /> + setOpacity(1)} /> + + + Escala {scaleRatio.label} + + {SCALE_LABELS.map((lbl, i) => { + const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!; + return ( + setScaleRatio(preset)} /> + ); + })} + + ); +} + +function TransformTab() { + const fineTuning = useModelStore((s) => s.fineTuning); + const setFineTuning = useModelStore((s) => s.setFineTuning); + const reset = useModelStore((s) => s.resetFineTuning); + + const Row = ({ + label, axis, isRot, y, + }: { label: string; axis: 'posX'|'posY'|'posZ'|'rotX'|'rotY'|'rotZ'; isRot: boolean; y: number }) => { + const step = isRot ? ROT_STEP : POS_STEP; + const val = fineTuning[axis]; + const display = isRot ? `${val.toFixed(1)}°` : `${(val * 1000).toFixed(1)}mm`; + return ( + + + {label} + + setFineTuning({ [axis]: val - step * 10 })} /> + setFineTuning({ [axis]: val - step })} /> + + {display} + + setFineTuning({ [axis]: val + step })} /> + setFineTuning({ [axis]: val + step * 10 })} /> + + ); + }; + + return ( + + + + + + + + + + ); +} + +function InspectionTab() { + const checklist = useModelStore((s) => s.checklist); + const setStatus = useModelStore((s) => s.setChecklistItemStatus); + return ( + + + Checklist de inspeção + + {checklist.map((item, i) => { + const y = 0.075 - i * 0.045; + const color = item.status === 'approved' ? '#22c55e' + : item.status === 'rejected' ? '#dc2626' : '#94a3b8'; + return ( + + + + + + + {item.label} + + setStatus(item.id, 'approved')} /> + setStatus(item.id, 'rejected')} /> + + ); + })} + + ); +} + +function ShareTab(p: XRHudInWorldProps) { + const isLive = !!p.liveCode; + return ( + + + {isLive ? '● TRANSMITINDO AO VIVO' : '○ Transmissão desligada'} + + {isLive ? ( + <> + + Código da sala + + + {p.liveCode} + + + {p.liveViewers ?? 0} espectador(es) conectado(s) + + + Os espectadores veem o ponto de vista 3D do operador, sem a câmera real (privacidade). + + p.onStopLive?.()} /> + + ) : ( + <> + + Inicie uma transmissão WebRTC para que cliente/supervisor acompanhem ao vivo (até 5 viewers, link público). + + p.onStartLive?.()} /> + + {getBroadcastSource() ? '✓ Mirror opaco ativo' : 'Mirror será criado ao iniciar'} + + + )} + + ); +} diff --git a/src/components/three/XRPanel3D.tsx b/src/components/three/XRPanel3D.tsx new file mode 100644 index 0000000..248fe8f --- /dev/null +++ b/src/components/three/XRPanel3D.tsx @@ -0,0 +1,103 @@ +import { useState, ReactNode } from 'react'; +import { Text } from '@react-three/drei'; +import * as THREE from 'three'; + +interface XR3DButtonProps { + position?: [number, number, number]; + size?: [number, number]; + label: string; + icon?: string; + active?: boolean; + disabled?: boolean; + onClick?: () => void; + color?: string; + fontSize?: number; +} + +/** Flat 3D button suitable for in-XR HUD. Reacts to controller raycast. */ +export function XR3DButton({ + position = [0, 0, 0], + size = [0.08, 0.025], + label, + icon, + active = false, + disabled = false, + onClick, + color, + fontSize = 0.008, +}: XR3DButtonProps) { + const [hovered, setHovered] = useState(false); + const [w, h] = size; + + const bgColor = disabled + ? '#222' + : active + ? (color ?? '#3b82f6') + : hovered + ? '#444' + : '#1a1a1a'; + const fgColor = disabled ? '#555' : active ? '#ffffff' : '#e5e7eb'; + const borderColor = active ? (color ?? '#3b82f6') : hovered ? '#666' : '#333'; + + return ( + { e.stopPropagation(); if (!disabled) setHovered(true); }} + onPointerOut={(e) => { e.stopPropagation(); setHovered(false); }} + onClick={(e) => { e.stopPropagation(); if (!disabled && onClick) onClick(); }} + > + {/* Border */} + + + + + {/* Background */} + + + + + + {icon ? `${icon} ${label}` : label} + + + ); +} + +interface XR3DPanelProps { + position?: [number, number, number]; + size?: [number, number]; + color?: string; + opacity?: number; + children?: ReactNode; +} + +/** Background panel for grouping XR3DButtons. */ +export function XR3DPanel({ + position = [0, 0, 0], + size = [0.4, 0.3], + color = '#0a0a0a', + opacity = 0.85, + children, +}: XR3DPanelProps) { + const [w, h] = size; + return ( + + + + + + + + + + {children} + + ); +} diff --git a/src/lib/webrtc/broadcastSource.ts b/src/lib/webrtc/broadcastSource.ts new file mode 100644 index 0000000..163b2cd --- /dev/null +++ b/src/lib/webrtc/broadcastSource.ts @@ -0,0 +1,20 @@ +/** + * Lightweight registry that lets the XR mirror expose its offscreen canvas + * as the preferred broadcast source. ShareButton checks here first. + */ +let activeCanvas: HTMLCanvasElement | null = null; +const listeners = new Set<(c: HTMLCanvasElement | null) => void>(); + +export function setBroadcastSource(c: HTMLCanvasElement | null) { + activeCanvas = c; + listeners.forEach((cb) => cb(c)); +} + +export function getBroadcastSource(): HTMLCanvasElement | null { + return activeCanvas; +} + +export function onBroadcastSourceChange(cb: (c: HTMLCanvasElement | null) => void) { + listeners.add(cb); + return () => listeners.delete(cb); +} diff --git a/src/pages/XRSession.tsx b/src/pages/XRSession.tsx index f612423..56e29f9 100644 --- a/src/pages/XRSession.tsx +++ b/src/pages/XRSession.tsx @@ -10,6 +10,9 @@ import { ArrowLeft, Download, QrCode, Crosshair, Home } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { generateMarkerDownloadURL } from '@/lib/trackingMarker'; import { XRHud } from '@/components/XRHud'; +import { ShareButton } from '@/components/ShareButton'; +import { XRHudInWorld } from '@/components/three/XRHudInWorld'; +import { XRBroadcastMirror } from '@/components/three/XRBroadcastMirror'; import { findNearestVertex } from '@/components/three/SmartMeasure'; import { XRHitTestPlacement } from '@/components/three/XRHitTestPlacement'; import { XRGrabbable } from '@/components/three/XRGrabbable'; @@ -433,6 +436,8 @@ const XRSession = () => { const [placementMode, setPlacementMode] = useState(true); // start in placement mode const [snapToPlanes, setSnapToPlanes] = useState(true); const [allowScale, setAllowScale] = useState(false); + const [liveCode, setLiveCode] = useState(null); + const [liveViewers, setLiveViewers] = useState(0); // DEVKIT: simXR forces in-XR rendering on desktop without a real WebXR session const devkit = useDevKit(); const [simXR, setSimXR] = useState(false); @@ -565,6 +570,26 @@ const XRSession = () => { + + {/* In-world HUD — visible inside passthrough where DOM overlays cannot reach */} + setFreeMove(!freeMove)} + placementMode={placementMode} + onTogglePlacement={() => setPlacementMode(!placementMode)} + snapToPlanes={snapToPlanes} + onToggleSnap={() => setSnapToPlanes(!snapToPlanes)} + allowScale={allowScale} + onToggleAllowScale={() => setAllowScale(!allowScale)} + liveCode={liveCode} + liveViewers={liveViewers} + onStartLive={() => (window as unknown as { __trackSteelStartLive?: () => void }).__trackSteelStartLive?.()} + onStopLive={() => (window as unknown as { __trackSteelStopLive?: () => void }).__trackSteelStopLive?.()} + /> + + {/* Opaque mirror canvas used as the WebRTC source while in AR */} + + {/* DEVKIT: fake controllers visible in scene + keyboard driver */} {devkit && simXR && } {/* DEVKIT: orbit camera in SimXR so you can navigate around the model */} @@ -603,17 +628,31 @@ const XRSession = () => { - {/* Floating HUD overlay with all controls */} - setFreeMove(!freeMove)} - placementMode={placementMode} - onTogglePlacement={() => setPlacementMode(!placementMode)} - snapToPlanes={snapToPlanes} - onToggleSnap={() => setSnapToPlanes(!snapToPlanes)} - allowScale={allowScale} - onToggleAllowScale={() => setAllowScale(!allowScale)} - /> + {/* Floating DOM HUD overlay — only visible OUTSIDE passthrough. + Inside AR, the DOM is occluded by the headset compositor; the + in-world XRHudInWorld replaces it. */} +
+ setFreeMove(!freeMove)} + placementMode={placementMode} + onTogglePlacement={() => setPlacementMode(!placementMode)} + snapToPlanes={snapToPlanes} + onToggleSnap={() => setSnapToPlanes(!snapToPlanes)} + allowScale={allowScale} + onToggleAllowScale={() => setAllowScale(!allowScale)} + /> +
+ + {/* Hidden ShareButton instance — registers window.__trackSteelStartLive + so the in-world Share tab can trigger broadcasts. Also keeps live + state in sync with the in-XR HUD. */} +
+ setLiveCode(h?.code ?? null)} + onViewerCountChange={setLiveViewers} + /> +
{/* DEVKIT: floating diagnostic panel + SimXR toggle */} {devkit && (