@ifc-lite/viewer 1.17.6 → 1.19.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 +20 -15
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +949 -0
- package/dist/assets/{basketViewActivator-86rgogji.js → basketViewActivator-RZy5c3Td.js} +1 -1
- package/dist/assets/decode-worker-Collf_X_.js +1320 -0
- package/dist/assets/{exporters-CcPS9MK5.js → exporters-BraHBeoi.js} +4194 -3025
- package/dist/assets/{geometry.worker-BFUYA08u.js → geometry.worker-DQEZB2rB.js} +1 -1
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-0XpVr_S5.css +1 -0
- package/dist/assets/{index-Bfms9I4A.js → index-BOi3BuUI.js} +46423 -31181
- package/dist/assets/index-XwKzDuw6.js +22 -0
- package/dist/assets/{native-bridge-DUyLCMZS.js → native-bridge-CpBeOPQa.js} +1 -1
- package/dist/assets/sandbox-Baez7n-t.js +9682 -0
- package/dist/assets/{server-client-BuZK7OST.js → server-client-BB6cMAXE.js} +1 -1
- package/dist/assets/{wasm-bridge-JsqEGDV8.js → wasm-bridge-CAYCUHbE.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +11 -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 +73 -12
- package/src/components/viewer/PointCloudPanel.tsx +174 -0
- 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 +29 -2
- package/src/components/viewer/ViewportContainer.tsx +45 -5
- package/src/components/viewer/ViewportOverlays.tsx +13 -2
- 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 +581 -0
- package/src/components/viewer/useDuplicateShortcut.ts +77 -0
- package/src/components/viewer/useMouseControls.ts +9 -1
- package/src/components/viewer/usePointCloudLifecycle.ts +64 -0
- package/src/components/viewer/usePointCloudSync.ts +98 -0
- package/src/hooks/ingest/pointCloudIngest.ts +391 -0
- package/src/hooks/ingest/viewerModelIngest.ts +32 -3
- package/src/hooks/useIfcFederation.ts +72 -3
- package/src/hooks/useIfcLoader.ts +89 -13
- 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 +8 -3
- 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 +79 -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/pointCloudSlice.ts +102 -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/types.ts +7 -0
- package/src/store.ts +14 -0
- package/vite.config.ts +1 -0
- package/dist/assets/ifc-lite_bg-BINvzoCP.wasm +0 -0
- package/dist/assets/index-_bfZsDCC.css +0 -1
- package/dist/assets/sandbox-C8575tul.js +0 -5951
|
@@ -0,0 +1,514 @@
|
|
|
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
|
+
* SearchModalFilter — chip-based structured-rule filtering.
|
|
7
|
+
*
|
|
8
|
+
* Owns the run lifecycle: assembles per-model arguments, folds the
|
|
9
|
+
* inline search query into a Tier-1/Tier-0 candidate set when present,
|
|
10
|
+
* runs the path-B evaluator (chunked + cancellable + progress), and
|
|
11
|
+
* renders the result table. The chip-editing UI lives in
|
|
12
|
+
* `SearchModalFilterBuilder`; that's a UI-only sibling that reads /
|
|
13
|
+
* writes the same slice state.
|
|
14
|
+
*
|
|
15
|
+
* No DuckDB. No SQL editor. The path-B evaluator handles 4M-entity
|
|
16
|
+
* models via `selectIterationSource` (byType / byStorey index
|
|
17
|
+
* prefilter under AND + op:in), cheap-first per-entity rule ordering,
|
|
18
|
+
* and async chunked yielding — so a single Run button is the whole
|
|
19
|
+
* story.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
23
|
+
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
24
|
+
import { Play, AlertCircle, Download } from 'lucide-react';
|
|
25
|
+
import { useShallow } from 'zustand/react/shallow';
|
|
26
|
+
import { useViewerStore } from '@/store';
|
|
27
|
+
import { toGlobalIdFromModels } from '@/store/globalId';
|
|
28
|
+
import { Button } from '@/components/ui/button';
|
|
29
|
+
import {
|
|
30
|
+
DropdownMenu,
|
|
31
|
+
DropdownMenuTrigger,
|
|
32
|
+
DropdownMenuContent,
|
|
33
|
+
DropdownMenuItem,
|
|
34
|
+
} from '@/components/ui/dropdown-menu';
|
|
35
|
+
import { cn } from '@/lib/utils';
|
|
36
|
+
import { evaluateFilterRulesFederated } from '@/lib/search/filter-evaluate';
|
|
37
|
+
import { runTier0Scan, type ScanModel } from '@/lib/search/tier0-scan';
|
|
38
|
+
import { queryTier1Indexes, type Tier1Index } from '@/lib/search/tier1-index';
|
|
39
|
+
import { downloadResult } from '@/lib/search/result-export';
|
|
40
|
+
import { SearchModalFilterBuilder } from './SearchModal.filter.builder';
|
|
41
|
+
|
|
42
|
+
/** Rows per virtualizer page — tuned for the result table row height. */
|
|
43
|
+
const RESULT_ROW_HEIGHT = 28;
|
|
44
|
+
const TEXT_HIT_LIMIT = 50_000;
|
|
45
|
+
const FILTER_CHUNK_SIZE = 20_000;
|
|
46
|
+
const DEFAULT_LIMIT = 5_000;
|
|
47
|
+
|
|
48
|
+
/** Columns we treat as "selection keys" — clicking a row routes the
|
|
49
|
+
* value through the viewer's selection system. */
|
|
50
|
+
const SELECTION_COLUMNS = ['express_id', 'entity_id'] as const;
|
|
51
|
+
|
|
52
|
+
export function SearchModalFilter() {
|
|
53
|
+
const {
|
|
54
|
+
searchFilter,
|
|
55
|
+
searchFilterResult,
|
|
56
|
+
searchFilterRunning,
|
|
57
|
+
searchFilterError,
|
|
58
|
+
searchQuery,
|
|
59
|
+
searchIndexes,
|
|
60
|
+
setSearchFilterRunning,
|
|
61
|
+
setSearchFilterResult,
|
|
62
|
+
setSearchFilterError,
|
|
63
|
+
models,
|
|
64
|
+
activeModelId,
|
|
65
|
+
setSelectedEntity,
|
|
66
|
+
setSelectedEntityId,
|
|
67
|
+
cameraCallbacks,
|
|
68
|
+
} = useViewerStore(
|
|
69
|
+
useShallow((s) => ({
|
|
70
|
+
searchFilter: s.searchFilter,
|
|
71
|
+
searchFilterResult: s.searchFilterResult,
|
|
72
|
+
searchFilterRunning: s.searchFilterRunning,
|
|
73
|
+
searchFilterError: s.searchFilterError,
|
|
74
|
+
searchQuery: s.searchQuery,
|
|
75
|
+
searchIndexes: s.searchIndexes,
|
|
76
|
+
setSearchFilterRunning: s.setSearchFilterRunning,
|
|
77
|
+
setSearchFilterResult: s.setSearchFilterResult,
|
|
78
|
+
setSearchFilterError: s.setSearchFilterError,
|
|
79
|
+
models: s.models,
|
|
80
|
+
activeModelId: s.activeModelId,
|
|
81
|
+
setSelectedEntity: s.setSelectedEntity,
|
|
82
|
+
setSelectedEntityId: s.setSelectedEntityId,
|
|
83
|
+
cameraCallbacks: s.cameraCallbacks,
|
|
84
|
+
})),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const activeModel = activeModelId ? models.get(activeModelId) : undefined;
|
|
88
|
+
const activeStore = activeModel?.ifcDataStore ?? null;
|
|
89
|
+
const multiModel = models.size > 1;
|
|
90
|
+
|
|
91
|
+
// ── Run lifecycle: progress, cancel, limit-hit badge ──────────────────
|
|
92
|
+
const runController = useRef<AbortController | null>(null);
|
|
93
|
+
const [progress, setProgress] = useState<{ scanned: number; total: number } | null>(null);
|
|
94
|
+
const [limitHit, setLimitHit] = useState<number | null>(null);
|
|
95
|
+
|
|
96
|
+
const runFilter = useCallback(async () => {
|
|
97
|
+
if (searchFilterRunning) return;
|
|
98
|
+
if (searchFilter.rules.length === 0) {
|
|
99
|
+
setSearchFilterError('Add at least one rule before running.');
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
runController.current?.abort();
|
|
104
|
+
const controller = new AbortController();
|
|
105
|
+
runController.current = controller;
|
|
106
|
+
|
|
107
|
+
setSearchFilterRunning(true);
|
|
108
|
+
setSearchFilterError(null);
|
|
109
|
+
setLimitHit(null);
|
|
110
|
+
setProgress({ scanned: 0, total: 0 });
|
|
111
|
+
|
|
112
|
+
const start = performance.now();
|
|
113
|
+
try {
|
|
114
|
+
const modelArgs: Array<{ id: string; store: typeof activeStore }> = [];
|
|
115
|
+
for (const m of models.values()) {
|
|
116
|
+
if (m.ifcDataStore) modelArgs.push({ id: m.id, store: m.ifcDataStore });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Fold the inline search query in as a Tier-1/Tier-0 candidate
|
|
120
|
+
// set when present. Empty query → no narrowing (full scan with
|
|
121
|
+
// index prefilter applied inside the evaluator).
|
|
122
|
+
const trimmedQuery = searchQuery.trim();
|
|
123
|
+
let candidatesByModel: Map<string, Iterable<number>> | undefined;
|
|
124
|
+
if (trimmedQuery.length > 0) {
|
|
125
|
+
const t0Models: ScanModel[] = [];
|
|
126
|
+
const t1Indexes: Tier1Index[] = [];
|
|
127
|
+
for (const m of modelArgs) {
|
|
128
|
+
const rec = searchIndexes.get(m.id);
|
|
129
|
+
if (rec?.status === 'ready' && rec.index) {
|
|
130
|
+
t1Indexes.push(rec.index);
|
|
131
|
+
} else {
|
|
132
|
+
t0Models.push({ id: m.id, ifcDataStore: m.store });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const t1Hits = t1Indexes.length > 0
|
|
136
|
+
? queryTier1Indexes(t1Indexes, trimmedQuery, { limit: TEXT_HIT_LIMIT })
|
|
137
|
+
: [];
|
|
138
|
+
const t0Hits = t0Models.length > 0
|
|
139
|
+
? runTier0Scan(t0Models, trimmedQuery, { limit: TEXT_HIT_LIMIT })
|
|
140
|
+
: [];
|
|
141
|
+
const grouped = new Map<string, Set<number>>();
|
|
142
|
+
for (const hit of t1Hits.concat(t0Hits)) {
|
|
143
|
+
let bucket = grouped.get(hit.modelId);
|
|
144
|
+
if (!bucket) { bucket = new Set(); grouped.set(hit.modelId, bucket); }
|
|
145
|
+
bucket.add(hit.expressId);
|
|
146
|
+
}
|
|
147
|
+
candidatesByModel = new Map();
|
|
148
|
+
for (const [id, set] of grouped) candidatesByModel.set(id, set);
|
|
149
|
+
for (const m of modelArgs) {
|
|
150
|
+
// Models with no text hits get an empty candidate so structured
|
|
151
|
+
// rules can't slip through under intersection semantics.
|
|
152
|
+
if (!candidatesByModel.has(m.id)) candidatesByModel.set(m.id, []);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const limit = searchFilter.limit > 0 ? searchFilter.limit : DEFAULT_LIMIT;
|
|
157
|
+
const matched = await evaluateFilterRulesFederated(
|
|
158
|
+
modelArgs,
|
|
159
|
+
searchFilter.rules,
|
|
160
|
+
searchFilter.combinator,
|
|
161
|
+
{
|
|
162
|
+
limit,
|
|
163
|
+
chunkSize: FILTER_CHUNK_SIZE,
|
|
164
|
+
candidateExpressIdsByModel: candidatesByModel,
|
|
165
|
+
signal: controller.signal,
|
|
166
|
+
onProgress: (scanned, total) => setProgress({ scanned, total }),
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const multi = modelArgs.length > 1;
|
|
171
|
+
const columns = multi
|
|
172
|
+
? ['express_id', 'global_id', 'name', 'type', 'model_id']
|
|
173
|
+
: ['express_id', 'global_id', 'name', 'type'];
|
|
174
|
+
const rows: unknown[][] = matched.map((m) =>
|
|
175
|
+
multi
|
|
176
|
+
? [m.expressId, m.globalId, m.name, m.ifcType, m.modelId]
|
|
177
|
+
: [m.expressId, m.globalId, m.name, m.ifcType],
|
|
178
|
+
);
|
|
179
|
+
setSearchFilterResult({
|
|
180
|
+
columns,
|
|
181
|
+
rows,
|
|
182
|
+
runMs: Math.round(performance.now() - start),
|
|
183
|
+
});
|
|
184
|
+
if (matched.length >= limit) setLimitHit(limit);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (err instanceof DOMException && err.name === 'AbortError') return;
|
|
187
|
+
setSearchFilterError(err instanceof Error ? err.message : String(err));
|
|
188
|
+
} finally {
|
|
189
|
+
if (runController.current === controller) {
|
|
190
|
+
runController.current = null;
|
|
191
|
+
setSearchFilterRunning(false);
|
|
192
|
+
setProgress(null);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}, [
|
|
196
|
+
models,
|
|
197
|
+
searchFilter,
|
|
198
|
+
searchFilterRunning,
|
|
199
|
+
searchIndexes,
|
|
200
|
+
searchQuery,
|
|
201
|
+
setSearchFilterError,
|
|
202
|
+
setSearchFilterResult,
|
|
203
|
+
setSearchFilterRunning,
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
const cancelFilter = useCallback(() => {
|
|
207
|
+
runController.current?.abort();
|
|
208
|
+
}, []);
|
|
209
|
+
|
|
210
|
+
// Cancel any in-flight run when the modal unmounts so background
|
|
211
|
+
// chunked work doesn't keep ticking after close.
|
|
212
|
+
useEffect(() => () => {
|
|
213
|
+
runController.current?.abort();
|
|
214
|
+
}, []);
|
|
215
|
+
|
|
216
|
+
// Locate the model_id column (only present in federated runs) — same
|
|
217
|
+
// routing rule as before: known column → use that model's id space.
|
|
218
|
+
const modelIdColumnIndex = useMemo(() => {
|
|
219
|
+
const cols = searchFilterResult?.columns;
|
|
220
|
+
if (!cols) return -1;
|
|
221
|
+
return cols.indexOf('model_id');
|
|
222
|
+
}, [searchFilterResult]);
|
|
223
|
+
|
|
224
|
+
const selectionKeyIndex = useMemo(() => {
|
|
225
|
+
const cols = searchFilterResult?.columns;
|
|
226
|
+
if (!cols) return -1;
|
|
227
|
+
for (const candidate of SELECTION_COLUMNS) {
|
|
228
|
+
const i = cols.indexOf(candidate);
|
|
229
|
+
if (i >= 0) return i;
|
|
230
|
+
}
|
|
231
|
+
return -1;
|
|
232
|
+
}, [searchFilterResult]);
|
|
233
|
+
|
|
234
|
+
const handleRowClick = useCallback((row: unknown[]) => {
|
|
235
|
+
if (selectionKeyIndex < 0) return;
|
|
236
|
+
const rowModelId = modelIdColumnIndex >= 0 && typeof row[modelIdColumnIndex] === 'string'
|
|
237
|
+
? (row[modelIdColumnIndex] as string)
|
|
238
|
+
: activeModelId;
|
|
239
|
+
if (!rowModelId) return;
|
|
240
|
+
const raw = row[selectionKeyIndex];
|
|
241
|
+
const expressId = typeof raw === 'number'
|
|
242
|
+
? raw
|
|
243
|
+
: typeof raw === 'string' && !Number.isNaN(Number(raw))
|
|
244
|
+
? Number(raw)
|
|
245
|
+
: null;
|
|
246
|
+
if (expressId === null || expressId <= 0) return;
|
|
247
|
+
const globalId = toGlobalIdFromModels(models, rowModelId, expressId);
|
|
248
|
+
setSelectedEntityId(globalId);
|
|
249
|
+
setSelectedEntity({ modelId: rowModelId, expressId });
|
|
250
|
+
if (cameraCallbacks.frameSelection) {
|
|
251
|
+
window.setTimeout(() => cameraCallbacks.frameSelection?.(), 50);
|
|
252
|
+
}
|
|
253
|
+
}, [activeModelId, cameraCallbacks, models, modelIdColumnIndex, selectionKeyIndex, setSelectedEntity, setSelectedEntityId]);
|
|
254
|
+
|
|
255
|
+
const handleExport = useCallback((format: 'csv' | 'json') => {
|
|
256
|
+
if (!searchFilterResult || searchFilterResult.rows.length === 0) return;
|
|
257
|
+
downloadResult(searchFilterResult, format);
|
|
258
|
+
}, [searchFilterResult]);
|
|
259
|
+
|
|
260
|
+
if (!activeStore) {
|
|
261
|
+
return (
|
|
262
|
+
<div className="flex flex-1 items-center justify-center p-8 text-center text-sm text-muted-foreground">
|
|
263
|
+
Load an IFC file first — the filter runs against the active model's data.
|
|
264
|
+
</div>
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const canRun = searchFilter.rules.length > 0;
|
|
269
|
+
|
|
270
|
+
return (
|
|
271
|
+
<div className="flex flex-1 min-h-0 flex-col">
|
|
272
|
+
{/* ── Builder (chip palette) ─────────────────────────────────────── */}
|
|
273
|
+
<div className="overflow-y-auto border-b">
|
|
274
|
+
<SearchModalFilterBuilder />
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
{/* ── Run bar: status · run/cancel · export ──────────────────────── */}
|
|
278
|
+
<div className="flex items-center gap-2 border-b px-3 py-2 text-[11px]">
|
|
279
|
+
<RuleSummary
|
|
280
|
+
ruleCount={searchFilter.rules.length}
|
|
281
|
+
combinator={searchFilter.combinator}
|
|
282
|
+
limit={searchFilter.limit}
|
|
283
|
+
/>
|
|
284
|
+
|
|
285
|
+
{progress && progress.total > 0 && (
|
|
286
|
+
<span className="inline-flex items-center gap-1.5 text-muted-foreground">
|
|
287
|
+
<span className="relative h-1.5 w-32 overflow-hidden rounded bg-zinc-200 dark:bg-zinc-800">
|
|
288
|
+
<span
|
|
289
|
+
className="absolute left-0 top-0 h-full bg-primary transition-[width] duration-100"
|
|
290
|
+
style={{
|
|
291
|
+
width: `${Math.min(100, Math.round((progress.scanned / progress.total) * 100))}%`,
|
|
292
|
+
}}
|
|
293
|
+
/>
|
|
294
|
+
</span>
|
|
295
|
+
<span className="font-mono">
|
|
296
|
+
{progress.scanned.toLocaleString()} / {progress.total.toLocaleString()}
|
|
297
|
+
</span>
|
|
298
|
+
</span>
|
|
299
|
+
)}
|
|
300
|
+
{progress && progress.total <= 0 && (
|
|
301
|
+
<span className="font-mono text-muted-foreground">
|
|
302
|
+
scanned {progress.scanned.toLocaleString()}
|
|
303
|
+
</span>
|
|
304
|
+
)}
|
|
305
|
+
|
|
306
|
+
{!searchFilterRunning && limitHit !== null && (
|
|
307
|
+
<span
|
|
308
|
+
className="rounded bg-amber-100 px-1.5 py-0.5 text-[10px] font-semibold text-amber-900 dark:bg-amber-900/40 dark:text-amber-200"
|
|
309
|
+
title="Increase the limit or narrow the rules to see more matches"
|
|
310
|
+
>
|
|
311
|
+
limited to {limitHit.toLocaleString()}
|
|
312
|
+
</span>
|
|
313
|
+
)}
|
|
314
|
+
|
|
315
|
+
{searchFilterResult && !searchFilterRunning && (
|
|
316
|
+
<span className="text-muted-foreground">
|
|
317
|
+
⏱ {searchFilterResult.runMs} ms · {searchFilterResult.rows.length.toLocaleString()} rows
|
|
318
|
+
</span>
|
|
319
|
+
)}
|
|
320
|
+
|
|
321
|
+
<div className="ml-auto flex items-center gap-1.5">
|
|
322
|
+
<DropdownMenu>
|
|
323
|
+
<DropdownMenuTrigger asChild>
|
|
324
|
+
<Button
|
|
325
|
+
variant="ghost"
|
|
326
|
+
size="sm"
|
|
327
|
+
disabled={!searchFilterResult || searchFilterResult.rows.length === 0}
|
|
328
|
+
className="h-7 gap-1 text-xs"
|
|
329
|
+
title="Export results"
|
|
330
|
+
>
|
|
331
|
+
<Download className="h-3 w-3" /> Export
|
|
332
|
+
</Button>
|
|
333
|
+
</DropdownMenuTrigger>
|
|
334
|
+
<DropdownMenuContent align="end">
|
|
335
|
+
<DropdownMenuItem onSelect={() => handleExport('csv')}>
|
|
336
|
+
Download CSV
|
|
337
|
+
</DropdownMenuItem>
|
|
338
|
+
<DropdownMenuItem onSelect={() => handleExport('json')}>
|
|
339
|
+
Download JSON
|
|
340
|
+
</DropdownMenuItem>
|
|
341
|
+
</DropdownMenuContent>
|
|
342
|
+
</DropdownMenu>
|
|
343
|
+
|
|
344
|
+
{searchFilterRunning ? (
|
|
345
|
+
<Button
|
|
346
|
+
variant="outline"
|
|
347
|
+
size="sm"
|
|
348
|
+
onClick={cancelFilter}
|
|
349
|
+
className="h-7 gap-1 text-xs"
|
|
350
|
+
>
|
|
351
|
+
Cancel
|
|
352
|
+
</Button>
|
|
353
|
+
) : (
|
|
354
|
+
<Button
|
|
355
|
+
variant="default"
|
|
356
|
+
size="sm"
|
|
357
|
+
onClick={runFilter}
|
|
358
|
+
disabled={!canRun}
|
|
359
|
+
className="h-7 gap-1 text-xs"
|
|
360
|
+
title={canRun ? 'Run the filter against every loaded model' : 'Add a rule first'}
|
|
361
|
+
>
|
|
362
|
+
<Play className="h-3 w-3" />
|
|
363
|
+
Run
|
|
364
|
+
</Button>
|
|
365
|
+
)}
|
|
366
|
+
</div>
|
|
367
|
+
</div>
|
|
368
|
+
|
|
369
|
+
{multiModel && (
|
|
370
|
+
<div className="border-b bg-zinc-50 px-3 py-1.5 text-[11px] text-muted-foreground dark:bg-zinc-900/30">
|
|
371
|
+
Filtering across all {models.size} loaded models. Click any row to
|
|
372
|
+
select that element in the right model.
|
|
373
|
+
</div>
|
|
374
|
+
)}
|
|
375
|
+
|
|
376
|
+
{/* ── Result area: error stacks above the last good table ────────── */}
|
|
377
|
+
{searchFilterError && <FilterErrorBox raw={searchFilterError} />}
|
|
378
|
+
<FilterResultTable
|
|
379
|
+
result={searchFilterResult}
|
|
380
|
+
selectionKeyIndex={selectionKeyIndex}
|
|
381
|
+
onRowClick={handleRowClick}
|
|
382
|
+
/>
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── Sub-components ────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
function RuleSummary({
|
|
390
|
+
ruleCount,
|
|
391
|
+
combinator,
|
|
392
|
+
limit,
|
|
393
|
+
}: {
|
|
394
|
+
ruleCount: number;
|
|
395
|
+
combinator: 'AND' | 'OR';
|
|
396
|
+
limit: number;
|
|
397
|
+
}) {
|
|
398
|
+
if (ruleCount === 0) {
|
|
399
|
+
return (
|
|
400
|
+
<span className="text-muted-foreground italic">No rules — add one to run.</span>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
return (
|
|
404
|
+
<span className="text-muted-foreground">
|
|
405
|
+
<span className="font-mono text-foreground">{ruleCount}</span>{' '}
|
|
406
|
+
rule{ruleCount === 1 ? '' : 's'}
|
|
407
|
+
<span className="mx-1">·</span>
|
|
408
|
+
<span className="font-mono">{combinator}</span>
|
|
409
|
+
<span className="mx-1">·</span>
|
|
410
|
+
limit{' '}
|
|
411
|
+
<span className="font-mono text-foreground">
|
|
412
|
+
{limit > 0 ? limit.toLocaleString() : '∞'}
|
|
413
|
+
</span>
|
|
414
|
+
</span>
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function FilterErrorBox({ raw }: { raw: string }) {
|
|
419
|
+
return (
|
|
420
|
+
<div className="border-b bg-red-50/50 px-4 py-3 dark:bg-red-950/20">
|
|
421
|
+
<div className="flex items-start gap-2">
|
|
422
|
+
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-red-600 dark:text-red-400" />
|
|
423
|
+
<div className="min-w-0 flex-1 text-xs">
|
|
424
|
+
<div className="font-semibold text-red-900 dark:text-red-200">Filter failed</div>
|
|
425
|
+
<div className="mt-1 break-words text-red-800 dark:text-red-300">{raw}</div>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
</div>
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
interface FilterResultTableProps {
|
|
433
|
+
result: { columns: string[]; rows: unknown[][] } | null;
|
|
434
|
+
selectionKeyIndex: number;
|
|
435
|
+
onRowClick: (row: unknown[]) => void;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function FilterResultTable({ result, selectionKeyIndex, onRowClick }: FilterResultTableProps) {
|
|
439
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
440
|
+
const virtualizer = useVirtualizer({
|
|
441
|
+
count: result?.rows.length ?? 0,
|
|
442
|
+
getScrollElement: () => scrollRef.current,
|
|
443
|
+
estimateSize: () => RESULT_ROW_HEIGHT,
|
|
444
|
+
overscan: 20,
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
if (!result) {
|
|
448
|
+
return (
|
|
449
|
+
<div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">
|
|
450
|
+
Add rules and click Run.
|
|
451
|
+
</div>
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
if (result.rows.length === 0) {
|
|
456
|
+
return (
|
|
457
|
+
<div className="flex flex-1 items-center justify-center text-xs text-muted-foreground">
|
|
458
|
+
0 matches — broaden the rules, lower the limit, or try OR.
|
|
459
|
+
</div>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return (
|
|
464
|
+
<div className="flex flex-1 min-h-0 flex-col">
|
|
465
|
+
<div className="flex items-center border-b bg-zinc-50/50 px-3 py-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground dark:bg-zinc-900/30">
|
|
466
|
+
{result.columns.map((c) => (
|
|
467
|
+
<div key={c} className="flex-1 truncate px-2 font-mono">
|
|
468
|
+
{c}
|
|
469
|
+
</div>
|
|
470
|
+
))}
|
|
471
|
+
</div>
|
|
472
|
+
<div ref={scrollRef} className="flex-1 overflow-auto">
|
|
473
|
+
<div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
|
|
474
|
+
{virtualizer.getVirtualItems().map((vRow) => {
|
|
475
|
+
const row = result.rows[vRow.index];
|
|
476
|
+
const clickable = selectionKeyIndex >= 0;
|
|
477
|
+
return (
|
|
478
|
+
<div
|
|
479
|
+
key={vRow.key}
|
|
480
|
+
style={{
|
|
481
|
+
position: 'absolute',
|
|
482
|
+
top: 0,
|
|
483
|
+
left: 0,
|
|
484
|
+
width: '100%',
|
|
485
|
+
height: vRow.size,
|
|
486
|
+
transform: `translateY(${vRow.start}px)`,
|
|
487
|
+
}}
|
|
488
|
+
className={cn(
|
|
489
|
+
'flex items-center border-b border-zinc-100 px-3 text-[11px] dark:border-zinc-900',
|
|
490
|
+
clickable && 'cursor-pointer hover:bg-zinc-100 dark:hover:bg-zinc-800',
|
|
491
|
+
)}
|
|
492
|
+
onClick={() => clickable && onRowClick(row)}
|
|
493
|
+
>
|
|
494
|
+
{result.columns.map((_, i) => (
|
|
495
|
+
<div key={i} className="flex-1 truncate px-2 font-mono">
|
|
496
|
+
{formatCell(row[i])}
|
|
497
|
+
</div>
|
|
498
|
+
))}
|
|
499
|
+
</div>
|
|
500
|
+
);
|
|
501
|
+
})}
|
|
502
|
+
</div>
|
|
503
|
+
</div>
|
|
504
|
+
</div>
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function formatCell(v: unknown): string {
|
|
509
|
+
if (v === null || v === undefined) return '';
|
|
510
|
+
if (typeof v === 'boolean') return v ? 'true' : 'false';
|
|
511
|
+
if (typeof v === 'bigint') return v.toString();
|
|
512
|
+
if (typeof v === 'object') return JSON.stringify(v);
|
|
513
|
+
return String(v);
|
|
514
|
+
}
|