feat: Novo módulo Analisador 3D BIM & QA/QC (Auditoria de Furação e Comparador de Reviseeeees)

This commit is contained in:
2026-10-08 10:16:48 -03:00
parent d177184a62
commit f42976a09d
10 changed files with 2394 additions and 1 deletions
+3
View File
@@ -59,6 +59,8 @@ export function AppSidebar() {
'estoque': '#8b5cf6', // roxo
'ofs': '#eab308', // amarelo
'producao': '#ef4444', // vermelho
'visualizador-3d': '#8b5cf6', // roxo
'analisador-3d': '#6366f1', // indigo
'painel-industrial': '#06b6d4', // ciano
'expedicao': '#ec4899', // rosa
'obra': '#a3a3a3', // marrom
@@ -112,6 +114,7 @@ export function AppSidebar() {
'painel-industrial': 'producao-dashboard',
// Engenharia, 3D & OFs
'visualizador-3d': 'visualizador-3d',
'analisador-3d': 'analisador-3d',
'ordens-fabricacao': 'ofs-lista',
'ofs-concluidas': 'ofs-lista',
'ficha-tecnica-of': 'cadastro-of',
+8 -1
View File
@@ -18,7 +18,8 @@ import {
Settings,
Shield,
UserCog,
Cog
Cog,
ScanSearch
} from "lucide-react";
import { MenuItem, MenuGroup } from './types';
@@ -156,6 +157,12 @@ export const menuGroups: MenuGroup[] = [
url: "/visualizador-3d",
icon: Box,
},
{
key: "analisador-3d",
title: "Analisador 3D",
url: "/analisador-3d",
icon: ScanSearch,
},
{
key: "painel-industrial",
title: "Painel Industrial",
@@ -0,0 +1,489 @@
import React, { useEffect, useRef, useState, useCallback } from 'react';
import * as THREE from 'three';
import { OrbitControls } from 'three-stdlib';
import { LoadedIFCResult, PieceInfo } from '@/lib/ifc/ifcLoaderService';
import { DefectItem, ColorizationMode } from '@/types/analisador3d';
import { ViewCube } from './ViewCube';
import {
RotateCcw,
Eye,
Maximize2,
Minimize2,
Box,
Layers,
Sparkles,
Search,
Crosshair,
AlertTriangle,
CheckCircle,
HelpCircle,
Cpu
} from 'lucide-react';
interface AnalyzerViewer3DProps {
modelData: LoadedIFCResult | null;
defects: DefectItem[];
selectedDefect: DefectItem | null;
selectedPieceMark: string | null;
colorizationMode: ColorizationMode;
onSelectDefect?: (defect: DefectItem | null) => void;
onSelectPiece?: (pieceMark: string | null) => void;
isGhostMode?: boolean;
onToggleGhostMode?: () => void;
isFullscreen?: boolean;
onToggleFullscreen?: () => void;
theme?: 'dark' | 'light';
}
export const AnalyzerViewer3D: React.FC<AnalyzerViewer3DProps> = ({
modelData,
defects,
selectedDefect,
selectedPieceMark,
colorizationMode,
onSelectDefect,
onSelectPiece,
isGhostMode = false,
onToggleGhostMode,
isFullscreen = false,
onToggleFullscreen,
theme = 'dark',
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const canvasContainerRef = useRef<HTMLDivElement>(null);
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
const sceneRef = useRef<THREE.Scene | null>(null);
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
const controlsRef = useRef<OrbitControls | null>(null);
const modelGroupRef = useRef<THREE.Group | null>(null);
const gridRef = useRef<THREE.GridHelper | null>(null);
const [wireframe, setWireframe] = useState<boolean>(false);
const [hoveredDefect, setHoveredDefect] = useState<DefectItem | null>(null);
const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null);
// Mapeamentos rápidos para busca e colorização
const defectByPieceMap = useRef<Map<string, DefectItem>>(new Map());
useEffect(() => {
const map = new Map<string, DefectItem>();
defects.forEach((d) => {
map.set(d.pecaMarca.toUpperCase(), d);
if (d.pecaConectadaMarca) {
map.set(d.pecaConectadaMarca.toUpperCase(), d);
}
});
defectByPieceMap.current = map;
}, [defects]);
// 1. Inicializa o Three.js
useEffect(() => {
if (!canvasContainerRef.current) return;
const width = canvasContainerRef.current.clientWidth || 800;
const height = canvasContainerRef.current.clientHeight || 500;
const scene = new THREE.Scene();
sceneRef.current = scene;
const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000);
camera.position.set(40, 35, 50);
cameraRef.current = camera;
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
powerPreference: 'high-performance',
});
renderer.setSize(width, height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.1;
rendererRef.current = renderer;
canvasContainerRef.current.innerHTML = '';
canvasContainerRef.current.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.maxDistance = 1500;
controls.minDistance = 0.2;
controlsRef.current = controls;
// Iluminação balanceada industrial
const ambientLight = new THREE.AmbientLight(0xffffff, 0.85);
scene.add(ambientLight);
const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2);
dirLight1.position.set(80, 120, 80);
scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.6);
dirLight2.position.set(-80, -40, -80);
scene.add(dirLight2);
// Grid no piso
const isDark = theme === 'dark' || document.documentElement.classList.contains('dark');
const gridColor1 = isDark ? 0x475569 : 0x94a3b8;
const gridColor2 = isDark ? 0x1e293b : 0xe2e8f0;
const grid = new THREE.GridHelper(100, 50, gridColor1, gridColor2);
grid.position.y = -0.01;
scene.add(grid);
gridRef.current = grid;
let animId: number;
const animate = () => {
animId = requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
const handleResize = () => {
if (!canvasContainerRef.current || !renderer || !camera) return;
const w = canvasContainerRef.current.clientWidth;
const h = canvasContainerRef.current.clientHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
};
window.addEventListener('resize', handleResize);
const observer = new ResizeObserver(handleResize);
observer.observe(canvasContainerRef.current);
return () => {
cancelAnimationFrame(animId);
window.removeEventListener('resize', handleResize);
observer.disconnect();
renderer.dispose();
controls.dispose();
};
}, [theme]);
// 2. Carrega o modelo na cena
useEffect(() => {
const scene = sceneRef.current;
if (!scene) return;
if (modelGroupRef.current) {
scene.remove(modelGroupRef.current);
modelGroupRef.current = null;
}
if (!modelData || !modelData.sceneGroup) return;
const group = modelData.sceneGroup.clone();
modelGroupRef.current = group;
scene.add(group);
// Ajusta piso do grid com base no tamanho do modelo
const bbox = new THREE.Box3().setFromObject(group);
if (!bbox.isEmpty()) {
const center = new THREE.Vector3();
bbox.getCenter(center);
const size = new THREE.Vector3();
bbox.getSize(size);
// Centraliza grupo na origem
group.position.x = -center.x;
group.position.y = -bbox.min.y;
group.position.z = -center.z;
if (gridRef.current) {
gridRef.current.position.y = 0;
}
// Ajusta câmera
const maxDim = Math.max(size.x, size.y, size.z);
if (cameraRef.current && controlsRef.current) {
cameraRef.current.position.set(maxDim * 1.3, maxDim * 1.1, maxDim * 1.5);
controlsRef.current.target.set(0, size.y / 2, 0);
controlsRef.current.update();
}
}
}, [modelData]);
// 3. Aplica Colorização Dinâmica com base no modo selecionado e na peça/defeito ativo
useEffect(() => {
if (!modelGroupRef.current) return;
const group = modelGroupRef.current;
const targetMark = selectedPieceMark?.toUpperCase() || selectedDefect?.pecaMarca.toUpperCase();
group.traverse((obj) => {
if (obj instanceof THREE.Mesh) {
// Tenta achar a marca associada na userData
const pieceInfo = obj.userData?.pieceInfo as PieceInfo | undefined;
const mark = (pieceInfo?.pieceMark || obj.userData?.pieceMark || '').toUpperCase();
const defect = defectByPieceMap.current.get(mark);
const isSelected = targetMark && (mark === targetMark || (defect && defect.pecaConectadaMarca?.toUpperCase() === targetMark));
let color = '#94a3b8'; // neutro default
let emissiveColor = '#000000';
let emissiveIntensity = 0;
if (colorizationMode === 'status_defeito') {
if (defect) {
if (defect.gravidade === 'critico') color = '#ef4444'; // vermelho
else if (defect.gravidade === 'alerta') color = '#f59e0b'; // laranja/amarelo
else color = '#3b82f6'; // azul
} else {
color = '#10b981'; // verde conforme
}
} else if (colorizationMode === 'diff_revisao') {
if (defect?.tipo === 'peca_nova') color = '#3b82f6'; // azul novo
else if (defect?.tipo === 'peca_excluida') color = '#ef4444'; // vermelho excluído
else if (defect?.tipo === 'peca_modificada' || defect?.tipo === 'deslocamento_furo' || defect?.tipo === 'furo_extra_revisao') {
color = '#f59e0b'; // amarelo modificado
} else {
color = '#10b981'; // verde inalterado
}
} else if (colorizationMode === 'risco_fabril') {
if (defect) {
if (defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto') color = '#dc2626';
else if (defect.riscoFabril === 'medio') color = '#f59e0b';
else color = '#10b981';
} else {
color = '#64748b';
}
} else if (colorizationMode === 'processo') {
color = defect?.processoCor || '#3b82f6';
}
// Se estiver em modo fantasma e houver seleção: atenua peças não selecionadas
let opacity = 1.0;
let transparent = false;
if (isGhostMode && targetMark) {
if (!isSelected) {
opacity = 0.12;
transparent = true;
} else {
opacity = 1.0;
emissiveColor = color;
emissiveIntensity = 0.5;
}
} else if (isSelected) {
emissiveColor = '#ffffff';
emissiveIntensity = 0.35;
}
// Aplica o material atualizado
obj.material = new THREE.MeshStandardMaterial({
color: new THREE.Color(color),
emissive: new THREE.Color(emissiveColor),
emissiveIntensity,
roughness: 0.35,
metalness: 0.2,
wireframe,
transparent,
opacity,
side: THREE.DoubleSide,
});
}
});
}, [colorizationMode, selectedDefect, selectedPieceMark, isGhostMode, wireframe, modelData]);
// 4. Foco na Câmera ao selecionar defeito/peça
const focusOnTarget = useCallback((coords?: { x: number; y: number; z: number }) => {
if (!coords || !cameraRef.current || !controlsRef.current) return;
const target = new THREE.Vector3(coords.x, coords.y, coords.z);
controlsRef.current.target.copy(target);
cameraRef.current.position.set(target.x + 3, target.y + 3, target.z + 4);
controlsRef.current.update();
}, []);
useEffect(() => {
if (selectedDefect && selectedDefect.coordenadas3D) {
focusOnTarget(selectedDefect.coordenadas3D);
}
}, [selectedDefect, focusOnTarget]);
// 5. Clique no 3D (Raycasting)
const handlePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (!canvasContainerRef.current || !cameraRef.current || !modelGroupRef.current) return;
const rect = canvasContainerRef.current.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const y = -((e.clientY - rect.top) / rect.height) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(new THREE.Vector2(x, y), cameraRef.current);
const intersects = raycaster.intersectObjects(modelGroupRef.current.children, true);
if (intersects.length > 0) {
const hit = intersects[0].object as THREE.Mesh;
const pieceInfo = hit.userData?.pieceInfo as PieceInfo | undefined;
const mark = pieceInfo?.pieceMark || hit.userData?.pieceMark;
if (mark) {
onSelectPiece?.(mark);
const def = defectByPieceMap.current.get(mark.toUpperCase());
onSelectDefect?.(def || null);
}
}
};
// Reset de Câmera
const handleResetCamera = () => {
if (!modelGroupRef.current || !cameraRef.current || !controlsRef.current) return;
const bbox = new THREE.Box3().setFromObject(modelGroupRef.current);
const size = new THREE.Vector3();
bbox.getSize(size);
const maxDim = Math.max(size.x, size.y, size.z);
cameraRef.current.position.set(maxDim * 1.3, maxDim * 1.1, maxDim * 1.5);
controlsRef.current.target.set(0, size.y / 2, 0);
controlsRef.current.update();
};
return (
<div
ref={containerRef}
className={`relative w-full h-full select-none overflow-hidden rounded-xl border ${
theme === 'dark'
? 'bg-slate-950/80 border-slate-800'
: 'bg-slate-900 border-slate-700'
}`}
>
{/* Container Three.js */}
<div
ref={canvasContainerRef}
className="w-full h-full cursor-grab active:cursor-grabbing"
onPointerDown={handlePointerDown}
/>
{/* ViewCube Superior Direito */}
{cameraRef.current && controlsRef.current && (
<ViewCube camera={cameraRef.current} controls={controlsRef.current} />
)}
{/* Barra de Ferramentas Flutuante Superior */}
<div className="absolute top-3 left-3 flex items-center gap-1.5 p-1.5 bg-slate-900/90 backdrop-blur-md border border-slate-700/80 rounded-lg shadow-xl z-20">
<button
onClick={handleResetCamera}
title="Resetar Câmera"
className="p-1.5 text-slate-300 hover:text-white hover:bg-slate-800 rounded transition"
>
<RotateCcw className="w-4 h-4" />
</button>
<div className="w-[1px] h-4 bg-slate-700 mx-1" />
<button
onClick={onToggleGhostMode}
title={isGhostMode ? 'Desativar Modo Raio-X / Foco' : 'Ativar Modo Raio-X / Foco'}
className={`flex items-center gap-1.5 px-2 py-1 text-xs font-medium rounded transition ${
isGhostMode
? 'bg-indigo-600 text-white shadow'
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
}`}
>
<Eye className="w-3.5 h-3.5" />
<span>Foco / Raio-X</span>
</button>
<button
onClick={() => setWireframe(!wireframe)}
title="Alternar Estrutura de Arame (Wireframe)"
className={`p-1.5 rounded transition ${
wireframe
? 'bg-indigo-600 text-white'
: 'text-slate-300 hover:bg-slate-800 hover:text-white'
}`}
>
<Box className="w-4 h-4" />
</button>
{onToggleFullscreen && (
<button
onClick={onToggleFullscreen}
title={isFullscreen ? 'Sair da Tela Cheia' : 'Tela Cheia'}
className="p-1.5 text-slate-300 hover:text-white hover:bg-slate-800 rounded transition"
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
)}
</div>
{/* Legenda de Cores Flutuante Inferior */}
<div className="absolute bottom-3 left-3 flex flex-wrap items-center gap-3 px-3 py-2 bg-slate-900/90 backdrop-blur-md border border-slate-700/80 rounded-lg shadow-xl text-xs z-20">
<span className="text-slate-400 font-medium">Legenda:</span>
{colorizationMode === 'status_defeito' && (
<>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500 shadow-sm" />
<span className="text-slate-300">Conforme</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-amber-500 shadow-sm" />
<span className="text-slate-300">Alerta (Deslocado)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500 shadow-sm" />
<span className="text-slate-300">Crítico (Furo Cego/Colisão)</span>
</div>
</>
)}
{colorizationMode === 'diff_revisao' && (
<>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
<span className="text-slate-300">Inalterada</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-amber-500" />
<span className="text-slate-300">Modificada</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-blue-500" />
<span className="text-slate-300">Nova</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-500" />
<span className="text-slate-300">Excluída</span>
</div>
</>
)}
{colorizationMode === 'risco_fabril' && (
<>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-emerald-500" />
<span className="text-slate-300">Baixo Risco (Sem Corte)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-amber-500" />
<span className="text-slate-300">Médio (Em Corte)</span>
</div>
<div className="flex items-center gap-1.5">
<span className="w-2.5 h-2.5 rounded-full bg-red-600 animate-pulse" />
<span className="text-slate-300">Crítico (Soldada/Expedida)</span>
</div>
</>
)}
</div>
{/* Alerta de elemento selecionado flutuante */}
{selectedPieceMark && (
<div className="absolute top-3 right-16 px-3 py-1.5 bg-indigo-950/90 border border-indigo-700/80 rounded-lg shadow-xl text-xs flex items-center gap-2 z-20">
<Crosshair className="w-3.5 h-3.5 text-indigo-400 animate-spin" style={{ animationDuration: '6s' }} />
<span className="text-indigo-200">
Foco Ativo: <strong className="text-white font-mono">{selectedPieceMark}</strong>
</span>
<button
onClick={() => {
onSelectPiece?.(null);
onSelectDefect?.(null);
}}
className="ml-1 text-indigo-400 hover:text-white"
title="Limpar seleção"
>
×
</button>
</div>
)}
</div>
);
};
@@ -0,0 +1,264 @@
import React from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import { DefectItem } from '@/types/analisador3d';
import {
AlertTriangle,
CheckCircle2,
AlertCircle,
Copy,
Crosshair,
Layers,
Wrench,
Factory,
ShieldAlert,
ArrowRight
} from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
interface PieceAuditDetailModalProps {
isOpen: boolean;
onClose: () => void;
defect: DefectItem | null;
onFocus3D?: (coords?: { x: number; y: number; z: number }) => void;
}
export const PieceAuditDetailModal: React.FC<PieceAuditDetailModalProps> = ({
isOpen,
onClose,
defect,
onFocus3D,
}) => {
const { toast } = useToast();
if (!defect) return null;
const handleCopy = () => {
const text = `[AUDITORIA 3D TRACKSTEEL]\nPeça: ${defect.pecaMarca}\nConjunto: ${defect.conjuntoMarca || 'N/A'}\nTipo: ${defect.tipo}\nGravidade: ${defect.gravidade}\nDescrição: ${defect.descricao}\nRecomendação: ${defect.recomendacao}`;
navigator.clipboard.writeText(text);
toast({
title: 'Copiado para Área de Transferência',
description: 'Dados técnicos copiados com sucesso.',
});
};
const getSeverityBadge = () => {
switch (defect.gravidade) {
case 'critico':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-red-500/10 text-red-500 border border-red-500/20">
<AlertTriangle className="w-3.5 h-3.5" />
CRÍTICO
</span>
);
case 'alerta':
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-amber-500/10 text-amber-500 border border-amber-500/20">
<AlertCircle className="w-3.5 h-3.5" />
ALERTA
</span>
);
default:
return (
<span className="inline-flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-semibold bg-blue-500/10 text-blue-500 border border-blue-500/20">
<CheckCircle2 className="w-3.5 h-3.5" />
INFORMATIVO
</span>
);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800 text-slate-100 p-6 shadow-2xl">
<DialogHeader className="border-b border-slate-800 pb-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-2">
<span className="p-2 bg-indigo-500/10 text-indigo-400 rounded-lg border border-indigo-500/20">
<ShieldAlert className="w-5 h-5" />
</span>
<div>
<DialogTitle className="text-lg font-bold text-white flex items-center gap-2">
Diagnóstico Técnico • {defect.pecaMarca}
{getSeverityBadge()}
</DialogTitle>
<DialogDescription className="text-xs text-slate-400">
{defect.titulo}
</DialogDescription>
</div>
</div>
<button
onClick={handleCopy}
className="p-2 text-slate-400 hover:text-white hover:bg-slate-800 rounded-lg transition"
title="Copiar relatório deste defeito"
>
<Copy className="w-4 h-4" />
</button>
</div>
</DialogHeader>
<div className="space-y-4 py-3 text-sm">
{/* Card Descrição do Problema */}
<div className="p-3.5 bg-slate-950/60 border border-slate-800 rounded-lg">
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-1 flex items-center gap-1.5">
<AlertCircle className="w-3.5 h-3.5 text-indigo-400" />
Descrição do Ocorrido / Inconformidade
</h4>
<p className="text-slate-200 leading-relaxed text-xs sm:text-sm">
{defect.descricao}
</p>
{defect.deslocamentoMm && (
<div className="mt-2.5 pt-2 border-t border-slate-800/80 flex items-center gap-4 text-xs font-mono">
<span className="text-slate-400">Deslocamento:</span>
<span className="text-amber-400">ΔX: {defect.deslocamentoMm.x}mm</span>
<span className="text-amber-400">ΔY: {defect.deslocamentoMm.y}mm</span>
<span className="text-amber-400">ΔZ: {defect.deslocamentoMm.z}mm</span>
</div>
)}
</div>
{/* Grid de Relação Peça vs Peça Conectada */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="p-3 bg-slate-950/40 border border-slate-800 rounded-lg">
<span className="text-xs text-slate-400 font-medium flex items-center gap-1 mb-1">
<Layers className="w-3.5 h-3.5 text-indigo-400" />
Peça Auditada
</span>
<div className="font-mono font-bold text-white text-base">{defect.pecaMarca}</div>
<div className="text-xs text-slate-400 mt-1">
Conjunto:{' '}
<strong className="text-slate-300 font-mono">
{defect.conjuntoMarca || 'N/A'}
</strong>
</div>
<div className="text-xs text-slate-400">
Fase:{' '}
<strong className="text-slate-300">{defect.fase || 'Não definida'}</strong>
</div>
</div>
<div className="p-3 bg-slate-950/40 border border-slate-800 rounded-lg">
<span className="text-xs text-slate-400 font-medium flex items-center gap-1 mb-1">
<Wrench className="w-3.5 h-3.5 text-cyan-400" />
Peça Conectada (Adjacente)
</span>
{defect.pecaConectadaMarca ? (
<>
<div className="font-mono font-bold text-cyan-300 text-base">
{defect.pecaConectadaMarca}
</div>
<div className="text-xs text-slate-400 mt-1">
Conjunto:{' '}
<strong className="text-slate-300 font-mono">
{defect.pecaConectadaConjunto || 'N/A'}
</strong>
</div>
<div className="text-xs text-cyan-400 mt-1">
Ligação: Furação faceada / Parafuso
</div>
</>
) : (
<div className="text-xs text-slate-500 italic mt-2">
Elemento avulso ou modificação intrínseca da peça.
</div>
)}
</div>
</div>
{/* Situação no Chão de Fábrica */}
<div className="p-3.5 bg-slate-950/60 border border-slate-800 rounded-lg">
<h4 className="text-xs font-semibold text-slate-400 uppercase tracking-wider mb-2 flex items-center gap-1.5">
<Factory className="w-3.5 h-3.5 text-amber-400" />
Impacto no Chão de Fábrica (Tracksteel Engine)
</h4>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div>
<span className="text-slate-500 block">Etapa Atual</span>
<span
className="font-medium px-1.5 py-0.5 rounded text-[11px] inline-block mt-0.5"
style={{
backgroundColor: `${defect.processoCor || '#64748b'}25`,
color: defect.processoCor || '#94a3b8',
}}
>
{defect.processoAtual || 'Pendente'}
</span>
</div>
<div>
<span className="text-slate-500 block">Qtd Apontada</span>
<span className="font-mono font-bold text-slate-200 mt-0.5 block">
{defect.quantidadeApontada || 0} de {defect.quantidadeAfetada} un
</span>
</div>
<div>
<span className="text-slate-500 block">Peso Total</span>
<span className="font-mono font-bold text-slate-200 mt-0.5 block">
{defect.pesoTotalKg ? `${defect.pesoTotalKg.toFixed(1)} kg` : '—'}
</span>
</div>
<div>
<span className="text-slate-500 block">Risco de Retrabalho</span>
<span
className={`font-bold mt-0.5 uppercase block ${
defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto'
? 'text-red-400 animate-pulse'
: defect.riscoFabril === 'medio'
? 'text-amber-400'
: 'text-emerald-400'
}`}
>
{defect.riscoFabril}
</span>
</div>
</div>
</div>
{/* Recomendação de Engenharia */}
<div className="p-3.5 bg-indigo-950/30 border border-indigo-900/50 rounded-lg">
<h4 className="text-xs font-semibold text-indigo-300 uppercase tracking-wider mb-1 flex items-center gap-1.5">
<CheckCircle2 className="w-3.5 h-3.5 text-indigo-400" />
Ação Corretiva Recomendada
</h4>
<p className="text-xs sm:text-sm text-indigo-100 leading-relaxed">
{defect.recomendacao}
</p>
</div>
</div>
{/* Rodapé com Botões */}
<div className="border-t border-slate-800 pt-4 flex items-center justify-between">
<button
onClick={onClose}
className="px-4 py-2 text-xs font-medium text-slate-400 hover:text-white transition"
>
Fechar
</button>
{defect.coordenadas3D && onFocus3D && (
<button
onClick={() => {
onFocus3D(defect.coordenadas3D);
onClose();
}}
className="flex items-center gap-1.5 px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-xs font-medium shadow transition"
>
<Crosshair className="w-4 h-4" />
<span>Focar e Isolar no Visualizador 3D</span>
</button>
)}
</div>
</DialogContent>
</Dialog>
);
};
+8
View File
@@ -183,6 +183,14 @@ export const routeConfig: RouteDefinition[] = [
resourceKey: 'visualizador-3d',
},
// ── Analisador 3D (QA/QC & Auditoria Geométrica) ──────────
{
path: '/analisador-3d',
component: () => import('@/pages/Analisador3D'),
guard: 'resource',
resourceKey: 'analisador-3d',
},
// ── Painel Industrial ────────────────────────────────────
{
path: '/painel-industrial',
+1
View File
@@ -63,6 +63,7 @@ export const GRUPOS_MODULOS: GrupoRecurso[] = [
descricao: 'Projetos, modelos 3D, fichas técnicas e cronogramas',
recursos: [
{ key: 'visualizador-3d', nome: 'Visualizador 3D BIM', rota: '/visualizador-3d' },
{ key: 'analisador-3d', nome: 'Analisador 3D & QA/QC', rota: '/analisador-3d' },
{ key: 'ofs-lista', nome: 'Ordens de Fabricação', rota: '/ofs' },
{ key: 'ofs-cronograma', nome: 'Cronogramas de Fabricação', rota: '/ofs/cronograma' },
{ key: 'cadastro-pecas', nome: 'Cadastro de Peças & Conjuntos', rota: '/cadastro/pecas' },
+479
View File
@@ -0,0 +1,479 @@
import * as THREE from 'three';
import { LoadedIFCResult, PieceInfo } from '@/lib/ifc/ifcLoaderService';
import { DefectItem, DefectType, DefectSeverity, ProductionRisk, ComparisonDiffSummary } from '@/types/analisador3d';
interface AnalyzeOptions {
modelBase: LoadedIFCResult;
modelRevisao?: LoadedIFCResult | null;
productionMap?: Map<string, any>;
toleranceMm?: number;
}
/**
* Motor de Auditoria Geométrica, Detecção de Inconformidades e Comparação de Revisões 3D.
*/
export class AuditEngine {
/**
* Executa a auditoria completa (modo modelo único ou comparação de revisões).
*/
public static runAudit(options: AnalyzeOptions): {
defects: DefectItem[];
summary: ComparisonDiffSummary;
pieceStatusMap: Map<string, { status: string; color: string; defectId?: string }>;
} {
const { modelBase, modelRevisao, productionMap, toleranceMm = 1.5 } = options;
const defects: DefectItem[] = [];
const pieceStatusMap = new Map<string, { status: string; color: string; defectId?: string }>();
let totalBase = 0;
let totalRev = 0;
let inalteradas = 0;
let modificadas = 0;
let novas = 0;
let excluidas = 0;
let criticos = 0;
let alertas = 0;
let pesoAfetadoKg = 0;
// ── MODO 1: COMPARAÇÃO ENTRE DUAS REVISÕES (A vs B) ──
if (modelRevisao) {
const basePiecesByMark = modelBase.piecesByMark;
const revPiecesByMark = modelRevisao.piecesByMark;
// 1. Identifica peças excluídas (estavam na base mas sumiram na revisão)
for (const [mark, baseList] of basePiecesByMark.entries()) {
totalBase += baseList.length;
const revList = revPiecesByMark.get(mark);
if (!revList || revList.length === 0) {
excluidas += baseList.length;
const samplePiece = baseList[0];
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
const risco = this.calcularRiscoProducao(prodInfo);
const pesoUnit = prodInfo?.pesoUnitario || samplePiece.span ? (samplePiece.span * 0.05) : 10;
const pesoTotal = pesoUnit * baseList.length;
pesoAfetadoKg += pesoTotal;
const defect: DefectItem = {
id: `del-${mark}-${Date.now()}-${Math.random()}`,
tipo: 'peca_excluida',
gravidade: 'critico',
titulo: `Peça Excluída no Projeto: ${mark}`,
descricao: `A marca ${mark} constava na revisão anterior com ${baseList.length} unidade(s) e foi removida na nova revisão do projeto.`,
pecaMarca: mark,
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
expressID: samplePiece.expressID,
guid: samplePiece.guid,
fase: samplePiece.phase,
quantidadeAfetada: baseList.length,
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
pesoTotalKg: Number(pesoTotal.toFixed(2)),
processoAtual: prodInfo?.currentProcessName || 'Pendente',
processoCor: prodInfo?.processColor,
quantidadeApontada: prodInfo?.pointedQtd || 0,
riscoFabril: risco,
recomendacao: risco === 'critico' || risco === 'alto'
? `ATENÇÃO: Peça já fabricada/iniciada na fábrica. Interromper imediatamente e emitir RNC.`
: `Atualizar ordens de corte e cancelar etiquetas de fabricação.`,
coordenadas3D: this.getPieceCenter(samplePiece),
};
defects.push(defect);
criticos++;
pieceStatusMap.set(mark, { status: 'Excluída', color: '#ef4444', defectId: defect.id });
} else {
// 2. Peça existe em ambas: checar se sofreu alterações geométricas/furações
totalRev += revList.length;
const diffResult = this.comparePieceGeometry(baseList[0], revList[0], toleranceMm);
if (diffResult.hasChanged) {
modificadas += revList.length;
const samplePiece = revList[0];
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
const risco = this.calcularRiscoProducao(prodInfo);
const pesoUnit = prodInfo?.pesoUnitario || 15;
const pesoTotal = pesoUnit * revList.length;
pesoAfetadoKg += pesoTotal;
const defect: DefectItem = {
id: `mod-${mark}-${Date.now()}-${Math.random()}`,
tipo: diffResult.tipoDefeito,
gravidade: diffResult.gravidade,
titulo: `${diffResult.titulo}: ${mark}`,
descricao: diffResult.descricao,
pecaMarca: mark,
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
expressID: samplePiece.expressID,
guid: samplePiece.guid,
fase: samplePiece.phase,
quantidadeAfetada: revList.length,
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
pesoTotalKg: Number(pesoTotal.toFixed(2)),
diametroMm: diffResult.diametroMm,
deslocamentoMm: diffResult.deslocamentoMm,
processoAtual: prodInfo?.currentProcessName || 'Pendente',
processoCor: prodInfo?.processColor,
quantidadeApontada: prodInfo?.pointedQtd || 0,
riscoFabril: risco,
recomendacao: diffResult.recomendacao,
coordenadas3D: this.getPieceCenter(samplePiece),
};
defects.push(defect);
if (defect.gravidade === 'critico') criticos++;
else alertas++;
pieceStatusMap.set(mark, { status: 'Modificada', color: '#f59e0b', defectId: defect.id });
} else {
inalteradas += revList.length;
pieceStatusMap.set(mark, { status: 'Inalterada', color: '#10b981' });
}
}
}
// 3. Identifica peças novas (estão na revisão mas não existiam na base)
for (const [mark, revList] of revPiecesByMark.entries()) {
if (!basePiecesByMark.has(mark)) {
novas += revList.length;
totalRev += revList.length;
const samplePiece = revList[0];
const prodInfo = productionMap?.get(mark) || productionMap?.get(samplePiece.cleanMark || '');
const pesoUnit = prodInfo?.pesoUnitario || 12;
const pesoTotal = pesoUnit * revList.length;
const defect: DefectItem = {
id: `new-${mark}-${Date.now()}-${Math.random()}`,
tipo: 'peca_nova',
gravidade: 'informativo',
titulo: `Peça Nova Adicionada: ${mark}`,
descricao: `A marca ${mark} foi introduzida na nova revisão com ${revList.length} unidade(s). Necessário incluir na lista de corte.`,
pecaMarca: mark,
conjuntoMarca: samplePiece.assemblyMark || samplePiece.cleanAssemblyMark,
expressID: samplePiece.expressID,
guid: samplePiece.guid,
fase: samplePiece.phase,
quantidadeAfetada: revList.length,
pesoUnitarioKg: Number(pesoUnit.toFixed(2)),
pesoTotalKg: Number(pesoTotal.toFixed(2)),
processoAtual: 'Não Iniciado',
processoCor: '#64748b',
quantidadeApontada: 0,
riscoFabril: 'baixo',
recomendacao: `Emitir nova ordem de corte para o setor fabril e conferir lista de materiais.`,
coordenadas3D: this.getPieceCenter(samplePiece),
};
defects.push(defect);
pieceStatusMap.set(mark, { status: 'Nova', color: '#3b82f6', defectId: defect.id });
}
}
} else {
// ── MODO 2: AUDITORIA DE INTEGRIDADE GEOMÉTRICA & CONEXÕES (MODELO ÚNICO) ──
const allPieces: PieceInfo[] = [];
for (const list of modelBase.piecesByMark.values()) {
allPieces.push(...list);
}
totalBase = allPieces.length;
// Executa varredura de conexões e coaxialidade de furos entre peças em contato
const connectionDefects = this.auditConnectionsAndHoles(allPieces, productionMap, toleranceMm);
defects.push(...connectionDefects);
for (const def of connectionDefects) {
if (def.gravidade === 'critico') criticos++;
else alertas++;
pesoAfetadoKg += def.pesoTotalKg || 0;
pieceStatusMap.set(def.pecaMarca, {
status: def.gravidade === 'critico' ? 'Crítico' : 'Alerta',
color: def.gravidade === 'critico' ? '#ef4444' : '#f59e0b',
defectId: def.id,
});
}
// Peças sem defeitos detectados são marcadas como conformes
for (const p of allPieces) {
if (!pieceStatusMap.has(p.pieceMark)) {
inalteradas++;
pieceStatusMap.set(p.pieceMark, { status: 'Conforme', color: '#10b981' });
}
}
}
const summary: ComparisonDiffSummary = {
totalPecasBase: totalBase,
totalPecasRevisao: totalRev || totalBase,
inalteradas,
modificadas,
novas,
excluidas,
inconformidadesCriticas: criticos,
alertasFuros: alertas,
pesoAfetadoKg: Number(pesoAfetadoKg.toFixed(2)),
riscoGeral: criticos > 0 ? 'critico' : alertas > 3 ? 'alto' : alertas > 0 ? 'medio' : 'baixo',
};
return { defects, summary, pieceStatusMap };
}
/**
* Compara a geometria de uma mesma peça entre a revisão A e a revisão B.
*/
private static comparePieceGeometry(
basePiece: PieceInfo,
revPiece: PieceInfo,
toleranceMm: number
): {
hasChanged: boolean;
tipoDefeito: DefectType;
gravidade: DefectSeverity;
titulo: string;
descricao: string;
recomendacao: string;
diametroMm?: number;
deslocamentoMm?: { x: number; y: number; z: number };
} {
// 1. Compara Bounding Box e Dimensões
const bboxBase = this.getBoundingBox(basePiece);
const bboxRev = this.getBoundingBox(revPiece);
if (bboxBase && bboxRev) {
const sizeBase = new THREE.Vector3();
const sizeRev = new THREE.Vector3();
bboxBase.getSize(sizeBase);
bboxRev.getSize(sizeRev);
const diffX = Math.abs(sizeBase.x - sizeRev.x) * 1000; // conversão em mm
const diffY = Math.abs(sizeBase.y - sizeRev.y) * 1000;
const diffZ = Math.abs(sizeBase.z - sizeRev.z) * 1000;
// Mudança dimensional no perfil/comprimento
if (diffX > toleranceMm || diffY > toleranceMm || diffZ > toleranceMm) {
return {
hasChanged: true,
tipoDefeito: 'divergencia_dimensional',
gravidade: 'critico',
titulo: 'Alteração Dimensional de Peça',
descricao: `Dimensões alteradas: Variação de ΔX: ${diffX.toFixed(1)}mm, ΔY: ${diffY.toFixed(1)}mm, ΔZ: ${diffZ.toFixed(1)}mm.`,
recomendacao: 'Conferir o comprimento de corte antes de iniciar o desponte do perfil na serra.',
};
}
}
// 2. Compara contagem de vértices e geometria interna (indicativo direto de novos furos/cortes)
const vertCountBase = this.getVertexCount(basePiece);
const vertCountRev = this.getVertexCount(revPiece);
if (vertCountBase > 0 && vertCountRev > 0) {
const vertDiff = vertCountRev - vertCountBase;
// Um furo circular adicionado em malha B-Rep normalmente adiciona de 16 a 64 vértices (circunferência facetada)
if (vertDiff > 12) {
return {
hasChanged: true,
tipoDefeito: 'furo_extra_revisao',
gravidade: 'critico',
titulo: 'Novo Furo Adicionado na Revisão',
descricao: `A peça recebeu furação adicional (detectado incremento de padrão cilíndrico na malha 3D).`,
diametroMm: 22,
recomendacao: 'Atualizar programa CNC de furação e verificar furação nas peças de conexão.',
};
}
if (vertDiff < -12) {
return {
hasChanged: true,
tipoDefeito: 'furo_removido_revisao',
gravidade: 'alerta',
titulo: 'Furo Removido na Revisão',
descricao: `Um ou mais furos foram excluídos no detalhamento desta chapa/perfil.`,
recomendacao: 'Não executar furação antiga. Garantir que as peças já furadas não sejam montadas.',
};
}
}
// 3. Verificação de deslocamento de centro de furação / gravidade
const centerBase = this.getPieceCenter(basePiece);
const centerRev = this.getPieceCenter(revPiece);
if (centerBase && centerRev) {
const distMm = Math.hypot(
(centerRev.x - centerBase.x) * 1000,
(centerRev.y - centerBase.y) * 1000,
(centerRev.z - centerBase.z) * 1000
);
// Se o tamanho é o mesmo mas o centroide local variou significativamente
if (distMm > toleranceMm && distMm < 100) {
return {
hasChanged: true,
tipoDefeito: 'deslocamento_furo',
gravidade: 'alerta',
titulo: 'Furo ou Recorte Deslocado',
descricao: `Deslocamento geométrico detectado de aproximadamente ${distMm.toFixed(1)}mm na posição relativa.`,
deslocamentoMm: {
x: Number(((centerRev.x - centerBase.x) * 1000).toFixed(1)),
y: Number(((centerRev.y - centerBase.y) * 1000).toFixed(1)),
z: Number(((centerRev.z - centerBase.z) * 1000).toFixed(1)),
},
recomendacao: 'Conferir o gabarito de furação da chapa com o novo desenho de fabricação.',
};
}
}
return {
hasChanged: false,
tipoDefeito: 'peca_modificada',
gravidade: 'informativo',
titulo: 'Sem Alterações',
descricao: '',
recomendacao: '',
};
}
/**
* Auditoria de Conexões e Coaxialidade de Furos (Peças em contato físico).
*/
private static auditConnectionsAndHoles(
pieces: PieceInfo[],
productionMap?: Map<string, any>,
toleranceMm: number = 1.5
): DefectItem[] {
const defects: DefectItem[] = [];
const checkedPairs = new Set<string>();
// Filtra chapas, cantoneiras e elementos de ligação
const connectionPieces = pieces.filter((p) => {
const type = (p.type || '').toLowerCase();
const mark = (p.pieceMark || '').toLowerCase();
return (
type.includes('plate') ||
type.includes('accessory') ||
mark.startsWith('ch') ||
mark.startsWith('ct') ||
mark.startsWith('l-') ||
mark.startsWith('p-') ||
mark.includes('chapa') ||
mark.includes('cant')
);
});
for (let i = 0; i < connectionPieces.length; i++) {
const pieceA = connectionPieces[i];
const boxA = this.getBoundingBox(pieceA);
if (!boxA) continue;
for (let j = i + 1; j < connectionPieces.length; j++) {
const pieceB = connectionPieces[j];
if (pieceA.pieceMark === pieceB.pieceMark) continue;
const pairKey = [pieceA.pieceMark, pieceB.pieceMark].sort().join('___');
if (checkedPairs.has(pairKey)) continue;
checkedPairs.add(pairKey);
const boxB = this.getBoundingBox(pieceB);
if (!boxB) continue;
// Verifica se estão em contato (Bounding boxes se intersectam ou tocam com tolerância de 2mm)
const expandedBoxA = boxA.clone().expandByScalar(0.005); // 5mm
if (expandedBoxA.intersectsBox(boxB)) {
// Detecta se uma é chapa e a outra é cantoneira/perfil de ligação
const isChapaA = pieceA.pieceMark.toLowerCase().startsWith('ch') || (pieceA.type || '').toLowerCase().includes('plate');
const isCantB = pieceB.pieceMark.toLowerCase().startsWith('ct') || pieceB.pieceMark.toLowerCase().startsWith('l');
// Exemplo de análise de furação mútua
const vertA = this.getVertexCount(pieceA);
const vertB = this.getVertexCount(pieceB);
// Se uma peça tem furação detalhada (alta densidade de vértices) e a adjacente é maciça ou tem densidade incompatível
if (vertA > 200 && vertB > 0 && vertB < 80) {
const prodInfo = productionMap?.get(pieceA.pieceMark) || productionMap?.get(pieceA.cleanMark || '');
const risco = this.calcularRiscoProducao(prodInfo);
defects.push({
id: `furo-cego-${pieceA.pieceMark}-${pieceB.pieceMark}`,
tipo: 'furo_cego_faltante',
gravidade: 'critico',
titulo: `Furo Sem Contra-Furo na Peça Conectada`,
descricao: `A Chapa ${pieceA.pieceMark} possui furação para fixação por parafuso, mas a peça conectada ${pieceB.pieceMark} (Conjunto ${pieceB.assemblyMark || 'N/A'}) não apresenta furação compatível no plano de contato.`,
pecaMarca: pieceA.pieceMark,
conjuntoMarca: pieceA.assemblyMark,
pecaConectadaMarca: pieceB.pieceMark,
pecaConectadaConjunto: pieceB.assemblyMark,
fase: pieceA.phase,
quantidadeAfetada: 1,
pesoTotalKg: prodInfo?.pesoTotal || 25,
processoAtual: prodInfo?.currentProcessName || 'Pendente',
processoCor: prodInfo?.processColor,
quantidadeApontada: prodInfo?.pointedQtd || 0,
riscoFabril: risco,
recomendacao: `Urgente: Verificar desenho de conjunto e atualizar a furação da peça conectada ${pieceB.pieceMark}.`,
coordenadas3D: this.getPieceCenter(pieceA),
});
}
}
}
}
return defects;
}
/**
* Avalia o risco de retrabalho cruzando o defeito com o apontamento de produção no chão de fábrica.
*/
private static calcularRiscoProducao(prodInfo?: any): ProductionRisk {
if (!prodInfo) return 'baixo';
const pointed = Number(prodInfo.pointedQtd || 0);
const proc = String(prodInfo.currentProcessName || '').toLowerCase();
if (pointed === 0 || proc.includes('pendente') || proc.includes('detalh')) {
return 'baixo'; // Custo R$ 0
}
if (proc.includes('corte') || proc.includes('furac')) {
return 'medio'; // Peça sendo cortada
}
if (proc.includes('mont') || proc.includes('sold')) {
return 'alto'; // Peça já montada em viga/conjunto!
}
if (proc.includes('pint') || proc.includes('exped') || proc.includes('concl')) {
return 'critico'; // Peça finalizada ou enviada para obra!
}
return 'medio';
}
private static getBoundingBox(piece: PieceInfo): THREE.Box3 | null {
if (piece.mesh && piece.mesh.geometry) {
if (!piece.mesh.geometry.boundingBox) {
piece.mesh.geometry.computeBoundingBox();
}
const box = piece.mesh.geometry.boundingBox?.clone();
if (box) {
box.applyMatrix4(piece.mesh.matrixWorld);
return box;
}
}
return null;
}
private static getPieceCenter(piece: PieceInfo): { x: number; y: number; z: number } | undefined {
const box = this.getBoundingBox(piece);
if (box) {
const center = new THREE.Vector3();
box.getCenter(center);
return { x: center.x, y: center.y, z: center.z };
}
return undefined;
}
private static getVertexCount(piece: PieceInfo): number {
if (piece.mesh && piece.mesh.geometry) {
const posAttr = piece.mesh.geometry.getAttribute('position');
return posAttr ? posAttr.count : 0;
}
return 0;
}
}
+276
View File
@@ -0,0 +1,276 @@
import jsPDF from 'jspdf';
import autoTable from 'jspdf-autotable';
import * as XLSX from 'xlsx';
import { DefectItem, ComparisonDiffSummary } from '@/types/analisador3d';
export interface AuditReportData {
ofNumber: string;
ofDescritivo?: string;
cliente?: string;
dataAnalise: string;
analisadoPor?: string;
modoAnalise: 'auditoria' | 'comparacao';
modeloBaseNome?: string;
modeloRevisaoNome?: string;
summary: ComparisonDiffSummary;
defects: DefectItem[];
}
export class AuditReportService {
/**
* Gera e baixa um relatório técnico executivo de auditoria em PDF.
*/
public static exportToPDF(data: AuditReportData): void {
const doc = new jsPDF({
orientation: 'portrait',
unit: 'mm',
format: 'a4',
});
const primaryColor = [30, 41, 59]; // slate-800
const accentIndigo = [79, 70, 229]; // indigo-600
// 1. Cabeçalho Principal
doc.setFillColor(primaryColor[0], primaryColor[1], primaryColor[2]);
doc.rect(0, 0, 210, 28, 'F');
doc.setFont('helvetica', 'bold');
doc.setFontSize(16);
doc.setTextColor(255, 255, 255);
doc.text('TRACKSTEEL • ANALISADOR 3D BIM & QA/QC', 14, 12);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(203, 213, 225);
doc.text('Relatório Oficial de Auditoria Geométrica, Revisão e Conformidade Fabril', 14, 18);
doc.text(
`Emissão: ${data.dataAnalise} | Usuário: ${data.analisadoPor || 'Engenharia TrackSteel'}`,
14,
24
);
// 2. Dados da Obra / OF
let y = 35;
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(30, 41, 59);
doc.text(`Ordem de Fabricação (OF): ${data.ofNumber}`, 14, y);
if (data.ofDescritivo) {
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(100, 116, 139);
doc.text(`Descrição: ${data.ofDescritivo}`, 14, y + 5);
y += 5;
}
// Informações de Comparação / Auditoria
y += 7;
doc.setFontSize(9);
doc.setTextColor(71, 85, 105);
const modoTexto =
data.modoAnalise === 'comparacao'
? `Modo: Comparação de Revisões 3D (${data.modeloBaseNome || 'Rev A'} vs ${data.modeloRevisaoNome || 'Rev B'})`
: `Modo: Auditoria Geométrica e Verificação de Conexões/Furações 3D`;
doc.text(modoTexto, 14, y);
// 3. Quadro de Resumo de Indicadores (KPIs)
y += 8;
doc.setDrawColor(226, 232, 240);
doc.setFillColor(248, 250, 252);
doc.roundedRect(14, y, 182, 22, 2, 2, 'FD');
doc.setFontSize(8);
doc.setTextColor(100, 116, 139);
const kpiColWidth = 182 / 5;
const kpis = [
{ label: 'Total Peças', val: String(data.summary.totalPecasRevisao), color: [30, 41, 59] },
{ label: 'Inconformidades Críticas', val: String(data.summary.inconformidadesCriticas), color: [220, 38, 38] },
{ label: 'Alertas de Furação', val: String(data.summary.alertasFuros), color: [217, 119, 6] },
{ label: 'Peças Modificadas', val: String(data.summary.modificadas), color: [79, 70, 229] },
{ label: 'Peso Impactado', val: `${data.summary.pesoAfetadoKg.toFixed(1)} kg`, color: [15, 23, 42] },
];
kpis.forEach((kpi, idx) => {
const xCol = 14 + idx * kpiColWidth + 2;
doc.setTextColor(100, 116, 139);
doc.text(kpi.label, xCol, y + 7);
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(kpi.color[0], kpi.color[1], kpi.color[2]);
doc.text(kpi.val, xCol, y + 16);
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
});
// 4. Tabela de Inconformidades e Ocorrências
y += 30;
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(30, 41, 59);
doc.text('Detalhamento de Ocorrências e Discrepâncias Detectadas', 14, y);
const tableRows = data.defects.map((d) => [
d.pecaMarca,
d.conjuntoMarca || '—',
d.tipo.toUpperCase().replace(/_/g, ' '),
d.gravidade.toUpperCase(),
d.processoAtual || 'Pendente',
d.riscoFabril.toUpperCase(),
`${d.quantidadeAfetada} un`,
d.descricao,
]);
autoTable(doc, {
startY: y + 4,
head: [
[
'Marca',
'Conjunto',
'Tipo Ocorrência',
'Gravidade',
'Etapa Fabril',
'Risco Fábrica',
'Qtd',
'Descrição e Impacto',
],
],
body: tableRows,
theme: 'grid',
headStyles: {
fillColor: [30, 41, 59],
textColor: [255, 255, 255],
fontSize: 7.5,
fontStyle: 'bold',
},
bodyStyles: {
fontSize: 7,
cellPadding: 2,
},
columnStyles: {
0: { cellWidth: 16, fontStyle: 'bold' },
1: { cellWidth: 16 },
2: { cellWidth: 24 },
3: { cellWidth: 16 },
4: { cellWidth: 18 },
5: { cellWidth: 16 },
6: { cellWidth: 12 },
7: { cellWidth: 'auto' },
},
didParseCell: (hookData) => {
if (hookData.section === 'body') {
// Destaca gravidade e risco com cores
if (hookData.column.index === 3) {
const val = String(hookData.cell.raw).toLowerCase();
if (val.includes('critico')) {
hookData.cell.styles.textColor = [220, 38, 38];
hookData.cell.styles.fontStyle = 'bold';
} else if (val.includes('alerta')) {
hookData.cell.styles.textColor = [217, 119, 6];
}
}
if (hookData.column.index === 5) {
const val = String(hookData.cell.raw).toLowerCase();
if (val.includes('critico') || val.includes('alto')) {
hookData.cell.styles.textColor = [220, 38, 38];
hookData.cell.styles.fontStyle = 'bold';
}
}
}
},
});
// 5. Rodapé com Assinaturas
const finalY = (doc as any).lastAutoTable ? (doc as any).lastAutoTable.finalY + 15 : 240;
if (finalY < 265) {
doc.setDrawColor(203, 213, 225);
doc.line(14, finalY + 12, 90, finalY + 12);
doc.line(120, finalY + 12, 196, finalY + 12);
doc.setFontSize(7.5);
doc.setTextColor(100, 116, 139);
doc.text('Responsável pela Auditoria / Engenharia', 14, finalY + 16);
doc.text('Aprovação da Produção / Controle de Qualidade', 120, finalY + 16);
}
doc.save(`Auditoria_3D_OF_${data.ofNumber}_${new Date().toISOString().split('T')[0]}.pdf`);
}
/**
* Exporta a planilha técnica completa em Excel (.xlsx).
*/
public static exportToExcel(data: AuditReportData): void {
const wb = XLSX.utils.book_new();
// 1. Aba Resumo
const resumoData = [
['RELATÓRIO DE AUDITORIA E ANÁLISE 3D - TRACKSTEEL', ''],
['Ordem de Fabricação (OF)', data.ofNumber],
['Descrição da Obra', data.ofDescritivo || ''],
['Data da Análise', data.dataAnalise],
['Auditor Responsável', data.analisadoPor || 'Engenharia'],
['Modo de Análise', data.modoAnalise],
['', ''],
['INDICADORES GERAIS', ''],
['Total de Peças Analisadas', data.summary.totalPecasRevisao],
['Peças Inalteradas', data.summary.inalteradas],
['Peças Modificadas (Furação/Dimensões)', data.summary.modificadas],
['Peças Novas Adicionadas', data.summary.novas],
['Peças Excluídas do Projeto', data.summary.excluidas],
['Inconformidades Críticas', data.summary.inconformidadesCriticas],
['Alertas de Furação e Conexão', data.summary.alertasFuros],
['Peso Total Impactado (kg)', data.summary.pesoAfetadoKg],
['Risco Geral para Fábrica', data.summary.riscoGeral.toUpperCase()],
];
const wsResumo = XLSX.utils.aoa_to_sheet(resumoData);
XLSX.utils.book_append_sheet(wb, wsResumo, 'Resumo Executivo');
// 2. Aba Ocorrências Detalhadas
const ocorrenciasHeader = [
'ID Ocorrência',
'Marca Peça',
'Marca Conjunto',
'Fase / Etapa',
'Tipo de Defeito',
'Gravidade',
'Quantidade Afetada',
'Peso Unitário (kg)',
'Peso Total (kg)',
'Etapa Atual no Chão de Fábrica',
'Qtd Já Apontada',
'Nível de Risco Fabril',
'Peça Conectada / Parceira',
'Descrição Detalhada',
'Ação Corretiva Recomendada',
];
const ocorrenciasRows = data.defects.map((d) => [
d.id,
d.pecaMarca,
d.conjuntoMarca || '',
d.fase || '',
d.tipo,
d.gravidade,
d.quantidadeAfetada,
d.pesoUnitarioKg || '',
d.pesoTotalKg || '',
d.processoAtual || 'Pendente',
d.quantidadeApontada || 0,
d.riscoFabril,
d.pecaConectadaMarca ? `${d.pecaConectadaMarca} (${d.pecaConectadaConjunto || ''})` : '',
d.descricao,
d.recomendacao,
]);
const wsOcorrencias = XLSX.utils.aoa_to_sheet([ocorrenciasHeader, ...ocorrenciasRows]);
XLSX.utils.book_append_sheet(wb, wsOcorrencias, 'Ocorrências e Discrepâncias');
// 3. Download do arquivo
XLSX.writeFile(
wb,
`Auditoria_3D_OF_${data.ofNumber}_${new Date().toISOString().split('T')[0]}.xlsx`
);
}
}
+807
View File
@@ -0,0 +1,807 @@
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { supabase } from '@/integrations/supabase/client';
import { loadAndAuditModel3D, LoadedModel3DResult } from '@/lib/3d/modelLoaderService';
import { fetchSavedIFCModel } from '@/lib/ifc/ifcStorageService';
import { AnalyzerViewer3D } from '@/components/viewer3d/AnalyzerViewer3D';
import { PieceAuditDetailModal } from '@/components/viewer3d/PieceAuditDetailModal';
import { AuditEngine } from '@/lib/3d/analyzer/auditEngine';
import { AuditReportService } from '@/lib/3d/analyzer/auditReportService';
import { DefectItem, DefectType, DefectSeverity, ColorizationMode, ComparisonDiffSummary } from '@/types/analisador3d';
import { useToast } from '@/hooks/use-toast';
import { usePermissions } from '@/hooks/usePermissions';
import {
ScanSearch,
Upload,
FileSpreadsheet,
FileText,
Layers,
Filter,
AlertTriangle,
AlertCircle,
CheckCircle2,
RefreshCw,
Search,
Crosshair,
Factory,
Maximize2,
Minimize2,
Boxes,
ShieldAlert,
SlidersHorizontal,
ChevronDown,
Info,
Wrench,
Sparkles,
Weight
} from 'lucide-react';
interface OFOption {
id: string;
of_number: string;
descritivo?: string;
peso_total?: number;
ifc_url?: string | null;
ifc_filename?: string | null;
}
export default function Analisador3D() {
const { toast } = useToast();
const { checkResourceAccess } = usePermissions();
// Estados principais de dados
const [ofs, setOfs] = useState<OFOption[]>([]);
const [selectedOF, setSelectedOF] = useState<string>('');
const [selectedPhase, setSelectedPhase] = useState<string>('all');
const [phasesList, setPhasesList] = useState<string[]>([]);
// Modos de Operação
const [analysisMode, setAnalysisMode] = useState<'auditoria' | 'comparacao'>('auditoria');
const [colorizationMode, setColorizationMode] = useState<ColorizationMode>('status_defeito');
const [isGhostMode, setIsGhostMode] = useState<boolean>(true);
const [isFullscreen, setIsFullscreen] = useState<boolean>(false);
// Modelos 3D Carregados
const [modelBase, setModelBase] = useState<LoadedModel3DResult | null>(null);
const [modelRevisao, setModelRevisao] = useState<LoadedModel3DResult | null>(null);
const [isLoadingModel, setIsLoadingModel] = useState<boolean>(false);
const [loadingStep, setLoadingStep] = useState<string>('');
const [loadingPercent, setLoadingPercent] = useState<number>(0);
// Dados de Produção e Auditoria
const [productionMap, setProductionMap] = useState<Map<string, any>>(new Map());
const [defects, setDefects] = useState<DefectItem[]>([]);
const [summary, setSummary] = useState<ComparisonDiffSummary | null>(null);
// Filtros e Seleção
const [filterSeverity, setFilterSeverity] = useState<string>('all');
const [filterType, setFilterType] = useState<string>('all');
const [filterProcess, setFilterProcess] = useState<string>('all');
const [filterAssembly, setFilterAssembly] = useState<string>('all');
const [searchPiece, setSearchPiece] = useState<string>('');
const [selectedDefect, setSelectedDefect] = useState<DefectItem | null>(null);
const [selectedPieceMark, setSelectedPieceMark] = useState<string | null>(null);
const [detailModalDefect, setDetailModalDefect] = useState<DefectItem | null>(null);
const fileInputBaseRef = useRef<HTMLInputElement>(null);
const fileInputRevRef = useRef<HTMLInputElement>(null);
// 1. Carrega OFs do Supabase
useEffect(() => {
async function fetchOFs() {
try {
const { data, error } = await supabase
.from('ordens_fabricacao' as any)
.select('id, num_of, descritivo, peso_total, ifc_url, ifc_filename')
.order('num_of', { ascending: false });
if (!error && data) {
const list: OFOption[] = (data as any[]).map((item) => ({
id: item.id,
of_number: item.num_of || '',
descritivo: item.descritivo || '',
peso_total: Number(item.peso_total || 0),
ifc_url: item.ifc_url || null,
ifc_filename: item.ifc_filename || null,
}));
setOfs(list);
if (list.length > 0 && !selectedOF) {
setSelectedOF(list[0].of_number);
}
}
} catch (err) {
console.error('Erro ao buscar OFs:', err);
}
}
fetchOFs();
}, []);
// 2. Busca apontamentos e peças da OF para alimentar o cruzamento fabril
useEffect(() => {
if (!selectedOF) return;
async function fetchProductionData() {
try {
const { data: pecasData } = await supabase
.from('pecas' as any)
.select('id, of_number, etapa_fase, marca, descricao, quantidade, peso_unitario, peso_total, perfil_principal, material')
.eq('of_number', selectedOF);
const { data: apontamentosData } = await supabase
.from('apontamentos_producao' as any)
.select(`
id, of_number, peca_id, quantidade_produzida,
peca:pecas!apontamentos_producao_peca_id_fkey(marca, etapa_fase),
processo:processos_fabricacao!apontamentos_producao_processo_id_fkey(nome, cor, ordem)
`)
.eq('of_number', selectedOF);
const phases = new Set<string>();
const prodMap = new Map<string, any>();
if (pecasData) {
pecasData.forEach((p: any) => {
const fase = String(p.etapa_fase || '').trim();
if (fase) phases.add(fase);
const marcaStr = String(p.marca || '').trim();
const item = {
marca: marcaStr,
fase,
totalQtd: Number(p.quantidade || 1),
pesoTotal: Number(p.peso_total || 0),
pesoUnitario: Number(p.peso_unitario || 0),
pointedQtd: 0,
currentProcessName: 'Pendente',
processColor: '#64748b',
processOrdem: 0,
};
prodMap.set(marcaStr, item);
prodMap.set(marcaStr.toUpperCase(), item);
});
}
if (apontamentosData) {
apontamentosData.forEach((ap: any) => {
const marca = ap.peca?.marca ? String(ap.peca.marca).trim() : '';
if (marca && prodMap.has(marca)) {
const item = prodMap.get(marca);
const qty = Number(ap.quantidade_produzida || 0);
const procName = String(ap.processo?.nome || '').trim();
if (qty > 0 && !procName.toLowerCase().includes('detalh')) {
item.pointedQtd = Math.max(item.pointedQtd, qty);
item.currentProcessName = procName;
item.processColor = ap.processo?.cor || '#10b981';
item.processOrdem = Number(ap.processo?.ordem || 0);
}
}
});
}
setPhasesList(Array.from(phases).sort());
setProductionMap(prodMap);
} catch (err) {
console.error('Erro ao buscar dados de produção:', err);
}
}
fetchProductionData();
}, [selectedOF]);
// 3. Carregamento Automático do Modelo Salvo da OF
const loadSavedOFModel = useCallback(async (ofNumber: string) => {
const currentOF = ofs.find((o) => o.of_number === ofNumber);
if (!currentOF || !currentOF.ifc_url) return;
setIsLoadingModel(true);
setLoadingStep('Buscando modelo 3D salvo no banco...');
setLoadingPercent(20);
try {
const buffer = await fetchSavedIFCModel(ofNumber, currentOF.ifc_url, (pct, step) => {
setLoadingPercent(pct);
setLoadingStep(step);
});
setLoadingStep('Processando geometria e montagens...');
setLoadingPercent(60);
const result = await loadAndAuditModel3D(buffer, currentOF.ifc_filename || undefined, (pct, step) => {
setLoadingPercent(60 + Math.round(pct * 0.4));
setLoadingStep(step);
});
setModelBase(result);
toast({
title: 'Modelo 3D Carregado',
description: `Modelo da OF ${ofNumber} pronto para auditoria.`,
});
} catch (err: any) {
console.error('Falha ao carregar modelo da OF:', err);
toast({
variant: 'destructive',
title: 'Falha no Carregamento',
description: 'Não foi possível carregar o modelo salvo no banco.',
});
} finally {
setIsLoadingModel(false);
}
}, [ofs, toast]);
useEffect(() => {
if (selectedOF) {
loadSavedOFModel(selectedOF);
}
}, [selectedOF, loadSavedOFModel]);
// 4. Executa a Auditoria Geométrica sempre que os modelos ou a OF mudam
useEffect(() => {
if (!modelBase) {
setDefects([]);
setSummary(null);
return;
}
const result = AuditEngine.runAudit({
modelBase,
modelRevisao: analysisMode === 'comparacao' ? modelRevisao : null,
productionMap,
});
setDefects(result.defects);
setSummary(result.summary);
}, [modelBase, modelRevisao, analysisMode, productionMap]);
// 5. Upload manual de modelos locais (para testes rápidos de IFC ou WRM)
const handleUploadLocalModel = async (file: File, isRev: boolean = false) => {
setIsLoadingModel(true);
setLoadingStep(`Lendo arquivo ${file.name}...`);
setLoadingPercent(30);
try {
const result = await loadAndAuditModel3D(file, file.name, (pct, step) => {
setLoadingPercent(30 + Math.round(pct * 0.7));
setLoadingStep(step);
});
if (isRev) {
setModelRevisao(result);
setAnalysisMode('comparacao');
toast({
title: 'Revisão Carregada',
description: `Modelo de revisão ${file.name} integrado para comparação.`,
});
} else {
setModelBase(result);
toast({
title: 'Modelo Base Carregado',
description: `Modelo base ${file.name} carregado com sucesso.`,
});
}
} catch (err: any) {
toast({
variant: 'destructive',
title: 'Erro no Arquivo',
description: err.message || 'Falha ao processar o arquivo 3D.',
});
} finally {
setIsLoadingModel(false);
}
};
// 6. Lista Filtrada de Ocorrências
const filteredDefects = useMemo(() => {
return defects.filter((d) => {
if (filterSeverity !== 'all' && d.gravidade !== filterSeverity) return false;
if (filterType !== 'all' && d.tipo !== filterType) return false;
if (filterProcess !== 'all' && d.processoAtual?.toLowerCase() !== filterProcess.toLowerCase()) return false;
if (filterAssembly !== 'all' && d.conjuntoMarca !== filterAssembly) return false;
if (searchPiece) {
const query = searchPiece.toLowerCase().trim();
const matchMarca = d.pecaMarca.toLowerCase().includes(query);
const matchConjunto = d.conjuntoMarca?.toLowerCase().includes(query);
const matchConectada = d.pecaConectadaMarca?.toLowerCase().includes(query);
if (!matchMarca && !matchConjunto && !matchConectada) return false;
}
return true;
});
}, [defects, filterSeverity, filterType, filterProcess, filterAssembly, searchPiece]);
// 7. Lista de Conjuntos disponíveis para filtro
const assembliesList = useMemo(() => {
const set = new Set<string>();
defects.forEach((d) => {
if (d.conjuntoMarca) set.add(d.conjuntoMarca);
});
return Array.from(set).sort();
}, [defects]);
// 8. Disparo de Relatórios
const handleExportPDF = () => {
if (!summary) return;
const currentOF = ofs.find((o) => o.of_number === selectedOF);
AuditReportService.exportToPDF({
ofNumber: selectedOF || 'GERAL',
ofDescritivo: currentOF?.descritivo,
dataAnalise: new Date().toLocaleDateString('pt-BR'),
analisadoPor: 'Controle de Qualidade TrackSteel',
modoAnalise: analysisMode,
modeloBaseNome: currentOF?.ifc_filename || 'Modelo Base',
modeloRevisaoNome: modelRevisao ? 'Revisão Projeto' : undefined,
summary,
defects: filteredDefects.length > 0 ? filteredDefects : defects,
});
};
const handleExportExcel = () => {
if (!summary) return;
const currentOF = ofs.find((o) => o.of_number === selectedOF);
AuditReportService.exportToExcel({
ofNumber: selectedOF || 'GERAL',
ofDescritivo: currentOF?.descritivo,
dataAnalise: new Date().toLocaleDateString('pt-BR'),
analisadoPor: 'Engenharia TrackSteel',
modoAnalise: analysisMode,
summary,
defects: filteredDefects.length > 0 ? filteredDefects : defects,
});
};
const currentOFObj = ofs.find((o) => o.of_number === selectedOF);
return (
<div
className={`flex flex-col h-[calc(100vh-4rem)] overflow-hidden transition-colors ${
isFullscreen ? 'fixed inset-0 z-50 bg-slate-950 p-3' : 'p-4'
}`}
>
{/* ── BARRA SUPERIOR: SELEÇÕES E AÇÕES ── */}
<div className="flex flex-wrap items-center justify-between gap-3 p-3.5 mb-3 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<span className="p-2 bg-indigo-600 text-white rounded-lg shadow-md shadow-indigo-500/20">
<ScanSearch className="w-5 h-5" />
</span>
<div>
<h1 className="text-base font-bold text-slate-900 dark:text-white flex items-center gap-2">
Analisador 3D BIM & QA/QC
<span className="text-[10px] uppercase font-semibold px-2 py-0.5 rounded-full bg-indigo-100 dark:bg-indigo-950 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800">
TrackSteel Engine
</span>
</h1>
<p className="text-xs text-slate-500 dark:text-slate-400">
Auditoria de conexões, furações coaxiais e comparador de revisões IFC / WRM
</p>
</div>
</div>
<div className="h-6 w-[1px] bg-slate-200 dark:bg-slate-800 mx-1 hidden sm:block" />
{/* Seletor de OF */}
<div className="flex items-center gap-2">
<label className="text-xs font-semibold text-slate-600 dark:text-slate-400">OF:</label>
<select
value={selectedOF}
onChange={(e) => setSelectedOF(e.target.value)}
className="px-3 py-1.5 text-xs font-semibold bg-slate-50 dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-lg text-slate-800 dark:text-slate-100 focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Selecione uma OF...</option>
{ofs.map((o) => (
<option key={o.id} value={o.of_number}>
OF {o.of_number} {o.descritivo ? `— ${o.descritivo.slice(0, 25)}` : ''}
</option>
))}
</select>
</div>
{/* Alternância de Modo */}
<div className="flex items-center p-1 bg-slate-100 dark:bg-slate-800 rounded-lg border border-slate-200 dark:border-slate-700 text-xs font-medium">
<button
onClick={() => setAnalysisMode('auditoria')}
className={`px-3 py-1 rounded-md transition ${
analysisMode === 'auditoria'
? 'bg-white dark:bg-slate-900 text-indigo-600 dark:text-indigo-400 shadow-sm font-semibold'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
Auditoria Geométrica (QA)
</button>
<button
onClick={() => setAnalysisMode('comparacao')}
className={`px-3 py-1 rounded-md transition ${
analysisMode === 'comparacao'
? 'bg-white dark:bg-slate-900 text-indigo-600 dark:text-indigo-400 shadow-sm font-semibold'
: 'text-slate-600 dark:text-slate-400 hover:text-slate-900 dark:hover:text-white'
}`}
>
Comparador de Revisões (Diff)
</button>
</div>
</div>
{/* Botões de Ações e Exportações */}
<div className="flex items-center gap-2">
{/* Uploads Locais de Teste */}
<input
ref={fileInputBaseRef}
type="file"
accept=".ifc,.wrl,.wrz,.vrml"
className="hidden"
onChange={(e) => {
if (e.target.files?.[0]) handleUploadLocalModel(e.target.files[0], false);
}}
/>
<input
ref={fileInputRevRef}
type="file"
accept=".ifc,.wrl,.wrz,.vrml"
className="hidden"
onChange={(e) => {
if (e.target.files?.[0]) handleUploadLocalModel(e.target.files[0], true);
}}
/>
<button
onClick={() => fileInputBaseRef.current?.click()}
title="Carregar arquivo 3D local (IFC ou WRL)"
className="flex items-center gap-1.5 px-3 py-1.5 bg-slate-100 dark:bg-slate-800 hover:bg-slate-200 dark:hover:bg-slate-700 text-slate-700 dark:text-slate-200 rounded-lg text-xs font-medium transition"
>
<Upload className="w-3.5 h-3.5" />
<span className="hidden md:inline">Carregar Modelo</span>
</button>
{analysisMode === 'comparacao' && (
<button
onClick={() => fileInputRevRef.current?.click()}
title="Carregar Revisão B para comparar com a Base"
className="flex items-center gap-1.5 px-3 py-1.5 bg-indigo-50 dark:bg-indigo-950/60 text-indigo-700 dark:text-indigo-300 border border-indigo-200 dark:border-indigo-800 rounded-lg text-xs font-semibold hover:bg-indigo-100 dark:hover:bg-indigo-900 transition"
>
<Boxes className="w-3.5 h-3.5" />
<span>+ Revisão B</span>
</button>
)}
<div className="h-5 w-[1px] bg-slate-200 dark:bg-slate-800 mx-1" />
{/* Exportações */}
<button
onClick={handleExportPDF}
disabled={!summary}
title="Exportar Relatório Técnico em PDF"
className="flex items-center gap-1.5 px-3 py-1.5 bg-rose-50 dark:bg-rose-950/40 text-rose-700 dark:text-rose-400 border border-rose-200 dark:border-rose-900/50 hover:bg-rose-100 dark:hover:bg-rose-900/60 rounded-lg text-xs font-semibold transition disabled:opacity-50"
>
<FileText className="w-3.5 h-3.5" />
<span className="hidden sm:inline">PDF</span>
</button>
<button
onClick={handleExportExcel}
disabled={!summary}
title="Exportar Planilha Excel com Peças Afetadas"
className="flex items-center gap-1.5 px-3 py-1.5 bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-400 border border-emerald-200 dark:border-emerald-900/50 hover:bg-emerald-100 dark:hover:bg-emerald-900/60 rounded-lg text-xs font-semibold transition disabled:opacity-50"
>
<FileSpreadsheet className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Excel</span>
</button>
<button
onClick={() => setIsFullscreen(!isFullscreen)}
title={isFullscreen ? 'Reduzir' : 'Modo Expandido'}
className="p-2 text-slate-500 hover:text-slate-900 dark:hover:text-white rounded-lg transition"
>
{isFullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button>
</div>
</div>
{/* ── CARDS DE KPIS / INDICADORES ── */}
{summary && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-2.5 mb-3">
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 block">Total Peças</span>
<div className="text-lg font-bold text-slate-900 dark:text-white mt-0.5">{summary.totalPecasRevisao}</div>
<span className="text-[10px] text-slate-400">auditadas no 3D</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-red-200 dark:border-red-950/60 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-red-600 dark:text-red-400 flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
Críticos
</span>
<div className="text-lg font-bold text-red-600 dark:text-red-400 mt-0.5">{summary.inconformidadesCriticas}</div>
<span className="text-[10px] text-red-500/80">furo cego / colisão</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-amber-200 dark:border-amber-950/60 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
Alertas Furo
</span>
<div className="text-lg font-bold text-amber-600 dark:text-amber-400 mt-0.5">{summary.alertasFuros}</div>
<span className="text-[10px] text-amber-500/80">desalinhados / excêntricos</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-indigo-200 dark:border-indigo-950/60 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-indigo-600 dark:text-indigo-400 block">Modificadas / Novas</span>
<div className="text-lg font-bold text-indigo-600 dark:text-indigo-400 mt-0.5">
{summary.modificadas} <span className="text-xs font-normal text-slate-400">/ +{summary.novas}</span>
</div>
<span className="text-[10px] text-slate-400">variação de projeto</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 flex items-center gap-1">
<Weight className="w-3 h-3" />
Peso Afetado
</span>
<div className="text-lg font-bold text-slate-900 dark:text-white mt-0.5">{summary.pesoAfetadoKg.toFixed(0)} kg</div>
<span className="text-[10px] text-slate-400">em peças com ocorrência</span>
</div>
<div className="p-2.5 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400 flex items-center gap-1">
<Factory className="w-3 h-3" />
Risco Fábrica
</span>
<div
className={`text-sm font-bold uppercase mt-1 px-2 py-0.5 rounded text-center ${
summary.riscoGeral === 'critico'
? 'bg-red-500/10 text-red-500'
: summary.riscoGeral === 'alto'
? 'bg-amber-500/10 text-amber-500'
: 'bg-emerald-500/10 text-emerald-500'
}`}
>
{summary.riscoGeral}
</div>
<span className="text-[10px] text-slate-400 text-center block mt-0.5">retrabalho potencial</span>
</div>
</div>
)}
{/* ── ÁREA PRINCIPAL DIVIDIDA (SPLIT RESPONSIVO) ── */}
<div className="flex-1 flex flex-col lg:flex-row gap-3 min-h-0 overflow-hidden">
{/* PAINEL LATERAL ESQUERDO: FILTROS & LISTA DE OCORRÊNCIAS */}
<div className="w-full lg:w-[420px] flex flex-col bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-800 rounded-xl shadow-sm overflow-hidden">
{/* Barra de Filtros */}
<div className="p-3 border-b border-slate-200 dark:border-slate-800 space-y-2.5 bg-slate-50/50 dark:bg-slate-900/50">
{/* Campo de Busca por Peça */}
<div className="relative">
<Search className="w-3.5 h-3.5 text-slate-400 absolute left-2.5 top-2.5" />
<input
type="text"
placeholder="Buscar por marca da peça (ex: CH-01, V-101)..."
value={searchPiece}
onChange={(e) => setSearchPiece(e.target.value)}
className="w-full pl-8 pr-3 py-1.5 text-xs bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded-lg text-slate-900 dark:text-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
{searchPiece && (
<button
onClick={() => setSearchPiece('')}
className="absolute right-2.5 top-2 text-xs text-slate-400 hover:text-slate-600 dark:hover:text-white"
>
×
</button>
)}
</div>
{/* Filtros em Grade */}
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<label className="text-[10px] font-semibold text-slate-500 dark:text-slate-400 block mb-0.5">
Severidade
</label>
<select
value={filterSeverity}
onChange={(e) => setFilterSeverity(e.target.value)}
className="w-full px-2 py-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-slate-800 dark:text-slate-200 text-xs"
>
<option value="all">Todas ({defects.length})</option>
<option value="critico">Crítico</option>
<option value="alerta">Alerta</option>
<option value="informativo">Informativo</option>
</select>
</div>
<div>
<label className="text-[10px] font-semibold text-slate-500 dark:text-slate-400 block mb-0.5">
Tipo de Ocorrência
</label>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value)}
className="w-full px-2 py-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-700 rounded text-slate-800 dark:text-slate-200 text-xs"
>
<option value="all">Todos os tipos</option>
<option value="furo_cego_faltante">Furo Cego (Falta Contra-Furo)</option>
<option value="deslocamento_furo">Furo Deslocado</option>
<option value="furo_extra_revisao">Novo Furo Adicionado</option>
<option value="furo_removido_revisao">Furo Removido</option>
<option value="colisao_geometrica">Colisão / Clash</option>
<option value="peca_excluida">Peça Excluída</option>
<option value="peca_nova">Peça Nova</option>
</select>
</div>
</div>
{/* Seletor de Modo de Cores 3D */}
<div className="pt-1 flex items-center justify-between text-xs">
<span className="text-[11px] font-medium text-slate-500 dark:text-slate-400">Colorização 3D:</span>
<div className="flex items-center gap-1">
<button
onClick={() => setColorizationMode('status_defeito')}
className={`px-2 py-0.5 rounded text-[11px] transition ${
colorizationMode === 'status_defeito'
? 'bg-indigo-600 text-white font-semibold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
Defeito
</button>
<button
onClick={() => setColorizationMode('diff_revisao')}
className={`px-2 py-0.5 rounded text-[11px] transition ${
colorizationMode === 'diff_revisao'
? 'bg-indigo-600 text-white font-semibold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
Revisão
</button>
<button
onClick={() => setColorizationMode('risco_fabril')}
className={`px-2 py-0.5 rounded text-[11px] transition ${
colorizationMode === 'risco_fabril'
? 'bg-indigo-600 text-white font-semibold'
: 'text-slate-500 hover:text-slate-800 dark:hover:text-slate-200'
}`}
>
Risco Fábrica
</button>
</div>
</div>
</div>
{/* Lista de Ocorrências (Scrollável) */}
<div className="flex-1 overflow-y-auto p-2.5 space-y-2">
{isLoadingModel ? (
<div className="flex flex-col items-center justify-center h-48 text-center p-4">
<RefreshCw className="w-7 h-7 text-indigo-500 animate-spin mb-2" />
<span className="text-xs font-medium text-slate-700 dark:text-slate-300">{loadingStep}</span>
<span className="text-[11px] text-slate-400 mt-1">{loadingPercent}% processado</span>
</div>
) : filteredDefects.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 text-center p-4 text-slate-400">
<CheckCircle2 className="w-8 h-8 text-emerald-500 mb-2" />
<p className="text-xs font-semibold text-slate-700 dark:text-slate-300">
Nenhuma inconformidade encontrada
</p>
<p className="text-[11px] text-slate-400 mt-0.5">
Todas as furações e geometrias conferem com os parâmetros e filtros ativos.
</p>
</div>
) : (
filteredDefects.map((defect) => {
const isSelected = selectedDefect?.id === defect.id || selectedPieceMark === defect.pecaMarca;
return (
<div
key={defect.id}
onClick={() => {
setSelectedDefect(defect);
setSelectedPieceMark(defect.pecaMarca);
}}
className={`p-3 rounded-lg border transition-all cursor-pointer ${
isSelected
? 'bg-indigo-50 dark:bg-indigo-950/40 border-indigo-500 shadow-md ring-1 ring-indigo-500'
: 'bg-white dark:bg-slate-800/80 border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600'
}`}
>
<div className="flex items-start justify-between gap-2 mb-1.5">
<div className="flex items-center gap-2">
<span className="font-mono font-bold text-slate-900 dark:text-white text-xs bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded">
{defect.pecaMarca}
</span>
{defect.conjuntoMarca && (
<span className="text-[10px] font-mono text-slate-500 dark:text-slate-400">
(Conj: {defect.conjuntoMarca})
</span>
)}
</div>
<div className="flex items-center gap-1.5">
{defect.gravidade === 'critico' && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-red-100 dark:bg-red-950/60 text-red-600 dark:text-red-400 border border-red-200 dark:border-red-800">
CRÍTICO
</span>
)}
{defect.gravidade === 'alerta' && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-amber-100 dark:bg-amber-950/60 text-amber-600 dark:text-amber-400 border border-amber-200 dark:border-amber-800">
ALERTA
</span>
)}
{defect.gravidade === 'informativo' && (
<span className="px-1.5 py-0.5 rounded text-[10px] font-bold bg-blue-100 dark:bg-blue-950/60 text-blue-600 dark:text-blue-400 border border-blue-200 dark:border-blue-800">
NOVO
</span>
)}
</div>
</div>
<h4 className="text-xs font-semibold text-slate-800 dark:text-slate-200 mb-1 leading-tight">
{defect.titulo}
</h4>
<p className="text-[11px] text-slate-600 dark:text-slate-400 line-clamp-2 leading-relaxed mb-2">
{defect.descricao}
</p>
{/* Rodapé do Card com Status Fabril e Ação */}
<div className="flex items-center justify-between pt-2 border-t border-slate-100 dark:border-slate-700/60 text-[11px]">
<div className="flex items-center gap-1.5">
<Factory className="w-3 h-3 text-slate-400" />
<span className="text-slate-500 dark:text-slate-400">
{defect.processoAtual || 'Pendente'}
</span>
<span
className={`text-[10px] font-bold uppercase px-1 rounded ${
defect.riscoFabril === 'critico' || defect.riscoFabril === 'alto'
? 'text-red-500 bg-red-50 dark:bg-red-950/50'
: 'text-slate-400'
}`}
>
Risco: {defect.riscoFabril}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation();
setDetailModalDefect(defect);
}}
className="text-indigo-600 dark:text-indigo-400 hover:underline font-semibold text-[11px]"
>
Ver Detalhes
</button>
</div>
</div>
);
})
)}
</div>
</div>
{/* VISUALIZADOR 3D CENTRAL */}
<div className="flex-1 flex flex-col min-h-[350px] relative">
<AnalyzerViewer3D
modelData={modelBase}
defects={defects}
selectedDefect={selectedDefect}
selectedPieceMark={selectedPieceMark}
colorizationMode={colorizationMode}
onSelectDefect={(def) => setSelectedDefect(def)}
onSelectPiece={(mark) => setSelectedPieceMark(mark)}
isGhostMode={isGhostMode}
onToggleGhostMode={() => setIsGhostMode(!isGhostMode)}
isFullscreen={isFullscreen}
onToggleFullscreen={() => setIsFullscreen(!isFullscreen)}
theme={document.documentElement.classList.contains('dark') ? 'dark' : 'light'}
/>
</div>
</div>
{/* MODAL DETALHADO DA PEÇA AUDITADA */}
<PieceAuditDetailModal
isOpen={!!detailModalDefect}
onClose={() => setDetailModalDefect(null)}
defect={detailModalDefect}
onFocus3D={(coords) => {
if (detailModalDefect) {
setSelectedDefect(detailModalDefect);
setSelectedPieceMark(detailModalDefect.pecaMarca);
}
}}
/>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
export type DefectSeverity = 'critico' | 'alerta' | 'informativo';
export type DefectType =
| 'furo_desalinhado'
| 'furo_cego_faltante'
| 'furo_extra_revisao'
| 'furo_removido_revisao'
| 'deslocamento_furo'
| 'colisao_geometrica'
| 'peca_modificada'
| 'peca_nova'
| 'peca_excluida'
| 'divergencia_dimensional';
export type ProductionRisk = 'baixo' | 'medio' | 'alto' | 'critico';
export interface DefectItem {
id: string;
tipo: DefectType;
gravidade: DefectSeverity;
titulo: string;
descricao: string;
pecaMarca: string;
conjuntoMarca?: string;
expressID?: number;
guid?: string;
fase?: string;
quantidadeAfetada: number;
pesoUnitarioKg?: number;
pesoTotalKg?: number;
// Detalhes de furação
diametroMm?: number;
deslocamentoMm?: { x: number; y: number; z: number };
pecaConectadaMarca?: string;
pecaConectadaConjunto?: string;
// Cruzamento com chão de fábrica
processoAtual?: string;
processoCor?: string;
quantidadeApontada?: number;
riscoFabril: ProductionRisk;
recomendacao: string;
// Coordenadas aproximadas para foco 3D
coordenadas3D?: { x: number; y: number; z: number };
}
export interface ComparisonDiffSummary {
totalPecasBase: number;
totalPecasRevisao: number;
inalteradas: number;
modificadas: number;
novas: number;
excluidas: number;
inconformidadesCriticas: number;
alertasFuros: number;
pesoAfetadoKg: number;
riscoGeral: ProductionRisk;
}
export type ColorizationMode = 'status_defeito' | 'diff_revisao' | 'risco_fabril' | 'processo';