@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
@@ -3,137 +3,23 @@
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
5
  /**
6
- * Hook for loading and processing IFC files
7
- * Includes binary cache support for fast subsequent loads
6
+ * Orchestrator hook for loading and processing IFC files
7
+ * Composes sub-hooks for server communication, file loading, and multi-model federation
8
+ *
9
+ * Sub-hooks:
10
+ * - useIfcServer: Server reachability, streaming/Parquet/JSON parsing paths
11
+ * - useIfcLoader: Single-model file loading, format detection, WASM geometry streaming, cache
12
+ * - useIfcFederation: Multi-model federation, addModel, ID offsets, RTC alignment, IFCX layers
8
13
  */
9
14
 
10
- import { useMemo, useCallback, useRef } from 'react';
11
- import { useViewerStore, type FederatedModel, type SchemaVersion } from '../store.js';
12
- import { IfcParser, detectFormat, parseIfcx, parseFederatedIfcx, type IfcDataStore, type FederatedIfcxParseResult } from '@ifc-lite/parser';
13
- import { GeometryProcessor, GeometryQuality, type MeshData, type CoordinateInfo } from '@ifc-lite/geometry';
15
+ import { useMemo, useRef } from 'react';
16
+ import { useViewerStore } from '../store.js';
14
17
  import { IfcQuery } from '@ifc-lite/query';
15
- import { buildSpatialIndex } from '@ifc-lite/spatial';
16
- import { type GeometryData, loadGLBToMeshData } from '@ifc-lite/cache';
17
- import { IfcTypeEnum, RelationshipType, IfcTypeEnumFromString, IfcTypeEnumToString, EntityFlags, type SpatialHierarchy, type SpatialNode, type EntityTable, type RelationshipGraph } from '@ifc-lite/data';
18
- import { StringTable } from '@ifc-lite/data';
19
- import { IfcServerClient, decodeDataModel, type ParquetBatch, type DataModel, type ParquetParseResponse, type ParquetStreamResult, type ParseResponse, type ModelMetadata, type ProcessingStats, type MeshData as ServerMeshData } from '@ifc-lite/server-client';
18
+ import type { IfcDataStore } from '@ifc-lite/parser';
20
19
 
21
- // Extracted utilities
22
- import { SERVER_URL, USE_SERVER, CACHE_SIZE_THRESHOLD, getDynamicBatchConfig } from '../utils/ifcConfig.js';
23
- import { rebuildSpatialHierarchy, rebuildOnDemandMaps } from '../utils/spatialHierarchy.js';
24
- import {
25
- createEmptyBounds,
26
- updateBoundsFromPositions,
27
- calculateMeshBounds,
28
- createCoordinateInfo,
29
- getRenderIntervalMs,
30
- getServerStreamIntervalMs,
31
- calculateStoreyHeights,
32
- normalizeColor,
33
- convertFloatColorToBytes,
34
- } from '../utils/localParsingUtils.js';
35
-
36
- // Cache hook
37
- import { useIfcCache, getCached, type CacheResult } from './useIfcCache.js';
38
-
39
- // Server data model conversion
40
- import { convertServerDataModel, type ServerParseResult } from '../utils/serverDataModel.js';
41
-
42
- // Define QuantitySet type inline (matches server-client's QuantitySet interface)
43
- interface ServerQuantitySet {
44
- qset_id: number;
45
- qset_name: string;
46
- method_of_measurement?: string;
47
- quantities: Array<{ quantity_name: string; quantity_value: number; quantity_type: string }>;
48
- }
49
-
50
- /**
51
- * Extended data store type for IFCX (IFC5) files.
52
- * IFCX uses schemaVersion 'IFC5' and may include federated composition metadata.
53
- */
54
- interface IfcxDataStore extends Omit<IfcDataStore, 'schemaVersion'> {
55
- schemaVersion: 'IFC5';
56
- /** Federated layer info for re-composition */
57
- _federatedLayers?: Array<{ id: string; name: string; enabled: boolean }>;
58
- /** Original buffers for re-composition when adding overlays */
59
- _federatedBuffers?: Array<{ buffer: ArrayBuffer; name: string }>;
60
- /** Composition statistics */
61
- _compositionStats?: { totalNodes: number; layersUsed: number; inheritanceResolutions: number; crossLayerReferences: number };
62
- /** Layer info for display */
63
- _layerInfo?: Array<{ id: string; name: string; meshCount: number }>;
64
- }
65
-
66
- /** Convert server mesh data (snake_case) to viewer format (camelCase) */
67
- function convertServerMesh(m: ServerMeshData): MeshData {
68
- return {
69
- expressId: m.express_id,
70
- positions: new Float32Array(m.positions),
71
- indices: new Uint32Array(m.indices),
72
- normals: new Float32Array(m.normals),
73
- color: m.color,
74
- ifcType: m.ifc_type,
75
- };
76
- }
77
-
78
- /** Server parse result type - union of streaming and non-streaming responses */
79
- type ServerParseResultType = ParquetParseResponse | ParquetStreamResult | ParseResponse;
80
-
81
- // Module-level server availability cache - avoids repeated failed connection attempts
82
- let serverAvailabilityCache: { available: boolean; checkedAt: number } | null = null;
83
- const SERVER_CHECK_CACHE_MS = 30000; // Re-check server availability every 30 seconds
84
-
85
- /**
86
- * Check if server URL is reachable from current origin
87
- * Returns false immediately if localhost server from non-localhost origin (would cause CORS)
88
- */
89
- function isServerReachable(serverUrl: string): boolean {
90
- try {
91
- const server = new URL(serverUrl);
92
- const isServerLocalhost = server.hostname === 'localhost' || server.hostname === '127.0.0.1';
93
-
94
- // In browser, check if we're on localhost
95
- if (typeof window !== 'undefined') {
96
- const isClientLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
97
-
98
- // Skip localhost server when running from remote origin (avoids CORS error in console)
99
- if (isServerLocalhost && !isClientLocalhost) {
100
- return false;
101
- }
102
- }
103
- return true;
104
- } catch {
105
- return false;
106
- }
107
- }
108
-
109
- /**
110
- * Silently check if server is available (no console logging on failure)
111
- * Returns cached result if recently checked
112
- */
113
- async function isServerAvailable(serverUrl: string, client: IfcServerClient): Promise<boolean> {
114
- // First check if server is even reachable (prevents CORS errors)
115
- if (!isServerReachable(serverUrl)) {
116
- return false;
117
- }
118
-
119
- const now = Date.now();
120
-
121
- // Use cached result if recent
122
- if (serverAvailabilityCache && (now - serverAvailabilityCache.checkedAt) < SERVER_CHECK_CACHE_MS) {
123
- return serverAvailabilityCache.available;
124
- }
125
-
126
- // Perform silent health check
127
- try {
128
- await client.health();
129
- serverAvailabilityCache = { available: true, checkedAt: now };
130
- return true;
131
- } catch {
132
- // Silent failure - don't log network errors for unavailable server
133
- serverAvailabilityCache = { available: false, checkedAt: now };
134
- return false;
135
- }
136
- }
20
+ // Sub-hooks
21
+ import { useIfcLoader } from './useIfcLoader.js';
22
+ import { useIfcFederation } from './useIfcFederation.js';
137
23
 
