@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.
Files changed (129) hide show
  1. package/.turbo/turbo-build.log +59 -43
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +496 -0
  4. package/dist/assets/basketViewActivator-Bzw51jhm.js +71 -0
  5. package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
  6. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  7. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  8. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  9. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  10. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  11. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  12. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  13. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  14. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  15. package/dist/assets/index-CSWgTe1s.css +1 -0
  16. package/dist/assets/{index-BOi3BuUI.js → index-DVNSvEMh.js} +49877 -28410
  17. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  18. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  19. package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-BiD01jI9.js} +2 -2
  20. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  21. package/dist/assets/{sandbox-Baez7n-t.js → sandbox-DPD1ROr0.js} +548 -530
  22. package/dist/assets/{server-client-BB6cMAXE.js → server-client-DP8fMPY9.js} +1 -1
  23. package/dist/assets/three-CDRZThFA.js +4057 -0
  24. package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-CErti6zX.js} +1 -1
  25. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  26. package/dist/index.html +10 -9
  27. package/dist/samples/building-architecture.ifc +453 -0
  28. package/dist/samples/hello-wall.ifc +1054 -0
  29. package/dist/samples/infra-bridge.ifc +962 -0
  30. package/index.html +1 -1
  31. package/package.json +15 -10
  32. package/public/samples/building-architecture.ifc +453 -0
  33. package/public/samples/hello-wall.ifc +1054 -0
  34. package/public/samples/infra-bridge.ifc +962 -0
  35. package/src/App.tsx +37 -3
  36. package/src/components/mcp/HeroScene.tsx +876 -0
  37. package/src/components/mcp/McpLanding.tsx +1318 -0
  38. package/src/components/mcp/McpPlayground.tsx +524 -0
  39. package/src/components/mcp/PlaygroundChat.tsx +1097 -0
  40. package/src/components/mcp/PlaygroundViewer.tsx +815 -0
  41. package/src/components/mcp/README.md +171 -0
  42. package/src/components/mcp/data.ts +659 -0
  43. package/src/components/mcp/playground-dispatcher.ts +1649 -0
  44. package/src/components/mcp/playground-files.ts +107 -0
  45. package/src/components/mcp/playground-uploads.ts +122 -0
  46. package/src/components/mcp/types.ts +65 -0
  47. package/src/components/mcp/use-mcp-page.ts +109 -0
  48. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  49. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  50. package/src/components/viewer/DeviationPanel.tsx +172 -0
  51. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  52. package/src/components/viewer/HoverTooltip.tsx +5 -0
  53. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  54. package/src/components/viewer/IDSPanel.tsx +80 -26
  55. package/src/components/viewer/MainToolbar.tsx +79 -7
  56. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  57. package/src/components/viewer/MobileToolbar.tsx +326 -0
  58. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  59. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  60. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  61. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  62. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  63. package/src/components/viewer/StatusBar.tsx +14 -0
  64. package/src/components/viewer/ViewerLayout.tsx +288 -95
  65. package/src/components/viewer/Viewport.tsx +86 -18
  66. package/src/components/viewer/ViewportContainer.tsx +60 -15
  67. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  68. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  69. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  70. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  71. package/src/components/viewer/selectionHandlers.ts +41 -0
  72. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  73. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  74. package/src/components/viewer/useAnimationLoop.ts +22 -0
  75. package/src/components/viewer/useMouseControls.ts +296 -3
  76. package/src/components/viewer/usePointCloudSync.ts +8 -1
  77. package/src/components/viewer/useRenderUpdates.ts +21 -1
  78. package/src/components/viewer/useTouchControls.ts +100 -41
  79. package/src/generated/mcp-catalog.json +82 -0
  80. package/src/hooks/federationLoadGate.test.ts +90 -0
  81. package/src/hooks/federationLoadGate.ts +127 -0
  82. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  83. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  84. package/src/hooks/useDrawingGeneration.ts +81 -8
  85. package/src/hooks/useIDS.ts +90 -10
  86. package/src/hooks/useIfcFederation.ts +94 -16
  87. package/src/hooks/useIfcLoader.ts +289 -64
  88. package/src/hooks/useViewerSelectors.ts +10 -0
  89. package/src/lib/geo/cesium-bridge.ts +84 -67
  90. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  91. package/src/lib/geo/clamp-anchor.ts +57 -0
  92. package/src/lib/geo/effective-georef.test.ts +79 -1
  93. package/src/lib/geo/effective-georef.ts +83 -0
  94. package/src/lib/geo/reproject.ts +26 -13
  95. package/src/lib/geo/terrain-elevation.ts +166 -0
  96. package/src/lib/lens/adapter.ts +1 -1
  97. package/src/lib/llm/context-builder.ts +1 -1
  98. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  99. package/src/lib/perf/memoryAccounting.ts +235 -0
  100. package/src/sdk/adapters/mutation-view.ts +1 -1
  101. package/src/store/constants.ts +39 -2
  102. package/src/store/index.ts +6 -1
  103. package/src/store/slices/cesiumSlice.ts +1 -1
  104. package/src/store/slices/idsSlice.ts +24 -0
  105. package/src/store/slices/loadingSlice.ts +12 -0
  106. package/src/store/slices/pointCloudSlice.ts +72 -1
  107. package/src/store/slices/sectionSlice.test.ts +590 -1
  108. package/src/store/slices/sectionSlice.ts +344 -17
  109. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  110. package/src/store/slices/uiSlice.ts +60 -2
  111. package/src/store/types.ts +42 -0
  112. package/src/store.ts +13 -0
  113. package/src/utils/acquireFileBuffer.test.ts +231 -0
  114. package/src/utils/acquireFileBuffer.ts +128 -0
  115. package/src/utils/ifcConfig.ts +24 -0
  116. package/src/utils/nativeSpatialDataStore.ts +20 -2
  117. package/src/utils/spatialHierarchy.test.ts +116 -0
  118. package/src/utils/spatialHierarchy.ts +23 -0
  119. package/tailwind.config.js +5 -0
  120. package/tsconfig.json +1 -0
  121. package/vite.config.ts +12 -0
  122. package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
  123. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  124. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  125. package/dist/assets/exporters-BraHBeoi.js +0 -81583
  126. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  127. package/dist/assets/ids-DQ5jY0E8.js +0 -1
  128. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  129. 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
- 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,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
+ }