@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.
- package/.turbo/turbo-build.log +59 -44
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +488 -0
- package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
- package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
- package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
- package/dist/assets/exporters-u0sz2Upj.js +259119 -0
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
- package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
- package/dist/assets/ids-B7AXEv7h.js +4067 -0
- package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
- package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
- package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
- package/dist/assets/index-CSWgTe1s.css +1 -0
- package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
- package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
- package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
- package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
- package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
- package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
- package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
- package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
- package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
- package/dist/index.html +8 -8
- package/index.html +1 -1
- package/package.json +10 -10
- package/src/components/viewer/BasketPresentationDock.tsx +3 -0
- package/src/components/viewer/CesiumOverlay.tsx +165 -120
- package/src/components/viewer/DeviationPanel.tsx +172 -0
- package/src/components/viewer/HierarchyPanel.tsx +29 -3
- package/src/components/viewer/HoverTooltip.tsx +5 -0
- package/src/components/viewer/IDSAuditSummary.tsx +389 -0
- package/src/components/viewer/IDSPanel.tsx +80 -26
- package/src/components/viewer/MainToolbar.tsx +60 -7
- package/src/components/viewer/MergeLayersBanner.tsx +108 -0
- package/src/components/viewer/MobileToolbar.tsx +326 -0
- package/src/components/viewer/PointCloudClasses.tsx +111 -0
- package/src/components/viewer/PointCloudLegend.tsx +119 -0
- package/src/components/viewer/PointCloudPanel.tsx +52 -1
- package/src/components/viewer/PropertiesPanel.tsx +37 -6
- package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
- package/src/components/viewer/StatusBar.tsx +14 -0
- package/src/components/viewer/ViewerLayout.tsx +288 -95
- package/src/components/viewer/Viewport.tsx +86 -18
- package/src/components/viewer/ViewportContainer.tsx +25 -11
- package/src/components/viewer/ViewportOverlays.tsx +41 -26
- package/src/components/viewer/mouseHandlerTypes.ts +22 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
- package/src/components/viewer/properties/MaterialCard.tsx +2 -2
- package/src/components/viewer/selectionHandlers.ts +41 -0
- package/src/components/viewer/tools/SectionPanel.tsx +181 -24
- package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
- package/src/components/viewer/useAnimationLoop.ts +22 -0
- package/src/components/viewer/useMouseControls.ts +296 -3
- package/src/components/viewer/usePointCloudSync.ts +8 -1
- package/src/components/viewer/useRenderUpdates.ts +21 -1
- package/src/components/viewer/useTouchControls.ts +100 -41
- package/src/hooks/federationLoadGate.test.ts +90 -0
- package/src/hooks/federationLoadGate.ts +127 -0
- package/src/hooks/ids/idsDataAccessor.ts +11 -259
- package/src/hooks/ingest/pointCloudIngest.ts +127 -16
- package/src/hooks/useDrawingGeneration.ts +81 -8
- package/src/hooks/useIDS.ts +90 -10
- package/src/hooks/useIfcFederation.ts +94 -16
- package/src/hooks/useIfcLoader.ts +289 -64
- package/src/hooks/useViewerSelectors.ts +10 -0
- package/src/lib/geo/cesium-bridge.ts +84 -67
- package/src/lib/geo/clamp-anchor.test.ts +80 -0
- package/src/lib/geo/clamp-anchor.ts +57 -0
- package/src/lib/geo/effective-georef.test.ts +79 -1
- package/src/lib/geo/effective-georef.ts +83 -0
- package/src/lib/geo/reproject.ts +26 -13
- package/src/lib/geo/terrain-elevation.ts +166 -0
- package/src/lib/lens/adapter.ts +1 -1
- package/src/lib/llm/context-builder.ts +1 -1
- package/src/lib/perf/memoryAccounting.test.ts +92 -0
- package/src/lib/perf/memoryAccounting.ts +235 -0
- package/src/sdk/adapters/mutation-view.ts +1 -1
- package/src/store/constants.ts +39 -2
- package/src/store/index.ts +6 -1
- package/src/store/slices/cesiumSlice.ts +1 -1
- package/src/store/slices/idsSlice.ts +24 -0
- package/src/store/slices/loadingSlice.ts +12 -0
- package/src/store/slices/pointCloudSlice.ts +72 -1
- package/src/store/slices/sectionSlice.test.ts +590 -1
- package/src/store/slices/sectionSlice.ts +344 -17
- package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
- package/src/store/slices/uiSlice.ts +60 -2
- package/src/store/types.ts +42 -0
- package/src/store.ts +13 -0
- package/src/utils/acquireFileBuffer.test.ts +231 -0
- package/src/utils/acquireFileBuffer.ts +128 -0
- package/src/utils/ifcConfig.ts +24 -0
- package/src/utils/nativeSpatialDataStore.ts +20 -2
- package/src/utils/spatialHierarchy.test.ts +116 -0
- package/src/utils/spatialHierarchy.ts +23 -0
- package/tailwind.config.js +5 -0
- package/tsconfig.json +1 -0
- package/vite.config.ts +6 -0
- package/dist/assets/decode-worker-Collf_X_.js +0 -1320
- package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
- package/dist/assets/exporters-xbXqEDlO.js +0 -81590
- package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
- package/dist/assets/ids-2WdONLlu.js +0 -2033
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
canvas.addEventListener('
|
|
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
|
+
}
|