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:
+102
-49
@@ -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`
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user