@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
@@ -7,11 +7,23 @@
7
7
  */
8
8
 
9
9
  import type { StateCreator } from 'zustand';
10
- import { UI_DEFAULTS } from '../constants.js';
10
+ import { MERGE_LAYERS_STORAGE_KEY, UI_DEFAULTS } from '../constants.js';
11
11
  import type { ContactShadingQuality, SeparationLinesQuality } from '@ifc-lite/renderer';
12
+ import type { FederatedModel } from '../types.js';
13
+ import type { GeometryResult } from '@ifc-lite/geometry';
12
14
 
13
15
  export type ThemeMode = 'light' | 'dark' | 'colorful';
14
16
 
17
+ /**
18
+ * Cross-slice surface UISlice reaches into via the combined Zustand
19
+ * `get()` to decide whether toggling a load-time setting needs a
20
+ * reload (only meaningful while a model is in scope).
21
+ */
22
+ export interface UICrossSliceState {
23
+ models: Map<string, FederatedModel>;
24
+ geometryResult: GeometryResult | null;
25
+ }
26
+
15
27
  export interface UISlice {
16
28
  // State
17
29
  leftPanelCollapsed: boolean;
@@ -30,6 +42,15 @@ export interface UISlice {
30
42
  separationLinesQuality: SeparationLinesQuality;
31
43
  separationLinesIntensity: number;
32
44
  separationLinesRadius: number;
45
+ /**
46
+ * Issue #540 — "Merge Multilayer Walls" load-time toggle. Reading
47
+ * this on next file load is what the WASM bridge actually uses;
48
+ * flipping it while a model is in scope sets
49
+ * `mergeLayersPendingReload` so the UI can prompt the user.
50
+ */
51
+ mergeLayers: boolean;
52
+ /** True after the user flipped `mergeLayers` while a model was loaded. */
53
+ mergeLayersPendingReload: boolean;
33
54
 
34
55
  // Actions
35
56
  setLeftPanelCollapsed: (collapsed: boolean) => void;
@@ -51,6 +72,10 @@ export interface UISlice {
51
72
  setSeparationLinesQuality: (quality: SeparationLinesQuality) => void;
52
73
  setSeparationLinesIntensity: (intensity: number) => void;
53
74
  setSeparationLinesRadius: (radius: number) => void;
75
+ /** Update the merge-layers toggle and persist to localStorage. */
76
+ setMergeLayers: (v: boolean) => void;
77
+ /** Acknowledge the reload banner without performing a reload. */
78
+ clearMergeLayersPendingReload: () => void;
54
79
  }
55
80
 
56
81
  /** Apply the correct CSS classes on <html> for the given theme */
@@ -60,7 +85,18 @@ function applyThemeClasses(theme: ThemeMode) {
60
85
  el.classList.toggle('colorful', theme === 'colorful');
61
86
  }
62
87
 
63
- export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get) => ({
88
+ /**
89
+ * Returns true when any geometry is loaded — federated model map has
90
+ * entries OR the legacy single-model `geometryResult` is non-null with
91
+ * at least one mesh. Centralised here so the merge-layers toggle has
92
+ * a single source of truth for "is a model loaded?".
93
+ */
94
+ function hasLoadedModel(state: UICrossSliceState): boolean {
95
+ if (state.models.size > 0) return true;
96
+ return (state.geometryResult?.meshes.length ?? 0) > 0;
97
+ }
98
+
99
+ export const createUISlice: StateCreator<UISlice & UICrossSliceState, [], [], UISlice> = (set, get) => ({
64
100
  // Initial state
65
101
  leftPanelCollapsed: false,
66
102
  rightPanelCollapsed: false,
@@ -78,6 +114,8 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
78
114
  separationLinesQuality: UI_DEFAULTS.SEPARATION_LINES_QUALITY,
79
115
  separationLinesIntensity: UI_DEFAULTS.SEPARATION_LINES_INTENSITY,
80
116
  separationLinesRadius: UI_DEFAULTS.SEPARATION_LINES_RADIUS,
117
+ mergeLayers: UI_DEFAULTS.MERGE_LAYERS,
118
+ mergeLayersPendingReload: false,
81
119
 
82
120
  // Actions
83
121
  setLeftPanelCollapsed: (leftPanelCollapsed) => set({ leftPanelCollapsed }),
@@ -121,4 +159,24 @@ export const createUISlice: StateCreator<UISlice, [], [], UISlice> = (set, get)
121
159
  setSeparationLinesQuality: (separationLinesQuality) => set({ separationLinesQuality }),
122
160
  setSeparationLinesIntensity: (separationLinesIntensity) => set({ separationLinesIntensity }),
123
161
  setSeparationLinesRadius: (separationLinesRadius) => set({ separationLinesRadius }),
162
+
163
+ setMergeLayers: (next) => {
164
+ const current = get();
165
+ if (current.mergeLayers === next) return;
166
+ // Persist eagerly so the next page-load picks the same value up
167
+ // through `getInitialMergeLayers` (constants.ts). Wrap in
168
+ // try/catch — Safari private mode / locked storage throws.
169
+ try {
170
+ localStorage.setItem(MERGE_LAYERS_STORAGE_KEY, String(next));
171
+ } catch {
172
+ /* storage unavailable — accept the in-memory toggle silently */
173
+ }
174
+ // Only ask the user to reload if a model is currently in scope.
175
+ // Toggling the setting on an empty viewer simply changes the
176
+ // future load behaviour with no visible effect.
177
+ const pending = hasLoadedModel(current);
178
+ set({ mergeLayers: next, mergeLayersPendingReload: pending });
179
+ },
180
+
181
+ clearMergeLayersPendingReload: () => set({ mergeLayersPendingReload: false }),
124
182
  });
@@ -92,6 +92,33 @@ export type SectionPlaneAxis = 'down' | 'front' | 'side';
92
92
  export type { HatchPatternId as SectionCapHatchId, SectionCapStyle } from '@ifc-lite/renderer';
93
93
  import type { SectionCapStyle } from '@ifc-lite/renderer';
94
94
 
95
+ /**
96
+ * Custom (face-picked) plane override. When present, the renderer uses
97
+ * `normal` + `distance` directly and ignores `axis` / `position`. The
98
+ * cardinal `axis` / `position` / `flipped` fields are still kept in sync
99
+ * (nearest-cardinal for axis, percentage along it for position) so any
100
+ * downstream reader that pre-dates custom planes (drawings export, BCF
101
+ * snapshots, view controls) still gets a sensible projection rather than
102
+ * crashing or emitting empty data.
103
+ *
104
+ * Tangent + bitangent are derived once at pick time from `normal` via the
105
+ * deterministic `planeBasis` helper so the cap shader and cutter share
106
+ * exactly one orientation — without this the cap-hatch can rotate when
107
+ * the renderer re-derives the basis on every frame.
108
+ */
109
+ export interface CustomSectionPlane {
110
+ /** Unit world-space normal. */
111
+ normal: [number, number, number];
112
+ /** Signed plane offset in world units: `dot(pointOnPlane, normal)`. */
113
+ distance: number;
114
+ /** World-space hit point at pick time (anchors the slider re-mapping). */
115
+ pickedAt: [number, number, number];
116
+ /** First in-plane axis, deterministic from `normal`. */
117
+ tangent: [number, number, number];
118
+ /** Second in-plane axis, deterministic from `normal`. */
119
+ bitangent: [number, number, number];
120
+ }
121
+
95
122
  export interface SectionPlane {
96
123
  axis: SectionPlaneAxis;
97
124
  /** 0-100 percentage of model bounds */
@@ -110,6 +137,13 @@ export interface SectionPlane {
110
137
  showOutlines: boolean;
111
138
  /** User-defined colour + hatch for the cut surface. */
112
139
  capStyle: SectionCapStyle;
140
+ /**
141
+ * Optional arbitrary-normal override populated by face-pick. When set,
142
+ * the renderer cuts on this plane verbatim; cardinal `axis` / `position`
143
+ * are kept in sync as the closest cardinal projection (see
144
+ * `CustomSectionPlane`).
145
+ */
146
+ custom?: CustomSectionPlane;
113
147
  }
114
148
 
115
149
  // ============================================================================
@@ -120,6 +154,14 @@ export interface HoverState {
120
154
  entityId: number | null;
121
155
  screenX: number;
122
156
  screenY: number;
157
+ /**
158
+ * World-space hit position from the GPU pick (depth readback +
159
+ * inverse view-projection). Unset when the picker couldn't recover
160
+ * one (e.g. `pointCount === 0` clear, or the pick fell on the
161
+ * background). Useful for point-cloud hover tooltips where the
162
+ * synthetic entity has no surface property to display.
163
+ */
164
+ worldXYZ?: { x: number; y: number; z: number };
123
165
  }
124
166
 
125
167
  export interface ContextMenuState {
package/src/store.ts CHANGED
@@ -51,6 +51,19 @@ export { resolveEntityRef } from './store/resolveEntityRef.js';
51
51
  export { toGlobalIdFromModels, fromGlobalIdFromModels, toGlobalIdForRef } from './store/globalId.js';
52
52
  export type { ForwardModelMapLike } from './store/globalId.js';
53
53
 
54
+ // Re-export custom-section-plane geometry helper (issue #243): projects
55
+ // `pickedAt` onto the live cut plane so visuals (cap basis origin, 3D
56
+ // drag gizmo) follow `distance` instead of staying anchored at the
57
+ // original face-pick location.
58
+ export { customPlaneCenter } from './store/slices/sectionSlice.js';
59
+
60
+ // Re-export last-used section mode persistence (issue #243 follow-up):
61
+ // `SectionPanel` reads this on mount to restore either the user's
62
+ // previous cardinal cut (axis + position + flipped) or to rearm pick
63
+ // mode for first-time users / users whose last action was a face pick.
64
+ export { loadLastSectionMode } from './store/slices/sectionSlice.js';
65
+ export type { LastSectionMode } from './store/slices/sectionSlice.js';
66
+
54
67
  // Re-export Schedule (4D) types + helpers
55
68
  export type { ScheduleSlice, ScheduleTimeRange, GanttTimeScale } from './store/slices/scheduleSlice.js';
56
69
  export {
@@ -0,0 +1,231 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import assert from 'node:assert/strict';
6
+ import { describe, it } from 'node:test';
7
+ import { readFileSync } from 'node:fs';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { dirname, join } from 'node:path';
10
+
11
+ // NOTE: We deliberately avoid importing from `./acquireFileBuffer` directly,
12
+ // because that module pulls in `./ifcConfig`, which references
13
+ // `import.meta.env.*` (Vite-only). Instead, we shadow the threshold dependency
14
+ // by re-implementing the public surface against a thin re-export. The
15
+ // production `acquireFileBuffer()` simply calls `__acquireFileBufferWithThreshold`
16
+ // with `STREAM_SAB_THRESHOLD`; tests bypass `STREAM_SAB_THRESHOLD` with a
17
+ // kilobyte-sized injected threshold so the streaming branch is exercised
18
+ // without multi-hundred-MB allocations.
19
+ //
20
+ // Importing the inner function directly would still trigger the ifcConfig
21
+ // side-effect, so we use Node's loader hook indirection: import via a tiny
22
+ // module-relative path that is re-exported from acquireFileBuffer.ts itself.
23
+ import { __acquireFileBufferWithThreshold } from './acquireFileBuffer';
24
+
25
+ function bytes(n: number, fill: (i: number) => number = (i) => i & 0xff): Uint8Array<ArrayBuffer> {
26
+ // Allocate via ArrayBuffer explicitly so the resulting Uint8Array satisfies
27
+ // `Uint8Array<ArrayBuffer>`. Default `new Uint8Array(n)` infers
28
+ // `ArrayBufferLike`, which TS 5.7+'s tightened DOM lib rejects as a BlobPart.
29
+ const ab = new ArrayBuffer(n);
30
+ const u = new Uint8Array(ab);
31
+ for (let i = 0; i < n; i++) u[i] = fill(i);
32
+ return u;
33
+ }
34
+
35
+ function viewsEqual(a: Uint8Array, b: Uint8Array): boolean {
36
+ if (a.byteLength !== b.byteLength) return false;
37
+ for (let i = 0; i < a.byteLength; i++) {
38
+ if (a[i] !== b[i]) return false;
39
+ }
40
+ return true;
41
+ }
42
+
43
+ const TEST_THRESHOLD = 4 * 1024; // 4 KB — small enough to exercise streaming cheaply.
44
+
45
+ describe('acquireFileBuffer', () => {
46
+ it('returns ArrayBuffer for small files (below the streaming threshold)', async () => {
47
+ const data = bytes(1024);
48
+ const file = new File([data], 'small.bin');
49
+
50
+ const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD);
51
+
52
+ assert.equal(acquired.isShared, false);
53
+ assert.equal(acquired.buffer.byteLength, data.byteLength);
54
+ assert.equal(acquired.view.byteLength, data.byteLength);
55
+ assert.ok(viewsEqual(acquired.view, data), 'bytes round-trip');
56
+ assert.ok(acquired.buffer instanceof ArrayBuffer, 'small files keep ArrayBuffer path');
57
+ });
58
+
59
+ it('returns empty buffer for zero-size file', async () => {
60
+ const file = new File([], 'empty.bin');
61
+
62
+ const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD);
63
+
64
+ assert.equal(acquired.buffer.byteLength, 0);
65
+ assert.equal(acquired.view.byteLength, 0);
66
+ assert.equal(acquired.isShared, false);
67
+ });
68
+
69
+ it('streams large files (≥ threshold) into SharedArrayBuffer with byte-identical contents', async () => {
70
+ // Compose a Blob whose total size sits above the test threshold using a
71
+ // handful of small chunks so the read loop iterates more than once. The
72
+ // pattern is `(offset & 0xff)` so we can verify byte-identity without
73
+ // keeping a parallel copy.
74
+ const target = TEST_THRESHOLD + 4096;
75
+ const chunkSize = 1024;
76
+ const chunks: Uint8Array<ArrayBuffer>[] = [];
77
+ let written = 0;
78
+ while (written < target) {
79
+ const remaining = target - written;
80
+ const size = Math.min(chunkSize, remaining);
81
+ const chunk = new Uint8Array(new ArrayBuffer(size));
82
+ for (let i = 0; i < size; i++) chunk[i] = (written + i) & 0xff;
83
+ chunks.push(chunk);
84
+ written += size;
85
+ }
86
+ const file = new File(chunks, 'large.bin');
87
+ assert.equal(file.size, target);
88
+
89
+ const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD);
90
+
91
+ assert.equal(acquired.buffer.byteLength, target);
92
+ assert.equal(acquired.view.byteLength, target);
93
+
94
+ // Spot-check at start, chunk boundaries, middle, and end. Full scan adds
95
+ // no coverage if any byte is correct (the streaming copy either works
96
+ // for all bytes or fails immediately on misalignment).
97
+ for (const offset of [0, 1, chunkSize - 1, chunkSize, chunkSize + 1, Math.floor(target / 2), target - 2, target - 1]) {
98
+ assert.equal(acquired.view[offset], offset & 0xff, `byte at offset ${offset}`);
99
+ }
100
+
101
+ // SAB iff the runtime supports it. Node 22 gives us SAB and an undefined
102
+ // `crossOriginIsolated`, so we expect the streaming path to engage.
103
+ if (typeof SharedArrayBuffer !== 'undefined') {
104
+ assert.equal(acquired.isShared, true);
105
+ assert.ok(
106
+ acquired.buffer instanceof SharedArrayBuffer,
107
+ 'large files use SharedArrayBuffer when supported',
108
+ );
109
+ }
110
+ });
111
+
112
+ it('rejects when the underlying stream errors', async () => {
113
+ const fakeFile = {
114
+ name: 'broken.bin',
115
+ size: TEST_THRESHOLD + 1,
116
+ stream(): ReadableStream<Uint8Array> {
117
+ return new ReadableStream<Uint8Array>({
118
+ start(controller) {
119
+ controller.error(new Error('synthetic stream failure'));
120
+ },
121
+ });
122
+ },
123
+ arrayBuffer(): Promise<ArrayBuffer> {
124
+ return Promise.reject(new Error('arrayBuffer not used in this test'));
125
+ },
126
+ } as unknown as File;
127
+
128
+ await assert.rejects(
129
+ __acquireFileBufferWithThreshold(fakeFile, TEST_THRESHOLD),
130
+ /synthetic stream failure/,
131
+ );
132
+ });
133
+
134
+ it('falls back to arrayBuffer() when SharedArrayBuffer is unavailable', async () => {
135
+ const originalSAB = (globalThis as { SharedArrayBuffer?: unknown }).SharedArrayBuffer;
136
+ try {
137
+ (globalThis as { SharedArrayBuffer?: unknown }).SharedArrayBuffer = undefined;
138
+
139
+ const data = bytes(1024);
140
+ const file = new File([data], 'no-sab.bin');
141
+ // Force the size check to think this is a large file while keeping the
142
+ // actual buffer small — verifies the fallback branch fires before any
143
+ // SAB allocation is attempted.
144
+ Object.defineProperty(file, 'size', { value: TEST_THRESHOLD + 1 });
145
+
146
+ const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD);
147
+
148
+ assert.equal(acquired.isShared, false);
149
+ assert.ok(acquired.buffer instanceof ArrayBuffer, 'fallback returns ArrayBuffer');
150
+ } finally {
151
+ (globalThis as { SharedArrayBuffer?: unknown }).SharedArrayBuffer = originalSAB;
152
+ }
153
+ });
154
+
155
+ it('IFCX federation call sites do NOT use SAB streaming (memory regression guard for #647)', () => {
156
+ // IFCX is JSON. The federation parser path is:
157
+ // parseFederatedIfcx → safeUtf8Decode(new Uint8Array(buffer)) → JSON.parse
158
+ // safeUtf8Decode must copy SAB-backed views into a scratch buffer in
159
+ // Chromium/Firefox (cross-thread JS string decoding cannot read SAB
160
+ // directly) and retains that scratch. Net peak with SAB streaming:
161
+ // SAB (file.size) + scratch copy (file.size) + JSON string (~file.size)
162
+ // + retained scratch — strictly worse than the plain ArrayBuffer path.
163
+ //
164
+ // This is a source-level guard: it ensures the two IFCX entry points in
165
+ // useIfcFederation.ts (loadFederatedIfcx + addIfcxOverlays) stay on
166
+ // file.arrayBuffer() and don't accidentally regress back to
167
+ // acquireFileBuffer(). The IFC/STEP path (addModel) keeps SAB streaming.
168
+ const here = dirname(fileURLToPath(import.meta.url));
169
+ const sourcePath = join(here, '..', 'hooks', 'useIfcFederation.ts');
170
+ const source = readFileSync(sourcePath, 'utf8');
171
+
172
+ // Find the loadFederatedIfcx and addIfcxOverlays function bodies and
173
+ // assert each one reads files via file.arrayBuffer(), not acquireFileBuffer.
174
+ const ifcxFnNames = ['loadFederatedIfcx', 'addIfcxOverlays'];
175
+ for (const fnName of ifcxFnNames) {
176
+ // Match the const declaration through the closing `}, [` of useCallback.
177
+ const startMarker = `const ${fnName} = useCallback`;
178
+ const startIdx = source.indexOf(startMarker);
179
+ assert.ok(startIdx >= 0, `expected ${fnName} declaration in useIfcFederation.ts`);
180
+ // End at the next useCallback dependency-array opener that closes this fn.
181
+ // We look for the first `}, [` after `startIdx`.
182
+ const endIdx = source.indexOf('}, [', startIdx);
183
+ assert.ok(endIdx > startIdx, `expected end of ${fnName} useCallback`);
184
+ const body = source.slice(startIdx, endIdx);
185
+ assert.ok(
186
+ body.includes('file.arrayBuffer()'),
187
+ `${fnName} must read files via file.arrayBuffer() (IFCX JSON path)`,
188
+ );
189
+ assert.ok(
190
+ !body.includes('acquireFileBuffer'),
191
+ `${fnName} must NOT use acquireFileBuffer() — SAB streaming worsens peak memory for IFCX/JSON (see PR #647 regression).`,
192
+ );
193
+ }
194
+
195
+ // Sanity check: the IFC addModel path SHOULD still use acquireFileBuffer
196
+ // (STEP/IFC binary path benefits from SAB streaming).
197
+ const addModelStart = source.indexOf('const addModel = useCallback');
198
+ assert.ok(addModelStart >= 0, 'expected addModel declaration');
199
+ const addModelEnd = source.indexOf('}, [', addModelStart);
200
+ const addModelBody = source.slice(addModelStart, addModelEnd);
201
+ assert.ok(
202
+ addModelBody.includes('acquireFileBuffer'),
203
+ 'addModel (IFC/STEP path) must keep using acquireFileBuffer() for SAB streaming',
204
+ );
205
+ });
206
+
207
+ it('falls back to arrayBuffer() when crossOriginIsolated is explicitly false', async () => {
208
+ const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'crossOriginIsolated');
209
+ try {
210
+ Object.defineProperty(globalThis, 'crossOriginIsolated', {
211
+ configurable: true,
212
+ get: () => false,
213
+ });
214
+
215
+ const data = bytes(64);
216
+ const file = new File([data], 'no-coi.bin');
217
+ Object.defineProperty(file, 'size', { value: TEST_THRESHOLD + 1 });
218
+
219
+ const acquired = await __acquireFileBufferWithThreshold(file, TEST_THRESHOLD);
220
+
221
+ assert.equal(acquired.isShared, false);
222
+ assert.ok(acquired.buffer instanceof ArrayBuffer);
223
+ } finally {
224
+ if (originalDescriptor) {
225
+ Object.defineProperty(globalThis, 'crossOriginIsolated', originalDescriptor);
226
+ } else {
227
+ delete (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated;
228
+ }
229
+ }
230
+ });
231
+ });
@@ -0,0 +1,128 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Reads a `File` into a single buffer, streaming directly into a
7
+ * `SharedArrayBuffer` for files above `STREAM_SAB_THRESHOLD`. Avoids the
8
+ * doubled peak memory of `await file.arrayBuffer()` followed by a SAB
9
+ * allocation+copy inside the geometry pipeline (issue #600).
10
+ *
11
+ * The returned `view` is suitable for every downstream consumer: parser,
12
+ * fingerprinter, format detector, geometry processor. Each downstream uses
13
+ * `new Uint8Array(buffer)` or works on the view directly, both of which
14
+ * accept SAB-backed views.
15
+ *
16
+ * Cache writes (`saveToCache`) and server uploads do their own copy via
17
+ * structured clone or `Blob`, so SAB ownership doesn't leak into IndexedDB
18
+ * or `fetch`.
19
+ */
20
+
21
+ // `STREAM_SAB_THRESHOLD` lives in `ifcConfig`, but importing that module
22
+ // eagerly drags in `import.meta.env.*` (Vite-only) and breaks Node-based
23
+ // unit tests. We dereference it lazily inside the public `acquireFileBuffer`
24
+ // wrapper so the test entry point (`__acquireFileBufferWithThreshold`) can
25
+ // run without ever loading `ifcConfig`.
26
+
27
+ export interface AcquiredBuffer {
28
+ /**
29
+ * Underlying buffer. Either a `SharedArrayBuffer` (large files when SAB is
30
+ * supported) or an `ArrayBuffer` (small files, or environments without
31
+ * cross-origin isolation).
32
+ */
33
+ buffer: ArrayBuffer | SharedArrayBuffer;
34
+ /** Zero-copy view over `buffer`. Pass this to consumers expecting bytes. */
35
+ view: Uint8Array;
36
+ /** Whether the underlying buffer is a SharedArrayBuffer. */
37
+ isShared: boolean;
38
+ }
39
+
40
+ function sharedArrayBufferAvailable(): boolean {
41
+ if (typeof SharedArrayBuffer === 'undefined') return false;
42
+ // `crossOriginIsolated` is the canonical gate; some early implementations
43
+ // lack the global, hence the `?? true` permissiveness — if SAB *exists* in
44
+ // scope the environment is generally COI-enabled.
45
+ const coi = (globalThis as { crossOriginIsolated?: boolean }).crossOriginIsolated;
46
+ return coi !== false;
47
+ }
48
+
49
+ /**
50
+ * Internal entry point that accepts an injected threshold. Production code
51
+ * should call `acquireFileBuffer` (which uses `STREAM_SAB_THRESHOLD` from
52
+ * `ifcConfig`). Tests use this overload so they can exercise the streaming
53
+ * branch without allocating a multi-hundred-MB buffer.
54
+ */
55
+ export async function __acquireFileBufferWithThreshold(
56
+ file: File,
57
+ threshold: number,
58
+ ): Promise<AcquiredBuffer> {
59
+ const useSharedStream =
60
+ file.size >= threshold
61
+ && sharedArrayBufferAvailable()
62
+ && typeof file.stream === 'function';
63
+
64
+ if (!useSharedStream) {
65
+ const buffer = await file.arrayBuffer();
66
+ return {
67
+ buffer,
68
+ view: new Uint8Array(buffer),
69
+ isShared: false,
70
+ };
71
+ }
72
+
73
+ const sab = new SharedArrayBuffer(file.size);
74
+ const view = new Uint8Array(sab);
75
+ const reader = (file.stream() as ReadableStream<Uint8Array>).getReader();
76
+ let offset = 0;
77
+
78
+ try {
79
+ // Stream chunks from the File directly into the SAB. No intermediate
80
+ // ArrayBuffer means peak memory is ~`fileSize` instead of `2 × fileSize`
81
+ // at this entry point.
82
+ while (true) {
83
+ const { done, value } = await reader.read();
84
+ if (done) break;
85
+ if (offset + value.byteLength > sab.byteLength) {
86
+ // Defensive: file grew while reading (rare, but possible on local
87
+ // disks with active writes). Truncate to the SAB size we promised.
88
+ view.set(value.subarray(0, sab.byteLength - offset), offset);
89
+ offset = sab.byteLength;
90
+ break;
91
+ }
92
+ view.set(value, offset);
93
+ offset += value.byteLength;
94
+ }
95
+ } finally {
96
+ // releaseLock can throw if the reader is already closed/released by the
97
+ // platform after a stream error. The lock is gone either way, so cleanup
98
+ // is safe to swallow here. (CR feedback on #627.)
99
+ try { reader.releaseLock(); } catch { /* cleanup — safe to ignore */ }
100
+ }
101
+
102
+ // Validate we read the expected number of bytes. A short read indicates
103
+ // the file shrank mid-load; surface it loudly so callers don't silently
104
+ // process a truncated buffer.
105
+ if (offset !== sab.byteLength) {
106
+ throw new Error(
107
+ `acquireFileBuffer: short read for ${file.name} (got ${offset} of ${sab.byteLength} bytes)`,
108
+ );
109
+ }
110
+
111
+ return {
112
+ buffer: sab,
113
+ view,
114
+ isShared: true,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Reads `file` into an in-memory buffer. Streams chunks into a pre-sized
120
+ * `SharedArrayBuffer` for files ≥ `STREAM_SAB_THRESHOLD` when SAB is
121
+ * available, otherwise falls back to `await file.arrayBuffer()`.
122
+ */
123
+ export async function acquireFileBuffer(file: File): Promise<AcquiredBuffer> {
124
+ // Lazy import keeps Node-based test runs out of the Vite `import.meta.env`
125
+ // path that `ifcConfig` evaluates at module-load time.
126
+ const { STREAM_SAB_THRESHOLD } = await import('./ifcConfig.js');
127
+ return __acquireFileBufferWithThreshold(file, STREAM_SAB_THRESHOLD);
128
+ }
@@ -49,6 +49,30 @@ export const CACHE_MAX_SOURCE_SIZE = 150 * 1024 * 1024;
49
49
  /** Route desktop IFCs above this threshold through the bounded-memory path. */
50
50
  export const HUGE_NATIVE_FILE_THRESHOLD = 128 * 1024 * 1024;
51
51
 
52
+ /**
53
+ * File size at which the browser-File-API entry path streams directly into a
54
+ * `SharedArrayBuffer` instead of going through `await file.arrayBuffer()`.
55
+ *
56
+ * Below this threshold, the doubled peak (ArrayBuffer + SAB) is small enough
57
+ * that the simpler one-shot read is preferable. Above it, the streaming path
58
+ * shaves ~`fileSize` MB from peak memory and avoids hitting V8's per-buffer
59
+ * allocation limits on huge files. (Issue #600.)
60
+ */
61
+ export const STREAM_SAB_THRESHOLD = 256 * 1024 * 1024;
62
+
63
+ /**
64
+ * File size at which the browser parser worker defers indexing of property
65
+ * atoms (`IFCPROPERTYSINGLEVALUE`, `IFCPROPERTYENUMERATEDVALUE`, etc.) until
66
+ * a property panel actually opens.
67
+ *
68
+ * On a 14 M-entity, 986 MB file roughly 3.4 M of those entities are
69
+ * property atoms. Skipping them in the primary `compactByIdIndex` shaves
70
+ * ~4 s off the parse path; the deferred index is built on-demand in
71
+ * ~50 ms when the first property panel hydrates. Mirrors the desktop
72
+ * `hugeNativeMode` gate.
73
+ */
74
+ export const HUGE_BROWSER_FILE_THRESHOLD = 500 * 1024 * 1024;
75
+
52
76
  /** File size thresholds for various optimizations */
53
77
  export const THRESHOLDS = {
54
78
  /** Use streaming Parquet above this (150MB) */
@@ -166,10 +166,28 @@ export function buildIfcDataStoreFromNativeMetadata(snapshot: NativeMetadataSnap
166
166
  const elements = node.elements.map((summary) => {
167
167
  entityLookup.addSummary(summary);
168
168
  if (nextStoreyId !== null) {
169
- elementToStorey.set(summary.expressId, nextStoreyId);
169
+ // Direct storey containment wins — only set if absent. Mirrors the
170
+ // descendant-walk path in `spatialHierarchy.ts` where direct
171
+ // IfcRelContainedInSpatialStructure entries take precedence over
172
+ // inherited aggregate-descendant assignments.
173
+ if (!elementToStorey.has(summary.expressId)) {
174
+ elementToStorey.set(summary.expressId, nextStoreyId);
175
+ }
176
+ // NOTE: aggregate descendants of an element (e.g. IfcBuildingElementPart
177
+ // children of an IfcWall) are NOT represented locally in the native
178
+ // metadata snapshot — `NativeMetadataSpatialNode.children` only contains
179
+ // spatial sub-nodes and `node.elements` is a flat list of
180
+ // directly-contained elements (no `children` field on
181
+ // `NativeMetadataEntitySummary`). They are fetched lazily through
182
+ // `getNativeMetadataChildren`. The aggregate-descendant-walk fix that
183
+ // `spatialHierarchy.ts` performs via `relationships.getRelated` cannot
184
+ // be replicated here without an additional native bootstrap payload
185
+ // change (see issue #540 follow-up).
170
186
  }
171
187
  if (nextSpaceId !== null) {
172
- elementToSpace.set(summary.expressId, nextSpaceId);
188
+ if (!elementToSpace.has(summary.expressId)) {
189
+ elementToSpace.set(summary.expressId, nextSpaceId);
190
+ }
173
191
  }
174
192
  return summary.expressId;
175
193
  });