@ifc-lite/viewer 1.26.0 → 1.28.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 (150) hide show
  1. package/.turbo/turbo-build.log +45 -38
  2. package/CHANGELOG.md +93 -0
  3. package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-BNRDNuUJ.js} +9 -9
  4. package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-DCwCuP7n.js} +56 -56
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
  8. package/dist/assets/deflate-DNGgs8Ur.js +1 -0
  9. package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
  10. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  11. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  12. package/dist/assets/{exporters-DSq76AVM.js → exporters-B9v81gi9.js} +1861 -1524
  13. package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
  14. package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-D-YCLS4g.js} +10 -10
  15. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  16. package/dist/assets/{ids-DiLcGTer.js → ids-CCpq-5d3.js} +952 -945
  17. package/dist/assets/ifc-lite_bg-DbgS5EUA.wasm +0 -0
  18. package/dist/assets/{index-BAH8IJVR.js → index-Bgb3_Pu_.js} +47682 -42474
  19. package/dist/assets/index-BtbXFKsX.css +1 -0
  20. package/dist/assets/index.es-CWfqZyyr.js +6866 -0
  21. package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-DGOAeUqU.js} +1 -1
  22. package/dist/assets/jspdf.es.min-XPLU2Wkq.js +19571 -0
  23. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  24. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  25. package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-1PMSCHwX.js} +1 -1
  26. package/dist/assets/{lzw-BBPPLW-0.js → lzw-C65U9lNM.js} +1 -1
  27. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  28. package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-XxXos6yI.js} +2 -2
  29. package/dist/assets/{packbits-yLSpjW-V.js → packbits-BdMWXC3m.js} +1 -1
  30. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  31. package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
  32. package/dist/assets/pdf-CRwaZf3s.js +135 -0
  33. package/dist/assets/raw-CJgQdyuZ.js +1 -0
  34. package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-0sDo3g3m.js} +3037 -2554
  35. package/dist/assets/server-client-cTCJ-853.js +719 -0
  36. package/dist/assets/{webimage-YafxjjGr.js → webimage-BtakWX7W.js} +1 -1
  37. package/dist/assets/xlsx-B1YOg2QB.js +142 -0
  38. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  39. package/dist/assets/{zstd-CkSLOiuu.js → zstd-CmwsbxmM.js} +1 -1
  40. package/dist/index.html +10 -10
  41. package/package.json +27 -23
  42. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  43. package/src/components/mcp/data.ts +6 -0
  44. package/src/components/mcp/playground-dispatcher.ts +280 -0
  45. package/src/components/mcp/playground-files.ts +33 -1
  46. package/src/components/mcp/types.ts +2 -1
  47. package/src/components/ui/combo-input.tsx +163 -0
  48. package/src/components/ui/tabs.tsx +1 -1
  49. package/src/components/viewer/CommandPalette.tsx +6 -1
  50. package/src/components/viewer/ComparePanel.tsx +420 -0
  51. package/src/components/viewer/HierarchyPanel.tsx +46 -7
  52. package/src/components/viewer/MainToolbar.tsx +19 -2
  53. package/src/components/viewer/PropertiesPanel.tsx +84 -8
  54. package/src/components/viewer/SearchInline.tsx +62 -2
  55. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  56. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  57. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  58. package/src/components/viewer/SearchModal.tsx +19 -6
  59. package/src/components/viewer/ViewerLayout.tsx +5 -0
  60. package/src/components/viewer/Viewport.tsx +18 -0
  61. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  62. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  63. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  64. package/src/components/viewer/hierarchy/types.ts +1 -0
  65. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  66. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  67. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  68. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  69. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  70. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  71. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  72. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  73. package/src/generated/mcp-catalog.json +4 -0
  74. package/src/hooks/federationLoadGate.test.ts +12 -2
  75. package/src/hooks/federationLoadGate.ts +9 -2
  76. package/src/hooks/ingest/federationAlign.ts +481 -0
  77. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  78. package/src/hooks/source-key.ts +35 -0
  79. package/src/hooks/useAlignmentLines3D.ts +1 -26
  80. package/src/hooks/useCompare.ts +0 -0
  81. package/src/hooks/useCompareOverlay.ts +119 -0
  82. package/src/hooks/useDrawingGeneration.ts +23 -1
  83. package/src/hooks/useGridLines3D.ts +140 -0
  84. package/src/hooks/useIfc.ts +1 -1
  85. package/src/hooks/useIfcCache.ts +32 -9
  86. package/src/hooks/useIfcFederation.ts +42 -810
  87. package/src/hooks/useIfcLoader.ts +361 -488
  88. package/src/hooks/useIfcServer.ts +3 -0
  89. package/src/hooks/useLens.ts +5 -1
  90. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  91. package/src/lib/compare/buildFingerprints.ts +173 -0
  92. package/src/lib/compare/describeChange.ts +0 -0
  93. package/src/lib/compare/geometricData.test.ts +54 -0
  94. package/src/lib/compare/geometricData.ts +37 -0
  95. package/src/lib/compare/overlay.test.ts +99 -0
  96. package/src/lib/compare/overlay.ts +91 -0
  97. package/src/lib/geo/cesium-placement.ts +1 -1
  98. package/src/lib/geo/reproject.ts +4 -1
  99. package/src/lib/length-unit-scale.ts +41 -0
  100. package/src/lib/lists/adapter.ts +136 -11
  101. package/src/lib/lists/export/csv.ts +47 -0
  102. package/src/lib/lists/export/index.ts +49 -0
  103. package/src/lib/lists/export/model.ts +111 -0
  104. package/src/lib/lists/export/pdf.ts +67 -0
  105. package/src/lib/lists/export/xlsx.ts +83 -0
  106. package/src/lib/lists/index.ts +2 -0
  107. package/src/lib/llm/script-edit-ops.ts +23 -0
  108. package/src/lib/llm/stream-client.ts +8 -1
  109. package/src/lib/search/filter-evaluate.test.ts +81 -0
  110. package/src/lib/search/filter-evaluate.ts +59 -87
  111. package/src/lib/search/filter-match.ts +167 -0
  112. package/src/lib/search/filter-rules.test.ts +25 -0
  113. package/src/lib/search/filter-rules.ts +75 -2
  114. package/src/lib/search/filter-schema.ts +0 -0
  115. package/src/lib/search/result-export.ts +7 -1
  116. package/src/lib/slab-edit.test.ts +72 -0
  117. package/src/lib/slab-edit.ts +159 -19
  118. package/src/sdk/adapters/export-adapter.ts +9 -4
  119. package/src/sdk/adapters/query-adapter.ts +3 -3
  120. package/src/store/globalId.ts +15 -13
  121. package/src/store/index.ts +16 -1
  122. package/src/store/slices/cesiumSlice.ts +8 -1
  123. package/src/store/slices/compareSlice.ts +96 -0
  124. package/src/store/slices/lensSlice.ts +8 -0
  125. package/src/store/slices/listSlice.ts +6 -0
  126. package/src/store/slices/mutationSlice.ts +14 -6
  127. package/src/store/slices/searchSlice.ts +29 -3
  128. package/src/utils/acquireFileBuffer.test.ts +12 -4
  129. package/src/utils/desktopModelSnapshot.ts +2 -1
  130. package/src/utils/loadingUtils.ts +32 -0
  131. package/src/utils/nativeSpatialDataStore.ts +6 -0
  132. package/src/utils/serverDataModel.test.ts +6 -0
  133. package/src/utils/serverDataModel.ts +7 -0
  134. package/src/utils/spatialHierarchy.test.ts +53 -1
  135. package/src/utils/spatialHierarchy.ts +42 -2
  136. package/src/vite-env.d.ts +2 -0
  137. package/dist/assets/deflate-Cnx0il6E.js +0 -1
  138. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  139. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  140. package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
  141. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  142. package/dist/assets/index-B9Ug2EqU.css +0 -1
  143. package/dist/assets/lens-PYsLu_MA.js +0 -1
  144. package/dist/assets/parser.worker-8md211IW.js +0 -182
  145. package/dist/assets/raw-BQrAgxwT.js +0 -1
  146. package/dist/assets/server-client-Bk4c1CPO.js +0 -626
  147. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  148. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  149. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  150. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
@@ -13,7 +13,7 @@
13
13
  import { useCallback, useRef } from 'react';
14
14
  import { flushSync } from 'react-dom';
15
15
  import { useShallow } from 'zustand/react/shallow';
16
- import { getViewerStoreApi, useViewerStore } from '@/store';
16
+ import { getViewerStoreApi, useViewerStore, type FederatedModel } from '@/store';
17
17
  import { IfcParser, detectFormat, type IfcDataStore } from '@ifc-lite/parser';
18
18
  import { WorkerParser } from '@ifc-lite/parser/browser';
19
19
  import { memoryAccounting } from '../lib/perf/memoryAccounting.js';
@@ -23,10 +23,11 @@ import {
23
23
  getGeometryStreamWatchdogMs as getGeometryStreamWatchdogMsImpl,
24
24
  type MeshData,
25
25
  type CoordinateInfo,
26
+ type GeometryResult,
26
27
  } from '@ifc-lite/geometry';
27
28
  import { acquireFileBuffer, type AcquiredBuffer } from '../utils/acquireFileBuffer.js';
28
29
  import initIfcLiteWasm, { IfcAPI } from '@ifc-lite/wasm';
