@ifc-lite/viewer 1.16.0 → 1.17.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 (55) hide show
  1. package/.turbo/turbo-build.log +46 -0
  2. package/.turbo/turbo-typecheck.log +4 -0
  3. package/CHANGELOG.md +15 -0
  4. package/dist/assets/{Arrow.dom--gdrQd-q.js → Arrow.dom-CcoDLP6E.js} +1 -1
  5. package/dist/assets/{basketViewActivator-CI3y6VYQ.js → basketViewActivator-FtbS__bG.js} +1 -1
  6. package/dist/assets/{browser-vWDubxDI.js → browser-CXd3z0DO.js} +1 -1
  7. package/dist/assets/ifc-lite-TI3u_Zyw.js +7 -0
  8. package/dist/assets/ifc-lite_bg-DeZrXTKQ.wasm +0 -0
  9. package/dist/assets/index-Ba4eoTe7.css +1 -0
  10. package/dist/assets/{index-BImINgzG.js → index-D99fzcwI.js} +28962 -26947
  11. package/dist/assets/{index-RXIK18da.js → index-DqNiuQep.js} +4 -4
  12. package/dist/assets/{native-bridge-4rLidc3f.js → native-bridge-DjDj2M6p.js} +1 -1
  13. package/dist/assets/{wasm-bridge-BkfXfw8O.js → wasm-bridge-CDTF4ZQc.js} +1 -1
  14. package/dist/assets/workerHelpers-G7llXNMi.js +36 -0
  15. package/dist/index.html +2 -2
  16. package/package.json +15 -14
  17. package/src/components/viewer/BCFPanel.tsx +12 -0
  18. package/src/components/viewer/BulkPropertyEditor.tsx +315 -154
  19. package/src/components/viewer/CommandPalette.tsx +0 -6
  20. package/src/components/viewer/DataConnector.tsx +489 -284
  21. package/src/components/viewer/ExportDialog.tsx +66 -6
  22. package/src/components/viewer/KeyboardShortcutsDialog.tsx +44 -1
  23. package/src/components/viewer/MainToolbar.tsx +1 -5
  24. package/src/components/viewer/Viewport.tsx +42 -56
  25. package/src/components/viewer/ViewportContainer.tsx +3 -0
  26. package/src/components/viewer/ViewportOverlays.tsx +12 -10
  27. package/src/components/viewer/bcf/BCFOverlay.tsx +254 -0
  28. package/src/components/viewer/lists/ListPanel.tsx +0 -21
  29. package/src/components/viewer/lists/ListResultsTable.tsx +93 -5
  30. package/src/components/viewer/measureHandlers.ts +558 -0
  31. package/src/components/viewer/mouseHandlerTypes.ts +108 -0
  32. package/src/components/viewer/selectionHandlers.ts +86 -0
  33. package/src/components/viewer/useAnimationLoop.ts +116 -44
  34. package/src/components/viewer/useGeometryStreaming.ts +155 -367
  35. package/src/components/viewer/useKeyboardControls.ts +30 -46
  36. package/src/components/viewer/useMouseControls.ts +169 -695
  37. package/src/components/viewer/useRenderUpdates.ts +9 -59
  38. package/src/components/viewer/useTouchControls.ts +55 -40
  39. package/src/hooks/bcfIdLookup.ts +70 -0
  40. package/src/hooks/useBCF.ts +12 -31
  41. package/src/hooks/useIfcCache.ts +2 -20
  42. package/src/hooks/useIfcFederation.ts +5 -11
  43. package/src/hooks/useIfcLoader.ts +47 -56
  44. package/src/hooks/useIfcServer.ts +9 -1
  45. package/src/hooks/useKeyboardShortcuts.ts +0 -10
  46. package/src/hooks/useLatestRef.ts +24 -0
  47. package/src/sdk/adapters/export-adapter.ts +2 -2
  48. package/src/sdk/adapters/model-adapter.ts +1 -0
  49. package/src/sdk/local-backend.ts +2 -0
  50. package/src/store/basketVisibleSet.ts +12 -0
  51. package/src/store/slices/bcfSlice.ts +9 -0
  52. package/src/utils/loadingUtils.ts +46 -0
  53. package/src/utils/serverDataModel.ts +4 -3
  54. package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
  55. package/dist/assets/index-ax1X2WPd.css +0 -1
