feat(3d-viewer): add Visualizador 3D module with IFC AISC EM.11 & fallback weld graph
This commit is contained in:
@@ -5,6 +5,7 @@ import {
|
||||
Warehouse,
|
||||
FolderOpen,
|
||||
Building2,
|
||||
Box,
|
||||
Monitor,
|
||||
Truck,
|
||||
HardHat,
|
||||
@@ -144,6 +145,12 @@ export const menuGroups: MenuGroup[] = [
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: "visualizador-3d",
|
||||
title: "Visualizador 3D",
|
||||
url: "/visualizador-3d",
|
||||
icon: Box,
|
||||
},
|
||||
{
|
||||
key: "painel-industrial",
|
||||
title: "Painel Industrial",
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import React from 'react';
|
||||
import { ShieldCheck, AlertTriangle, CheckCircle2, Layers, Cpu, FileCode2, X, ArrowRight } from 'lucide-react';
|
||||
import { IFCQualityAudit } from '@/lib/ifc/ifcLoaderService';
|
||||
|
||||
interface IFCQualityModalProps {
|
||||
audit: IFCQualityAudit | null;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const IFCQualityModal: React.FC<IFCQualityModalProps> = ({ audit, isOpen, onClose }) => {
|
||||
if (!isOpen || !audit) return null;
|
||||
|
||||
const isExcellent = audit.qualityScore === 'EXCELENTE';
|
||||
const isGood = audit.qualityScore === 'BOM';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200">
|
||||
<div className="relative w-full max-w-2xl bg-slate-900 border border-slate-800 rounded-2xl shadow-2xl overflow-hidden">
|
||||
{/* Top Header Banner */}
|
||||
<div className={`p-6 border-b ${
|
||||
isExcellent
|
||||
? 'bg-emerald-950/40 border-emerald-500/30'
|
||||
: isGood
|
||||
? 'bg-amber-950/40 border-amber-500/30'
|
||||
: 'bg-rose-950/40 border-rose-500/30'
|
||||
}`}>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-3 rounded-xl ${
|
||||
isExcellent
|
||||
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/40'
|
||||
: isGood
|
||||
? 'bg-amber-500/20 text-amber-400 border border-amber-500/40'
|
||||
: 'bg-rose-500/20 text-rose-400 border border-rose-500/40'
|
||||
}`}>
|
||||
{isExcellent ? (
|
||||
<ShieldCheck className="w-7 h-7" />
|
||||
) : isGood ? (
|
||||
<CheckCircle2 className="w-7 h-7" />
|
||||
) : (
|
||||
<AlertTriangle className="w-7 h-7" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<h2 className="text-xl font-bold text-white tracking-tight">
|
||||
Auditoria de Qualidade do IFC
|
||||
</h2>
|
||||
<span className={`px-2.5 py-0.5 rounded-full text-xs font-mono font-bold uppercase tracking-wider ${
|
||||
isExcellent
|
||||
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40'
|
||||
: isGood
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40'
|
||||
: 'bg-rose-500/20 text-rose-300 border border-rose-500/40'
|
||||
}`}>
|
||||
Score: {audit.qualityScore}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-300 mt-1">{audit.qualityMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition-all"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Body */}
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Key Metrics Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<div className="bg-slate-950/60 p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[11px] font-medium text-slate-400 uppercase tracking-wider block">
|
||||
Padrão / View
|
||||
</span>
|
||||
<span className="text-sm font-bold text-cyan-400 mt-1 block truncate">
|
||||
{audit.viewDefinition}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950/60 p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[11px] font-medium text-slate-400 uppercase tracking-wider block">
|
||||
Montagens (Assemblies)
|
||||
</span>
|
||||
<span className="text-lg font-mono font-extrabold text-white mt-1 block">
|
||||
{audit.totalAssemblies}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950/60 p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[11px] font-medium text-slate-400 uppercase tracking-wider block">
|
||||
Marcas Mapeadas
|
||||
</span>
|
||||
<span className="text-lg font-mono font-extrabold text-emerald-400 mt-1 block">
|
||||
{audit.uniqueMarksFound}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-950/60 p-3.5 rounded-xl border border-slate-800">
|
||||
<span className="text-[11px] font-medium text-slate-400 uppercase tracking-wider block">
|
||||
Sólidos Estruturais
|
||||
</span>
|
||||
<span className="text-lg font-mono font-extrabold text-amber-400 mt-1 block">
|
||||
{audit.totalStructuralElements}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breakdown Table */}
|
||||
<div className="bg-slate-950/60 rounded-xl border border-slate-800 p-4 space-y-3">
|
||||
<h3 className="text-xs font-semibold text-slate-300 uppercase tracking-wider flex items-center gap-2">
|
||||
<Layers className="w-4 h-4 text-cyan-400" />
|
||||
Detalhamento de Elementos no Modelo
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||
<div className="p-2 rounded bg-slate-900/80 border border-slate-800/80">
|
||||
<span className="text-slate-400">Vigas/Terças:</span>{' '}
|
||||
<span className="font-bold text-white">{audit.totalBeams}</span>
|
||||
</div>
|
||||
<div className="p-2 rounded bg-slate-900/80 border border-slate-800/80">
|
||||
<span className="text-slate-400">Pilares:</span>{' '}
|
||||
<span className="font-bold text-white">{audit.totalColumns}</span>
|
||||
</div>
|
||||
<div className="p-2 rounded bg-slate-900/80 border border-slate-800/80">
|
||||
<span className="text-slate-400">Chapas/Plates:</span>{' '}
|
||||
<span className="font-bold text-white">{audit.totalPlates}</span>
|
||||
</div>
|
||||
<div className="p-2 rounded bg-slate-900/80 border border-slate-800/80">
|
||||
<span className="text-slate-400">Fixadores/Soldas:</span>{' '}
|
||||
<span className="font-bold text-white">{audit.totalFasteners}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendation Box if not EM.11 */}
|
||||
{audit.recommendation && (
|
||||
<div className="p-4 rounded-xl bg-amber-950/30 border border-amber-500/30 text-xs text-amber-200/90 leading-relaxed flex items-start gap-3">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<span className="font-bold text-amber-300 block mb-1">
|
||||
Recomendação de Engenharia:
|
||||
</span>
|
||||
{audit.recommendation}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer Actions */}
|
||||
<div className="p-4 bg-slate-950/80 border-t border-slate-800 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-slate-950 font-bold text-sm rounded-xl shadow-lg shadow-cyan-500/20 transition-all"
|
||||
>
|
||||
<span>Prosseguir para o Visualizador 3D</span>
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,467 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import * as THREE from 'three';
|
||||
import { OrbitControls } from 'three-stdlib';
|
||||
import { PieceInfo, LoadedIFCResult } from '@/lib/ifc/ifcLoaderService';
|
||||
import { ViewCube } from './ViewCube';
|
||||
import { Viewer3DToolbar } from './Viewer3DToolbar';
|
||||
import { PieceHoverTooltip } from './PieceHoverTooltip';
|
||||
|
||||
interface ProductionPieceStatus {
|
||||
marca: string;
|
||||
fase: string;
|
||||
totalQtd: number;
|
||||
pointedQtd: number;
|
||||
currentProcessName?: string;
|
||||
processColor?: string;
|
||||
}
|
||||
|
||||
interface ModelViewer3DProps {
|
||||
modelData: LoadedIFCResult | null;
|
||||
productionData?: Map<string, ProductionPieceStatus>;
|
||||
selectedPieceMark?: string | null;
|
||||
onSelectPiece?: (piece: PieceInfo | null) => void;
|
||||
}
|
||||
|
||||
export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||
modelData,
|
||||
productionData,
|
||||
selectedPieceMark,
|
||||
onSelectPiece,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||
const perspCameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||
const orthoCameraRef = useRef<THREE.OrthographicCamera | null>(null);
|
||||
const activeCameraRef = useRef<THREE.Camera | null>(null);
|
||||
const controlsRef = useRef<OrbitControls | null>(null);
|
||||
const gridHelperRef = useRef<THREE.GridHelper | null>(null);
|
||||
const modelGroupRef = useRef<THREE.Group | null>(null);
|
||||
const raycasterRef = useRef<THREE.Raycaster>(new THREE.Raycaster());
|
||||
const mousePosRef = useRef<THREE.Vector2>(new THREE.Vector2());
|
||||
|
||||
// Tool states
|
||||
const [opacity, setOpacity] = useState<number>(100);
|
||||
const [showGrid, setShowGrid] = useState<boolean>(true);
|
||||
const [isOrthographic, setIsOrthographic] = useState<boolean>(false);
|
||||
const [isWireframe, setIsWireframe] = useState<boolean>(false);
|
||||
const [navMode, setNavMode] = useState<'orbit' | 'walk'>('orbit');
|
||||
const [colorMode, setColorMode] = useState<'description' | 'production'>('description');
|
||||
const [hasSectionPlanes, setHasSectionPlanes] = useState<boolean>(false);
|
||||
const [isMeasuring, setIsMeasuring] = useState<boolean>(false);
|
||||
|
||||
// Hover Tooltip States (1.5 seconds)
|
||||
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const [hoveredPiece, setHoveredPiece] = useState<PieceInfo | null>(null);
|
||||
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
// Highlight Material (Light Green #4ade80)
|
||||
const highlightMaterialRef = useRef<THREE.MeshStandardMaterial>(
|
||||
new THREE.MeshStandardMaterial({
|
||||
color: 0x4ade80,
|
||||
emissive: 0x22c55e,
|
||||
emissiveIntensity: 0.35,
|
||||
metalness: 0.3,
|
||||
roughness: 0.4,
|
||||
side: THREE.DoubleSide,
|
||||
})
|
||||
);
|
||||
|
||||
// Initialize Scene
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
const width = containerRef.current.clientWidth || 800;
|
||||
const height = containerRef.current.clientHeight || 600;
|
||||
|
||||
// 1. Scene
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(0x0a0f1d);
|
||||
sceneRef.current = scene;
|
||||
|
||||
// 2. Cameras
|
||||
const perspCamera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000);
|
||||
perspCamera.position.set(50, 40, 60);
|
||||
perspCameraRef.current = perspCamera;
|
||||
|
||||
const frustumSize = 80;
|
||||
const aspect = width / height;
|
||||
const orthoCamera = new THREE.OrthographicCamera(
|
||||
(frustumSize * aspect) / -2,
|
||||
(frustumSize * aspect) / 2,
|
||||
frustumSize / 2,
|
||||
frustumSize / -2,
|
||||
0.1,
|
||||
2000
|
||||
);
|
||||
orthoCamera.position.copy(perspCamera.position);
|
||||
orthoCameraRef.current = orthoCamera;
|
||||
|
||||
activeCameraRef.current = perspCamera;
|
||||
|
||||
// 3. Renderer
|
||||
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
||||
renderer.setSize(width, height);
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
||||
renderer.shadowMap.enabled = true;
|
||||
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
||||
rendererRef.current = renderer;
|
||||
|
||||
containerRef.current.innerHTML = '';
|
||||
containerRef.current.appendChild(renderer.domElement);
|
||||
|
||||
// 4. OrbitControls
|
||||
const controls = new OrbitControls(perspCamera, renderer.domElement);
|
||||
controls.enableDamping = true;
|
||||
controls.dampingFactor = 0.05;
|
||||
controls.maxDistance = 1500;
|
||||
controls.minDistance = 1;
|
||||
controlsRef.current = controls;
|
||||
|
||||
// 5. Lights
|
||||
const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
|
||||
scene.add(ambientLight);
|
||||
|
||||
const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2);
|
||||
dirLight1.position.set(100, 200, 100);
|
||||
dirLight1.castShadow = true;
|
||||
scene.add(dirLight1);
|
||||
|
||||
const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.6);
|
||||
dirLight2.position.set(-100, -50, -100);
|
||||
scene.add(dirLight2);
|
||||
|
||||
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x1e293b, 0.5);
|
||||
scene.add(hemiLight);
|
||||
|
||||
// 6. Floor Grid
|
||||
const grid = new THREE.GridHelper(200, 100, 0x06b6d4, 0x1e293b);
|
||||
grid.position.y = 0;
|
||||
scene.add(grid);
|
||||
gridHelperRef.current = grid;
|
||||
|
||||
// 7. Render Loop
|
||||
let animationFrameId: number;
|
||||
const animate = () => {
|
||||
animationFrameId = requestAnimationFrame(animate);
|
||||
controls.update();
|
||||
if (rendererRef.current && sceneRef.current && activeCameraRef.current) {
|
||||
rendererRef.current.render(sceneRef.current, activeCameraRef.current);
|
||||
}
|
||||
};
|
||||
animate();
|
||||
|
||||
// 8. Resize Handler
|
||||
const handleResize = () => {
|
||||
if (!containerRef.current || !rendererRef.current) return;
|
||||
const w = containerRef.current.clientWidth;
|
||||
const h = containerRef.current.clientHeight;
|
||||
|
||||
perspCamera.aspect = w / h;
|
||||
perspCamera.updateProjectionMatrix();
|
||||
|
||||
const newAspect = w / h;
|
||||
orthoCamera.left = (frustumSize * newAspect) / -2;
|
||||
orthoCamera.right = (frustumSize * newAspect) / 2;
|
||||
orthoCamera.top = frustumSize / 2;
|
||||
orthoCamera.bottom = frustumSize / -2;
|
||||
orthoCamera.updateProjectionMatrix();
|
||||
|
||||
rendererRef.current.setSize(w, h);
|
||||
};
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
renderer.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Update Model in Scene
|
||||
useEffect(() => {
|
||||
if (!sceneRef.current || !modelData) return;
|
||||
|
||||
// Remove old model if present
|
||||
if (modelGroupRef.current) {
|
||||
sceneRef.current.remove(modelGroupRef.current);
|
||||
modelGroupRef.current = null;
|
||||
}
|
||||
|
||||
const group = modelData.sceneGroup;
|
||||
modelGroupRef.current = group;
|
||||
sceneRef.current.add(group);
|
||||
|
||||
// Fit View (Center Model)
|
||||
fitModelToView(group);
|
||||
}, [modelData]);
|
||||
|
||||
// Function: Fit Model to View
|
||||
const fitModelToView = useCallback((group?: THREE.Group | null) => {
|
||||
const targetGroup = group || modelGroupRef.current;
|
||||
if (!targetGroup || !activeCameraRef.current || !controlsRef.current) return;
|
||||
|
||||
const box = new THREE.Box3().setFromObject(targetGroup);
|
||||
if (box.isEmpty()) return;
|
||||
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const maxDim = Math.max(size.x, size.y, size.z);
|
||||
|
||||
const fov = 45 * (Math.PI / 180);
|
||||
let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 1.5;
|
||||
cameraZ = Math.max(cameraZ, 20);
|
||||
|
||||
const camera = activeCameraRef.current;
|
||||
camera.position.set(center.x + cameraZ * 0.7, center.y + cameraZ * 0.5, center.z + cameraZ * 0.7);
|
||||
camera.lookAt(center);
|
||||
|
||||
controlsRef.current.target.copy(center);
|
||||
controlsRef.current.update();
|
||||
|
||||
if (gridHelperRef.current) {
|
||||
gridHelperRef.current.position.y = box.min.y;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Color Mode & Proportional Apontamentos Application
|
||||
useEffect(() => {
|
||||
if (!modelData || !modelGroupRef.current) return;
|
||||
|
||||
const group = modelGroupRef.current;
|
||||
|
||||
group.traverse((child) => {
|
||||
if ((child as THREE.Mesh).isMesh) {
|
||||
const mesh = child as THREE.Mesh;
|
||||
const pmark = mesh.userData.pieceMark || '';
|
||||
|
||||
if (colorMode === 'production' && productionData) {
|
||||
// Look up production pointing status
|
||||
const prod = productionData.get(pmark);
|
||||
if (prod && prod.pointedQtd > 0) {
|
||||
// Apply stage color (e.g., Light Green #4ade80 for pointed)
|
||||
const stageHex = prod.processColor || '#4ade80';
|
||||
mesh.material = new THREE.MeshStandardMaterial({
|
||||
color: new THREE.Color(stageHex),
|
||||
metalness: 0.35,
|
||||
roughness: 0.45,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
} else {
|
||||
// Unpointed / Pending (Default Gray)
|
||||
mesh.material = new THREE.MeshStandardMaterial({
|
||||
color: 0x64748b,
|
||||
metalness: 0.3,
|
||||
roughness: 0.6,
|
||||
transparent: opacity < 100,
|
||||
opacity: opacity / 100,
|
||||
side: THREE.DoubleSide,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Color Mode "Descrição" (Palette by Profile/Section)
|
||||
const colorKey = mesh.userData.colorKey;
|
||||
const cachedMat = modelData.materialsByColor.get(colorKey);
|
||||
if (cachedMat) {
|
||||
mesh.material = cachedMat;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply Opacity & Wireframe
|
||||
if (Array.isArray(mesh.material)) {
|
||||
mesh.material.forEach((m) => {
|
||||
m.transparent = opacity < 100;
|
||||
m.opacity = opacity / 100;
|
||||
if ('wireframe' in m) (m as any).wireframe = isWireframe;
|
||||
});
|
||||
} else if (mesh.material) {
|
||||
mesh.material.transparent = opacity < 100;
|
||||
mesh.material.opacity = opacity / 100;
|
||||
if ('wireframe' in mesh.material) (mesh.material as any).wireframe = isWireframe;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [modelData, colorMode, productionData, opacity, isWireframe]);
|
||||
|
||||
// Camera Toggle (Ortogonal / Perspectiva)
|
||||
const handleToggleCamera = useCallback(() => {
|
||||
if (!perspCameraRef.current || !orthoCameraRef.current || !controlsRef.current) return;
|
||||
|
||||
const nextOrtho = !isOrthographic;
|
||||
setIsOrthographic(nextOrtho);
|
||||
|
||||
const prevCamera = activeCameraRef.current!;
|
||||
const nextCamera = nextOrtho ? orthoCameraRef.current : perspCameraRef.current;
|
||||
|
||||
nextCamera.position.copy(prevCamera.position);
|
||||
nextCamera.quaternion.copy(prevCamera.quaternion);
|
||||
|
||||
activeCameraRef.current = nextCamera;
|
||||
controlsRef.current.object = nextCamera;
|
||||
controlsRef.current.update();
|
||||
}, [isOrthographic]);
|
||||
|
||||
// ViewCube Direction Switcher
|
||||
const handleViewCubeSelect = useCallback(
|
||||
(view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso') => {
|
||||
if (!controlsRef.current || !activeCameraRef.current) return;
|
||||
const target = controlsRef.current.target;
|
||||
const camera = activeCameraRef.current;
|
||||
const distance = camera.position.distanceTo(target);
|
||||
|
||||
const newPos = target.clone();
|
||||
switch (view) {
|
||||
case 'top':
|
||||
newPos.y += distance;
|
||||
break;
|
||||
case 'bottom':
|
||||
newPos.y -= distance;
|
||||
break;
|
||||
case 'front':
|
||||
newPos.z += distance;
|
||||
break;
|
||||
case 'back':
|
||||
newPos.z -= distance;
|
||||
break;
|
||||
case 'left':
|
||||
newPos.x -= distance;
|
||||
break;
|
||||
case 'right':
|
||||
newPos.x += distance;
|
||||
break;
|
||||
case 'iso':
|
||||
newPos.x += distance * 0.7;
|
||||
newPos.y += distance * 0.5;
|
||||
newPos.z += distance * 0.7;
|
||||
break;
|
||||
}
|
||||
|
||||
camera.position.copy(newPos);
|
||||
camera.lookAt(target);
|
||||
controlsRef.current.update();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Fullscreen Toggle
|
||||
const handleToggleFullscreen = useCallback(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (!document.fullscreenElement) {
|
||||
containerRef.current.requestFullscreen().catch(() => {});
|
||||
} else {
|
||||
document.exitFullscreen().catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 1.5-Second Hover Detection
|
||||
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (!containerRef.current || !activeCameraRef.current || !modelGroupRef.current) return;
|
||||
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
|
||||
const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1;
|
||||
mousePosRef.current.set(mouseX, mouseY);
|
||||
|
||||
// Clear previous timer on movement
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}
|
||||
setHoveredPiece(null);
|
||||
setHoverPosition(null);
|
||||
|
||||
// Set 1.5s delay timer for stable hover
|
||||
const clientX = e.clientX;
|
||||
const clientY = e.clientY;
|
||||
|
||||
hoverTimerRef.current = setTimeout(() => {
|
||||
raycasterRef.current.setFromCamera(mousePosRef.current, activeCameraRef.current!);
|
||||
const intersects = raycasterRef.current.intersectObjects(modelGroupRef.current!.children, true);
|
||||
|
||||
if (intersects.length > 0) {
|
||||
const hitMesh = intersects[0].object as THREE.Mesh;
|
||||
const expressID = hitMesh.userData.expressID;
|
||||
const pmark = hitMesh.userData.pieceMark;
|
||||
|
||||
if (modelData && expressID) {
|
||||
const piece = modelData.pieceByExpressID.get(expressID) || {
|
||||
expressID,
|
||||
guid: '',
|
||||
name: hitMesh.userData.section || 'Peça',
|
||||
type: 'PIECE',
|
||||
pieceMark: pmark,
|
||||
section: hitMesh.userData.section,
|
||||
};
|
||||
setHoveredPiece(piece);
|
||||
setHoverPosition({ x: clientX, y: clientY });
|
||||
}
|
||||
}
|
||||
}, 1500); // 1.5 seconds exact requirement
|
||||
};
|
||||
|
||||
const handlePointerLeave = () => {
|
||||
if (hoverTimerRef.current) {
|
||||
clearTimeout(hoverTimerRef.current);
|
||||
hoverTimerRef.current = null;
|
||||
}
|
||||
setHoveredPiece(null);
|
||||
setHoverPosition(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerLeave={handlePointerLeave}
|
||||
className="relative w-full h-full min-h-[600px] bg-slate-950 rounded-2xl overflow-hidden border border-slate-800 shadow-2xl select-none"
|
||||
>
|
||||
{/* Top Right: Interactive ViewCube */}
|
||||
<div className="absolute top-4 right-4 z-20">
|
||||
<ViewCube onSelectView={handleViewCubeSelect} />
|
||||
</div>
|
||||
|
||||
{/* Hover Tooltip (1.5s delay trigger) */}
|
||||
<PieceHoverTooltip
|
||||
piece={hoveredPiece}
|
||||
position={hoverPosition}
|
||||
productionInfo={
|
||||
hoveredPiece?.pieceMark && productionData
|
||||
? {
|
||||
pointedQty: productionData.get(hoveredPiece.pieceMark)?.pointedQtd,
|
||||
totalQty: productionData.get(hoveredPiece.pieceMark)?.totalQtd,
|
||||
currentStage: productionData.get(hoveredPiece.pieceMark)?.currentProcessName,
|
||||
stageColor: productionData.get(hoveredPiece.pieceMark)?.processColor,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Bottom Floating Toolbar */}
|
||||
<div className="absolute bottom-4 left-4 right-4 z-20 max-w-5xl mx-auto">
|
||||
<Viewer3DToolbar
|
||||
opacity={opacity}
|
||||
onOpacityChange={setOpacity}
|
||||
showGrid={showGrid}
|
||||
onToggleGrid={() => {
|
||||
setShowGrid(!showGrid);
|
||||
if (gridHelperRef.current) gridHelperRef.current.visible = !showGrid;
|
||||
}}
|
||||
isOrthographic={isOrthographic}
|
||||
onToggleCamera={handleToggleCamera}
|
||||
isWireframe={isWireframe}
|
||||
onToggleWireframe={() => setIsWireframe(!isWireframe)}
|
||||
navMode={navMode}
|
||||
onNavModeChange={setNavMode}
|
||||
colorMode={colorMode}
|
||||
onColorModeChange={setColorMode}
|
||||
onFitView={() => fitModelToView()}
|
||||
onToggleFullscreen={handleToggleFullscreen}
|
||||
onToggleSectionPlanes={() => setHasSectionPlanes(!hasSectionPlanes)}
|
||||
onToggleMeasure={() => setIsMeasuring(!isMeasuring)}
|
||||
hasSectionPlanes={hasSectionPlanes}
|
||||
isMeasuring={isMeasuring}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,110 @@
|
||||
import React from 'react';
|
||||
import { PieceInfo } from '@/lib/ifc/ifcLoaderService';
|
||||
import { Box, Layers, CheckCircle2, Clock } from 'lucide-react';
|
||||
|
||||
interface PieceHoverTooltipProps {
|
||||
piece: PieceInfo | null;
|
||||
position: { x: number; y: number } | null;
|
||||
productionInfo?: {
|
||||
pointedQty?: number;
|
||||
totalQty?: number;
|
||||
currentStage?: string;
|
||||
stageColor?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export const PieceHoverTooltip: React.FC<PieceHoverTooltipProps> = ({
|
||||
piece,
|
||||
position,
|
||||
productionInfo,
|
||||
}) => {
|
||||
if (!piece || !position) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed pointer-events-none z-40 p-4 rounded-2xl bg-slate-900/95 backdrop-blur-xl border border-cyan-500/40 shadow-2xl shadow-cyan-950/60 text-slate-100 min-w-[260px] animate-in fade-in zoom-in-95 duration-150"
|
||||
style={{
|
||||
left: `${position.x + 16}px`,
|
||||
top: `${position.y + 16}px`,
|
||||
maxWidth: '320px',
|
||||
}}
|
||||
>
|
||||
{/* Header Badge */}
|
||||
<div className="flex items-center justify-between gap-2 pb-2.5 mb-2.5 border-b border-slate-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-cyan-500/20 text-cyan-400 border border-cyan-500/30">
|
||||
<Box className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] uppercase font-mono tracking-widest text-slate-400 block">
|
||||
Marca Principal
|
||||
</span>
|
||||
<span className="text-base font-extrabold text-cyan-300 font-mono tracking-tight">
|
||||
{piece.pieceMark || 'Sem Marca'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{piece.phase && (
|
||||
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold font-mono bg-slate-800 text-slate-300 border border-slate-700">
|
||||
Fase {piece.phase}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="space-y-1.5 text-xs">
|
||||
{piece.section && (
|
||||
<div className="flex items-center justify-between text-slate-300">
|
||||
<span className="text-slate-400">Perfil / Bitola:</span>
|
||||
<span className="font-semibold text-white font-mono bg-slate-950/60 px-2 py-0.5 rounded border border-slate-800">
|
||||
{piece.section}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{piece.childrenIDs && piece.childrenIDs.length > 0 && (
|
||||
<div className="flex items-center justify-between text-slate-300">
|
||||
<span className="text-slate-400">Componentes:</span>
|
||||
<span className="font-semibold text-slate-200">
|
||||
{piece.childrenIDs.length} peças soldadas
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Production Apontamentos */}
|
||||
{productionInfo && (
|
||||
<div className="mt-3 pt-2.5 border-t border-slate-800/80 space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-slate-400 flex items-center gap-1.5">
|
||||
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
|
||||
Apontamento:
|
||||
</span>
|
||||
<span className="font-bold text-emerald-400 font-mono">
|
||||
{productionInfo.pointedQty ?? 0} de {productionInfo.totalQty ?? 1} peças
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{productionInfo.currentStage && (
|
||||
<div className="flex items-center justify-between text-[11px]">
|
||||
<span className="text-slate-400 flex items-center gap-1">
|
||||
<Clock className="w-3 h-3 text-amber-400" />
|
||||
Etapa Atual:
|
||||
</span>
|
||||
<span
|
||||
className="px-2 py-0.5 rounded-full font-bold uppercase tracking-wider"
|
||||
style={{
|
||||
backgroundColor: `${productionInfo.stageColor || '#10b981'}20`,
|
||||
color: productionInfo.stageColor || '#10b981',
|
||||
border: `1px solid ${productionInfo.stageColor || '#10b981'}40`,
|
||||
}}
|
||||
>
|
||||
{productionInfo.currentStage}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
|
||||
interface ViewCubeProps {
|
||||
onSelectView: (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso') => void;
|
||||
}
|
||||
|
||||
export const ViewCube: React.FC<ViewCubeProps> = ({ onSelectView }) => {
|
||||
return (
|
||||
<div className="relative w-28 h-28 select-none">
|
||||
<div className="w-full h-full flex items-center justify-center p-2 rounded-xl bg-slate-900/80 backdrop-blur-md border border-cyan-500/30 shadow-2xl shadow-cyan-950/40">
|
||||
<div className="relative w-20 h-20 perspective-[600px] flex items-center justify-center">
|
||||
{/* Central Cube Simulation with Interactive Buttons */}
|
||||
<div className="grid grid-cols-3 grid-rows-3 gap-1 w-full h-full text-[10px] font-bold tracking-wider">
|
||||
<button
|
||||
onClick={() => onSelectView('top')}
|
||||
className="col-span-3 py-1 bg-cyan-950/70 hover:bg-cyan-500 hover:text-slate-950 text-cyan-300 rounded border border-cyan-500/40 transition-all flex items-center justify-center shadow"
|
||||
title="Vista Superior (Planta)"
|
||||
>
|
||||
TOPO
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSelectView('left')}
|
||||
className="py-2 bg-cyan-950/70 hover:bg-cyan-500 hover:text-slate-950 text-cyan-300 rounded border border-cyan-500/40 transition-all flex items-center justify-center shadow"
|
||||
title="Vista Esquerda"
|
||||
>
|
||||
ESQ
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSelectView('front')}
|
||||
className="py-2 bg-cyan-600/80 hover:bg-cyan-400 hover:text-slate-950 text-white rounded border border-cyan-400 transition-all flex items-center justify-center shadow font-extrabold"
|
||||
title="Vista Frontal"
|
||||
>
|
||||
FRENTE
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSelectView('right')}
|
||||
className="py-2 bg-cyan-950/70 hover:bg-cyan-500 hover:text-slate-950 text-cyan-300 rounded border border-cyan-500/40 transition-all flex items-center justify-center shadow"
|
||||
title="Vista Direita"
|
||||
>
|
||||
DIR
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSelectView('back')}
|
||||
className="col-span-3 py-1 bg-cyan-950/70 hover:bg-cyan-500 hover:text-slate-950 text-cyan-300 rounded border border-cyan-500/40 transition-all flex items-center justify-center shadow"
|
||||
title="Vista Posterior (Atrás)"
|
||||
>
|
||||
ATRÁS
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center mt-1 text-[9px] font-mono uppercase text-cyan-400/80 tracking-widest">
|
||||
Orientação 3D
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Eye,
|
||||
Grid as GridIcon,
|
||||
Scissors,
|
||||
Maximize2,
|
||||
Box,
|
||||
Layers,
|
||||
Compass,
|
||||
Footprints,
|
||||
Ruler,
|
||||
Palette,
|
||||
CheckCircle2
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Viewer3DToolbarProps {
|
||||
opacity: number;
|
||||
onOpacityChange: (val: number) => void;
|
||||
showGrid: boolean;
|
||||
onToggleGrid: () => void;
|
||||
isOrthographic: boolean;
|
||||
onToggleCamera: () => void;
|
||||
isWireframe: boolean;
|
||||
onToggleWireframe: () => void;
|
||||
navMode: 'orbit' | 'walk';
|
||||
onNavModeChange: (mode: 'orbit' | 'walk') => void;
|
||||
colorMode: 'description' | 'production';
|
||||
onColorModeChange: (mode: 'description' | 'production') => void;
|
||||
onFitView: () => void;
|
||||
onToggleFullscreen: () => void;
|
||||
onToggleSectionPlanes?: () => void;
|
||||
onToggleMeasure?: () => void;
|
||||
hasSectionPlanes?: boolean;
|
||||
isMeasuring?: boolean;
|
||||
}
|
||||
|
||||
export const Viewer3DToolbar: React.FC<Viewer3DToolbarProps> = ({
|
||||
opacity,
|
||||
onOpacityChange,
|
||||
showGrid,
|
||||
onToggleGrid,
|
||||
isOrthographic,
|
||||
onToggleCamera,
|
||||
isWireframe,
|
||||
onToggleWireframe,
|
||||
navMode,
|
||||
onNavModeChange,
|
||||
colorMode,
|
||||
onColorModeChange,
|
||||
onFitView,
|
||||
onToggleFullscreen,
|
||||
onToggleSectionPlanes,
|
||||
onToggleMeasure,
|
||||
hasSectionPlanes,
|
||||
isMeasuring,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 p-3 bg-slate-900/90 backdrop-blur-xl border border-slate-800 rounded-2xl shadow-2xl text-slate-200">
|
||||
{/* Left: Opacity Slider */}
|
||||
<div className="flex items-center gap-3 bg-slate-950/60 px-4 py-2 rounded-xl border border-slate-800">
|
||||
<Eye className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-xs font-medium text-slate-300">Opacidade</span>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={100}
|
||||
value={opacity}
|
||||
onChange={(e) => onOpacityChange(Number(e.target.value))}
|
||||
className="w-24 h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500"
|
||||
/>
|
||||
<span className="text-xs font-mono font-bold text-amber-400 min-w-[32px]">
|
||||
{opacity}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Middle: Control Buttons */}
|
||||
<div className="flex flex-wrap items-center gap-1.5 bg-slate-950/60 p-1.5 rounded-xl border border-slate-800">
|
||||
{/* Grid */}
|
||||
<button
|
||||
onClick={onToggleGrid}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
showGrid
|
||||
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/40 shadow-sm'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||
}`}
|
||||
title="Alternar Grade de Piso"
|
||||
>
|
||||
<GridIcon className="w-3.5 h-3.5" />
|
||||
<span>Grid</span>
|
||||
</button>
|
||||
|
||||
{/* Section / Cortes */}
|
||||
<button
|
||||
onClick={onToggleSectionPlanes}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
hasSectionPlanes
|
||||
? 'bg-rose-500/20 text-rose-300 border border-rose-500/40 shadow-sm'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||
}`}
|
||||
title="Planos de Corte Estrutural"
|
||||
>
|
||||
<Scissors className="w-3.5 h-3.5" />
|
||||
<span>Cortes</span>
|
||||
</button>
|
||||
|
||||
{/* Escala 1:1 / Fit */}
|
||||
<button
|
||||
onClick={onFitView}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-800/60 transition-all"
|
||||
title="Enquadrar Estrutura Completa"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
<span>Escala 1:1</span>
|
||||
</button>
|
||||
|
||||
{/* Sólido / Wireframe */}
|
||||
<button
|
||||
onClick={onToggleWireframe}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
!isWireframe
|
||||
? 'bg-blue-500/20 text-blue-300 border border-blue-500/40'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||
}`}
|
||||
title="Modo Sólido ou Aramado"
|
||||
>
|
||||
{isWireframe ? <Layers className="w-3.5 h-3.5" /> : <Box className="w-3.5 h-3.5" />}
|
||||
<span>{isWireframe ? 'Aramado' : 'Sólido'}</span>
|
||||
</button>
|
||||
|
||||
{/* Ortogonal / Perspectiva */}
|
||||
<button
|
||||
onClick={onToggleCamera}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
isOrthographic
|
||||
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||
}`}
|
||||
title="Alternar Câmera Ortogonal ou Perspectiva"
|
||||
>
|
||||
<Box className="w-3.5 h-3.5" />
|
||||
<span>{isOrthographic ? 'Ortogonal' : 'Perspectiva'}</span>
|
||||
</button>
|
||||
|
||||
{/* Posicionar (Orbit) */}
|
||||
<button
|
||||
onClick={() => onNavModeChange('orbit')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
navMode === 'orbit'
|
||||
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/40'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||
}`}
|
||||
title="Modo Orbitar / Rotacionar"
|
||||
>
|
||||
<Compass className="w-3.5 h-3.5" />
|
||||
<span>Posicionar</span>
|
||||
</button>
|
||||
|
||||
{/* Caminhar */}
|
||||
<button
|
||||
onClick={() => onNavModeChange('walk')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
navMode === 'walk'
|
||||
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/40'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||
}`}
|
||||
title="Modo Primeira Pessoa (Caminhar)"
|
||||
>
|
||||
<Footprints className="w-3.5 h-3.5" />
|
||||
<span>Caminhar</span>
|
||||
</button>
|
||||
|
||||
{/* Medir */}
|
||||
<button
|
||||
onClick={onToggleMeasure}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
isMeasuring
|
||||
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40'
|
||||
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||
}`}
|
||||
title="Régua de Medição"
|
||||
>
|
||||
<Ruler className="w-3.5 h-3.5" />
|
||||
<span>Medir</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Right: Color Mode & Fullscreen */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mode Selector */}
|
||||
<div className="flex items-center bg-slate-950/60 p-1 rounded-xl border border-slate-800">
|
||||
<button
|
||||
onClick={() => onColorModeChange('description')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
colorMode === 'description'
|
||||
? 'bg-amber-500 text-slate-950 font-bold shadow-lg shadow-amber-500/20'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
title="Cores por Família/Perfil"
|
||||
>
|
||||
<Palette className="w-3.5 h-3.5" />
|
||||
<span>Descrição</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onColorModeChange('production')}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||
colorMode === 'production'
|
||||
? 'bg-emerald-500 text-slate-950 font-bold shadow-lg shadow-emerald-500/20'
|
||||
: 'text-slate-400 hover:text-slate-200'
|
||||
}`}
|
||||
title="Cores por Status de Apontamento"
|
||||
>
|
||||
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||
<span>Produção</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Fullscreen Button */}
|
||||
<button
|
||||
onClick={onToggleFullscreen}
|
||||
className="flex items-center gap-1.5 px-3 py-2 bg-slate-950/60 hover:bg-cyan-500 hover:text-slate-950 text-cyan-400 border border-slate-800 hover:border-cyan-400 rounded-xl text-xs font-mono font-bold uppercase tracking-wider transition-all"
|
||||
title="Tela Cheia"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
<span>Tela Cheia</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user