From fbdf7e379e6c4cb0e97f1c203da889f9437724dd Mon Sep 17 00:00:00 2001 From: admbrainsteel Date: Thu, 8 Oct 2026 10:45:17 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20Ferramenta=20de=20rota=C3=A7=C3=A3o=209?= =?UTF-8?q?0=C2=B0=20e=20trava=20de=20mem=C3=B3ria=20no=20Analisador=203D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/viewer3d/AnalyzerViewer3D.tsx | 230 ++++++++++++++++--- src/pages/Analisador3D.tsx | 6 +- 2 files changed, 203 insertions(+), 33 deletions(-) diff --git a/src/components/viewer3d/AnalyzerViewer3D.tsx b/src/components/viewer3d/AnalyzerViewer3D.tsx index a7232e7..7503f6d 100644 --- a/src/components/viewer3d/AnalyzerViewer3D.tsx +++ b/src/components/viewer3d/AnalyzerViewer3D.tsx @@ -6,6 +6,9 @@ import { DefectItem, ColorizationMode } from '@/types/analisador3d'; import { ViewCube } from './ViewCube'; import { RotateCcw, + RotateCw, + Lock, + Unlock, Eye, Maximize2, Minimize2, @@ -19,6 +22,8 @@ import { HelpCircle, Cpu } from 'lucide-react'; +import { supabase } from '@/integrations/supabase/client'; +import { useToast } from '@/hooks/use-toast'; interface AnalyzerViewer3DProps { modelData: LoadedIFCResult | null; @@ -33,6 +38,8 @@ interface AnalyzerViewer3DProps { isFullscreen?: boolean; onToggleFullscreen?: () => void; theme?: 'dark' | 'light'; + selectedOF?: string; + initialRotation?: { x: number; y: number; z: number } | null; } export const AnalyzerViewer3D: React.FC = ({ @@ -48,7 +55,13 @@ export const AnalyzerViewer3D: React.FC = ({ isFullscreen = false, onToggleFullscreen, theme = 'dark', + selectedOF, + initialRotation, }) => { + const { toast } = useToast(); + const [rotationX, setRotationX] = useState(0); + const [rotationY, setRotationY] = useState(0); + const [isRotationLocked, setIsRotationLocked] = useState(false); const containerRef = useRef(null); const canvasContainerRef = useRef(null); const rendererRef = useRef(null); @@ -192,7 +205,41 @@ export const AnalyzerViewer3D: React.FC = ({ }; }, [theme]); - // 2. Carrega o modelo na cena diretamente sem deep clone circular + // Centraliza e enquadra perfeitamente o modelo na câmera (Fit View) + const fitModelToView = useCallback((targetObj?: THREE.Object3D) => { + const obj = targetObj || modelGroupRef.current; + if (!obj || !cameraRef.current || !controlsRef.current) return; + + obj.updateMatrixWorld(true); + const box = new THREE.Box3().setFromObject(obj); + if (box.isEmpty()) return; + + const center = box.getCenter(new THREE.Vector3()); + const size = box.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z); + if (maxDim <= 0) return; + + const fov = 45 * (Math.PI / 180); + let cameraDistance = Math.abs(maxDim / (2 * Math.tan(fov / 2))) * 1.6; + cameraDistance = Math.max(cameraDistance, 5); + + const camera = cameraRef.current; + camera.near = Math.max(0.01, maxDim / 500); + camera.far = Math.max(2000, maxDim * 20); + camera.updateProjectionMatrix(); + camera.position.set(center.x + cameraDistance * 0.7, center.y + cameraDistance * 0.5, center.z + cameraDistance * 0.7); + camera.lookAt(center); + + controlsRef.current.target.copy(center); + controlsRef.current.maxDistance = Math.max(1500, maxDim * 10); + controlsRef.current.update(); + + if (gridRef.current) { + gridRef.current.position.set(center.x, box.min.y, center.z); + } + }, []); + + // 2. Carrega o modelo na cena diretamente sem deep clone circular e aplica rotação useEffect(() => { const scene = sceneRef.current; if (!scene) return; @@ -209,32 +256,101 @@ export const AnalyzerViewer3D: React.FC = ({ 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); + // 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; - // 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(); + 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) {} } } - }, [modelData]); + + 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 inicial no grupo do modelo + group.rotation.set( + THREE.MathUtils.degToRad(savedX), + THREE.MathUtils.degToRad(savedY), + 0 + ); + group.updateMatrixWorld(true); + + fitModelToView(group); + }, [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(modelGroupRef.current); + }, [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) { + toast({ + title: 'Aviso', + description: 'Selecione uma OF para memorizar a rotação do modelo.', + }); + return; + } + + if (!isRotationLocked) { + // Trava e grava a rotação + setIsRotationLocked(true); + const rotData = { x: rotationX, y: rotationY, z: 0 }; + + // 1. Grava no localStorage para acesso instantâneo + localStorage.setItem(`tracksteel_model_rot_${selectedOF}`, JSON.stringify(rotData)); + + // 2. Persiste na nuvem/Supabase na tabela ordens_fabricacao + 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); + } + + toast({ + title: 'Posição Travada e Salva', + description: `Posição ideal de visualização (${rotationX}°) gravada com sucesso para a OF ${selectedOF}.`, + }); + } else { + // Destrava para permitir novo ajuste + setIsRotationLocked(false); + toast({ + title: 'Posição Destravada', + description: 'Clique em "Girar 90°" para ajustar e trave no cadeado novamente quando estiver na posição correta.', + }); + } + }, [selectedOF, isRotationLocked, rotationX, rotationY, toast]); // 3. Aplica Colorização Dinâmica com base no modo selecionado e na peça/defeito ativo useEffect(() => { @@ -423,13 +539,63 @@ export const AnalyzerViewer3D: React.FC = ({ onPointerDown={handlePointerDown} /> - {/* ViewCube Superior Direito */} - {cameraRef.current && controlsRef.current && ( - } - /> - )} + {/* Top Right: ViewCube + Widget de Rotação 90° & Cadeado de Memória */} +
+ {cameraRef.current && controlsRef.current && ( + } + /> + )} + + {/* 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 */} + +
+
{/* Barra de Ferramentas Flutuante Superior */}
@@ -538,7 +704,7 @@ export const AnalyzerViewer3D: React.FC = ({ {/* Alerta de elemento selecionado flutuante */} {selectedPieceMark && ( -
+
Foco Ativo: {selectedPieceMark} diff --git a/src/pages/Analisador3D.tsx b/src/pages/Analisador3D.tsx index 703e4fc..2bb2199 100644 --- a/src/pages/Analisador3D.tsx +++ b/src/pages/Analisador3D.tsx @@ -42,6 +42,7 @@ interface OFOption { peso_total?: number; ifc_url?: string | null; ifc_filename?: string | null; + model_3d_rotation?: { x: number; y: number; z: number } | null; } export default function Analisador3D() { @@ -103,7 +104,7 @@ export default function Analisador3D() { try { const { data, error } = await supabase .from('ordens_fabricacao' as any) - .select('id, num_of, descritivo, peso_total, ifc_url, ifc_filename') + .select('id, num_of, descritivo, peso_total, ifc_url, ifc_filename, model_3d_rotation') .order('num_of', { ascending: false }); if (!error && data) { @@ -114,6 +115,7 @@ export default function Analisador3D() { peso_total: Number(item.peso_total || 0), ifc_url: item.ifc_url || null, ifc_filename: item.ifc_filename || null, + model_3d_rotation: item.model_3d_rotation || null, })); setOfs(list); if (list.length > 0 && !selectedOF) { @@ -797,6 +799,8 @@ export default function Analisador3D() { isFullscreen={isFullscreen} onToggleFullscreen={() => setIsFullscreen(!isFullscreen)} theme={isDarkTheme ? 'dark' : 'light'} + selectedOF={selectedOF} + initialRotation={currentOFObj?.model_3d_rotation || null} />