@ifc-lite/viewer 1.17.6 → 1.18.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 (143) hide show
  1. package/.turbo/turbo-build.log +17 -14
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +513 -0
  4. package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  5. package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
  6. package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
  7. package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
  8. package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
  9. package/dist/assets/index-COnQRuqY.css +1 -0
  10. package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
  11. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  12. package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
  13. package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
  14. package/dist/index.html +6 -6
  15. package/package.json +10 -10
  16. package/src/apache-arrow.d.ts +30 -0
  17. package/src/components/viewer/AddElementPanel.tsx +758 -0
  18. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  19. package/src/components/viewer/ChatPanel.tsx +64 -2
  20. package/src/components/viewer/CommandPalette.tsx +56 -7
  21. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  22. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  23. package/src/components/viewer/ExportDialog.tsx +19 -1
  24. package/src/components/viewer/MainToolbar.tsx +69 -10
  25. package/src/components/viewer/PropertiesPanel.tsx +222 -22
  26. package/src/components/viewer/SearchInline.tsx +669 -0
  27. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  28. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  29. package/src/components/viewer/SearchModal.text.tsx +388 -0
  30. package/src/components/viewer/SearchModal.tsx +235 -0
  31. package/src/components/viewer/ToolOverlays.tsx +5 -0
  32. package/src/components/viewer/ViewerLayout.tsx +24 -4
  33. package/src/components/viewer/Viewport.tsx +11 -1
  34. package/src/components/viewer/ViewportContainer.tsx +2 -0
  35. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  36. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  37. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  38. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  39. package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
  40. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  41. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  42. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  43. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  44. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  45. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  46. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  47. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  48. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  49. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  50. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  51. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  52. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  53. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  54. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  55. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  56. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  57. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  58. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  59. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  60. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  61. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  62. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  63. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  64. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  65. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  66. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  67. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  68. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  69. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  70. package/src/components/viewer/selectionHandlers.ts +446 -0
  71. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  72. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  73. package/src/components/viewer/useMouseControls.ts +9 -1
  74. package/src/hooks/useIfcLoader.ts +22 -10
  75. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  76. package/src/hooks/useSandbox.ts +1 -1
  77. package/src/hooks/useSearchIndex.ts +125 -0
  78. package/src/index.css +66 -0
  79. package/src/lib/llm/system-prompt.test.ts +14 -0
  80. package/src/lib/llm/system-prompt.ts +102 -1
  81. package/src/lib/llm/types.ts +6 -0
  82. package/src/lib/recent-files.ts +38 -4
  83. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  84. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  85. package/src/lib/scripts/templates.ts +7 -0
  86. package/src/lib/search/common-ifc-types.ts +36 -0
  87. package/src/lib/search/filter-evaluate.test.ts +537 -0
  88. package/src/lib/search/filter-evaluate.ts +610 -0
  89. package/src/lib/search/filter-rules.test.ts +119 -0
  90. package/src/lib/search/filter-rules.ts +198 -0
  91. package/src/lib/search/filter-schema.test.ts +233 -0
  92. package/src/lib/search/filter-schema.ts +146 -0
  93. package/src/lib/search/recent-searches.test.ts +116 -0
  94. package/src/lib/search/recent-searches.ts +93 -0
  95. package/src/lib/search/result-export.test.ts +101 -0
  96. package/src/lib/search/result-export.ts +104 -0
  97. package/src/lib/search/saved-filters.test.ts +118 -0
  98. package/src/lib/search/saved-filters.ts +154 -0
  99. package/src/lib/search/tier0-scan.test.ts +196 -0
  100. package/src/lib/search/tier0-scan.ts +237 -0
  101. package/src/lib/search/tier1-index.test.ts +242 -0
  102. package/src/lib/search/tier1-index.ts +448 -0
  103. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  104. package/src/sdk/adapters/export-adapter.ts +404 -1
  105. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  106. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  107. package/src/sdk/adapters/model-compat.ts +8 -2
  108. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  109. package/src/sdk/adapters/store-adapter.ts +201 -0
  110. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  111. package/src/sdk/local-backend.ts +16 -8
  112. package/src/services/desktop-export.ts +3 -1
  113. package/src/services/desktop-native-metadata.ts +41 -18
  114. package/src/services/file-dialog.ts +4 -1
  115. package/src/services/tauri-modules.d.ts +25 -0
  116. package/src/store/basketVisibleSet.ts +3 -0
  117. package/src/store/globalId.ts +4 -1
  118. package/src/store/index.ts +70 -1
  119. package/src/store/slices/addElementMeshes.ts +365 -0
  120. package/src/store/slices/addElementSlice.ts +275 -0
  121. package/src/store/slices/annotationsSlice.test.ts +133 -0
  122. package/src/store/slices/annotationsSlice.ts +251 -0
  123. package/src/store/slices/dataSlice.test.ts +23 -4
  124. package/src/store/slices/dataSlice.ts +1 -1
  125. package/src/store/slices/modelSlice.test.ts +67 -9
  126. package/src/store/slices/modelSlice.ts +39 -7
  127. package/src/store/slices/mutationSlice.ts +964 -3
  128. package/src/store/slices/overlayCompositor.test.ts +164 -0
  129. package/src/store/slices/overlaySlice.test.ts +93 -0
  130. package/src/store/slices/overlaySlice.ts +151 -0
  131. package/src/store/slices/pinboardSlice.test.ts +6 -1
  132. package/src/store/slices/playbackSlice.ts +128 -0
  133. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  134. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  135. package/src/store/slices/scheduleSlice.test.ts +694 -0
  136. package/src/store/slices/scheduleSlice.ts +1330 -0
  137. package/src/store/slices/searchSlice.test.ts +342 -0
  138. package/src/store/slices/searchSlice.ts +341 -0
  139. package/src/store/slices/selectionSlice.test.ts +46 -0
  140. package/src/store/slices/selectionSlice.ts +20 -0
  141. package/src/store.ts +14 -0
  142. package/dist/assets/index-_bfZsDCC.css +0 -1
  143. package/dist/assets/sandbox-C8575tul.js +0 -5951
