@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
@@ -4,7 +4,10 @@
4
4
 
5
5
  /**
6
6
  * Render updates hook for the 3D viewport
7
- * Handles visibility/selection/section/hover state re-render effects
7
+ * Handles visibility/selection/section/hover state re-render effects.
8
+ *
9
+ * These effects update refs and request a render — the animation loop
10
+ * picks up the new state on the next frame.
8
11
  */
9
12
 
10
13
  import { useEffect, type MutableRefObject } from 'react';
@@ -56,7 +59,6 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
56
59
  isInitialized,
57
60
  theme,
58
61
  clearColorRef,
59
- visualEnhancementRef,
60
62
  hiddenEntities,
61
63
  isolatedEntities,
62
64
  selectedEntityId,
@@ -66,14 +68,8 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
66
68
  sectionPlane,
67
69
  sectionRange,
68
70
  coordinateInfo,
69
- hiddenEntitiesRef,
70
- isolatedEntitiesRef,
71
- selectedEntityIdRef,
72
- selectedModelIndexRef,
73
- selectedEntityIdsRef,
74
71
  sectionPlaneRef,
75
72
  sectionRangeRef,
76
- activeToolRef,
77
73
  drawing2D,
78
74
  show3DOverlay,
79
75
  showHiddenLines,
@@ -81,20 +77,8 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
81
77
 
82
78
  // Theme-aware clear color update
83
79
  useEffect(() => {
84
- // Update clear color when theme changes
85
80
  clearColorRef.current = getThemeClearColor(theme as 'light' | 'dark');
86
- // Re-render with new clear color
87
- const renderer = rendererRef.current;
88
- if (renderer && isInitialized) {
89
- renderer.render({
90
- hiddenIds: hiddenEntitiesRef.current,
91
- isolatedIds: isolatedEntitiesRef.current,
92
- selectedId: selectedEntityIdRef.current,
93
- selectedModelIndex: selectedModelIndexRef.current,
94
- clearColor: clearColorRef.current,
95
- visualEnhancement: visualEnhancementRef.current,
96
- });
97
- }
81
+ rendererRef.current?.requestRender();
98
82
  }, [theme, isInitialized]);
99
83
 
100
84
  // 2D section overlay: upload drawing data to renderer when available
@@ -102,16 +86,13 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
102
86
  const renderer = rendererRef.current;
103
87
  if (!renderer || !isInitialized) return;
104
88
 
105
- // Only show overlay when section tool is active, we have a drawing, AND 3D overlay is enabled
106
89
  if (activeTool === 'section' && drawing2D && drawing2D.cutPolygons.length > 0 && show3DOverlay) {
107
- // Convert Drawing2D format to renderer format
108
90
  const polygons: CutPolygon2D[] = drawing2D.cutPolygons.map((cp) => ({
109
91
  polygon: cp.polygon,
110
92
  ifcType: cp.ifcType,
111
- expressId: cp.entityId, // DrawingPolygon uses entityId
93
+ expressId: cp.entityId,
112
94
  }));
113
95
 
114
- // Include linework from the generated drawing on the section plane overlay.
115
96
  const lines: DrawingLine2D[] = drawing2D.lines
116
97
  .filter((line) => showHiddenLines || line.visibility !== 'hidden')
117
98
  .map((line) => ({
@@ -119,36 +100,19 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
119
100
  category: line.category,
120
101
  }));
121
102
 
122
- // Upload to renderer - will be drawn on the section plane
123
- // Pass sectionRange to match exactly what render() uses for section plane position
124
103
  renderer.uploadSection2DOverlay(
125
104
  polygons,
126
105
  lines,
127
106
  sectionPlane.axis,
128
107
  sectionPlane.position,
129
- sectionRangeRef.current ?? undefined, // Same range as section plane
108
+ sectionRangeRef.current ?? undefined,
130
109
  sectionPlane.flipped
131
110
  );
132
111
  } else {
133
- // Clear overlay when not in section mode, no drawing, or overlay disabled
134
112
  renderer.clearSection2DOverlay();
135
113
  }
