@ifc-lite/viewer 1.26.0 → 1.28.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 (150) hide show
  1. package/.turbo/turbo-build.log +45 -38
  2. package/CHANGELOG.md +93 -0
  3. package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-BNRDNuUJ.js} +9 -9
  4. package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-DCwCuP7n.js} +56 -56
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
  8. package/dist/assets/deflate-DNGgs8Ur.js +1 -0
  9. package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
  10. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  11. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  12. package/dist/assets/{exporters-DSq76AVM.js → exporters-B9v81gi9.js} +1861 -1524
  13. package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
  14. package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-D-YCLS4g.js} +10 -10
  15. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  16. package/dist/assets/{ids-DiLcGTer.js → ids-CCpq-5d3.js} +952 -945
  17. package/dist/assets/ifc-lite_bg-DbgS5EUA.wasm +0 -0
  18. package/dist/assets/{index-BAH8IJVR.js → index-Bgb3_Pu_.js} +47682 -42474
  19. package/dist/assets/index-BtbXFKsX.css +1 -0
  20. package/dist/assets/index.es-CWfqZyyr.js +6866 -0
  21. package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-DGOAeUqU.js} +1 -1
  22. package/dist/assets/jspdf.es.min-XPLU2Wkq.js +19571 -0
  23. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  24. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  25. package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-1PMSCHwX.js} +1 -1
  26. package/dist/assets/{lzw-BBPPLW-0.js → lzw-C65U9lNM.js} +1 -1
  27. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  28. package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-XxXos6yI.js} +2 -2
  29. package/dist/assets/{packbits-yLSpjW-V.js → packbits-BdMWXC3m.js} +1 -1
  30. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  31. package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
  32. package/dist/assets/pdf-CRwaZf3s.js +135 -0
  33. package/dist/assets/raw-CJgQdyuZ.js +1 -0
  34. package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-0sDo3g3m.js} +3037 -2554
  35. package/dist/assets/server-client-cTCJ-853.js +719 -0
  36. package/dist/assets/{webimage-YafxjjGr.js → webimage-BtakWX7W.js} +1 -1
  37. package/dist/assets/xlsx-B1YOg2QB.js +142 -0
  38. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  39. package/dist/assets/{zstd-CkSLOiuu.js → zstd-CmwsbxmM.js} +1 -1
  40. package/dist/index.html +10 -10
  41. package/package.json +27 -23
  42. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  43. package/src/components/mcp/data.ts +6 -0
  44. package/src/components/mcp/playground-dispatcher.ts +280 -0
  45. package/src/components/mcp/playground-files.ts +33 -1
  46. package/src/components/mcp/types.ts +2 -1
  47. package/src/components/ui/combo-input.tsx +163 -0
  48. package/src/components/ui/tabs.tsx +1 -1
  49. package/src/components/viewer/CommandPalette.tsx +6 -1
  50. package/src/components/viewer/ComparePanel.tsx +420 -0
  51. package/src/components/viewer/HierarchyPanel.tsx +46 -7
  52. package/src/components/viewer/MainToolbar.tsx +19 -2
  53. package/src/components/viewer/PropertiesPanel.tsx +84 -8
  54. package/src/components/viewer/SearchInline.tsx +62 -2
  55. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  56. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  57. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  58. package/src/components/viewer/SearchModal.tsx +19 -6
  59. package/src/components/viewer/ViewerLayout.tsx +5 -0
  60. package/src/components/viewer/Viewport.tsx +18 -0
  61. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  62. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  63. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  64. package/src/components/viewer/hierarchy/types.ts +1 -0
  65. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  66. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  67. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  68. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  69. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  70. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  71. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  72. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  73. package/src/generated/mcp-catalog.json +4 -0
  74. package/src/hooks/federationLoadGate.test.ts +12 -2
  75. package/src/hooks/federationLoadGate.ts +9 -2
  76. package/src/hooks/ingest/federationAlign.ts +481 -0
  77. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  78. package/src/hooks/source-key.ts +35 -0
  79. package/src/hooks/useAlignmentLines3D.ts +1 -26
  80. package/src/hooks/useCompare.ts +0 -0
  81. package/src/hooks/useCompareOverlay.ts +119 -0
  82. package/src/hooks/useDrawingGeneration.ts +23 -1
  83. package/src/hooks/useGridLines3D.ts +140 -0
  84. package/src/hooks/useIfc.ts +1 -1
  85. package/src/hooks/useIfcCache.ts +32 -9
  86. package/src/hooks/useIfcFederation.ts +42 -810
  87. package/src/hooks/useIfcLoader.ts +361 -488
  88. package/src/hooks/useIfcServer.ts +3 -0
  89. package/src/hooks/useLens.ts +5 -1
  90. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  91. package/src/lib/compare/buildFingerprints.ts +173 -0
  92. package/src/lib/compare/describeChange.ts +0 -0
  93. package/src/lib/compare/geometricData.test.ts +54 -0
  94. package/src/lib/compare/geometricData.ts +37 -0
  95. package/src/lib/compare/overlay.test.ts +99 -0
  96. package/src/lib/compare/overlay.ts +91 -0
  97. package/src/lib/geo/cesium-placement.ts +1 -1
  98. package/src/lib/geo/reproject.ts +4 -1
  99. package/src/lib/length-unit-scale.ts +41 -0
  100. package/src/lib/lists/adapter.ts +136 -11
  101. package/src/lib/lists/export/csv.ts +47 -0
  102. package/src/lib/lists/export/index.ts +49 -0
  103. package/src/lib/lists/export/model.ts +111 -0
  104. package/src/lib/lists/export/pdf.ts +67 -0
  105. package/src/lib/lists/export/xlsx.ts +83 -0
  106. package/src/lib/lists/index.ts +2 -0
  107. package/src/lib/llm/script-edit-ops.ts +23 -0
  108. package/src/lib/llm/stream-client.ts +8 -1
  109. package/src/lib/search/filter-evaluate.test.ts +81 -0
  110. package/src/lib/search/filter-evaluate.ts +59 -87
  111. package/src/lib/search/filter-match.ts +167 -0
  112. package/src/lib/search/filter-rules.test.ts +25 -0
  113. package/src/lib/search/filter-rules.ts +75 -2
  114. package/src/lib/search/filter-schema.ts +0 -0
  115. package/src/lib/search/result-export.ts +7 -1
  116. package/src/lib/slab-edit.test.ts +72 -0
  117. package/src/lib/slab-edit.ts +159 -19
  118. package/src/sdk/adapters/export-adapter.ts +9 -4
  119. package/src/sdk/adapters/query-adapter.ts +3 -3
  120. package/src/store/globalId.ts +15 -13
  121. package/src/store/index.ts +16 -1
  122. package/src/store/slices/cesiumSlice.ts +8 -1
  123. package/src/store/slices/compareSlice.ts +96 -0
  124. package/src/store/slices/lensSlice.ts +8 -0
  125. package/src/store/slices/listSlice.ts +6 -0
  126. package/src/store/slices/mutationSlice.ts +14 -6
  127. package/src/store/slices/searchSlice.ts +29 -3
  128. package/src/utils/acquireFileBuffer.test.ts +12 -4
  129. package/src/utils/desktopModelSnapshot.ts +2 -1
  130. package/src/utils/loadingUtils.ts +32 -0
  131. package/src/utils/nativeSpatialDataStore.ts +6 -0
  132. package/src/utils/serverDataModel.test.ts +6 -0
  133. package/src/utils/serverDataModel.ts +7 -0
  134. package/src/utils/spatialHierarchy.test.ts +53 -1
  135. package/src/utils/spatialHierarchy.ts +42 -2
  136. package/src/vite-env.d.ts +2 -0
  137. package/dist/assets/deflate-Cnx0il6E.js +0 -1
  138. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  139. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  140. package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
  141. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  142. package/dist/assets/index-B9Ug2EqU.css +0 -1
  143. package/dist/assets/lens-PYsLu_MA.js +0 -1
  144. package/dist/assets/parser.worker-8md211IW.js +0 -182
  145. package/dist/assets/raw-BQrAgxwT.js +0 -1
  146. package/dist/assets/server-client-Bk4c1CPO.js +0 -626
  147. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  148. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  149. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  150. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