29
- import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
30
+ import { buildSpatialIndexGuarded, buildSpatialIndexForModel } from '../utils/loadingUtils.js';
30
31
  import { type GeometryData } from '@ifc-lite/cache';
31
32
 
32
33
  import { SERVER_URL, USE_SERVER, CACHE_SIZE_THRESHOLD, CACHE_MAX_SOURCE_SIZE, HUGE_NATIVE_FILE_THRESHOLD, getDynamicBatchConfig } from '../utils/ifcConfig.js';
@@ -58,6 +59,35 @@ import { getMaxExpressId, parseGlbViewerModel, parseIfcxViewerModel } from './in
58
59
  import { boundedIteratorReturn } from './ingest/streamCleanup.js';
59
60
  import { detectPointCloudFormat, ingestPointCloud } from './ingest/pointCloudIngest.js';
60
61
  import { getGlobalRenderer } from './useBCF.js';
62
+ import { extractModelGeoref, alignGeometryToReference, findReferenceGeorefModel } from './ingest/federationAlign.js';
63
+ import { toast } from '../components/ui/toast.js';
64
+
65
+ /**
66
+ * Where a {@link useIfcLoader.loadFile} call should land the model.
67
+ *
68
+ * `primary` is the historical single-model load: it resets all viewer state,
69
+ * clears the model map, and streams progressively into the active slot.
70
+ * `federated` is an additional model joining an existing federation — it does
71
+ * NOT reset state, carries the pre-allocated `modelId`, and the shared RTC
72
+ * origin picked by the federation gate. Both flow through the SAME geometry
73
+ * pipeline + the SAME `finalizeModel`, so load-time behaviour can never again
74
+ * diverge between the two (the cause of the model-diff "all geometry changed"
75
+ * bug). The georef anchor + the user's saved georef edits are resolved inside
76
+ * `finalizeModel` from the live store, exactly as the old federated path did.
77
+ * Default is `primary`.
78
+ */
79
+ export type LoadTarget =
80
+ | { kind: 'primary' }
81
+ | {
82
+ kind: 'federated';
83
+ modelId: string;
84
+ name?: string;
85
+ visible?: boolean;
86
+ collapsed?: boolean;
87
+ loadedAt?: number;
88
+ /** Shared RTC offset from the earliest existing model (IFC Z-up). */
89
+ sharedRtcOffset?: { x: number; y: number; z: number };
90
+ };
61
91
 