136
114
 
137
- // Re-render to show/hide overlay
138
- renderer.render({
139
- hiddenIds: hiddenEntitiesRef.current,
140
- isolatedIds: isolatedEntitiesRef.current,
141
- selectedId: selectedEntityIdRef.current,
142
- selectedIds: selectedEntityIdsRef.current,
143
- selectedModelIndex: selectedModelIndexRef.current,
144
- clearColor: clearColorRef.current,
145
- visualEnhancement: visualEnhancementRef.current,
146
- sectionPlane: activeTool === 'section' ? {
147
- ...sectionPlane,
148
- min: sectionRangeRef.current?.min,
149
- max: sectionRangeRef.current?.max,
150
- } : undefined,
151
- });
115
+ renderer.requestRender();
152
116
  }, [drawing2D, activeTool, sectionPlane, isInitialized, coordinateInfo, show3DOverlay, showHiddenLines]);
153
117
 
154
118
  // Re-render when visibility, selection, or section plane changes
@@ -156,21 +120,7 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
156
120
  const renderer = rendererRef.current;
157
121
  if (!renderer || !isInitialized) return;
158
122
 
159
- renderer.render({
160
- hiddenIds: hiddenEntities,
161
- isolatedIds: isolatedEntities,
162
- selectedId: selectedEntityId,
163
- selectedIds: selectedEntityIds,
164
- selectedModelIndex,
165
- clearColor: clearColorRef.current,
166
- visualEnhancement: visualEnhancementRef.current,
167
- sectionPlane: activeTool === 'section' ? {
168
- ...sectionPlane,
169
- min: sectionRange?.min,
170
- max: sectionRange?.max,
171
- } : undefined,
172
- buildingRotation: coordinateInfo?.buildingRotation,
173
- });
123
+ renderer.requestRender();
174
124
  }, [hiddenEntities, isolatedEntities, selectedEntityId, selectedEntityIds, selectedModelIndex, isInitialized, sectionPlane, activeTool, sectionRange, coordinateInfo?.buildingRotation]);
175
125
  }
176
126
 
@@ -37,6 +37,7 @@ export interface UseTouchControlsParams {
37
37
  sectionPlaneRef: MutableRefObject<SectionPlane>;
38
38
  sectionRangeRef: MutableRefObject<{ min: number; max: number } | null>;
39
39
  geometryRef: MutableRefObject<MeshData[] | null>;
40
+ isInteractingRef: MutableRefObject<boolean>;
40
41
  handlePickForSelection: (pickResult: PickResult | null) => void;
41
42
  getPickOptions: () => { isStreaming: boolean; hiddenIds: Set<number>; isolatedIds: Set<number> | null };
42
43
  }
@@ -56,6 +57,7 @@ export function useTouchControls(params: UseTouchControlsParams): void {
56
57
  sectionPlaneRef,
57
58
  sectionRangeRef,
58
59
  geometryRef,
60
+ isInteractingRef,
59
61
  handlePickForSelection,
60
62
  getPickOptions,
61
63
  } = params;
@@ -90,22 +92,38 @@ export function useTouchControls(params: UseTouchControlsParams): void {
90
92
  };
91
93
  touchState.didMove = false;
92
94
 
93
- // Set orbit pivot to what user touches (same as mouse click behavior)
95
+ // Set orbit pivot to the 3D point under the finger.
96
+ // On miss, place pivot at current distance along the finger ray.
94
97
  const rect = canvas.getBoundingClientRect();
