From 3abc47e3c4746ea2befc42cd470c82fe95f4c6d2 Mon Sep 17 00:00:00 2001 From: Marcos Reifonas Date: Thu, 24 Sep 2026 11:51:52 +0000 Subject: [PATCH] feat: suporte a modelos 3D WRL (VRML) com extrator de pecas e apontamentos --- src/lib/3d/modelLoaderService.ts | 63 ++++ src/lib/3d/wrlLoaderService.ts | 313 ++++++++++++++++++ src/lib/ifc/ifcStorageService.ts | 20 +- src/pages/Visualizador3D.tsx | 26 +- .../smart/components/SmartViewer3DFlow.tsx | 6 +- 5 files changed, 404 insertions(+), 24 deletions(-) create mode 100644 src/lib/3d/modelLoaderService.ts create mode 100644 src/lib/3d/wrlLoaderService.ts diff --git a/src/lib/3d/modelLoaderService.ts b/src/lib/3d/modelLoaderService.ts new file mode 100644 index 0000000..7203865 --- /dev/null +++ b/src/lib/3d/modelLoaderService.ts @@ -0,0 +1,63 @@ +import { loadAndAuditIFC, LoadedIFCResult, PieceInfo, IFCQualityAudit, getColorForMaterialName, isValidMark } from '@/lib/ifc/ifcLoaderService'; +import { loadAndAuditWRL } from '@/lib/3d/wrlLoaderService'; + +export type { LoadedIFCResult, PieceInfo, IFCQualityAudit }; +export { getColorForMaterialName, isValidMark }; + +// Tipo genérico para o resultado de qualquer modelo 3D carregado (IFC ou WRL) +export type LoadedModel3DResult = LoadedIFCResult; + +/** + * Detecta se os bytes iniciais ou a extensão correspondem a um arquivo VRML / WRL. + */ +function isWrlFormat(fileName?: string, buffer?: ArrayBuffer): boolean { + if (fileName) { + const lower = fileName.toLowerCase(); + if (lower.endsWith('.wrl') || lower.endsWith('.wrz') || lower.endsWith('.vrml')) { + return true; + } + } + + if (buffer && buffer.byteLength >= 10) { + const bytes = new Uint8Array(buffer.slice(0, 100)); + + // Checa assinatura mágica GZIP (0x1f 0x8b) que arquivos .wrz frequentemente usam + if (bytes[0] === 0x1f && bytes[1] === 0x8b) { + // É gzip, em arquivos 3D industriais costuma ser VRML comprimido (.wrz) + return true; + } + + // Checa cabeçalho ASCII VRML (ex: "#VRML V2.0 utf8" ou "#VRML V1.0 ascii") + const headerStr = new TextDecoder('utf-8', { fatal: false }).decode(bytes); + if (headerStr.includes('#VRML')) { + return true; + } + } + + return false; +} + +/** + * Função unificada para carregar e auditar qualquer modelo 3D suportado (IFC ou WRL/VRML). + */ +export async function loadAndAuditModel3D( + fileOrBuffer: File | ArrayBuffer, + fileNameHint?: string, + onProgress?: (percent: number, step: string) => void +): Promise { + const fileName = fileOrBuffer instanceof File ? fileOrBuffer.name : fileNameHint; + let bufferSample: ArrayBuffer | undefined = undefined; + + if (fileOrBuffer instanceof ArrayBuffer) { + bufferSample = fileOrBuffer; + } + + const isWrl = isWrlFormat(fileName, bufferSample); + + if (isWrl) { + return await loadAndAuditWRL(fileOrBuffer, onProgress); + } + + // Padrão: IFC + return await loadAndAuditIFC(fileOrBuffer, onProgress); +} diff --git a/src/lib/3d/wrlLoaderService.ts b/src/lib/3d/wrlLoaderService.ts new file mode 100644 index 0000000..8791cc6 --- /dev/null +++ b/src/lib/3d/wrlLoaderService.ts @@ -0,0 +1,313 @@ +import * as THREE from 'three'; +import { VRMLLoader } from 'three-stdlib'; +import { PieceInfo, LoadedIFCResult, IFCQualityAudit, getColorForMaterialName, isValidMark } from '@/lib/ifc/ifcLoaderService'; + +/** + * Função utilitária para descompactar arquivos gzip (como .wrz ou .wrl comprimidos) no navegador. + */ +async function decompressGzipIfNeeded(buffer: ArrayBuffer): Promise { + const bytes = new Uint8Array(buffer); + // Assinatura mágica do GZIP: 0x1F, 0x8B + if (bytes.length > 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) { + if (typeof DecompressionStream !== 'undefined') { + try { + const stream = new Blob([buffer]).stream(); + const decompressedStream = stream.pipeThrough(new DecompressionStream('gzip')); + const response = new Response(decompressedStream); + const decompressedBuffer = await response.arrayBuffer(); + return new TextDecoder('utf-8', { fatal: false }).decode(decompressedBuffer); + } catch (err) { + console.warn('[WRL Loader] Falha ao descomprimir GZIP nativo:', err); + } + } + } + return new TextDecoder('utf-8', { fatal: false }).decode(buffer); +} + +/** + * Heurística avançada para extrair e normalizar a marca da peça a partir do nó VRML (DEF). + * Softwares como Tekla, Advance Steel, Bocad e outros exportam identificadores variados: + * Ex: "DEF V101 Transform", "DEF Part_V101_1 Transform", "DEF C1_V101 Transform", "DEF 01-V101 Transform" + */ +function parseWrlNodeName(rawName: string): { + pieceMark: string; + cleanMark: string; + assemblyMark?: string; + cleanAssemblyMark?: string; + phase?: string; + detectedType: string; +} { + let name = String(rawName || '').trim(); + + // Remove caracteres especiais de escape ou tags extras + name = name.replace(/^DEF\s+/i, '').replace(/[\{\}\[\]"']/g, '').trim(); + + let phase: string | undefined = undefined; + let assemblyMark: string | undefined = undefined; + let pieceMark = name; + + // 1. Detecção de Fase no início (ex: "01-V101", "F1_V101", "B138-01-V101") + const phaseMatch = name.match(/^(?:[A-Za-z0-9]+-)?(?:Fase|F|Etapa)?(\d+)[-_](.+)$/i); + if (phaseMatch) { + phase = phaseMatch[1]; + pieceMark = phaseMatch[2]; + } + + // 2. Remoção de prefixos comuns como "Part_", "Piece_", "Peca_", "Elem_", "Assembly_", "Conjunto_" + pieceMark = pieceMark.replace(/^(?:Part|Piece|Peca|Elem|Item|Member|Profile|Mesh)[-_]/i, ''); + + // 3. Detecção de Conjunto e Peça (ex: "C1_V101", "ASSEMBLY1-V101", "V101-PL1") + if (pieceMark.includes('_') || pieceMark.includes('-')) { + const parts = pieceMark.split(/[-_]/); + if (parts.length >= 2) { + // Se a primeira parte parecer um conjunto (ex: C1, M1, CONJ1) + const first = parts[0].trim(); + const second = parts.slice(1).join('-').trim(); + if (/^(?:C|M|CONJ|ASS|G)\d+$/i.test(first)) { + assemblyMark = first; + pieceMark = second; + } + } + } + + // 4. Marca limpa (remove sufixo de instância numérica gerada por exportadores, ex: "V101_1", "V101-2", "V101.1", "V101#1") + let cleanMark = pieceMark.replace(/[-_.#]\d+$/, '').trim().toUpperCase(); + if (!cleanMark) { + cleanMark = pieceMark.toUpperCase(); + } + + const cleanAssemblyMark = assemblyMark ? assemblyMark.replace(/[-_.#]\d+$/, '').trim().toUpperCase() : cleanMark; + + // 5. Detecção de Tipo de Elemento Estrutural com base na marca + let detectedType = 'ELEMENT'; + const upper = cleanMark.toUpperCase(); + if (/^(?:V|VIGA|BEAM|VIG)/.test(upper)) { + detectedType = 'BEAM'; + } else if (/^(?:P|PILAR|COL|COLUMN|PIL)/.test(upper)) { + detectedType = 'COLUMN'; + } else if (/^(?:PL|CH|CHAPA|PLATE|GUSSET|BASE|FLANGE)/.test(upper)) { + detectedType = 'PLATE'; + } else if (/^(?:CV|CONTRA|BRACE|TIE|DIAG)/.test(upper)) { + detectedType = 'BRACE'; + } else if (/^(?:TER|TERCA|PURLIN)/.test(upper)) { + detectedType = 'PURLIN'; + } else if (/^(?:T|TRENA|TIE|TIRANTE)/.test(upper)) { + detectedType = 'TIE'; + } + + return { + pieceMark: pieceMark.toUpperCase(), + cleanMark, + assemblyMark: assemblyMark ? assemblyMark.toUpperCase() : undefined, + cleanAssemblyMark, + phase, + detectedType, + }; +} + +/** + * Carrega, analisa e gera estrutura idêntica ao IFC para modelos VRML (.wrl / .wrz). + */ +export async function loadAndAuditWRL( + fileOrBuffer: File | ArrayBuffer, + onProgress?: (percent: number, step: string) => void +): Promise { + if (onProgress) onProgress(15, 'Lendo arquivo WRL (VRML)...'); + + let arrayBuffer: ArrayBuffer; + if (fileOrBuffer instanceof File) { + arrayBuffer = await fileOrBuffer.arrayBuffer(); + } else { + arrayBuffer = fileOrBuffer; + } + + if (onProgress) onProgress(30, 'Descomprimindo e analisando formato VRML...'); + const vrmlText = await decompressGzipIfNeeded(arrayBuffer); + + if (onProgress) onProgress(50, 'Processando geometria 3D com motor VRML...'); + const loader = new VRMLLoader(); + let vrmlScene: THREE.Scene; + + try { + vrmlScene = loader.parse(vrmlText, ''); + } catch (parseError: any) { + console.error('[WRL Loader] Erro ao analisar VRML:', parseError); + throw new Error(`Falha ao decodificar arquivo WRL: ${parseError.message || 'Formato VRML inválido'}`); + } + + if (onProgress) onProgress(75, 'Mapeando peças estruturais e hierarquia...'); + + const sceneGroup = new THREE.Group(); + sceneGroup.name = 'WRL_Root_Model'; + + const piecesByMark = new Map(); + const pieceByExpressID = new Map(); + const weldedGroupMap = new Map(); + const materialsByColor = new Map(); + const detectedPhasesSet = new Set(); + + let idCounter = 1; + let totalMeshes = 0; + let totalBeams = 0; + let totalColumns = 0; + let totalPlates = 0; + let totalMembers = 0; + const marksFound = new Set(); + + // Helper para buscar o nome do nó mais relevante na árvore + const resolveMeaningfulNodeName = (mesh: THREE.Mesh): string => { + let curr: THREE.Object3D | null = mesh; + while (curr) { + if (curr.name && !/^(Scene|Root|Group|Transform|Shape|IndexedFaceSet)$/i.test(curr.name.trim())) { + return curr.name.trim(); + } + curr = curr.parent; + } + return mesh.name || `PECA-${idCounter}`; + }; + + // Percorre todos os nós e malhas gerados pelo VRMLLoader + vrmlScene.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const mesh = child as THREE.Mesh; + totalMeshes++; + + // Garante normais de vértices para iluminação perfeita + if (mesh.geometry) { + if (!mesh.geometry.attributes.normal) { + mesh.geometry.computeVertexNormals(); + } + mesh.castShadow = true; + mesh.receiveShadow = true; + } + + const nodeName = resolveMeaningfulNodeName(mesh); + const parsed = parseWrlNodeName(nodeName); + + const expressID = idCounter++; + const guid = `wrl-${expressID}-${parsed.cleanMark}`; + + if (parsed.phase) { + detectedPhasesSet.add(parsed.phase); + } + + if (parsed.cleanMark && isValidMark(parsed.cleanMark)) { + marksFound.add(parsed.cleanMark); + } + + // Contabiliza tipos + if (parsed.detectedType === 'BEAM') totalBeams++; + else if (parsed.detectedType === 'COLUMN') totalColumns++; + else if (parsed.detectedType === 'PLATE') totalPlates++; + else totalMembers++; + + // Aplica material inicial elegante baseado na marca/perfil + const baseColorHex = getColorForMaterialName(parsed.cleanMark || parsed.pieceMark); + let material = materialsByColor.get(baseColorHex); + if (!material) { + material = new THREE.MeshStandardMaterial({ + color: new THREE.Color(baseColorHex), + roughness: 0.45, + metalness: 0.15, + side: THREE.DoubleSide, + }); + materialsByColor.set(baseColorHex, material); + } + mesh.material = material; + + const pieceInfo: PieceInfo = { + expressID, + guid, + name: nodeName, + type: parsed.detectedType, + pieceMark: parsed.pieceMark, + cleanMark: parsed.cleanMark, + assemblyMark: parsed.assemblyMark, + cleanAssemblyMark: parsed.cleanAssemblyMark, + phase: parsed.phase, + mesh, + }; + + // Injeta metadados essenciais no userData do Mesh (exatamente o que o ModelViewer3D espera) + mesh.userData = { + expressID, + guid, + pieceMark: parsed.pieceMark, + cleanMark: parsed.cleanMark, + assemblyMark: parsed.assemblyMark, + cleanAssemblyMark: parsed.cleanAssemblyMark, + phase: parsed.phase, + type: parsed.detectedType, + name: nodeName, + pieceInfo, + originalMaterial: material, + }; + + pieceByExpressID.set(expressID, pieceInfo); + + // Agrupa peças por marca + const markKey = parsed.cleanMark || parsed.pieceMark; + if (markKey) { + const list = piecesByMark.get(markKey) || []; + list.push(pieceInfo); + piecesByMark.set(markKey, list); + } + } + }); + + // Adiciona a cena VRML ao grupo final + sceneGroup.add(vrmlScene); + + // Normaliza dimensões e orientação: se o modelo estiver em milímetros (típico CAD metálico > 1000 unidades), converte para metros + const bbox = new THREE.Box3().setFromObject(sceneGroup); + const size = bbox.getSize(new THREE.Vector3()); + const maxDim = Math.max(size.x, size.y, size.z); + + // Se o modelo estiver em milímetros (ex: 20 metros = 20000mm) + if (maxDim > 500) { + const scaleFactor = 0.001; + sceneGroup.scale.set(scaleFactor, scaleFactor, scaleFactor); + sceneGroup.updateMatrixWorld(true); + } + + // Gera auditoria de qualidade compatível + const uniqueMarksFound = marksFound.size; + const marksList = Array.from(marksFound).sort(); + const qualityScore: 'EXCELENTE' | 'BOM' | 'ATENÇÃO' = + uniqueMarksFound > 0 ? (uniqueMarksFound >= 10 ? 'EXCELENTE' : 'BOM') : 'ATENÇÃO'; + + const audit: IFCQualityAudit = { + schema: 'VRML 2.0 (WRL)', + viewDefinition: 'Virtual Reality Modeling Language 3D', + isEM11: false, + totalAssemblies: marksFound.size, + totalBeams, + totalColumns, + totalMembers, + totalPlates, + totalFasteners: 0, + totalStructuralElements: totalMeshes, + totalWeldedGroups: 0, + uniqueMarksFound, + marksList, + qualityScore, + qualityMessage: + uniqueMarksFound > 0 + ? `Modelo WRL carregado com sucesso. ${totalMeshes} malhas processadas e ${uniqueMarksFound} marcas identificadas para apontamento.` + : `Modelo WRL carregado com ${totalMeshes} malhas. Nenhuma marca nominal identificada na geometria.`, + fallbackActive: false, + }; + + if (onProgress) onProgress(100, 'Modelo WRL pronto para visualização!'); + + return { + sceneGroup, + audit, + piecesByMark, + pieceByExpressID, + weldedGroupMap, + materialsByColor, + totalMeshes, + detectedPhases: Array.from(detectedPhasesSet).sort(), + }; +} diff --git a/src/lib/ifc/ifcStorageService.ts b/src/lib/ifc/ifcStorageService.ts index 4682c74..ab65491 100644 --- a/src/lib/ifc/ifcStorageService.ts +++ b/src/lib/ifc/ifcStorageService.ts @@ -103,7 +103,7 @@ export async function clearCachedIFCModel(ofNumber: string): Promise { } /** - * Realiza o upload do arquivo IFC para o Supabase Storage e salva o vínculo na OF. + * Realiza o upload do arquivo de modelo 3D (IFC ou WRL) para o Supabase Storage e salva o vínculo na OF. */ export async function uploadIFCToCloud( file: File, @@ -113,9 +113,11 @@ export async function uploadIFCToCloud( ): Promise<{ publicUrl: string; filePath: string }> { const sanitizedOf = ofNumber.replace(/[^a-zA-Z0-9_-]/g, '_'); const cleanFileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); - const filePath = `ifc/${sanitizedOf}/${cleanFileName}`; + const ext = file.name.split('.').pop()?.toLowerCase(); + const folder = (ext === 'wrl' || ext === 'wrz') ? 'wrl' : 'ifc'; + const filePath = `${folder}/${sanitizedOf}/${cleanFileName}`; - if (onProgress) onProgress('Enviando arquivo IFC para a nuvem...', 20); + if (onProgress) onProgress('Enviando modelo 3D para a nuvem...', 20); // 1. Upload no Supabase Storage (bucket modelos-3d) const { error: uploadError } = await supabase.storage @@ -126,7 +128,7 @@ export async function uploadIFCToCloud( }); if (uploadError) { - console.error('[IFC Storage] Erro no upload:', uploadError); + console.error('[Storage] Erro no upload:', uploadError); throw new Error(`Falha ao salvar no storage: ${uploadError.message}`); } @@ -151,7 +153,7 @@ export async function uploadIFCToCloud( .eq('num_of', ofNumber); if (dbError) { - console.warn('[IFC Storage] Aviso ao atualizar ordens_fabricacao:', dbError); + console.warn('[Storage] Aviso ao atualizar ordens_fabricacao:', dbError); } // 4. Inserir histórico em of_modelos_3d @@ -168,7 +170,7 @@ export async function uploadIFCToCloud( updated_at: new Date().toISOString() }); } catch (histErr) { - console.debug('[IFC Storage] Registro histórico:', histErr); + console.debug('[Storage] Registro histórico:', histErr); } // 5. Salva no cache local do navegador @@ -176,14 +178,16 @@ export async function uploadIFCToCloud( const buffer = await file.arrayBuffer(); await cacheIFCModel(ofNumber, publicUrl, buffer); } catch (cErr) { - console.debug('[IFC Storage] Cache local:', cErr); + console.debug('[Storage] Cache local:', cErr); } - if (onProgress) onProgress('Modelo IFC salvo com sucesso!', 100); + if (onProgress) onProgress('Modelo 3D salvo com sucesso!', 100); return { publicUrl, filePath }; } +export const uploadModel3DToCloud = uploadIFCToCloud; + /** * Baixa e armazena em cache o modelo IFC salvo para a OF. */ diff --git a/src/pages/Visualizador3D.tsx b/src/pages/Visualizador3D.tsx index 0d43233..2138e66 100644 --- a/src/pages/Visualizador3D.tsx +++ b/src/pages/Visualizador3D.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { supabase } from '@/integrations/supabase/client'; -import { loadAndAuditIFC, LoadedIFCResult, IFCQualityAudit } from '@/lib/ifc/ifcLoaderService'; +import { loadAndAuditModel3D, LoadedModel3DResult, IFCQualityAudit } from '@/lib/3d/modelLoaderService'; import { uploadIFCToCloud, fetchSavedIFCModel, removeSavedIFCModel } from '@/lib/ifc/ifcStorageService'; import { ModelViewer3D } from '@/components/viewer3d/ModelViewer3D'; import { IFCQualityModal } from '@/components/viewer3d/IFCQualityModal'; @@ -58,7 +58,7 @@ export default function Visualizador3D() { const [progressoOF, setProgressoOF] = useState(0); // 3D Model and Quality State - const [modelData, setModelData] = useState(null); + const [modelData, setModelData] = useState(null); const [auditData, setAuditData] = useState(null); const [isAuditModalOpen, setIsAuditModalOpen] = useState(false); const [loadingStep, setLoadingStep] = useState(''); @@ -258,10 +258,10 @@ export default function Visualizador3D() { setLoadingStep(step); }); - setLoadingStep('Decodificando entidades e montagens IFC...'); + setLoadingStep('Decodificando entidades e montagens 3D...'); setLoadingPercent(55); - const result = await loadAndAuditIFC(buffer, (percent, step) => { + const result = await loadAndAuditModel3D(buffer, filename || undefined, (percent, step) => { setLoadingPercent(55 + Math.round(percent * 0.45)); setLoadingStep(step); }); @@ -342,7 +342,7 @@ export default function Visualizador3D() { try { // 1. Processa e audita localmente para renderização imediata - const result = await loadAndAuditIFC(file, (percent, step) => { + const result = await loadAndAuditModel3D(file, file.name, (percent, step) => { setLoadingPercent(Math.min(percent, 70)); setLoadingStep(step); }); @@ -393,11 +393,11 @@ export default function Visualizador3D() { ); toast({ - title: 'Modelo IFC Gravado no Banco!', + title: 'Modelo 3D Gravado no Banco!', description: `O arquivo "${file.name}" foi salvo com sucesso e carregará automaticamente ao entrar na OF ${selectedOF}.`, }); } catch (err: any) { - console.error('Erro ao carregar e salvar IFC:', err); + console.error('Erro ao carregar e salvar Modelo 3D:', err); toast({ variant: 'destructive', title: 'Erro no Carregamento', @@ -411,7 +411,7 @@ export default function Visualizador3D() { // Remover modelo salvo const handleRemoveSavedModel = async () => { - if (!confirm(`Deseja remover o modelo IFC vinculado à OF ${selectedOF}?`)) return; + if (!confirm(`Deseja remover o modelo 3D vinculado à OF ${selectedOF}?`)) return; try { await removeSavedIFCModel(selectedOF); @@ -524,7 +524,7 @@ export default function Visualizador3D() { {currentOFInfo?.ifc_filename || 'Modelo Salvo'} @@ -585,7 +585,7 @@ export default function Visualizador3D() { @@ -603,7 +603,7 @@ export default function Visualizador3D() { ) : ( <> - {hasSavedModel ? 'Substituir Modelo IFC' : 'Carregar Modelo IFC'} + {hasSavedModel ? 'Substituir Modelo 3D' : 'Carregar Modelo 3D (IFC/WRL)'} )} @@ -687,9 +687,9 @@ export default function Visualizador3D() {
-