@@ -0,0 +1,537 @@
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 { describe, it } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { StringTable, EntityTableBuilder } from '@ifc-lite/data';
8
+ import type { IfcDataStore } from '@ifc-lite/parser';
9
+ import { evaluateFilterRules, evaluateFilterRulesFederated, __internal } from './filter-evaluate.js';
10
+ import { Rule } from './filter-rules.js';
11
+
12
+ interface Row {
13
+ expressId: number;
14
+ type: string;
15
+ globalId: string;
16
+ name: string;
17
+ description?: string;
18
+ objectType?: string;
19
+ }
20
+
21
+ function buildStore(rows: Row[]): IfcDataStore {
22
+ const strings = new StringTable();
23
+ const builder = new EntityTableBuilder(rows.length, strings);
24
+ for (const r of rows) {
25
+ builder.add(
26
+ r.expressId,
27
+ r.type,
28
+ r.globalId,
29
+ r.name,
30
+ r.description ?? '',
31
+ r.objectType ?? '',
32
+ false,
33
+ false,
34
+ );
35
+ }
36
+ const entities = builder.build();
37
+ // Populate byType so the prefilter has something to chew on. STEP
38
+ // type names are stored UPPERCASE in this index — match the parser.
39
+ const byType = new Map<string, number[]>();
40
+ for (const r of rows) {
41
+ const key = r.type.toUpperCase();
42
+ let bucket = byType.get(key);
43
+ if (!bucket) { bucket = []; byType.set(key, bucket); }
44
+ bucket.push(r.expressId);
45
+ }
46
+ return {
47
+ fileSize: 0,
48
+ schemaVersion: 'IFC4',
49
+ entityCount: rows.length,
50
+ parseTime: 0,
51
+ source: new Uint8Array(0),
52
+ entityIndex: { byId: { ranges: new Uint32Array(0), index: new Map() }, byType },
53
+ strings,
54
+ entities,
55
+ properties: { count: 0 },
56
+ quantities: { count: 0 },
57
+ relationships: { count: 0 },
58
+ } as unknown as IfcDataStore;
59
+ }
60
+
61
+ const rows: Row[] = [
62
+ { expressId: 10, type: 'IFCWALL', globalId: '1abcdefghijklmnopqrstu', name: 'Wall-EXT-001' },
63
+ { expressId: 20, type: 'IFCWALL', globalId: '2abcdefghijklmnopqrstu', name: 'Wall-INT-002' },
64
+ { expressId: 30, type: 'IFCDOOR', globalId: '3abcdefghijklmnopqrstu', name: 'Door-A-201' },
65
+ { expressId: 40, type: 'IFCSLAB', globalId: '4abcdefghijklmnopqrstu', name: 'Slab-G-1' },
66
+ ];
67
+
68
+ describe('evaluateFilterRules — column-only rules', () => {
69
+ it('IfcType IN narrows to walls', () => {
70
+ const store = buildStore(rows);
71
+ const out = evaluateFilterRules('m1', store, [Rule.ifcType(['IfcWall'])], 'AND');
72
+ assert.deepStrictEqual(out.map((r) => r.expressId).sort(), [10, 20]);
73
+ });
74
+
75
+ it('IfcType NOT IN excludes walls', () => {
76
+ const store = buildStore(rows);
77
+ const out = evaluateFilterRules('m1', store, [Rule.ifcType(['IfcWall'], 'notIn')], 'AND');
78
+ assert.deepStrictEqual(out.map((r) => r.expressId).sort(), [30, 40]);
79
+ });
80
+
81
+ it('Name contains is case-insensitive', () => {
82
+ const store = buildStore(rows);
83
+ const out = evaluateFilterRules('m1', store, [Rule.name('contains', 'EXT')], 'AND');
84
+ assert.deepStrictEqual(out.map((r) => r.expressId), [10]);
85
+ });
86
+
87
+ it('AND combinator narrows; OR widens', () => {
88
+ const store = buildStore(rows);
89
+ const andOut = evaluateFilterRules('m1', store, [
90
+ Rule.ifcType(['IfcWall']),
91
+ Rule.name('contains', 'EXT'),
92
+ ], 'AND');
93
+ assert.deepStrictEqual(andOut.map((r) => r.expressId), [10]);
94
+
95
+ const orOut = evaluateFilterRules('m1', store, [
96
+ Rule.ifcType(['IfcDoor']),
97
+ Rule.name('contains', 'EXT'),
98
+ ], 'OR');
99
+ assert.deepStrictEqual(orOut.map((r) => r.expressId).sort(), [10, 30]);
100
+ });
101
+
102
+ it('respects candidateExpressIds (Tier-1 narrowing)', () => {
103
+ const store = buildStore(rows);
104
+ const out = evaluateFilterRules('m1', store, [Rule.ifcType(['IfcWall'])], 'AND', {
105
+ candidateExpressIds: [20, 30, 40],
106
+ });
107
+ assert.deepStrictEqual(out.map((r) => r.expressId), [20]);
108
+ });
109
+
110
+ it('honours the limit option', () => {
111
+ const store = buildStore(rows);
112
+ const out = evaluateFilterRules('m1', store, [Rule.ifcType(['IfcWall'])], 'AND', { limit: 1 });
113
+ assert.strictEqual(out.length, 1);
114
+ });
115
+
116
+ it('returns matching elements with model id and ifc type populated', () => {
117
+ const store = buildStore(rows);
118
+ const out = evaluateFilterRules('m1', store, [Rule.name('eq', 'Door-A-201')], 'AND');
119
+ assert.strictEqual(out.length, 1);
120
+ assert.strictEqual(out[0].modelId, 'm1');
121
+ assert.strictEqual(out[0].ifcType, 'IfcDoor');
122
+ assert.strictEqual(out[0].globalId, '3abcdefghijklmnopqrstu');
123
+ });
124
+ });
125
+
126
+ describe('evaluateFilterRules — storey & predefinedType resolvers', () => {
127
+ it('uses storeyNameOf when provided', () => {
128
+ const store = buildStore(rows);
129
+ const storeyByExpressId = new Map([[10, 'Level 1'], [20, 'Level 2'], [30, 'Level 1']]);
130
+ const out = evaluateFilterRules('m1', store, [Rule.storey(['Level 1'])], 'AND', {
131
+ storeyNameOf: (id) => storeyByExpressId.get(id) ?? '',
132
+ });
133
+ assert.deepStrictEqual(out.map((r) => r.expressId).sort(), [10, 30]);
134
+ });
135
+
136
+ it('uses predefinedTypeOf when provided', () => {
137
+ const store = buildStore(rows);
138
+ const ptByExpressId = new Map([[10, 'SOLIDWALL'], [20, 'PARTITIONING'], [30, 'DOOR']]);
139
+ const out = evaluateFilterRules('m1', store, [
140
+ Rule.predefinedType(['SOLIDWALL']),
141
+ ], 'AND', { predefinedTypeOf: (id) => ptByExpressId.get(id) ?? '' });
142
+ assert.deepStrictEqual(out.map((r) => r.expressId), [10]);
143
+ });
144
+ });
145
+
146
+ describe('evaluateFilterRulesFederated', () => {
147
+ it('merges results from multiple models', async () => {
148
+ const a = buildStore(rows);
149
+ const b = buildStore([
150
+ { expressId: 100, type: 'IFCWALL', globalId: 'aabcdefghijklmnopqrstu', name: 'Wall-B-1' },
151
+ ]);
152
+ const out = await evaluateFilterRulesFederated(
153
+ [{ id: 'a', store: a }, { id: 'b', store: b }],
154
+ [Rule.ifcType(['IfcWall'])],
155
+ 'AND',
156
+ );
157
+ assert.strictEqual(out.length, 3);
158
+ const modelIds = new Set(out.map((r) => r.modelId));
159
+ assert.deepStrictEqual([...modelIds].sort(), ['a', 'b']);
160
+ });
161
+
162
+ it('caps total across federated models', async () => {
163
+ const a = buildStore(rows);
164
+ const b = buildStore(rows.map((r) => ({ ...r, expressId: r.expressId + 1000 })));
165
+ const out = await evaluateFilterRulesFederated(
166
+ [{ id: 'a', store: a }, { id: 'b', store: b }],
167
+ [Rule.ifcType(['IfcWall'])],
168
+ 'AND',
169
+ { limit: 3 },
170
+ );
171
+ assert.strictEqual(out.length, 3);
172
+ });
173
+ });
174
+
175
+ describe('flattenPsets / matchPropertyRule', () => {
176
+ it('stringifies booleans and numbers consistently', () => {
177
+ const flat = __internal.flattenPsets([
178
+ {
179
+ name: 'Pset_WallCommon',
180
+ properties: [
181
+ { name: 'IsExternal', type: 0, value: true },
182
+ { name: 'ThermalTransmittance', type: 0, value: 0.24 },
183
+ { name: 'Reference', type: 0, value: 'EXT-A' },
184
+ { name: 'Empty', type: 0, value: null },
185
+ ],
186
+ },
187
+ ]);
188
+ assert.deepStrictEqual(flat.map((r) => r.value), ['true', '0.24', 'EXT-A', '']);
189
+ });
190
+
191
+ it('matches isSet / isNotSet by (set, prop) presence only', () => {
192
+ const flat = __internal.flattenPsets([
193
+ { name: 'Pset_WallCommon', properties: [{ name: 'IsExternal', type: 0, value: true }] },
194
+ ]);
195
+ assert.strictEqual(
196
+ __internal.matchPropertyRule(Rule.property('Pset_WallCommon', 'IsExternal', 'isSet', ''), flat),
197
+ true,
198
+ );
199
+ assert.strictEqual(
200
+ __internal.matchPropertyRule(Rule.property('Pset_WallCommon', 'Missing', 'isSet', ''), flat),
201
+ false,
202
+ );
203
+ assert.strictEqual(
204
+ __internal.matchPropertyRule(Rule.property('Pset_WallCommon', 'Missing', 'isNotSet', ''), flat),
205
+ true,
206
+ );
207
+ });
208
+
209
+ it('contains is case-insensitive over the stringified value', () => {
210
+ const flat = __internal.flattenPsets([
211
+ { name: 'Pset_WallCommon', properties: [{ name: 'Reference', type: 0, value: 'WALL-EXT-A' }] },
212
+ ]);
213
+ assert.strictEqual(
214
+ __internal.matchPropertyRule(
215
+ Rule.property('Pset_WallCommon', 'Reference', 'contains', 'ext'),
216
+ flat,
217
+ ),
218
+ true,
219
+ );
220
+ });
221
+
222
+ it('numeric value ops parse both sides; NaN fails closed', () => {
223
+ const flat = __internal.flattenPsets([
224
+ { name: 'Pset_WallCommon', properties: [{ name: 'U', type: 0, value: 0.24 }] },
225
+ ]);
226
+ assert.strictEqual(
227
+ __internal.matchPropertyRule(Rule.property('Pset_WallCommon', 'U', 'lt', '0.3'), flat),
228
+ true,
229
+ );
230
+ assert.strictEqual(
231
+ __internal.matchPropertyRule(Rule.property('Pset_WallCommon', 'U', 'gt', 'abc'), flat),
232
+ false,
233
+ );
234
+ });
235
+ });
236
+
237
+ describe('matchQuantityRule', () => {
238
+ it('matches by (set, qty) with numeric op', () => {
239
+ const flat = __internal.flattenQtys([
240
+ { name: 'Qto_WallBaseQuantities', quantities: [{ name: 'NetSideArea', type: 0, value: 12.5 }] },
241
+ ]);
242
+ assert.strictEqual(
243
+ __internal.matchQuantityRule(
244
+ Rule.quantity('Qto_WallBaseQuantities', 'NetSideArea', 'gt', 10),
245
+ flat,
246
+ ),
247
+ true,
248
+ );
249
+ assert.strictEqual(
250
+ __internal.matchQuantityRule(
251
+ Rule.quantity('Qto_WallBaseQuantities', 'Missing', 'gt', 10),
252
+ flat,
253
+ ),
254
+ false,
255
+ );
256
+ });
257
+ });
258
+
259
+ describe('evaluateFilterRulesFederated — per-model candidate narrowing', () => {
260
+ it('candidateExpressIdsByModel narrows each model independently', async () => {
261
+ const a = buildStore(rows);
262
+ const b = buildStore([
263
+ { expressId: 100, type: 'IFCWALL', globalId: 'aabcdefghijklmnopqrstu', name: 'Wall-B-1' },
264
+ { expressId: 101, type: 'IFCDOOR', globalId: 'babcdefghijklmnopqrstu', name: 'Door-B-2' },
265
+ ]);
266
+ const candidatesByModel = new Map<string, Iterable<number>>([
267
+ ['a', [10]], // only Wall-EXT-001 from a
268
+ ['b', [101]], // only Door-B-2 from b
269
+ ]);
270
+ const out = await evaluateFilterRulesFederated(
271
+ [{ id: 'a', store: a }, { id: 'b', store: b }],
272
+ [Rule.ifcType(['IfcWall', 'IfcDoor'])],
273
+ 'AND',
274
+ { candidateExpressIdsByModel: candidatesByModel },
275
+ );
276
+ // Two narrow hits — one wall from `a`, one door from `b`.
277
+ assert.deepStrictEqual(
278
+ out.map((r) => `${r.modelId}:${r.expressId}`).sort(),
279
+ ['a:10', 'b:101'],
280
+ );
281
+ });
282
+
283
+ it('an empty candidate set for a model yields zero results from that model (intersection semantics)', async () => {
284
+ // Codex P1 invariant: a misspelt text query that produced zero
285
+ // Tier-0/Tier-1 hits must NOT degrade to a full-table scan when
286
+ // the user has structured rules. Empty Iterable per model ⇒ no rows.
287
+ const a = buildStore(rows);
288
+ const candidatesByModel = new Map<string, Iterable<number>>([['a', []]]);
289
+ const out = await evaluateFilterRulesFederated(
290
+ [{ id: 'a', store: a }],
291
+ [Rule.ifcType(['IfcWall'])],
292
+ 'AND',
293
+ { candidateExpressIdsByModel: candidatesByModel },
294
+ );
295
+ assert.deepStrictEqual(out, []);
296
+ });
297
+
298
+ it('omitting the map keeps the legacy full-scan behaviour', async () => {
299
+ const a = buildStore(rows);
300
+ const out = await evaluateFilterRulesFederated(
301
+ [{ id: 'a', store: a }],
302
+ [Rule.ifcType(['IfcWall'])],
303
+ 'AND',
304
+ );
305
+ assert.deepStrictEqual(out.map((r) => r.expressId).sort(), [10, 20]);
306
+ });
307
+
308
+ it('storeyNameOf / predefinedTypeOf flow through the federated wrapper', async () => {
309
+ const a = buildStore(rows);
310
+ const out = await evaluateFilterRulesFederated(
311
+ [{ id: 'a', store: a }],
312
+ [Rule.storey(['Level 1'])],
313
+ 'AND',
314
+ { storeyNameOf: (id) => (id === 10 ? 'Level 1' : '') },
315
+ );
316
+ assert.deepStrictEqual(out.map((r) => r.expressId), [10]);
317
+ });
318
+ });
319
+
320
+ describe('evaluateFilterRules — empty rules', () => {
321
+ it('returns [] when rules is empty (matches Rust behaviour)', () => {
322
+ const store = buildStore(rows);
323
+ assert.deepStrictEqual(evaluateFilterRules('m1', store, [], 'AND'), []);
324
+ });
325
+ });
326
+
327
+ describe('orderRulesByCost — cheap-first reordering', () => {
328
+ const order = __internal.orderRulesByCost;
329
+
330
+ it('lifts cheap kinds (ifcType, name, storey) before expensive (property, quantity)', () => {
331
+ const reordered = order([
332
+ Rule.property('Pset_X', 'P', 'eq', 'v'),
333
+ Rule.ifcType(['IfcWall']),
334
+ Rule.quantity('Qto_X', 'Q', 'gt', 1),
335
+ Rule.name('contains', 'wall'),
336
+ ]);
337
+ // Equal-cost rules retain their authored order — `ifcType` before
338
+ // `name` because cost(ifcType)=0 < cost(name)=2.
339
+ assert.deepStrictEqual(reordered.map((r) => r.kind), ['ifcType', 'name', 'property', 'quantity']);
340
+ });
341
+
342
+ it('is a stable sort — two equal-cost rules keep their input order', () => {
343
+ const a = Rule.name('contains', 'a');
344
+ const b = Rule.name('contains', 'b');
345
+ const reordered = order([a, b]);
346
+ assert.strictEqual(reordered[0], a);
347
+ assert.strictEqual(reordered[1], b);
348
+ });
349
+
350
+ it('does not mutate the input array', () => {
351
+ const input = [
352
+ Rule.property('Pset_X', 'P', 'eq', 'v'),
353
+ Rule.ifcType(['IfcWall']),
354
+ ];
355
+ const before = input.map((r) => r.kind);
356
+ void order(input);
357
+ assert.deepStrictEqual(input.map((r) => r.kind), before);
358
+ });
359
+ });
360
+
361
+ describe('selectIterationSource — index prefilter (AND + op:in)', () => {
362
+ const select = __internal.selectIterationSource;
363
+
364
+ it('AND + ifcType op:in narrows to byType bucket(s)', () => {
365
+ const store = buildStore(rows);
366
+ const source = select(store, [Rule.ifcType(['IfcWall'])], 'AND', undefined);
367
+ const ids = Array.from(source as Iterable<number>);
368
+ // Bucket holds only the two walls — not the door / slab.
369
+ assert.deepStrictEqual(ids.sort(), [10, 20]);
370
+ });
371
+
372
+ it('AND + multiple narrowing rules picks the smallest bucket', () => {
373
+ const store = buildStore(rows);
374
+ // ifcType {IfcWall} = 2 entries; ifcType {IfcDoor} = 1 entry.
375
+ // The smaller of the two should be chosen as the iteration source.
376
+ const source = select(
377
+ store,
378
+ [Rule.ifcType(['IfcWall']), Rule.ifcType(['IfcDoor'])],
379
+ 'AND',
380
+ undefined,
381
+ );
382
+ const ids = Array.from(source as Iterable<number>);
383
+ assert.deepStrictEqual(ids, [30]);
384
+ });
385
+
386
+ it('OR combinator skips the prefilter and falls back to the full table', () => {
387
+ const store = buildStore(rows);
388
+ const source = select(store, [Rule.ifcType(['IfcWall'])], 'OR', undefined);
389
+ const ids = Array.from(source as Iterable<number>);
390
+ // Generator over the full expressId column — all four entities.
391
+ assert.deepStrictEqual(ids.sort(), [10, 20, 30, 40]);
392
+ });
393
+
394
+ it('notIn ops skip the prefilter (inverting a small set is still big)', () => {
395
+ const store = buildStore(rows);
396
+ const source = select(store, [Rule.ifcType(['IfcWall'], 'notIn')], 'AND', undefined);
397
+ const ids = Array.from(source as Iterable<number>);
398
+ // No bucket suggested → full-table iteration.
399
+ assert.strictEqual(ids.length, 4);
400
+ });
401
+
402
+ it('explicit candidateExpressIds wins over the prefilter', () => {
403
+ const store = buildStore(rows);
404
+ const source = select(store, [Rule.ifcType(['IfcWall'])], 'AND', [99]);
405
+ assert.deepStrictEqual(Array.from(source as Iterable<number>), [99]);
406
+ });
407
+ });
408
+
409
+ describe('evaluateFilterRulesFederated — large-model scaling', () => {
410
+ // Synthetic 50K-entity store: 200 walls in a sea of slabs. The
411
+ // prefilter MUST narrow the scan to the wall bucket (≤ 200 entities)
412
+ // rather than walking the full table — otherwise huge models would
413
+ // freeze the main thread on Fast Run, which is the AGENTS.md §2 trap
414
+ // this whole module is built to avoid.
415
+ it('AND + ifcType prefilter scans only the bucket on a 50K-entity model', async () => {
416
+ const big: Row[] = [];
417
+ for (let i = 0; i < 50_000; i++) {
418
+ big.push({
419
+ expressId: i + 1,
420
+ type: i % 250 === 0 ? 'IFCWALL' : 'IFCSLAB',
421
+ globalId: `${String(i).padStart(22, '0')}`.slice(0, 22),
422
+ name: `entity-${i}`,
423
+ });
424
+ }
425
+ const store = buildStore(big);
426
+ let lastTotal = 0;
427
+ const out = await evaluateFilterRulesFederated(
428
+ [{ id: 'm', store }],
429
+ [Rule.ifcType(['IfcWall'])],
430
+ 'AND',
431
+ {
432
+ chunkSize: 1_000,
433
+ onProgress: (_scanned, total) => { lastTotal = total; },
434
+ },
435
+ );
436
+ // 50_000 / 250 = 200 walls.
437
+ assert.strictEqual(out.length, 200);
438
+ // Progress total is the SCAN size (the bucket, not the full table).
439
+ // Without the prefilter this would have been 50_000.
440
+ assert.strictEqual(lastTotal, 200);
441
+ });
442
+
443
+ it('OR mode falls back to full scan (prefilter is unsafe under OR)', async () => {
444
+ const big: Row[] = [];
445
+ for (let i = 0; i < 1_000; i++) {
446
+ big.push({
447
+ expressId: i + 1,
448
+ type: i % 100 === 0 ? 'IFCWALL' : 'IFCSLAB',
449
+ globalId: `${String(i).padStart(22, '0')}`.slice(0, 22),
450
+ name: i === 0 ? 'special' : `entity-${i}`,
451
+ });
452
+ }
453
+ const store = buildStore(big);
454
+ let lastTotal = 0;
455
+ const out = await evaluateFilterRulesFederated(
456
+ [{ id: 'm', store }],
457
+ [Rule.ifcType(['IfcWall']), Rule.name('eq', 'special')],
458
+ 'OR',
459
+ {
460
+ chunkSize: 100,
461
+ onProgress: (_scanned, total) => { lastTotal = total; },
462
+ },
463
+ );
464
+ // 10 walls + 1 special = 10 results (the 'special' wall is also in
465
+ // the wall bucket, so it counts once via dedupe of the OR — but
466
+ // the evaluator doesn't dedupe; it just scans, which produces 10
467
+ // hits since 'special' IS one of the walls). Either way the test
468
+ // verifies OR scans the full table.
469
+ assert.strictEqual(out.length, 10);
470
+ assert.strictEqual(lastTotal, 1_000);
471
+ });
472
+ });
473
+
474
+ describe('evaluateFilterRulesFederated — async chunking, abort, progress', () => {
475
+ it('reports onProgress with monotonically growing scanned counter', async () => {
476
+ const store = buildStore(rows);
477
+ const ticks: Array<{ scanned: number; total: number }> = [];
478
+ await evaluateFilterRulesFederated(
479
+ [{ id: 'm', store }],
480
+ [Rule.ifcType(['IfcWall'])],
481
+ 'AND',
482
+ {
483
+ chunkSize: 1,
484
+ onProgress: (scanned, total) => { ticks.push({ scanned, total }); },
485
+ },
486
+ );
487
+ // First tick is the initial 0/total emission; subsequent ticks
488
+ // monotonically grow; final tick equals total.
489
+ assert.ok(ticks.length >= 2, `expected ≥2 progress ticks, got ${ticks.length}`);
490
+ assert.strictEqual(ticks[0].scanned, 0);
491
+ for (let i = 1; i < ticks.length; i++) {
492
+ assert.ok(
493
+ ticks[i].scanned >= ticks[i - 1].scanned,
494
+ `progress regressed from ${ticks[i - 1].scanned} → ${ticks[i].scanned}`,
495
+ );
496
+ }
497
+ });
498
+
499
+ it('honours AbortSignal at chunk boundaries', async () => {
500
+ const store = buildStore(rows);
501
+ const controller = new AbortController();
502
+ // Abort before the first await — the evaluator's chunk-boundary
503
+ // check fires after the first chunk completes.
504
+ controller.abort();
505
+ let threwAbort = false;
506
+ try {
507
+ await evaluateFilterRulesFederated(
508
+ [{ id: 'm', store }],
509
+ [Rule.ifcType(['IfcWall'])],
510
+ 'AND',
511
+ { chunkSize: 1, signal: controller.signal },
512
+ );
513
+ } catch (err) {
514
+ if (err instanceof DOMException && err.name === 'AbortError') threwAbort = true;
515
+ else throw err;
516
+ }
517
+ assert.ok(threwAbort, 'expected AbortError when signal is pre-aborted');
518
+ });
519
+
520
+ it('limit short-circuits the run before scanning the rest', async () => {
521
+ const store = buildStore(rows);
522
+ let lastScanned = 0;
523
+ const out = await evaluateFilterRulesFederated(
524
+ [{ id: 'm', store }],
525
+ [Rule.ifcType(['IfcWall'])],
526
+ 'AND',
527
+ {
528
+ limit: 1,
529
+ chunkSize: 1,
530
+ onProgress: (scanned) => { lastScanned = scanned; },
531
+ },
532
+ );
533
+ assert.strictEqual(out.length, 1);
534
+ // We should have stopped before scanning all four entities.
535
+ assert.ok(lastScanned < rows.length, `expected early termination, scanned ${lastScanned}`);
536
+ });
537
+ });