95
- const x = touchState.touches[0].clientX - rect.left;
96
- const y = touchState.touches[0].clientY - rect.top;
97
-
98
- // Uses visibility filtering so hidden elements don't affect orbit pivot
99
- const pickResult = await renderer.pick(x, y, getPickOptions());
100
- if (pickResult !== null) {
101
- const center = getEntityCenter(geometryRef.current, pickResult.expressId);
98
+ const tx = touchState.touches[0].clientX - rect.left;
99
+ const ty = touchState.touches[0].clientY - rect.top;
100
+ const hit = renderer.raycastScene(tx, ty, {
101
+ hiddenIds: hiddenEntitiesRef.current,
102
+ isolatedIds: isolatedEntitiesRef.current,
103
+ });
104
+ if (hit?.intersection) {
105
+ camera.setOrbitCenter(hit.intersection.point);
106
+ } else if (selectedEntityIdRef.current) {
107
+ const center = getEntityCenter(geometryRef.current, selectedEntityIdRef.current);
102
108
  if (center) {
103
- camera.setOrbitPivot(center);
109
+ camera.setOrbitCenter(center);
104
110
  } else {
105
- camera.setOrbitPivot(null);
111
+ camera.setOrbitCenter(null);
106
112
  }
107
113
  } else {
108
- camera.setOrbitPivot(null);
114
+ const ray = camera.unprojectToRay(tx, ty, canvas.width, canvas.height);
115
+ const target = camera.getTarget();
116
+ const toTarget = {
117
+ x: target.x - ray.origin.x,
118
+ y: target.y - ray.origin.y,
119
+ z: target.z - ray.origin.z,
120
+ };
121
+ const d = Math.max(1, toTarget.x * ray.direction.x + toTarget.y * ray.direction.y + toTarget.z * ray.direction.z);
122
+ camera.setOrbitCenter({
123
+ x: ray.origin.x + ray.direction.x * d,
124
+ y: ray.origin.y + ray.direction.y * d,
125
+ z: ray.origin.z + ray.direction.z * d,
126
+ });
109
127
  }
110
128
  } else if (touchState.touches.length === 1) {
111
129
  // Single touch after multi-touch - just update center for orbit
@@ -144,19 +162,8 @@ export function useTouchControls(params: UseTouchControlsParams): void {
144
162
  x: touchState.touches[0].clientX,
145
163
  y: touchState.touches[0].clientY,
146
164
  };
147
- renderer.render({
148
- hiddenIds: hiddenEntitiesRef.current,
149
- isolatedIds: isolatedEntitiesRef.current,
150
- selectedId: selectedEntityIdRef.current,
151
- selectedModelIndex: selectedModelIndexRef.current,
152
- clearColor: clearColorRef.current,
153
- isInteracting: true,
154
- sectionPlane: activeToolRef.current === 'section' ? {
155
- ...sectionPlaneRef.current,
156
- min: sectionRangeRef.current?.min,
157
- max: sectionRangeRef.current?.max,
158
- } : undefined,
159
- });
165
+ isInteractingRef.current = true;
166
+ renderer.requestRender();
160
167
  } else if (touchState.touches.length === 2) {
161
168
  const dx1 = touchState.touches[1].clientX - touchState.touches[0].clientX;
162
169
  const dy1 = touchState.touches[1].clientY - touchState.touches[0].clientY;
@@ -174,19 +181,8 @@ export function useTouchControls(params: UseTouchControlsParams): void {
174
181
 
175
182
  touchState.lastDistance = distance;
176
183
  touchState.lastCenter = { x: centerX, y: centerY };
177
- renderer.render({
178
- hiddenIds: hiddenEntitiesRef.current,
179
- isolatedIds: isolatedEntitiesRef.current,
180
- selectedId: selectedEntityIdRef.current,
181
- selectedModelIndex: selectedModelIndexRef.current,
182
- clearColor: clearColorRef.current,
183
- isInteracting: true,
184
- sectionPlane: activeToolRef.current === 'section' ? {
185
- ...sectionPlaneRef.current,
186
- min: sectionRangeRef.current?.min,
187
- max: sectionRangeRef.current?.max,
188
- } : undefined,
189
- });
184
+ isInteractingRef.current = true;
185
+ renderer.requestRender();
190
186
  }
191
187
  };
192
188
 
