@ifc-lite/viewer 1.17.4 → 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 (196) hide show
  1. package/.turbo/turbo-build.log +20 -17
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +630 -0
  4. package/DESKTOP_CONTRACT_VERSION +1 -1
  5. package/dist/assets/{basketViewActivator-BmnNtVfZ.js → basketViewActivator-Cm1QEk_R.js} +1 -1
  6. package/dist/assets/drawing-2d-DoxKMqbO.js +257 -0
  7. package/dist/assets/{exporters-ChAtBmlj.js → exporters-B_OBqIyD.js} +3479 -2845
  8. package/dist/assets/{geometry.worker-BQ0rzNo-.js → geometry.worker-xHHy-9DV.js} +1 -1
  9. package/dist/assets/ids-DQ5jY0E8.js +1 -0
  10. package/dist/assets/ifc-lite_bg-ADjKXSms.wasm +0 -0
  11. package/dist/assets/{index-Co8E2-FE.js → index-BKq-M3Mk.js} +55873 -40593
  12. package/dist/assets/index-COnQRuqY.css +1 -0
  13. package/dist/assets/{native-bridge-BRvbckFQ.js → native-bridge-SHXiQwFW.js} +104 -104
  14. package/dist/assets/sandbox-jez21HtV.js +9627 -0
  15. package/dist/assets/{server-client-BV8zHZ7Y.js → server-client-ncOQVNso.js} +1 -1
  16. package/dist/assets/{wasm-bridge-g01g7T9b.js → wasm-bridge-DyfBSB8z.js} +1 -1
  17. package/dist/index.html +8 -7
  18. package/index.html +1 -0
  19. package/package.json +13 -13
  20. package/src/App.tsx +16 -2
  21. package/src/apache-arrow.d.ts +30 -0
  22. package/src/components/viewer/AddElementPanel.tsx +758 -0
  23. package/src/components/viewer/BulkPropertyEditor.tsx +7 -0
  24. package/src/components/viewer/CesiumOverlay.tsx +62 -19
  25. package/src/components/viewer/ChatPanel.tsx +259 -93
  26. package/src/components/viewer/CommandPalette.tsx +56 -7
  27. package/src/components/viewer/EntityContextMenu.tsx +168 -4
  28. package/src/components/viewer/ExportChangesButton.tsx +25 -5
  29. package/src/components/viewer/ExportDialog.tsx +19 -1
  30. package/src/components/viewer/MainToolbar.tsx +73 -13
  31. package/src/components/viewer/PropertiesPanel.tsx +237 -23
  32. package/src/components/viewer/SearchInline.tsx +669 -0
  33. package/src/components/viewer/SearchModal.filter.builder.tsx +766 -0
  34. package/src/components/viewer/SearchModal.filter.tsx +514 -0
  35. package/src/components/viewer/SearchModal.text.tsx +388 -0
  36. package/src/components/viewer/SearchModal.tsx +235 -0
  37. package/src/components/viewer/SettingsPage.tsx +252 -101
  38. package/src/components/viewer/ThemeSwitch.tsx +63 -7
  39. package/src/components/viewer/ToolOverlays.tsx +5 -0
  40. package/src/components/viewer/ViewerLayout.tsx +25 -4
  41. package/src/components/viewer/Viewport.tsx +25 -3
  42. package/src/components/viewer/ViewportContainer.tsx +51 -64
  43. package/src/components/viewer/ViewportOverlays.tsx +5 -2
  44. package/src/components/viewer/annotations/AnnotationDropInput.tsx +203 -0
  45. package/src/components/viewer/annotations/AnnotationLayer.tsx +287 -0
  46. package/src/components/viewer/annotations/AnnotationPin.tsx +90 -0
  47. package/src/components/viewer/annotations/AnnotationPopover.tsx +296 -0
  48. package/src/components/viewer/bcf/BCFTopicDetail.tsx +4 -4
  49. package/src/components/viewer/chat/ModelSelector.tsx +90 -54
  50. package/src/components/viewer/lists/ListPanel.tsx +14 -21
  51. package/src/components/viewer/properties/GeoreferencingPanel.tsx +113 -51
  52. package/src/components/viewer/properties/LocationMap.tsx +9 -7
  53. package/src/components/viewer/properties/ModelMetadataPanel.tsx +1 -1
  54. package/src/components/viewer/properties/RawStepCard.tsx +332 -0
  55. package/src/components/viewer/properties/RawStepRow.tsx +261 -0
  56. package/src/components/viewer/properties/ScheduleCard.tsx +224 -0
  57. package/src/components/viewer/properties/TaskEditCard.tsx +510 -0
  58. package/src/components/viewer/properties/raw-step-format.ts +193 -0
  59. package/src/components/viewer/schedule/AnimationSettingsPopover.tsx +542 -0
  60. package/src/components/viewer/schedule/GanttDependencyArrows.tsx +89 -0
  61. package/src/components/viewer/schedule/GanttDragTooltip.tsx +48 -0
  62. package/src/components/viewer/schedule/GanttEmptyState.tsx +97 -0
  63. package/src/components/viewer/schedule/GanttPanel.tsx +295 -0
  64. package/src/components/viewer/schedule/GanttTaskBar.tsx +199 -0
  65. package/src/components/viewer/schedule/GanttTaskTree.tsx +250 -0
  66. package/src/components/viewer/schedule/GanttTimeline.tsx +305 -0
  67. package/src/components/viewer/schedule/GanttToolbar.tsx +406 -0
  68. package/src/components/viewer/schedule/GenerateAdvancedPanel.tsx +147 -0
  69. package/src/components/viewer/schedule/GenerateScheduleDialog.tsx +392 -0
  70. package/src/components/viewer/schedule/HeightStrategyPanel.tsx +120 -0
  71. package/src/components/viewer/schedule/generate-schedule.test.ts +439 -0
  72. package/src/components/viewer/schedule/generate-schedule.ts +648 -0
  73. package/src/components/viewer/schedule/schedule-animator.test.ts +452 -0
  74. package/src/components/viewer/schedule/schedule-animator.ts +488 -0
  75. package/src/components/viewer/schedule/schedule-selection.test.ts +148 -0
  76. package/src/components/viewer/schedule/schedule-selection.ts +163 -0
  77. package/src/components/viewer/schedule/schedule-utils.ts +223 -0
  78. package/src/components/viewer/schedule/useConstructionSequence.ts +156 -0
  79. package/src/components/viewer/schedule/useGanttBarDrag.test.ts +90 -0
  80. package/src/components/viewer/schedule/useGanttBarDrag.ts +305 -0
  81. package/src/components/viewer/schedule/useGanttSelection3DHighlight.ts +152 -0
  82. package/src/components/viewer/schedule/useOverlayCompositor.ts +108 -0
  83. package/src/components/viewer/selectionHandlers.ts +446 -0
  84. package/src/components/viewer/tools/AddElementOverlay.tsx +540 -0
  85. package/src/components/viewer/tools/SectionCapControls.tsx +237 -0
  86. package/src/components/viewer/tools/SectionPanel.tsx +39 -18
  87. package/src/components/viewer/useAnimationLoop.ts +9 -1
  88. package/src/components/viewer/useDuplicateShortcut.ts +77 -0
  89. package/src/components/viewer/useMouseControls.ts +9 -1
  90. package/src/components/viewer/useRenderUpdates.ts +1 -1
  91. package/src/hooks/ids/idsDataAccessor.ts +60 -24
  92. package/src/hooks/ingest/viewerModelIngest.ts +7 -2
  93. package/src/hooks/useIfcFederation.ts +326 -71
  94. package/src/hooks/useIfcLoader.ts +23 -10
  95. package/src/hooks/useKeyboardShortcuts.ts +25 -0
  96. package/src/hooks/useSandbox.ts +1 -1
  97. package/src/hooks/useSearchIndex.ts +125 -0
  98. package/src/hooks/useViewControls.ts +13 -5
  99. package/src/index.css +550 -10
  100. package/src/lib/desktop-entitlement.ts +2 -4
  101. package/src/lib/geo/cesium-bridge.ts +15 -7
  102. package/src/lib/geo/effective-georef.test.ts +73 -0
  103. package/src/lib/geo/effective-georef.ts +111 -0
  104. package/src/lib/geo/reproject.ts +105 -19
  105. package/src/lib/llm/byok-guard.test.ts +77 -0
  106. package/src/lib/llm/byok-guard.ts +39 -0
  107. package/src/lib/llm/free-models.test.ts +0 -6
  108. package/src/lib/llm/models.ts +104 -42
  109. package/src/lib/llm/stream-client.ts +74 -110
  110. package/src/lib/llm/stream-direct.test.ts +130 -0
  111. package/src/lib/llm/stream-direct.ts +316 -0
  112. package/src/lib/llm/system-prompt.test.ts +14 -0
  113. package/src/lib/llm/system-prompt.ts +102 -1
  114. package/src/lib/llm/types.ts +20 -2
  115. package/src/lib/recent-files.ts +38 -4
  116. package/src/lib/scripts/templates/bim-globals.d.ts +136 -114
  117. package/src/lib/scripts/templates/construction-schedule.ts +223 -0
  118. package/src/lib/scripts/templates.ts +7 -0
  119. package/src/lib/search/common-ifc-types.ts +36 -0
  120. package/src/lib/search/filter-evaluate.test.ts +537 -0
  121. package/src/lib/search/filter-evaluate.ts +610 -0
  122. package/src/lib/search/filter-rules.test.ts +119 -0
  123. package/src/lib/search/filter-rules.ts +198 -0
  124. package/src/lib/search/filter-schema.test.ts +233 -0
  125. package/src/lib/search/filter-schema.ts +146 -0
  126. package/src/lib/search/recent-searches.test.ts +116 -0
  127. package/src/lib/search/recent-searches.ts +93 -0
  128. package/src/lib/search/result-export.test.ts +101 -0
  129. package/src/lib/search/result-export.ts +104 -0
  130. package/src/lib/search/saved-filters.test.ts +118 -0
  131. package/src/lib/search/saved-filters.ts +154 -0
  132. package/src/lib/search/tier0-scan.test.ts +196 -0
  133. package/src/lib/search/tier0-scan.ts +237 -0
  134. package/src/lib/search/tier1-index.test.ts +242 -0
  135. package/src/lib/search/tier1-index.ts +448 -0
  136. package/src/main.tsx +1 -10
  137. package/src/sdk/adapters/export-adapter.test.ts +434 -1
  138. package/src/sdk/adapters/export-adapter.ts +404 -1
  139. package/src/sdk/adapters/export-schedule-splice.test.ts +127 -0
  140. package/src/sdk/adapters/export-schedule-splice.ts +87 -0
  141. package/src/sdk/adapters/model-compat.ts +8 -2
  142. package/src/sdk/adapters/schedule-adapter.ts +73 -0
  143. package/src/sdk/adapters/store-adapter.ts +201 -0
  144. package/src/sdk/adapters/visibility-adapter.ts +3 -0
  145. package/src/sdk/local-backend.ts +16 -8
  146. package/src/services/api-keys.ts +73 -0
  147. package/src/services/desktop-export.ts +3 -1
  148. package/src/services/desktop-native-metadata.ts +41 -18
  149. package/src/services/file-dialog.ts +4 -1
  150. package/src/services/tauri-modules.d.ts +25 -0
  151. package/src/store/basketVisibleSet.ts +3 -0
  152. package/src/store/constants.ts +20 -2
  153. package/src/store/globalId.ts +4 -1
  154. package/src/store/index.ts +82 -6
  155. package/src/store/slices/addElementMeshes.ts +365 -0
  156. package/src/store/slices/addElementSlice.ts +275 -0
  157. package/src/store/slices/annotationsSlice.test.ts +133 -0
  158. package/src/store/slices/annotationsSlice.ts +251 -0
  159. package/src/store/slices/cesiumSlice.ts +5 -0
  160. package/src/store/slices/chatSlice.test.ts +6 -76
  161. package/src/store/slices/chatSlice.ts +17 -58
  162. package/src/store/slices/dataSlice.test.ts +23 -4
  163. package/src/store/slices/dataSlice.ts +1 -1
  164. package/src/store/slices/modelSlice.test.ts +67 -9
  165. package/src/store/slices/modelSlice.ts +39 -7
  166. package/src/store/slices/mutationSlice.ts +964 -3
  167. package/src/store/slices/overlayCompositor.test.ts +164 -0
  168. package/src/store/slices/overlaySlice.test.ts +93 -0
  169. package/src/store/slices/overlaySlice.ts +151 -0
  170. package/src/store/slices/pinboardSlice.test.ts +6 -1
  171. package/src/store/slices/playbackSlice.ts +128 -0
  172. package/src/store/slices/schedule-edit-helpers.test.ts +97 -0
  173. package/src/store/slices/schedule-edit-helpers.ts +179 -0
  174. package/src/store/slices/scheduleSlice.test.ts +694 -0
  175. package/src/store/slices/scheduleSlice.ts +1330 -0
  176. package/src/store/slices/searchSlice.test.ts +342 -0
  177. package/src/store/slices/searchSlice.ts +341 -0
  178. package/src/store/slices/sectionSlice.test.ts +87 -7
  179. package/src/store/slices/sectionSlice.ts +151 -5
  180. package/src/store/slices/selectionSlice.test.ts +46 -0
  181. package/src/store/slices/selectionSlice.ts +20 -0
  182. package/src/store/slices/uiSlice.ts +28 -5
  183. package/src/store/types.ts +26 -0
  184. package/src/store.ts +14 -0
  185. package/src/utils/nativeSpatialDataStore.ts +4 -1
  186. package/src/utils/viewportUtils.ts +7 -2
  187. package/src/vite-env.d.ts +0 -4
  188. package/dist/assets/drawing-2d-gWfpdfYe.js +0 -257
  189. package/dist/assets/ids-B4jTqB1O.js +0 -1
  190. package/dist/assets/ifc-lite_bg-BX4E7TX8.wasm +0 -0
  191. package/dist/assets/index-DckuDqlv.css +0 -1
  192. package/dist/assets/sandbox-DZiNLNMk.js +0 -5933
  193. package/src/components/viewer/UpgradePage.tsx +0 -71
  194. package/src/lib/desktop/ClerkDesktopEntitlementSync.tsx +0 -175
  195. package/src/lib/llm/ClerkChatSync.tsx +0 -74
  196. package/src/lib/llm/clerk-auth.ts +0 -62
