@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
@@ -15,7 +15,16 @@ import { flushSync } from 'react-dom';
15
15
  import { useShallow } from 'zustand/react/shallow';
16
16
  import { getViewerStoreApi, useViewerStore } from '@/store';
17
17
  import { IfcParser, detectFormat, type IfcDataStore } from '@ifc-lite/parser';
18
- import { GeometryProcessor, GeometryQuality, type MeshData, type CoordinateInfo } from '@ifc-lite/geometry';
18
+ import { WorkerParser } from '@ifc-lite/parser/browser';
19
+ import { memoryAccounting } from '../lib/perf/memoryAccounting.js';
20
+ import {
21
+ GeometryProcessor,
22
+ GeometryQuality,
23
+ getGeometryStreamWatchdogMs as getGeometryStreamWatchdogMsImpl,
24
+ type MeshData,
25
+ type CoordinateInfo,
26
+ } from '@ifc-lite/geometry';
27
+ import { acquireFileBuffer, type AcquiredBuffer } from '../utils/acquireFileBuffer.js';
19
28
  import initIfcLiteWasm, { IfcAPI } from '@ifc-lite/wasm';
20
29
  import { buildSpatialIndexGuarded } from '../utils/loadingUtils.js';
21
30
  import { type GeometryData } from '@ifc-lite/cache';
@@ -95,14 +104,23 @@ function yieldToUiThread(): Promise<void> {
95
104
  });
96
105
  }
97
106
 
107
+ /**
108
+ * Size-aware first-batch watchdog. Delegates to the package-level helper so
109
+ * the formula stays unit-tested in `@ifc-lite/geometry`. Subsequent-batch
110
+ * deadlines are unchanged from the previous fixed values; only the
111
+ * first-batch deadline grows with file size to give the WASM pre-pass time
112
+ * to finish on multi-GB files (issue #600).
113
+ */
98
114
  function getGeometryStreamWatchdogMs(
99
115
  desktopStableWasm: boolean,
100
116
  batchCount: number,
117
+ fileSizeMB: number = 0,
101
118
  ): number {
102
- if (desktopStableWasm) {
103
- return batchCount > 0 ? 5_000 : 15_000;
104
- }
105
- return batchCount > 0 ? 15_000 : 30_000;
119
+ return getGeometryStreamWatchdogMsImpl({
120
+ desktopStableWasm,
121
+ batchCount,
122
+ fileSizeMB,
123
+ });
106
124
  }
107
125
 
