539 lines
20 KiB
TypeScript
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;
|