@ifc-lite/viewer 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
  5. package/dist/assets/index-yTqs8kgX.css +1 -0
  6. package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
  7. package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +18 -15
  10. package/src/components/viewer/BCFPanel.tsx +7 -789
  11. package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/HierarchyPanel.tsx +110 -842
  14. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  15. package/src/components/viewer/IDSPanel.tsx +126 -17
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  17. package/src/components/viewer/LensPanel.tsx +603 -0
  18. package/src/components/viewer/MainToolbar.tsx +188 -21
  19. package/src/components/viewer/PropertiesPanel.tsx +171 -663
  20. package/src/components/viewer/PropertyEditor.tsx +866 -77
  21. package/src/components/viewer/Section2DPanel.tsx +76 -2648
  22. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  23. package/src/components/viewer/ViewerLayout.tsx +132 -45
  24. package/src/components/viewer/Viewport.tsx +237 -1659
  25. package/src/components/viewer/ViewportContainer.tsx +11 -3
  26. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  27. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  28. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  29. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  30. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  31. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  32. package/src/components/viewer/hierarchy/types.ts +54 -0
  33. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  34. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  35. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  36. package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
  37. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  38. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  39. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  40. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  41. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  42. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  43. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  44. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  45. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  46. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  47. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  48. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  49. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  50. package/src/components/viewer/tools/formatDistance.ts +18 -0
  51. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  52. package/src/components/viewer/useAnimationLoop.ts +166 -0
  53. package/src/components/viewer/useGeometryStreaming.ts +398 -0
  54. package/src/components/viewer/useKeyboardControls.ts +221 -0
  55. package/src/components/viewer/useMouseControls.ts +1009 -0
  56. package/src/components/viewer/useRenderUpdates.ts +165 -0
  57. package/src/components/viewer/useTouchControls.ts +245 -0
  58. package/src/hooks/ids/idsColorSystem.ts +125 -0
  59. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  60. package/src/hooks/ids/idsExportService.ts +444 -0
  61. package/src/hooks/useBCF.ts +7 -0
  62. package/src/hooks/useDrawingExport.ts +627 -0
  63. package/src/hooks/useDrawingGeneration.ts +627 -0
  64. package/src/hooks/useFloorplanView.ts +108 -0
  65. package/src/hooks/useIDS.ts +270 -463
  66. package/src/hooks/useIfc.ts +26 -1628
  67. package/src/hooks/useIfcFederation.ts +803 -0
  68. package/src/hooks/useIfcLoader.ts +508 -0
  69. package/src/hooks/useIfcServer.ts +465 -0
  70. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  71. package/src/hooks/useLens.ts +129 -0
  72. package/src/hooks/useMeasure2D.ts +365 -0
  73. package/src/hooks/useViewControls.ts +218 -0
  74. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  75. package/src/lib/ifc4-pset-definitions.ts +621 -0
  76. package/src/lib/ifc4-qto-definitions.ts +315 -0
  77. package/src/lib/lens/adapter.ts +138 -0
  78. package/src/lib/lens/index.ts +5 -0
  79. package/src/lib/lists/adapter.ts +69 -0
  80. package/src/lib/lists/index.ts +28 -0
  81. package/src/lib/lists/persistence.ts +64 -0
  82. package/src/services/fs-cache.ts +1 -1
  83. package/src/services/tauri-modules.d.ts +25 -0
  84. package/src/store/index.ts +38 -2
  85. package/src/store/slices/cameraSlice.ts +14 -1
  86. package/src/store/slices/dataSlice.ts +14 -1
  87. package/src/store/slices/lensSlice.ts +184 -0
  88. package/src/store/slices/listSlice.ts +74 -0
  89. package/src/store/slices/pinboardSlice.ts +114 -0
  90. package/src/store/types.ts +5 -0
  91. package/src/utils/ifcConfig.ts +16 -3
  92. package/src/utils/serverDataModel.ts +64 -101
  93. package/src/vite-env.d.ts +3 -0
  94. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  95. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -0,0 +1,803 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Hook for multi-model federation operations
