From ac8bacc9e1b4ecc9f18f19fcca805158668daa44 Mon Sep 17 00:00:00 2001 From: admbrainsteel Date: Thu, 15 Oct 2026 16:29:17 -0300 Subject: [PATCH] feat(smart-3d): replica card de engenharia e adiciona ferramenta girar 90 com trava --- .../viewer3d/SmartModelViewer3D.tsx | 441 ++++++++++++++---- .../smart/components/SmartViewer3DFlow.tsx | 11 +- 2 files changed, 354 insertions(+), 98 deletions(-) diff --git a/src/components/viewer3d/SmartModelViewer3D.tsx b/src/components/viewer3d/SmartModelViewer3D.tsx index 22abd1d..9f3862d 100644 --- a/src/components/viewer3d/SmartModelViewer3D.tsx +++ b/src/components/viewer3d/SmartModelViewer3D.tsx @@ -22,8 +22,12 @@ import { Moon, FileSpreadsheet, Grid as GridIcon, - Bell + Bell, + RotateCw, + Lock, + Unlock } from 'lucide-react'; +import { supabase } from '@/integrations/supabase/client'; export interface ProductionPieceStatus { pecaId?: string; @@ -38,6 +42,91 @@ export interface ProductionPieceStatus { processesCompleted?: string[]; processPointedQtds?: Record; hasDetalhamento?: boolean; + perfilPrincipal?: string; + descricao?: string; +} + +/** + * Busca de apontamento e status fabril com máxima tolerância e amostragem de chaves para IFC e WRL. + */ +function findProductionDataForPiece( + productionData?: Map, + piece?: { + pieceMark?: string; + cleanMark?: string; + assemblyMark?: string; + cleanAssemblyMark?: string; + phase?: string; + } | null, + selectedOF?: string +): ProductionPieceStatus | undefined { + if (!productionData || !piece) return undefined; + + const pmark = String(piece.pieceMark || '').trim().toUpperCase(); + const cleanMark = String(piece.cleanMark || pmark).trim().toUpperCase(); + const assMark = String(piece.assemblyMark || '').trim().toUpperCase(); + const cleanAssMark = String(piece.cleanAssemblyMark || cleanMark).trim().toUpperCase(); + const elementPhase = piece.phase ? String(piece.phase).trim() : ''; + + const searchKeys = [ + elementPhase && selectedOF ? `${selectedOF}-${elementPhase}-${cleanAssMark}` : '', + elementPhase ? `${elementPhase}-${cleanAssMark}` : '', + elementPhase && selectedOF ? `${selectedOF}-${elementPhase}-${cleanMark}` : '', + elementPhase ? `${elementPhase}-${cleanMark}` : '', + selectedOF ? `${selectedOF}-${cleanAssMark}` : '', + selectedOF ? `${selectedOF}-${cleanMark}` : '', + assMark, + pmark, + cleanAssMark, + cleanMark, + ].filter(Boolean).map((k) => k.toUpperCase()); + + for (const key of searchKeys) { + const match = productionData.get(key); + if (match) { + if (!elementPhase || !match.fase || String(match.fase) === String(elementPhase)) { + return match; + } + } + } + + return undefined; +} + +/** + * Helper para formatar a Marca Principal da Peça no padrão de Engenharia (OF-Fase-Marca, ex: B138-1-59 / B138-2-10) + * e obter o Perfil/Bitola oficial a partir do banco de dados ou do modelo 3D. + */ +function resolvePieceEngineeringDetails( + rawPieceMark: string, + rawSection: string | undefined, + phase: string | undefined, + singlePartMark: string | undefined, + prod: ProductionPieceStatus | undefined, + selectedOF: string | undefined +): { + displayMark: string; + displaySection: string; +} { + const baseMark = prod?.marca || rawPieceMark || 'Peça'; + let displayMark = baseMark; + + // Se tivermos OF e Fase e a marca for pura (ex: "1" ou "8" ou "59"), formata como "B138-1-59" + if (selectedOF && phase && !baseMark.toUpperCase().startsWith(selectedOF.toUpperCase())) { + displayMark = `${selectedOF}-${phase}-${baseMark}`; + } + + // Perfil / Bitola oficial: prioriza banco de produção (TS_ERP.pecas.perfil_principal), seguido de section limpo do modelo + let displaySection = prod?.perfilPrincipal || ''; + if (!displaySection || displaySection === '1001' || displaySection === 'Perfil') { + if (rawSection && rawSection !== '1001' && rawSection !== 'Perfil') { + displaySection = rawSection; + } else { + displaySection = prod?.descricao || 'Perfil'; + } + } + + return { displayMark, displaySection }; } interface SmartModelViewer3DProps { @@ -46,6 +135,7 @@ interface SmartModelViewer3DProps { selectedOF: string; cliente?: string; phasesList: string[]; + initialRotation?: { x: number; y: number; z: number } | null; onVoltar: () => void; } @@ -65,6 +155,7 @@ export const SmartModelViewer3D: React.FC = ({ selectedOF, cliente, phasesList, + initialRotation, onVoltar, }) => { const containerRef = useRef(null); @@ -97,6 +188,11 @@ export const SmartModelViewer3D: React.FC = ({ // Modal de Lista da Seleção const [isSelectionModalOpen, setIsSelectionModalOpen] = useState(false); + // Controles de Rotação 90° e Trava com Cadeado (Memória de Posição) + const [rotationX, setRotationX] = useState(0); + const [rotationY, setRotationY] = useState(0); + const [isRotationLocked, setIsRotationLocked] = useState(false); + // Seleção e Toast Discreto interface SelectedElement { expressID: number; @@ -372,7 +468,7 @@ export const SmartModelViewer3D: React.FC = ({ controlsRef.current.update(); }, []); - // 3. Montar malhas do modelo IFC na cena Three.js + // 3. Montar malhas do modelo IFC/WRL na cena Three.js & Aplicar Rotação Memorizada useEffect(() => { if (!modelData || !sceneRef.current) return; @@ -384,11 +480,87 @@ export const SmartModelViewer3D: React.FC = ({ modelGroupRef.current = group; sceneRef.current.add(group); + // Recupera rotação memorizada para esta OF (localStorage ou banco de dados) + const storageKey = selectedOF ? `tracksteel_model_rot_${selectedOF}` : null; + let savedX = 0; + let savedY = 0; + let isLocked = false; + + if (storageKey) { + const stored = localStorage.getItem(storageKey); + if (stored) { + try { + const parsed = JSON.parse(stored); + if (typeof parsed.x === 'number') savedX = parsed.x; + if (typeof parsed.y === 'number') savedY = parsed.y; + isLocked = true; + } catch (e) {} + } + } + + if (!isLocked && initialRotation) { + if (typeof initialRotation.x === 'number') savedX = initialRotation.x; + if (typeof initialRotation.y === 'number') savedY = initialRotation.y; + isLocked = true; + } + + setRotationX(savedX); + setRotationY(savedY); + setIsRotationLocked(isLocked); + + // Aplica rotação no grupo do modelo Three.js (corrige modelos tombados) + group.rotation.set( + THREE.MathUtils.degToRad(savedX), + THREE.MathUtils.degToRad(savedY), + 0 + ); + group.updateMatrixWorld(true); + // Enquadra a câmera automaticamente após carregar o modelo setTimeout(() => { fitModelToView(); }, 150); - }, [modelData, fitModelToView]); + }, [modelData, selectedOF, initialRotation, fitModelToView]); + + // Gira o modelo em 90 graus no eixo X (tombamento vertical para alinhar Z-Up do CAD com Y-Up) + const handleRotate90X = useCallback(() => { + if (!modelGroupRef.current) return; + + const nextX = (rotationX + 90) % 360; + setRotationX(nextX); + setIsRotationLocked(false); + + modelGroupRef.current.rotation.x = THREE.MathUtils.degToRad(nextX); + modelGroupRef.current.rotation.y = THREE.MathUtils.degToRad(rotationY); + modelGroupRef.current.updateMatrixWorld(true); + + fitModelToView(); + smartAudio.playClick(); + }, [rotationX, rotationY, fitModelToView]); + + // Alterna o bloqueio/gravação da posição ideal de visualização no banco e no navegador + const handleToggleLockRotation = useCallback(async () => { + if (!selectedOF) return; + + if (!isRotationLocked) { + setIsRotationLocked(true); + const rotData = { x: rotationX, y: rotationY, z: 0 }; + localStorage.setItem(`tracksteel_model_rot_${selectedOF}`, JSON.stringify(rotData)); + + try { + await supabase + .from('ordens_fabricacao' as any) + .update({ model_3d_rotation: rotData }) + .eq('num_of', selectedOF); + } catch (err) { + console.error('Erro ao salvar rotação no banco:', err); + } + smartAudio.playSuccess(); + } else { + setIsRotationLocked(false); + smartAudio.playClick(); + } + }, [selectedOF, isRotationLocked, rotationX, rotationY]); // 4. Aplicação de Filtros (Fase e Processo) e Cores de Produção idêntico ao modo desktop useEffect(() => { @@ -655,16 +827,40 @@ export const SmartModelViewer3D: React.FC = ({ } }); - const pmark = hitMesh.userData.pieceMark; - const phase = hitMesh.userData.phase; - const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(targetIfcId)) || { + const rawPiece = modelData?.pieceByExpressID.get(targetIfcId); + const rawMark = hitMesh.userData.pieceMark || rawPiece?.pieceMark || ''; + const phase = hitMesh.userData.phase || rawPiece?.phase; + const singlePartMark = hitMesh.userData.singlePartMark || rawPiece?.singlePartMark; + + const prodInfo = findProductionDataForPiece(productionData, { + pieceMark: rawMark, + cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark, + assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark, + cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark, + phase, + }, selectedOF); + + const { displayMark, displaySection } = resolvePieceEngineeringDetails( + rawMark, + hitMesh.userData.section || rawPiece?.section, + phase, + singlePartMark, + prodInfo, + selectedOF + ); + + const piece: PieceInfo = { expressID: targetIfcId, - guid: '', - name: hitMesh.userData.section || 'Peça', - type: 'PIECE', - pieceMark: pmark, - section: hitMesh.userData.section, - phase: phase, + guid: hitMesh.userData.guid || rawPiece?.guid || '', + name: hitMesh.userData.name || rawPiece?.name || 'Peça', + type: hitMesh.userData.type || rawPiece?.type || 'PIECE', + pieceMark: displayMark, + cleanMark: hitMesh.userData.cleanMark || rawPiece?.cleanMark, + assemblyMark: hitMesh.userData.assemblyMark || rawPiece?.assemblyMark, + cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark || rawPiece?.cleanAssemblyMark, + section: displaySection, + phase, + singlePartMark, }; nextMap.set(targetIfcId, { @@ -675,22 +871,7 @@ export const SmartModelViewer3D: React.FC = ({ setSelectedElementsMap(nextMap); smartAudio.playSuccess(); - // Abre o Toast discreto ao lado do toque com matching enriquecido - let prodInfo: ProductionPieceStatus | undefined = undefined; - if (productionData && productionData.size > 0 && pmark) { - const searchKeys = [ - phase && selectedOF ? `${selectedOF}-${phase}-${pmark}` : '', - phase ? `${phase}-${pmark}` : '', - selectedOF ? `${selectedOF}-${pmark}` : '', - pmark, - pmark.toUpperCase(), - ].filter(Boolean); - for (const k of searchKeys) { - const m = productionData.get(k); - if (m) { prodInfo = m; break; } - } - } - + // Abre o Toast com dados de engenharia enriquecidos setToastInfo({ piece, position: { x: e.clientX, y: e.clientY }, @@ -718,26 +899,10 @@ export const SmartModelViewer3D: React.FC = ({ // Lista consolidada de peças físicas para o modal de seleção (Print 3) const selectedPiecesList: SelectedPieceItem[] = useMemo(() => { return Array.from(selectedElementsMap.values()).map(({ expressID, piece }) => { - const pmark = piece.pieceMark || 'Sem Marca'; - const phase = piece.phase; - let prod: ProductionPieceStatus | undefined = undefined; - if (productionData && productionData.size > 0 && pmark) { - const searchKeys = [ - phase && selectedOF ? `${selectedOF}-${phase}-${pmark}` : '', - phase ? `${phase}-${pmark}` : '', - selectedOF ? `${selectedOF}-${pmark}` : '', - pmark, - pmark.toUpperCase(), - ].filter(Boolean); - for (const k of searchKeys) { - const m = productionData.get(k); - if (m) { prod = m; break; } - } - } - + const prod = findProductionDataForPiece(productionData, piece, selectedOF); return { id: String(expressID), - pieceMark: pmark, + pieceMark: piece.pieceMark || 'Sem Marca', phase: piece.phase, section: piece.section, name: piece.name, @@ -775,13 +940,14 @@ export const SmartModelViewer3D: React.FC = ({ let left = toastInfo.position.x - rect.left + 14; let top = toastInfo.position.y - rect.top - 20; - if (left + 230 > rect.width) { - left = Math.max(12, left - 245); + if (left + 310 > rect.width) { + left = Math.max(12, left - 325); } - if (top + 160 > rect.height) { - top = Math.max(12, rect.height - 170); + if (top + 230 > rect.height) { + top = Math.max(12, rect.height - 240); } if (top < 12) top = 12; + if (left < 12) left = 12; return { left: `${left}px`, top: `${top}px` }; }, [toastInfo]); @@ -943,7 +1109,7 @@ export const SmartModelViewer3D: React.FC = ({ - {/* 2. Top Right: Cubo 3D Compacto + Card de Ações da Seleção (Print 3) */} + {/* 2. Top Right: Cubo 3D Compacto + Card de Giro 90° + Card de Ações da Seleção */}
{/* Cubo Compacto Discreto */} = ({ compact={true} /> + {/* Card de Ajuste e Travamento de Rotação 3D (Tombamento 90° + Cadeado Memorizável) */} +
+ {/* Botão Rotacionar / Tombar 90° */} + + + {/* Botão Cadeado: Travar / Gravar ou Destravar */} + +
+ {/* Card Flutuante de Seleção com "Gerar lista da seleção" (Print 3) */} {selectedCount > 0 && (
{/* Badge Indicador de Seleção Ativa */}
@@ -995,7 +1207,7 @@ export const SmartModelViewer3D: React.FC = ({ )}
- {/* 3. Toast Discreto ao Lado da Peça Clicada */} + {/* 3. Toast Discreto ao Lado da Peça Clicada (Réplica Idêntica do Modo Completo - Print 2) */} {toastInfo && (
{ @@ -1003,63 +1215,100 @@ export const SmartModelViewer3D: React.FC = ({ setToastInfo(null); }} style={toastStyle} - className="absolute z-40 p-3 rounded-2xl bg-white/95 dark:bg-slate-900/95 backdrop-blur-xl border border-fuchsia-500/50 shadow-2xl shadow-fuchsia-950/20 text-slate-800 dark: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)" + className="absolute z-40 p-4 rounded-2xl bg-slate-900/95 backdrop-blur-xl border border-cyan-500/40 shadow-2xl shadow-cyan-950/60 text-slate-100 min-w-[270px] max-w-[330px] animate-in fade-in zoom-in-95 duration-150 cursor-pointer pointer-events-auto select-none transition-all group" + title="Toque no card para fechar (a peça continua marcada)" > - {/* Cabeçalho do Toast */} -
-
-
- - {toastInfo.piece.pieceMark || 'Sem Marca'} - + {/* Cabeçalho do Toast com Ícone Box e Marca Principal de Engenharia */} +
+
+
+ +
+
+ + Marca Principal + + + {toastInfo.piece.pieceMark || 'Sem Marca'} + +
+
+
+ {toastInfo.piece.phase && ( + + Fase {toastInfo.piece.phase} + + )} +
-
- {/* Dados Rápidos da Peça */} -
- {toastInfo.piece.phase && ( -
- Fase: - Fase {toastInfo.piece.phase} -
- )} + {/* Detalhes Técnicos da Peça */} +
{toastInfo.piece.section && ( -
- Perfil: - +
+ Perfil / Bitola: + {toastInfo.piece.section}
)} - {toastInfo.prod && ( -
- Status: - - {toastInfo.prod.currentProcessName || 'Produção'} ({toastInfo.prod.pointedQtd}/{toastInfo.prod.totalQtd}) + + {toastInfo.piece.singlePartMark && toastInfo.piece.singlePartMark !== toastInfo.piece.pieceMark && ( +
+ Sub-peça / Corte: + + Pos. {toastInfo.piece.singlePartMark}
)} + + {/* Apontamentos e Etapa Atual */} + {toastInfo.prod && ( +
+
+ + + Apontamento: + + + {toastInfo.prod.pointedQtd ?? 0} de {toastInfo.prod.totalQtd ?? 1} peças + +
+ + {toastInfo.prod.currentProcessName && ( +
+ + + Etapa Atual: + + + {toastInfo.prod.currentProcessName} + +
+ )} +
+ )}
{/* Dica para Fechar */} -
+
Toque no card para fechar ✕
diff --git a/src/pages/smart/components/SmartViewer3DFlow.tsx b/src/pages/smart/components/SmartViewer3DFlow.tsx index d1f23e8..e463462 100644 --- a/src/pages/smart/components/SmartViewer3DFlow.tsx +++ b/src/pages/smart/components/SmartViewer3DFlow.tsx @@ -20,6 +20,7 @@ export const SmartViewer3DFlow: React.FC = ({ const [modelData, setModelData] = useState(null); const [phasesList, setPhasesList] = useState([]); const [productionMap, setProductionMap] = useState>(new Map()); + const [initialRotation, setInitialRotation] = useState<{ x: number; y: number; z: number } | null>(null); // Loading States const [isLoading, setIsLoading] = useState(true); @@ -35,10 +36,10 @@ export const SmartViewer3DFlow: React.FC = ({ setLoadingPercent(15); try { - // 1.1. Buscar registro da OF para obter URL do IFC + // 1.1. Buscar registro da OF para obter URL do IFC e rotação memorizada const { data: ofData, error: ofError } = await supabase .from('ordens_fabricacao' as any) - .select('id, num_of, descritivo, ifc_url, ifc_filename') + .select('id, num_of, descritivo, ifc_url, ifc_filename, model_3d_rotation') .eq('num_of', obra.of_number) .maybeSingle(); @@ -52,6 +53,9 @@ export const SmartViewer3DFlow: React.FC = ({ const ifcUrl = (ofData as any).ifc_url; const ifcFilename = (ofData as any).ifc_filename; + if ((ofData as any).model_3d_rotation) { + setInitialRotation((ofData as any).model_3d_rotation); + } // 1.2. Buscar Peças e Apontamentos em paralelo setLoadingStep('Carregando dados das peças e apontamentos...'); @@ -87,6 +91,8 @@ export const SmartViewer3DFlow: React.FC = ({ marca: marcaStr, fase, etapa_fase: fase, + descricao: p.descricao || '', + perfilPrincipal: p.perfil_principal || p.descricao || '', totalQtd: Number(p.quantidade || 1), pointedQtd: 0, currentProcessName: 'Pendente', @@ -277,6 +283,7 @@ export const SmartViewer3DFlow: React.FC = ({ selectedOF={obra.of_number} cliente={obra.cliente} phasesList={phasesList} + initialRotation={initialRotation} onVoltar={onVoltarHub} />