@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.
- package/.turbo/turbo-build.log +45 -38
- package/CHANGELOG.md +93 -0
- package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-BNRDNuUJ.js} +9 -9
- package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-DCwCuP7n.js} +56 -56
- 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/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
- package/dist/assets/deflate-DNGgs8Ur.js +1 -0
- package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
- package/dist/assets/e57-source-2wI9jkCA.js +1 -0
- package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
- package/dist/assets/{exporters-DSq76AVM.js → exporters-B9v81gi9.js} +1861 -1524
- package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
- package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-D-YCLS4g.js} +10 -10
- package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
- package/dist/assets/{ids-DiLcGTer.js → ids-CCpq-5d3.js} +952 -945
- package/dist/assets/ifc-lite_bg-DbgS5EUA.wasm +0 -0
- package/dist/assets/{index-BAH8IJVR.js → index-Bgb3_Pu_.js} +47682 -42474
- package/dist/assets/index-BtbXFKsX.css +1 -0
- package/dist/assets/index.es-CWfqZyyr.js +6866 -0
- package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-DGOAeUqU.js} +1 -1
- package/dist/assets/jspdf.es.min-XPLU2Wkq.js +19571 -0
- package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
- package/dist/assets/lens-C4p1kQ0p.js +1 -0
- package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-1PMSCHwX.js} +1 -1
- package/dist/assets/{lzw-BBPPLW-0.js → lzw-C65U9lNM.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-XxXos6yI.js} +2 -2
- package/dist/assets/{packbits-yLSpjW-V.js → packbits-BdMWXC3m.js} +1 -1
- package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
- package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
- package/dist/assets/pdf-CRwaZf3s.js +135 -0
- package/dist/assets/raw-CJgQdyuZ.js +1 -0
- package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-0sDo3g3m.js} +3037 -2554
- package/dist/assets/server-client-cTCJ-853.js +719 -0
- package/dist/assets/{webimage-YafxjjGr.js → webimage-BtakWX7W.js} +1 -1
- package/dist/assets/xlsx-B1YOg2QB.js +142 -0
- package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
- package/dist/assets/{zstd-CkSLOiuu.js → zstd-CmwsbxmM.js} +1 -1
- package/dist/index.html +10 -10
- package/package.json +27 -23
- package/src/components/mcp/PlaygroundChat.tsx +1 -0
- package/src/components/mcp/data.ts +6 -0
- package/src/components/mcp/playground-dispatcher.ts +280 -0
- package/src/components/mcp/playground-files.ts +33 -1
- 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/CommandPalette.tsx +6 -1
- package/src/components/viewer/ComparePanel.tsx +420 -0
- package/src/components/viewer/HierarchyPanel.tsx +46 -7
- package/src/components/viewer/MainToolbar.tsx +19 -2
- package/src/components/viewer/PropertiesPanel.tsx +84 -8
- 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/ViewerLayout.tsx +5 -0
- package/src/components/viewer/Viewport.tsx +18 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
- package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
- package/src/components/viewer/hierarchy/types.ts +1 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
- 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/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
- package/src/generated/mcp-catalog.json +4 -0
- package/src/hooks/federationLoadGate.test.ts +12 -2
- package/src/hooks/federationLoadGate.ts +9 -2
- package/src/hooks/ingest/federationAlign.ts +481 -0
- package/src/hooks/ingest/viewerModelIngest.ts +3 -212
- package/src/hooks/source-key.ts +35 -0
- package/src/hooks/useAlignmentLines3D.ts +1 -26
- package/src/hooks/useCompare.ts +0 -0
- package/src/hooks/useCompareOverlay.ts +119 -0
- package/src/hooks/useDrawingGeneration.ts +23 -1
- package/src/hooks/useGridLines3D.ts +140 -0
- package/src/hooks/useIfc.ts +1 -1
- package/src/hooks/useIfcCache.ts +32 -9
- package/src/hooks/useIfcFederation.ts +42 -810
- package/src/hooks/useIfcLoader.ts +361 -488
- package/src/hooks/useIfcServer.ts +3 -0
- package/src/hooks/useLens.ts +5 -1
- package/src/hooks/useSymbolicAnnotations.ts +70 -38
- package/src/lib/compare/buildFingerprints.ts +173 -0
- package/src/lib/compare/describeChange.ts +0 -0
- package/src/lib/compare/geometricData.test.ts +54 -0
- package/src/lib/compare/geometricData.ts +37 -0
- package/src/lib/compare/overlay.test.ts +99 -0
- package/src/lib/compare/overlay.ts +91 -0
- package/src/lib/geo/cesium-placement.ts +1 -1
- package/src/lib/geo/reproject.ts +4 -1
- 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/llm/script-edit-ops.ts +23 -0
- package/src/lib/llm/stream-client.ts +8 -1
- 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/search/result-export.ts +7 -1
- package/src/lib/slab-edit.test.ts +72 -0
- package/src/lib/slab-edit.ts +159 -19
- package/src/sdk/adapters/export-adapter.ts +9 -4
- package/src/sdk/adapters/query-adapter.ts +3 -3
- package/src/store/globalId.ts +15 -13
- package/src/store/index.ts +16 -1
- package/src/store/slices/cesiumSlice.ts +8 -1
- package/src/store/slices/compareSlice.ts +96 -0
- package/src/store/slices/lensSlice.ts +8 -0
- 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/acquireFileBuffer.test.ts +12 -4
- package/src/utils/desktopModelSnapshot.ts +2 -1
- package/src/utils/loadingUtils.ts +32 -0
- package/src/utils/nativeSpatialDataStore.ts +6 -0
- package/src/utils/serverDataModel.test.ts +6 -0
- package/src/utils/serverDataModel.ts +7 -0
- package/src/utils/spatialHierarchy.test.ts +53 -1
- package/src/utils/spatialHierarchy.ts +42 -2
- package/src/vite-env.d.ts +2 -0
- package/dist/assets/deflate-Cnx0il6E.js +0 -1
- package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
- package/dist/assets/e57-source-CQHxE8n3.js +0 -1
- 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/lens-PYsLu_MA.js +0 -1
- package/dist/assets/parser.worker-8md211IW.js +0 -182
- package/dist/assets/raw-BQrAgxwT.js +0 -1
- package/dist/assets/server-client-Bk4c1CPO.js +0 -626
- package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
- package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
- package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
- package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
|
@@ -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
|
};
|
|
@@ -0,0 +1,167 @@
|
|
|
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
|
+
* Per-rule value resolution + matching for the path-B evaluator.
|
|
7
|
+
*
|
|
8
|
+
* Split out of `filter-evaluate.ts` (which keeps the iteration /
|
|
9
|
+
* orchestration logic) to stay under the module size cap. These helpers
|
|
10
|
+
* are pure given their inputs, which is what makes them unit-testable in
|
|
11
|
+
* `filter-evaluate.test.ts` via the evaluator's `__internal` re-export.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
extractPropertiesOnDemand,
|
|
16
|
+
extractQuantitiesOnDemand,
|
|
17
|
+
type IfcDataStore,
|
|
18
|
+
type MaterialInfo,
|
|
19
|
+
type ClassificationInfo,
|
|
20
|
+
} from '@ifc-lite/parser';
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
valueOpMatches,
|
|
24
|
+
numericOpMatches,
|
|
25
|
+
matchStringAnyNone,
|
|
26
|
+
type PropertyRule,
|
|
27
|
+
type QuantityRule,
|
|
28
|
+
type ClassificationRule,
|
|
29
|
+
} from './filter-rules.js';
|
|
30
|
+
|
|
31
|
+
// ── Pset / Qto matching ──────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
export interface PsetRow { setName: string; propertyName: string; value: string }
|
|
34
|
+
export type PsetRows = ReadonlyArray<PsetRow>;
|
|
35
|
+
|
|
36
|
+
export interface QtyRow { setName: string; quantityName: string; value: number }
|
|
37
|
+
export type QtyRows = ReadonlyArray<QtyRow>;
|
|
38
|
+
|
|
39
|
+
export function flattenPsets(
|
|
40
|
+
psets: ReturnType<typeof extractPropertiesOnDemand>,
|
|
41
|
+
): PsetRows {
|
|
42
|
+
const out: PsetRow[] = [];
|
|
43
|
+
for (const set of psets) {
|
|
44
|
+
for (const p of set.properties) {
|
|
45
|
+
out.push({
|
|
46
|
+
setName: set.name,
|
|
47
|
+
propertyName: p.name,
|
|
48
|
+
// Stringify everything — `valueOpMatches` re-parses numeric ops
|
|
49
|
+
// from this representation. Booleans render as "true"/"false"
|
|
50
|
+
// which matches the chip UI's lowercased input convention.
|
|
51
|
+
value: stringifyValue(p.value),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function flattenQtys(
|
|
59
|
+
qtos: ReturnType<typeof extractQuantitiesOnDemand>,
|
|
60
|
+
): QtyRows {
|
|
61
|
+
const out: QtyRow[] = [];
|
|
62
|
+
for (const set of qtos) {
|
|
63
|
+
for (const q of set.quantities) {
|
|
64
|
+
out.push({ setName: set.name, quantityName: q.name, value: q.value });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function stringifyValue(value: unknown): string {
|
|
71
|
+
if (value === null || value === undefined) return '';
|
|
72
|
+
if (typeof value === 'boolean') return value ? 'true' : 'false';
|
|
73
|
+
if (typeof value === 'number') return String(value);
|
|
74
|
+
return String(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function matchPropertyRule(rule: PropertyRule, rows: PsetRows): boolean {
|
|
78
|
+
// isSet / isNotSet are presence checks against (setName, propertyName).
|
|
79
|
+
if (rule.op === 'isSet' || rule.op === 'isNotSet') {
|
|
80
|
+
const present = rows.some(
|
|
81
|
+
(r) =>
|
|
82
|
+
r.setName.toLowerCase() === rule.setName.toLowerCase() &&
|
|
83
|
+
r.propertyName.toLowerCase() === rule.propertyName.toLowerCase(),
|
|
84
|
+
);
|
|
85
|
+
return rule.op === 'isSet' ? present : !present;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return rows.some(
|
|
89
|
+
(r) =>
|
|
90
|
+
r.setName.toLowerCase() === rule.setName.toLowerCase() &&
|
|
91
|
+
r.propertyName.toLowerCase() === rule.propertyName.toLowerCase() &&
|
|
92
|
+
valueOpMatches(rule.op, r.value, rule.value),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function matchQuantityRule(rule: QuantityRule, rows: QtyRows): boolean {
|
|
97
|
+
return rows.some(
|
|
98
|
+
(r) =>
|
|
99
|
+
r.setName.toLowerCase() === rule.setName.toLowerCase() &&
|
|
100
|
+
r.quantityName.toLowerCase() === rule.quantityName.toLowerCase() &&
|
|
101
|
+
numericOpMatches(rule.op, r.value, rule.value),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ── Storey lookup fallback ────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
export function defaultStoreyName(store: IfcDataStore, expressId: number): string {
|
|
108
|
+
const hierarchy = store.spatialHierarchy;
|
|
109
|
+
if (!hierarchy) return '';
|
|
110
|
+
const storeyId = hierarchy.elementToStorey.get(expressId);
|
|
111
|
+
if (!storeyId) return '';
|
|
112
|
+
return store.entities.getName(storeyId);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Material / classification / elevation resolution ─────────────────────────
|
|
116
|
+
|
|
117
|
+
/** Collect every material-name string an element exposes — top-level
|
|
118
|
+
* material, plus layer / constituent / profile names and list members.
|
|
119
|
+
* Used by the multi-valued `material` rule matcher. */
|
|
120
|
+
export function materialNamesOf(info: MaterialInfo | null): string[] {
|
|
121
|
+
if (!info) return [];
|
|
122
|
+
const names: string[] = [];
|
|
123
|
+
const push = (s: string | undefined) => { if (s) names.push(s); };
|
|
124
|
+
push(info.name);
|
|
125
|
+
for (const l of info.layers ?? []) { push(l.materialName); push(l.name); }
|
|
126
|
+
for (const c of info.constituents ?? []) { push(c.materialName); push(c.name); }
|
|
127
|
+
for (const p of info.profiles ?? []) { push(p.materialName); push(p.name); }
|
|
128
|
+
for (const m of info.materials ?? []) push(m.name);
|
|
129
|
+
return names;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Match a classification rule against an element's classification refs.
|
|
133
|
+
* `system` (when set) scopes to one classification system; value ops
|
|
134
|
+
* match a ref's code (identification) OR name. */
|
|
135
|
+
export function matchClassificationRule(
|
|
136
|
+
rule: ClassificationRule,
|
|
137
|
+
refs: readonly ClassificationInfo[],
|
|
138
|
+
): boolean {
|
|
139
|
+
const sys = rule.system?.trim().toLowerCase();
|
|
140
|
+
const scoped = sys
|
|
141
|
+
? refs.filter((r) => (r.system ?? '').toLowerCase() === sys)
|
|
142
|
+
: refs;
|
|
143
|
+
|
|
144
|
+
if (rule.op === 'isSet') return scoped.length > 0;
|
|
145
|
+
if (rule.op === 'isNotSet') return scoped.length === 0;
|
|
146
|
+
|
|
147
|
+
// Value ops — match against identification (code) and name of each ref.
|
|
148
|
+
const candidates: string[] = [];
|
|
149
|
+
for (const r of scoped) {
|
|
150
|
+
if (r.identification) candidates.push(r.identification);
|
|
151
|
+
if (r.name) candidates.push(r.name);
|
|
152
|
+
}
|
|
153
|
+
// rule.op is now eq | ne | contains | notContains — a StringOp subset.
|
|
154
|
+
return matchStringAnyNone(rule.op, candidates, rule.value);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Element elevation in metres, derived from its building storey's
|
|
158
|
+
* elevation. Returns null when the element isn't placed in the spatial
|
|
159
|
+
* hierarchy (so an elevation rule simply doesn't match it). */
|
|
160
|
+
export function elevationOf(store: IfcDataStore, expressId: number): number | null {
|
|
161
|
+
const hierarchy = store.spatialHierarchy;
|
|
162
|
+
if (!hierarchy) return null;
|
|
163
|
+
const storeyId = hierarchy.elementToStorey.get(expressId);
|
|
164
|
+
if (!storeyId) return null;
|
|
165
|
+
const elev = hierarchy.storeyElevations.get(storeyId);
|
|
166
|
+
return typeof elev === 'number' ? elev : null;
|
|
167
|
+
}
|
|
@@ -7,6 +7,7 @@ import assert from 'node:assert';
|
|
|
7
7
|
import {
|
|
8
8
|
setOpMatches,
|
|
9
9
|
stringOpMatches,
|
|
10
|
+
matchStringAnyNone,
|
|
10
11
|
numericOpMatches,
|
|
11
12
|
valueOpMatches,
|
|
12
13
|
combineRuleResults,
|
|
@@ -46,6 +47,27 @@ describe('stringOpMatches', () => {
|
|
|
46
47
|
});
|
|
47
48
|
});
|
|
48
49
|
|
|
50
|
+
describe('matchStringAnyNone', () => {
|
|
51
|
+
const layers = ['Concrete C30/37', 'Rigid Insulation', 'Gypsum Board'];
|
|
52
|
+
it('positive ops match if ANY candidate satisfies them', () => {
|
|
53
|
+
assert.strictEqual(matchStringAnyNone('contains', layers, 'insulation'), true);
|
|
54
|
+
assert.strictEqual(matchStringAnyNone('eq', layers, 'gypsum board'), true);
|
|
55
|
+
assert.strictEqual(matchStringAnyNone('startsWith', layers, 'concrete'), true);
|
|
56
|
+
assert.strictEqual(matchStringAnyNone('contains', layers, 'timber'), false);
|
|
57
|
+
});
|
|
58
|
+
it('negative ops match only if NO candidate violates them', () => {
|
|
59
|
+
assert.strictEqual(matchStringAnyNone('notContains', layers, 'timber'), true);
|
|
60
|
+
assert.strictEqual(matchStringAnyNone('notContains', layers, 'concrete'), false);
|
|
61
|
+
assert.strictEqual(matchStringAnyNone('ne', layers, 'steel'), true);
|
|
62
|
+
assert.strictEqual(matchStringAnyNone('ne', layers, 'gypsum board'), false);
|
|
63
|
+
});
|
|
64
|
+
it('an empty candidate set never matches — including negative ops', () => {
|
|
65
|
+
assert.strictEqual(matchStringAnyNone('contains', [], 'concrete'), false);
|
|
66
|
+
assert.strictEqual(matchStringAnyNone('notContains', [], 'concrete'), false);
|
|
67
|
+
assert.strictEqual(matchStringAnyNone('ne', [], 'concrete'), false);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
49
71
|
describe('numericOpMatches', () => {
|
|
50
72
|
it('eq uses 1e-9 epsilon (matches Rust impl)', () => {
|
|
51
73
|
assert.strictEqual(numericOpMatches('eq', 1.0 + 1e-12, 1.0), true);
|
|
@@ -101,6 +123,9 @@ describe('isFilterRule / parseFilterRules', () => {
|
|
|
101
123
|
assert.strictEqual(isFilterRule(Rule.name('contains', 'wall')), true);
|
|
102
124
|
assert.strictEqual(isFilterRule(Rule.property('Pset_X', 'P', 'eq', 'v')), true);
|
|
103
125
|
assert.strictEqual(isFilterRule(Rule.quantity('Qto_X', 'Q', 'gt', 1)), true);
|
|
126
|
+
assert.strictEqual(isFilterRule(Rule.material('contains', 'Concrete')), true);
|
|
127
|
+
assert.strictEqual(isFilterRule(Rule.classification('Uniclass', 'contains', 'Pr_')), true);
|
|
128
|
+
assert.strictEqual(isFilterRule(Rule.elevation('gt', 3)), true);
|
|
104
129
|
});
|
|
105
130
|
it('rejects unknown kinds and non-objects', () => {
|
|
106
131
|
assert.strictEqual(isFilterRule({ kind: 'bogus' }), false);
|
|
@@ -37,6 +37,11 @@ export type ValueOp =
|
|
|
37
37
|
| 'isSet'
|
|
38
38
|
| 'isNotSet';
|
|
39
39
|
|
|
40
|
+
/** Classification value+presence ops. A classification is matched against
|
|
41
|
+
* its code / name string, so this is the StringOp comparison subset plus
|
|
42
|
+
* presence — numeric ops don't apply. */
|
|
43
|
+
export type ClassificationOp = 'eq' | 'ne' | 'contains' | 'notContains' | 'isSet' | 'isNotSet';
|
|
44
|
+
|
|
40
45
|
/** Top-level rule combinator. */
|
|
41
46
|
export type Combinator = 'AND' | 'OR';
|
|
42
47
|
|
|
@@ -83,13 +88,47 @@ export interface QuantityRule {
|
|
|
83
88
|
value: number;
|
|
84
89
|
}
|
|
85
90
|
|
|
91
|
+
/** Match against an element's material name(s) — top-level material,
|
|
92
|
+
* layer / constituent / profile names, and list members. Multi-valued:
|
|
93
|
+
* the evaluator matches if ANY name satisfies a positive op, or NONE
|
|
94
|
+
* violates a negative op (ne / notContains). */
|
|
95
|
+
export interface MaterialRule {
|
|
96
|
+
kind: 'material';
|
|
97
|
+
op: StringOp;
|
|
98
|
+
value: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Match against an element's classification references (e.g. Uniclass,
|
|
102
|
+
* OmniClass). `system` optionally scopes to one classification system;
|
|
103
|
+
* the value matches a reference's code (identification) OR name. */
|
|
104
|
+
export interface ClassificationRule {
|
|
105
|
+
kind: 'classification';
|
|
106
|
+
/** Optional system scope (e.g. "Uniclass 2015"). Empty = any system. */
|
|
107
|
+
system?: string;
|
|
108
|
+
op: ClassificationOp;
|
|
109
|
+
/** Matched against identification (code) OR name. Ignored for isSet/isNotSet. */
|
|
110
|
+
value: string;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Match against an element's elevation in metres — derived from the
|
|
114
|
+
* elevation of the building storey the element belongs to. */
|
|
115
|
+
export interface ElevationRule {
|
|
116
|
+
kind: 'elevation';
|
|
117
|
+
op: NumericOp;
|
|
118
|
+
/** Threshold in metres. */
|
|
119
|
+
value: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
86
122
|
export type FilterRule =
|
|
87
123
|
| StoreyRule
|
|
88
124
|
| IfcTypeRule
|
|
89
125
|
| PredefinedTypeRule
|
|
90
126
|
| NameRule
|
|
91
127
|
| PropertyRule
|
|
92
|
-
| QuantityRule
|
|
128
|
+
| QuantityRule
|
|
129
|
+
| MaterialRule
|
|
130
|
+
| ClassificationRule
|
|
131
|
+
| ElevationRule;
|
|
93
132
|
|
|
94
133
|
// ── Pure op helpers (ported verbatim from filter.rs) ──────────────────────────
|
|
95
134
|
|
|
@@ -110,6 +149,33 @@ export function stringOpMatches(op: StringOp, candidate: string, value: string):
|
|
|
110
149
|
}
|
|
111
150
|
}
|
|
112
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Match a StringOp against a *set* of candidate strings — used for
|
|
154
|
+
* multi-valued dimensions (an element's material names, a classification's
|
|
155
|
+
* code+name pair). Positive ops (eq / contains / startsWith) match if ANY
|
|
156
|
+
* candidate satisfies them; negative ops (ne / notContains) match only if
|
|
157
|
+
* NO candidate violates them. An empty candidate set never matches: an
|
|
158
|
+
* element with no materials/classifications shouldn't satisfy a filter on
|
|
159
|
+
* that dimension (including the negative ops).
|
|
160
|
+
*/
|
|
161
|
+
export function matchStringAnyNone(
|
|
162
|
+
op: StringOp,
|
|
163
|
+
candidates: readonly string[],
|
|
164
|
+
value: string,
|
|
165
|
+
): boolean {
|
|
166
|
+
if (candidates.length === 0) return false;
|
|
167
|
+
switch (op) {
|
|
168
|
+
case 'eq':
|
|
169
|
+
case 'contains':
|
|
170
|
+
case 'startsWith':
|
|
171
|
+
return candidates.some((c) => stringOpMatches(op, c, value));
|
|
172
|
+
case 'ne':
|
|
173
|
+
return candidates.every((c) => stringOpMatches('eq', c, value) === false);
|
|
174
|
+
case 'notContains':
|
|
175
|
+
return candidates.every((c) => stringOpMatches('contains', c, value) === false);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
113
179
|
export function numericOpMatches(op: NumericOp, candidate: number, value: number): boolean {
|
|
114
180
|
// The Rust side uses 1e-9 as the epsilon for eq/ne. Match it here for
|
|
115
181
|
// IDS-style parity — IFC quantities are stored as IFC4 IfcReal so the
|
|
@@ -175,6 +241,10 @@ export const Rule = {
|
|
|
175
241
|
({ kind: 'property', setName, propertyName, op, value }),
|
|
176
242
|
quantity: (setName: string, quantityName: string, op: NumericOp, value: number): QuantityRule =>
|
|
177
243
|
({ kind: 'quantity', setName, quantityName, op, value }),
|
|
244
|
+
material: (op: StringOp, value: string): MaterialRule => ({ kind: 'material', op, value }),
|
|
245
|
+
classification: (system: string, op: ClassificationOp, value: string): ClassificationRule =>
|
|
246
|
+
({ kind: 'classification', system: system || undefined, op, value }),
|
|
247
|
+
elevation: (op: NumericOp, value: number): ElevationRule => ({ kind: 'elevation', op, value }),
|
|
178
248
|
} as const;
|
|
179
249
|
|
|
180
250
|
// ── JSON guards ──────────────────────────────────────────────────────────────
|
|
@@ -188,7 +258,10 @@ export function isFilterRule(value: unknown): value is FilterRule {
|
|
|
188
258
|
kind === 'predefinedType' ||
|
|
189
259
|
kind === 'name' ||
|
|
190
260
|
kind === 'property' ||
|
|
191
|
-
kind === 'quantity'
|
|
261
|
+
kind === 'quantity' ||
|
|
262
|
+
kind === 'material' ||
|
|
263
|
+
kind === 'classification' ||
|
|
264
|
+
kind === 'elevation'
|
|
192
265
|
);
|
|
193
266
|
}
|
|
194
267
|
|
|
Binary file
|
|
@@ -33,9 +33,15 @@ function cellToString(v: unknown): string {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/** RFC-4180-style escaping: quote any cell containing comma, quote, or
|
|
36
|
-
* newline; double-up embedded quotes inside the wrapped cell.
|
|
36
|
+
* newline; double-up embedded quotes inside the wrapped cell. Also
|
|
37
|
+
* neutralises spreadsheet formula triggers (CWE-1236) so user/model-
|
|
38
|
+
* controlled cell values are treated as text on open. */
|
|
37
39
|
function escapeCsvCell(raw: string): string {
|
|
38
40
|
if (raw.length === 0) return '';
|
|
41
|
+
// CWE-1236: neutralise spreadsheet formula triggers in the leading
|
|
42
|
+
// position. Prefixing first ensures the needsQuotes check below still
|
|
43
|
+
// wraps values that also contain comma/quote/newline.
|
|
44
|
+
if (/^[=+\-@\t\r]/.test(raw)) raw = `'${raw}`;
|
|
39
45
|
const needsQuotes = raw.includes(',') || raw.includes('"') || raw.includes('\n') || raw.includes('\r');
|
|
40
46
|
if (!needsQuotes) return raw;
|
|
41
47
|
return `"${raw.replace(/"/g, '""')}"`;
|