Added multi-model IFC support

X-Lovable-Edit-ID: edt-ce287174-4728-48f2-b0fd-47b1e3380540
Co-authored-by: Reifonas <211114984+Reifonas@users.noreply.github.com>
This commit is contained in:
gpt-engineer-app[bot]
2026-05-14 16:46:34 +00:00
9 changed files with 505 additions and 176 deletions
+49 -105
View File
@@ -1,118 +1,62 @@
# Plano — Compartilhar tela do Quest 3 via link público ## Resposta curta
Permitir que o inspetor no Quest gere um link tipo `tracksteelxr.lovable.app/watch/AB12CD`, mande no WhatsApp/email, e até 3 espectadores assistam ao vivo (vídeo + áudio do Quest) em qualquer celular ou PC. Sim, é totalmente possível — hoje o app só carrega 1 modelo por vez (`model: ModelInfo | null` no store), mas a arquitetura (Three.js + R3F) suporta naturalmente múltiplas peças em uma mesma cena. Precisa migrar de "modelo único" para "lista de modelos", cada um com seu próprio transform (posição/rotação/escala) e seleção ativa.
## Como vai funcionar para o usuário ## O que muda
**No Quest (broadcaster):** ### 1. 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.
+10 -4
View File
@@ -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}>
+121
View File
@@ -0,0 +1,121 @@
import { Eye, EyeOff, Lock, Unlock, Copy, Trash2, Box, Layers } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useModelStore } from '@/stores/useModelStore';
import { toast } from 'sonner';
interface SceneModelListProps {
compact?: boolean;
}
export function SceneModelList({ compact = false }: SceneModelListProps) {
const models = useModelStore((s) => s.models);
const activeId = useModelStore((s) => s.activeModelId);
const setActive = useModelStore((s) => s.setActiveModel);
const removeModel = useModelStore((s) => s.removeModel);
const toggleVisible = useModelStore((s) => s.toggleModelVisible);
const toggleLocked = useModelStore((s) => s.toggleModelLocked);
const duplicate = useModelStore((s) => s.duplicateModel);
const max = useModelStore((s) => s.maxModels);
if (models.length === 0) {
return (
<div className="rounded-lg border border-dashed bg-muted/20 p-3 text-center">
<Layers className="mx-auto mb-1 h-4 w-4 text-muted-foreground" />
<p className="font-mono text-[10px] text-muted-foreground">Nenhuma peça na cena</p>
</div>
);
}
const textSize = compact ? 'text-[9px]' : 'text-[11px]';
const padding = compact ? 'p-1.5' : 'p-2';
return (
<div className="space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Layers className="h-3 w-3 text-primary" />
<span className={`font-mono ${textSize} font-semibold uppercase tracking-wider text-muted-foreground`}>
Cena
</span>
</div>
<span className={`font-mono ${textSize} text-muted-foreground`}>
{models.length}/{max}
</span>
</div>
<div className="space-y-1">
{models.map((m) => {
const isActive = m.id === activeId;
return (
<div
key={m.id}
className={`flex items-center gap-1 rounded-md border ${padding} transition-colors cursor-pointer ${
isActive
? 'border-primary bg-primary/10'
: 'border-border bg-muted/30 hover:bg-muted/50'
}`}
onClick={() => setActive(m.id)}
>
<div
className="h-2.5 w-2.5 shrink-0 rounded-full ring-1 ring-foreground/20"
style={{ backgroundColor: m.color }}
title="Cor da peça"
/>
<Box className={`h-3 w-3 shrink-0 ${isActive ? 'text-primary' : 'text-muted-foreground'}`} />
<span className={`flex-1 truncate font-mono ${textSize} ${isActive ? 'text-foreground font-semibold' : 'text-muted-foreground'}`}>
{m.fileName}
</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
onClick={(e) => { e.stopPropagation(); toggleVisible(m.id); }}
title={m.visible ? 'Ocultar' : 'Exibir'}
>
{m.visible ? <Eye className="h-3 w-3" /> : <EyeOff className="h-3 w-3 text-muted-foreground" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
onClick={(e) => { e.stopPropagation(); toggleLocked(m.id); }}
title={m.locked ? 'Destravar' : 'Travar'}
>
{m.locked ? <Lock className="h-3 w-3 text-warning" /> : <Unlock className="h-3 w-3 text-muted-foreground" />}
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
onClick={(e) => {
e.stopPropagation();
if (models.length >= max) {
toast.error(`Limite de ${max} peças atingido`);
return;
}
duplicate(m.id);
}}
title="Duplicar"
>
<Copy className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 shrink-0"
onClick={(e) => {
e.stopPropagation();
removeModel(m.id);
toast.success(`"${m.fileName}" removido`);
}}
title="Remover"
>
<Trash2 className="h-3 w-3 text-destructive" />
</Button>
</div>
);
})}
</div>
</div>
);
}
+5 -1
View File
@@ -12,6 +12,7 @@ import { InspectionChecklist } from '@/components/InspectionChecklist';
import { ScaleSelector } from '@/components/ScaleSelector'; import { 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">
+36 -13
View File
@@ -3,11 +3,11 @@ import { Canvas, useThree, useFrame } from '@react-three/fiber';
import { OrbitControls, useGLTF, Grid, Html, Line } from '@react-three/drei'; import { 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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 = {