@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.
- package/.turbo/turbo-build.log +17 -14
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +513 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-Cm1QEk_R.js} +1 -1
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-B_OBqIyD.js} +3235 -2648
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-xHHy-9DV.js} +1 -1
- package/dist/assets/{ifc-lite_bg-BINvzoCP.wasm → ifc-lite_bg-ADjKXSms.wasm} +0 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BKq-M3Mk.js} +44124 -30920
- package/dist/assets/index-COnQRuqY.css +1 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-SHXiQwFW.js} +1 -1
- package/dist/assets/sandbox-jez21HtV.js +9627 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-ncOQVNso.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-DyfBSB8z.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +10 -10
- package/src/apache-arrow.d.ts +30 -0
- package/src/components/viewer/AddElementPanel.tsx +758 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
- package/src/components/viewer/ChatPanel.tsx +64 -2
- package/src/components/viewer/CommandPalette.tsx +56 -7
- package/src/components/viewer/EntityContextMenu.tsx +168 -4
- package/src/components/viewer/ExportChangesButton.tsx +25 -5
- package/src/components/viewer/ExportDialog.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +69 -10
- package/src/components/viewer/PropertiesPanel.tsx +222 -22
- package/src/components/viewer/SearchInline.tsx +669 -0
- package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
- package/src/components/viewer/SearchModal.filter.tsx +514 -0
- package/src/components/viewer/SearchModal.text.tsx +388 -0
- package/src/components/viewer/SearchModal.tsx +235 -0
- package/src/components/viewer/ToolOverlays.tsx +5 -0
- package/src/components/viewer/ViewerLayout.tsx +24 -4
- package/src/components/viewer/Viewport.tsx +11 -1
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
- package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
- package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
- package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +1 -1
- package/src/components/viewer/lists/ListPanel.tsx +14 -21
- package/src/components/viewer/properties/RawStepCard.tsx +332 -0
- package/src/components/viewer/properties/RawStepRow.tsx +261 -0
- package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
- package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
- package/src/components/viewer/properties/raw-step-format.ts +193 -0
- package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
- package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
- package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
- package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
- package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
- package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
- package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
- package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
- package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
- package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
- package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
- package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
- package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
- package/src/components/viewer/schedule/generate-schedule.ts +648 -0
- package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
- package/src/components/viewer/schedule/schedule-animator.ts +488 -0
- package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
- package/src/components/viewer/schedule/schedule-selection.ts +163 -0
- package/src/components/viewer/schedule/schedule-utils.ts +223 -0
- package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
- package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
- package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
- package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
- package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +446 -0
- package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/hooks/useIfcLoader.ts +22 -10
- package/src/hooks/useKeyboardShortcuts.ts +25 -0
- package/src/hooks/useSandbox.ts +1 -1
- package/src/hooks/useSearchIndex.ts +125 -0
- package/src/index.css +66 -0
- package/src/lib/llm/system-prompt.test.ts +14 -0
- package/src/lib/llm/system-prompt.ts +102 -1
- package/src/lib/llm/types.ts +6 -0
- package/src/lib/recent-files.ts +38 -4
- package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
- package/src/lib/scripts/templates/construction-schedule.ts +223 -0
- package/src/lib/scripts/templates.ts +7 -0
- package/src/lib/search/common-ifc-types.ts +36 -0
- package/src/lib/search/filter-evaluate.test.ts +537 -0
- package/src/lib/search/filter-evaluate.ts +610 -0
- package/src/lib/search/filter-rules.test.ts +119 -0
- package/src/lib/search/filter-rules.ts +198 -0
- package/src/lib/search/filter-schema.test.ts +233 -0
- package/src/lib/search/filter-schema.ts +146 -0
- package/src/lib/search/recent-searches.test.ts +116 -0
- package/src/lib/search/recent-searches.ts +93 -0
- package/src/lib/search/result-export.test.ts +101 -0
- package/src/lib/search/result-export.ts +104 -0
- package/src/lib/search/saved-filters.test.ts +118 -0
- package/src/lib/search/saved-filters.ts +154 -0
- package/src/lib/search/tier0-scan.test.ts +196 -0
- package/src/lib/search/tier0-scan.ts +237 -0
- package/src/lib/search/tier1-index.test.ts +242 -0
- package/src/lib/search/tier1-index.ts +448 -0
- package/src/sdk/adapters/export-adapter.test.ts +434 -1
- package/src/sdk/adapters/export-adapter.ts +404 -1
- package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
- package/src/sdk/adapters/export-schedule-splice.ts +87 -0
- package/src/sdk/adapters/model-compat.ts +8 -2
- package/src/sdk/adapters/schedule-adapter.ts +73 -0
- package/src/sdk/adapters/store-adapter.ts +201 -0
- package/src/sdk/adapters/visibility-adapter.ts +3 -0
- package/src/sdk/local-backend.ts +16 -8
- package/src/services/desktop-export.ts +3 -1
- package/src/services/desktop-native-metadata.ts +41 -18
- package/src/services/file-dialog.ts +4 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/basketVisibleSet.ts +3 -0
- package/src/store/globalId.ts +4 -1
- package/src/store/index.ts +70 -1
- package/src/store/slices/addElementMeshes.ts +365 -0
- package/src/store/slices/addElementSlice.ts +275 -0
- package/src/store/slices/annotationsSlice.test.ts +133 -0
- package/src/store/slices/annotationsSlice.ts +251 -0
- package/src/store/slices/dataSlice.test.ts +23 -4
- package/src/store/slices/dataSlice.ts +1 -1
- package/src/store/slices/modelSlice.test.ts +67 -9
- package/src/store/slices/modelSlice.ts +39 -7
- package/src/store/slices/mutationSlice.ts +964 -3
- package/src/store/slices/overlayCompositor.test.ts +164 -0
- package/src/store/slices/overlaySlice.test.ts +93 -0
- package/src/store/slices/overlaySlice.ts +151 -0
- package/src/store/slices/pinboardSlice.test.ts +6 -1
- package/src/store/slices/playbackSlice.ts +128 -0
- package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
- package/src/store/slices/schedule-edit-helpers.ts +179 -0
- package/src/store/slices/scheduleSlice.test.ts +694 -0
- package/src/store/slices/scheduleSlice.ts +1330 -0
- package/src/store/slices/searchSlice.test.ts +342 -0
- package/src/store/slices/searchSlice.ts +341 -0
- package/src/store/slices/selectionSlice.test.ts +46 -0
- package/src/store/slices/selectionSlice.ts +20 -0
- package/src/store.ts +14 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- 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
|
+
};
|