Added multi-model IFC support

X-Lovable-Edit-ID: edt-ce287174-4728-48f2-b0fd-47b1e3380540
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-14 16:46:34 +00:00
9 changed files with 505 additions and 176 deletions
+49 -105
View File
@@ -1,118 +1,62 @@
# Plano — Compartilhar tela do Quest 3 via link público
## Resposta curta
Permitir que o inspetor no Quest gere um link tipo `tracksteelxr.lovable.app/watch/AB12CD`, mande no WhatsApp/email, e até 3 espectadores assistam ao vivo (vídeo + áudio do Quest) em qualquer celular ou PC.
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.
## Como vai funcionar para o usuário
## O que muda
**No Quest (broadcaster):**
1. No HUD do `/xr` aparece novo botão **"Compartilhar"** (ícone Share/Cast)
2. Ao clicar: app gera código de 6 caracteres, mostra QR + link encurtado
3. Botões "Copiar link" e "Compartilhar" (Web Share API → abre WhatsApp/email do sistema)
4. Indicador vermelho 🔴 LIVE fica visível enquanto transmite, com contador de viewers
5. Botão "Encerrar" para parar
### 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.
**No celular/PC (viewer):**
1. Abre o link → tela cheia com vídeo da sessão XR
2. Sem login, sem instalar nada
3. Mostra "Aguardando inspetor…" se a sala não estiver ativa
4. Read-only: só assiste
### 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).
## Arquitetura técnica
### 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.
```text
Quest 3 (broadcaster) Lovable Cloud Viewers (celular/PC)
┌──────────────────┐ ┌─────────────────────┐ ┌──────────────────┐
│ /xr │ │ Supabase Realtime │ │ /watch/AB12CD │
│ - canvas WebXR │ ──SDP──▶│ (signaling channel) │◀──SDP── │ - <video> tag │
│ - getDisplayMedia│ │ rooms table │ │ - peer connection│
│ - RTCPeerConn x3 │◀════════ ICE candidates ═════════════▶ │ │
└──────────────────┘ │ viewers count │ └──────────────────┘
║ └─────────────────────┘ ▲
╚════════ WebRTC P2P media stream (vídeo H264) ════════════════╝
```
### 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.
**Stack:**
- **Lovable Cloud** (precisa habilitar): tabela `share_rooms` + canal Realtime para signaling
- **WebRTC nativo do browser** (`RTCPeerConnection`) — sem libs pesadas
- **Captura no Quest:** `navigator.mediaDevices.getDisplayMedia()` no browser do Quest captura a janela atual (inclui o canvas 3D). Em sessão XR imersiva, alternativa é usar `canvas.captureStream(30)` direto do canvas WebGL — funciona melhor e não precisa de prompt
- **Sem servidor de mídia (SFU):** P2P 1→3 viewers, banda do Quest aguenta tranquilo (~2 Mbps por viewer)
### 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.
## Estrutura de arquivos
## Limitações conhecidas
**Novos:**
- `src/pages/Watch.tsx` — página pública do viewer (rota `/watch/:code`)
- `src/components/ShareButton.tsx` — botão + dialog com QR/link no HUD
- `src/lib/webrtc/broadcaster.ts` — lógica do lado Quest (cria offer, gerencia múltiplos peers)
- `src/lib/webrtc/viewer.ts` — lógica do lado viewer (recebe offer, monta `<video>`)
- `src/lib/webrtc/signaling.ts` — wrapper sobre Supabase Realtime para troca SDP/ICE
- **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).
**Editados:**
- `src/App.tsx` — adicionar rota `/watch/:code` (pública, fora do layout XR)
- `src/components/XRHud.tsx` — adicionar `<ShareButton />` na toolbar
- `src/pages/XRSession.tsx` — botão também visível fora do HUD
- `src/stores/useModelStore.ts` — estado `shareRoom: { code, viewerCount, isLive }`
## Fora do escopo desta v1
## Schema do banco (Lovable Cloud)
- 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)
```sql
create table public.share_rooms (
code text primary key, -- 6 chars, ex: 'AB12CD'
created_at timestamptz default now(),
expires_at timestamptz default now() + interval '4 hours',
is_active boolean default true,
viewer_count int default 0
);
## Arquivos afetados
-- RLS: leitura pública (viewers precisam saber se sala existe), escrita só anon controlada
alter table public.share_rooms enable row level security;
create policy "anyone reads active rooms" on public.share_rooms
for select using (is_active and expires_at > now());
create policy "anyone creates room" on public.share_rooms
for insert with check (true);
create policy "anyone updates count" on public.share_rooms
for update using (true);
```
Signaling propriamente dito (SDP offers/answers, ICE candidates) acontece via **Supabase Realtime broadcast channel** `room:${code}` — não persiste em tabela, é efêmero.
## STUN/TURN
- **STUN público do Google** (`stun:stun.l.google.com:19302`) — grátis, resolve ~80% dos casos (NAT comum)
- **TURN:** se inspetor estiver atrás de NAT simétrico (raro em fábrica), conexão falha. Para v1 deixamos só STUN. Se precisar TURN depois, integramos Twilio TURN (~$0,40/GB) ou Metered.ca (free tier 50GB/mês)
## Captura no Quest — detalhes importantes
Tem dois cenários:
1. **Fora da sessão XR** (modo viewer 2D): `canvas.captureStream(30)` — fácil, funciona já
2. **Dentro da sessão XR imersiva** (passthrough AR): aqui o canvas é renderizado para o display do headset, não para a tela visível. Solução: usar `XRWebGLLayer.framebuffer` e renderizar uma view extra "spectator" para um canvas offscreen que vira o stream. **Esta parte tem risco** — pode precisar fallback para `getDisplayMedia()` que pede permissão do usuário no Quest
Plano: começar com cenário 1 (já cobre uso fora do AR imersivo), e cenário 2 numa segunda iteração se necessário.
## Limitações honestas
- **Latência:** 200-500ms (não é instantâneo, mas serve para acompanhamento)
- **Sem áudio do microfone do Quest** na v1 (adicionar depois com `getUserMedia({audio:true})`)
- **Sala expira em 4h** — geração de novo código se sessão durar mais
- **Link público:** qualquer um com a URL vê. Se vazar, código pode ser revogado pelo inspetor
- **Não grava** — só transmissão ao vivo. Gravação seria feature separada
## Pré-requisito
Precisa **habilitar Lovable Cloud** (atualmente o projeto não tem). Vou pedir confirmação antes de habilitar, pois cria infraestrutura backend.
## Escopo desta implementação
✅ Botão "Compartilhar" no HUD do XR + tela inicial
✅ Geração de código + QR + link curto + Web Share API
✅ Página `/watch/:code` pública com vídeo
✅ Signaling via Supabase Realtime
✅ WebRTC P2P 1→3 viewers
✅ Contador de viewers em tempo real para o broadcaster
✅ Captura via `canvas.captureStream` (cenário fora do XR imersivo)
❌ Captura dentro da sessão XR imersiva (segunda iteração)
❌ Áudio do mic (segunda iteração)
❌ Chat / interação (você optou por read-only)
❌ TURN server (só se aparecer problema de NAT)
❌ Gravação
Aprovando o plano, eu habilito o Lovable Cloud e implemento.
- `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
+10 -4
View File
@@ -93,13 +93,19 @@ interface CloudLoaderProps {
}
export function CloudLoader({ compact = false, variant = 'outline', className }: CloudLoaderProps) {
const setModel = useModelStore((s) => s.setModel);
const addModel = useModelStore((s) => s.addModel);
const modelsCount = useModelStore((s) => s.models.length);
const maxModels = useModelStore((s) => s.maxModels);
const [open, setOpen] = useState(false);
const [url, setUrl] = useState(DEFAULT_URL);
const [loading, setLoading] = useState(false);
const handleLoad = useCallback(async (rawUrl: string) => {
if (!rawUrl) return;
if (modelsCount >= maxModels) {
toast.error(`Limite de ${maxModels} peças simultâneas atingido. Remova uma antes de adicionar outra.`);
return;
}
setLoading(true);
try {
const fileName = rawUrl.split('/').pop()?.split('?')[0] || 'modelo-cloud';
@@ -123,8 +129,8 @@ export function CloudLoader({ compact = false, variant = 'outline', className }:
blob = r.blob; outName = r.fileName; outSize = r.fileSize;
}
const blobUrl = URL.createObjectURL(blob);
setModel({ fileName: outName, fileSize: outSize, url: blobUrl });
toast.success(`"${outName}" carregado da nuvem`);
addModel({ fileName: outName, fileSize: outSize, url: blobUrl });
toast.success(`"${outName}" adicionado à cena`);
setOpen(false);
} catch (err) {
console.error(err);
@@ -132,7 +138,7 @@ export function CloudLoader({ compact = false, variant = 'outline', className }:
} finally {
setLoading(false);
}
}, [setModel]);
}, [addModel, modelsCount, maxModels]);
return (
<Dialog open={open} onOpenChange={setOpen}>
+121
View File
@@ -0,0 +1,121 @@
import { Eye, EyeOff, Lock, Unlock, Copy, Trash2, Box, Layers } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useModelStore } from '@/stores/useModelStore';
import { toast } from 'sonner';
interface SceneModelListProps {
compact?: boolean;
}
export function SceneModelList({ compact = false }: SceneModelListProps) {
const models = useModelStore((s) => s.models);
const activeId = useModelStore((s) => s.activeModelId);
const setActive = useModelStore((s) => s.setActiveModel);
const removeModel = useModelStore((s) => s.removeModel);
const toggleVisible = useModelStore((s) => s.toggleModelVisible);
const toggleLocked = useModelStore((s) => s.toggleModelLocked);
const duplicate = useModelStore((s) => s.duplicateModel);
const max = useModelStore((s) => s.maxModels);
if (models.length === 0) {
return (
<div className="rounded-lg border border-dashed bg-muted/20 p-3 text-center">
<Layers className="mx-auto mb-1 h-4 w-4 text-muted-foreground" />
<p className="font-mono text-[10px] text-muted-foreground">Nenhuma peça na cena</p>
</div>
);
}
const textSize = compact ? 'text-[9px]' : 'text-[11px]';
const padding = compact ? 'p-1.5' : 'p-2';
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Layers className="h-3 w-3 text-primary" />
<span className={`font-mono ${textSize} font-semibold uppercase tracking-wider text-muted-foreground`}>
Cena
</span>
</div>
<span className={`font-mono ${textSize} text-muted-foreground`}>
{models.length}/{max}
</span>
</div>
<div className="space-y-1">
{models.map((m) => {
const isActive = m.id === activeId;
return (
<div
key={m.id}
className={`flex items-center gap-1 rounded-md border ${padding} transition-colors cursor-pointer ${
isActive
? 'border-primary bg-primary/10'
: 'border-border bg-muted/30 hover:bg-muted/50'
}`}
onClick={() => setActive(m.id)}
>
<div
className="h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-foreground/20"
style={{ backgroundColor: m.color }}
title="Cor da peça"
/>
<Box className={`h-3 w-3 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`flex-1 truncate font-mono ${textSize} ${isActive ? 'text-foreground font-semibold' : 'text-muted-foreground'}`}>
{m.fileName}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
onClick={(e) => { e.stopPropagation(); toggleVisible(m.id); }}
title={m.visible ? 'Ocultar' : 'Exibir'}
>
{m.visible ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3 text-muted-foreground" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
onClick={(e) => { e.stopPropagation(); toggleLocked(m.id); }}
title={m.locked ? 'Destravar' : 'Travar'}
>
{m.locked ? <Lock className="h-3 w-3 text-warning" /> : <Unlock className="h-3 w-3 text-muted-foreground" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
onClick={(e) => {
e.stopPropagation();
if (models.length >= max) {
toast.error(`Limite de ${max} peças atingido`);
return;
}
duplicate(m.id);
}}
title="Duplicar"
>
<Copy className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
onClick={(e) => {
e.stopPropagation();
removeModel(m.id);
toast.success(`"${m.fileName}" removido`);
}}
title="Remover"
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
);
})}
</div>
</div>
);
}
+5 -1
View File
@@ -12,6 +12,7 @@ import { InspectionChecklist } from '@/components/InspectionChecklist';
import { ScaleSelector } from '@/components/ScaleSelector';
import { CloudLoader } from '@/components/CloudLoader';
import { ShareButton } from '@/components/ShareButton';
import { SceneModelList } from '@/components/SceneModelList';
import { toast } from 'sonner';
const POSITION_STEP = 0.001;
@@ -197,7 +198,10 @@ export function XRHud({ freeMove, onToggleFreeMove, placementMode = false, onTog
<div className="pointer-events-auto mx-auto max-w-lg space-y-2">
{/* Expanded panel */}
{expanded && (
<div className="rounded-xl border bg-card/90 backdrop-blur-md p-3 shadow-2xl space-y-3 animate-in slide-in-from-bottom-2 duration-200">
<div className="rounded-xl border bg-card/90 backdrop-blur-md p-3 shadow-2xl space-y-3 animate-in slide-in-from-bottom-2 duration-200 max-h-[60vh] overflow-y-auto">
{/* Scene models list */}
<SceneModelList compact />
{/* Opacity */}
<div>
<div className="flex items-center justify-between mb-1.5">
+36 -13
View File
@@ -3,11 +3,11 @@ import { Canvas, useThree, useFrame } from '@react-three/fiber';
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
import { Loader2 } from 'lucide-react';
import * as THREE from 'three';
import { useModelStore } from '@/stores/useModelStore';
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment } from './SmartMeasure';
interface ModelViewerProps {
url: string;
url?: string; // legacy, ignored — uses store.models
}
function LoadingFallback() {
@@ -21,8 +21,10 @@ function LoadingFallback() {
);
}
function GLBModel({ url }: { url: string }) {
const { scene } = useGLTF(url);
function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: boolean }) {
const { scene: rawScene } = useGLTF(sceneModel.url);
// Clone scene per instance so multiple GLBModels with same url don't share materials/transforms
const scene = useMemo(() => rawScene.clone(true), [rawScene]);
const ref = useRef<THREE.Group>(null);
const opacity = useModelStore((s) => s.opacity);
const renderMode = useModelStore((s) => s.renderMode);
@@ -30,7 +32,8 @@ function GLBModel({ url }: { url: string }) {
const wireframeThickness = useModelStore((s) => s.wireframeThickness);
const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle);
const checklist = useModelStore((s) => s.checklist);
const fineTuning = useModelStore((s) => s.fineTuning);
const setActive = useModelStore((s) => s.setActiveModel);
const fineTuning = sceneModel.fineTuning;
const modelInfo = useMemo(() => {
const box = new THREE.Box3().setFromObject(scene);
@@ -41,7 +44,6 @@ function GLBModel({ url }: { url: string }) {
return { size, center };
}, [scene]);
// Store original colors so we can restore them
const originalColors = useRef<Map<THREE.Material, THREE.Color>>(new Map());
useEffect(() => {
@@ -56,7 +58,6 @@ function GLBModel({ url }: { url: string }) {
child.material = child.material.clone();
}
// Clean up previous edge lines
const toRemove: THREE.Object3D[] = [];
child.children.forEach(c => {
if (c.userData.__edgeLine) toRemove.push(c);
@@ -72,7 +73,6 @@ function GLBModel({ url }: { url: string }) {
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach((mat: THREE.Material) => {
if (mat instanceof THREE.MeshStandardMaterial) {
// Store original color on first encounter
if (!originalColors.current.has(mat)) {
originalColors.current.set(mat, mat.color.clone());
}
@@ -92,14 +92,14 @@ function GLBModel({ url }: { url: string }) {
} else if (allApproved) {
mat.color.setHSL(0.38, 0.7, 0.45);
} else {
mat.color.set(0x8899aa);
// Tint with the per-model color (subtle)
mat.color.set(sceneModel.color);
}
}
mat.needsUpdate = true;
}
});
// Create edge lines for edges mode
if (renderMode === 'edges' && child.geometry) {
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
const lineMat = new THREE.LineBasicMaterial({
@@ -112,7 +112,7 @@ function GLBModel({ url }: { url: string }) {
}
}
});
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle]);
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color]);
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
@@ -121,8 +121,13 @@ function GLBModel({ url }: { url: string }) {
const scaleRatio = useModelStore((st) => st.scaleRatio);
const renderFactor = scaleRatio?.factor ?? 1;
if (!sceneModel.visible) return null;
return (
<group scale={[renderFactor, renderFactor, renderFactor]}>
<group
scale={[renderFactor, renderFactor, renderFactor]}
onClick={(e) => { e.stopPropagation(); setActive(sceneModel.id); }}
>
<group
ref={ref}
position={[
@@ -134,11 +139,29 @@ function GLBModel({ url }: { url: string }) {
scale={[s, s, s]}
>
<primitive object={scene} />
{isActive && (
<mesh>
<boxGeometry args={[modelInfo.size.x * 1.05, modelInfo.size.y * 1.05, modelInfo.size.z * 1.05]} />
<meshBasicMaterial color={sceneModel.color} wireframe transparent opacity={0.25} />
</mesh>
)}
</group>
</group>
);
}
function SceneModels() {
const models = useModelStore((s) => s.models);
const activeId = useModelStore((s) => s.activeModelId);
return (
<>
{models.map((m) => (
<GLBModel key={m.id} sceneModel={m} isActive={m.id === activeId} />
))}
</>
);
}
/** Sphere marker at a 3D point */
function PointMarker({ position, color = '#e8a838' }: { position: [number, number, number]; color?: string }) {
return (
@@ -472,7 +495,7 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
<Suspense fallback={<LoadingFallback />}>
<GLBModel url={url} />
<SceneModels />
</Suspense>
<MeasurementOverlay />
+17 -20
View File
@@ -7,6 +7,7 @@ import { toast } from 'sonner';
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from '@/lib/convertToGLB';
import { convertIFCtoGLB } from '@/lib/convertIFC';
import { CloudLoader } from '@/components/CloudLoader';
import { SceneModelList } from '@/components/SceneModelList';
const Index = () => {
const navigate = useNavigate();
@@ -15,7 +16,7 @@ const Index = () => {
const [loadingDemoCotovelo, setLoadingDemoCotovelo] = useState(false);
const [converting, setConverting] = useState(false);
const { model, setModel, xrSupported, setXrSupported } = useModelStore();
const { model, addModel, models, maxModels, xrSupported, setXrSupported } = useModelStore();
useEffect(() => {
if (navigator.xr) {
@@ -37,7 +38,7 @@ const Index = () => {
if (ext === 'glb') {
const url = URL.createObjectURL(file);
setModel({ fileName: file.name, fileSize: file.size, url });
addModel({ fileName: file.name, fileSize: file.size, url });
toast.success(`Modelo "${file.name}" carregado com sucesso!`);
return;
}
@@ -53,7 +54,7 @@ const Index = () => {
result = await convertToGLB(buffer, ext, file.name);
}
const url = URL.createObjectURL(result.blob);
setModel({ fileName: result.fileName, fileSize: result.fileSize, url });
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
toast.success(`"${file.name}" convertido para GLB e carregado!`);
} catch (err) {
console.error(err);
@@ -61,7 +62,7 @@ const Index = () => {
} finally {
setConverting(false);
}
}, [setModel]);
}, [addModel]);
// Carregar da Nuvem agora é encapsulado em <CloudLoader />
@@ -72,7 +73,7 @@ const Index = () => {
const buffer = await response.arrayBuffer();
const result = await convertIFCtoGLB(buffer, 'demo250hp.ifc');
const url = URL.createObjectURL(result.blob);
setModel({ fileName: result.fileName, fileSize: result.fileSize, url });
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
toast.success('Modelo demo "250 HP" (IFC) carregado!');
} catch (err) {
console.error(err);
@@ -80,7 +81,7 @@ const Index = () => {
} finally {
setLoadingDemoIFC(false);
}
}, [setModel]);
}, [addModel]);
const handleLoadDemoCotovelo = useCallback(async () => {
setLoadingDemoCotovelo(true);
@@ -89,7 +90,7 @@ const Index = () => {
const buffer = await response.arrayBuffer();
const result = await convertIFCtoGLB(buffer, 'B126-COTOVELO.ifc');
const url = URL.createObjectURL(result.blob);
setModel({ fileName: result.fileName, fileSize: result.fileSize, url });
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
toast.success('Modelo demo "B126 Cotovelo" (IFC) carregado!');
} catch (err) {
console.error(err);
@@ -97,7 +98,7 @@ const Index = () => {
} finally {
setLoadingDemoCotovelo(false);
}
}, [setModel]);
}, [addModel]);
const handleEnterViewer = () => {
if (!model) {
@@ -170,20 +171,16 @@ const Index = () => {
</div>
{model &&
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4 glow-primary">
<div className="flex items-center gap-3">
<Box className="h-5 w-5 text-primary" />
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-sm font-medium text-foreground">{model.fileName}</p>
<p className="text-xs text-muted-foreground">
{(model.fileSize / (1024 * 1024)).toFixed(2)} MB
{models.length > 0 && (
<div className="rounded-lg border border-primary/30 bg-primary/5 p-3 glow-primary">
<SceneModelList />
<p className="mt-2 text-center font-mono text-[10px] text-muted-foreground">
{models.length < maxModels
? `Você pode adicionar mais ${maxModels - models.length} peça(s) à cena`
: `Limite de ${maxModels} peças simultâneas atingido`}
</p>
</div>
<CheckCircle className="h-5 w-5 text-success shrink-0" />
</div>
</div>
}
)}
{/* Enter viewer */}
<Button
+85 -8
View File
@@ -1,5 +1,5 @@
import { useNavigate } from "react-router-dom";
import { ArrowLeft, Glasses, Box, Ruler, FileText } from "lucide-react";
import { ArrowLeft, Glasses, Box, Ruler, FileText, Upload, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useModelStore } from "@/stores/useModelStore";
import { ModelViewerCanvas } from "@/components/three/ModelViewer";
@@ -9,14 +9,20 @@ import { FineTuningControls } from "@/components/FineTuningControls";
import { MeasurementsList } from "@/components/MeasurementsList";
import { ScreenshotGallery } from "@/components/ScreenshotGallery";
import { ShareButton } from "@/components/ShareButton";
import { useEffect } from "react";
import { CloudLoader } from "@/components/CloudLoader";
import { SceneModelList } from "@/components/SceneModelList";
import { useEffect, useRef, useCallback, useState } from "react";
import { toast } from "sonner";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from "@/lib/convertToGLB";
import { convertIFCtoGLB } from "@/lib/convertIFC";
const Viewer = () => {
const navigate = useNavigate();
const { model, xrSupported } = useModelStore();
const { model, xrSupported, addModel, models, maxModels } = useModelStore();
const fileInputRef = useRef<HTMLInputElement>(null);
const [converting, setConverting] = useState(false);
useEffect(() => {
if (!model) {
@@ -24,6 +30,42 @@ const Viewer = () => {
}
}, [model, navigate]);
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
if (models.length >= maxModels) {
toast.error(`Limite de ${maxModels} peças simultâneas atingido`);
return;
}
const ext = getSupportedExtension(file.name);
if (!ext) {
toast.error('Formato inválido. Selecione .GLB, .OBJ, .STL ou .IFC');
return;
}
if (ext === 'glb') {
const url = URL.createObjectURL(file);
addModel({ fileName: file.name, fileSize: file.size, url });
toast.success(`"${file.name}" adicionado à cena`);
return;
}
setConverting(true);
try {
const buffer = await file.arrayBuffer();
const result = ext === 'ifc'
? await convertIFCtoGLB(buffer, file.name)
: await convertToGLB(buffer, ext, file.name);
const url = URL.createObjectURL(result.blob);
addModel({ fileName: result.fileName, fileSize: result.fileSize, url });
toast.success(`"${file.name}" convertido e adicionado!`);
} catch (err) {
console.error(err);
toast.error(`Falha ao converter "${file.name}"`);
} finally {
setConverting(false);
}
}, [addModel, models.length, maxModels]);
if (!model) return null;
const handleEnterXR = async () => {
@@ -34,8 +76,18 @@ const Viewer = () => {
navigate("/xr");
};
const canAddMore = models.length < maxModels;
return (
<div className="flex h-screen flex-col bg-background">
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_EXTENSIONS}
className="hidden"
onChange={handleFileUpload}
/>
{/* Top bar */}
<header className="flex items-center justify-between border-b bg-card px-4 py-3">
<div className="flex items-center gap-3">
@@ -62,7 +114,7 @@ const Viewer = () => {
<div className="flex flex-1 overflow-hidden">
{/* 3D Canvas with floating controls */}
<div className="relative flex-1">
<ModelViewerCanvas url={model.url} />
<ModelViewerCanvas />
<ViewerControls />
</div>
@@ -71,8 +123,33 @@ const Viewer = () => {
<ScrollArea className="h-full">
<div className="p-4 space-y-5">
<div>
<h2 className="mb-3 font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Informações do Modelo
<div className="flex items-center justify-between mb-3">
<h2 className="font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Cena · {models.length}/{maxModels} peças
</h2>
</div>
<SceneModelList />
<div className="mt-2 grid grid-cols-2 gap-1.5">
<Button
variant="outline"
size="sm"
className="h-8 gap-1.5 text-[10px] font-mono"
onClick={() => fileInputRef.current?.click()}
disabled={!canAddMore || converting}
title={canAddMore ? 'Adicionar arquivo local' : 'Limite atingido'}
>
{converting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
{converting ? 'Convertendo…' : 'Adicionar'}
</Button>
<CloudLoader compact variant="outline" />
</div>
</div>
<Separator />
<div>
<h2 className="mb-2 font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Peça selecionada
</h2>
<div className="space-y-3">
<InfoItem icon={FileText} label="Arquivo" value={model.fileName} />
@@ -98,8 +175,8 @@ const Viewer = () => {
<ScreenshotGallery />
<div className="rounded-lg border bg-muted/50 p-3">
<p className="font-mono text-xs text-muted-foreground">
Use o mouse para orbitar, scroll para zoom. Controle a opacidade e o modo de renderização nos
controles flutuantes.
Clique em uma peça na cena para selecioná-la os controles de ajuste fino
passam a operar sobre ela. Use "Adicionar" / "Nuvem" para empilhar peças.
</p>
</div>
</div>
+37 -11
View File
@@ -32,10 +32,11 @@ const store = createXRStore({
});
// ─── XRModel ───────────────────────────────────────────
function XRModel({ url }: { url: string }) {
const { scene } = useGLTF(url);
function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').SceneModel }) {
const { scene: rawScene } = useGLTF(sceneModel.url);
const scene = useMemo(() => rawScene.clone(true), [rawScene]);
const ref = useRef<THREE.Group>(null);
const fineTuning = useModelStore((s) => s.fineTuning);
const fineTuning = sceneModel.fineTuning;
const opacity = useModelStore((s) => s.opacity);
const renderMode = useModelStore((s) => s.renderMode);
const wireframeColor = useModelStore((s) => s.wireframeColor);
@@ -64,7 +65,6 @@ function XRModel({ url }: { url: string }) {
child.material = child.material.clone();
}
// Clean up previous edge lines
const toRemove: THREE.Object3D[] = [];
child.children.forEach(c => {
if (c.userData.__edgeLine) toRemove.push(c);
@@ -95,7 +95,7 @@ function XRModel({ url }: { url: string }) {
} else if (allApproved) {
mat.color.setHSL(0.38, 0.7, 0.45);
} else {
mat.color.set(0x8899aa);
mat.color.set(sceneModel.color);
}
}
mat.needsUpdate = true;
@@ -111,7 +111,7 @@ function XRModel({ url }: { url: string }) {
}
}
});
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle]);
}, [scene, opacity, renderMode, checklist, wireframeColor, wireframeThickness, edgeThresholdAngle, sceneModel.color]);
const rotXRad = (fineTuning.rotX * Math.PI) / 180;
const rotYRad = (fineTuning.rotY * Math.PI) / 180;
@@ -120,6 +120,8 @@ function XRModel({ url }: { url: string }) {
const scaleRatio = useModelStore((st) => st.scaleRatio);
const renderFactor = scaleRatio?.factor ?? 1;
if (!sceneModel.visible) return null;
return (
<group scale={[renderFactor, renderFactor, renderFactor]}>
<group
@@ -138,6 +140,28 @@ function XRModel({ url }: { url: string }) {
);
}
/** Renders all NON-active models as static background (placement + grab apply only to active). */
function XRBackgroundModels() {
const models = useModelStore((s) => s.models);
const activeId = useModelStore((s) => s.activeModelId);
return (
<>
{models.filter(m => m.id !== activeId).map((m) => (
<XRModel key={m.id} sceneModel={m} />
))}
</>
);
}
/** Renders the currently-active model (the one wrapped by grab/placement). */
function XRActiveModel() {
const models = useModelStore((s) => s.models);
const activeId = useModelStore((s) => s.activeModelId);
const active = models.find(m => m.id === activeId);
if (!active) return null;
return <XRModel sceneModel={active} />;
}
// ─── ControllerFineTuning ──────────────────────────────
// Joystick for slow numeric adjustments. Disabled while any grip is held
// (grab takes priority). Trigger pressure modulates speed: light = fine,
@@ -509,14 +533,15 @@ const XRSession = () => {
{effectiveInXR ? (
<>
{/* In XR (or SimXR): grab wrapper → model. Skip hit-test in SimXR mode. */}
{/* In XR (or SimXR): grab wrapper → ACTIVE model. Other models render as static background. */}
<XRBackgroundModels />
{simXR ? (
<group position={[0, 1.0, -1.2]}>
<XRGrabbable
allowScale={allowScale}
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
>
<XRModel url={model.url} />
<XRActiveModel />
</XRGrabbable>
</group>
) : (
@@ -532,7 +557,7 @@ const XRSession = () => {
allowScale={allowScale}
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
>
<XRModel url={model.url} />
<XRActiveModel />
</XRGrabbable>
</XRHitTestPlacement>
)}
@@ -556,9 +581,10 @@ const XRSession = () => {
</>
) : (
<>
{/* Desktop preview (no headset): show model with OrbitControls */}
{/* Desktop preview (no headset): show all models with OrbitControls */}
<group position={[0, 0, 0]}>
<XRModel url={model.url} />
<XRBackgroundModels />
<XRActiveModel />
</group>
<XRMeasurementOverlay />
<OrbitControls
+138 -7
View File
@@ -37,6 +37,20 @@ interface FineTuning {
scale: number;
}
export interface SceneModel {
id: string;
fileName: string;
fileSize: number;
url: string;
color: string;
visible: boolean;
locked: boolean;
fineTuning: FineTuning;
}
const MAX_MODELS = 5;
const MODEL_COLORS = ['#3b82f6', '#22c55e', '#eab308', '#a855f7', '#ec4899'];
type AnchorMode = 'tracking' | 'manual' | 'fine-tuning';
type InspectionResult = 'pending' | 'approved' | 'rejected';
type RenderMode = 'solid' | 'wireframe' | 'edges';
@@ -75,7 +89,22 @@ const defaultChecklist: ChecklistItem[] = [
{ id: 'weld', label: 'Especificação da Solda', status: 'pending', note: '', audioUrl: null },
];
const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
interface ModelStore {
// ── Multi-model scene ─────────────
models: SceneModel[];
activeModelId: string | null;
addModel: (m: ModelInfo) => string; // returns id
removeModel: (id: string) => void;
setActiveModel: (id: string | null) => void;
toggleModelVisible: (id: string) => void;
toggleModelLocked: (id: string) => void;
duplicateModel: (id: string) => void;
clearAllModels: () => void;
maxModels: number;
// ── Legacy single-model accessors (proxy to active) ─────────────
model: ModelInfo | null;
setModel: (model: ModelInfo | null) => void;
@@ -144,15 +173,119 @@ interface ModelStore {
clearScreenshots: () => void;
}
const defaultFineTuning: FineTuning = { posX: 0, posY: 0, posZ: 0, rotX: 0, rotY: 0, rotZ: 0, scale: 1 };
/** Sync top-level legacy `model` / `fineTuning` from active SceneModel. */
function syncActive(state: Pick<ModelStore, 'models' | 'activeModelId'>): { model: ModelInfo | null; fineTuning: FineTuning } {
const active = state.models.find(m => m.id === state.activeModelId) ?? null;
return {
model: active ? { fileName: active.fileName, fileSize: active.fileSize, url: active.url } : null,
fineTuning: active ? { ...active.fineTuning } : { ...defaultFineTuning },
};
}
export const useModelStore = create<ModelStore>((set) => ({
export const useModelStore = create<ModelStore>((set, get) => ({
// ── Multi-model ─────────────
models: [],
activeModelId: null,
maxModels: MAX_MODELS,
addModel: (m) => {
const state = get();
if (state.models.length >= MAX_MODELS) {
return '';
}
const id = `mdl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const color = MODEL_COLORS[state.models.length % MODEL_COLORS.length];
// Stagger position so multiple models don't perfectly overlap
const offsetIdx = state.models.length;
const newModel: SceneModel = {
id,
fileName: m.fileName,
fileSize: m.fileSize,
url: m.url,
color,
visible: true,
locked: false,
fineTuning: { ...defaultFineTuning, posX: offsetIdx * 0.15 },
};
const models = [...state.models, newModel];
set({ models, activeModelId: id, ...syncActive({ models, activeModelId: id }) });
return id;
},
removeModel: (id) => {
const state = get();
const models = state.models.filter(m => m.id !== id);
const activeModelId = state.activeModelId === id
? (models[0]?.id ?? null)
: state.activeModelId;
set({ models, activeModelId, ...syncActive({ models, activeModelId }) });
},
setActiveModel: (id) => {
const state = get();
set({ activeModelId: id, ...syncActive({ models: state.models, activeModelId: id }) });
},
toggleModelVisible: (id) => set((state) => ({
models: state.models.map(m => m.id === id ? { ...m, visible: !m.visible } : m),
})),
toggleModelLocked: (id) => set((state) => ({
models: state.models.map(m => m.id === id ? { ...m, locked: !m.locked } : m),
})),
duplicateModel: (id) => {
const state = get();
if (state.models.length >= MAX_MODELS) return;
const src = state.models.find(m => m.id === id);
if (!src) return;
const newId = `mdl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const color = MODEL_COLORS[state.models.length % MODEL_COLORS.length];
const copy: SceneModel = {
...src,
id: newId,
color,
fineTuning: { ...src.fineTuning, posX: src.fineTuning.posX + 0.15 },
};
const models = [...state.models, copy];
set({ models, activeModelId: newId, ...syncActive({ models, activeModelId: newId }) });
},
clearAllModels: () => set({ models: [], activeModelId: null, model: null, fineTuning: { ...defaultFineTuning } }),
// ── Legacy single-model proxies ─────────────
model: null,
setModel: (model) => set({ model }),
setModel: (m) => {
if (!m) {
set({ models: [], activeModelId: null, model: null, fineTuning: { ...defaultFineTuning } });
return;
}
// Legacy: replace all models with this single one
const id = `mdl_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
const newModel: SceneModel = {
id, fileName: m.fileName, fileSize: m.fileSize, url: m.url,
color: MODEL_COLORS[0], visible: true, locked: false,
fineTuning: { ...defaultFineTuning },
};
set({ models: [newModel], activeModelId: id, ...syncActive({ models: [newModel], activeModelId: id }) });
},
fineTuning: { ...defaultFineTuning },
setFineTuning: (ft) => set((state) => ({ fineTuning: { ...state.fineTuning, ...ft } })),
resetFineTuning: () => set({ fineTuning: { ...defaultFineTuning } }),
setFineTuning: (ft) => set((state) => {
const newFT = { ...state.fineTuning, ...ft };
// Also write to the active model's fineTuning
const models = state.activeModelId
? state.models.map(m => m.id === state.activeModelId ? { ...m, fineTuning: newFT } : m)
: state.models;
return { fineTuning: newFT, models };
}),
resetFineTuning: () => set((state) => {
const reset = { ...defaultFineTuning };
const models = state.activeModelId
? state.models.map(m => m.id === state.activeModelId ? { ...m, fineTuning: reset } : m)
: state.models;
return { fineTuning: reset, models };
}),
anchorMode: 'manual',
setAnchorMode: (anchorMode) => set({ anchorMode }),
@@ -213,8 +346,6 @@ export const useModelStore = create<ModelStore>((set) => ({
const dy = (a.y - b.y) * 1000;
const dz = (a.z - b.z) * 1000;
const distanceWorldMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
// Compensate for current render scale: world ≠ real when scaled.
// realMM = worldMM / factor (factor 1/50 → multiply by 50).
const factor = state.scaleRatio?.factor ?? 1;
const distanceMM = distanceWorldMM / factor;
const measurement: Measurement = {