@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,388 @@
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
+ * SearchModal.text — the Search tab content.
7
+ *
8
+ * Chip filters (field match type + per-model include) narrow the pool
9
+ * of results provided by the parent modal, which owns the scan + merge.
10
+ * The result list is virtualized via @tanstack/react-virtual so 5000
11
+ * rows render at constant cost. Batch actions route through the
12
+ * existing selection / visibility slices.
13
+ */
14
+
15
+ import { useCallback, useEffect, useMemo, useRef } from 'react';
16
+ import { useVirtualizer } from '@tanstack/react-virtual';
17
+ import { Crosshair, SquareX, ListChecks, Filter } from 'lucide-react';
18
+ import { useShallow } from 'zustand/react/shallow';
19
+ import { useViewerStore } from '@/store';
20
+ import { toGlobalIdFromModels } from '@/store/globalId';
21
+ import { cn } from '@/lib/utils';
22
+ import { Button } from '@/components/ui/button';
23
+ import { Badge } from '@/components/ui/badge';
24
+ import type { SearchResult } from '@/lib/search/tier0-scan';
25
+ import type { SearchFieldFilter } from '@/store/slices/searchSlice';
26
+
27
+ const ROW_HEIGHT = 36;
28
+
29
+ const FIELD_FILTERS: { value: SearchFieldFilter; label: string }[] = [
30
+ { value: 'all', label: 'All' },
31
+ { value: 'name', label: 'Name' },
32
+ { value: 'type', label: 'Type' },
33
+ { value: 'globalId', label: 'GUID' },
34
+ { value: 'description', label: 'Description' },
35
+ { value: 'objectType', label: 'ObjectType' },
36
+ ];
37
+
38
+ export interface SearchModalTextProps {
39
+ /** Full result pool from the parent modal (before filter chips). */
40
+ results: SearchResult[];
41
+ /** All modelIds currently loaded (for the model-filter chips). */
42
+ availableModelIds: readonly string[];
43
+ /** Close the parent modal — invoked on Enter-commit from a row. */
44
+ onClose: () => void;
45
+ }
46
+
47
+ export function SearchModalText({ results, availableModelIds, onClose }: SearchModalTextProps) {
48
+ const {
49
+ searchFieldFilter,
50
+ searchModelFilter,
51
+ searchQuery,
52
+ searchHighlightIndex,
53
+ selectedEntitiesSet,
54
+ models,
55
+ setSearchFieldFilter,
56
+ toggleSearchModelFilter,
57
+ clearSearchModelFilter,
58
+ setSearchHighlightIndex,
59
+ setSelectedEntity,
60
+ setSelectedEntityId,
61
+ addEntitiesToSelection,
62
+ toggleEntitySelection,
63
+ clearEntitySelection,
64
+ enterVimCycle,
65
+ cameraCallbacks,
66
+ } = useViewerStore(
67
+ useShallow((s) => ({
68
+ searchFieldFilter: s.searchFieldFilter,
69
+ searchModelFilter: s.searchModelFilter,
70
+ searchQuery: s.searchQuery,
71
+ searchHighlightIndex: s.searchHighlightIndex,
72
+ selectedEntitiesSet: s.selectedEntitiesSet,
73
+ models: s.models,
74
+ setSearchFieldFilter: s.setSearchFieldFilter,
75
+ toggleSearchModelFilter: s.toggleSearchModelFilter,
76
+ clearSearchModelFilter: s.clearSearchModelFilter,
77
+ setSearchHighlightIndex: s.setSearchHighlightIndex,
78
+ setSelectedEntity: s.setSelectedEntity,
79
+ setSelectedEntityId: s.setSelectedEntityId,
80
+ addEntitiesToSelection: s.addEntitiesToSelection,
81
+ toggleEntitySelection: s.toggleEntitySelection,
82
+ clearEntitySelection: s.clearEntitySelection,
83
+ enterVimCycle: s.enterVimCycle,
84
+ cameraCallbacks: s.cameraCallbacks,
85
+ })),
86
+ );
87
+
88
+ // Filter the result pool by the active chip selections.
89
+ const filtered = useMemo(() => {
90
+ return results.filter((r) => {
91
+ if (searchFieldFilter !== 'all' && r.matchField !== searchFieldFilter) return false;
92
+ if (searchModelFilter && !searchModelFilter.has(r.modelId)) return false;
93
+ return true;
94
+ });
95
+ }, [results, searchFieldFilter, searchModelFilter]);
96
+
97
+ // Virtualized list setup. `count` tracks filtered.length; scrollElement
98
+ // is the parent div the virtualizer measures.
99
+ const scrollRef = useRef<HTMLDivElement>(null);
100
+ const virtualizer = useVirtualizer({
101
+ count: filtered.length,
102
+ getScrollElement: () => scrollRef.current,
103
+ estimateSize: () => ROW_HEIGHT,
104
+ overscan: 10,
105
+ });
106
+
107
+ // Keep the highlight index in range as filtered results change.
108
+ useEffect(() => {
109
+ if (filtered.length === 0) {
110
+ if (searchHighlightIndex !== 0) setSearchHighlightIndex(0);
111
+ return;
112
+ }
113
+ if (searchHighlightIndex >= filtered.length) {
114
+ setSearchHighlightIndex(Math.max(0, filtered.length - 1));
115
+ }
116
+ }, [filtered, searchHighlightIndex, setSearchHighlightIndex]);
117
+
118
+ // Scroll the highlighted row into view when it moves.
119
+ useEffect(() => {
120
+ if (filtered.length === 0) return;
121
+ virtualizer.scrollToIndex(searchHighlightIndex, { align: 'auto' });
122
+ }, [searchHighlightIndex, filtered.length, virtualizer]);
123
+
124
+ /** Primary "click row" handler — selects + frames + enters vim cycle. */
125
+ const commit = useCallback(
126
+ (r: SearchResult, indexInFiltered: number) => {
127
+ const ref = { modelId: r.modelId, expressId: r.expressId };
128
+ const isLegacy = r.modelId === 'legacy' || r.modelId === '__legacy__' || models.size === 0;
129
+ const globalId = isLegacy ? r.expressId : toGlobalIdFromModels(models, r.modelId, r.expressId);
130
+ setSelectedEntityId(globalId);
131
+ setSelectedEntity(ref);
132
+ if (cameraCallbacks.frameSelection) {
133
+ window.setTimeout(() => cameraCallbacks.frameSelection?.(), 50);
134
+ }
135
+ enterVimCycle(searchQuery, filtered, indexInFiltered);
136
+ onClose();
137
+ },
138
+ [
139
+ cameraCallbacks,
140
+ enterVimCycle,
141
+ filtered,
142
+ models,
143
+ onClose,
144
+ searchQuery,
145
+ setSelectedEntity,
146
+ setSelectedEntityId,
147
+ ],
148
+ );
149
+
150
+ /**
151
+ * Additive toggle — adds OR removes from multi-selection without
152
+ * closing. Uses `toggleEntitySelection` so a second Shift+Enter (or
153
+ * a second checkbox click on the same row) deselects, rather than
154
+ * being a no-op that forces the user to clear the entire selection.
155
+ */
156
+ const toggleAdditive = useCallback(
157
+ (r: SearchResult) => {
158
+ toggleEntitySelection({ modelId: r.modelId, expressId: r.expressId });
159
+ },
160
+ [toggleEntitySelection],
161
+ );
162
+
163
+ /**
164
+ * Batch: add every filtered result to multi-selection in a single
165
+ * Zustand `set`. The naïve loop over `addEntityToSelection` triggered
166
+ * one re-render per row — visibly janky on a 5K-row filtered set.
167
+ */
168
+ const selectAll = useCallback(() => {
169
+ if (filtered.length === 0) return;
170
+ addEntitiesToSelection(
171
+ filtered.map((r) => ({ modelId: r.modelId, expressId: r.expressId })),
172
+ );
173
+ }, [addEntitiesToSelection, filtered]);
174
+
175
+ /** Batch: frame whatever is the primary selection (if any). */
176
+ const frame = useCallback(() => {
177
+ cameraCallbacks.frameSelection?.();
178
+ }, [cameraCallbacks]);
179
+
180
+ const multiCount = selectedEntitiesSet.size;
181
+ const hasModelChips = availableModelIds.length > 1;
182
+
183
+ return (
184
+ <div className="flex flex-1 min-h-0 flex-col">
185
+ {/* ── Chip filters ── */}
186
+ <div className="flex flex-wrap items-center gap-1.5 border-b px-4 py-2 text-xs">
187
+ <Filter className="h-3 w-3 text-muted-foreground" />
188
+ <span className="mr-1 text-muted-foreground">Field:</span>
189
+ {FIELD_FILTERS.map((f) => (
190
+ <button
191
+ key={f.value}
192
+ type="button"
193
+ onClick={() => setSearchFieldFilter(f.value)}
194
+ className={cn(
195
+ 'rounded border px-2 py-0.5 transition-colors',
196
+ searchFieldFilter === f.value
197
+ ? 'border-primary bg-primary/10 text-primary'
198
+ : 'border-zinc-300 hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800',
199
+ )}
200
+ >
201
+ {f.label}
202
+ </button>
203
+ ))}
204
+ {hasModelChips && (
205
+ <>
206
+ <span className="mx-2 h-4 w-px bg-zinc-300 dark:bg-zinc-700" />
207
+ <span className="mr-1 text-muted-foreground">Models:</span>
208
+ {availableModelIds.map((id) => {
209
+ const included = searchModelFilter === null || searchModelFilter.has(id);
210
+ const model = models.get(id);
211
+ const label = model?.name ?? id.slice(0, 6);
212
+ return (
213
+ <button
214
+ key={id}
215
+ type="button"
216
+ onClick={() => toggleSearchModelFilter(id, availableModelIds)}
217
+ className={cn(
218
+ 'rounded border px-2 py-0.5 transition-colors',
219
+ included
220
+ ? 'border-primary bg-primary/10 text-primary'
221
+ : 'border-zinc-300 text-muted-foreground line-through hover:bg-zinc-100 dark:border-zinc-700 dark:hover:bg-zinc-800',
222
+ )}
223
+ title={id}
224
+ >
225
+ {label}
226
+ </button>
227
+ );
228
+ })}
229
+ {searchModelFilter !== null && (
230
+ <button
231
+ type="button"
232
+ onClick={clearSearchModelFilter}
233
+ className="ml-1 text-[10px] text-muted-foreground underline hover:text-foreground"
234
+ >
235
+ reset
236
+ </button>
237
+ )}
238
+ </>
239
+ )}
240
+ </div>
241
+
242
+ {/* ── Virtualized results list ── */}
243
+ <div
244
+ ref={scrollRef}
245
+ className="flex-1 min-h-0 overflow-y-auto"
246
+ role="listbox"
247
+ aria-label="Search results"
248
+ tabIndex={0}
249
+ onKeyDown={(e) => {
250
+ if (filtered.length === 0) return;
251
+ if (e.key === 'ArrowDown') {
252
+ e.preventDefault();
253
+ const next = (searchHighlightIndex + 1) % filtered.length;
254
+ setSearchHighlightIndex(next);
255
+ } else if (e.key === 'ArrowUp') {
256
+ e.preventDefault();
257
+ const next = (searchHighlightIndex - 1 + filtered.length) % filtered.length;
258
+ setSearchHighlightIndex(next);
259
+ } else if (e.key === 'Enter') {
260
+ e.preventDefault();
261
+ const target = filtered[searchHighlightIndex];
262
+ if (target) {
263
+ if (e.shiftKey) toggleAdditive(target);
264
+ else commit(target, searchHighlightIndex);
265
+ }
266
+ }
267
+ }}
268
+ >
269
+ {filtered.length === 0 ? (
270
+ <div className="px-4 py-8 text-center text-xs text-muted-foreground">
271
+ {results.length === 0
272
+ ? 'Start typing to search — GlobalIds, names, IFC types, descriptions.'
273
+ : 'No results match the active filters. Clear chips to widen the search.'}
274
+ </div>
275
+ ) : (
276
+ <div style={{ height: virtualizer.getTotalSize(), position: 'relative' }}>
277
+ {virtualizer.getVirtualItems().map((vRow) => {
278
+ const r = filtered[vRow.index];
279
+ const key = `${r.modelId}:${r.expressId}`;
280
+ const isChecked = selectedEntitiesSet.has(key);
281
+ const isHighlighted = vRow.index === searchHighlightIndex;
282
+ return (
283
+ <div
284
+ key={key}
285
+ role="option"
286
+ aria-selected={isHighlighted}
287
+ style={{
288
+ position: 'absolute',
289
+ top: 0,
290
+ left: 0,
291
+ width: '100%',
292
+ height: vRow.size,
293
+ transform: `translateY(${vRow.start}px)`,
294
+ }}
295
+ className={cn(
296
+ 'flex items-center gap-2 border-b border-zinc-100 px-4 text-xs dark:border-zinc-900',
297
+ isHighlighted && 'bg-zinc-100 dark:bg-zinc-800',
298
+ )}
299
+ onMouseEnter={() => setSearchHighlightIndex(vRow.index)}
300
+ onClick={(e) => {
301
+ if (e.shiftKey) toggleAdditive(r);
302
+ else commit(r, vRow.index);
303
+ }}
304
+ >
305
+ <input
306
+ type="checkbox"
307
+ checked={isChecked}
308
+ onClick={(e) => e.stopPropagation()}
309
+ onChange={() => toggleAdditive(r)}
310
+ aria-label={`Toggle ${r.name || r.globalId} in selection`}
311
+ className="shrink-0 cursor-pointer"
312
+ />
313
+ <Badge variant="secondary" className="shrink-0 font-mono text-[10px] uppercase">
314
+ {r.typeName}
315
+ </Badge>
316
+ <span className="min-w-0 flex-1 truncate font-medium">
317
+ {r.name || <span className="italic text-muted-foreground">unnamed</span>}
318
+ </span>
319
+ {r.globalId && (
320
+ <span className="shrink-0 font-mono text-[10px] text-muted-foreground">
321
+ {r.globalId.slice(0, 10)}…
322
+ </span>
323
+ )}
324
+ {availableModelIds.length > 1 && (
325
+ <span className="shrink-0 rounded border border-zinc-300 px-1 py-0.5 text-[10px] text-muted-foreground dark:border-zinc-700">
326
+ {(models.get(r.modelId)?.name ?? r.modelId).slice(0, 8)}
327
+ </span>
328
+ )}
329
+ <span className="shrink-0 text-[10px] uppercase text-muted-foreground opacity-60">
330
+ {r.matchField}
331
+ </span>
332
+ </div>
333
+ );
334
+ })}
335
+ </div>
336
+ )}
337
+ </div>
338
+
339
+ {/* ── Footer: counts + batch actions ── */}
340
+ <div className="flex flex-wrap items-center gap-2 border-t px-4 py-2 text-xs">
341
+ <span className="text-muted-foreground">
342
+ {filtered.length} result{filtered.length === 1 ? '' : 's'}
343
+ {filtered.length !== results.length && (
344
+ <span className="ml-1 opacity-70">(of {results.length})</span>
345
+ )}
346
+ {multiCount > 0 && (
347
+ <span className="ml-2 font-medium text-foreground">· {multiCount} selected</span>
348
+ )}
349
+ </span>
350
+ <div className="ml-auto flex items-center gap-1.5">
351
+ <Button
352
+ variant="ghost"
353
+ size="sm"
354
+ onClick={frame}
355
+ disabled={!cameraCallbacks.frameSelection}
356
+ title="Frame primary selection"
357
+ className="h-7 gap-1 text-xs"
358
+ >
359
+ <Crosshair className="h-3 w-3" />
360
+ Frame
361
+ </Button>
362
+ <Button
363
+ variant="ghost"
364
+ size="sm"
365
+ onClick={selectAll}
366
+ disabled={filtered.length === 0}
367
+ title={`Add all ${filtered.length} results to multi-selection`}
368
+ className="h-7 gap-1 text-xs"
369
+ >
370
+ <ListChecks className="h-3 w-3" />
371
+ Select all
372
+ </Button>
373
+ <Button
374
+ variant="ghost"
375
+ size="sm"
376
+ onClick={clearEntitySelection}
377
+ disabled={multiCount === 0}
378
+ title="Clear multi-selection"
379
+ className="h-7 gap-1 text-xs"
380
+ >
381
+ <SquareX className="h-3 w-3" />
382
+ Clear
383
+ </Button>
384
+ </div>
385
+ </div>
386
+ </div>
387
+ );
388
+ }
@@ -0,0 +1,235 @@
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
+ * SearchModal — advanced search (⌘⇧F / Ctrl+Shift+F).
7
+ *
8
+ * Shares `searchSlice.searchQuery` with the inline field, so the modal
9
+ * can never "lose" what you've already typed — open it and the query is
10
+ * already there, adjust it and closing the modal leaves the inline in
11
+ * sync. The tab switcher has a "Search" tab (P3) and a "SQL" tab stub
12
+ * reserved for P4. All search engines (Tier-0 linear scan, Tier-1 token
13
+ * index) are reused — the modal just renders a bigger, unfiltered,
14
+ * virtualized version of what the inline popover shows.
15
+ *
16
+ * Keyboard (inside the modal):
17
+ * • ↑ / ↓ — navigate result rows
18
+ * • Enter — commit (select + frame + enter vim cycle + close)
19
+ * • ⇧Enter — toggle row in multi-selection (stays open)
20
+ * • Esc — close modal
21
+ * • ⌘⇧F / Ctrl+⇧F — toggle modal closed (symmetric with open)
22
+ */
23
+
24
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
25
+ import { Search, SlidersHorizontal } from 'lucide-react';
26
+ import { useShallow } from 'zustand/react/shallow';
27
+ import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
28
+ import { Input } from '@/components/ui/input';
29
+ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
30
+ import { useViewerStore } from '@/store';
31
+ import { runTier0Scan, type SearchResult, type ScanModel } from '@/lib/search/tier0-scan';
32
+ import { queryTier1Indexes, type Tier1Index } from '@/lib/search/tier1-index';
33
+ import { useSearchIndex } from '@/hooks/useSearchIndex';
34
+ import { pushRecentSearch } from '@/lib/search/recent-searches';
35
+ import { SearchModalText } from './SearchModal.text';
36
+ import { SearchModalFilter } from './SearchModal.filter';
37
+
38
+ /** Modal-side result cap. Well above what any user scrolls through, small
39
+ * enough that the score/merge arrays stay cheap. Virtualization keeps
40
+ * DOM cost constant regardless. */
41
+ const RESULT_LIMIT_MODAL = 5000;
42
+ const DEBOUNCE_MS = 80;
43
+
44
+ export function SearchModal() {
45
+ const {
46
+ searchQuery,
47
+ searchModalOpen,
48
+ searchIndexes,
49
+ models,
50
+ setSearchModalOpen,
51
+ toggleSearchModal,
52
+ setSearchQuery,
53
+ } = useViewerStore(
54
+ useShallow((s) => ({
55
+ searchQuery: s.searchQuery,
56
+ searchModalOpen: s.searchModalOpen,
57
+ searchIndexes: s.searchIndexes,
58
+ models: s.models,
59
+ setSearchModalOpen: s.setSearchModalOpen,
60
+ toggleSearchModal: s.toggleSearchModal,
61
+ setSearchQuery: s.setSearchQuery,
62
+ })),
63
+ );
64
+
65
+ // Make sure Tier-1 indexes continue building while the modal is open
66
+ // (the inline also mounts this hook — cheap re-registration).
67
+ useSearchIndex();
68
+
69
+ // Debounce the query the same way the inline does, so fast typing
70
+ // inside the modal doesn't re-scan per keystroke.
71
+ const [debouncedQuery, setDebouncedQuery] = useState(searchQuery);
72
+ useEffect(() => {
73
+ const handle = window.setTimeout(() => setDebouncedQuery(searchQuery), DEBOUNCE_MS);
74
+ return () => window.clearTimeout(handle);
75
+ }, [searchQuery]);
76
+
77
+ // Split models into the two search tiers. Same logic as SearchInline.
78
+ const { tier0Models, tier1Indexes, availableModelIds } = useMemo(() => {
79
+ const t0: ScanModel[] = [];
80
+ const t1: Tier1Index[] = [];
81
+ const ids: string[] = [];
82
+ for (const m of models.values()) {
83
+ if (!m.ifcDataStore) continue;
84
+ ids.push(m.id);
85
+ const record = searchIndexes.get(m.id);
86
+ if (record?.status === 'ready' && record.index) {
87
+ t1.push(record.index);
88
+ } else {
89
+ t0.push({ id: m.id, ifcDataStore: m.ifcDataStore });
90
+ }
91
+ }
92
+ return { tier0Models: t0, tier1Indexes: t1, availableModelIds: ids };
93
+ }, [models, searchIndexes]);
94
+
95
+ // Full result pool (pre-filter). Filtering happens inside the tab.
96
+ const results = useMemo<SearchResult[]>(() => {
97
+ if (!debouncedQuery.trim()) return [];
98
+ if (tier0Models.length === 0 && tier1Indexes.length === 0) return [];
99
+
100
+ const t1Results = tier1Indexes.length > 0
101
+ ? queryTier1Indexes(tier1Indexes, debouncedQuery, { limit: RESULT_LIMIT_MODAL })
102
+ : [];
103
+ const t0Results = tier0Models.length > 0
104
+ ? runTier0Scan(tier0Models, debouncedQuery, { limit: RESULT_LIMIT_MODAL })
105
+ : [];
106
+
107
+ if (t1Results.length === 0) return t0Results;
108
+ if (t0Results.length === 0) return t1Results;
109
+
110
+ const combined = [...t1Results, ...t0Results];
111
+ combined.sort((a, b) => {
112
+ if (b.score !== a.score) return b.score - a.score;
113
+ if (a.modelId !== b.modelId) return a.modelId < b.modelId ? -1 : 1;
114
+ return a.expressId - b.expressId;
115
+ });
116
+ const seen = new Set<string>();
117
+ const out: SearchResult[] = [];
118
+ for (const r of combined) {
119
+ const key = `${r.modelId}:${r.expressId}`;
120
+ if (seen.has(key)) continue;
121
+ seen.add(key);
122
+ out.push(r);
123
+ if (out.length >= RESULT_LIMIT_MODAL) break;
124
+ }
125
+ return out;
126
+ }, [tier0Models, tier1Indexes, debouncedQuery]);
127
+
128
+ /** Global ⌘⇧F / Ctrl+⇧F toggle — opens from anywhere, also closes when open. */
129
+ useEffect(() => {
130
+ const handler = (e: globalThis.KeyboardEvent) => {
131
+ const isAdvancedShortcut =
132
+ (e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'f' || e.key === 'F');
133
+ if (isAdvancedShortcut) {
134
+ e.preventDefault();
135
+ toggleSearchModal();
136
+ }
137
+ };
138
+ window.addEventListener('keydown', handler);
139
+ return () => window.removeEventListener('keydown', handler);
140
+ }, [toggleSearchModal]);
141
+
142
+ /**
143
+ * Record the query in recents on the modal-close *transition* — once
144
+ * per close, with the final query at that moment. We watch only
145
+ * `searchModalOpen` (not `searchQuery`) so typing in the inline bar
146
+ * while the modal is closed never fires this effect; without that
147
+ * gate, every keystroke in the inline bar (which shares `searchQuery`
148
+ * with the modal) would push a partial-prefix recent.
149
+ *
150
+ * `prevOpenRef` distinguishes the "opened then closed" transition
151
+ * from the initial mount where `searchModalOpen` is already false.
152
+ */
153
+ const prevOpenRef = useRef(searchModalOpen);
154
+ useEffect(() => {
155
+ const wasOpen = prevOpenRef.current;
156
+ prevOpenRef.current = searchModalOpen;
157
+ if (wasOpen && !searchModalOpen) {
158
+ // Use the latest searchQuery via a fresh read — depending on it
159
+ // would re-fire this effect on every keystroke. Since the close
160
+ // transition is what we care about, the latest value at close
161
+ // time is the right thing to record.
162
+ const q = searchQuery.trim();
163
+ if (q) pushRecentSearch(q);
164
+ }
165
+ }, [searchModalOpen, searchQuery]);
166
+
167
+ // Auto-select the input on open so typing is immediate.
168
+ const inputRef = useRef<HTMLInputElement>(null);
169
+ useEffect(() => {
170
+ if (searchModalOpen) {
171
+ // Next tick so Radix Dialog has mounted the content.
172
+ const t = window.setTimeout(() => {
173
+ inputRef.current?.focus();
174
+ inputRef.current?.select();
175
+ }, 10);
176
+ return () => window.clearTimeout(t);
177
+ }
178
+ }, [searchModalOpen]);
179
+
180
+ const close = useCallback(() => setSearchModalOpen(false), [setSearchModalOpen]);
181
+
182
+ if (!searchModalOpen) return null;
183
+
184
+ return (
185
+ <Dialog open={searchModalOpen} onOpenChange={(open) => setSearchModalOpen(open)}>
186
+ <DialogContent
187
+ hideCloseButton
188
+ className="max-w-4xl h-[80vh] p-0 gap-0 flex flex-col"
189
+ onEscapeKeyDown={close}
190
+ >
191
+ <DialogTitle className="sr-only">Advanced Search</DialogTitle>
192
+ <Tabs defaultValue="search" className="flex flex-col flex-1 min-h-0">
193
+ <div className="flex items-center justify-between border-b px-4 py-3">
194
+ <TabsList>
195
+ <TabsTrigger value="search">
196
+ <Search className="h-3.5 w-3.5 mr-1.5" />
197
+ Search
198
+ </TabsTrigger>
199
+ <TabsTrigger value="filter">
200
+ <SlidersHorizontal className="h-3.5 w-3.5 mr-1.5" />
201
+ Filter
202
+ </TabsTrigger>
203
+ </TabsList>
204
+ <div className="text-[11px] text-muted-foreground">
205
+ <kbd className="rounded border border-zinc-300 bg-zinc-100 px-1 font-mono text-[10px] dark:border-zinc-700 dark:bg-zinc-900">Esc</kbd>
206
+ <span className="ml-1">close</span>
207
+ </div>
208
+ </div>
209
+ <TabsContent value="search" className="flex-1 min-h-0 mt-0 flex flex-col">
210
+ <div className="border-b px-4 py-3">
211
+ <Input
212
+ ref={inputRef}
213
+ type="text"
214
+ placeholder="Search GUID, name, type, description, objectType…"
215
+ value={searchQuery}
216
+ leftIcon={<Search className="h-4 w-4" />}
217
+ onChange={(e) => setSearchQuery(e.target.value)}
218
+ className="h-10 text-sm"
219
+ aria-label="Advanced search query"
220
+ />
221
+ </div>
222
+ <SearchModalText
223
+ results={results}
224
+ availableModelIds={availableModelIds}
225
+ onClose={close}
226
+ />
227
+ </TabsContent>
228
+ <TabsContent value="filter" className="flex-1 min-h-0 mt-0 flex">
229
+ <SearchModalFilter />
230
+ </TabsContent>
231
+ </Tabs>
232
+ </DialogContent>
233
+ </Dialog>
234
+ );
235
+ }
@@ -9,6 +9,7 @@
9
9
  import { useViewerStore } from '@/store';
10
10
  import { MeasureOverlay } from './tools/MeasurePanel';
11
11
  import { SectionOverlay } from './tools/SectionPanel';
12
+ import { AddElementOverlay } from './tools/AddElementOverlay';
12
13
 
13
14
  export function ToolOverlays() {
14
15
  const activeTool = useViewerStore((s) => s.activeTool);
@@ -21,5 +22,9 @@ export function ToolOverlays() {
21
22
  return <SectionOverlay />;
22
23
  }
23
24
 
25
+ if (activeTool === 'addElement') {
26
+ return <AddElementOverlay />;
27
+ }
28
+
24
29
  return null;
25
30
  }