@ifc-lite/viewer 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
  5. package/dist/assets/index-yTqs8kgX.css +1 -0
  6. package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
  7. package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +18 -15
  10. package/src/components/viewer/BCFPanel.tsx +7 -789
  11. package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/HierarchyPanel.tsx +110 -842
  14. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  15. package/src/components/viewer/IDSPanel.tsx +126 -17
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  17. package/src/components/viewer/LensPanel.tsx +603 -0
  18. package/src/components/viewer/MainToolbar.tsx +188 -21
  19. package/src/components/viewer/PropertiesPanel.tsx +171 -663
  20. package/src/components/viewer/PropertyEditor.tsx +866 -77
  21. package/src/components/viewer/Section2DPanel.tsx +76 -2648
  22. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  23. package/src/components/viewer/ViewerLayout.tsx +132 -45
  24. package/src/components/viewer/Viewport.tsx +237 -1659
  25. package/src/components/viewer/ViewportContainer.tsx +11 -3
  26. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  27. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  28. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  29. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  30. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  31. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  32. package/src/components/viewer/hierarchy/types.ts +54 -0
  33. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  34. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  35. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  36. package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
  37. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  38. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  39. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  40. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  41. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  42. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  43. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  44. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  45. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  46. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  47. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  48. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  49. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  50. package/src/components/viewer/tools/formatDistance.ts +18 -0
  51. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  52. package/src/components/viewer/useAnimationLoop.ts +166 -0
  53. package/src/components/viewer/useGeometryStreaming.ts +398 -0
  54. package/src/components/viewer/useKeyboardControls.ts +221 -0
  55. package/src/components/viewer/useMouseControls.ts +1009 -0
  56. package/src/components/viewer/useRenderUpdates.ts +165 -0
  57. package/src/components/viewer/useTouchControls.ts +245 -0
  58. package/src/hooks/ids/idsColorSystem.ts +125 -0
  59. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  60. package/src/hooks/ids/idsExportService.ts +444 -0
  61. package/src/hooks/useBCF.ts +7 -0
  62. package/src/hooks/useDrawingExport.ts +627 -0
  63. package/src/hooks/useDrawingGeneration.ts +627 -0
  64. package/src/hooks/useFloorplanView.ts +108 -0
  65. package/src/hooks/useIDS.ts +270 -463
  66. package/src/hooks/useIfc.ts +26 -1628
  67. package/src/hooks/useIfcFederation.ts +803 -0
  68. package/src/hooks/useIfcLoader.ts +508 -0
  69. package/src/hooks/useIfcServer.ts +465 -0
  70. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  71. package/src/hooks/useLens.ts +129 -0
  72. package/src/hooks/useMeasure2D.ts +365 -0
  73. package/src/hooks/useViewControls.ts +218 -0
  74. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  75. package/src/lib/ifc4-pset-definitions.ts +621 -0
  76. package/src/lib/ifc4-qto-definitions.ts +315 -0
  77. package/src/lib/lens/adapter.ts +138 -0
  78. package/src/lib/lens/index.ts +5 -0
  79. package/src/lib/lists/adapter.ts +69 -0
  80. package/src/lib/lists/index.ts +28 -0
  81. package/src/lib/lists/persistence.ts +64 -0
  82. package/src/services/fs-cache.ts +1 -1
  83. package/src/services/tauri-modules.d.ts +25 -0
  84. package/src/store/index.ts +38 -2
  85. package/src/store/slices/cameraSlice.ts +14 -1
  86. package/src/store/slices/dataSlice.ts +14 -1
  87. package/src/store/slices/lensSlice.ts +184 -0
  88. package/src/store/slices/listSlice.ts +74 -0
  89. package/src/store/slices/pinboardSlice.ts +114 -0
  90. package/src/store/types.ts +5 -0
  91. package/src/utils/ifcConfig.ts +16 -3
  92. package/src/utils/serverDataModel.ts +64 -101
  93. package/src/vite-env.d.ts +3 -0
  94. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  95. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -0,0 +1,508 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Hook for loading and processing IFC files (single-model path)
