@ifc-lite/viewer 1.27.0 → 1.28.1

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 (162) hide show
  1. package/.turbo/turbo-build.log +35 -42
  2. package/CHANGELOG.md +74 -0
  3. package/dist/assets/{basketViewActivator-B3CdrLsb.js → basketViewActivator-Ce38DhXd.js} +8 -8
  4. package/dist/assets/{bcf-QeHK_Aud.js → bcf-Cv_O3JfD.js} +56 -56
  5. package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
  6. package/dist/assets/{deflate-B-d0SYQM.js → deflate-HbyMq59o.js} +1 -1
  7. package/dist/assets/drawing-2d-DW98umlt.js +257 -0
  8. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  9. package/dist/assets/{exporters-B4LbZFeT.js → exporters-BuD3XRzB.js} +1309 -1153
  10. package/dist/assets/geometry.worker-TH3fCCoY.js +1 -0
  11. package/dist/assets/{geotiff-CrVtDRFq.js → geotiff-B2HA8Bwm.js} +10 -10
  12. package/dist/assets/{ids-DjsGFN10.js → ids-DYUFMd5f.js} +952 -945
  13. package/dist/assets/{ifc-lite_bg-DsYUIHm3.wasm → ifc-lite_bg-BEA5DLmg.wasm} +0 -0
  14. package/dist/assets/index-E9wB0zWt.css +1 -0
  15. package/dist/assets/{index-COYokSKc.js → index-n5O1QJMM.js} +37877 -38126
  16. package/dist/assets/{index.es-CY202jA3.js → index.es-BKVIpZgL.js} +9 -9
  17. package/dist/assets/{jpeg-D4wOkf5h.js → jpeg-C7hjKjPX.js} +1 -1
  18. package/dist/assets/{jspdf.es.min-DIGb9BHN.js → jspdf.es.min-oWlFc42Y.js} +4 -4
  19. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  20. package/dist/assets/{lerc-DmW0_tgf.js → lerc-BfIOGhQz.js} +1 -1
  21. package/dist/assets/{lzw-oWetY-d6.js → lzw-B0jRuuW5.js} +1 -1
  22. package/dist/assets/{native-bridge-BX8_tHXE.js → native-bridge-DpB-dtEn.js} +6 -3
  23. package/dist/assets/{packbits-F8Nkp4NY.js → packbits-DVvBTC39.js} +1 -1
  24. package/dist/assets/parser.worker-BDsWQ6rc.js +182 -0
  25. package/dist/assets/{pdf-Dsh3HPZB.js → pdf-dVIqI5ac.js} +10 -10
  26. package/dist/assets/raw-C0ZJYGmN.js +1 -0
  27. package/dist/assets/{sandbox-BAC3a-eN.js → sandbox-qpJlrNN0.js} +2962 -2554
  28. package/dist/assets/server-client-DVZ2huNS.js +719 -0
  29. package/dist/assets/{webimage-BLV1dgmd.js → webimage-B394g0Tw.js} +1 -1
  30. package/dist/assets/{xlsx-Bc2HTrjC.js → xlsx-D-oHO76J.js} +8 -8
  31. package/dist/assets/{zstd-C_1HxVrA.js → zstd-Bf38MwV2.js} +1 -1
  32. package/dist/index.html +9 -9
  33. package/package.json +24 -23
  34. package/src/App.tsx +1 -3
  35. package/src/components/mcp/playground-dispatcher.ts +3 -0
  36. package/src/components/mcp/playground-files.ts +33 -1
  37. package/src/components/viewer/BCFPanel.tsx +1 -16
  38. package/src/components/viewer/ChatPanel.tsx +11 -46
  39. package/src/components/viewer/CommandPalette.tsx +6 -1
  40. package/src/components/viewer/ComparePanel.tsx +420 -0
  41. package/src/components/viewer/HierarchyPanel.tsx +48 -183
  42. package/src/components/viewer/IDSPanel.tsx +1 -26
  43. package/src/components/viewer/MainToolbar.tsx +94 -187
  44. package/src/components/viewer/MobileToolbar.tsx +1 -9
  45. package/src/components/viewer/PropertiesPanel.tsx +98 -127
  46. package/src/components/viewer/ScriptPanel.tsx +8 -34
  47. package/src/components/viewer/Section2DPanel.tsx +32 -1
  48. package/src/components/viewer/ViewerLayout.tsx +5 -2
  49. package/src/components/viewer/Viewport.tsx +3 -0
  50. package/src/components/viewer/ViewportContainer.tsx +24 -42
  51. package/src/components/viewer/ViewportOverlays.tsx +1 -4
  52. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  53. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  54. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  55. package/src/components/viewer/hierarchy/types.ts +1 -0
  56. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  57. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  58. package/src/components/viewer/useGeometryStreaming.ts +0 -2
  59. package/src/hooks/federationLoadGate.test.ts +12 -2
  60. package/src/hooks/federationLoadGate.ts +9 -2
  61. package/src/hooks/ingest/federationAlign.ts +488 -0
  62. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  63. package/src/hooks/useCompare.ts +0 -0
  64. package/src/hooks/useCompareOverlay.ts +119 -0
  65. package/src/hooks/useDrawingGeneration.ts +234 -14
  66. package/src/hooks/useIfc.ts +1 -1
  67. package/src/hooks/useIfcCache.ts +100 -24
  68. package/src/hooks/useIfcFederation.ts +42 -811
  69. package/src/hooks/useIfcLoader.ts +349 -1517
  70. package/src/hooks/useIfcServer.ts +3 -0
  71. package/src/hooks/useLens.ts +5 -1
  72. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  73. package/src/lib/compare/buildFingerprints.ts +173 -0
  74. package/src/lib/compare/describeChange.ts +0 -0
  75. package/src/lib/compare/geometricData.test.ts +54 -0
  76. package/src/lib/compare/geometricData.ts +37 -0
  77. package/src/lib/compare/overlay.test.ts +99 -0
  78. package/src/lib/compare/overlay.ts +91 -0
  79. package/src/lib/geo/cesium-placement.ts +1 -1
  80. package/src/lib/geo/reproject.ts +4 -1
  81. package/src/lib/llm/script-edit-ops.ts +23 -0
  82. package/src/lib/llm/stream-client.ts +8 -1
  83. package/src/lib/search/result-export.ts +7 -1
  84. package/src/sdk/adapters/export-adapter.ts +6 -1
  85. package/src/services/cacheService.ts +9 -25
  86. package/src/services/desktop-export.ts +2 -59
  87. package/src/services/file-dialog.ts +8 -142
  88. package/src/store/constants.ts +23 -0
  89. package/src/store/globalId.ts +15 -13
  90. package/src/store/index.ts +19 -6
  91. package/src/store/slices/cesiumSlice.ts +8 -1
  92. package/src/store/slices/compareSlice.ts +96 -0
  93. package/src/store/slices/drawing2DSlice.ts +8 -0
  94. package/src/store/slices/lensSlice.ts +8 -0
  95. package/src/store/slices/visibilitySlice.ts +22 -1
  96. package/src/store/types.ts +1 -71
  97. package/src/utils/acquireFileBuffer.test.ts +12 -4
  98. package/src/utils/ifcConfig.ts +0 -12
  99. package/src/utils/loadingUtils.ts +32 -0
  100. package/src/utils/spatialHierarchy.test.ts +53 -1
  101. package/src/utils/spatialHierarchy.ts +42 -2
  102. package/src/vite-env.d.ts +2 -0
  103. package/vite.config.ts +6 -3
  104. package/DESKTOP_CONTRACT_VERSION +0 -1
  105. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  106. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  107. package/dist/assets/event-B0kAzHa-.js +0 -1
  108. package/dist/assets/geometry.worker-BdH-E6NB.js +0 -1
  109. package/dist/assets/index-ajK6D32J.css +0 -1
  110. package/dist/assets/lens-PYsLu_MA.js +0 -1
  111. package/dist/assets/parser.worker-D591Zu_-.js +0 -182
  112. package/dist/assets/raw-D9iw0tmc.js +0 -1
  113. package/dist/assets/server-client-Cjwnm7il.js +0 -706
  114. package/dist/assets/tauri-core-stub-D8Fa-u43.js +0 -1
  115. package/dist/assets/tauri-dialog-stub-r7Wksg7o.js +0 -1
  116. package/dist/assets/tauri-fs-stub-BdeRC7aK.js +0 -1
  117. package/src/components/viewer/DesktopEntitlementBanner.tsx +0 -74
  118. package/src/components/viewer/SettingsPage.tsx +0 -581
  119. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  120. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  121. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  122. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
  123. package/src/lib/desktop/desktopEntitlementEvents.ts +0 -39
  124. package/src/lib/desktop-entitlement.ts +0 -43
  125. package/src/lib/desktop-product.ts +0 -130
  126. package/src/lib/platform.ts +0 -23
  127. package/src/services/desktop-cache.ts +0 -186
  128. package/src/services/desktop-harness.ts +0 -196
  129. package/src/services/desktop-logger.ts +0 -20
  130. package/src/services/desktop-native-metadata.ts +0 -230
  131. package/src/services/desktop-panel-actions.ts +0 -43
  132. package/src/services/desktop-preferences.ts +0 -44
  133. package/src/services/fs-cache.ts +0 -212
  134. package/src/services/tauri-core-stub.ts +0 -7
  135. package/src/services/tauri-dialog-stub.ts +0 -7
  136. package/src/services/tauri-fs-stub.ts +0 -7
  137. package/src/services/tauri-modules.d.ts +0 -50
  138. package/src/store/slices/desktopEntitlementSlice.ts +0 -86
  139. package/src/utils/desktopModelSnapshot.ts +0 -358
  140. package/src/utils/nativeSpatialDataStore.ts +0 -277
  141. package/src-tauri/Cargo.toml +0 -29
  142. package/src-tauri/build.rs +0 -7
  143. package/src-tauri/capabilities/default.json +0 -18
  144. package/src-tauri/icons/128x128.png +0 -0
  145. package/src-tauri/icons/128x128@2x.png +0 -0
  146. package/src-tauri/icons/32x32.png +0 -0
  147. package/src-tauri/icons/Square107x107Logo.png +0 -0
  148. package/src-tauri/icons/Square142x142Logo.png +0 -0
  149. package/src-tauri/icons/Square150x150Logo.png +0 -0
  150. package/src-tauri/icons/Square284x284Logo.png +0 -0
  151. package/src-tauri/icons/Square30x30Logo.png +0 -0
  152. package/src-tauri/icons/Square310x310Logo.png +0 -0
  153. package/src-tauri/icons/Square44x44Logo.png +0 -0
  154. package/src-tauri/icons/Square71x71Logo.png +0 -0
  155. package/src-tauri/icons/Square89x89Logo.png +0 -0
  156. package/src-tauri/icons/StoreLogo.png +0 -0
  157. package/src-tauri/icons/icon.icns +0 -0
  158. package/src-tauri/icons/icon.ico +0 -0
  159. package/src-tauri/icons/icon.png +0 -0
  160. package/src-tauri/src/lib.rs +0 -21
  161. package/src-tauri/src/main.rs +0 -10
  162. package/src-tauri/tauri.conf.json +0 -39
@@ -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,30 +23,20 @@ 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
- import initIfcLiteWasm, { IfcAPI } from '@ifc-lite/wasm';
29
- import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
29
+ import { buildSpatialIndexGuarded, buildSpatialIndexForModel } from '../utils/loadingUtils.js';
30
30
  import { type GeometryData } from '@ifc-lite/cache';
31
31
 
32
- import { SERVER_URL, USE_SERVER, CACHE_SIZE_THRESHOLD, CACHE_MAX_SOURCE_SIZE, HUGE_NATIVE_FILE_THRESHOLD, getDynamicBatchConfig } from '../utils/ifcConfig.js';
32
+ import { SERVER_URL, USE_SERVER, CACHE_SIZE_THRESHOLD, CACHE_MAX_SOURCE_SIZE, getDynamicBatchConfig } from '../utils/ifcConfig.js';
33
33
  import {
34
34
  calculateMeshBounds,
35
35
  createCoordinateInfo,
36
36
  getRenderIntervalMs,
37
37
  calculateStoreyHeights,
38
38
  } from '../utils/localParsingUtils.js';
39
- import { buildDesktopMetadataSnapshot, restoreDesktopMetadataSnapshot } from '../utils/desktopModelSnapshot.js';
40
- import { buildIfcDataStoreFromNativeMetadata } from '../utils/nativeSpatialDataStore.js';
41
39
  import { applyColorUpdatesToMeshes } from './meshColorUpdates.js';
42
- import { readNativeFile, type NativeFileHandle } from '../services/file-dialog.js';
43
- import {
44
- bootstrapNativeMetadata,
45
- persistNativeMetadataSnapshot,
46
- restoreNativeMetadataSnapshot,
47
- } from '../services/desktop-native-metadata.js';
48
- import { finalizeActiveHarnessRun, getActiveHarnessRequest } from '../services/desktop-harness.js';
49
- import { logToDesktopTerminal } from '../services/desktop-logger.js';
50
40
 
51
41
  // Cache hook
