@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
@@ -11,7 +11,9 @@ import { useEffect, type MutableRefObject, type RefObject } from 'react';
11
11
  import type { Renderer, PickResult } from '@ifc-lite/renderer';
12
12
  import type { MeshData } from '@ifc-lite/geometry';
13
13
  import type { SectionPlane } from '@/store';
14
- import { getEntityCenter } from '../../utils/viewportUtils.js';
14
+
15
+ /** Locked gesture mode for 2-finger interactions */
16
+ type TwoFingerGesture = 'none' | 'pinch' | 'pan';
15
17
 
16
18
  export interface TouchState {
17
19
  touches: Touch[];
@@ -21,6 +23,12 @@ export interface TouchState {
21
23
  tapStartPos: { x: number; y: number };
22
24
  didMove: boolean;
23
25
  multiTouch: boolean;
26
+ /** Locked 2-finger gesture mode (reset on finger lift) */
27
+ twoFingerGesture: TwoFingerGesture;
28
+ /** Accumulated distance change since 2-finger gesture start */
29
+ gestureDistanceAccum: number;
30
+ /** Accumulated center movement since 2-finger gesture start */
31
+ gesturePanAccum: number;
24
32
  }
25
33
 
26
34
  export interface UseTouchControlsParams {
@@ -70,6 +78,40 @@ export function useTouchControls(params: UseTouchControlsParams): void {
70
78
  const camera = renderer.getCamera();
71
79
  const touchState = touchStateRef.current;
72
80
 
81
+ // Anchor the orbit pivot to the 3D point directly under a finger.
82
+ // Touch UX: prefer the finger's actual hit, then fall back to ray-projection
83
+ // at current view distance — never to a selected entity's center, which
84
+ // would pivot far from the user's touch and feel disconnected.
85
+ const anchorOrbitPivotUnderFinger = (touch: Touch) => {
86
+ const rect = canvas.getBoundingClientRect();
87
+ const tx = touch.clientX - rect.left;
88
+ const ty = touch.clientY - rect.top;
89
+ const hit = renderer.raycastScene(tx, ty, {
90
+ hiddenIds: hiddenEntitiesRef.current,
91
+ isolatedIds: isolatedEntitiesRef.current,
92
+ });
93
+ if (hit?.intersection) {
94
+ camera.setOrbitCenter(hit.intersection.point);
95
+ return;
96
+ }
97
+ const ray = camera.unprojectToRay(tx, ty, canvas.width, canvas.height);
98
+ const target = camera.getTarget();
99
+ const toTarget = {
100
+ x: target.x - ray.origin.x,
101
+ y: target.y - ray.origin.y,
102
+ z: target.z - ray.origin.z,
103
+ };
104
+ const d = Math.max(
105
+ 1,
106
+ toTarget.x * ray.direction.x + toTarget.y * ray.direction.y + toTarget.z * ray.direction.z,
107
+ );
108
+ camera.setOrbitCenter({
109
+ x: ray.origin.x + ray.direction.x * d,
110
+ y: ray.origin.y + ray.direction.y * d,
111
+ z: ray.origin.z + ray.direction.z * d,
112
+ });
113
+ };
114
+
73
115
  const handleTouchStart = async (e: TouchEvent) => {
74
116
  e.preventDefault();
75
117
  touchState.touches = Array.from(e.touches);
@@ -92,39 +134,7 @@ export function useTouchControls(params: UseTouchControlsParams): void {
92
134
  };
93
135
  touchState.didMove = false;
94
136
 
95
- // Set orbit pivot to the 3D point under the finger.
96
- // On miss, place pivot at current distance along the finger ray.
97
- const rect = canvas.getBoundingClientRect();
98
- const tx = touchState.touches[0].clientX - rect.left;
99
- const ty = touchState.touches[0].clientY - rect.top;
100
- const hit = renderer.raycastScene(tx, ty, {
101
- hiddenIds: hiddenEntitiesRef.current,
102
- isolatedIds: isolatedEntitiesRef.current,
103
- });
104
- if (hit?.intersection) {
105
- camera.setOrbitCenter(hit.intersection.point);
106
- } else if (selectedEntityIdRef.current) {
107
- const center = getEntityCenter(geometryRef.current, selectedEntityIdRef.current);
108
- if (center) {
109
- camera.setOrbitCenter(center);
110
- } else {
111
- camera.setOrbitCenter(null);
112
- }
113
- } else {
114
- const ray = camera.unprojectToRay(tx, ty, canvas.width, canvas.height);
115
- const target = camera.getTarget();
116
- const toTarget = {
117
- x: target.x - ray.origin.x,
118
- y: target.y - ray.origin.y,
119
- z: target.z - ray.origin.z,
120
- };
121
- const d = Math.max(1, toTarget.x * ray.direction.x + toTarget.y * ray.direction.y + toTarget.z * ray.direction.z);
122
- camera.setOrbitCenter({
123
- x: ray.origin.x + ray.direction.x * d,
124
- y: ray.origin.y + ray.direction.y * d,
125
- z: ray.origin.z + ray.direction.z * d,
126
- });
127
- }
137
+ anchorOrbitPivotUnderFinger(touchState.touches[0]);
128
138
  } else if (touchState.touches.length === 1) {
129
139
  // Single touch after multi-touch - just update center for orbit
130
140
  touchState.lastCenter = {
@@ -139,6 +149,10 @@ export function useTouchControls(params: UseTouchControlsParams): void {
139
149
  x: (touchState.touches[0].clientX + touchState.touches[1].clientX) / 2,
140
150
  y: (touchState.touches[0].clientY + touchState.touches[1].clientY) / 2,
141
151
  };
152
+ // Reset gesture lock for new 2-finger interaction
153
+ touchState.twoFingerGesture = 'none';
154
+ touchState.gestureDistanceAccum = 0;
155
+ touchState.gesturePanAccum = 0;
142
156
  }
143
157
  };
144
158
 
@@ -173,11 +187,30 @@ export function useTouchControls(params: UseTouchControlsParams): void {
173
187
  const centerY = (touchState.touches[0].clientY + touchState.touches[1].clientY) / 2;
174
188
  const panDx = centerX - touchState.lastCenter.x;
175
189
  const panDy = centerY - touchState.lastCenter.y;
176
- camera.pan(panDx, panDy, false);
177
190
 
178
191
  const zoomDelta = distance - touchState.lastDistance;
179
- const rect = canvas.getBoundingClientRect();
180
- camera.zoom(zoomDelta * 10, false, centerX - rect.left, centerY - rect.top, canvas.width, canvas.height);
192
+
193
+ // Determine dominant gesture if not yet locked
194
+ if (touchState.twoFingerGesture === 'none') {
195
+ touchState.gestureDistanceAccum += Math.abs(zoomDelta);
196
+ touchState.gesturePanAccum += Math.abs(panDx) + Math.abs(panDy);
197
+
198
+ // Lock gesture after enough movement (8px threshold)
199
+ const threshold = 8;
200
+ if (touchState.gestureDistanceAccum > threshold || touchState.gesturePanAccum > threshold) {
201
+ touchState.twoFingerGesture =
202
+ touchState.gestureDistanceAccum > touchState.gesturePanAccum ? 'pinch' : 'pan';
203
+ }
204
+ }
205
+
206
+ // Apply only the locked gesture
207
+ if (touchState.twoFingerGesture === 'pan') {
208
+ camera.pan(panDx, panDy, false);
209
+ } else if (touchState.twoFingerGesture === 'pinch') {
210
+ const rect = canvas.getBoundingClientRect();
211
+ camera.zoom(zoomDelta * 3, false, centerX - rect.left, centerY - rect.top, canvas.width, canvas.height);
212
+ }
213
+ // While gesture is 'none' (detecting), don't apply either — avoids jitter
181
214
 
182
215
  touchState.lastDistance = distance;
183
216
  touchState.lastCenter = { x: centerX, y: centerY };
@@ -192,6 +225,18 @@ export function useTouchControls(params: UseTouchControlsParams): void {
192
225
  const wasMultiTouch = touchState.multiTouch;
193
226
  touchState.touches = Array.from(e.touches);
194
227
 
228
+ // Multi-touch → single-touch transition: re-anchor everything to the
229
+ // remaining finger so the next orbit move computes a clean delta from
230
+ // the finger's actual position (not the stale 2-finger midpoint) and
231
+ // pivots under the finger (not the old pinch pivot).
232
+ if (previousTouchCount >= 2 && touchState.touches.length === 1) {
233
+ touchState.lastCenter = {
234
+ x: touchState.touches[0].clientX,
235
+ y: touchState.touches[0].clientY,
236
+ };
237
+ anchorOrbitPivotUnderFinger(touchState.touches[0]);
238
+ }
239
+
195
240
  // Only clear interaction when all fingers are lifted (gesture truly ended).
196
241
  // Clearing earlier would briefly drop interaction mode during 2-finger → 1-finger
197
242
  // transitions, triggering an expensive full-quality render mid-gesture.
@@ -229,8 +274,11 @@ export function useTouchControls(params: UseTouchControlsParams): void {
229
274
  handlePickForSelection(pickResult);
230
275
  }
231
276
 
232
- // Reset multi-touch flag when all touches end
277
+ // Reset multi-touch and gesture lock when all touches end
233
278
  touchState.multiTouch = false;
279
+ touchState.twoFingerGesture = 'none';
280
+ touchState.gestureDistanceAccum = 0;
281
+ touchState.gesturePanAccum = 0;
234
282
  }
235
283
  };
236
284
 
@@ -245,16 +293,27 @@ export function useTouchControls(params: UseTouchControlsParams): void {
245
293
  touchState.multiTouch = false;
246
294
  };
247
295
 
248
- canvas.addEventListener('touchstart', handleTouchStart);
249
- canvas.addEventListener('touchmove', handleTouchMove);
250
- canvas.addEventListener('touchend', handleTouchEnd);
296
+ // Use { passive: false } to ensure preventDefault() works on mobile
297
+ // Safari and Chrome mobile require this for smooth touch handling
298
+ canvas.addEventListener('touchstart', handleTouchStart, { passive: false });
299
+ canvas.addEventListener('touchmove', handleTouchMove, { passive: false });
300
+ canvas.addEventListener('touchend', handleTouchEnd, { passive: false });
251
301
  canvas.addEventListener('touchcancel', handleTouchCancel);
252
302
 
303
+ // Prevent iOS Safari pull-to-refresh and elastic bounce on the canvas
304
+ const preventOverscroll = (e: TouchEvent) => {
305
+ if (e.target === canvas) {
306
+ e.preventDefault();
307
+ }
308
+ };
309
+ document.addEventListener('touchmove', preventOverscroll, { passive: false });
310
+
253
311
  return () => {
254
312
  canvas.removeEventListener('touchstart', handleTouchStart);
255
313
  canvas.removeEventListener('touchmove', handleTouchMove);
256
314
  canvas.removeEventListener('touchend', handleTouchEnd);
257
315
  canvas.removeEventListener('touchcancel', handleTouchCancel);
316
+ document.removeEventListener('touchmove', preventOverscroll);
258
317
  };
259
318
  }, [isInitialized]);
260
319
  }
