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. 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
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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 { 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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user