diff --git a/package-lock.json b/package-lock.json index 062e42a..8819c83 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@supabase/supabase-js": "^2.98.0", "@tanstack/react-query": "^5.90.21", + "@types/three": "^0.186.0", "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -55,6 +56,9 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", + "three": "^0.186.0", + "three-stdlib": "^2.36.1", + "web-ifc": "^0.0.77", "xlsx": "^0.18.5", "zod": "^3.25.76" }, @@ -341,6 +345,12 @@ "node": ">=6.9.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -3360,6 +3370,12 @@ "react": "^18 || ^19" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -3457,6 +3473,12 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "license": "MIT" + }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -3497,6 +3519,12 @@ "undici-types": "~7.18.0" } }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "license": "MIT" + }, "node_modules/@types/pako": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", @@ -3557,6 +3585,32 @@ "redux": "^4.0.0" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.186.0.tgz", + "integrity": "sha512-mxYSBpDC+D0pLfSP6sW4WZTcT+nrtmZcimMqnVmy36Hte3XpeYSrvgg4TRdaM1GemGog1AWzI5qL2VoIfMXbJQ==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.3", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/three/node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -3570,6 +3624,12 @@ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -4663,6 +4723,12 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "license": "Apache-2.0" + }, "node_modules/electron-to-chromium": { "version": "1.5.307", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", @@ -5575,6 +5641,12 @@ "node": ">= 8" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -5992,6 +6064,12 @@ "dev": true, "license": "MIT" }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6746,6 +6824,35 @@ "node": ">=0.8" } }, + "node_modules/three": { + "version": "0.186.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.186.0.tgz", + "integrity": "sha512-cr/fIM2ddMSVbYVgkfD4jLJv7Fh/8ZTjvo+7gQeSVGUZHxpx9FDwoL5iC7hUz/LiRA8wMbqfnb90xKfm1/HHkQ==", + "license": "MIT" + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.11", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.11.tgz", + "integrity": "sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==", + "license": "MIT" + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -7080,6 +7187,12 @@ } } }, + "node_modules/web-ifc": { + "version": "0.0.77", + "resolved": "https://registry.npmjs.org/web-ifc/-/web-ifc-0.0.77.tgz", + "integrity": "sha512-VzQ0W/Iiqbidxn1ECUvz6qJ6p2sXBVNcOsUOBCETzy77psAH6yFLKQm74aXabkx3JH4OvFVHe8k1qS6+Z2zl1w==", + "license": "MPL-2.0" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index c56b036..6099740 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@supabase/supabase-js": "^2.98.0", "@tanstack/react-query": "^5.90.21", + "@types/three": "^0.186.0", "@xyflow/react": "^12.10.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -58,6 +59,9 @@ "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tailwindcss-animate": "^1.0.7", + "three": "^0.186.0", + "three-stdlib": "^2.36.1", + "web-ifc": "^0.0.77", "xlsx": "^0.18.5", "zod": "^3.25.76" }, diff --git a/public/wasm/web-ifc.wasm b/public/wasm/web-ifc.wasm new file mode 100755 index 0000000..fa38fef Binary files /dev/null and b/public/wasm/web-ifc.wasm differ diff --git a/src/components/sidebar/menuConfig.ts b/src/components/sidebar/menuConfig.ts index 7aaeb70..6238f07 100644 --- a/src/components/sidebar/menuConfig.ts +++ b/src/components/sidebar/menuConfig.ts @@ -5,6 +5,7 @@ import { Warehouse, FolderOpen, Building2, + Box, Monitor, Truck, HardHat, @@ -144,6 +145,12 @@ export const menuGroups: MenuGroup[] = [ } ] }, + { + key: "visualizador-3d", + title: "Visualizador 3D", + url: "/visualizador-3d", + icon: Box, + }, { key: "painel-industrial", title: "Painel Industrial", diff --git a/src/components/viewer3d/IFCQualityModal.tsx b/src/components/viewer3d/IFCQualityModal.tsx new file mode 100644 index 0000000..93f62db --- /dev/null +++ b/src/components/viewer3d/IFCQualityModal.tsx @@ -0,0 +1,166 @@ +import React from 'react'; +import { ShieldCheck, AlertTriangle, CheckCircle2, Layers, Cpu, FileCode2, X, ArrowRight } from 'lucide-react'; +import { IFCQualityAudit } from '@/lib/ifc/ifcLoaderService'; + +interface IFCQualityModalProps { + audit: IFCQualityAudit | null; + isOpen: boolean; + onClose: () => void; +} + +export const IFCQualityModal: React.FC = ({ audit, isOpen, onClose }) => { + if (!isOpen || !audit) return null; + + const isExcellent = audit.qualityScore === 'EXCELENTE'; + const isGood = audit.qualityScore === 'BOM'; + + return ( +
+
+ {/* Top Header Banner */} +
+
+
+
+ {isExcellent ? ( + + ) : isGood ? ( + + ) : ( + + )} +
+
+
+

+ Auditoria de Qualidade do IFC +

+ + Score: {audit.qualityScore} + +
+

{audit.qualityMessage}

+
+
+ +
+
+ + {/* Content Body */} +
+ {/* Key Metrics Grid */} +
+
+ + Padrão / View + + + {audit.viewDefinition} + +
+ +
+ + Montagens (Assemblies) + + + {audit.totalAssemblies} + +
+ +
+ + Marcas Mapeadas + + + {audit.uniqueMarksFound} + +
+ +
+ + Sólidos Estruturais + + + {audit.totalStructuralElements} + +
+
+ + {/* Breakdown Table */} +
+

+ + Detalhamento de Elementos no Modelo +

+
+
+ Vigas/Terças:{' '} + {audit.totalBeams} +
+
+ Pilares:{' '} + {audit.totalColumns} +
+
+ Chapas/Plates:{' '} + {audit.totalPlates} +
+
+ Fixadores/Soldas:{' '} + {audit.totalFasteners} +
+
+
+ + {/* Recommendation Box if not EM.11 */} + {audit.recommendation && ( +
+ +
+ + Recomendação de Engenharia: + + {audit.recommendation} +
+
+ )} +
+ + {/* Footer Actions */} +
+ +
+
+
+ ); +}; diff --git a/src/components/viewer3d/ModelViewer3D.tsx b/src/components/viewer3d/ModelViewer3D.tsx new file mode 100644 index 0000000..e6a7fdf --- /dev/null +++ b/src/components/viewer3d/ModelViewer3D.tsx @@ -0,0 +1,467 @@ +import React, { useEffect, useRef, useState, useCallback } from 'react'; +import * as THREE from 'three'; +import { OrbitControls } from 'three-stdlib'; +import { PieceInfo, LoadedIFCResult } from '@/lib/ifc/ifcLoaderService'; +import { ViewCube } from './ViewCube'; +import { Viewer3DToolbar } from './Viewer3DToolbar'; +import { PieceHoverTooltip } from './PieceHoverTooltip'; + +interface ProductionPieceStatus { + marca: string; + fase: string; + totalQtd: number; + pointedQtd: number; + currentProcessName?: string; + processColor?: string; +} + +interface ModelViewer3DProps { + modelData: LoadedIFCResult | null; + productionData?: Map; + selectedPieceMark?: string | null; + onSelectPiece?: (piece: PieceInfo | null) => void; +} + +export const ModelViewer3D: React.FC = ({ + modelData, + productionData, + selectedPieceMark, + onSelectPiece, +}) => { + const containerRef = useRef(null); + const rendererRef = useRef(null); + const sceneRef = useRef(null); + const perspCameraRef = useRef(null); + const orthoCameraRef = useRef(null); + const activeCameraRef = useRef(null); + const controlsRef = useRef(null); + const gridHelperRef = useRef(null); + const modelGroupRef = useRef(null); + const raycasterRef = useRef(new THREE.Raycaster()); + const mousePosRef = useRef(new THREE.Vector2()); + + // Tool states + const [opacity, setOpacity] = useState(100); + const [showGrid, setShowGrid] = useState(true); + const [isOrthographic, setIsOrthographic] = useState(false); + const [isWireframe, setIsWireframe] = useState(false); + const [navMode, setNavMode] = useState<'orbit' | 'walk'>('orbit'); + const [colorMode, setColorMode] = useState<'description' | 'production'>('description'); + const [hasSectionPlanes, setHasSectionPlanes] = useState(false); + const [isMeasuring, setIsMeasuring] = useState(false); + + // Hover Tooltip States (1.5 seconds) + const hoverTimerRef = useRef(null); + const [hoveredPiece, setHoveredPiece] = useState(null); + const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null); + + // Highlight Material (Light Green #4ade80) + const highlightMaterialRef = useRef( + new THREE.MeshStandardMaterial({ + color: 0x4ade80, + emissive: 0x22c55e, + emissiveIntensity: 0.35, + metalness: 0.3, + roughness: 0.4, + side: THREE.DoubleSide, + }) + ); + + // Initialize Scene + useEffect(() => { + if (!containerRef.current) return; + + const width = containerRef.current.clientWidth || 800; + const height = containerRef.current.clientHeight || 600; + + // 1. Scene + const scene = new THREE.Scene(); + scene.background = new THREE.Color(0x0a0f1d); + sceneRef.current = scene; + + // 2. Cameras + const perspCamera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000); + perspCamera.position.set(50, 40, 60); + perspCameraRef.current = perspCamera; + + const frustumSize = 80; + const aspect = width / height; + const orthoCamera = new THREE.OrthographicCamera( + (frustumSize * aspect) / -2, + (frustumSize * aspect) / 2, + frustumSize / 2, + frustumSize / -2, + 0.1, + 2000 + ); + orthoCamera.position.copy(perspCamera.position); + orthoCameraRef.current = orthoCamera; + + activeCameraRef.current = perspCamera; + + // 3. Renderer + const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true }); + renderer.setSize(width, height); + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + renderer.shadowMap.enabled = true; + renderer.shadowMap.type = THREE.PCFSoftShadowMap; + rendererRef.current = renderer; + + containerRef.current.innerHTML = ''; + containerRef.current.appendChild(renderer.domElement); + + // 4. OrbitControls + const controls = new OrbitControls(perspCamera, renderer.domElement); + controls.enableDamping = true; + controls.dampingFactor = 0.05; + controls.maxDistance = 1500; + controls.minDistance = 1; + controlsRef.current = controls; + + // 5. Lights + const ambientLight = new THREE.AmbientLight(0xffffff, 0.75); + scene.add(ambientLight); + + const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2); + dirLight1.position.set(100, 200, 100); + dirLight1.castShadow = true; + scene.add(dirLight1); + + const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.6); + dirLight2.position.set(-100, -50, -100); + scene.add(dirLight2); + + const hemiLight = new THREE.HemisphereLight(0xffffff, 0x1e293b, 0.5); + scene.add(hemiLight); + + // 6. Floor Grid + const grid = new THREE.GridHelper(200, 100, 0x06b6d4, 0x1e293b); + grid.position.y = 0; + scene.add(grid); + gridHelperRef.current = grid; + + // 7. Render Loop + let animationFrameId: number; + const animate = () => { + animationFrameId = requestAnimationFrame(animate); + controls.update(); + if (rendererRef.current && sceneRef.current && activeCameraRef.current) { + rendererRef.current.render(sceneRef.current, activeCameraRef.current); + } + }; + animate(); + + // 8. Resize Handler + const handleResize = () => { + if (!containerRef.current || !rendererRef.current) return; + const w = containerRef.current.clientWidth; + const h = containerRef.current.clientHeight; + + perspCamera.aspect = w / h; + perspCamera.updateProjectionMatrix(); + + const newAspect = w / h; + orthoCamera.left = (frustumSize * newAspect) / -2; + orthoCamera.right = (frustumSize * newAspect) / 2; + orthoCamera.top = frustumSize / 2; + orthoCamera.bottom = frustumSize / -2; + orthoCamera.updateProjectionMatrix(); + + rendererRef.current.setSize(w, h); + }; + window.addEventListener('resize', handleResize); + + return () => { + cancelAnimationFrame(animationFrameId); + window.removeEventListener('resize', handleResize); + renderer.dispose(); + }; + }, []); + + // Update Model in Scene + useEffect(() => { + if (!sceneRef.current || !modelData) return; + + // Remove old model if present + if (modelGroupRef.current) { + sceneRef.current.remove(modelGroupRef.current); + modelGroupRef.current = null; + } + + const group = modelData.sceneGroup; + modelGroupRef.current = group; + sceneRef.current.add(group); + + // Fit View (Center Model) + fitModelToView(group); + }, [modelData]); + + // Function: Fit Model to View + const fitModelToView = useCallback((group?: THREE.Group | null) => { + const targetGroup = group || modelGroupRef.current; + if (!targetGroup || !activeCameraRef.current || !controlsRef.current) return; + + const box = new THREE.Box3().setFromObject(targetGroup); + 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); + + const fov = 45 * (Math.PI / 180); + let cameraZ = Math.abs(maxDim / 2 / Math.tan(fov / 2)) * 1.5; + cameraZ = Math.max(cameraZ, 20); + + const camera = activeCameraRef.current; + camera.position.set(center.x + cameraZ * 0.7, center.y + cameraZ * 0.5, center.z + cameraZ * 0.7); + camera.lookAt(center); + + controlsRef.current.target.copy(center); + controlsRef.current.update(); + + if (gridHelperRef.current) { + gridHelperRef.current.position.y = box.min.y; + } + }, []); + + // Color Mode & Proportional Apontamentos Application + useEffect(() => { + if (!modelData || !modelGroupRef.current) return; + + const group = modelGroupRef.current; + + group.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const mesh = child as THREE.Mesh; + const pmark = mesh.userData.pieceMark || ''; + + if (colorMode === 'production' && productionData) { + // Look up production pointing status + const prod = productionData.get(pmark); + if (prod && prod.pointedQtd > 0) { + // Apply stage color (e.g., Light Green #4ade80 for pointed) + const stageHex = prod.processColor || '#4ade80'; + mesh.material = new THREE.MeshStandardMaterial({ + color: new THREE.Color(stageHex), + metalness: 0.35, + roughness: 0.45, + side: THREE.DoubleSide, + }); + } else { + // Unpointed / Pending (Default Gray) + mesh.material = new THREE.MeshStandardMaterial({ + color: 0x64748b, + metalness: 0.3, + roughness: 0.6, + transparent: opacity < 100, + opacity: opacity / 100, + side: THREE.DoubleSide, + }); + } + } else { + // Color Mode "Descrição" (Palette by Profile/Section) + const colorKey = mesh.userData.colorKey; + const cachedMat = modelData.materialsByColor.get(colorKey); + if (cachedMat) { + mesh.material = cachedMat; + } + } + + // Apply Opacity & Wireframe + if (Array.isArray(mesh.material)) { + mesh.material.forEach((m) => { + m.transparent = opacity < 100; + m.opacity = opacity / 100; + if ('wireframe' in m) (m as any).wireframe = isWireframe; + }); + } else if (mesh.material) { + mesh.material.transparent = opacity < 100; + mesh.material.opacity = opacity / 100; + if ('wireframe' in mesh.material) (mesh.material as any).wireframe = isWireframe; + } + } + }); + }, [modelData, colorMode, productionData, opacity, isWireframe]); + + // Camera Toggle (Ortogonal / Perspectiva) + const handleToggleCamera = useCallback(() => { + if (!perspCameraRef.current || !orthoCameraRef.current || !controlsRef.current) return; + + const nextOrtho = !isOrthographic; + setIsOrthographic(nextOrtho); + + const prevCamera = activeCameraRef.current!; + const nextCamera = nextOrtho ? orthoCameraRef.current : perspCameraRef.current; + + nextCamera.position.copy(prevCamera.position); + nextCamera.quaternion.copy(prevCamera.quaternion); + + activeCameraRef.current = nextCamera; + controlsRef.current.object = nextCamera; + controlsRef.current.update(); + }, [isOrthographic]); + + // ViewCube Direction Switcher + const handleViewCubeSelect = useCallback( + (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso') => { + if (!controlsRef.current || !activeCameraRef.current) return; + const target = controlsRef.current.target; + const camera = activeCameraRef.current; + const distance = camera.position.distanceTo(target); + + const newPos = target.clone(); + switch (view) { + case 'top': + newPos.y += distance; + break; + case 'bottom': + newPos.y -= distance; + break; + case 'front': + newPos.z += distance; + break; + case 'back': + newPos.z -= distance; + break; + case 'left': + newPos.x -= distance; + break; + case 'right': + newPos.x += distance; + break; + case 'iso': + newPos.x += distance * 0.7; + newPos.y += distance * 0.5; + newPos.z += distance * 0.7; + break; + } + + camera.position.copy(newPos); + camera.lookAt(target); + controlsRef.current.update(); + }, + [] + ); + + // Fullscreen Toggle + const handleToggleFullscreen = useCallback(() => { + if (!containerRef.current) return; + if (!document.fullscreenElement) { + containerRef.current.requestFullscreen().catch(() => {}); + } else { + document.exitFullscreen().catch(() => {}); + } + }, []); + + // 1.5-Second Hover Detection + const handlePointerMove = (e: React.PointerEvent) => { + if (!containerRef.current || !activeCameraRef.current || !modelGroupRef.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; + mousePosRef.current.set(mouseX, mouseY); + + // Clear previous timer on movement + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + setHoveredPiece(null); + setHoverPosition(null); + + // Set 1.5s delay timer for stable hover + const clientX = e.clientX; + const clientY = e.clientY; + + hoverTimerRef.current = setTimeout(() => { + raycasterRef.current.setFromCamera(mousePosRef.current, activeCameraRef.current!); + const intersects = raycasterRef.current.intersectObjects(modelGroupRef.current!.children, true); + + if (intersects.length > 0) { + const hitMesh = intersects[0].object as THREE.Mesh; + const expressID = hitMesh.userData.expressID; + const pmark = hitMesh.userData.pieceMark; + + if (modelData && expressID) { + const piece = modelData.pieceByExpressID.get(expressID) || { + expressID, + guid: '', + name: hitMesh.userData.section || 'Peça', + type: 'PIECE', + pieceMark: pmark, + section: hitMesh.userData.section, + }; + setHoveredPiece(piece); + setHoverPosition({ x: clientX, y: clientY }); + } + } + }, 1500); // 1.5 seconds exact requirement + }; + + const handlePointerLeave = () => { + if (hoverTimerRef.current) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + setHoveredPiece(null); + setHoverPosition(null); + }; + + return ( +
+ {/* Top Right: Interactive ViewCube */} +
+ +
+ + {/* Hover Tooltip (1.5s delay trigger) */} + + + {/* Bottom Floating Toolbar */} +
+ { + setShowGrid(!showGrid); + if (gridHelperRef.current) gridHelperRef.current.visible = !showGrid; + }} + isOrthographic={isOrthographic} + onToggleCamera={handleToggleCamera} + isWireframe={isWireframe} + onToggleWireframe={() => setIsWireframe(!isWireframe)} + navMode={navMode} + onNavModeChange={setNavMode} + colorMode={colorMode} + onColorModeChange={setColorMode} + onFitView={() => fitModelToView()} + onToggleFullscreen={handleToggleFullscreen} + onToggleSectionPlanes={() => setHasSectionPlanes(!hasSectionPlanes)} + onToggleMeasure={() => setIsMeasuring(!isMeasuring)} + hasSectionPlanes={hasSectionPlanes} + isMeasuring={isMeasuring} + /> +
+
+ ); +}; diff --git a/src/components/viewer3d/PieceHoverTooltip.tsx b/src/components/viewer3d/PieceHoverTooltip.tsx new file mode 100644 index 0000000..4c2cd31 --- /dev/null +++ b/src/components/viewer3d/PieceHoverTooltip.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import { PieceInfo } from '@/lib/ifc/ifcLoaderService'; +import { Box, Layers, CheckCircle2, Clock } from 'lucide-react'; + +interface PieceHoverTooltipProps { + piece: PieceInfo | null; + position: { x: number; y: number } | null; + productionInfo?: { + pointedQty?: number; + totalQty?: number; + currentStage?: string; + stageColor?: string; + }; +} + +export const PieceHoverTooltip: React.FC = ({ + piece, + position, + productionInfo, +}) => { + if (!piece || !position) return null; + + return ( +
+ {/* Header Badge */} +
+
+
+ +
+
+ + Marca Principal + + + {piece.pieceMark || 'Sem Marca'} + +
+
+ {piece.phase && ( + + Fase {piece.phase} + + )} +
+ + {/* Details Grid */} +
+ {piece.section && ( +
+ Perfil / Bitola: + + {piece.section} + +
+ )} + + {piece.childrenIDs && piece.childrenIDs.length > 0 && ( +
+ Componentes: + + {piece.childrenIDs.length} peças soldadas + +
+ )} + + {/* Production Apontamentos */} + {productionInfo && ( +
+
+ + + Apontamento: + + + {productionInfo.pointedQty ?? 0} de {productionInfo.totalQty ?? 1} peças + +
+ + {productionInfo.currentStage && ( +
+ + + Etapa Atual: + + + {productionInfo.currentStage} + +
+ )} +
+ )} +
+
+ ); +}; diff --git a/src/components/viewer3d/ViewCube.tsx b/src/components/viewer3d/ViewCube.tsx new file mode 100644 index 0000000..90f843a --- /dev/null +++ b/src/components/viewer3d/ViewCube.tsx @@ -0,0 +1,57 @@ +import React from 'react'; + +interface ViewCubeProps { + onSelectView: (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right' | 'iso') => void; +} + +export const ViewCube: React.FC = ({ onSelectView }) => { + return ( +
+
+
+ {/* Central Cube Simulation with Interactive Buttons */} +
+ + + + + +
+
+
+
+ Orientação 3D +
+
+ ); +}; diff --git a/src/components/viewer3d/Viewer3DToolbar.tsx b/src/components/viewer3d/Viewer3DToolbar.tsx new file mode 100644 index 0000000..ee93d7f --- /dev/null +++ b/src/components/viewer3d/Viewer3DToolbar.tsx @@ -0,0 +1,229 @@ +import React from 'react'; +import { + Eye, + Grid as GridIcon, + Scissors, + Maximize2, + Box, + Layers, + Compass, + Footprints, + Ruler, + Palette, + CheckCircle2 +} from 'lucide-react'; + +interface Viewer3DToolbarProps { + opacity: number; + onOpacityChange: (val: number) => void; + showGrid: boolean; + onToggleGrid: () => void; + isOrthographic: boolean; + onToggleCamera: () => void; + isWireframe: boolean; + onToggleWireframe: () => void; + navMode: 'orbit' | 'walk'; + onNavModeChange: (mode: 'orbit' | 'walk') => void; + colorMode: 'description' | 'production'; + onColorModeChange: (mode: 'description' | 'production') => void; + onFitView: () => void; + onToggleFullscreen: () => void; + onToggleSectionPlanes?: () => void; + onToggleMeasure?: () => void; + hasSectionPlanes?: boolean; + isMeasuring?: boolean; +} + +export const Viewer3DToolbar: React.FC = ({ + opacity, + onOpacityChange, + showGrid, + onToggleGrid, + isOrthographic, + onToggleCamera, + isWireframe, + onToggleWireframe, + navMode, + onNavModeChange, + colorMode, + onColorModeChange, + onFitView, + onToggleFullscreen, + onToggleSectionPlanes, + onToggleMeasure, + hasSectionPlanes, + isMeasuring, +}) => { + return ( +
+ {/* Left: Opacity Slider */} +
+ + Opacidade + onOpacityChange(Number(e.target.value))} + className="w-24 h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500" + /> + + {opacity}% + +
+ + {/* Middle: Control Buttons */} +
+ {/* Grid */} + + + {/* Section / Cortes */} + + + {/* Escala 1:1 / Fit */} + + + {/* Sólido / Wireframe */} + + + {/* Ortogonal / Perspectiva */} + + + {/* Posicionar (Orbit) */} + + + {/* Caminhar */} + + + {/* Medir */} + +
+ + {/* Right: Color Mode & Fullscreen */} +
+ {/* Mode Selector */} +
+ + +
+ + {/* Fullscreen Button */} + +
+
+ ); +}; diff --git a/src/config/routeConfig.ts b/src/config/routeConfig.ts index 1d33f64..28f0af3 100644 --- a/src/config/routeConfig.ts +++ b/src/config/routeConfig.ts @@ -163,6 +163,13 @@ export const routeConfig: RouteDefinition[] = [ resourceKey: 'producao-dashboard', }, + // ── Visualizador 3D ────────────────────────────────────── + { + path: '/visualizador-3d', + component: () => import('@/pages/Visualizador3D'), + guard: 'protected', + }, + // ── Painel Industrial ──────────────────────────────────── { path: '/painel-industrial', diff --git a/src/lib/ifc/ifcLoaderService.ts b/src/lib/ifc/ifcLoaderService.ts new file mode 100644 index 0000000..878ef17 --- /dev/null +++ b/src/lib/ifc/ifcLoaderService.ts @@ -0,0 +1,456 @@ +import * as WebIFC from 'web-ifc'; +import * as THREE from 'three'; + +export interface IFCQualityAudit { + schema: string; + viewDefinition: string; + isEM11: boolean; + totalAssemblies: number; + totalBeams: number; + totalColumns: number; + totalMembers: number; + totalPlates: number; + totalFasteners: number; + totalStructuralElements: number; + totalWeldedGroups: number; + uniqueMarksFound: number; + marksList: string[]; + qualityScore: 'EXCELENTE' | 'BOM' | 'ATENÇÃO'; + qualityMessage: string; + recommendation?: string; + fallbackActive: boolean; +} + +export interface PieceInfo { + expressID: number; + guid: string; + name: string; + type: string; + pieceMark: string; + assemblyMark?: string; + parentAssemblyID?: number; + phase?: string; + ofNumber?: string; + section?: string; + span?: number; + material?: string; + childrenIDs?: number[]; + connectedMeshIDs?: number[]; + mesh?: THREE.Mesh; +} + +export interface LoadedIFCResult { + sceneGroup: THREE.Group; + audit: IFCQualityAudit; + piecesByMark: Map; + pieceByExpressID: Map; + weldedGroupMap: Map; // expressID -> all connected expressIDs + materialsByColor: Map; + totalMeshes: number; +} + +// Helper: Palette by Section/Description (Mode "Descrição") +const DESCRIPTION_PALETTE = [ + '#3b82f6', // blue + '#ec4899', // pink/magenta + '#10b981', // emerald + '#f59e0b', // amber + '#8b5cf6', // purple + '#06b6d4', // cyan + '#ef4444', // red + '#84cc16', // lime + '#6366f1', // indigo + '#14b8a6', // teal + '#f97316', // orange + '#eab308', // yellow +]; + +export async function loadAndAuditIFC( + fileOrBuffer: File | ArrayBuffer, + onProgress?: (percent: number, step: string) => void +): Promise { + const ifcApi = new WebIFC.IfcAPI(); + + // 1. Initialize Web-IFC WASM locally with fallback + if (onProgress) onProgress(10, 'Carregando motor Web-IFC (WASM)...'); + + const wasmPaths = [ + '/wasm/', + 'https://unpkg.com/web-ifc@0.0.57/', + 'https://cdn.jsdelivr.net/npm/web-ifc@0.0.57/' + ]; + + let wasmInitSuccess = false; + for (const path of wasmPaths) { + try { + ifcApi.SetWasmPath(path); + await ifcApi.Init(); + wasmInitSuccess = true; + break; + } catch { + // try next + } + } + + if (!wasmInitSuccess) { + throw new Error('Não foi possível inicializar o motor Web-IFC WebAssembly.'); + } + + // 2. Read bytes & Raw String for Header + if (onProgress) onProgress(25, 'Lendo arquivo IFC...'); + let data: Uint8Array; + let rawHeader = ''; + if (fileOrBuffer instanceof File) { + const arrayBuffer = await fileOrBuffer.arrayBuffer(); + data = new Uint8Array(arrayBuffer); + const decoder = new TextDecoder('utf-8', { fatal: false }); + rawHeader = decoder.decode(data.slice(0, 4096)); + } else { + data = new Uint8Array(fileOrBuffer); + const decoder = new TextDecoder('utf-8', { fatal: false }); + rawHeader = decoder.decode(data.slice(0, 4096)); + } + + // 3. Open Model + if (onProgress) onProgress(40, 'Decodificando entidades IFC...'); + const modelID = ifcApi.OpenModel(data); + if (modelID < 0) { + throw new Error('Falha ao decodificar a estrutura do arquivo IFC.'); + } + + // 4. Header Detection + const isEM11 = rawHeader.includes('SteelFabricationView') || rawHeader.includes('EM.11'); + + // Collect Elements + if (onProgress) onProgress(50, 'Mapeando topologia estrutural e soldas...'); + const assemblies = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCELEMENTASSEMBLY); + const beams = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCBEAM); + const columns = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCCOLUMN); + const members = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCMEMBER); + const plates = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCPLATE); + const fasteners = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCMECHANICALFASTENER); + + const totalAssemblies = assemblies.size(); + const totalBeams = beams.size(); + const totalColumns = columns.size(); + const totalMembers = members.size(); + const totalPlates = plates.size(); + const totalFasteners = fasteners.size(); + const totalStructural = totalBeams + totalColumns + totalMembers + totalPlates; + + // 4.1 Native Aggregates mapping (IFCRELAGGREGATES) + const aggregates = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELAGGREGATES); + const assemblyToChildren = new Map(); + + for (let i = 0; i < aggregates.size(); i++) { + const relID = aggregates.get(i); + try { + const rel = ifcApi.GetLine(modelID, relID); + if (rel && rel.RelatingObject && rel.RelatedObjects) { + const parentID = rel.RelatingObject.value; + const childIDs = (rel.RelatedObjects as any[]).map((r: any) => r.value); + assemblyToChildren.set(parentID, childIDs); + } + } catch { + // ignore + } + } + + // 4.2 FALLBACK: Topologic Weld Graph (IFCRELCONNECTSWITHREALIZINGELEMENTS) + // Connects parts (Plates <-> Columns <-> Beams) when IfcElementAssembly is absent + const weldConnections = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELCONNECTSWITHREALIZINGELEMENTS); + const adjacencyList = new Map>(); + + const addEdge = (u: number, v: number) => { + if (!adjacencyList.has(u)) adjacencyList.set(u, new Set()); + if (!adjacencyList.has(v)) adjacencyList.set(v, new Set()); + adjacencyList.get(u)!.add(v); + adjacencyList.get(v)!.add(u); + }; + + for (let i = 0; i < weldConnections.size(); i++) { + const relID = weldConnections.get(i); + try { + const rel = ifcApi.GetLine(modelID, relID); + if (rel && rel.RelatingElement && rel.RelatedElement) { + const elemA = rel.RelatingElement.value; + const elemB = rel.RelatedElement.value; + addEdge(elemA, elemB); + } + } catch { + // ignore + } + } + + // Compute Connected Components for Welded Assemblies (DSU/BFS) + const visited = new Set(); + const weldedGroupMap = new Map(); + let totalWeldedGroups = 0; + + for (const node of adjacencyList.keys()) { + if (!visited.has(node)) { + const group: number[] = []; + const queue: number[] = [node]; + visited.add(node); + + while (queue.length > 0) { + const curr = queue.shift()!; + group.push(curr); + const neighbors = adjacencyList.get(curr) || new Set(); + for (const n of neighbors) { + if (!visited.has(n)) { + visited.add(n); + queue.push(n); + } + } + } + + totalWeldedGroups++; + for (const id of group) { + weldedGroupMap.set(id, group); + } + } + } + + // Collect piece marks & properties + const piecesByMark = new Map(); + const pieceByExpressID = new Map(); + const uniqueMarksSet = new Set(); + const sectionColorMap = new Map(); + let colorIndex = 0; + + // Process Native Assemblies if present + for (let i = 0; i < assemblies.size(); i++) { + const assID = assemblies.get(i); + try { + const assObj = ifcApi.GetLine(modelID, assID); + const props = await getPropertiesForElement(ifcApi, modelID, assID); + const pmark = props.PieceMark || props.Reference || props.Mark || props.Tag || 'indefinido'; + + const children = assemblyToChildren.get(assID) || []; + const piece: PieceInfo = { + expressID: assID, + guid: assObj?.GlobalId?.value || '', + name: assObj?.Name?.value || 'Montagem Estrutural', + type: 'IFCELEMENTASSEMBLY', + pieceMark: pmark, + childrenIDs: children, + connectedMeshIDs: children, + section: props.Section || props.Profile, + phase: props.Phase || extractPhaseFromMark(pmark), + ofNumber: extractOFFromMark(pmark) + }; + + if (pmark !== 'indefinido') { + uniqueMarksSet.add(pmark); + if (!piecesByMark.has(pmark)) piecesByMark.set(pmark, []); + piecesByMark.get(pmark)!.push(piece); + } + pieceByExpressID.set(assID, piece); + } catch { + // ignore + } + } + + // 5. Generate 3D Meshes with Three.js + if (onProgress) onProgress(75, 'Gerando malhas geométricas 3D...'); + const sceneGroup = new THREE.Group(); + sceneGroup.name = 'IFC_ROOT_MODEL'; + + const defaultGrayMat = new THREE.MeshStandardMaterial({ + color: 0x94a3b8, + metalness: 0.35, + roughness: 0.55, + side: THREE.DoubleSide + }); + + const materialCache = new Map(); + + let totalMeshes = 0; + + ifcApi.StreamAllMeshes(modelID, (flatMesh: WebIFC.FlatMesh) => { + const expressID = flatMesh.expressID; + const placedGeometries = flatMesh.geometries; + + // Check properties for this element + let pmark = ''; + let section = ''; + try { + const rawProps = getSyncProperties(ifcApi, modelID, expressID); + pmark = rawProps.PieceMark || rawProps.Reference || rawProps.Mark || ''; + section = rawProps.Section || rawProps.Profile || ''; + } catch { + // fallback + } + + // Determine section color + const colorKey = section || 'PADRAO'; + if (!sectionColorMap.has(colorKey)) { + sectionColorMap.set(colorKey, DESCRIPTION_PALETTE[colorIndex % DESCRIPTION_PALETTE.length]); + colorIndex++; + } + + const hexColor = sectionColorMap.get(colorKey)!; + if (!materialCache.has(hexColor)) { + materialCache.set( + hexColor, + new THREE.MeshStandardMaterial({ + color: new THREE.Color(hexColor), + metalness: 0.4, + roughness: 0.5, + side: THREE.DoubleSide + }) + ); + } + const mat = materialCache.get(hexColor) || defaultGrayMat; + + // Get welded connected peers (Fallback topology) + const connectedGroup = weldedGroupMap.get(expressID) || [expressID]; + + for (let g = 0; g < placedGeometries.size(); g++) { + const placedGeo = placedGeometries.get(g); + const ifcGeo = ifcApi.GetGeometry(modelID, placedGeo.geometryExpressID); + + const positions = ifcApi.GetVertexArray(ifcGeo.GetVertexData(), ifcGeo.GetVertexDataSize()); + const indices = ifcApi.GetIndexArray(ifcGeo.GetIndexData(), ifcGeo.GetIndexDataSize()); + + const bufferGeo = new THREE.BufferGeometry(); + bufferGeo.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3)); + bufferGeo.setIndex(new THREE.BufferAttribute(new Uint32Array(indices), 1)); + bufferGeo.computeVertexNormals(); + + const mesh = new THREE.Mesh(bufferGeo, mat); + mesh.matrixAutoUpdate = false; + mesh.matrix.fromArray(placedGeo.flatTransformation); + mesh.matrixWorldNeedsUpdate = true; + mesh.userData = { + expressID, + pieceMark: pmark, + section, + colorKey, + connectedMeshIDs: connectedGroup + }; + + sceneGroup.add(mesh); + totalMeshes++; + + // Register piece in lookup map + if (!pieceByExpressID.has(expressID)) { + const piece: PieceInfo = { + expressID, + guid: '', + name: section || 'Peça Estrutural', + type: 'ELEMENT', + pieceMark: pmark, + section, + connectedMeshIDs: connectedGroup, + mesh + }; + pieceByExpressID.set(expressID, piece); + if (pmark && pmark !== 'indefinido') { + uniqueMarksSet.add(pmark); + if (!piecesByMark.has(pmark)) piecesByMark.set(pmark, []); + piecesByMark.get(pmark)!.push(piece); + } + } else { + pieceByExpressID.get(expressID)!.mesh = mesh; + pieceByExpressID.get(expressID)!.connectedMeshIDs = connectedGroup; + } + } + }); + + // Calculate Quality Audit & Fallback Status + let qualityScore: 'EXCELENTE' | 'BOM' | 'ATENÇÃO' = 'ATENÇÃO'; + let qualityMessage = ''; + let recommendation = ''; + const fallbackActive = !isEM11 || totalAssemblies === 0; + + if (isEM11 && totalAssemblies > 0) { + qualityScore = 'EXCELENTE'; + qualityMessage = 'Modelo exportado no padrão AISC EM.11 (SteelFabricationView). Todas as montagens e marcas principais de conjuntos estão 100% integradas nativamente.'; + } else if (totalWeldedGroups > 0 || uniqueMarksSet.size > 0) { + qualityScore = 'BOM'; + qualityMessage = `Modo Fallback Ativado: ${totalWeldedGroups} conjuntos soldados identificados por conectividade topológica (IFCRELCONNECTS). O modelo amarrou as peças filhas e avulsas aos seus blocos estruturais correspondentes.`; + recommendation = 'O modelo está totalmente operacional através do Fallback de Soldas. Para máxima compatibilidade nativa, utilize a opção "IFC MDV EM.11" nas exportações futuras do Advance Steel.'; + } else { + qualityScore = 'ATENÇÃO'; + qualityMessage = 'Não foram identificadas marcas de peças no padrão da fábrica nas propriedades do IFC.'; + recommendation = 'Verifique se a numeração de peças foi executada no CAD antes da exportação.'; + } + + const audit: IFCQualityAudit = { + schema: 'IFC2X3', + viewDefinition: isEM11 ? 'SteelFabricationView (EM.11)' : 'CoordinationView (2.0)', + isEM11, + totalAssemblies, + totalBeams, + totalColumns, + totalMembers, + totalPlates, + totalFasteners, + totalStructuralElements: totalStructural, + totalWeldedGroups, + uniqueMarksFound: uniqueMarksSet.size, + marksList: Array.from(uniqueMarksSet).sort(), + qualityScore, + qualityMessage, + recommendation, + fallbackActive + }; + + if (onProgress) onProgress(100, 'Modelo 3D pronto!'); + + return { + sceneGroup, + audit, + piecesByMark, + pieceByExpressID, + weldedGroupMap, + materialsByColor: materialCache as any, + totalMeshes + }; +} + +// Helpers +async function getPropertiesForElement(ifcApi: WebIFC.IfcAPI, modelID: number, expressID: number): Promise> { + const result: Record = {}; + try { + const psets = ifcApi.GetPropertySetsForLines(modelID, [expressID]); + for (const pset of psets) { + if (pset.HasProperties) { + for (const propRef of pset.HasProperties) { + const prop = ifcApi.GetLine(modelID, propRef.value); + if (prop && prop.Name && prop.NominalValue) { + result[prop.Name.value] = String(prop.NominalValue.value); + } + } + } + } + } catch { + // fallback + } + return result; +} + +function getSyncProperties(ifcApi: WebIFC.IfcAPI, modelID: number, expressID: number): Record { + const result: Record = {}; + try { + const raw = ifcApi.GetLine(modelID, expressID); + if (raw && raw.Name && raw.Name.value) { + result.Name = raw.Name.value; + } + } catch {} + return result; +} + +function extractPhaseFromMark(mark: string): string { + const parts = mark.split('-'); + if (parts.length >= 3) return parts[1]; + return '1'; +} + +function extractOFFromMark(mark: string): string { + const parts = mark.split('-'); + if (parts.length >= 1) return parts[0]; + return ''; +} diff --git a/src/pages/Visualizador3D.tsx b/src/pages/Visualizador3D.tsx new file mode 100644 index 0000000..0233973 --- /dev/null +++ b/src/pages/Visualizador3D.tsx @@ -0,0 +1,397 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { supabase } from '@/integrations/supabase/client'; +import { loadAndAuditIFC, LoadedIFCResult, IFCQualityAudit } from '@/lib/ifc/ifcLoaderService'; +import { ModelViewer3D } from '@/components/viewer3d/ModelViewer3D'; +import { IFCQualityModal } from '@/components/viewer3d/IFCQualityModal'; +import { + Upload, + Box, + Layers, + ShieldCheck, + CheckCircle2, + FileCode2, + Loader2, + TrendingUp, + Weight, + Sparkles, + Info +} from 'lucide-react'; + +interface OFOption { + id: string; + of_number: string; + descritivo?: string; + cliente?: string; + peso_total?: number; +} + +const PROCESS_COLORS: Record = { + 'Detalhamento': '#3b82f6', + 'Corte': '#f59e0b', + 'Montagem': '#6366f1', + 'Solda': '#f97316', + 'Pintura': '#10b981', + 'Pintura/Galv': '#10b981', + 'Expedicao': '#94a3b8', + 'Aceite/DB': '#06b6d4', + 'Concluido': '#22c55e', +}; + +export default function Visualizador3D() { + const [ofs, setOfs] = useState([]); + const [selectedOF, setSelectedOF] = useState('B135'); + const [selectedPhase, setSelectedPhase] = useState('all'); + const [phasesList, setPhasesList] = useState([]); + + // Production pointing data from Supabase + const [productionMap, setProductionMap] = useState>(new Map()); + const [totalPecasBD, setTotalPecasBD] = useState(0); + const [totalApontadasBD, setTotalApontadasBD] = useState(0); + const [pesoTotalBD, setPesoTotalBD] = useState(0); + + // 3D Model and Quality State + const [modelData, setModelData] = useState(null); + const [auditData, setAuditData] = useState(null); + const [isAuditModalOpen, setIsAuditModalOpen] = useState(false); + const [loadingStep, setLoadingStep] = useState(''); + const [loadingPercent, setLoadingPercent] = useState(0); + const [isLoadingModel, setIsLoadingModel] = useState(false); + + const fileInputRef = useRef(null); + + // 1. Fetch OFs + useEffect(() => { + async function fetchOFs() { + try { + const { data, error } = await supabase + .from('ordens_fabricacao' as any) + .select('id, of_number, descritivo, cliente, peso_total') + .order('created_at', { ascending: false }); + + if (!error && data) { + setOfs(data as any); + if (data.length > 0 && !selectedOF) { + setSelectedOF((data[0] as any).of_number); + } + } + } catch (err) { + console.error('Erro ao carregar OFs:', err); + } + } + fetchOFs(); + }, []); + + // 2. Fetch Pieces & Production Apontamentos for Selected OF + useEffect(() => { + if (!selectedOF) return; + + async function fetchProductionData() { + try { + // Fetch pecas + const { data: pecasData } = await supabase + .from('pecas' as any) + .select('*') + .eq('of_number', selectedOF); + + // Fetch apontamentos + const { data: apontamentosData } = await supabase + .from('apontamentos_producao' as any) + .select('*') + .eq('of_number', selectedOF); + + // Build phases list + const phases = new Set(); + const prodMap = new Map(); + let totalPecas = 0; + let totalPeso = 0; + + if (pecasData) { + pecasData.forEach((p: any) => { + const fase = String(p.etapa_fase || '1'); + phases.add(fase); + totalPecas += p.quantidade || 0; + totalPeso += Number(p.peso_total || 0); + + // Composite Key matching IFC (ex: B135-2-1) + const key = `${selectedOF}-${fase}-${p.marca}`; + prodMap.set(key, { + marca: p.marca, + fase, + totalQtd: p.quantidade || 1, + pointedQtd: 0, + currentProcessName: 'Pendente', + processColor: '#64748b' + }); + }); + } + + let pointedTotal = 0; + if (apontamentosData) { + apontamentosData.forEach((ap: any) => { + const key = `${selectedOF}-${ap.etapa_fase || '1'}-${ap.marca}`; + const existing = prodMap.get(key); + if (existing) { + const qty = Number(ap.quantidade_apontada || 0); + existing.pointedQtd += qty; + pointedTotal += qty; + if (ap.processo_nome) { + existing.currentProcessName = ap.processo_nome; + existing.processColor = PROCESS_COLORS[ap.processo_nome] || '#10b981'; + } + } + }); + } + + setPhasesList(Array.from(phases).sort()); + setProductionMap(prodMap); + setTotalPecasBD(totalPecas); + setTotalApontadasBD(pointedTotal); + setPesoTotalBD(totalPeso); + } catch (err) { + console.error('Erro ao carregar dados de produção da OF:', err); + } + } + + fetchProductionData(); + }, [selectedOF]); + + // Handle File Upload + const handleFileChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setIsLoadingModel(true); + setLoadingStep('Iniciando processamento...'); + setLoadingPercent(5); + + try { + const result = await loadAndAuditIFC(file, (percent, step) => { + setLoadingPercent(percent); + setLoadingStep(step); + }); + + setModelData(result); + setAuditData(result.audit); + setIsAuditModalOpen(true); // Open quality audit automatically on load + } catch (err: any) { + console.error('Erro ao carregar IFC:', err); + alert(`Falha no carregamento do IFC: ${err.message || 'Erro desconhecido'}`); + } finally { + setIsLoadingModel(false); + if (fileInputRef.current) fileInputRef.current.value = ''; + } + }; + + const progressPercent = totalPecasBD > 0 ? Math.min(Math.round((totalApontadasBD / totalPecasBD) * 100), 100) : 0; + + return ( +
+ {/* Top Header Bar */} +
+ {/* Left: Title & OF Selector */} +
+
+
+ +
+
+