@@ -0,0 +1,123 @@
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
+ * Pure helpers for the Lists results table — value formatting, comparison,
7
+ * numeric-column detection, content-aware column widths, and the grouping /
8
+ * aggregation that powers the in-table (settings-free) grouped view.
9
+ */
10
+
11
+ import type { CellValue, ColumnDefinition, ListRow, ListGrouping } from '@ifc-lite/lists';
12
+
13
+ export function formatCellValue(value: CellValue): string {
14
+ if (value === null || value === undefined) return '';
15
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
16
+ if (typeof value === 'number') {
17
+ if (Number.isInteger(value)) return value.toLocaleString();
18
+ return value.toFixed(4).replace(/\.?0+$/, '');
19
+ }
20
+ return String(value);
21
+ }
22
+
23
+ export function compareCells(a: CellValue, b: CellValue): number {
24
+ if (a === null && b === null) return 0;
25
+ if (a === null) return -1;
26
+ if (b === null) return 1;
27
+ if (typeof a === 'number' && typeof b === 'number') return a - b;
28
+ return String(a).localeCompare(String(b));
29
+ }
30
+
31
+ /** A column is numeric (summable) when every sampled non-empty value is a
32
+ * finite number and at least one such value exists. */
33
+ export function detectNumericColumns(columns: ColumnDefinition[], rows: ListRow[]): boolean[] {
34
+ const sample = rows.slice(0, 120);
35
+ return columns.map((_, i) => {
36
+ let sawNumber = false;
37
+ for (const r of sample) {
38
+ const v = r.values[i];
39
+ if (v === null || v === undefined || v === '') continue;
40
+ if (typeof v === 'number' && Number.isFinite(v)) { sawNumber = true; continue; }
41
+ return false;
42
+ }
43
+ return sawNumber;
44
+ });
45
+ }
46
+
47
+ /** Content-aware default width: fits the header + the widest sampled value
48
+ * (≈7px/char), clamped to a readable range. */
49
+ export function autoColumnWidth(label: string, rows: ListRow[], colIdx: number): number {
50
+ let maxLen = label.length;
51
+ const sample = rows.slice(0, 200);
52
+ for (const r of sample) {
53
+ const v = r.values[colIdx];
54
+ if (v === null || v === undefined) continue;
55
+ const len = (typeof v === 'number' ? formatCellValue(v) : String(v)).length;
56
+ if (len > maxLen) maxLen = len;
57
+ }
58
+ return Math.max(80, Math.min(460, maxLen * 7 + 34));
59
+ }
60
+
61
+ export type DisplayItem =
62
+ | { kind: 'group'; key: string; label: string; count: number; sums: Record<string, number> }
63
+ | { kind: 'row'; row: ListRow };
64
+
65
+ export interface Totals { count: number; sums: Record<string, number> }
66
+ export interface GroupedView { items: DisplayItem[]; groupCount: number; totals: Totals }
67
+
68
+ function sumIndices(columns: ColumnDefinition[], sumColumnIds: string[]) {
69
+ return sumColumnIds
70
+ .map((id) => ({ id, idx: columns.findIndex((c) => c.id === id) }))
71
+ .filter((s) => s.idx >= 0);
72
+ }
73
+
74
+ /** Bucket already-filtered/sorted rows by the group-by column, accumulate
75
+ * per-group + grand count/sums, and flatten into a virtualizable list
76
+ * (group header followed by its rows when the group is expanded). */
77
+ export function buildGroupedView(
78
+ rows: ListRow[],
79
+ columns: ColumnDefinition[],
80
+ grouping: ListGrouping,
81
+ expanded: Set<string>,
82
+ ): GroupedView {
83
+ const groupIdx = columns.findIndex((c) => c.id === grouping.columnId);
84
+ const sums = sumIndices(columns, grouping.sumColumnIds);
85
+ const zero = (): Record<string, number> => Object.fromEntries(sums.map((s) => [s.id, 0]));
86
+
87
+ const totals: Totals = { count: rows.length, sums: zero() };
88
+ const byKey = new Map<string, { key: string; label: string; count: number; sums: Record<string, number>; rows: ListRow[] }>();
89
+
90
+ for (const row of rows) {
91
+ const raw = groupIdx >= 0 ? row.values[groupIdx] : null;
92
+ const label = raw === null || raw === undefined || raw === '' ? '(none)' : formatCellValue(raw);
93
+ let g = byKey.get(label);
94
+ if (!g) { g = { key: label, label, count: 0, sums: zero(), rows: [] }; byKey.set(label, g); }
95
+ g.count++;
96
+ g.rows.push(row);
97
+ for (const s of sums) {
98
+ const v = row.values[s.idx];
99
+ if (typeof v === 'number' && Number.isFinite(v)) { g.sums[s.id] += v; totals.sums[s.id] += v; }
100
+ }
101
+ }
102
+
103
+ const groups = Array.from(byKey.values()).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
104
+ const items: DisplayItem[] = [];
105
+ for (const g of groups) {
106
+ items.push({ kind: 'group', key: g.key, label: g.label, count: g.count, sums: g.sums });
107
+ if (expanded.has(g.key)) for (const r of g.rows) items.push({ kind: 'row', row: r });
108
+ }
109
+ return { items, groupCount: groups.length, totals };
110
+ }
111
+
112
+ /** Grand totals for the flat (ungrouped) view when sum columns are active. */
113
+ export function flatTotals(rows: ListRow[], columns: ColumnDefinition[], sumColumnIds: string[]): Totals {
114
+ const sums = sumIndices(columns, sumColumnIds);
115
+ const acc: Record<string, number> = Object.fromEntries(sums.map((s) => [s.id, 0]));
116
+ for (const r of rows) {
117
+ for (const s of sums) {
118
+ const v = r.values[s.idx];
119
+ if (typeof v === 'number' && Number.isFinite(v)) acc[s.id] += v;
120
+ }
121
+ }
122
+ return { count: rows.length, sums: acc };
123
+ }
@@ -0,0 +1,283 @@
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
+ * Material totals panel — shown when a material is selected from the
7
+ * "Materials" hierarchy tab. Surfaces the material's own property sets
8
+ * (IfcMaterialProperties) plus quantities aggregated across every element that
9
+ * uses the material. Volumes/areas are apportioned by each element's material
10
+ * share (layer thickness / constituent fraction), so a layered wall's volume is
11
+ * split between its concrete and insulation rather than double-counted.
12
+ */
13
+
14
+ import { useMemo } from 'react';
15
+ import { Layers, Calculator, Boxes, Info } from 'lucide-react';
16
+ import { ScrollArea } from '@/components/ui/scroll-area';
17
+ import { useIfc } from '@/hooks/useIfc';
18
+ import {
19
+ buildMaterialUsageIndex,
20
+ getMaterialDisplay,
21
+ extractMaterialPropertiesForMaterialId,
22
+ extractQuantitiesOnDemand,
23
+ type IfcDataStore,
24
+ } from '@ifc-lite/parser';
25
+ import { QuantityType } from '@ifc-lite/data';
26
+ import { PropertySetCard } from './PropertySetCard';
27
+ import type { PropertySet } from './encodingUtils';
28
+
29
+ interface MaterialTotals {
30
+ /** Number of elements using this material (across all loaded models). */
31
+ elementCount: number;
32
+ /** Elements that contributed at least one volume quantity. */
33
+ elementsWithVolume: number;
34
+ volume: number;
35
+ hasVolume: boolean;
36
+ area: number;
37
+ hasArea: boolean;
38
+ weight: number;
39
+ hasWeight: boolean;
40
+ /** Element count per IFC class, sorted desc. */
41
+ byClass: Array<{ ifcClass: string; count: number }>;
42
+ }
43
+
44
+ /** Pick a quantity value by candidate names (case-insensitive), else by type. */
45
+ function pickQuantity(
46
+ byName: Map<string, number>,
47
+ candidates: string[],
48
+ ): number | undefined {
49
+ for (const c of candidates) {
50
+ const v = byName.get(c);
51
+ if (v !== undefined) return v;
52
+ }
53
+ return undefined;
54
+ }
55
+
56
+ /** Format an aggregated quantity with magnitude-appropriate precision. */
57
+ function formatNumber(value: number): string {
58
+ if (value === 0) return '0';
59
+ if (Math.abs(value) >= 1000) return value.toLocaleString(undefined, { maximumFractionDigits: 0 });
60
+ if (Math.abs(value) >= 1) return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
61
+ return value.toLocaleString(undefined, { maximumFractionDigits: 4 });
62
+ }
63
+
64
+ export function MaterialTotalsPanel({ materialId, modelId }: { materialId: number; modelId: string }) {
65
+ const { ifcDataStore, models } = useIfc();
66
+
67
+ // The store the selected material lives in, plus every loaded store (so the
68
+ // totals merge same-named materials across a federation).
69
+ const { selectedStore, allStores } = useMemo(() => {
70
+ const stores: IfcDataStore[] = [];
71
+ if (models.size > 0) {
72
+ for (const [, m] of models) {
73
+ if (m.ifcDataStore) stores.push(m.ifcDataStore as IfcDataStore);
74
+ }
75
+ } else if (ifcDataStore) {
76
+ stores.push(ifcDataStore as IfcDataStore);
77
+ }
78
+ const sel = modelId !== 'legacy'
79
+ ? (models.get(modelId)?.ifcDataStore as IfcDataStore | undefined) ?? (ifcDataStore as IfcDataStore | null) ?? undefined
80
+ : (ifcDataStore as IfcDataStore | null) ?? undefined;
81
+ return { selectedStore: sel, allStores: stores.length > 0 ? stores : (sel ? [sel] : []) };
82
+ }, [models, ifcDataStore, modelId]);
83
+
84
+ const display = useMemo(() => {
85
+ if (!selectedStore) return { name: `Material #${materialId}`, type: 'IfcMaterial' };
86
+ return getMaterialDisplay(selectedStore, materialId);
87
+ }, [selectedStore, materialId]);
88
+
89
+ // The material's own property sets (Pset_Material*).
90
+ const psetGroups = useMemo(() => {
91
+ if (!selectedStore) return [];
92
+ return extractMaterialPropertiesForMaterialId(selectedStore, materialId);
93
+ }, [selectedStore, materialId]);
94
+
95
+ // Aggregate quantities across all elements using a material of this name.
96
+ const totals = useMemo<MaterialTotals>(() => {
97
+ const result: MaterialTotals = {
98
+ elementCount: 0,
99
+ elementsWithVolume: 0,
100
+ volume: 0,
101
+ hasVolume: false,
102
+ area: 0,
103
+ hasArea: false,
104
+ weight: 0,
105
+ hasWeight: false,
106
+ byClass: [],
107
+ };
108
+ const classCounts = new Map<string, number>();
109
+ const targetName = display.name;
110
+
111
+ for (const store of allStores) {
112
+ const usageIndex = buildMaterialUsageIndex(store);
113
+ // Forward map of entity -> quantity-set ids (when on-demand parsing is
114
+ // active). Used to skip the per-element extractor allocation for elements
115
+ // that carry no quantities — the common case in large models, so a
116
+ // material used by thousands of elements only pays the parse cost for the
117
+ // subset that actually has Qto data.
118
+ const qMap = store.onDemandQuantityMap;
119
+ for (const usage of usageIndex.values()) {
120
+ if (usage.name !== targetName) continue;
121
+ for (const { entityId, weight } of usage.entries) {
122
+ result.elementCount += 1;
123
+
124
+ const ifcClass = store.entityIndex.byId.get(entityId)?.type || usage.ifcClass;
125
+ classCounts.set(ifcClass, (classCounts.get(ifcClass) ?? 0) + 1);
126
+
127
+ if (qMap && !qMap.get(entityId)?.length) continue; // no quantities — skip extraction
128
+ const qsets = extractQuantitiesOnDemand(store, entityId);
129
+ if (qsets.length === 0) continue;
130
+ const volByName = new Map<string, number>();
131
+ const areaByName = new Map<string, number>();
132
+ const weightByName = new Map<string, number>();
133
+ for (const qset of qsets) {
134
+ for (const q of qset.quantities) {
135
+ const key = q.name.toLowerCase();
136
+ if (q.type === QuantityType.Volume) volByName.set(key, q.value);
137
+ else if (q.type === QuantityType.Area) areaByName.set(key, q.value);
138
+ else if (q.type === QuantityType.Weight) weightByName.set(key, q.value);
139
+ }
140
+ }
141
+
142
+ const vol = pickQuantity(volByName, ['netvolume', 'grossvolume', 'volume'])
143
+ ?? (volByName.size > 0 ? [...volByName.values()][0] : undefined);
144
+ if (vol !== undefined) {
145
+ result.volume += vol * weight;
146
+ result.hasVolume = true;
147
+ result.elementsWithVolume += 1;
148
+ }
149
+
150
+ const area = pickQuantity(areaByName, ['netarea', 'grossarea', 'netsidearea', 'grosssidearea', 'netfloorarea', 'grossfloorarea', 'area']);
151
+ if (area !== undefined) {
152
+ result.area += area * weight;
153
+ result.hasArea = true;
154
+ }
155
+
156
+ const wt = pickQuantity(weightByName, ['netweight', 'grossweight', 'weight']);
157
+ if (wt !== undefined) {
158
+ result.weight += wt * weight;
159
+ result.hasWeight = true;
160
+ }
161
+ }
162
+ }
163
+ }
164
+
165
+ result.byClass = [...classCounts.entries()]
166
+ .map(([ifcClass, count]) => ({ ifcClass, count }))
167
+ .sort((a, b) => b.count - a.count);
168
+ return result;
169
+ }, [allStores, display.name]);
170
+
171
+ const psetCount = psetGroups.reduce((sum, g) => sum + g.psets.length, 0);
172
+
173
+ return (
174
+ <div className="h-full flex flex-col border-l-2 border-zinc-200 dark:border-zinc-800 bg-white dark:bg-black">
175
+ {/* Header */}
176
+ <div className="p-4 border-b-2 border-zinc-200 dark:border-zinc-800 bg-amber-50/40 dark:bg-amber-950/20 space-y-2">
177
+ <div className="flex items-start gap-3">
178
+ <div className="p-2 border-2 border-amber-200 dark:border-amber-800 bg-white dark:bg-zinc-950 shrink-0">
179
+ <Layers className="h-5 w-5 text-amber-600 dark:text-amber-400" />
180
+ </div>
181
+ <div className="flex-1 min-w-0 pt-0.5">
182
+ <h3 className="font-bold text-sm truncate uppercase tracking-tight text-zinc-900 dark:text-zinc-100 min-w-0">
183
+ {display.name}
184
+ </h3>
185
+ <p className="text-xs font-mono text-amber-600/80 dark:text-amber-400/80">{display.type}</p>
186
+ </div>
187
+ </div>
188
+ </div>
189
+
190
+ <ScrollArea className="flex-1 bg-white dark:bg-black">
191
+ <div className="p-3 space-y-3 w-full overflow-hidden">
192
+ {/* Totals */}
193
+ <div className="border-2 border-amber-200 dark:border-amber-800 bg-amber-50/20 dark:bg-amber-950/20">
194
+ <div className="flex items-center gap-2 px-2.5 py-2 border-b-2 border-amber-200 dark:border-amber-800">
195
+ <Calculator className="h-3.5 w-3.5 text-amber-600 dark:text-amber-400 shrink-0" />
196
+ <span className="font-bold text-xs text-amber-700 dark:text-amber-400 uppercase tracking-wide">Totals</span>
197
+ </div>
198
+ <div className="divide-y divide-amber-100 dark:divide-amber-900/30">
199
+ <TotalRow label="Elements" value={totals.elementCount.toLocaleString()} />
200
+ {totals.hasVolume && (
201
+ <TotalRow label="Volume" value={`${formatNumber(totals.volume)} m³`} />
202
+ )}
203
+ {totals.hasArea && (
204
+ <TotalRow label="Area" value={`${formatNumber(totals.area)} m²`} />
205
+ )}
206
+ {totals.hasWeight && (
207
+ <TotalRow label="Weight" value={`${formatNumber(totals.weight)} kg`} />
208
+ )}
209
+ </div>
210
+ {totals.elementCount > 0 && !totals.hasVolume && (
211
+ <div className="flex items-start gap-1.5 px-2.5 py-2 text-[10px] text-zinc-500 dark:text-zinc-400 border-t border-amber-100 dark:border-amber-900/30">
212
+ <Info className="h-3 w-3 shrink-0 mt-px" />
213
+ <span>No volume quantities (Qto_*) found on these elements.</span>
214
+ </div>
215
+ )}
216
+ {totals.hasVolume && totals.elementsWithVolume < totals.elementCount && (
217
+ <div className="flex items-start gap-1.5 px-2.5 py-2 text-[10px] text-zinc-500 dark:text-zinc-400 border-t border-amber-100 dark:border-amber-900/30">
218
+ <Info className="h-3 w-3 shrink-0 mt-px" />
219
+ <span>
220
+ Volume from {totals.elementsWithVolume.toLocaleString()} of {totals.elementCount.toLocaleString()} elements with reported quantities;
221
+ multi-material elements are split by layer thickness / constituent fraction.
222
+ </span>
223
+ </div>
224
+ )}
225
+ </div>
226
+
227
+ {/* Breakdown by class */}
228
+ {totals.byClass.length > 0 && (
229
+ <div className="border border-zinc-200 dark:border-zinc-800">
230
+ <div className="flex items-center gap-2 px-2.5 py-2 border-b border-zinc-200 dark:border-zinc-800">
231
+ <Boxes className="h-3.5 w-3.5 text-zinc-500 shrink-0" />
232
+ <span className="font-bold text-xs text-zinc-600 dark:text-zinc-300 uppercase tracking-wide">By Class</span>
233
+ </div>
234
+ <div className="divide-y divide-zinc-100 dark:divide-zinc-900">
235
+ {totals.byClass.map((c) => (
236
+ <div key={c.ifcClass} className="flex items-center justify-between px-2.5 py-1.5 text-xs">
237
+ <span className="font-mono text-zinc-600 dark:text-zinc-400 truncate">{c.ifcClass}</span>
238
+ <span className="font-mono text-zinc-900 dark:text-zinc-100">{c.count.toLocaleString()}</span>
239
+ </div>
240
+ ))}
241
+ </div>
242
+ </div>
243
+ )}
244
+
245
+ {/* Material property sets */}
246
+ {psetCount > 0 && (
247
+ <div className="space-y-3">
248
+ <div className="flex items-center gap-2 px-1 pt-1 pb-0.5 text-[11px] text-amber-600/70 dark:text-amber-400/60 uppercase tracking-wider font-semibold">
249
+ <Layers className="h-3 w-3 shrink-0" />
250
+ <span className="truncate">Material Properties</span>
251
+ </div>
252
+ {psetGroups.map((group) =>
253
+ group.psets.map((pset) => {
254
+ const psetView: PropertySet = {
255
+ name: pset.name,
256
+ properties: pset.properties.map((p) => ({ name: p.name, value: p.value, isMutated: false })),
257
+ };
258
+ return <PropertySetCard key={`${group.materialId}-${pset.name}`} pset={psetView} />;
259
+ }),
260
+ )}
261
+ </div>
262
+ )}
263
+
264
+ {psetCount === 0 && totals.elementCount === 0 && (
265
+ <p className="text-sm text-zinc-500 dark:text-zinc-500 text-center py-8 font-mono">
266
+ No data for this material
267
+ </p>
268
+ )}
269
+ </div>
270
+ </ScrollArea>
271
+ </div>
272
+ );
273
+ }
274
+
275
+ /** A single label/value row in the material totals card. */
276
+ function TotalRow({ label, value }: { label: string; value: string }) {
277
+ return (
278
+ <div className="flex items-center justify-between px-2.5 py-2 text-xs">
279
+ <span className="text-zinc-500 dark:text-zinc-400 font-medium">{label}</span>
280
+ <span className="font-mono font-semibold text-amber-700 dark:text-amber-300 tabular-nums">{value}</span>
281
+ </div>
282
+ );
283
+ }
@@ -25,6 +25,10 @@
25
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
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
27
 