@@ -196,9 +192,16 @@ export function useTouchControls(params: UseTouchControlsParams): void {
196
192
  const wasMultiTouch = touchState.multiTouch;
197
193
  touchState.touches = Array.from(e.touches);
198
194
 
195
+ // Only clear interaction when all fingers are lifted (gesture truly ended).
196
+ // Clearing earlier would briefly drop interaction mode during 2-finger → 1-finger
197
+ // transitions, triggering an expensive full-quality render mid-gesture.
198
+ if (touchState.touches.length === 0 && isInteractingRef.current) {
199
+ isInteractingRef.current = false;
200
+ renderer.requestRender();
201
+ }
202
+
199
203
  if (touchState.touches.length === 0) {
200
204
  camera.stopInertia();
201
- camera.setOrbitPivot(null);
202
205
 
203
206
  // Tap-to-select: detect quick tap without significant movement
204
207
  const tapDuration = Date.now() - touchState.tapStartTime;
@@ -208,13 +211,12 @@ export function useTouchControls(params: UseTouchControlsParams): void {
208
211
  // - Was a single-finger touch (not after multi-touch gesture)
209
212
  // - Tap was quick (< 300ms)
210
213
  // - Didn't move significantly
211
- // - Tool supports selection (not orbit/pan/walk/measure)
214
+ // - Tool supports selection (not pan/walk/measure)
212
215
  if (
213
216
  previousTouchCount === 1 &&
214
217
  !wasMultiTouch &&
215
218
  tapDuration < 300 &&
216
219
  !touchState.didMove &&
217
- tool !== 'orbit' &&
218
220
  tool !== 'pan' &&
219
221
  tool !== 'walk' &&
220
222
  tool !== 'measure'
@@ -232,14 +234,27 @@ export function useTouchControls(params: UseTouchControlsParams): void {
232
234
  }
233
235
  };
234
236
 
237
+ // Also reset interaction on touchcancel — mobile browsers can cancel
238
+ // gestures (system gestures, tab switch, lost focus) without touchend.
239
+ const handleTouchCancel = () => {
240
+ if (isInteractingRef.current) {
241
+ isInteractingRef.current = false;
242
+ renderer.requestRender();
243
+ }
244
+ touchState.touches = [];
245
+ touchState.multiTouch = false;
246
+ };
247
+
235
248
  canvas.addEventListener('touchstart', handleTouchStart);
236
249
  canvas.addEventListener('touchmove', handleTouchMove);
237
250
  canvas.addEventListener('touchend', handleTouchEnd);
251
+ canvas.addEventListener('touchcancel', handleTouchCancel);
238
252
 
239
253
  return () => {
240
254
  canvas.removeEventListener('touchstart', handleTouchStart);
241
255
  canvas.removeEventListener('touchmove', handleTouchMove);
242
256
  canvas.removeEventListener('touchend', handleTouchEnd);
257
+ canvas.removeEventListener('touchcancel', handleTouchCancel);
243
258
  };
244
259
  }, [isInitialized]);
245
260
  }
