Files
SteelXR/src/pages/Viewer.tsx
T

539 lines
20 KiB
TypeScript

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<HTMLInputElement>(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<ReturnType<typeof supabase.channel> | 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<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;
}
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 (
<div className="flex h-screen flex-col bg-background">
<input
ref={fileInputRef}
type="file"
accept={ACCEPTED_EXTENSIONS}
className="hidden"
title="Selecionar arquivo de maquete para upload"
placeholder="Upload de maquete"
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">
<Button
variant="ghost"
size={roomId ? "default" : "icon"}
className={roomId ? "gap-1.5 px-3 text-xs font-mono h-9 hover:bg-slate-800" : ""}
onClick={() => roomId ? navigate(`/meeting/${roomId}`) : navigate("/")}
>
<ArrowLeft className="h-4 w-4" />
{roomId && "Voltar para Sala"}
</Button>
<div className="flex items-center gap-2">
<Box className="h-5 w-5 text-primary" />
<h1 className="font-mono text-sm font-semibold text-foreground">
Steel<span className="text-primary">XR</span>
</h1>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
className="gap-2 border-primary/30 hover:bg-primary/10 text-primary font-mono h-9"
onClick={handleEnterMeeting}
>
<Users className="h-4 w-4" />
Reunião Virtual
</Button>
<ShareButton />
<Button className="gap-2 glow-primary h-9" disabled={!xrSupported || enteringXR} onClick={handleEnterXR}>
{enteringXR ? <Loader2 className="h-4 w-4 animate-spin" /> : <Glasses className="h-4 w-4" />}
{enteringXR ? 'Iniciando AR…' : 'Entrar em Modo XR'}
</Button>
</div>
</header>
<div className="flex flex-1 overflow-hidden">
{/* 3D Canvas with floating controls */}
<div className="relative flex-1 min-w-0" id="canvas-container">
<ModelViewerCanvas />
{hoverIfcProps && (
<div
className={`absolute left-4 z-40 w-72 rounded-xl border border-primary/20 bg-background/80 p-3.5 shadow-2xl backdrop-blur-md transition-all duration-300 pointer-events-none select-none font-mono text-[11px] ${
isFullscreen ? 'top-16' : 'top-4'
}`}
>
<div className="flex flex-col gap-2.5">
<div className="flex items-center gap-1.5 border-b border-primary/10 pb-1.5">
<span className="h-1.5 w-1.5 rounded-full bg-primary animate-pulse" />
<span className="font-semibold text-primary uppercase text-[9px] tracking-wider font-sans">Propriedades IFC</span>
</div>
{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 (
<div key={key} className="flex flex-col gap-0.5">
<span className="text-[8px] text-muted-foreground uppercase tracking-widest">{displayKey}</span>
<span className="text-foreground font-semibold truncate text-[11px] max-w-[250px]" title={value}>{value}</span>
</div>
);
})}
</div>
</div>
)}
{isFullscreen ? (
<div className="absolute top-4 left-4 z-50 flex gap-2">
<Button
variant="outline"
className="gap-2 bg-background/80 border-primary/40 hover:bg-primary/20 text-foreground font-mono shadow-md backdrop-blur-sm h-9"
onClick={handleRecenter}
>
<Target className="h-4 w-4 text-primary animate-pulse" />
Centralizar
</Button>
<Button
variant="outline"
className="gap-2 bg-background/80 border-destructive/40 hover:bg-destructive/20 text-foreground font-mono shadow-md backdrop-blur-sm h-9"
onClick={exitFullscreen}
>
<ArrowLeft className="h-4 w-4 text-destructive" />
Retornar (ESC)
</Button>
</div>
) : (
<>
{/* Botão de Expandir Sidebar (visível apenas se recolhido) */}
{!sidebarOpen && (
<Button
variant="outline"
size="icon"
onClick={() => setSidebarOpen(true)}
className="absolute right-4 top-[205px] z-30 h-8 w-8 rounded-md bg-background/80 border-primary/30 text-primary shadow-md hover:bg-primary/10"
title="Expandir menu"
>
<Menu className="h-4 w-4" />
</Button>
)}
<ViewCube />
<ViewerControls />
<SectionCutPanel />
</>
)}
</div>
{/* Side panel */}
<aside className={`transition-all duration-300 shrink-0 border-l bg-card flex flex-col ${sidebarOpen ? 'w-80 opacity-100' : 'w-0 opacity-0 pointer-events-none border-l-0 overflow-hidden'}`}>
<ScrollArea className="h-full">
<div className="p-4 space-y-5">
<div>
<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>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-muted-foreground hover:text-foreground hover:bg-muted"
onClick={() => setSidebarOpen(false)}
title="Recolher menu"
>
<Menu className="h-4 w-4" />
</Button>
</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>
{model ? (
<div className="space-y-3">
<InfoItem icon={FileText} label="Arquivo" value={model.fileName} />
<InfoItem icon={Box} label="Tamanho" value={`${(model.fileSize / (1024 * 1024)).toFixed(2)} MB`} />
<InfoItem icon={Ruler} label="Escala" value={`${scaleRatio.label} (mm)`} />
</div>
) : (
<div className="rounded-lg border border-dashed border-muted p-4 text-center bg-muted/20">
<p className="font-mono text-xs text-muted-foreground">Nenhuma peça ativa na cena</p>
</div>
)}
</div>
<Separator />
<FineTuningControls />
<Separator />
<MeasurementsList />
<Separator />
<InspectionChecklist />
<Separator />
<ScreenshotGallery />
<div className="rounded-lg border bg-muted/50 p-3">
<p className="font-mono text-xs text-muted-foreground">
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>
</ScrollArea>
</aside>
</div>
</div>
);
};
function InfoItem({ icon: Icon, label, value }: { icon: React.ComponentType<{ className?: string }>; label: string; value: string }) {
return (
<div className="flex items-start gap-3">
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
<div className="min-w-0">
<p className="text-xs text-muted-foreground">{label}</p>
<p className="truncate font-mono text-sm text-foreground">{value}</p>
</div>
</div>
);
}
export default Viewer;