feat: adicionar visualizador 3d touch-first no modo smart com filtros e toast discreto
This commit is contained in:
@@ -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<string, ProductionPieceStatus>;
|
||||||
|
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<SmartModelViewer3DProps> = ({
|
||||||
|
modelData,
|
||||||
|
productionData,
|
||||||
|
selectedOF,
|
||||||
|
cliente,
|
||||||
|
phasesList,
|
||||||
|
onVoltar,
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const canvasContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||||
|
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||||
|
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||||
|
const controlsRef = useRef<OrbitControls | null>(null);
|
||||||
|
const modelGroupRef = useRef<THREE.Group | null>(null);
|
||||||
|
const raycasterRef = useRef<THREE.Raycaster>(new THREE.Raycaster());
|
||||||
|
|
||||||
|
// Filtros principais solicitados: Fase e Processo
|
||||||
|
const [selectedPhase, setSelectedPhase] = useState<string>('all');
|
||||||
|
const [selectedProcess, setSelectedProcess] = useState<string>('all');
|
||||||
|
|
||||||
|
// Seleção e Toast Discreto
|
||||||
|
interface SelectedElement {
|
||||||
|
expressID: number;
|
||||||
|
piece: PieceInfo;
|
||||||
|
meshes: THREE.Mesh[];
|
||||||
|
}
|
||||||
|
const [selectedElementsMap, setSelectedElementsMap] = useState<Map<number, SelectedElement>>(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<THREE.MeshStandardMaterial>(
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
pointerDownRef.current = {
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY,
|
||||||
|
time: Date.now(),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
onPointerUp={handlePointerUp}
|
||||||
|
className="relative w-full h-full flex-1 bg-slate-950 text-slate-100 select-none overflow-hidden touch-none"
|
||||||
|
>
|
||||||
|
{/* Canvas Three.js */}
|
||||||
|
<div ref={canvasContainerRef} className="absolute inset-0 w-full h-full z-0 cursor-grab active:cursor-grabbing" />
|
||||||
|
|
||||||
|
{/* 1. Header Minimalista Flutuante (Touch-First) */}
|
||||||
|
<div className="absolute top-2.5 left-2.5 right-2.5 z-30 flex items-center justify-between gap-2 pointer-events-none">
|
||||||
|
{/* Lado Esquerdo: Botão Voltar + Info da Obra */}
|
||||||
|
<div className="flex items-center gap-2 pointer-events-auto">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
onVoltar();
|
||||||
|
}}
|
||||||
|
className="h-10 px-3 rounded-xl bg-slate-900/90 hover:bg-slate-800 text-slate-100 border border-slate-700/80 shadow-md backdrop-blur-md flex items-center gap-1.5 text-xs font-bold transition-all active:scale-95"
|
||||||
|
title="Voltar ao Menu Smart"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span className="hidden sm:inline">Voltar</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="px-3 py-1.5 rounded-xl bg-slate-900/85 backdrop-blur-md border border-slate-800 shadow-md flex flex-col justify-center">
|
||||||
|
<span className="text-[9px] font-black uppercase tracking-wider text-cyan-400">
|
||||||
|
OF {selectedOF}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-bold text-slate-200 truncate max-w-[120px] sm:max-w-[200px]">
|
||||||
|
{cliente || 'Obra Industrial'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lado Direito: Filtros Discretos (Fase & Processo) + Enquadrar */}
|
||||||
|
<div className="flex items-center gap-1.5 pointer-events-auto">
|
||||||
|
{/* Filtro por Fase */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={selectedPhase}
|
||||||
|
onChange={(e) => setSelectedPhase(e.target.value)}
|
||||||
|
className="h-10 pl-7 pr-3 rounded-xl bg-slate-900/90 text-slate-200 border border-slate-700/80 text-xs font-bold backdrop-blur-md shadow-md appearance-none cursor-pointer focus:outline-none focus:ring-1 focus:ring-cyan-500"
|
||||||
|
title="Filtrar por Fase da Obra"
|
||||||
|
>
|
||||||
|
<option value="all">Todas as Fases</option>
|
||||||
|
{phasesList.map((fase) => (
|
||||||
|
<option key={fase} value={fase}>
|
||||||
|
Fase {fase}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Layers className="w-3.5 h-3.5 text-cyan-400 absolute left-2.5 top-3.5 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filtro por Processo */}
|
||||||
|
<div className="relative">
|
||||||
|
<select
|
||||||
|
value={selectedProcess}
|
||||||
|
onChange={(e) => setSelectedProcess(e.target.value)}
|
||||||
|
className="h-10 pl-7 pr-3 rounded-xl bg-slate-900/90 text-slate-200 border border-slate-700/80 text-xs font-bold backdrop-blur-md shadow-md appearance-none cursor-pointer focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||||
|
title="Filtrar por Etapa Fabril"
|
||||||
|
>
|
||||||
|
{PROCESS_LIST.map((proc) => (
|
||||||
|
<option key={proc.id} value={proc.id}>
|
||||||
|
{proc.nome}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Activity className="w-3.5 h-3.5 text-emerald-400 absolute left-2.5 top-3.5 pointer-events-none" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Botão Enquadrar (Fit View) */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={fitModelToView}
|
||||||
|
className="h-10 w-10 rounded-xl bg-slate-900/90 hover:bg-slate-800 text-slate-200 border border-slate-700/80 shadow-md backdrop-blur-md flex items-center justify-center transition-all active:scale-95"
|
||||||
|
title="Enquadrar Modelo Centralizado"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-4 h-4 text-cyan-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Top Right: Cubo 3D Compacto + Botão Limpar Seleção */}
|
||||||
|
<div className="absolute top-16 right-3 z-20 flex flex-col items-end gap-2 pointer-events-auto">
|
||||||
|
{/* Cubo Compacto Discreto */}
|
||||||
|
<ViewCube
|
||||||
|
onSelectView={handleViewCubeSelect}
|
||||||
|
mainCameraRef={cameraRef}
|
||||||
|
size={56}
|
||||||
|
compact={true}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Botão Limpar Seleção (Apenas quando houver peças marcadas) */}
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClearSelection}
|
||||||
|
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-xl bg-slate-900/95 hover:bg-rose-950/80 text-rose-300 border border-rose-500/40 shadow-lg backdrop-blur-md text-[11px] font-bold animate-in fade-in zoom-in-95 duration-150 active:scale-95 transition-all"
|
||||||
|
title="Desmarcar todas as peças"
|
||||||
|
>
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-fuchsia-400 animate-pulse" />
|
||||||
|
<span>{selectedCount} {selectedCount === 1 ? 'marcada' : 'marcadas'}</span>
|
||||||
|
<span className="text-slate-500">|</span>
|
||||||
|
<span className="text-rose-400 hover:text-white">Limpar ✕</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Toast Discreto ao Lado da Peça Clicada */}
|
||||||
|
{toastInfo && (
|
||||||
|
<div
|
||||||
|
onClick={() => {
|
||||||
|
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 */}
|
||||||
|
<div className="flex items-center justify-between gap-1.5 pb-1.5 mb-1.5 border-b border-slate-800">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<div className="w-2 h-2 rounded-full bg-fuchsia-500 shadow-sm" />
|
||||||
|
<span className="text-xs font-black text-fuchsia-300 font-mono tracking-tight truncate">
|
||||||
|
{toastInfo.piece.pieceMark || 'Sem Marca'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setToastInfo(null);
|
||||||
|
}}
|
||||||
|
className="p-1 rounded-lg text-slate-400 hover:text-white hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dados Rápidos da Peça */}
|
||||||
|
<div className="space-y-1 text-[11px]">
|
||||||
|
{toastInfo.piece.phase && (
|
||||||
|
<div className="flex items-center justify-between text-slate-300">
|
||||||
|
<span className="text-slate-400">Fase:</span>
|
||||||
|
<span className="font-bold text-slate-200">Fase {toastInfo.piece.phase}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toastInfo.piece.section && (
|
||||||
|
<div className="flex items-center justify-between text-slate-300">
|
||||||
|
<span className="text-slate-400">Perfil:</span>
|
||||||
|
<span className="font-mono font-semibold text-slate-100 truncate max-w-[120px]">
|
||||||
|
{toastInfo.piece.section}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{toastInfo.prod && (
|
||||||
|
<div className="pt-1 mt-1 border-t border-slate-800/80 flex items-center justify-between">
|
||||||
|
<span className="text-slate-400">Status:</span>
|
||||||
|
<span
|
||||||
|
className="font-bold px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wider"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${toastInfo.prod.processColor || '#10b981'}25`,
|
||||||
|
color: toastInfo.prod.processColor || '#10b981',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{toastInfo.prod.currentProcessName || 'Produção'} ({toastInfo.prod.pointedQtd}/{toastInfo.prod.totalQtd})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dica para Fechar */}
|
||||||
|
<div className="mt-2 pt-1 border-t border-slate-800 text-[9px] text-center text-slate-400 font-medium">
|
||||||
|
Toque no card para fechar ✕
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -5,9 +5,16 @@ import { RotateCcw } from 'lucide-react';
|
|||||||
interface ViewCubeProps {
|
interface ViewCubeProps {
|
||||||
onSelectView: (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso') => void;
|
onSelectView: (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso') => void;
|
||||||
mainCameraRef?: React.MutableRefObject<THREE.Camera | null>;
|
mainCameraRef?: React.MutableRefObject<THREE.Camera | null>;
|
||||||
|
size?: number;
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ViewCube: React.FC<ViewCubeProps> = ({ onSelectView, mainCameraRef }) => {
|
export const ViewCube: React.FC<ViewCubeProps> = ({
|
||||||
|
onSelectView,
|
||||||
|
mainCameraRef,
|
||||||
|
size = 84,
|
||||||
|
compact = false,
|
||||||
|
}) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||||
const sceneRef = useRef<THREE.Scene | null>(null);
|
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||||
@@ -20,8 +27,8 @@ export const ViewCube: React.FC<ViewCubeProps> = ({ onSelectView, mainCameraRef
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
|
||||||
const width = 84;
|
const width = size;
|
||||||
const height = 84;
|
const height = size;
|
||||||
|
|
||||||
// 1. Scene & Camera
|
// 1. Scene & Camera
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
@@ -202,13 +209,20 @@ export const ViewCube: React.FC<ViewCubeProps> = ({ onSelectView, mainCameraRef
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative select-none flex flex-col items-center">
|
<div className="relative select-none flex flex-col items-center">
|
||||||
{/* Container Reduzido (~70%) Harmonizado para Modo Claro e Escuro */}
|
{/* Container Reduzido Harmonizado para Modo Claro e Escuro */}
|
||||||
<div className="relative w-24 h-24 p-1 rounded-2xl bg-white/90 dark:bg-slate-950/85 backdrop-blur-xl border border-slate-200 dark:border-cyan-500/40 shadow-lg dark:shadow-xl dark:shadow-cyan-950/60 flex items-center justify-center cursor-pointer group transition-all">
|
<div
|
||||||
|
className={`relative p-1 rounded-2xl bg-white/90 dark:bg-slate-950/85 backdrop-blur-xl border border-slate-200 dark:border-cyan-500/40 shadow-lg dark:shadow-xl dark:shadow-cyan-950/60 flex items-center justify-center cursor-pointer group transition-all`}
|
||||||
|
style={{ width: `${size + 12}px`, height: `${size + 12}px` }}
|
||||||
|
>
|
||||||
{/* Glow sutil harmonizado */}
|
{/* Glow sutil harmonizado */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-tr from-cyan-500/5 dark:from-cyan-500/10 via-transparent to-blue-500/5 dark:to-blue-500/10 rounded-2xl pointer-events-none" />
|
<div className="absolute inset-0 bg-gradient-to-tr from-cyan-500/5 dark:from-cyan-500/10 via-transparent to-blue-500/5 dark:to-blue-500/10 rounded-2xl pointer-events-none" />
|
||||||
|
|
||||||
{/* Canvas WebGL 3D do Cubo Sincronizado */}
|
{/* Canvas WebGL 3D do Cubo Sincronizado */}
|
||||||
<div ref={containerRef} className="w-[84px] h-[84px] flex items-center justify-center" />
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
style={{ width: `${size}px`, height: `${size}px` }}
|
||||||
|
className="flex items-center justify-center"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Botão ISO no canto inferior harmonizado com o tema */}
|
{/* Botão ISO no canto inferior harmonizado com o tema */}
|
||||||
<button
|
<button
|
||||||
@@ -216,18 +230,20 @@ export const ViewCube: React.FC<ViewCubeProps> = ({ onSelectView, mainCameraRef
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onSelectView('iso');
|
onSelectView('iso');
|
||||||
}}
|
}}
|
||||||
className="absolute bottom-1 right-1 px-1 py-0.5 rounded bg-slate-100 hover:bg-cyan-600 text-slate-700 hover:text-white border border-slate-300 hover:border-cyan-500 dark:bg-slate-900/90 dark:text-cyan-300 dark:border-cyan-500/30 transition-all text-[9px] font-mono font-bold flex items-center gap-0.5 shadow-sm cursor-pointer"
|
className="absolute bottom-0.5 right-0.5 px-1 py-0.5 rounded bg-slate-100 hover:bg-cyan-600 text-slate-700 hover:text-white border border-slate-300 hover:border-cyan-500 dark:bg-slate-900/90 dark:text-cyan-300 dark:border-cyan-500/30 transition-all text-[8px] font-mono font-bold flex items-center gap-0.5 shadow-sm cursor-pointer"
|
||||||
title="Restaurar Vista Isométrica 3D"
|
title="Restaurar Vista Isométrica 3D"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-2.5 h-2.5" />
|
<RotateCcw className="w-2 h-2" />
|
||||||
<span>ISO</span>
|
<span>ISO</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rótulo inferior */}
|
{/* Rótulo inferior opcional */}
|
||||||
|
{!compact && (
|
||||||
<span className="text-[8px] font-mono font-bold tracking-widest uppercase text-cyan-800 dark:text-cyan-400 mt-0.5">
|
<span className="text-[8px] font-mono font-bold tracking-widest uppercase text-cyan-800 dark:text-cyan-400 mt-0.5">
|
||||||
Cubo 3D
|
Cubo 3D
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+12
-3
@@ -6,6 +6,7 @@ import { SmartProducaoFlow } from './smart/components/SmartProducaoFlow';
|
|||||||
import { SmartEmbarqueFlow } from './smart/components/SmartEmbarqueFlow';
|
import { SmartEmbarqueFlow } from './smart/components/SmartEmbarqueFlow';
|
||||||
import { SmartMontagemFlow } from './smart/components/SmartMontagemFlow';
|
import { SmartMontagemFlow } from './smart/components/SmartMontagemFlow';
|
||||||
import { SmartConsultaRelatoriosFlow } from './smart/components/SmartConsultaRelatoriosFlow';
|
import { SmartConsultaRelatoriosFlow } from './smart/components/SmartConsultaRelatoriosFlow';
|
||||||
|
import { SmartViewer3DFlow } from './smart/components/SmartViewer3DFlow';
|
||||||
import { OFAtiva } from '@/hooks/useOFsAtivas';
|
import { OFAtiva } from '@/hooks/useOFsAtivas';
|
||||||
import { useAuth } from '@/hooks/useAuth';
|
import { useAuth } from '@/hooks/useAuth';
|
||||||
import { supabase } from '@/integrations/supabase/client';
|
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<
|
const [telaAtiva, setTelaAtiva] = useState<
|
||||||
'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf'
|
'hub' | 'producao' | 'embarque' | 'montagem' | 'consulta' | 'turno_pdf' | 'modelo3d'
|
||||||
>('hub');
|
>('hub');
|
||||||
|
|
||||||
// Produção do operador hoje
|
// Produção do operador hoje
|
||||||
@@ -113,6 +114,7 @@ export const ModoSmart: React.FC = () => {
|
|||||||
montagem: '3. Apontar Montagem',
|
montagem: '3. Apontar Montagem',
|
||||||
consulta: '4. Consultar Peças',
|
consulta: '4. Consultar Peças',
|
||||||
turno_pdf: 'Relatório do Turno',
|
turno_pdf: 'Relatório do Turno',
|
||||||
|
modelo3d: 'Modelo 3D da Obra',
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -126,13 +128,15 @@ export const ModoSmart: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-100/90 text-slate-800 dark:bg-slate-950 dark:text-slate-100 flex flex-col font-sans select-none antialiased transition-colors duration-200">
|
<div className="min-h-screen bg-slate-100/90 text-slate-800 dark:bg-slate-950 dark:text-slate-100 flex flex-col font-sans select-none antialiased transition-colors duration-200">
|
||||||
{/* Cabeçalho Fixo do Modo Smart */}
|
{/* Cabeçalho Fixo do Modo Smart (oculto quando em tela cheia do modelo 3D) */}
|
||||||
|
{telaAtiva !== 'modelo3d' && (
|
||||||
<SmartHeader
|
<SmartHeader
|
||||||
titulo={headerInfo.titulo}
|
titulo={headerInfo.titulo}
|
||||||
subtitulo={headerInfo.subtitulo}
|
subtitulo={headerInfo.subtitulo}
|
||||||
mostrarVoltar={headerInfo.mostrarVoltar}
|
mostrarVoltar={headerInfo.mostrarVoltar}
|
||||||
onVoltar={() => setTelaAtiva('hub')}
|
onVoltar={() => setTelaAtiva('hub')}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Conteúdo Principal Touch-First */}
|
{/* Conteúdo Principal Touch-First */}
|
||||||
<main className="flex-1 flex flex-col overflow-y-auto">
|
<main className="flex-1 flex flex-col overflow-y-auto">
|
||||||
@@ -173,6 +177,11 @@ export const ModoSmart: React.FC = () => {
|
|||||||
onVoltarHub={() => setTelaAtiva('hub')}
|
onVoltarHub={() => setTelaAtiva('hub')}
|
||||||
abrirDiretoTurno={true}
|
abrirDiretoTurno={true}
|
||||||
/>
|
/>
|
||||||
|
) : telaAtiva === 'modelo3d' ? (
|
||||||
|
<SmartViewer3DFlow
|
||||||
|
obra={obraAtiva}
|
||||||
|
onVoltarHub={() => setTelaAtiva('hub')}
|
||||||
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import {
|
|||||||
ArrowLeftRight,
|
ArrowLeftRight,
|
||||||
FileText,
|
FileText,
|
||||||
TrendingUp,
|
TrendingUp,
|
||||||
Sparkles
|
Sparkles,
|
||||||
|
Box
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { OFAtiva } from '@/hooks/useOFsAtivas';
|
import { OFAtiva } from '@/hooks/useOFsAtivas';
|
||||||
import { smartAudio } from '@/utils/smartAudio';
|
import { smartAudio } from '@/utils/smartAudio';
|
||||||
@@ -16,7 +17,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
interface SmartHubProps {
|
interface SmartHubProps {
|
||||||
obra: OFAtiva;
|
obra: OFAtiva;
|
||||||
onTrocarObra: () => void;
|
onTrocarObra: () => void;
|
||||||
onNavigate: (destino: 'producao' | 'embarque' | 'montagem' | 'consulta') => void;
|
onNavigate: (destino: 'producao' | 'embarque' | 'montagem' | 'consulta' | 'modelo3d') => void;
|
||||||
producaoHoje: { totalPecas: number; totalKg: number };
|
producaoHoje: { totalPecas: number; totalKg: number };
|
||||||
onGerarRelatorioTurno: () => void;
|
onGerarRelatorioTurno: () => void;
|
||||||
}
|
}
|
||||||
@@ -28,7 +29,7 @@ export const SmartHub: React.FC<SmartHubProps> = ({
|
|||||||
producaoHoje,
|
producaoHoje,
|
||||||
onGerarRelatorioTurno,
|
onGerarRelatorioTurno,
|
||||||
}) => {
|
}) => {
|
||||||
const handleAcao = (destino: 'producao' | 'embarque' | 'montagem' | 'consulta') => {
|
const handleAcao = (destino: 'producao' | 'embarque' | 'montagem' | 'consulta' | 'modelo3d') => {
|
||||||
smartAudio.playClick();
|
smartAudio.playClick();
|
||||||
onNavigate(destino);
|
onNavigate(destino);
|
||||||
};
|
};
|
||||||
@@ -45,6 +46,18 @@ export const SmartHub: React.FC<SmartHubProps> = ({
|
|||||||
OF {obra.of_number} — {obra.cliente || 'Obra Industrial'}
|
OF {obra.of_number} — {obra.cliente || 'Obra Industrial'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleAcao('modelo3d')}
|
||||||
|
className="h-10 px-3 border-cyan-400 dark:border-cyan-500/50 bg-cyan-50 hover:bg-cyan-100 dark:bg-cyan-950/40 dark:hover:bg-cyan-900/50 text-cyan-800 dark:text-cyan-200 rounded-xl text-xs font-black shrink-0 active:scale-95 gap-1.5 shadow-sm transition-all"
|
||||||
|
title="Abrir Visualizador 3D do modelo IFC desta obra"
|
||||||
|
>
|
||||||
|
<Box className="h-4 w-4 text-cyan-600 dark:text-cyan-400 stroke-[2.2]" />
|
||||||
|
<span>Modelo 3D</span>
|
||||||
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -58,6 +71,7 @@ export const SmartHub: React.FC<SmartHubProps> = ({
|
|||||||
<span>Trocar</span>
|
<span>Trocar</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Grade dos 4 Botões Principais Gigantes */}
|
{/* Grade dos 4 Botões Principais Gigantes */}
|
||||||
<div className="grid grid-cols-1 xs:grid-cols-2 gap-3.5 my-auto py-2">
|
<div className="grid grid-cols-1 xs:grid-cols-2 gap-3.5 my-auto py-2">
|
||||||
|
|||||||
@@ -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<SmartViewer3DFlowProps> = ({
|
||||||
|
obra,
|
||||||
|
onVoltarHub,
|
||||||
|
}) => {
|
||||||
|
const [modelData, setModelData] = useState<LoadedIFCResult | null>(null);
|
||||||
|
const [phasesList, setPhasesList] = useState<string[]>([]);
|
||||||
|
const [productionMap, setProductionMap] = useState<Map<string, ProductionPieceStatus>>(new Map());
|
||||||
|
|
||||||
|
// Loading States
|
||||||
|
const [isLoading, setIsLoading] = useState<boolean>(true);
|
||||||
|
const [loadingStep, setLoadingStep] = useState<string>('Buscando modelo da obra...');
|
||||||
|
const [loadingPercent, setLoadingPercent] = useState<number>(10);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(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<string, ProductionPieceStatus>();
|
||||||
|
const phasesSet = new Set<string>();
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center p-6 bg-slate-950 text-slate-100 min-h-[500px]">
|
||||||
|
<div className="p-4 rounded-3xl bg-slate-900 border border-slate-800 shadow-2xl mb-6">
|
||||||
|
<Loader2 className="w-10 h-10 text-cyan-400 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center max-w-sm w-full space-y-3">
|
||||||
|
<span className="text-[11px] font-black uppercase tracking-wider text-cyan-400">
|
||||||
|
OF {obra.of_number} — Visualizador 3D
|
||||||
|
</span>
|
||||||
|
<div className="text-sm font-bold text-slate-200">{loadingStep}</div>
|
||||||
|
<div className="w-full bg-slate-800 h-2.5 rounded-full overflow-hidden border border-slate-700">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-cyan-500 to-blue-500 h-full transition-all duration-300 rounded-full"
|
||||||
|
style={{ width: `${loadingPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono text-cyan-400 font-bold">{loadingPercent}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Se houver erro ou a OF não tiver IFC
|
||||||
|
if (errorMessage || !modelData) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center p-6 bg-slate-950 text-slate-100 min-h-[500px]">
|
||||||
|
<div className="p-5 rounded-3xl bg-slate-900 border border-slate-800 shadow-xl mb-4 text-amber-400">
|
||||||
|
<AlertCircle className="w-12 h-12" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-base font-bold text-slate-100 text-center mb-1">
|
||||||
|
Modelo 3D Indisponível
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 text-center max-w-sm leading-relaxed mb-6">
|
||||||
|
{errorMessage || `A OF ${obra.of_number} ainda não possui arquivo IFC cadastrado.`}
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
smartAudio.playClick();
|
||||||
|
onVoltarHub();
|
||||||
|
}}
|
||||||
|
className="h-11 px-5 rounded-xl bg-slate-800 hover:bg-slate-700 text-slate-100 border border-slate-700 font-bold text-xs gap-2 active:scale-95 transition-all"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4 text-cyan-400" />
|
||||||
|
<span>Voltar ao Menu</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Renderiza o Visualizador 3D Touch-First em Tela Cheia
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col bg-slate-950">
|
||||||
|
<SmartModelViewer3D
|
||||||
|
modelData={modelData}
|
||||||
|
productionData={productionMap}
|
||||||
|
selectedOF={obra.of_number}
|
||||||
|
cliente={obra.cliente}
|
||||||
|
phasesList={phasesList}
|
||||||
|
onVoltar={onVoltarHub}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user