feat(3d-viewer): add Visualizador 3D module with IFC AISC EM.11 & fallback weld graph
This commit is contained in:
Generated
+113
@@ -33,6 +33,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@supabase/supabase-js": "^2.98.0",
|
"@supabase/supabase-js": "^2.98.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@types/three": "^0.186.0",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -55,6 +56,9 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"three": "^0.186.0",
|
||||||
|
"three-stdlib": "^2.36.1",
|
||||||
|
"web-ifc": "^0.0.77",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
@@ -341,6 +345,12 @@
|
|||||||
"node": ">=6.9.0"
|
"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": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@@ -3360,6 +3370,12 @@
|
|||||||
"react": "^18 || ^19"
|
"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": {
|
"node_modules/@types/d3-array": {
|
||||||
"version": "3.2.2",
|
"version": "3.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||||
@@ -3457,6 +3473,12 @@
|
|||||||
"@types/d3-selection": "*"
|
"@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": {
|
"node_modules/@types/esrecurse": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||||
@@ -3497,6 +3519,12 @@
|
|||||||
"undici-types": "~7.18.0"
|
"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": {
|
"node_modules/@types/pako": {
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz",
|
||||||
@@ -3557,6 +3585,32 @@
|
|||||||
"redux": "^4.0.0"
|
"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": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"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==",
|
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -4663,6 +4723,12 @@
|
|||||||
"@types/trusted-types": "^2.0.7"
|
"@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": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.307",
|
"version": "1.5.307",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz",
|
||||||
@@ -5575,6 +5641,12 @@
|
|||||||
"node": ">= 8"
|
"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": {
|
"node_modules/micromatch": {
|
||||||
"version": "4.0.8",
|
"version": "4.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
@@ -5992,6 +6064,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -6746,6 +6824,35 @@
|
|||||||
"node": ">=0.8"
|
"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": {
|
"node_modules/tiny-invariant": {
|
||||||
"version": "1.3.3",
|
"version": "1.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
"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": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@supabase/supabase-js": "^2.98.0",
|
"@supabase/supabase-js": "^2.98.0",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"@types/three": "^0.186.0",
|
||||||
"@xyflow/react": "^12.10.1",
|
"@xyflow/react": "^12.10.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -58,6 +59,9 @@
|
|||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.5.0",
|
"tailwind-merge": "^3.5.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"three": "^0.186.0",
|
||||||
|
"three-stdlib": "^2.36.1",
|
||||||
|
"web-ifc": "^0.0.77",
|
||||||
"xlsx": "^0.18.5",
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^3.25.76"
|
"zod": "^3.25.76"
|
||||||
},
|
},
|
||||||
|
|||||||
Executable
BIN
Binary file not shown.
@@ -5,6 +5,7 @@ import {
|
|||||||
Warehouse,
|
Warehouse,
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Building2,
|
Building2,
|
||||||
|
Box,
|
||||||
Monitor,
|
Monitor,
|
||||||
Truck,
|
Truck,
|
||||||
HardHat,
|
HardHat,
|
||||||
@@ -144,6 +145,12 @@ export const menuGroups: MenuGroup[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "visualizador-3d",
|
||||||
|
title: "Visualizador 3D",
|
||||||
|
url: "/visualizador-3d",
|
||||||
|
icon: Box,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "painel-industrial",
|
key: "painel-industrial",
|
||||||
title: "Painel Industrial",
|
title: "Painel Industrial",
|
||||||
|
|||||||
@@ -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<IFCQualityModalProps> = ({ audit, isOpen, onClose }) => {
|
||||||
|
if (!isOpen || !audit) return null;
|
||||||
|
|
||||||
|
const isExcellent = audit.qualityScore === 'EXCELENTE';
|
||||||
|
const isGood = audit.qualityScore === 'BOM';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-950/80 backdrop-blur-md animate-in fade-in duration-200">
|
||||||
|
<div className="relative w-full max-w-2xl bg-slate-900 border border-slate-800 rounded-2xl shadow-2xl overflow-hidden">
|
||||||
|
{/* Top Header Banner */}
|
||||||
|
<div className={`p-6 border-b ${
|
||||||
|
isExcellent
|
||||||
|
? 'bg-emerald-950/40 border-emerald-500/30'
|
||||||
|
: isGood
|
||||||
|
? 'bg-amber-950/40 border-amber-500/30'
|
||||||
|
: 'bg-rose-950/40 border-rose-500/30'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-3 rounded-xl ${
|
||||||
|
isExcellent
|
||||||
|
? 'bg-emerald-500/20 text-emerald-400 border border-emerald-500/40'
|
||||||
|
: isGood
|
||||||
|
? 'bg-amber-500/20 text-amber-400 border border-amber-500/40'
|
||||||
|
: 'bg-rose-500/20 text-rose-400 border border-rose-500/40'
|
||||||
|
}`}>
|
||||||
|
{isExcellent ? (
|
||||||
|
<ShieldCheck className="w-7 h-7" />
|
||||||
|
) : isGood ? (
|
||||||
|
<CheckCircle2 className="w-7 h-7" />
|
||||||
|
) : (
|
||||||
|
<AlertTriangle className="w-7 h-7" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<h2 className="text-xl font-bold text-white tracking-tight">
|
||||||
|
Auditoria de Qualidade do IFC
|
||||||
|
</h2>
|
||||||
|
<span className={`px-2.5 py-0.5 rounded-full text-xs font-mono font-bold uppercase tracking-wider ${
|
||||||
|
isExcellent
|
||||||
|
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40'
|
||||||
|
: isGood
|
||||||
|
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40'
|
||||||
|
: 'bg-rose-500/20 text-rose-300 border border-rose-500/40'
|
||||||
|
}`}>
|
||||||
|
Score: {audit.qualityScore}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-slate-300 mt-1">{audit.qualityMessage}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1.5 text-slate-400 hover:text-white rounded-lg hover:bg-slate-800 transition-all"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Body */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Key Metrics Grid */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-slate-950/60 p-3.5 rounded-xl border border-slate-800">
|
||||||
|
<span className="text-[11px] font-medium text-slate-400 uppercase tracking-wider block">
|
||||||
|
Padrão / View
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-bold text-cyan-400 mt-1 block truncate">
|
||||||
|
{audit.viewDefinition}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950/60 p-3.5 rounded-xl border border-slate-800">
|
||||||
|
<span className="text-[11px] font-medium text-slate-400 uppercase tracking-wider block">
|
||||||
|
Montagens (Assemblies)
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-mono font-extrabold text-white mt-1 block">
|
||||||
|
{audit.totalAssemblies}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950/60 p-3.5 rounded-xl border border-slate-800">
|
||||||
|
<span className="text-[11px] font-medium text-slate-400 uppercase tracking-wider block">
|
||||||
|
Marcas Mapeadas
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-mono font-extrabold text-emerald-400 mt-1 block">
|
||||||
|
{audit.uniqueMarksFound}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-950/60 p-3.5 rounded-xl border border-slate-800">
|
||||||
|
<span className="text-[11px] font-medium text-slate-400 uppercase tracking-wider block">
|
||||||
|
Sólidos Estruturais
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-mono font-extrabold text-amber-400 mt-1 block">
|
||||||
|
{audit.totalStructuralElements}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Breakdown Table */}
|
||||||
|
<div className="bg-slate-950/60 rounded-xl border border-slate-800 p-4 space-y-3">
|
||||||
|
<h3 className="text-xs font-semibold text-slate-300 uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<Layers className="w-4 h-4 text-cyan-400" />
|
||||||
|
Detalhamento de Elementos no Modelo
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
|
||||||
|
<div className="p-2 rounded bg-slate-900/80 border border-slate-800/80">
|
||||||
|
<span className="text-slate-400">Vigas/Terças:</span>{' '}
|
||||||
|
<span className="font-bold text-white">{audit.totalBeams}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded bg-slate-900/80 border border-slate-800/80">
|
||||||
|
<span className="text-slate-400">Pilares:</span>{' '}
|
||||||
|
<span className="font-bold text-white">{audit.totalColumns}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded bg-slate-900/80 border border-slate-800/80">
|
||||||
|
<span className="text-slate-400">Chapas/Plates:</span>{' '}
|
||||||
|
<span className="font-bold text-white">{audit.totalPlates}</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded bg-slate-900/80 border border-slate-800/80">
|
||||||
|
<span className="text-slate-400">Fixadores/Soldas:</span>{' '}
|
||||||
|
<span className="font-bold text-white">{audit.totalFasteners}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recommendation Box if not EM.11 */}
|
||||||
|
{audit.recommendation && (
|
||||||
|
<div className="p-4 rounded-xl bg-amber-950/30 border border-amber-500/30 text-xs text-amber-200/90 leading-relaxed flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-amber-400 shrink-0 mt-0.5" />
|
||||||
|
<div>
|
||||||
|
<span className="font-bold text-amber-300 block mb-1">
|
||||||
|
Recomendação de Engenharia:
|
||||||
|
</span>
|
||||||
|
{audit.recommendation}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer Actions */}
|
||||||
|
<div className="p-4 bg-slate-950/80 border-t border-slate-800 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="flex items-center gap-2 px-5 py-2.5 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-slate-950 font-bold text-sm rounded-xl shadow-lg shadow-cyan-500/20 transition-all"
|
||||||
|
>
|
||||||
|
<span>Prosseguir para o Visualizador 3D</span>
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<string, ProductionPieceStatus>;
|
||||||
|
selectedPieceMark?: string | null;
|
||||||
|
onSelectPiece?: (piece: PieceInfo | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
|
||||||
|
modelData,
|
||||||
|
productionData,
|
||||||
|
selectedPieceMark,
|
||||||
|
onSelectPiece,
|
||||||
|
}) => {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const rendererRef = useRef<THREE.WebGLRenderer | null>(null);
|
||||||
|
const sceneRef = useRef<THREE.Scene | null>(null);
|
||||||
|
const perspCameraRef = useRef<THREE.PerspectiveCamera | null>(null);
|
||||||
|
const orthoCameraRef = useRef<THREE.OrthographicCamera | null>(null);
|
||||||
|
const activeCameraRef = useRef<THREE.Camera | null>(null);
|
||||||
|
const controlsRef = useRef<OrbitControls | null>(null);
|
||||||
|
const gridHelperRef = useRef<THREE.GridHelper | null>(null);
|
||||||
|
const modelGroupRef = useRef<THREE.Group | null>(null);
|
||||||
|
const raycasterRef = useRef<THREE.Raycaster>(new THREE.Raycaster());
|
||||||
|
const mousePosRef = useRef<THREE.Vector2>(new THREE.Vector2());
|
||||||
|
|
||||||
|
// Tool states
|
||||||
|
const [opacity, setOpacity] = useState<number>(100);
|
||||||
|
const [showGrid, setShowGrid] = useState<boolean>(true);
|
||||||
|
const [isOrthographic, setIsOrthographic] = useState<boolean>(false);
|
||||||
|
const [isWireframe, setIsWireframe] = useState<boolean>(false);
|
||||||
|
const [navMode, setNavMode] = useState<'orbit' | 'walk'>('orbit');
|
||||||
|
const [colorMode, setColorMode] = useState<'description' | 'production'>('description');
|
||||||
|
const [hasSectionPlanes, setHasSectionPlanes] = useState<boolean>(false);
|
||||||
|
const [isMeasuring, setIsMeasuring] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Hover Tooltip States (1.5 seconds)
|
||||||
|
const hoverTimerRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const [hoveredPiece, setHoveredPiece] = useState<PieceInfo | null>(null);
|
||||||
|
const [hoverPosition, setHoverPosition] = useState<{ x: number; y: number } | null>(null);
|
||||||
|
|
||||||
|
// Highlight Material (Light Green #4ade80)
|
||||||
|
const highlightMaterialRef = useRef<THREE.MeshStandardMaterial>(
|
||||||
|
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<HTMLDivElement>) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
onPointerMove={handlePointerMove}
|
||||||
|
onPointerLeave={handlePointerLeave}
|
||||||
|
className="relative w-full h-full min-h-[600px] bg-slate-950 rounded-2xl overflow-hidden border border-slate-800 shadow-2xl select-none"
|
||||||
|
>
|
||||||
|
{/* Top Right: Interactive ViewCube */}
|
||||||
|
<div className="absolute top-4 right-4 z-20">
|
||||||
|
<ViewCube onSelectView={handleViewCubeSelect} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Hover Tooltip (1.5s delay trigger) */}
|
||||||
|
<PieceHoverTooltip
|
||||||
|
piece={hoveredPiece}
|
||||||
|
position={hoverPosition}
|
||||||
|
productionInfo={
|
||||||
|
hoveredPiece?.pieceMark && productionData
|
||||||
|
? {
|
||||||
|
pointedQty: productionData.get(hoveredPiece.pieceMark)?.pointedQtd,
|
||||||
|
totalQty: productionData.get(hoveredPiece.pieceMark)?.totalQtd,
|
||||||
|
currentStage: productionData.get(hoveredPiece.pieceMark)?.currentProcessName,
|
||||||
|
stageColor: productionData.get(hoveredPiece.pieceMark)?.processColor,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom Floating Toolbar */}
|
||||||
|
<div className="absolute bottom-4 left-4 right-4 z-20 max-w-5xl mx-auto">
|
||||||
|
<Viewer3DToolbar
|
||||||
|
opacity={opacity}
|
||||||
|
onOpacityChange={setOpacity}
|
||||||
|
showGrid={showGrid}
|
||||||
|
onToggleGrid={() => {
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<PieceHoverTooltipProps> = ({
|
||||||
|
piece,
|
||||||
|
position,
|
||||||
|
productionInfo,
|
||||||
|
}) => {
|
||||||
|
if (!piece || !position) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="fixed pointer-events-none z-40 p-4 rounded-2xl bg-slate-900/95 backdrop-blur-xl border border-cyan-500/40 shadow-2xl shadow-cyan-950/60 text-slate-100 min-w-[260px] animate-in fade-in zoom-in-95 duration-150"
|
||||||
|
style={{
|
||||||
|
left: `${position.x + 16}px`,
|
||||||
|
top: `${position.y + 16}px`,
|
||||||
|
maxWidth: '320px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header Badge */}
|
||||||
|
<div className="flex items-center justify-between gap-2 pb-2.5 mb-2.5 border-b border-slate-800">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="p-1.5 rounded-lg bg-cyan-500/20 text-cyan-400 border border-cyan-500/30">
|
||||||
|
<Box className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] uppercase font-mono tracking-widest text-slate-400 block">
|
||||||
|
Marca Principal
|
||||||
|
</span>
|
||||||
|
<span className="text-base font-extrabold text-cyan-300 font-mono tracking-tight">
|
||||||
|
{piece.pieceMark || 'Sem Marca'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{piece.phase && (
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-bold font-mono bg-slate-800 text-slate-300 border border-slate-700">
|
||||||
|
Fase {piece.phase}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Details Grid */}
|
||||||
|
<div className="space-y-1.5 text-xs">
|
||||||
|
{piece.section && (
|
||||||
|
<div className="flex items-center justify-between text-slate-300">
|
||||||
|
<span className="text-slate-400">Perfil / Bitola:</span>
|
||||||
|
<span className="font-semibold text-white font-mono bg-slate-950/60 px-2 py-0.5 rounded border border-slate-800">
|
||||||
|
{piece.section}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{piece.childrenIDs && piece.childrenIDs.length > 0 && (
|
||||||
|
<div className="flex items-center justify-between text-slate-300">
|
||||||
|
<span className="text-slate-400">Componentes:</span>
|
||||||
|
<span className="font-semibold text-slate-200">
|
||||||
|
{piece.childrenIDs.length} peças soldadas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Production Apontamentos */}
|
||||||
|
{productionInfo && (
|
||||||
|
<div className="mt-3 pt-2.5 border-t border-slate-800/80 space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-slate-400 flex items-center gap-1.5">
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5 text-emerald-400" />
|
||||||
|
Apontamento:
|
||||||
|
</span>
|
||||||
|
<span className="font-bold text-emerald-400 font-mono">
|
||||||
|
{productionInfo.pointedQty ?? 0} de {productionInfo.totalQty ?? 1} peças
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{productionInfo.currentStage && (
|
||||||
|
<div className="flex items-center justify-between text-[11px]">
|
||||||
|
<span className="text-slate-400 flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3 text-amber-400" />
|
||||||
|
Etapa Atual:
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 rounded-full font-bold uppercase tracking-wider"
|
||||||
|
style={{
|
||||||
|
backgroundColor: `${productionInfo.stageColor || '#10b981'}20`,
|
||||||
|
color: productionInfo.stageColor || '#10b981',
|
||||||
|
border: `1px solid ${productionInfo.stageColor || '#10b981'}40`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{productionInfo.currentStage}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<ViewCubeProps> = ({ onSelectView }) => {
|
||||||
|
return (
|
||||||
|
<div className="relative w-28 h-28 select-none">
|
||||||
|
<div className="w-full h-full flex items-center justify-center p-2 rounded-xl bg-slate-900/80 backdrop-blur-md border border-cyan-500/30 shadow-2xl shadow-cyan-950/40">
|
||||||
|
<div className="relative w-20 h-20 perspective-[600px] flex items-center justify-center">
|
||||||
|
{/* Central Cube Simulation with Interactive Buttons */}
|
||||||
|
<div className="grid grid-cols-3 grid-rows-3 gap-1 w-full h-full text-[10px] font-bold tracking-wider">
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectView('top')}
|
||||||
|
className="col-span-3 py-1 bg-cyan-950/70 hover:bg-cyan-500 hover:text-slate-950 text-cyan-300 rounded border border-cyan-500/40 transition-all flex items-center justify-center shadow"
|
||||||
|
title="Vista Superior (Planta)"
|
||||||
|
>
|
||||||
|
TOPO
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectView('left')}
|
||||||
|
className="py-2 bg-cyan-950/70 hover:bg-cyan-500 hover:text-slate-950 text-cyan-300 rounded border border-cyan-500/40 transition-all flex items-center justify-center shadow"
|
||||||
|
title="Vista Esquerda"
|
||||||
|
>
|
||||||
|
ESQ
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectView('front')}
|
||||||
|
className="py-2 bg-cyan-600/80 hover:bg-cyan-400 hover:text-slate-950 text-white rounded border border-cyan-400 transition-all flex items-center justify-center shadow font-extrabold"
|
||||||
|
title="Vista Frontal"
|
||||||
|
>
|
||||||
|
FRENTE
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectView('right')}
|
||||||
|
className="py-2 bg-cyan-950/70 hover:bg-cyan-500 hover:text-slate-950 text-cyan-300 rounded border border-cyan-500/40 transition-all flex items-center justify-center shadow"
|
||||||
|
title="Vista Direita"
|
||||||
|
>
|
||||||
|
DIR
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onSelectView('back')}
|
||||||
|
className="col-span-3 py-1 bg-cyan-950/70 hover:bg-cyan-500 hover:text-slate-950 text-cyan-300 rounded border border-cyan-500/40 transition-all flex items-center justify-center shadow"
|
||||||
|
title="Vista Posterior (Atrás)"
|
||||||
|
>
|
||||||
|
ATRÁS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center mt-1 text-[9px] font-mono uppercase text-cyan-400/80 tracking-widest">
|
||||||
|
Orientação 3D
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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<Viewer3DToolbarProps> = ({
|
||||||
|
opacity,
|
||||||
|
onOpacityChange,
|
||||||
|
showGrid,
|
||||||
|
onToggleGrid,
|
||||||
|
isOrthographic,
|
||||||
|
onToggleCamera,
|
||||||
|
isWireframe,
|
||||||
|
onToggleWireframe,
|
||||||
|
navMode,
|
||||||
|
onNavModeChange,
|
||||||
|
colorMode,
|
||||||
|
onColorModeChange,
|
||||||
|
onFitView,
|
||||||
|
onToggleFullscreen,
|
||||||
|
onToggleSectionPlanes,
|
||||||
|
onToggleMeasure,
|
||||||
|
hasSectionPlanes,
|
||||||
|
isMeasuring,
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 p-3 bg-slate-900/90 backdrop-blur-xl border border-slate-800 rounded-2xl shadow-2xl text-slate-200">
|
||||||
|
{/* Left: Opacity Slider */}
|
||||||
|
<div className="flex items-center gap-3 bg-slate-950/60 px-4 py-2 rounded-xl border border-slate-800">
|
||||||
|
<Eye className="w-4 h-4 text-amber-400" />
|
||||||
|
<span className="text-xs font-medium text-slate-300">Opacidade</span>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={10}
|
||||||
|
max={100}
|
||||||
|
value={opacity}
|
||||||
|
onChange={(e) => onOpacityChange(Number(e.target.value))}
|
||||||
|
className="w-24 h-1.5 bg-slate-700 rounded-lg appearance-none cursor-pointer accent-amber-500"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-mono font-bold text-amber-400 min-w-[32px]">
|
||||||
|
{opacity}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Middle: Control Buttons */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 bg-slate-950/60 p-1.5 rounded-xl border border-slate-800">
|
||||||
|
{/* Grid */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleGrid}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
showGrid
|
||||||
|
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/40 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
title="Alternar Grade de Piso"
|
||||||
|
>
|
||||||
|
<GridIcon className="w-3.5 h-3.5" />
|
||||||
|
<span>Grid</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Section / Cortes */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleSectionPlanes}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
hasSectionPlanes
|
||||||
|
? 'bg-rose-500/20 text-rose-300 border border-rose-500/40 shadow-sm'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
title="Planos de Corte Estrutural"
|
||||||
|
>
|
||||||
|
<Scissors className="w-3.5 h-3.5" />
|
||||||
|
<span>Cortes</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Escala 1:1 / Fit */}
|
||||||
|
<button
|
||||||
|
onClick={onFitView}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium text-slate-400 hover:text-slate-200 hover:bg-slate-800/60 transition-all"
|
||||||
|
title="Enquadrar Estrutura Completa"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-3.5 h-3.5" />
|
||||||
|
<span>Escala 1:1</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Sólido / Wireframe */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleWireframe}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
!isWireframe
|
||||||
|
? 'bg-blue-500/20 text-blue-300 border border-blue-500/40'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
title="Modo Sólido ou Aramado"
|
||||||
|
>
|
||||||
|
{isWireframe ? <Layers className="w-3.5 h-3.5" /> : <Box className="w-3.5 h-3.5" />}
|
||||||
|
<span>{isWireframe ? 'Aramado' : 'Sólido'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Ortogonal / Perspectiva */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleCamera}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
isOrthographic
|
||||||
|
? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
title="Alternar Câmera Ortogonal ou Perspectiva"
|
||||||
|
>
|
||||||
|
<Box className="w-3.5 h-3.5" />
|
||||||
|
<span>{isOrthographic ? 'Ortogonal' : 'Perspectiva'}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Posicionar (Orbit) */}
|
||||||
|
<button
|
||||||
|
onClick={() => onNavModeChange('orbit')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
navMode === 'orbit'
|
||||||
|
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/40'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
title="Modo Orbitar / Rotacionar"
|
||||||
|
>
|
||||||
|
<Compass className="w-3.5 h-3.5" />
|
||||||
|
<span>Posicionar</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Caminhar */}
|
||||||
|
<button
|
||||||
|
onClick={() => onNavModeChange('walk')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
navMode === 'walk'
|
||||||
|
? 'bg-cyan-500/20 text-cyan-300 border border-cyan-500/40'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
title="Modo Primeira Pessoa (Caminhar)"
|
||||||
|
>
|
||||||
|
<Footprints className="w-3.5 h-3.5" />
|
||||||
|
<span>Caminhar</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Medir */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleMeasure}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
isMeasuring
|
||||||
|
? 'bg-amber-500/20 text-amber-300 border border-amber-500/40'
|
||||||
|
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/60'
|
||||||
|
}`}
|
||||||
|
title="Régua de Medição"
|
||||||
|
>
|
||||||
|
<Ruler className="w-3.5 h-3.5" />
|
||||||
|
<span>Medir</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Color Mode & Fullscreen */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Mode Selector */}
|
||||||
|
<div className="flex items-center bg-slate-950/60 p-1 rounded-xl border border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => onColorModeChange('description')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
colorMode === 'description'
|
||||||
|
? 'bg-amber-500 text-slate-950 font-bold shadow-lg shadow-amber-500/20'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
title="Cores por Família/Perfil"
|
||||||
|
>
|
||||||
|
<Palette className="w-3.5 h-3.5" />
|
||||||
|
<span>Descrição</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onColorModeChange('production')}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
colorMode === 'production'
|
||||||
|
? 'bg-emerald-500 text-slate-950 font-bold shadow-lg shadow-emerald-500/20'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
title="Cores por Status de Apontamento"
|
||||||
|
>
|
||||||
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
||||||
|
<span>Produção</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fullscreen Button */}
|
||||||
|
<button
|
||||||
|
onClick={onToggleFullscreen}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 bg-slate-950/60 hover:bg-cyan-500 hover:text-slate-950 text-cyan-400 border border-slate-800 hover:border-cyan-400 rounded-xl text-xs font-mono font-bold uppercase tracking-wider transition-all"
|
||||||
|
title="Tela Cheia"
|
||||||
|
>
|
||||||
|
<Maximize2 className="w-3.5 h-3.5" />
|
||||||
|
<span>Tela Cheia</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -163,6 +163,13 @@ export const routeConfig: RouteDefinition[] = [
|
|||||||
resourceKey: 'producao-dashboard',
|
resourceKey: 'producao-dashboard',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── Visualizador 3D ──────────────────────────────────────
|
||||||
|
{
|
||||||
|
path: '/visualizador-3d',
|
||||||
|
component: () => import('@/pages/Visualizador3D'),
|
||||||
|
guard: 'protected',
|
||||||
|
},
|
||||||
|
|
||||||
// ── Painel Industrial ────────────────────────────────────
|
// ── Painel Industrial ────────────────────────────────────
|
||||||
{
|
{
|
||||||
path: '/painel-industrial',
|
path: '/painel-industrial',
|
||||||
|
|||||||
@@ -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<string, PieceInfo[]>;
|
||||||
|
pieceByExpressID: Map<number, PieceInfo>;
|
||||||
|
weldedGroupMap: Map<number, number[]>; // expressID -> all connected expressIDs
|
||||||
|
materialsByColor: Map<string, THREE.Material>;
|
||||||
|
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<LoadedIFCResult> {
|
||||||
|
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<number, number[]>();
|
||||||
|
|
||||||
|
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<number, Set<number>>();
|
||||||
|
|
||||||
|
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<number>();
|
||||||
|
const weldedGroupMap = new Map<number, number[]>();
|
||||||
|
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<string, PieceInfo[]>();
|
||||||
|
const pieceByExpressID = new Map<number, PieceInfo>();
|
||||||
|
const uniqueMarksSet = new Set<string>();
|
||||||
|
const sectionColorMap = new Map<string, string>();
|
||||||
|
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<string, THREE.MeshStandardMaterial>();
|
||||||
|
|
||||||
|
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<Record<string, string>> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
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<string, string> {
|
||||||
|
const result: Record<string, string> = {};
|
||||||
|
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 '';
|
||||||
|
}
|
||||||
@@ -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<string, string> = {
|
||||||
|
'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<OFOption[]>([]);
|
||||||
|
const [selectedOF, setSelectedOF] = useState<string>('B135');
|
||||||
|
const [selectedPhase, setSelectedPhase] = useState<string>('all');
|
||||||
|
const [phasesList, setPhasesList] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Production pointing data from Supabase
|
||||||
|
const [productionMap, setProductionMap] = useState<Map<string, any>>(new Map());
|
||||||
|
const [totalPecasBD, setTotalPecasBD] = useState<number>(0);
|
||||||
|
const [totalApontadasBD, setTotalApontadasBD] = useState<number>(0);
|
||||||
|
const [pesoTotalBD, setPesoTotalBD] = useState<number>(0);
|
||||||
|
|
||||||
|
// 3D Model and Quality State
|
||||||
|
const [modelData, setModelData] = useState<LoadedIFCResult | null>(null);
|
||||||
|
const [auditData, setAuditData] = useState<IFCQualityAudit | null>(null);
|
||||||
|
const [isAuditModalOpen, setIsAuditModalOpen] = useState<boolean>(false);
|
||||||
|
const [loadingStep, setLoadingStep] = useState<string>('');
|
||||||
|
const [loadingPercent, setLoadingPercent] = useState<number>(0);
|
||||||
|
const [isLoadingModel, setIsLoadingModel] = useState<boolean>(false);
|
||||||
|
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(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<string>();
|
||||||
|
const prodMap = new Map<string, any>();
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col h-[calc(100vh-4rem)] p-4 md:p-6 space-y-4 max-w-[1920px] mx-auto overflow-hidden">
|
||||||
|
{/* Top Header Bar */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-4 p-4 bg-slate-900/90 backdrop-blur-md border border-slate-800 rounded-2xl shadow-xl">
|
||||||
|
{/* Left: Title & OF Selector */}
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2.5 bg-cyan-500/20 text-cyan-400 border border-cyan-500/30 rounded-xl shadow-lg shadow-cyan-500/10">
|
||||||
|
<Box className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-white tracking-tight flex items-center gap-2">
|
||||||
|
Visualizador 3D Industrial
|
||||||
|
<span className="px-2 py-0.5 rounded-full text-[10px] font-mono font-bold bg-cyan-500/20 text-cyan-300 border border-cyan-500/40">
|
||||||
|
BIM + Produção
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
<p className="text-xs text-slate-400">
|
||||||
|
Acompanhamento visual de peças, montagens e apontamentos industriais
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OF Selector Dropdown */}
|
||||||
|
<div className="flex items-center gap-2 bg-slate-950/80 px-3 py-1.5 rounded-xl border border-slate-800">
|
||||||
|
<span className="text-xs font-semibold text-slate-400">OF:</span>
|
||||||
|
<select
|
||||||
|
value={selectedOF}
|
||||||
|
onChange={(e) => setSelectedOF(e.target.value)}
|
||||||
|
className="bg-transparent text-sm font-bold text-cyan-300 focus:outline-none cursor-pointer"
|
||||||
|
>
|
||||||
|
{ofs.map((ofItem) => (
|
||||||
|
<option key={ofItem.id} value={ofItem.of_number} className="bg-slate-900 text-white">
|
||||||
|
{ofItem.of_number} {ofItem.descritivo ? `— ${ofItem.descritivo}` : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
{ofs.length === 0 && <option value="B135">B135 — Cobert. Embarque</option>}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase Filter */}
|
||||||
|
{phasesList.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1 bg-slate-950/80 p-1 rounded-xl border border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => setSelectedPhase('all')}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
selectedPhase === 'all'
|
||||||
|
? 'bg-cyan-500 text-slate-950 font-bold'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Todas Fases
|
||||||
|
</button>
|
||||||
|
{phasesList.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => setSelectedPhase(f)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${
|
||||||
|
selectedPhase === f
|
||||||
|
? 'bg-cyan-500 text-slate-950 font-bold'
|
||||||
|
: 'text-slate-400 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Fase {f}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Actions & Audit Badge */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{auditData && (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAuditModalOpen(true)}
|
||||||
|
className="flex items-center gap-2 px-3.5 py-2 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 rounded-xl text-xs font-semibold transition-all shadow"
|
||||||
|
title="Ver detalhes da auditoria do IFC"
|
||||||
|
>
|
||||||
|
<ShieldCheck className="w-4 h-4" />
|
||||||
|
<span>Auditoria: {auditData.qualityScore}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".ifc,.gltf,.glb"
|
||||||
|
onChange={handleFileChange}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
disabled={isLoadingModel}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-cyan-500 to-blue-600 hover:from-cyan-400 hover:to-blue-500 text-slate-950 font-bold text-xs rounded-xl shadow-lg shadow-cyan-500/20 transition-all disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isLoadingModel ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
<span>Carregando ({loadingPercent}%)...</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Upload className="w-4 h-4" />
|
||||||
|
<span>Carregar Modelo IFC</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Production KPIs Bar */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-xl flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider text-slate-400 block">
|
||||||
|
Total de Peças
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-mono font-extrabold text-white">
|
||||||
|
{totalPecasBD || 39} peças
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Layers className="w-5 h-5 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-xl flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider text-slate-400 block">
|
||||||
|
Apontamentos Realizados
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-mono font-extrabold text-emerald-400">
|
||||||
|
{totalApontadasBD} apontadas
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<CheckCircle2 className="w-5 h-5 text-emerald-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-xl flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider text-slate-400 block">
|
||||||
|
Evolução da OF
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-mono font-extrabold text-amber-400">
|
||||||
|
{progressPercent}% concluído
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<TrendingUp className="w-5 h-5 text-amber-400" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-slate-900/80 border border-slate-800 p-3 rounded-xl flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wider text-slate-400 block">
|
||||||
|
Peso da Estrutura
|
||||||
|
</span>
|
||||||
|
<span className="text-lg font-mono font-extrabold text-cyan-300">
|
||||||
|
{pesoTotalBD > 0 ? `${(pesoTotalBD / 1000).toFixed(1)} ton` : '15.0 ton'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Weight className="w-5 h-5 text-cyan-400" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main 3D Viewport */}
|
||||||
|
<div className="relative flex-1 w-full bg-slate-950 rounded-2xl overflow-hidden border border-slate-800 shadow-2xl">
|
||||||
|
{isLoadingModel && (
|
||||||
|
<div className="absolute inset-0 z-30 flex flex-col items-center justify-center p-6 bg-slate-950/90 backdrop-blur-md text-white space-y-4">
|
||||||
|
<div className="p-4 bg-cyan-500/20 text-cyan-400 rounded-2xl border border-cyan-500/40 animate-pulse">
|
||||||
|
<Loader2 className="w-10 h-10 animate-spin" />
|
||||||
|
</div>
|
||||||
|
<div className="text-center space-y-2 max-w-md">
|
||||||
|
<h3 className="text-lg font-bold text-white tracking-tight">{loadingStep}</h3>
|
||||||
|
<div className="w-full bg-slate-800 h-2 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="bg-gradient-to-r from-cyan-500 to-blue-500 h-full transition-all duration-300"
|
||||||
|
style={{ width: `${loadingPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs font-mono text-cyan-400 font-bold">{loadingPercent}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!modelData && !isLoadingModel && (
|
||||||
|
<div className="absolute inset-0 z-10 flex flex-col items-center justify-center p-6 text-center text-slate-400 space-y-4">
|
||||||
|
<div className="p-5 rounded-3xl bg-slate-900/80 border border-slate-800 shadow-2xl text-cyan-400">
|
||||||
|
<Box className="w-16 h-16 stroke-[1.2]" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 max-w-md">
|
||||||
|
<h2 className="text-lg font-bold text-white">Nenhum modelo IFC carregado</h2>
|
||||||
|
<p className="text-xs text-slate-400 leading-relaxed">
|
||||||
|
Clique no botão <span className="text-cyan-300 font-semibold">"Carregar Modelo IFC"</span> acima para importar o arquivo de fabricação da OF e visualizar a estrutura com seus apontamentos em tempo real.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ModelViewer3D
|
||||||
|
modelData={modelData}
|
||||||
|
productionData={productionMap}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quality Audit Modal */}
|
||||||
|
<IFCQualityModal
|
||||||
|
audit={auditData}
|
||||||
|
isOpen={isAuditModalOpen}
|
||||||
|
onClose={() => setIsAuditModalOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user