@@ -10,12 +10,12 @@
10
10
  * Extracted from useIfc.ts for better separation of concerns
11
11
  */
12
12
 
13
- import { useCallback } from 'react';
13
+ import { useCallback, useRef } from 'react';
14
14
  import { useShallow } from 'zustand/react/shallow';
15
15
  import { useViewerStore } from '../store.js';
16
16
  import { IfcParser, detectFormat, parseIfcx, type IfcDataStore } from '@ifc-lite/parser';
17
17
  import { GeometryProcessor, GeometryQuality, type MeshData, type CoordinateInfo } from '@ifc-lite/geometry';
18
- import { buildSpatialIndex } from '@ifc-lite/spatial';
18
+ import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
19
19
  import { type GeometryData, loadGLBToMeshData } from '@ifc-lite/cache';
20
20
 
21
21
  import { SERVER_URL, USE_SERVER, CACHE_SIZE_THRESHOLD, CACHE_MAX_SOURCE_SIZE, getDynamicBatchConfig } from '../utils/ifcConfig.js';
@@ -69,6 +69,10 @@ function computeFastFingerprint(buffer: ArrayBuffer): string {
69
69
  * Includes binary cache support for fast subsequent loads
70
70
  */
71
71
  export function useIfcLoader() {
72
+ // Guard against stale async writes when user loads a new file before previous completes.
73
+ // Incremented on each loadFile call; deferred callbacks check their captured session.
74
+ const loadSessionRef = useRef(0);
75
+
72
76
  const {
73
77
  setLoading,
74
78
  setError,
@@ -97,6 +101,7 @@ export function useIfcLoader() {
97
101
 
98
102
  const loadFile = useCallback(async (file: File) => {
99
103
  const { resetViewerState, clearAllModels } = useViewerStore.getState();
104
+ const currentSession = ++loadSessionRef.current;
100
105
 
101
106
  // Track total elapsed time for complete user experience
102
107
  const totalStartTime = performance.now();
@@ -243,8 +248,6 @@ export function useIfcLoader() {
243
248
 
244
249
  setProgress({ phase: 'Complete', percent: 100 });
245
250
 
246
- const totalElapsedMs = performance.now() - totalStartTime;
247
- console.log(`[useIfc] GLB loaded: ${meshes.length} meshes, ${stats.totalTriangles} triangles in ${totalElapsedMs.toFixed(0)}ms`);
248
251
  setLoading(false);
249
252
  return;
250
253
  } catch (err: unknown) {
@@ -267,8 +270,7 @@ export function useIfcLoader() {
267
270
  if (cacheResult) {
268
271
  const success = await loadFromCache(cacheResult, file.name, cacheKey);
269
272
  if (success) {
270
- const totalElapsedMs = performance.now() - totalStartTime;
271
- console.log(`[useIfc] TOTAL LOAD TIME (from cache): ${totalElapsedMs.toFixed(0)}ms (${(totalElapsedMs / 1000).toFixed(1)}s)`);
273
+ console.log(`[useIfc] TOTAL LOAD TIME (from cache): ${(performance.now() - totalStartTime).toFixed(0)}ms`);
272
274
  setLoading(false);
273
275
  return;
274
276
  }
@@ -279,10 +281,9 @@ export function useIfcLoader() {
279
281
  // Only for IFC4 STEP files (server doesn't support IFCX)
280
282
  if (format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '') {
281
283
  // Pass buffer directly - server uses File object for parsing, buffer is only for size checks
282
- const serverSuccess = await loadFromServer(file, buffer);
284
+ const serverSuccess = await loadFromServer(file, buffer, () => loadSessionRef.current !== currentSession);
283
285
  if (serverSuccess) {
284
- const totalElapsedMs = performance.now() - totalStartTime;
285
- console.log(`[useIfc] TOTAL LOAD TIME (server): ${totalElapsedMs.toFixed(0)}ms (${(totalElapsedMs / 1000).toFixed(1)}s)`);
286
+ console.log(`[useIfc] TOTAL LOAD TIME (server): ${(performance.now() - totalStartTime).toFixed(0)}ms`);
286
287
  setLoading(false);
287
288
  return;
288
289
  }
@@ -300,9 +301,10 @@ export function useIfcLoader() {
300
301
  });
301
302
  await geometryProcessor.init();
302
303
 
303
- // DEFER data model parsing - start it AFTER geometry streaming begins
304
- // This ensures geometry gets first crack at the CPU for fast first frame
305
- // Data model parsing is lower priority - UI can work without it initially
304
+ // Data model parsing runs IN PARALLEL with geometry streaming.
305
+ // Entity scanning uses a Web Worker (non-blocking, ~1.2s).
306
+ // Columnar parse uses time-sliced yielding (~2.3s, 60fps maintained).
307
+ // Neither depends on geometry output — both just need the raw buffer.
306
308
  let resolveDataStore: (dataStore: IfcDataStore) => void;
307
309
  let rejectDataStore: (err: unknown) => void;
308
310
  const dataStorePromise = new Promise<IfcDataStore>((resolve, reject) => {
@@ -311,13 +313,25 @@ export function useIfcLoader() {
311
313
  });
312
314
 
313
315
  const startDataModelParsing = () => {
314
- // Use main thread - worker parsing disabled (IfcDataStore has closures that can't be serialized)
315
316
  const parser = new IfcParser();
317
+ // wasmApi as fallback if Web Worker unavailable
316
318
  const wasmApi = geometryProcessor.getApi();
317
319
  parser.parseColumnar(buffer, {
318
- wasmApi, // Pass WASM API for 5-10x faster entity scanning
320
+ wasmApi,
321
+ // Emit spatial hierarchy EARLY — lets the panel render while
322
+ // property/association parsing continues (~0.5-1s earlier).
323
+ onSpatialReady: (partialStore) => {
324
+ if (loadSessionRef.current !== currentSession) return;
325
+ if (partialStore.spatialHierarchy && partialStore.spatialHierarchy.storeyHeights.size === 0 && partialStore.spatialHierarchy.storeyElevations.size > 1) {
326
+ const calculatedHeights = calculateStoreyHeights(partialStore.spatialHierarchy.storeyElevations);
327
+ for (const [storeyId, height] of calculatedHeights) {
328
+ partialStore.spatialHierarchy.storeyHeights.set(storeyId, height);
329
+ }
330
+ }
331
+ setIfcDataStore(partialStore);
332
+ },
319
333
  }).then(dataStore => {
320
-
334
+ if (loadSessionRef.current !== currentSession) return;
321
335
  // Calculate storey heights from elevation differences if not already populated
322
336
  if (dataStore.spatialHierarchy && dataStore.spatialHierarchy.storeyHeights.size === 0 && dataStore.spatialHierarchy.storeyElevations.size > 1) {
323
337
  const calculatedHeights = calculateStoreyHeights(dataStore.spatialHierarchy.storeyElevations);
@@ -326,6 +340,7 @@ export function useIfcLoader() {
326
340
  }
327
341
  }
328
342
 
343
+ // Update with full data (includes property/association maps)
329
344
  setIfcDataStore(dataStore);
330
345
  resolveDataStore(dataStore);
331
346
  }).catch(err => {
@@ -334,9 +349,10 @@ export function useIfcLoader() {
334
349
  });
335
350
  };
336
351
 
337
- // Data model parsing is deferred to the 'complete' event (see below).
338
- // Running it concurrently with geometry streaming steals main-thread cycles
339
- // from the WASM↔JS bridge, adding ~1-2s to geometry completion time.
352
+ // Start data model parsing IMMEDIATELY runs in parallel with geometry.
353
+ // Entity scan uses Web Worker (off main thread), columnar parse yields
354
+ // every ~4ms to maintain 60fps navigation during geometry streaming.
355
+ setTimeout(startDataModelParsing, 0);
340
356
 
341
357
  // Use adaptive processing: sync for small files, streaming for large files
342
358
  let estimatedTotal = 0;
@@ -352,11 +368,7 @@ export function useIfcLoader() {
352
368
  setGeometryResult(null);
353
369
 
354
370
  // Timing instrumentation
355
- const processingStart = performance.now();
356
371
  let batchCount = 0;
357
- let lastBatchTime = processingStart;
358
- let totalWaitTime = 0; // Time waiting for WASM to yield batches
359
- let totalProcessTime = 0; // Time processing batches in JS
360
372
  let firstGeometryTime = 0; // Time to first rendered geometry
361
373
  let modelOpenMs = 0;
362
374
  let lastTotalMeshes = 0;
@@ -377,7 +389,6 @@ export function useIfcLoader() {
377
389
  batchSize: dynamicBatchConfig, // Dynamic batches: small first, then large
378
390
  })) {
379
391
  const eventReceived = performance.now();
380
- const waitTime = eventReceived - lastBatchTime;
381
392
 
382
393
  switch (event.type) {
383
394
  case 'start':
@@ -410,7 +421,6 @@ export function useIfcLoader() {
410
421
  }
411
422
  case 'batch': {
412
423
  batchCount++;
413
- totalWaitTime += waitTime;
414
424
 
415
425
  // Track time to first geometry
416
426
  if (batchCount === 1) {
@@ -418,7 +428,6 @@ export function useIfcLoader() {
418
428
  console.log(`[useIfc] Batch #1: ${event.meshes.length} meshes, wait: ${firstGeometryTime.toFixed(0)}ms`);
419
429
  }
420
430
 
421
- const processStart = performance.now();
422
431
 
423
432
  // Collect meshes for BVH building (use loop to avoid stack overflow with large batches)
424
433
  for (let i = 0; i < event.meshes.length; i++) allMeshes.push(event.meshes[i]);
@@ -447,8 +456,6 @@ export function useIfcLoader() {
447
456
  });
448
457
  }
449
458
 
450
- const processTime = performance.now() - processStart;
451
- totalProcessTime += processTime;
452
459
  break;
453
460
  }
454
461
  case 'complete':
@@ -460,11 +467,8 @@ export function useIfcLoader() {
460
467
 
461
468
  finalCoordinateInfo = event.coordinateInfo ?? null;
462
469
 
463
- // PERF: Defer data model parsing to next macrotask so the browser
464
- // can paint the streaming-complete state first. parseColumnar()
465
- // synchronously calls scanEntitiesFast() which blocks the main
466
- // thread for ~7s on large files (487MB → 8.4M entities).
467
- setTimeout(startDataModelParsing, 0);
470
+ // Data model parsing already started in parallel (see above).
471
+ // No need to start it here — it runs concurrently with geometry.
468
472
 
469
473
  // Apply all accumulated color updates in a single store update
470
474
  // instead of one updateMeshColors() call per colorUpdate event.
@@ -482,31 +486,17 @@ export function useIfcLoader() {
482
486
 
483
487
  setProgress({ phase: 'Complete', percent: 100 });
484
488
  console.log(`[useIfc] Geometry streaming complete: ${batchCount} batches, ${lastTotalMeshes} meshes`);
485
- console.log(`Total wait (WASM): ${totalWaitTime.toFixed(0)}ms`);
486
- console.log(`Total process (JS): ${totalProcessTime.toFixed(0)}ms`);
487
489
 
488
490
  // Build spatial index and cache in background (non-blocking)
489
491
  // Wait for data model to complete first
490
492
  dataStorePromise.then(dataStore => {
491
- // Build spatial index from meshes (in background)
492
- if (allMeshes.length > 0) {
493
- const buildIndex = () => {
494
- try {
495
- const spatialIndex = buildSpatialIndex(allMeshes);
496
- dataStore.spatialIndex = spatialIndex;
497
- setIfcDataStore({ ...dataStore });
498
- } catch (err) {
499
- console.warn('[useIfc] Failed to build spatial index:', err);
500
- }
501
- };
502
-
503
- // Use requestIdleCallback if available (type assertion for optional browser API)
504
- if ('requestIdleCallback' in window) {
505
- (window as { requestIdleCallback: (cb: () => void, opts?: { timeout: number }) => void }).requestIdleCallback(buildIndex, { timeout: 2000 });
506
- } else {
507
- setTimeout(buildIndex, 100);
508
- }
509
- }
493
+ // Guard: skip if user loaded a new file since this load started
494
+ if (loadSessionRef.current !== currentSession) return;
495
+ // Build spatial index from meshes in time-sliced chunks (non-blocking).
496
+ // Previously this was synchronous inside requestIdleCallback, blocking
497
+ // the main thread for seconds on 200K+ mesh models (190M+ float reads
498
+ // for bounds computation alone).
499
+ buildSpatialIndexGuarded(allMeshes, dataStore, setIfcDataStore);
510
500
 
511
501
  // Cache the result in the background (files between 10 MB and 150 MB).
512
502
  // Files above CACHE_MAX_SOURCE_SIZE are not cached because the
@@ -536,14 +526,15 @@ export function useIfcLoader() {
536
526
  break;
537
527
  }
538
528
 
539
- lastBatchTime = performance.now();
540
529
  }
541
530
  } catch (err) {
531
+ if (loadSessionRef.current !== currentSession) return;
542
532
  console.error('[useIfc] Error in processing:', err);
543
533
  setError(err instanceof Error ? err.message : 'Unknown error during geometry processing');
544
534
  }
545
535
 
546
- // Log developer-friendly summary with key metrics
536
+ if (loadSessionRef.current !== currentSession) return;
537
+
547
538
  const totalElapsedMs = performance.now() - totalStartTime;
548
539
  const totalVertices = allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0);
549
540
  console.log(
@@ -552,9 +543,9 @@ export function useIfcLoader() {
552
543
  `first: ${firstGeometryTime.toFixed(0)}ms, total: ${totalElapsedMs.toFixed(0)}ms`
553
544
  );
554
545
  console.log(`[useIfc] TOTAL LOAD TIME (local): ${totalElapsedMs.toFixed(0)}ms (${(totalElapsedMs / 1000).toFixed(1)}s)`);
555
-
556
546
  setLoading(false);
557
547
  } catch (err) {
548
+ if (loadSessionRef.current !== currentSession) return;
558
549
  setError(err instanceof Error ? err.message : 'Unknown error');
559
550
  setLoading(false);
560
551
  }
@@ -37,6 +37,7 @@ import {
37
37
 
38
38
  // Server data model conversion
39
39
  import { convertServerDataModel, type ServerParseResult } from '../utils/serverDataModel.js';
40
+ import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
40
41
 
41
42
  /** Convert server mesh data (snake_case) to viewer format (camelCase) */
42
43
  function convertServerMesh(m: ServerMeshData): MeshData {
@@ -125,7 +126,9 @@ export function useIfcServer() {
125
126
  */
126
127
  const loadFromServer = useCallback(async (
127
128
  file: File,
128
- buffer: ArrayBuffer
129
+ buffer: ArrayBuffer,
130
+ /** Optional staleness check — returns true if this load has been superseded. */
131
+ isStale?: () => boolean,
129
132
  ): Promise<boolean> => {
130
133
  const { setProgress, setIfcDataStore, setGeometryResult } = useViewerStore.getState();
131
134
  try {
@@ -140,6 +143,7 @@ export function useIfcServer() {
140
143
  return false; // Silently fall back - caller handles logging
141
144
  }
142
145
 
146
+ if (isStale?.()) return false;
143
147
  setProgress({ phase: 'Processing on server (parallel)', percent: 15 });
144
148
 
145
149
  // Check if Parquet is supported (requires parquet-wasm)
@@ -397,6 +401,7 @@ export function useIfcServer() {
397
401
 
398
402
  // Start data model fetch in background - don't block rendering
399
403
  (async () => {
404
+ if (isStale?.()) return;
400
405
  setProgress({ phase: 'Fetching data model', percent: 85 });
401
406
  const dataModelStart = performance.now();
402
407
 
@@ -436,9 +441,12 @@ export function useIfcServer() {
436
441
  allMeshes
437
442
  );
438
443
 
444
+ if (isStale?.()) return;
439
445
  setIfcDataStore(dataStore);
440
446
  console.log('[useIfc] ✅ Property panel ready with server data model');
441
447
  console.log(`[useIfc] Data model loaded in ${(performance.now() - dataModelStart).toFixed(0)}ms (background)`);
448
+
449
+ buildSpatialIndexGuarded(allMeshes, dataStore, setIfcDataStore);
442
450
  } catch (err) {
443
451
  console.warn('[useIfc] Failed to decode data model:', err);
444
452
  console.log('[useIfc] ⚡ Skipping data model (decoding failed)');
@@ -76,14 +76,6 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
76
76
  e.preventDefault();
77
77
  setActiveTool('select');
78
78
  }
79
- if (key === 'p' && !ctrl && !shift) {
80
- e.preventDefault();
81
- setActiveTool('pan');
82
- }
83
- if (key === 'o' && !ctrl && !shift) {
84
- e.preventDefault();
85
- setActiveTool('orbit');
86
- }
87
79
  if (key === 'c' && !ctrl && !shift) {
88
80
  e.preventDefault();
89
81
  setActiveTool('walk');
@@ -249,8 +241,6 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
249
241
  // Export shortcut definitions for UI display
250
242
  export const KEYBOARD_SHORTCUTS = [
251
243
  { key: 'V', description: 'Select tool', category: 'Tools' },
252
- { key: 'P', description: 'Pan tool', category: 'Tools' },
253
- { key: 'O', description: 'Orbit tool', category: 'Tools' },
254
244
  { key: 'C', description: 'Walk mode', category: 'Tools' },
255
245
  { key: 'M', description: 'Measure tool', category: 'Tools' },
256
246
  { key: 'X', description: 'Section tool', category: 'Tools' },
@@ -0,0 +1,24 @@
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
+ import { useRef, useEffect, type MutableRefObject } from 'react';
6
+
7
+ /**
8
+ * Keep a ref in sync with a value on every render.
9
+ *
10
+ * This replaces the common pattern of:
11
+ * const fooRef = useRef(foo);
12
+ * useEffect(() => { fooRef.current = foo; }, [foo]);
13
+ *
14
+ * The ref is updated synchronously during render (before effects),
15
+ * so event handlers and animation loops always see the latest value
16
+ * without needing to be re-created.
17
+ */
18
+ export function useLatestRef<T>(value: T): MutableRefObject<T> {
19
+ const ref = useRef(value);
20
+ // Update synchronously during render — no useEffect needed.
21
+ // This is safe because we're only writing to a ref, not causing side effects.
22
+ ref.current = value;
23
+ return ref;
24
+ }
@@ -369,7 +369,7 @@ export function createExportAdapter(store: StoreApi): ExportBackendMethods {
369
369
  return exporter.export(exportOptions).content;
370
370
  },
371
371
 
372
- download(content: string, filename: string, mimeType?: string) {
372
+ download(content: string | Uint8Array, filename: string, mimeType?: string) {
373
373
  triggerDownload(content, filename, mimeType ?? 'text/plain');
374
374
  return undefined;
375
375
  },
@@ -377,7 +377,7 @@ export function createExportAdapter(store: StoreApi): ExportBackendMethods {
377
377
  }
378
378
 
379
379
  /** Trigger a browser file download */
380
- function triggerDownload(content: string, filename: string, mimeType: string): void {
380
+ function triggerDownload(content: string | Uint8Array, filename: string, mimeType: string): void {
381
381
  if (typeof document === 'undefined') {
382
382
  throw new Error('download() requires a browser environment (document is unavailable)');
383
383
  }
@@ -15,6 +15,7 @@ export function createModelAdapter(store: StoreApi): ModelBackendMethods {
15
15
  result.push({
16
16
  id: model.id,
17
17
  name: model.name,
18
+ schema: model.schemaVersion,
18
19
  schemaVersion: model.schemaVersion,
19
20
  entityCount: model.ifcDataStore?.entities?.count ?? 0,
20
21
  fileSize: model.fileSize,
@@ -82,6 +82,7 @@ export class LocalBackend implements BimBackend {
82
82
  model: {
83
83
  id: model.id,
84
84
  name: model.name,
85
+ schema: model.schemaVersion,
85
86
  schemaVersion: model.schemaVersion,
86
87
  entityCount: model.ifcDataStore?.entities?.count ?? 0,
87
88
  fileSize: model.fileSize,
@@ -96,6 +97,7 @@ export class LocalBackend implements BimBackend {
96
97
  model: {
97
98
  id: LEGACY_MODEL_ID,
98
99
  name: 'Model',
100
+ schema: state.ifcDataStore.schemaVersion ?? 'IFC4',
99
101
  schemaVersion: state.ifcDataStore.schemaVersion ?? 'IFC4',
100
102
  entityCount: state.ifcDataStore.entities?.count ?? 0,
101
103
  fileSize: state.ifcDataStore.source?.byteLength ?? 0,
@@ -54,6 +54,15 @@ function digestModelEntityMap(map: Map<string, Set<number>>): string {
54
54
 
55
55
  function visibilityFingerprint(state: ViewerStateSnapshot): string {
56
56
  const tv = state.typeVisibility;
57
+
58
+ // Include per-model visible flag and geometry mesh count so the cache
59
+ // invalidates when model visibility is toggled or geometry finishes loading.
60
+ const modelParts: string[] = [];
61
+ for (const [modelId, model] of state.models) {
62
+ modelParts.push(`${modelId}:${model.visible ? 1 : 0}:${model.geometryResult?.meshes?.length ?? 0}`);
63
+ }
64
+ modelParts.sort();
65
+
57
66
  return [
58
67
  digestNumberSet(state.hiddenEntities),
59
68
  state.isolatedEntities ? digestNumberSet(state.isolatedEntities) : 'none',
@@ -61,10 +70,13 @@ function visibilityFingerprint(state: ViewerStateSnapshot): string {
61
70
  digestNumberSet(state.lensHiddenIds),
62
71
  digestModelEntityMap(state.hiddenEntitiesByModel),
63
72
  digestModelEntityMap(state.isolatedEntitiesByModel),
73
+ digestNumberSet(state.selectedStoreys),
64
74
  tv.spaces ? 1 : 0,
65
75
  tv.openings ? 1 : 0,
66
76
  tv.site ? 1 : 0,
67
77
  state.models.size,
78
+ modelParts.join(';'),
79
+ state.geometryResult?.meshes?.length ?? 0,
68
80
  state.activeBasketViewId ?? 'none',
69
81
  ].join(':');
70
82
  }
@@ -35,6 +35,8 @@ export interface BCFSliceState {
35
35
  bcfError: string | null;
36
36
  /** Default author for new topics/comments */
37
37
  bcfAuthor: string;
38
+ /** Whether 3D overlay markers are shown in the viewport */
39
+ bcfOverlayVisible: boolean;
38
40
  }
39
41
 
40
42
  export interface BCFSlice extends BCFSliceState {
@@ -65,6 +67,8 @@ export interface BCFSlice extends BCFSliceState {
65
67
  setBcfLoading: (loading: boolean) => void;
66
68
  setBcfError: (error: string | null) => void;
67
69
  setBcfAuthor: (author: string) => void;
70
+ setBcfOverlayVisible: (visible: boolean) => void;
71
+ toggleBcfOverlay: () => void;
68
72
 
69
73
  // Utility getters
70
74
  getActiveTopic: () => BCFTopic | null;
@@ -102,6 +106,7 @@ export const createBcfSlice: StateCreator<BCFSlice, [], [], BCFSlice> = (set, ge
102
106
  bcfLoading: false,
103
107
  bcfError: null,
104
108
  bcfAuthor: getDefaultBcfAuthor(),
109
+ bcfOverlayVisible: false,
105
110
 
106
111
  // Project actions
107
112
  setBcfProject: (bcfProject) => set({
@@ -350,6 +355,10 @@ export const createBcfSlice: StateCreator<BCFSlice, [], [], BCFSlice> = (set, ge
350
355
  set({ bcfAuthor });
351
356
  },
352
357
 
358
+ setBcfOverlayVisible: (bcfOverlayVisible) => set({ bcfOverlayVisible }),
359
+
360
+ toggleBcfOverlay: () => set((state) => ({ bcfOverlayVisible: !state.bcfOverlayVisible })),
361
+
353
362
  // Utility getters
354
363
  getActiveTopic: () => {
355
364
  const state = get();
@@ -0,0 +1,46 @@
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
+ * Shared loading utilities used across all IFC loading hooks.
7
+ *
8
+ * Consolidates the guarded spatial-index build pattern that was
9
+ * duplicated across useIfcLoader, useIfcCache, useIfcServer, and
10
+ * useIfcFederation.
11
+ */
12
+
13
+ import type { MeshData } from '@ifc-lite/geometry';
14
+ import type { IfcDataStore } from '@ifc-lite/parser';
15
+ import { buildSpatialIndexAsync } from '@ifc-lite/spatial';
16
+ import { useViewerStore } from '../store.js';
17
+
18
+ /**
19
+ * Build a spatial index in the background (time-sliced, non-blocking)
20
+ * with a guard against stale loads.
21
+ *
22
+ * The guard captures the dataStore reference and compares it to the
23
+ * current store when the async build completes. If the store has been
24
+ * replaced (e.g. user loaded a new file), the result is discarded.
25
+ *
26
+ * @param meshes - Final mesh array with correct IDs and world-space positions
27
+ * @param dataStore - The IfcDataStore to attach the spatial index to
28
+ * @param setIfcDataStore - Store setter to trigger re-render
29
+ */
30
+ export function buildSpatialIndexGuarded(
31
+ meshes: MeshData[],
32
+ dataStore: IfcDataStore,
33
+ setIfcDataStore: (store: IfcDataStore) => void,
34
+ ): void {
35
+ if (meshes.length === 0) return;
36
+
37
+ const capturedStore = dataStore;
38
+ buildSpatialIndexAsync(meshes).then(spatialIndex => {
39
+ const { ifcDataStore: currentStore } = useViewerStore.getState();
40
+ if (currentStore !== capturedStore) return;
41
+ capturedStore.spatialIndex = spatialIndex;
42
+ setIfcDataStore({ ...capturedStore });
43
+ }).catch(err => {
44
+ console.warn('[loadingUtils] Failed to build spatial index:', err);
45
+ });
46
+ }
@@ -32,7 +32,7 @@ import {
32
32
  type QuantitySet,
33
33
  } from '@ifc-lite/data';
34
34
  import { StringTable } from '@ifc-lite/data';
35
- import { buildSpatialIndex, type SpatialIndex } from '@ifc-lite/spatial';
35
+ import type { SpatialIndex } from '@ifc-lite/spatial';
36
36
 
37
37
  // ============================================================================
38
38
  // Types
@@ -713,8 +713,9 @@ export function convertServerDataModel(
713
713
  },
714
714
  };
715
715
 
716
- // Build spatial index
717
- const spatialIndex = allMeshes.length > 0 ? buildSpatialIndex(allMeshes) : undefined;
716
+ // Spatial index is built asynchronously by the caller after this returns
717
+ // to avoid blocking the main thread for seconds on large models.
718
+ const spatialIndex: SpatialIndex | undefined = undefined;
718
719
 
719
720
  // Validate schemaVersion against allowed values
720
721
  const VALID_SCHEMA_VERSIONS = ['IFC2X3', 'IFC4', 'IFC4X3', 'IFC5'] as const;