From f42976a09d5492c6fb7729b689877be18a13062a Mon Sep 17 00:00:00 2001 From: admbrainsteel Date: Thu, 8 Oct 2026 10:16:48 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20Novo=20m=C3=B3dulo=20Analisador=203D=20?= =?UTF-8?q?BIM=20&=20QA/QC=20(Auditoria=20de=20Fura=C3=A7=C3=A3o=20e=20Com?= =?UTF-8?q?parador=20de=20Reviseeeees)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/AppSidebar.tsx | 3 + src/components/sidebar/menuConfig.ts | 9 +- src/components/viewer3d/AnalyzerViewer3D.tsx | 489 +++++++++++ .../viewer3d/PieceAuditDetailModal.tsx | 264 ++++++ src/config/routeConfig.ts | 8 + src/hooks/useMatrizAcessos.ts | 1 + src/lib/3d/analyzer/auditEngine.ts | 479 +++++++++++ src/lib/3d/analyzer/auditReportService.ts | 276 ++++++ src/pages/Analisador3D.tsx | 807 ++++++++++++++++++ src/types/analisador3d.ts | 59 ++ 10 files changed, 2394 insertions(+), 1 deletion(-) create mode 100644 src/components/viewer3d/AnalyzerViewer3D.tsx create mode 100644 src/components/viewer3d/PieceAuditDetailModal.tsx create mode 100644 src/lib/3d/analyzer/auditEngine.ts create mode 100644 src/lib/3d/analyzer/auditReportService.ts create mode 100644 src/pages/Analisador3D.tsx create mode 100644 src/types/analisador3d.ts diff --git a/src/components/AppSidebar.tsx b/src/components/AppSidebar.tsx index 4fca906..e3116bc 100644 --- a/src/components/AppSidebar.tsx +++ b/src/components/AppSidebar.tsx @@ -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', diff --git a/src/components/sidebar/menuConfig.ts b/src/components/sidebar/menuConfig.ts index 9f15bac..aa0b056 100644 --- a/src/components/sidebar/menuConfig.ts +++ b/src/components/sidebar/menuConfig.ts @@ -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", diff --git a/src/components/viewer3d/AnalyzerViewer3D.tsx b/src/components/viewer3d/AnalyzerViewer3D.tsx new file mode 100644 index 0000000..7dfa242 --- /dev/null +++ b/src/components/viewer3d/AnalyzerViewer3D.tsx @@ -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 = ({ + modelData, + defects, + selectedDefect, + selectedPieceMark, + colorizationMode, + onSelectDefect, + onSelectPiece, + isGhostMode = false, + onToggleGhostMode, + isFullscreen = false, + onToggleFullscreen, + theme = 'dark', +}) => { + const containerRef = useRef(null); + const canvasContainerRef = useRef(null); + const rendererRef = useRef(null); + const sceneRef = useRef(null); + const cameraRef = useRef(null); + const controlsRef = useRef(null); + const modelGroupRef = useRef(null); + const gridRef = useRef(null); + + const [wireframe, setWireframe] = useState(false); + const [hoveredDefect, setHoveredDefect] = useState(null); + const [hoverPos, setHoverPos] = useState<{ x: number; y: number } | null>(null); + + // Mapeamentos rápidos para busca e colorização + const defectByPieceMap = useRef>(new Map()); + useEffect(() => { + const map = new Map(); + 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) => { + 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 ( +
+ {/* Container Three.js */} +
+ + {/* ViewCube Superior Direito */} + {cameraRef.current && controlsRef.current && ( + + )} + + {/* Barra de Ferramentas Flutuante Superior */} +
+ + +
+ + + + + + {onToggleFullscreen && ( + + )} +
+ + {/* Legenda de Cores Flutuante Inferior */} +
+ Legenda: + {colorizationMode === 'status_defeito' && ( + <> +
+ + Conforme +
+
+ + Alerta (Deslocado) +
+
+ + Crítico (Furo Cego/Colisão) +
+ + )} + {colorizationMode === 'diff_revisao' && ( + <> +
+ + Inalterada +
+
+ + Modificada +
+
+ + Nova +
+
+ + Excluída +
+ + )} + {colorizationMode === 'risco_fabril' && ( + <> +
+ + Baixo Risco (Sem Corte) +
+
+ + Médio (Em Corte) +
+
+ + Crítico (Soldada/Expedida) +
+ + )} +
+ + {/* Alerta de elemento selecionado flutuante */} + {selectedPieceMark && ( +
+ + + Foco Ativo: {selectedPieceMark} + + +
+ )} +
+ ); +}; diff --git a/src/components/viewer3d/PieceAuditDetailModal.tsx b/src/components/viewer3d/PieceAuditDetailModal.tsx new file mode 100644 index 0000000..fa92563 --- /dev/null +++ b/src/components/viewer3d/PieceAuditDetailModal.tsx @@ -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 = ({ + 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 ( + + + CRÍTICO + + ); + case 'alerta': + return ( + + + ALERTA + + ); + default: + return ( + + + INFORMATIVO + + ); + } + }; + + return ( + !open && onClose()}> + + +
+
+ + + +
+ + Diagnóstico Técnico • {defect.pecaMarca} + {getSeverityBadge()} + + + {defect.titulo} + +
+
+ + +
+
+ +
+ {/* Card Descrição do Problema */} +
+

