@ifc-lite/viewer 1.19.1 → 1.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/.turbo/turbo-build.log +59 -44
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +488 -0
  4. package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
  5. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  6. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  7. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  8. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  9. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  10. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  11. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  12. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  13. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  14. package/dist/assets/index-CSWgTe1s.css +1 -0
  15. package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
  16. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  17. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  18. package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
  19. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  20. package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
  21. package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
  22. package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
  23. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  24. package/dist/index.html +8 -8
  25. package/index.html +1 -1
  26. package/package.json +10 -10
  27. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  28. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  29. package/src/components/viewer/DeviationPanel.tsx +172 -0
  30. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  31. package/src/components/viewer/HoverTooltip.tsx +5 -0
  32. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  33. package/src/components/viewer/IDSPanel.tsx +80 -26
  34. package/src/components/viewer/MainToolbar.tsx +60 -7
  35. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  36. package/src/components/viewer/MobileToolbar.tsx +326 -0
  37. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  38. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  39. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  40. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  41. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  42. package/src/components/viewer/StatusBar.tsx +14 -0
  43. package/src/components/viewer/ViewerLayout.tsx +288 -95
  44. package/src/components/viewer/Viewport.tsx +86 -18
  45. package/src/components/viewer/ViewportContainer.tsx +25 -11
  46. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  47. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  48. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  49. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  50. package/src/components/viewer/selectionHandlers.ts +41 -0
  51. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  52. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  53. package/src/components/viewer/useAnimationLoop.ts +22 -0
  54. package/src/components/viewer/useMouseControls.ts +296 -3
  55. package/src/components/viewer/usePointCloudSync.ts +8 -1
  56. package/src/components/viewer/useRenderUpdates.ts +21 -1
  57. package/src/components/viewer/useTouchControls.ts +100 -41
  58. package/src/hooks/federationLoadGate.test.ts +90 -0
  59. package/src/hooks/federationLoadGate.ts +127 -0
  60. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  61. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  62. package/src/hooks/useDrawingGeneration.ts +81 -8
  63. package/src/hooks/useIDS.ts +90 -10
  64. package/src/hooks/useIfcFederation.ts +94 -16
  65. package/src/hooks/useIfcLoader.ts +289 -64
  66. package/src/hooks/useViewerSelectors.ts +10 -0
  67. package/src/lib/geo/cesium-bridge.ts +84 -67
  68. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  69. package/src/lib/geo/clamp-anchor.ts +57 -0
  70. package/src/lib/geo/effective-georef.test.ts +79 -1
  71. package/src/lib/geo/effective-georef.ts +83 -0
  72. package/src/lib/geo/reproject.ts +26 -13
  73. package/src/lib/geo/terrain-elevation.ts +166 -0
  74. package/src/lib/lens/adapter.ts +1 -1
  75. package/src/lib/llm/context-builder.ts +1 -1
  76. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  77. package/src/lib/perf/memoryAccounting.ts +235 -0
  78. package/src/sdk/adapters/mutation-view.ts +1 -1
  79. package/src/store/constants.ts +39 -2
  80. package/src/store/index.ts +6 -1
  81. package/src/store/slices/cesiumSlice.ts +1 -1
  82. package/src/store/slices/idsSlice.ts +24 -0
  83. package/src/store/slices/loadingSlice.ts +12 -0
  84. package/src/store/slices/pointCloudSlice.ts +72 -1
  85. package/src/store/slices/sectionSlice.test.ts +590 -1
  86. package/src/store/slices/sectionSlice.ts +344 -17
  87. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  88. package/src/store/slices/uiSlice.ts +60 -2
  89. package/src/store/types.ts +42 -0
  90. package/src/store.ts +13 -0
  91. package/src/utils/acquireFileBuffer.test.ts +231 -0
  92. package/src/utils/acquireFileBuffer.ts +128 -0
  93. package/src/utils/ifcConfig.ts +24 -0
  94. package/src/utils/nativeSpatialDataStore.ts +20 -2
  95. package/src/utils/spatialHierarchy.test.ts +116 -0
  96. package/src/utils/spatialHierarchy.ts +23 -0
  97. package/tailwind.config.js +5 -0
  98. package/tsconfig.json +1 -0
  99. package/vite.config.ts +6 -0
  100. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  101. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  102. package/dist/assets/exporters-xbXqEDlO.js +0 -81590
  103. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  104. package/dist/assets/ids-2WdONLlu.js +0 -2033
  105. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  106. package/dist/assets/index-BXeEKqJG.css +0 -1
