Added in-world HUD & mirror

X-Lovable-Edit-ID: edt-460b76d5-c2a2-4f00-895b-2b280bcee551
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-14 18:07:04 +00:00
7 changed files with 832 additions and 68 deletions
+102 -49
View File
@@ -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 => <GLBModel key={m.id} model={m} isActive={m.id === activeModelId} />)`.
- Cada `<GLBModel>` 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 `<canvas>`. Quando o Quest entra em `immersive-ar`, o headset para de renderizar o DOM da página — só renderiza o conteúdo WebGL do `<XR>`. 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 `<a>` 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 `<XRWristMenu />` e `<XRToolPanel />`. O `<XRHud>` 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 `<canvas>` 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 `<canvas>` 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 `<XR>`
- `src/components/ShareButton.tsx` — preferir `getBroadcastSource()` quando disponível
- `src/components/XRHud.tsx` — esconder via CSS quando `inXR`
+39 -8
View File
@@ -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<BroadcasterHandle | null>(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 () => {
+119
View File
@@ -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<HTMLCanvasElement | null>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const mirrorCamRef = useRef<THREE.PerspectiveCamera | null>(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;
}
+399
View File
@@ -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<THREE.Group, { open: boolean; onToggle: () => void }>(
function WristToggle({ open, onToggle }, ref) {
return (
<group ref={ref}>
<mesh position={[0, 0, -0.0005]}>
<planeGeometry args={[0.064, 0.032]} />
<meshBasicMaterial color="#3b82f6" transparent opacity={0.6} />
</mesh>
<mesh onClick={(e) => { e.stopPropagation(); onToggle(); }}>
<planeGeometry args={[0.06, 0.028]} />
<meshBasicMaterial color={open ? '#3b82f6' : '#1a1a1a'} transparent opacity={0.95} />
</mesh>
<Text position={[0, 0, 0.001]} fontSize={0.0085} color="#ffffff" anchorX="center" anchorY="middle">
{open ? '✕ Fechar' : '☰ Menu'}
</Text>
</group>
);
},
);
export function XRHudInWorld(props: XRHudInWorldProps) {
const { camera } = useThree();
const leftCtrl = useXRInputSourceState('controller', 'left');
const [open, setOpen] = useState(true);
const [tab, setTab] = useState<Tab>('scene');
const panelRef = useRef<THREE.Group>(null);
const wristRef = useRef<THREE.Group>(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 (
<>
<WristToggle ref={wristRef} open={open} onToggle={() => setOpen((v) => !v)} />
{open && (
<group ref={panelRef}>
<FloatingPanel tab={tab} setTab={setTab} {...props} />
</group>
)}
</>
);
}
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 (
<XR3DPanel size={[W, H]}>
<Text position={[-W / 2 + 0.012, H / 2 - 0.018, 0.002]} fontSize={0.011} color="#3b82f6"
anchorX="left" anchorY="middle">
TrackSteelXR · HUD AR
</Text>
<Text position={[W / 2 - 0.012, H / 2 - 0.018, 0.002]} fontSize={0.008} color="#94a3b8"
anchorX="right" anchorY="middle">
Botão A (esq) abre/fecha
</Text>
<group position={[0, H / 2 - 0.045, 0.001]}>
{tabs.map((t, i) => (
<XR3DButton key={t.id}
position={[-W / 2 + 0.045 + i * 0.092, 0, 0]} size={[0.085, 0.022]}
label={t.label} icon={t.icon} active={tab === t.id}
onClick={() => setTab(t.id)} fontSize={0.0075} />
))}
</group>
<group position={[0, -0.012, 0.001]}>
{tab === 'scene' && <SceneTab />}
{tab === 'tools' && <ToolsTab {...p} />}
{tab === 'transform' && <TransformTab />}
{tab === 'inspection' && <InspectionTab />}
{tab === 'share' && <ShareTab {...p} />}
</group>
</XR3DPanel>
);
}
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 (
<Text fontSize={0.011} color="#94a3b8" anchorX="center" anchorY="middle" maxWidth={0.45}>
Nenhuma peça na cena. Saia do AR para carregar arquivos no Viewer.
</Text>
);
}
return (
<group>
<Text position={[-0.24, 0.11, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Toque no nome para tornar a peça ativa
</Text>
{models.slice(0, 5).map((m, i) => {
const y = 0.085 - i * 0.04;
const isActive = m.id === activeId;
return (
<group key={m.id} position={[0, y, 0]}>
<mesh position={[-0.235, 0, 0.001]}>
<circleGeometry args={[0.005, 16]} />
<meshBasicMaterial color={m.color} />
</mesh>
<group onClick={(e) => { e.stopPropagation(); setActive(m.id); }}>
<mesh position={[-0.11, 0, 0]}>
<planeGeometry args={[0.22, 0.026]} />
<meshBasicMaterial color={isActive ? '#1e3a8a' : '#1a1a1a'} transparent opacity={0.85} />
</mesh>
<Text position={[-0.215, 0, 0.001]} fontSize={0.0085}
color={isActive ? '#ffffff' : '#cbd5e1'} anchorX="left" anchorY="middle"
maxWidth={0.2}>
{m.fileName}
</Text>
</group>
<XR3DButton position={[0.04, 0, 0]} size={[0.04, 0.024]}
label={m.visible ? '👁' : '⊘'} fontSize={0.012}
onClick={() => toggleVisible(m.id)} />
<XR3DButton position={[0.085, 0, 0]} size={[0.04, 0.024]}
label={m.locked ? '🔒' : '🔓'} fontSize={0.012}
onClick={() => toggleLocked(m.id)} />
<XR3DButton position={[0.13, 0, 0]} size={[0.04, 0.024]}
label="⎘" fontSize={0.012}
onClick={() => duplicate(m.id)} />
<XR3DButton position={[0.175, 0, 0]} size={[0.04, 0.024]}
label="✕" fontSize={0.012} color="#dc2626"
onClick={() => remove(m.id)} />
</group>
);
})}
</group>
);
}
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 (
<group>
<Text position={[-0.24, 0.11, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Render</Text>
<XR3DButton position={[-0.16, 0.085, 0]} size={[0.075, 0.022]} label="Sólido"
active={renderMode === 'solid'} onClick={() => setRenderMode('solid')} />
<XR3DButton position={[-0.08, 0.085, 0]} size={[0.075, 0.022]} label="Wire"
active={renderMode === 'wireframe'} onClick={() => setRenderMode('wireframe')} />
<XR3DButton position={[0.0, 0.085, 0]} size={[0.075, 0.022]} label="Bordas"
active={renderMode === 'edges'} onClick={() => setRenderMode('edges')} />
<Text position={[-0.24, 0.05, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">Toggles</Text>
<XR3DButton position={[-0.16, 0.025, 0]} size={[0.075, 0.022]} label={`Grid ${showGrid ? 'ON' : 'OFF'}`}
active={showGrid} onClick={() => setShowGrid(!showGrid)} />
<XR3DButton position={[-0.08, 0.025, 0]} size={[0.075, 0.022]} label={`Medir ${measureMode ? 'ON' : 'OFF'}`}
active={measureMode} onClick={() => setMeasureMode(!measureMode)} />
<XR3DButton position={[0.0, 0.025, 0]} size={[0.075, 0.022]} label={`Snap ${p.snapToPlanes ? 'ON' : 'OFF'}`}
active={p.snapToPlanes} onClick={p.onToggleSnap} />
<XR3DButton position={[0.08, 0.025, 0]} size={[0.075, 0.022]} label={`Escala ${p.allowScale ? 'ON' : 'OFF'}`}
active={p.allowScale} onClick={p.onToggleAllowScale} />
<XR3DButton position={[0.16, 0.025, 0]} size={[0.075, 0.022]}
label={p.placementMode ? 'Posic…' : 'Reposic.'}
active={p.placementMode} onClick={p.onTogglePlacement} />
<Text position={[-0.24, -0.02, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Opacidade {Math.round(opacity * 100)}%
</Text>
<XR3DButton position={[-0.16, -0.045, 0]} size={[0.05, 0.022]} label="25%"
onClick={() => setOpacity(Math.max(0, opacity - 0.25))} />
<XR3DButton position={[-0.105, -0.045, 0]} size={[0.05, 0.022]} label="5%"
onClick={() => setOpacity(Math.max(0, opacity - 0.05))} />
<XR3DButton position={[-0.05, -0.045, 0]} size={[0.05, 0.022]} label="+5%"
onClick={() => setOpacity(Math.min(1, opacity + 0.05))} />
<XR3DButton position={[0.005, -0.045, 0]} size={[0.05, 0.022]} label="+25%"
onClick={() => setOpacity(Math.min(1, opacity + 0.25))} />
<XR3DButton position={[0.06, -0.045, 0]} size={[0.05, 0.022]} label="100%"
onClick={() => setOpacity(1)} />
<Text position={[-0.24, -0.085, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Escala {scaleRatio.label}
</Text>
{SCALE_LABELS.map((lbl, i) => {
const preset = SCALE_PRESETS.find((sp) => sp.label === lbl)!;
return (
<XR3DButton key={lbl}
position={[-0.16 + i * 0.06, -0.11, 0]} size={[0.055, 0.022]} label={lbl}
active={scaleRatio.label === lbl}
onClick={() => setScaleRatio(preset)} />
);
})}
</group>
);
}
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 (
<group position={[0, y, 0]}>
<Text position={[-0.235, 0, 0]} fontSize={0.009} color="#cbd5e1" anchorX="left" anchorY="middle">
{label}
</Text>
<XR3DButton position={[-0.10, 0, 0]} size={[0.04, 0.024]} label="−−"
onClick={() => setFineTuning({ [axis]: val - step * 10 })} />
<XR3DButton position={[-0.05, 0, 0]} size={[0.04, 0.024]} label=""
onClick={() => setFineTuning({ [axis]: val - step })} />
<Text position={[0.0, 0, 0]} fontSize={0.009} color="#3b82f6" anchorX="center" anchorY="middle">
{display}
</Text>
<XR3DButton position={[0.05, 0, 0]} size={[0.04, 0.024]} label="+"
onClick={() => setFineTuning({ [axis]: val + step })} />
<XR3DButton position={[0.10, 0, 0]} size={[0.04, 0.024]} label="++"
onClick={() => setFineTuning({ [axis]: val + step * 10 })} />
</group>
);
};
return (
<group>
<Row label="Pos X" axis="posX" isRot={false} y={0.105} />
<Row label="Pos Y" axis="posY" isRot={false} y={0.075} />
<Row label="Pos Z" axis="posZ" isRot={false} y={0.045} />
<Row label="Rot X" axis="rotX" isRot={true} y={0.005} />
<Row label="Rot Y" axis="rotY" isRot={true} y={-0.025} />
<Row label="Rot Z" axis="rotZ" isRot={true} y={-0.055} />
<XR3DButton position={[0, -0.105, 0]} size={[0.12, 0.026]} label="↺ Reset" onClick={reset} />
</group>
);
}
function InspectionTab() {
const checklist = useModelStore((s) => s.checklist);
const setStatus = useModelStore((s) => s.setChecklistItemStatus);
return (
<group>
<Text position={[-0.24, 0.11, 0]} fontSize={0.008} color="#94a3b8" anchorX="left">
Checklist de inspeção
</Text>
{checklist.map((item, i) => {
const y = 0.075 - i * 0.045;
const color = item.status === 'approved' ? '#22c55e'
: item.status === 'rejected' ? '#dc2626' : '#94a3b8';
return (
<group key={item.id} position={[0, y, 0]}>
<mesh position={[-0.235, 0, 0]}>
<circleGeometry args={[0.005, 16]} />
<meshBasicMaterial color={color} />
</mesh>
<Text position={[-0.22, 0, 0]} fontSize={0.009} color="#e5e7eb"
anchorX="left" anchorY="middle" maxWidth={0.18}>
{item.label}
</Text>
<XR3DButton position={[0.06, 0, 0]} size={[0.05, 0.024]} label="✓ OK" color="#16a34a"
active={item.status === 'approved'} onClick={() => setStatus(item.id, 'approved')} />
<XR3DButton position={[0.115, 0, 0]} size={[0.05, 0.024]} label="✗ NOK" color="#dc2626"
active={item.status === 'rejected'} onClick={() => setStatus(item.id, 'rejected')} />
</group>
);
})}
</group>
);
}
function ShareTab(p: XRHudInWorldProps) {
const isLive = !!p.liveCode;
return (
<group>
<Text position={[0, 0.105, 0]} fontSize={0.012} color={isLive ? '#22c55e' : '#94a3b8'}
anchorX="center" anchorY="middle">
{isLive ? '● TRANSMITINDO AO VIVO' : '○ Transmissão desligada'}
</Text>
{isLive ? (
<>
<Text position={[0, 0.07, 0]} fontSize={0.010} color="#94a3b8" anchorX="center" anchorY="middle">
Código da sala
</Text>
<Text position={[0, 0.04, 0]} fontSize={0.024} color="#3b82f6" anchorX="center" anchorY="middle">
{p.liveCode}
</Text>
<Text position={[0, -0.005, 0]} fontSize={0.010} color="#cbd5e1" anchorX="center" anchorY="middle">
{p.liveViewers ?? 0} espectador(es) conectado(s)
</Text>
<Text position={[0, -0.04, 0]} fontSize={0.008} color="#64748b" anchorX="center" anchorY="middle"
maxWidth={0.45}>
Os espectadores veem o ponto de vista 3D do operador, sem a câmera real (privacidade).
</Text>
<XR3DButton position={[0, -0.09, 0]} size={[0.18, 0.028]}
label="✕ Encerrar transmissão" color="#dc2626"
onClick={() => p.onStopLive?.()} />
</>
) : (
<>
<Text position={[0, 0.04, 0]} fontSize={0.010} color="#cbd5e1" anchorX="center" anchorY="middle"
maxWidth={0.45}>
Inicie uma transmissão WebRTC para que cliente/supervisor acompanhem ao vivo (até 5 viewers, link público).
</Text>
<XR3DButton position={[0, -0.04, 0]} size={[0.22, 0.03]}
label="📡 Iniciar transmissão" color="#3b82f6"
onClick={() => p.onStartLive?.()} />
<Text position={[0, -0.09, 0]} fontSize={0.008} color="#64748b" anchorX="center" anchorY="middle"
maxWidth={0.45}>
{getBroadcastSource() ? '✓ Mirror opaco ativo' : 'Mirror será criado ao iniciar'}
</Text>
</>
)}
</group>
);
}
+103
View File
@@ -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 (
<group
position={position}
onPointerOver={(e) => { e.stopPropagation(); if (!disabled) setHovered(true); }}
onPointerOut={(e) => { e.stopPropagation(); setHovered(false); }}
onClick={(e) => { e.stopPropagation(); if (!disabled && onClick) onClick(); }}
>
{/* Border */}
<mesh position={[0, 0, -0.0005]}>
<planeGeometry args={[w + 0.002, h + 0.002]} />
<meshBasicMaterial color={borderColor} transparent opacity={0.95} />
</mesh>
{/* Background */}
<mesh>
<planeGeometry args={[w, h]} />
<meshBasicMaterial color={bgColor} transparent opacity={0.92} side={THREE.DoubleSide} />
</mesh>
<Text
position={[0, 0, 0.001]}
fontSize={fontSize}
color={fgColor}
anchorX="center"
anchorY="middle"
maxWidth={w * 0.95}
>
{icon ? `${icon} ${label}` : label}
</Text>
</group>
);
}
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 (
<group position={position}>
<mesh position={[0, 0, -0.001]}>
<planeGeometry args={[w + 0.004, h + 0.004]} />
<meshBasicMaterial color="#3b82f6" transparent opacity={0.6} />
</mesh>
<mesh>
<planeGeometry args={[w, h]} />
<meshBasicMaterial color={color} transparent opacity={opacity} side={THREE.DoubleSide} />
</mesh>
{children}
</group>
);
}
+20
View File
@@ -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);
}
+50 -11
View File
@@ -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<string | null>(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 = () => {
<XRMeasurementOverlay />
<ControllerFineTuning freeMove={freeMove} />
<XRDebugHud />
{/* In-world HUD — visible inside passthrough where DOM overlays cannot reach */}
<XRHudInWorld
freeMove={freeMove}
onToggleFreeMove={() => 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 */}
<XRBroadcastMirror enabled />
{/* DEVKIT: fake controllers visible in scene + keyboard driver */}
{devkit && simXR && <FakeControllers />}
{/* DEVKIT: orbit camera in SimXR so you can navigate around the model */}
@@ -603,17 +628,31 @@ const XRSession = () => {
</XR>
</Canvas>
{/* Floating HUD overlay with all controls */}
<XRHud
freeMove={freeMove}
onToggleFreeMove={() => 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. */}
<div style={{ display: inXR ? 'none' : 'contents' }}>
<XRHud
freeMove={freeMove}
onToggleFreeMove={() => setFreeMove(!freeMove)}
placementMode={placementMode}
onTogglePlacement={() => setPlacementMode(!placementMode)}
snapToPlanes={snapToPlanes}
onToggleSnap={() => setSnapToPlanes(!snapToPlanes)}
allowScale={allowScale}
onToggleAllowScale={() => setAllowScale(!allowScale)}
/>
</div>
{/* 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. */}
<div style={{ display: 'none' }}>
<ShareButton
onHandleChange={(h) => setLiveCode(h?.code ?? null)}
onViewerCountChange={setLiveViewers}
/>
</div>
{/* DEVKIT: floating diagnostic panel + SimXR toggle */}
{devkit && (