Nenhum modelo IFC carregado para {selectedOF}

+

Nenhum modelo 3D carregado para {selectedOF}

- Clique no botão "Carregar Modelo IFC" acima para importar o arquivo de fabricação desta obra. Ele será gravado no banco de dados e recarregado automaticamente nas próximas visitas. + Clique no botão "Carregar Modelo 3D (IFC/WRL)" acima para importar o arquivo de fabricação desta obra. Ele será gravado no banco de dados e recarregado automaticamente nas próximas visitas.

diff --git a/src/pages/smart/components/SmartViewer3DFlow.tsx b/src/pages/smart/components/SmartViewer3DFlow.tsx index 6f4d3f9..d1f23e8 100644 --- a/src/pages/smart/components/SmartViewer3DFlow.tsx +++ b/src/pages/smart/components/SmartViewer3DFlow.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useCallback } from 'react'; import { OFAtiva } from '@/hooks/useOFsAtivas'; import { supabase } from '@/integrations/supabase/client'; -import { loadAndAuditIFC, LoadedIFCResult } from '@/lib/ifc/ifcLoaderService'; +import { loadAndAuditModel3D, LoadedModel3DResult } from '@/lib/3d/modelLoaderService'; import { fetchSavedIFCModel } from '@/lib/ifc/ifcStorageService'; import { SmartModelViewer3D, ProductionPieceStatus } from '@/components/viewer3d/SmartModelViewer3D'; import { smartAudio } from '@/utils/smartAudio'; @@ -17,7 +17,7 @@ export const SmartViewer3DFlow: React.FC = ({ obra, onVoltarHub, }) => { - const [modelData, setModelData] = useState(null); + const [modelData, setModelData] = useState(null); const [phasesList, setPhasesList] = useState([]); const [productionMap, setProductionMap] = useState>(new Map()); @@ -186,7 +186,7 @@ export const SmartViewer3DFlow: React.FC = ({ setLoadingStep('Decodificando montagens e geometrias do modelo...'); setLoadingPercent(68); - const result = await loadAndAuditIFC(buffer, (pct, msg) => { + const result = await loadAndAuditModel3D(buffer, ifcFilename || undefined, (pct, msg) => { setLoadingPercent(68 + Math.round(pct * 0.32)); setLoadingStep(msg); });