@@ -16,6 +16,7 @@
16
16
  import { useCallback, useMemo, useEffect, useRef, useState } from 'react';
17
17
  import { useViewerStore } from '@/store';
18
18
  import type {
19
+ IDSAuditReport,
19
20
  IDSDocument,
20
21
  IDSValidationReport,
21
22
  IDSModelInfo,
@@ -23,6 +24,8 @@ import type {
23
24
  ValidationProgress,
24
25
  } from '@ifc-lite/ids';
25
26
  import {
27
+ auditIDSDocument,
28
+ IDSParseError,
26
29
  parseIDS,
27
30
  validateIDS,
28
31
  createTranslationService,
@@ -61,6 +64,15 @@ export interface UseIDSResult {
61
64
  // State
62
65
  /** Loaded IDS document */
63
66
  document: IDSDocument | null;
67
+ /**
68
+ * Audit report for the loaded IDS document — flags authoring issues
69
+ * surfaced by the document auditor (invalid IFC entities, malformed
70
+ * restrictions, missing required attributes, …). `null` when no
71
+ * document is loaded or the audit is still in flight.
72
+ */
73
+ auditReport: IDSAuditReport | null;
74
+ /** True while the document auditor is running. */
75
+ auditing: boolean;
64
76
  /** Validation report */
65
77
  report: IDSValidationReport | null;
66
78
  /** Loading state */
@@ -176,6 +188,8 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
176
188
 
177
189
  // IDS store state
178
190
  const document = useViewerStore((s) => s.idsDocument);
191
+ const auditReport = useViewerStore((s) => s.idsAuditReport);
192
+ const auditing = useViewerStore((s) => s.idsAuditing);
179
193
  const report = useViewerStore((s) => s.idsValidationReport);
180
194
  const loading = useViewerStore((s) => s.idsLoading);
181
195
  const progress = useViewerStore((s) => s.idsProgress);
@@ -190,6 +204,8 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
190
204
  // IDS store actions
191
205
  const setIdsDocument = useViewerStore((s) => s.setIdsDocument);
192
206
  const clearIdsDocument = useViewerStore((s) => s.clearIdsDocument);
207
+ const setIdsAuditReport = useViewerStore((s) => s.setIdsAuditReport);
208
+ const setIdsAuditing = useViewerStore((s) => s.setIdsAuditing);
193
209
  const setIdsValidationReport = useViewerStore((s) => s.setIdsValidationReport);
194
210
  const clearIdsValidationReport = useViewerStore((s) => s.clearIdsValidationReport);
195
211
  const setIdsProgress = useViewerStore((s) => s.setIdsProgress);
@@ -249,22 +265,84 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
249
265
  // ============================================================================
250
266
 
251
267
  const loadIDS = useCallback((xmlContent: string) => {
268
+ setIdsLoading(true);
269
+ setIdsError(null);
270
+ setIdsAuditing(true);
271
+ // Clear the previous audit/document up front so a re-load with a
272
+ // malformed file doesn't show stale issues from the previous one.
273
+ setIdsAuditReport(null);
274
+
275
+ // Try to parse synchronously so the panel switches into "document
276
+ // loaded" mode immediately. Capture any parse error but DON'T early-
277
+ // return — the auditor's permissive shim has its own parser and can
278
+ // still surface structured `E_PARSE_XML` / `E_XSD_*` issues even
279
+ // when the strict parser threw.
280
+ let parsed: IDSDocument | null = null;
281
+ let parseErrorMessage: string | null = null;
252
282
  try {
253
- setIdsLoading(true);
254
- setIdsError(null);
255
-
256
- const doc = parseIDS(xmlContent);
257
- setIdsDocument(doc);
258
-
259
- console.info(`[IDS] Loaded: "${doc.info.title}" (${doc.specifications.length} specifications)`);
283
+ parsed = parseIDS(xmlContent);
284
+ setIdsDocument(parsed);
285
+ console.info(
286
+ `[IDS] Loaded: "${parsed.info.title}" (${parsed.specifications.length} specifications)`
287
+ );
260
288
  } catch (err) {
261
- const message = err instanceof Error ? err.message : 'Failed to parse IDS file';
262
- setIdsError(message);
289
+ // Drop any previously-loaded document so the panel shows the
290
+ // empty state with the new audit, not the stale prior content.
291
+ setIdsDocument(null);
292
+ // Preserve the underlying detail (e.g. xmldom's
293
+ // "unexpected token at line N column M") instead of just the
294
+ // top-level "Invalid XML format" — that's the actionable bit.
295
+ if (err instanceof IDSParseError) {
296
+ parseErrorMessage = err.details
297
+ ? `${err.message}: ${err.details}`
298
+ : err.message;
299
+ } else {
300
+ parseErrorMessage =
301
+ err instanceof Error ? err.message : 'Failed to parse IDS file';
302
+ }
263
303
  console.error('[IDS] Parse error:', err);
264
304
  } finally {
265
305
  setIdsLoading(false);
266
306
  }
267
- }, [setIdsDocument, setIdsLoading, setIdsError]);
307
+
308
+ // Always run the audit, even on parse failure. The permissive
309
+ // shim handles malformed XML gracefully and produces a single
310
+ // `E_PARSE_XML` issue plus whatever else it can salvage.
311
+ void auditIDSDocument(xmlContent)
312
+ .then((report) => {
313
+ setIdsAuditReport(report);
314
+ // If parse failed but the audit succeeded with no errors,
315
+ // something is internally inconsistent — keep the parse error
316
+ // visible. If the audit also reported errors (almost always the
317
+ // case on parse failure), the panel will surface those rich
318
+ // issues alongside / instead of the bare error string.
319
+ if (parseErrorMessage && report.issues.length === 0) {
320
+ setIdsError(parseErrorMessage);
321
+ } else if (parseErrorMessage) {
322
+ // Audit has structured issues — clear the bare-string error
323
+ // so the panel relies on the audit summary as the source of
324
+ // truth (it carries the same information in richer form).
325
+ setIdsError(null);
326
+ }
327
+ if (report.status === 'error') {
328
+ console.warn(
329
+ `[IDS] Audit found ${
330
+ report.issues.filter((i) => i.severity === 'error').length
331
+ } error(s) in the IDS document`
332
+ );
333
+ }
334
+ })
335
+ .catch((auditErr) => {
336
+ // Audit itself crashed — non-fatal but unusual. Clear the audit
337
+ // and fall back to whatever parse error we collected.
338
+ console.error('[IDS] Audit failed:', auditErr);
339
+ setIdsAuditReport(null);
340
+ if (parseErrorMessage) setIdsError(parseErrorMessage);
341
+ })
342
+ .finally(() => {
343
+ setIdsAuditing(false);
344
+ });
345
+ }, [setIdsDocument, setIdsLoading, setIdsError, setIdsAuditReport, setIdsAuditing]);
268
346
 
269
347
  const loadIDSFile = useCallback(async (file: File) => {
270
348
  try {
@@ -830,6 +908,8 @@ export function useIDS(options: UseIDSOptions = {}): UseIDSResult {
830
908
  return {
831
909
  // State
832
910
  document,
911
+ auditReport,
912
+ auditing,
833
913
  report,
834
914
  loading,
835
915
  progress,
@@ -10,7 +10,7 @@
10
10
  * Extracted from useIfc.ts for better separation of concerns
11
11
  */
12
12
 
13
- import { useCallback } from 'react';
13
+ import { useCallback, useRef } from 'react';
14
14
  import { useShallow } from 'zustand/react/shallow';
15
15
  import { useViewerStore, type FederatedModel, type SchemaVersion } from '../store.js';
16
16
  import {
@@ -40,7 +40,9 @@ import {
40
40
  } from './ingest/pointCloudIngest.js';
41
41
  import { getGlobalRenderer } from './useBCF.js';
42
42
  import { readNativeFile, type NativeFileHandle } from '../services/file-dialog.js';
43
- import { getEffectiveGeoreference, type GeorefMutationDataLike } from '../lib/geo/effective-georef.js';
43
+ import { getEffectiveGeoreference, getEffectiveHorizontalScale, type GeorefMutationDataLike } from '../lib/geo/effective-georef.js';
44
+ import { acquireFederationLoadSlot, releaseFederationLoadSlot } from './federationLoadGate.js';
45
+ import { acquireFileBuffer } from '../utils/acquireFileBuffer.js';
44
46
 
45
47
  function isNativeFileHandle(file: File | NativeFileHandle): file is NativeFileHandle {
46
48
  return typeof (file as NativeFileHandle).path === 'string';
@@ -81,10 +83,14 @@ function getMapUnitScale(georef: ModelGeoref): number {
81
83
  return georef.projectedCRS.mapUnitScale ?? georef.lengthUnitScale ?? 1;
82
84
  }
83
85
 
84
- function getAxis(conversion: MapConversion): { a: number; o: number; scale: number; denom: number } {
86
+ function getAxis(georef: ModelGeoref): { a: number; o: number; scale: number; denom: number } {
87
+ const conversion = georef.mapConversion;
85
88
  const a = conversion.xAxisAbscissa ?? 1;
86
89
  const o = conversion.xAxisOrdinate ?? 0;
87
- const scale = conversion.scale ?? 1;
90
+ // Use the effective horizontal scale: viewer geometry is already in metres,
91
+ // so applying IfcMapConversion.Scale raw would double-scale — see issue #595.
92
+ const mapUnitScale = georef.projectedCRS.mapUnitScale ?? georef.lengthUnitScale ?? 1;
93
+ const scale = getEffectiveHorizontalScale(conversion.scale, mapUnitScale, georef.lengthUnitScale ?? 1);
88
94
  const denom = Math.max(a * a + o * o, 1e-12);
89
95
  return { a, o, scale, denom };
90
96
  }
@@ -151,8 +157,8 @@ function updateBounds(bounds: ReturnType<typeof emptyBounds>, x: number, y: numb
151
157
  function buildGeorefAlignmentTransform(source: ModelGeoref, reference: ModelGeoref): AffineTransform3D | null {
152
158
  const sourceConv = source.mapConversion;
153
159
  const refConv = reference.mapConversion;
154
- const sourceAxis = getAxis(sourceConv);
155
- const refAxis = getAxis(refConv);
160
+ const sourceAxis = getAxis(source);
161
+ const refAxis = getAxis(reference);
156
162
  const refDenom = refAxis.scale * refAxis.denom;
157
163
  if (Math.abs(refDenom) < 1e-12) return null;
158
164
 
@@ -372,6 +378,13 @@ export function useIfcFederation() {
372
378
  findModelForGlobalId: s.findModelForGlobalId,
373
379
  })));
374
380
 
381
+ // Per-call ownership token. Each addModel() bumps this; state writes
382
+ // (loading/error/progress) in the catch block must compare back to
383
+ // their captured value before mutating, so a cancelled load A doesn't
384
+ // overwrite progress for a newer load B that started after A's abort.
385
+ // Mirrors the same pattern in useIfcLoader.ts.
386
+ const loadSessionRef = useRef(0);
387
+
375
388
  /**
376
389
  * Add a model to the federation (multi-model support)
377
390
  * Uses FederationRegistry to assign unique ID offsets - BULLETPROOF against ID collisions
@@ -389,6 +402,16 @@ export function useIfcFederation() {
389
402
  ): Promise<string | null> => {
390
403
  const modelId = options?.modelId ?? crypto.randomUUID();
391
404
  const addStart = performance.now();
405
+ // Bump the per-call ownership token first so that any error path
406
+ // (including the load gate) can compare against this captured value
407
+ // before mutating shared loading/error/progress state.
408
+ const currentSession = ++loadSessionRef.current;
409
+ // Memory-aware load gate: if a previous federation load is still in
410
+ // flight on this tab and admitting this one would exceed the device
411
+ // memory budget, wait until headroom frees. Single-file loads never
412
+ // wait. See `federationLoadGate.ts` for the budget formula. (#600)
413
+ const fileSizeForGateMB = (typeof (file as File).size === 'number' ? (file as File).size : 0) / (1024 * 1024);
414
+ const gateSlot = await acquireFederationLoadSlot(fileSizeForGateMB);
392
415
  try {
393
416
  // IMPORTANT: Before adding a new model, check if there's a legacy model
394
417
  // (loaded via loadFile) that's not in the Map yet. If so, migrate it first.
@@ -439,10 +462,24 @@ export function useIfcFederation() {
439
462
  setError(null);
440
463
  setProgress({ phase: 'Loading file', percent: 0 });
441
464
 
442
- // Read file from disk
443
- const buffer = isNativeFileHandle(file)
444
- ? toExactArrayBuffer(await readNativeFile(file.path))
445
- : await file.arrayBuffer();
465
+ // Read file from disk. The browser path streams files above
466
+ // `STREAM_SAB_THRESHOLD` directly into a SharedArrayBuffer, eliminating
467
+ // the doubled peak (ArrayBuffer + SAB) of `await file.arrayBuffer()`
468
+ // when the geometry pipeline copies into its own SAB. The native path
469
+ // still reads via Tauri's Rust IPC because it bounds memory differently.
470
+ // (#600)
471
+ let buffer: ArrayBuffer;
472
+ if (isNativeFileHandle(file)) {
473
+ buffer = toExactArrayBuffer(await readNativeFile(file.path));
474
+ } else {
475
+ // The cast preserves the previous ArrayBuffer-shaped contract for
476
+ // every downstream consumer. When the underlying store is a SAB,
477
+ // downstream code only ever reads bytes via `new Uint8Array(buffer)`
478
+ // / `new DataView(buffer)`, both of which work on either backing
479
+ // store. The cast is purely type-system; runtime is identical.
480
+ const acquired = await acquireFileBuffer(file as File);
481
+ buffer = acquired.buffer as ArrayBuffer;
482
+ }
446
483
  const fileSizeMB = buffer.byteLength / (1024 * 1024);
447
484
 
448
485
  // Detect point cloud formats first — we never run them through
@@ -461,7 +498,7 @@ export function useIfcFederation() {
461
498
  // depends on persisting it onto the FederatedModel record.
462
499
  let pointCloudHandleId: number | undefined;
463
500
 
464
- if (format === 'las' || format === 'laz' || format === 'ply' || format === 'pcd' || format === 'e57') {
501
+ if (format === 'las' || format === 'laz' || format === 'ply' || format === 'pcd' || format === 'e57' || format === 'pts' || format === 'xyz') {
465
502
  const renderer = getGlobalRenderer();
466
503
  if (!renderer) {
467
504
  setError('Renderer not initialised — try again after the viewer mounts.');
@@ -482,11 +519,25 @@ export function useIfcFederation() {
482
519
  onProgress: setProgress,
483
520
  onAssetCountDelta: incCount,
484
521
  });
522
+ // Expose cancellation while the stream is in-flight. Capture
523
+ // the canceller as a named ref so the cleanup can verify the
524
+ // store still points at us before clearing — a second
525
+ // addModel() that began before this one settles must not lose
526
+ // its Cancel button to our finally block.
527
+ const { setActiveStreamCanceller } = useViewerStore.getState();
528
+ const cancelStream = () => ingest.streamHandle.cancel();
529
+ setActiveStreamCanceller(cancelStream);
485
530
  // ingest.done rejects on stream errors; ingestPointCloud's onError
486
531
  // callback already calls removePointCloudAsset + incCount(-1), so
487
532
  // the outer catch must NOT repeat that cleanup or the count goes
488
533
  // negative when other point clouds are still loaded.
489
- await ingest.done;
534
+ try {
535
+ await ingest.done;
536
+ } finally {
537
+ if (useViewerStore.getState().activeStreamCanceller === cancelStream) {
538
+ setActiveStreamCanceller(null);
539
+ }
540
+ }
490
541
  parsedDataStore = ingest.dataStore;
491
542
  parsedGeometry = ingest.geometryResult;
492
543
  schemaVersion = ingest.schemaVersion;
@@ -652,10 +703,31 @@ export function useIfcFederation() {
652
703
  return modelId;
653
704
 
654
705
  } catch (err) {
706
+ // Only mutate shared loading/error/progress state if our session
707
+ // is still the active one. A second addModel() that started after
708
+ // we were cancelled has already taken over the spinner — we must
709
+ // not overwrite it with our "Cancelled" state.
710
+ const isCurrent = loadSessionRef.current === currentSession;
711
+ // User-initiated cancel surfaces as an AbortError. Map it to a
712
+ // benign "Cancelled" state so the federated path matches the
713
+ // single-model loader rather than reporting a parse failure.
714
+ if (err instanceof DOMException && err.name === 'AbortError') {
715
+ console.log('[useIfc] addModel cancelled by user');
716
+ if (isCurrent) {
717
+ setError(null);
718
+ setProgress({ phase: 'Cancelled', percent: 0 });
719
+ setLoading(false);
720
+ }
721
+ return null;
722
+ }
655
723
  console.error('[useIfc] addModel failed:', err);
656
- setError(err instanceof Error ? err.message : 'Unknown error');
657
- setLoading(false);
724
+ if (isCurrent) {
725
+ setError(err instanceof Error ? err.message : 'Unknown error');
726
+ setLoading(false);
727
+ }
658
728
  return null;
729
+ } finally {
730
+ releaseFederationLoadSlot(gateSlot);
659
731
  }
660
732
  }, [setLoading, setError, setProgress, setIfcDataStore, setGeometryResult, storeAddModel, hasModels, registerModelOffset]);
661
733
 
@@ -864,7 +936,10 @@ export function useIfcFederation() {
864
936
  return;
865
937
  }
866
938
 
867
- // Check that all files are IFCX format and read buffers
939
+ // Check that all files are IFCX format and read buffers.
940
+ // IFCX is JSON; SAB streaming would force a SAB→scratch copy in
941
+ // safeUtf8Decode + retain the scratch (net worse peak than ArrayBuffer).
942
+ // Keep on file.arrayBuffer().
868
943
  const buffers: Array<{ buffer: ArrayBuffer; name: string }> = [];
869
944
  for (const file of files) {
870
945
  const buffer = await file.arrayBuffer();
@@ -918,7 +993,10 @@ export function useIfcFederation() {
918
993
  return;
919
994
  }
920
995
 
921
- // Read new overlay buffers
996
+ // Read new overlay buffers.
997
+ // IFCX is JSON; SAB streaming would force a SAB→scratch copy in
998
+ // safeUtf8Decode + retain the scratch (net worse peak than ArrayBuffer).
999
+ // Keep on file.arrayBuffer().
922
1000
  const newBuffers: Array<{ buffer: ArrayBuffer; name: string }> = [];
923
1001
  for (const file of files) {
924
1002
  const buffer = await file.arrayBuffer();