7
+ * Handles format detection, WASM geometry streaming, IFC parsing,
8
+ * cache management, and server-side parsing delegation
9
+ *
10
+ * Extracted from useIfc.ts for better separation of concerns
11
+ */
12
+
13
+ import { useCallback } from 'react';
14
+ import { useViewerStore } from '../store.js';
15
+ import { IfcParser, detectFormat, parseIfcx, type IfcDataStore } from '@ifc-lite/parser';
16
+ import { GeometryProcessor, GeometryQuality, type MeshData, type CoordinateInfo } from '@ifc-lite/geometry';
17
+ import { buildSpatialIndex } from '@ifc-lite/spatial';
18
+ import { type GeometryData, loadGLBToMeshData } from '@ifc-lite/cache';
19
+
20
+ import { SERVER_URL, USE_SERVER, CACHE_SIZE_THRESHOLD, getDynamicBatchConfig } from '../utils/ifcConfig.js';
21
+ import {
22
+ calculateMeshBounds,
23
+ createCoordinateInfo,
24
+ getRenderIntervalMs,
25
+ calculateStoreyHeights,
26
+ normalizeColor,
27
+ } from '../utils/localParsingUtils.js';
28
+
29
+ // Cache hook
30
+ import { useIfcCache, getCached } from './useIfcCache.js';
31
+
32
+ // Server hook
33
+ import { useIfcServer } from './useIfcServer.js';
34
+
35
+ // Import IfcxDataStore type from federation hook
36
+ import type { IfcxDataStore } from './useIfcFederation.js';
37
+
38
+ /**
39
+ * Compute a fast content fingerprint from the first and last 4KB of a buffer.
40
+ * Uses FNV-1a hash for speed — no crypto overhead, sufficient to distinguish
41
+ * files with identical name and byte length.
42
+ */
43
+ function computeFastFingerprint(buffer: ArrayBuffer): string {
44
+ const CHUNK_SIZE = 4096;
45
+ const view = new Uint8Array(buffer);
46
+ const len = view.length;
47
+
48
+ // FNV-1a hash
49
+ let hash = 2166136261; // FNV offset basis (32-bit)
50
+ const firstEnd = Math.min(CHUNK_SIZE, len);
51
+ for (let i = 0; i < firstEnd; i++) {
52
+ hash ^= view[i];
53
+ hash = Math.imul(hash, 16777619); // FNV prime
54
+ }
55
+ if (len > CHUNK_SIZE) {
56
+ const lastStart = Math.max(CHUNK_SIZE, len - CHUNK_SIZE);
57
+ for (let i = lastStart; i < len; i++) {
58
+ hash ^= view[i];
59
+ hash = Math.imul(hash, 16777619);
60
+ }
61
+ }
62
+ return (hash >>> 0).toString(16);
63
+ }
64
+
65
+ /**
66
+ * Hook providing file loading operations for single-model path
67
+ * Includes binary cache support for fast subsequent loads
68
+ */
69
+ export function useIfcLoader() {
70
+ const {
71
+ setLoading,
72
+ setError,
73
+ setProgress,
74
+ setIfcDataStore,
75
+ setGeometryResult,
76
+ appendGeometryBatch,
77
+ updateMeshColors,
78
+ updateCoordinateInfo,
79
+ } = useViewerStore();
80
+
81
+ // Cache operations from extracted hook
82
+ const { loadFromCache, saveToCache } = useIfcCache();
83
+
84
+ // Server operations from extracted hook
85
+ const { loadFromServer } = useIfcServer();
86
+
87
+ const loadFile = useCallback(async (file: File) => {
88
+ const { resetViewerState, clearAllModels } = useViewerStore.getState();
89
+
90
+ // Track total elapsed time for complete user experience
91
+ const totalStartTime = performance.now();
92
+
93
+ try {
94
+ // Reset all viewer state before loading new file
95
+ // Also clear models Map to ensure clean single-file state
96
+ resetViewerState();
97
+ clearAllModels();
98
+
99
+ setLoading(true);
100
+ setError(null);
101
+ setProgress({ phase: 'Loading file', percent: 0 });
102
+
103
+ // Read file from disk
104
+ const buffer = await file.arrayBuffer();
105
+ const fileSizeMB = buffer.byteLength / (1024 * 1024);
106
+
107
+ // Detect file format (IFCX/IFC5 vs IFC4 STEP vs GLB)
108
+ const format = detectFormat(buffer);
109
+
110
+ // IFCX files must be parsed client-side (server only supports IFC4 STEP)
111
+ if (format === 'ifcx') {
112
+ setProgress({ phase: 'Parsing IFCX (client-side)', percent: 10 });
113
+
114
+ try {
115
+ const ifcxResult = await parseIfcx(buffer, {
116
+ onProgress: (prog: { phase: string; percent: number }) => {
117
+ setProgress({ phase: `IFCX ${prog.phase}`, percent: 10 + (prog.percent * 0.8) });
118
+ },
119
+ });
120
+
121
+ // Convert IFCX meshes to viewer format
122
+ // Note: IFCX geometry extractor already handles Y-up to Z-up conversion
123
+ // and applies transforms correctly in Z-up space, so we just pass through
124
+
125
+ const meshes: MeshData[] = ifcxResult.meshes.map((m: { expressId?: number; express_id?: number; id?: number; positions: Float32Array | number[]; indices: Uint32Array | number[]; normals: Float32Array | number[]; color?: [number, number, number, number] | [number, number, number]; ifcType?: string; ifc_type?: string }) => {
126
+ // IFCX MeshData has: expressId, ifcType, positions (Float32Array), indices (Uint32Array), normals (Float32Array), color
127
+ const positions = m.positions instanceof Float32Array ? m.positions : new Float32Array(m.positions || []);
128
+ const indices = m.indices instanceof Uint32Array ? m.indices : new Uint32Array(m.indices || []);
129
+ const normals = m.normals instanceof Float32Array ? m.normals : new Float32Array(m.normals || []);
130
+
131
+ // Normalize color to RGBA format (4 elements)
132
+ const color = normalizeColor(m.color);
133
+
134
+ return {
135
+ expressId: m.expressId ?? m.express_id ?? m.id ?? 0,
136
+ positions,
137
+ indices,
138
+ normals,
139
+ color,
140
+ ifcType: m.ifcType || m.ifc_type || 'IfcProduct',
141
+ };
142
+ }).filter((m: MeshData) => m.positions.length > 0 && m.indices.length > 0); // Filter out empty meshes
143
+
144
+ // Check if this is an overlay-only file (no geometry)
145
+ if (meshes.length === 0) {
146
+ console.warn(`[useIfc] IFCX file "${file.name}" has no geometry - this appears to be an overlay file that adds properties to a base model.`);
147
+ console.warn('[useIfc] To use this file, load it together with a base IFCX file (select both files at once).');
148
+
149
+ // Check if file has data references that suggest it's an overlay
150
+ const hasReferences = ifcxResult.entityCount > 0;
151
+ if (hasReferences) {
152
+ setError(`"${file.name}" is an overlay file with no geometry. Please load it together with a base IFCX file (select all files at once).`);
153
+ setLoading(false);
154
+ return;
155
+ }
156
+ }
157
+
158
+ // Calculate bounds and statistics
159
+ const { bounds, stats } = calculateMeshBounds(meshes);
160
+ const coordinateInfo = createCoordinateInfo(bounds);
161
+
162
+ setGeometryResult({
163
+ meshes,
164
+ totalVertices: stats.totalVertices,
165
+ totalTriangles: stats.totalTriangles,
166
+ coordinateInfo,
167
+ });
168
+
169
+ // Convert IFCX data model to IfcDataStore format
170
+ // IFCX already provides entities, properties, quantities, relationships, spatialHierarchy
171
+ const dataStore = {
172
+ fileSize: ifcxResult.fileSize,
173
+ schemaVersion: 'IFC5' as const,
174
+ entityCount: ifcxResult.entityCount,
175
+ parseTime: ifcxResult.parseTime,
176
+ source: new Uint8Array(buffer),
177
+ entityIndex: {
178
+ byId: new Map(),
179
+ byType: new Map(),
180
+ },
181
+ strings: ifcxResult.strings,
182
+ entities: ifcxResult.entities,
183
+ properties: ifcxResult.properties,
184
+ quantities: ifcxResult.quantities,
185
+ relationships: ifcxResult.relationships,
186
+ spatialHierarchy: ifcxResult.spatialHierarchy,
187
+ } as IfcxDataStore;
188
+
189
+ // IfcxDataStore extends IfcDataStore (with schemaVersion: 'IFC5'), so this is safe
190
+ setIfcDataStore(dataStore);
191
+
192
+ setProgress({ phase: 'Complete', percent: 100 });
193
+ setLoading(false);
194
+ return;
195
+ } catch (err: unknown) {
196
+ console.error('[useIfc] IFCX parsing failed:', err);
197
+ const message = err instanceof Error ? err.message : String(err);
198
+ setError(`IFCX parsing failed: ${message}`);
199
+ setLoading(false);
200
+ return;
201
+ }
202
+ }
203
+
204
+ // GLB files: parse directly to MeshData (no data model, geometry only)
205
+ if (format === 'glb') {
206
+ setProgress({ phase: 'Parsing GLB', percent: 10 });
207
+
208
+ try {
209
+ const meshes = loadGLBToMeshData(new Uint8Array(buffer));
210
+
211
+ if (meshes.length === 0) {
212
+ setError('GLB file contains no geometry');
213
+ setLoading(false);
214
+ return;
215
+ }
216
+
217
+ const { bounds, stats } = calculateMeshBounds(meshes);
218
+ const coordinateInfo = createCoordinateInfo(bounds);
219
+
220
+ setGeometryResult({
221
+ meshes,
222
+ totalVertices: stats.totalVertices,
223
+ totalTriangles: stats.totalTriangles,
224
+ coordinateInfo,
225
+ });
226
+
227
+ // GLB files have no IFC data model - set a minimal store
228
+ setIfcDataStore(null);
229
+
230
+ setProgress({ phase: 'Complete', percent: 100 });
231
+
232
+ const totalElapsedMs = performance.now() - totalStartTime;
233
+ console.log(`[useIfc] GLB loaded: ${meshes.length} meshes, ${stats.totalTriangles} triangles in ${totalElapsedMs.toFixed(0)}ms`);
234
+ setLoading(false);
235
+ return;
236
+ } catch (err: unknown) {
237
+ console.error('[useIfc] GLB parsing failed:', err);
238
+ const message = err instanceof Error ? err.message : String(err);
239
+ setError(`GLB parsing failed: ${message}`);
240
+ setLoading(false);
241
+ return;
242
+ }
243
+ }
244
+
245
+ // Cache key uses filename + size + content fingerprint + format version
246
+ // Fingerprint prevents collisions for different files with the same name and size
247
+ const fingerprint = computeFastFingerprint(buffer);
248
+ const cacheKey = `${file.name}-${buffer.byteLength}-${fingerprint}-v4`;
249
+
250
+ if (buffer.byteLength >= CACHE_SIZE_THRESHOLD) {
251
+ setProgress({ phase: 'Checking cache', percent: 5 });
252
+ const cacheResult = await getCached(cacheKey);
253
+ if (cacheResult) {
254
+ const success = await loadFromCache(cacheResult, file.name, cacheKey);
255
+ if (success) {
256
+ const totalElapsedMs = performance.now() - totalStartTime;
257
+ console.log(`[useIfc] TOTAL LOAD TIME (from cache): ${totalElapsedMs.toFixed(0)}ms (${(totalElapsedMs / 1000).toFixed(1)}s)`);
258
+ setLoading(false);
259
+ return;
260
+ }
261
+ }
262
+ }
263
+
264
+ // Try server parsing first (enabled by default for multi-core performance)
265
+ // Only for IFC4 STEP files (server doesn't support IFCX)
266
+ if (format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '') {
267
+ // Pass buffer directly - server uses File object for parsing, buffer is only for size checks
268
+ const serverSuccess = await loadFromServer(file, buffer);
269
+ if (serverSuccess) {
270
+ const totalElapsedMs = performance.now() - totalStartTime;
271
+ console.log(`[useIfc] TOTAL LOAD TIME (server): ${totalElapsedMs.toFixed(0)}ms (${(totalElapsedMs / 1000).toFixed(1)}s)`);
272
+ setLoading(false);
273
+ return;
274
+ }
275
+ // Server not available - continue with local WASM (no error logging needed)
276
+ } else if (format === 'unknown') {
277
+ console.warn('[useIfc] Unknown file format - attempting to parse as IFC4 STEP');
278
+ }
279
+
280
+ // Using local WASM parsing
281
+ setProgress({ phase: 'Starting geometry streaming', percent: 10 });
282
+
283
+ // Initialize geometry processor first (WASM init is fast if already loaded)
284
+ const geometryProcessor = new GeometryProcessor({
285
+ quality: GeometryQuality.Balanced
286
+ });
287
+ await geometryProcessor.init();
288
+
289
+ // DEFER data model parsing - start it AFTER geometry streaming begins
290
+ // This ensures geometry gets first crack at the CPU for fast first frame
291
+ // Data model parsing is lower priority - UI can work without it initially
292
+ let resolveDataStore: (dataStore: IfcDataStore) => void;
293
+ let rejectDataStore: (err: unknown) => void;
294
+ const dataStorePromise = new Promise<IfcDataStore>((resolve, reject) => {
295
+ resolveDataStore = resolve;
296
+ rejectDataStore = reject;
297
+ });
298
+
299
+ const startDataModelParsing = () => {
300
+ // Use main thread - worker parsing disabled (IfcDataStore has closures that can't be serialized)
301
+ const parser = new IfcParser();
302
+ const wasmApi = geometryProcessor.getApi();
303
+ parser.parseColumnar(buffer, {
304
+ wasmApi, // Pass WASM API for 5-10x faster entity scanning
305
+ }).then(dataStore => {
306
+
307
+ // Calculate storey heights from elevation differences if not already populated
308
+ if (dataStore.spatialHierarchy && dataStore.spatialHierarchy.storeyHeights.size === 0 && dataStore.spatialHierarchy.storeyElevations.size > 1) {
309
+ const calculatedHeights = calculateStoreyHeights(dataStore.spatialHierarchy.storeyElevations);
310
+ for (const [storeyId, height] of calculatedHeights) {
311
+ dataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
312
+ }
313
+ }
314
+
315
+ setIfcDataStore(dataStore);
316
+ resolveDataStore(dataStore);
317
+ }).catch(err => {
318
+ console.error('[useIfc] Data model parsing failed:', err);
319
+ rejectDataStore(err);
320
+ });
321
+ };
322
+
323
+ // Schedule data model parsing to start after geometry begins streaming
324
+ setTimeout(startDataModelParsing, 0);
325
+
326
+ // Use adaptive processing: sync for small files, streaming for large files
327
+ let estimatedTotal = 0;
328
+ let totalMeshes = 0;
329
+ const allMeshes: MeshData[] = []; // Collect all meshes for BVH building
330
+ let finalCoordinateInfo: CoordinateInfo | null = null;
331
+ // Capture RTC offset from WASM for proper multi-model alignment
332
+ let capturedRtcOffset: { x: number; y: number; z: number } | null = null;
333
+
334
+ // Clear existing geometry result
335
+ setGeometryResult(null);
336
+
337
+ // Timing instrumentation
338
+ const processingStart = performance.now();
339
+ let batchCount = 0;
340
+ let lastBatchTime = processingStart;
341
+ let totalWaitTime = 0; // Time waiting for WASM to yield batches
342
+ let totalProcessTime = 0; // Time processing batches in JS
343
+ let firstGeometryTime = 0; // Time to first rendered geometry
344
+
345
+ // OPTIMIZATION: Accumulate meshes and batch state updates
346
+ // First batch renders immediately, then accumulate for throughput
347
+ // Adaptive interval: larger files get less frequent updates to reduce React re-render overhead
348
+ let pendingMeshes: MeshData[] = [];
349
+ let lastRenderTime = 0;
350
+ const RENDER_INTERVAL_MS = getRenderIntervalMs(fileSizeMB);
351
+
352
+ try {
353
+ // Use dynamic batch sizing for optimal throughput
354
+ const dynamicBatchConfig = getDynamicBatchConfig(fileSizeMB);
355
+
356
+ for await (const event of geometryProcessor.processAdaptive(new Uint8Array(buffer), {
357
+ sizeThreshold: 2 * 1024 * 1024, // 2MB threshold
358
+ batchSize: dynamicBatchConfig, // Dynamic batches: small first, then large
359
+ })) {
360
+ const eventReceived = performance.now();
361
+ const waitTime = eventReceived - lastBatchTime;
362
+
363
+ switch (event.type) {
364
+ case 'start':
365
+ estimatedTotal = event.totalEstimate;
366
+ break;
367
+ case 'model-open':
368
+ setProgress({ phase: 'Processing geometry', percent: 50 });
369
+ break;
370
+ case 'colorUpdate': {
371
+ // Update colors for already-rendered meshes
372
+ updateMeshColors(event.updates);
373
+ break;
374
+ }
375
+ case 'rtcOffset': {
376
+ // Capture RTC offset from WASM for multi-model alignment
377
+ if (event.hasRtc) {
378
+ capturedRtcOffset = event.rtcOffset;
379
+ }
380
+ break;
381
+ }
382
+ case 'batch': {
383
+ batchCount++;
384
+ totalWaitTime += waitTime;
385
+
386
+ // Track time to first geometry
387
+ if (batchCount === 1) {
388
+ firstGeometryTime = performance.now() - totalStartTime;
389
+ }
390
+
391
+ const processStart = performance.now();
392
+
393
+ // Collect meshes for BVH building (use loop to avoid stack overflow with large batches)
394
+ for (let i = 0; i < event.meshes.length; i++) allMeshes.push(event.meshes[i]);
395
+ finalCoordinateInfo = event.coordinateInfo ?? null;
396
+ totalMeshes = event.totalSoFar;
397
+
398
+ // Accumulate meshes for batched rendering
399
+ for (let i = 0; i < event.meshes.length; i++) pendingMeshes.push(event.meshes[i]);
400
+
401
+ // FIRST BATCH: Render immediately for fast first frame
402
+ // SUBSEQUENT: Throttle to reduce React re-renders
403
+ const timeSinceLastRender = eventReceived - lastRenderTime;
404
+ const shouldRender = batchCount === 1 || timeSinceLastRender >= RENDER_INTERVAL_MS;
405
+
406
+ if (shouldRender && pendingMeshes.length > 0) {
407
+ appendGeometryBatch(pendingMeshes, event.coordinateInfo);
408
+ pendingMeshes = [];
409
+ lastRenderTime = eventReceived;
410
+
411
+ // Update progress
412
+ const progressPercent = 50 + Math.min(45, (totalMeshes / Math.max(estimatedTotal / 10, totalMeshes)) * 45);
413
+ setProgress({
414
+ phase: `Rendering geometry (${totalMeshes} meshes)`,
415
+ percent: progressPercent
416
+ });
417
+ }
418
+
419
+ const processTime = performance.now() - processStart;
420
+ totalProcessTime += processTime;
421
+ break;
422
+ }
423
+ case 'complete':
424
+ // Flush any remaining pending meshes
425
+ if (pendingMeshes.length > 0) {
426
+ appendGeometryBatch(pendingMeshes, event.coordinateInfo);
427
+ pendingMeshes = [];
428
+ }
429
+
430
+ finalCoordinateInfo = event.coordinateInfo ?? null;
431
+
432
+ // Store captured RTC offset in coordinate info for multi-model alignment
433
+ if (finalCoordinateInfo && capturedRtcOffset) {
434
+ finalCoordinateInfo.wasmRtcOffset = capturedRtcOffset;
435
+ }
436
+
437
+ // Update geometry result with final coordinate info
438
+ updateCoordinateInfo(finalCoordinateInfo);
439
+
440
+ setProgress({ phase: 'Complete', percent: 100 });
441
+
442
+ // Build spatial index and cache in background (non-blocking)
443
+ // Wait for data model to complete first
444
+ dataStorePromise.then(dataStore => {
445
+ // Build spatial index from meshes (in background)
446
+ if (allMeshes.length > 0) {
447
+ const buildIndex = () => {
448
+ try {
449
+ const spatialIndex = buildSpatialIndex(allMeshes);
450
+ dataStore.spatialIndex = spatialIndex;
451
+ setIfcDataStore({ ...dataStore });
452
+ } catch (err) {
453
+ console.warn('[useIfc] Failed to build spatial index:', err);
454
+ }
455
+ };
456
+
457
+ // Use requestIdleCallback if available (type assertion for optional browser API)
458
+ if ('requestIdleCallback' in window) {
459
+ (window as { requestIdleCallback: (cb: () => void, opts?: { timeout: number }) => void }).requestIdleCallback(buildIndex, { timeout: 2000 });
460
+ } else {
461
+ setTimeout(buildIndex, 100);
462
+ }
463
+ }
464
+
465
+ // Cache the result in the background (for files above threshold)
466
+ if (buffer.byteLength >= CACHE_SIZE_THRESHOLD && allMeshes.length > 0 && finalCoordinateInfo) {
467
+ const geometryData: GeometryData = {
468
+ meshes: allMeshes,
469
+ totalVertices: allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0),
470
+ totalTriangles: allMeshes.reduce((sum, m) => sum + m.indices.length / 3, 0),
471
+ coordinateInfo: finalCoordinateInfo,
472
+ };
473
+ saveToCache(cacheKey, dataStore, geometryData, buffer, file.name);
474
+ }
475
+ }).catch(err => {
476
+ // Data model parsing failed - spatial index and caching skipped
477
+ console.warn('[useIfc] Skipping spatial index/cache - data model unavailable:', err);
478
+ });
479
+ break;
480
+ }
481
+
482
+ lastBatchTime = performance.now();
483
+ }
484
+ } catch (err) {
485
+ console.error('[useIfc] Error in processing:', err);
486
+ setError(err instanceof Error ? err.message : 'Unknown error during geometry processing');
487
+ }
488
+
489
+ // Log developer-friendly summary with key metrics
490
+ const totalElapsedMs = performance.now() - totalStartTime;
491
+ const totalVertices = allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0);
492
+ console.log(
493
+ `[useIfc] ✓ ${file.name} (${fileSizeMB.toFixed(1)}MB) → ` +
494
+ `${allMeshes.length} meshes, ${(totalVertices / 1000).toFixed(0)}k vertices | ` +
495
+ `first: ${firstGeometryTime.toFixed(0)}ms, total: ${totalElapsedMs.toFixed(0)}ms`
496
+ );
497
+
498
+ setLoading(false);
499
+ } catch (err) {
500
+ setError(err instanceof Error ? err.message : 'Unknown error');
501
+ setLoading(false);
502
+ }
503
+ }, [setLoading, setError, setProgress, setIfcDataStore, setGeometryResult, appendGeometryBatch, updateMeshColors, updateCoordinateInfo, loadFromCache, saveToCache, loadFromServer]);
504
+
505
+ return { loadFile };
506
+ }
507
+
508
+ export default useIfcLoader;