@@ -0,0 +1,766 @@
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
+ * SearchModalFilterBuilder — chip palette over the unified
7
+ * `FilterRule[]`. Storey / IFC type / Predefined type / Name / Property /
8
+ * Quantity rules with AND/OR + IsSet/IsNotSet, schema-aware dropdowns
9
+ * (storeys + types load eagerly, pset/qto names lazily), and saved
10
+ * preset persistence.
11
+ *
12
+ * UI-only: this component owns rule editing, not run lifecycle. The
13
+ * parent `SearchModalFilter` reads the same slice state and triggers
14
+ * the path-B evaluator from a single Run button.
15
+ */
16
+
17
+ import { useCallback, useEffect, useMemo, useState } from 'react';
18
+ import { Plus, Trash2, X, Bookmark, Save } from 'lucide-react';
19
+ import { useShallow } from 'zustand/react/shallow';
20
+ import { useViewerStore } from '@/store';
21
+ import { Button } from '@/components/ui/button';
22
+ import { Input } from '@/components/ui/input';
23
+ import {
24
+ DropdownMenu,
25
+ DropdownMenuTrigger,
26
+ DropdownMenuContent,
27
+ DropdownMenuItem,
28
+ DropdownMenuSeparator,
29
+ DropdownMenuLabel,
30
+ } from '@/components/ui/dropdown-menu';
31
+ import { COMMON_IFC_TYPES } from '@/lib/search/common-ifc-types';
32
+ import {
33
+ Rule,
34
+ type FilterRule,
35
+ type SetOp,
36
+ type StringOp,
37
+ type ValueOp,
38
+ type NumericOp,
39
+ type Combinator,
40
+ } from '@/lib/search/filter-rules';
41
+ import {
42
+ discoverFilterSchema,
43
+ discoverPropertyAndQuantitySchema,
44
+ } from '@/lib/search/filter-schema';
45
+ import {
46
+ loadSavedFilters,
47
+ saveFilter,
48
+ deleteSavedFilter,
49
+ type SavedFilterPreset,
50
+ } from '@/lib/search/saved-filters';
51
+
52
+ // ── Op constants ──────────────────────────────────────────────────────
53
+
54
+ const SET_OPS: SetOp[] = ['in', 'notIn'];
55
+ const STRING_OPS: StringOp[] = ['eq', 'ne', 'contains', 'notContains', 'startsWith'];
56
+ const VALUE_OPS: ValueOp[] = [
57
+ 'eq', 'ne', 'contains', 'notContains', 'gt', 'gte', 'lt', 'lte', 'isSet', 'isNotSet',
58
+ ];
59
+ const NUMERIC_OPS: NumericOp[] = ['eq', 'ne', 'gt', 'gte', 'lt', 'lte'];
60
+
61
+ const OP_LABEL: Record<string, string> = {
62
+ in: 'is one of', notIn: 'is not one of',
63
+ eq: '=', ne: '≠',
64
+ contains: 'contains', notContains: 'does not contain',
65
+ startsWith: 'starts with',
66
+ gt: '>', gte: '≥', lt: '<', lte: '≤',
67
+ isSet: 'is set', isNotSet: 'is not set',
68
+ };
69
+
70
+ const RULE_KIND_LABEL: Record<FilterRule['kind'], string> = {
71
+ storey: 'Storey',
72
+ ifcType: 'IFC Type',
73
+ predefinedType: 'Predefined Type',
74
+ name: 'Name',
75
+ property: 'Property',
76
+ quantity: 'Quantity',
77
+ };
78
+
79
+ export function SearchModalFilterBuilder() {
80
+ const {
81
+ filter,
82
+ schemaMap,
83
+ models,
84
+ activeModelId,
85
+ searchQuery,
86
+ setFilterCombinator,
87
+ setFilterLimit,
88
+ addFilterRule,
89
+ updateFilterRule,
90
+ removeFilterRule,
91
+ clearFilterRules,
92
+ setFilterSchema,
93
+ setFilterPsetQtoSchema,
94
+ setSearchFilter,
95
+ } = useViewerStore(
96
+ useShallow((s) => ({
97
+ filter: s.searchFilter,
98
+ schemaMap: s.searchFilterSchema,
99
+ models: s.models,
100
+ activeModelId: s.activeModelId,
101
+ searchQuery: s.searchQuery,
102
+ setFilterCombinator: s.setFilterCombinator,
103
+ setFilterLimit: s.setFilterLimit,
104
+ addFilterRule: s.addFilterRule,
105
+ updateFilterRule: s.updateFilterRule,
106
+ removeFilterRule: s.removeFilterRule,
107
+ clearFilterRules: s.clearFilterRules,
108
+ setFilterSchema: s.setFilterSchema,
109
+ setFilterPsetQtoSchema: s.setFilterPsetQtoSchema,
110
+ setSearchFilter: s.setSearchFilter,
111
+ })),
112
+ );
113
+
114
+ const [savedPresets, setSavedPresets] = useState<SavedFilterPreset[]>(() => loadSavedFilters());
115
+
116
+ const activeModel = activeModelId ? models.get(activeModelId) : undefined;
117
+ const activeStore = activeModel?.ifcDataStore ?? null;
118
+ const schemaEntry = activeModelId ? schemaMap.get(activeModelId) : undefined;
119
+
120
+ // Cheap schema discovery — runs once per active model.
121
+ useEffect(() => {
122
+ if (!activeModelId || !activeStore) return;
123
+ if (schemaMap.has(activeModelId)) return;
124
+ setFilterSchema(activeModelId, discoverFilterSchema(activeStore));
125
+ }, [activeModelId, activeStore, schemaMap, setFilterSchema]);
126
+
127
+ // Lazy pset/qto schema — fired the first time a property/quantity rule appears.
128
+ useEffect(() => {
129
+ if (!activeModelId || !activeStore) return;
130
+ const entry = schemaMap.get(activeModelId);
131
+ if (entry?.psetQto) return;
132
+ const needs = filter.rules.some((r) => r.kind === 'property' || r.kind === 'quantity');
133
+ if (!needs) return;
134
+ setFilterPsetQtoSchema(activeModelId, discoverPropertyAndQuantitySchema(activeStore));
135
+ }, [activeModelId, activeStore, filter.rules, schemaMap, setFilterPsetQtoSchema]);
136
+
137
+ const ifcTypeOptions = useMemo<string[]>(() => {
138
+ if (schemaEntry?.basic.ifcTypes && schemaEntry.basic.ifcTypes.length > 0) {
139
+ return schemaEntry.basic.ifcTypes;
140
+ }
141
+ return COMMON_IFC_TYPES.slice();
142
+ }, [schemaEntry]);
143
+ const storeyOptions = schemaEntry?.basic.storeys ?? [];
144
+
145
+ // ── Rule construction ─────────────────────────────────────────────
146
+
147
+ const addRuleOfKind = useCallback((kind: FilterRule['kind']) => {
148
+ let rule: FilterRule;
149
+ switch (kind) {
150
+ case 'storey': rule = Rule.storey([], 'in'); break;
151
+ case 'ifcType': rule = Rule.ifcType([], 'in'); break;
152
+ case 'predefinedType': rule = Rule.predefinedType([], 'in'); break;
153
+ case 'name': rule = Rule.name('contains', ''); break;
154
+ case 'property': rule = Rule.property('', '', 'eq', ''); break;
155
+ case 'quantity': rule = Rule.quantity('', '', 'gt', 0); break;
156
+ }
157
+ addFilterRule(rule);
158
+ }, [addFilterRule]);
159
+
160
+ const promoteSearchQuery = useCallback(() => {
161
+ const q = searchQuery.trim();
162
+ if (!q) return;
163
+ addFilterRule(Rule.name('contains', q));
164
+ }, [addFilterRule, searchQuery]);
165
+
166
+ // ── Preset handlers ─────────────────────────────────────────────────
167
+
168
+ const handleSavePreset = useCallback(() => {
169
+ if (filter.rules.length === 0) return;
170
+ // eslint-disable-next-line no-alert
171
+ const name = window.prompt('Save filter as…', '');
172
+ if (!name) return;
173
+ setSavedPresets(saveFilter(name, filter.combinator, filter.rules));
174
+ }, [filter.combinator, filter.rules]);
175
+
176
+ const handleLoadPreset = useCallback((preset: SavedFilterPreset) => {
177
+ setSearchFilter({
178
+ rules: preset.rules.map((r) => ({ ...r }) as FilterRule),
179
+ combinator: preset.combinator,
180
+ limit: filter.limit,
181
+ });
182
+ }, [filter.limit, setSearchFilter]);
183
+
184
+ const handleDeletePreset = useCallback((name: string) => {
185
+ setSavedPresets(deleteSavedFilter(name));
186
+ }, []);
187
+
188
+ return (
189
+ <div className="flex flex-col gap-3 p-4">
190
+ {/* ── Toolbar: AND/OR · Limit · promote-query · Presets · Save · Reset ── */}
191
+ <div className="flex flex-wrap items-center gap-2 text-xs">
192
+ <CombinatorToggle value={filter.combinator} onChange={setFilterCombinator} />
193
+
194
+ <div className="ml-1 flex items-center gap-1">
195
+ <label className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
196
+ Limit
197
+ </label>
198
+ <Input
199
+ type="number"
200
+ min={0}
201
+ value={filter.limit}
202
+ onChange={(e) => setFilterLimit(Number.parseInt(e.target.value, 10) || 0)}
203
+ className="h-7 w-20 text-xs"
204
+ />
205
+ <span className="text-[10px] text-muted-foreground">0 = none</span>
206
+ </div>
207
+
208
+ {searchQuery.trim().length > 0 && (
209
+ <Button
210
+ type="button"
211
+ variant="ghost"
212
+ size="sm"
213
+ onClick={promoteSearchQuery}
214
+ className="h-7 gap-1 text-[11px]"
215
+ title="Add a Name contains rule from the search bar query"
216
+ >
217
+ <Plus className="h-3 w-3" />
218
+ Add &ldquo;{truncate(searchQuery.trim(), 18)}&rdquo; as rule
219
+ </Button>
220
+ )}
221
+
222
+ <div className="ml-auto flex items-center gap-1">
223
+ <PresetMenu
224
+ presets={savedPresets}
225
+ onLoad={handleLoadPreset}
226
+ onDelete={handleDeletePreset}
227
+ />
228
+ <Button
229
+ type="button"
230
+ variant="ghost"
231
+ size="sm"
232
+ onClick={handleSavePreset}
233
+ disabled={filter.rules.length === 0}
234
+ className="h-7 gap-1 text-[11px]"
235
+ title="Save the current rules as a named preset"
236
+ >
237
+ <Save className="h-3 w-3" /> Save
238
+ </Button>
239
+ {filter.rules.length > 0 && (
240
+ <Button
241
+ type="button"
242
+ variant="ghost"
243
+ size="sm"
244
+ onClick={clearFilterRules}
245
+ className="h-7 gap-1 text-[11px] text-muted-foreground"
246
+ >
247
+ <X className="h-3 w-3" /> Reset
248
+ </Button>
249
+ )}
250
+ </div>
251
+ </div>
252
+
253
+ {/* ── Rules list ──────────────────────────────────────────────────── */}
254
+ <div className="flex flex-col gap-2">
255
+ {filter.rules.length === 0 && (
256
+ <p className="rounded border border-dashed border-zinc-300 bg-zinc-50 px-3 py-3 text-center text-xs italic text-muted-foreground dark:border-zinc-800 dark:bg-zinc-900/30">
257
+ Add a rule to start filtering — pick by storey, IFC type, name, property, or quantity.
258
+ </p>
259
+ )}
260
+ {filter.rules.map((rule, i) => (
261
+ <RuleRow
262
+ key={i}
263
+ rule={rule}
264
+ ifcTypeOptions={ifcTypeOptions}
265
+ storeyOptions={storeyOptions}
266
+ psetQto={schemaEntry?.psetQto ?? null}
267
+ onChange={(next) => updateFilterRule(i, next)}
268
+ onRemove={() => removeFilterRule(i)}
269
+ />
270
+ ))}
271
+ <AddRuleMenu onAdd={addRuleOfKind} />
272
+ </div>
273
+ </div>
274
+ );
275
+ }
276
+
277
+ // ── Sub-components ────────────────────────────────────────────────────
278
+
279
+ function CombinatorToggle({
280
+ value,
281
+ onChange,
282
+ }: {
283
+ value: Combinator;
284
+ onChange: (next: Combinator) => void;
285
+ }) {
286
+ return (
287
+ <div
288
+ className="inline-flex rounded border border-zinc-200 bg-white p-0.5 text-[11px] dark:border-zinc-800 dark:bg-zinc-950"
289
+ title="AND requires every rule to match. OR matches any rule."
290
+ >
291
+ {(['AND', 'OR'] as const).map((c) => (
292
+ <button
293
+ key={c}
294
+ type="button"
295
+ onClick={() => onChange(c)}
296
+ className={`rounded px-2 py-0.5 font-mono font-medium transition-colors ${
297
+ value === c
298
+ ? 'bg-primary text-primary-foreground'
299
+ : 'text-muted-foreground hover:text-foreground'
300
+ }`}
301
+ >
302
+ {c}
303
+ </button>
304
+ ))}
305
+ </div>
306
+ );
307
+ }
308
+
309
+ function PresetMenu({
310
+ presets,
311
+ onLoad,
312
+ onDelete,
313
+ }: {
314
+ presets: SavedFilterPreset[];
315
+ onLoad: (preset: SavedFilterPreset) => void;
316
+ onDelete: (name: string) => void;
317
+ }) {
318
+ if (presets.length === 0) {
319
+ return (
320
+ <Button
321
+ type="button"
322
+ variant="ghost"
323
+ size="sm"
324
+ disabled
325
+ className="h-7 gap-1 text-[11px] text-muted-foreground"
326
+ title="Save a preset first"
327
+ >
328
+ <Bookmark className="h-3 w-3" /> Presets
329
+ </Button>
330
+ );
331
+ }
332
+ return (
333
+ <DropdownMenu>
334
+ <DropdownMenuTrigger asChild>
335
+ <Button
336
+ type="button"
337
+ variant="ghost"
338
+ size="sm"
339
+ className="h-7 gap-1 text-[11px]"
340
+ >
341
+ <Bookmark className="h-3 w-3" /> Presets
342
+ </Button>
343
+ </DropdownMenuTrigger>
344
+ <DropdownMenuContent align="end" className="w-72">
345
+ <DropdownMenuLabel className="text-[10px] uppercase">Saved presets</DropdownMenuLabel>
346
+ <DropdownMenuSeparator />
347
+ {presets.map((p) => (
348
+ <DropdownMenuItem
349
+ key={p.name}
350
+ onSelect={() => onLoad(p)}
351
+ className="flex items-start justify-between gap-2"
352
+ >
353
+ <div className="flex flex-col">
354
+ <span className="font-medium">{p.name}</span>
355
+ <span className="text-[10px] text-muted-foreground">
356
+ {p.rules.length} rule{p.rules.length === 1 ? '' : 's'} · {p.combinator}
357
+ </span>
358
+ </div>
359
+ <button
360
+ type="button"
361
+ aria-label={`Delete preset ${p.name}`}
362
+ onClick={(e) => {
363
+ e.stopPropagation();
364
+ onDelete(p.name);
365
+ }}
366
+ className="rounded p-1 text-muted-foreground hover:bg-zinc-100 hover:text-destructive dark:hover:bg-zinc-800"
367
+ >
368
+ <Trash2 className="h-3 w-3" />
369
+ </button>
370
+ </DropdownMenuItem>
371
+ ))}
372
+ </DropdownMenuContent>
373
+ </DropdownMenu>
374
+ );
375
+ }
376
+
377
+ function AddRuleMenu({
378
+ onAdd,
379
+ }: {
380
+ onAdd: (kind: FilterRule['kind']) => void;
381
+ }) {
382
+ return (
383
+ <DropdownMenu>
384
+ <DropdownMenuTrigger asChild>
385
+ <Button variant="ghost" size="sm" className="h-7 gap-1 self-start text-xs">
386
+ <Plus className="h-3 w-3" />
387
+ Add rule
388
+ </Button>
389
+ </DropdownMenuTrigger>
390
+ <DropdownMenuContent align="start">
391
+ <DropdownMenuLabel className="text-[10px] uppercase">Filter dimension</DropdownMenuLabel>
392
+ <DropdownMenuSeparator />
393
+ {(Object.keys(RULE_KIND_LABEL) as FilterRule['kind'][]).map((k) => (
394
+ <DropdownMenuItem key={k} onSelect={() => onAdd(k)}>
395
+ {RULE_KIND_LABEL[k]}
396
+ </DropdownMenuItem>
397
+ ))}
398
+ </DropdownMenuContent>
399
+ </DropdownMenu>
400
+ );
401
+ }
402
+
403
+ interface RuleRowProps {
404
+ rule: FilterRule;
405
+ ifcTypeOptions: string[];
406
+ storeyOptions: ReadonlyArray<readonly [string, number | null]>;
407
+ psetQto: { psets: ReadonlyArray<readonly [string, ReadonlyArray<string>]>; qtos: ReadonlyArray<readonly [string, ReadonlyArray<readonly [string, string]>]> } | null;
408
+ onChange: (next: FilterRule) => void;
409
+ onRemove: () => void;
410
+ }
411
+
412
+ function RuleRow({ rule, ifcTypeOptions, storeyOptions, psetQto, onChange, onRemove }: RuleRowProps) {
413
+ return (
414
+ <div className="flex flex-wrap items-center gap-1.5 rounded border border-zinc-200 bg-white px-2 py-1.5 dark:border-zinc-800 dark:bg-zinc-950">
415
+ <span className="rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wider text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300">
416
+ {RULE_KIND_LABEL[rule.kind]}
417
+ </span>
418
+
419
+ {rule.kind === 'storey' && (
420
+ <SetRuleEditor
421
+ values={rule.values}
422
+ op={rule.op}
423
+ options={storeyOptions.map(([name, elev]) => ({
424
+ label: elev != null ? `${name} (${elev.toFixed(2)} m)` : name,
425
+ value: name,
426
+ }))}
427
+ onChange={(values, op) => onChange(Rule.storey(values, op))}
428
+ />
429
+ )}
430
+
431
+ {rule.kind === 'ifcType' && (
432
+ <SetRuleEditor
433
+ values={rule.values}
434
+ op={rule.op}
435
+ options={ifcTypeOptions.map((t) => ({ label: t, value: t }))}
436
+ onChange={(values, op) => onChange(Rule.ifcType(values, op))}
437
+ />
438
+ )}
439
+
440
+ {rule.kind === 'predefinedType' && (
441
+ <PredefinedTypeEditor
442
+ values={rule.values}
443
+ op={rule.op}
444
+ onChange={(values, op) => onChange(Rule.predefinedType(values, op))}
445
+ />
446
+ )}
447
+
448
+ {rule.kind === 'name' && (
449
+ <NameEditor
450
+ op={rule.op}
451
+ value={rule.value}
452
+ onChange={(op, value) => onChange(Rule.name(op, value))}
453
+ />
454
+ )}
455
+
456
+ {rule.kind === 'property' && (
457
+ <PropertyEditor rule={rule} psetQto={psetQto} onChange={onChange} />
458
+ )}
459
+
460
+ {rule.kind === 'quantity' && (
461
+ <QuantityEditor rule={rule} psetQto={psetQto} onChange={onChange} />
462
+ )}
463
+
464
+ <button
465
+ type="button"
466
+ onClick={onRemove}
467
+ aria-label="Remove rule"
468
+ className="ml-auto rounded p-1 text-muted-foreground hover:bg-zinc-100 hover:text-foreground dark:hover:bg-zinc-800"
469
+ >
470
+ <Trash2 className="h-3 w-3" />
471
+ </button>
472
+ </div>
473
+ );
474
+ }
475
+
476
+ // ── Per-kind editors ──────────────────────────────────────────────────
477
+
478
+ interface SetRuleEditorProps {
479
+ values: string[];
480
+ op: SetOp;
481
+ options: Array<{ label: string; value: string }>;
482
+ onChange: (values: string[], op: SetOp) => void;
483
+ }
484
+
485
+ function SetRuleEditor({ values, op, options, onChange }: SetRuleEditorProps) {
486
+ const toggle = (v: string) => {
487
+ const next = values.includes(v) ? values.filter((x) => x !== v) : [...values, v];
488
+ onChange(next, op);
489
+ };
490
+ return (
491
+ <>
492
+ <OpDropdown ops={SET_OPS} value={op} onChange={(next) => onChange(values, next)} />
493
+ <DropdownMenu>
494
+ <DropdownMenuTrigger asChild>
495
+ <Button variant="outline" size="sm" className="h-7 gap-1 text-xs font-mono">
496
+ {values.length === 0 ? 'Pick values…' : `${values.length} selected`}
497
+ </Button>
498
+ </DropdownMenuTrigger>
499
+ <DropdownMenuContent align="start" className="max-h-72 overflow-y-auto">
500
+ {options.length === 0 && (
501
+ <DropdownMenuItem disabled className="text-muted-foreground italic">
502
+ No options available — load a model first.
503
+ </DropdownMenuItem>
504
+ )}
505
+ {options.map((o) => (
506
+ <DropdownMenuItem
507
+ key={o.value}
508
+ onSelect={(e) => {
509
+ // Keep the menu open for multi-select.
510
+ e.preventDefault();
511
+ toggle(o.value);
512
+ }}
513
+ className="font-mono"
514
+ >
515
+ <span className="mr-2 inline-block w-3 text-center">
516
+ {values.includes(o.value) ? '✓' : ''}
517
+ </span>
518
+ {o.label}
519
+ </DropdownMenuItem>
520
+ ))}
521
+ </DropdownMenuContent>
522
+ </DropdownMenu>
523
+ {values.length > 0 && (
524
+ <div className="flex flex-wrap items-center gap-1">
525
+ {values.map((v) => (
526
+ <span
527
+ key={v}
528
+ className="inline-flex items-center gap-1 rounded bg-zinc-100 px-1.5 py-0.5 text-[10px] font-mono dark:bg-zinc-800"
529
+ >
530
+ {v}
531
+ <button
532
+ type="button"
533
+ aria-label={`Remove ${v}`}
534
+ onClick={() => toggle(v)}
535
+ className="text-muted-foreground hover:text-foreground"
536
+ >
537
+ ×
538
+ </button>
539
+ </span>
540
+ ))}
541
+ </div>
542
+ )}
543
+ </>
544
+ );
545
+ }
546
+
547
+ function PredefinedTypeEditor({
548
+ values,
549
+ op,
550
+ onChange,
551
+ }: {
552
+ values: string[];
553
+ op: SetOp;
554
+ onChange: (values: string[], op: SetOp) => void;
555
+ }) {
556
+ // Predefined types aren't materialised in the parser today — pick
557
+ // them via free-text. The user enters comma-separated values.
558
+ const text = values.join(', ');
559
+ return (
560
+ <>
561
+ <OpDropdown ops={SET_OPS} value={op} onChange={(next) => onChange(values, next)} />
562
+ <Input
563
+ placeholder="e.g. SOLIDWALL, PARTITIONING"
564
+ value={text}
565
+ onChange={(e) =>
566
+ onChange(
567
+ e.target.value.split(',').map((s) => s.trim()).filter((s) => s.length > 0),
568
+ op,
569
+ )
570
+ }
571
+ className="h-7 w-72 text-xs font-mono"
572
+ />
573
+ </>
574
+ );
575
+ }
576
+
577
+ function NameEditor({
578
+ op,
579
+ value,
580
+ onChange,
581
+ }: {
582
+ op: StringOp;
583
+ value: string;
584
+ onChange: (op: StringOp, value: string) => void;
585
+ }) {
586
+ return (
587
+ <>
588
+ <OpDropdown ops={STRING_OPS} value={op} onChange={(next) => onChange(next, value)} />
589
+ <Input
590
+ placeholder="text"
591
+ value={value}
592
+ onChange={(e) => onChange(op, e.target.value)}
593
+ className="h-7 w-56 text-xs font-mono"
594
+ />
595
+ </>
596
+ );
597
+ }
598
+
599
+ interface PropertyEditorProps {
600
+ rule: Extract<FilterRule, { kind: 'property' }>;
601
+ psetQto: RuleRowProps['psetQto'];
602
+ onChange: (next: FilterRule) => void;
603
+ }
604
+
605
+ function PropertyEditor({ rule, psetQto, onChange }: PropertyEditorProps) {
606
+ const psetNames = useMemo(() => (psetQto ? psetQto.psets.map(([n]) => n) : []), [psetQto]);
607
+ const propNames = useMemo(() => {
608
+ if (!psetQto) return [];
609
+ const entry = psetQto.psets.find(([n]) => n === rule.setName);
610
+ return entry ? Array.from(entry[1]) : [];
611
+ }, [psetQto, rule.setName]);
612
+
613
+ const valueless = rule.op === 'isSet' || rule.op === 'isNotSet';
614
+
615
+ return (
616
+ <>
617
+ <FreeOrPickInput
618
+ placeholder="Pset_… (e.g. Pset_WallCommon)"
619
+ value={rule.setName}
620
+ options={psetNames}
621
+ widthClass="w-52"
622
+ onChange={(next) => onChange({ ...rule, setName: next, propertyName: '' })}
623
+ />
624
+ <span className="text-muted-foreground">.</span>
625
+ <FreeOrPickInput
626
+ placeholder="prop name"
627
+ value={rule.propertyName}
628
+ options={propNames}
629
+ widthClass="w-44"
630
+ onChange={(next) => onChange({ ...rule, propertyName: next })}
631
+ />
632
+ <OpDropdown ops={VALUE_OPS} value={rule.op} onChange={(next) => onChange({ ...rule, op: next })} />
633
+ {!valueless && (
634
+ <Input
635
+ placeholder="value"
636
+ value={rule.value}
637
+ onChange={(e) => onChange({ ...rule, value: e.target.value })}
638
+ className="h-7 w-40 text-xs font-mono"
639
+ />
640
+ )}
641
+ </>
642
+ );
643
+ }
644
+
645
+ interface QuantityEditorProps {
646
+ rule: Extract<FilterRule, { kind: 'quantity' }>;
647
+ psetQto: RuleRowProps['psetQto'];
648
+ onChange: (next: FilterRule) => void;
649
+ }
650
+
651
+ function QuantityEditor({ rule, psetQto, onChange }: QuantityEditorProps) {
652
+ const qsetNames = useMemo(() => (psetQto ? psetQto.qtos.map(([n]) => n) : []), [psetQto]);
653
+ const qtyNames = useMemo(() => {
654
+ if (!psetQto) return [];
655
+ const entry = psetQto.qtos.find(([n]) => n === rule.setName);
656
+ return entry ? entry[1].map(([n]) => n) : [];
657
+ }, [psetQto, rule.setName]);
658
+
659
+ return (
660
+ <>
661
+ <FreeOrPickInput
662
+ placeholder="Qto_… (e.g. Qto_WallBaseQuantities)"
663
+ value={rule.setName}
664
+ options={qsetNames}
665
+ widthClass="w-56"
666
+ onChange={(next) => onChange({ ...rule, setName: next, quantityName: '' })}
667
+ />
668
+ <span className="text-muted-foreground">.</span>
669
+ <FreeOrPickInput
670
+ placeholder="quantity name"
671
+ value={rule.quantityName}
672
+ options={qtyNames}
673
+ widthClass="w-44"
674
+ onChange={(next) => onChange({ ...rule, quantityName: next })}
675
+ />
676
+ <OpDropdown ops={NUMERIC_OPS} value={rule.op} onChange={(next) => onChange({ ...rule, op: next })} />
677
+ <Input
678
+ type="number"
679
+ placeholder="value"
680
+ value={rule.value}
681
+ onChange={(e) => onChange({ ...rule, value: Number.parseFloat(e.target.value) || 0 })}
682
+ className="h-7 w-32 text-xs font-mono"
683
+ />
684
+ </>
685
+ );
686
+ }
687
+
688
+ // ── Building-block widgets ───────────────────────────────────────────
689
+
690
+ function OpDropdown<T extends string>({
691
+ ops,
692
+ value,
693
+ onChange,
694
+ }: {
695
+ ops: ReadonlyArray<T>;
696
+ value: T;
697
+ onChange: (next: T) => void;
698
+ }) {
699
+ return (
700
+ <DropdownMenu>
701
+ <DropdownMenuTrigger asChild>
702
+ <Button variant="outline" size="sm" className="h-7 min-w-[3.5rem] gap-1 text-xs font-mono">
703
+ {OP_LABEL[value] ?? value}
704
+ </Button>
705
+ </DropdownMenuTrigger>
706
+ <DropdownMenuContent>
707
+ {ops.map((op) => (
708
+ <DropdownMenuItem key={op} onSelect={() => onChange(op)} className="font-mono">
709
+ {OP_LABEL[op] ?? op}
710
+ <span className="ml-2 text-[10px] text-muted-foreground">{op}</span>
711
+ </DropdownMenuItem>
712
+ ))}
713
+ </DropdownMenuContent>
714
+ </DropdownMenu>
715
+ );
716
+ }
717
+
718
+ /**
719
+ * Free-text input that exposes a small dropdown of known options when
720
+ * the schema knows them. Users can either pick from the menu or type a
721
+ * value not present in the schema (useful for typos / custom psets).
722
+ */
723
+ function FreeOrPickInput({
724
+ placeholder,
725
+ value,
726
+ options,
727
+ widthClass,
728
+ onChange,
729
+ }: {
730
+ placeholder: string;
731
+ value: string;
732
+ options: ReadonlyArray<string>;
733
+ widthClass: string;
734
+ onChange: (next: string) => void;
735
+ }) {
736
+ return (
737
+ <div className="relative inline-flex items-center gap-1">
738
+ <Input
739
+ placeholder={placeholder}
740
+ value={value}
741
+ onChange={(e) => onChange(e.target.value)}
742
+ className={`h-7 ${widthClass} text-xs font-mono`}
743
+ />
744
+ {options.length > 0 && (
745
+ <DropdownMenu>
746
+ <DropdownMenuTrigger asChild>
747
+ <Button variant="ghost" size="sm" className="h-7 px-1 text-[10px] text-muted-foreground" title="Pick from schema">
748
+
749
+ </Button>
750
+ </DropdownMenuTrigger>
751
+ <DropdownMenuContent align="start" className="max-h-72 overflow-y-auto">
752
+ {options.map((o) => (
753
+ <DropdownMenuItem key={o} onSelect={() => onChange(o)} className="font-mono">
754
+ {o}
755
+ </DropdownMenuItem>
756
+ ))}
757
+ </DropdownMenuContent>
758
+ </DropdownMenu>
759
+ )}
760
+ </div>
761
+ );
762
+ }
763
+
764
+ function truncate(s: string, max: number): string {
765
+ return s.length <= max ? s : s.slice(0, max - 1) + '…';
766
+ }