@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
@@ -8,7 +8,7 @@ import { buildErrorFeedbackContent } from './chatSlice.js';
8
8
  import { create } from 'zustand';
9
9
  import { createChatSlice, type ChatSlice } from './chatSlice.js';
10
10
  import { createPatchDiagnostic, createPreflightDiagnostic } from '../../lib/llm/script-diagnostics.js';
11
- import { DEFAULT_FREE_MODEL, DEFAULT_PRO_MODEL } from '../../lib/llm/models.js';
11
+ import { DEFAULT_FREE_MODEL, DEFAULT_BYOK_MODEL } from '../../lib/llm/models.js';
12
12
 
13
13
  function withMockLocalStorage(fn: () => void) {
14
14
  const original = globalThis.localStorage;
@@ -217,52 +217,14 @@ test('clearChatMessages resets streaming state as well as persisted messages', (
217
217
  assert.deepEqual(useChatStore.getState().chatAttachments, []);
218
218
  });
219
219
 
220
- test('switchChatUserContext restores per-user history and coerces disallowed models', () => {
221
- withMockLocalStorage(() => {
222
- globalThis.localStorage.setItem('ifc-lite-chat-model:user-a', DEFAULT_PRO_MODEL.id);
223
- globalThis.localStorage.setItem('ifc-lite-chat-messages:user-a', JSON.stringify([
224
- {
225
- id: 'persisted-a',
226
- role: 'user',
227
- content: 'hello from A',
228
- createdAt: 1,
229
- },
230
- ]));
231
- globalThis.localStorage.setItem('ifc-lite-chat-model:user-b', DEFAULT_FREE_MODEL.id);
232
- globalThis.localStorage.setItem('ifc-lite-chat-messages:user-b', JSON.stringify([
233
- {
234
- id: 'persisted-b',
235
- role: 'assistant',
236
- content: 'hello from B',
237
- createdAt: 2,
238
- },
239
- ]));
240
-
241
- const useChatStore = create<ChatSlice>()((...args) => createChatSlice(...args));
242
- useChatStore.getState().switchChatUserContext('user-a', true, { restoreMessages: true });
243
-
244
- assert.equal(useChatStore.getState().chatActiveModel, DEFAULT_PRO_MODEL.id);
245
- assert.equal(useChatStore.getState().chatMessages[0]?.id, 'persisted-a');
246
-
247
- useChatStore.getState().switchChatUserContext('user-b', false, {
248
- clearPersistedCurrent: true,
249
- restoreMessages: true,
250
- });
251
-
252
- assert.equal(useChatStore.getState().chatActiveModel, DEFAULT_FREE_MODEL.id);
253
- assert.equal(useChatStore.getState().chatMessages[0]?.id, 'persisted-b');
254
- assert.equal(globalThis.localStorage.getItem('ifc-lite-chat-messages:user-a'), null);
255
- });
256
- });
257
-
258
- test('setChatHasPro falls back to a free model when entitlement is removed', () => {
220
+ test('setChatHasByokKey falls back to a free model when keys are removed', () => {
259
221
  const useChatStore = create<ChatSlice>()((...args) => createChatSlice(...args));
260
- useChatStore.getState().setChatHasPro(true);
261
- useChatStore.getState().setChatActiveModel(DEFAULT_PRO_MODEL.id);
222
+ useChatStore.getState().setChatHasByokKey(true);
223
+ useChatStore.getState().setChatActiveModel(DEFAULT_BYOK_MODEL.id);
262
224
 
263
- useChatStore.getState().setChatHasPro(false);
225
+ useChatStore.getState().setChatHasByokKey(false);
264
226
 
265
- assert.equal(useChatStore.getState().chatHasPro, false);
227
+ assert.equal(useChatStore.getState().chatHasByokKey, false);
266
228
  assert.equal(useChatStore.getState().chatActiveModel, DEFAULT_FREE_MODEL.id);
267
229
  });
268
230
 
@@ -291,35 +253,3 @@ test('removeChatAttachment only removes the targeted attachment id', () => {
291
253
  );
292
254
  });
293
255
 
294
- test('switchChatUserContext ignores malformed persisted messages', () => {
295
- withMockLocalStorage(() => {
296
- globalThis.localStorage.setItem('ifc-lite-chat-messages:user-a', JSON.stringify([
297
- {
298
- id: 'valid',
299
- role: 'user',
300
- content: 'hello',
301
- createdAt: 1,
302
- attachments: [
303
- { id: 'att-1', name: 'ok.csv', type: 'text/csv', size: 20, textContent: 'a,b\n1,2' },
304
- { name: 'missing-id.csv', type: 'text/csv', size: 20 },
305
- ],
306
- },
307
- {
308
- id: 123,
309
- role: 'assistant',
310
- content: 'bad',
311
- createdAt: 2,
312
- },
313
- ]));
314
-
315
- const useChatStore = create<ChatSlice>()((...args) => createChatSlice(...args));
316
- useChatStore.getState().switchChatUserContext('user-a', false, { restoreMessages: true });
317
-
318
- assert.equal(useChatStore.getState().chatMessages.length, 1);
319
- assert.equal(useChatStore.getState().chatMessages[0]?.id, 'valid');
320
- assert.deepEqual(
321
- useChatStore.getState().chatMessages[0]?.attachments?.map((attachment) => attachment.id),
322
- ['att-1'],
323
- );
324
- });
325
- });
@@ -13,6 +13,7 @@ import { coerceModelForEntitlement, DEFAULT_FREE_MODEL } from '../../lib/llm/mod
13
13
  import { extractCodeBlocks } from '../../lib/llm/code-extractor.js';
14
14
  import type { ScriptDiagnostic } from '../../lib/llm/script-diagnostics.js';
15
15
  import { formatDiagnosticsForPrompt, getPrimaryRootCause, groupDiagnosticsByRootCause } from '../../lib/llm/script-diagnostics.js';
16
+ import { hasAnyApiKey as hasAnyApiKeyAtInit } from '../../services/api-keys.js';
16
17
 
17
18
  const MODEL_STORAGE_KEY = 'ifc-lite-chat-model';
18
19
  const MESSAGES_STORAGE_KEY = 'ifc-lite-chat-messages';
@@ -43,11 +44,9 @@ export interface ChatSlice {
43
44
  chatPendingRepairRequest: ChatRepairRequest | null;
44
45
  /** Auto-captured viewport screenshot (base64 data URL) to include with next LLM message */
45
46
  chatViewportScreenshot: string | null;
46
- /** Clerk JWT for authenticated API calls (null for anonymous/free tier) */
47
- chatAuthToken: string | null;
48
- /** Whether the current user has a pro subscription */
49
- chatHasPro: boolean;
50
- /** Usage info from the server: credits (pro) or request count (free) */
47
+ /** Whether the user has at least one BYOK API key configured */
48
+ chatHasByokKey: boolean;
49
+ /** Usage info from the proxy: request count for free tier */
51
50
  chatUsage: ChatUsage | null;
52
51
  /** User ID used to scope persisted model preference (null for anonymous). */
53
52
  chatStorageUserId: string | null;
@@ -78,18 +77,10 @@ export interface ChatSlice {
78
77
  sendErrorFeedback: (code: string, error: string) => void;
79
78
  /** Store a viewport screenshot to include with the next LLM message */
80
79
  setChatViewportScreenshot: (dataUrl: string | null) => void;
81
- /** Set the Clerk auth token (called by ClerkProvider wrapper when user signs in) */
82
- setChatAuthToken: (token: string | null) => void;
83
- /** Set whether user has pro subscription (called by ClerkProvider wrapper) */
84
- setChatHasPro: (hasPro: boolean) => void;
80
+ /** Set whether user has at least one BYOK API key configured */
81
+ setChatHasByokKey: (hasByokKey: boolean) => void;
85
82
  /** Update usage info from server response headers */
86
83
  setChatUsage: (usage: ChatUsage | null) => void;
87
- /** Switch the active chat user/session context. */
88
- switchChatUserContext: (
89
- userId: string | null,
90
- hasPro: boolean,
91
- options?: { clearPersistedCurrent?: boolean; restoreMessages?: boolean },
92
- ) => void;
93
84
  }
94
85
 
95
86
  export interface ChatUsage {
@@ -279,7 +270,7 @@ export const createChatSlice: StateCreator<ChatSlice, [], [], ChatSlice> = (set,
279
270
  chatMessages: loadStoredMessages(null),
280
271
  chatStatus: 'idle',
281
272
  chatStreamingContent: '',
282
- chatActiveModel: loadValidStoredModel(null, false),
273
+ chatActiveModel: loadValidStoredModel(null, hasAnyApiKeyAtInit()),
283
274
  chatAutoExecute: loadStoredAutoExecute(),
284
275
  chatError: null,
285
276
  chatAbortController: null,
@@ -287,8 +278,7 @@ export const createChatSlice: StateCreator<ChatSlice, [], [], ChatSlice> = (set,
287
278
  chatPendingPrompt: null,
288
279
  chatPendingRepairRequest: null,
289
280
  chatViewportScreenshot: null,
290
- chatAuthToken: null,
291
- chatHasPro: false,
281
+ chatHasByokKey: false,
292
282
  chatUsage: null,
293
283
  chatStorageUserId: null,
294
284
 
@@ -340,12 +330,15 @@ export const createChatSlice: StateCreator<ChatSlice, [], [], ChatSlice> = (set,
340
330
  setChatStreamingContent: (chatStreamingContent) => set({ chatStreamingContent }),
341
331
 
342
332
  setChatActiveModel: (chatActiveModel) => {
343
- const nextModel = coerceModelForEntitlement(chatActiveModel, get().chatHasPro);
333
+ // Accept any model the user picks — the ChatPanel shows an inline key
334
+ // prompt if the selected BYOK model doesn't have a key yet, and guards
335
+ // the send path. Coercion only happens on init and when keys are removed
336
+ // (via setChatHasByokKey).
344
337
  try {
345
338
  const key = getModelStorageKey(get().chatStorageUserId);
346
- localStorage.setItem(key, nextModel);
339
+ localStorage.setItem(key, chatActiveModel);
347
340
  } catch { /* ignore */ }
348
- set({ chatActiveModel: nextModel });
341
+ set({ chatActiveModel });
349
342
  },
350
343
 
351
344
  setChatAutoExecute: (chatAutoExecute) => {
@@ -404,51 +397,17 @@ export const createChatSlice: StateCreator<ChatSlice, [], [], ChatSlice> = (set,
404
397
 
405
398
  setChatViewportScreenshot: (chatViewportScreenshot) => set({ chatViewportScreenshot }),
406
399
 
407
- setChatAuthToken: (chatAuthToken) => set({ chatAuthToken }),
408
-
409
- setChatHasPro: (chatHasPro) => {
410
- const nextModel = coerceModelForEntitlement(get().chatActiveModel, chatHasPro);
400
+ setChatHasByokKey: (chatHasByokKey) => {
401
+ const nextModel = coerceModelForEntitlement(get().chatActiveModel, chatHasByokKey);
411
402
  try {
412
403
  const key = getModelStorageKey(get().chatStorageUserId);
413
404
  localStorage.setItem(key, nextModel);
414
405
  } catch { /* ignore */ }
415
- set({ chatHasPro, chatActiveModel: nextModel });
406
+ set({ chatHasByokKey, chatActiveModel: nextModel });
416
407
  },
417
408
 
418
409
  setChatUsage: (chatUsage) => set({ chatUsage }),
419
410
 
420
- switchChatUserContext: (chatStorageUserId, chatHasPro, options) => {
421
- const state = get();
422
- state.chatAbortController?.abort();
423
- if (options?.clearPersistedCurrent) {
424
- try {
425
- localStorage.removeItem(getMessagesStorageKey(state.chatStorageUserId));
426
- } catch { /* ignore */ }
427
- }
428
- const restoredModel = coerceModelForEntitlement(
429
- loadStoredModel(chatStorageUserId, state.chatActiveModel),
430
- chatHasPro,
431
- );
432
- const restoredMessages = options?.restoreMessages === false
433
- ? []
434
- : loadStoredMessages(chatStorageUserId);
435
- set({
436
- chatStorageUserId,
437
- chatHasPro,
438
- chatActiveModel: restoredModel,
439
- chatMessages: restoredMessages,
440
- chatStatus: 'idle',
441
- chatStreamingContent: '',
442
- chatError: null,
443
- chatAbortController: null,
444
- chatAttachments: [],
445
- chatPendingPrompt: null,
446
- chatPendingRepairRequest: null,
447
- chatViewportScreenshot: null,
448
- chatUsage: null,
449
- });
450
- },
451
-
452
411
  sendErrorFeedback: (code, error) => {
453
412
  const feedbackMessage: ChatMessage = {
454
413
  id: crypto.randomUUID(),
@@ -4,9 +4,11 @@
4
4
 
5
5
  import { describe, it, beforeEach } from 'node:test';
6
6
  import assert from 'node:assert';
7
- import { createDataSlice, type DataSlice } from './dataSlice.js';
7
+ import { createDataSlice, type DataSlice, type DataCrossSliceState } from './dataSlice.js';
8
8
  import { DATA_DEFAULTS } from '../constants.js';
9
9
 
10
+ type DataTestState = DataSlice & DataCrossSliceState;
11
+
10
12
  // Mock mesh data for testing
11
13
  const createMockMesh = (expressId: number, color: [number, number, number, number] = [1, 0, 0, 1]) => ({
12
14
  expressId,
@@ -17,9 +19,16 @@ const createMockMesh = (expressId: number, color: [number, number, number, numbe
17
19
  ifcType: 'IfcWall',
18
20
  });
19
21
 
22
+ type TestSetState = (
23
+ partial:
24
+ | Partial<DataTestState>
25
+ | ((state: DataTestState) => Partial<DataTestState>),
26
+ ) => void;
27
+ type TestGetState = () => DataTestState;
28
+
20
29
  describe('DataSlice', () => {
21
- let state: DataSlice;
22
- let setState: (partial: Partial<DataSlice> | ((state: DataSlice) => Partial<DataSlice>)) => void;
30
+ let state: DataTestState;
31
+ let setState: TestSetState;
23
32
 
24
33
  beforeEach(() => {
25
34
  setState = (partial) => {
@@ -31,7 +40,17 @@ describe('DataSlice', () => {
31
40
  }
32
41
  };
33
42
 
34
- state = createDataSlice(setState, () => state, {} as any);
43
+ const getState: TestGetState = () => state;
44
+
45
+ // Seed the cross-slice fields owned by ModelSlice. dataSlice's
46
+ // updaters look up the active model in this map, so the test mock
47
+ // has to provide it for the typed StateCreator to be satisfiable.
48
+ const slice = createDataSlice(
49
+ setState as Parameters<typeof createDataSlice>[0],
50
+ getState as Parameters<typeof createDataSlice>[1],
51
+ undefined as unknown as Parameters<typeof createDataSlice>[2],
52
+ );
53
+ state = { ...slice, activeModelId: null, models: new Map() };
35
54
  });
36
55
 
37
56
  describe('initial state', () => {
@@ -20,7 +20,7 @@ import { DATA_DEFAULTS } from '../constants.js';
20
20
  * consistent. The types below describe the minimal ModelSlice surface
21
21
  * that dataSlice accesses through the merged Zustand state.
22
22
  */
23
- interface DataCrossSliceState {
23
+ export interface DataCrossSliceState {
24
24
  activeModelId: string | null;
25
25
  models: Map<string, FederatedModel>;
26
26
  }
@@ -4,16 +4,34 @@
4
4
 
5
5
  import { describe, it, beforeEach } from 'node:test';
6
6
  import assert from 'node:assert';
7
- import { createModelSlice, type ModelSlice } from './modelSlice.js';
7
+ import type { IfcDataStore } from '@ifc-lite/parser';
8
+ import type { GeometryResult } from '@ifc-lite/geometry';
9
+ import { createModelSlice, type ModelSlice, type ModelCrossSliceState } from './modelSlice.js';
8
10
  import type { FederatedModel } from '../types.js';
9
11
 
10
- // Helper to create a mock model
12
+ type ModelTestState = ModelSlice & ModelCrossSliceState;
13
+
14
+ // Typed setter / getter shim that mirrors zustand's StateCreator
15
+ // signature without the broader middleware machinery the test doesn't
16
+ // need. Using StateCreator's exact types here would pull in the whole
17
+ // store; the local aliases below are tight enough for this test.
18
+ type TestSetState = (
19
+ partial:
20
+ | Partial<ModelTestState>
21
+ | ((state: ModelTestState) => Partial<ModelTestState>),
22
+ ) => void;
23
+ type TestGetState = () => ModelTestState;
24
+
25
+ // Helper to create a mock model. `IfcDataStore` and `GeometryResult` are
26
+ // large interfaces that the slice never inspects on these paths — the
27
+ // double-cast through `unknown` is the minimum that satisfies the
28
+ // compiler without an `any`.
11
29
  function createMockModel(id: string, name: string): FederatedModel {
12
30
  return {
13
31
  id,
14
32
  name,
15
- ifcDataStore: {} as any,
16
- geometryResult: {} as any,
33
+ ifcDataStore: {} as unknown as IfcDataStore,
34
+ geometryResult: {} as unknown as GeometryResult,
17
35
  visible: true,
18
36
  collapsed: false,
19
37
  schemaVersion: 'IFC4',
@@ -25,11 +43,10 @@ function createMockModel(id: string, name: string): FederatedModel {
25
43
  }
26
44
 
27
45
  describe('ModelSlice', () => {
28
- let state: ModelSlice;
29
- let setState: (partial: Partial<ModelSlice> | ((state: ModelSlice) => Partial<ModelSlice>)) => void;
46
+ let state: ModelTestState;
47
+ let setState: TestSetState;
30
48
 
31
49
  beforeEach(() => {
32
- // Create a mock set function that updates state
33
50
  setState = (partial) => {
34
51
  if (typeof partial === 'function') {
35
52
  const updates = partial(state);
@@ -39,8 +56,17 @@ describe('ModelSlice', () => {
39
56
  }
40
57
  };
41
58
 
42
- // Create slice with mock set function
43
- state = createModelSlice(setState, () => state, {} as any);
59
+ const getState: TestGetState = () => state;
60
+
61
+ // The slice's StateCreator signature includes a third middleware
62
+ // argument (store API) that the slice's body never reads. We pass
63
+ // `undefined` cast to the empty middleware shape rather than `any`.
64
+ const slice = createModelSlice(
65
+ setState as Parameters<typeof createModelSlice>[0],
66
+ getState as Parameters<typeof createModelSlice>[1],
67
+ undefined as unknown as Parameters<typeof createModelSlice>[2],
68
+ );
69
+ state = { ...slice, ifcDataStore: null, geometryResult: null };
44
70
  });
45
71
 
46
72
  describe('initial state', () => {
@@ -270,4 +296,36 @@ describe('ModelSlice', () => {
270
296
  assert.strictEqual(visible.length, 0);
271
297
  });
272
298
  });
299
+
300
+ describe('resolveGlobalIdFromModels — overlay-allocated ids', () => {
301
+ it('falls through to mutation views when the id is past maxExpressId', () => {
302
+ const model = createMockModel('model-1', 'First');
303
+ model.idOffset = 0;
304
+ model.maxExpressId = 10_000;
305
+ state.addModel(model);
306
+
307
+ // Seed a fake mutation view with a fresh overlay entity. The
308
+ // resolver only reads `getNewEntity` from each view, so we type
309
+ // the map narrowly and let it satisfy the slice's wider type via
310
+ // a single-property cast on the wrapping state object.
311
+ type StubView = { getNewEntity: (id: number) => { expressId: number } | null };
312
+ const stubViews: Map<string, StubView> = new Map([
313
+ ['model-1', { getNewEntity: (id: number) => (id === 11_001 ? { expressId: id } : null) }],
314
+ ]);
315
+ state = { ...state, mutationViews: stubViews } as typeof state & { mutationViews: Map<string, StubView> };
316
+
317
+ // Inside the parsed range — first pass resolves it.
318
+ const within = state.resolveGlobalIdFromModels(42);
319
+ assert.deepStrictEqual(within, { modelId: 'model-1', expressId: 42 });
320
+
321
+ // Above the parsed range but in the overlay — second pass resolves it.
322
+ const overlay = state.resolveGlobalIdFromModels(11_001);
323
+ assert.deepStrictEqual(overlay, { modelId: 'model-1', expressId: 11_001 });
324
+
325
+ // Above the parsed range and NOT in the overlay — returns null
326
+ // so callers can fall back to the legacy single-model path.
327
+ const phantom = state.resolveGlobalIdFromModels(99_999);
328
+ assert.strictEqual(phantom, null);
329
+ });
330
+ });
273
331
  });
@@ -13,8 +13,20 @@
13
13
 
14
14
  import type { StateCreator } from 'zustand';
15
15
  import type { FederatedModel } from '../types.js';
16
+ import type { IfcDataStore } from '@ifc-lite/parser';
17
+ import type { GeometryResult } from '@ifc-lite/geometry';
16
18
  import { federationRegistry, type GlobalIdLookup } from '@ifc-lite/renderer';
17
19
 
20
+ /**
21
+ * Cross-slice fields the model actions write to. `ifcDataStore` and
22
+ * `geometryResult` are owned by `dataSlice` but `modelSlice`'s set()
23
+ * calls need to keep them in sync with the active model.
24
+ */
25
+ export interface ModelCrossSliceState {
26
+ ifcDataStore: IfcDataStore | null;
27
+ geometryResult: GeometryResult | null;
28
+ }
29
+
18
30
  export interface ModelSlice {
19
31
  // State
20
32
  /** Map of all loaded models by ID */
@@ -72,7 +84,7 @@ export interface ModelSlice {
72
84
  resolveGlobalIdFromModels: (globalId: number) => GlobalIdLookup | null;
73
85
  }
74
86
 
75
- export const createModelSlice: StateCreator<ModelSlice, [], [], ModelSlice> = (set, get) => ({
87
+ export const createModelSlice: StateCreator<ModelSlice & ModelCrossSliceState, [], [], ModelSlice> = (set, get) => ({
76
88
  // Initial state
77
89
  models: new Map(),
78
90
  activeModelId: null,
@@ -245,19 +257,39 @@ export const createModelSlice: StateCreator<ModelSlice, [], [], ModelSlice> = (s
245
257
  */
246
258
  resolveGlobalIdFromModels: (globalId: number) => {
247
259
  const models = get().models;
260
+ const mutationViews = (get() as unknown as { mutationViews?: Map<string, { getNewEntity: (id: number) => unknown }> }).mutationViews;
248
261
 
249
262
  // Sort models by offset for correct range checking
250
263
  const sortedModels = Array.from(models.values()).sort((a, b) => a.idOffset - b.idOffset);
251
264
 
252
- // Find the model that contains this globalId
253
- // A model contains a globalId if: offset <= globalId <= offset + maxExpressId
265
+ // Find the model that contains this globalId.
266
+ //
267
+ // First pass — parse-time range. A model owns ids in
268
+ // `[offset, offset + maxExpressId]` from the original parse. This
269
+ // is the fast path covering 99% of selections.
270
+ //
271
+ // Second pass — overlay-allocated ids. Duplicates / scripted adds
272
+ // through StoreEditor land ABOVE the model's parse-time
273
+ // maxExpressId, so they fall outside the first-pass range. The
274
+ // federation resolver knows nothing about overlay state, so we
275
+ // consult each model's mutation view for the freshly-added
276
+ // entity. Falls back gracefully when no view is registered.
254
277
  for (const model of sortedModels) {
255
278
  const localId = globalId - model.idOffset;
256
279
  if (localId >= 0 && localId <= model.maxExpressId) {
257
- return {
258
- modelId: model.id,
259
- expressId: localId,
260
- };
280
+ return { modelId: model.id, expressId: localId };
281
+ }
282
+ }
283
+
284
+ if (mutationViews) {
285
+ for (const model of sortedModels) {
286
+ const localId = globalId - model.idOffset;
287
+ if (localId <= model.maxExpressId) continue; // already covered above
288
+ const view = mutationViews.get(model.id);
289
+ if (!view) continue;
290
+ if (view.getNewEntity(localId) !== null) {
291
+ return { modelId: model.id, expressId: localId };
292
+ }
261
293
  }
262
294
  }
263
295