@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,342 @@
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
+ import { describe, it, beforeEach } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { createStore, type StoreApi } from 'zustand/vanilla';
8
+ import { createSearchSlice, type SearchSlice } from './searchSlice.js';
9
+ import type { SearchResult } from '@/lib/search/tier0-scan';
10
+
11
+ function makeResult(modelId: string, expressId: number, name: string): SearchResult {
12
+ return {
13
+ modelId,
14
+ expressId,
15
+ typeName: 'IfcWall',
16
+ name,
17
+ globalId: `g${String(expressId).padStart(21, 'x')}`,
18
+ description: '',
19
+ objectType: '',
20
+ matchField: 'name',
21
+ score: 100,
22
+ };
23
+ }
24
+
25
+ describe('searchSlice — vim cycle', () => {
26
+ let store: StoreApi<SearchSlice>;
27
+ const rs = (name: string, id: number): SearchResult => makeResult('m', id, name);
28
+
29
+ beforeEach(() => {
30
+ store = createStore<SearchSlice>((set, get, api) => createSearchSlice(set, get, api));
31
+ });
32
+
33
+ it('is inactive by default', () => {
34
+ assert.strictEqual(store.getState().searchVimCycle, null);
35
+ });
36
+
37
+ it('enters with a frozen results snapshot and clamped index', () => {
38
+ const results = [rs('a', 1), rs('b', 2), rs('c', 3)];
39
+ store.getState().enterVimCycle('wall', results, 1);
40
+ const cycle = store.getState().searchVimCycle;
41
+ assert.ok(cycle);
42
+ assert.strictEqual(cycle!.query, 'wall');
43
+ assert.strictEqual(cycle!.index, 1);
44
+ assert.strictEqual(cycle!.results, results, 'snapshot is the same reference');
45
+ });
46
+
47
+ it('clamps entry index into range', () => {
48
+ const results = [rs('a', 1), rs('b', 2)];
49
+ store.getState().enterVimCycle('w', results, 99);
50
+ assert.strictEqual(store.getState().searchVimCycle!.index, 1);
51
+
52
+ store.getState().enterVimCycle('w', results, -5);
53
+ assert.strictEqual(store.getState().searchVimCycle!.index, 0);
54
+ });
55
+
56
+ it('no-ops when called with an empty results list', () => {
57
+ store.getState().enterVimCycle('w', [], 0);
58
+ assert.strictEqual(store.getState().searchVimCycle, null);
59
+ });
60
+
61
+ it('step +1 advances; step -1 retreats; both wrap around', () => {
62
+ const results = [rs('a', 1), rs('b', 2), rs('c', 3)];
63
+ store.getState().enterVimCycle('w', results, 0);
64
+
65
+ store.getState().stepVimCycle(1);
66
+ assert.strictEqual(store.getState().searchVimCycle!.index, 1);
67
+ store.getState().stepVimCycle(1);
68
+ assert.strictEqual(store.getState().searchVimCycle!.index, 2);
69
+ store.getState().stepVimCycle(1);
70
+ assert.strictEqual(store.getState().searchVimCycle!.index, 0, 'wraps forward');
71
+
72
+ store.getState().stepVimCycle(-1);
73
+ assert.strictEqual(store.getState().searchVimCycle!.index, 2, 'wraps backward');
74
+ });
75
+
76
+ it('step creates a new cycle object (for React change detection)', () => {
77
+ const results = [rs('a', 1), rs('b', 2)];
78
+ store.getState().enterVimCycle('w', results, 0);
79
+ const first = store.getState().searchVimCycle;
80
+ store.getState().stepVimCycle(1);
81
+ const second = store.getState().searchVimCycle;
82
+ assert.notStrictEqual(first, second, 'new object reference per step');
83
+ assert.strictEqual(second!.results, first!.results, 'results snapshot is stable');
84
+ });
85
+
86
+ it('step is a no-op when inactive', () => {
87
+ store.getState().stepVimCycle(1);
88
+ assert.strictEqual(store.getState().searchVimCycle, null);
89
+ });
90
+
91
+ it('exits cleanly', () => {
92
+ const results = [rs('a', 1), rs('b', 2)];
93
+ store.getState().enterVimCycle('w', results, 0);
94
+ store.getState().exitVimCycle();
95
+ assert.strictEqual(store.getState().searchVimCycle, null);
96
+ });
97
+
98
+ it('typing breaks the cycle (setSearchQuery clears vimCycle)', () => {
99
+ const results = [rs('a', 1), rs('b', 2)];
100
+ store.getState().enterVimCycle('w', results, 0);
101
+ store.getState().setSearchQuery('door');
102
+ assert.strictEqual(store.getState().searchVimCycle, null);
103
+ });
104
+
105
+ it('resetSearch clears the cycle', () => {
106
+ const results = [rs('a', 1), rs('b', 2)];
107
+ store.getState().enterVimCycle('w', results, 0);
108
+ store.getState().resetSearch();
109
+ assert.strictEqual(store.getState().searchVimCycle, null);
110
+ });
111
+
112
+ it('closeSearch preserves the cycle (user can hit n/N after popover closes)', () => {
113
+ const results = [rs('a', 1), rs('b', 2)];
114
+ store.getState().enterVimCycle('w', results, 0);
115
+ store.getState().closeSearch();
116
+ assert.ok(store.getState().searchVimCycle, 'cycle still active after popover close');
117
+ });
118
+ });
119
+
120
+ describe('searchSlice — advanced modal state', () => {
121
+ let store: StoreApi<SearchSlice>;
122
+
123
+ beforeEach(() => {
124
+ store = createStore<SearchSlice>((set, get, api) => createSearchSlice(set, get, api));
125
+ });
126
+
127
+ it('modal is closed by default with "all" field filter and null model filter', () => {
128
+ const s = store.getState();
129
+ assert.strictEqual(s.searchModalOpen, false);
130
+ assert.strictEqual(s.searchFieldFilter, 'all');
131
+ assert.strictEqual(s.searchModelFilter, null);
132
+ });
133
+
134
+ it('setSearchModalOpen + toggleSearchModal flip the open flag', () => {
135
+ store.getState().setSearchModalOpen(true);
136
+ assert.strictEqual(store.getState().searchModalOpen, true);
137
+ store.getState().toggleSearchModal();
138
+ assert.strictEqual(store.getState().searchModalOpen, false);
139
+ store.getState().toggleSearchModal();
140
+ assert.strictEqual(store.getState().searchModalOpen, true);
141
+ });
142
+
143
+ it('setSearchFieldFilter updates the chip selection', () => {
144
+ store.getState().setSearchFieldFilter('name');
145
+ assert.strictEqual(store.getState().searchFieldFilter, 'name');
146
+ store.getState().setSearchFieldFilter('all');
147
+ assert.strictEqual(store.getState().searchFieldFilter, 'all');
148
+ });
149
+
150
+ it('toggleSearchModelFilter materialises the include set on first toggle', () => {
151
+ const available = ['m1', 'm2', 'm3'];
152
+ store.getState().toggleSearchModelFilter('m2', available);
153
+ const filter = store.getState().searchModelFilter;
154
+ assert.ok(filter);
155
+ assert.deepStrictEqual(Array.from(filter!).sort(), ['m1', 'm3']);
156
+ });
157
+
158
+ it('toggleSearchModelFilter re-including the last excluded model collapses back to null', () => {
159
+ const available = ['m1', 'm2'];
160
+ store.getState().toggleSearchModelFilter('m1', available);
161
+ // now filter is {m2}
162
+ store.getState().toggleSearchModelFilter('m1', available);
163
+ // user re-included m1 → all available included → collapse to null
164
+ assert.strictEqual(store.getState().searchModelFilter, null);
165
+ });
166
+
167
+ it('toggleSearchModelFilter successive toggles on different models', () => {
168
+ const available = ['a', 'b', 'c'];
169
+ store.getState().toggleSearchModelFilter('a', available);
170
+ store.getState().toggleSearchModelFilter('b', available);
171
+ const filter = store.getState().searchModelFilter;
172
+ assert.ok(filter);
173
+ assert.deepStrictEqual(Array.from(filter!).sort(), ['c']);
174
+ });
175
+
176
+ it('clearSearchModelFilter resets to null', () => {
177
+ const available = ['a', 'b', 'c'];
178
+ store.getState().toggleSearchModelFilter('a', available);
179
+ store.getState().clearSearchModelFilter();
180
+ assert.strictEqual(store.getState().searchModelFilter, null);
181
+ });
182
+ });
183
+
184
+ describe('searchSlice — filter rule actions', () => {
185
+ let store: StoreApi<SearchSlice>;
186
+
187
+ beforeEach(() => {
188
+ store = createStore<SearchSlice>((set, get, api) => createSearchSlice(set, get, api));
189
+ });
190
+
191
+ it('starts with the empty filter state', () => {
192
+ const s = store.getState();
193
+ assert.deepStrictEqual(s.searchFilter.rules, []);
194
+ assert.strictEqual(s.searchFilter.combinator, 'AND');
195
+ assert.strictEqual(s.searchFilter.limit, 500);
196
+ assert.strictEqual(s.searchFilterSchema.size, 0);
197
+ assert.strictEqual(s.searchFilterResult, null);
198
+ assert.strictEqual(s.searchFilterRunning, false);
199
+ assert.strictEqual(s.searchFilterError, null);
200
+ });
201
+
202
+ it('setFilterCombinator / setFilterLimit patch the filter state', () => {
203
+ store.getState().setFilterCombinator('OR');
204
+ assert.strictEqual(store.getState().searchFilter.combinator, 'OR');
205
+ store.getState().setFilterLimit(100);
206
+ assert.strictEqual(store.getState().searchFilter.limit, 100);
207
+ });
208
+
209
+ it('addFilterRule appends a rule', () => {
210
+ const r = { kind: 'ifcType' as const, values: ['IfcWall'], op: 'in' as const };
211
+ store.getState().addFilterRule(r);
212
+ const rules = store.getState().searchFilter.rules;
213
+ assert.strictEqual(rules.length, 1);
214
+ assert.deepStrictEqual(rules[0], r);
215
+ });
216
+
217
+ it('updateFilterRule replaces a rule at the given index', () => {
218
+ const r1 = { kind: 'ifcType' as const, values: ['IfcWall'], op: 'in' as const };
219
+ const r2 = { kind: 'ifcType' as const, values: ['IfcDoor'], op: 'in' as const };
220
+ store.getState().addFilterRule(r1);
221
+ store.getState().updateFilterRule(0, r2);
222
+ assert.deepStrictEqual(store.getState().searchFilter.rules[0], r2);
223
+ });
224
+
225
+ it('updateFilterRule is a no-op for out-of-range indices', () => {
226
+ const before = store.getState().searchFilter;
227
+ store.getState().updateFilterRule(5, {
228
+ kind: 'ifcType' as const, values: [], op: 'in' as const,
229
+ });
230
+ assert.strictEqual(store.getState().searchFilter, before);
231
+ });
232
+
233
+ it('removeFilterRule drops the rule at the given index', () => {
234
+ const r1 = { kind: 'ifcType' as const, values: ['IfcWall'], op: 'in' as const };
235
+ const r2 = { kind: 'name' as const, op: 'contains' as const, value: 'EXT' };
236
+ store.getState().addFilterRule(r1);
237
+ store.getState().addFilterRule(r2);
238
+ store.getState().removeFilterRule(0);
239
+ const rules = store.getState().searchFilter.rules;
240
+ assert.strictEqual(rules.length, 1);
241
+ assert.strictEqual(rules[0].kind, 'name');
242
+ });
243
+
244
+ it('removeFilterRule is a no-op for out-of-range indices', () => {
245
+ const before = store.getState().searchFilter;
246
+ store.getState().removeFilterRule(2);
247
+ assert.strictEqual(store.getState().searchFilter, before);
248
+ });
249
+
250
+ it('clearFilterRules empties rules but preserves combinator + limit', () => {
251
+ store.getState().setFilterCombinator('OR');
252
+ store.getState().setFilterLimit(123);
253
+ store.getState().addFilterRule({
254
+ kind: 'ifcType' as const, values: ['IfcWall'], op: 'in' as const,
255
+ });
256
+ store.getState().clearFilterRules();
257
+ const f = store.getState().searchFilter;
258
+ assert.deepStrictEqual(f.rules, []);
259
+ assert.strictEqual(f.combinator, 'OR');
260
+ assert.strictEqual(f.limit, 123);
261
+ });
262
+
263
+ it('setSearchFilter replaces the whole filter state', () => {
264
+ const next = {
265
+ rules: [{ kind: 'name' as const, op: 'eq' as const, value: 'X' }],
266
+ combinator: 'OR' as const,
267
+ limit: 42,
268
+ };
269
+ store.getState().setSearchFilter(next);
270
+ assert.strictEqual(store.getState().searchFilter, next);
271
+ });
272
+ });
273
+
274
+ describe('searchSlice — Filter run result/error pairing', () => {
275
+ let store: StoreApi<SearchSlice>;
276
+
277
+ beforeEach(() => {
278
+ store = createStore<SearchSlice>((set, get, api) => createSearchSlice(set, get, api));
279
+ });
280
+
281
+ it('setSearchFilterResult clears the prior error (success supersedes failure)', () => {
282
+ store.getState().setSearchFilterError('boom');
283
+ store.getState().setSearchFilterResult({ columns: ['a'], rows: [[1]], runMs: 5 });
284
+ assert.strictEqual(store.getState().searchFilterError, null);
285
+ assert.deepStrictEqual(store.getState().searchFilterResult?.columns, ['a']);
286
+ });
287
+
288
+ it('setSearchFilterError preserves the prior result (notebook semantics)', () => {
289
+ store.getState().setSearchFilterResult({ columns: ['a'], rows: [[1]], runMs: 5 });
290
+ store.getState().setSearchFilterError('boom');
291
+ // Error and result coexist — UI stacks the error above the last good
292
+ // result. Without this, debugging a failed run loses the previous
293
+ // table the user was reading.
294
+ assert.strictEqual(store.getState().searchFilterError, 'boom');
295
+ assert.deepStrictEqual(store.getState().searchFilterResult?.columns, ['a']);
296
+ });
297
+
298
+ it('callers can wipe both via explicit nulls', () => {
299
+ store.getState().setSearchFilterResult({ columns: ['a'], rows: [], runMs: 1 });
300
+ store.getState().setSearchFilterError('x');
301
+ store.getState().setSearchFilterResult(null);
302
+ store.getState().setSearchFilterError(null);
303
+ assert.strictEqual(store.getState().searchFilterError, null);
304
+ assert.strictEqual(store.getState().searchFilterResult, null);
305
+ });
306
+ });
307
+
308
+ describe('searchSlice — schema cache', () => {
309
+ let store: StoreApi<SearchSlice>;
310
+
311
+ beforeEach(() => {
312
+ store = createStore<SearchSlice>((set, get, api) => createSearchSlice(set, get, api));
313
+ });
314
+
315
+ it('setFilterSchema inserts a basic entry with null psetQto', () => {
316
+ store.getState().setFilterSchema('m1', { storeys: [['L1', 0]], ifcTypes: ['IfcWall'] });
317
+ const entry = store.getState().searchFilterSchema.get('m1');
318
+ assert.ok(entry);
319
+ assert.deepStrictEqual(entry!.basic.ifcTypes, ['IfcWall']);
320
+ assert.strictEqual(entry!.psetQto, null);
321
+ });
322
+
323
+ it('setFilterSchema preserves existing psetQto when re-setting basic', () => {
324
+ store.getState().setFilterSchema('m1', { storeys: [], ifcTypes: ['IfcWall'] });
325
+ store.getState().setFilterPsetQtoSchema('m1', { psets: [['Pset_X', ['P']]], qtos: [] });
326
+ store.getState().setFilterSchema('m1', { storeys: [], ifcTypes: ['IfcWall', 'IfcDoor'] });
327
+ const entry = store.getState().searchFilterSchema.get('m1');
328
+ assert.ok(entry?.psetQto);
329
+ assert.deepStrictEqual(entry!.psetQto!.psets, [['Pset_X', ['P']]]);
330
+ });
331
+
332
+ it('setFilterPsetQtoSchema is a no-op without a prior basic entry', () => {
333
+ store.getState().setFilterPsetQtoSchema('mX', { psets: [], qtos: [] });
334
+ assert.strictEqual(store.getState().searchFilterSchema.has('mX'), false);
335
+ });
336
+
337
+ it('removeFilterSchema drops the model entry', () => {
338
+ store.getState().setFilterSchema('m1', { storeys: [], ifcTypes: [] });
339
+ store.getState().removeFilterSchema('m1');
340
+ assert.strictEqual(store.getState().searchFilterSchema.has('m1'), false);
341
+ });
342
+ });
@@ -0,0 +1,341 @@
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
+ * Search state slice.
7
+ *
8
+ * Two surfaces — both backed by the same per-model Tier-1 indexes:
9
+ * 1. Inline toolbar field (Tier-0/Tier-1 fuzzy text scan).
10
+ * 2. Advanced modal with two tabs:
11
+ * - Search: same Tier-0/Tier-1 scan, virtualised + chip filters
12
+ * (matchField, model include).
13
+ * - Filter: chip-based structured rules over the unified
14
+ * `FilterRule[]` shape, evaluated by the in-memory path-B
15
+ * evaluator. No DuckDB — Builder + run is the whole UI.
16
+ *
17
+ * The path-B evaluator is the only run path; the previous DuckDB
18
+ * editor / SQL emitter / Run SQL button were removed once it became
19
+ * clear the in-memory engine handles 4M-entity models with the index
20
+ * prefilter + cheap-first ordering + chunked yielding pattern.
21
+ */
22
+
23
+ import type { StateCreator } from 'zustand';
24
+ import type { Tier1Index } from '@/lib/search/tier1-index';
25
+ import type { SearchResult, MatchField } from '@/lib/search/tier0-scan';
26
+ import type { FilterRule, Combinator } from '@/lib/search/filter-rules';
27
+ import type { FilterSchema, PsetQtoSchema } from '@/lib/search/filter-schema';
28
+
29
+ /** Index lifecycle state for a single model. */
30
+ export type Tier1IndexStatus = 'pending' | 'building' | 'ready' | 'error';
31
+
32
+ export interface Tier1IndexRecord {
33
+ status: Tier1IndexStatus;
34
+ /** Only present when status === 'ready'. */
35
+ index?: Tier1Index;
36
+ /** Progress in [0, 1] while status === 'building'. */
37
+ progress?: number;
38
+ /** Diagnostic message when status === 'error'. */
39
+ error?: string;
40
+ }
41
+
42
+ /**
43
+ * Vim-style search cycle — enters on Enter-commit from the inline field.
44
+ * While active, `n` / `N` step through the frozen result list, framing
45
+ * each match, and a small hint badge is shown near the search field.
46
+ * Any typing, Esc, or clicking elsewhere exits the cycle.
47
+ */
48
+ export interface SearchVimCycleState {
49
+ /** The query string at the moment of commit (shown in the hint). */
50
+ query: string;
51
+ /** Frozen snapshot of results at commit; stable for the cycle's lifetime. */
52
+ results: SearchResult[];
53
+ /** 0-based index of the currently selected result. */
54
+ index: number;
55
+ }
56
+
57
+ /**
58
+ * Chip filter for the advanced modal — narrows results to rows whose
59
+ * `matchField` equals the selected value, or 'all' for no restriction.
60
+ */
61
+ export type SearchFieldFilter = MatchField | 'all';
62
+
63
+ /**
64
+ * Tabular result from a Filter run. Flat snapshot so the modal can
65
+ * re-render rows without holding live evaluator state.
66
+ */
67
+ export interface SearchFilterResult {
68
+ columns: string[];
69
+ rows: unknown[][];
70
+ runMs: number;
71
+ }
72
+
73
+ /**
74
+ * Unified filter state — chip rules + AND/OR + result cap. Drives the
75
+ * path-B evaluator in `lib/search/filter-evaluate.ts`.
76
+ */
77
+ export interface SearchFilterStateValue {
78
+ rules: FilterRule[];
79
+ combinator: Combinator;
80
+ /** Result cap. `0` = no cap (evaluator's internal default applies). */
81
+ limit: number;
82
+ }
83
+
84
+ export function emptyFilterState(): SearchFilterStateValue {
85
+ return { rules: [], combinator: 'AND', limit: 500 };
86
+ }
87
+
88
+ /**
89
+ * Per-model filter-schema cache for chip dropdowns. Populated on demand
90
+ * when the modal opens; cleared when a model is removed.
91
+ */
92
+ export interface FilterSchemaCacheEntry {
93
+ /** Cheap pass — storeys + ifcTypes. Always populated when entry exists. */
94
+ basic: FilterSchema;
95
+ /** Expensive pass — pset / qto names. Lazy; null until first request. */
96
+ psetQto: PsetQtoSchema | null;
97
+ }
98
+
99
+ export interface SearchSlice {
100
+ /** Current input value (debounced consumers may stage their own copy). */
101
+ searchQuery: string;
102
+ /** Popover open below the inline field. */
103
+ searchOpen: boolean;
104
+ /** Currently highlighted result index in the popover (arrow-key nav). */
105
+ searchHighlightIndex: number;
106
+ /** Per-model Tier-1 index lifecycle (modelId → record). */
107
+ searchIndexes: Map<string, Tier1IndexRecord>;
108
+ /** Active vim-style cycle, or null when not cycling. */
109
+ searchVimCycle: SearchVimCycleState | null;
110
+ /** Advanced search modal (⌘⇧F) is open. */
111
+ searchModalOpen: boolean;
112
+ /** Field chip filter active inside the modal. Defaults to 'all'. */
113
+ searchFieldFilter: SearchFieldFilter;
114
+ /** Per-modelId include filter inside the modal. `null` means all models included. */
115
+ searchModelFilter: Set<string> | null;
116
+
117
+ /** Latest Filter run result (or null on fresh tab open / error). */
118
+ searchFilterResult: SearchFilterResult | null;
119
+ /** Whether a Filter run is currently in flight. */
120
+ searchFilterRunning: boolean;
121
+ /** Latest Filter error message — set when the evaluator throws. */
122
+ searchFilterError: string | null;
123
+ /** Filter rule state — chip rules, combinator, limit. */
124
+ searchFilter: SearchFilterStateValue;
125
+ /** Per-model filter-schema cache (lazy). */
126
+ searchFilterSchema: Map<string, FilterSchemaCacheEntry>;
127
+
128
+ setSearchQuery: (query: string) => void;
129
+ setSearchOpen: (open: boolean) => void;
130
+ setSearchHighlightIndex: (index: number) => void;
131
+ /** Convenience: close popover and reset highlight (preserves query). */
132
+ closeSearch: () => void;
133
+ /** Convenience: clear query and close popover. */
134
+ resetSearch: () => void;
135
+
136
+ /** Replace (or insert) the index record for a model. */
137
+ setSearchIndexRecord: (modelId: string, record: Tier1IndexRecord) => void;
138
+ /** Drop the index record for a model (called when a model is removed). */
139
+ removeSearchIndexRecord: (modelId: string) => void;
140
+
141
+ /** Enter vim cycle mode with a frozen result snapshot at `index`. */
142
+ enterVimCycle: (query: string, results: SearchResult[], index: number) => void;
143
+ /** Exit vim cycle mode (no-op when inactive). */
144
+ exitVimCycle: () => void;
145
+ /** Advance the cycle by +1 / -1, wrapping around. */
146
+ stepVimCycle: (delta: 1 | -1) => void;
147
+
148
+ setSearchModalOpen: (open: boolean) => void;
149
+ toggleSearchModal: () => void;
150
+ setSearchFieldFilter: (filter: SearchFieldFilter) => void;
151
+ /** Toggle a model in/out of the include filter. If the filter is null,
152
+ * the first toggle materialises it as "all models except this one". */
153
+ toggleSearchModelFilter: (modelId: string, availableModelIds: readonly string[]) => void;
154
+ /** Clear model filter (null → all models included). */
155
+ clearSearchModelFilter: () => void;
156
+
157
+ // ── Filter run state ──────────────────────────────────────────────
158
+ setSearchFilterRunning: (running: boolean) => void;
159
+ /** Successful run — sets the result and clears any prior error
160
+ * (the new run supersedes the old failure). */
161
+ setSearchFilterResult: (result: SearchFilterResult | null) => void;
162
+ /** Error path — keeps the prior result visible while showing the
163
+ * error above it (notebook-style pairing). */
164
+ setSearchFilterError: (error: string | null) => void;
165
+
166
+ // ── Filter-rule actions ───────────────────────────────────────────
167
+ /** Replace the whole filter state — used by Reset and preset loading. */
168
+ setSearchFilter: (state: SearchFilterStateValue) => void;
169
+ setFilterCombinator: (combinator: Combinator) => void;
170
+ setFilterLimit: (limit: number) => void;
171
+ addFilterRule: (rule: FilterRule) => void;
172
+ updateFilterRule: (index: number, rule: FilterRule) => void;
173
+ removeFilterRule: (index: number) => void;
174
+ /** Drop every rule but keep combinator + limit. */
175
+ clearFilterRules: () => void;
176
+
177
+ // ── Schema cache actions ──────────────────────────────────────────
178
+ setFilterSchema: (modelId: string, basic: FilterSchema) => void;
179
+ setFilterPsetQtoSchema: (modelId: string, psetQto: PsetQtoSchema) => void;
180
+ removeFilterSchema: (modelId: string) => void;
181
+ }
182
+
183
+ export const createSearchSlice: StateCreator<SearchSlice, [], [], SearchSlice> = (set) => ({
184
+ searchQuery: '',
185
+ searchOpen: false,
186
+ searchHighlightIndex: 0,
187
+ searchIndexes: new Map(),
188
+ searchVimCycle: null,
189
+ searchModalOpen: false,
190
+ searchFieldFilter: 'all',
191
+ searchModelFilter: null,
192
+ searchFilterResult: null,
193
+ searchFilterRunning: false,
194
+ searchFilterError: null,
195
+ searchFilter: emptyFilterState(),
196
+ searchFilterSchema: new Map(),
197
+
198
+ // Typing or programmatically changing the query breaks out of vim cycle —
199
+ // the user is re-searching, not stepping through a committed result list.
200
+ setSearchQuery: (searchQuery) => set({ searchQuery, searchHighlightIndex: 0, searchVimCycle: null }),
201
+ setSearchOpen: (searchOpen) => set({ searchOpen }),
202
+ setSearchHighlightIndex: (searchHighlightIndex) => set({ searchHighlightIndex }),
203
+
204
+ closeSearch: () => set({ searchOpen: false, searchHighlightIndex: 0 }),
205
+ resetSearch: () =>
206
+ set({ searchQuery: '', searchOpen: false, searchHighlightIndex: 0, searchVimCycle: null }),
207
+
208
+ setSearchIndexRecord: (modelId, record) =>
209
+ set((state) => {
210
+ const next = new Map(state.searchIndexes);
211
+ next.set(modelId, record);
212
+ return { searchIndexes: next };
213
+ }),
214
+
215
+ removeSearchIndexRecord: (modelId) =>
216
+ set((state) => {
217
+ if (!state.searchIndexes.has(modelId)) return {};
218
+ const next = new Map(state.searchIndexes);
219
+ next.delete(modelId);
220
+ return { searchIndexes: next };
221
+ }),
222
+
223
+ enterVimCycle: (query, results, index) => {
224
+ if (results.length === 0) return;
225
+ const clamped = Math.max(0, Math.min(index, results.length - 1));
226
+ set({ searchVimCycle: { query, results, index: clamped } });
227
+ },
228
+
229
+ exitVimCycle: () => set({ searchVimCycle: null }),
230
+
231
+ stepVimCycle: (delta) =>
232
+ set((state) => {
233
+ const cycle = state.searchVimCycle;
234
+ if (!cycle || cycle.results.length === 0) return {};
235
+ const len = cycle.results.length;
236
+ const next = (cycle.index + delta + len) % len;
237
+ return { searchVimCycle: { ...cycle, index: next } };
238
+ }),
239
+
240
+ setSearchModalOpen: (searchModalOpen) => set({ searchModalOpen }),
241
+ toggleSearchModal: () => set((state) => ({ searchModalOpen: !state.searchModalOpen })),
242
+ setSearchFieldFilter: (searchFieldFilter) => set({ searchFieldFilter }),
243
+
244
+ toggleSearchModelFilter: (modelId, availableModelIds) =>
245
+ set((state) => {
246
+ const current = state.searchModelFilter;
247
+ // First toggle from the "all included" null state materialises an
248
+ // explicit set containing every OTHER model — checking the box that
249
+ // was "on by default" and unchecking it feels the same to the user.
250
+ if (current === null) {
251
+ const next = new Set(availableModelIds);
252
+ next.delete(modelId);
253
+ return { searchModelFilter: next };
254
+ }
255
+ const next = new Set(current);
256
+ if (next.has(modelId)) next.delete(modelId);
257
+ else next.add(modelId);
258
+ // If the user has re-included every available model, collapse back
259
+ // to null so the "all included" default re-applies when a new model
260
+ // loads later.
261
+ let allIncluded = true;
262
+ for (const id of availableModelIds) {
263
+ if (!next.has(id)) { allIncluded = false; break; }
264
+ }
265
+ return { searchModelFilter: allIncluded ? null : next };
266
+ }),
267
+
268
+ clearSearchModelFilter: () => set({ searchModelFilter: null }),
269
+
270
+ setSearchFilterRunning: (searchFilterRunning) => set({ searchFilterRunning }),
271
+ // Notebook-style pairing: a successful run clears the prior error
272
+ // (new result supersedes failure) but an error keeps the prior result
273
+ // on screen so the user doesn't lose their last good table while
274
+ // debugging.
275
+ setSearchFilterResult: (searchFilterResult) => set({ searchFilterResult, searchFilterError: null }),
276
+ setSearchFilterError: (searchFilterError) => set({ searchFilterError }),
277
+
278
+ setSearchFilter: (searchFilter) => set({ searchFilter }),
279
+
280
+ setFilterCombinator: (combinator) =>
281
+ set((state) => ({ searchFilter: { ...state.searchFilter, combinator } })),
282
+
283
+ setFilterLimit: (limit) =>
284
+ set((state) => ({ searchFilter: { ...state.searchFilter, limit } })),
285
+
286
+ addFilterRule: (rule) =>
287
+ set((state) => ({
288
+ searchFilter: {
289
+ ...state.searchFilter,
290
+ rules: [...state.searchFilter.rules, rule],
291
+ },
292
+ })),
293
+
294
+ updateFilterRule: (index, rule) =>
295
+ set((state) => {
296
+ const rules = state.searchFilter.rules;
297
+ if (index < 0 || index >= rules.length) return {};
298
+ const next = rules.slice();
299
+ next[index] = rule;
300
+ return { searchFilter: { ...state.searchFilter, rules: next } };
301
+ }),
302
+
303
+ removeFilterRule: (index) =>
304
+ set((state) => {
305
+ const rules = state.searchFilter.rules;
306
+ if (index < 0 || index >= rules.length) return {};
307
+ const next = rules.slice();
308
+ next.splice(index, 1);
309
+ return { searchFilter: { ...state.searchFilter, rules: next } };
310
+ }),
311
+
312
+ clearFilterRules: () =>
313
+ set((state) => ({ searchFilter: { ...state.searchFilter, rules: [] } })),
314
+
315
+ setFilterSchema: (modelId, basic) =>
316
+ set((state) => {
317
+ const next = new Map(state.searchFilterSchema);
318
+ const existing = next.get(modelId);
319
+ next.set(modelId, { basic, psetQto: existing?.psetQto ?? null });
320
+ return { searchFilterSchema: next };
321
+ }),
322
+
323
+ setFilterPsetQtoSchema: (modelId, psetQto) =>
324
+ set((state) => {
325
+ const next = new Map(state.searchFilterSchema);
326
+ const existing = next.get(modelId);
327
+ // Only meaningful once a basic schema has been set — the modal
328
+ // calls discoverFilterSchema first then queues the heavy pass.
329
+ if (!existing) return {};
330
+ next.set(modelId, { basic: existing.basic, psetQto });
331
+ return { searchFilterSchema: next };
332
+ }),
333
+
334
+ removeFilterSchema: (modelId) =>
335
+ set((state) => {
336
+ if (!state.searchFilterSchema.has(modelId)) return {};
337
+ const next = new Map(state.searchFilterSchema);
338
+ next.delete(modelId);
339
+ return { searchFilterSchema: next };
340
+ }),
341
+ });