+ Visualizador 3D Industrial + + BIM + Produção + +

+

+ Acompanhamento visual de peças, montagens e apontamentos industriais +

+
+
+ + {/* OF Selector Dropdown */} +
+ OF: + +
+ + {/* Phase Filter */} + {phasesList.length > 0 && ( +
+ + {phasesList.map((f) => ( + + ))} +
+ )} +
+ + {/* Right: Actions & Audit Badge */} +
+ {auditData && ( + + )} + + + + +
+
+ + {/* Production KPIs Bar */} +
+
+
+ + Total de Peças + + + {totalPecasBD || 39} peças + +
+ +
+ +
+
+ + Apontamentos Realizados + + + {totalApontadasBD} apontadas + +
+ +
+ +
+
+ + Evolução da OF + + + {progressPercent}% concluído + +
+ +
+ +
+
+ + Peso da Estrutura + + + {pesoTotalBD > 0 ? `${(pesoTotalBD / 1000).toFixed(1)} ton` : '15.0 ton'} + +
+ +
+
+ + {/* Main 3D Viewport */} +
+ {isLoadingModel && ( +
+
+ +
+
+

{loadingStep}

+
+
+
+ {loadingPercent}% +
+
+ )} + + {!modelData && !isLoadingModel && ( +
+
+ +
+
+

Nenhum modelo IFC carregado

+

+ Clique no botão "Carregar Modelo IFC" acima para importar o arquivo de fabricação da OF e visualizar a estrutura com seus apontamentos em tempo real. +

+
+
+ )} + + +
+ + {/* Quality Audit Modal */} + setIsAuditModalOpen(false)} + /> +
+ ); +}