+ + Descrição do Ocorrido / Inconformidade +

+

+ {defect.descricao} +

+ + {defect.deslocamentoMm && ( +
+ Deslocamento: + ΔX: {defect.deslocamentoMm.x}mm + ΔY: {defect.deslocamentoMm.y}mm + ΔZ: {defect.deslocamentoMm.z}mm +
+ )} +
+ + {/* Grid de Relação Peça vs Peça Conectada */} +
+
+ + + Peça Auditada + +
{defect.pecaMarca}
+
+ Conjunto:{' '} + + {defect.conjuntoMarca || 'N/A'} + +
+
+ Fase:{' '} + {defect.fase || 'Não definida'} +
+
+ +
+ + + Peça Conectada (Adjacente) + + {defect.pecaConectadaMarca ? ( + <> +
+ {defect.pecaConectadaMarca} +
+
+ Conjunto:{' '} + + {defect.pecaConectadaConjunto || 'N/A'} + +
+
+ Ligação: Furação faceada / Parafuso +
+ + ) : ( +
+ Elemento avulso ou modificação intrínseca da peça. +
+ )} +
+
+ + {/* Situação no Chão de Fábrica */} +
+

+ + Impacto no Chão de Fábrica (Tracksteel Engine) +

+ +
+
+ Etapa Atual + + {defect.processoAtual || 'Pendente'} + +
+ +
+ Qtd Apontada + + {defect.quantidadeApontada || 0} de {defect.quantidadeAfetada} un + +
+ +
+ Peso Total + + {defect.pesoTotalKg ? `${defect.pesoTotalKg.toFixed(1)} kg` : '—'} + +
+ +
+ Risco de Retrabalho + + {defect.riscoFabril} + +
+
+
+ + {/* Recomendação de Engenharia */} +
+

+ + Ação Corretiva Recomendada +

+

+ {defect.recomendacao} +