62
92
  /**
63
93
  * Compute a fast content fingerprint from the first and last 4KB of a buffer.
@@ -160,13 +190,6 @@ async function getMetadataScanApi(): Promise<IfcAPI> {
160
190
 
161
191
  const ENABLE_HUGE_TIME_FLUSH = import.meta.env.VITE_IFC_ENABLE_HUGE_TIME_FLUSH === 'true';
162
192
 
163
- async function* startDisabledNativeDesktopRendererModel(
164
- _path: string,
165
- _cacheKey?: string,
166
- ): AsyncGenerator<any, void, unknown> {
167
- throw new Error('Native desktop renderer is disabled');
168
- }
169
-
170
193
  /**
171
194
  * Hook providing file loading operations for single-model path
172
195
  * Includes binary cache support for fast subsequent loads
@@ -216,78 +239,181 @@ export function useIfcLoader() {
216
239
  // Server operations from extracted hook
217
240
  const { loadFromServer } = useIfcServer();
218
241
 
219
- const loadFile = useCallback(async (file: File | NativeFileHandle) => {
242
+ const loadFile = useCallback(async (
243
+ file: File | NativeFileHandle,
244
+ target: LoadTarget = { kind: 'primary' },
245
+ ) => {
220
246
  const { resetViewerState, clearAllModels } = useViewerStore.getState();
221
- const currentSession = ++loadSessionRef.current;
222
- const primaryModelId = crypto.randomUUID();
247
+ // Only a primary (destructive, replace-everything) load bumps the session.
248
+ // Federated adds are independent and run concurrently — they capture the
249
+ // current session without invalidating each other; a subsequent primary
250
+ // load still bumps it and aborts any in-flight federated adds.
251
+ const currentSession = target.kind === 'primary'
252
+ ? ++loadSessionRef.current
253
+ : loadSessionRef.current;
254
+ // Federated adds carry a pre-allocated id; primary loads mint a fresh one.
255
+ const modelId = target.kind === 'federated' ? target.modelId : crypto.randomUUID();
223
256
 
224
257
  // Track total elapsed time for complete user experience
225
258
  const totalStartTime = performance.now();
226
259
 
227
260
  try {
228
- // Reset all viewer state before loading new file
229
- // Also clear models Map to ensure clean single-file state
230
- resetViewerState();
231
- clearAllModels();
261
+ // Reset all viewer state before loading new file — PRIMARY ONLY. A
262
+ // federated add must never wipe model #1; it joins the existing map.
263
+ if (target.kind === 'primary') {
264
+ resetViewerState();
265
+ clearAllModels();
266
+ }
232
267
 
233
268
  // Reset memory accounting so per-load summaries don't accumulate across files.
234
269
  memoryAccounting.reset();
235
270
  memoryAccounting.recordPhase({ phase: 'load-start' });
236
271
 
237
272
  setLoading(true);
238
- setGeometryStreamingActive(false);
239
273
  setError(null);
240
- setBoundedGeometryMode(false);
241
- setGeometryProgress(null);
242
- setMetadataProgress(null);
243
274
  setProgress({ phase: 'Loading file', percent: 0 });
244
275
 
245
276
  const fileName = file.name;
246
277
  const fileSize = file.size;
247
278
  const fileSizeMB = fileSize / (1024 * 1024);
248
279
 
249
- upsertModel({
250
- id: primaryModelId,
251
- name: fileName,
252
- ifcDataStore: null,
253
- geometryResult: null,
254
- visible: true,
255
- collapsed: false,
256
- schemaVersion: 'IFC4',
257
- loadedAt: Date.now(),
258
- fileSize,
259
- sourceFile: file,
260
- idOffset: 0,
261
- maxExpressId: 0,
262
- loadState: 'pending',
280
+ // PRIMARY owns the active-model slots + top-level UI/memory flags and
281
+ // creates the model record. A federated add leaves all of that untouched
282
+ // (model #1 must not be disturbed) and registers atomically at finalize
283
+ // via addModel — so it creates NO placeholder entry here (which also
284
+ // keeps the `collapsed` default counting only the other models).
285
+ if (target.kind === 'primary') {
286
+ setGeometryStreamingActive(false);
287
+ setBoundedGeometryMode(false);
288
+ setGeometryProgress(null);
289
+ setMetadataProgress(null);
290
+
291
+ upsertModel({
292
+ id: modelId,
293
+ name: fileName,
294
+ ifcDataStore: null,
295
+ geometryResult: null,
296
+ visible: true,
297
+ collapsed: false,
298
+ schemaVersion: 'IFC4',
299
+ loadedAt: Date.now(),
300
+ fileSize,
301
+ sourceFile: file,
302
+ idOffset: 0,
303
+ maxExpressId: 0,
304
+ loadState: 'pending',
263
305
  geometryLoadState: 'pending',
264
306
  metadataLoadState: 'idle',
265
307
  interactiveReady: false,
266
308
  nativeMetadata: null,
267
- cacheState: 'none',
268
- loadError: null,
269
- });
270
- updateModel(primaryModelId, {
271
- loadState: 'streaming-geometry',
272
- geometryLoadState: 'opening',
273
- metadataLoadState: 'idle',
274
- interactiveReady: false,
275
- });
309
+ cacheState: 'none',
310
+ loadError: null,
311
+ });
312
+ updateModel(modelId, {
313
+ loadState: 'streaming-geometry',
314
+ geometryLoadState: 'opening',
315
+ metadataLoadState: 'idle',
316
+ interactiveReady: false,
317
+ });
318
+ }
276
319
 
277
- const finalizePrimaryModel = (
320
+ // The ONE finalizer for every format/platform/role. Primary keeps the
321
+ // historical updateModel-only behaviour; federated runs the georef-align
322
+ // → id-offset → relabel → spatial-index → addModel sequence lifted
323
+ // verbatim from the old useIfcFederation.addModel block (same order).
324
+ const finalizeModel = async (
278
325
  dataStore: IfcDataStore | null,
279
- geometryResult: { meshes: MeshData[]; totalVertices: number; totalTriangles: number; coordinateInfo: CoordinateInfo } | null,
326
+ geometryResult: GeometryResult | null,
280
327
  schemaVersion: 'IFC2X3' | 'IFC4' | 'IFC4X3' | 'IFC5',
281
328
  patch?: { loadState?: 'pending' | 'streaming-geometry' | 'hydrating-metadata' | 'complete' | 'error'; cacheState?: 'none' | 'hit' | 'miss' | 'writing'; loadError?: string | null; pointCloudHandleId?: number },
282
- ) => {
329
+ ): Promise<void> => {
330
+ if (target.kind === 'federated') {
331
+ if (!dataStore || !geometryResult) {
332
+ throw new Error('Federated model is missing its data store or geometry');
333
+ }
334
+ // Georef alignment against the federation anchor (resolved live from
335
+ // the store, exactly as the former addModel finalize did).
336
+ const referenceGeoref = findReferenceGeorefModel()?.georef ?? null;
337
+ const parsedGeorefMutations = useViewerStore.getState().georefMutations.get(modelId);
338
+ const parsedGeoref = extractModelGeoref(dataStore, geometryResult.coordinateInfo, parsedGeorefMutations);
339
+ let preAlignmentPositions: Float32Array[] | undefined;
340
+ let preAlignmentNormals: (Float32Array | undefined)[] | undefined;
341
+ let preAlignmentCoordinateInfo: CoordinateInfo | undefined;
342
+ let federationAlignmentStatus: FederatedModel['federationAlignmentStatus'] = 'none';
343
+ if (referenceGeoref && parsedGeoref) {
344
+ setProgress({ phase: 'Aligning georeferenced model', percent: 90 });
345
+ preAlignmentPositions = geometryResult.meshes.map((mesh) => new Float32Array(mesh.positions));
346
+ preAlignmentNormals = geometryResult.meshes.map((mesh) =>
347
+ mesh.normals && mesh.normals.length > 0 ? new Float32Array(mesh.normals) : undefined,
348
+ );
349
+ preAlignmentCoordinateInfo = geometryResult.coordinateInfo;
350
+ const status = await alignGeometryToReference(geometryResult, parsedGeoref, referenceGeoref);
351
+ federationAlignmentStatus = status;
352
+ if (status === 'reprojected') {
353
+ toast.info(
354
+ `Reprojected "${file.name}" from ${parsedGeoref.projectedCRS.name} `
355
+ + `to ${referenceGeoref.projectedCRS.name} for federation alignment.`,
356
+ );
357
+ } else if (status === 'failed') {
358
+ toast.error(
359
+ `Could not align "${file.name}" with the federation anchor — `
360
+ + `${parsedGeoref.projectedCRS.name} → ${referenceGeoref.projectedCRS.name} `
361
+ + 'reprojection failed. The model is shown in its own local frame and may '
362
+ + 'appear at the wrong real-world position.',
363
+ );
364
+ }
365
+ } else if (parsedGeoref) {
366
+ federationAlignmentStatus = 'anchor';
367
+ }
368
+
369
+ // Federation registry: transform expressIds to globally-unique ids.
370
+ const maxExpressId = getMaxExpressId(dataStore, geometryResult.meshes);
371
+ const idOffset = registerModelOffset(modelId, maxExpressId);
372
+ if (idOffset > 0) {
373
+ for (const mesh of geometryResult.meshes) mesh.expressId = mesh.expressId + idOffset;
374
+ for (const asset of geometryResult.pointClouds ?? []) asset.expressId = asset.expressId + idOffset;
375
+ }
376
+ if (idOffset > 0 && patch?.pointCloudHandleId !== undefined) {
377
+ const renderer = getGlobalRenderer();
378
+ if (renderer && geometryResult.pointClouds && geometryResult.pointClouds.length > 0) {
379
+ renderer.relabelPointCloudAsset({ id: patch.pointCloudHandleId }, geometryResult.pointClouds[0].expressId);
380
+ }
381
+ }
382
+ const federatedModel: FederatedModel = {
383
+ id: modelId,
384
+ name: target.name ?? file.name,
385
+ ifcDataStore: dataStore,
386
+ geometryResult,
387
+ visible: target.visible ?? true,
388
+ collapsed: target.collapsed ?? (useViewerStore.getState().models.size > 0),
389
+ schemaVersion,
390
+ loadedAt: target.loadedAt ?? Date.now(),
391
+ fileSize: buffer.byteLength,
392
+ sourceFile: file,
393
+ idOffset,
394
+ maxExpressId,
395
+ pointCloudHandleId: patch?.pointCloudHandleId,
396
+ preAlignmentPositions,
397
+ preAlignmentNormals,
398
+ preAlignmentCoordinateInfo,
399
+ federationAlignmentStatus,
400
+ };
401
+ useViewerStore.getState().addModel(federatedModel);
402
+ // Spatial index AFTER id offset + alignment (final ids + world positions)
403
+ // and AFTER addModel so it attaches to THIS model, not the active slot.
404
+ buildSpatialIndexForModel(geometryResult.meshes, modelId, dataStore);
405
+ return;
406
+ }
407
+
408
+ // PRIMARY — unchanged from the former finalizePrimaryModel.
283
409
  let idOffset = 0;
284
410
  let maxExpressId = 0;
285
411
  if (dataStore && geometryResult) {
286
412
  maxExpressId = getMaxExpressId(dataStore, geometryResult.meshes);
287
- idOffset = registerModelOffset(primaryModelId, maxExpressId);
413
+ idOffset = registerModelOffset(modelId, maxExpressId);
288
414
  }
289
415
 
290
- updateModel(primaryModelId, {
416
+ updateModel(modelId, {
291
417
  ifcDataStore: dataStore,
292
418
  geometryResult,
293
419
  schemaVersion,
@@ -307,367 +433,17 @@ export function useIfcLoader() {
307
433
  return 'IFC2X3';
308
434
  };
309
435
 
310
- // Native renderer streaming path is currently disabled — the
311
- // `huge native file` block further down handles real desktop
312
- // streaming. This branch is retained as a scaffold for the future
313
- // always-on native renderer integration.
314
- const NATIVE_RENDERER_PATH_ENABLED = false as boolean;
315
- if (
316
- NATIVE_RENDERER_PATH_ENABLED &&
317
- isNativeFileHandle(file) &&
318
- fileName.toLowerCase().endsWith('.ifc')
319
- ) {
320
- // Re-narrow `file` for the body — TS occasionally drops the
321
- // type-predicate result inside a dead branch.
322
- const nativeFile: NativeFileHandle = file;
323
- const harnessRequest = getActiveHarnessRequest();
324
- const nativeCacheKey = computeNativeCacheKey(nativeFile);
325
- const shouldUseNativeCache = nativeFile.size >= CACHE_SIZE_THRESHOLD;
326
- const hugeNativeMode = nativeFile.size >= HUGE_NATIVE_FILE_THRESHOLD;
327
- let firstBatchWaitMs: number | null = null;
328
- let firstVisibleGeometryMs: number | null = null;
329
- let modelOpenMs: number | null = null;
330
- let streamCompleteMs: number | null = null;
331
- let batchCount = 0;
332
- let totalMeshes = 0;
333
- let spatialReadyMs: number | null = null;
334
- let metadataStartMs: number | null = null;
335
- let metadataReadCompleteMs: number | null = null;
336
- let metadataParseStartMs: number | null = null;
337
- let metadataCompleteMs: number | null = null;
338
- let metadataFailedMs: number | null = null;
339
- let metadataReadDurationMs: number | null = null;
340
- let metadataBufferCopyDurationMs: number | null = null;
341
- let metadataParseDurationMs: number | null = null;
342
- let metadataSnapshotWritePromise: Promise<void> | null = null;
343
- let metadataParsingPromise: Promise<void> | null = null;
344
- let metadataParsingStarted = false;
345
- let geometryCompleted = false;
346
- let nativeGeometryCacheHit = false;
347
- let nativeMetadataSnapshotHit = false;
348
- let nativeMetadataSource: 'snapshot' | 'ifc-parse' = 'ifc-parse';
349
- let nativeMetadataStartGate = 'immediate' as 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete';
350
- let finalCoordinateInfo: CoordinateInfo | null = null;
351
-
352
- console.log(`[useIfc] Native renderer load: ${fileName}, size: ${fileSizeMB.toFixed(2)}MB`);
353
- void logToDesktopTerminal(
354
- 'info',
355
- `[useIfc] Native renderer load start: ${fileName} (${fileSizeMB.toFixed(2)} MB) path=${file.path}`
356
- );
357
-
358
- setBoundedGeometryMode(true);
359
- setGeometryResult(null);
360
- setIfcDataStore(null);
361
- setProgress({ phase: 'Starting native renderer', percent: 10 });
362
-
363
- const queueNativeMetadataSnapshotWrite = (
364
- dataStore: IfcDataStore,
365
- sourceBuffer: ArrayBuffer,
366
- ) => {
367
- metadataSnapshotWritePromise = (async () => {
368
- await yieldToUiThread();
369
- if (typeof requestAnimationFrame === 'function') {
370
- await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
371
- }
372
- if (!shouldUseNativeCache) return;
373
- try {
374
- const { setNativeModelSnapshot } = await import('../services/desktop-cache.js');
375
- const snapshotBuffer = await buildDesktopMetadataSnapshot(dataStore, sourceBuffer);
376
- await setNativeModelSnapshot(nativeCacheKey, snapshotBuffer);
377
- } catch (error) {
378
- void logToDesktopTerminal(
379
- 'warn',
380
- `[useIfc] Native metadata snapshot write failed for ${fileName}: ${error instanceof Error ? error.message : String(error)}`
381
- );
382
- }
383
- })();
384
- };
385
-
386
- const finalizeNativeMetadata = (dataStore: IfcDataStore) => {
387
- if (dataStore.spatialHierarchy && dataStore.spatialHierarchy.storeyHeights.size === 0 && dataStore.spatialHierarchy.storeyElevations.size > 1) {
388
- const calculatedHeights = calculateStoreyHeights(dataStore.spatialHierarchy.storeyElevations);
389
- for (const [storeyId, height] of calculatedHeights) {
390
- dataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
391
- }
392
- }
393
- setIfcDataStore(dataStore);
394
- finalizePrimaryModel(
395
- dataStore,
396
- null,
397
- getSchemaVersion(dataStore),
398
- {
399
- loadState: geometryCompleted ? 'complete' : 'hydrating-metadata',
400
- cacheState: nativeGeometryCacheHit ? 'hit' : shouldUseNativeCache ? 'writing' : 'none',
401
- },
402
- );
403
- };
404
-
405
- const startNativeMetadataParsing = (): Promise<void> | null => {
406
- if (metadataParsingStarted) return metadataParsingPromise;
407
- metadataParsingStarted = true;
408
- metadataStartMs = performance.now() - totalStartTime;
409
- updateModel(primaryModelId, { loadState: 'hydrating-metadata' });
410
- void logToDesktopTerminal(
411
- 'info',
412
- `[useIfc] Native metadata parse start for ${fileName} source=${nativeMetadataSource} gate=${nativeMetadataStartGate}`
413
- );
414
-
415
- metadataParsingPromise = (async () => {
416
- const metadataReadStart = performance.now();
417
- let parseStart = 0;
418
-
419
- if (nativeMetadataSnapshotHit) {
420
- try {
421
- const { getNativeModelSnapshot } = await import('../services/desktop-cache.js');
422
- const snapshotBuffer = await getNativeModelSnapshot(nativeCacheKey);
423
- if (snapshotBuffer) {
424
- metadataReadCompleteMs = performance.now() - totalStartTime;
425
- metadataReadDurationMs = performance.now() - metadataReadStart;
426
- metadataParseStartMs = performance.now() - totalStartTime;
427
- parseStart = performance.now();
428
- const dataStore = await restoreDesktopMetadataSnapshot(snapshotBuffer);
429
- if (spatialReadyMs === null) {
430
- spatialReadyMs = performance.now() - totalStartTime;
431
- }
432
- metadataCompleteMs = performance.now() - totalStartTime;
433
- metadataParseDurationMs = performance.now() - parseStart;
434
- finalizeNativeMetadata(dataStore);
435
- return;
436
- }
437
- } catch (error) {
438
- nativeMetadataSnapshotHit = false;
439
- nativeMetadataSource = 'ifc-parse';
440
- void logToDesktopTerminal(
441
- 'warn',
442
- `[useIfc] Native metadata snapshot hydration failed for ${fileName}, falling back to IFC parse: ${error instanceof Error ? error.message : String(error)}`
443
- );
444
- }
445
- }
446
-
447
- const bytes = await readNativeFile(file.path);
448
- if (loadSessionRef.current !== currentSession) return;
449
- metadataReadCompleteMs = performance.now() - totalStartTime;
450
- metadataReadDurationMs = performance.now() - metadataReadStart;
451
- const copyStart = performance.now();
452
- const metadataBuffer = toExactArrayBuffer(bytes);
453
- metadataBufferCopyDurationMs = performance.now() - copyStart;
454
- metadataParseStartMs = performance.now() - totalStartTime;
455
- parseStart = performance.now();
456
- const parser = new IfcParser();
457
- const wasmApi = hugeNativeMode ? await getMetadataScanApi() : undefined;
458
- const dataStore = await parser.parseColumnar(metadataBuffer, {
459
- wasmApi,
460
- yieldIntervalMs: hugeNativeMode ? 32 : undefined,
461
- deferPropertyAtomIndex: hugeNativeMode,
462
- disableWorkerScan: false,
463
- onSpatialReady: (partialStore) => {
464
- if (loadSessionRef.current !== currentSession) return;
465
- if (spatialReadyMs === null) {
466
- spatialReadyMs = performance.now() - totalStartTime;
467
- }
468
- setIfcDataStore(partialStore);
469
- },
470
- });
471
- queueNativeMetadataSnapshotWrite(dataStore, metadataBuffer);
472
- metadataCompleteMs = performance.now() - totalStartTime;
473
- metadataParseDurationMs = performance.now() - parseStart;
474
- finalizeNativeMetadata(dataStore);
475
- })().catch((error) => {
476
- if (loadSessionRef.current !== currentSession) return;
477
- metadataFailedMs = performance.now() - totalStartTime;
478
- updateModel(primaryModelId, {
479
- loadState: 'error',
480
- loadError: error instanceof Error ? error.message : String(error),
481
- });
482
- void logToDesktopTerminal(
483
- 'warn',
484
- `[useIfc] Native metadata parse failed for ${fileName}: ${error instanceof Error ? error.message : String(error)}`
485
- );
486
- });
487
-
488
- return metadataParsingPromise;
489
- };
490
-
491
- if (shouldUseNativeCache) {
492
- const { hasNativeGeometryCache, hasNativeModelSnapshot } = await import('../services/desktop-cache.js');
493
- setProgress({ phase: 'Checking native cache', percent: 5 });
494
- nativeGeometryCacheHit = await hasNativeGeometryCache(nativeCacheKey);
495
- nativeMetadataSnapshotHit = nativeGeometryCacheHit ? await hasNativeModelSnapshot(nativeCacheKey) : false;
496
- nativeMetadataSource = nativeGeometryCacheHit && nativeMetadataSnapshotHit ? 'snapshot' : 'ifc-parse';
497
- nativeMetadataStartGate = 'immediate';
498
- updateModel(primaryModelId, { cacheState: nativeGeometryCacheHit ? 'hit' : 'miss' });
499
- }
500
-
501
- if (nativeMetadataStartGate === 'immediate') {
502
- startNativeMetadataParsing();
503
- } else {
504
- void logToDesktopTerminal(
505
- 'info',
506
- `[useIfc] Deferring native metadata to ${nativeMetadataStartGate} for ${fileName}`
507
- );
508
- }
509
-
510
- const nativeStream = await startDisabledNativeDesktopRendererModel(
511
- file.path,
512
- shouldUseNativeCache ? nativeCacheKey : undefined,
513
- );
514
-
515
- for await (const event of nativeStream) {
516
- switch (event.type) {
517
- case 'sessionReady':
518
- void logToDesktopTerminal(
519
- 'info',
520
- event.cacheHit
521
- ? `[useIfc] Native renderer cache hit for ${fileName}`
522
- : `[useIfc] Native renderer cold load for ${fileName}`
523
- );
524
- break;
525
- case 'modelOpen':
526
- modelOpenMs = performance.now() - totalStartTime;
527
- setProgress({ phase: 'Streaming geometry into native renderer', percent: 35 });
528
- break;
529
- case 'batch':
530
- batchCount = event.batchCount;
531
- totalMeshes = event.totalMeshes;
532
- if (firstBatchWaitMs === null) {
533
- firstBatchWaitMs = performance.now() - totalStartTime;
534
- }
535
- setProgress({
536
- phase: `Uploading native geometry (${(event.totalMeshes ?? 0).toLocaleString()} meshes)`,
537
- percent: Math.min(85, 35 + Math.log10(Math.max(10, event.totalMeshes ?? 0)) * 12),
538
- });
539
- break;
540
- case 'firstFrame':
541
- firstVisibleGeometryMs = performance.now() - totalStartTime;
542
- if (nativeMetadataStartGate === 'afterInteractiveGeometry' && !metadataParsingStarted) {
543
- startNativeMetadataParsing();
544
- }
545
- break;
546
- case 'complete':
547
- geometryCompleted = true;
548
- streamCompleteMs = performance.now() - totalStartTime;
549
- totalMeshes = event.totalMeshes;
550
- finalCoordinateInfo = event.coordinateInfo;
551
- updateCoordinateInfo(event.coordinateInfo);
552
- if (nativeMetadataStartGate === 'afterGeometryComplete' && !metadataParsingStarted) {
553
- startNativeMetadataParsing();
554
- }
555
- updateModel(primaryModelId, {
556
- loadState: metadataParsingStarted ? 'hydrating-metadata' : 'complete',
557
- cacheState: nativeGeometryCacheHit ? 'hit' : shouldUseNativeCache ? 'writing' : 'none',
558
- });
559
- setProgress({
560
- phase: metadataParsingStarted ? 'Geometry ready, hydrating metadata' : 'Native geometry ready',
561
- percent: metadataParsingStarted ? 92 : 100,
562
- });
563
- break;
564
- case 'error':
565
- throw new Error(event.message);
566
- }
567
- }
568
-
569
- if (harnessRequest?.waitForMetadataCompletion) {
570
- if (!metadataParsingStarted) {
571
- startNativeMetadataParsing();
572
- }
573
- if (metadataParsingPromise) {
574
- await metadataParsingPromise;
575
- }
576
- if (metadataSnapshotWritePromise) {
577
- await metadataSnapshotWritePromise;
578
- }
579
- }
580
-
581
- if (firstVisibleGeometryMs === null && streamCompleteMs !== null) {
582
- firstVisibleGeometryMs = streamCompleteMs;
583
- }
584
-
585
- if (!metadataParsingStarted) {
586
- setLoading(false);
587
- } else if (!harnessRequest?.waitForMetadataCompletion) {
588
- setLoading(false);
589
- }
590
-
591
- await finalizeActiveHarnessRun({
592
- schemaVersion: 1,
593
- source: 'desktop-native',
594
- mode: harnessRequest ? 'startup-harness' : 'manual',
595
- success: true,
596
- runLabel: harnessRequest?.runLabel,
597
- cache: {
598
- key: nativeCacheKey,
599
- hit: nativeGeometryCacheHit,
600
- manifestMeshCount: null,
601
- manifestShardCount: null,
602
- },
603
- file: {
604
- path: file.path,
605
- name: file.name,
606
- sizeBytes: file.size,
607
- sizeMB: fileSizeMB,
608
- },
609
- timings: {
610
- modelOpenMs,
611
- firstBatchWaitMs,
612
- firstAppendGeometryBatchMs: null,
613
- firstVisibleGeometryMs,
614
- streamCompleteMs,
615
- totalWallClockMs: performance.now() - totalStartTime,
616
- metadataStartMs,
617
- metadataReadCompleteMs,
618
- metadataParseStartMs,
619
- spatialReadyMs,
620
- metadataCompleteMs,
621
- metadataFailedMs,
622
- metadataReadDurationMs,
623
- metadataBufferCopyDurationMs,
624
- metadataParseDurationMs,
625
- nativeRendererFirstFrameMs: firstVisibleGeometryMs,
626
- },
627
- batches: {
628
- estimatedTotal: shouldUseNativeCache ? totalMeshes : null,
629
- totalBatches: batchCount,
630
- totalMeshes,
631
- firstBatchMeshes: null,
632
- firstPayloadKind: 'native-renderer',
633
- },
634
- nativeStats: finalCoordinateInfo
635
- ? {
636
- parseTimeMs: null,
637
- entityScanTimeMs: null,
638
- lookupTimeMs: null,
639
- preprocessTimeMs: null,
640
- geometryTimeMs: streamCompleteMs,
641
- totalTimeMs: streamCompleteMs,
642
- firstChunkReadyTimeMs: firstBatchWaitMs,
643
- firstChunkPackTimeMs: null,
644
- firstChunkEmittedTimeMs: null,
645
- firstChunkEmitTimeMs: null,
646
- }
647
- : null,
648
- metadata: {
649
- started: metadataParsingStarted,
650
- metadataStartMs,
651
- metadataReadCompleteMs,
652
- metadataParseStartMs,
653
- spatialReadyMs,
654
- metadataCompleteMs,
655
- metadataFailedMs,
656
- metadataReadDurationMs,
657
- metadataBufferCopyDurationMs,
658
- metadataParseDurationMs,
659
- },
660
- firstBatchTelemetry: null,
661
- });
662
-
663
- return;
664
- }
665
436
 
666
437
  // Desktop native streaming path is reserved for truly large IFC files.
667
438
  // Mid-size files are more stable on the shared WASM/web loader and still
668
439
  // provide full viewer parity without the native streaming complexity.
440
+ // PRIMARY only: the native path paints the active slot and isn't target-
441
+ // aware, so a federated huge .ifc routes through the awaited WASM stream
442
+ // (which gates active-model writes) instead — matching the former
443
+ // federated path, which always used the WASM ingest regardless of size.
669
444
  if (
670
- isNativeFileHandle(file)
445
+ target.kind === 'primary'
446
+ && isNativeFileHandle(file)
671
447
  && fileName.toLowerCase().endsWith('.ifc')
672
448
  && file.size >= HUGE_NATIVE_FILE_THRESHOLD
673
449
  ) {
@@ -819,7 +595,7 @@ export function useIfcLoader() {
819
595
  const stateAfterAppend = useViewerStore.getState();
820
596
  void logToDesktopTerminal(
821
597
  'info',
822
- `[useIfc] Store after append for ${fileName}: activeModelId=${stateAfterAppend.activeModelId ?? 'null'} legacyMeshes=${stateAfterAppend.geometryResult?.meshes.length ?? 0} modelMeshes=${stateAfterAppend.models.get(primaryModelId)?.geometryResult?.meshes.length ?? 0} geometryTick=${stateAfterAppend.geometryUpdateTick}`
598
+ `[useIfc] Store after append for ${fileName}: activeModelId=${stateAfterAppend.activeModelId ?? 'null'} legacyMeshes=${stateAfterAppend.geometryResult?.meshes.length ?? 0} modelMeshes=${stateAfterAppend.models.get(modelId)?.geometryResult?.meshes.length ?? 0} geometryTick=${stateAfterAppend.geometryUpdateTick}`
823
599
  );
824
600
  loggedFirstAppendStoreState = true;
825
601
  }
@@ -843,7 +619,7 @@ export function useIfcLoader() {
843
619
  const stateAfterAppend = useViewerStore.getState();
844
620
  void logToDesktopTerminal(
845
621
  'info',
846
- `[useIfc] Store after append for ${fileName}: activeModelId=${stateAfterAppend.activeModelId ?? 'null'} legacyMeshes=${stateAfterAppend.geometryResult?.meshes.length ?? 0} modelMeshes=${stateAfterAppend.models.get(primaryModelId)?.geometryResult?.meshes.length ?? 0} geometryTick=${stateAfterAppend.geometryUpdateTick}`
622
+ `[useIfc] Store after append for ${fileName}: activeModelId=${stateAfterAppend.activeModelId ?? 'null'} legacyMeshes=${stateAfterAppend.geometryResult?.meshes.length ?? 0} modelMeshes=${stateAfterAppend.models.get(modelId)?.geometryResult?.meshes.length ?? 0} geometryTick=${stateAfterAppend.geometryUpdateTick}`
847
623
  );
848
624
  loggedFirstAppendStoreState = true;
849
625
  }
@@ -891,7 +667,7 @@ export function useIfcLoader() {
891
667
  if (geometryCompleted) {
892
668
  nativeLoadStage = 'complete';
893
669
  }
894
- finalizePrimaryModel(
670
+ void finalizeModel(
895
671
  dataStore,
896
672
  useViewerStore.getState().geometryResult,
897
673
  getSchemaVersion(dataStore),
@@ -900,7 +676,7 @@ export function useIfcLoader() {
900
676
  cacheState: nativeGeometryCacheHit ? 'hit' : shouldUseNativeCache ? 'writing' : 'none',
901
677
  },
902
678
  );
903
- updateModel(primaryModelId, {
679
+ updateModel(modelId, {
904
680
  geometryLoadState: geometryCompleted ? 'complete' : 'interactive',
905
681
  metadataLoadState: 'complete',
906
682
  interactiveReady: true,
@@ -923,10 +699,10 @@ export function useIfcLoader() {
923
699
  }
924
700
  const state = useViewerStore.getState();
925
701
  const currentGeometryResult =
926
- state.models.get(primaryModelId)?.geometryResult ??
702
+ state.models.get(modelId)?.geometryResult ??
927
703
  state.geometryResult;
928
704
  setIfcDataStore(spatialDataStore);
929
- finalizePrimaryModel(
705
+ void finalizeModel(
930
706
  spatialDataStore,
931
707
  currentGeometryResult,
932
708
  nativeMetadata.schemaVersion,
@@ -1015,7 +791,7 @@ export function useIfcLoader() {
1015
791
  let lastMetadataProgressPercent = -1;
1016
792
  startMetadataStallWatch();
1017
793
  setMetadataProgress({ phase: 'Bootstrapping metadata', percent: 5, indeterminate: hugeNativeMode });
1018
- updateModel(primaryModelId, {
794
+ updateModel(modelId, {
1019
795
  loadState: 'hydrating-metadata',
1020
796
  metadataLoadState: 'bootstrapping',
1021
797
  });
@@ -1037,7 +813,7 @@ export function useIfcLoader() {
1037
813
  try {
1038
814
  spatialReadyMs = performance.now() - totalStartTime;
1039
815
  hydrateNativeSpatialDataStore(restoredSnapshot);
1040
- updateModel(primaryModelId, {
816
+ updateModel(modelId, {
1041
817
  nativeMetadata: restoredSnapshot,
1042
818
  schemaVersion: restoredSnapshot.schemaVersion,
1043
819
  metadataLoadState: 'spatial-ready',
@@ -1075,7 +851,7 @@ export function useIfcLoader() {
1075
851
  `[useIfc] Applying native metadata to store for ${fileName}`
1076
852
  );
1077
853
  hydrateNativeSpatialDataStore(nativeMetadata);
1078
- updateModel(primaryModelId, {
854
+ updateModel(modelId, {
1079
855
  nativeMetadata,
1080
856
  schemaVersion: nativeMetadata.schemaVersion,
1081
857
  metadataLoadState: 'spatial-ready',
@@ -1091,7 +867,7 @@ export function useIfcLoader() {
1091
867
  }
1092
868
  metadataCompleteMs = performance.now() - totalStartTime;
1093
869
  metadataParseDurationMs = performance.now() - parseStartTime;
1094
- updateModel(primaryModelId, {
870
+ updateModel(modelId, {
1095
871
  loadState: geometryCompleted ? 'complete' : 'hydrating-metadata',
1096
872
  metadataLoadState: 'lazy',
1097
873
  });
@@ -1219,7 +995,7 @@ export function useIfcLoader() {
1219
995
  stopMetadataStallWatch();
1220
996
  metadataFailedMs = performance.now() - totalStartTime;
1221
997
  console.warn('[useIfc] Native metadata parsing failed:', error);
1222
- updateModel(primaryModelId, {
998
+ updateModel(modelId, {
1223
999
  loadState: 'error',
1224
1000
  metadataLoadState: 'error',
1225
1001
  loadError: error instanceof Error ? error.message : String(error),
@@ -1256,7 +1032,7 @@ export function useIfcLoader() {
1256
1032
  : false;
1257
1033
  nativeMetadataSource = nativeMetadataSnapshotHit ? 'snapshot' : 'ifc-parse';
1258
1034
  nativeMetadataStartGate = 'immediate';
1259
- updateModel(primaryModelId, { cacheState: nativeGeometryCacheHit ? 'hit' : 'miss' });
1035
+ updateModel(modelId, { cacheState: nativeGeometryCacheHit ? 'hit' : 'miss' });
1260
1036
  void logToDesktopTerminal(
1261
1037
  'info',
1262
1038
  nativeGeometryCacheHit
@@ -1319,7 +1095,7 @@ export function useIfcLoader() {
1319
1095
  firstGeometryTime = performance.now() - totalStartTime;
1320
1096
  jsFirstChunkReceivedMs = event.nativeTelemetry?.jsReceivedTimeMs ?? firstGeometryTime;
1321
1097
  firstNativeBatchTelemetry = event.nativeTelemetry ?? null;
1322
- updateModel(primaryModelId, {
1098
+ updateModel(modelId, {
1323
1099
  geometryLoadState: 'interactive',
1324
1100
  interactiveReady: true,
1325
1101
  });
@@ -1411,7 +1187,7 @@ export function useIfcLoader() {
1411
1187
  ? { phase: 'Preparing metadata', percent: nativeMetadataStartGate === 'afterGeometryComplete' ? 5 : 0, indeterminate: false }
1412
1188
  : { phase: 'Metadata complete', percent: 100 }
1413
1189
  );
1414
- updateModel(primaryModelId, {
1190
+ updateModel(modelId, {
1415
1191
  loadState: hugeNativeMode ? 'hydrating-metadata' : 'complete',
1416
1192
  geometryLoadState: 'complete',
1417
1193
  metadataLoadState: hugeNativeMode ? 'bootstrapping' : 'complete',
@@ -1621,7 +1397,7 @@ export function useIfcLoader() {
1621
1397
  const renderer = getGlobalRenderer();
1622
1398
  if (!renderer) {
1623
1399
  setError('Renderer not initialised — try again after the viewer mounts.');
1624
- updateModel(primaryModelId, { loadState: 'error', loadError: 'renderer-missing' });
1400
+ updateModel(modelId, { loadState: 'error', loadError: 'renderer-missing' });
1625
1401
  setLoading(false);
1626
1402
  return;
1627
1403
  }
@@ -1677,17 +1453,17 @@ export function useIfcLoader() {
1677
1453
  const isAbort = err instanceof DOMException && err.name === 'AbortError';
1678
1454
  if (isAbort) {
1679
1455
  console.log(
1680
- `[useIfc] pointcloud ingest cancelled (model=${primaryModelId}, handle=${ingest.rendererHandle.id})`,
1456
+ `[useIfc] pointcloud ingest cancelled (model=${modelId}, handle=${ingest.rendererHandle.id})`,
1681
1457
  );
1682
- updateModel(primaryModelId, { loadState: 'error', loadError: 'cancelled' });
1458
+ updateModel(modelId, { loadState: 'error', loadError: 'cancelled' });
1683
1459
  setError(null);
1684
1460
  setProgress({ phase: 'Cancelled', percent: 0 });
1685
1461
  } else {
1686
1462
  console.error(
1687
- `[useIfc] pointcloud ingest failed (format=${format}, model=${primaryModelId}):`,
1463
+ `[useIfc] pointcloud ingest failed (format=${format}, model=${modelId}):`,
1688
1464
  err,
1689
1465
  );
1690
- updateModel(primaryModelId, { loadState: 'error', loadError: message });
1466
+ updateModel(modelId, { loadState: 'error', loadError: message });
1691
1467
  setError(`${format.toUpperCase()} parsing failed: ${message}`);
1692
1468
  }
1693
1469
  clearOwnedCanceller();
@@ -1702,9 +1478,13 @@ export function useIfcLoader() {
1702
1478
  renderer.removePointCloudAsset(ingest.rendererHandle);
1703
1479
  return;
1704
1480
  }
1705
- setGeometryResult(ingest.geometryResult);
1706
- setIfcDataStore(ingest.dataStore);
1707
- finalizePrimaryModel(ingest.dataStore, ingest.geometryResult, ingest.schemaVersion, {
1481
+ // Primary owns the active-model slots; a federated add must not touch
1482
+ // them (finalizeModel's federated branch wires via addModel instead).
1483
+ if (target.kind === 'primary') {
1484
+ setGeometryResult(ingest.geometryResult);
1485
+ setIfcDataStore(ingest.dataStore);
1486
+ }
1487
+ await finalizeModel(ingest.dataStore, ingest.geometryResult, ingest.schemaVersion, {
1708
1488
  pointCloudHandleId: ingest.rendererHandle.id,
1709
1489
  });
1710
1490
  setProgress({ phase: 'Complete', percent: 100 });
@@ -1719,9 +1499,11 @@ export function useIfcLoader() {
1719
1499
 
1720
1500
  try {
1721
1501
  const result = await parseIfcxViewerModel(buffer, setProgress);
1722
- setGeometryResult(result.geometryResult);
1723
- setIfcDataStore(result.dataStore);
1724
- finalizePrimaryModel(result.dataStore, result.geometryResult, result.schemaVersion);
1502
+ if (target.kind === 'primary') {
1503
+ setGeometryResult(result.geometryResult);
1504
+ setIfcDataStore(result.dataStore);
1505
+ }
1506
+ await finalizeModel(result.dataStore, result.geometryResult, result.schemaVersion);
1725
1507
 
1726
1508
  setProgress({ phase: 'Complete', percent: 100 });
1727
1509
  setLoading(false);
@@ -1731,13 +1513,13 @@ export function useIfcLoader() {
1731
1513
  console.warn(`[useIfc] IFCX file "${file.name}" has no geometry - this appears to be an overlay file that adds properties to a base model.`);
1732
1514
  console.warn('[useIfc] To use this file, load it together with a base IFCX file (select both files at once).');
1733
1515
  setError(`"${file.name}" is an overlay file with no geometry. Please load it together with a base IFCX file (select all files at once).`);
1734
- updateModel(primaryModelId, { loadState: 'error', loadError: 'overlay-only-ifcx' });
1516
+ updateModel(modelId, { loadState: 'error', loadError: 'overlay-only-ifcx' });
1735
1517
  setLoading(false);
1736
1518
  return;
1737
1519
  }
1738
1520
  console.error('[useIfc] IFCX parsing failed:', err);
1739
1521
  const message = err instanceof Error ? err.message : String(err);
1740
- updateModel(primaryModelId, { loadState: 'error', loadError: message });
1522
+ updateModel(modelId, { loadState: 'error', loadError: message });
1741
1523
  setError(`IFCX parsing failed: ${message}`);
1742
1524
  setLoading(false);
1743
1525
  return;
@@ -1751,9 +1533,18 @@ export function useIfcLoader() {
1751
1533
 
1752
1534
  try {
1753
1535
  const result = await parseGlbViewerModel(buffer);
1754
- setGeometryResult(result.geometryResult);
1755
- setIfcDataStore(null);
1756
- finalizePrimaryModel(null, result.geometryResult, result.schemaVersion);
1536
+ if (target.kind === 'primary') {
1537
+ setGeometryResult(result.geometryResult);
1538
+ setIfcDataStore(null);
1539
+ }
1540
+ // Primary keeps the historical null data store (GLB has no entities);
1541
+ // a federated add needs the minimal store so finalizeModel can offset
1542
+ // ids + register the model (matches the old addModel GLB path).
1543
+ await finalizeModel(
1544
+ target.kind === 'federated' ? result.dataStore : null,
1545
+ result.geometryResult,
1546
+ result.schemaVersion,
1547
+ );
1757
1548
 
1758
1549
  setProgress({ phase: 'Complete', percent: 100 });
1759
1550
 
@@ -1762,7 +1553,7 @@ export function useIfcLoader() {
1762
1553
  } catch (err: unknown) {
1763
1554
  console.error('[useIfc] GLB parsing failed:', err);
1764
1555
  const message = err instanceof Error ? err.message : String(err);
1765
- updateModel(primaryModelId, { loadState: 'error', loadError: message });
1556
+ updateModel(modelId, { loadState: 'error', loadError: message });
1766
1557
  setError(`GLB parsing failed: ${message}`);
1767
1558
  setLoading(false);
1768
1559
  return;
@@ -1776,14 +1567,19 @@ export function useIfcLoader() {
1776
1567
  // persisted key filename-safe and independent of the original filename.
1777
1568
  const cacheKey = `ifc-${buffer.byteLength}-${fingerprint}-v4`;
1778
1569
 
1779
- if (buffer.byteLength >= CACHE_SIZE_THRESHOLD) {
1570
+ // Cache + server are PRIMARY-ONLY: a federated add is WASM-only with no
1571
+ // cache/server round-trip (matches the former parseStepBufferViewerModel).
1572
+ if (target.kind === 'primary' && buffer.byteLength >= CACHE_SIZE_THRESHOLD) {
1780
1573
  setProgress({ phase: 'Checking cache', percent: 5 });
1781
1574
  const cacheResult = await getCached(cacheKey);
1782
1575
  if (cacheResult) {
1783
- const cacheLoadResult = await loadFromCache(cacheResult, file.name, cacheKey);
1576
+ // Pass the freshly read file buffer as the source fallback: the
1577
+ // desktop cache doesn't persist a sourceBuffer, and without one the
1578
+ // restored store can't carry the lazy entity accessors.
1579
+ const cacheLoadResult = await loadFromCache(cacheResult, file.name, cacheKey, buffer);
1784
1580
  if (cacheLoadResult.success) {
1785
1581
  const state = useViewerStore.getState();
1786
- finalizePrimaryModel(state.ifcDataStore, state.geometryResult, getSchemaVersion(state.ifcDataStore), {
1582
+ await finalizeModel(state.ifcDataStore, state.geometryResult, getSchemaVersion(state.ifcDataStore), {
1787
1583
  loadState: 'complete',
1788
1584
  cacheState: 'hit',
1789
1585
  });
@@ -1798,12 +1594,12 @@ export function useIfcLoader() {
1798
1594
  // Only for IFC4 STEP files (server doesn't support IFCX). Native
1799
1595
  // file handles (Tauri) don't have an HTTP-uploadable body, so skip
1800
1596
  // the server path and fall through to the WASM loader.
1801
- if (format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '' && !isNativeFileHandle(file)) {
1597
+ if (target.kind === 'primary' && format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '' && !isNativeFileHandle(file)) {
1802
1598
  // Pass buffer directly - server uses File object for parsing, buffer is only for size checks
1803
1599
  const serverSuccess = await loadFromServer(file, buffer, () => loadSessionRef.current !== currentSession);
1804
1600
  if (serverSuccess) {
1805
1601
  const state = useViewerStore.getState();
1806
- finalizePrimaryModel(state.ifcDataStore, state.geometryResult, getSchemaVersion(state.ifcDataStore));
1602
+ await finalizeModel(state.ifcDataStore, state.geometryResult, getSchemaVersion(state.ifcDataStore));
1807
1603
  console.log(`[useIfc] TOTAL LOAD TIME (server): ${(performance.now() - totalStartTime).toFixed(0)}ms`);
1808
1604
  setLoading(false);
1809
1605
  return;
@@ -1814,7 +1610,11 @@ export function useIfcLoader() {
1814
1610
 
1815
1611
  // Using local WASM parsing
1816
1612
  setProgress({ phase: 'Starting geometry streaming', percent: 10 });
1817
- setGeometryStreamingActive(true);
1613
+ // Global streaming flag is a PRIMARY (active-model) concern; a federated
1614
+ // add must not toggle it (the former federated path never did).
1615
+ if (target.kind === 'primary') {
1616
+ setGeometryStreamingActive(true);
1617
+ }
1818
1618
 
1819
1619
  const shouldUseDesktopStableWasmGeometry =
1820
1620
  isNativeFileHandle(file)
@@ -1831,6 +1631,11 @@ export function useIfcLoader() {
1831
1631
  mergeLayers: mergeLayersAtLoad,
1832
1632
  });
1833
1633
  await geometryProcessor.init();
1634
+ // Issue #924: enable RTC-invariant per-entity geometry fingerprints so
1635
+ // the model-compare feature can detect geometry changes. The hash rides
1636
+ // on each MeshData.geometryHash (and through the worker pool); cost is
1637
+ // the O(verts) quantized hash, negligible next to tessellation.
1638
+ geometryProcessor.enableGeometryHashes();
1834
1639
 
1835
1640
  // Allocate (or reuse) a SharedArrayBuffer so the parser worker and
1836
1641
  // the geometry workers read the same memory zero-copy. When
@@ -1881,7 +1686,10 @@ export function useIfcLoader() {
1881
1686
  partialStore.spatialHierarchy.storeyHeights.set(storeyId, height);
1882
1687
  }
1883
1688
  }
1884
- setIfcDataStore(partialStore);
1689
+ // PRIMARY only: setIfcDataStore writes the ACTIVE model. A federated
1690
+ // add must not touch model #1's store — it wires its own via
1691
+ // finalizeModel → addModel once dataStorePromise resolves.
1692
+ if (target.kind === 'primary') setIfcDataStore(partialStore);
1885
1693
  };
1886
1694
 
1887
1695
  const onFullDataStore = (dataStore: IfcDataStore) => {
@@ -1893,7 +1701,10 @@ export function useIfcLoader() {
1893
1701
  dataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
1894
1702
  }
1895
1703
  }
1896
- setIfcDataStore(dataStore);
1704
+ // PRIMARY only (active-model write); federated wires via finalizeModel.
1705
+ // resolveDataStore stays unconditional so the federated finalizePromise
1706
+ // still resolves and registers the model.
1707
+ if (target.kind === 'primary') setIfcDataStore(dataStore);
1897
1708
  console.log(`[useIfc] Data model parsing complete for ${file.name}: ${metadataCompleteMs.toFixed(0)}ms`);
1898
1709
  memoryAccounting.endPhase('parser-worker');
1899
1710
  memoryAccounting.recordPhase({ phase: 'parser-complete' });
@@ -2001,8 +1812,11 @@ export function useIfcLoader() {
2001
1812
  let metadataCompleteMs: number | null = null;
2002
1813
  let metadataFailedMs: number | null = null;
2003
1814
 
2004
- // Clear existing geometry result
2005
- setGeometryResult(null);
1815
+ // Clear existing geometry result — PRIMARY only (federated must not
1816
+ // disturb the active model's geometry).
1817
+ if (target.kind === 'primary') {
1818
+ setGeometryResult(null);
1819
+ }
2006
1820
 
2007
1821
  // Timing instrumentation
2008
1822
  let batchCount = 0;
@@ -2025,6 +1839,11 @@ export function useIfcLoader() {
2025
1839
 
2026
1840
  // Declare at function scope so the catch block can always reach it.
2027
1841
  let closeGeometryIterator: (() => Promise<void>) | null = null;
1842
+ // The background finalize (spatial index / cache for primary; align +
1843
+ // addModel for federated). Primary leaves it running in the background
1844
+ // for a fast first frame; federated MUST await it so the model is
1845
+ // registered before loadFile resolves (loadFilesSequentially relies on it).
1846
+ let finalizePromise: Promise<void> | null = null;
2028
1847
 
2029
1848
  try {
2030
1849
  // Use dynamic batch sizing for optimal throughput
@@ -2039,6 +1858,9 @@ export function useIfcLoader() {
2039
1858
  sizeThreshold: 2 * 1024 * 1024, // 2MB threshold
2040
1859
  batchSize: dynamicBatchConfig, // Dynamic batches: small first, then large
2041
1860
  existingSab: sharedSource ?? undefined,
1861
+ // Federated adds share the anchor's RTC origin so all models sit in
1862
+ // one coordinate space (pixel-perfect alignment, no post-shift).
1863
+ sharedRtcOffset: target.kind === 'federated' ? target.sharedRtcOffset : undefined,
2042
1864
  // Hand the streaming pre-pass's entity index to the parser
2043
1865
  // worker so it skips a duplicate ~10 s WASM scan. Safe even
2044
1866
  // when the parser falls back to main-thread (instance is
@@ -2141,29 +1963,39 @@ export function useIfcLoader() {
2141
1963
  totalMeshes = event.totalSoFar;
2142
1964
  lastTotalMeshes = event.totalSoFar;
2143
1965
 
2144
- // Accumulate meshes for batched rendering
2145
- for (let i = 0; i < event.meshes.length; i++) pendingMeshes.push(event.meshes[i]);
1966
+ if (target.kind === 'primary') {
1967
+ // Accumulate meshes for batched rendering
1968
+ for (let i = 0; i < event.meshes.length; i++) pendingMeshes.push(event.meshes[i]);
2146
1969
 
2147
- // FIRST BATCH: Render immediately for fast first frame
2148
- // SUBSEQUENT: Throttle to reduce React re-renders
2149
- const timeSinceLastRender = eventReceived - lastRenderTime;
2150
- const shouldRender = batchCount === 1 || timeSinceLastRender >= RENDER_INTERVAL_MS;
1970
+ // FIRST BATCH: Render immediately for fast first frame
1971
+ // SUBSEQUENT: Throttle to reduce React re-renders
1972
+ const timeSinceLastRender = eventReceived - lastRenderTime;
1973
+ const shouldRender = batchCount === 1 || timeSinceLastRender >= RENDER_INTERVAL_MS;
2151
1974
 
2152
- if (shouldRender && pendingMeshes.length > 0) {
2153
- if (firstAppendGeometryBatchMs === null) {
2154
- firstAppendGeometryBatchMs = performance.now() - totalStartTime;
2155
- console.log(`[useIfc] First appendGeometryBatch for ${file.name}: ${firstAppendGeometryBatchMs.toFixed(0)}ms`);
1975
+ if (shouldRender && pendingMeshes.length > 0) {
1976
+ if (firstAppendGeometryBatchMs === null) {
1977
+ firstAppendGeometryBatchMs = performance.now() - totalStartTime;
1978
+ console.log(`[useIfc] First appendGeometryBatch for ${file.name}: ${firstAppendGeometryBatchMs.toFixed(0)}ms`);
1979
+ }
1980
+ appendGeometryBatch(pendingMeshes, event.coordinateInfo);
1981
+ pendingMeshes = [];
1982
+ lastRenderTime = eventReceived;
1983
+ markFirstVisibleGeometry();
1984
+
1985
+ // Update progress
1986
+ const progressPercent = 50 + Math.min(45, (totalMeshes / Math.max(estimatedTotal / 10, totalMeshes)) * 45);
1987
+ setProgress({
1988
+ phase: `Rendering geometry (${totalMeshes} meshes)`,
1989
+ percent: progressPercent
1990
+ });
2156
1991
  }
2157
- appendGeometryBatch(pendingMeshes, event.coordinateInfo);
2158
- pendingMeshes = [];
2159
- lastRenderTime = eventReceived;
2160
- markFirstVisibleGeometry();
2161
-
2162
- // Update progress
2163
- const progressPercent = 50 + Math.min(45, (totalMeshes / Math.max(estimatedTotal / 10, totalMeshes)) * 45);
1992
+ } else {
1993
+ // Federated add: accumulate into allMeshes only (done above) and
1994
+ // surface progress — it paints atomically at completion via
1995
+ // finalizeModel's addModel, never touching the active slot.
2164
1996
  setProgress({
2165
- phase: `Rendering geometry (${totalMeshes} meshes)`,
2166
- percent: progressPercent
1997
+ phase: `Processing geometry (${totalMeshes} meshes)`,
1998
+ percent: 10 + Math.min(80, (allMeshes.length / 1000) * 0.8),
2167
1999
  });
2168
2000
  }
2169
2001
 
@@ -2171,8 +2003,9 @@ export function useIfcLoader() {
2171
2003
  }
2172
2004
  case 'complete':
2173
2005
  streamCompleteMs = performance.now() - totalStartTime;
2174
- // Flush any remaining pending meshes
2175
- if (pendingMeshes.length > 0) {
2006
+ // Flush remaining pending meshes — PRIMARY only. A federated add
2007
+ // never pushed to pendingMeshes; it paints atomically at finalize.
2008
+ if (target.kind === 'primary' && pendingMeshes.length > 0) {
2176
2009
  if (firstAppendGeometryBatchMs === null) {
2177
2010
  firstAppendGeometryBatchMs = performance.now() - totalStartTime;
2178
2011
  console.log(`[useIfc] First appendGeometryBatch for ${file.name}: ${firstAppendGeometryBatchMs.toFixed(0)}ms`);
@@ -2184,40 +2017,58 @@ export function useIfcLoader() {
2184
2017
 
2185
2018
  finalCoordinateInfo = event.coordinateInfo ?? null;
2186
2019
 
2187
- // Data model parsing already started in parallel (see above).
2188
- // No need to start it here — it runs concurrently with geometry.
2189
-
2190
- // Apply all accumulated color updates in a single store update
2191
- // instead of one updateMeshColors() call per colorUpdate event.
2192
- if (cumulativeColorUpdates.size > 0) {
2193
- updateMeshColors(cumulativeColorUpdates);
2194
- }
2195
-
2196
- // Store captured RTC offset in coordinate info for multi-model alignment
2020
+ // Store captured RTC offset in coordinate info for multi-model alignment.
2197
2021
  if (finalCoordinateInfo && capturedRtcOffset) {
2198
2022
  finalCoordinateInfo.wasmRtcOffset = capturedRtcOffset;
2199
2023
  }
2200
2024
 
2201
- // Update geometry result with final coordinate info
2202
- updateCoordinateInfo(finalCoordinateInfo);
2025
+ if (target.kind === 'primary') {
2026
+ // Active-model writes — PRIMARY only. Federated meshes already
2027
+ // carry colours (applied during streaming) and their coordinate
2028
+ // info rides the geometryResult handed to addModel at finalize.
2029
+ if (cumulativeColorUpdates.size > 0) {
2030
+ updateMeshColors(cumulativeColorUpdates);
2031
+ }
2032
+ updateCoordinateInfo(finalCoordinateInfo);
2033
+ }
2203
2034
 
2204
2035
  setProgress({ phase: 'Complete', percent: 100 });
2205
2036
  memoryAccounting.endPhase('geometry');
2206
2037
  memoryAccounting.recordPhase({ phase: 'geometry-complete' });
2207
2038
  console.log(memoryAccounting.formatSummary());
2208
2039
  await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
2209
- if (loadSessionRef.current === currentSession) {
2040
+ if (loadSessionRef.current === currentSession && target.kind === 'primary') {
2210
2041
  setGeometryStreamingActive(false);
2211
2042
  }
2212
2043
  console.log(`[useIfc] Geometry streaming complete: ${batchCount} batches, ${lastTotalMeshes} meshes`);
2213
2044
  console.log(`[useIfc] Stream complete for ${file.name}: ${streamCompleteMs.toFixed(0)}ms`);
2214
2045
 
2215
- // Build spatial index and cache in background (non-blocking)
2216
- // Wait for data model to complete first
2217
- dataStorePromise.then(async dataStore => {
2046
+ // Finalize once the data model is ready (parses in parallel).
2047
+ finalizePromise = dataStorePromise.then(async dataStore => {
2218
2048
  // Guard: skip if user loaded a new file since this load started
2219
2049
  if (loadSessionRef.current !== currentSession) return;
2220
- finalizePrimaryModel(dataStore, useViewerStore.getState().geometryResult, getSchemaVersion(dataStore), {
2050
+
2051
+ if (target.kind === 'federated') {
2052
+ // Build the model's geometryResult from the accumulated meshes —
2053
+ // federated never streamed into the active slot — and hand it to
2054
+ // finalizeModel, which aligns, offsets ids, builds the spatial
2055
+ // index, and registers the model via addModel. NOT cached (the
2056
+ // former federated path never cached); allMeshes stays alive as
2057
+ // the model's geometryResult.meshes, so it is NOT cleared.
2058
+ applyColorUpdatesToMeshes(allMeshes, cumulativeColorUpdates);
2059
+ const federatedGeometry: GeometryResult = {
2060
+ meshes: allMeshes,
2061
+ totalVertices: allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0),
2062
+ totalTriangles: allMeshes.reduce((sum, m) => sum + m.indices.length / 3, 0),
2063
+ coordinateInfo: finalCoordinateInfo ?? createCoordinateInfo(calculateMeshBounds(allMeshes).bounds),
2064
+ };
2065
+ await finalizeModel(dataStore, federatedGeometry, getSchemaVersion(dataStore), {
2066
+ loadState: 'complete',
2067
+ });
2068
+ return;
2069
+ }
2070
+
2071
+ await finalizeModel(dataStore, useViewerStore.getState().geometryResult, getSchemaVersion(dataStore), {
2221
2072
  loadState: 'complete',
2222
2073
  cacheState: buffer.byteLength >= CACHE_SIZE_THRESHOLD ? 'writing' : 'none',
2223
2074
  });
@@ -2261,10 +2112,19 @@ export function useIfcLoader() {
2261
2112
  }).catch(err => {
2262
2113
  // Data model parsing failed - spatial index and caching skipped
2263
2114
  console.warn('[useIfc] Skipping spatial index/cache - data model unavailable:', err);
2264
- updateModel(primaryModelId, {
2265
- loadState: 'error',
2266
- loadError: err instanceof Error ? err.message : String(err),
2267
- });
2115
+ const message = err instanceof Error ? err.message : String(err);
2116
+ if (target.kind === 'federated') {
2117
+ // No placeholder model exists for a federated add (it is only
2118
+ // registered on success via finalizeModel→addModel), so
2119
+ // updateModel would no-op and the failure would vanish —
2120
+ // addModel just returns null. Surface it to the user instead.
2121
+ toast.error(`Failed to load "${file.name}": ${message}`);
2122
+ } else {
2123
+ updateModel(modelId, {
2124
+ loadState: 'error',
2125
+ loadError: message,
2126
+ });
2127
+ }
2268
2128
  });
2269
2129
  break;
2270
2130
  }
@@ -2275,6 +2135,11 @@ export function useIfcLoader() {
2275
2135
  if (closeGeometryIterator) {
2276
2136
  await closeGeometryIterator();
2277
2137
  }
2138
+ // The parser worker may be parked in `waitForEntityIndex` (the aborted
2139
+ // geometry pre-pass would have unblocked it); it self-terminates on its
2140
+ // own watchdog. Swallow the now-orphaned dataStorePromise rejection so
2141
+ // it doesn't surface as an unhandled rejection.
2142
+ void dataStorePromise.catch(() => {});
2278
2143
  if (loadSessionRef.current !== currentSession) return;
2279
2144
  console.error('[useIfc] Error in processing:', err);
2280
2145
  setError(err instanceof Error ? err.message : 'Unknown error during geometry processing');
@@ -2285,6 +2150,14 @@ export function useIfcLoader() {
2285
2150
 
2286
2151
  if (loadSessionRef.current !== currentSession) return;
2287
2152
 
2153
+ // Federated adds register the model inside finalizePromise (georef align
2154
+ // → id offset → spatial index → addModel). Await it so loadFile resolves
2155
+ // only AFTER the model is in the map — loadFilesSequentially loads the
2156
+ // next file serially and relies on this ordering for id-offset assignment.
2157
+ if (target.kind === 'federated' && finalizePromise) {
2158
+ await finalizePromise;
2159
+ }
2160
+
2288
2161
  if (firstVisibleGeometryMs === null && firstAppendGeometryBatchMs !== null) {
2289
2162
  await new Promise<void>((resolve) => {
2290
2163
  const fallbackTimer = globalThis.setTimeout(() => {
@@ -2314,7 +2187,7 @@ export function useIfcLoader() {
2314
2187
  setGeometryStreamingActive(false);
2315
2188
  } catch (err) {
2316
2189
  if (loadSessionRef.current !== currentSession) return;
2317
- updateModel(primaryModelId, {
2190
+ updateModel(modelId, {
2318
2191
  loadState: 'error',
2319
2192
  loadError: err instanceof Error ? err.message : String(err),
2320
2193
  });