138
24
  export function useIfc() {
139
25
  const {
@@ -142,19 +28,9 @@ export function useIfc() {
142
28
  error,
143
29
  ifcDataStore,
144
30
  geometryResult,
145
- setLoading,
146
- setProgress,
147
- setError,
148
- setIfcDataStore,
149
- setGeometryResult,
150
- appendGeometryBatch,
151
- updateMeshColors,
152
- updateCoordinateInfo,
153
31
  // Multi-model state and actions
154
32
  models,
155
33
  activeModelId,
156
- addModel: storeAddModel,
157
- removeModel: storeRemoveModel,
158
34
  clearAllModels,
159
35
  setActiveModel,
160
36
  setModelVisibility,
@@ -164,790 +40,26 @@ export function useIfc() {
164
40
  getAllVisibleModels,
165
41
  hasModels,
166
42
  // Federation Registry helpers
167
- registerModelOffset,
168
43
  toGlobalId,
169
- fromGlobalId,
170
- findModelForGlobalId,
171
44
  } = useViewerStore();
172
45
 
173
46
  // Track if we've already logged for this ifcDataStore
174
47
  const lastLoggedDataStoreRef = useRef<typeof ifcDataStore>(null);
175
48
 
176
- // Cache operations from extracted hook
177
- const { loadFromCache, saveToCache } = useIfcCache();
178
-
179
- /**
180
- * Load from server - uses server-side PARALLEL parsing for maximum speed
181
- * Uses full parse endpoint (not streaming) for all-at-once parallel processing
182
- */
183
- const loadFromServer = useCallback(async (
184
- file: File,
185
- buffer: ArrayBuffer
186
- ): Promise<boolean> => {
187
- try {
188
- const serverStart = performance.now();
189
- setProgress({ phase: 'Connecting to server', percent: 5 });
190
-
191
- const client = new IfcServerClient({ baseUrl: SERVER_URL });
192
-
193
- // Silent server availability check (cached, no error logging)
194
- const serverAvailable = await isServerAvailable(SERVER_URL, client);
195
- if (!serverAvailable) {
196
- return false; // Silently fall back - caller handles logging
197
- }
198
-
199
- setProgress({ phase: 'Processing on server (parallel)', percent: 15 });
200
-
201
- // Check if Parquet is supported (requires parquet-wasm)
202
- const parquetSupported = await client.isParquetSupported();
203
-
204
- let allMeshes: MeshData[];
205
- let result: ServerParseResultType;
206
- let parseTime: number;
207
- let convertTime: number;
208
-
209
- // Use streaming for large files (>150MB) for progressive rendering
210
- // Smaller files use non-streaming path (faster - avoids ~1.1s background re-processing overhead)
211
- // Streaming overhead: ~67 batch serializations + background re-processing (~1100ms)
212
- // Non-streaming: single serialization (~218ms for 60k meshes)
213
- // Threshold chosen to balance UX (progressive rendering) vs performance (overhead)
214
- const fileSizeMB = buffer.byteLength / (1024 * 1024);
215
- const USE_STREAMING_THRESHOLD_MB = 150;
216
-
217
- if (parquetSupported && fileSizeMB > USE_STREAMING_THRESHOLD_MB) {
218
- // STREAMING PATH - for large files, render progressively
219
- console.log(`[useIfc] Using STREAMING endpoint for large file (${fileSizeMB.toFixed(1)}MB)`);
220
-
221
- allMeshes = [];
222
- let totalVertices = 0;
223
- let totalTriangles = 0;
224
- let cacheKey = '';
225
- let streamMetadata: ModelMetadata | null = null;
226
- let streamStats: ProcessingStats | null = null;
227
- let batchCount = 0;
228
-
229
- // Progressive bounds calculation
230
- const bounds = createEmptyBounds();
231
-
232
- const parseStart = performance.now();
233
-
234
- // Throttle server streaming updates - large files get less frequent UI updates
235
- let lastServerStreamRenderTime = 0;
236
- const SERVER_STREAM_INTERVAL_MS = getServerStreamIntervalMs(fileSizeMB);
237
-
238
- // Use streaming endpoint with batch callback
239
- const streamResult = await client.parseParquetStream(file, (batch: ParquetBatch) => {
240
- batchCount++;
241
-
242
- // Convert batch meshes to viewer format (snake_case to camelCase, number[] to TypedArray)
243
- const batchMeshes: MeshData[] = batch.meshes.map((m: ServerMeshData) => ({
244
- expressId: m.express_id,
245
- positions: new Float32Array(m.positions),
246
- indices: new Uint32Array(m.indices),
247
- normals: new Float32Array(m.normals),
248
- color: m.color,
249
- ifcType: m.ifc_type,
250
- }));
251
-
252
- // Update bounds incrementally
253
- for (const mesh of batchMeshes) {
254
- updateBoundsFromPositions(bounds, mesh.positions);
255
- totalVertices += mesh.positions.length / 3;
256
- totalTriangles += mesh.indices.length / 3;
257
- }
258
-
259
- // Add to collection
260
- allMeshes.push(...batchMeshes);
261
-
262
- // THROTTLED PROGRESSIVE RENDERING: Update UI at controlled rate
263
- // First batch renders immediately, subsequent batches throttled
264
- const now = performance.now();
265
- const shouldRender = batchCount === 1 || (now - lastServerStreamRenderTime >= SERVER_STREAM_INTERVAL_MS);
266
-
267
- if (shouldRender) {
268
- lastServerStreamRenderTime = now;
269
-
270
- // Update progress
271
- setProgress({
272
- phase: `Streaming batch ${batchCount}`,
273
- percent: Math.min(15 + (batchCount * 5), 85)
274
- });
275
-
276
- // PROGRESSIVE RENDERING: Set geometry after each batch
277
- // This allows the user to see geometry appearing progressively
278
- const coordinateInfo = {
279
- originShift: { x: 0, y: 0, z: 0 },
280
- originalBounds: bounds,
281
- shiftedBounds: bounds,
282
- hasLargeCoordinates: false,
283
- };
284
-
285
- setGeometryResult({
286
- meshes: [...allMeshes], // Clone to trigger re-render
287
- totalVertices,
288
- totalTriangles,
289
- coordinateInfo,
290
- });
291
- }
292
- });
293
-
294
- parseTime = performance.now() - parseStart;
295
- cacheKey = streamResult.cache_key;
296
- streamMetadata = streamResult.metadata;
297
- streamStats = streamResult.stats;
298
-
299
- console.log(`[useIfc] Streaming complete in ${parseTime.toFixed(0)}ms`);
300
- console.log(` ${batchCount} batches, ${allMeshes.length} meshes`);
301
- console.log(` Cache key: ${cacheKey}`);
302
-
303
- // Build final result object for data model fetching
304
- // Note: meshes field is omitted - allMeshes is passed separately to convertServerDataModel
305
- result = {
306
- cache_key: cacheKey,
307
- metadata: streamMetadata,
308
- stats: streamStats,
309
- } as ParquetStreamResult;
310
- convertTime = 0; // Already converted inline
311
-
312
- // Final geometry set with complete bounds
313
- // Server already applies RTC shift to mesh positions, so bounds are shifted
314
- // Reconstruct originalBounds by adding originShift back to shifted bounds
315
- const originShift = streamMetadata?.coordinate_info?.origin_shift
316
- ? { x: streamMetadata.coordinate_info.origin_shift[0], y: streamMetadata.coordinate_info.origin_shift[1], z: streamMetadata.coordinate_info.origin_shift[2] }
317
- : { x: 0, y: 0, z: 0 };
318
- const finalCoordinateInfo = {
319
- originShift,
320
- // Original bounds = shifted bounds + originShift (reconstruct world coordinates)
321
- originalBounds: {
322
- min: {
323
- x: bounds.min.x + originShift.x,
324
- y: bounds.min.y + originShift.y,
325
- z: bounds.min.z + originShift.z,
326
- },
327
- max: {
328
- x: bounds.max.x + originShift.x,
329
- y: bounds.max.y + originShift.y,
330
- z: bounds.max.z + originShift.z,
331
- },
332
- },
333
- // Shifted bounds = bounds as-is (server already applied shift)
334
- shiftedBounds: bounds,
335
- // Note: server returns is_geo_referenced but it really means "had large coordinates"
336
- hasLargeCoordinates: streamMetadata?.coordinate_info?.is_geo_referenced ?? false,
337
- };
338
-
339
- setGeometryResult({
340
- meshes: allMeshes,
341
- totalVertices,
342
- totalTriangles,
343
- coordinateInfo: finalCoordinateInfo,
344
- });
345
-
346
- } else if (parquetSupported) {
347
- // NON-STREAMING PATH - for smaller files, use batch request (with cache check)
348
- console.log(`[useIfc] Using PARQUET endpoint - 15x smaller payload, faster transfer`);
349
-
350
- // Use Parquet endpoint - much smaller payload (~15x compression)
351
- const parseStart = performance.now();
352
- const parquetResult = await client.parseParquet(file);
353
- result = parquetResult;
354
- parseTime = performance.now() - parseStart;
355
-
356
- console.log(`[useIfc] Server parse response received in ${parseTime.toFixed(0)}ms`);
357
- console.log(` Server stats: ${parquetResult.stats.total_time_ms}ms total (parse: ${parquetResult.stats.parse_time_ms}ms, geometry: ${parquetResult.stats.geometry_time_ms}ms)`);
358
- console.log(` Parquet payload: ${(parquetResult.parquet_stats.payload_size / 1024 / 1024).toFixed(2)}MB, decode: ${parquetResult.parquet_stats.decode_time_ms}ms`);
359
- console.log(` Meshes: ${parquetResult.meshes.length}, Vertices: ${parquetResult.stats.total_vertices}, Triangles: ${parquetResult.stats.total_triangles}`);
360
- console.log(` Cache key: ${parquetResult.cache_key}`);
361
-
362
- setProgress({ phase: 'Converting meshes', percent: 70 });
363
-
364
- // Convert server mesh format to viewer format (TypedArrays)
365
- const convertStart = performance.now();
366
- allMeshes = parquetResult.meshes.map((m: ServerMeshData): MeshData => ({
367
- expressId: m.express_id,
368
- positions: new Float32Array(m.positions),
369
- indices: new Uint32Array(m.indices),
370
- normals: new Float32Array(m.normals),
371
- color: m.color,
372
- ifcType: m.ifc_type,
373
- }));
374
- convertTime = performance.now() - convertStart;
375
- console.log(`[useIfc] Mesh conversion: ${convertTime.toFixed(0)}ms for ${allMeshes.length} meshes`);
376
- } else {
377
- console.log(`[useIfc] Parquet not available, using JSON endpoint (install parquet-wasm for 15x faster transfer)`);
378
- console.log(`[useIfc] Using FULL PARSE (parallel) - all geometry processed at once`);
379
-
380
- // Fallback to JSON endpoint
381
- const parseStart = performance.now();
382
- result = await client.parse(file);
383
- parseTime = performance.now() - parseStart;
384
-
385
- console.log(`[useIfc] Server parse response received in ${parseTime.toFixed(0)}ms`);
386
- console.log(` Server stats: ${result.stats.total_time_ms}ms total (parse: ${result.stats.parse_time_ms}ms, geometry: ${result.stats.geometry_time_ms}ms)`);
387
- console.log(` Meshes: ${result.meshes.length}, Vertices: ${result.stats.total_vertices}, Triangles: ${result.stats.total_triangles}`);
388
- console.log(` Cache key: ${result.cache_key}`);
389
-
390
- setProgress({ phase: 'Converting meshes', percent: 70 });
391
-
392
- // Convert server mesh format to viewer format
393
- // NOTE: Server sends colors as floats [0-1], viewer expects bytes [0-255]
394
- const convertStart = performance.now();
395
- const jsonResult = result as ParseResponse;
396
- allMeshes = jsonResult.meshes.map((m: ServerMeshData) => ({
397
- expressId: m.express_id,
398
- positions: new Float32Array(m.positions),
399
- indices: new Uint32Array(m.indices),
400
- normals: m.normals ? new Float32Array(m.normals) : new Float32Array(0),
401
- color: m.color,
402
- }));
403
- convertTime = performance.now() - convertStart;
404
- console.log(`[useIfc] Mesh conversion: ${convertTime.toFixed(0)}ms for ${allMeshes.length} meshes`);
405
- }
406
-
407
- // For non-streaming paths, calculate bounds and set geometry
408
- // (Streaming path already handled this progressively)
409
- const wasStreaming = parquetSupported && fileSizeMB > USE_STREAMING_THRESHOLD_MB;
410
-
411
- if (!wasStreaming) {
412
- // Calculate bounds from mesh positions for camera fitting
413
- // IMPORTANT: Server already applies RTC shift to mesh positions, so bounds calculated
414
- // from mesh positions are ALREADY in shifted coordinates (small values near origin).
415
- // We must NOT subtract originShift again - that would give huge negative bounds!
416
- const { bounds } = calculateMeshBounds(allMeshes);
417
-
418
- // Build CoordinateInfo correctly for server-shifted meshes:
419
- // - shiftedBounds = bounds (already shifted by server)
420
- // - originalBounds = bounds + originShift (reconstruct original world coordinates)
421
- const serverCoordInfo = result.metadata.coordinate_info;
422
- const originShift = serverCoordInfo?.origin_shift
423
- ? { x: serverCoordInfo.origin_shift[0], y: serverCoordInfo.origin_shift[1], z: serverCoordInfo.origin_shift[2] }
424
- : { x: 0, y: 0, z: 0 };
425
-
426
- // When server already shifted meshes, shiftedBounds IS the calculated bounds
427
- // (don't use createCoordinateInfo which would subtract originShift again)
428
- const coordinateInfo: CoordinateInfo = {
429
- originShift,
430
- // Original bounds = shifted bounds + originShift (reconstruct world coordinates)
431
- originalBounds: {
432
- min: {
433
- x: bounds.min.x + originShift.x,
434
- y: bounds.min.y + originShift.y,
435
- z: bounds.min.z + originShift.z,
436
- },
437
- max: {
438
- x: bounds.max.x + originShift.x,
439
- y: bounds.max.y + originShift.y,
440
- z: bounds.max.z + originShift.z,
441
- },
442
- },
443
- // Shifted bounds = bounds as-is (server already applied shift)
444
- shiftedBounds: {
445
- min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z },
446
- max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z },
447
- },
448
- // Note: server returns is_geo_referenced but it really means "had large coordinates"
449
- hasLargeCoordinates: serverCoordInfo?.is_geo_referenced ?? false,
450
- };
451
-
452
- console.log(`[useIfc] Calculated bounds:`, {
453
- min: `(${bounds.min.x.toFixed(1)}, ${bounds.min.y.toFixed(1)}, ${bounds.min.z.toFixed(1)})`,
454
- max: `(${bounds.max.x.toFixed(1)}, ${bounds.max.y.toFixed(1)}, ${bounds.max.z.toFixed(1)})`,
455
- size: `${(bounds.max.x - bounds.min.x).toFixed(1)} x ${(bounds.max.y - bounds.min.y).toFixed(1)} x ${(bounds.max.z - bounds.min.z).toFixed(1)}`,
456
- });
457
-
458
- // Set all geometry at once
459
- setProgress({ phase: 'Rendering geometry', percent: 80 });
460
- const renderStart = performance.now();
461
- setGeometryResult({
462
- meshes: allMeshes,
463
- totalVertices: result.stats.total_vertices,
464
- totalTriangles: result.stats.total_triangles,
465
- coordinateInfo,
466
- });
467
- const renderTime = performance.now() - renderStart;
468
- console.log(`[useIfc] Geometry set: ${renderTime.toFixed(0)}ms`);
469
- }
470
-
471
- // Fetch and decode data model asynchronously (geometry already displayed)
472
- // Data model is processed on server in background, fetch via separate endpoint
473
- const cacheKey = result.cache_key;
474
-
475
- // Start data model fetch in background - don't block rendering
476
- (async () => {
477
- setProgress({ phase: 'Fetching data model', percent: 85 });
478
- const dataModelStart = performance.now();
479
-
480
- try {
481
- // If data model was included in response (ParquetParseResponse), use it directly
482
- // Otherwise, fetch from the data model endpoint
483
- let dataModelBuffer: ArrayBuffer | null = null;
484
- if ('data_model' in result && result.data_model) {
485
- dataModelBuffer = result.data_model;
486
- }
487
-
488
- if (!dataModelBuffer || dataModelBuffer.byteLength === 0) {
489
- console.log('[useIfc] Fetching data model from server (background processing)...');
490
- dataModelBuffer = await client.fetchDataModel(cacheKey);
491
- }
492
-
493
- if (!dataModelBuffer) {
494
- console.log('[useIfc] ⚡ Data model not available - property panel disabled');
495
- return;
496
- }
497
-
498
- const dataModel: DataModel = await decodeDataModel(dataModelBuffer);
499
-
500
- console.log(`[useIfc] Data model decoded in ${(performance.now() - dataModelStart).toFixed(0)}ms`);
501
- console.log(` Entities: ${dataModel.entities.size}`);
502
- console.log(` PropertySets: ${dataModel.propertySets.size}`);
503
- const quantitySetsSize = (dataModel as { quantitySets?: Map<number, unknown> }).quantitySets?.size ?? 0;
504
- console.log(` QuantitySets: ${quantitySetsSize}`);
505
- console.log(` Relationships: ${dataModel.relationships.length}`);
506
- console.log(` Spatial nodes: ${dataModel.spatialHierarchy.nodes.length}`);
507
-
508
- // Convert server data model to viewer data store format using utility
509
- // ViewerDataStore is structurally compatible with IfcDataStore
510
- const dataStore = convertServerDataModel(
511
- dataModel,
512
- result as ServerParseResult,
513
- file,
514
- allMeshes
515
- ) as unknown as IfcDataStore;
516
-
517
- setIfcDataStore(dataStore);
518
- console.log('[useIfc] ✅ Property panel ready with server data model');
519
- console.log(`[useIfc] Data model loaded in ${(performance.now() - dataModelStart).toFixed(0)}ms (background)`);
520
- } catch (err) {
521
- console.warn('[useIfc] Failed to decode data model:', err);
522
- console.log('[useIfc] ⚡ Skipping data model (decoding failed)');
523
- }
524
- })(); // End of async data model fetch block - runs in background, doesn't block
525
-
526
- // Geometry is ready - mark complete immediately (data model loads in background)
527
- setProgress({ phase: 'Complete', percent: 100 });
528
- const totalServerTime = performance.now() - serverStart;
529
- console.log(`[useIfc] SERVER PARALLEL complete: ${file.name}`);
530
- console.log(` Total time: ${totalServerTime.toFixed(0)}ms`);
531
- console.log(` Breakdown: parse=${parseTime.toFixed(0)}ms, convert=${convertTime.toFixed(0)}ms`);
532
-
533
- return true;
534
- } catch (err) {
535
- console.error('[useIfc] Server parse failed:', err);
536
- return false;
537
- }
538
- }, [setProgress, setIfcDataStore, setGeometryResult]);
539
-
540
- const loadFile = useCallback(async (file: File) => {
541
- const { resetViewerState, clearAllModels } = useViewerStore.getState();
542
-
543
- // Track total elapsed time for complete user experience
544
- const totalStartTime = performance.now();
545
-
546
- try {
547
- // Reset all viewer state before loading new file
548
- // Also clear models Map to ensure clean single-file state
549
- resetViewerState();
550
- clearAllModels();
551
-
552
- setLoading(true);
553
- setError(null);
554
- setProgress({ phase: 'Loading file', percent: 0 });
555
-
556
- // Read file from disk
557
- const buffer = await file.arrayBuffer();
558
- const fileSizeMB = buffer.byteLength / (1024 * 1024);
559
-
560
- // Detect file format (IFCX/IFC5 vs IFC4 STEP vs GLB)
561
- const format = detectFormat(buffer);
562
-
563
- // IFCX files must be parsed client-side (server only supports IFC4 STEP)
564
- if (format === 'ifcx') {
565
- setProgress({ phase: 'Parsing IFCX (client-side)', percent: 10 });
566
-
567
- try {
568
- const ifcxResult = await parseIfcx(buffer, {
569
- onProgress: (prog: { phase: string; percent: number }) => {
570
- setProgress({ phase: `IFCX ${prog.phase}`, percent: 10 + (prog.percent * 0.8) });
571
- },
572
- });
573
-
574
- // Convert IFCX meshes to viewer format
575
- // Note: IFCX geometry extractor already handles Y-up to Z-up conversion
576
- // and applies transforms correctly in Z-up space, so we just pass through
577
-
578
- 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 }) => {
579
- // IFCX MeshData has: expressId, ifcType, positions (Float32Array), indices (Uint32Array), normals (Float32Array), color
580
- const positions = m.positions instanceof Float32Array ? m.positions : new Float32Array(m.positions || []);
581
- const indices = m.indices instanceof Uint32Array ? m.indices : new Uint32Array(m.indices || []);
582
- const normals = m.normals instanceof Float32Array ? m.normals : new Float32Array(m.normals || []);
583
-
584
- // Normalize color to RGBA format (4 elements)
585
- const color = normalizeColor(m.color);
586
-
587
- return {
588
- expressId: m.expressId || m.express_id || m.id || 0,
589
- positions,
590
- indices,
591
- normals,
592
- color,
593
- ifcType: m.ifcType || m.ifc_type || 'IfcProduct',
594
- };
595
- }).filter((m: MeshData) => m.positions.length > 0 && m.indices.length > 0); // Filter out empty meshes
596
-
597
- // Check if this is an overlay-only file (no geometry)
598
- if (meshes.length === 0) {
599
- console.warn(`[useIfc] IFCX file "${file.name}" has no geometry - this appears to be an overlay file that adds properties to a base model.`);
600
- console.warn('[useIfc] To use this file, load it together with a base IFCX file (select both files at once).');
601
-
602
- // Check if file has data references that suggest it's an overlay
603
- const hasReferences = ifcxResult.entityCount > 0;
604
- if (hasReferences) {
605
- setError(`"${file.name}" is an overlay file with no geometry. Please load it together with a base IFCX file (select all files at once).`);
606
- setLoading(false);
607
- return;
608
- }
609
- }
610
-
611
- // Calculate bounds and statistics
612
- const { bounds, stats } = calculateMeshBounds(meshes);
613
- const coordinateInfo = createCoordinateInfo(bounds);
614
-
615
- setGeometryResult({
616
- meshes,
617
- totalVertices: stats.totalVertices,
618
- totalTriangles: stats.totalTriangles,
619
- coordinateInfo,
620
- });
621
-
622
- // Convert IFCX data model to IfcDataStore format
623
- // IFCX already provides entities, properties, quantities, relationships, spatialHierarchy
624
- const dataStore = {
625
- fileSize: ifcxResult.fileSize,
626
- schemaVersion: 'IFC5' as const,
627
- entityCount: ifcxResult.entityCount,
628
- parseTime: ifcxResult.parseTime,
629
- source: new Uint8Array(buffer),
630
- entityIndex: {
631
- byId: new Map(),
632
- byType: new Map(),
633
- },
634
- strings: ifcxResult.strings,
635
- entities: ifcxResult.entities,
636
- properties: ifcxResult.properties,
637
- quantities: ifcxResult.quantities,
638
- relationships: ifcxResult.relationships,
639
- spatialHierarchy: ifcxResult.spatialHierarchy,
640
- } as IfcxDataStore;
641
-
642
- // Cast to IfcDataStore for store compatibility (IFC5 schema extension)
643
- setIfcDataStore(dataStore as unknown as IfcDataStore);
644
-
645
- setProgress({ phase: 'Complete', percent: 100 });
646
- setLoading(false);
647
- return;
648
- } catch (err: unknown) {
649
- console.error('[useIfc] IFCX parsing failed:', err);
650
- const message = err instanceof Error ? err.message : String(err);
651
- setError(`IFCX parsing failed: ${message}`);
652
- setLoading(false);
653
- return;
654
- }
655
- }
656
-
657
- // GLB files: parse directly to MeshData (no data model, geometry only)
658
- if (format === 'glb') {
659
- setProgress({ phase: 'Parsing GLB', percent: 10 });
660
-
661
- try {
662
- const meshes = loadGLBToMeshData(new Uint8Array(buffer));
663
-
664
- if (meshes.length === 0) {
665
- setError('GLB file contains no geometry');
666
- setLoading(false);
667
- return;
668
- }
669
-
670
- const { bounds, stats } = calculateMeshBounds(meshes);
671
- const coordinateInfo = createCoordinateInfo(bounds);
672
-
673
- setGeometryResult({
674
- meshes,
675
- totalVertices: stats.totalVertices,
676
- totalTriangles: stats.totalTriangles,
677
- coordinateInfo,
678
- });
679
-
680
- // GLB files have no IFC data model - set a minimal store
681
- setIfcDataStore(null);
682
-
683
- setProgress({ phase: 'Complete', percent: 100 });
684
-
685
- const totalElapsedMs = performance.now() - totalStartTime;
686
- console.log(`[useIfc] GLB loaded: ${meshes.length} meshes, ${stats.totalTriangles} triangles in ${totalElapsedMs.toFixed(0)}ms`);
687
- setLoading(false);
688
- return;
689
- } catch (err: unknown) {
690
- console.error('[useIfc] GLB parsing failed:', err);
691
- const message = err instanceof Error ? err.message : String(err);
692
- setError(`GLB parsing failed: ${message}`);
693
- setLoading(false);
694
- return;
695
- }
696
- }
697
-
698
- // INSTANT cache lookup: Use filename + size + format version as key (no hashing!)
699
- // Same filename + same size = same file (fast and reliable enough)
700
- // Include format version to invalidate old caches when format changes
701
- const cacheKey = `${file.name}-${buffer.byteLength}-v3`;
702
-
703
- if (buffer.byteLength >= CACHE_SIZE_THRESHOLD) {
704
- setProgress({ phase: 'Checking cache', percent: 5 });
705
- const cacheResult = await getCached(cacheKey);
706
- if (cacheResult) {
707
- const success = await loadFromCache(cacheResult, file.name, cacheKey);
708
- if (success) {
709
- const totalElapsedMs = performance.now() - totalStartTime;
710
- console.log(`[useIfc] TOTAL LOAD TIME (from cache): ${totalElapsedMs.toFixed(0)}ms (${(totalElapsedMs / 1000).toFixed(1)}s)`);
711
- setLoading(false);
712
- return;
713
- }
714
- }
715
- }
716
-
717
- // Try server parsing first (enabled by default for multi-core performance)
718
- // Only for IFC4 STEP files (server doesn't support IFCX)
719
- if (format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '') {
720
- // Pass buffer directly - server uses File object for parsing, buffer is only for size checks
721
- const serverSuccess = await loadFromServer(file, buffer);
722
- if (serverSuccess) {
723
- const totalElapsedMs = performance.now() - totalStartTime;
724
- console.log(`[useIfc] TOTAL LOAD TIME (server): ${totalElapsedMs.toFixed(0)}ms (${(totalElapsedMs / 1000).toFixed(1)}s)`);
725
- setLoading(false);
726
- return;
727
- }
728
- // Server not available - continue with local WASM (no error logging needed)
729
- } else if (format === 'unknown') {
730
- console.warn('[useIfc] Unknown file format - attempting to parse as IFC4 STEP');
731
- }
732
-
733
- // Using local WASM parsing
734
- setProgress({ phase: 'Starting geometry streaming', percent: 10 });
735
-
736
- // Initialize geometry processor first (WASM init is fast if already loaded)
737
- const geometryProcessor = new GeometryProcessor({
738
- quality: GeometryQuality.Balanced
739
- });
740
- await geometryProcessor.init();
741
-
742
- // DEFER data model parsing - start it AFTER geometry streaming begins
743
- // This ensures geometry gets first crack at the CPU for fast first frame
744
- // Data model parsing is lower priority - UI can work without it initially
745
- let resolveDataStore: (dataStore: IfcDataStore) => void;
746
- const dataStorePromise = new Promise<IfcDataStore>((resolve) => {
747
- resolveDataStore = resolve;
748
- });
749
-
750
- const startDataModelParsing = () => {
751
- // Use main thread - worker parsing disabled (IfcDataStore has closures that can't be serialized)
752
- const parser = new IfcParser();
753
- const wasmApi = geometryProcessor.getApi();
754
- parser.parseColumnar(buffer, {
755
- wasmApi, // Pass WASM API for 5-10x faster entity scanning
756
- }).then(dataStore => {
757
-
758
- // Calculate storey heights from elevation differences if not already populated
759
- if (dataStore.spatialHierarchy && dataStore.spatialHierarchy.storeyHeights.size === 0 && dataStore.spatialHierarchy.storeyElevations.size > 1) {
760
- const calculatedHeights = calculateStoreyHeights(dataStore.spatialHierarchy.storeyElevations);
761
- for (const [storeyId, height] of calculatedHeights) {
762
- dataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
763
- }
764
- }
765
-
766
- setIfcDataStore(dataStore);
767
- resolveDataStore(dataStore);
768
- }).catch(err => {
769
- console.error('[useIfc] Data model parsing failed:', err);
770
- });
771
- };
772
-
773
- // Schedule data model parsing to start after geometry begins streaming
774
- setTimeout(startDataModelParsing, 0);
775
-
776
- // Use adaptive processing: sync for small files, streaming for large files
777
- let estimatedTotal = 0;
778
- let totalMeshes = 0;
779
- const allMeshes: MeshData[] = []; // Collect all meshes for BVH building
780
- let finalCoordinateInfo: CoordinateInfo | null = null;
781
- // Capture RTC offset from WASM for proper multi-model alignment
782
- let capturedRtcOffset: { x: number; y: number; z: number } | null = null;
783
-
784
- // Clear existing geometry result
785
- setGeometryResult(null);
49
+ // File loading (single-model path)
50
+ const { loadFile } = useIfcLoader();
786
51
 
787
- // Timing instrumentation
788
- const processingStart = performance.now();
789
- let batchCount = 0;
790
- let lastBatchTime = processingStart;
791
- let totalWaitTime = 0; // Time waiting for WASM to yield batches
792
- let totalProcessTime = 0; // Time processing batches in JS
793
- let firstGeometryTime = 0; // Time to first rendered geometry
794
-
795
- // OPTIMIZATION: Accumulate meshes and batch state updates
796
- // First batch renders immediately, then accumulate for throughput
797
- // Adaptive interval: larger files get less frequent updates to reduce React re-render overhead
798
- let pendingMeshes: MeshData[] = [];
799
- let lastRenderTime = 0;
800
- const RENDER_INTERVAL_MS = getRenderIntervalMs(fileSizeMB);
801
-
802
- try {
803
- // Use dynamic batch sizing for optimal throughput
804
- const dynamicBatchConfig = getDynamicBatchConfig(fileSizeMB);
805
-
806
- for await (const event of geometryProcessor.processAdaptive(new Uint8Array(buffer), {
807
- sizeThreshold: 2 * 1024 * 1024, // 2MB threshold
808
- batchSize: dynamicBatchConfig, // Dynamic batches: small first, then large
809
- })) {
810
- const eventReceived = performance.now();
811
- const waitTime = eventReceived - lastBatchTime;
812
-
813
- switch (event.type) {
814
- case 'start':
815
- estimatedTotal = event.totalEstimate;
816
- break;
817
- case 'model-open':
818
- setProgress({ phase: 'Processing geometry', percent: 50 });
819
- break;
820
- case 'colorUpdate': {
821
- // Update colors for already-rendered meshes
822
- updateMeshColors(event.updates);
823
- break;
824
- }
825
- case 'rtcOffset': {
826
- // Capture RTC offset from WASM for multi-model alignment
827
- if (event.hasRtc) {
828
- capturedRtcOffset = event.rtcOffset;
829
- }
830
- break;
831
- }
832
- case 'batch': {
833
- batchCount++;
834
- totalWaitTime += waitTime;
835
-
836
- // Track time to first geometry
837
- if (batchCount === 1) {
838
- firstGeometryTime = performance.now() - totalStartTime;
839
- }
840
-
841
- const processStart = performance.now();
842
-
843
- // Collect meshes for BVH building
844
- allMeshes.push(...event.meshes);
845
- finalCoordinateInfo = event.coordinateInfo ?? null;
846
- totalMeshes = event.totalSoFar;
847
-
848
- // Accumulate meshes for batched rendering
849
- pendingMeshes.push(...event.meshes);
850
-
851
- // FIRST BATCH: Render immediately for fast first frame
852
- // SUBSEQUENT: Throttle to reduce React re-renders
853
- const timeSinceLastRender = eventReceived - lastRenderTime;
854
- const shouldRender = batchCount === 1 || timeSinceLastRender >= RENDER_INTERVAL_MS;
855
-
856
- if (shouldRender && pendingMeshes.length > 0) {
857
- appendGeometryBatch(pendingMeshes, event.coordinateInfo);
858
- pendingMeshes = [];
859
- lastRenderTime = eventReceived;
860
-
861
- // Update progress
862
- const progressPercent = 50 + Math.min(45, (totalMeshes / Math.max(estimatedTotal / 10, totalMeshes)) * 45);
863
- setProgress({
864
- phase: `Rendering geometry (${totalMeshes} meshes)`,
865
- percent: progressPercent
866
- });
867
- }
868
-
869
- const processTime = performance.now() - processStart;
870
- totalProcessTime += processTime;
871
- break;
872
- }
873
- case 'complete':
874
- // Flush any remaining pending meshes
875
- if (pendingMeshes.length > 0) {
876
- appendGeometryBatch(pendingMeshes, event.coordinateInfo);
877
- pendingMeshes = [];
878
- }
879
-
880
- finalCoordinateInfo = event.coordinateInfo ?? null;
881
-
882
- // Store captured RTC offset in coordinate info for multi-model alignment
883
- if (finalCoordinateInfo && capturedRtcOffset) {
884
- finalCoordinateInfo.wasmRtcOffset = capturedRtcOffset;
885
- }
886
-
887
- // Update geometry result with final coordinate info
888
- updateCoordinateInfo(finalCoordinateInfo);
889
-
890
- setProgress({ phase: 'Complete', percent: 100 });
891
-
892
- // Build spatial index and cache in background (non-blocking)
893
- // Wait for data model to complete first
894
- dataStorePromise.then(dataStore => {
895
- // Build spatial index from meshes (in background)
896
- if (allMeshes.length > 0) {
897
- const buildIndex = () => {
898
- try {
899
- const spatialIndex = buildSpatialIndex(allMeshes);
900
- dataStore.spatialIndex = spatialIndex;
901
- setIfcDataStore({ ...dataStore });
902
- } catch (err) {
903
- console.warn('[useIfc] Failed to build spatial index:', err);
904
- }
905
- };
906
-
907
- // Use requestIdleCallback if available (type assertion for optional browser API)
908
- if ('requestIdleCallback' in window) {
909
- (window as { requestIdleCallback: (cb: () => void, opts?: { timeout: number }) => void }).requestIdleCallback(buildIndex, { timeout: 2000 });
910
- } else {
911
- setTimeout(buildIndex, 100);
912
- }
913
- }
914
-
915
- // Cache the result in the background (for files above threshold)
916
- if (buffer.byteLength >= CACHE_SIZE_THRESHOLD && allMeshes.length > 0 && finalCoordinateInfo) {
917
- const geometryData: GeometryData = {
918
- meshes: allMeshes,
919
- totalVertices: allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0),
920
- totalTriangles: allMeshes.reduce((sum, m) => sum + m.indices.length / 3, 0),
921
- coordinateInfo: finalCoordinateInfo,
922
- };
923
- saveToCache(cacheKey, dataStore, geometryData, buffer, file.name);
924
- }
925
- });
926
- break;
927
- }
928
-
929
- lastBatchTime = performance.now();
930
- }
931
- } catch (err) {
932
- console.error('[useIfc] Error in processing:', err);
933
- setError(err instanceof Error ? err.message : 'Unknown error during geometry processing');
934
- }
935
-
936
- // Log developer-friendly summary with key metrics
937
- const totalElapsedMs = performance.now() - totalStartTime;
938
- const totalVertices = allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0);
939
- console.log(
940
- `[useIfc] ✓ ${file.name} (${fileSizeMB.toFixed(1)}MB) → ` +
941
- `${allMeshes.length} meshes, ${(totalVertices / 1000).toFixed(0)}k vertices | ` +
942
- `first: ${firstGeometryTime.toFixed(0)}ms, total: ${totalElapsedMs.toFixed(0)}ms`
943
- );
944
-
945
- setLoading(false);
946
- } catch (err) {
947
- setError(err instanceof Error ? err.message : 'Unknown error');
948
- setLoading(false);
949
- }
950
- }, [setLoading, setError, setProgress, setIfcDataStore, setGeometryResult, appendGeometryBatch, updateCoordinateInfo, loadFromCache, saveToCache]);
52
+ // Multi-model federation
53
+ const {
54
+ addModel,
55
+ removeModel,
56
+ getQueryForModel,
57
+ loadFilesSequentially,
58
+ loadFederatedIfcx,
59
+ addIfcxOverlays,
60
+ findModelForEntity,
61
+ resolveGlobalId,
62
+ } = useIfcFederation();
951
63
 