@@ -0,0 +1,70 @@
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 BCF ID lookup utilities.
7
+ *
8
+ * Provides conversion between IFC GlobalId strings and expressIds,
9
+ * accounting for multi-model federation offsets and single-model fallback.
10
+ */
11
+
12
+ import type { FederatedModel, IfcDataStore } from '@/store/types';
13
+
14
+ export interface IdLookupResult {
15
+ expressId: number;
16
+ modelId: string;
17
+ }
18
+
19
+ /**
20
+ * Convert IFC GlobalId string to expressId (with model offset for federation).
21
+ * Searches federated models first, then falls back to the legacy single-model store.
22
+ */
23
+ export function globalIdToExpressId(
24
+ globalIdString: string,
25
+ models: Map<string, FederatedModel>,
26
+ ifcDataStore: IfcDataStore | null | undefined,
27
+ ): IdLookupResult | null {
28
+ // Multi-model path
29
+ for (const [modelId, model] of models.entries()) {
30
+ const localExpressId = model.ifcDataStore?.entities?.getExpressIdByGlobalId(globalIdString);
31
+ if (localExpressId !== undefined && localExpressId > 0) {
32
+ const offset = model.idOffset ?? 0;
33
+ return { expressId: localExpressId + offset, modelId };
34
+ }
35
+ }
36
+ // Single-model fallback
37
+ if (models.size === 0 && ifcDataStore?.entities) {
38
+ const localExpressId = ifcDataStore.entities.getExpressIdByGlobalId(globalIdString);
39
+ if (localExpressId !== undefined && localExpressId > 0) {
40
+ return { expressId: localExpressId, modelId: 'legacy' };
41
+ }
42
+ }
43
+ return null;
44
+ }
45
+
46
+ /**
47
+ * Convert expressId to IFC GlobalId string (reversing federation offset).
48
+ * Searches federated models first, then falls back to the legacy single-model store.
49
+ */
50
+ export function expressIdToGlobalId(
51
+ expressId: number,
52
+ models: Map<string, FederatedModel>,
53
+ ifcDataStore: IfcDataStore | null | undefined,
54
+ ): string | null {
55
+ // Multi-model path: search federated models
56
+ for (const model of models.values()) {
57
+ const offset = model.idOffset ?? 0;
58
+ const localExpressId = expressId - offset;
59
+ if (localExpressId > 0 && localExpressId <= (model.maxExpressId ?? Infinity)) {
60
+ const globalIdString = model.ifcDataStore?.entities?.getGlobalId(localExpressId);
61
+ if (globalIdString) return globalIdString;
62
+ }
63
+ }
64
+ // Single-model fallback: use legacy ifcDataStore directly
65
+ if (models.size === 0 && ifcDataStore?.entities) {
66
+ const globalIdString = ifcDataStore.entities.getGlobalId(expressId);
67
+ if (globalIdString) return globalIdString;
68
+ }
69
+ return null;
70
+ }
@@ -22,6 +22,10 @@ import {
22
22
  type ViewerBounds,
23
23
  } from '@ifc-lite/bcf';
24
24
  import type { Renderer } from '@ifc-lite/renderer';
25
+ import {
26
+ globalIdToExpressId as globalIdToExpressIdLookup,
27
+ expressIdToGlobalId as expressIdToGlobalIdLookup,
28
+ } from './bcfIdLookup';
25
29
 
26
30
  // ============================================================================
27
31
  // Types