@@ -0,0 +1,90 @@
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 { describe, it, beforeEach } from 'node:test';
6
+ import assert from 'node:assert';
7
+
8
+ import {
9
+ acquireFederationLoadSlot,
10
+ releaseFederationLoadSlot,
11
+ __resetFederationLoadGate,
12
+ __getFederationLoadGateStats,
13
+ } from './federationLoadGate.js';
14
+
15
+ describe('federationLoadGate', () => {
16
+ beforeEach(() => {
17
+ __resetFederationLoadGate();
18
+ });
19
+
20
+ it('admits a single load immediately regardless of size', async () => {
21
+ const id = await acquireFederationLoadSlot(8000);
22
+ assert.strictEqual(__getFederationLoadGateStats().activeCount, 1);
23
+ releaseFederationLoadSlot(id);
24
+ assert.strictEqual(__getFederationLoadGateStats().activeCount, 0);
25
+ });
26
+
27
+ it('admits two small loads concurrently when budget allows', async () => {
28
+ const a = await acquireFederationLoadSlot(50);
29
+ const b = await acquireFederationLoadSlot(50);
30
+ const stats = __getFederationLoadGateStats();
31
+ assert.strictEqual(stats.activeCount, 2);
32
+ assert.strictEqual(stats.queuedCount, 0);
33
+ releaseFederationLoadSlot(a);
34
+ releaseFederationLoadSlot(b);
35
+ });
36
+
37
+ it('queues a second large load when the budget is full', async () => {
38
+ const first = await acquireFederationLoadSlot(2048);
39
+ let secondAcquired = false;
40
+ const secondPromise = acquireFederationLoadSlot(2048).then((id) => {
41
+ secondAcquired = true;
42
+ return id;
43
+ });
44
+ await new Promise((r) => setTimeout(r, 10));
45
+ assert.strictEqual(secondAcquired, false);
46
+ assert.strictEqual(__getFederationLoadGateStats().queuedCount, 1);
47
+
48
+ releaseFederationLoadSlot(first);
49
+ const second = await secondPromise;
50
+ assert.strictEqual(secondAcquired, true);
51
+ releaseFederationLoadSlot(second);
52
+ });
53
+
54
+ it('FIFO order: first queued resolves first', async () => {
55
+ const blocker = await acquireFederationLoadSlot(2048);
56
+
57
+ const order: string[] = [];
58
+ const aPromise = acquireFederationLoadSlot(2048).then((id) => { order.push('a'); return id; });
59
+ const bPromise = acquireFederationLoadSlot(50).then((id) => { order.push('b'); return id; });
60
+
61
+ await new Promise((r) => setTimeout(r, 10));
62
+ releaseFederationLoadSlot(blocker);
63
+
64
+ const [a, b] = await Promise.all([aPromise, bPromise]);
65
+ assert.strictEqual(order[0], 'a');
66
+ releaseFederationLoadSlot(a);
67
+ releaseFederationLoadSlot(b);
68
+ });
69
+
70
+ it('release wakes multiple queued loads that fit together', async () => {
71
+ const blocker = await acquireFederationLoadSlot(2048);
72
+ const p1 = acquireFederationLoadSlot(50);
73
+ const p2 = acquireFederationLoadSlot(50);
74
+
75
+ await new Promise((r) => setTimeout(r, 10));
76
+ assert.strictEqual(__getFederationLoadGateStats().queuedCount, 2);
77
+
78
+ releaseFederationLoadSlot(blocker);
79
+ const [id1, id2] = await Promise.all([p1, p2]);
80
+ assert.strictEqual(__getFederationLoadGateStats().activeCount, 2);
81
+ releaseFederationLoadSlot(id1);
82
+ releaseFederationLoadSlot(id2);
83
+ });
84
+
85
+ it('treats negative file size as zero', async () => {
86
+ const id = await acquireFederationLoadSlot(-100);
87
+ assert.strictEqual(__getFederationLoadGateStats().activeCount, 1);
88
+ releaseFederationLoadSlot(id);
89
+ });
90
+ });
@@ -0,0 +1,127 @@
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
+ * Process-wide gate for federation `addModel` calls.
7
+ *
8
+ * Two concurrent sequences (e.g. user drag-drops a second batch before the
9
+ * first has finished) used to multiply worker count and OOM the tab on
10
+ * large files. The geometry processor already caps workers per load by
11
+ * memory budget (`packages/geometry/src/worker-count.ts`) — but two loads
12
+ * each at the cap still exceed the budget together.
13
+ *
14
+ * This gate enforces a simple invariant: the *sum* of all in-flight loads
15
+ * fits under the same memory budget the worker count uses. When it
16
+ * doesn't, the new load waits in a FIFO queue until an in-flight load
17
+ * releases. Single-file drops never wait — only concurrent ones do.
18
+ *
19
+ * Pure-JS module, no React. Importable from anywhere; the singleton state
20
+ * lives on the module.
21
+ */
22
+
23
+ interface PendingAcquire {
24
+ fileSizeMB: number;
25
+ resolve: () => void;
26
+ }
27
+
28
+ interface ActiveLoad {
29
+ id: number;
30
+ fileSizeMB: number;
31
+ }
32
+
33
+ let nextId = 1;
34
+ const active: Map<number, ActiveLoad> = new Map();
35
+ const queue: PendingAcquire[] = [];
36
+
37
+ /**
38
+ * Memory cost estimate per concurrent load, in MB. Mirrors the worker-count
39
+ * formula at a coarser grain: input buffer (1×) + per-worker WASM
40
+ * (1.5×, with worker cap already applied) + accumulating meshes (1.5×) ≈ 4×.
41
+ */
42
+ const COST_PER_FILE_MULTIPLIER = 4;
43
+
44
+ function getDeviceMemoryGB(): number {
45
+ if (typeof navigator === 'undefined') return 8;
46
+ const dm = (navigator as unknown as { deviceMemory?: number }).deviceMemory;
47
+ return typeof dm === 'number' && dm > 0 ? dm : 8;
48
+ }
49
+
50
+ function getAvailableBudgetMB(): number {
51
+ const totalRAMmb = getDeviceMemoryGB() * 1024;
52
+ // Same headroom logic as worker-count.ts.
53
+ const reservedHeadroomMB = Math.max(1024, totalRAMmb * 0.25);
54
+ return Math.max(512, totalRAMmb - reservedHeadroomMB);
55
+ }
56
+
57
+ function activeCostMB(): number {
58
+ let sum = 0;
59
+ for (const load of active.values()) {
60
+ sum += load.fileSizeMB * COST_PER_FILE_MULTIPLIER;
61
+ }
62
+ return sum;
63
+ }
64
+
65
+ function tryAdmit(): void {
66
+ while (queue.length > 0) {
67
+ const head = queue[0];
68
+ const wouldCost = head.fileSizeMB * COST_PER_FILE_MULTIPLIER;
69
+ const available = getAvailableBudgetMB() - activeCostMB();
70
+ // Always admit when nothing is active (single file should never wait).
71
+ if (active.size === 0 || wouldCost <= available) {
72
+ queue.shift();
73
+ head.resolve();
74
+ // Loop continues — we may be able to admit several queued small loads
75
+ // after a single large load releases.
76
+ continue;
77
+ }
78
+ break;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Acquire a slot for a load of the given estimated size. Resolves
84
+ * immediately when budget allows; otherwise queues FIFO and resolves when
85
+ * an active load releases. Returns the slot id to pass to `release`.
86
+ */
87
+ export async function acquireFederationLoadSlot(fileSizeMB: number): Promise<number> {
88
+ const id = nextId++;
89
+ const cost = Math.max(0, fileSizeMB) * COST_PER_FILE_MULTIPLIER;
90
+ const available = getAvailableBudgetMB() - activeCostMB();
91
+
92
+ if (active.size === 0 || cost <= available) {
93
+ active.set(id, { id, fileSizeMB });
94
+ return id;
95
+ }
96
+
97
+ await new Promise<void>((resolve) => {
98
+ queue.push({ fileSizeMB, resolve });
99
+ });
100
+ active.set(id, { id, fileSizeMB });
101
+ return id;
102
+ }
103
+
104
+ /**
105
+ * Release a previously-acquired slot. Wakes the next queued load(s) that
106
+ * fit in the freed budget.
107
+ */
108
+ export function releaseFederationLoadSlot(id: number): void {
109
+ active.delete(id);
110
+ tryAdmit();
111
+ }
112
+
113
+ /** Internal — for tests only. Resets state. */
114
+ export function __resetFederationLoadGate(): void {
115
+ active.clear();
116
+ queue.length = 0;
117
+ nextId = 1;
118
+ }
119
+
120
+ /** Internal — for tests/diagnostics. */
121
+ export function __getFederationLoadGateStats(): { activeCount: number; queuedCount: number; activeCostMB: number } {
122
+ return {
123
+ activeCount: active.size,
124
+ queuedCount: queue.length,
125
+ activeCostMB: activeCostMB(),
126
+ };
127
+ }