7
+ * Handles addModel, removeModel, ID offset management, RTC alignment,
8
+ * IFCX federated layer composition, and legacy model migration
9
+ *
10
+ * Extracted from useIfc.ts for better separation of concerns
11
+ */
12
+
13
+ import { useCallback } from 'react';
14
+ import { useViewerStore, type FederatedModel, type SchemaVersion } from '../store.js';
15
+ import { IfcParser, detectFormat, parseIfcx, parseFederatedIfcx, type IfcDataStore, type FederatedIfcxParseResult } from '@ifc-lite/parser';
16
+ import { GeometryProcessor, GeometryQuality, type MeshData, type CoordinateInfo } from '@ifc-lite/geometry';
17
+ import { IfcQuery } from '@ifc-lite/query';
18
+ import { buildSpatialIndex } from '@ifc-lite/spatial';
19
+ import { loadGLBToMeshData } from '@ifc-lite/cache';
20
+
21
+ import { getDynamicBatchConfig } from '../utils/ifcConfig.js';
22
+ import {
23
+ calculateMeshBounds,
24
+ createCoordinateInfo,
25
+ calculateStoreyHeights,
26
+ normalizeColor,
27
+ } from '../utils/localParsingUtils.js';
28
+
29
+ /**
30
+ * Extended data store type for IFCX (IFC5) files.
31
+ * IFCX uses schemaVersion 'IFC5' and may include federated composition metadata.
32
+ */
33
+ export interface IfcxDataStore extends IfcDataStore {
34
+ schemaVersion: 'IFC5';
35
+ /** Federated layer info for re-composition */
36
+ _federatedLayers?: Array<{ id: string; name: string; enabled: boolean }>;
37
+ /** Original buffers for re-composition when adding overlays */
38
+ _federatedBuffers?: Array<{ buffer: ArrayBuffer; name: string }>;
39
+ /** Composition statistics */
40
+ _compositionStats?: { totalNodes: number; layersUsed: number; inheritanceResolutions: number; crossLayerReferences: number };
41
+ /** Layer info for display */
42
+ _layerInfo?: Array<{ id: string; name: string; meshCount: number }>;
43
+ }
44
+
45
+ /** Shape of raw meshes returned from IFCX parsers before conversion to MeshData */
46
+ interface RawIfcxMesh {
47
+ expressId?: number;
48
+ express_id?: number;
49
+ id?: number;
50
+ positions: Float32Array | number[];
51
+ indices: Uint32Array | number[];
52
+ normals: Float32Array | number[];
53
+ color?: [number, number, number, number] | [number, number, number];
54
+ ifcType?: string;
55
+ ifc_type?: string;
56
+ }
57
+
58
+ /**
59
+ * Convert raw IFCX parser meshes to viewer-compatible MeshData[].
60
+ * Ensures typed arrays, normalizes colors, and filters out empty meshes.
61
+ */
62
+ function convertIfcxMeshes(rawMeshes: RawIfcxMesh[]): MeshData[] {
63
+ return rawMeshes.map((m) => {
64
+ const positions = m.positions instanceof Float32Array ? m.positions : new Float32Array(m.positions || []);
65
+ const indices = m.indices instanceof Uint32Array ? m.indices : new Uint32Array(m.indices || []);
66
+ const normals = m.normals instanceof Float32Array ? m.normals : new Float32Array(m.normals || []);
67
+ const color = normalizeColor(m.color);
68
+
69
+ return {
70
+ expressId: m.expressId ?? m.express_id ?? m.id ?? 0,
71
+ positions,
72
+ indices,
73
+ normals,
74
+ color,
75
+ ifcType: m.ifcType ?? m.ifc_type ?? 'IfcProduct',
76
+ };
77
+ }).filter((m) => m.positions.length > 0 && m.indices.length > 0);
78
+ }
79
+
80
+ /**
81
+ * Hook providing multi-model federation operations
82
+ * Includes addModel, removeModel, federated IFCX loading, overlay management,
83
+ * and ID resolution helpers
84
+ */
85
+ export function useIfcFederation() {
86
+ const {
87
+ setLoading,
88
+ setError,
89
+ setProgress,
90
+ setIfcDataStore,
91
+ setGeometryResult,
92
+ // Multi-model state and actions
93
+ addModel: storeAddModel,
94
+ removeModel: storeRemoveModel,
95
+ clearAllModels,
96
+ getModel,
97
+ hasModels,
98
+ // Federation Registry helpers
99
+ registerModelOffset,
100
+ fromGlobalId,
101
+ findModelForGlobalId,
102
+ } = useViewerStore();
103
+
104
+ /**
105
+ * Add a model to the federation (multi-model support)
106
+ * Uses FederationRegistry to assign unique ID offsets - BULLETPROOF against ID collisions
107
+ * Returns the model ID on success, null on failure
108
+ */
109
+ const addModel = useCallback(async (
110
+ file: File,
111
+ options?: { name?: string }
112
+ ): Promise<string | null> => {
113
+ const modelId = crypto.randomUUID();
114
+ const totalStartTime = performance.now();
115
+
116
+ try {
117
+ // IMPORTANT: Before adding a new model, check if there's a legacy model
118
+ // (loaded via loadFile) that's not in the Map yet. If so, migrate it first.
119
+ const currentModels = useViewerStore.getState().models;
120
+ const currentIfcDataStore = useViewerStore.getState().ifcDataStore;
121
+ const currentGeometryResult = useViewerStore.getState().geometryResult;
122
+
123
+ if (currentModels.size === 0 && currentIfcDataStore && currentGeometryResult) {
124
+ // Migrate the legacy model to the Map
125
+ // Legacy model has offset 0 (IDs are unchanged)
126
+ const legacyModelId = crypto.randomUUID();
127
+ const legacyName = currentIfcDataStore.spatialHierarchy?.project?.name || 'Model 1';
128
+
129
+ // Find max expressId in legacy model for registry
130
+ // IMPORTANT: Include ALL entities, not just meshes, for proper globalId resolution
131
+ const legacyMeshes = currentGeometryResult.meshes || [];
132
+ const legacyMaxExpressIdFromMeshes = legacyMeshes.reduce((max: number, m: MeshData) => Math.max(max, m.expressId), 0);
133
+ // FIXED: Use iteration instead of spread to avoid stack overflow with large Maps
134
+ let legacyMaxExpressIdFromEntities = 0;
135
+ if (currentIfcDataStore.entityIndex?.byId) {
136
+ for (const key of currentIfcDataStore.entityIndex.byId.keys()) {
137
+ if (key > legacyMaxExpressIdFromEntities) legacyMaxExpressIdFromEntities = key;
138
+ }
139
+ }
140
+ const legacyMaxExpressId = Math.max(legacyMaxExpressIdFromMeshes, legacyMaxExpressIdFromEntities);
141
+
142
+ // Register legacy model with offset 0 (IDs already in use as-is)
143
+ const legacyOffset = registerModelOffset(legacyModelId, legacyMaxExpressId);
144
+
145
+ const legacyModel: FederatedModel = {
146
+ id: legacyModelId,
147
+ name: legacyName,
148
+ ifcDataStore: currentIfcDataStore,
149
+ geometryResult: currentGeometryResult,
150
+ visible: true,
151
+ collapsed: false,
152
+ schemaVersion: 'IFC4',
153
+ loadedAt: Date.now() - 1000,
154
+ fileSize: 0,
155
+ idOffset: legacyOffset,
156
+ maxExpressId: legacyMaxExpressId,
157
+ };
158
+ storeAddModel(legacyModel);
159
+ console.log(`[useIfc] Migrated legacy model "${legacyModel.name}" to federation (offset: ${legacyOffset}, maxId: ${legacyMaxExpressId})`);
160
+ }
161
+
162
+ setLoading(true);
163
+ setError(null);
164
+ setProgress({ phase: 'Loading file', percent: 0 });
165
+
166
+ // Read file from disk
167
+ const buffer = await file.arrayBuffer();
168
+ const fileSizeMB = buffer.byteLength / (1024 * 1024);
169
+
170
+ // Detect file format
171
+ const format = detectFormat(buffer);
172
+
173
+ let parsedDataStore: IfcDataStore | null = null;
174
+ let parsedGeometry: { meshes: MeshData[]; totalVertices: number; totalTriangles: number; coordinateInfo: CoordinateInfo } | null = null;
175
+ let schemaVersion: SchemaVersion = 'IFC4';
176
+
177
+ // IFCX files must be parsed client-side
178
+ if (format === 'ifcx') {
179
+ setProgress({ phase: 'Parsing IFCX (client-side)', percent: 10 });
180
+
181
+ const ifcxResult = await parseIfcx(buffer, {
182
+ onProgress: (prog: { phase: string; percent: number }) => {
183
+ setProgress({ phase: `IFCX ${prog.phase}`, percent: 10 + (prog.percent * 0.8) });
184
+ },
185
+ });
186
+
187
+ // Convert IFCX meshes to viewer format
188
+ const meshes: MeshData[] = convertIfcxMeshes(ifcxResult.meshes);
189
+
190
+ // Check if this is an overlay-only IFCX file (no geometry)
191
+ if (meshes.length === 0 && ifcxResult.entityCount > 0) {
192
+ console.warn(`[useIfc] IFCX file "${file.name}" has no geometry - this is an overlay file.`);
193
+ 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).`);
194
+ setLoading(false);
195
+ return null;
196
+ }
197
+
198
+ const { bounds, stats } = calculateMeshBounds(meshes);
199
+ const coordinateInfo = createCoordinateInfo(bounds);
200
+
201
+ parsedGeometry = {
202
+ meshes,
203
+ totalVertices: stats.totalVertices,
204
+ totalTriangles: stats.totalTriangles,
205
+ coordinateInfo,
206
+ };
207
+
208
+ parsedDataStore = {
209
+ fileSize: ifcxResult.fileSize,
210
+ schemaVersion: 'IFC5' as const,
211
+ entityCount: ifcxResult.entityCount,
212
+ parseTime: ifcxResult.parseTime,
213
+ source: new Uint8Array(buffer),
214
+ entityIndex: { byId: new Map(), byType: new Map() },
215
+ strings: ifcxResult.strings,
216
+ entities: ifcxResult.entities,
217
+ properties: ifcxResult.properties,
218
+ quantities: ifcxResult.quantities,
219
+ relationships: ifcxResult.relationships,
220
+ spatialHierarchy: ifcxResult.spatialHierarchy,
221
+ } as IfcxDataStore;
222
+
223
+ schemaVersion = 'IFC5';
224
+
225
+ } else if (format === 'glb') {
226
+ // GLB files: parse directly to MeshData (geometry only, no IFC data model)
227
+ setProgress({ phase: 'Parsing GLB', percent: 10 });
228
+
229
+ const meshes = loadGLBToMeshData(new Uint8Array(buffer));
230
+
231
+ if (meshes.length === 0) {
232
+ setError('GLB file contains no geometry');
233
+ setLoading(false);
234
+ return null;
235
+ }
236
+
237
+ const { bounds, stats } = calculateMeshBounds(meshes);
238
+ const coordinateInfo = createCoordinateInfo(bounds);
239
+
240
+ parsedGeometry = {
241
+ meshes,
242
+ totalVertices: stats.totalVertices,
243
+ totalTriangles: stats.totalTriangles,
244
+ coordinateInfo,
245
+ };
246
+
247
+ // Create a minimal data store for GLB (no IFC properties)
248
+ parsedDataStore = {
249
+ fileSize: buffer.byteLength,
250
+ schemaVersion: 'IFC4' as const,
251
+ entityCount: meshes.length,
252
+ parseTime: 0,
253
+ source: new Uint8Array(0),
254
+ entityIndex: { byId: new Map(), byType: new Map() },
255
+ strings: { getString: () => undefined, getStringId: () => undefined, count: 0 } as unknown as IfcDataStore['strings'],
256
+ entities: { count: 0, getId: () => 0, getType: () => 0, getName: () => undefined, getGlobalId: () => undefined } as unknown as IfcDataStore['entities'],
257
+ properties: { count: 0, getPropertiesForEntity: () => [], getPropertySetForEntity: () => [] } as unknown as IfcDataStore['properties'],
258
+ quantities: { count: 0, getQuantitiesForEntity: () => [] } as unknown as IfcDataStore['quantities'],
259
+ relationships: { count: 0, getRelationships: () => [], getRelated: () => [] } as unknown as IfcDataStore['relationships'],
260
+ spatialHierarchy: null as unknown as IfcDataStore['spatialHierarchy'],
261
+ } as unknown as IfcDataStore;
262
+
263
+ schemaVersion = 'IFC4'; // GLB doesn't have a schema version, use IFC4 as default
264
+
265
+ } else {
266
+ // IFC4/IFC2X3 STEP format - use WASM parsing
267
+ setProgress({ phase: 'Starting geometry streaming', percent: 10 });
268
+
269
+ const geometryProcessor = new GeometryProcessor({ quality: GeometryQuality.Balanced });
270
+ await geometryProcessor.init();
271
+
272
+ // Parse data model
273
+ const parser = new IfcParser();
274
+ const wasmApi = geometryProcessor.getApi();
275
+
276
+ const dataStorePromise = parser.parseColumnar(buffer, { wasmApi });
277
+
278
+ // Process geometry
279
+ const allMeshes: MeshData[] = [];
280
+ let finalCoordinateInfo: CoordinateInfo | null = null;
281
+ // Capture RTC offset from WASM for proper multi-model alignment
282
+ let capturedRtcOffset: { x: number; y: number; z: number } | null = null;
283
+
284
+ const dynamicBatchConfig = getDynamicBatchConfig(fileSizeMB);
285
+
286
+ for await (const event of geometryProcessor.processAdaptive(new Uint8Array(buffer), {
287
+ sizeThreshold: 2 * 1024 * 1024,
288
+ batchSize: dynamicBatchConfig,
289
+ })) {
290
+ switch (event.type) {
291
+ case 'batch': {
292
+ for (let i = 0; i < event.meshes.length; i++) allMeshes.push(event.meshes[i]);
293
+ finalCoordinateInfo = event.coordinateInfo ?? null;
294
+ const progressPercent = 10 + Math.min(80, (allMeshes.length / 1000) * 0.8);
295
+ setProgress({ phase: `Processing geometry (${allMeshes.length} meshes)`, percent: progressPercent });
296
+ break;
297
+ }
298
+ case 'rtcOffset': {
299
+ // Capture RTC offset from WASM for multi-model alignment
300
+ if (event.hasRtc) {
301
+ capturedRtcOffset = event.rtcOffset;
302
+ }
303
+ break;
304
+ }
305
+ case 'complete':
306
+ finalCoordinateInfo = event.coordinateInfo ?? null;
307
+ break;
308
+ }
309
+ }
310
+
311
+ parsedDataStore = await dataStorePromise;
312
+
313
+ // Calculate storey heights
314
+ if (parsedDataStore.spatialHierarchy && parsedDataStore.spatialHierarchy.storeyHeights.size === 0 && parsedDataStore.spatialHierarchy.storeyElevations.size > 1) {
315
+ const calculatedHeights = calculateStoreyHeights(parsedDataStore.spatialHierarchy.storeyElevations);
316
+ for (const [storeyId, height] of calculatedHeights) {
317
+ parsedDataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
318
+ }
319
+ }
320
+
321
+ // Build spatial index
322
+ if (allMeshes.length > 0) {
323
+ try {
324
+ const spatialIndex = buildSpatialIndex(allMeshes);
325
+ parsedDataStore.spatialIndex = spatialIndex;
326
+ } catch (err) {
327
+ console.warn('[useIfc] Failed to build spatial index:', err);
328
+ }
329
+ }
330
+
331
+ parsedGeometry = {
332
+ meshes: allMeshes,
333
+ totalVertices: allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0),
334
+ totalTriangles: allMeshes.reduce((sum, m) => sum + m.indices.length / 3, 0),
335
+ coordinateInfo: finalCoordinateInfo || createCoordinateInfo(calculateMeshBounds(allMeshes).bounds),
336
+ };
337
+
338
+ // Store captured RTC offset in coordinate info for multi-model alignment
339
+ if (parsedGeometry.coordinateInfo && capturedRtcOffset) {
340
+ parsedGeometry.coordinateInfo.wasmRtcOffset = capturedRtcOffset;
341
+ }
342
+
343
+ schemaVersion = parsedDataStore.schemaVersion === 'IFC4X3' ? 'IFC4X3' :
344
+ parsedDataStore.schemaVersion === 'IFC4' ? 'IFC4' : 'IFC2X3';
345
+ }
346
+
347
+ if (!parsedDataStore || !parsedGeometry) {
348
+ throw new Error('Failed to parse file');
349
+ }
350
+
351
+ // =========================================================================
352
+ // FEDERATION REGISTRY: Transform expressIds to globally unique IDs
353
+ // This is the BULLETPROOF fix for multi-model ID collisions
354
+ // =========================================================================
355
+
356
+ // Step 1: Find max expressId in this model
357
+ // IMPORTANT: Use ALL entities from data store, not just meshes
358
+ // Spatial containers (IfcProject, IfcSite, etc.) don't have geometry but need valid globalId resolution
359
+ const maxExpressIdFromMeshes = parsedGeometry.meshes.reduce((max, m) => Math.max(max, m.expressId), 0);
360
+ // FIXED: Use iteration instead of spread to avoid stack overflow with large Maps
361
+ let maxExpressIdFromEntities = 0;
362
+ if (parsedDataStore.entityIndex?.byId) {
363
+ for (const key of parsedDataStore.entityIndex.byId.keys()) {
364
+ if (key > maxExpressIdFromEntities) maxExpressIdFromEntities = key;
365
+ }
366
+ }
367
+ const maxExpressId = Math.max(maxExpressIdFromMeshes, maxExpressIdFromEntities);
368
+
369
+ // Step 2: Register with federation registry to get unique offset
370
+ const idOffset = registerModelOffset(modelId, maxExpressId);
371
+
372
+ // Step 3: Transform ALL mesh expressIds to globalIds
373
+ // globalId = originalExpressId + offset
374
+ // This ensures no two models can have the same ID
375
+ if (idOffset > 0) {
376
+ for (const mesh of parsedGeometry.meshes) {
377
+ mesh.expressId = mesh.expressId + idOffset;
378
+ }
379
+ }
380
+
381
+ // =========================================================================
382
+ // COORDINATE ALIGNMENT: Align new model with existing models using RTC delta
383
+ // WASM applies per-model RTC offsets. To align models from the same project,
384
+ // we calculate the difference in RTC offsets and apply it to the new model.
385
+ //
386
+ // RTC offset is in IFC coordinates (Z-up). After Z-up to Y-up conversion:
387
+ // - IFC X → WebGL X
388
+ // - IFC Y → WebGL -Z
389
+ // - IFC Z → WebGL Y (vertical)
390
+ // =========================================================================
391
+ const existingModels = Array.from(useViewerStore.getState().models.values()) as FederatedModel[];
392
+ if (existingModels.length > 0) {
393
+ const firstModel = existingModels[0];
394
+ const firstRtc = firstModel.geometryResult?.coordinateInfo?.wasmRtcOffset;
395
+ const newRtc = parsedGeometry.coordinateInfo?.wasmRtcOffset;
396
+
397
+ // If both models have RTC offsets, use RTC delta for precise alignment
398
+ if (firstRtc && newRtc) {
399
+ // Calculate what adjustment is needed to align new model with first model
400
+ // First model: pos = original - firstRtc
401
+ // New model: pos = original - newRtc
402
+ // To align: newPos + adjustment = firstPos (assuming same original)
403
+ // adjustment = firstRtc - newRtc (add back new's RTC, subtract first's RTC)
404
+ const adjustX = firstRtc.x - newRtc.x; // IFC X adjustment
405
+ const adjustY = firstRtc.y - newRtc.y; // IFC Y adjustment
406
+ const adjustZ = firstRtc.z - newRtc.z; // IFC Z adjustment (vertical)
407
+
408
+ // Convert to WebGL coordinates:
409
+ // IFC X → WebGL X (no change)
410
+ // IFC Y → WebGL -Z (swap and negate)
411
+ // IFC Z → WebGL Y (vertical)
412
+ const webglAdjustX = adjustX;
413
+ const webglAdjustY = adjustZ; // IFC Z is WebGL Y (vertical)
414
+ const webglAdjustZ = -adjustY; // IFC Y is WebGL -Z
415
+
416
+ const hasSignificantAdjust = Math.abs(webglAdjustX) > 0.01 ||
417
+ Math.abs(webglAdjustY) > 0.01 ||
418
+ Math.abs(webglAdjustZ) > 0.01;
419
+
420
+ if (hasSignificantAdjust) {
421
+ 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`);
422
+
423
+ // Apply adjustment to all mesh vertices
424
+ // SUBTRACT adjustment: if firstRtc > newRtc, first was shifted MORE,
425
+ // so new model needs to be shifted in same direction (subtract more)
426
+ for (const mesh of parsedGeometry.meshes) {
427
+ const positions = mesh.positions;
428
+ for (let i = 0; i < positions.length; i += 3) {
429
+ positions[i] -= webglAdjustX;
430
+ positions[i + 1] -= webglAdjustY;
431
+ positions[i + 2] -= webglAdjustZ;
432
+ }
433
+ }
434
+
435
+ // Update coordinate info bounds
436
+ if (parsedGeometry.coordinateInfo) {
437
+ parsedGeometry.coordinateInfo.shiftedBounds.min.x -= webglAdjustX;
438
+ parsedGeometry.coordinateInfo.shiftedBounds.max.x -= webglAdjustX;
439
+ parsedGeometry.coordinateInfo.shiftedBounds.min.y -= webglAdjustY;
440
+ parsedGeometry.coordinateInfo.shiftedBounds.max.y -= webglAdjustY;
441
+ parsedGeometry.coordinateInfo.shiftedBounds.min.z -= webglAdjustZ;
442
+ parsedGeometry.coordinateInfo.shiftedBounds.max.z -= webglAdjustZ;
443
+ }
444
+ }
445
+ } else {
446
+ // No RTC info - can't align reliably. This happens with old cache entries.
447
+ console.warn(`[useIfc] Cannot align "${file.name}" - missing RTC offset. Clear cache and reload.`);
448
+ }
449
+ }
450
+
451
+ // Create the federated model with offset info
452
+ const federatedModel: FederatedModel = {
453
+ id: modelId,
454
+ name: options?.name ?? file.name,
455
+ ifcDataStore: parsedDataStore,
456
+ geometryResult: parsedGeometry,
457
+ visible: true,
458
+ collapsed: hasModels(), // Collapse if not first model
459
+ schemaVersion,
460
+ loadedAt: Date.now(),
461
+ fileSize: buffer.byteLength,
462
+ idOffset,
463
+ maxExpressId,
464
+ };
465
+
466
+ // Add to store
467
+ storeAddModel(federatedModel);
468
+
469
+ // Also set legacy single-model state for backward compatibility
470
+ setIfcDataStore(parsedDataStore);
471
+ setGeometryResult(parsedGeometry);
472
+
473
+ setProgress({ phase: 'Complete', percent: 100 });
474
+ setLoading(false);
475
+
476
+ const totalElapsedMs = performance.now() - totalStartTime;
477
+ console.log(`[useIfc] ✓ Added model ${file.name} (${fileSizeMB.toFixed(1)}MB) | ${totalElapsedMs.toFixed(0)}ms`);
478
+
479
+ return modelId;
480
+
481
+ } catch (err) {
482
+ console.error('[useIfc] addModel failed:', err);
483
+ setError(err instanceof Error ? err.message : 'Unknown error');
484
+ setLoading(false);
485
+ return null;
486
+ }
487
+ }, [setLoading, setError, setProgress, setIfcDataStore, setGeometryResult, storeAddModel, hasModels, registerModelOffset]);
488
+
489
+ /**
490
+ * Remove a model from the federation
491
+ */
492
+ const removeModel = useCallback((modelId: string) => {
493
+ storeRemoveModel(modelId);
494
+
495
+ // Read fresh state from store after removal to avoid stale closure
496
+ const freshModels = useViewerStore.getState().models;
497
+ const remaining = Array.from(freshModels.values()) as FederatedModel[];
498
+ if (remaining.length > 0) {
499
+ const newActive = remaining[0];
500
+ setIfcDataStore(newActive.ifcDataStore);
501
+ setGeometryResult(newActive.geometryResult);
502
+ } else {
503
+ setIfcDataStore(null);
504
+ setGeometryResult(null);
505
+ }
506
+ }, [storeRemoveModel, setIfcDataStore, setGeometryResult]);
507
+
508
+ /**
509
+ * Get query instance for a specific model
510
+ */
511
+ const getQueryForModel = useCallback((modelId: string): IfcQuery | null => {
512
+ const model = getModel(modelId);
513
+ if (!model) return null;
514
+ return new IfcQuery(model.ifcDataStore);
515
+ }, [getModel]);
516
+
517
+ /**
518
+ * Load multiple files sequentially (WASM parser isn't thread-safe)
519
+ * Each file fully loads before the next one starts
520
+ */
521
+ const loadFilesSequentially = useCallback(async (files: File[]): Promise<void> => {
522
+ for (const file of files) {
523
+ await addModel(file);
524
+ }
525
+ }, [addModel]);
526
+
527
+ /**
528
+ * Load multiple IFCX files as federated layers
529
+ * Uses IFC5's layer composition system where later files override earlier ones.
530
+ * Properties from overlay files are merged with the base file(s).
531
+ *
532
+ * @param files - Array of IFCX files (first = base/weakest, last = strongest overlay)
533
+ *
534
+ * @example
535
+ * ```typescript
536
+ * // Load base model with property overlay
537
+ * await loadFederatedIfcx([
538
+ * baseFile, // hello-wall.ifcx
539
+ * fireRatingFile, // add-fire-rating.ifcx (adds FireRating property)
540
+ * ]);
541
+ * ```
542
+ */
543
+ /**
544
+ * Internal: Load federated IFCX from buffers (used by both initial load and add overlay)
545
+ */
546
+ const loadFederatedIfcxFromBuffers = useCallback(async (
547
+ buffers: Array<{ buffer: ArrayBuffer; name: string }>,
548
+ options: { resetState?: boolean } = {}
549
+ ): Promise<void> => {
550
+ const { resetViewerState, clearAllModels } = useViewerStore.getState();
551
+
552
+ try {
553
+ // Always reset viewer state when geometry changes (selection, hidden entities, etc.)
554
+ // This ensures 3D highlighting works correctly after re-composition
555
+ resetViewerState();
556
+
557
+ // Clear legacy geometry BEFORE clearing models to prevent stale fallback
558
+ // This avoids a race condition where mergedGeometryResult uses old geometry
559
+ // during the brief moment when storeModels.size === 0
560
+ setGeometryResult(null);
561
+ clearAllModels();
562
+
563
+ setLoading(true);
564
+ setError(null);
565
+ setProgress({ phase: 'Parsing federated IFCX', percent: 0 });
566
+
567
+ // Parse federated IFCX files
568
+ const result = await parseFederatedIfcx(buffers, {
569
+ onProgress: (prog: { phase: string; percent: number }) => {
570
+ setProgress({ phase: `IFCX ${prog.phase}`, percent: prog.percent });
571
+ },
572
+ });
573
+
574
+ // Convert IFCX meshes to viewer format
575
+ const meshes: MeshData[] = convertIfcxMeshes(result.meshes);
576
+
577
+ // Calculate bounds
578
+ const { bounds, stats } = calculateMeshBounds(meshes);
579
+ const coordinateInfo = createCoordinateInfo(bounds);
580
+
581
+ const geometryResult = {
582
+ meshes,
583
+ totalVertices: stats.totalVertices,
584
+ totalTriangles: stats.totalTriangles,
585
+ coordinateInfo,
586
+ };
587
+
588
+ // NOTE: Do NOT call setGeometryResult() here!
589
+ // For federated loading, geometry comes from the models Map via mergedGeometryResult.
590
+ // Calling setGeometryResult() before models are added causes a race condition where
591
+ // meshes are added to the scene WITHOUT modelIndex, breaking selection highlighting.
592
+
593
+ // Get layer info with mesh counts
594
+ const layers = result.layerStack.getLayers();
595
+
596
+ // Create data store from federated result
597
+ const dataStore = {
598
+ fileSize: result.fileSize,
599
+ schemaVersion: 'IFC5' as const,
600
+ entityCount: result.entityCount,
601
+ parseTime: result.parseTime,
602
+ source: new Uint8Array(buffers[0].buffer),
603
+ entityIndex: {
604
+ byId: new Map(),
605
+ byType: new Map(),
606
+ },
607
+ strings: result.strings,
608
+ entities: result.entities,
609
+ properties: result.properties,
610
+ quantities: result.quantities,
611
+ relationships: result.relationships,
612
+ spatialHierarchy: result.spatialHierarchy,
613
+ // Federated-specific: store layer info and ORIGINAL BUFFERS for re-composition
614
+ _federatedLayers: layers.map((l: { id: string; name: string; enabled: boolean }) => ({
615
+ id: l.id,
616
+ name: l.name,
617
+ enabled: l.enabled,
618
+ })),
619
+ _federatedBuffers: buffers.map(b => ({
620
+ buffer: b.buffer.slice(0), // Clone buffer
621
+ name: b.name,
622
+ })),
623
+ _compositionStats: result.compositionStats,
624
+ } as IfcxDataStore;
625
+
626
+ // IfcxDataStore extends IfcDataStore (with schemaVersion: 'IFC5'), so this is safe
627
+ setIfcDataStore(dataStore);
628
+
629
+ // Clear existing models and add each layer as a "model" in the Models panel
630
+ // This shows users all the files that contributed to the composition
631
+ clearAllModels();
632
+
633
+ // Find max expressId for proper ID range tracking
634
+ // This is needed for resolveGlobalIdFromModels to work correctly
635
+ let maxExpressId = 0;
636
+ if (result.entities?.expressId) {
637
+ for (let i = 0; i < result.entities.count; i++) {
638
+ const id = result.entities.expressId[i];
639
+ if (id > maxExpressId) maxExpressId = id;
640
+ }
641
+ }
642
+
643
+ for (let i = 0; i < layers.length; i++) {
644
+ const layer = layers[i];
645
+ const layerBuffer = buffers.find(b => b.name === layer.name);
646
+
647
+ // Count how many meshes came from this layer
648
+ // For base layers: count meshes, for overlays: show as data-only
649
+ const isBaseLayer = i === layers.length - 1; // Last layer (weakest) is typically base
650
+
651
+ const layerModel: FederatedModel = {
652
+ id: layer.id,
653
+ name: layer.name,
654
+ ifcDataStore: dataStore, // Share the composed data store
655
+ geometryResult: isBaseLayer ? geometryResult : {
656
+ meshes: [],
657
+ totalVertices: 0,
658
+ totalTriangles: 0,
659
+ coordinateInfo,
660
+ },
661
+ visible: true,
662
+ collapsed: i > 0, // Collapse overlays by default
663
+ schemaVersion: 'IFC5',
664
+ loadedAt: Date.now() - (layers.length - i) * 100, // Stagger timestamps
665
+ fileSize: layerBuffer?.buffer.byteLength || 0,
666
+ // For base layer: set proper ID range for resolveGlobalIdFromModels
667
+ // Overlays share the same data store so they don't need their own range
668
+ idOffset: 0,
669
+ maxExpressId: isBaseLayer ? maxExpressId : 0,
670
+ // Mark overlay-only layers
671
+ _isOverlay: !isBaseLayer,
672
+ _layerIndex: i,
673
+ } as FederatedModel & { _isOverlay?: boolean; _layerIndex?: number };
674
+
675
+ storeAddModel(layerModel);
676
+ }
677
+
678
+ console.log(`[useIfc] Federated IFCX loaded: ${layers.length} layers, ${result.entityCount} entities, ${meshes.length} meshes`);
679
+ console.log(`[useIfc] Composition stats: ${result.compositionStats.inheritanceResolutions} inheritance resolutions, ${result.compositionStats.crossLayerReferences} cross-layer refs`);
680
+ console.log(`[useIfc] Layers in Models panel: ${layers.map((l: { name: string }) => l.name).join(', ')}`);
681
+
682
+ setProgress({ phase: 'Complete', percent: 100 });
683
+ setLoading(false);
684
+ } catch (err: unknown) {
685
+ console.error('[useIfc] Federated IFCX loading failed:', err);
686
+ const message = err instanceof Error ? err.message : String(err);
687
+ setError(`Federated IFCX loading failed: ${message}`);
688
+ setLoading(false);
689
+ }
690
+ }, [setLoading, setError, setProgress, setGeometryResult, setIfcDataStore, storeAddModel, clearAllModels]);
691
+
692
+ const loadFederatedIfcx = useCallback(async (files: File[]): Promise<void> => {
693
+ if (files.length === 0) {
694
+ setError('No files provided for federated loading');
695
+ return;
696
+ }
697
+
698
+ // Check that all files are IFCX format and read buffers
699
+ const buffers: Array<{ buffer: ArrayBuffer; name: string }> = [];
700
+ for (const file of files) {
701
+ const buffer = await file.arrayBuffer();
702
+ const format = detectFormat(buffer);
703
+ if (format !== 'ifcx') {
704
+ setError(`File "${file.name}" is not an IFCX file. Federated loading only supports IFCX files.`);
705
+ return;
706
+ }
707
+ buffers.push({ buffer, name: file.name });
708
+ }
709
+
710
+ await loadFederatedIfcxFromBuffers(buffers);
711
+ }, [setError, loadFederatedIfcxFromBuffers]);
712
+
713
+ /**
714
+ * Add IFCX overlay files to existing federated model
715
+ * Re-composes all layers including new overlays
716
+ * Also handles adding overlays to a single IFCX file that wasn't loaded via federated loading
717
+ */
718
+ const addIfcxOverlays = useCallback(async (files: File[]): Promise<void> => {
719
+ const currentStore = useViewerStore.getState().ifcDataStore as IfcxDataStore | null;
720
+ const currentModels = useViewerStore.getState().models;
721
+
722
+ // Get existing buffers - either from federated loading or from single file load
723
+ let existingBuffers: Array<{ buffer: ArrayBuffer; name: string }> = [];
724
+
725
+ if (currentStore?._federatedBuffers) {
726
+ // Already federated - use stored buffers
727
+ existingBuffers = currentStore._federatedBuffers as Array<{ buffer: ArrayBuffer; name: string }>;
728
+ } else if (currentStore?.source && currentStore.schemaVersion === 'IFC5') {
729
+ // Single IFCX file loaded via loadFile() - reconstruct buffer from source
730
+ // Get the model name from the models map
731
+ let modelName = 'base.ifcx';
732
+ for (const [, model] of currentModels) {
733
+ // Compare object identity (cast needed due to IFC5 schema extension)
734
+ if ((model.ifcDataStore as unknown) === currentStore || model.schemaVersion === 'IFC5') {
735
+ modelName = model.name;
736
+ break;
737
+ }
738
+ }
739
+
740
+ // Convert Uint8Array source back to ArrayBuffer
741
+ const sourceBuffer = currentStore.source.buffer.slice(
742
+ currentStore.source.byteOffset,
743
+ currentStore.source.byteOffset + currentStore.source.byteLength
744
+ ) as ArrayBuffer;
745
+
746
+ existingBuffers = [{ buffer: sourceBuffer, name: modelName }];
747
+ console.log(`[useIfc] Converting single IFCX file "${modelName}" to federated mode`);
748
+ } else {
749
+ setError('Cannot add overlays: no IFCX model loaded');
750
+ return;
751
+ }
752
+
753
+ // Read new overlay buffers
754
+ const newBuffers: Array<{ buffer: ArrayBuffer; name: string }> = [];
755
+ for (const file of files) {
756
+ const buffer = await file.arrayBuffer();
757
+ const format = detectFormat(buffer);
758
+ if (format !== 'ifcx') {
759
+ setError(`File "${file.name}" is not an IFCX file.`);
760
+ return;
761
+ }
762
+ newBuffers.push({ buffer, name: file.name });
763
+ }
764
+
765
+ // Combine: existing layers + new overlays (new overlays are strongest = first in array)
766
+ const allBuffers = [...newBuffers, ...existingBuffers];
767
+
768
+ console.log(`[useIfc] Re-composing federated IFCX with ${newBuffers.length} new overlay(s)`);
769
+ console.log(`[useIfc] Total layers: ${allBuffers.length} (${existingBuffers.length} existing + ${newBuffers.length} new)`);
770
+
771
+ await loadFederatedIfcxFromBuffers(allBuffers, { resetState: false });
772
+ }, [setError, loadFederatedIfcxFromBuffers]);
773
+
774
+ /**
775
+ * Find which model contains a given globalId
776
+ * Uses FederationRegistry for O(log N) lookup - BULLETPROOF
777
+ * Returns the modelId or null if not found
778
+ */
779
+ const findModelForEntity = useCallback((globalId: number): string | null => {
780
+ return findModelForGlobalId(globalId);
781
+ }, [findModelForGlobalId]);
782
+
783
+ /**
784
+ * Convert a globalId back to the original (modelId, expressId) pair
785
+ * Use this when you need to look up properties in the IfcDataStore
786
+ */
787
+ const resolveGlobalId = useCallback((globalId: number): { modelId: string; expressId: number } | null => {
788
+ return fromGlobalId(globalId);
789
+ }, [fromGlobalId]);
790
+
791
+ return {
792
+ addModel,
793
+ removeModel,
794
+ getQueryForModel,
795
+ loadFilesSequentially,
796
+ loadFederatedIfcx,
797
+ addIfcxOverlays,
798
+ findModelForEntity,
799
+ resolveGlobalId,
800
+ };
801
+ }
802
+
803
+ export default useIfcFederation;