+
+
+ + {/* Rodapé com Botões */} +
+ + + {defect.coordenadas3D && onFocus3D && ( + + )} +
+
+
+ ); +}; diff --git a/src/config/routeConfig.ts b/src/config/routeConfig.ts index 5ddaab7..96cb0c4 100644 --- a/src/config/routeConfig.ts +++ b/src/config/routeConfig.ts @@ -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', diff --git a/src/hooks/useMatrizAcessos.ts b/src/hooks/useMatrizAcessos.ts index 2ff85fe..e2ccd6e 100644 --- a/src/hooks/useMatrizAcessos.ts +++ b/src/hooks/useMatrizAcessos.ts @@ -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' }, diff --git a/src/lib/3d/analyzer/auditEngine.ts b/src/lib/3d/analyzer/auditEngine.ts new file mode 100644 index 0000000..1883a58 --- /dev/null +++ b/src/lib/3d/analyzer/auditEngine.ts @@ -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; + 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; + } { + const { modelBase, modelRevisao, productionMap, toleranceMm = 1.5 } = options; + const defects: DefectItem[] = []; + const pieceStatusMap = new Map(); + + 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, + toleranceMm: number = 1.5 + ): DefectItem[] { + const defects: DefectItem[] = []; + const checkedPairs = new Set(); + + // 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; + } +} diff --git a/src/lib/3d/analyzer/auditReportService.ts b/src/lib/3d/analyzer/auditReportService.ts new file mode 100644 index 0000000..c1c8d2c --- /dev/null +++ b/src/lib/3d/analyzer/auditReportService.ts @@ -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` + ); + } +} diff --git a/src/pages/Analisador3D.tsx b/src/pages/Analisador3D.tsx new file mode 100644 index 0000000..af5deb6 --- /dev/null +++ b/src/pages/Analisador3D.tsx @@ -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([]); + const [selectedOF, setSelectedOF] = useState(''); + const [selectedPhase, setSelectedPhase] = useState('all'); + const [phasesList, setPhasesList] = useState([]); + + // Modos de Operação + const [analysisMode, setAnalysisMode] = useState<'auditoria' | 'comparacao'>('auditoria'); + const [colorizationMode, setColorizationMode] = useState('status_defeito'); + const [isGhostMode, setIsGhostMode] = useState(true); + const [isFullscreen, setIsFullscreen] = useState(false); + + // Modelos 3D Carregados + const [modelBase, setModelBase] = useState(null); + const [modelRevisao, setModelRevisao] = useState(null); + const [isLoadingModel, setIsLoadingModel] = useState(false); + const [loadingStep, setLoadingStep] = useState(''); + const [loadingPercent, setLoadingPercent] = useState(0); + + // Dados de Produção e Auditoria + const [productionMap, setProductionMap] = useState>(new Map()); + const [defects, setDefects] = useState([]); + const [summary, setSummary] = useState(null); + + // Filtros e Seleção + const [filterSeverity, setFilterSeverity] = useState('all'); + const [filterType, setFilterType] = useState('all'); + const [filterProcess, setFilterProcess] = useState('all'); + const [filterAssembly, setFilterAssembly] = useState('all'); + const [searchPiece, setSearchPiece] = useState(''); + + const [selectedDefect, setSelectedDefect] = useState(null); + const [selectedPieceMark, setSelectedPieceMark] = useState(null); + const [detailModalDefect, setDetailModalDefect] = useState(null); + + const fileInputBaseRef = useRef(null); + const fileInputRevRef = useRef(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(); + const prodMap = new Map(); + + 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(); + 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 ( +
+ {/* ── BARRA SUPERIOR: SELEÇÕES E AÇÕES ── */} +
+
+
+ + + +
+

+ Analisador 3D BIM & QA/QC + + TrackSteel Engine + +

+

+ Auditoria de conexões, furações coaxiais e comparador de revisões IFC / WRM +

+
+
+ +
+ + {/* Seletor de OF */} +
+ + +
+ + {/* Alternância de Modo */} +
+ + +
+
+ + {/* Botões de Ações e Exportações */} +
+ {/* Uploads Locais de Teste */} + { + if (e.target.files?.[0]) handleUploadLocalModel(e.target.files[0], false); + }} + /> + { + if (e.target.files?.[0]) handleUploadLocalModel(e.target.files[0], true); + }} + /> + + + + {analysisMode === 'comparacao' && ( + + )} + +
+ + {/* Exportações */} + + + + + +
+
+ + {/* ── CARDS DE KPIS / INDICADORES ── */} + {summary && ( +
+
+ Total Peças +
{summary.totalPecasRevisao}
+ auditadas no 3D +
+ +
+ + + Críticos + +
{summary.inconformidadesCriticas}
+ furo cego / colisão +
+ +
+ + + Alertas Furo + +
{summary.alertasFuros}
+ desalinhados / excêntricos +
+ +
+ Modificadas / Novas +
+ {summary.modificadas} / +{summary.novas} +
+ variação de projeto +
+ +
+ + + Peso Afetado + +
{summary.pesoAfetadoKg.toFixed(0)} kg
+ em peças com ocorrência +
+ +
+ + + Risco Fábrica + +
+ {summary.riscoGeral} +
+ retrabalho potencial +
+
+ )} + + {/* ── ÁREA PRINCIPAL DIVIDIDA (SPLIT RESPONSIVO) ── */} +
+ {/* PAINEL LATERAL ESQUERDO: FILTROS & LISTA DE OCORRÊNCIAS */} +
+ {/* Barra de Filtros */} +
+ {/* Campo de Busca por Peça */} +
+ + 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 && ( + + )} +
+ + {/* Filtros em Grade */} +
+
+ + +
+ +
+ + +
+
+ + {/* Seletor de Modo de Cores 3D */} +
+ Colorização 3D: +
+ + + +
+
+
+ + {/* Lista de Ocorrências (Scrollável) */} +
+ {isLoadingModel ? ( +
+ + {loadingStep} + {loadingPercent}% processado +
+ ) : filteredDefects.length === 0 ? ( +
+ +

+ Nenhuma inconformidade encontrada +

+

+ Todas as furações e geometrias conferem com os parâmetros e filtros ativos. +

+
+ ) : ( + filteredDefects.map((defect) => { + const isSelected = selectedDefect?.id === defect.id || selectedPieceMark === defect.pecaMarca; + return ( +
{ + 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' + }`} + > +
+
+ + {defect.pecaMarca} + + {defect.conjuntoMarca && ( + + (Conj: {defect.conjuntoMarca}) + + )} +
+ +
+ {defect.gravidade === 'critico' && ( + + CRÍTICO + + )} + {defect.gravidade === 'alerta' && ( + + ALERTA + + )} + {defect.gravidade === 'informativo' && ( + + NOVO + + )} +
+
+ +

+ {defect.titulo} +

+ +

+ {defect.descricao} +

+ + {/* Rodapé do Card com Status Fabril e Ação */} +
+
+ + + {defect.processoAtual || 'Pendente'} + + + Risco: {defect.riscoFabril} + +
+ + +
+
+ ); + }) + )} +
+
+ + {/* VISUALIZADOR 3D CENTRAL */} +
+ setSelectedDefect(def)} + onSelectPiece={(mark) => setSelectedPieceMark(mark)} + isGhostMode={isGhostMode} + onToggleGhostMode={() => setIsGhostMode(!isGhostMode)} + isFullscreen={isFullscreen} + onToggleFullscreen={() => setIsFullscreen(!isFullscreen)} + theme={document.documentElement.classList.contains('dark') ? 'dark' : 'light'} + /> +
+
+ + {/* MODAL DETALHADO DA PEÇA AUDITADA */} + setDetailModalDefect(null)} + defect={detailModalDefect} + onFocus3D={(coords) => { + if (detailModalDefect) { + setSelectedDefect(detailModalDefect); + setSelectedPieceMark(detailModalDefect.pecaMarca); + } + }} + /> +
+ ); +} diff --git a/src/types/analisador3d.ts b/src/types/analisador3d.ts new file mode 100644 index 0000000..25da470 --- /dev/null +++ b/src/types/analisador3d.ts @@ -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';