952
64
  // Memoize query to prevent recreation on every render
953
65
  // For single-model backward compatibility
@@ -960,720 +72,6 @@ export function useIfc() {
960
72
  return new IfcQuery(ifcDataStore);
961
73
  }, [ifcDataStore]);
962
74
 
963
- /**
964
- * Add a model to the federation (multi-model support)
965
- * Uses FederationRegistry to assign unique ID offsets - BULLETPROOF against ID collisions
966
- * Returns the model ID on success, null on failure
967
- */
968
- const addModel = useCallback(async (
969
- file: File,
970
- options?: { name?: string }
971
- ): Promise<string | null> => {
972
- const modelId = crypto.randomUUID();
973
- const totalStartTime = performance.now();
974
-
975
- try {
976
- // IMPORTANT: Before adding a new model, check if there's a legacy model
977
- // (loaded via loadFile) that's not in the Map yet. If so, migrate it first.
978
- const currentModels = useViewerStore.getState().models;
979
- const currentIfcDataStore = useViewerStore.getState().ifcDataStore;
980
- const currentGeometryResult = useViewerStore.getState().geometryResult;
981
-
982
- if (currentModels.size === 0 && currentIfcDataStore && currentGeometryResult) {
983
- // Migrate the legacy model to the Map
984
- // Legacy model has offset 0 (IDs are unchanged)
985
- const legacyModelId = crypto.randomUUID();
986
- const legacyName = currentIfcDataStore.spatialHierarchy?.project?.name || 'Model 1';
987
-
988
- // Find max expressId in legacy model for registry
989
- // IMPORTANT: Include ALL entities, not just meshes, for proper globalId resolution
990
- const legacyMeshes = currentGeometryResult.meshes || [];
991
- const legacyMaxExpressIdFromMeshes = legacyMeshes.reduce((max, m) => Math.max(max, m.expressId), 0);
992
- // FIXED: Use iteration instead of spread to avoid stack overflow with large Maps
993
- let legacyMaxExpressIdFromEntities = 0;
994
- if (currentIfcDataStore.entityIndex?.byId) {
995
- for (const key of currentIfcDataStore.entityIndex.byId.keys()) {
996
- if (key > legacyMaxExpressIdFromEntities) legacyMaxExpressIdFromEntities = key;
997
- }
998
- }
999
- const legacyMaxExpressId = Math.max(legacyMaxExpressIdFromMeshes, legacyMaxExpressIdFromEntities);
1000
-
1001
- // Register legacy model with offset 0 (IDs already in use as-is)
1002
- const legacyOffset = registerModelOffset(legacyModelId, legacyMaxExpressId);
1003
-
1004
- const legacyModel: FederatedModel = {
1005
- id: legacyModelId,
1006
- name: legacyName,
1007
- ifcDataStore: currentIfcDataStore,
1008
- geometryResult: currentGeometryResult,
1009
- visible: true,
1010
- collapsed: false,
1011
- schemaVersion: 'IFC4',
1012
- loadedAt: Date.now() - 1000,
1013
- fileSize: 0,
1014
- idOffset: legacyOffset,
1015
- maxExpressId: legacyMaxExpressId,
1016
- };
1017
- storeAddModel(legacyModel);
1018
- console.log(`[useIfc] Migrated legacy model "${legacyModel.name}" to federation (offset: ${legacyOffset}, maxId: ${legacyMaxExpressId})`);
1019
- }
1020
-
1021
- setLoading(true);
1022
- setError(null);
1023
- setProgress({ phase: 'Loading file', percent: 0 });
1024
-
1025
- // Read file from disk
1026
- const buffer = await file.arrayBuffer();
1027
- const fileSizeMB = buffer.byteLength / (1024 * 1024);
1028
-
1029
- // Detect file format
1030
- const format = detectFormat(buffer);
1031
-
1032
- let parsedDataStore: IfcDataStore | null = null;
1033
- let parsedGeometry: { meshes: MeshData[]; totalVertices: number; totalTriangles: number; coordinateInfo: CoordinateInfo } | null = null;
1034
- let schemaVersion: SchemaVersion = 'IFC4';
1035
-
1036
- // IFCX files must be parsed client-side
1037
- if (format === 'ifcx') {
1038
- setProgress({ phase: 'Parsing IFCX (client-side)', percent: 10 });
1039
-
1040
- const ifcxResult = await parseIfcx(buffer, {
1041
- onProgress: (prog: { phase: string; percent: number }) => {
1042
- setProgress({ phase: `IFCX ${prog.phase}`, percent: 10 + (prog.percent * 0.8) });
1043
- },
1044
- });
1045
-
1046
- // Convert IFCX meshes to viewer format
1047
- 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 }) => {
1048
- const positions = m.positions instanceof Float32Array ? m.positions : new Float32Array(m.positions || []);
1049
- const indices = m.indices instanceof Uint32Array ? m.indices : new Uint32Array(m.indices || []);
1050
- const normals = m.normals instanceof Float32Array ? m.normals : new Float32Array(m.normals || []);
1051
- const color = normalizeColor(m.color);
1052
-
1053
- return {
1054
- expressId: m.expressId || m.express_id || m.id || 0,
1055
- positions,
1056
- indices,
1057
- normals,
1058
- color,
1059
- ifcType: m.ifcType || m.ifc_type || 'IfcProduct',
1060
- };
1061
- }).filter((m: MeshData) => m.positions.length > 0 && m.indices.length > 0);
1062
-
1063
- // Check if this is an overlay-only IFCX file (no geometry)
1064
- if (meshes.length === 0 && ifcxResult.entityCount > 0) {
1065
- console.warn(`[useIfc] IFCX file "${file.name}" has no geometry - this is an overlay file.`);
1066
- setError(`"${file.name}" is an overlay file with no geometry. Please load it together with a base IFCX file (select all files at once for federated loading).`);
1067
- setLoading(false);
1068
- return null;
1069
- }
1070
-
1071
- const { bounds, stats } = calculateMeshBounds(meshes);
1072
- const coordinateInfo = createCoordinateInfo(bounds);
1073
-
1074
- parsedGeometry = {
1075
- meshes,
1076
- totalVertices: stats.totalVertices,
1077
- totalTriangles: stats.totalTriangles,
1078
- coordinateInfo,
1079
- };
1080
-
1081
- parsedDataStore = {
1082
- fileSize: ifcxResult.fileSize,
1083
- schemaVersion: 'IFC5' as const,
1084
- entityCount: ifcxResult.entityCount,
1085
- parseTime: ifcxResult.parseTime,
1086
- source: new Uint8Array(buffer),
1087
- entityIndex: { byId: new Map(), byType: new Map() },
1088
- strings: ifcxResult.strings,
1089
- entities: ifcxResult.entities,
1090
- properties: ifcxResult.properties,
1091
- quantities: ifcxResult.quantities,
1092
- relationships: ifcxResult.relationships,
1093
- spatialHierarchy: ifcxResult.spatialHierarchy,
1094
- } as unknown as IfcDataStore; // IFC5 schema extension
1095
-
1096
- schemaVersion = 'IFC5';
1097
-
1098
- } else if (format === 'glb') {
1099
- // GLB files: parse directly to MeshData (geometry only, no IFC data model)
1100
- setProgress({ phase: 'Parsing GLB', percent: 10 });
1101
-
1102
- const meshes = loadGLBToMeshData(new Uint8Array(buffer));
1103
-
1104
- if (meshes.length === 0) {
1105
- setError('GLB file contains no geometry');
1106
- setLoading(false);
1107
- return null;
1108
- }
1109
-
1110
- const { bounds, stats } = calculateMeshBounds(meshes);
1111
- const coordinateInfo = createCoordinateInfo(bounds);
1112
-
1113
- parsedGeometry = {
1114
- meshes,
1115
- totalVertices: stats.totalVertices,
1116
- totalTriangles: stats.totalTriangles,
1117
- coordinateInfo,
1118
- };
1119
-
1120
- // Create a minimal data store for GLB (no IFC properties)
1121
- parsedDataStore = {
1122
- fileSize: buffer.byteLength,
1123
- schemaVersion: 'IFC4' as const,
1124
- entityCount: meshes.length,
1125
- parseTime: 0,
1126
- source: new Uint8Array(0),
1127
- entityIndex: { byId: new Map(), byType: new Map() },
1128
- strings: { getString: () => undefined, getStringId: () => undefined, count: 0 } as unknown as IfcDataStore['strings'],
1129
- entities: { count: 0, getId: () => 0, getType: () => 0, getName: () => undefined, getGlobalId: () => undefined } as unknown as IfcDataStore['entities'],
1130
- properties: { count: 0, getPropertiesForEntity: () => [], getPropertySetForEntity: () => [] } as unknown as IfcDataStore['properties'],
1131
- quantities: { count: 0, getQuantitiesForEntity: () => [] } as unknown as IfcDataStore['quantities'],
1132
- relationships: { count: 0, getRelationships: () => [], getRelated: () => [] } as unknown as IfcDataStore['relationships'],
1133
- spatialHierarchy: null as unknown as IfcDataStore['spatialHierarchy'],
1134
- } as unknown as IfcDataStore;
1135
-
1136
- schemaVersion = 'IFC4'; // GLB doesn't have a schema version, use IFC4 as default
1137
-
1138
- } else {
1139
- // IFC4/IFC2X3 STEP format - use WASM parsing
1140
- setProgress({ phase: 'Starting geometry streaming', percent: 10 });
1141
-
1142
- const geometryProcessor = new GeometryProcessor({ quality: GeometryQuality.Balanced });
1143
- await geometryProcessor.init();
1144
-
1145
- // Parse data model
1146
- const parser = new IfcParser();
1147
- const wasmApi = geometryProcessor.getApi();
1148
-
1149
- const dataStorePromise = parser.parseColumnar(buffer, { wasmApi });
1150
-
1151
- // Process geometry
1152
- const allMeshes: MeshData[] = [];
1153
- let finalCoordinateInfo: CoordinateInfo | null = null;
1154
- // Capture RTC offset from WASM for proper multi-model alignment
1155
- let capturedRtcOffset: { x: number; y: number; z: number } | null = null;
1156
-
1157
- const dynamicBatchConfig = getDynamicBatchConfig(fileSizeMB);
1158
-
1159
- for await (const event of geometryProcessor.processAdaptive(new Uint8Array(buffer), {
1160
- sizeThreshold: 2 * 1024 * 1024,
1161
- batchSize: dynamicBatchConfig,
1162
- })) {
1163
- switch (event.type) {
1164
- case 'batch': {
1165
- allMeshes.push(...event.meshes);
1166
- finalCoordinateInfo = event.coordinateInfo ?? null;
1167
- const progressPercent = 10 + Math.min(80, (allMeshes.length / 1000) * 0.8);
1168
- setProgress({ phase: `Processing geometry (${allMeshes.length} meshes)`, percent: progressPercent });
1169
- break;
1170
- }
1171
- case 'rtcOffset': {
1172
- // Capture RTC offset from WASM for multi-model alignment
1173
- if (event.hasRtc) {
1174
- capturedRtcOffset = event.rtcOffset;
1175
- }
1176
- break;
1177
- }
1178
- case 'complete':
1179
- finalCoordinateInfo = event.coordinateInfo ?? null;
1180
- break;
1181
- }
1182
- }
1183
-
1184
- parsedDataStore = await dataStorePromise;
1185
-
1186
- // Calculate storey heights
1187
- if (parsedDataStore.spatialHierarchy && parsedDataStore.spatialHierarchy.storeyHeights.size === 0 && parsedDataStore.spatialHierarchy.storeyElevations.size > 1) {
1188
- const calculatedHeights = calculateStoreyHeights(parsedDataStore.spatialHierarchy.storeyElevations);
1189
- for (const [storeyId, height] of calculatedHeights) {
1190
- parsedDataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
1191
- }
1192
- }
1193
-
1194
- // Build spatial index
1195
- if (allMeshes.length > 0) {
1196
- try {
1197
- const spatialIndex = buildSpatialIndex(allMeshes);
1198
- parsedDataStore.spatialIndex = spatialIndex;
1199
- } catch (err) {
1200
- console.warn('[useIfc] Failed to build spatial index:', err);
1201
- }
1202
- }
1203
-
1204
- parsedGeometry = {
1205
- meshes: allMeshes,
1206
- totalVertices: allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0),
1207
- totalTriangles: allMeshes.reduce((sum, m) => sum + m.indices.length / 3, 0),
1208
- coordinateInfo: finalCoordinateInfo || createCoordinateInfo(calculateMeshBounds(allMeshes).bounds),
1209
- };
1210
-
1211
- // Store captured RTC offset in coordinate info for multi-model alignment
1212
- if (parsedGeometry.coordinateInfo && capturedRtcOffset) {
1213
- parsedGeometry.coordinateInfo.wasmRtcOffset = capturedRtcOffset;
1214
- }
1215
-
1216
- schemaVersion = parsedDataStore.schemaVersion === 'IFC4X3' ? 'IFC4X3' :
1217
- parsedDataStore.schemaVersion === 'IFC4' ? 'IFC4' : 'IFC2X3';
1218
- }
1219
-
1220
- if (!parsedDataStore || !parsedGeometry) {
1221
- throw new Error('Failed to parse file');
1222
- }
1223
-
1224
- // =========================================================================
1225
- // FEDERATION REGISTRY: Transform expressIds to globally unique IDs
1226
- // This is the BULLETPROOF fix for multi-model ID collisions
1227
- // =========================================================================
1228
-
1229
- // Step 1: Find max expressId in this model
1230
- // IMPORTANT: Use ALL entities from data store, not just meshes
1231
- // Spatial containers (IfcProject, IfcSite, etc.) don't have geometry but need valid globalId resolution
1232
- const maxExpressIdFromMeshes = parsedGeometry.meshes.reduce((max, m) => Math.max(max, m.expressId), 0);
1233
- // FIXED: Use iteration instead of spread to avoid stack overflow with large Maps
1234
- let maxExpressIdFromEntities = 0;
1235
- if (parsedDataStore.entityIndex?.byId) {
1236
- for (const key of parsedDataStore.entityIndex.byId.keys()) {
1237
- if (key > maxExpressIdFromEntities) maxExpressIdFromEntities = key;
1238
- }
1239
- }
1240
- const maxExpressId = Math.max(maxExpressIdFromMeshes, maxExpressIdFromEntities);
1241
-
1242
- // Step 2: Register with federation registry to get unique offset
1243
- const idOffset = registerModelOffset(modelId, maxExpressId);
1244
-
1245
- // Step 3: Transform ALL mesh expressIds to globalIds
1246
- // globalId = originalExpressId + offset
1247
- // This ensures no two models can have the same ID
1248
- if (idOffset > 0) {
1249
- for (const mesh of parsedGeometry.meshes) {
1250
- mesh.expressId = mesh.expressId + idOffset;
1251
- }
1252
- }
1253
-
1254
- // =========================================================================
1255
- // COORDINATE ALIGNMENT: Align new model with existing models using RTC delta
1256
- // WASM applies per-model RTC offsets. To align models from the same project,
1257
- // we calculate the difference in RTC offsets and apply it to the new model.
1258
- //
1259
- // RTC offset is in IFC coordinates (Z-up). After Z-up to Y-up conversion:
1260
- // - IFC X → WebGL X
1261
- // - IFC Y → WebGL -Z
1262
- // - IFC Z → WebGL Y (vertical)
1263
- // =========================================================================
1264
- const existingModels = Array.from(useViewerStore.getState().models.values());
1265
- if (existingModels.length > 0) {
1266
- const firstModel = existingModels[0];
1267
- const firstRtc = firstModel.geometryResult?.coordinateInfo?.wasmRtcOffset;
1268
- const newRtc = parsedGeometry.coordinateInfo?.wasmRtcOffset;
1269
-
1270
- // If both models have RTC offsets, use RTC delta for precise alignment
1271
- if (firstRtc && newRtc) {
1272
- // Calculate what adjustment is needed to align new model with first model
1273
- // First model: pos = original - firstRtc
1274
- // New model: pos = original - newRtc
1275
- // To align: newPos + adjustment = firstPos (assuming same original)
1276
- // adjustment = firstRtc - newRtc (add back new's RTC, subtract first's RTC)
1277
- const adjustX = firstRtc.x - newRtc.x; // IFC X adjustment
1278
- const adjustY = firstRtc.y - newRtc.y; // IFC Y adjustment
1279
- const adjustZ = firstRtc.z - newRtc.z; // IFC Z adjustment (vertical)
1280
-
1281
- // Convert to WebGL coordinates:
1282
- // IFC X → WebGL X (no change)
1283
- // IFC Y → WebGL -Z (swap and negate)
1284
- // IFC Z → WebGL Y (vertical)
1285
- const webglAdjustX = adjustX;
1286
- const webglAdjustY = adjustZ; // IFC Z is WebGL Y (vertical)
1287
- const webglAdjustZ = -adjustY; // IFC Y is WebGL -Z
1288
-
1289
- const hasSignificantAdjust = Math.abs(webglAdjustX) > 0.01 ||
1290
- Math.abs(webglAdjustY) > 0.01 ||
1291
- Math.abs(webglAdjustZ) > 0.01;
1292
-
1293
- if (hasSignificantAdjust) {
1294
- console.log(`[useIfc] Aligning model "${file.name}" using RTC adjustment: X=${webglAdjustX.toFixed(2)}m, Y=${webglAdjustY.toFixed(2)}m, Z=${webglAdjustZ.toFixed(2)}m`);
1295
-
1296
- // Apply adjustment to all mesh vertices
1297
- // SUBTRACT adjustment: if firstRtc > newRtc, first was shifted MORE,
1298
- // so new model needs to be shifted in same direction (subtract more)
1299
- for (const mesh of parsedGeometry.meshes) {
1300
- const positions = mesh.positions;
1301
- for (let i = 0; i < positions.length; i += 3) {
1302
- positions[i] -= webglAdjustX;
1303
- positions[i + 1] -= webglAdjustY;
1304
- positions[i + 2] -= webglAdjustZ;
1305
- }
1306
- }
1307
-
1308
- // Update coordinate info bounds
1309
- if (parsedGeometry.coordinateInfo) {
1310
- parsedGeometry.coordinateInfo.shiftedBounds.min.x -= webglAdjustX;
1311
- parsedGeometry.coordinateInfo.shiftedBounds.max.x -= webglAdjustX;
1312
- parsedGeometry.coordinateInfo.shiftedBounds.min.y -= webglAdjustY;
1313
- parsedGeometry.coordinateInfo.shiftedBounds.max.y -= webglAdjustY;
1314
- parsedGeometry.coordinateInfo.shiftedBounds.min.z -= webglAdjustZ;
1315
- parsedGeometry.coordinateInfo.shiftedBounds.max.z -= webglAdjustZ;
1316
- }
1317
- }
1318
- } else {
1319
- // No RTC info - can't align reliably. This happens with old cache entries.
1320
- console.warn(`[useIfc] Cannot align "${file.name}" - missing RTC offset. Clear cache and reload.`);
1321
- }
1322
- }
1323
-
1324
- // Create the federated model with offset info
1325
- const federatedModel: FederatedModel = {
1326
- id: modelId,
1327
- name: options?.name ?? file.name,
1328
- ifcDataStore: parsedDataStore,
1329
- geometryResult: parsedGeometry,
1330
- visible: true,
1331
- collapsed: hasModels(), // Collapse if not first model
1332
- schemaVersion,
1333
- loadedAt: Date.now(),
1334
- fileSize: buffer.byteLength,
1335
- idOffset,
1336
- maxExpressId,
1337
- };
1338
-
1339
- // Add to store
1340
- storeAddModel(federatedModel);
1341
-
1342
- // Also set legacy single-model state for backward compatibility
1343
- setIfcDataStore(parsedDataStore);
1344
- setGeometryResult(parsedGeometry);
1345
-
1346
- setProgress({ phase: 'Complete', percent: 100 });
1347
- setLoading(false);
1348
-
1349
- const totalElapsedMs = performance.now() - totalStartTime;
1350
- console.log(`[useIfc] ✓ Added model ${file.name} (${fileSizeMB.toFixed(1)}MB) | ${totalElapsedMs.toFixed(0)}ms`);
1351
-
1352
- return modelId;
1353
-
1354
- } catch (err) {
1355
- console.error('[useIfc] addModel failed:', err);
1356
- setError(err instanceof Error ? err.message : 'Unknown error');
1357
- setLoading(false);
1358
- return null;
1359
- }
1360
- }, [setLoading, setError, setProgress, setIfcDataStore, setGeometryResult, storeAddModel, hasModels]);
1361
-
1362
- /**
1363
- * Remove a model from the federation
1364
- */
1365
- const removeModel = useCallback((modelId: string) => {
1366
- storeRemoveModel(modelId);
1367
-
1368
- // Read fresh state from store after removal to avoid stale closure
1369
- const freshModels = useViewerStore.getState().models;
1370
- const remaining = Array.from(freshModels.values());
1371
- if (remaining.length > 0) {
1372
- const newActive = remaining[0];
1373
- setIfcDataStore(newActive.ifcDataStore);
1374
- setGeometryResult(newActive.geometryResult);
1375
- } else {
1376
- setIfcDataStore(null);
1377
- setGeometryResult(null);
1378
- }
1379
- }, [storeRemoveModel, setIfcDataStore, setGeometryResult]);
1380
-
1381
- /**
1382
- * Get query instance for a specific model
1383
- */
1384
- const getQueryForModel = useCallback((modelId: string): IfcQuery | null => {
1385
- const model = getModel(modelId);
1386
- if (!model) return null;
1387
- return new IfcQuery(model.ifcDataStore);
1388
- }, [getModel]);
1389
-
1390
- /**
1391
- * Load multiple files sequentially (WASM parser isn't thread-safe)
1392
- * Each file fully loads before the next one starts
1393
- */
1394
- const loadFilesSequentially = useCallback(async (files: File[]): Promise<void> => {
1395
- for (const file of files) {
1396
- await addModel(file);
1397
- }
1398
- }, [addModel]);
1399
-
1400
- /**
1401
- * Load multiple IFCX files as federated layers
1402
- * Uses IFC5's layer composition system where later files override earlier ones.
1403
- * Properties from overlay files are merged with the base file(s).
1404
- *
1405
- * @param files - Array of IFCX files (first = base/weakest, last = strongest overlay)
1406
- *
1407
- * @example
1408
- * ```typescript
1409
- * // Load base model with property overlay
1410
- * await loadFederatedIfcx([
1411
- * baseFile, // hello-wall.ifcx
1412
- * fireRatingFile, // add-fire-rating.ifcx (adds FireRating property)
1413
- * ]);
1414
- * ```
1415
- */
1416
- /**
1417
- * Internal: Load federated IFCX from buffers (used by both initial load and add overlay)
1418
- */
1419
- const loadFederatedIfcxFromBuffers = useCallback(async (
1420
- buffers: Array<{ buffer: ArrayBuffer; name: string }>,
1421
- options: { resetState?: boolean } = {}
1422
- ): Promise<void> => {
1423
- const { resetViewerState, clearAllModels } = useViewerStore.getState();
1424
-
1425
- try {
1426
- // Always reset viewer state when geometry changes (selection, hidden entities, etc.)
1427
- // This ensures 3D highlighting works correctly after re-composition
1428
- resetViewerState();
1429
-
1430
- // Clear legacy geometry BEFORE clearing models to prevent stale fallback
1431
- // This avoids a race condition where mergedGeometryResult uses old geometry
1432
- // during the brief moment when storeModels.size === 0
1433
- setGeometryResult(null);
1434
- clearAllModels();
1435
-
1436
- setLoading(true);
1437
- setError(null);
1438
- setProgress({ phase: 'Parsing federated IFCX', percent: 0 });
1439
-
1440
- // Parse federated IFCX files
1441
- const result = await parseFederatedIfcx(buffers, {
1442
- onProgress: (prog: { phase: string; percent: number }) => {
1443
- setProgress({ phase: `IFCX ${prog.phase}`, percent: prog.percent });
1444
- },
1445
- });
1446
-
1447
- // Convert IFCX meshes to viewer format
1448
- const meshes: MeshData[] = result.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 }) => {
1449
- const positions = m.positions instanceof Float32Array ? m.positions : new Float32Array(m.positions || []);
1450
- const indices = m.indices instanceof Uint32Array ? m.indices : new Uint32Array(m.indices || []);
1451
- const normals = m.normals instanceof Float32Array ? m.normals : new Float32Array(m.normals || []);
1452
- const color = normalizeColor(m.color);
1453
-
1454
- return {
1455
- expressId: m.expressId || m.express_id || m.id || 0,
1456
- positions,
1457
- indices,
1458
- normals,
1459
- color,
1460
- ifcType: m.ifcType || m.ifc_type || 'IfcProduct',
1461
- };
1462
- }).filter((m: MeshData) => m.positions.length > 0 && m.indices.length > 0);
1463
-
1464
- // Calculate bounds
1465
- const { bounds, stats } = calculateMeshBounds(meshes);
1466
- const coordinateInfo = createCoordinateInfo(bounds);
1467
-
1468
- const geometryResult = {
1469
- meshes,
1470
- totalVertices: stats.totalVertices,
1471
- totalTriangles: stats.totalTriangles,
1472
- coordinateInfo,
1473
- };
1474
-
1475
- // NOTE: Do NOT call setGeometryResult() here!
1476
- // For federated loading, geometry comes from the models Map via mergedGeometryResult.
1477
- // Calling setGeometryResult() before models are added causes a race condition where
1478
- // meshes are added to the scene WITHOUT modelIndex, breaking selection highlighting.
1479
-
1480
- // Get layer info with mesh counts
1481
- const layers = result.layerStack.getLayers();
1482
-
1483
- // Create data store from federated result
1484
- const dataStore = {
1485
- fileSize: result.fileSize,
1486
- schemaVersion: 'IFC5' as const,
1487
- entityCount: result.entityCount,
1488
- parseTime: result.parseTime,
1489
- source: new Uint8Array(buffers[0].buffer),
1490
- entityIndex: {
1491
- byId: new Map(),
1492
- byType: new Map(),
1493
- },
1494
- strings: result.strings,
1495
- entities: result.entities,
1496
- properties: result.properties,
1497
- quantities: result.quantities,
1498
- relationships: result.relationships,
1499
- spatialHierarchy: result.spatialHierarchy,
1500
- // Federated-specific: store layer info and ORIGINAL BUFFERS for re-composition
1501
- _federatedLayers: layers.map(l => ({
1502
- id: l.id,
1503
- name: l.name,
1504
- enabled: l.enabled,
1505
- })),
1506
- _federatedBuffers: buffers.map(b => ({
1507
- buffer: b.buffer.slice(0), // Clone buffer
1508
- name: b.name,
1509
- })),
1510
- _compositionStats: result.compositionStats,
1511
- } as unknown as IfcDataStore; // IFC5 schema extension
1512
-
1513
- setIfcDataStore(dataStore);
1514
-
1515
- // Clear existing models and add each layer as a "model" in the Models panel
1516
- // This shows users all the files that contributed to the composition
1517
- clearAllModels();
1518
-
1519
- // Find max expressId for proper ID range tracking
1520
- // This is needed for resolveGlobalIdFromModels to work correctly
1521
- let maxExpressId = 0;
1522
- if (result.entities?.expressId) {
1523
- for (let i = 0; i < result.entities.count; i++) {
1524
- const id = result.entities.expressId[i];
1525
- if (id > maxExpressId) maxExpressId = id;
1526
- }
1527
- }
1528
-
1529
- for (let i = 0; i < layers.length; i++) {
1530
- const layer = layers[i];
1531
- const layerBuffer = buffers.find(b => b.name === layer.name);
1532
-
1533
- // Count how many meshes came from this layer
1534
- // For base layers: count meshes, for overlays: show as data-only
1535
- const isBaseLayer = i === layers.length - 1; // Last layer (weakest) is typically base
1536
-
1537
- const layerModel: FederatedModel = {
1538
- id: layer.id,
1539
- name: layer.name,
1540
- ifcDataStore: dataStore, // Share the composed data store
1541
- geometryResult: isBaseLayer ? geometryResult : {
1542
- meshes: [],
1543
- totalVertices: 0,
1544
- totalTriangles: 0,
1545
- coordinateInfo,
1546
- },
1547
- visible: true,
1548
- collapsed: i > 0, // Collapse overlays by default
1549
- schemaVersion: 'IFC5',
1550
- loadedAt: Date.now() - (layers.length - i) * 100, // Stagger timestamps
1551
- fileSize: layerBuffer?.buffer.byteLength || 0,
1552
- // For base layer: set proper ID range for resolveGlobalIdFromModels
1553
- // Overlays share the same data store so they don't need their own range
1554
- idOffset: 0,
1555
- maxExpressId: isBaseLayer ? maxExpressId : 0,
1556
- // Mark overlay-only layers
1557
- _isOverlay: !isBaseLayer,
1558
- _layerIndex: i,
1559
- } as FederatedModel & { _isOverlay?: boolean; _layerIndex?: number };
1560
-
1561
- storeAddModel(layerModel);
1562
- }
1563
-
1564
- console.log(`[useIfc] Federated IFCX loaded: ${layers.length} layers, ${result.entityCount} entities, ${meshes.length} meshes`);
1565
- console.log(`[useIfc] Composition stats: ${result.compositionStats.inheritanceResolutions} inheritance resolutions, ${result.compositionStats.crossLayerReferences} cross-layer refs`);
1566
- console.log(`[useIfc] Layers in Models panel: ${layers.map(l => l.name).join(', ')}`);
1567
-
1568
- setProgress({ phase: 'Complete', percent: 100 });
1569
- setLoading(false);
1570
- } catch (err: unknown) {
1571
- console.error('[useIfc] Federated IFCX loading failed:', err);
1572
- const message = err instanceof Error ? err.message : String(err);
1573
- setError(`Federated IFCX loading failed: ${message}`);
1574
- setLoading(false);
1575
- }
1576
- }, [setLoading, setError, setProgress, setGeometryResult, setIfcDataStore, storeAddModel, clearAllModels]);
1577
-
1578
- const loadFederatedIfcx = useCallback(async (files: File[]): Promise<void> => {
1579
- if (files.length === 0) {
1580
- setError('No files provided for federated loading');
1581
- return;
1582
- }
1583
-
1584
- // Check that all files are IFCX format and read buffers
1585
- const buffers: Array<{ buffer: ArrayBuffer; name: string }> = [];
1586
- for (const file of files) {
1587
- const buffer = await file.arrayBuffer();
1588
- const format = detectFormat(buffer);
1589
- if (format !== 'ifcx') {
1590
- setError(`File "${file.name}" is not an IFCX file. Federated loading only supports IFCX files.`);
1591
- return;
1592
- }
1593
- buffers.push({ buffer, name: file.name });
1594
- }
1595
-
1596
- await loadFederatedIfcxFromBuffers(buffers);
1597
- }, [setError, loadFederatedIfcxFromBuffers]);
1598
-
1599
- /**
1600
- * Add IFCX overlay files to existing federated model
1601
- * Re-composes all layers including new overlays
1602
- * Also handles adding overlays to a single IFCX file that wasn't loaded via federated loading
1603
- */
1604
- const addIfcxOverlays = useCallback(async (files: File[]): Promise<void> => {
1605
- const currentStore = useViewerStore.getState().ifcDataStore as IfcxDataStore | null;
1606
- const currentModels = useViewerStore.getState().models;
1607
-
1608
- // Get existing buffers - either from federated loading or from single file load
1609
- let existingBuffers: Array<{ buffer: ArrayBuffer; name: string }> = [];
1610
-
1611
- if (currentStore?._federatedBuffers) {
1612
- // Already federated - use stored buffers
1613
- existingBuffers = currentStore._federatedBuffers as Array<{ buffer: ArrayBuffer; name: string }>;
1614
- } else if (currentStore?.source && currentStore.schemaVersion === 'IFC5') {
1615
- // Single IFCX file loaded via loadFile() - reconstruct buffer from source
1616
- // Get the model name from the models map
1617
- let modelName = 'base.ifcx';
1618
- for (const [, model] of currentModels) {
1619
- // Compare object identity (cast needed due to IFC5 schema extension)
1620
- if ((model.ifcDataStore as unknown) === currentStore || model.schemaVersion === 'IFC5') {
1621
- modelName = model.name;
1622
- break;
1623
- }
1624
- }
1625
-
1626
- // Convert Uint8Array source back to ArrayBuffer
1627
- const sourceBuffer = currentStore.source.buffer.slice(
1628
- currentStore.source.byteOffset,
1629
- currentStore.source.byteOffset + currentStore.source.byteLength
1630
- ) as ArrayBuffer;
1631
-
1632
- existingBuffers = [{ buffer: sourceBuffer, name: modelName }];
1633
- console.log(`[useIfc] Converting single IFCX file "${modelName}" to federated mode`);
1634
- } else {
1635
- setError('Cannot add overlays: no IFCX model loaded');
1636
- return;
1637
- }
1638
-
1639
- // Read new overlay buffers
1640
- const newBuffers: Array<{ buffer: ArrayBuffer; name: string }> = [];
1641
- for (const file of files) {
1642
- const buffer = await file.arrayBuffer();
1643
- const format = detectFormat(buffer);
1644
- if (format !== 'ifcx') {
1645
- setError(`File "${file.name}" is not an IFCX file.`);
1646
- return;
1647
- }
1648
- newBuffers.push({ buffer, name: file.name });
1649
- }
1650
-
1651
- // Combine: existing layers + new overlays (new overlays are strongest = first in array)
1652
- const allBuffers = [...newBuffers, ...existingBuffers];
1653
-
1654
- console.log(`[useIfc] Re-composing federated IFCX with ${newBuffers.length} new overlay(s)`);
1655
- console.log(`[useIfc] Total layers: ${allBuffers.length} (${existingBuffers.length} existing + ${newBuffers.length} new)`);
1656
-
1657
- await loadFederatedIfcxFromBuffers(allBuffers, { resetState: false });
1658
- }, [setError, loadFederatedIfcxFromBuffers]);
1659
-
1660
- /**
1661
- * Find which model contains a given globalId
1662
- * Uses FederationRegistry for O(log N) lookup - BULLETPROOF
1663
- * Returns the modelId or null if not found
1664
- */
1665
- const findModelForEntity = useCallback((globalId: number): string | null => {
1666
- return findModelForGlobalId(globalId);
1667
- }, [findModelForGlobalId]);
1668
-
1669
- /**
1670
- * Convert a globalId back to the original (modelId, expressId) pair
1671
- * Use this when you need to look up properties in the IfcDataStore
1672
- */
1673
- const resolveGlobalId = useCallback((globalId: number): { modelId: string; expressId: number } | null => {
1674
- return fromGlobalId(globalId);
1675
- }, [fromGlobalId]);
1676
-
1677
75
  return {
1678
76
  // Legacy single-model API (backward compatibility)
1679
77
  loading,