@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,610 @@
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
+ * Path-B runtime evaluator.
7
+ *
8
+ * Applies a list of `FilterRule`s to one or more `IfcDataStore`s without
9
+ * touching DuckDB. Three optimisations make this safe on huge (4M-entity)
10
+ * models without a Worker:
11
+ *
12
+ * 1. **Index prefilter (AND + op:in only).** When the rule list contains
13
+ * any `ifcType` or `storey` `op:'in'` rule under an AND combinator,
14
+ * the iteration source is derived from `entityIndex.byType` /
15
+ * `spatialHierarchy.byStorey` — typically 100× narrowing. Per-entity
16
+ * rule evaluation still re-checks every rule for correctness, so
17
+ * picking one prefilter (the smallest bucket) is enough; we don't
18
+ * need to intersect. `notIn` and `OR` skip the prefilter and fall
19
+ * back to the full column scan.
20
+ *
21
+ * 2. **Cheap-first per-entity ordering.** Rules are sorted by cost at
22
+ * evaluation time so column-only checks (`ifcType`, `name`, `storey`,
23
+ * `predefinedType`) run before `property` / `quantity` rules that
24
+ * trigger on-demand source-buffer parses. Combined with AND/OR
25
+ * short-circuit, this avoids the AGENTS.md §2 "never call
26
+ * extractPropertiesOnDemand in a large loop" trap — a single
27
+ * ifcType rule excluding 99% of entities skips 99% of the parses.
28
+ *
29
+ * 3. **Async chunked yielding (federated entry).** The federated entry
30
+ * is async and yields to the event loop every `chunkSize` rows
31
+ * (default 20_000, same as `buildTier1Index`). `AbortSignal` is
32
+ * honoured at chunk boundaries; an `onProgress(scanned, total)`
33
+ * callback fires once per chunk. The synchronous single-model
34
+ * entry remains for tests and small candidate sets.
35
+ */
36
+
37
+ import {
38
+ extractPropertiesOnDemand,
39
+ extractQuantitiesOnDemand,
40
+ type IfcDataStore,
41
+ } from '@ifc-lite/parser';
42
+
43
+ import {
44
+ combineRuleResults,
45
+ setOpMatches,
46
+ stringOpMatches,
47
+ numericOpMatches,
48
+ valueOpMatches,
49
+ type Combinator,
50
+ type FilterRule,
51
+ type PropertyRule,
52
+ type QuantityRule,
53
+ } from './filter-rules.js';
54
+
55
+ /** A single matched element. Mirrors the Rust `FilteredElement` shape. */
56
+ export interface FilteredElement {
57
+ modelId: string;
58
+ expressId: number;
59
+ ifcType: string;
60
+ name: string;
61
+ globalId: string;
62
+ }
63
+
64
+ export interface EvaluateOptions {
65
+ /**
66
+ * Restrict evaluation to these expressIds (e.g. the result list from
67
+ * Tier-1). Omit to scan every populated entity in the store, with
68
+ * index prefilters applied where possible.
69
+ */
70
+ candidateExpressIds?: Iterable<number>;
71
+ /** Cap. Default 5_000 — enough for downstream batch ops, cheap to bump. */
72
+ limit?: number;
73
+ /** Optional storey-name resolver. Falls back to spatial-hierarchy lookup. */
74
+ storeyNameOf?: (expressId: number) => string;
75
+ /** Optional predefined-type resolver. Falls back to "" when omitted. */
76
+ predefinedTypeOf?: (expressId: number) => string;
77
+ }
78
+
79
+ const DEFAULT_LIMIT = 5_000;
80
+ const DEFAULT_CHUNK_SIZE = 20_000;
81
+
82
+ // ── Sync entry (small candidate sets, tests) ─────────────────────────────────
83
+
84
+ /**
85
+ * Evaluate `rules` against one model synchronously. Suitable for tests
86
+ * and small candidate sets where the chunked async path's overhead
87
+ * isn't justified. For real UI flows (huge models, cancellable runs),
88
+ * use `evaluateFilterRulesFederated` (async).
89
+ */
90
+ export function evaluateFilterRules(
91
+ modelId: string,
92
+ store: IfcDataStore,
93
+ rules: readonly FilterRule[],
94
+ combinator: Combinator,
95
+ options: EvaluateOptions = {},
96
+ ): FilteredElement[] {
97
+ if (rules.length === 0) return [];
98
+
99
+ const limit = options.limit ?? DEFAULT_LIMIT;
100
+ const orderedRules = orderRulesByCost(rules);
101
+ const iterIds = toIterable(
102
+ selectIterationSource(store, rules, combinator, options.candidateExpressIds),
103
+ );
104
+ const out: FilteredElement[] = [];
105
+ const ctx: EvalContext = {
106
+ store,
107
+ table: store.entities,
108
+ options,
109
+ hasPropertyRule: orderedRules.some((r) => r.kind === 'property'),
110
+ hasQuantityRule: orderedRules.some((r) => r.kind === 'quantity'),
111
+ };
112
+
113
+ for (const expressId of iterIds) {
114
+ if (out.length >= limit) break;
115
+ // Skip empty rows from the raw expressId column. ArrayLike sources
116
+ // (the full-table fast-path) include zero-padded slots; bucket
117
+ // sources (byType / byStorey) never do, so this is a no-op there.
118
+ if (!expressId) continue;
119
+ if (!evaluateOneEntity(ctx, expressId, orderedRules, combinator)) continue;
120
+ out.push(buildResult(modelId, ctx, expressId));
121
+ }
122
+ return out;
123
+ }
124
+
125
+ /** Coerce ArrayLike-or-Iterable into an Iterable so the sync entry can
126
+ * use `for…of`. The federated entry takes the array fast-path
127
+ * separately. */
128
+ function toIterable(source: ArrayLike<number> | Iterable<number>): Iterable<number> {
129
+ if (Symbol.iterator in Object(source)) return source as Iterable<number>;
130
+ // ArrayLike fallback — wrap as a generator so the for…of loop works.
131
+ return (function* () {
132
+ const arr = source as ArrayLike<number>;
133
+ for (let i = 0; i < arr.length; i++) yield arr[i];
134
+ })();
135
+ }
136
+
137
+ // ── Async federated entry — production UI path ──────────────────────────────
138
+
139
+ export interface FederatedEvaluateOptions extends Omit<EvaluateOptions, 'candidateExpressIds'> {
140
+ /**
141
+ * Optional per-model candidate set. When supplied for a model, only
142
+ * those expressIds are evaluated (the typical use is "narrow with
143
+ * Tier-1 first, then verify structured rules"). Models absent from
144
+ * the map fall back to a full scan with index prefilters applied.
145
+ * Pass an empty iterable to skip a model entirely.
146
+ */
147
+ candidateExpressIdsByModel?: ReadonlyMap<string, Iterable<number>>;
148
+ /** Rows per yield boundary. Default 20_000. */
149
+ chunkSize?: number;
150
+ /** Aborts the run between chunks. Throws DOMException("…", "AbortError"). */
151
+ signal?: AbortSignal;
152
+ /** Progress callback fired after each chunk: (scanned, total). When
153
+ * `total` is unknown (Tier-1 candidate iterables without `.size`),
154
+ * it's reported as -1. */
155
+ onProgress?: (scanned: number, total: number) => void;
156
+ }
157
+
158
+ /**
159
+ * Evaluate `rules` across multiple federated models, producing a single
160
+ * sorted result list. Async chunked + cancellable + progress-reporting.
161
+ */
162
+ export async function evaluateFilterRulesFederated(
163
+ models: ReadonlyArray<{ id: string; store: IfcDataStore | null }>,
164
+ rules: readonly FilterRule[],
165
+ combinator: Combinator,
166
+ options: FederatedEvaluateOptions = {},
167
+ ): Promise<FilteredElement[]> {
168
+ if (rules.length === 0) return [];
169
+
170
+ const limit = options.limit ?? DEFAULT_LIMIT;
171
+ const chunkSize = options.chunkSize ?? DEFAULT_CHUNK_SIZE;
172
+ const signal = options.signal;
173
+ const orderedRules = orderRulesByCost(rules);
174
+ const out: FilteredElement[] = [];
175
+
176
+ // Pre-compute per-model iteration plans + a global total so the
177
+ // progress callback can render a single bar across the federation.
178
+ interface Plan {
179
+ modelId: string;
180
+ store: IfcDataStore;
181
+ iter: ArrayLike<number> | Iterable<number>;
182
+ total: number;
183
+ }
184
+ const plans: Plan[] = [];
185
+ let grandTotal = 0;
186
+ let totalKnown = true;
187
+ for (const m of models) {
188
+ if (!m.store) continue;
189
+ const candidates = options.candidateExpressIdsByModel?.get(m.id);
190
+ const source = candidates ?? selectIterationSource(m.store, rules, combinator, undefined);
191
+ const arr = materialiseIterable(source);
192
+ if (arr === null) {
193
+ totalKnown = false;
194
+ } else {
195
+ grandTotal += arr.length;
196
+ }
197
+ plans.push({ modelId: m.id, store: m.store, iter: arr ?? source, total: arr ? arr.length : -1 });
198
+ }
199
+
200
+ let scanned = 0;
201
+ options.onProgress?.(0, totalKnown ? grandTotal : -1);
202
+
203
+ for (const plan of plans) {
204
+ if (out.length >= limit) break;
205
+ if (signal?.aborted) throwAbort(signal);
206
+
207
+ const ctx: EvalContext = {
208
+ store: plan.store,
209
+ table: plan.store.entities,
210
+ options,
211
+ hasPropertyRule: orderedRules.some((r) => r.kind === 'property'),
212
+ hasQuantityRule: orderedRules.some((r) => r.kind === 'quantity'),
213
+ };
214
+
215
+ // Walk the per-model iter in chunkSize-sized strides, yielding the
216
+ // event loop between chunks. ArrayLike fast-path uses index access;
217
+ // the fallback path drains an iterator into chunks.
218
+ if (Array.isArray(plan.iter) || isArrayLike(plan.iter)) {
219
+ const arr = plan.iter as ArrayLike<number>;
220
+ for (let i = 0; i < arr.length && out.length < limit; i += chunkSize) {
221
+ if (signal?.aborted) throwAbort(signal);
222
+ const end = Math.min(i + chunkSize, arr.length);
223
+ for (let j = i; j < end; j++) {
224
+ const expressId = arr[j];
225
+ if (!expressId) continue;
226
+ if (!evaluateOneEntity(ctx, expressId, orderedRules, combinator)) continue;
227
+ out.push(buildResult(plan.modelId, ctx, expressId));
228
+ if (out.length >= limit) break;
229
+ }
230
+ scanned += end - i;
231
+ options.onProgress?.(scanned, totalKnown ? grandTotal : -1);
232
+ if (end < arr.length && out.length < limit) await yieldToEventLoop();
233
+ }
234
+ } else {
235
+ let buffered = 0;
236
+ for (const expressId of plan.iter as Iterable<number>) {
237
+ if (out.length >= limit) break;
238
+ if (!expressId) continue;
239
+ if (evaluateOneEntity(ctx, expressId, orderedRules, combinator)) {
240
+ out.push(buildResult(plan.modelId, ctx, expressId));
241
+ }
242
+ buffered++;
243
+ scanned++;
244
+ if (buffered >= chunkSize) {
245
+ buffered = 0;
246
+ if (signal?.aborted) throwAbort(signal);
247
+ options.onProgress?.(scanned, totalKnown ? grandTotal : -1);
248
+ await yieldToEventLoop();
249
+ }
250
+ }
251
+ // Final progress tick for the residual.
252
+ options.onProgress?.(scanned, totalKnown ? grandTotal : -1);
253
+ }
254
+ }
255
+
256
+ return out;
257
+ }
258
+
259
+ // ── Iteration source: index prefilter (AND + op:in) ──────────────────────────
260
+
261
+ /**
262
+ * Decide which expressIds the evaluator walks. Public for testability —
263
+ * consumers should only depend on the results returned, not on the
264
+ * iteration count, but a benchmark / regression test may want to assert
265
+ * the prefilter actually narrows.
266
+ */
267
+ export function selectIterationSource(
268
+ store: IfcDataStore,
269
+ rules: readonly FilterRule[],
270
+ combinator: Combinator,
271
+ candidateExpressIds: Iterable<number> | undefined,
272
+ ): ArrayLike<number> | Iterable<number> {
273
+ // Caller-supplied narrowing wins (Tier-1 candidates).
274
+ if (candidateExpressIds !== undefined) return candidateExpressIds;
275
+
276
+ // Prefilter only applies under AND. OR rules are unioned; you can't
277
+ // shrink the candidate set from a single OR clause without losing
278
+ // results from the other clauses.
279
+ if (combinator !== 'AND') return iterateAllExpressIds(store);
280
+
281
+ // Try to find the smallest narrowing source. Multiple op:in rules in
282
+ // the same query can each suggest a candidate bucket; we pick the
283
+ // smallest one (the per-entity loop re-checks every rule, so any one
284
+ // valid bucket is correctness-safe — fewer rows = less work).
285
+ let best: number[] | null = null;
286
+
287
+ for (const rule of rules) {
288
+ if (rule.kind === 'ifcType' && rule.op === 'in' && rule.values.length > 0) {
289
+ const bucket = unionByType(store, rule.values);
290
+ if (bucket && (best === null || bucket.length < best.length)) best = bucket;
291
+ } else if (rule.kind === 'storey' && rule.op === 'in' && rule.values.length > 0) {
292
+ const bucket = unionByStorey(store, rule.values);
293
+ if (bucket && (best === null || bucket.length < best.length)) best = bucket;
294
+ }
295
+ }
296
+
297
+ return best ?? iterateAllExpressIds(store);
298
+ }
299
+
300
+ function unionByType(store: IfcDataStore, names: readonly string[]): number[] | null {
301
+ const byType = store.entityIndex.byType;
302
+ if (!byType || byType.size === 0) return null;
303
+ // STEP type names are stored UPPERCASE; rule values arrive in canonical
304
+ // PascalCase ("IfcWall") so we uppercase here at the boundary.
305
+ const out: number[] = [];
306
+ for (const name of names) {
307
+ const bucket = byType.get(name.toUpperCase());
308
+ if (bucket) for (const id of bucket) out.push(id);
309
+ }
310
+ return out.length > 0 ? out : null;
311
+ }
312
+
313
+ function unionByStorey(store: IfcDataStore, storeyNames: readonly string[]): number[] | null {
314
+ const hierarchy = store.spatialHierarchy;
315
+ if (!hierarchy) return null;
316
+ const wanted = new Set(storeyNames.map((n) => n.toLowerCase()));
317
+ const out: number[] = [];
318
+ // byStorey keys are storey expressIds; their name comes from the
319
+ // entity table. Models rarely have more than ~20 storeys, so this
320
+ // pass is essentially free.
321
+ for (const storeyId of hierarchy.byStorey.keys()) {
322
+ const name = store.entities.getName(storeyId);
323
+ if (!wanted.has(name.toLowerCase())) continue;
324
+ const elements = hierarchy.byStorey.get(storeyId);
325
+ if (elements) for (const id of elements) out.push(id);
326
+ }
327
+ return out.length > 0 ? out : null;
328
+ }
329
+
330
+ // ── Cheap-first rule ordering ────────────────────────────────────────────────
331
+
332
+ /**
333
+ * AGENTS.md §2: never call `extractPropertiesOnDemand` in a large loop.
334
+ * We can't avoid it entirely for `property`/`quantity` rules, but we can
335
+ * make sure cheap rules check first so AND/OR short-circuit skips the
336
+ * expensive parse for entities that already fail/pass.
337
+ */
338
+ const RULE_COST: Record<FilterRule['kind'], number> = {
339
+ // Column-only — single TypedArray read.
340
+ ifcType: 0,
341
+ // Pre-built reverse-map lookup.
342
+ storey: 1,
343
+ // String-table indirection.
344
+ name: 2,
345
+ predefinedType: 2,
346
+ // Source-buffer parse (the AGENTS.md §2 hot path).
347
+ property: 10,
348
+ quantity: 10,
349
+ };
350
+
351
+ export function orderRulesByCost(rules: readonly FilterRule[]): FilterRule[] {
352
+ // Stable sort — equal-cost rules retain their authored order so the
353
+ // user's intent is visible in debug logs / SQL preview.
354
+ return rules
355
+ .map((r, i) => ({ r, i, cost: RULE_COST[r.kind] }))
356
+ .sort((a, b) => a.cost - b.cost || a.i - b.i)
357
+ .map((x) => x.r);
358
+ }
359
+
360
+ // ── Per-entity inner loop ────────────────────────────────────────────────────
361
+
362
+ interface EvalContext {
363
+ store: IfcDataStore;
364
+ table: IfcDataStore['entities'];
365
+ options: EvaluateOptions;
366
+ hasPropertyRule: boolean;
367
+ hasQuantityRule: boolean;
368
+ }
369
+
370
+ function evaluateOneEntity(
371
+ ctx: EvalContext,
372
+ expressId: number,
373
+ orderedRules: readonly FilterRule[],
374
+ combinator: Combinator,
375
+ ): boolean {
376
+ // Lazy pset/qto reads — only invoked when an ordered rule for that
377
+ // family actually needs the data. Cheap-first ordering means cheap
378
+ // rules check first; AND short-circuit on a cheap miss skips the
379
+ // parse entirely.
380
+ let psetCache: PsetRows | null = null;
381
+ let qtyCache: QtyRows | null = null;
382
+ const psetsFor = (): PsetRows => {
383
+ if (!psetCache) psetCache = flattenPsets(extractPropertiesOnDemand(ctx.store, expressId));
384
+ return psetCache;
385
+ };
386
+ const qtysFor = (): QtyRows => {
387
+ if (!qtyCache) qtyCache = flattenQtys(extractQuantitiesOnDemand(ctx.store, expressId));
388
+ return qtyCache;
389
+ };
390
+
391
+ const ruleResults: boolean[] = [];
392
+ for (const rule of orderedRules) {
393
+ const result = evaluateRule(
394
+ rule,
395
+ ctx,
396
+ expressId,
397
+ ctx.hasPropertyRule ? psetsFor : null,
398
+ ctx.hasQuantityRule ? qtysFor : null,
399
+ );
400
+ ruleResults.push(result);
401
+ if (combinator === 'AND' && !result) return false;
402
+ if (combinator === 'OR' && result) return true;
403
+ }
404
+ return combineRuleResults(combinator, ruleResults);
405
+ }
406
+
407
+ function evaluateRule(
408
+ rule: FilterRule,
409
+ ctx: EvalContext,
410
+ expressId: number,
411
+ psetsFor: (() => PsetRows) | null,
412
+ qtysFor: (() => QtyRows) | null,
413
+ ): boolean {
414
+ switch (rule.kind) {
415
+ case 'storey': {
416
+ const storeyName = ctx.options.storeyNameOf?.(expressId)
417
+ ?? defaultStoreyName(ctx.store, expressId);
418
+ return setOpMatches(rule.op, storeyName, rule.values);
419
+ }
420
+ case 'ifcType': {
421
+ return setOpMatches(rule.op, ctx.table.getTypeName(expressId), rule.values);
422
+ }
423
+ case 'predefinedType': {
424
+ const pt = ctx.options.predefinedTypeOf?.(expressId) ?? '';
425
+ return setOpMatches(rule.op, pt, rule.values);
426
+ }
427
+ case 'name': {
428
+ return stringOpMatches(rule.op, ctx.table.getName(expressId), rule.value);
429
+ }
430
+ case 'property': {
431
+ if (!psetsFor) return false;
432
+ return matchPropertyRule(rule, psetsFor());
433
+ }
434
+ case 'quantity': {
435
+ if (!qtysFor) return false;
436
+ return matchQuantityRule(rule, qtysFor());
437
+ }
438
+ }
439
+ }
440
+
441
+ function buildResult(modelId: string, ctx: EvalContext, expressId: number): FilteredElement {
442
+ return {
443
+ modelId,
444
+ expressId,
445
+ ifcType: ctx.table.getTypeName(expressId),
446
+ name: ctx.table.getName(expressId),
447
+ globalId: ctx.table.getGlobalId(expressId),
448
+ };
449
+ }
450
+
451
+ // ── Helpers ──────────────────────────────────────────────────────────────────
452
+
453
+ /** Return the raw expressId column as the iteration source. The
454
+ * per-entity loops already skip empty rows (`if (!expressId) continue`)
455
+ * so the typed-array shape is correctness-safe AND lets the federated
456
+ * entry report a `total` rather than streaming with `total = -1`. */
457
+ function iterateAllExpressIds(store: IfcDataStore): ArrayLike<number> {
458
+ return store.entities.expressId;
459
+ }
460
+
461
+ function isArrayLike(value: unknown): value is ArrayLike<number> {
462
+ return (
463
+ typeof value === 'object' &&
464
+ value !== null &&
465
+ typeof (value as { length?: unknown }).length === 'number'
466
+ );
467
+ }
468
+
469
+ /** Try to materialise an iterable into an array so the federated loop
470
+ * can chunk-iterate by index (faster + provides a `total` for progress).
471
+ * Returns null when the source is unknown-size and we'd rather stream. */
472
+ function materialiseIterable(
473
+ source: ArrayLike<number> | Iterable<number>,
474
+ ): ArrayLike<number> | null {
475
+ if (Array.isArray(source)) return source;
476
+ if (isArrayLike(source)) return source;
477
+ if (source instanceof Set) return Array.from(source);
478
+ // Generators / unknown-size iterables: keep streaming. The federated
479
+ // loop falls back to the iterator branch with a buffered chunk count.
480
+ return null;
481
+ }
482
+
483
+ function throwAbort(signal: AbortSignal): never {
484
+ // Match the shape DOM throws on AbortController.signal.aborted reads —
485
+ // callers can `instanceof DOMException && err.name === 'AbortError'`.
486
+ throw new DOMException(
487
+ signal.reason instanceof Error ? signal.reason.message : 'evaluateFilterRules aborted',
488
+ 'AbortError',
489
+ );
490
+ }
491
+
492
+ /** Yield control to the event loop. Mirrors `tier1-index.ts` so we
493
+ * don't pin the Node test runner — `scheduler.yield` (browsers /
494
+ * Node 22+) and `setImmediate` (Node fallback) are preferred over
495
+ * the MessageChannel trick because the latter requires explicit
496
+ * port closure to release the loop reference. */
497
+ function yieldToEventLoop(): Promise<void> {
498
+ const maybeScheduler = (globalThis as typeof globalThis & {
499
+ scheduler?: { yield?: () => Promise<void> };
500
+ }).scheduler;
501
+ if (typeof maybeScheduler?.yield === 'function') return maybeScheduler.yield();
502
+ if (typeof setImmediate === 'function') {
503
+ return new Promise<void>((resolve) => { setImmediate(() => resolve()); });
504
+ }
505
+ return new Promise<void>((resolve) => {
506
+ const channel = new MessageChannel();
507
+ channel.port1.onmessage = () => {
508
+ channel.port1.close();
509
+ channel.port2.close();
510
+ resolve();
511
+ };
512
+ channel.port2.postMessage(null);
513
+ });
514
+ }
515
+
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
+ // ── Exposed for tests ────────────────────────────────────────────────────────
601
+
602
+ export const __internal = {
603
+ flattenPsets,
604
+ flattenQtys,
605
+ stringifyValue,
606
+ matchPropertyRule,
607
+ matchQuantityRule,
608
+ orderRulesByCost,
609
+ selectIterationSource,
610
+ };