28
+ { "name": "clash_check", "category": "Clash", "scope": "read", "description": "Find clashes in a single model — the DEFAULT for any 'find/run clashes' request. Omit BOTH a and b to check every element vs every other (all clashes in the model). Give a TYPE selector for a to self-clash within a group, or both a and b for a pairwise check (a=\"IfcDuct*\", b=\"IfcWall*\"). Meshes the model in-browser; returns a summary plus the worst clashes by penetration depth.", "inputSchema": { "type": "object", "properties": { "a": { "type": "string", "description": "Type selector for set A. Defaults to \"*\" (all elements). e.g. \"IfcDuct*|IfcPipe*\", \"!IfcSpace\"." }, "b": { "type": "string", "description": "Type selector for set B. OMIT for a self-clash within A (every element vs every other in the group)." }, "mode": { "type": "string", "enum": ["hard", "clearance"] }, "tolerance": { "type": "number", "description": "Touching band (m). Defaults to the engine tolerance." }, "clearance": { "type": "number", "description": "Required gap (m) for mode=\"clearance\"." }, "model_id": { "type": "string", "description": "Optional; defaults to the only loaded model." } } } },
29
+ { "name": "clash_matrix", "category": "Clash", "scope": "read", "description": "Run the standard discipline clash matrix (MEP×STR, HVAC×ARCH, ...) — INTER-discipline pairs ONLY. Returns NOTHING on a single-discipline or architectural model (no cross-discipline pairs exist to test). For a general 'find all clashes' request use clash_check instead; use this only when the user explicitly asks for the discipline matrix. Returns per-rule and per-severity breakdowns plus a sample of the worst clashes.", "inputSchema": { "type": "object", "properties": { "mode": { "type": "string", "enum": ["hard", "clearance"] }, "clearance": { "type": "number", "description": "Required gap (m) applied to every matrix rule when mode=\"clearance\". Without it a clearance matrix reports nothing." }, "model_id": { "type": "string", "description": "Optional; defaults to the only loaded model." } } } },
30
+ { "name": "clash_bcf_export", "category": "Clash", "scope": "export", "description": "Turn the last clash run (clash_check / clash_matrix) into a rich .bcfzip: one BCF topic per clash group, each with a framed 3D viewpoint, the clashing elements as components, and severity/status/distance metadata. Runs a default all-vs-all clash_check first if you haven't clashed yet. Use THIS for 'create/export BCF from clashes' — not bcf_topic_create. (Snapshots are omitted: the inline viewer can't render frames headlessly; BCF viewpoints are valid without an image.)", "inputSchema": { "type": "object", "properties": { "group_by": { "type": "string", "enum": ["cluster", "rule", "typePair", "element"], "description": "How clashes collapse into topics. Default \"cluster\" (spatially-near clashes merge into one topic)." }, "cluster_epsilon": { "type": "number", "description": "Cluster radius (m) for group_by=\"cluster\". Default 1.5." }, "status": { "type": "string", "description": "BCF topic status, e.g. \"Open\"." }, "max_topics": { "type": "integer", "description": "Cap the number of topics. Default 1000." }, "file_path": { "type": "string", "description": "Output .bcfzip name." } } } },
31
+
28
32
  { "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
33
  { "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
34
  { "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"] } },