108
126
  function countNativeSpatialNodes(
@@ -211,6 +229,10 @@ export function useIfcLoader() {
211
229
  resetViewerState();
212
230
  clearAllModels();
213
231
 
232
+ // Reset memory accounting so per-load summaries don't accumulate across files.
233
+ memoryAccounting.reset();
234
+ memoryAccounting.recordPhase({ phase: 'load-start' });
235
+
214
236
  setLoading(true);
215
237
  setGeometryStreamingActive(false);
216
238
  setError(null);
@@ -663,9 +685,14 @@ export function useIfcLoader() {
663
685
  setIfcDataStore(null);
664
686
  setProgress({ phase: 'Starting native geometry streaming', percent: 10 });
665
687
 
688
+ // Snapshot the user's "Merge Multilayer Walls" preference once
689
+ // at load time — flipping the toggle mid-stream cannot affect
690
+ // an in-flight WASM pipeline, the reload banner handles that.
691
+ const mergeLayersAtLoad = useViewerStore.getState().mergeLayers;
666
692
  const geometryProcessor = new GeometryProcessor({
667
693
  quality: GeometryQuality.Balanced,
668
694
  preferNative: true,
695
+ mergeLayers: mergeLayersAtLoad,
669
696
  });
670
697
 
671
698
  let estimatedTotal = 0;
@@ -1555,13 +1582,33 @@ export function useIfcLoader() {
1555
1582
  return;
1556
1583
  }
1557
1584
 
1558
- // Read file from disk
1585
+ // Read file from disk. The browser path streams files ≥
1586
+ // STREAM_SAB_THRESHOLD directly into a SharedArrayBuffer, which avoids
1587
+ // a doubled-peak ArrayBuffer + SAB allocation when the geometry
1588
+ // pipeline copies into its own SAB. The native path still reads via
1589
+ // Tauri's Rust IPC because it bounds memory differently. (#600)
1559
1590
  const fileReadStart = performance.now();
1560
- const buffer = isNativeFileHandle(file)
1561
- ? toExactArrayBuffer(await readNativeFile(file.path))
1562
- : await file.arrayBuffer();
1591
+ let acquired: AcquiredBuffer;
1592
+ if (isNativeFileHandle(file)) {
1593
+ const nativeBytes = await readNativeFile(file.path);
1594
+ const nativeBuffer = toExactArrayBuffer(nativeBytes);
1595
+ acquired = {
1596
+ buffer: nativeBuffer,
1597
+ view: new Uint8Array(nativeBuffer),
1598
+ isShared: false,
1599
+ };
1600
+ } else {
1601
+ acquired = await acquireFileBuffer(file as File);
1602
+ }
1603
+ // `buffer` retains its previous semantics (ArrayBuffer-shaped) for
1604
+ // every downstream consumer. When `acquired.isShared` is true the
1605
+ // backing store is a SharedArrayBuffer; downstream code only ever
1606
+ // reads bytes via `new Uint8Array(buffer)` / `new DataView(buffer)`,
1607
+ // both of which work on either backing store. The TS cast is purely
1608
+ // type-system: the runtime is identical.
1609
+ const buffer = acquired.buffer as ArrayBuffer;
1563
1610
  const fileReadMs = performance.now() - fileReadStart;
1564
- console.log(`[useIfc] File: ${file.name}, size: ${fileSizeMB.toFixed(2)}MB, read in ${fileReadMs.toFixed(0)}ms`);
1611
+ console.log(`[useIfc] File: ${file.name}, size: ${fileSizeMB.toFixed(2)}MB, read in ${fileReadMs.toFixed(0)}ms${acquired.isShared ? ' (streamed→SAB)' : ''}`);
1565
1612
 
1566
1613
  // Detect file format (IFCX/IFC5 vs IFC4 STEP vs GLB vs LAS/LAZ)
1567
1614
  const pointCloudFormat = detectPointCloudFormat(file.name, buffer);
@@ -1569,7 +1616,7 @@ export function useIfcLoader() {
1569
1616
 
1570
1617
  // LAS / LAZ point clouds: stream chunks straight to the renderer.
1571
1618
  // No on-disk cache, no server upload — the data goes worker → GPU.
1572
- if (format === 'las' || format === 'laz' || format === 'ply' || format === 'pcd' || format === 'e57') {
1619
+ if (format === 'las' || format === 'laz' || format === 'ply' || format === 'pcd' || format === 'e57' || format === 'pts' || format === 'xyz') {
1573
1620
  const renderer = getGlobalRenderer();
1574
1621
  if (!renderer) {
1575
1622
  setError('Renderer not initialised — try again after the viewer mounts.');
@@ -1590,6 +1637,20 @@ export function useIfcLoader() {
1590
1637
  onProgress: setProgress,
1591
1638
  onAssetCountDelta: incCount,
1592
1639
  });
1640
+ // Expose cancellation to the UI (StatusBar shows a Cancel
1641
+ // button while this is non-null). Cleared via the
1642
+ // `clearOwnedCanceller` helper below so a later load that
1643
+ // installed its own canceller never gets clobbered by our
1644
+ // cleanup paths — the helper only nulls the store when the
1645
+ // stored function is still ours.
1646
+ const { setActiveStreamCanceller } = useViewerStore.getState();
1647
+ const cancelStream = () => ingest.streamHandle.cancel();
1648
+ setActiveStreamCanceller(cancelStream);
1649
+ const clearOwnedCanceller = () => {
1650
+ if (useViewerStore.getState().activeStreamCanceller === cancelStream) {
1651
+ setActiveStreamCanceller(null);
1652
+ }
1653
+ };
1593
1654
  // ingestPointCloud's onError callback already runs renderer cleanup
1594
1655
  // + incCount(-1); the outer catch must NOT repeat them or the
1595
1656
  // pointCloudAssetCount will go negative.
@@ -1601,15 +1662,38 @@ export function useIfcLoader() {
1601
1662
  // the spinner / model record now. Free the renderer handle
1602
1663
  // so we don't leak the half-streamed asset.
1603
1664
  if (loadSessionRef.current !== currentSession) {
1665
+ console.warn(
1666
+ `[useIfc] pointcloud ingest rejected on stale session (handle=${ingest.rendererHandle.id}):`,
1667
+ err,
1668
+ );
1604
1669
  renderer.removePointCloudAsset(ingest.rendererHandle);
1670
+ clearOwnedCanceller();
1605
1671
  return;
1606
1672
  }
1607
1673
  const message = err instanceof Error ? err.message : String(err);
1608
- updateModel(primaryModelId, { loadState: 'error', loadError: message });
1609
- setError(`${format.toUpperCase()} parsing failed: ${message}`);
1674
+ // Distinguish a user-initiated abort from a real failure so
1675
+ // the status bar shows "Cancelled" instead of a scary error.
1676
+ const isAbort = err instanceof DOMException && err.name === 'AbortError';
1677
+ if (isAbort) {
1678
+ console.log(
1679
+ `[useIfc] pointcloud ingest cancelled (model=${primaryModelId}, handle=${ingest.rendererHandle.id})`,
1680
+ );
1681
+ updateModel(primaryModelId, { loadState: 'error', loadError: 'cancelled' });
1682
+ setError(null);
1683
+ setProgress({ phase: 'Cancelled', percent: 0 });
1684
+ } else {
1685
+ console.error(
1686
+ `[useIfc] pointcloud ingest failed (format=${format}, model=${primaryModelId}):`,
1687
+ err,
1688
+ );
1689
+ updateModel(primaryModelId, { loadState: 'error', loadError: message });
1690
+ setError(`${format.toUpperCase()} parsing failed: ${message}`);
1691
+ }
1692
+ clearOwnedCanceller();
1610
1693
  setLoading(false);
1611
1694
  return;
1612
1695
  }
1696
+ clearOwnedCanceller();
1613
1697
  if (loadSessionRef.current !== currentSession) {
1614
1698
  // A newer load already began. Drop our streamed asset and
1615
1699
  // skip every store/UI mutation so we don't overwrite the
@@ -1737,16 +1821,46 @@ export function useIfcLoader() {
1737
1821
  && file.size < HUGE_NATIVE_FILE_THRESHOLD;
1738
1822
 
1739
1823
  // Initialize geometry processor first (WASM init is fast if already loaded)
1824
+ const mergeLayersAtLoad = useViewerStore.getState().mergeLayers;
1740
1825
  const geometryProcessor = new GeometryProcessor({
1741
1826
  quality: GeometryQuality.Balanced,
1742
1827
  preferNative: false,
1828
+ // Issue #540: snapshot at load time so the WASM bridge applies
1829
+ // the flag before the first parseMeshes* call.
1830
+ mergeLayers: mergeLayersAtLoad,
1743
1831
  });
1744
1832
  await geometryProcessor.init();
1745
1833
 
1834
+ // Allocate (or reuse) a SharedArrayBuffer so the parser worker and
1835
+ // the geometry workers read the same memory zero-copy. When
1836
+ // `acquireFileBuffer` already streamed the file directly into a SAB
1837
+ // (large-file entry path, issue #600), reuse it — no second copy.
1838
+ // `WorkerParser.isSupported()` rolls together: COI enabled, SAB
1839
+ // available, AND TextDecoder accepts SAB-backed views (Firefox fails
1840
+ // the third check; we skip the worker path entirely there so the
1841
+ // SAB allocation isn't wasted).
1842
+ const useParserWorker = WorkerParser.isSupported() && !isNativeFileHandle(file);
1843
+ let sharedSource: SharedArrayBuffer | null = null;
1844
+ if (useParserWorker) {
1845
+ if (acquired.isShared && acquired.buffer instanceof SharedArrayBuffer) {
1846
+ // acquireFileBuffer already streamed bytes into a SAB. Reuse it.
1847
+ sharedSource = acquired.buffer;
1848
+ } else {
1849
+ // Smaller files (or non-COI) took the `await file.arrayBuffer()`
1850
+ // branch — make a SAB copy so the parser worker can read it.
1851
+ sharedSource = new SharedArrayBuffer(buffer.byteLength);
1852
+ new Uint8Array(sharedSource).set(new Uint8Array(buffer));
1853
+ }
1854
+ memoryAccounting.setSourceBytes(buffer.byteLength);
1855
+ }
1856
+
1746
1857
  // Data model parsing runs IN PARALLEL with geometry streaming.
1747
- // Entity scanning uses a Web Worker (non-blocking, ~1.2s).
1748
- // Columnar parse uses time-sliced yielding (~2.3s, 60fps maintained).
1749
- // Neither depends on geometry output — both just need the raw buffer.
1858
+ // Default path: parser runs in a Web Worker via WorkerParser, both
1859
+ // workers + main share the same SharedArrayBuffer source, and the
1860
+ // main thread never blocks on parse.
1861
+ // Fallback: in-process IfcParser.parseColumnar (the previous default)
1862
+ // — used when cross-origin isolation is missing or the worker spawn
1863
+ // fails (auto-fallback inside the catch).
1750
1864
  let resolveDataStore: (dataStore: IfcDataStore) => void;
1751
1865
  let rejectDataStore: (err: unknown) => void;
1752
1866
  const dataStorePromise = new Promise<IfcDataStore>((resolve, reject) => {
@@ -1754,59 +1868,119 @@ export function useIfcLoader() {
1754
1868
  rejectDataStore = reject;
1755
1869
  });
1756
1870
 
1757
- const startDataModelParsing = () => {
1758
- const parser = new IfcParser();
1759
- metadataStartMs = performance.now() - totalStartTime;
1760
- console.log(`[useIfc] Data model parsing start for ${file.name}: ${metadataStartMs.toFixed(0)}ms`);
1761
- // Do not share the geometry processor's WASM API with the parser on
1762
- // desktop fallback loads. Concurrent access can corrupt the WASM state
1763
- // and freeze or crash the viewer. Let the parser use worker/TS scanning
1764
- // instead.
1871
+ const onPartialDataStore = (partialStore: IfcDataStore) => {
1872
+ if (loadSessionRef.current !== currentSession) return;
1873
+ if (spatialReadyMs === null) {
1874
+ spatialReadyMs = performance.now() - totalStartTime;
1875
+ console.log(`[useIfc] Spatial tree ready for ${file.name} at ${spatialReadyMs.toFixed(0)}ms`);
1876
+ }
1877
+ if (partialStore.spatialHierarchy && partialStore.spatialHierarchy.storeyHeights.size === 0 && partialStore.spatialHierarchy.storeyElevations.size > 1) {
1878
+ const calculatedHeights = calculateStoreyHeights(partialStore.spatialHierarchy.storeyElevations);
1879
+ for (const [storeyId, height] of calculatedHeights) {
1880
+ partialStore.spatialHierarchy.storeyHeights.set(storeyId, height);
1881
+ }
1882
+ }
1883
+ setIfcDataStore(partialStore);
1884
+ };
1885
+
1886
+ const onFullDataStore = (dataStore: IfcDataStore) => {
1887
+ if (loadSessionRef.current !== currentSession) return;
1888
+ metadataCompleteMs = performance.now() - totalStartTime;
1889
+ if (dataStore.spatialHierarchy && dataStore.spatialHierarchy.storeyHeights.size === 0 && dataStore.spatialHierarchy.storeyElevations.size > 1) {
1890
+ const calculatedHeights = calculateStoreyHeights(dataStore.spatialHierarchy.storeyElevations);
1891
+ for (const [storeyId, height] of calculatedHeights) {
1892
+ dataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
1893
+ }
1894
+ }
1895
+ setIfcDataStore(dataStore);
1896
+ console.log(`[useIfc] Data model parsing complete for ${file.name}: ${metadataCompleteMs.toFixed(0)}ms`);
1897
+ memoryAccounting.endPhase('parser-worker');
1898
+ memoryAccounting.recordPhase({ phase: 'parser-complete' });
1899
+ resolveDataStore(dataStore);
1900
+ };
1901
+
1902
+ const runMainThreadParser = async (): Promise<IfcDataStore> => {
1903
+ // Same `wasmApi` heuristic as before — desktop loads cannot share
1904
+ // the geometry processor's WASM instance with the parser without
1905
+ // risking corruption.
1765
1906
  const parserWasmApi = isNativeFileHandle(file) ? undefined : geometryProcessor.getApi();
1766
- parser.parseColumnar(buffer, {
1907
+ return new IfcParser().parseColumnar(buffer, {
1767
1908
  wasmApi: parserWasmApi,
1768
- // Emit spatial hierarchy EARLY — lets the panel render while
1769
- // property/association parsing continues (~0.5-1s earlier).
1770
- onSpatialReady: (partialStore) => {
1771
- if (loadSessionRef.current !== currentSession) return;
1772
- if (spatialReadyMs === null) {
1773
- spatialReadyMs = performance.now() - totalStartTime;
1774
- console.log(`[useIfc] Spatial tree ready for ${file.name} at ${spatialReadyMs.toFixed(0)}ms`);
1775
- }
1776
- if (partialStore.spatialHierarchy && partialStore.spatialHierarchy.storeyHeights.size === 0 && partialStore.spatialHierarchy.storeyElevations.size > 1) {
1777
- const calculatedHeights = calculateStoreyHeights(partialStore.spatialHierarchy.storeyElevations);
1778
- for (const [storeyId, height] of calculatedHeights) {
1779
- partialStore.spatialHierarchy.storeyHeights.set(storeyId, height);
1780
- }
1781
- }
1782
- setIfcDataStore(partialStore);
1783
- },
1784
- }).then(dataStore => {
1785
- if (loadSessionRef.current !== currentSession) return;
1786
- metadataCompleteMs = performance.now() - totalStartTime;
1787
- // Calculate storey heights from elevation differences if not already populated
1788
- if (dataStore.spatialHierarchy && dataStore.spatialHierarchy.storeyHeights.size === 0 && dataStore.spatialHierarchy.storeyElevations.size > 1) {
1789
- const calculatedHeights = calculateStoreyHeights(dataStore.spatialHierarchy.storeyElevations);
1790
- for (const [storeyId, height] of calculatedHeights) {
1791
- dataStore.spatialHierarchy.storeyHeights.set(storeyId, height);
1792
- }
1909
+ onSpatialReady: onPartialDataStore,
1910
+ });
1911
+ };
1912
+
1913
+ // Hoisted so the geometry pre-pass's `onEntityIndex` callback can
1914
+ // hand the SAB triple to the same worker the parser is running in.
1915
+ // Receiving the index lets the parser worker skip its own ~10 s
1916
+ // `scanEntitiesFastBytes` call — the streaming pre-pass already
1917
+ // walked the file and built the same index.
1918
+ let workerParserInstance: WorkerParser | null = null;
1919
+
1920
+ // The geometry pre-pass only emits `entity-index` on the parallel
1921
+ // streaming path inside `processAdaptive`. Files smaller than the
1922
+ // sync threshold (2 MB) and the desktop-stable path don't fire it
1923
+ // — gate `waitForEntityIndex` so the parser doesn't hang.
1924
+ const ADAPTIVE_SYNC_THRESHOLD_MB = 2;
1925
+ const geometryWillEmitEntityIndex =
1926
+ useParserWorker
1927
+ && !shouldUseDesktopStableWasmGeometry
1928
+ && fileSizeMB >= ADAPTIVE_SYNC_THRESHOLD_MB;
1929
+
1930
+ const startDataModelParsing = () => {
1931
+ metadataStartMs = performance.now() - totalStartTime;
1932
+ console.log(`[useIfc] Data model parsing start for ${file.name}: ${metadataStartMs.toFixed(0)}ms (${useParserWorker ? 'worker' : 'main-thread'})`);
1933
+ memoryAccounting.beginPhase('parser-worker');
1934
+ memoryAccounting.recordPhase({ phase: 'parser-start' });
1935
+
1936
+ const workerAttempt = (): Promise<IfcDataStore> => {
1937
+ if (!useParserWorker || !sharedSource) {
1938
+ return Promise.reject(new Error('parser worker disabled (no SAB / native file)'));
1793
1939
  }
1940
+ // NOTE: `deferPropertyAtomIndex` is not enabled here. The current
1941
+ // implementation in `columnar-parser.ts` calls
1942
+ // `entityRefs.filter(...)` to split property atoms out of the
1943
+ // primary index, which costs more on a 14 M-entity file (~3 s
1944
+ // for the filter pass) than the index-build time it saves.
1945
+ // Re-enable once the categorization loop builds the two
1946
+ // ref arrays inline so there is no second O(N) walk.
1947
+ const worker = new WorkerParser();
1948
+ workerParserInstance = worker;
1949
+ return worker.parseColumnar(sharedSource, {
1950
+ onSpatialReady: onPartialDataStore,
1951
+ // Hold the parser's WASM scan until the pre-pass hands over
1952
+ // the entity index — but only when we know the geometry
1953
+ // path will actually emit one (parallel-streaming branch).
1954
+ waitForEntityIndex: geometryWillEmitEntityIndex,
1955
+ onMemorySnapshot: (snapshot) => {
1956
+ if (snapshot.jsHeapBytes !== undefined) {
1957
+ memoryAccounting.recordWorkerMemory('parser', snapshot.jsHeapBytes);
1958
+ }
1959
+ memoryAccounting.recordPhase({
1960
+ phase: 'parser-transport',
1961
+ transportBytes: snapshot.transportBytes,
1962
+ });
1963
+ },
1964
+ });
1965
+ };
1794
1966
 
1795
- // Update with full data (includes property/association maps)
1796
- setIfcDataStore(dataStore);
1797
- console.log(`[useIfc] Data model parsing complete for ${file.name}: ${metadataCompleteMs.toFixed(0)}ms`);
1798
- resolveDataStore(dataStore);
1799
- }).catch(err => {
1800
- metadataFailedMs = performance.now() - totalStartTime;
1801
- console.error('[useIfc] Data model parsing failed:', err);
1802
- console.log(`[useIfc] Data model parsing failed for ${file.name}: ${metadataFailedMs.toFixed(0)}ms`);
1803
- rejectDataStore(err);
1804
- });
1967
+ workerAttempt()
1968
+ .catch((err) => {
1969
+ console.warn('[useIfc] Parser worker failed, falling back to main-thread parse:', err);
1970
+ memoryAccounting.recordPhase({ phase: 'parser-worker-fallback' });
1971
+ return runMainThreadParser();
1972
+ })
1973
+ .then(onFullDataStore)
1974
+ .catch((err) => {
1975
+ metadataFailedMs = performance.now() - totalStartTime;
1976
+ console.error('[useIfc] Data model parsing failed:', err);
1977
+ console.log(`[useIfc] Data model parsing failed for ${file.name}: ${metadataFailedMs.toFixed(0)}ms`);
1978
+ memoryAccounting.recordPhase({ phase: 'parser-failed' });
1979
+ rejectDataStore(err);
1980
+ });
1805
1981
  };
1806
1982
 
1807
1983
  // Start data model parsing IMMEDIATELY — runs in parallel with geometry.
1808
- // Entity scan uses Web Worker (off main thread), columnar parse yields
1809
- // every ~4ms to maintain 60fps navigation during geometry streaming.
1810
1984
  setTimeout(startDataModelParsing, 0);
1811
1985
 
1812
1986
  // Use adaptive processing: sync for small files, streaming for large files
@@ -1854,11 +2028,47 @@ export function useIfcLoader() {
1854
2028
  try {
1855
2029
  // Use dynamic batch sizing for optimal throughput
1856
2030
  const dynamicBatchConfig = getDynamicBatchConfig(fileSizeMB);
2031
+ memoryAccounting.beginPhase('geometry');
2032
+ // When the parser worker is in use, hand the geometry workers the
2033
+ // same SAB so we don't pay the file-bytes copy twice.
2034
+ const geometryView = sharedSource ? new Uint8Array(sharedSource) : new Uint8Array(buffer);
2035
+ // Phase 2 of single-controller-rayon-design.md — opt-in via
2036
+ // localStorage so we can A/B compare against the N-worker
2037
+ // baseline without rolling out for everyone. Users (and the
2038
+ // benchmark harness) flip this with:
2039
+ // localStorage.setItem('ifc-lite:single-controller', '1')
2040
+ // and reload. Set to anything else (or unset) for the legacy
2041
+ // N-worker path. Safe: if the threaded WASM bundle fails to
2042
+ // load (no COI, Safari, etc.) the controller worker falls back
2043
+ // to per-task serial execution within the controller itself
2044
+ // (par_iter without an initialized pool).
2045
+ const useSingleController = (() => {
2046
+ try {
2047
+ return typeof localStorage !== 'undefined'
2048
+ && localStorage.getItem('ifc-lite:single-controller') === '1';
2049
+ } catch {
2050
+ return false;
2051
+ }
2052
+ })();
2053
+ if (useSingleController) {
2054
+ console.log('[useIfc] single-controller path enabled (Phase 2)');
2055
+ }
1857
2056
  const geometryEvents = shouldUseDesktopStableWasmGeometry
1858
- ? geometryProcessor.processStreaming(new Uint8Array(buffer), undefined, dynamicBatchConfig)
1859
- : geometryProcessor.processAdaptive(new Uint8Array(buffer), {
2057
+ ? geometryProcessor.processStreaming(geometryView, undefined, dynamicBatchConfig)
2058
+ : geometryProcessor.processAdaptive(geometryView, {
1860
2059
  sizeThreshold: 2 * 1024 * 1024, // 2MB threshold
1861
2060
  batchSize: dynamicBatchConfig, // Dynamic batches: small first, then large
2061
+ existingSab: sharedSource ?? undefined,
2062
+ useSingleController,
2063
+ // Hand the streaming pre-pass's entity index to the parser
2064
+ // worker so it skips a duplicate ~10 s WASM scan. Safe even
2065
+ // when the parser falls back to main-thread (instance is
2066
+ // null then; the callback no-ops).
2067
+ onEntityIndex: (ids, starts, lengths) => {
2068
+ if (workerParserInstance) {
2069
+ workerParserInstance.setEntityIndex(ids, starts, lengths);
2070
+ }
2071
+ },
1862
2072
  });
1863
2073
  const geometryIterator = geometryEvents[Symbol.asyncIterator]();
1864
2074
  let geometryIteratorClosed = false;
@@ -1878,6 +2088,7 @@ export function useIfcLoader() {
1878
2088
  const watchdogMs = getGeometryStreamWatchdogMs(
1879
2089
  shouldUseDesktopStableWasmGeometry,
1880
2090
  batchCount,
2091
+ fileSizeMB,
1881
2092
  );
1882
2093
  let watchdogId: ReturnType<typeof globalThis.setTimeout> | null = null;
1883
2094
  const nextResult = await Promise.race([
@@ -1910,6 +2121,11 @@ export function useIfcLoader() {
1910
2121
  case 'model-open':
1911
2122
  setProgress({ phase: 'Processing geometry', percent: 50 });
1912
2123
  break;
2124
+ case 'progress':
2125
+ // Liveness heartbeat from the parallel pipeline. Receiving
2126
+ // any event resets the watchdog implicitly because the next
2127
+ // loop iteration re-creates the timer; nothing to do here.
2128
+ break;
1913
2129
  case 'colorUpdate': {
1914
2130
  // Accumulate color updates locally during streaming.
1915
2131
  // We apply them in a single pass at 'complete' instead of
@@ -1930,6 +2146,12 @@ export function useIfcLoader() {
1930
2146
  }
1931
2147
  break;
1932
2148
  }
2149
+ case 'workerMemory': {
2150
+ // Aggregated by memoryAccounting for per-load summaries.
2151
+ memoryAccounting.recordWorkerMemory(`geom-${event.workerIndex}`, event.wasmHeapBytes);
2152
+ memoryAccounting.addGeometryBytes(event.meshBytes);
2153
+ break;
2154
+ }
1933
2155
  case 'batch': {
1934
2156
  batchCount++;
1935
2157
 
@@ -2004,6 +2226,9 @@ export function useIfcLoader() {
2004
2226
  updateCoordinateInfo(finalCoordinateInfo);
2005
2227
 
2006
2228
  setProgress({ phase: 'Complete', percent: 100 });
2229
+ memoryAccounting.endPhase('geometry');
2230
+ memoryAccounting.recordPhase({ phase: 'geometry-complete' });
2231
+ console.log(memoryAccounting.formatSummary());
2007
2232
  await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
2008
2233
  if (loadSessionRef.current === currentSession) {
2009
2234
  setGeometryStreamingActive(false);
@@ -50,10 +50,20 @@ export function useVisibilityState() {
50
50
  export function useToolState() {
51
51
  const activeTool = useViewerStore((state) => state.activeTool);
52
52
  const sectionPlane = useViewerStore((state) => state.sectionPlane);
53
+ const sectionPickMode = useViewerStore((state) => state.sectionPickMode);
54
+ const setSectionPlaneFromFace = useViewerStore((state) => state.setSectionPlaneFromFace);
55
+ const setSectionPickMode = useViewerStore((state) => state.setSectionPickMode);
56
+ const setSectionPickPreview = useViewerStore((state) => state.setSectionPickPreview);
57
+ const setSectionCustomDistance = useViewerStore((state) => state.setSectionCustomDistance);
53
58
 
54
59
  return {
55
60
  activeTool,
56
61
  sectionPlane,
62
+ sectionPickMode,
63
+ setSectionPlaneFromFace,
64
+ setSectionPickMode,
65
+ setSectionPickPreview,
66
+ setSectionCustomDistance,
57
67
  };
58
68
  }
59
69