feat(3d-viewer): replica selecao e hover para modelos WRL com destaque magenta e apontamentos

This commit is contained in:
2026-10-15 10:02:49 -03:00
parent 9cbc9b383e
commit fdec79f81b
3 changed files with 161 additions and 55 deletions
+145 -47
View File
@@ -20,6 +20,53 @@ interface ProductionPieceStatus {
processOrdem?: number;
}
/**
* Busca de apontamento e status fabril com máxima tolerância e amostragem de chaves para IFC e WRL.
*/
function findProductionDataForPiece(
productionData?: Map<string, ProductionPieceStatus>,
piece?: {
pieceMark?: string;
cleanMark?: string;
assemblyMark?: string;
cleanAssemblyMark?: string;
phase?: string;
} | null,
selectedOF?: string
): ProductionPieceStatus | undefined {
if (!productionData || !piece) return undefined;
const pmark = String(piece.pieceMark || '').trim().toUpperCase();
const cleanMark = String(piece.cleanMark || pmark).trim().toUpperCase();
const assMark = String(piece.assemblyMark || '').trim().toUpperCase();
const cleanAssMark = String(piece.cleanAssemblyMark || cleanMark).trim().toUpperCase();
const elementPhase = piece.phase ? String(piece.phase).trim() : '';
const searchKeys = [
elementPhase && selectedOF ? `${selectedOF}-${elementPhase}-${cleanAssMark}` : '',
elementPhase ? `${elementPhase}-${cleanAssMark}` : '',
elementPhase && selectedOF ? `${selectedOF}-${elementPhase}-${cleanMark}` : '',
elementPhase ? `${elementPhase}-${cleanMark}` : '',
selectedOF ? `${selectedOF}-${cleanAssMark}` : '',
selectedOF ? `${selectedOF}-${cleanMark}` : '',
assMark,
pmark,
cleanAssMark,
cleanMark,
].filter(Boolean).map((k) => k.toUpperCase());
for (const key of searchKeys) {
const match = productionData.get(key);
if (match) {
if (!elementPhase || !match.fase || String(match.fase) === String(elementPhase)) {
return match;
}
}
}
return undefined;
}
interface ModelViewer3DProps {
modelData: LoadedIFCResult | null;
productionData?: Map<string, ProductionPieceStatus>;
@@ -721,7 +768,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
}
// Se a peça física pertencer à seleção permanente, preserva a cor magenta
const ifcId = mesh.userData.ifcId;
const ifcId = mesh.userData.ifcId ?? mesh.userData.expressID;
if (ifcId !== undefined && selectedElementsMapRef.current.has(ifcId)) {
mesh.userData.originalMaterial = mesh.material;
mesh.material = magentaMaterialRef.current;
@@ -889,17 +936,21 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
}
if (hitMesh) {
const expressID = hitMesh.userData.ifcId;
const expressID = hitMesh.userData.ifcId ?? hitMesh.userData.expressID;
const pmark = hitMesh.userData.pieceMark;
if (modelData && expressID !== undefined) {
const piece = modelData.pieceByExpressID.get(expressID) || {
expressID,
guid: '',
name: hitMesh.userData.section || 'Peça',
type: 'PIECE',
guid: hitMesh.userData.guid || '',
name: hitMesh.userData.name || hitMesh.userData.section || 'Peça',
type: hitMesh.userData.type || 'PIECE',
pieceMark: pmark,
section: hitMesh.userData.section,
cleanMark: hitMesh.userData.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark,
section: hitMesh.userData.section || hitMesh.userData.name,
phase: hitMesh.userData.phase,
};
setHoveredPiece(piece);
setHoverPosition({ x: clientX, y: clientY });
@@ -956,7 +1007,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
}
if (hitMesh && hitMesh.visible) {
const targetIfcId = hitMesh.userData.ifcId;
const targetIfcId = hitMesh.userData.ifcId ?? hitMesh.userData.expressID;
if (targetIfcId === undefined) return;
const nextMap = new Map(selectedElementsMapRef.current);
@@ -972,16 +1023,19 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
});
nextMap.delete(targetIfcId);
} else {
// Marcar peça física inteira: localiza e pinta todas as sub-malhas do mesmo ifcId
// Marcar peça física inteira: localiza e pinta todas as sub-malhas do mesmo targetIfcId
const relatedMeshes: THREE.Mesh[] = [];
if (modelGroupRef.current) {
modelGroupRef.current.traverse((child) => {
if ((child as THREE.Mesh).isMesh && child.userData.ifcId === targetIfcId) {
const m = child as THREE.Mesh;
m.userData.originalMaterial = m.material;
m.userData.isMagentaSelected = true;
m.material = magentaMaterialRef.current;
relatedMeshes.push(m);
if ((child as THREE.Mesh).isMesh) {
const childId = child.userData.ifcId ?? child.userData.expressID;
if (childId === targetIfcId) {
const m = child as THREE.Mesh;
m.userData.originalMaterial = m.material;
m.userData.isMagentaSelected = true;
m.material = magentaMaterialRef.current;
relatedMeshes.push(m);
}
}
});
}
@@ -989,10 +1043,13 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
const pmark = hitMesh.userData.pieceMark;
const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(targetIfcId)) || {
expressID: targetIfcId,
guid: '',
name: hitMesh.userData.section || 'Peça',
type: 'PIECE',
guid: hitMesh.userData.guid || '',
name: hitMesh.userData.name || hitMesh.userData.section || 'Peça',
type: hitMesh.userData.type || 'PIECE',
pieceMark: pmark,
cleanMark: hitMesh.userData.cleanMark,
assemblyMark: hitMesh.userData.assemblyMark,
cleanAssemblyMark: hitMesh.userData.cleanAssemblyMark,
section: hitMesh.userData.section,
phase: hitMesh.userData.phase,
};
@@ -1018,8 +1075,32 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
const mouseX = ((e.clientX - rect.left) / rect.width) * 2 - 1;
const mouseY = -((e.clientY - rect.top) / rect.height) * 2 + 1;
raycasterRef.current.setFromCamera(new THREE.Vector2(mouseX, mouseY), activeCameraRef.current);
const intersects = raycasterRef.current.intersectObjects(modelGroupRef.current.children, true);
const raycaster = raycasterRef.current;
raycaster.params.Line = { threshold: 4.0 };
raycaster.params.Points = { threshold: 4.0 };
raycaster.setFromCamera(new THREE.Vector2(mouseX, mouseY), activeCameraRef.current);
let intersects = raycaster.intersectObjects(modelGroupRef.current.children, true);
// Amostragem circular magnética de tolerância para peças esbeltas/finas
if (intersects.length === 0) {
const radiusOffsetsPx = [6, 12, 18];
const angles = [0, Math.PI / 4, Math.PI / 2, (3 * Math.PI) / 4, Math.PI, (5 * Math.PI) / 4, (3 * Math.PI) / 2, (7 * Math.PI) / 4];
for (const rPx of radiusOffsetsPx) {
if (intersects.length > 0) break;
for (const angle of angles) {
const dx = ((rPx * Math.cos(angle)) / rect.width) * 2;
const dy = -((rPx * Math.sin(angle)) / rect.height) * 2;
const samplePos = new THREE.Vector2(mouseX + dx, mouseY + dy);
raycaster.setFromCamera(samplePos, activeCameraRef.current);
const sampleIntersects = raycaster.intersectObjects(modelGroupRef.current.children, true);
if (sampleIntersects.length > 0) {
intersects = sampleIntersects;
break;
}
}
}
}
if (intersects.length > 0) {
const hitPoint = intersects[0].point;
@@ -1033,36 +1114,49 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
if (hitMesh) {
const targetPieceMark = String(hitMesh.userData.pieceMark || '').trim().toUpperCase();
const targetCleanMark = String(hitMesh.userData.cleanMark || targetPieceMark).trim().toUpperCase();
const targetAssemblyMark = String(hitMesh.userData.assemblyMark || '').trim().toUpperCase();
const targetCleanAssMark = String(hitMesh.userData.cleanAssemblyMark || targetCleanMark).trim().toUpperCase();
const targetPhase = hitMesh.userData.phase ? String(hitMesh.userData.phase).trim() : '';
if (targetPieceMark) {
if (targetPieceMark || targetCleanMark) {
const nextMap = new Map(selectedElementsMapRef.current);
// Agrupa todas as sub-malhas por elemento físico (ifcId único)
const meshesByIfcId = new Map<number, { meshes: THREE.Mesh[]; sampleMesh: THREE.Mesh }>();
// Agrupa todas as sub-malhas por elemento físico único (ifcId ?? expressID)
const meshesByElementId = new Map<number, { meshes: THREE.Mesh[]; sampleMesh: THREE.Mesh }>();
modelGroupRef.current.traverse((child) => {
if ((child as THREE.Mesh).isMesh && child.visible) {
const mesh = child as THREE.Mesh;
const pmark = String(mesh.userData.pieceMark || '').trim().toUpperCase();
const cmark = String(mesh.userData.cleanMark || pmark).trim().toUpperCase();
const amark = String(mesh.userData.assemblyMark || '').trim().toUpperCase();
const cleanAm = String(mesh.userData.cleanAssemblyMark || cmark).trim().toUpperCase();
const phase = mesh.userData.phase ? String(mesh.userData.phase).trim() : '';
// Mesma marca e mesma fase
const isMatch = pmark === targetPieceMark && (!targetPhase || !phase || phase === targetPhase);
if (isMatch) {
const ifcId = mesh.userData.ifcId;
if (ifcId !== undefined) {
if (!meshesByIfcId.has(ifcId)) {
meshesByIfcId.set(ifcId, { meshes: [], sampleMesh: mesh });
// Mesma marca (peça, conjunto ou limpa) e mesma fase (se especificada)
const isMarkMatch =
(pmark && pmark === targetPieceMark) ||
(cmark && cmark === targetCleanMark) ||
(targetAssemblyMark && amark === targetAssemblyMark) ||
(targetCleanAssMark && cleanAm === targetCleanAssMark);
const isPhaseMatch = !targetPhase || !phase || phase === targetPhase;
if (isMarkMatch && isPhaseMatch) {
const elId = mesh.userData.ifcId ?? mesh.userData.expressID;
if (elId !== undefined) {
if (!meshesByElementId.has(elId)) {
meshesByElementId.set(elId, { meshes: [], sampleMesh: mesh });
}
meshesByIfcId.get(ifcId)!.meshes.push(mesh);
meshesByElementId.get(elId)!.meshes.push(mesh);
}
}
}
});
// Pinta e adiciona cada peça física real como 1 unidade
meshesByIfcId.forEach(({ meshes, sampleMesh }, ifcId) => {
meshesByElementId.forEach(({ meshes, sampleMesh }, elId) => {
meshes.forEach((m) => {
if (!m.userData.isMagentaSelected) {
m.userData.originalMaterial = m.material;
@@ -1071,18 +1165,21 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
m.material = magentaMaterialRef.current;
});
const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(ifcId)) || {
expressID: ifcId,
guid: '',
name: sampleMesh.userData.section || 'Peça',
type: 'PIECE',
const piece: PieceInfo = (modelData && modelData.pieceByExpressID.get(elId)) || {
expressID: elId,
guid: sampleMesh.userData.guid || '',
name: sampleMesh.userData.name || sampleMesh.userData.section || 'Peça',
type: sampleMesh.userData.type || 'PIECE',
pieceMark: sampleMesh.userData.pieceMark,
cleanMark: sampleMesh.userData.cleanMark,
assemblyMark: sampleMesh.userData.assemblyMark,
cleanAssemblyMark: sampleMesh.userData.cleanAssemblyMark,
section: sampleMesh.userData.section,
phase: sampleMesh.userData.phase || targetPhase,
};
nextMap.set(ifcId, {
expressID: ifcId,
nextMap.set(elId, {
expressID: elId,
piece,
meshes,
});
@@ -1118,7 +1215,7 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
const selectedPiecesList: SelectedPieceItem[] = Array.from(selectedElementsMap.values()).map(
({ expressID, piece }) => {
const pmark = piece.pieceMark || 'Sem Marca';
const prod = productionData?.get(pmark);
const prod = findProductionDataForPiece(productionData, piece, selectedOF);
return {
id: String(expressID),
pieceMark: pmark,
@@ -1338,16 +1435,17 @@ export const ModelViewer3D: React.FC<ModelViewer3DProps> = ({
<PieceHoverTooltip
piece={hoveredPiece}
position={hoverPosition}
productionInfo={
hoveredPiece?.pieceMark && productionData
productionInfo={(() => {
const prod = findProductionDataForPiece(productionData, hoveredPiece, selectedOF);
return prod
? {
pointedQty: productionData.get(hoveredPiece.pieceMark)?.pointedQtd,
totalQty: productionData.get(hoveredPiece.pieceMark)?.totalQtd,
currentStage: productionData.get(hoveredPiece.pieceMark)?.currentProcessName,
stageColor: productionData.get(hoveredPiece.pieceMark)?.processColor,
pointedQty: prod.pointedQtd,
totalQty: prod.totalQtd,
currentStage: prod.currentProcessName,
stageColor: prod.processColor,
}
: undefined
}
: undefined;
})()}
/>
{/* Modal de Lista e Exportação da Seleção 3D */}
+10 -7
View File
@@ -557,7 +557,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
}
// 4.3. Preserva o destaque magenta se a peça estiver selecionada pelo operador
const ifcId = mesh.userData.ifcId;
const ifcId = mesh.userData.ifcId ?? mesh.userData.expressID;
if (ifcId !== undefined && selectedElementsMapRef.current.has(ifcId)) {
mesh.userData.originalMaterial = mesh.material;
mesh.material = magentaMaterialRef.current;
@@ -620,7 +620,7 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
else if (hitObj.parent && (hitObj.parent as THREE.Mesh).isMesh) hitMesh = hitObj.parent as THREE.Mesh;
if (hitMesh && hitMesh.visible) {
const targetIfcId = hitMesh.userData.ifcId;
const targetIfcId = hitMesh.userData.ifcId ?? hitMesh.userData.expressID;
if (targetIfcId === undefined) return;
const nextMap = new Map(selectedElementsMapRef.current);
@@ -644,11 +644,14 @@ export const SmartModelViewer3D: React.FC<SmartModelViewer3DProps> = ({
// Marca a peça física inteira em Magenta permanente
const relatedMeshes: THREE.Mesh[] = [];
modelGroupRef.current.traverse((child) => {
if ((child as THREE.Mesh).isMesh && child.userData.ifcId === targetIfcId) {
const m = child as THREE.Mesh;
m.userData.originalMaterial = m.material;
m.material = magentaMaterialRef.current;
relatedMeshes.push(m);
if ((child as THREE.Mesh).isMesh) {
const childId = child.userData.ifcId ?? child.userData.expressID;
if (childId === targetIfcId) {
const m = child as THREE.Mesh;
m.userData.originalMaterial = m.material;
m.material = magentaMaterialRef.current;
relatedMeshes.push(m);
}
}
});
+6 -1
View File
@@ -503,6 +503,8 @@ export async function loadAndAuditWRL(
}
mesh.material = material;
const pieceSection = parsed.cleanMark || parsed.pieceMark || 'Perfil';
const pieceInfo: PieceInfo = {
expressID,
guid,
@@ -512,18 +514,21 @@ export async function loadAndAuditWRL(
cleanMark: parsed.cleanMark,
assemblyMark: parsed.assemblyMark,
cleanAssemblyMark: parsed.cleanAssemblyMark,
section: pieceSection,
phase: parsed.phase,
mesh,
};
// Injeta metadados essenciais no userData do Mesh
// Injeta metadados essenciais no userData do Mesh (com total compatibilidade IFC / Three.js)
mesh.userData = {
ifcId: expressID, // Identificador primordial compatível com Raycaster, Hover e Seleção do visualizador
expressID,
guid,
pieceMark: parsed.pieceMark,
cleanMark: parsed.cleanMark,
assemblyMark: parsed.assemblyMark,
cleanAssemblyMark: parsed.cleanAssemblyMark,
section: pieceSection,
phase: parsed.phase,
type: parsed.detectedType,
name: rawNodeName,