@@ -59,11 +59,21 @@ describe('federationLoadGate', () => {
59
59
  const bPromise = acquireFederationLoadSlot(50).then((id) => { order.push('b'); return id; });
60
60
 
61
61
  await new Promise((r) => setTimeout(r, 10));
62
+ // Releasing the blocker frees the budget for the head of the FIFO queue.
63
+ // A 2048 MB load costs more than the whole budget, so the first-queued load
64
+ // is admitted alone (single-file exception) and the 50 MB load stays queued
65
+ // until it releases. Awaiting them together would deadlock — and asserting
66
+ // that B does NOT wake alongside A is exactly what proves the gate respects
67
+ // the budget during the drain (the regression this guards against).
62
68
  releaseFederationLoadSlot(blocker);
63
69
 
64
- const [a, b] = await Promise.all([aPromise, bPromise]);
65
- assert.strictEqual(order[0], 'a');
70
+ const a = await aPromise;
71
+ assert.deepStrictEqual(order, ['a']);
72
+ assert.strictEqual(__getFederationLoadGateStats().queuedCount, 1);
73
+
66
74
  releaseFederationLoadSlot(a);
75
+ const b = await bPromise;
76
+ assert.deepStrictEqual(order, ['a', 'b']);
67
77
  releaseFederationLoadSlot(b);
68
78
  });
