From ade8d4823332c71ab39f7aee69037f0771cf6c01 Mon Sep 17 00:00:00 2001 From: Marcos Reifonas Date: Wed, 23 Sep 2026 14:06:56 +0000 Subject: [PATCH] feat: adicionar visualizador 3d touch-first no modo smart com filtros e toast discreto --- .../viewer3d/SmartModelViewer3D.tsx | 675 ++++++++++++++++++ src/components/viewer3d/ViewCube.tsx | 40 +- src/pages/ModoSmart.tsx | 27 +- src/pages/smart/components/SmartHub.tsx | 44 +- .../smart/components/SmartViewer3DFlow.tsx | 233 ++++++ 5 files changed, 983 insertions(+), 36 deletions(-) create mode 100644 src/components/viewer3d/SmartModelViewer3D.tsx create mode 100644 src/pages/smart/components/SmartViewer3DFlow.tsx diff --git a/src/components/viewer3d/SmartModelViewer3D.tsx b/src/components/viewer3d/SmartModelViewer3D.tsx new file mode 100644 index 0000000..bdc4b1e --- /dev/null +++ b/src/components/viewer3d/SmartModelViewer3D.tsx @@ -0,0 +1,675 @@ +import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'; +import * as THREE from 'three'; +import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'; +import { LoadedIFCResult, PieceInfo } from '@/lib/ifc/ifcLoaderService'; +import { ViewCube } from './ViewCube'; +import { smartAudio } from '@/utils/smartAudio'; +import { + ArrowLeft, + Layers, + Activity, + Maximize2, + Trash2, + X, + CheckCircle2, + Clock, + Box, + Check +} from 'lucide-react'; + +export interface ProductionPieceStatus { + marca: string; + etapa_fase: string; + totalQtd: number; + pointedQtd: number; + currentProcessName?: string; + processColor?: string; + processOrdem?: number; +} + +interface SmartModelViewer3DProps { + modelData: LoadedIFCResult | null; + productionData?: Map; + selectedOF: string; + cliente?: string; + phasesList: string[]; + onVoltar: () => void; +} + +const PROCESS_LIST = [ + { id: 'all', nome: 'Todos os Processos', cor: '#06b6d4' }, + { id: 'corte', nome: 'Corte', cor: '#3b82f6' }, + { id: 'montagem', nome: 'Montagem', cor: '#6366f1' }, + { id: 'solda', nome: 'Solda', cor: '#f97316' }, + { id: 'pintura', nome: 'Pintura', cor: '#10b981' }, + { id: 'concluido', nome: 'Concluído', cor: '#22c55e' }, +]; + +export const SmartModelViewer3D: React.FC = ({ + modelData, + productionData, + selectedOF, + cliente, + phasesList, + onVoltar, +}) => { + 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 raycasterRef = useRef(new THREE.Raycaster()); + + // Filtros principais solicitados: Fase e Processo + const [selectedPhase, setSelectedPhase] = useState('all'); + const [selectedProcess, setSelectedProcess] = useState('all'); + + // Seleção e Toast Discreto + interface SelectedElement { + expressID: number; + piece: PieceInfo; + meshes: THREE.Mesh[]; + } + const [selectedElementsMap, setSelectedElementsMap] = useState>(new Map()); + const selectedElementsMapRef = useRef(selectedElementsMap); + useEffect(() => { + selectedElementsMapRef.current = selectedElementsMap; + }, [selectedElementsMap]); + + // Toast flutuante posicionado ao lado do toque + const [toastInfo, setToastInfo] = useState<{ + piece: PieceInfo; + position: { x: number; y: number }; + prod?: ProductionPieceStatus; + } | null>(null); + + // Material Magenta Permanente (#FF00FF) + const magentaMaterialRef = useRef( + new THREE.MeshStandardMaterial({ + color: new THREE.Color('#FF00FF'), + emissive: new THREE.Color('#C000C0'), + emissiveIntensity: 0.45, + metalness: 0.25, + roughness: 0.35, + side: THREE.DoubleSide, + }) + ); + + // Rastreamento para diferenciar gesto touch/drag de toque simples (Tap) + const pointerDownRef = useRef<{ x: number; y: number; time: number }>({ x: 0, y: 0, time: 0 }); + + // 1. Inicialização do Three.js e OrbitControls Touch-First + useEffect(() => { + if (!containerRef.current || !canvasContainerRef.current) return; + + const width = containerRef.current.clientWidth; + const height = containerRef.current.clientHeight; + + // Cena + const scene = new THREE.Scene(); + scene.background = null; // Fundo transparente controlado por Tailwind + sceneRef.current = scene; + + // Câmera Perspectiva otimizada para campo de visão amplo em telas touch + const camera = new THREE.PerspectiveCamera(48, width / height, 0.1, 10000); + camera.position.set(25, 20, 30); + cameraRef.current = camera; + + // Renderer WebGL com antialias e color space SRGB + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true, powerPreference: 'high-performance' }); + renderer.setSize(width, height); + renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2)); + renderer.outputColorSpace = THREE.SRGBColorSpace; + rendererRef.current = renderer; + + canvasContainerRef.current.innerHTML = ''; + canvasContainerRef.current.appendChild(renderer.domElement); + + // OrbitControls otimizado para toque (Touch-First) + const controls = new OrbitControls(camera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.08; + controls.rotateSpeed = 0.8; + controls.zoomSpeed = 1.0; + controls.panSpeed = 0.8; + // 1 dedo: Rotação suave | 2 dedos: Pinça para zoom + arrastar para pan + controls.touches = { + ONE: THREE.TOUCH.ROTATE, + TWO: THREE.TOUCH.DOLLY_PAN, + }; + controlsRef.current = controls; + + // Iluminação balanceada para aço industrial + const ambientLight = new THREE.AmbientLight(0xffffff, 0.95); + scene.add(ambientLight); + + const dirLight1 = new THREE.DirectionalLight(0xffffff, 0.85); + dirLight1.position.set(40, 60, 40); + scene.add(dirLight1); + + const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.45); + dirLight2.position.set(-40, -20, -40); + scene.add(dirLight2); + + // Grid suave de chão + const grid = new THREE.GridHelper(60, 60, 0x06b6d4, 0x94a3b8); + (grid.material as THREE.Material).opacity = 0.15; + (grid.material as THREE.Material).transparent = true; + grid.position.y = -0.05; + scene.add(grid); + + // Grupo de Modelo + const group = new THREE.Group(); + scene.add(group); + modelGroupRef.current = group; + + // Loop de renderização + let animId: number; + const animate = () => { + animId = requestAnimationFrame(animate); + controls.update(); + renderer.render(scene, camera); + }; + animate(); + + // Redimensionamento responsivo + const handleResize = () => { + if (!containerRef.current || !rendererRef.current || !cameraRef.current) return; + const w = containerRef.current.clientWidth; + const h = containerRef.current.clientHeight; + cameraRef.current.aspect = w / h; + cameraRef.current.updateProjectionMatrix(); + rendererRef.current.setSize(w, h); + }; + window.addEventListener('resize', handleResize); + + return () => { + cancelAnimationFrame(animId); + window.removeEventListener('resize', handleResize); + renderer.dispose(); + }; + }, []); + + // 2. Centralizar Modelo na Visão (Fit View) + const fitModelToView = useCallback(() => { + if (!modelGroupRef.current || !cameraRef.current || !controlsRef.current) return; + + const box = new THREE.Box3().setFromObject(modelGroupRef.current); + if (box.isEmpty()) return; + + const size = new THREE.Vector3(); + const center = new THREE.Vector3(); + box.getSize(size); + box.getCenter(center); + + const maxDim = Math.max(size.x, size.y, size.z); + const fov = cameraRef.current.fov * (Math.PI / 180); + let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 1.5; + cameraZ = Math.max(cameraZ, 5); + + cameraRef.current.position.set(center.x + cameraZ * 0.7, center.y + cameraZ * 0.5, center.z + cameraZ * 0.9); + cameraRef.current.lookAt(center); + cameraRef.current.updateProjectionMatrix(); + + controlsRef.current.target.copy(center); + controlsRef.current.update(); + }, []); + + // 3. Montar malhas do modelo IFC na cena Three.js + useEffect(() => { + if (!modelData || !sceneRef.current) return; + + if (modelGroupRef.current && modelGroupRef.current !== modelData.sceneGroup) { + sceneRef.current.remove(modelGroupRef.current); + } + + const group = modelData.sceneGroup; + modelGroupRef.current = group; + sceneRef.current.add(group); + + // Enquadra a câmera automaticamente após carregar o modelo + setTimeout(() => { + fitModelToView(); + }, 150); + }, [modelData, fitModelToView]); + + // 4. Aplicação de Filtros (Fase e Processo) e Cores de Produção + useEffect(() => { + if (!modelGroupRef.current) return; + + const targetPhaseClean = selectedPhase !== 'all' ? String(selectedPhase).trim().toLowerCase() : null; + const targetProcessNorm = selectedProcess !== 'all' + ? selectedProcess.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase() + : null; + + modelGroupRef.current.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const mesh = child as THREE.Mesh; + const meshPhase = mesh.userData.phase ? String(mesh.userData.phase).trim().toLowerCase() : ''; + const pieceMark = mesh.userData.pieceMark ? String(mesh.userData.pieceMark).trim().toUpperCase() : ''; + const prod = productionData?.get(pieceMark); + + // 4.1. Filtro de Fase: visibilidade da malha + let isPhaseVisible = true; + if (targetPhaseClean) { + isPhaseVisible = meshPhase === targetPhaseClean; + } + mesh.visible = isPhaseVisible; + + if (!isPhaseVisible) return; + + // 4.2. Aplicação de Cor por Processo / Produção + let pieceColorHex = '#a1a1aa'; // Padrão cinza neutro + let isDimmed = false; + + if (prod && prod.pointedQtd > 0) { + const currNorm = (prod.currentProcessName || '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase(); + + // Determina a cor base do processo + if (currNorm.includes('corte')) pieceColorHex = '#3b82f6'; + else if (currNorm.includes('montag')) pieceColorHex = '#6366f1'; + else if (currNorm.includes('solda')) pieceColorHex = '#f97316'; + else if (currNorm.includes('pintu')) pieceColorHex = '#10b981'; + else if (currNorm.includes('conclu')) pieceColorHex = '#22c55e'; + else pieceColorHex = prod.processColor || '#10b981'; + + // Se houver filtro de processo ativo, atenua peças de outros processos + if (targetProcessNorm && !currNorm.includes(targetProcessNorm)) { + isDimmed = true; + } + } else { + // Sem apontamento na produção: cinza atenuado se houver filtro de processo + if (targetProcessNorm) { + isDimmed = true; + } + } + + // Se está atenuado pelo filtro de processo, aplica opacidade fantasma + if (isDimmed) { + mesh.material = new THREE.MeshStandardMaterial({ + color: new THREE.Color('#94a3b8'), + metalness: 0.1, + roughness: 0.8, + transparent: true, + opacity: 0.12, + depthWrite: false, + side: THREE.DoubleSide, + }); + } else { + mesh.material = new THREE.MeshStandardMaterial({ + color: new THREE.Color(pieceColorHex), + metalness: 0.3, + roughness: 0.45, + side: THREE.DoubleSide, + }); + } + + // 4.3. Preserva o destaque magenta se a peça estiver selecionada pelo operador + const ifcId = mesh.userData.ifcId; + if (ifcId !== undefined && selectedElementsMapRef.current.has(ifcId)) { + mesh.userData.originalMaterial = mesh.material; + mesh.material = magentaMaterialRef.current; + } else { + mesh.userData.originalMaterial = mesh.material; + } + } + }); + }, [selectedPhase, selectedProcess, productionData]); + + // 5. Interação Touch / Clique na Peça (Single Tap) + const handlePointerDown = (e: React.PointerEvent) => { + pointerDownRef.current = { + x: e.clientX, + y: e.clientY, + time: Date.now(), + }; + }; + + const handlePointerUp = (e: React.PointerEvent) => { + if (!containerRef.current || !cameraRef.current || !modelGroupRef.current) return; + + const dx = e.clientX - pointerDownRef.current.x; + const dy = e.clientY - pointerDownRef.current.y; + const dt = Date.now() - pointerDownRef.current.time; + + // Filtra rotação e pinça touch: somente considera toque intencional (< 8px e < 350ms) + if (Math.hypot(dx, dy) > 8 || dt > 350) return; + + const rect = containerRef.current.getBoundingClientRect(); + const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1; + const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1; + + raycasterRef.current.setFromCamera(new THREE.Vector2(mouseX, mouseY), cameraRef.current); + let intersects = raycasterRef.current.intersectObjects(modelGroupRef.current.children, true); + + // Amostragem de tolerância radial para barras finas em touch + if (intersects.length === 0) { + const radiusOffsetsPx = [8, 16, 24]; + const angles = [0, Math.PI / 4, Math.PI / 2, (3 * Math.PI) / 4, Math.PI, (5 * Math.PI) / 4, (3 * Math.PI) / 2, (7 * Math.PI) / 4]; + for (const rPx of radiusOffsetsPx) { + if (intersects.length > 0) break; + for (const angle of angles) { + const sampleX = mouseX + ((rPx * Math.cos(angle)) / rect.width) * 2; + const sampleY = mouseY - ((rPx * Math.sin(angle)) / rect.height) * 2; + raycasterRef.current.setFromCamera(new THREE.Vector2(sampleX, sampleY), cameraRef.current); + const sampleHits = raycasterRef.current.intersectObjects(modelGroupRef.current.children, true); + if (sampleHits.length > 0) { + intersects = sampleHits; + break; + } + } + } + } + + if (intersects.length > 0) { + const hitObj = intersects[0].object; + let hitMesh: THREE.Mesh | null = null; + if ((hitObj as THREE.Mesh).isMesh) hitMesh = hitObj as THREE.Mesh; + else if (hitObj.parent && (hitObj.parent as THREE.Mesh).isMesh) hitMesh = hitObj.parent as THREE.Mesh; + + if (hitMesh && hitMesh.visible) { + const targetIfcId = hitMesh.userData.ifcId; + if (targetIfcId === undefined) return; + + const nextMap = new Map(selectedElementsMapRef.current); + const isAlreadySelected = nextMap.has(targetIfcId); + + if (isAlreadySelected) { + // Desmarca a peça física inteira + const element = nextMap.get(targetIfcId)!; + element.meshes.forEach((m) => { + if (m.userData.originalMaterial) m.material = m.userData.originalMaterial; + }); + nextMap.delete(targetIfcId); + setSelectedElementsMap(nextMap); + smartAudio.playClick(); + + // Fecha o toast se for da peça desmarcada + if (toastInfo?.piece.expressID === targetIfcId) { + setToastInfo(null); + } + } else { + // Marca a peça física inteira em Magenta permanente + const relatedMeshes: THREE.Mesh[] = []; + modelGroupRef.current.traverse((child) => { + if ((child as THREE.Mesh).isMesh && child.userData.ifcId === targetIfcId) { + const m = child as THREE.Mesh; + m.userData.originalMaterial = m.material; + m.material = magentaMaterialRef.current; + relatedMeshes.push(m); + } + }); + + const pmark = hitMesh.userData.pieceMark; + const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(targetIfcId)) || { + expressID: targetIfcId, + guid: '', + name: hitMesh.userData.section || 'Peça', + type: 'PIECE', + pieceMark: pmark, + section: hitMesh.userData.section, + phase: hitMesh.userData.phase, + }; + + nextMap.set(targetIfcId, { + expressID: targetIfcId, + piece, + meshes: relatedMeshes, + }); + setSelectedElementsMap(nextMap); + smartAudio.playSuccess(); + + // Abre o Toast discreto ao lado do toque + const prodInfo = pmark ? productionData?.get(pmark) : undefined; + setToastInfo({ + piece, + position: { x: e.clientX, y: e.clientY }, + prod: prodInfo, + }); + } + } + } + }; + + // Limpar todas as seleções + const handleClearSelection = useCallback(() => { + smartAudio.playClick(); + selectedElementsMapRef.current.forEach(({ meshes }) => { + meshes.forEach((m) => { + if (m.userData.originalMaterial) m.material = m.userData.originalMaterial; + }); + }); + setSelectedElementsMap(new Map()); + setToastInfo(null); + }, []); + + const selectedCount = selectedElementsMap.size; + + // Orientação da Câmera pelo ViewCube + const handleViewCubeSelect = (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso') => { + if (!cameraRef.current || !controlsRef.current) return; + const target = controlsRef.current.target; + const dist = cameraRef.current.position.distanceTo(target) || 30; + + switch (view) { + case 'top': cameraRef.current.position.set(target.x, target.y + dist, target.z + 0.001); break; + case 'bottom': cameraRef.current.position.set(target.x, target.y - dist, target.z + 0.001); break; + case 'front': cameraRef.current.position.set(target.x, target.y, target.z + dist); break; + case 'back': cameraRef.current.position.set(target.x, target.y, target.z - dist); break; + case 'left': cameraRef.current.position.set(target.x - dist, target.y, target.z); break; + case 'right': cameraRef.current.position.set(target.x + dist, target.y, target.z); break; + case 'iso': cameraRef.current.position.set(target.x + dist * 0.7, target.y + dist * 0.6, target.z + dist * 0.7); break; + } + cameraRef.current.lookAt(target); + controlsRef.current.update(); + }; + + // Posição ajustada do Toast para não sair das bordas da tela + const toastStyle = useMemo(() => { + if (!toastInfo || !containerRef.current) return {}; + const rect = containerRef.current.getBoundingClientRect(); + let left = toastInfo.position.x - rect.left + 14; + let top = toastInfo.position.y - rect.top - 20; + + // Se estiver muito à direita, joga para a esquerda do cursor + if (left + 230 > rect.width) { + left = Math.max(12, left - 245); + } + // Se estiver muito abaixo, sobe + if (top + 160 > rect.height) { + top = Math.max(12, rect.height - 170); + } + if (top < 12) top = 12; + + return { left: `${left}px`, top: `${top}px` }; + }, [toastInfo]); + + return ( +
+ {/* Canvas Three.js */} +
+ + {/* 1. Header Minimalista Flutuante (Touch-First) */} +
+ {/* Lado Esquerdo: Botão Voltar + Info da Obra */} +
+ + +
+ + OF {selectedOF} + + + {cliente || 'Obra Industrial'} + +
+
+ + {/* Lado Direito: Filtros Discretos (Fase & Processo) + Enquadrar */} +
+ {/* Filtro por Fase */} +
+ + +
+ + {/* Filtro por Processo */} +
+ + +
+ + {/* Botão Enquadrar (Fit View) */} + +
+
+ + {/* 2. Top Right: Cubo 3D Compacto + Botão Limpar Seleção */} +
+ {/* Cubo Compacto Discreto */} + + + {/* Botão Limpar Seleção (Apenas quando houver peças marcadas) */} + {selectedCount > 0 && ( + + )} +
+ + {/* 3. Toast Discreto ao Lado da Peça Clicada */} + {toastInfo && ( +
{ + smartAudio.playClick(); + setToastInfo(null); + }} + style={toastStyle} + className="absolute z-40 p-3 rounded-2xl bg-slate-900/95 backdrop-blur-xl border border-fuchsia-500/50 shadow-2xl shadow-fuchsia-950/50 text-slate-100 min-w-[210px] max-w-[260px] animate-in fade-in zoom-in-95 duration-150 cursor-pointer pointer-events-auto select-none transition-all group" + title="Toque no toast para fechar (a peça continua marcada)" + > + {/* Cabeçalho do Toast */} +
+
+
+ + {toastInfo.piece.pieceMark || 'Sem Marca'} + +
+ +
+ + {/* Dados Rápidos da Peça */} +
+ {toastInfo.piece.phase && ( +
+ Fase: + Fase {toastInfo.piece.phase} +
+ )} + {toastInfo.piece.section && ( +
+ Perfil: + + {toastInfo.piece.section} + +
+ )} + {toastInfo.prod && ( +
+ Status: + + {toastInfo.prod.currentProcessName || 'Produção'} ({toastInfo.prod.pointedQtd}/{toastInfo.prod.totalQtd}) + +
+ )} +
+ + {/* Dica para Fechar */} +
+ Toque no card para fechar ✕ +
+
+ )} +
+ ); +}; diff --git a/src/components/viewer3d/ViewCube.tsx b/src/components/viewer3d/ViewCube.tsx index 7f7ff24..14a9a7f 100644 --- a/src/components/viewer3d/ViewCube.tsx +++ b/src/components/viewer3d/ViewCube.tsx @@ -5,9 +5,16 @@ import { RotateCcw } from 'lucide-react'; interface ViewCubeProps { onSelectView: (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso') => void; mainCameraRef?: React.MutableRefObject; + size?: number; + compact?: boolean; } -export const ViewCube: React.FC = ({ onSelectView, mainCameraRef }) => { +export const ViewCube: React.FC = ({ + onSelectView, + mainCameraRef, + size = 84, + compact = false, +}) => { const containerRef = useRef(null); const rendererRef = useRef(null); const sceneRef = useRef(null); @@ -20,8 +27,8 @@ export const ViewCube: React.FC = ({ onSelectView, mainCameraRef useEffect(() => { if (!containerRef.current) return; - const width = 84; - const height = 84; + const width = size; + const height = size; // 1. Scene & Camera const scene = new THREE.Scene(); @@ -202,13 +209,20 @@ export const ViewCube: React.FC = ({ onSelectView, mainCameraRef return (
- {/* Container Reduzido (~70%) Harmonizado para Modo Claro e Escuro */} -
+ {/* Container Reduzido Harmonizado para Modo Claro e Escuro */} +
{/* Glow sutil harmonizado */}
{/* Canvas WebGL 3D do Cubo Sincronizado */} -
+
{/* Botão ISO no canto inferior harmonizado com o tema */}
- {/* Rótulo inferior */} - - Cubo 3D - + {/* Rótulo inferior opcional */} + {!compact && ( + + Cubo 3D + + )}
); }; diff --git a/src/pages/ModoSmart.tsx b/src/pages/ModoSmart.tsx index d6b891d..4dd48cb 100644 --- a/src/pages/ModoSmart.tsx +++ b/src/pages/ModoSmart.tsx @@ -6,6 +6,7 @@ import { SmartProducaoFlow } from './smart/components/SmartProducaoFlow'; import { SmartEmbarqueFlow } from './smart/components/SmartEmbarqueFlow'; import { SmartMontagemFlow } from './smart/components/SmartMontagemFlow'; import { SmartConsultaRelatoriosFlow } from './smart/components/SmartConsultaRelatoriosFlow'; +import { SmartViewer3DFlow } from './smart/components/SmartViewer3DFlow'; import { OFAtiva } from '@/hooks/useOFsAtivas'; import { useAuth } from '@/hooks/useAuth'; import { supabase } from '@/integrations/supabase/client'; @@ -23,9 +24,9 @@ export const ModoSmart: React.FC = () => { } }); - // Tela ativa: 'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf' + // Tela ativa: 'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf' | 'modelo3d' const [telaAtiva, setTelaAtiva] = useState< - 'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf' + 'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf' | 'modelo3d' >('hub'); // Produção do operador hoje @@ -113,6 +114,7 @@ export const ModoSmart: React.FC = () => { montagem: '3. Apontar Montagem', consulta: '4. Consultar Peças', turno_pdf: 'Relatório do Turno', + modelo3d: 'Modelo 3D da Obra', }; return { @@ -126,13 +128,15 @@ export const ModoSmart: React.FC = () => { return (
- {/* Cabeçalho Fixo do Modo Smart */} - setTelaAtiva('hub')} - /> + {/* Cabeçalho Fixo do Modo Smart (oculto quando em tela cheia do modelo 3D) */} + {telaAtiva !== 'modelo3d' && ( + setTelaAtiva('hub')} + /> + )} {/* Conteúdo Principal Touch-First */}
@@ -173,6 +177,11 @@ export const ModoSmart: React.FC = () => { onVoltarHub={() => setTelaAtiva('hub')} abrirDiretoTurno={true} /> + ) : telaAtiva === 'modelo3d' ? ( + setTelaAtiva('hub')} + /> ) : null}
diff --git a/src/pages/smart/components/SmartHub.tsx b/src/pages/smart/components/SmartHub.tsx index dc18e09..ad74e45 100644 --- a/src/pages/smart/components/SmartHub.tsx +++ b/src/pages/smart/components/SmartHub.tsx @@ -7,7 +7,8 @@ import { ArrowLeftRight, FileText, TrendingUp, - Sparkles + Sparkles, + Box } from 'lucide-react'; import { OFAtiva } from '@/hooks/useOFsAtivas'; import { smartAudio } from '@/utils/smartAudio'; @@ -16,7 +17,7 @@ import { Button } from '@/components/ui/button'; interface SmartHubProps { obra: OFAtiva; onTrocarObra: () => void; - onNavigate: (destino: 'producao' | 'embarque' | 'montagem' | 'consulta') => void; + onNavigate: (destino: 'producao' | 'embarque' | 'montagem' | 'consulta' | 'modelo3d') => void; producaoHoje: { totalPecas: number; totalKg: number }; onGerarRelatorioTurno: () => void; } @@ -28,7 +29,7 @@ export const SmartHub: React.FC = ({ producaoHoje, onGerarRelatorioTurno, }) => { - const handleAcao = (destino: 'producao' | 'embarque' | 'montagem' | 'consulta') => { + const handleAcao = (destino: 'producao' | 'embarque' | 'montagem' | 'consulta' | 'modelo3d') => { smartAudio.playClick(); onNavigate(destino); }; @@ -45,18 +46,31 @@ export const SmartHub: React.FC = ({ OF {obra.of_number} — {obra.cliente || 'Obra Industrial'}
- +
+ + + +
{/* Grade dos 4 Botões Principais Gigantes */} diff --git a/src/pages/smart/components/SmartViewer3DFlow.tsx b/src/pages/smart/components/SmartViewer3DFlow.tsx new file mode 100644 index 0000000..d9f7ab6 --- /dev/null +++ b/src/pages/smart/components/SmartViewer3DFlow.tsx @@ -0,0 +1,233 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { OFAtiva } from '@/hooks/useOFsAtivas'; +import { supabase } from '@/integrations/supabase/client'; +import { loadAndAuditIFC, LoadedIFCResult } from '@/lib/ifc/ifcLoaderService'; +import { fetchSavedIFCModel } from '@/lib/ifc/ifcStorageService'; +import { SmartModelViewer3D, ProductionPieceStatus } from '@/components/viewer3d/SmartModelViewer3D'; +import { smartAudio } from '@/utils/smartAudio'; +import { Box, ArrowLeft, Loader2, AlertCircle } from 'lucide-react'; +import { Button } from '@/components/ui/button'; + +interface SmartViewer3DFlowProps { + obra: OFAtiva; + onVoltarHub: () => void; +} + +export const SmartViewer3DFlow: React.FC = ({ + obra, + onVoltarHub, +}) => { + const [modelData, setModelData] = useState(null); + const [phasesList, setPhasesList] = useState([]); + const [productionMap, setProductionMap] = useState>(new Map()); + + // Loading States + const [isLoading, setIsLoading] = useState(true); + const [loadingStep, setLoadingStep] = useState('Buscando modelo da obra...'); + const [loadingPercent, setLoadingPercent] = useState(10); + const [errorMessage, setErrorMessage] = useState(null); + + // 1. Carrega dados de produção e modelo IFC + const carregarModeloEProducao = useCallback(async () => { + setIsLoading(true); + setErrorMessage(null); + setLoadingStep('Buscando modelo IFC vinculado à obra...'); + setLoadingPercent(15); + + try { + // 1.1. Buscar registro da OF para obter URL do IFC + const { data: ofData, error: ofError } = await supabase + .from('ordens_fabricacao' as any) + .select('id, num_of, descritivo, ifc_url, ifc_filename') + .eq('num_of', obra.of_number) + .maybeSingle(); + + if (ofError) throw ofError; + + if (!ofData || !(ofData as any).ifc_url) { + setIsLoading(false); + setErrorMessage('Nenhum modelo IFC 3D cadastrado na nuvem para esta OF.'); + return; + } + + const ifcUrl = (ofData as any).ifc_url; + const ifcFilename = (ofData as any).ifc_filename; + + // 1.2. Buscar Peças e Apontamentos em paralelo + setLoadingStep('Carregando dados das peças e apontamentos...'); + setLoadingPercent(30); + + const [pecasRes, apontamentosRes] = await Promise.all([ + supabase + .from('pecas' as any) + .select('id, of_number, etapa_fase, marca, descricao, quantidade, perfil_principal') + .eq('of_number', obra.of_number), + 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', obra.of_number), + ]); + + // Mapeamento de Produção + const prodMap = new Map(); + const phasesSet = new Set(); + + if (pecasRes.data) { + pecasRes.data.forEach((p: any) => { + const marca = p.marca ? String(p.marca).trim().toUpperCase() : ''; + const fase = p.etapa_fase ? String(p.etapa_fase).trim() : ''; + if (fase) phasesSet.add(fase); + + if (marca) { + const existing = prodMap.get(marca); + if (!existing) { + prodMap.set(marca, { + marca, + etapa_fase: fase, + totalQtd: Number(p.quantidade || 1), + pointedQtd: 0, + currentProcessName: 'Pendente', + processColor: '#94a3b8', + processOrdem: 0, + }); + } else { + existing.totalQtd += Number(p.quantidade || 0); + } + } + }); + } + + if (apontamentosRes.data) { + apontamentosRes.data.forEach((ap: any) => { + const marca = ap.peca?.marca ? String(ap.peca.marca).trim().toUpperCase() : ''; + if (marca && prodMap.has(marca)) { + const item = prodMap.get(marca)!; + const q = Number(ap.quantidade_produzida || 0); + const procNome = ap.processo?.nome || 'Produção'; + const procOrdem = Number(ap.processo?.ordem || 0); + + if (q > 0 && procOrdem >= (item.processOrdem || 0)) { + item.pointedQtd = Math.max(item.pointedQtd, q); + item.currentProcessName = procNome; + item.processColor = ap.processo?.cor || '#10b981'; + item.processOrdem = procOrdem; + } + } + }); + } + + setProductionMap(prodMap); + + // 1.3. Baixar IFC da nuvem ou cache IndexedDB + setLoadingStep('Baixando arquivo 3D de alta performance...'); + setLoadingPercent(50); + + const buffer = await fetchSavedIFCModel(obra.of_number, ifcUrl, (pct, msg) => { + setLoadingPercent(30 + Math.round(pct * 0.35)); + setLoadingStep(msg); + }); + + // 1.4. Decodificar Entidades IFC e Gerar Geometrias Three.js + setLoadingStep('Decodificando montagens e geometrias do modelo...'); + setLoadingPercent(68); + + const result = await loadAndAuditIFC(buffer, (pct, msg) => { + setLoadingPercent(68 + Math.round(pct * 0.32)); + setLoadingStep(msg); + }); + + // Mescla fases encontradas no IFC + if (result.detectedPhases && result.detectedPhases.length > 0) { + result.detectedPhases.forEach((f) => phasesSet.add(f)); + } + + const sortedPhases = Array.from(phasesSet).sort((a, b) => { + const na = Number(a); + const nb = Number(b); + return !isNaN(na) && !isNaN(nb) ? na - nb : a.localeCompare(b); + }); + + setPhasesList(sortedPhases); + setModelData(result); + setLoadingPercent(100); + setIsLoading(false); + } catch (err: any) { + console.error('Erro ao carregar modelo no modo Smart:', err); + setIsLoading(false); + setErrorMessage(err.message || 'Falha ao processar o modelo 3D desta obra.'); + } + }, [obra.of_number]); + + useEffect(() => { + carregarModeloEProducao(); + }, [carregarModeloEProducao]); + + // Se estiver carregando, exibe progresso elegante + if (isLoading) { + return ( +
+
+ +
+
+ + OF {obra.of_number} — Visualizador 3D + +
{loadingStep}
+
+
+
+
{loadingPercent}%
+
+
+ ); + } + + // Se houver erro ou a OF não tiver IFC + if (errorMessage || !modelData) { + return ( +
+
+ +
+

+ Modelo 3D Indisponível +

+

+ {errorMessage || `A OF ${obra.of_number} ainda não possui arquivo IFC cadastrado.`} +

+ +
+ ); + } + + // Renderiza o Visualizador 3D Touch-First em Tela Cheia + return ( +
+ +
+ ); +};