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:
+49
-105
@@ -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. Store (`useModelStore.ts`)
|
||||||
1. No HUD do `/xr` aparece novo botão **"Compartilhar"** (ícone Share/Cast)
|
- Substituir `model: ModelInfo | null` por `models: SceneModel[]` (até 5 simultâneos para manter 72 FPS no Quest 3).
|
||||||
2. Ao clicar: app gera código de 6 caracteres, mostra QR + link encurtado
|
- Cada `SceneModel` tem: `id`, `fileName`, `url`, `fineTuning` próprio, `visible`, `color` (cor de identificação), `locked`.
|
||||||
3. Botões "Copiar link" e "Compartilhar" (Web Share API → abre WhatsApp/email do sistema)
|
- Adicionar `activeModelId: string | null` — o "modelo selecionado" recebe os controles de fine-tuning, escala e medição.
|
||||||
4. Indicador vermelho 🔴 LIVE fica visível enquanto transmite, com contador de viewers
|
- Ações: `addModel`, `removeModel`, `duplicateModel`, `setActiveModel`, `updateModelTransform`, `toggleVisibility`.
|
||||||
5. Botão "Encerrar" para parar
|
- Compatibilidade: `model` (singular) continua exposto como getter derivado do `activeModelId` para não quebrar telas legadas.
|
||||||
|
|
||||||
**No celular/PC (viewer):**
|
### 2. Cena 3D (`ModelViewer.tsx`)
|
||||||
1. Abre o link → tela cheia com vídeo da sessão XR
|
- Renderizar `models.map(m => <GLBModel key={m.id} model={m} isActive={m.id === activeModelId} />)`.
|
||||||
2. Sem login, sem instalar nada
|
- Cada `<GLBModel>` aplica seu próprio transform (já existe a lógica, só vira por-instância).
|
||||||
3. Mostra "Aguardando inspetor…" se a sala não estiver ativa
|
- Outline/halo sutil no modelo ativo para feedback visual.
|
||||||
4. Read-only: só assiste
|
- 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
|
### 4. UI XR/AR (`XRHud.tsx`)
|
||||||
Quest 3 (broadcaster) Lovable Cloud Viewers (celular/PC)
|
- Painel flutuante com a lista de peças (mesmos botões compactos).
|
||||||
┌──────────────────┐ ┌─────────────────────┐ ┌──────────────────┐
|
- Botão "Adicionar" abre o `CloudLoader` já existente em modo append.
|
||||||
│ /xr │ │ Supabase Realtime │ │ /watch/AB12CD │
|
- Gestos do controle: o grab (já implementado em `XRGrabbable`) move só o modelo ativo / o que foi pinçado diretamente.
|
||||||
│ - 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) ════════════════╝
|
|
||||||
```
|
|
||||||
|
|
||||||
**Stack:**
|
### 5. Funcionalidades dependentes
|
||||||
- **Lovable Cloud** (precisa habilitar): tabela `share_rooms` + canal Realtime para signaling
|
- **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.
|
||||||
- **WebRTC nativo do browser** (`RTCPeerConnection`) — sem libs pesadas
|
- **Checklist**: por enquanto continua global (uma inspeção por sessão). Pode-se evoluir depois para checklist por peça.
|
||||||
- **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
|
- **Screenshot/PDF**: capturam a cena inteira (todas as peças visíveis), sem mudança.
|
||||||
- **Sem servidor de mídia (SFU):** P2P 1→3 viewers, banda do Quest aguenta tranquilo (~2 Mbps por viewer)
|
- **Compare mode**: opera sobre a cena toda, sem mudança.
|
||||||
|
|
||||||
## Estrutura de arquivos
|
## Limitações conhecidas
|
||||||
|
|
||||||
**Novos:**
|
- **Performance**: GLB grandes somados podem derrubar FPS no Quest 3. Vou impor limite de **5 modelos simultâneos** com aviso.
|
||||||
- `src/pages/Watch.tsx` — página pública do viewer (rota `/watch/:code`)
|
- **Conversão IFC**: cada arquivo passa pelo conversor cliente (`convertIFC.ts`) já existente — sem mudança.
|
||||||
- `src/components/ShareButton.tsx` — botão + dialog com QR/link no HUD
|
- **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.
|
||||||
- `src/lib/webrtc/broadcaster.ts` — lógica do lado Quest (cria offer, gerencia múltiplos peers)
|
- **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/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
|
|
||||||
|
|
||||||
**Editados:**
|
## Fora do escopo desta v1
|
||||||
- `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 }`
|
|
||||||
|
|
||||||
## 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
|
## Arquivos afetados
|
||||||
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
|
|
||||||
);
|
|
||||||
|
|
||||||
-- RLS: leitura pública (viewers precisam saber se sala existe), escrita só anon controlada
|
- `src/stores/useModelStore.ts` — refactor central
|
||||||
alter table public.share_rooms enable row level security;
|
- `src/components/three/ModelViewer.tsx` — render por instância
|
||||||
create policy "anyone reads active rooms" on public.share_rooms
|
- `src/pages/Viewer.tsx` — painel de cena + botão "Adicionar"
|
||||||
for select using (is_active and expires_at > now());
|
- `src/components/XRHud.tsx` — lista de peças no AR
|
||||||
create policy "anyone creates room" on public.share_rooms
|
- `src/components/FineTuningControls.tsx` — opera no ativo
|
||||||
for insert with check (true);
|
- `src/components/ScaleSelector.tsx` — opera no ativo
|
||||||
create policy "anyone updates count" on public.share_rooms
|
- `src/components/three/XRGrabbable.tsx` — grab por instância
|
||||||
for update using (true);
|
- novo `src/components/SceneModelList.tsx` — componente reutilizável da lista
|
||||||
```
|
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -93,13 +93,19 @@ interface CloudLoaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function CloudLoader({ compact = false, variant = 'outline', className }: 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 [open, setOpen] = useState(false);
|
||||||
const [url, setUrl] = useState(DEFAULT_URL);
|
const [url, setUrl] = useState(DEFAULT_URL);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const handleLoad = useCallback(async (rawUrl: string) => {
|
const handleLoad = useCallback(async (rawUrl: string) => {
|
||||||
if (!rawUrl) return;
|
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);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const fileName = rawUrl.split('/').pop()?.split('?')[0] || 'modelo-cloud';
|
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;
|
blob = r.blob; outName = r.fileName; outSize = r.fileSize;
|
||||||
}
|
}
|
||||||
const blobUrl = URL.createObjectURL(blob);
|
const blobUrl = URL.createObjectURL(blob);
|
||||||
setModel({ fileName: outName, fileSize: outSize, url: blobUrl });
|
addModel({ fileName: outName, fileSize: outSize, url: blobUrl });
|
||||||
toast.success(`"${outName}" carregado da nuvem`);
|
toast.success(`"${outName}" adicionado à cena`);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -132,7 +138,7 @@ export function CloudLoader({ compact = false, variant = 'outline', className }:
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [setModel]);
|
}, [addModel, modelsCount, maxModels]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import { InspectionChecklist } from '@/components/InspectionChecklist';
|
|||||||
import { ScaleSelector } from '@/components/ScaleSelector';
|
import { ScaleSelector } from '@/components/ScaleSelector';
|
||||||
import { CloudLoader } from '@/components/CloudLoader';
|
import { CloudLoader } from '@/components/CloudLoader';
|
||||||
import { ShareButton } from '@/components/ShareButton';
|
import { ShareButton } from '@/components/ShareButton';
|
||||||
|
import { SceneModelList } from '@/components/SceneModelList';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const POSITION_STEP = 0.001;
|
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">
|
<div className="pointer-events-auto mx-auto max-w-lg space-y-2">
|
||||||
{/* Expanded panel */}
|
{/* Expanded panel */}
|
||||||
{expanded && (
|
{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 */}
|
{/* Opacity */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-1.5">
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
|||||||
@@ -3,11 +3,11 @@ import { Canvas, useThree, useFrame } from '@react-three/fiber';
|
|||||||
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
|
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
import * as THREE from 'three';
|
import * as THREE from 'three';
|
||||||
import { useModelStore } from '@/stores/useModelStore';
|
import { useModelStore, type SceneModel } from '@/stores/useModelStore';
|
||||||
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment } from './SmartMeasure';
|
import { findNearestVertex, detectHoleAtFace, findNearestEdgeSegment } from './SmartMeasure';
|
||||||
|
|
||||||
interface ModelViewerProps {
|
interface ModelViewerProps {
|
||||||
url: string;
|
url?: string; // legacy, ignored — uses store.models
|
||||||
}
|
}
|
||||||
|
|
||||||
function LoadingFallback() {
|
function LoadingFallback() {
|
||||||
@@ -21,8 +21,10 @@ function LoadingFallback() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function GLBModel({ url }: { url: string }) {
|
function GLBModel({ sceneModel, isActive }: { sceneModel: SceneModel; isActive: boolean }) {
|
||||||
const { scene } = useGLTF(url);
|
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 ref = useRef<THREE.Group>(null);
|
||||||
const opacity = useModelStore((s) => s.opacity);
|
const opacity = useModelStore((s) => s.opacity);
|
||||||
const renderMode = useModelStore((s) => s.renderMode);
|
const renderMode = useModelStore((s) => s.renderMode);
|
||||||
@@ -30,7 +32,8 @@ function GLBModel({ url }: { url: string }) {
|
|||||||
const wireframeThickness = useModelStore((s) => s.wireframeThickness);
|
const wireframeThickness = useModelStore((s) => s.wireframeThickness);
|
||||||
const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle);
|
const edgeThresholdAngle = useModelStore((s) => s.edgeThresholdAngle);
|
||||||
const checklist = useModelStore((s) => s.checklist);
|
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 modelInfo = useMemo(() => {
|
||||||
const box = new THREE.Box3().setFromObject(scene);
|
const box = new THREE.Box3().setFromObject(scene);
|
||||||
@@ -41,7 +44,6 @@ function GLBModel({ url }: { url: string }) {
|
|||||||
return { size, center };
|
return { size, center };
|
||||||
}, [scene]);
|
}, [scene]);
|
||||||
|
|
||||||
// Store original colors so we can restore them
|
|
||||||
const originalColors = useRef<Map<THREE.Material, THREE.Color>>(new Map());
|
const originalColors = useRef<Map<THREE.Material, THREE.Color>>(new Map());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,7 +58,6 @@ function GLBModel({ url }: { url: string }) {
|
|||||||
child.material = child.material.clone();
|
child.material = child.material.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up previous edge lines
|
|
||||||
const toRemove: THREE.Object3D[] = [];
|
const toRemove: THREE.Object3D[] = [];
|
||||||
child.children.forEach(c => {
|
child.children.forEach(c => {
|
||||||
if (c.userData.__edgeLine) toRemove.push(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];
|
const materials = Array.isArray(child.material) ? child.material : [child.material];
|
||||||
materials.forEach((mat: THREE.Material) => {
|
materials.forEach((mat: THREE.Material) => {
|
||||||
if (mat instanceof THREE.MeshStandardMaterial) {
|
if (mat instanceof THREE.MeshStandardMaterial) {
|
||||||
// Store original color on first encounter
|
|
||||||
if (!originalColors.current.has(mat)) {
|
if (!originalColors.current.has(mat)) {
|
||||||
originalColors.current.set(mat, mat.color.clone());
|
originalColors.current.set(mat, mat.color.clone());
|
||||||
}
|
}
|
||||||
@@ -92,14 +92,14 @@ function GLBModel({ url }: { url: string }) {
|
|||||||
} else if (allApproved) {
|
} else if (allApproved) {
|
||||||
mat.color.setHSL(0.38, 0.7, 0.45);
|
mat.color.setHSL(0.38, 0.7, 0.45);
|
||||||
} else {
|
} else {
|
||||||
mat.color.set(0x8899aa);
|
// Tint with the per-model color (subtle)
|
||||||
|
mat.color.set(sceneModel.color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mat.needsUpdate = true;
|
mat.needsUpdate = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Create edge lines for edges mode
|
|
||||||
if (renderMode === 'edges' && child.geometry) {
|
if (renderMode === 'edges' && child.geometry) {
|
||||||
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
|
const edgesGeo = new THREE.EdgesGeometry(child.geometry, edgeThresholdAngle);
|
||||||
const lineMat = new THREE.LineBasicMaterial({
|
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 rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
||||||
const rotYRad = (fineTuning.rotY * 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 scaleRatio = useModelStore((st) => st.scaleRatio);
|
||||||
const renderFactor = scaleRatio?.factor ?? 1;
|
const renderFactor = scaleRatio?.factor ?? 1;
|
||||||
|
|
||||||
|
if (!sceneModel.visible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group scale={[renderFactor, renderFactor, renderFactor]}>
|
<group
|
||||||
|
scale={[renderFactor, renderFactor, renderFactor]}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setActive(sceneModel.id); }}
|
||||||
|
>
|
||||||
<group
|
<group
|
||||||
ref={ref}
|
ref={ref}
|
||||||
position={[
|
position={[
|
||||||
@@ -134,11 +139,29 @@ function GLBModel({ url }: { url: string }) {
|
|||||||
scale={[s, s, s]}
|
scale={[s, s, s]}
|
||||||
>
|
>
|
||||||
<primitive object={scene} />
|
<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>
|
||||||
</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 */
|
/** Sphere marker at a 3D point */
|
||||||
function PointMarker({ position, color = '#e8a838' }: { position: [number, number, number]; color?: string }) {
|
function PointMarker({ position, color = '#e8a838' }: { position: [number, number, number]; color?: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -472,7 +495,7 @@ export function ModelViewerCanvas({ url }: ModelViewerProps) {
|
|||||||
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
|
<directionalLight position={[-5, 5, -5]} intensity={0.3} />
|
||||||
|
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<GLBModel url={url} />
|
<SceneModels />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
<MeasurementOverlay />
|
<MeasurementOverlay />
|
||||||
|
|||||||
+18
-21
@@ -7,6 +7,7 @@ import { toast } from 'sonner';
|
|||||||
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from '@/lib/convertToGLB';
|
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from '@/lib/convertToGLB';
|
||||||
import { convertIFCtoGLB } from '@/lib/convertIFC';
|
import { convertIFCtoGLB } from '@/lib/convertIFC';
|
||||||
import { CloudLoader } from '@/components/CloudLoader';
|
import { CloudLoader } from '@/components/CloudLoader';
|
||||||
|
import { SceneModelList } from '@/components/SceneModelList';
|
||||||
|
|
||||||
const Index = () => {
|
const Index = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -15,7 +16,7 @@ const Index = () => {
|
|||||||
const [loadingDemoCotovelo, setLoadingDemoCotovelo] = useState(false);
|
const [loadingDemoCotovelo, setLoadingDemoCotovelo] = useState(false);
|
||||||
|
|
||||||
const [converting, setConverting] = useState(false);
|
const [converting, setConverting] = useState(false);
|
||||||
const { model, setModel, xrSupported, setXrSupported } = useModelStore();
|
const { model, addModel, models, maxModels, xrSupported, setXrSupported } = useModelStore();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (navigator.xr) {
|
if (navigator.xr) {
|
||||||
@@ -37,7 +38,7 @@ const Index = () => {
|
|||||||
|
|
||||||
if (ext === 'glb') {
|
if (ext === 'glb') {
|
||||||
const url = URL.createObjectURL(file);
|
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!`);
|
toast.success(`Modelo "${file.name}" carregado com sucesso!`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -53,7 +54,7 @@ const Index = () => {
|
|||||||
result = await convertToGLB(buffer, ext, file.name);
|
result = await convertToGLB(buffer, ext, file.name);
|
||||||
}
|
}
|
||||||
const url = URL.createObjectURL(result.blob);
|
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!`);
|
toast.success(`"${file.name}" convertido para GLB e carregado!`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -61,7 +62,7 @@ const Index = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setConverting(false);
|
setConverting(false);
|
||||||
}
|
}
|
||||||
}, [setModel]);
|
}, [addModel]);
|
||||||
|
|
||||||
// Carregar da Nuvem agora é encapsulado em <CloudLoader />
|
// Carregar da Nuvem agora é encapsulado em <CloudLoader />
|
||||||
|
|
||||||
@@ -72,7 +73,7 @@ const Index = () => {
|
|||||||
const buffer = await response.arrayBuffer();
|
const buffer = await response.arrayBuffer();
|
||||||
const result = await convertIFCtoGLB(buffer, 'demo250hp.ifc');
|
const result = await convertIFCtoGLB(buffer, 'demo250hp.ifc');
|
||||||
const url = URL.createObjectURL(result.blob);
|
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!');
|
toast.success('Modelo demo "250 HP" (IFC) carregado!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -80,7 +81,7 @@ const Index = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingDemoIFC(false);
|
setLoadingDemoIFC(false);
|
||||||
}
|
}
|
||||||
}, [setModel]);
|
}, [addModel]);
|
||||||
|
|
||||||
const handleLoadDemoCotovelo = useCallback(async () => {
|
const handleLoadDemoCotovelo = useCallback(async () => {
|
||||||
setLoadingDemoCotovelo(true);
|
setLoadingDemoCotovelo(true);
|
||||||
@@ -89,7 +90,7 @@ const Index = () => {
|
|||||||
const buffer = await response.arrayBuffer();
|
const buffer = await response.arrayBuffer();
|
||||||
const result = await convertIFCtoGLB(buffer, 'B126-COTOVELO.ifc');
|
const result = await convertIFCtoGLB(buffer, 'B126-COTOVELO.ifc');
|
||||||
const url = URL.createObjectURL(result.blob);
|
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!');
|
toast.success('Modelo demo "B126 Cotovelo" (IFC) carregado!');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@@ -97,7 +98,7 @@ const Index = () => {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoadingDemoCotovelo(false);
|
setLoadingDemoCotovelo(false);
|
||||||
}
|
}
|
||||||
}, [setModel]);
|
}, [addModel]);
|
||||||
|
|
||||||
const handleEnterViewer = () => {
|
const handleEnterViewer = () => {
|
||||||
if (!model) {
|
if (!model) {
|
||||||
@@ -170,20 +171,16 @@ const Index = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
{model &&
|
{models.length > 0 && (
|
||||||
<div className="rounded-lg border border-primary/30 bg-primary/5 p-4 glow-primary">
|
<div className="rounded-lg border border-primary/30 bg-primary/5 p-3 glow-primary">
|
||||||
<div className="flex items-center gap-3">
|
<SceneModelList />
|
||||||
<Box className="h-5 w-5 text-primary" />
|
<p className="mt-2 text-center font-mono text-[10px] text-muted-foreground">
|
||||||
<div className="min-w-0 flex-1">
|
{models.length < maxModels
|
||||||
<p className="truncate font-mono text-sm font-medium text-foreground">{model.fileName}</p>
|
? `Você pode adicionar mais ${maxModels - models.length} peça(s) à cena`
|
||||||
<p className="text-xs text-muted-foreground">
|
: `Limite de ${maxModels} peças simultâneas atingido`}
|
||||||
{(model.fileSize / (1024 * 1024)).toFixed(2)} MB
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<CheckCircle className="h-5 w-5 text-success shrink-0" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
)}
|
||||||
|
|
||||||
{/* Enter viewer */}
|
{/* Enter viewer */}
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
+85
-8
@@ -1,5 +1,5 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { useModelStore } from "@/stores/useModelStore";
|
import { useModelStore } from "@/stores/useModelStore";
|
||||||
import { ModelViewerCanvas } from "@/components/three/ModelViewer";
|
import { ModelViewerCanvas } from "@/components/three/ModelViewer";
|
||||||
@@ -9,14 +9,20 @@ import { FineTuningControls } from "@/components/FineTuningControls";
|
|||||||
import { MeasurementsList } from "@/components/MeasurementsList";
|
import { MeasurementsList } from "@/components/MeasurementsList";
|
||||||
import { ScreenshotGallery } from "@/components/ScreenshotGallery";
|
import { ScreenshotGallery } from "@/components/ScreenshotGallery";
|
||||||
import { ShareButton } from "@/components/ShareButton";
|
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 { toast } from "sonner";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { getSupportedExtension, convertToGLB, ACCEPTED_EXTENSIONS } from "@/lib/convertToGLB";
|
||||||
|
import { convertIFCtoGLB } from "@/lib/convertIFC";
|
||||||
|
|
||||||
const Viewer = () => {
|
const Viewer = () => {
|
||||||
const navigate = useNavigate();
|
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(() => {
|
useEffect(() => {
|
||||||
if (!model) {
|
if (!model) {
|
||||||
@@ -24,6 +30,42 @@ const Viewer = () => {
|
|||||||
}
|
}
|
||||||
}, [model, navigate]);
|
}, [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;
|
if (!model) return null;
|
||||||
|
|
||||||
const handleEnterXR = async () => {
|
const handleEnterXR = async () => {
|
||||||
@@ -34,8 +76,18 @@ const Viewer = () => {
|
|||||||
navigate("/xr");
|
navigate("/xr");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const canAddMore = models.length < maxModels;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-screen flex-col bg-background">
|
<div className="flex h-screen flex-col bg-background">
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPTED_EXTENSIONS}
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<header className="flex items-center justify-between border-b bg-card px-4 py-3">
|
<header className="flex items-center justify-between border-b bg-card px-4 py-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -62,7 +114,7 @@ const Viewer = () => {
|
|||||||
<div className="flex flex-1 overflow-hidden">
|
<div className="flex flex-1 overflow-hidden">
|
||||||
{/* 3D Canvas with floating controls */}
|
{/* 3D Canvas with floating controls */}
|
||||||
<div className="relative flex-1">
|
<div className="relative flex-1">
|
||||||
<ModelViewerCanvas url={model.url} />
|
<ModelViewerCanvas />
|
||||||
<ViewerControls />
|
<ViewerControls />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -71,8 +123,33 @@ const Viewer = () => {
|
|||||||
<ScrollArea className="h-full">
|
<ScrollArea className="h-full">
|
||||||
<div className="p-4 space-y-5">
|
<div className="p-4 space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="mb-3 font-mono text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
<div className="flex items-center justify-between mb-3">
|
||||||
Informações do Modelo
|
<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>
|
</h2>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<InfoItem icon={FileText} label="Arquivo" value={model.fileName} />
|
<InfoItem icon={FileText} label="Arquivo" value={model.fileName} />
|
||||||
@@ -98,8 +175,8 @@ const Viewer = () => {
|
|||||||
<ScreenshotGallery />
|
<ScreenshotGallery />
|
||||||
<div className="rounded-lg border bg-muted/50 p-3">
|
<div className="rounded-lg border bg-muted/50 p-3">
|
||||||
<p className="font-mono text-xs text-muted-foreground">
|
<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
|
Clique em uma peça na cena para selecioná-la — os controles de ajuste fino
|
||||||
controles flutuantes.
|
passam a operar sobre ela. Use "Adicionar" / "Nuvem" para empilhar peças.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+37
-11
@@ -32,10 +32,11 @@ const store = createXRStore({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ─── XRModel ───────────────────────────────────────────
|
// ─── XRModel ───────────────────────────────────────────
|
||||||
function XRModel({ url }: { url: string }) {
|
function XRModel({ sceneModel }: { sceneModel: import('@/stores/useModelStore').SceneModel }) {
|
||||||
const { scene } = useGLTF(url);
|
const { scene: rawScene } = useGLTF(sceneModel.url);
|
||||||
|
const scene = useMemo(() => rawScene.clone(true), [rawScene]);
|
||||||
const ref = useRef<THREE.Group>(null);
|
const ref = useRef<THREE.Group>(null);
|
||||||
const fineTuning = useModelStore((s) => s.fineTuning);
|
const fineTuning = sceneModel.fineTuning;
|
||||||
const opacity = useModelStore((s) => s.opacity);
|
const opacity = useModelStore((s) => s.opacity);
|
||||||
const renderMode = useModelStore((s) => s.renderMode);
|
const renderMode = useModelStore((s) => s.renderMode);
|
||||||
const wireframeColor = useModelStore((s) => s.wireframeColor);
|
const wireframeColor = useModelStore((s) => s.wireframeColor);
|
||||||
@@ -64,7 +65,6 @@ function XRModel({ url }: { url: string }) {
|
|||||||
child.material = child.material.clone();
|
child.material = child.material.clone();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up previous edge lines
|
|
||||||
const toRemove: THREE.Object3D[] = [];
|
const toRemove: THREE.Object3D[] = [];
|
||||||
child.children.forEach(c => {
|
child.children.forEach(c => {
|
||||||
if (c.userData.__edgeLine) toRemove.push(c);
|
if (c.userData.__edgeLine) toRemove.push(c);
|
||||||
@@ -95,7 +95,7 @@ function XRModel({ url }: { url: string }) {
|
|||||||
} else if (allApproved) {
|
} else if (allApproved) {
|
||||||
mat.color.setHSL(0.38, 0.7, 0.45);
|
mat.color.setHSL(0.38, 0.7, 0.45);
|
||||||
} else {
|
} else {
|
||||||
mat.color.set(0x8899aa);
|
mat.color.set(sceneModel.color);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mat.needsUpdate = true;
|
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 rotXRad = (fineTuning.rotX * Math.PI) / 180;
|
||||||
const rotYRad = (fineTuning.rotY * 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 scaleRatio = useModelStore((st) => st.scaleRatio);
|
||||||
const renderFactor = scaleRatio?.factor ?? 1;
|
const renderFactor = scaleRatio?.factor ?? 1;
|
||||||
|
|
||||||
|
if (!sceneModel.visible) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<group scale={[renderFactor, renderFactor, renderFactor]}>
|
<group scale={[renderFactor, renderFactor, renderFactor]}>
|
||||||
<group
|
<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 ──────────────────────────────
|
// ─── ControllerFineTuning ──────────────────────────────
|
||||||
// Joystick for slow numeric adjustments. Disabled while any grip is held
|
// Joystick for slow numeric adjustments. Disabled while any grip is held
|
||||||
// (grab takes priority). Trigger pressure modulates speed: light = fine,
|
// (grab takes priority). Trigger pressure modulates speed: light = fine,
|
||||||
@@ -509,14 +533,15 @@ const XRSession = () => {
|
|||||||
|
|
||||||
{effectiveInXR ? (
|
{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 ? (
|
{simXR ? (
|
||||||
<group position={[0, 1.0, -1.2]}>
|
<group position={[0, 1.0, -1.2]}>
|
||||||
<XRGrabbable
|
<XRGrabbable
|
||||||
allowScale={allowScale}
|
allowScale={allowScale}
|
||||||
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
|
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
|
||||||
>
|
>
|
||||||
<XRModel url={model.url} />
|
<XRActiveModel />
|
||||||
</XRGrabbable>
|
</XRGrabbable>
|
||||||
</group>
|
</group>
|
||||||
) : (
|
) : (
|
||||||
@@ -532,7 +557,7 @@ const XRSession = () => {
|
|||||||
allowScale={allowScale}
|
allowScale={allowScale}
|
||||||
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
|
onGrabStart={() => { if (placementMode) setPlacementMode(false); }}
|
||||||
>
|
>
|
||||||
<XRModel url={model.url} />
|
<XRActiveModel />
|
||||||
</XRGrabbable>
|
</XRGrabbable>
|
||||||
</XRHitTestPlacement>
|
</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]}>
|
<group position={[0, 0, 0]}>
|
||||||
<XRModel url={model.url} />
|
<XRBackgroundModels />
|
||||||
|
<XRActiveModel />
|
||||||
</group>
|
</group>
|
||||||
<XRMeasurementOverlay />
|
<XRMeasurementOverlay />
|
||||||
<OrbitControls
|
<OrbitControls
|
||||||
|
|||||||
+144
-13
@@ -37,6 +37,20 @@ interface FineTuning {
|
|||||||
scale: number;
|
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 AnchorMode = 'tracking' | 'manual' | 'fine-tuning';
|
||||||
type InspectionResult = 'pending' | 'approved' | 'rejected';
|
type InspectionResult = 'pending' | 'approved' | 'rejected';
|
||||||
type RenderMode = 'solid' | 'wireframe' | 'edges';
|
type RenderMode = 'solid' | 'wireframe' | 'edges';
|
||||||
@@ -75,17 +89,32 @@ const defaultChecklist: ChecklistItem[] = [
|
|||||||
{ id: 'weld', label: 'Especificação da Solda', status: 'pending', note: '', audioUrl: null },
|
{ 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 {
|
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;
|
model: ModelInfo | null;
|
||||||
setModel: (model: ModelInfo | null) => void;
|
setModel: (model: ModelInfo | null) => void;
|
||||||
|
|
||||||
fineTuning: FineTuning;
|
fineTuning: FineTuning;
|
||||||
setFineTuning: (ft: Partial<FineTuning>) => void;
|
setFineTuning: (ft: Partial<FineTuning>) => void;
|
||||||
resetFineTuning: () => void;
|
resetFineTuning: () => void;
|
||||||
|
|
||||||
anchorMode: AnchorMode;
|
anchorMode: AnchorMode;
|
||||||
setAnchorMode: (mode: AnchorMode) => void;
|
setAnchorMode: (mode: AnchorMode) => void;
|
||||||
|
|
||||||
inspectionResult: InspectionResult;
|
inspectionResult: InspectionResult;
|
||||||
setInspectionResult: (result: InspectionResult) => void;
|
setInspectionResult: (result: InspectionResult) => void;
|
||||||
|
|
||||||
@@ -144,19 +173,123 @@ interface ModelStore {
|
|||||||
clearScreenshots: () => void;
|
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,
|
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 },
|
fineTuning: { ...defaultFineTuning },
|
||||||
setFineTuning: (ft) => set((state) => ({ fineTuning: { ...state.fineTuning, ...ft } })),
|
setFineTuning: (ft) => set((state) => {
|
||||||
resetFineTuning: () => set({ fineTuning: { ...defaultFineTuning } }),
|
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',
|
anchorMode: 'manual',
|
||||||
setAnchorMode: (anchorMode) => set({ anchorMode }),
|
setAnchorMode: (anchorMode) => set({ anchorMode }),
|
||||||
|
|
||||||
inspectionResult: 'pending',
|
inspectionResult: 'pending',
|
||||||
setInspectionResult: (inspectionResult) => set({ inspectionResult }),
|
setInspectionResult: (inspectionResult) => set({ inspectionResult }),
|
||||||
|
|
||||||
@@ -213,8 +346,6 @@ export const useModelStore = create<ModelStore>((set) => ({
|
|||||||
const dy = (a.y - b.y) * 1000;
|
const dy = (a.y - b.y) * 1000;
|
||||||
const dz = (a.z - b.z) * 1000;
|
const dz = (a.z - b.z) * 1000;
|
||||||
const distanceWorldMM = Math.sqrt(dx * dx + dy * dy + dz * dz);
|
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 factor = state.scaleRatio?.factor ?? 1;
|
||||||
const distanceMM = distanceWorldMM / factor;
|
const distanceMM = distanceWorldMM / factor;
|
||||||
const measurement: Measurement = {
|
const measurement: Measurement = {
|
||||||
|
|||||||
Reference in New Issue
Block a user