@ifc-lite/viewer 1.26.0 → 1.27.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 (89) hide show
  1. package/.turbo/turbo-build.log +38 -31
  2. package/CHANGELOG.md +29 -0
  3. package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-B3CdrLsb.js} +7 -7
  4. package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-QeHK_Aud.js} +1 -1
  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/{deflate-Cnx0il6E.js → deflate-B-d0SYQM.js} +1 -1
  8. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  9. package/dist/assets/{exporters-DSq76AVM.js → exporters-B4LbZFeT.js} +1422 -1194
  10. package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
  11. package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-CrVtDRFq.js} +10 -10
  12. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  13. package/dist/assets/{ids-DiLcGTer.js → ids-DjsGFN10.js} +4 -4
  14. package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
  15. package/dist/assets/{index-BAH8IJVR.js → index-COYokSKc.js} +38319 -35469
  16. package/dist/assets/index-ajK6D32J.css +1 -0
  17. package/dist/assets/index.es-CY202jA3.js +6866 -0
  18. package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-D4wOkf5h.js} +1 -1
  19. package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
  20. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  21. package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-DmW0_tgf.js} +1 -1
  22. package/dist/assets/{lzw-BBPPLW-0.js → lzw-oWetY-d6.js} +1 -1
  23. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  24. package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-BX8_tHXE.js} +1 -1
  25. package/dist/assets/{packbits-yLSpjW-V.js → packbits-F8Nkp4NY.js} +1 -1
  26. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  27. package/dist/assets/{parser.worker-8md211IW.js → parser.worker-D591Zu_-.js} +3 -3
  28. package/dist/assets/pdf-Dsh3HPZB.js +135 -0
  29. package/dist/assets/raw-D9iw0tmc.js +1 -0
  30. package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-BAC3a-eN.js} +1735 -1660
  31. package/dist/assets/server-client-Cjwnm7il.js +706 -0
  32. package/dist/assets/{webimage-YafxjjGr.js → webimage-BLV1dgmd.js} +1 -1
  33. package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
  34. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  35. package/dist/assets/{zstd-CkSLOiuu.js → zstd-C_1HxVrA.js} +1 -1
  36. package/dist/index.html +8 -8
  37. package/package.json +10 -7
  38. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  39. package/src/components/mcp/data.ts +6 -0
  40. package/src/components/mcp/playground-dispatcher.ts +277 -0
  41. package/src/components/mcp/types.ts +2 -1
  42. package/src/components/ui/combo-input.tsx +163 -0
  43. package/src/components/ui/tabs.tsx +1 -1
  44. package/src/components/viewer/PropertiesPanel.tsx +13 -6
  45. package/src/components/viewer/SearchInline.tsx +62 -2
  46. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  47. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  48. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  49. package/src/components/viewer/SearchModal.tsx +19 -6
  50. package/src/components/viewer/Viewport.tsx +15 -0
  51. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  52. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  53. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  54. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  55. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  56. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  57. package/src/generated/mcp-catalog.json +4 -0
  58. package/src/hooks/source-key.ts +35 -0
  59. package/src/hooks/useAlignmentLines3D.ts +1 -26
  60. package/src/hooks/useGridLines3D.ts +140 -0
  61. package/src/lib/length-unit-scale.ts +41 -0
  62. package/src/lib/lists/adapter.ts +136 -11
  63. package/src/lib/lists/export/csv.ts +47 -0
  64. package/src/lib/lists/export/index.ts +49 -0
  65. package/src/lib/lists/export/model.ts +111 -0
  66. package/src/lib/lists/export/pdf.ts +67 -0
  67. package/src/lib/lists/export/xlsx.ts +83 -0
  68. package/src/lib/lists/index.ts +2 -0
  69. package/src/lib/search/filter-evaluate.test.ts +81 -0
  70. package/src/lib/search/filter-evaluate.ts +59 -87
  71. package/src/lib/search/filter-match.ts +167 -0
  72. package/src/lib/search/filter-rules.test.ts +25 -0
  73. package/src/lib/search/filter-rules.ts +75 -2
  74. package/src/lib/search/filter-schema.ts +0 -0
  75. package/src/lib/slab-edit.test.ts +72 -0
  76. package/src/lib/slab-edit.ts +159 -19
  77. package/src/sdk/adapters/export-adapter.ts +3 -3
  78. package/src/sdk/adapters/query-adapter.ts +3 -3
  79. package/src/store/slices/listSlice.ts +6 -0
  80. package/src/store/slices/mutationSlice.ts +14 -6
  81. package/src/store/slices/searchSlice.ts +29 -3
  82. package/src/utils/nativeSpatialDataStore.ts +6 -0
  83. package/src/utils/serverDataModel.test.ts +6 -0
  84. package/src/utils/serverDataModel.ts +7 -0
  85. package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
  86. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  87. package/dist/assets/index-B9Ug2EqU.css +0 -1
  88. package/dist/assets/raw-BQrAgxwT.js +0 -1
  89. package/dist/assets/server-client-Bk4c1CPO.js +0 -626
