From 4da2444938d11dee727599689221f625cef8dff4 Mon Sep 17 00:00:00 2001 From: Marcos Reifonas Date: Wed, 23 Sep 2026 13:16:04 +0000 Subject: [PATCH] feat: selecao de pecas magenta no 3D, hover de 1s, botoes abaixo do cubo e modal de lista de selecao --- src/components/viewer3d/ModelViewer3D.tsx | 276 ++++++++++++- .../viewer3d/SelectionListModal.tsx | 367 ++++++++++++++++++ 2 files changed, 632 insertions(+), 11 deletions(-) create mode 100644 src/components/viewer3d/SelectionListModal.tsx diff --git a/src/components/viewer3d/ModelViewer3D.tsx b/src/components/viewer3d/ModelViewer3D.tsx index e72c8ca..0369272 100644 --- a/src/components/viewer3d/ModelViewer3D.tsx +++ b/src/components/viewer3d/ModelViewer3D.tsx @@ -5,6 +5,8 @@ import { PieceInfo, LoadedIFCResult, getColorForMaterialName } from '@/lib/ifc/i import { ViewCube } from './ViewCube'; import { Viewer3DSidebar } from './Viewer3DSidebar'; import { PieceHoverTooltip } from './PieceHoverTooltip'; +import { SelectionListModal, SelectedPieceItem } from './SelectionListModal'; +import { Trash2, FileSpreadsheet, RotateCcw } from 'lucide-react'; interface ProductionPieceStatus { marca: string; @@ -53,11 +55,33 @@ export const ModelViewer3D: React.FC = ({ const [colorMode, setColorMode] = useState<'description' | 'production'>('production'); const [selectedProcess, setSelectedProcess] = useState('all'); - // Hover Tooltip States (1.5 seconds) + // Hover Tooltip States (1.0 second delay) const hoverTimerRef = useRef(null); const [hoveredPiece, setHoveredPiece] = useState(null); const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null); + // Seleção Permanente Magenta (Single Click e Double Click) + const [selectedPiecesMap, setSelectedPiecesMap] = useState>(new Map()); + const selectedPiecesMapRef = useRef(selectedPiecesMap); + useEffect(() => { + selectedPiecesMapRef.current = selectedPiecesMap; + }, [selectedPiecesMap]); + + const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false); + const pointerDownInfoRef = useRef<{ x: number; y: number; time: number }>({ x: 0, y: 0, time: 0 }); + + // Material Magenta Permanente (#FF00FF) + const magentaMaterialRef = useRef( + new THREE.MeshStandardMaterial({ + color: new THREE.Color('#FF00FF'), + emissive: new THREE.Color('#C000C0'), + emissiveIntensity: 0.4, + metalness: 0.25, + roughness: 0.35, + side: THREE.DoubleSide, + }) + ); + // Highlight Material (Light Green #4ade80) const highlightMaterialRef = useRef( new THREE.MeshStandardMaterial({ @@ -522,6 +546,14 @@ export const ModelViewer3D: React.FC = ({ } } } + + // Se a peça estiver marcada na seleção permanente, preserva a cor magenta + if (selectedPiecesMapRef.current.has(mesh.uuid)) { + mesh.userData.originalMaterial = mesh.material; + mesh.material = magentaMaterialRef.current; + } else { + mesh.userData.originalMaterial = mesh.material; + } } }); @@ -700,7 +732,7 @@ export const ModelViewer3D: React.FC = ({ } } } - }, 1500); // 1.5 seconds exact requirement + }, 1000); // Exato 1.0 segundo de tempo de pouso do mouse }; const handlePointerLeave = () => { @@ -712,30 +744,189 @@ export const ModelViewer3D: React.FC = ({ setHoverPosition(null); }; - // Double Click to focus camera target + // Pointer Down para diferenciar clique simples de arraste de câmera dos OrbitControls + const handlePointerDown = (e: React.PointerEvent) => { + pointerDownInfoRef.current = { + x: e.clientX, + y: e.clientY, + time: Date.now(), + }; + }; + + // 1 Clique: Marcação e Desmarcação Permanente em Magenta (#FF00FF) + const handlePointerUp = (e: React.PointerEvent) => { + if (!containerRef.current || !activeCameraRef.current || !modelGroupRef.current) return; + + const dx = e.clientX - pointerDownInfoRef.current.x; + const dy = e.clientY - pointerDownInfoRef.current.y; + const dt = Date.now() - pointerDownInfoRef.current.time; + + // Se houve arraste de rotação/pan (mais de 6px ou clique longo > 400ms), ignora + if (Math.hypot(dx, dy) > 6 || dt > 400) 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; + + const raycaster = raycasterRef.current; + raycaster.setFromCamera(new THREE.Vector2(mouseX, mouseY), activeCameraRef.current); + const intersects = raycaster.intersectObjects(modelGroupRef.current.children, true); + + 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 nextMap = new Map(selectedPiecesMapRef.current); + + if (nextMap.has(hitMesh.uuid)) { + // Desmarcar peça (restaura material original) + if (hitMesh.userData.originalMaterial) { + hitMesh.material = hitMesh.userData.originalMaterial; + } + hitMesh.userData.isMagentaSelected = false; + nextMap.delete(hitMesh.uuid); + } else { + // Marcar peça em magenta permanente + hitMesh.userData.originalMaterial = hitMesh.material; + hitMesh.userData.isMagentaSelected = true; + hitMesh.material = magentaMaterialRef.current; + + const expressID = hitMesh.userData.ifcId; + const pmark = hitMesh.userData.pieceMark; + const piece: PieceInfo = (modelData && expressID !== undefined && modelData.pieceByExpressID.get(expressID)) || { + expressID: expressID || 0, + guid: '', + name: hitMesh.userData.section || 'Peça', + type: 'PIECE', + pieceMark: pmark, + section: hitMesh.userData.section, + phase: hitMesh.userData.phase, + }; + + nextMap.set(hitMesh.uuid, { mesh: hitMesh, piece }); + onSelectPiece?.(piece); + } + + setSelectedPiecesMap(nextMap); + } + } + }; + + // 2 Cliques (Duplo Clique): Seleciona TODAS as peças idênticas no modelo (mesma marca/tag da mesma fase) const handleDoubleClick = (e: React.MouseEvent) => { if (!containerRef.current || !activeCameraRef.current || !modelGroupRef.current || !controlsRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1; const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1; - - const v = new THREE.Vector2(mouseX, mouseY); - raycasterRef.current.setFromCamera(v, activeCameraRef.current); + + raycasterRef.current.setFromCamera(new THREE.Vector2(mouseX, mouseY), activeCameraRef.current); const intersects = raycasterRef.current.intersectObjects(modelGroupRef.current.children, true); if (intersects.length > 0) { const hitPoint = intersects[0].point; - controlsRef.current.target.copy(hitPoint); - controlsRef.current.update(); + 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) { + const targetPieceMark = String(hitMesh.userData.pieceMark || '').trim().toUpperCase(); + const targetPhase = hitMesh.userData.phase ? String(hitMesh.userData.phase).trim() : ''; + + if (targetPieceMark) { + const nextMap = new Map(selectedPiecesMapRef.current); + + modelGroupRef.current.traverse((child) => { + if ((child as THREE.Mesh).isMesh && child.visible) { + const mesh = child as THREE.Mesh; + const pmark = String(mesh.userData.pieceMark || '').trim().toUpperCase(); + const phase = mesh.userData.phase ? String(mesh.userData.phase).trim() : ''; + + // Mesma marca e mesma fase (se aplicável) + const isMatch = pmark === targetPieceMark && (!targetPhase || !phase || phase === targetPhase); + + if (isMatch) { + if (!nextMap.has(mesh.uuid)) { + mesh.userData.originalMaterial = mesh.material; + } + mesh.userData.isMagentaSelected = true; + mesh.material = magentaMaterialRef.current; + + const expressID = mesh.userData.ifcId; + const piece: PieceInfo = (modelData && expressID !== undefined && modelData.pieceByExpressID.get(expressID)) || { + expressID: expressID || 0, + guid: '', + name: mesh.userData.section || 'Peça', + type: 'PIECE', + pieceMark: pmark, + section: mesh.userData.section, + phase: phase || targetPhase, + }; + + nextMap.set(mesh.uuid, { mesh, piece }); + } + } + }); + + setSelectedPiecesMap(nextMap); + } + + // Foca suavemente o alvo dos controles na peça clicada + controlsRef.current.target.copy(hitPoint); + controlsRef.current.update(); + } } }; + // Limpar Seleção Completa + const handleClearSelection = useCallback(() => { + selectedPiecesMapRef.current.forEach(({ mesh }) => { + if (mesh.userData.originalMaterial) { + mesh.material = mesh.userData.originalMaterial; + } + mesh.userData.isMagentaSelected = false; + }); + setSelectedPiecesMap(new Map()); + }, []); + + const selectedCount = selectedPiecesMap.size; + + // Lista consolidada para o modal de seleção + const selectedPiecesList: SelectedPieceItem[] = Array.from(selectedPiecesMap.values()).map( + ({ mesh, piece }) => { + const pmark = piece.pieceMark || mesh.userData.pieceMark || 'Sem Marca'; + const prod = productionData?.get(pmark); + return { + id: mesh.uuid, + pieceMark: pmark, + phase: piece.phase || mesh.userData.phase, + section: piece.section || mesh.userData.section, + name: piece.name, + pointedQty: prod?.pointedQtd, + totalQty: prod?.totalQtd, + currentStage: prod?.currentProcessName, + stageColor: prod?.processColor, + }; + } + ); + return (
@@ -761,12 +952,66 @@ export const ModelViewer3D: React.FC = ({ onToggleFullscreen={handleToggleFullscreen} /> - {/* Top Right: Interactive ViewCube WebGL sincronizado */} -
+ {/* Top Right: ViewCube + Painel de Ações de Seleção de Peças */} +
+ + {/* Card de Controle de Seleção Flutuante Abaixo do Cubo */} +
0 + ? 'bg-slate-900/95 border-fuchsia-500/50 shadow-fuchsia-950/50 ring-1 ring-fuchsia-500/30' + : 'bg-white/80 dark:bg-slate-900/70 border-slate-200/80 dark:border-slate-800/80 opacity-75 hover:opacity-100' + }`} + style={{ minWidth: '130px' }} + > + {/* Badge Indicador de Seleção Ativa */} + {selectedCount > 0 && ( +
+ + + {selectedCount} {selectedCount === 1 ? 'peça' : 'peças'} + +
+ )} + + {/* Botão Gerar Lista da Seleção ("Acende" e Habilita quando há peças selecionadas) */} + + + {/* Botão Limpar Seleção */} + +
- {/* Hover Tooltip (1.5s delay trigger) */} + {/* Hover Tooltip (1.0s delay trigger) */} = ({ : undefined } /> + + {/* Modal de Lista e Exportação da Seleção 3D */} + setIsSelectionModalOpen(false)} + selectedPieces={selectedPiecesList} + selectedOF={selectedOF} + onClearSelection={handleClearSelection} + />
); }; diff --git a/src/components/viewer3d/SelectionListModal.tsx b/src/components/viewer3d/SelectionListModal.tsx new file mode 100644 index 0000000..ed8204a --- /dev/null +++ b/src/components/viewer3d/SelectionListModal.tsx @@ -0,0 +1,367 @@ +import React, { useState } from 'react'; +import { + X, + FileSpreadsheet, + Copy, + Check, + Trash2, + Layers, + Box, + CheckCircle2, + Clock, + Download, +} from 'lucide-react'; +import * as XLSX from 'xlsx'; +import { toast } from 'sonner'; + +export interface SelectedPieceItem { + id: string; // mesh.uuid + pieceMark: string; + phase?: string; + section?: string; + name?: string; + pointedQty?: number; + totalQty?: number; + currentStage?: string; + stageColor?: string; +} + +interface SelectionListModalProps { + isOpen: boolean; + onClose: () => void; + selectedPieces: SelectedPieceItem[]; + selectedOF?: string; + onClearSelection: () => void; +} + +export const SelectionListModal: React.FC = ({ + isOpen, + onClose, + selectedPieces, + selectedOF, + onClearSelection, +}) => { + const [copied, setCopied] = useState(false); + + if (!isOpen) return null; + + // Agrupamento por Marca + Fase para consolidar quantidades + const groupedMap = new Map< + string, + { + pieceMark: string; + phase: string; + section: string; + selectedCount: number; + totalQty: number; + pointedQty: number; + currentStage: string; + stageColor: string; + } + >(); + + selectedPieces.forEach((p) => { + const key = `${p.phase || '1'}_${p.pieceMark || 'Sem Marca'}`; + const existing = groupedMap.get(key); + if (existing) { + existing.selectedCount += 1; + } else { + groupedMap.set(key, { + pieceMark: p.pieceMark || 'Sem Marca', + phase: p.phase || '-', + section: p.section || '-', + selectedCount: 1, + totalQty: p.totalQty || 1, + pointedQty: p.pointedQty || 0, + currentStage: p.currentStage || 'Pendente', + stageColor: p.stageColor || '#64748b', + }); + } + }); + + const consolidatedList = Array.from(groupedMap.values()); + const totalPhysicalPieces = selectedPieces.length; + const totalDistinctMarks = consolidatedList.length; + + // Exportação para Excel + const handleExportExcel = () => { + if (consolidatedList.length === 0) { + toast.warning('Nenhuma peça selecionada para exportar.'); + return; + } + + try { + const dataRows = consolidatedList.map((item, idx) => ({ + 'Item': idx + 1, + 'Ordem de Fabricação (OF)': selectedOF || '-', + 'Fase': item.phase, + 'Marca Principal': item.pieceMark, + 'Perfil / Bitola': item.section, + 'Qtd. Selecionada no 3D': item.selectedCount, + 'Qtd. Total Cadastrada': item.totalQty, + 'Qtd. Apontada na Fábrica': item.pointedQty, + 'Etapa / Status Atual': item.currentStage, + })); + + const worksheet = XLSX.utils.json_to_sheet(dataRows); + const workbook = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(workbook, worksheet, 'Peças Selecionadas'); + + // Ajuste de largura das colunas + worksheet['!cols'] = [ + { wch: 6 }, + { wch: 24 }, + { wch: 10 }, + { wch: 18 }, + { wch: 22 }, + { wch: 22 }, + { wch: 20 }, + { wch: 22 }, + { wch: 20 }, + ]; + + const fileName = `Selecao_3D_${selectedOF || 'OF'}_${new Date().toISOString().slice(0, 10)}.xlsx`; + XLSX.writeFile(workbook, fileName); + toast.success('Planilha Excel gerada com sucesso!', { + description: fileName, + }); + } catch (err) { + console.error('Erro ao gerar Excel:', err); + toast.error('Falha ao exportar para Excel.'); + } + }; + + // Copiar para Área de Transferência + const handleCopy = () => { + if (consolidatedList.length === 0) return; + + const headers = ['OF', 'Fase', 'Marca', 'Perfil', 'Qtd Selecionada', 'Qtd Total', 'Status']; + const lines = consolidatedList.map((item) => + [ + selectedOF || '-', + item.phase, + item.pieceMark, + item.section, + item.selectedCount, + item.totalQty, + item.currentStage, + ].join('\t') + ); + + const textToCopy = [headers.join('\t'), ...lines].join('\n'); + navigator.clipboard.writeText(textToCopy); + setCopied(true); + toast.success('Lista copiada para a área de transferência!'); + setTimeout(() => setCopied(false), 2000); + }; + + return ( +
+
+ {/* Header do Modal com Destaque Magenta */} +
+
+
+ +
+
+
+

+ Lista de Peças Selecionadas no 3D +

+ + {totalPhysicalPieces} {totalPhysicalPieces === 1 ? 'peça' : 'peças'} + +
+

+ Relação consolidada das peças marcadas em magenta permanente no modelo 3D +

+
+
+ +
+ + {/* KPIs Resumo no Topo */} +
+
+
+ +
+
+ + Total de Peças + + + {totalPhysicalPieces} + +
+
+ +
+
+ +
+
+ + Marcas Distintas + + + {totalDistinctMarks} + +
+
+ +
+
+ +
+
+ + Ordem de Fabricação + + + {selectedOF || 'N/A'} + +
+
+ +
+
+ +
+
+ + Status + + + {totalPhysicalPieces > 0 ? 'Filtro Ativo' : 'Vazio'} + +
+
+
+ + {/* Tabela de Itens Selecionados com Rolagem Suave */} +
+ {consolidatedList.length === 0 ? ( +
+ +

Nenhuma peça selecionada

+

+ Clique nas peças na cena 3D (ou clique duplo para pegar todas da mesma marca) para incluí-las nesta lista. +

+
+ ) : ( +
+ + + + + + + + + + + + + {consolidatedList.map((item, idx) => ( + + + + + + + + + ))} + +
Marca PrincipalFasePerfil / Bitola + Qtd. 3D + Qtd. OFEtapa / Apontamento
+ + {item.pieceMark} + + + {item.phase} + + + {item.section} + + {item.selectedCount} + + {item.pointedQty} / {item.totalQty} + + + + {item.currentStage} + +
+
+ )} +
+ + {/* Rodapé com Botões de Ação */} +
+ + +
+ + + +
+
+
+
+ ); +};