import { useNavigate, useSearchParams } from "react-router-dom"; import { supabase } from "@/integrations/supabase/client"; import { ArrowLeft, Glasses, Box, Ruler, FileText, Upload, Loader2, Users, Menu, Target } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useModelStore } from "@/stores/useModelStore"; import { ModelViewerCanvas } from "@/components/three/ModelViewer"; import { ViewerControls } from "@/components/ViewerControls"; import { InspectionChecklist } from "@/components/InspectionChecklist"; import { FineTuningControls } from "@/components/FineTuningControls"; import { MeasurementsList } from "@/components/MeasurementsList"; import { ScreenshotGallery } from "@/components/ScreenshotGallery"; import { ViewCube } from "@/components/ViewCube"; import { SectionCutPanel } from "@/components/SectionCutPanel"; import { ShareButton } from "@/components/ShareButton"; 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 { validateModelFile } from "@/lib/validateModelFile"; import { convertIFCtoGLB } from "@/lib/convertIFC"; import { mainCameraRef, mainControlsRef } from "@/components/three/viewCubeBus"; const Viewer = () => { const navigate = useNavigate(); const { model, xrSupported, addModel, models, maxModels, scaleRatio, fineTuning, sectionEnabled, sectionInvert, sectionLevel, hoverIfcProps } = useModelStore(); const fileInputRef = useRef(null); const [converting, setConverting] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(true); useEffect(() => { const handleFullscreenChange = () => { setIsFullscreen(!!document.fullscreenElement); }; document.addEventListener("fullscreenchange", handleFullscreenChange); return () => { document.removeEventListener("fullscreenchange", handleFullscreenChange); }; }, []); const handleRecenter = useCallback(() => { const camera = mainCameraRef.current; const controls = mainControlsRef.current; if (camera) { camera.position.set(2, 2, 2); camera.up.set(0, 1, 0); camera.lookAt(0, 0, 0); } if (controls) { // @ts-ignore controls.target.set(0, 0, 0); // @ts-ignore controls.update(); } toast.success("Visualização centralizada"); }, []); const exitFullscreen = useCallback(() => { if (document.fullscreenElement) { document.exitFullscreen().catch((err) => { console.error("Erro ao sair de tela cheia:", err); }); } }, []); const [searchParams] = useSearchParams(); const roomId = searchParams.get("room"); const channelRef = useRef | null>(null); // Se não houver modelo e não estivermos em Reunião, volta para a Home useEffect(() => { if (!model && !roomId) { navigate("/"); } }, [model, roomId, navigate]); const sendMeetingUpdate = useCallback((ft: typeof fineTuning, secEn: typeof sectionEnabled, secLevel: typeof sectionLevel, secInv: typeof sectionInvert) => { if (!roomId || !channelRef.current) return; const enabledAxis = (['x', 'y', 'z'] as const).find(ax => secEn[ax]); const isSectionActive = !!enabledAxis; const sectionAxis = enabledAxis || 'y'; const sectionLevelVal = isSectionActive ? secLevel[sectionAxis] : 0.0; const sectionInvertVal = isSectionActive ? secInv[sectionAxis] : false; // Obtém dados da maquete ativa na store const activeModelStore = useModelStore.getState().model; const payload = { rotation: [ft.rotX, ft.rotY, ft.rotZ], scale: ft.scale, sectionEnabled: isSectionActive, sectionAxis, sectionLevel: sectionLevelVal, sectionInvert: sectionInvertVal, modelUrl: activeModelStore?.url, modelFileName: activeModelStore?.fileName, modelFileSize: activeModelStore?.fileSize }; console.log("📡 [Viewer Meeting Sync] Enviando atualização de apresentação:", payload); channelRef.current.send({ type: 'broadcast', event: 'presentation_update', payload }); }, [roomId]); // Conecta ao canal do Supabase se o Viewer foi aberto no modo Reunião useEffect(() => { if (!roomId) return; const finalRoomId = roomId.trim().toUpperCase(); console.log(`🔌 [Viewer Meeting Sync] Conectando ao canal da reunião: ${finalRoomId}`); const channel = supabase.channel(`meeting_room_${finalRoomId}`, { config: { broadcast: { self: false } } }); channelRef.current = channel; channel.subscribe((status) => { console.log(`🔌 [Viewer Meeting Sync] Status do canal: ${status}`); if (status === 'SUBSCRIBED') { const state = useModelStore.getState(); sendMeetingUpdate(state.fineTuning, state.sectionEnabled, state.sectionLevel, state.sectionInvert); } }); return () => { console.log(`🔌 [Viewer Meeting Sync] Desconectando do canal da reunião: ${finalRoomId}`); channel.unsubscribe(); supabase.removeChannel(channel); }; }, [roomId, sendMeetingUpdate]); // Observa mudanças primitivas nas ferramentas 3D para propagação imediata via Broadcast const rotX = fineTuning.rotX; const rotY = fineTuning.rotY; const rotZ = fineTuning.rotZ; const scaleVal = fineTuning.scale; const secEnabledX = sectionEnabled.x; const secEnabledY = sectionEnabled.y; const secEnabledZ = sectionEnabled.z; const secLevelX = sectionLevel.x; const secLevelY = sectionLevel.y; const secLevelZ = sectionLevel.z; const secInvertX = sectionInvert.x; const secInvertY = sectionInvert.y; const secInvertZ = sectionInvert.z; const activeModelFileName = model?.fileName; useEffect(() => { if (!roomId || !channelRef.current) return; sendMeetingUpdate(fineTuning, sectionEnabled, sectionLevel, sectionInvert); }, [ roomId, rotX, rotY, rotZ, scaleVal, secEnabledX, secEnabledY, secEnabledZ, secLevelX, secLevelY, secLevelZ, secInvertX, secInvertY, secInvertZ, activeModelFileName, sendMeetingUpdate, fineTuning, sectionEnabled, sectionLevel, sectionInvert ]); const handleFileUpload = useCallback(async (e: React.ChangeEvent) => { 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; } const sizeCheck = validateModelFile(file.name, file.size, ext); if (!sizeCheck.ok) { toast.error(sizeCheck.reason!); return; } if (ext === 'glb') { try { const head = await file.slice(0, 16).arrayBuffer(); const sig = validateModelFile(file.name, file.size, ext, head); if (!sig.ok) { toast.error(sig.reason!); return; } } catch (err) { console.error('[upload] header read failed', err); } 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 sig = validateModelFile(file.name, file.size, ext, buffer); if (!sig.ok) { toast.error(sig.reason!); if (sig.detail) console.warn('[validateModelFile]', sig.detail); return; } 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('[upload] conversion failed', err); const msg = err instanceof Error ? err.message : 'erro desconhecido'; toast.error(`Falha ao converter "${file.name}": ${msg}`); } finally { setConverting(false); } }, [addModel, models.length, maxModels]); // Removido o bloqueio para permitir carregamento de modelo na sala const [enteringXR, setEnteringXR] = useState(false); const handleEnterXR = useCallback(async () => { if (!xrSupported) { toast.error("WebXR não é suportado neste navegador. Use o navegador do Meta Quest 3."); return; } setEnteringXR(true); // Timeout de segurança — se não entrar em 15s, cancela const timeoutId = setTimeout(() => { setEnteringXR(false); toast.error("Tempo limite ao iniciar modo AR. Verifique se o Meta Quest Browser está conectado."); }, 15000); try { const { xrStore } = await import('@/stores/useXRStore'); // Marca que está entrando no AR antes de navegar — bloqueia auto-return prematuro (window as unknown as { __setXrEntering?: (v: boolean) => void }).__setXrEntering?.(true); await xrStore.enterAR(); clearTimeout(timeoutId); navigate("/xr"); } catch (e) { clearTimeout(timeoutId); setEnteringXR(false); (window as unknown as { __setXrEntering?: (v: boolean) => void }).__setXrEntering?.(false); console.error('[Viewer] XR enterAR error:', e); toast.error("Falha ao iniciar modo AR. Verifique se o Meta Quest Browser está conectado."); } }, [xrSupported, navigate]); const handleEnterMeeting = () => { // Coleta o eixo habilitado de seção no Viewer const enabledAxis = (['x', 'y', 'z'] as const).find(ax => sectionEnabled[ax]); const isSectionActive = !!enabledAxis; const sectionAxis = enabledAxis || 'y'; const sectionLevelVal = isSectionActive ? sectionLevel[sectionAxis] : 0.0; const sectionInvertVal = isSectionActive ? sectionInvert[sectionAxis] : false; // Salva o estado atual da peça const initialMeetingState = { rotation: [fineTuning.rotX, fineTuning.rotY, fineTuning.rotZ], scale: fineTuning.scale, sectionEnabled: isSectionActive, sectionAxis, sectionLevel: sectionLevelVal, sectionInvert: sectionInvertVal }; localStorage.setItem('tsxr_initial_meeting_state', JSON.stringify(initialMeetingState)); toast.success('Configurações da maquete salvas. Direcionando para a reunião...'); navigate('/meeting'); }; const canAddMore = models.length < maxModels; return (
{/* Top bar */}

SteelXR

{/* 3D Canvas with floating controls */}
{hoverIfcProps && (
Propriedades IFC
{Object.entries(hoverIfcProps).map(([key, value]) => { if (!value) return null; let displayKey = key; if (key === 'name') displayKey = 'Nome'; else if (key === 'tag') displayKey = 'Marca'; else if (key === 'material') displayKey = 'Material'; else if (key === 'objectType') displayKey = 'Tipo'; else if (key === 'description') displayKey = 'Descrição'; return (
{displayKey} {value}
); })}
)} {isFullscreen ? (
) : ( <> {/* Botão de Expandir Sidebar (visível apenas se recolhido) */} {!sidebarOpen && ( )} )}
{/* Side panel */}
); }; function InfoItem({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) { return (

{label}

{value}

); } export default Viewer;