@@ -0,0 +1,111 @@
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
+ * Normalised export model shared by the CSV / Excel / PDF writers. Built from
7
+ * the on-screen list view so every export honours the configured columns
8
+ * (order, labels, widths), the active grouping, and the summed columns —
9
+ * grouped sections with per-group count + subtotals, plus grand totals.
10
+ */
11
+
12
+ import type { CellValue, ColumnDefinition, ListRow, ListGrouping } from '@ifc-lite/lists';
13
+
14
+ export interface ExportColumn {
15
+ id: string;
16
+ label: string;
17
+ numeric: boolean;
18
+ summed: boolean;
19
+ /** Pixel width from the table (for proportional column sizing in exports). */
20
+ width: number;
21
+ }
22
+
23
+ export interface ExportGroup {
24
+ label: string;
25
+ count: number;
26
+ sums: Record<string, number>;
27
+ rows: CellValue[][];
28
+ }
29
+
30
+ export interface ExportModel {
31
+ title: string;
32
+ generatedAt: string;
33
+ columns: ExportColumn[];
34
+ /** Grouped sections (with member rows), or null when the list isn't grouped. */
35
+ groups: ExportGroup[] | null;
36
+ /** All rows in display order (flat) — used by writers that don't section. */
37
+ rows: CellValue[][];
38
+ groupColumnId: string | null;
39
+ sumColumnIds: string[];
40
+ totals: { count: number; sums: Record<string, number> };
41
+ }
42
+
43
+ export interface BuildModelInput {
44
+ title: string;
45
+ columns: ColumnDefinition[];
46
+ /** Rows already filtered + sorted exactly as shown on screen. */
47
+ rows: ListRow[];
48
+ grouping?: ListGrouping;
49
+ numericCols: boolean[];
50
+ columnWidths: number[];
51
+ generatedAt: string;
52
+ }
53
+
54
+ /** Format a cell for text-based exports (CSV/PDF). Excel keeps raw numbers. */
55
+ export function displayCell(value: CellValue): string {
56
+ if (value === null || value === undefined) return '';
57
+ if (typeof value === 'boolean') return value ? 'Yes' : 'No';
58
+ if (typeof value === 'number') {
59
+ if (Number.isInteger(value)) return value.toLocaleString();
60
+ return value.toFixed(4).replace(/\.?0+$/, '');
61
+ }
62
+ return String(value);
63
+ }
64
+
65
+ export function buildExportModel(input: BuildModelInput): ExportModel {
66
+ const { columns, rows, grouping, numericCols, columnWidths, title, generatedAt } = input;
67
+ const sumColumnIds = grouping?.sumColumnIds ?? [];
68
+ const exportCols: ExportColumn[] = columns.map((c, i) => ({
69
+ id: c.id,
70
+ label: c.label ?? c.propertyName,
71
+ numeric: !!numericCols[i],
72
+ summed: sumColumnIds.includes(c.id),
73
+ width: columnWidths[i] ?? 120,
74
+ }));
75
+
76
+ const sumIdx = sumColumnIds
77
+ .map((id) => ({ id, idx: columns.findIndex((c) => c.id === id) }))
78
+ .filter((s) => s.idx >= 0);
79
+ const zeroSums = (): Record<string, number> => Object.fromEntries(sumIdx.map((s) => [s.id, 0]));
80
+ const addSums = (acc: Record<string, number>, values: CellValue[]) => {
81
+ for (const s of sumIdx) {
82
+ const v = values[s.idx];
83
+ if (typeof v === 'number' && Number.isFinite(v)) acc[s.id] += v;
84
+ }
85
+ };
86
+
87
+ const totals = { count: rows.length, sums: zeroSums() };
88
+ const flatRows: CellValue[][] = [];
89
+ for (const r of rows) { flatRows.push(r.values); addSums(totals.sums, r.values); }
90
+
91
+ const groupColumnId = grouping?.columnId && columns.some((c) => c.id === grouping.columnId)
92
+ ? grouping.columnId : null;
93
+
94
+ let groups: ExportGroup[] | null = null;
95
+ if (groupColumnId) {
96
+ const groupIdx = columns.findIndex((c) => c.id === groupColumnId);
97
+ const byKey = new Map<string, ExportGroup>();
98
+ for (const r of rows) {
99
+ const raw = r.values[groupIdx];
100
+ const label = raw === null || raw === undefined || raw === '' ? '(none)' : displayCell(raw);
101
+ let g = byKey.get(label);
102
+ if (!g) { g = { label, count: 0, sums: zeroSums(), rows: [] }; byKey.set(label, g); }
103
+ g.count++;
104
+ g.rows.push(r.values);
105
+ addSums(g.sums, r.values);
106
+ }
107
+ groups = Array.from(byKey.values()).sort((a, b) => b.count - a.count || a.label.localeCompare(b.label));
108
+ }
109
+
110
+ return { title, generatedAt, columns: exportCols, groups, rows: flatRows, groupColumnId, sumColumnIds, totals };
111
+ }
@@ -0,0 +1,67 @@
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 type { CellValue } from '@ifc-lite/lists';
6
+ import { displayCell, type ExportModel } from './model';
7
+
8
+ /** Quality tabular PDF report: title + meta, dark header, grouped sections
9
+ * (bold group rows carrying per-group count + subtotals), right-aligned
10
+ * numerics, a grand-total foot, and page numbers. */
11
+ export async function toPdf(model: ExportModel): Promise<Blob> {
12
+ const { jsPDF } = await import('jspdf');
13
+ const autoTable = (await import('jspdf-autotable')).default;
14
+
15
+ const landscape = model.columns.length > 5;
16
+ const doc = new jsPDF({ orientation: landscape ? 'landscape' : 'portrait', unit: 'pt', format: 'a4' });
17
+
18
+ doc.setFont('helvetica', 'bold'); doc.setFontSize(14);
19
+ doc.text(model.title || 'List', 40, 42);
20
+ doc.setFont('helvetica', 'normal'); doc.setFontSize(9); doc.setTextColor(130);
21
+ doc.text(`${model.totals.count.toLocaleString()} elements · ${model.generatedAt}`, 40, 58);
22
+ doc.setTextColor(0);
23
+
24
+ const head = [model.columns.map((c) => c.label)];
25
+ const cell = (vals: CellValue[], i: number) => displayCell(vals[i]);
26
+ const body: Array<Array<string | { content: string; styles: Record<string, unknown> }>> = [];
27
+
28
+ if (model.groups) {
29
+ for (const g of model.groups) {
30
+ body.push(model.columns.map((c, i) => ({
31
+ content: i === 0 ? `${g.label} (${g.count})` : (c.summed ? displayCell(g.sums[c.id]) : ''),
32
+ styles: { fontStyle: 'bold', fillColor: [226, 232, 240] as unknown as number[] },
33
+ })));
34
+ for (const r of g.rows) body.push(model.columns.map((_, i) => cell(r, i)));
35
+ }
36
+ } else {
37
+ for (const r of model.rows) body.push(model.columns.map((_, i) => cell(r, i)));
38
+ }
39
+
40
+ const foot = model.sumColumnIds.length > 0
41
+ ? [model.columns.map((c, i) => (c.summed ? displayCell(model.totals.sums[c.id]) : (i === 0 ? `Total (${model.totals.count})` : '')))]
42
+ : undefined;
43
+
44
+ const columnStyles: Record<number, { halign: 'right' }> = {};
45
+ model.columns.forEach((c, i) => { if (c.numeric) columnStyles[i] = { halign: 'right' }; });
46
+
47
+ autoTable(doc, {
48
+ head,
49
+ body,
50
+ foot,
51
+ startY: 72,
52
+ margin: { left: 40, right: 40, top: 70, bottom: 40 },
53
+ styles: { fontSize: 8, cellPadding: 3, overflow: 'ellipsize', lineColor: [226, 232, 240], lineWidth: 0.5 },
54
+ headStyles: { fillColor: [51, 65, 85], textColor: 255, fontStyle: 'bold' },
55
+ footStyles: { fillColor: [241, 245, 249], textColor: 20, fontStyle: 'bold' },
56
+ columnStyles,
57
+ didDrawPage: () => {
58
+ doc.setFontSize(8); doc.setTextColor(150);
59
+ const w = doc.internal.pageSize.getWidth();
60
+ const h = doc.internal.pageSize.getHeight();
61
+ doc.text(`Page ${doc.getNumberOfPages()}`, w - 60, h - 20);
62
+ doc.setTextColor(0);
63
+ },
64
+ });
65
+
66
+ return doc.output('blob');
67
+ }
@@ -0,0 +1,83 @@
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 type { CellValue } from '@ifc-lite/lists';
6
+ import { displayCell, type ExportModel, type ExportColumn } from './model';
7
+
8
+ const XLSX_MIME = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
9
+ const NUM_FMT = '#,##0.####';
10
+
11
+ /** px → Excel column-width units (≈ 7px per char). */
12
+ const excelWidth = (px: number): number => Math.max(8, Math.min(80, Math.round(px / 7)));
13
+
14
+ /** Excel keeps real numbers (so the recipient can re-aggregate); other types
15
+ * fall back to the same display string the table shows. */
16
+ function cellValue(v: CellValue, c: ExportColumn): string | number | null {
17
+ if (v === null || v === undefined) return null;
18
+ if (c.numeric && typeof v === 'number' && Number.isFinite(v)) return v;
19
+ return displayCell(v);
20
+ }
21
+
22
+ export async function toXlsx(model: ExportModel): Promise<Blob> {
23
+ const ExcelJS = (await import('exceljs')).default;
24
+ const wb = new ExcelJS.Workbook();
25
+ wb.creator = 'IFC-Lite';
26
+ const ws = wb.addWorksheet((model.title || 'List').slice(0, 31), {
27
+ views: [{ state: 'frozen', ySplit: 4 }],
28
+ });
29
+
30
+ const cols = model.columns;
31
+ const ncol = cols.length;
32
+
33
+ // Title + meta.
34
+ ws.addRow([model.title || 'List']);
35
+ ws.mergeCells(1, 1, 1, ncol);
36
+ ws.getCell(1, 1).font = { bold: true, size: 14 };
37
+ ws.addRow([`${model.totals.count.toLocaleString()} elements · ${model.generatedAt}`]);
38
+ ws.mergeCells(2, 1, 2, ncol);
39
+ ws.getCell(2, 1).font = { italic: true, size: 9, color: { argb: 'FF94A3B8' } };
40
+ ws.addRow([]);
41
+
42
+ // Header.
43
+ const header = ws.addRow(cols.map((c) => c.label));
44
+ header.font = { bold: true, color: { argb: 'FFFFFFFF' } };
45
+ header.eachCell((cell) => {
46
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF334155' } };
47
+ cell.alignment = { vertical: 'middle' };
48
+ });
49
+
50
+ // Column widths + numeric formatting/alignment.
51
+ cols.forEach((c, i) => {
52
+ const col = ws.getColumn(i + 1);
53
+ col.width = excelWidth(c.width);
54
+ if (c.numeric) { col.numFmt = NUM_FMT; col.alignment = { horizontal: 'right' }; }
55
+ });
56
+
57
+ const addDataRow = (values: CellValue[], outline?: number) => {
58
+ const r = ws.addRow(cols.map((c, i) => cellValue(values[i], c)));
59
+ if (outline) r.outlineLevel = outline;
60
+ };
61
+
62
+ if (model.groups) {
63
+ for (const g of model.groups) {
64
+ const gr = ws.addRow(cols.map((c, i) => (i === 0 ? `${g.label} (${g.count})` : (c.summed ? g.sums[c.id] : null))));
65
+ gr.font = { bold: true };
66
+ gr.eachCell((cell) => { cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } }; });
67
+ for (const row of g.rows) addDataRow(row, 1);
68
+ }
69
+ ws.properties.outlineLevelRow = 1;
70
+ } else {
71
+ for (const row of model.rows) addDataRow(row);
72
+ }
73
+
74
+ // Grand total.
75
+ if (model.sumColumnIds.length > 0) {
76
+ const tr = ws.addRow(cols.map((c, i) => (c.summed ? model.totals.sums[c.id] : (i === 0 ? `Total (${model.totals.count})` : null))));
77
+ tr.font = { bold: true };
78
+ tr.eachCell((cell) => { cell.border = { top: { style: 'double', color: { argb: 'FF334155' } } }; });
79
+ }
80
+
81
+ const buf = await wb.xlsx.writeBuffer();
82
+ return new Blob([buf], { type: XLSX_MIME });
83
+ }
@@ -14,10 +14,12 @@ export type {
14
14
  ConditionOperator,
15
15
  DiscoveredColumns,
16
16
  EntityAttribute,
17
+ ListGrouping,
17
18
  } from '@ifc-lite/lists';
