fix(3d-viewer): improve ifc model loading resiliency and fallback topology

This commit is contained in:
Marcos Reifonas
2026-09-19 21:35:18 -03:00
parent 14301cb769
commit d83c0e17d8
3 changed files with 289 additions and 106 deletions
+187 -68
View File
@@ -46,7 +46,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
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 [colorMode, setColorMode] = useState<'description' | 'production'>('production');
const [hasSectionPlanes, setHasSectionPlanes] = useState<boolean>(false);
const [isMeasuring, setIsMeasuring] = useState<boolean>(false);
@@ -55,16 +55,39 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
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,
})
// Smart Matcher: Matches any mark format (e.g. "B135-3-21", "3-21", "21", "B135-21") against production data
const getPieceProductionStatus = useCallback(
(rawMark: string): ProductionPieceStatus | undefined => {
if (!productionData || !rawMark) return undefined;
const clean = rawMark.trim();
// 1. Direct map lookup
if (productionData.has(clean)) return productionData.get(clean);
// 2. Tokenize by -, /, ., _, space
const tokens = clean.split(/[-/._\s]+/).filter(Boolean);
// Suffix combinations e.g. "3-21"
if (tokens.length >= 2) {
const lastTwo = `${tokens[tokens.length - 2]}-${tokens[tokens.length - 1]}`;
if (productionData.has(lastTwo)) return productionData.get(lastTwo);
}
// Last token is the specific piece mark (e.g. "21" in "B135-3-21")
if (tokens.length > 0) {
const lastTok = tokens[tokens.length - 1];
if (productionData.has(lastTok)) return productionData.get(lastTok);
}
// 3. Fallback scan across map values
for (const [key, val] of productionData.entries()) {
if (val.marca === clean) return val;
if (tokens.length > 0 && val.marca === tokens[tokens.length - 1]) return val;
}
return undefined;
},
[productionData]
);
// Initialize Scene
@@ -119,7 +142,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
controlsRef.current = controls;
// 5. Lights
const ambientLight = new THREE.AmbientLight(0xffffff, 0.75);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.85);
scene.add(ambientLight);
const dirLight1 = new THREE.DirectionalLight(0xffffff, 1.2);
@@ -127,11 +150,11 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
dirLight1.castShadow = true;
scene.add(dirLight1);
const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.6);
const dirLight2 = new THREE.DirectionalLight(0x94a3b8, 0.7);
dirLight2.position.set(-100, -50, -100);
scene.add(dirLight2);
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x1e293b, 0.5);
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x1e293b, 0.6);
scene.add(hemiLight);
// 6. Floor Grid
@@ -229,59 +252,116 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
if (!modelData || !modelGroupRef.current) return;
const group = modelGroupRef.current;
const allMeshes: THREE.Mesh[] = [];
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;
}
allMeshes.push(child as THREE.Mesh);
}
});
}, [modelData, colorMode, productionData, opacity, isWireframe]);
if (colorMode === 'production') {
// Group meshes by their resolved piece/assembly mark
const meshesByMark = new Map<string, THREE.Mesh[]>();
allMeshes.forEach((mesh) => {
let pmark = mesh.userData.pieceMark || '';
if (!pmark && mesh.userData.expressID && modelData.pieceByExpressID) {
const pInfo = modelData.pieceByExpressID.get(mesh.userData.expressID);
pmark = pInfo?.pieceMark || '';
}
const prod = getPieceProductionStatus(pmark);
const groupKey = prod ? `${prod.fase}-${prod.marca}` : (pmark || `mesh-${mesh.id}`);
if (!meshesByMark.has(groupKey)) {
meshesByMark.set(groupKey, []);
}
meshesByMark.get(groupKey)!.push(mesh);
});
// Apply proportional coloring for each mark
meshesByMark.forEach((meshes, groupKey) => {
const sampleMark = meshes[0]?.userData.pieceMark || groupKey;
const prod = getPieceProductionStatus(sampleMark);
if (prod && prod.pointedQtd > 0) {
// If pointed, calculate how many mesh instances to highlight green
// Example: 2 of 2 un. -> all 2 green. 2 of 4 un. -> 2 green, 2 gray.
const totalQtd = Math.max(prod.totalQtd || 1, 1);
const pointedQtd = Math.max(prod.pointedQtd, 0);
// If there are multiple meshes, highlight proportional count
const ratio = Math.min(pointedQtd / totalQtd, 1.0);
const countToHighlight = Math.max(1, Math.min(meshes.length, Math.round(meshes.length * ratio)));
meshes.forEach((mesh, idx) => {
const isPointed = idx < countToHighlight;
if (isPointed) {
// Vibrant Light Green (#4ade80) for pointed pieces
mesh.material = new THREE.MeshStandardMaterial({
color: 0x4ade80,
emissive: 0x16a34a,
emissiveIntensity: 0.28,
metalness: 0.35,
roughness: 0.4,
side: THREE.DoubleSide,
});
} else {
// Sleek Metallic Light Gray for unpointed
mesh.material = new THREE.MeshStandardMaterial({
color: 0xb0bec5,
metalness: 0.58,
roughness: 0.38,
side: THREE.DoubleSide,
});
}
});
} else {
// Unpointed / Pending -> Sleek Metallic Light Gray (Steel)
meshes.forEach((mesh) => {
mesh.material = new THREE.MeshStandardMaterial({
color: 0xb0bec5,
metalness: 0.58,
roughness: 0.38,
side: THREE.DoubleSide,
});
});
}
});
} else {
// Color Mode "Descrição" (Palette by Profile/Section)
allMeshes.forEach((mesh) => {
const colorKey = mesh.userData.colorKey;
const cachedMat = modelData.materialsByColor.get(colorKey);
if (cachedMat) {
mesh.material = cachedMat;
} else {
mesh.material = new THREE.MeshStandardMaterial({
color: 0xb0bec5,
metalness: 0.58,
roughness: 0.38,
side: THREE.DoubleSide,
});
}
});
}
// Apply Opacity & Wireframe to all meshes
allMeshes.forEach((mesh) => {
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, getPieceProductionStatus]);
// Camera Toggle (Ortogonal / Perspectiva)
const handleToggleCamera = useCallback(() => {
@@ -353,6 +433,39 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
}
}, []);
// Click Selection
const handleClick = (e: React.MouseEvent<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;
const clickPos = new THREE.Vector2(mouseX, mouseY);
raycasterRef.current.setFromCamera(clickPos, 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,
};
onSelectPiece?.(piece);
}
} else {
onSelectPiece?.(null);
}
};
// 1.5-Second Hover Detection
const handlePointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
if (!containerRef.current || !activeCameraRef.current || !modelGroupRef.current) return;
@@ -411,6 +524,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
return (
<div
ref={containerRef}
onClick={handleClick}
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"
@@ -426,12 +540,17 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
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,
}
? (() => {
const prod = getPieceProductionStatus(hoveredPiece.pieceMark);
return prod
? {
pointedQty: prod.pointedQtd,
totalQty: prod.totalQtd,
currentStage: prod.currentProcessName,
stageColor: prod.processColor,
}
: undefined;
})()
: undefined
}
/>
+59 -20
View File
@@ -160,14 +160,23 @@ export async function loadAndAuditIFC(
// 4.1 Native Aggregates mapping (IFCRELAGGREGATES)
const aggregates = ifcApi.GetLineIDsWithType(modelID, WebIFC.IFCRELAGGREGATES);
const assemblyToChildren = new Map<number, number[]>();
const childToAssemblyMap = 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);
const parentID = typeof rel.RelatingObject === 'number' ? rel.RelatingObject : rel.RelatingObject.value;
const childObjs = Array.isArray(rel.RelatedObjects) ? rel.RelatedObjects : [rel.RelatedObjects];
const childIDs: number[] = [];
for (const c of childObjs) {
const cId = typeof c === 'number' ? c : c?.value;
if (typeof cId === 'number' && cId > 0) {
childIDs.push(cId);
childToAssemblyMap.set(cId, parentID);
}
}
assemblyToChildren.set(parentID, childIDs);
}
} catch {
@@ -240,31 +249,35 @@ export async function loadAndAuditIFC(
if (relId && relId > 0) {
const rel = ifcApi.GetLine(modelID, relId);
if (rel && rel.RelatedObjects && rel.RelatingPropertyDefinition) {
const propDefId = rel.RelatingPropertyDefinition.value;
const propDefId = typeof rel.RelatingPropertyDefinition === 'number'
? rel.RelatingPropertyDefinition
: rel.RelatingPropertyDefinition.value;
if (propDefId && typeof propDefId === 'number' && propDefId > 0) {
const propDef = ifcApi.GetLine(modelID, propDefId);
if (propDef && propDef.HasProperties) {
const props: Record<string, string> = {};
for (const pRef of propDef.HasProperties) {
const pId = pRef?.value;
const pId = typeof pRef === 'number' ? pRef : pRef?.value;
if (pId && typeof pId === 'number' && pId > 0) {
const pLine = ifcApi.GetLine(modelID, pId);
if (pLine && pLine.Name) {
const name = pLine.Name.value;
const name = pLine.Name.value || pLine.Name;
let value = '';
if (pLine.NominalValue) {
value = String(pLine.NominalValue.value);
value = String(pLine.NominalValue.value !== undefined ? pLine.NominalValue.value : pLine.NominalValue);
}
if (name && value) {
props[name] = value;
props[String(name)] = value;
}
}
}
}
const relatedObjs = Array.isArray(rel.RelatedObjects) ? rel.RelatedObjects : [rel.RelatedObjects];
for (const objRef of relatedObjs) {
const objId = objRef?.value || objRef;
if (objId && typeof objId === 'number') {
const objId = typeof objRef === 'number' ? objRef : objRef?.value;
if (objId && typeof objId === 'number' && objId > 0) {
const existing = elementPropertiesMap.get(objId) || {};
elementPropertiesMap.set(objId, { ...existing, ...props });
}
@@ -292,7 +305,8 @@ export async function loadAndAuditIFC(
const assObj = ifcApi.GetLine(modelID, assID);
const props = elementPropertiesMap.get(assID) || {};
const rawProps = getSyncProperties(ifcApi, modelID, assID);
const pmark = props.PieceMark || props.Reference || props.Mark || props.Tag || rawProps.Name || 'indefinido';
let pmark = props.PieceMark || props.Reference || props.Mark || props.Tag || rawProps.Name || 'indefinido';
if (pmark === 'indefinido') pmark = '';
const children = assemblyToChildren.get(assID) || [];
const piece: PieceInfo = {
@@ -308,7 +322,7 @@ export async function loadAndAuditIFC(
ofNumber: extractOFFromMark(pmark)
};
if (pmark !== 'indefinido') {
if (pmark) {
uniqueMarksSet.add(pmark);
if (!piecesByMark.has(pmark)) piecesByMark.set(pmark, []);
piecesByMark.get(pmark)!.push(piece);
@@ -324,10 +338,11 @@ export async function loadAndAuditIFC(
const sceneGroup = new THREE.Group();
sceneGroup.name = 'IFC_ROOT_MODEL';
const defaultGrayMat = new THREE.MeshStandardMaterial({
color: 0x94a3b8,
metalness: 0.35,
roughness: 0.55,
// Base metallic light gray material
const defaultSteelMat = new THREE.MeshStandardMaterial({
color: 0xb0bec5, // Metallic Light Gray
metalness: 0.58,
roughness: 0.38,
side: THREE.DoubleSide
});
@@ -342,7 +357,34 @@ export async function loadAndAuditIFC(
// Check properties for this element
const props = elementPropertiesMap.get(expressID) || {};
const rawProps = getSyncProperties(ifcApi, modelID, expressID);
const pmark = props.PieceMark || props.Reference || props.Mark || props.Tag || rawProps.Name || '';
// Look up mark from element properties OR parent assembly OR weld group peers
let pmark = props.PieceMark || props.Reference || props.Mark || props.Tag || rawProps.Name || '';
if (pmark === 'indefinido') pmark = '';
if (!pmark) {
const parentID = childToAssemblyMap.get(expressID);
if (parentID) {
const parentProps = elementPropertiesMap.get(parentID) || {};
const parentRaw = getSyncProperties(ifcApi, modelID, parentID);
pmark = parentProps.PieceMark || parentProps.Reference || parentProps.Mark || parentProps.Tag || parentRaw.Name || '';
if (pmark === 'indefinido') pmark = '';
}
}
const connectedGroup = weldedGroupMap.get(expressID) || [expressID];
if (!pmark) {
for (const peerID of connectedGroup) {
const peerProps = elementPropertiesMap.get(peerID) || {};
const peerRaw = getSyncProperties(ifcApi, modelID, peerID);
const peerMark = peerProps.PieceMark || peerProps.Reference || peerProps.Mark || peerProps.Tag || peerRaw.Name || '';
if (peerMark && peerMark !== 'indefinido') {
pmark = peerMark;
break;
}
}
}
const section = props.Section || props.Profile || props.Description || rawProps.Name || '';
// Determine section color
@@ -364,10 +406,7 @@ export async function loadAndAuditIFC(
})
);
}
const mat = materialCache.get(hexColor) || defaultGrayMat;
// Get welded connected peers (Fallback topology)
const connectedGroup = weldedGroupMap.get(expressID) || [expressID];
const mat = defaultSteelMat;
for (let g = 0; g < placedGeometries.size(); g++) {
const placedGeo = placedGeometries.get(g);
+43 -18
View File
@@ -99,17 +99,25 @@ export default function Visualizador3D() {
.select('id, of_number, etapa_fase, marca, descricao, quantidade, peso_unitario, peso_total, perfil_principal, material')
.eq('of_number', selectedOF);
// Fetch apontamentos com join de peca e processo
// Fetch apontamentos
const { data: apontamentosData } = await supabase
.from('apontamentos_producao' as any)
.select(`
id, of_number, peca_id, quantidade_produzida,
peca:pecas(marca, etapa_fase),
processo:processos_fabricacao(nome, cor, ordem)
`)
.select('id, of_number, peca_id, processo_id, quantidade_produzida')
.eq('of_number', selectedOF);
// Build phases list
// Fetch processos de fabricação
const { data: processosData } = await supabase
.from('processos_fabricacao' as any)
.select('id, nome, cor, ordem');
const processoById = new Map<string, any>();
if (processosData) {
processosData.forEach((pr: any) => {
processoById.set(pr.id, pr);
});
}
const pecaById = new Map<string, any>();
const phases = new Set<string>();
const prodMap = new Map<string, any>();
let totalPecas = 0;
@@ -117,40 +125,57 @@ export default function Visualizador3D() {
if (pecasData) {
pecasData.forEach((p: any) => {
pecaById.set(p.id, p);
const fase = String(p.etapa_fase || '1');
const marca = String(p.marca || '').trim();
phases.add(fase);
totalPecas += Number(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, {
const pieceStatus = {
pecaId: p.id,
marca: p.marca,
marca,
fase,
of_number: selectedOF,
totalQtd: Number(p.quantidade || 1),
pointedQtd: 0,
currentProcessName: 'Pendente',
processColor: '#64748b'
});
};
// Register multiple lookup keys for resilient matching:
// 1. OF-Fase-Marca: e.g. "B135-3-21"
prodMap.set(`${selectedOF}-${fase}-${marca}`, pieceStatus);
// 2. Fase-Marca: e.g. "3-21"
prodMap.set(`${fase}-${marca}`, pieceStatus);
// 3. Marca only: e.g. "21"
prodMap.set(marca, pieceStatus);
// 4. OF-Marca: e.g. "B135-21"
prodMap.set(`${selectedOF}-${marca}`, pieceStatus);
});
}
let pointedTotal = 0;
if (apontamentosData) {
apontamentosData.forEach((ap: any) => {
const marca = ap.peca?.marca;
const fase = String(ap.peca?.etapa_fase || '1');
if (marca) {
const peca = pecaById.get(ap.peca_id);
if (peca) {
const marca = String(peca.marca || '').trim();
const fase = String(peca.etapa_fase || '1');
const key = `${selectedOF}-${fase}-${marca}`;
const existing = prodMap.get(key);
if (existing) {
const qty = Number(ap.quantidade_produzida || 0);
existing.pointedQtd += qty;
pointedTotal += qty;
if (ap.processo?.nome) {
existing.currentProcessName = ap.processo.nome;
existing.processColor = ap.processo.cor || PROCESS_COLORS[ap.processo.nome] || '#10b981';
const proc = processoById.get(ap.processo_id);
if (proc) {
existing.currentProcessName = proc.nome;
existing.processColor = proc.cor || PROCESS_COLORS[proc.nome] || '#22c55e';
} else {
existing.currentProcessName = 'Apontado';
existing.processColor = '#22c55e';
}
}
}