@@ -122,6 +126,8 @@ export function useBCF(options: UseBCFOptions = {}): UseBCFResult {
122
126
 
123
127
  // Get coordinate info for bounds
124
128
  const models = useViewerStore((s) => s.models);
129
+ // Legacy single-model data store (used when models Map is empty)
130
+ const ifcDataStore = useViewerStore((s) => s.ifcDataStore);
125
131
 
126
132
  /**
127
133
  * Get the canvas element (local ref or global)
@@ -225,22 +231,9 @@ export function useBCF(options: UseBCFOptions = {}): UseBCFResult {
225
231
  * Handles multi-model federation by finding the correct model and subtracting offset
226
232
  */
227
233
  const expressIdToGlobalId = useCallback(
228
- (expressId: number): string | null => {
229
- for (const model of models.values()) {
230
- const offset = model.idOffset ?? 0;
231
- const localExpressId = expressId - offset;
232
-
233
- // Check if this expressId belongs to this model's range
234
- if (localExpressId > 0 && localExpressId <= (model.maxExpressId ?? Infinity)) {
235
- const globalIdString = model.ifcDataStore?.entities?.getGlobalId(localExpressId);
236
- if (globalIdString) {
237
- return globalIdString;
238
- }
239
- }
240
- }
241
- return null;
242
- },
243
- [models]
234
+ (expressId: number): string | null =>
235
+ expressIdToGlobalIdLookup(expressId, models, ifcDataStore),
236
+ [models, ifcDataStore]
244
237
  );
245
238
 
246
239
  /**
@@ -248,21 +241,9 @@ export function useBCF(options: UseBCFOptions = {}): UseBCFResult {
248
241
  * Returns { expressId, modelId } or null if not found
249
242
  */
250
243
  const globalIdToExpressId = useCallback(
251
- (globalIdString: string): { expressId: number; modelId: string } | null => {
252
- for (const [modelId, model] of models.entries()) {
253
- const localExpressId = model.ifcDataStore?.entities?.getExpressIdByGlobalId(globalIdString);
254
- if (localExpressId !== undefined && localExpressId > 0) {
255
- // Add model offset for federation
256
- const offset = model.idOffset ?? 0;
257
- return {
258
- expressId: localExpressId + offset,
259
- modelId,
260
- };
261
- }
262
- }
263
- return null;
264
- },
265
- [models]
244
+ (globalIdString: string): { expressId: number; modelId: string } | null =>
245
+ globalIdToExpressIdLookup(globalIdString, models, ifcDataStore),
246
+ [models, ifcDataStore]
266
247
  );
267
248
 
268
249
  /**
@@ -17,7 +17,7 @@ import {
17
17
  type GeometryData,
18
18
  } from '@ifc-lite/cache';
19
19
  import { SpatialHierarchyBuilder, StepTokenizer, buildCompactEntityIndex, extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
20
- import { buildSpatialIndex } from '@ifc-lite/spatial';
20
+ import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
21
21
  import type { MeshData } from '@ifc-lite/geometry';
22
22
 
23
23
  import { useShallow } from 'zustand/react/shallow';
@@ -191,25 +191,7 @@ export function useIfcCache() {
191
191
  // Set data store
192
192
  setIfcDataStore(dataStore);
193
193
 
194
- // Build spatial index in background (non-blocking)
195
- if (meshes.length > 0) {
196
- const buildIndex = () => {
197
- try {
198
- const spatialIndex = buildSpatialIndex(meshes);
199
- dataStore.spatialIndex = spatialIndex;
200
- setIfcDataStore({ ...dataStore });
201
- } catch (err) {
202
- console.warn('[useIfcCache] Failed to build spatial index:', err);
203
- }
204
- };
205
-
206
- if ('requestIdleCallback' in window) {
207
- (window as any).requestIdleCallback(buildIndex, { timeout: 2000 });
208
- } else {
209
- // Fallback for browsers without requestIdleCallback
210
- setTimeout(buildIndex, 100);
211
- }
212
- }
194
+ buildSpatialIndexGuarded(meshes, dataStore, setIfcDataStore);
213
195
  } else {
214
196
  setIfcDataStore(dataStore);
215
197
  }
@@ -16,7 +16,7 @@ import { useViewerStore, type FederatedModel, type SchemaVersion } from '../stor
16
16
  import { IfcParser, detectFormat, parseIfcx, parseFederatedIfcx, type IfcDataStore, type FederatedIfcxParseResult } from '@ifc-lite/parser';
17
17
  import { GeometryProcessor, GeometryQuality, type MeshData, type CoordinateInfo } from '@ifc-lite/geometry';
18
18
  import { IfcQuery } from '@ifc-lite/query';
19
- import { buildSpatialIndex } from '@ifc-lite/spatial';
19
+ import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
20
20
  import { loadGLBToMeshData } from '@ifc-lite/cache';
21
21
 
22
22
  import { getDynamicBatchConfig } from '../utils/ifcConfig.js';
@@ -333,16 +333,6 @@ export function useIfcFederation() {
333
333
  }
334
334
  }
335
335
 
336
- // Build spatial index
337
- if (allMeshes.length > 0) {
338
- try {
339
- const spatialIndex = buildSpatialIndex(allMeshes);
340
- parsedDataStore.spatialIndex = spatialIndex;
341
- } catch (err) {
342
- console.warn('[useIfc] Failed to build spatial index:', err);
343
- }
344
- }
345
-
346
336
  parsedGeometry = {
347
337
  meshes: allMeshes,
348
338
  totalVertices: allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0),
@@ -463,6 +453,10 @@ export function useIfcFederation() {
463
453
  }
464
454
  }
465
455
 
456
+ // Build spatial index AFTER ID offset + RTC alignment so it stores
457
+ // correct globalIds and final world-space positions.
458
+ buildSpatialIndexGuarded(parsedGeometry.meshes, parsedDataStore, setIfcDataStore);
459
+
466
460
  // Create the federated model with offset info
467
461
  const federatedModel: FederatedModel = {
468
462
  id: modelId,