298 lines
11 KiB
TypeScript
298 lines
11 KiB
TypeScript
import { Eye, Grid3X3, Ruler, Trash2, Camera, LayoutGrid, Palette, Box, Move3D, Undo2, MousePointerClick, EyeOff, Focus, Download } from 'lucide-react';
|
|
import { Slider } from '@/components/ui/slider';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
import { useModelStore } from '@/stores/useModelStore';
|
|
import { ScaleSelector } from '@/components/ScaleSelector';
|
|
import { useCallback } from 'react';
|
|
import { toast } from 'sonner';
|
|
import { exportObjectsAsGLB } from '@/lib/exportSelectionGLB';
|
|
import * as THREE from 'three';
|
|
import { elementKey, sceneRef } from '@/components/three/ModelViewer';
|
|
|
|
const WIRE_COLORS = [
|
|
{ label: 'Cinza', value: '#8899aa' },
|
|
{ label: 'Vermelho', value: '#ef4444' },
|
|
{ label: 'Verde', value: '#22c55e' },
|
|
{ label: 'Azul', value: '#3b82f6' },
|
|
{ label: 'Amarelo', value: '#eab308' },
|
|
{ label: 'Laranja', value: '#f97316' },
|
|
{ label: 'Branco', value: '#e2e8f0' },
|
|
];
|
|
|
|
export function ViewerControls() {
|
|
const {
|
|
opacity, setOpacity, renderMode, setRenderMode,
|
|
measureMode, setMeasureMode, measurements, clearMeasurements, undoLastMeasurement,
|
|
measurePoints, addScreenshot,
|
|
showGrid, setShowGrid,
|
|
wireframeColor, setWireframeColor,
|
|
wireframeThickness, setWireframeThickness,
|
|
edgeThresholdAngle, setEdgeThresholdAngle,
|
|
positionMode, setPositionMode,
|
|
activeModelId, models,
|
|
selectionMode, setSelectionMode,
|
|
selectedElementKeys, hiddenElementKeys, isolatedElementKeys,
|
|
hideSelectedElements, isolateSelectedElements, showAllElements, clearElementSelection,
|
|
} = useModelStore();
|
|
const activeModel = models.find(m => m.id === activeModelId);
|
|
const hasSelection = selectedElementKeys.size > 0;
|
|
const hasHiddenOrIsolated = hiddenElementKeys.size > 0 || isolatedElementKeys !== null;
|
|
|
|
const handleExportSelection = useCallback(async () => {
|
|
if (!activeModel || selectedElementKeys.size === 0) return;
|
|
const scene = sceneRef.current;
|
|
if (!scene) { toast.error('Cena 3D não disponível'); return; }
|
|
const objects: THREE.Object3D[] = [];
|
|
const collect = (root: THREE.Object3D) => {
|
|
const modelId = root.userData?.modelId as string | undefined;
|
|
if (!modelId) { root.children.forEach(collect); return; }
|
|
root.traverse((el) => {
|
|
if (!el.userData?.ifcElement) return;
|
|
if (selectedElementKeys.has(elementKey(modelId, el))) objects.push(el);
|
|
});
|
|
};
|
|
collect(scene);
|
|
if (objects.length === 0) {
|
|
toast.error('Não foi possível localizar elementos selecionados. Use IFC para seleção persistente.');
|
|
return;
|
|
}
|
|
const base = activeModel.fileName.replace(/\.(glb|gltf|ifc|obj|stl)$/i, '');
|
|
await exportObjectsAsGLB(objects, `${base}_selecao_${objects.length}elem.glb`);
|
|
toast.success(`GLB exportado com ${objects.length} elemento(s)`);
|
|
}, [activeModel, selectedElementKeys]);
|
|
|
|
const handleScreenshot = useCallback(() => {
|
|
const canvas = document.querySelector('canvas');
|
|
if (!canvas) {
|
|
toast.error('Canvas não encontrado');
|
|
return;
|
|
}
|
|
const dataUrl = canvas.toDataURL('image/png');
|
|
addScreenshot(dataUrl);
|
|
toast.success('Screenshot capturado!');
|
|
}, [addScreenshot]);
|
|
|
|
return (
|
|
<div className="absolute bottom-4 left-4 z-10 flex items-end gap-3 flex-wrap">
|
|
{/* Opacity slider */}
|
|
<div className="rounded-lg border bg-card/90 backdrop-blur-sm p-3 shadow-lg w-56">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<div className="flex items-center gap-2">
|
|
<Eye className="h-3.5 w-3.5 text-primary" />
|
|
<span className="font-mono text-xs text-muted-foreground">Opacidade</span>
|
|
</div>
|
|
<span className="font-mono text-xs text-foreground">{Math.round(opacity * 100)}%</span>
|
|
</div>
|
|
<Slider
|
|
value={[opacity * 100]}
|
|
min={0}
|
|
max={100}
|
|
step={1}
|
|
onValueChange={([v]) => setOpacity(v / 100)}
|
|
/>
|
|
</div>
|
|
|
|
{/* Grid toggle */}
|
|
<Button
|
|
variant={showGrid ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="gap-2 h-9"
|
|
onClick={() => setShowGrid(!showGrid)}
|
|
>
|
|
<LayoutGrid className="h-3.5 w-3.5" />
|
|
<span className="font-mono text-xs">Grid</span>
|
|
</Button>
|
|
|
|
{/* Scale presets */}
|
|
<ScaleSelector />
|
|
|
|
<Button
|
|
variant={renderMode !== 'solid' ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="gap-2 h-9"
|
|
onClick={() => {
|
|
const next = renderMode === 'solid' ? 'edges' : 'solid';
|
|
setRenderMode(next);
|
|
}}
|
|
>
|
|
{renderMode === 'edges' ? <Box className="h-3.5 w-3.5" /> : <Grid3X3 className="h-3.5 w-3.5" />}
|
|
<span className="font-mono text-xs">
|
|
{renderMode === 'solid' ? 'Sólido' : 'Bordas'}
|
|
</span>
|
|
</Button>
|
|
|
|
{/* Wireframe/Edges settings popover */}
|
|
{renderMode !== 'solid' && (
|
|
<Popover>
|
|
<PopoverTrigger asChild>
|
|
<Button variant="outline" size="sm" className="gap-2 h-9">
|
|
<Palette className="h-3.5 w-3.5" />
|
|
<div className="h-3 w-3 rounded-full border border-muted-foreground/30" style={{ backgroundColor: wireframeColor }} />
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent className="w-56 space-y-3" side="top" align="start">
|
|
<div>
|
|
<span className="font-mono text-xs text-muted-foreground">Cor das Linhas</span>
|
|
<div className="mt-2 flex flex-wrap gap-2">
|
|
{WIRE_COLORS.map((c) => (
|
|
<button
|
|
key={c.value}
|
|
className={`h-6 w-6 rounded-full border-2 transition-all ${wireframeColor === c.value ? 'border-primary scale-110' : 'border-muted-foreground/30'}`}
|
|
style={{ backgroundColor: c.value }}
|
|
onClick={() => setWireframeColor(c.value)}
|
|
title={c.label}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="font-mono text-xs text-muted-foreground">Espessura</span>
|
|
<span className="font-mono text-xs text-foreground">{wireframeThickness.toFixed(1)}</span>
|
|
</div>
|
|
<Slider
|
|
value={[wireframeThickness]}
|
|
min={0.5}
|
|
max={5}
|
|
step={0.5}
|
|
onValueChange={([v]) => setWireframeThickness(v)}
|
|
/>
|
|
</div>
|
|
{renderMode === 'edges' && (
|
|
<div>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<span className="font-mono text-xs text-muted-foreground">Ângulo Limite</span>
|
|
<span className="font-mono text-xs text-foreground">{edgeThresholdAngle}°</span>
|
|
</div>
|
|
<Slider
|
|
value={[edgeThresholdAngle]}
|
|
min={1}
|
|
max={45}
|
|
step={1}
|
|
onValueChange={([v]) => setEdgeThresholdAngle(v)}
|
|
/>
|
|
</div>
|
|
)}
|
|
</PopoverContent>
|
|
</Popover>
|
|
)}
|
|
{/* Posicionar (manual mouse placement) */}
|
|
<Button
|
|
variant={positionMode ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="gap-2 h-9"
|
|
onClick={() => setPositionMode(!positionMode)}
|
|
disabled={!activeModelId}
|
|
title="Esquerdo: mover · Shift+Esq: profundidade · Meio (roda): girar no próprio eixo · Direito: rotação livre · Shift+Dir: rotZ"
|
|
>
|
|
<Move3D className="h-3.5 w-3.5" />
|
|
<span className="font-mono text-xs">
|
|
{positionMode
|
|
? `Posicionando${activeModel ? ` · ${activeModel.fileName.slice(0, 14)}` : ''}`
|
|
: 'Posicionar'}
|
|
</span>
|
|
</Button>
|
|
|
|
{/* Measure tool */}
|
|
<div className="flex items-end gap-1.5">
|
|
<Button
|
|
variant={measureMode ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="gap-2 h-9"
|
|
onClick={() => setMeasureMode(!measureMode)}
|
|
title="Clique para marcar pontos · Esc desfaz ponto pendente · Z desfaz última medição"
|
|
>
|
|
<Ruler className="h-3.5 w-3.5" />
|
|
<span className="font-mono text-xs">
|
|
{measureMode
|
|
? measurePoints.length === 0
|
|
? 'Ponto A…'
|
|
: 'Ponto B…'
|
|
: 'Medir'}
|
|
</span>
|
|
</Button>
|
|
|
|
{(measurePoints.length > 0 || measurements.length > 0) && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-9 w-9"
|
|
onClick={undoLastMeasurement}
|
|
title="Desfazer (Z)"
|
|
>
|
|
<Undo2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</Button>
|
|
)}
|
|
|
|
{measurements.length > 0 && (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-9 w-9"
|
|
onClick={clearMeasurements}
|
|
title="Limpar medições"
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Selection cluster */}
|
|
<div className="flex items-end gap-1.5">
|
|
<Button
|
|
variant={selectionMode ? 'default' : 'outline'}
|
|
size="sm"
|
|
className="gap-2 h-9"
|
|
onClick={() => setSelectionMode(!selectionMode)}
|
|
disabled={!activeModelId}
|
|
title="Clique nos elementos do modelo para selecionar (viga, chapa…)"
|
|
>
|
|
<MousePointerClick className="h-3.5 w-3.5" />
|
|
<span className="font-mono text-xs">
|
|
{selectionMode
|
|
? hasSelection ? `${selectedElementKeys.size} sel.` : 'Selecionar…'
|
|
: 'Selecionar'}
|
|
</span>
|
|
</Button>
|
|
|
|
{hasSelection && (
|
|
<>
|
|
<Button variant="ghost" size="icon" className="h-9 w-9" onClick={hideSelectedElements} title="Esconder selecionados">
|
|
<EyeOff className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-9 w-9" onClick={isolateSelectedElements} title="Isolar selecionados (esconder o resto)">
|
|
<Focus className="h-3.5 w-3.5 text-primary" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-9 w-9" onClick={handleExportSelection} title="Exportar selecionados como GLB">
|
|
<Download className="h-3.5 w-3.5 text-primary" />
|
|
</Button>
|
|
<Button variant="ghost" size="icon" className="h-9 w-9" onClick={clearElementSelection} title="Limpar seleção">
|
|
<Undo2 className="h-3.5 w-3.5 text-muted-foreground" />
|
|
</Button>
|
|
</>
|
|
)}
|
|
|
|
{hasHiddenOrIsolated && (
|
|
<Button variant="ghost" size="sm" className="h-9 gap-1.5" onClick={showAllElements} title="Mostrar todos os elementos">
|
|
<Eye className="h-3.5 w-3.5" />
|
|
<span className="font-mono text-xs">Mostrar tudo</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
|
|
{/* Screenshot */}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="gap-2 h-9"
|
|
onClick={handleScreenshot}
|
|
>
|
|
<Camera className="h-3.5 w-3.5" />
|
|
<span className="font-mono text-xs">Capturar</span>
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|