@ifc-lite/viewer 1.26.0 → 1.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/.turbo/turbo-build.log +45 -38
  2. package/CHANGELOG.md +93 -0
  3. package/dist/assets/{basketViewActivator-ZpTYWE3K.js → basketViewActivator-BNRDNuUJ.js} +9 -9
  4. package/dist/assets/{bcf-Ctcu_Sc2.js → bcf-DCwCuP7n.js} +56 -56
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
  8. package/dist/assets/deflate-DNGgs8Ur.js +1 -0
  9. package/dist/assets/drawing-2d-D0dDf6Lh.js +257 -0
  10. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  11. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  12. package/dist/assets/{exporters-DSq76AVM.js → exporters-B9v81gi9.js} +1861 -1524
  13. package/dist/assets/geometry.worker-Bpa3115V.js +1 -0
  14. package/dist/assets/{geotiff-A5UjhI6L.js → geotiff-D-YCLS4g.js} +10 -10
  15. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  16. package/dist/assets/{ids-DiLcGTer.js → ids-CCpq-5d3.js} +952 -945
  17. package/dist/assets/ifc-lite_bg-DbgS5EUA.wasm +0 -0
  18. package/dist/assets/{index-BAH8IJVR.js → index-Bgb3_Pu_.js} +47682 -42474
  19. package/dist/assets/index-BtbXFKsX.css +1 -0
  20. package/dist/assets/index.es-CWfqZyyr.js +6866 -0
  21. package/dist/assets/{jpeg-BzSkwo5D.js → jpeg-DGOAeUqU.js} +1 -1
  22. package/dist/assets/jspdf.es.min-XPLU2Wkq.js +19571 -0
  23. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  24. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  25. package/dist/assets/{lerc-Cg2Rz-D5.js → lerc-1PMSCHwX.js} +1 -1
  26. package/dist/assets/{lzw-BBPPLW-0.js → lzw-C65U9lNM.js} +1 -1
  27. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  28. package/dist/assets/{native-bridge-CPojOeGE.js → native-bridge-XxXos6yI.js} +2 -2
  29. package/dist/assets/{packbits-yLSpjW-V.js → packbits-BdMWXC3m.js} +1 -1
  30. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  31. package/dist/assets/parser.worker-Ddwo3_06.js +182 -0
  32. package/dist/assets/pdf-CRwaZf3s.js +135 -0
  33. package/dist/assets/raw-CJgQdyuZ.js +1 -0
  34. package/dist/assets/{sandbox-CsRXlgCO.js → sandbox-0sDo3g3m.js} +3037 -2554
  35. package/dist/assets/server-client-cTCJ-853.js +719 -0
  36. package/dist/assets/{webimage-YafxjjGr.js → webimage-BtakWX7W.js} +1 -1
  37. package/dist/assets/xlsx-B1YOg2QB.js +142 -0
  38. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  39. package/dist/assets/{zstd-CkSLOiuu.js → zstd-CmwsbxmM.js} +1 -1
  40. package/dist/index.html +10 -10
  41. package/package.json +27 -23
  42. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  43. package/src/components/mcp/data.ts +6 -0
  44. package/src/components/mcp/playground-dispatcher.ts +280 -0
  45. package/src/components/mcp/playground-files.ts +33 -1
  46. package/src/components/mcp/types.ts +2 -1
  47. package/src/components/ui/combo-input.tsx +163 -0
  48. package/src/components/ui/tabs.tsx +1 -1
  49. package/src/components/viewer/CommandPalette.tsx +6 -1
  50. package/src/components/viewer/ComparePanel.tsx +420 -0
  51. package/src/components/viewer/HierarchyPanel.tsx +46 -7
  52. package/src/components/viewer/MainToolbar.tsx +19 -2
  53. package/src/components/viewer/PropertiesPanel.tsx +84 -8
  54. package/src/components/viewer/SearchInline.tsx +62 -2
  55. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  56. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  57. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  58. package/src/components/viewer/SearchModal.tsx +19 -6
  59. package/src/components/viewer/ViewerLayout.tsx +5 -0
  60. package/src/components/viewer/Viewport.tsx +18 -0
  61. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  62. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  63. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  64. package/src/components/viewer/hierarchy/types.ts +1 -0
  65. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  66. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  67. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  68. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  69. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  70. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  71. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  72. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  73. package/src/generated/mcp-catalog.json +4 -0
  74. package/src/hooks/federationLoadGate.test.ts +12 -2
  75. package/src/hooks/federationLoadGate.ts +9 -2
  76. package/src/hooks/ingest/federationAlign.ts +481 -0
  77. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  78. package/src/hooks/source-key.ts +35 -0
  79. package/src/hooks/useAlignmentLines3D.ts +1 -26
  80. package/src/hooks/useCompare.ts +0 -0
  81. package/src/hooks/useCompareOverlay.ts +119 -0
  82. package/src/hooks/useDrawingGeneration.ts +23 -1
  83. package/src/hooks/useGridLines3D.ts +140 -0
  84. package/src/hooks/useIfc.ts +1 -1
  85. package/src/hooks/useIfcCache.ts +32 -9
  86. package/src/hooks/useIfcFederation.ts +42 -810
  87. package/src/hooks/useIfcLoader.ts +361 -488
  88. package/src/hooks/useIfcServer.ts +3 -0
  89. package/src/hooks/useLens.ts +5 -1
  90. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  91. package/src/lib/compare/buildFingerprints.ts +173 -0
  92. package/src/lib/compare/describeChange.ts +0 -0
  93. package/src/lib/compare/geometricData.test.ts +54 -0
  94. package/src/lib/compare/geometricData.ts +37 -0
  95. package/src/lib/compare/overlay.test.ts +99 -0
  96. package/src/lib/compare/overlay.ts +91 -0
  97. package/src/lib/geo/cesium-placement.ts +1 -1
  98. package/src/lib/geo/reproject.ts +4 -1
  99. package/src/lib/length-unit-scale.ts +41 -0
  100. package/src/lib/lists/adapter.ts +136 -11
  101. package/src/lib/lists/export/csv.ts +47 -0
  102. package/src/lib/lists/export/index.ts +49 -0
  103. package/src/lib/lists/export/model.ts +111 -0
  104. package/src/lib/lists/export/pdf.ts +67 -0
  105. package/src/lib/lists/export/xlsx.ts +83 -0
  106. package/src/lib/lists/index.ts +2 -0
  107. package/src/lib/llm/script-edit-ops.ts +23 -0
  108. package/src/lib/llm/stream-client.ts +8 -1
  109. package/src/lib/search/filter-evaluate.test.ts +81 -0
  110. package/src/lib/search/filter-evaluate.ts +59 -87
  111. package/src/lib/search/filter-match.ts +167 -0
  112. package/src/lib/search/filter-rules.test.ts +25 -0
  113. package/src/lib/search/filter-rules.ts +75 -2
  114. package/src/lib/search/filter-schema.ts +0 -0
  115. package/src/lib/search/result-export.ts +7 -1
  116. package/src/lib/slab-edit.test.ts +72 -0
  117. package/src/lib/slab-edit.ts +159 -19
  118. package/src/sdk/adapters/export-adapter.ts +9 -4
  119. package/src/sdk/adapters/query-adapter.ts +3 -3
  120. package/src/store/globalId.ts +15 -13
  121. package/src/store/index.ts +16 -1
  122. package/src/store/slices/cesiumSlice.ts +8 -1
  123. package/src/store/slices/compareSlice.ts +96 -0
  124. package/src/store/slices/lensSlice.ts +8 -0
  125. package/src/store/slices/listSlice.ts +6 -0
  126. package/src/store/slices/mutationSlice.ts +14 -6
  127. package/src/store/slices/searchSlice.ts +29 -3
  128. package/src/utils/acquireFileBuffer.test.ts +12 -4
  129. package/src/utils/desktopModelSnapshot.ts +2 -1
  130. package/src/utils/loadingUtils.ts +32 -0
  131. package/src/utils/nativeSpatialDataStore.ts +6 -0
  132. package/src/utils/serverDataModel.test.ts +6 -0
  133. package/src/utils/serverDataModel.ts +7 -0
  134. package/src/utils/spatialHierarchy.test.ts +53 -1
  135. package/src/utils/spatialHierarchy.ts +42 -2
  136. package/src/vite-env.d.ts +2 -0
  137. package/dist/assets/deflate-Cnx0il6E.js +0 -1
  138. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  139. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  140. package/dist/assets/geometry.worker-0Q9qEa6p.js +0 -1
  141. package/dist/assets/ifc-lite_bg-CEZnhM2e.wasm +0 -0
  142. package/dist/assets/index-B9Ug2EqU.css +0 -1
  143. package/dist/assets/lens-PYsLu_MA.js +0 -1
  144. package/dist/assets/parser.worker-8md211IW.js +0 -182
  145. package/dist/assets/raw-BQrAgxwT.js +0 -1
  146. package/dist/assets/server-client-Bk4c1CPO.js +0 -626
  147. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  148. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  149. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  150. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
@@ -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, '""')}"`;