52
42
  import { useIfcCache, getCached } from './useIfcCache.js';
@@ -58,6 +48,35 @@ import { getMaxExpressId, parseGlbViewerModel, parseIfcxViewerModel } from './in
58
48
  import { boundedIteratorReturn } from './ingest/streamCleanup.js';
59
49
  import { detectPointCloudFormat, ingestPointCloud } from './ingest/pointCloudIngest.js';
60
50
  import { getGlobalRenderer } from './useBCF.js';
51
+ import { extractModelGeoref, alignGeometryToReference, findReferenceGeorefModel } from './ingest/federationAlign.js';
52
+ import { toast } from '../components/ui/toast.js';
53
+
54
+ /**
55
+ * Where a {@link useIfcLoader.loadFile} call should land the model.
56
+ *
57
+ * `primary` is the historical single-model load: it resets all viewer state,
58
+ * clears the model map, and streams progressively into the active slot.
59
+ * `federated` is an additional model joining an existing federation — it does
60
+ * NOT reset state, carries the pre-allocated `modelId`, and the shared RTC
61
+ * origin picked by the federation gate. Both flow through the SAME geometry
62
+ * pipeline + the SAME `finalizeModel`, so load-time behaviour can never again
63
+ * diverge between the two (the cause of the model-diff "all geometry changed"
64
+ * bug). The georef anchor + the user's saved georef edits are resolved inside
65
+ * `finalizeModel` from the live store, exactly as the old federated path did.
66
+ * Default is `primary`.
67
+ */
68
+ export type LoadTarget =
69
+ | { kind: 'primary' }
70
+ | {
71
+ kind: 'federated';
72
+ modelId: string;
73
+ name?: string;
74
+ visible?: boolean;
75
+ collapsed?: boolean;
76
+ loadedAt?: number;
77
+ /** Shared RTC offset from the earliest existing model (IFC Z-up). */
78
+ sharedRtcOffset?: { x: number; y: number; z: number };
79
+ };
61
80
 