18
19
  export {
19
20
  ENTITY_ATTRIBUTES,
20
21
  executeList,
22
+ summariseListRows,
21
23
  listResultToCSV,
22
24
  discoverColumns,
23
25
  LIST_PRESETS,
@@ -256,6 +256,87 @@ describe('matchQuantityRule', () => {
256
256
  });
257
257
  });
258
258
 
259
+ describe('materialNamesOf', () => {
260
+ it('collects top-level, layer, constituent, profile, and list names', () => {
261
+ const names = __internal.materialNamesOf({
262
+ type: 'MaterialLayerSet',
263
+ name: 'Wall Buildup',
264
+ layers: [
265
+ { materialName: 'Concrete C30/37' },
266
+ { materialName: 'Rigid Insulation', name: 'Insulation Layer' },
267
+ ],
268
+ materials: [{ name: 'Steel S355' }],
269
+ });
270
+ assert.deepStrictEqual(names, [
271
+ 'Wall Buildup',
272
+ 'Concrete C30/37',
273
+ 'Rigid Insulation',
274
+ 'Insulation Layer',
275
+ 'Steel S355',
276
+ ]);
277
+ });
278
+ it('returns [] for a null MaterialInfo (no association)', () => {
279
+ assert.deepStrictEqual(__internal.materialNamesOf(null), []);
280
+ });
281
+ });
282
+
283
+ describe('matchClassificationRule', () => {
284
+ const refs = [
285
+ { system: 'Uniclass 2015', identification: 'Pr_60_10_32', name: 'External wall' },
286
+ { system: 'OmniClass', identification: '23-13 11 11', name: 'Walls' },
287
+ ];
288
+ it('isSet / isNotSet check presence, optionally scoped by system', () => {
289
+ assert.strictEqual(__internal.matchClassificationRule(Rule.classification('', 'isSet', ''), refs), true);
290
+ assert.strictEqual(__internal.matchClassificationRule(Rule.classification('OmniClass', 'isSet', ''), refs), true);
291
+ assert.strictEqual(__internal.matchClassificationRule(Rule.classification('SfB', 'isSet', ''), refs), false);
292
+ assert.strictEqual(__internal.matchClassificationRule(Rule.classification('SfB', 'isNotSet', ''), refs), true);
293
+ assert.strictEqual(__internal.matchClassificationRule(Rule.classification('', 'isNotSet', ''), []), true);
294
+ });
295
+ it('value ops match code (identification) OR name', () => {
296
+ assert.strictEqual(__internal.matchClassificationRule(Rule.classification('', 'contains', 'Pr_60'), refs), true);
297
+ assert.strictEqual(__internal.matchClassificationRule(Rule.classification('', 'contains', 'external'), refs), true);
298
+ assert.strictEqual(__internal.matchClassificationRule(Rule.classification('', 'eq', 'Pr_60_10_32'), refs), true);
299
+ assert.strictEqual(__internal.matchClassificationRule(Rule.classification('', 'contains', 'Ss_'), refs), false);
300
+ });
301
+ it('system scope excludes refs from other systems', () => {
302
+ // Pr_60 only exists in the Uniclass ref — scoping to OmniClass misses it.
303
+ assert.strictEqual(__internal.matchClassificationRule(Rule.classification('OmniClass', 'contains', 'Pr_60'), refs), false);
304
+ assert.strictEqual(__internal.matchClassificationRule(Rule.classification('OmniClass', 'contains', '23-13'), refs), true);
305
+ });
306
+ });
307
+
308
+ describe('elevationOf + elevation rule', () => {
309
+ function withHierarchy(store: IfcDataStore): IfcDataStore {
310
+ // elementToStorey: 10,20 → storey 100 (z=0); 30 → storey 200 (z=3.5).
311
+ // 40 is unplaced (no storey) → elevation null → never matches.
312
+ (store as unknown as { spatialHierarchy: unknown }).spatialHierarchy = {
313
+ elementToStorey: new Map<number, number>([[10, 100], [20, 100], [30, 200]]),
314
+ storeyElevations: new Map<number, number>([[100, 0], [200, 3.5]]),
315
+ };
316
+ return store;
317
+ }
318
+
319
+ it('resolves elevation from the element’s storey, null when unplaced', () => {
320
+ const store = withHierarchy(buildStore(rows));
321
+ assert.strictEqual(__internal.elevationOf(store, 10), 0);
322
+ assert.strictEqual(__internal.elevationOf(store, 30), 3.5);
323
+ assert.strictEqual(__internal.elevationOf(store, 40), null);
324
+ });
325
+
326
+ it('elevation > 3 matches only elements on the high storey', () => {
327
+ const store = withHierarchy(buildStore(rows));
328
+ const out = evaluateFilterRules('m1', store, [Rule.elevation('gt', 3)], 'AND');
329
+ assert.deepStrictEqual(out.map((r) => r.expressId), [30]);
330
+ });
331
+
332
+ it('elevation rule excludes unplaced elements even with lte', () => {
333
+ const store = withHierarchy(buildStore(rows));
334
+ const out = evaluateFilterRules('m1', store, [Rule.elevation('lte', 100)], 'AND');
335
+ // 10, 20 (z=0) and 30 (z=3.5) qualify; 40 (unplaced) is excluded.
336
+ assert.deepStrictEqual(out.map((r) => r.expressId).sort(), [10, 20, 30]);
337
+ });
338
+ });
339
+
259
340
  describe('evaluateFilterRulesFederated — per-model candidate narrowing', () => {
260
341
  it('candidateExpressIdsByModel narrows each model independently', async () => {
261
342
  const a = buildStore(rows);
@@ -37,21 +37,36 @@
37
37
  import {
38
38
  extractPropertiesOnDemand,
39
39
  extractQuantitiesOnDemand,
40
+ extractMaterialsOnDemand,
41
+ extractClassificationsOnDemand,
40
42
  type IfcDataStore,
43
+ type ClassificationInfo,
41
44
  } from '@ifc-lite/parser';
42
45
 
43
46
  import {
44
47
  combineRuleResults,
45
48
  setOpMatches,
46
49
  stringOpMatches,
50
+ matchStringAnyNone,
47
51
  numericOpMatches,
48
- valueOpMatches,
49
52
  type Combinator,
50
53
  type FilterRule,
51
- type PropertyRule,
52
- type QuantityRule,
53
54
  } from './filter-rules.js';
54
55
 
56
+ import {
57
+ flattenPsets,
58
+ flattenQtys,
59
+ stringifyValue,
60
+ matchPropertyRule,
61
+ matchQuantityRule,
62
+ defaultStoreyName,
63
+ materialNamesOf,
64
+ matchClassificationRule,
65
+ elevationOf,
66
+ type PsetRows,
67
+ type QtyRows,
68
+ } from './filter-match.js';
69
+
55
70
  /** A single matched element. Mirrors the Rust `FilteredElement` shape. */
56
71
  export interface FilteredElement {
57
72
  modelId: string;
@@ -108,6 +123,8 @@ export function evaluateFilterRules(
108
123
  options,
109
124
  hasPropertyRule: orderedRules.some((r) => r.kind === 'property'),
110
125
  hasQuantityRule: orderedRules.some((r) => r.kind === 'quantity'),
126
+ hasMaterialRule: orderedRules.some((r) => r.kind === 'material'),
127
+ hasClassificationRule: orderedRules.some((r) => r.kind === 'classification'),
111
128
  };
112
129
 
113
130
  for (const expressId of iterIds) {
@@ -210,6 +227,8 @@ export async function evaluateFilterRulesFederated(
210
227
  options,
211
228
  hasPropertyRule: orderedRules.some((r) => r.kind === 'property'),
212
229
  hasQuantityRule: orderedRules.some((r) => r.kind === 'quantity'),
230
+ hasMaterialRule: orderedRules.some((r) => r.kind === 'material'),
231
+ hasClassificationRule: orderedRules.some((r) => r.kind === 'classification'),
213
232
  };
214
233
 
215
234
  // Walk the per-model iter in chunkSize-sized strides, yielding the
@@ -340,12 +359,17 @@ const RULE_COST: Record<FilterRule['kind'], number> = {
340
359
  ifcType: 0,
341
360
  // Pre-built reverse-map lookup.
342
361
  storey: 1,
362
+ // Pre-built reverse-map lookup (elementToStorey → storeyElevations).
363
+ elevation: 1,
343
364
  // String-table indirection.
344
365
  name: 2,
345
366
  predefinedType: 2,
346
367
  // Source-buffer parse (the AGENTS.md §2 hot path).
347
368
  property: 10,
348
369
  quantity: 10,
370
+ // Relationship-graph walk + on-demand resolve — as costly as a pset parse.
371
+ material: 10,
372
+ classification: 10,
349
373
  };
350
374
 
351
375
  export function orderRulesByCost(rules: readonly FilterRule[]): FilterRule[] {
@@ -365,6 +389,8 @@ interface EvalContext {
365
389
  options: EvaluateOptions;
366
390
  hasPropertyRule: boolean;
367
391
  hasQuantityRule: boolean;
392
+ hasMaterialRule: boolean;
393
+ hasClassificationRule: boolean;
368
394
  }
369
395
 
370
396
  function evaluateOneEntity(
@@ -379,6 +405,8 @@ function evaluateOneEntity(
379
405
  // parse entirely.
380
406
  let psetCache: PsetRows | null = null;
381
407
  let qtyCache: QtyRows | null = null;
408
+ let matCache: string[] | null = null;
409
+ let classCache: readonly ClassificationInfo[] | null = null;
382
410
  const psetsFor = (): PsetRows => {
383
411
  if (!psetCache) psetCache = flattenPsets(extractPropertiesOnDemand(ctx.store, expressId));
384
412
  return psetCache;
@@ -387,6 +415,14 @@ function evaluateOneEntity(
387
415
  if (!qtyCache) qtyCache = flattenQtys(extractQuantitiesOnDemand(ctx.store, expressId));
388
416
  return qtyCache;
389
417
  };
418
+ const matNamesFor = (): string[] => {
419
+ if (!matCache) matCache = materialNamesOf(extractMaterialsOnDemand(ctx.store, expressId));
420
+ return matCache;
421
+ };
422
+ const classFor = (): readonly ClassificationInfo[] => {
423
+ if (!classCache) classCache = extractClassificationsOnDemand(ctx.store, expressId);
424
+ return classCache;
425
+ };
390
426
 
391
427
  const ruleResults: boolean[] = [];
392
428
  for (const rule of orderedRules) {
@@ -396,6 +432,8 @@ function evaluateOneEntity(
396
432
  expressId,
397
433
  ctx.hasPropertyRule ? psetsFor : null,
398
434
  ctx.hasQuantityRule ? qtysFor : null,
435
+ ctx.hasMaterialRule ? matNamesFor : null,
436
+ ctx.hasClassificationRule ? classFor : null,
399
437
  );
400
438
  ruleResults.push(result);
401
439
  if (combinator === 'AND' && !result) return false;
@@ -410,6 +448,8 @@ function evaluateRule(
410
448
  expressId: number,
411
449
  psetsFor: (() => PsetRows) | null,
412
450
  qtysFor: (() => QtyRows) | null,
451
+ matNamesFor: (() => string[]) | null,
452
+ classFor: (() => readonly ClassificationInfo[]) | null,
413
453
  ): boolean {
414
454
  switch (rule.kind) {
415
455
  case 'storey': {
@@ -435,6 +475,19 @@ function evaluateRule(
435
475
  if (!qtysFor) return false;
436
476
  return matchQuantityRule(rule, qtysFor());
437
477
  }
478
+ case 'material': {
479
+ if (!matNamesFor) return false;
480
+ return matchStringAnyNone(rule.op, matNamesFor(), rule.value);
481
+ }
482
+ case 'classification': {
483
+ if (!classFor) return false;
484
+ return matchClassificationRule(rule, classFor());
485
+ }
486
+ case 'elevation': {
487
+ const elev = elevationOf(ctx.store, expressId);
488
+ if (elev === null) return false;
489
+ return numericOpMatches(rule.op, elev, rule.value);
490
+ }
438
491
  }
439
492
  }
440
493
 
@@ -513,90 +566,6 @@ function yieldToEventLoop(): Promise<void> {
513
566
  });
514
567
  }
515
568
 
516
- // ── Pset / Qto matching ──────────────────────────────────────────────────────
517
-
518
- interface PsetRow { setName: string; propertyName: string; value: string }
519
- type PsetRows = ReadonlyArray<PsetRow>;
520
-
521
- interface QtyRow { setName: string; quantityName: string; value: number }
522
- type QtyRows = ReadonlyArray<QtyRow>;
523
-
524
- function flattenPsets(
525
- psets: ReturnType<typeof extractPropertiesOnDemand>,
526
- ): PsetRows {
527
- const out: PsetRow[] = [];
528
- for (const set of psets) {
529
- for (const p of set.properties) {
530
- out.push({
531
- setName: set.name,
532
- propertyName: p.name,
533
- // Stringify everything — `valueOpMatches` re-parses numeric ops
534
- // from this representation. Booleans render as "true"/"false"
535
- // which matches the chip UI's lowercased input convention.
536
- value: stringifyValue(p.value),
537
- });
538
- }
539
- }
540
- return out;
541
- }
542
-
543
- function flattenQtys(
544
- qtos: ReturnType<typeof extractQuantitiesOnDemand>,
545
- ): QtyRows {
546
- const out: QtyRow[] = [];
547
- for (const set of qtos) {
548
- for (const q of set.quantities) {
549
- out.push({ setName: set.name, quantityName: q.name, value: q.value });
550
- }
551
- }
552
- return out;
553
- }
554
-
555
- function stringifyValue(value: unknown): string {
556
- if (value === null || value === undefined) return '';
557
- if (typeof value === 'boolean') return value ? 'true' : 'false';
558
- if (typeof value === 'number') return String(value);
559
- return String(value);
560
- }
561
-
562
- function matchPropertyRule(rule: PropertyRule, rows: PsetRows): boolean {
563
- // isSet / isNotSet are presence checks against (setName, propertyName).
564
- if (rule.op === 'isSet' || rule.op === 'isNotSet') {
565
- const present = rows.some(
566
- (r) =>
567
- r.setName.toLowerCase() === rule.setName.toLowerCase() &&
568
- r.propertyName.toLowerCase() === rule.propertyName.toLowerCase(),
569
- );
570
- return rule.op === 'isSet' ? present : !present;
571
- }
572
-
573
- return rows.some(
574
- (r) =>
575
- r.setName.toLowerCase() === rule.setName.toLowerCase() &&
576
- r.propertyName.toLowerCase() === rule.propertyName.toLowerCase() &&
577
- valueOpMatches(rule.op, r.value, rule.value),
578
- );
579
- }
580
-
581
- function matchQuantityRule(rule: QuantityRule, rows: QtyRows): boolean {
582
- return rows.some(
583
- (r) =>
584
- r.setName.toLowerCase() === rule.setName.toLowerCase() &&
585
- r.quantityName.toLowerCase() === rule.quantityName.toLowerCase() &&
586
- numericOpMatches(rule.op, r.value, rule.value),
587
- );
588
- }
589
-
590
- // ── Storey lookup fallback ────────────────────────────────────────────────────
591
-
592
- function defaultStoreyName(store: IfcDataStore, expressId: number): string {
593
- const hierarchy = store.spatialHierarchy;
594
- if (!hierarchy) return '';
595
- const storeyId = hierarchy.elementToStorey.get(expressId);
596
- if (!storeyId) return '';
597
- return store.entities.getName(storeyId);
598
- }
599
-
600
569
  // ── Exposed for tests ────────────────────────────────────────────────────────
601
570
 
602
571
  export const __internal = {
@@ -605,6 +574,9 @@ export const __internal = {
605
574
  stringifyValue,
606
575
  matchPropertyRule,
607
576
  matchQuantityRule,
577
+ materialNamesOf,
578
+ matchClassificationRule,
579
+ elevationOf,
608
580
  orderRulesByCost,
609
581
  selectIterationSource,
610
582
  };