@ifc-lite/viewer 1.19.0 → 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 -43
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +496 -0
- package/dist/assets/basketViewActivator-Bzw51jhm.js +71 -0
- package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
- 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-BOi3BuUI.js → index-DVNSvEMh.js} +49877 -28410
- package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
- package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
- package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-BiD01jI9.js} +2 -2
- package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
- package/dist/assets/{sandbox-Baez7n-t.js → sandbox-DPD1ROr0.js} +548 -530
- package/dist/assets/{server-client-BB6cMAXE.js → server-client-DP8fMPY9.js} +1 -1
- package/dist/assets/three-CDRZThFA.js +4057 -0
- package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-CErti6zX.js} +1 -1
- package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
- package/dist/index.html +10 -9
- package/dist/samples/building-architecture.ifc +453 -0
- package/dist/samples/hello-wall.ifc +1054 -0
- package/dist/samples/infra-bridge.ifc +962 -0
- package/index.html +1 -1
- package/package.json +15 -10
- package/public/samples/building-architecture.ifc +453 -0
- package/public/samples/hello-wall.ifc +1054 -0
- package/public/samples/infra-bridge.ifc +962 -0
- package/src/App.tsx +37 -3
- package/src/components/mcp/HeroScene.tsx +876 -0
- package/src/components/mcp/McpLanding.tsx +1318 -0
- package/src/components/mcp/McpPlayground.tsx +524 -0
- package/src/components/mcp/PlaygroundChat.tsx +1097 -0
- package/src/components/mcp/PlaygroundViewer.tsx +815 -0
- package/src/components/mcp/README.md +171 -0
- package/src/components/mcp/data.ts +659 -0
- package/src/components/mcp/playground-dispatcher.ts +1649 -0
- package/src/components/mcp/playground-files.ts +107 -0
- package/src/components/mcp/playground-uploads.ts +122 -0
- package/src/components/mcp/types.ts +65 -0
- package/src/components/mcp/use-mcp-page.ts +109 -0
- 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 +79 -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 +60 -15
- 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/generated/mcp-catalog.json +82 -0
- 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 +12 -0
- package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
- package/dist/assets/decode-worker-Collf_X_.js +0 -1320
- package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
- package/dist/assets/exporters-BraHBeoi.js +0 -81583
- package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
- package/dist/assets/ids-DQ5jY0E8.js +0 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-0XpVr_S5.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,82 @@
|
|
|
1
|
+
{
|
|
2
|
+
"generatedAt": "2026-05-03T09:30:00Z",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"tools": [
|
|
5
|
+
{ "name": "model_info", "category": "Discovery", "scope": "read", "description": "Schema, entity counts, units, georeferencing — the at-a-glance summary of a loaded IFC.", "inputSchema": { "type": "object", "properties": { "model_id": { "type": "string" } } } },
|
|
6
|
+
{ "name": "model_list", "category": "Discovery", "scope": "read", "description": "List every model loaded in the current MCP session.", "inputSchema": { "type": "object" } },
|
|
7
|
+
{ "name": "model_load", "category": "Discovery", "scope": "mutate", "description": "Load an additional .ifc from disk into the federated session.", "inputSchema": { "type": "object", "required": ["file_path"], "properties": { "file_path": { "type": "string" }, "model_id": { "type": "string" } } } },
|
|
8
|
+
{ "name": "model_unload", "category": "Discovery", "scope": "mutate", "description": "Drop a model from the registry; frees memory.", "inputSchema": { "type": "object", "required": ["model_id"], "properties": { "model_id": { "type": "string" } } } },
|
|
9
|
+
{ "name": "schema_describe", "category": "Discovery", "scope": "read", "description": "Attributes, parent class, and inheritance for any IfcType — useful before mutating.", "inputSchema": { "type": "object", "required": ["type"], "properties": { "type": { "type": "string" }, "include_inherited": { "type": "boolean" } } } },
|
|
10
|
+
|
|
11
|
+
{ "name": "query_entities", "category": "Query", "scope": "read", "description": "Type + property filters with pagination. Returns expressId, GlobalId, name, type for each match.", "inputSchema": { "type": "object", "properties": { "type": { "type": "string" }, "limit": { "type": "integer" } } } },
|
|
12
|
+
{ "name": "count_entities", "category": "Query", "scope": "read", "description": "Group counts by type, storey, or material — histogram form, not the full set.", "inputSchema": { "type": "object", "properties": { "group_by": { "type": "string", "enum": ["type", "storey", "material"] } } } },
|
|
13
|
+
{ "name": "get_entity", "category": "Query", "scope": "read", "description": "Full attributes + property sets for one entity by GlobalId or expressId.", "inputSchema": { "type": "object", "properties": { "global_id": { "type": "string" }, "express_id": { "type": "integer" } } } },
|
|
14
|
+
{ "name": "get_entities_bulk", "category": "Query", "scope": "read", "description": "Batched get_entity for up to 200 ids at once.", "inputSchema": { "type": "object", "required": ["global_ids"], "properties": { "global_ids": { "type": "array", "items": { "type": "string" } } } } },
|
|
15
|
+
{ "name": "spatial_hierarchy", "category": "Query", "scope": "read", "description": "Project → site → building → storey → space tree, with element counts at each node.", "inputSchema": { "type": "object" } },
|
|
16
|
+
{ "name": "containment_chain", "category": "Query", "scope": "read", "description": "Walk up the spatial chain for one entity (storey + parent + grandparent…).", "inputSchema": { "type": "object", "required": ["global_id"] } },
|
|
17
|
+
{ "name": "relationships", "category": "Query", "scope": "read", "description": "Voids, fills, groups, connections — every IfcRel touching this entity.", "inputSchema": { "type": "object", "required": ["global_id"] } },
|
|
18
|
+
{ "name": "properties_unique", "category": "Query", "scope": "read", "description": "Unique values + counts for one property across a type set (perfect for filter UIs).", "inputSchema": { "type": "object", "required": ["type", "pset", "property"] } },
|
|
19
|
+
{ "name": "materials_list", "category": "Query", "scope": "read", "description": "Distinct materials across the model with usage counts.", "inputSchema": { "type": "object" } },
|
|
20
|
+
{ "name": "classifications_list","category": "Query", "scope": "read", "description": "Distinct classification references (system + identification) and how often each is used.", "inputSchema": { "type": "object" } },
|
|
21
|
+
{ "name": "georeferencing", "category": "Query", "scope": "read", "description": "MapConversion, projected CRS, project north, true north — the geo handshake.", "inputSchema": { "type": "object" } },
|
|
22
|
+
{ "name": "units", "category": "Query", "scope": "read", "description": "Length unit scale + the unit assignments declared in the file.", "inputSchema": { "type": "object" } },
|
|
23
|
+
|
|
24
|
+
{ "name": "geometry_bbox", "category": "Geometry", "scope": "read", "description": "Per-entity axis-aligned bounding box (read from quantity sets when available).", "inputSchema": { "type": "object", "required": ["global_id"] } },
|
|
25
|
+
{ "name": "geometry_volume", "category": "Geometry", "scope": "read", "description": "Net/gross volume in m³ for a single entity or a type aggregate.", "inputSchema": { "type": "object", "required": ["global_id"] } },
|
|
26
|
+
{ "name": "geometry_area", "category": "Geometry", "scope": "read", "description": "Surface area for an entity (front/side/footprint depending on what the IFC carries).", "inputSchema": { "type": "object", "required": ["global_id"] } },
|
|
27
|
+
|
|
28
|
+
{ "name": "model_audit", "category": "Validation", "scope": "read", "description": "Out-of-the-box health score + a list of issues (missing GlobalIds, broken refs, orphan entities).", "inputSchema": { "type": "object" } },
|
|
29
|
+
{ "name": "ids_validate", "category": "Validation", "scope": "read", "description": "Run a buildingSMART IDS spec against the loaded model. Per-spec pass/fail with offending entities.", "inputSchema": { "type": "object", "required": ["ids_path"], "properties": { "ids_path": { "type": "string" } } } },
|
|
30
|
+
{ "name": "ids_explain", "category": "Validation", "scope": "read", "description": "Parse + summarize an IDS file in plain language — what each spec asks for, in what order.", "inputSchema": { "type": "object", "required": ["ids_path"] } },
|
|
31
|
+
|
|
32
|
+
{ "name": "entity_set_property", "category": "Mutation", "scope": "mutate", "description": "Queue a Pset.property write on one entity. Persist later via export_ifc / model_save.", "inputSchema": { "type": "object", "required": ["pset", "name"] } },
|
|
33
|
+
{ "name": "entity_delete_property", "category": "Mutation","scope": "mutate", "description": "Queue a property removal from a Pset. Reversible via mutation_undo.", "inputSchema": { "type": "object", "required": ["pset", "name"] } },
|
|
34
|
+
{ "name": "entity_set_attribute","category": "Mutation", "scope": "mutate", "description": "Set Name, Description, ObjectType, or Tag on an entity.", "inputSchema": { "type": "object", "required": ["attribute", "value"] } },
|
|
35
|
+
{ "name": "entity_create", "category": "Mutation", "scope": "mutate", "description": "Create a new IFC entity with raw STEP attributes and get back its expressId.", "inputSchema": { "type": "object", "required": ["type"] } },
|
|
36
|
+
{ "name": "entity_delete", "category": "Mutation", "scope": "mutate", "description": "Delete an entity by expressId/GlobalId. Caller is responsible for cascading rels.", "inputSchema": { "type": "object" } },
|
|
37
|
+
{ "name": "mutation_batch", "category": "Mutation", "scope": "mutate", "description": "Apply N mutation ops in order, returning per-step results.", "inputSchema": { "type": "object", "required": ["operations"] } },
|
|
38
|
+
{ "name": "mutation_diff", "category": "Mutation", "scope": "read", "description": "Inspect every queued mutation vs the original parsed state.", "inputSchema": { "type": "object" } },
|
|
39
|
+
{ "name": "mutation_undo", "category": "Mutation", "scope": "mutate", "description": "Pop the last N pending mutations off the queue.", "inputSchema": { "type": "object", "properties": { "n": { "type": "integer" } } } },
|
|
40
|
+
{ "name": "model_save", "category": "Mutation", "scope": "mutate", "description": "Write the current model (with pending mutations) back to .ifc.", "inputSchema": { "type": "object", "required": ["file_path"] } },
|
|
41
|
+
|
|
42
|
+
{ "name": "bcf_topic_list", "category": "BCF", "scope": "read", "description": "List BCF topics in this session, optionally filtered by status.", "inputSchema": { "type": "object" } },
|
|
43
|
+
{ "name": "bcf_topic_create", "category": "BCF", "scope": "mutate", "description": "Create a topic with title/description/priority and get the GUID for follow-ups.", "inputSchema": { "type": "object", "required": ["title"] } },
|
|
44
|
+
{ "name": "bcf_topic_update", "category": "BCF", "scope": "mutate", "description": "Update topic fields or append a comment.", "inputSchema": { "type": "object", "required": ["guid"] } },
|
|
45
|
+
{ "name": "bcf_topic_close", "category": "BCF", "scope": "mutate", "description": "Mark a topic resolved (status=Closed).", "inputSchema": { "type": "object", "required": ["guid"] } },
|
|
46
|
+
{ "name": "bcf_viewpoint_create","category": "BCF", "scope": "mutate", "description": "Attach a selection-based viewpoint (or full viewer state) to a topic.", "inputSchema": { "type": "object", "required": ["guid"] } },
|
|
47
|
+
{ "name": "bcf_export", "category": "BCF", "scope": "export", "description": "Export the in-memory BCF project as a .bcfzip file.", "inputSchema": { "type": "object", "required": ["file_path"] } },
|
|
48
|
+
|
|
49
|
+
{ "name": "bsdd_search", "category": "bSDD", "scope": "read", "description": "Full-text search the buildingSMART Data Dictionary for classes by keyword.", "inputSchema": { "type": "object", "required": ["query"] } },
|
|
50
|
+
{ "name": "bsdd_class", "category": "bSDD", "scope": "read", "description": "Full bSDD class info for an IFC entity name (definition, parent, properties).", "inputSchema": { "type": "object", "required": ["ifc_type"] } },
|
|
51
|
+
{ "name": "bsdd_property_sets", "category": "bSDD", "scope": "read", "description": "Pset_* groups for an IFC type (Pset_WallCommon for IfcWall, etc.).", "inputSchema": { "type": "object", "required": ["ifc_type"] } },
|
|
52
|
+
{ "name": "bsdd_match", "category": "bSDD", "scope": "read", "description": "Suggest matching bSDD classes for an entity in the loaded model.", "inputSchema": { "type": "object" } },
|
|
53
|
+
|
|
54
|
+
{ "name": "model_diff", "category": "Diff", "scope": "read", "description": "Compare two loaded models. Reports added/removed entities by GlobalId and per-type count deltas.", "inputSchema": { "type": "object", "required": ["a", "b"] } },
|
|
55
|
+
{ "name": "quantity_diff", "category": "Diff", "scope": "read", "description": "Per-entity-type quantity comparison between two models, optionally grouped by storey.", "inputSchema": { "type": "object", "required": ["a", "b"] } },
|
|
56
|
+
|
|
57
|
+
{ "name": "export_ifc", "category": "Export", "scope": "export", "description": "Write the model (with pending mutations) to .ifc/.ifczip on disk.", "inputSchema": { "type": "object", "required": ["file_path"] } },
|
|
58
|
+
{ "name": "export_csv", "category": "Export", "scope": "export", "description": "Tabular property/quantity export. Columns may be Pset_X.Property paths.", "inputSchema": { "type": "object" } },
|
|
59
|
+
{ "name": "export_json", "category": "Export", "scope": "export", "description": "Structured JSON dump of attributes/properties/quantities for a type set.", "inputSchema": { "type": "object" } },
|
|
60
|
+
{ "name": "export_glb", "category": "Export", "scope": "export", "description": "(v0.2) Geometry export to glTF binary — needs the WASM mesh pipeline.", "inputSchema": { "type": "object" } },
|
|
61
|
+
{ "name": "export_ifcx", "category": "Export", "scope": "export", "description": "(v0.2) Export to the new IFCx interchange format.", "inputSchema": { "type": "object" } },
|
|
62
|
+
{ "name": "export_pdf_report", "category": "Export", "scope": "export", "description": "(v0.5) Branded PDF audit report with charts.", "inputSchema": { "type": "object" } },
|
|
63
|
+
|
|
64
|
+
{ "name": "viewer_ask", "category": "Viewer", "scope": "read", "description": "Suggest wording the agent can use to ask the user for permission to open the viewer.", "inputSchema": { "type": "object" } },
|
|
65
|
+
{ "name": "viewer_open", "category": "Viewer", "scope": "read", "description": "Boot the in-process WebGL viewer and return its URL for the user to open.", "inputSchema": { "type": "object" } },
|
|
66
|
+
{ "name": "viewer_close", "category": "Viewer", "scope": "read", "description": "Stop the viewer + clear its selection state.", "inputSchema": { "type": "object" } },
|
|
67
|
+
{ "name": "viewer_status", "category": "Viewer", "scope": "read", "description": "Report whether the viewer is open, on what port, and the current selection.", "inputSchema": { "type": "object" } },
|
|
68
|
+
{ "name": "viewer_colorize", "category": "Viewer", "scope": "read", "description": "Paint a set of entities with a color (hex / rgb / named).", "inputSchema": { "type": "object", "required": ["color"] } },
|
|
69
|
+
{ "name": "viewer_isolate", "category": "Viewer", "scope": "read", "description": "Hide everything except the picked set.", "inputSchema": { "type": "object" } },
|
|
70
|
+
{ "name": "viewer_hide", "category": "Viewer", "scope": "read", "description": "Hide the picked set; everything else stays.", "inputSchema": { "type": "object" } },
|
|
71
|
+
{ "name": "viewer_show", "category": "Viewer", "scope": "read", "description": "Show the picked set (un-hide).", "inputSchema": { "type": "object" } },
|
|
72
|
+
{ "name": "viewer_reset", "category": "Viewer", "scope": "read", "description": "Reset visibility + colors to the model defaults.", "inputSchema": { "type": "object" } },
|
|
73
|
+
{ "name": "viewer_fly_to", "category": "Viewer", "scope": "read", "description": "Frame the camera on a set of entities or a bbox.", "inputSchema": { "type": "object" } },
|
|
74
|
+
{ "name": "viewer_set_section", "category": "Viewer", "scope": "read", "description": "Apply an axis-aligned section plane.", "inputSchema": { "type": "object", "required": ["axis", "position"] } },
|
|
75
|
+
{ "name": "viewer_clear_section","category": "Viewer", "scope": "read", "description": "Remove the active section plane.", "inputSchema": { "type": "object" } },
|
|
76
|
+
{ "name": "viewer_color_by_storey","category": "Viewer", "scope": "read", "description": "Apply a per-storey overlay (built-in viewer preset).", "inputSchema": { "type": "object" } },
|
|
77
|
+
{ "name": "viewer_color_by_property","category": "Viewer", "scope": "read", "description": "Color a type set by property value, returns a legend the agent can describe.", "inputSchema": { "type": "object", "required": ["type", "pset", "property"] } },
|
|
78
|
+
{ "name": "viewer_get_selection","category": "Viewer", "scope": "read", "description": "Return the current selection — type, expressId, GlobalId, name, attributes, materials.", "inputSchema": { "type": "object" } },
|
|
79
|
+
{ "name": "viewer_describe_selection","category": "Viewer","scope": "read", "description": "Kitchen-sink: every section (attributes, properties, quantities, classifications, materials) for the picked entity.", "inputSchema": { "type": "object" } },
|
|
80
|
+
{ "name": "viewer_wait_for_selection","category": "Viewer","scope": "read", "description": "Block until the user picks something in the viewer (or timeout).", "inputSchema": { "type": "object" } }
|
|
81
|
+
]
|
|
82
|
+
}
|
|
@@ -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
|
+
}
|