@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.
- package/.turbo/turbo-build.log +38 -31
- package/CHANGELOG.md +29 -0
- package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-B3CdrLsb.js} +7 -7
- package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-QeHK_Aud.js} +1 -1
- package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
- package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
- package/dist/assets/{deflate-Cnx0il6E.js → deflate-B-d0SYQM.js} +1 -1
- package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
- package/dist/assets/{exporters-DSq76AVM.js → exporters-B4LbZFeT.js} +1422 -1194
- package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
- package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-CrVtDRFq.js} +10 -10
- package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
- package/dist/assets/{ids-DiLcGTer.js → ids-DjsGFN10.js} +4 -4
- package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
- package/dist/assets/{index-BAH8IJVR.js → index-COYokSKc.js} +38319 -35469
- package/dist/assets/index-ajK6D32J.css +1 -0
- package/dist/assets/index.es-CY202jA3.js +6866 -0
- package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-D4wOkf5h.js} +1 -1
- package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
- package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
- package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-DmW0_tgf.js} +1 -1
- package/dist/assets/{lzw-BBPPLW-0.js → lzw-oWetY-d6.js} +1 -1
- package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
- package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-BX8_tHXE.js} +1 -1
- package/dist/assets/{packbits-yLSpjW-V.js → packbits-F8Nkp4NY.js} +1 -1
- package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
- package/dist/assets/{parser.worker-8md211IW.js → parser.worker-D591Zu_-.js} +3 -3
- package/dist/assets/pdf-Dsh3HPZB.js +135 -0
- package/dist/assets/raw-D9iw0tmc.js +1 -0
- package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-BAC3a-eN.js} +1735 -1660
- package/dist/assets/server-client-Cjwnm7il.js +706 -0
- package/dist/assets/{webimage-YafxjjGr.js → webimage-BLV1dgmd.js} +1 -1
- package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
- package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
- package/dist/assets/{zstd-CkSLOiuu.js → zstd-C_1HxVrA.js} +1 -1
- package/dist/index.html +8 -8
- package/package.json +10 -7
- package/src/components/mcp/PlaygroundChat.tsx +1 -0
- package/src/components/mcp/data.ts +6 -0
- package/src/components/mcp/playground-dispatcher.ts +277 -0
- package/src/components/mcp/types.ts +2 -1
- package/src/components/ui/combo-input.tsx +163 -0
- package/src/components/ui/tabs.tsx +1 -1
- package/src/components/viewer/PropertiesPanel.tsx +13 -6
- package/src/components/viewer/SearchInline.tsx +62 -2
- package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
- package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
- package/src/components/viewer/SearchModal.filter.tsx +64 -1
- package/src/components/viewer/SearchModal.tsx +19 -6
- package/src/components/viewer/Viewport.tsx +15 -0
- package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
- package/src/components/viewer/lists/ListBuilder.tsx +789 -280
- package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
- package/src/components/viewer/lists/ListPanel.tsx +49 -5
- package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
- package/src/components/viewer/lists/list-table-utils.ts +123 -0
- package/src/generated/mcp-catalog.json +4 -0
- package/src/hooks/source-key.ts +35 -0
- package/src/hooks/useAlignmentLines3D.ts +1 -26
- package/src/hooks/useGridLines3D.ts +140 -0
- package/src/lib/length-unit-scale.ts +41 -0
- package/src/lib/lists/adapter.ts +136 -11
- package/src/lib/lists/export/csv.ts +47 -0
- package/src/lib/lists/export/index.ts +49 -0
- package/src/lib/lists/export/model.ts +111 -0
- package/src/lib/lists/export/pdf.ts +67 -0
- package/src/lib/lists/export/xlsx.ts +83 -0
- package/src/lib/lists/index.ts +2 -0
- package/src/lib/search/filter-evaluate.test.ts +81 -0
- package/src/lib/search/filter-evaluate.ts +59 -87
- package/src/lib/search/filter-match.ts +167 -0
- package/src/lib/search/filter-rules.test.ts +25 -0
- package/src/lib/search/filter-rules.ts +75 -2
- package/src/lib/search/filter-schema.ts +0 -0
- package/src/lib/slab-edit.test.ts +72 -0
- package/src/lib/slab-edit.ts +159 -19
- package/src/sdk/adapters/export-adapter.ts +3 -3
- package/src/sdk/adapters/query-adapter.ts +3 -3
- package/src/store/slices/listSlice.ts +6 -0
- package/src/store/slices/mutationSlice.ts +14 -6
- package/src/store/slices/searchSlice.ts +29 -3
- package/src/utils/nativeSpatialDataStore.ts +6 -0
- package/src/utils/serverDataModel.test.ts +6 -0
- package/src/utils/serverDataModel.ts +7 -0
- package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
- package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
- package/dist/assets/index-B9Ug2EqU.css +0 -1
- package/dist/assets/raw-BQrAgxwT.js +0 -1
- 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
|
+
}
|
package/src/lib/lists/index.ts
CHANGED
|
@@ -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
|
};
|