62
81
  /**
63
82
  * Compute a fast content fingerprint from the first and last 4KB of a buffer.
@@ -86,25 +105,6 @@ function computeFastFingerprint(buffer: ArrayBuffer): string {
86
105
  return (hash >>> 0).toString(16);
87
106
  }
88
107
 
89
- function toExactArrayBuffer(bytes: Uint8Array): ArrayBuffer {
90
- if (
91
- bytes.buffer instanceof ArrayBuffer &&
92
- bytes.byteOffset === 0 &&
93
- bytes.byteLength === bytes.buffer.byteLength
94
- ) {
95
- return bytes.buffer;
96
- }
97
- return bytes.slice().buffer;
98
- }
99
-
100
- function yieldToUiThread(): Promise<void> {
101
- return new Promise<void>((resolve) => {
102
- const channel = new MessageChannel();
103
- channel.port1.onmessage = () => resolve();
104
- channel.port2.postMessage(null);
105
- });
106
- }
107
-
108
108
  /**
109
109
  * Size-aware first-batch watchdog. Delegates to the package-level helper so
110
110
  * the formula stays unit-tested in `@ifc-lite/geometry`. Subsequent-batch
@@ -124,48 +124,6 @@ function getGeometryStreamWatchdogMs(
124
124
  });
125
125
  }
126
126
 
127
- function countNativeSpatialNodes(
128
- node: { children?: Array<{ children?: unknown[] }> } | null | undefined,
129
- ): number {
130
- if (!node) return 0;
131
- const children = Array.isArray(node.children) ? node.children : [];
132
- let total = 1;
133
- for (let i = 0; i < children.length; i += 1) {
134
- total += countNativeSpatialNodes(children[i] as { children?: Array<{ children?: unknown[] }> });
135
- }
136
- return total;
137
- }
138
-
139
- function computeNativeCacheKey(file: NativeFileHandle): string {
140
- const encodedPath = new TextEncoder().encode(file.path);
141
- const pathHash = computeFastFingerprint(toExactArrayBuffer(encodedPath));
142
- return `native-ifc-${file.size}-${file.modifiedMs ?? 0}-${pathHash}-v1`;
143
- }
144
-
145
- function isNativeFileHandle(file: File | NativeFileHandle): file is NativeFileHandle {
146
- return typeof (file as NativeFileHandle).path === 'string';
147
- }
148
-
149
- let metadataScanApiPromise: Promise<IfcAPI> | null = null;
150
-
151
- async function getMetadataScanApi(): Promise<IfcAPI> {
152
- if (!metadataScanApiPromise) {
153
- metadataScanApiPromise = (async () => {
154
- await initIfcLiteWasm();
155
- return new IfcAPI();
156
- })();
157
- }
158
- return metadataScanApiPromise;
159
- }
160
-
161
- const ENABLE_HUGE_TIME_FLUSH = import.meta.env.VITE_IFC_ENABLE_HUGE_TIME_FLUSH === 'true';
162
-
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
127
 
170
128
  /**
171
129
  * Hook providing file loading operations for single-model path
@@ -216,78 +174,180 @@ export function useIfcLoader() {
216
174
  // Server operations from extracted hook
217
175
  const { loadFromServer } = useIfcServer();
218
176
 
219
- const loadFile = useCallback(async (file: File | NativeFileHandle) => {
177
+ const loadFile = useCallback(async (
178
+ file: File,
179
+ target: LoadTarget = { kind: 'primary' },
180
+ ) => {
220
181
  const { resetViewerState, clearAllModels } = useViewerStore.getState();
221
- const currentSession = ++loadSessionRef.current;
222
- const primaryModelId = crypto.randomUUID();
182
+ // Only a primary (destructive, replace-everything) load bumps the session.
183
+ // Federated adds are independent and run concurrently — they capture the
184
+ // current session without invalidating each other; a subsequent primary
185
+ // load still bumps it and aborts any in-flight federated adds.
186
+ const currentSession = target.kind === 'primary'
187
+ ? ++loadSessionRef.current
188
+ : loadSessionRef.current;
189
+ // Federated adds carry a pre-allocated id; primary loads mint a fresh one.
190
+ const modelId = target.kind === 'federated' ? target.modelId : crypto.randomUUID();
223
191
 
224
192
  // Track total elapsed time for complete user experience
225
193
  const totalStartTime = performance.now();
226
194
 
227
195
  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();
196
+ // Reset all viewer state before loading new file — PRIMARY ONLY. A
197
+ // federated add must never wipe model #1; it joins the existing map.
198
+ if (target.kind === 'primary') {
199
+ resetViewerState();
200
+ clearAllModels();
201
+ }
232
202
 
233
203
  // Reset memory accounting so per-load summaries don't accumulate across files.
234
204
  memoryAccounting.reset();
235
205
  memoryAccounting.recordPhase({ phase: 'load-start' });
236
206
 
237
207
  setLoading(true);
238
- setGeometryStreamingActive(false);
239
208
  setError(null);
240
- setBoundedGeometryMode(false);
241
- setGeometryProgress(null);
242
- setMetadataProgress(null);
243
209
  setProgress({ phase: 'Loading file', percent: 0 });
244
210
 
245
211
  const fileName = file.name;
246
212
  const fileSize = file.size;
247
213
  const fileSizeMB = fileSize / (1024 * 1024);
248
214
 
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',
215
+ // PRIMARY owns the active-model slots + top-level UI/memory flags and
216
+ // creates the model record. A federated add leaves all of that untouched
217
+ // (model #1 must not be disturbed) and registers atomically at finalize
218
+ // via addModel — so it creates NO placeholder entry here (which also
219
+ // keeps the `collapsed` default counting only the other models).
220
+ if (target.kind === 'primary') {
221
+ setGeometryStreamingActive(false);
222
+ setBoundedGeometryMode(false);
223
+ setGeometryProgress(null);
224
+ setMetadataProgress(null);
225
+
226
+ upsertModel({
227
+ id: modelId,
228
+ name: fileName,
229
+ ifcDataStore: null,
230
+ geometryResult: null,
231
+ visible: true,
232
+ collapsed: false,
233
+ schemaVersion: 'IFC4',
234
+ loadedAt: Date.now(),
235
+ fileSize,
236
+ sourceFile: file,
237
+ idOffset: 0,
238
+ maxExpressId: 0,
239
+ loadState: 'pending',
263
240
  geometryLoadState: 'pending',
264
241
  metadataLoadState: 'idle',
265
242
  interactiveReady: false,
266
- 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
- });
243
+ cacheState: 'none',
244
+ loadError: null,
245
+ });
246
+ updateModel(modelId, {
247
+ loadState: 'streaming-geometry',
248
+ geometryLoadState: 'opening',
249
+ metadataLoadState: 'idle',
250
+ interactiveReady: false,
251
+ });
252
+ }
276
253
 
277
- const finalizePrimaryModel = (
254
+ // The ONE finalizer for every format/platform/role. Primary keeps the
255
+ // historical updateModel-only behaviour; federated runs the georef-align
256
+ // → id-offset → relabel → spatial-index → addModel sequence lifted
257
+ // verbatim from the old useIfcFederation.addModel block (same order).
258
+ const finalizeModel = async (
278
259
  dataStore: IfcDataStore | null,
279
- geometryResult: { meshes: MeshData[]; totalVertices: number; totalTriangles: number; coordinateInfo: CoordinateInfo } | null,
260
+ geometryResult: GeometryResult | null,
280
261
  schemaVersion: 'IFC2X3' | 'IFC4' | 'IFC4X3' | 'IFC5',
281
262
  patch?: { loadState?: 'pending' | 'streaming-geometry' | 'hydrating-metadata' | 'complete' | 'error'; cacheState?: 'none' | 'hit' | 'miss' | 'writing'; loadError?: string | null; pointCloudHandleId?: number },
282
- ) => {
263
+ ): Promise<void> => {
264
+ if (target.kind === 'federated') {
265
+ if (!dataStore || !geometryResult) {
266
+ throw new Error('Federated model is missing its data store or geometry');
267
+ }
268
+ // Georef alignment against the federation anchor (resolved live from
269
+ // the store, exactly as the former addModel finalize did).
270
+ const referenceGeoref = findReferenceGeorefModel()?.georef ?? null;
271
+ const parsedGeorefMutations = useViewerStore.getState().georefMutations.get(modelId);
272
+ const parsedGeoref = extractModelGeoref(dataStore, geometryResult.coordinateInfo, parsedGeorefMutations);
273
+ let preAlignmentPositions: Float32Array[] | undefined;
274
+ let preAlignmentNormals: (Float32Array | undefined)[] | undefined;
275
+ let preAlignmentCoordinateInfo: CoordinateInfo | undefined;
276
+ let federationAlignmentStatus: FederatedModel['federationAlignmentStatus'] = 'none';
277
+ if (referenceGeoref && parsedGeoref) {
278
+ setProgress({ phase: 'Aligning georeferenced model', percent: 90 });
279
+ preAlignmentPositions = geometryResult.meshes.map((mesh) => new Float32Array(mesh.positions));
280
+ preAlignmentNormals = geometryResult.meshes.map((mesh) =>
281
+ mesh.normals && mesh.normals.length > 0 ? new Float32Array(mesh.normals) : undefined,
282
+ );
283
+ preAlignmentCoordinateInfo = geometryResult.coordinateInfo;
284
+ const status = await alignGeometryToReference(geometryResult, parsedGeoref, referenceGeoref);
285
+ federationAlignmentStatus = status;
286
+ if (status === 'reprojected') {
287
+ toast.info(
288
+ `Reprojected "${file.name}" from ${parsedGeoref.projectedCRS.name} `
289
+ + `to ${referenceGeoref.projectedCRS.name} for federation alignment.`,
290
+ );
291
+ } else if (status === 'failed') {
292
+ toast.error(
293
+ `Could not align "${file.name}" with the federation anchor — `
294
+ + `${parsedGeoref.projectedCRS.name} → ${referenceGeoref.projectedCRS.name} `
295
+ + 'reprojection failed. The model is shown in its own local frame and may '
296
+ + 'appear at the wrong real-world position.',
297
+ );
298
+ }
299
+ } else if (parsedGeoref) {
300
+ federationAlignmentStatus = 'anchor';
301
+ }
302
+
303
+ // Federation registry: transform expressIds to globally-unique ids.
304
+ const maxExpressId = getMaxExpressId(dataStore, geometryResult.meshes);
305
+ const idOffset = registerModelOffset(modelId, maxExpressId);
306
+ if (idOffset > 0) {
307
+ for (const mesh of geometryResult.meshes) mesh.expressId = mesh.expressId + idOffset;
308
+ for (const asset of geometryResult.pointClouds ?? []) asset.expressId = asset.expressId + idOffset;
309
+ }
310
+ if (idOffset > 0 && patch?.pointCloudHandleId !== undefined) {
311
+ const renderer = getGlobalRenderer();
312
+ if (renderer && geometryResult.pointClouds && geometryResult.pointClouds.length > 0) {
313
+ renderer.relabelPointCloudAsset({ id: patch.pointCloudHandleId }, geometryResult.pointClouds[0].expressId);
314
+ }
315
+ }
316
+ const federatedModel: FederatedModel = {
317
+ id: modelId,
318
+ name: target.name ?? file.name,
319
+ ifcDataStore: dataStore,
320
+ geometryResult,
321
+ visible: target.visible ?? true,
322
+ collapsed: target.collapsed ?? (useViewerStore.getState().models.size > 0),
323
+ schemaVersion,
324
+ loadedAt: target.loadedAt ?? Date.now(),
325
+ fileSize: buffer.byteLength,
326
+ sourceFile: file,
327
+ idOffset,
328
+ maxExpressId,
329
+ pointCloudHandleId: patch?.pointCloudHandleId,
330
+ preAlignmentPositions,
331
+ preAlignmentNormals,
332
+ preAlignmentCoordinateInfo,
333
+ federationAlignmentStatus,
334
+ };
335
+ useViewerStore.getState().addModel(federatedModel);
336
+ // Spatial index AFTER id offset + alignment (final ids + world positions)
337
+ // and AFTER addModel so it attaches to THIS model, not the active slot.
338
+ buildSpatialIndexForModel(geometryResult.meshes, modelId, dataStore);
339
+ return;
340
+ }
341
+
342
+ // PRIMARY — unchanged from the former finalizePrimaryModel.
283
343
  let idOffset = 0;
284
344
  let maxExpressId = 0;
285
345
  if (dataStore && geometryResult) {
286
346
  maxExpressId = getMaxExpressId(dataStore, geometryResult.meshes);
287
- idOffset = registerModelOffset(primaryModelId, maxExpressId);
347
+ idOffset = registerModelOffset(modelId, maxExpressId);
288
348
  }
289
349
 
290
- updateModel(primaryModelId, {
350
+ updateModel(modelId, {
291
351
  ifcDataStore: dataStore,
292
352
  geometryResult,
293
353
  schemaVersion,
@@ -307,1300 +367,14 @@ export function useIfcLoader() {
307
367
  return 'IFC2X3';
308
368
  };
309
369
 
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
-
666
- // Desktop native streaming path is reserved for truly large IFC files.
667
- // Mid-size files are more stable on the shared WASM/web loader and still
668
- // provide full viewer parity without the native streaming complexity.
669
- if (
670
- isNativeFileHandle(file)
671
- && fileName.toLowerCase().endsWith('.ifc')
672
- && file.size >= HUGE_NATIVE_FILE_THRESHOLD
673
- ) {
674
- const harnessRequest = getActiveHarnessRequest();
675
- const nativeCacheKey = computeNativeCacheKey(file);
676
- const shouldUseNativeCache = file.size >= CACHE_SIZE_THRESHOLD;
677
- const hugeNativeMode = file.size >= HUGE_NATIVE_FILE_THRESHOLD;
678
- const retainAllMeshes = !hugeNativeMode;
679
- console.log(`[useIfc] Native path load: ${fileName}, size: ${fileSizeMB.toFixed(2)}MB`);
680
- void logToDesktopTerminal(
681
- 'info',
682
- `[useIfc] Native path load start: ${fileName} (${fileSizeMB.toFixed(2)} MB) path=${file.path} hugeMode=${hugeNativeMode ? 'yes' : 'no'}`
683
- );
684
- setBoundedGeometryMode(hugeNativeMode);
685
- setGeometryStreamingActive(true);
686
- setIfcDataStore(null);
687
- setProgress({ phase: 'Starting native geometry streaming', percent: 10 });
688
-
689
- // Snapshot the user's "Merge Multilayer Walls" preference once
690
- // at load time — flipping the toggle mid-stream cannot affect
691
- // an in-flight WASM pipeline, the reload banner handles that.
692
- const mergeLayersAtLoad = useViewerStore.getState().mergeLayers;
693
- const geometryProcessor = new GeometryProcessor({
694
- quality: GeometryQuality.Balanced,
695
- preferNative: true,
696
- mergeLayers: mergeLayersAtLoad,
697
- });
698
370
 
699
- let estimatedTotal = 0;
700
- let totalMeshes = 0;
701
- let totalVertices = 0;
702
- let totalTriangles = 0;
703
- const allMeshes: MeshData[] = [];
704
- let finalCoordinateInfo: CoordinateInfo | null = null;
705
- let batchCount = 0;
706
- let modelOpenMs: number | null = null;
707
- let firstGeometryTime = 0;
708
- let firstAppendGeometryBatchMs: number | null = null;
709
- let firstVisibleGeometryMs: number | null = null;
710
- let jsFirstChunkReceivedMs: number | null = null;
711
- let lastTotalMeshes = 0;
712
- let pendingMeshes: MeshData[] = [];
713
- let loggedFirstAppendStoreState = false;
714
- let lastRenderTime = 0;
715
- let streamCompleteMs: number | null = null;
716
- let metadataStartMs: number | null = null;
717
- let metadataReadCompleteMs: number | null = null;
718
- let metadataParseStartMs: number | null = null;
719
- let spatialReadyMs: number | null = null;
720
- let metadataCompleteMs: number | null = null;
721
- let metadataFailedMs: number | null = null;
722
- let metadataReadDurationMs: number | null = null;
723
- let metadataBufferCopyDurationMs: number | null = null;
724
- let metadataParseDurationMs: number | null = null;
725
- let metadataParsingPromise: Promise<void> | null = null;
726
- let metadataStallWatchId: ReturnType<typeof globalThis.setInterval> | null = null;
727
- let lastMetadataActivityTime = 0;
728
- let currentMetadataActivity = 'idle';
729
- let firstNativeBatchTelemetry: {
730
- batchSequence: number;
731
- payloadKind: string;
732
- meshCount: number;
733
- positionsLen: number;
734
- normalsLen: number;
735
- indicesLen: number;
736
- chunkReadyTimeMs: number;
737
- packTimeMs: number;
738
- emittedTimeMs: number;
739
- emitTimeMs: number;
740
- jsReceivedTimeMs?: number;
741
- } | null = null;
742
- let nativeStats: {
743
- parseTimeMs?: number;
744
- entityScanTimeMs?: number;
745
- lookupTimeMs?: number;
746
- preprocessTimeMs?: number;
747
- geometryTimeMs?: number;
748
- totalTimeMs?: number;
749
- firstChunkReadyTimeMs?: number;
750
- firstChunkPackTimeMs?: number;
751
- firstChunkEmittedTimeMs?: number;
752
- firstChunkEmitTimeMs?: number;
753
- } | null = null;
754
- const RENDER_INTERVAL_MS = getRenderIntervalMs(fileSizeMB);
755
- const NATIVE_PENDING_MESH_THRESHOLD =
756
- fileSizeMB > 768 ? 8192 :
757
- fileSizeMB > 512 ? 6144 :
758
- fileSizeMB > 256 ? 4096 :
759
- fileSizeMB > 100 ? 2048 :
760
- 512;
761
- const HUGE_NATIVE_APPEND_CHUNK_SIZE = fileSizeMB > 768 ? 2048 : hugeNativeMode ? 1536 : 0;
762
- const HUGE_NATIVE_APPEND_YIELD_THRESHOLD = fileSizeMB > 768 ? 8192 : 6144;
763
- const HUGE_NATIVE_APPEND_YIELD_BUDGET_MS = 10;
764
- let metadataParsingStarted = false;
765
- let geometryCompleted = false;
766
- let fullNativeDataStore: IfcDataStore | null = null;
767
- let nativeLoadStage: 'open' | 'streamGeometry' | 'finalizeGeometry' | 'hydrateMetadata' | 'complete' = 'open';
768
- let nativeMetadataSource: 'snapshot' | 'ifc-parse' = 'ifc-parse';
769
- let nativeMetadataStartGate = 'immediate' as 'immediate' | 'afterInteractiveGeometry' | 'afterGeometryComplete';
770
-
771
- setGeometryResult(null);
772
-
773
- const maybeBuildNativeSpatialIndex = () => {
774
- if (
775
- !retainAllMeshes ||
776
- !geometryCompleted ||
777
- !fullNativeDataStore ||
778
- allMeshes.length === 0 ||
779
- hugeNativeMode ||
780
- loadSessionRef.current !== currentSession
781
- ) {
782
- return;
783
- }
784
- buildSpatialIndexGuarded(allMeshes, fullNativeDataStore, setIfcDataStore);
785
- };
786
-
787
- const flushPendingNativeMeshes = async (
788
- coordinateInfo: CoordinateInfo | null | undefined,
789
- totalMeshesSoFar: number,
790
- ) => {
791
- if (pendingMeshes.length === 0) {
792
- return;
793
- }
794
-
795
- if (firstAppendGeometryBatchMs === null) {
796
- firstAppendGeometryBatchMs = performance.now() - totalStartTime;
797
- void logToDesktopTerminal(
798
- 'info',
799
- `[useIfc] Native first appendGeometryBatch for ${fileName}: ${firstAppendGeometryBatchMs.toFixed(0)}ms`
800
- );
801
- }
802
-
803
- void totalMeshesSoFar;
804
-
805
- const appendMeshesToStore = (meshesToAppend: MeshData[]) => {
806
- const appendGeometryBatchToStore = getViewerStoreApi().getState().appendGeometryBatch;
807
- if (hugeNativeMode) {
808
- flushSync(() => {
809
- appendGeometryBatchToStore(meshesToAppend, coordinateInfo ?? undefined);
810
- });
811
- return;
812
- }
813
- appendGeometryBatchToStore(meshesToAppend, coordinateInfo ?? undefined);
814
- };
815
-
816
- if (!hugeNativeMode || HUGE_NATIVE_APPEND_CHUNK_SIZE <= 0 || pendingMeshes.length <= HUGE_NATIVE_APPEND_CHUNK_SIZE) {
817
- appendMeshesToStore(pendingMeshes);
818
- if (!loggedFirstAppendStoreState) {
819
- const stateAfterAppend = useViewerStore.getState();
820
- void logToDesktopTerminal(
821
- '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}`
823
- );
824
- loggedFirstAppendStoreState = true;
825
- }
826
- if (hugeNativeMode) {
827
- await yieldToUiThread();
828
- if (typeof requestAnimationFrame === 'function') {
829
- await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
830
- }
831
- }
832
- pendingMeshes = [];
833
- markFirstVisibleGeometry();
834
- return;
835
- }
836
-
837
- let appendedSinceYield = 0;
838
- let appendWindowStart = performance.now();
839
- while (pendingMeshes.length > 0) {
840
- const chunk = pendingMeshes.splice(0, HUGE_NATIVE_APPEND_CHUNK_SIZE);
841
- appendMeshesToStore(chunk);
842
- if (!loggedFirstAppendStoreState) {
843
- const stateAfterAppend = useViewerStore.getState();
844
- void logToDesktopTerminal(
845
- '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}`
847
- );
848
- loggedFirstAppendStoreState = true;
849
- }
850
- appendedSinceYield += chunk.length;
851
- markFirstVisibleGeometry();
852
- if (pendingMeshes.length === 0) {
853
- break;
854
- }
855
-
856
- const shouldYield =
857
- appendedSinceYield >= HUGE_NATIVE_APPEND_YIELD_THRESHOLD ||
858
- performance.now() - appendWindowStart >= HUGE_NATIVE_APPEND_YIELD_BUDGET_MS;
859
- if (shouldYield) {
860
- await yieldToUiThread();
861
- if (typeof requestAnimationFrame === 'function') {
862
- await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
863
- }
864
- appendedSinceYield = 0;
865
- appendWindowStart = performance.now();
866
- }
867
- }
868
- };
869
-
870
- const markFirstVisibleGeometry = () => {
871
- if (firstVisibleGeometryMs !== null) return;
872
- requestAnimationFrame(() => {
873
- if (firstVisibleGeometryMs !== null || loadSessionRef.current !== currentSession) return;
874
- firstVisibleGeometryMs = performance.now() - totalStartTime;
875
- void logToDesktopTerminal(
876
- 'info',
877
- `[useIfc] Native first visible geometry for ${fileName}: ${firstVisibleGeometryMs.toFixed(0)}ms`
878
- );
879
- });
880
- };
881
-
882
- const finalizeNativeDataStore = (dataStore: IfcDataStore) => {
883
- if (dataStore.spatialHierarchy && dataStore.spatialHierarchy.storeyHeights.size === 0 && dataStore.spatialHierarchy.storeyElevations.size > 1) {
884
- const calculatedHeights = calculateStoreyHeights(dataStore.spatialHierarchy.storeyElevations);
885
- for (const [storeyId, height] of calculatedHeights) {
886
- dataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
887
- }
888
- }
889
- fullNativeDataStore = dataStore;
890
- setIfcDataStore(dataStore);
891
- if (geometryCompleted) {
892
- nativeLoadStage = 'complete';
893
- }
894
- finalizePrimaryModel(
895
- dataStore,
896
- useViewerStore.getState().geometryResult,
897
- getSchemaVersion(dataStore),
898
- {
899
- loadState: geometryCompleted ? 'complete' : 'hydrating-metadata',
900
- cacheState: nativeGeometryCacheHit ? 'hit' : shouldUseNativeCache ? 'writing' : 'none',
901
- },
902
- );
903
- updateModel(primaryModelId, {
904
- geometryLoadState: geometryCompleted ? 'complete' : 'interactive',
905
- metadataLoadState: 'complete',
906
- interactiveReady: true,
907
- });
908
- maybeBuildNativeSpatialIndex();
909
- };
910
-
911
- const hydrateNativeSpatialDataStore = (
912
- nativeMetadata: NonNullable<Awaited<ReturnType<typeof restoreNativeMetadataSnapshot>>>,
913
- ) => {
914
- const spatialDataStore = buildIfcDataStoreFromNativeMetadata(nativeMetadata);
915
- if (!spatialDataStore) {
916
- return;
917
- }
918
- if (spatialDataStore.spatialHierarchy && spatialDataStore.spatialHierarchy.storeyHeights.size === 0 && spatialDataStore.spatialHierarchy.storeyElevations.size > 1) {
919
- const calculatedHeights = calculateStoreyHeights(spatialDataStore.spatialHierarchy.storeyElevations);
920
- for (const [storeyId, height] of calculatedHeights) {
921
- spatialDataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
922
- }
923
- }
924
- const state = useViewerStore.getState();
925
- const currentGeometryResult =
926
- state.models.get(primaryModelId)?.geometryResult ??
927
- state.geometryResult;
928
- setIfcDataStore(spatialDataStore);
929
- finalizePrimaryModel(
930
- spatialDataStore,
931
- currentGeometryResult,
932
- nativeMetadata.schemaVersion,
933
- {
934
- loadState: geometryCompleted ? 'complete' : 'hydrating-metadata',
935
- cacheState: nativeGeometryCacheHit ? 'hit' : shouldUseNativeCache ? 'writing' : 'none',
936
- },
937
- );
938
- };
939
-
940
- let nativeMetadataSnapshotHit = false;
941
- let metadataSnapshotWritePromise: Promise<void> | null = null;
942
-
943
- const queueNativeMetadataSnapshotWrite = (
944
- dataStore: IfcDataStore,
945
- sourceBuffer: ArrayBuffer,
946
- ) => {
947
- metadataSnapshotWritePromise = (async () => {
948
- await new Promise<void>((resolve) => {
949
- const channel = new MessageChannel();
950
- channel.port1.onmessage = () => resolve();
951
- channel.port2.postMessage(null);
952
- });
953
- if (typeof requestAnimationFrame === 'function') {
954
- await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
955
- }
956
- await writeNativeMetadataSnapshot(dataStore, sourceBuffer);
957
- })();
958
- };
959
-
960
- const writeNativeMetadataSnapshot = async (
961
- dataStore: IfcDataStore,
962
- sourceBuffer: ArrayBuffer,
963
- ): Promise<void> => {
964
- if (!shouldUseNativeCache || !nativeCacheKey) return;
965
- try {
966
- const { setNativeModelSnapshot } = await import('../services/desktop-cache.js');
967
- const snapshotBuffer = await buildDesktopMetadataSnapshot(dataStore, sourceBuffer);
968
- await setNativeModelSnapshot(nativeCacheKey, snapshotBuffer);
969
- } catch (error) {
970
- console.warn('[useIfc] Failed to persist native metadata snapshot:', error);
971
- void logToDesktopTerminal(
972
- 'warn',
973
- `[useIfc] Native metadata snapshot write failed for ${fileName}: ${error instanceof Error ? error.message : String(error)}`
974
- );
975
- }
976
- };
977
-
978
- const noteMetadataActivity = (activity: string) => {
979
- currentMetadataActivity = activity;
980
- lastMetadataActivityTime = performance.now();
981
- };
982
-
983
- const stopMetadataStallWatch = () => {
984
- if (metadataStallWatchId !== null) {
985
- globalThis.clearInterval(metadataStallWatchId);
986
- metadataStallWatchId = null;
987
- }
988
- };
989
-
990
- const startMetadataStallWatch = () => {
991
- stopMetadataStallWatch();
992
- noteMetadataActivity('starting');
993
- metadataStallWatchId = globalThis.setInterval(() => {
994
- if (loadSessionRef.current !== currentSession) {
995
- stopMetadataStallWatch();
996
- return;
997
- }
998
- const idleForMs = performance.now() - lastMetadataActivityTime;
999
- if (idleForMs < 8000) return;
1000
- lastMetadataActivityTime = performance.now();
1001
- void logToDesktopTerminal(
1002
- 'warn',
1003
- `[useIfc] Metadata stall watch for ${fileName}: stage=${nativeLoadStage} idle=${idleForMs.toFixed(0)}ms phase=${currentMetadataActivity} batches=${batchCount} meshes=${lastTotalMeshes} geometryCompleted=${geometryCompleted}`
1004
- );
1005
- }, 5000);
1006
- };
1007
-
1008
- const startNativeMetadataParsing = (): Promise<void> | null => {
1009
- if (metadataParsingStarted) return metadataParsingPromise;
1010
- metadataParsingStarted = true;
1011
- nativeLoadStage = 'hydrateMetadata';
1012
- const metadataStartTime = performance.now();
1013
- metadataStartMs = metadataStartTime - totalStartTime;
1014
- let lastMetadataProgressPhase = '';
1015
- let lastMetadataProgressPercent = -1;
1016
- startMetadataStallWatch();
1017
- setMetadataProgress({ phase: 'Bootstrapping metadata', percent: 5, indeterminate: hugeNativeMode });
1018
- updateModel(primaryModelId, {
1019
- loadState: 'hydrating-metadata',
1020
- metadataLoadState: 'bootstrapping',
1021
- });
1022
- void logToDesktopTerminal(
1023
- 'info',
1024
- `[useIfc] Native metadata parse start for ${fileName} source=${nativeMetadataSource} gate=${nativeMetadataStartGate}`
1025
- );
1026
-
1027
- const metadataReadStartTime = performance.now();
1028
- let parseStartTime = 0;
1029
- metadataParsingPromise = (async () => {
1030
- if (hugeNativeMode) {
1031
- noteMetadataActivity('native bootstrap');
1032
- metadataParseStartMs = performance.now() - totalStartTime;
1033
- parseStartTime = performance.now();
1034
- if (nativeMetadataSnapshotHit) {
1035
- const restoredSnapshot = await restoreNativeMetadataSnapshot(nativeCacheKey);
1036
- if (restoredSnapshot && loadSessionRef.current === currentSession) {
1037
- try {
1038
- spatialReadyMs = performance.now() - totalStartTime;
1039
- hydrateNativeSpatialDataStore(restoredSnapshot);
1040
- updateModel(primaryModelId, {
1041
- nativeMetadata: restoredSnapshot,
1042
- schemaVersion: restoredSnapshot.schemaVersion,
1043
- metadataLoadState: 'spatial-ready',
1044
- interactiveReady: true,
1045
- });
1046
- setMetadataProgress({ phase: 'Restored metadata sidecar', percent: 70 });
1047
- } catch (error) {
1048
- nativeMetadataSnapshotHit = false;
1049
- nativeMetadataSource = 'ifc-parse';
1050
- void logToDesktopTerminal(
1051
- 'warn',
1052
- `[useIfc] Native metadata snapshot restore incompatible for ${fileName}, continuing with live bootstrap: ${error instanceof Error ? error.message : String(error)}`
1053
- );
1054
- }
1055
- }
1056
- }
1057
- void logToDesktopTerminal(
1058
- 'info',
1059
- `[useIfc] Awaiting native metadata bootstrap for ${fileName}`
1060
- );
1061
- const nativeMetadata = await bootstrapNativeMetadata(file.path, nativeCacheKey);
1062
- if (loadSessionRef.current !== currentSession) {
1063
- return null;
1064
- }
1065
- const spatialNodeCount = countNativeSpatialNodes(nativeMetadata.spatialTree);
1066
- void logToDesktopTerminal(
1067
- 'info',
1068
- `[useIfc] Native metadata bootstrap resolved for ${fileName}: elapsed=${(performance.now() - parseStartTime).toFixed(0)}ms hasTree=${nativeMetadata.spatialTree ? 'yes' : 'no'} spatialNodes=${spatialNodeCount}`
1069
- );
1070
- metadataReadCompleteMs = performance.now() - totalStartTime;
1071
- metadataReadDurationMs = metadataReadCompleteMs - metadataStartMs;
1072
- spatialReadyMs = performance.now() - totalStartTime;
1073
- void logToDesktopTerminal(
1074
- 'info',
1075
- `[useIfc] Applying native metadata to store for ${fileName}`
1076
- );
1077
- hydrateNativeSpatialDataStore(nativeMetadata);
1078
- updateModel(primaryModelId, {
1079
- nativeMetadata,
1080
- schemaVersion: nativeMetadata.schemaVersion,
1081
- metadataLoadState: 'spatial-ready',
1082
- interactiveReady: true,
1083
- });
1084
- void logToDesktopTerminal(
1085
- 'info',
1086
- `[useIfc] Native metadata store update complete for ${fileName}`
1087
- );
1088
- setMetadataProgress({ phase: 'Spatial tree ready', percent: 70 });
1089
- if (!nativeMetadataSnapshotHit) {
1090
- void persistNativeMetadataSnapshot(nativeMetadata);
1091
- }
1092
- metadataCompleteMs = performance.now() - totalStartTime;
1093
- metadataParseDurationMs = performance.now() - parseStartTime;
1094
- updateModel(primaryModelId, {
1095
- loadState: geometryCompleted ? 'complete' : 'hydrating-metadata',
1096
- metadataLoadState: 'lazy',
1097
- });
1098
- setMetadataProgress({ phase: 'Metadata ready on demand', percent: 100 });
1099
- return null;
1100
- }
1101
-
1102
- if (nativeGeometryCacheHit && nativeMetadataSnapshotHit) {
1103
- try {
1104
- const { getNativeModelSnapshot } = await import('../services/desktop-cache.js');
1105
- const snapshotBuffer = await getNativeModelSnapshot(nativeCacheKey);
1106
- if (!snapshotBuffer) {
1107
- throw new Error(`missing-native-metadata-snapshot:${nativeCacheKey}`);
1108
- }
1109
- metadataReadCompleteMs = performance.now() - totalStartTime;
1110
- metadataReadDurationMs = performance.now() - metadataReadStartTime;
1111
- metadataParseStartMs = performance.now() - totalStartTime;
1112
- parseStartTime = performance.now();
1113
- noteMetadataActivity('snapshot hydrate');
1114
- if (spatialReadyMs === null) {
1115
- spatialReadyMs = performance.now() - totalStartTime;
1116
- }
1117
- setMetadataProgress({ phase: 'Restoring cached metadata', percent: 80 });
1118
- return restoreDesktopMetadataSnapshot(snapshotBuffer);
1119
- } catch (error) {
1120
- nativeMetadataSnapshotHit = false;
1121
- nativeMetadataSource = 'ifc-parse';
1122
- void logToDesktopTerminal(
1123
- 'warn',
1124
- `[useIfc] Native metadata snapshot hydration failed for ${fileName}, falling back to IFC parse: ${error instanceof Error ? error.message : String(error)}`
1125
- );
1126
- }
1127
- }
1128
-
1129
- const bytes = await readNativeFile(file.path);
1130
- if (loadSessionRef.current !== currentSession) {
1131
- return null;
1132
- }
1133
- metadataReadCompleteMs = performance.now() - totalStartTime;
1134
- metadataReadDurationMs = performance.now() - metadataReadStartTime;
1135
- void logToDesktopTerminal(
1136
- 'info',
1137
- `[useIfc] Native metadata file read complete for ${fileName}: ${metadataReadDurationMs.toFixed(0)}ms`
1138
- );
1139
- const copyStartTime = performance.now();
1140
- const metadataBuffer = toExactArrayBuffer(bytes);
1141
- metadataBufferCopyDurationMs = performance.now() - copyStartTime;
1142
- metadataParseStartMs = performance.now() - totalStartTime;
1143
- parseStartTime = performance.now();
1144
- noteMetadataActivity('parse setup');
1145
- void logToDesktopTerminal(
1146
- 'info',
1147
- `[useIfc] Native metadata buffer copy complete for ${fileName}: ${metadataBufferCopyDurationMs.toFixed(0)}ms`
1148
- );
1149
-
1150
- const parser = new IfcParser();
1151
- const wasmApi = hugeNativeMode ? await getMetadataScanApi() : undefined;
1152
- const dataStore = await parser.parseColumnar(metadataBuffer, {
1153
- wasmApi,
1154
- yieldIntervalMs: hugeNativeMode ? 32 : undefined,
1155
- deferPropertyAtomIndex: hugeNativeMode,
1156
- disableWorkerScan: false,
1157
- onProgress: (progress) => {
1158
- if (!hugeNativeMode) return;
1159
- noteMetadataActivity(`progress:${progress.phase}:${Math.round(progress.percent)}`);
1160
- const roundedPercent = Math.round(progress.percent);
1161
- const shouldLog =
1162
- progress.phase !== lastMetadataProgressPhase ||
1163
- roundedPercent >= lastMetadataProgressPercent + 5 ||
1164
- roundedPercent === 100;
1165
- if (!shouldLog) return;
1166
- setMetadataProgress({
1167
- phase: `Metadata ${progress.phase}`,
1168
- percent: roundedPercent,
1169
- indeterminate: false,
1170
- });
1171
- lastMetadataProgressPhase = progress.phase;
1172
- lastMetadataProgressPercent = roundedPercent;
1173
- void logToDesktopTerminal(
1174
- 'info',
1175
- `[useIfc] Native metadata progress for ${fileName}: ${progress.phase} ${roundedPercent}%`
1176
- );
1177
- },
1178
- onSpatialReady: (partialStore) => {
1179
- if (loadSessionRef.current !== currentSession) return;
1180
- noteMetadataActivity('spatial ready');
1181
- if (spatialReadyMs === null) {
1182
- spatialReadyMs = performance.now() - totalStartTime;
1183
- }
1184
- setMetadataProgress({ phase: 'Spatial tree ready', percent: 70 });
1185
- if (partialStore.spatialHierarchy && partialStore.spatialHierarchy.storeyHeights.size === 0 && partialStore.spatialHierarchy.storeyElevations.size > 1) {
1186
- const calculatedHeights = calculateStoreyHeights(partialStore.spatialHierarchy.storeyElevations);
1187
- for (const [storeyId, height] of calculatedHeights) {
1188
- partialStore.spatialHierarchy.storeyHeights.set(storeyId, height);
1189
- }
1190
- }
1191
- setIfcDataStore(partialStore);
1192
- void logToDesktopTerminal(
1193
- 'info',
1194
- `[useIfc] Native spatial tree ready for ${fileName} at ${(performance.now() - totalStartTime).toFixed(0)}ms`
1195
- );
1196
- },
1197
- onDiagnostic: (message) => {
1198
- noteMetadataActivity(`diag:${message}`);
1199
- void logToDesktopTerminal('info', `[useIfc][diag] ${fileName}: ${message}`);
1200
- },
1201
- });
1202
- queueNativeMetadataSnapshotWrite(dataStore, metadataBuffer);
1203
- return dataStore;
1204
- })()
1205
- .then((dataStore) => {
1206
- stopMetadataStallWatch();
1207
- if (loadSessionRef.current !== currentSession || !dataStore) return;
1208
- metadataCompleteMs = performance.now() - totalStartTime;
1209
- metadataParseDurationMs = parseStartTime > 0 ? performance.now() - parseStartTime : null;
1210
- setMetadataProgress({ phase: 'Metadata ready', percent: 100 });
1211
- finalizeNativeDataStore(dataStore);
1212
- void logToDesktopTerminal(
1213
- 'info',
1214
- `[useIfc] Native metadata parse complete for ${fileName}: total=${(performance.now() - metadataStartTime).toFixed(0)}ms read=${metadataReadDurationMs?.toFixed(0) ?? 'n/a'}ms copy=${metadataBufferCopyDurationMs?.toFixed(0) ?? 'n/a'}ms parse=${metadataParseDurationMs?.toFixed(0) ?? 'n/a'}ms`
1215
- );
1216
- })
1217
- .catch((error) => {
1218
- if (loadSessionRef.current !== currentSession) return;
1219
- stopMetadataStallWatch();
1220
- metadataFailedMs = performance.now() - totalStartTime;
1221
- console.warn('[useIfc] Native metadata parsing failed:', error);
1222
- updateModel(primaryModelId, {
1223
- loadState: 'error',
1224
- metadataLoadState: 'error',
1225
- loadError: error instanceof Error ? error.message : String(error),
1226
- });
1227
- setMetadataProgress({ phase: 'Metadata failed', percent: 100 });
1228
- void logToDesktopTerminal(
1229
- 'warn',
1230
- `[useIfc] Native metadata parse failed for ${fileName}: ${error instanceof Error ? error.message : String(error)}`
1231
- );
1232
- });
1233
- return metadataParsingPromise;
1234
- };
1235
-
1236
- const HUGE_NATIVE_METADATA_START_BATCH = 20;
1237
- let metadataStartQueued = false;
1238
- const queueNativeMetadataStart = (reason: string) => {
1239
- if (metadataParsingStarted || metadataStartQueued) return;
1240
- metadataStartQueued = true;
1241
- void logToDesktopTerminal('info', `[useIfc] Queueing metadata hydration for ${fileName} after ${reason}`);
1242
- metadataStartQueued = false;
1243
- if (loadSessionRef.current !== currentSession || metadataParsingStarted) return;
1244
- void logToDesktopTerminal('info', `[useIfc] Starting metadata hydration after ${reason} for ${fileName}`);
1245
- startNativeMetadataParsing();
1246
- };
1247
-
1248
- let nativeGeometryCacheHit = false;
1249
- if (shouldUseNativeCache) {
1250
- const { hasNativeGeometryCache, hasNativeModelSnapshot } = await import('../services/desktop-cache.js');
1251
- setProgress({ phase: 'Checking cache', percent: 5 });
1252
- setGeometryProgress({ phase: 'Checking geometry cache', percent: 5 });
1253
- nativeGeometryCacheHit = await hasNativeGeometryCache(nativeCacheKey);
1254
- nativeMetadataSnapshotHit = nativeGeometryCacheHit
1255
- ? await hasNativeModelSnapshot(nativeCacheKey)
1256
- : false;
1257
- nativeMetadataSource = nativeMetadataSnapshotHit ? 'snapshot' : 'ifc-parse';
1258
- nativeMetadataStartGate = 'immediate';
1259
- updateModel(primaryModelId, { cacheState: nativeGeometryCacheHit ? 'hit' : 'miss' });
1260
- void logToDesktopTerminal(
1261
- 'info',
1262
- nativeGeometryCacheHit
1263
- ? `[useIfc] Native geometry cache hit for ${fileName}`
1264
- : `[useIfc] Native geometry cache miss for ${fileName}`
1265
- );
1266
- if (nativeMetadataStartGate === 'immediate') {
1267
- startNativeMetadataParsing();
1268
- } else {
1269
- void logToDesktopTerminal(
1270
- 'info',
1271
- nativeMetadataStartGate === 'afterInteractiveGeometry'
1272
- ? `[useIfc] Deferring metadata hydration until geometry batch ${HUGE_NATIVE_METADATA_START_BATCH} for ${fileName}`
1273
- : `[useIfc] Deferring metadata hydration until geometry complete for ${fileName}`
1274
- );
1275
- }
1276
- }
1277
-
1278
- if (!shouldUseNativeCache) {
1279
- if (nativeMetadataStartGate === 'immediate') {
1280
- startNativeMetadataParsing();
1281
- } else {
1282
- void logToDesktopTerminal(
1283
- 'info',
1284
- `[useIfc] Deferring metadata hydration until geometry complete for ${fileName}`
1285
- );
1286
- }
1287
- }
1288
- await geometryProcessor.init();
1289
- void logToDesktopTerminal('info', `[useIfc] GeometryProcessor.init complete for ${fileName}`);
1290
-
1291
- const nativeStream = nativeGeometryCacheHit
1292
- ? geometryProcessor.processStreamingCache(nativeCacheKey)
1293
- : geometryProcessor.processStreamingPath(
1294
- file.path,
1295
- file.size,
1296
- shouldUseNativeCache ? nativeCacheKey : undefined,
1297
- );
1298
-
1299
- for await (const event of nativeStream) {
1300
- const eventReceived = performance.now();
1301
-
1302
- switch (event.type) {
1303
- case 'start':
1304
- estimatedTotal = event.totalEstimate;
1305
- void logToDesktopTerminal('info', `[useIfc] Native stream start for ${fileName}: estimate=${Math.round(estimatedTotal)}`);
1306
- break;
1307
- case 'model-open':
1308
- nativeLoadStage = 'streamGeometry';
1309
- setProgress({ phase: 'Processing geometry (native precompute)', percent: 50, indeterminate: true });
1310
- setGeometryProgress({ phase: 'Opening native geometry stream', percent: 10, indeterminate: true });
1311
- modelOpenMs = performance.now() - totalStartTime;
1312
- console.log(`[useIfc] Native model opened at ${modelOpenMs.toFixed(0)}ms`);
1313
- void logToDesktopTerminal('info', `[useIfc] Native model opened for ${fileName} at ${modelOpenMs.toFixed(0)}ms`);
1314
- break;
1315
- case 'batch': {
1316
- batchCount++;
1317
-
1318
- if (batchCount === 1) {
1319
- firstGeometryTime = performance.now() - totalStartTime;
1320
- jsFirstChunkReceivedMs = event.nativeTelemetry?.jsReceivedTimeMs ?? firstGeometryTime;
1321
- firstNativeBatchTelemetry = event.nativeTelemetry ?? null;
1322
- updateModel(primaryModelId, {
1323
- geometryLoadState: 'interactive',
1324
- interactiveReady: true,
1325
- });
1326
- console.log(`[useIfc] Native batch #1: ${event.meshes.length} meshes, wait: ${firstGeometryTime.toFixed(0)}ms`);
1327
- void logToDesktopTerminal('info', `[useIfc] Native first batch for ${fileName}: meshes=${event.meshes.length}, wait=${firstGeometryTime.toFixed(0)}ms`);
1328
- if (event.nativeTelemetry) {
1329
- const transferLagMs = (event.nativeTelemetry.jsReceivedTimeMs ?? 0) - event.nativeTelemetry.emittedTimeMs;
1330
- void logToDesktopTerminal(
1331
- 'info',
1332
- `[useIfc] Native first batch transport for ${fileName}: rustReady=${event.nativeTelemetry.chunkReadyTimeMs.toFixed(0)}ms pack=${event.nativeTelemetry.packTimeMs.toFixed(0)}ms emit=${event.nativeTelemetry.emitTimeMs.toFixed(0)}ms rustEmitted=${event.nativeTelemetry.emittedTimeMs.toFixed(0)}ms jsReceived=${(event.nativeTelemetry.jsReceivedTimeMs ?? 0).toFixed(0)}ms transfer=${transferLagMs.toFixed(0)}ms`
1333
- );
1334
- }
1335
- } else if (batchCount % 20 === 0) {
1336
- void logToDesktopTerminal('info', `[useIfc] Native batch milestone for ${fileName}: batch=${batchCount}, totalMeshes=${event.totalSoFar}`);
1337
- }
1338
-
1339
- for (let i = 0; i < event.meshes.length; i++) {
1340
- const mesh = event.meshes[i];
1341
- if (retainAllMeshes) {
1342
- allMeshes.push(mesh);
1343
- }
1344
- totalVertices += mesh.positions.length / 3;
1345
- totalTriangles += mesh.indices.length / 3;
1346
- }
1347
- finalCoordinateInfo = event.coordinateInfo ?? null;
1348
- totalMeshes = event.totalSoFar;
1349
- lastTotalMeshes = event.totalSoFar;
1350
-
1351
- for (let i = 0; i < event.meshes.length; i++) pendingMeshes.push(event.meshes[i]);
1352
-
1353
- if (
1354
- nativeMetadataStartGate === 'afterInteractiveGeometry' &&
1355
- !metadataParsingStarted &&
1356
- batchCount >= HUGE_NATIVE_METADATA_START_BATCH &&
1357
- firstAppendGeometryBatchMs !== null
1358
- ) {
1359
- queueNativeMetadataStart(`geometry batch ${batchCount}`);
1360
- }
1361
-
1362
- const timeSinceLastRender = eventReceived - lastRenderTime;
1363
- const allowTimeBasedFlush = !hugeNativeMode || ENABLE_HUGE_TIME_FLUSH;
1364
- const shouldRender =
1365
- batchCount === 1 ||
1366
- pendingMeshes.length >= NATIVE_PENDING_MESH_THRESHOLD ||
1367
- (allowTimeBasedFlush && timeSinceLastRender >= RENDER_INTERVAL_MS);
1368
-
1369
- if (shouldRender && pendingMeshes.length > 0) {
1370
- await flushPendingNativeMeshes(event.coordinateInfo, totalMeshes);
1371
- lastRenderTime = eventReceived;
1372
-
1373
- const progressPercent = 50 + Math.min(45, (totalMeshes / Math.max(estimatedTotal / 10, totalMeshes || 1)) * 45);
1374
- setProgress({
1375
- phase: `Rendering geometry (${totalMeshes} meshes)`,
1376
- percent: progressPercent,
1377
- indeterminate: false,
1378
- });
1379
- setGeometryProgress({
1380
- phase: `Rendering geometry (${totalMeshes} meshes)`,
1381
- percent: Math.min(99, progressPercent),
1382
- indeterminate: false,
1383
- });
1384
- }
1385
- break;
1386
- }
1387
- case 'complete':
1388
- nativeLoadStage = 'finalizeGeometry';
1389
- geometryCompleted = true;
1390
- streamCompleteMs = performance.now() - totalStartTime;
1391
- if (pendingMeshes.length > 0) {
1392
- await flushPendingNativeMeshes(event.coordinateInfo, lastTotalMeshes);
1393
- }
1394
-
1395
- finalCoordinateInfo = event.coordinateInfo;
1396
- updateCoordinateInfo(finalCoordinateInfo);
1397
- maybeBuildNativeSpatialIndex();
1398
- if (nativeMetadataStartGate === 'afterGeometryComplete' && !metadataParsingStarted) {
1399
- queueNativeMetadataStart('geometry complete');
1400
- }
1401
- setProgress({
1402
- phase: hugeNativeMode ? 'Geometry ready, hydrating metadata' : 'Complete',
1403
- percent: 100,
1404
- });
1405
- setGeometryProgress({
1406
- phase: 'Geometry interactive',
1407
- percent: 100,
1408
- });
1409
- setMetadataProgress(
1410
- hugeNativeMode
1411
- ? { phase: 'Preparing metadata', percent: nativeMetadataStartGate === 'afterGeometryComplete' ? 5 : 0, indeterminate: false }
1412
- : { phase: 'Metadata complete', percent: 100 }
1413
- );
1414
- updateModel(primaryModelId, {
1415
- loadState: hugeNativeMode ? 'hydrating-metadata' : 'complete',
1416
- geometryLoadState: 'complete',
1417
- metadataLoadState: hugeNativeMode ? 'bootstrapping' : 'complete',
1418
- interactiveReady: true,
1419
- cacheState: nativeGeometryCacheHit ? 'hit' : shouldUseNativeCache ? 'writing' : 'none',
1420
- });
1421
- console.log(`[useIfc] Native geometry streaming complete: ${batchCount} batches, ${lastTotalMeshes} meshes`);
1422
- void logToDesktopTerminal(
1423
- 'info',
1424
- `[useIfc] Native stream complete for ${fileName}: stage=${nativeLoadStage} batches=${batchCount}, meshes=${lastTotalMeshes}`
1425
- );
1426
- await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
1427
- if (loadSessionRef.current === currentSession) {
1428
- setGeometryStreamingActive(false);
1429
- }
1430
- break;
1431
- }
1432
- }
1433
-
1434
- nativeStats = geometryProcessor.getLastNativeStats();
1435
-
1436
- const totalElapsedMs = performance.now() - totalStartTime;
1437
- console.log(
1438
- `[useIfc] ✓ ${fileName} (${fileSizeMB.toFixed(1)}MB) → ` +
1439
- `${lastTotalMeshes} meshes, ${(totalVertices / 1000).toFixed(0)}k vertices | ` +
1440
- `first: ${firstGeometryTime.toFixed(0)}ms, total: ${totalElapsedMs.toFixed(0)}ms`
1441
- );
1442
- if (nativeStats) {
1443
- void logToDesktopTerminal(
1444
- 'info',
1445
- `[useIfc] Native timings for ${fileName}: scan=${nativeStats.entityScanTimeMs ?? 0}ms lookup=${nativeStats.lookupTimeMs ?? 0}ms preprocess=${nativeStats.preprocessTimeMs ?? 0}ms parse=${nativeStats.parseTimeMs ?? 0}ms geometry=${nativeStats.geometryTimeMs ?? 0}ms total=${nativeStats.totalTimeMs ?? 0}ms`
1446
- );
1447
- }
1448
- if (!metadataParsingStarted) {
1449
- console.warn('[useIfc] Native large-file mode completed without metadata parsing');
1450
- void logToDesktopTerminal('warn', `[useIfc] Native large-file mode completed without metadata parsing for ${fileName}`);
1451
- }
1452
- if (harnessRequest?.waitForMetadataCompletion) {
1453
- if (!metadataParsingStarted) {
1454
- startNativeMetadataParsing();
1455
- }
1456
- if (metadataParsingPromise) {
1457
- await metadataParsingPromise;
1458
- }
1459
- if (metadataSnapshotWritePromise) {
1460
- await metadataSnapshotWritePromise;
1461
- }
1462
- }
1463
- if (firstVisibleGeometryMs === null && firstAppendGeometryBatchMs !== null) {
1464
- await new Promise<void>((resolve) => {
1465
- const fallbackTimer = globalThis.setTimeout(() => {
1466
- if (firstVisibleGeometryMs === null && loadSessionRef.current === currentSession) {
1467
- firstVisibleGeometryMs = firstAppendGeometryBatchMs;
1468
- }
1469
- resolve();
1470
- }, 250);
1471
- requestAnimationFrame(() => {
1472
- globalThis.clearTimeout(fallbackTimer);
1473
- if (firstVisibleGeometryMs === null && loadSessionRef.current === currentSession) {
1474
- firstVisibleGeometryMs = performance.now() - totalStartTime;
1475
- }
1476
- resolve();
1477
- });
1478
- });
1479
- }
1480
- if (hugeNativeMode) {
1481
- setLoading(false);
1482
- }
1483
- const telemetryElapsedMs = performance.now() - totalStartTime;
1484
- await finalizeActiveHarnessRun({
1485
- schemaVersion: 1,
1486
- source: 'desktop-native',
1487
- mode: harnessRequest ? 'startup-harness' : 'manual',
1488
- success: true,
1489
- runLabel: harnessRequest?.runLabel,
1490
- cache: {
1491
- key: nativeCacheKey,
1492
- hit: nativeGeometryCacheHit,
1493
- manifestMeshCount: null,
1494
- manifestShardCount: null,
1495
- },
1496
- file: {
1497
- path: file.path,
1498
- name: file.name,
1499
- sizeBytes: file.size,
1500
- sizeMB: fileSizeMB,
1501
- },
1502
- timings: {
1503
- modelOpenMs,
1504
- firstBatchWaitMs: firstGeometryTime || null,
1505
- firstAppendGeometryBatchMs,
1506
- firstVisibleGeometryMs,
1507
- streamCompleteMs,
1508
- totalWallClockMs: telemetryElapsedMs,
1509
- metadataStartMs,
1510
- metadataReadCompleteMs,
1511
- metadataParseStartMs,
1512
- spatialReadyMs,
1513
- metadataCompleteMs,
1514
- metadataFailedMs,
1515
- metadataReadDurationMs,
1516
- metadataBufferCopyDurationMs,
1517
- metadataParseDurationMs,
1518
- },
1519
- batches: {
1520
- estimatedTotal,
1521
- totalBatches: batchCount,
1522
- totalMeshes: lastTotalMeshes,
1523
- firstBatchMeshes: firstNativeBatchTelemetry?.meshCount ?? null,
1524
- firstPayloadKind: firstNativeBatchTelemetry?.payloadKind ?? null,
1525
- },
1526
- nativeStats: nativeStats
1527
- ? {
1528
- parseTimeMs: nativeStats.parseTimeMs ?? null,
1529
- entityScanTimeMs: nativeStats.entityScanTimeMs ?? null,
1530
- lookupTimeMs: nativeStats.lookupTimeMs ?? null,
1531
- preprocessTimeMs: nativeStats.preprocessTimeMs ?? null,
1532
- geometryTimeMs: nativeStats.geometryTimeMs ?? null,
1533
- totalTimeMs: nativeStats.totalTimeMs ?? null,
1534
- firstChunkReadyTimeMs: nativeStats.firstChunkReadyTimeMs ?? null,
1535
- firstChunkPackTimeMs: nativeStats.firstChunkPackTimeMs ?? null,
1536
- firstChunkEmittedTimeMs: nativeStats.firstChunkEmittedTimeMs ?? null,
1537
- firstChunkEmitTimeMs: nativeStats.firstChunkEmitTimeMs ?? null,
1538
- }
1539
- : null,
1540
- metadata: {
1541
- started: metadataParsingStarted,
1542
- metadataStartMs,
1543
- metadataReadCompleteMs,
1544
- metadataParseStartMs,
1545
- spatialReadyMs,
1546
- metadataCompleteMs,
1547
- metadataFailedMs,
1548
- metadataReadDurationMs,
1549
- metadataBufferCopyDurationMs,
1550
- metadataParseDurationMs,
1551
- },
1552
- firstBatchTelemetry: firstNativeBatchTelemetry
1553
- ? {
1554
- batchSequence: firstNativeBatchTelemetry.batchSequence,
1555
- payloadKind: firstNativeBatchTelemetry.payloadKind,
1556
- meshCount: firstNativeBatchTelemetry.meshCount,
1557
- positionsLen: firstNativeBatchTelemetry.positionsLen,
1558
- normalsLen: firstNativeBatchTelemetry.normalsLen,
1559
- indicesLen: firstNativeBatchTelemetry.indicesLen,
1560
- rustChunkReadyMs: firstNativeBatchTelemetry.chunkReadyTimeMs,
1561
- rustPackMs: firstNativeBatchTelemetry.packTimeMs,
1562
- rustEmittedMs: firstNativeBatchTelemetry.emittedTimeMs,
1563
- rustEmitMs: firstNativeBatchTelemetry.emitTimeMs,
1564
- jsReceivedMs: jsFirstChunkReceivedMs,
1565
- transportToJsMs:
1566
- jsFirstChunkReceivedMs !== null
1567
- ? jsFirstChunkReceivedMs - firstNativeBatchTelemetry.emittedTimeMs
1568
- : null,
1569
- appendAfterReceiveMs:
1570
- jsFirstChunkReceivedMs !== null && firstAppendGeometryBatchMs !== null
1571
- ? firstAppendGeometryBatchMs - jsFirstChunkReceivedMs
1572
- : null,
1573
- visibleAfterAppendMs:
1574
- firstVisibleGeometryMs !== null && firstAppendGeometryBatchMs !== null
1575
- ? firstVisibleGeometryMs - firstAppendGeometryBatchMs
1576
- : null,
1577
- }
1578
- : null,
1579
- });
1580
- if (!hugeNativeMode) {
1581
- setLoading(false);
1582
- }
1583
- return;
1584
- }
1585
371
 
1586
372
  // Read file from disk. The browser path streams files ≥
1587
373
  // STREAM_SAB_THRESHOLD directly into a SharedArrayBuffer, which avoids
1588
374
  // a doubled-peak ArrayBuffer + SAB allocation when the geometry
1589
- // pipeline copies into its own SAB. The native path still reads via
1590
- // Tauri's Rust IPC because it bounds memory differently. (#600)
375
+ // pipeline copies into its own SAB. (#600)
1591
376
  const fileReadStart = performance.now();
1592
- let acquired: AcquiredBuffer;
1593
- if (isNativeFileHandle(file)) {
1594
- const nativeBytes = await readNativeFile(file.path);
1595
- const nativeBuffer = toExactArrayBuffer(nativeBytes);
1596
- acquired = {
1597
- buffer: nativeBuffer,
1598
- view: new Uint8Array(nativeBuffer),
1599
- isShared: false,
1600
- };
1601
- } else {
1602
- acquired = await acquireFileBuffer(file as File);
1603
- }
377
+ const acquired: AcquiredBuffer = await acquireFileBuffer(file);
1604
378
  // `buffer` retains its previous semantics (ArrayBuffer-shaped) for
1605
379
  // every downstream consumer. When `acquired.isShared` is true the
1606
380
  // backing store is a SharedArrayBuffer; downstream code only ever
@@ -1621,13 +395,13 @@ export function useIfcLoader() {
1621
395
  const renderer = getGlobalRenderer();
1622
396
  if (!renderer) {
1623
397
  setError('Renderer not initialised — try again after the viewer mounts.');
1624
- updateModel(primaryModelId, { loadState: 'error', loadError: 'renderer-missing' });
398
+ updateModel(modelId, { loadState: 'error', loadError: 'renderer-missing' });
1625
399
  setLoading(false);
1626
400
  return;
1627
401
  }
1628
402
  setProgress({ phase: `Streaming ${format.toUpperCase()}`, percent: 5 });
1629
403
  setGeometryStreamingActive(false);
1630
- const blob = isNativeFileHandle(file) ? new Blob([buffer]) : (file as File);
404
+ const blob = file;
1631
405
  const incCount = useViewerStore.getState().incrementPointCloudAssetCount;
1632
406
  const ingest = ingestPointCloud({
1633
407
  format,
@@ -1677,17 +451,17 @@ export function useIfcLoader() {
1677
451
  const isAbort = err instanceof DOMException && err.name === 'AbortError';
1678
452
  if (isAbort) {
1679
453
  console.log(
1680
- `[useIfc] pointcloud ingest cancelled (model=${primaryModelId}, handle=${ingest.rendererHandle.id})`,
454
+ `[useIfc] pointcloud ingest cancelled (model=${modelId}, handle=${ingest.rendererHandle.id})`,
1681
455
  );
1682
- updateModel(primaryModelId, { loadState: 'error', loadError: 'cancelled' });
456
+ updateModel(modelId, { loadState: 'error', loadError: 'cancelled' });
1683
457
  setError(null);
1684
458
  setProgress({ phase: 'Cancelled', percent: 0 });
1685
459
  } else {
1686
460
  console.error(
1687
- `[useIfc] pointcloud ingest failed (format=${format}, model=${primaryModelId}):`,
461
+ `[useIfc] pointcloud ingest failed (format=${format}, model=${modelId}):`,
1688
462
  err,
1689
463
  );
1690
- updateModel(primaryModelId, { loadState: 'error', loadError: message });
464
+ updateModel(modelId, { loadState: 'error', loadError: message });
1691
465
  setError(`${format.toUpperCase()} parsing failed: ${message}`);
1692
466
  }
1693
467
  clearOwnedCanceller();
@@ -1702,9 +476,13 @@ export function useIfcLoader() {
1702
476
  renderer.removePointCloudAsset(ingest.rendererHandle);
1703
477
  return;
1704
478
  }
1705
- setGeometryResult(ingest.geometryResult);
1706
- setIfcDataStore(ingest.dataStore);
1707
- finalizePrimaryModel(ingest.dataStore, ingest.geometryResult, ingest.schemaVersion, {
479
+ // Primary owns the active-model slots; a federated add must not touch
480
+ // them (finalizeModel's federated branch wires via addModel instead).
481
+ if (target.kind === 'primary') {
482
+ setGeometryResult(ingest.geometryResult);
483
+ setIfcDataStore(ingest.dataStore);
484
+ }
485
+ await finalizeModel(ingest.dataStore, ingest.geometryResult, ingest.schemaVersion, {
1708
486
  pointCloudHandleId: ingest.rendererHandle.id,
1709
487
  });
1710
488
  setProgress({ phase: 'Complete', percent: 100 });
@@ -1719,9 +497,11 @@ export function useIfcLoader() {
1719
497
 
1720
498
  try {
1721
499
  const result = await parseIfcxViewerModel(buffer, setProgress);
1722
- setGeometryResult(result.geometryResult);
1723
- setIfcDataStore(result.dataStore);
1724
- finalizePrimaryModel(result.dataStore, result.geometryResult, result.schemaVersion);
500
+ if (target.kind === 'primary') {
501
+ setGeometryResult(result.geometryResult);
502
+ setIfcDataStore(result.dataStore);
503
+ }
504
+ await finalizeModel(result.dataStore, result.geometryResult, result.schemaVersion);
1725
505
 
1726
506
  setProgress({ phase: 'Complete', percent: 100 });
1727
507
  setLoading(false);
@@ -1731,13 +511,13 @@ export function useIfcLoader() {
1731
511
  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
512
  console.warn('[useIfc] To use this file, load it together with a base IFCX file (select both files at once).');
1733
513
  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' });
514
+ updateModel(modelId, { loadState: 'error', loadError: 'overlay-only-ifcx' });
1735
515
  setLoading(false);
1736
516
  return;
1737
517
  }
1738
518
  console.error('[useIfc] IFCX parsing failed:', err);
1739
519
  const message = err instanceof Error ? err.message : String(err);
1740
- updateModel(primaryModelId, { loadState: 'error', loadError: message });
520
+ updateModel(modelId, { loadState: 'error', loadError: message });
1741
521
  setError(`IFCX parsing failed: ${message}`);
1742
522
  setLoading(false);
1743
523
  return;
@@ -1751,9 +531,18 @@ export function useIfcLoader() {
1751
531
 
1752
532
  try {
1753
533
  const result = await parseGlbViewerModel(buffer);
1754
- setGeometryResult(result.geometryResult);
1755
- setIfcDataStore(null);
1756
- finalizePrimaryModel(null, result.geometryResult, result.schemaVersion);
534
+ if (target.kind === 'primary') {
535
+ setGeometryResult(result.geometryResult);
536
+ setIfcDataStore(null);
537
+ }
538
+ // Primary keeps the historical null data store (GLB has no entities);
539
+ // a federated add needs the minimal store so finalizeModel can offset
540
+ // ids + register the model (matches the old addModel GLB path).
541
+ await finalizeModel(
542
+ target.kind === 'federated' ? result.dataStore : null,
543
+ result.geometryResult,
544
+ result.schemaVersion,
545
+ );
1757
546
 
1758
547
  setProgress({ phase: 'Complete', percent: 100 });
1759
548
 
@@ -1762,7 +551,7 @@ export function useIfcLoader() {
1762
551
  } catch (err: unknown) {
1763
552
  console.error('[useIfc] GLB parsing failed:', err);
1764
553
  const message = err instanceof Error ? err.message : String(err);
1765
- updateModel(primaryModelId, { loadState: 'error', loadError: message });
554
+ updateModel(modelId, { loadState: 'error', loadError: message });
1766
555
  setError(`GLB parsing failed: ${message}`);
1767
556
  setLoading(false);
1768
557
  return;
@@ -1776,14 +565,19 @@ export function useIfcLoader() {
1776
565
  // persisted key filename-safe and independent of the original filename.
1777
566
  const cacheKey = `ifc-${buffer.byteLength}-${fingerprint}-v4`;
1778
567
 
1779
- if (buffer.byteLength >= CACHE_SIZE_THRESHOLD) {
568
+ // Cache + server are PRIMARY-ONLY: a federated add is WASM-only with no
569
+ // cache/server round-trip (matches the former parseStepBufferViewerModel).
570
+ if (target.kind === 'primary' && buffer.byteLength >= CACHE_SIZE_THRESHOLD) {
1780
571
  setProgress({ phase: 'Checking cache', percent: 5 });
1781
572
  const cacheResult = await getCached(cacheKey);
1782
573
  if (cacheResult) {
1783
- const cacheLoadResult = await loadFromCache(cacheResult, file.name, cacheKey);
574
+ // Pass the freshly read file buffer as the source fallback: the
575
+ // desktop cache doesn't persist a sourceBuffer, and without one the
576
+ // restored store can't carry the lazy entity accessors.
577
+ const cacheLoadResult = await loadFromCache(cacheResult, file.name, cacheKey, buffer);
1784
578
  if (cacheLoadResult.success) {
1785
579
  const state = useViewerStore.getState();
1786
- finalizePrimaryModel(state.ifcDataStore, state.geometryResult, getSchemaVersion(state.ifcDataStore), {
580
+ await finalizeModel(state.ifcDataStore, state.geometryResult, getSchemaVersion(state.ifcDataStore), {
1787
581
  loadState: 'complete',
1788
582
  cacheState: 'hit',
1789
583
  });
@@ -1798,12 +592,12 @@ export function useIfcLoader() {
1798
592
  // Only for IFC4 STEP files (server doesn't support IFCX). Native
1799
593
  // file handles (Tauri) don't have an HTTP-uploadable body, so skip
1800
594
  // the server path and fall through to the WASM loader.
1801
- if (format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '' && !isNativeFileHandle(file)) {
595
+ if (target.kind === 'primary' && format === 'ifc' && USE_SERVER && SERVER_URL && SERVER_URL !== '') {
1802
596
  // Pass buffer directly - server uses File object for parsing, buffer is only for size checks
1803
597
  const serverSuccess = await loadFromServer(file, buffer, () => loadSessionRef.current !== currentSession);
1804
598
  if (serverSuccess) {
1805
599
  const state = useViewerStore.getState();
1806
- finalizePrimaryModel(state.ifcDataStore, state.geometryResult, getSchemaVersion(state.ifcDataStore));
600
+ await finalizeModel(state.ifcDataStore, state.geometryResult, getSchemaVersion(state.ifcDataStore));
1807
601
  console.log(`[useIfc] TOTAL LOAD TIME (server): ${(performance.now() - totalStartTime).toFixed(0)}ms`);
1808
602
  setLoading(false);
1809
603
  return;
@@ -1814,12 +608,11 @@ export function useIfcLoader() {
1814
608
 
1815
609
  // Using local WASM parsing
1816
610
  setProgress({ phase: 'Starting geometry streaming', percent: 10 });
1817
- setGeometryStreamingActive(true);
1818
-
1819
- const shouldUseDesktopStableWasmGeometry =
1820
- isNativeFileHandle(file)
1821
- && fileName.toLowerCase().endsWith('.ifc')
1822
- && file.size < HUGE_NATIVE_FILE_THRESHOLD;
611
+ // Global streaming flag is a PRIMARY (active-model) concern; a federated
612
+ // add must not toggle it (the former federated path never did).
613
+ if (target.kind === 'primary') {
614
+ setGeometryStreamingActive(true);
615
+ }
1823
616
 
1824
617
  // Initialize geometry processor first (WASM init is fast if already loaded)
1825
618
  const mergeLayersAtLoad = useViewerStore.getState().mergeLayers;
@@ -1831,6 +624,11 @@ export function useIfcLoader() {
1831
624
  mergeLayers: mergeLayersAtLoad,
1832
625
  });
1833
626
  await geometryProcessor.init();
627
+ // Issue #924: enable RTC-invariant per-entity geometry fingerprints so
628
+ // the model-compare feature can detect geometry changes. The hash rides
629
+ // on each MeshData.geometryHash (and through the worker pool); cost is
630
+ // the O(verts) quantized hash, negligible next to tessellation.
631
+ geometryProcessor.enableGeometryHashes();
1834
632
 
1835
633
  // Allocate (or reuse) a SharedArrayBuffer so the parser worker and
1836
634
  // the geometry workers read the same memory zero-copy. When
@@ -1840,7 +638,7 @@ export function useIfcLoader() {
1840
638
  // available, AND TextDecoder accepts SAB-backed views (Firefox fails
1841
639
  // the third check; we skip the worker path entirely there so the
1842
640
  // SAB allocation isn't wasted).
1843
- const useParserWorker = WorkerParser.isSupported() && !isNativeFileHandle(file);
641
+ const useParserWorker = WorkerParser.isSupported();
1844
642
  let sharedSource: SharedArrayBuffer | null = null;
1845
643
  if (useParserWorker) {
1846
644
  if (acquired.isShared && acquired.buffer instanceof SharedArrayBuffer) {
@@ -1881,7 +679,10 @@ export function useIfcLoader() {
1881
679
  partialStore.spatialHierarchy.storeyHeights.set(storeyId, height);
1882
680
  }
1883
681
  }
1884
- setIfcDataStore(partialStore);
682
+ // PRIMARY only: setIfcDataStore writes the ACTIVE model. A federated
683
+ // add must not touch model #1's store — it wires its own via
684
+ // finalizeModel → addModel once dataStorePromise resolves.
685
+ if (target.kind === 'primary') setIfcDataStore(partialStore);
1885
686
  };
1886
687
 
1887
688
  const onFullDataStore = (dataStore: IfcDataStore) => {
@@ -1893,7 +694,10 @@ export function useIfcLoader() {
1893
694
  dataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
1894
695
  }
1895
696
  }
1896
- setIfcDataStore(dataStore);
697
+ // PRIMARY only (active-model write); federated wires via finalizeModel.
698
+ // resolveDataStore stays unconditional so the federated finalizePromise
699
+ // still resolves and registers the model.
700
+ if (target.kind === 'primary') setIfcDataStore(dataStore);
1897
701
  console.log(`[useIfc] Data model parsing complete for ${file.name}: ${metadataCompleteMs.toFixed(0)}ms`);
1898
702
  memoryAccounting.endPhase('parser-worker');
1899
703
  memoryAccounting.recordPhase({ phase: 'parser-complete' });
@@ -1904,7 +708,7 @@ export function useIfcLoader() {
1904
708
  // Same `wasmApi` heuristic as before — desktop loads cannot share
1905
709
  // the geometry processor's WASM instance with the parser without
1906
710
  // risking corruption.
1907
- const parserWasmApi = isNativeFileHandle(file) ? undefined : geometryProcessor.getApi();
711
+ const parserWasmApi = geometryProcessor.getApi();
1908
712
  return new IfcParser().parseColumnar(buffer, {
1909
713
  wasmApi: parserWasmApi ?? undefined,
1910
714
  onSpatialReady: onPartialDataStore,
@@ -1925,7 +729,6 @@ export function useIfcLoader() {
1925
729
  const ADAPTIVE_SYNC_THRESHOLD_MB = 2;
1926
730
  const geometryWillEmitEntityIndex =
1927
731
  useParserWorker
1928
- && !shouldUseDesktopStableWasmGeometry
1929
732
  && fileSizeMB >= ADAPTIVE_SYNC_THRESHOLD_MB;
1930
733
 
1931
734
  const startDataModelParsing = () => {
@@ -2001,8 +804,11 @@ export function useIfcLoader() {
2001
804
  let metadataCompleteMs: number | null = null;
2002
805
  let metadataFailedMs: number | null = null;
2003
806
 
2004
- // Clear existing geometry result
2005
- setGeometryResult(null);
807
+ // Clear existing geometry result — PRIMARY only (federated must not
808
+ // disturb the active model's geometry).
809
+ if (target.kind === 'primary') {
810
+ setGeometryResult(null);
811
+ }
2006
812
 
2007
813
  // Timing instrumentation
2008
814
  let batchCount = 0;
@@ -2025,6 +831,11 @@ export function useIfcLoader() {
2025
831
 
2026
832
  // Declare at function scope so the catch block can always reach it.
2027
833
  let closeGeometryIterator: (() => Promise<void>) | null = null;
834
+ // The background finalize (spatial index / cache for primary; align +
835
+ // addModel for federated). Primary leaves it running in the background
836
+ // for a fast first frame; federated MUST await it so the model is
837
+ // registered before loadFile resolves (loadFilesSequentially relies on it).
838
+ let finalizePromise: Promise<void> | null = null;
2028
839
 
2029
840
  try {
2030
841
  // Use dynamic batch sizing for optimal throughput
@@ -2033,12 +844,13 @@ export function useIfcLoader() {
2033
844
  // When the parser worker is in use, hand the geometry workers the
2034
845
  // same SAB so we don't pay the file-bytes copy twice.
2035
846
  const geometryView = sharedSource ? new Uint8Array(sharedSource) : new Uint8Array(buffer);
2036
- const geometryEvents = shouldUseDesktopStableWasmGeometry
2037
- ? geometryProcessor.processStreaming(geometryView, undefined, dynamicBatchConfig)
2038
- : geometryProcessor.processAdaptive(geometryView, {
847
+ const geometryEvents = geometryProcessor.processAdaptive(geometryView, {
2039
848
  sizeThreshold: 2 * 1024 * 1024, // 2MB threshold
2040
849
  batchSize: dynamicBatchConfig, // Dynamic batches: small first, then large
2041
850
  existingSab: sharedSource ?? undefined,
851
+ // Federated adds share the anchor's RTC origin so all models sit in
852
+ // one coordinate space (pixel-perfect alignment, no post-shift).
853
+ sharedRtcOffset: target.kind === 'federated' ? target.sharedRtcOffset : undefined,
2042
854
  // Hand the streaming pre-pass's entity index to the parser
2043
855
  // worker so it skips a duplicate ~10 s WASM scan. Safe even
2044
856
  // when the parser falls back to main-thread (instance is
@@ -2062,7 +874,7 @@ export function useIfcLoader() {
2062
874
 
2063
875
  while (true) {
2064
876
  const watchdogMs = getGeometryStreamWatchdogMs(
2065
- shouldUseDesktopStableWasmGeometry,
877
+ false,
2066
878
  batchCount,
2067
879
  fileSizeMB,
2068
880
  );
@@ -2141,29 +953,39 @@ export function useIfcLoader() {
2141
953
  totalMeshes = event.totalSoFar;
2142
954
  lastTotalMeshes = event.totalSoFar;
2143
955
 
2144
- // Accumulate meshes for batched rendering
2145
- for (let i = 0; i < event.meshes.length; i++) pendingMeshes.push(event.meshes[i]);
956
+ if (target.kind === 'primary') {
957
+ // Accumulate meshes for batched rendering
958
+ for (let i = 0; i < event.meshes.length; i++) pendingMeshes.push(event.meshes[i]);
2146
959
 
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;
960
+ // FIRST BATCH: Render immediately for fast first frame
961
+ // SUBSEQUENT: Throttle to reduce React re-renders
962
+ const timeSinceLastRender = eventReceived - lastRenderTime;
963
+ const shouldRender = batchCount === 1 || timeSinceLastRender >= RENDER_INTERVAL_MS;
2151
964
 
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`);
965
+ if (shouldRender && pendingMeshes.length > 0) {
966
+ if (firstAppendGeometryBatchMs === null) {
967
+ firstAppendGeometryBatchMs = performance.now() - totalStartTime;
968
+ console.log(`[useIfc] First appendGeometryBatch for ${file.name}: ${firstAppendGeometryBatchMs.toFixed(0)}ms`);
969
+ }
970
+ appendGeometryBatch(pendingMeshes, event.coordinateInfo);
971
+ pendingMeshes = [];
972
+ lastRenderTime = eventReceived;
973
+ markFirstVisibleGeometry();
974
+
975
+ // Update progress
976
+ const progressPercent = 50 + Math.min(45, (totalMeshes / Math.max(estimatedTotal / 10, totalMeshes)) * 45);
977
+ setProgress({
978
+ phase: `Rendering geometry (${totalMeshes} meshes)`,
979
+ percent: progressPercent
980
+ });
2156
981
  }
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);
982
+ } else {
983
+ // Federated add: accumulate into allMeshes only (done above) and
984
+ // surface progress — it paints atomically at completion via
985
+ // finalizeModel's addModel, never touching the active slot.
2164
986
  setProgress({
2165
- phase: `Rendering geometry (${totalMeshes} meshes)`,
2166
- percent: progressPercent
987
+ phase: `Processing geometry (${totalMeshes} meshes)`,
988
+ percent: 10 + Math.min(80, (allMeshes.length / 1000) * 0.8),
2167
989
  });
2168
990
  }
2169
991
 
@@ -2171,8 +993,9 @@ export function useIfcLoader() {
2171
993
  }
2172
994
  case 'complete':
2173
995
  streamCompleteMs = performance.now() - totalStartTime;
2174
- // Flush any remaining pending meshes
2175
- if (pendingMeshes.length > 0) {
996
+ // Flush remaining pending meshes — PRIMARY only. A federated add
997
+ // never pushed to pendingMeshes; it paints atomically at finalize.
998
+ if (target.kind === 'primary' && pendingMeshes.length > 0) {
2176
999
  if (firstAppendGeometryBatchMs === null) {
2177
1000
  firstAppendGeometryBatchMs = performance.now() - totalStartTime;
2178
1001
  console.log(`[useIfc] First appendGeometryBatch for ${file.name}: ${firstAppendGeometryBatchMs.toFixed(0)}ms`);
@@ -2184,40 +1007,58 @@ export function useIfcLoader() {
2184
1007
 
2185
1008
  finalCoordinateInfo = event.coordinateInfo ?? null;
2186
1009
 
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
1010
+ // Store captured RTC offset in coordinate info for multi-model alignment.
2197
1011
  if (finalCoordinateInfo && capturedRtcOffset) {
2198
1012
  finalCoordinateInfo.wasmRtcOffset = capturedRtcOffset;
2199
1013
  }
2200
1014
 
2201
- // Update geometry result with final coordinate info
2202
- updateCoordinateInfo(finalCoordinateInfo);
1015
+ if (target.kind === 'primary') {
1016
+ // Active-model writes — PRIMARY only. Federated meshes already
1017
+ // carry colours (applied during streaming) and their coordinate
1018
+ // info rides the geometryResult handed to addModel at finalize.
1019
+ if (cumulativeColorUpdates.size > 0) {
1020
+ updateMeshColors(cumulativeColorUpdates);
1021
+ }
1022
+ updateCoordinateInfo(finalCoordinateInfo);
1023
+ }
2203
1024
 
2204
1025
  setProgress({ phase: 'Complete', percent: 100 });
2205
1026
  memoryAccounting.endPhase('geometry');
2206
1027
  memoryAccounting.recordPhase({ phase: 'geometry-complete' });
2207
1028
  console.log(memoryAccounting.formatSummary());
2208
1029
  await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
2209
- if (loadSessionRef.current === currentSession) {
1030
+ if (loadSessionRef.current === currentSession && target.kind === 'primary') {
2210
1031
  setGeometryStreamingActive(false);
2211
1032
  }
2212
1033
  console.log(`[useIfc] Geometry streaming complete: ${batchCount} batches, ${lastTotalMeshes} meshes`);
2213
1034
  console.log(`[useIfc] Stream complete for ${file.name}: ${streamCompleteMs.toFixed(0)}ms`);
2214
1035
 
2215
- // Build spatial index and cache in background (non-blocking)
2216
- // Wait for data model to complete first
2217
- dataStorePromise.then(async dataStore => {
1036
+ // Finalize once the data model is ready (parses in parallel).
1037
+ finalizePromise = dataStorePromise.then(async dataStore => {
2218
1038
  // Guard: skip if user loaded a new file since this load started
2219
1039
  if (loadSessionRef.current !== currentSession) return;
2220
- finalizePrimaryModel(dataStore, useViewerStore.getState().geometryResult, getSchemaVersion(dataStore), {
1040
+
1041
+ if (target.kind === 'federated') {
1042
+ // Build the model's geometryResult from the accumulated meshes —
1043
+ // federated never streamed into the active slot — and hand it to
1044
+ // finalizeModel, which aligns, offsets ids, builds the spatial
1045
+ // index, and registers the model via addModel. NOT cached (the
1046
+ // former federated path never cached); allMeshes stays alive as
1047
+ // the model's geometryResult.meshes, so it is NOT cleared.
1048
+ applyColorUpdatesToMeshes(allMeshes, cumulativeColorUpdates);
1049
+ const federatedGeometry: GeometryResult = {
1050
+ meshes: allMeshes,
1051
+ totalVertices: allMeshes.reduce((sum, m) => sum + m.positions.length / 3, 0),
1052
+ totalTriangles: allMeshes.reduce((sum, m) => sum + m.indices.length / 3, 0),
1053
+ coordinateInfo: finalCoordinateInfo ?? createCoordinateInfo(calculateMeshBounds(allMeshes).bounds),
1054
+ };
1055
+ await finalizeModel(dataStore, federatedGeometry, getSchemaVersion(dataStore), {
1056
+ loadState: 'complete',
1057
+ });
1058
+ return;
1059
+ }
1060
+
1061
+ await finalizeModel(dataStore, useViewerStore.getState().geometryResult, getSchemaVersion(dataStore), {
2221
1062
  loadState: 'complete',
2222
1063
  cacheState: buffer.byteLength >= CACHE_SIZE_THRESHOLD ? 'writing' : 'none',
2223
1064
  });
@@ -2261,10 +1102,19 @@ export function useIfcLoader() {
2261
1102
  }).catch(err => {
2262
1103
  // Data model parsing failed - spatial index and caching skipped
2263
1104
  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
- });
1105
+ const message = err instanceof Error ? err.message : String(err);
1106
+ if (target.kind === 'federated') {
1107
+ // No placeholder model exists for a federated add (it is only
1108
+ // registered on success via finalizeModel→addModel), so
1109
+ // updateModel would no-op and the failure would vanish —
1110
+ // addModel just returns null. Surface it to the user instead.
1111
+ toast.error(`Failed to load "${file.name}": ${message}`);
1112
+ } else {
1113
+ updateModel(modelId, {
1114
+ loadState: 'error',
1115
+ loadError: message,
1116
+ });
1117
+ }
2268
1118
  });
2269
1119
  break;
2270
1120
  }
@@ -2275,6 +1125,11 @@ export function useIfcLoader() {
2275
1125
  if (closeGeometryIterator) {
2276
1126
  await closeGeometryIterator();
2277
1127
  }
1128
+ // The parser worker may be parked in `waitForEntityIndex` (the aborted
1129
+ // geometry pre-pass would have unblocked it); it self-terminates on its
1130
+ // own watchdog. Swallow the now-orphaned dataStorePromise rejection so
1131
+ // it doesn't surface as an unhandled rejection.
1132
+ void dataStorePromise.catch(() => {});
2278
1133
  if (loadSessionRef.current !== currentSession) return;
2279
1134
  console.error('[useIfc] Error in processing:', err);
2280
1135
  setError(err instanceof Error ? err.message : 'Unknown error during geometry processing');
@@ -2285,6 +1140,14 @@ export function useIfcLoader() {
2285
1140
 
2286
1141
  if (loadSessionRef.current !== currentSession) return;
2287
1142
 
1143
+ // Federated adds register the model inside finalizePromise (georef align
1144
+ // → id offset → spatial index → addModel). Await it so loadFile resolves
1145
+ // only AFTER the model is in the map — loadFilesSequentially loads the
1146
+ // next file serially and relies on this ordering for id-offset assignment.
1147
+ if (target.kind === 'federated' && finalizePromise) {
1148
+ await finalizePromise;
1149
+ }
1150
+
2288
1151
  if (firstVisibleGeometryMs === null && firstAppendGeometryBatchMs !== null) {
2289
1152
  await new Promise<void>((resolve) => {
2290
1153
  const fallbackTimer = globalThis.setTimeout(() => {
@@ -2314,41 +1177,10 @@ export function useIfcLoader() {
2314
1177
  setGeometryStreamingActive(false);
2315
1178
  } catch (err) {
2316
1179
  if (loadSessionRef.current !== currentSession) return;
2317
- updateModel(primaryModelId, {
1180
+ updateModel(modelId, {
2318
1181
  loadState: 'error',
2319
1182
  loadError: err instanceof Error ? err.message : String(err),
2320
1183
  });
2321
- if (isNativeFileHandle(file)) {
2322
- const harnessRequest = getActiveHarnessRequest();
2323
- await finalizeActiveHarnessRun({
2324
- schemaVersion: 1,
2325
- source: 'desktop-native',
2326
- mode: harnessRequest ? 'startup-harness' : 'manual',
2327
- success: false,
2328
- runLabel: harnessRequest?.runLabel,
2329
- cache: {
2330
- key: computeNativeCacheKey(file),
2331
- hit: null,
2332
- manifestMeshCount: null,
2333
- manifestShardCount: null,
2334
- },
2335
- file: {
2336
- path: file.path,
2337
- name: file.name,
2338
- sizeBytes: file.size,
2339
- sizeMB: file.size / (1024 * 1024),
2340
- },
2341
- timings: {
2342
- totalWallClockMs: performance.now() - totalStartTime,
2343
- },
2344
- batches: {},
2345
- nativeStats: null,
2346
- metadata: null,
2347
- firstBatchTelemetry: null,
2348
- error: err instanceof Error ? err.message : String(err),
2349
- });
2350
- }
2351
- void logToDesktopTerminal('error', `[useIfc] Load failed: ${err instanceof Error ? err.message : String(err)}`);
2352
1184
  setError(err instanceof Error ? err.message : 'Unknown error');
2353
1185
  setLoading(false);
2354
1186
  setGeometryStreamingActive(false);