69
79
 
@@ -21,6 +21,7 @@
21
21
  */
22
22
 
23
23
  interface PendingAcquire {
24
+ id: number;
24
25
  fileSizeMB: number;
25
26
  resolve: () => void;
26
27
  }
@@ -70,6 +71,11 @@ function tryAdmit(): void {
70
71
  // Always admit when nothing is active (single file should never wait).
71
72
  if (active.size === 0 || wouldCost <= available) {
72
73
  queue.shift();
74
+ // Reserve the budget synchronously so activeCostMB() reflects this
75
+ // admission for the rest of this pass. The awaited acquire resumes in a
76
+ // later microtask, so registering here (not after the await) prevents the
77
+ // freed budget from being counted in full against every queued item.
78
+ active.set(head.id, { id: head.id, fileSizeMB: head.fileSizeMB });
73
79
  head.resolve();
74
80
  // Loop continues — we may be able to admit several queued small loads
75
81
  // after a single large load releases.
@@ -95,9 +101,10 @@ export async function acquireFederationLoadSlot(fileSizeMB: number): Promise<num
95
101
  }
96
102
 
97
103
  await new Promise<void>((resolve) => {
98
- queue.push({ fileSizeMB, resolve });
104
+ queue.push({ id, fileSizeMB, resolve });
99
105
  });
100
- active.set(id, { id, fileSizeMB });
106
+ // The slot is registered into `active` by tryAdmit at the moment of
107
+ // admission, so no active.set is needed here.
101
108
  return id;
102
109
  }
103
110