@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
@@ -29,7 +29,6 @@ import {
29
29
  ArrowDown,
30
30
  Zap,
31
31
  } from 'lucide-react';
32
- import { SignInButton, SignedIn, SignedOut, UserButton } from '@clerk/clerk-react';
33
32
  import { Button } from '@/components/ui/button';
34
33
  import { toast } from '@/components/ui/toast';
35
34
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -38,6 +37,7 @@ import { buildErrorFeedbackContent } from '@/store/slices/chatSlice';
38
37
  import { ChatMessageComponent } from './chat/ChatMessage';
39
38
  import { ModelSelector } from './chat/ModelSelector';
40
39
  import { fetchUsageSnapshot, streamChat, type StreamMessage, type TextContentPart, type ImageContentPart, type UsageInfo } from '@/lib/llm/stream-client';
40
+ import { streamAnthropicChat, streamOpenAiChat } from '@/lib/llm/stream-direct';
41
41
  import { buildStreamMessagesForModel, filterAttachmentsForModel } from '@/lib/llm/message-capabilities';
42
42
  import { buildSystemPrompt } from '@/lib/llm/system-prompt';
43
43
  import { getModelContext, parseCSV } from '@/lib/llm/context-builder';
@@ -49,11 +49,11 @@ import type { ScriptDiagnostic } from '@/lib/llm/script-diagnostics';
49
49
  import { buildRepairSessionKey, getEscalatedRepairScope, pruneMessagesForRepair } from '@/lib/llm/repair-loop';
50
50
  import type { ChatMessage, ChatRepairRequest, FileAttachment } from '@/lib/llm/types';
51
51
  import { canUsePlainCodeBlockFallback, type ScriptMutationIntent } from '@/lib/llm/script-preservation';
52
- import { Image as ImageIcon } from 'lucide-react';
53
- import { isClerkConfigured } from '@/lib/llm/clerk-auth';
54
- import { buildDesktopUpgradeUrl, hasDesktopFeatureAccess } from '@/lib/desktop-product';
55
- import { navigateToPath } from '@/services/app-navigation';
52
+ import { Check, Image as ImageIcon, Key, Eye, EyeOff, ExternalLink } from 'lucide-react';
53
+ import { hasDesktopFeatureAccess } from '@/lib/desktop-product';
56
54
  import { getModelById } from '@/lib/llm/models';
55
+ import { resolveStreamRoute } from '@/lib/llm/byok-guard';
56
+ import { getApiKeys, updateApiKeys, hasAnthropicKey, hasOpenaiKey, subscribeApiKeys } from '@/services/api-keys';
57
57
  import { useSandbox } from '@/hooks/useSandbox';
58
58
 
59
59
  // Environment variable for the proxy URL
@@ -67,7 +67,6 @@ const EXAMPLE_PROMPTS = [
67
67
  ];
68
68
 
69
69
  const CONTINUE_PROMPT = 'Continue from exactly where your last response stopped. Do not repeat previously generated text.';
70
- const DEFAULT_PRO_MONTHLY_CREDIT_LIMIT = 1000;
71
70
  const USAGE_REFRESH_INTERVAL_MS = 15_000;
72
71
  const EST_CHARS_PER_TOKEN = 4;
73
72
  const IMAGE_TOKEN_COST_EST = 850;
@@ -80,11 +79,24 @@ const MAX_INLINE_IMAGE_DATA_URL_CHARS = 1_200_000;
80
79
  const MAX_ATTACHMENTS_PER_MESSAGE = 6;
81
80
  const MAX_TEXT_ATTACHMENT_BYTES = 512_000;
82
81
  const MAX_IMAGE_ATTACHMENT_BYTES = 8_000_000;
82
+ /** Anthropic's PDF content-block limit is ~32 MB; keep our upload cap lower. */
83
+ const MAX_PDF_ATTACHMENT_BYTES = 16_000_000;
83
84
 
84
85
  function createAttachmentId(): string {
85
86
  return crypto.randomUUID();
86
87
  }
87
88
 
89
+ /** Convert an ArrayBuffer (binary file) to raw base64 — no data-URL prefix. */
90
+ function arrayBufferToBase64(buffer: ArrayBuffer): string {
91
+ const bytes = new Uint8Array(buffer);
92
+ let binary = '';
93
+ const chunk = 0x8000;
94
+ for (let i = 0; i < bytes.length; i += chunk) {
95
+ binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
96
+ }
97
+ return btoa(binary);
98
+ }
99
+
88
100
  interface ChatSendOptions {
89
101
  continuationBase?: string;
90
102
  intent?: ScriptMutationIntent;
@@ -211,23 +223,30 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
211
223
  const consumePendingPrompt = useViewerStore((s) => s.consumeChatPendingPrompt);
212
224
  const pendingRepairRequest = useViewerStore((s) => s.chatPendingRepairRequest);
213
225
  const consumePendingRepairRequest = useViewerStore((s) => s.consumeChatPendingRepairRequest);
214
- const authToken = useViewerStore((s) => s.chatAuthToken);
215
- const hasPro = useViewerStore((s) => s.chatHasPro);
226
+ const hasByokKey = useViewerStore((s) => s.chatHasByokKey);
227
+ const setChatHasByokKey = useViewerStore((s) => s.setChatHasByokKey);
216
228
  const usage = useViewerStore((s) => s.chatUsage);
217
229
  const setChatUsage = useViewerStore((s) => s.setChatUsage);
218
230
  const desktopEntitlement = useViewerStore((s) => s.desktopEntitlement);
219
231
  const { execute } = useSandbox();
220
232
  const canUseAiAssistant = hasDesktopFeatureAccess(desktopEntitlement, 'ai_assistant');
221
- const displayUsage: UsageInfo | null = usage ?? (hasPro
222
- ? {
223
- type: 'credits',
224
- used: 0,
225
- limit: DEFAULT_PRO_MONTHLY_CREDIT_LIMIT,
226
- pct: 0,
227
- resetAt: 0,
228
- billable: false,
229
- }
230
- : null);
233
+
234
+ // Sync BYOK key availability into the store and track per-provider state
235
+ const [keyStateAnthropic, setKeyStateAnthropic] = useState(hasAnthropicKey);
236
+ const [keyStateOpenai, setKeyStateOpenai] = useState(hasOpenaiKey);
237
+ useEffect(() => {
238
+ const refresh = () => {
239
+ const a = hasAnthropicKey();
240
+ const o = hasOpenaiKey();
241
+ setKeyStateAnthropic(a);
242
+ setKeyStateOpenai(o);
243
+ setChatHasByokKey(a || o);
244
+ };
245
+ refresh();
246
+ return subscribeApiKeys(refresh);
247
+ }, [setChatHasByokKey]);
248
+
249
+ const displayUsage: UsageInfo | null = usage;
231
250
  const usageResetLabel = displayUsage?.resetAt && displayUsage.resetAt > 0
232
251
  ? new Date(displayUsage.resetAt * 1000).toLocaleDateString()
233
252
  : '—';
@@ -238,14 +257,10 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
238
257
  const [showScrollBtn, setShowScrollBtn] = useState(false);
239
258
  const [userScrolledUp, setUserScrolledUp] = useState(false);
240
259
  const [lastFinishReason, setLastFinishReason] = useState<string | null>(null);
241
- const openUpgradePage = useCallback(() => {
242
- navigateToPath(buildDesktopUpgradeUrl());
243
- }, []);
244
260
  const promptAiUpgrade = useCallback(() => {
245
261
  setChatError('AI assistant is available with Desktop Pro.');
246
262
  toast.info('AI assistant is available with Desktop Pro');
247
- openUpgradePage();
248
- }, [openUpgradePage, setChatError]);
263
+ }, [setChatError]);
249
264
 
250
265
  const inputRef = useRef<HTMLTextAreaElement>(null);
251
266
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -307,7 +322,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
307
322
  useEffect(() => {
308
323
  let cancelled = false;
309
324
  const refreshUsage = async () => {
310
- const snapshot = await fetchUsageSnapshot(PROXY_URL, authToken);
325
+ const snapshot = await fetchUsageSnapshot(PROXY_URL);
311
326
  if (!cancelled && snapshot) {
312
327
  setChatUsage(snapshot);
313
328
  }
@@ -322,7 +337,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
322
337
  cancelled = true;
323
338
  window.clearInterval(timer);
324
339
  };
325
- }, [authToken, setChatUsage]);
340
+ }, [setChatUsage]);
326
341
 
327
342
  // ── Keyboard shortcuts ──
328
343
  useEffect(() => {
@@ -403,6 +418,20 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
403
418
  return;
404
419
  }
405
420
 
421
+ // Resolve the stream route BEFORE any user-visible side effects (adding
422
+ // the user message, clearing attachments, setting sending state). If the
423
+ // selected BYOK model has no key, bail out now so the chat transcript
424
+ // doesn't stack orphaned user messages on repeated sends.
425
+ const route = resolveStreamRoute(activeModel, getApiKeys());
426
+ if (route.kind === 'missing-key') {
427
+ setChatError(
428
+ route.provider === 'anthropic'
429
+ ? 'Enter your Anthropic API key above to use this model.'
430
+ : 'Enter your OpenAI API key above to use this model.',
431
+ );
432
+ return;
433
+ }
434
+
406
435
  const continuationBase = options?.continuationBase;
407
436
  const responseIntent = options?.intent ?? 'create';
408
437
  if (responseIntent !== 'repair') {
@@ -588,14 +617,8 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
588
617
  }
589
618
  };
590
619
 
591
- await streamChat({
592
- proxyUrl: PROXY_URL,
593
- model: activeModel,
594
- messages: streamMessages,
595
- system: systemPrompt,
596
- authToken,
597
- signal: abortController.signal,
598
- onChunk: (chunk) => {
620
+ // ── Shared stream callbacks ──
621
+ const handleChunk = (chunk: string) => {
599
622
  clearPendingAttachmentsOnce();
600
623
  accumulated += chunk;
601
624
  if (!responseEditState.applyFailed && responseEditState.intent !== 'repair') {
@@ -629,8 +652,8 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
629
652
  }
630
653
  setChatStatus('streaming');
631
654
  updateStreaming(accumulated);
632
- },
633
- onComplete: (fullText) => {
655
+ };
656
+ const handleComplete = (fullText: string) => {
634
657
  clearPendingAttachmentsOnce();
635
658
  const normalizedText = continuationBase
636
659
  ? stripContinuationOverlap(continuationBase, fullText)
@@ -772,22 +795,62 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
772
795
  }
773
796
 
774
797
  commitAssistantTurn();
775
- },
776
- onUsageInfo: (info: UsageInfo) => {
798
+ };
799
+ const handleUsageInfo = (info: UsageInfo) => {
777
800
  setChatUsage(info);
778
- },
779
- onFinishReason: (reason) => {
801
+ };
802
+ const handleFinishReason = (reason: string | null) => {
780
803
  setLastFinishReason(reason);
781
804
  if (reason === 'length') {
782
805
  setChatError('Response reached output limit. Click Continue to resume.');
783
806
  }
784
- },
785
- onError: (err) => {
807
+ };
808
+ const handleError = (err: Error) => {
786
809
  setChatError(err.message);
787
810
  setChatAbortController(null);
788
811
  commitAssistantTurn();
789
- },
790
- });
812
+ };
813
+
814
+ // Route to direct provider streaming for BYOK models, or through the proxy
815
+ // for free models. The route was already resolved (and the missing-key
816
+ // case handled) at the top of doSend, so this dispatch is total.
817
+ if (route.kind === 'anthropic') {
818
+ await streamAnthropicChat(route.apiKey, {
819
+ model: activeModel,
820
+ messages: streamMessages,
821
+ system: systemPrompt,
822
+ signal: abortController.signal,
823
+ onChunk: handleChunk,
824
+ onComplete: handleComplete,
825
+ onFinishReason: handleFinishReason,
826
+ onError: handleError,
827
+ });
828
+ } else if (route.kind === 'openai') {
829
+ await streamOpenAiChat(route.apiKey, {
830
+ model: activeModel,
831
+ messages: streamMessages,
832
+ system: systemPrompt,
833
+ signal: abortController.signal,
834
+ onChunk: handleChunk,
835
+ onComplete: handleComplete,
836
+ onFinishReason: handleFinishReason,
837
+ onError: handleError,
838
+ });
839
+ } else {
840
+ await streamChat({
841
+ proxyUrl: PROXY_URL,
842
+ model: activeModel,
843
+ messages: streamMessages,
844
+ system: systemPrompt,
845
+ signal: abortController.signal,
846
+ onChunk: handleChunk,
847
+ onComplete: handleComplete,
848
+ onFinishReason: handleFinishReason,
849
+ onError: handleError,
850
+ onUsageInfo: handleUsageInfo,
851
+ });
852
+ }
853
+
791
854
  if (abortController.signal.aborted) {
792
855
  commitAssistantTurn();
793
856
  const currentState = useViewerStore.getState();
@@ -797,7 +860,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
797
860
  }
798
861
  }
799
862
  }, [
800
- canUseAiAssistant, status, activeModel, attachments, authToken,
863
+ canUseAiAssistant, status, activeModel, attachments,
801
864
  addMessage, setChatStatus, updateStreaming, finalizeAssistant,
802
865
  setChatError, setChatAbortController, clearAttachments, setChatUsage, resizeInput,
803
866
  buildRepairPromptFromLiveState, triggerAutoRepair, execute,
@@ -969,7 +1032,54 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
969
1032
  remainingSlots -= 1;
970
1033
  continue;
971
1034
  }
972
- // Only accept text-based files
1035
+ // PDFs are supported by Claude as native document content blocks.
1036
+ // Route them separately from text attachments so the chat request
1037
+ // can emit the correct multimodal block type.
1038
+ if (file.name.match(/\.pdf$/i) || file.type === 'application/pdf') {
1039
+ if (!supportsFileAttachments) {
1040
+ setChatError('Selected model does not support file attachments. Switch model to attach PDFs.');
1041
+ continue;
1042
+ }
1043
+ if (file.size > MAX_PDF_ATTACHMENT_BYTES) {
1044
+ setChatError(`PDF attachments must be smaller than ${Math.round(MAX_PDF_ATTACHMENT_BYTES / 1_000_000)} MB.`);
1045
+ continue;
1046
+ }
1047
+ const buffer = await file.arrayBuffer();
1048
+ const base64 = arrayBufferToBase64(buffer);
1049
+ const attachment: FileAttachment = {
1050
+ id: createAttachmentId(),
1051
+ name: file.name,
1052
+ type: 'application/pdf',
1053
+ size: file.size,
1054
+ pdfBase64: base64,
1055
+ isPdf: true,
1056
+ };
1057
+ addAttachment(attachment);
1058
+ remainingSlots -= 1;
1059
+ continue;
1060
+ }
1061
+ // Excel / ODS binaries — we can't parse them yet, but we don't want
1062
+ // to silently drop them. Register a metadata-only attachment so the
1063
+ // user (and the LLM via the system prompt) know it's there and can
1064
+ // suggest exporting as CSV.
1065
+ if (file.name.match(/\.(xlsx|xls|ods)$/i)) {
1066
+ if (!supportsFileAttachments) {
1067
+ setChatError('Selected model does not support file attachments. Switch model to attach spreadsheets.');
1068
+ continue;
1069
+ }
1070
+ const attachment: FileAttachment = {
1071
+ id: createAttachmentId(),
1072
+ name: file.name,
1073
+ type: file.type || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
1074
+ size: file.size,
1075
+ isSpreadsheetBinary: true,
1076
+ textContent: `[Binary spreadsheet ${file.name} (${Math.round(file.size / 1024)} KB). Export to CSV for full content access.]`,
1077
+ };
1078
+ addAttachment(attachment);
1079
+ remainingSlots -= 1;
1080
+ continue;
1081
+ }
1082
+ // Text-based files — CSV, TSV, JSON, TXT
973
1083
  if (!file.name.match(/\.(csv|json|txt|tsv)$/i)) continue;
974
1084
  if (!supportsFileAttachments) {
975
1085
  setChatError('Selected model does not support file attachments. Switch model to attach files.');
@@ -1067,12 +1177,17 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1067
1177
  const modelSupportsImages = modelForUi?.supportsImages ?? false;
1068
1178
  const modelSupportsFiles = modelForUi?.supportsFileAttachments ?? true;
1069
1179
  const attachmentAccept = [
1070
- modelSupportsFiles ? '.csv,.json,.txt,.tsv' : '',
1180
+ modelSupportsFiles
1181
+ ? '.csv,.json,.txt,.tsv,.pdf,application/pdf,.xlsx,.xls,.ods,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,application/vnd.ms-excel,application/vnd.oasis.opendocument.spreadsheet'
1182
+ : '',
1071
1183
  modelSupportsImages ? 'image/*' : '',
1072
1184
  ].filter(Boolean).join(',');
1073
1185
  const canAttachInput = modelSupportsFiles || modelSupportsImages;
1074
- const clerkEnabled = isClerkConfigured();
1075
- const showUpgradeNudge = Boolean(error && (error.includes('Upgrade to Pro') || error.includes('daily limit')));
1186
+ // Detect when selected model needs a missing BYOK key (reactive state, not raw reads)
1187
+ const modelSource = modelForUi?.source ?? 'proxy';
1188
+ const needsAnthropicKey = modelSource === 'anthropic' && !keyStateAnthropic;
1189
+ const needsOpenaiKey = modelSource === 'openai' && !keyStateOpenai;
1190
+ const needsByokKey = needsAnthropicKey || needsOpenaiKey;
1076
1191
  const showSupportEmail = Boolean(error && error.includes('louis@ltplus.com'));
1077
1192
  const canContinue = Boolean(
1078
1193
  !isActive && (streamingContent.trim().length > 0 || lastFinishReason === 'length'),
@@ -1111,7 +1226,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1111
1226
  <TooltipContent>Clear</TooltipContent>
1112
1227
  </Tooltip>
1113
1228
 
1114
- <ModelSelector hasPro={hasPro} />
1229
+ <ModelSelector />
1115
1230
  <div className="flex-1" />
1116
1231
 
1117
1232
  <Tooltip>
@@ -1128,31 +1243,6 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1128
1243
  <TooltipContent>Auto-run: {autoExecute ? 'ON' : 'OFF'}</TooltipContent>
1129
1244
  </Tooltip>
1130
1245
 
1131
- {clerkEnabled && (
1132
- <>
1133
- <SignedOut>
1134
- <SignInButton mode="modal">
1135
- <Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-muted-foreground">
1136
- Sign in
1137
- </Button>
1138
- </SignInButton>
1139
- </SignedOut>
1140
- {!hasPro && (
1141
- <Button
1142
- variant="ghost"
1143
- size="sm"
1144
- className="h-6 px-2 text-xs text-muted-foreground"
1145
- onClick={openUpgradePage}
1146
- >
1147
- Pro
1148
- </Button>
1149
- )}
1150
- <SignedIn>
1151
- <UserButton afterSignOutUrl="/" />
1152
- </SignedIn>
1153
- </>
1154
- )}
1155
-
1156
1246
  {onClose && (
1157
1247
  <Button variant="ghost" size="icon-xs" onClick={onClose}>
1158
1248
  <X className="h-3.5 w-3.5" />
@@ -1161,14 +1251,16 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1161
1251
  </div>
1162
1252
 
1163
1253
  {!canUseAiAssistant && (
1164
- <div className="border-b bg-muted/40 px-3 py-2 text-xs text-muted-foreground flex items-center justify-between gap-3">
1165
- <span>AI assistant is available with Desktop Pro. Core viewing and scripting stay available without it.</span>
1166
- <Button variant="outline" size="sm" className="h-6 px-2 text-xs" onClick={openUpgradePage}>
1167
- Upgrade
1168
- </Button>
1254
+ <div className="border-b bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
1255
+ AI assistant requires Desktop Pro. Core viewing and scripting stay available without it.
1169
1256
  </div>
1170
1257
  )}
1171
1258
 
1259
+ {/* Inline BYOK key prompt — shown when user picks a model without the matching key */}
1260
+ {needsByokKey && canUseAiAssistant && (
1261
+ <InlineKeyPrompt provider={needsAnthropicKey ? 'anthropic' : 'openai'} />
1262
+ )}
1263
+
1172
1264
  {/* Clear confirmation */}
1173
1265
  {showClearConfirm && (
1174
1266
  <div className="px-3 py-2 bg-destructive/5 border-b flex items-center gap-2 text-xs">
@@ -1273,16 +1365,6 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1273
1365
  Continue
1274
1366
  </Button>
1275
1367
  )}
1276
- {showUpgradeNudge && clerkEnabled && (
1277
- <Button
1278
- variant="outline"
1279
- size="sm"
1280
- className="h-5 px-2 text-[10px]"
1281
- onClick={openUpgradePage}
1282
- >
1283
- Upgrade
1284
- </Button>
1285
- )}
1286
1368
  {showSupportEmail && (
1287
1369
  <a className="underline text-[10px]" href="mailto:louis@ltplus.com">
1288
1370
  Contact support
@@ -1351,7 +1433,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1351
1433
  </TooltipTrigger>
1352
1434
  <TooltipContent>
1353
1435
  {!canUseAiAssistant
1354
- ? 'Desktop Pro required for AI assistant'
1436
+ ? 'AI assistant not available'
1355
1437
  : canAttachInput
1356
1438
  ? 'Attach file or image (paste, drag & drop)'
1357
1439
  : 'Selected model does not support attachments'}
@@ -1367,11 +1449,11 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1367
1449
  }}
1368
1450
  onKeyDown={handleKeyDown}
1369
1451
  onPaste={handlePaste}
1370
- placeholder={canUseAiAssistant ? 'Ask anything...' : 'Desktop Pro required for AI assistant'}
1452
+ placeholder={!canUseAiAssistant ? 'AI assistant not available' : needsByokKey ? `Enter your ${needsAnthropicKey ? 'Anthropic' : 'OpenAI'} API key above` : 'Ask anything...'}
1371
1453
  rows={1}
1372
1454
  className="flex-1 resize-none rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground px-3 py-1.5 text-sm min-h-[32px] max-h-[120px] focus:outline-none focus:ring-1 focus:ring-ring"
1373
1455
  style={{ height: 'auto', overflow: 'hidden' }}
1374
- disabled={!canUseAiAssistant}
1456
+ disabled={!canUseAiAssistant || needsByokKey}
1375
1457
  />
1376
1458
 
1377
1459
  {isActive ? (
@@ -1395,7 +1477,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1395
1477
  variant="default"
1396
1478
  size="icon-xs"
1397
1479
  onClick={handleSend}
1398
- disabled={!inputText.trim() || !canUseAiAssistant}
1480
+ disabled={!inputText.trim() || !canUseAiAssistant || needsByokKey}
1399
1481
  className="shrink-0 mb-0.5"
1400
1482
  >
1401
1483
  <Send className="h-3.5 w-3.5" />
@@ -1438,3 +1520,87 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1438
1520
  </div>
1439
1521
  );
1440
1522
  }
1523
+
1524
+ // ── Inline BYOK key prompt (shown inside chat panel when key is missing) ──
1525
+
1526
+ const PROVIDER_INFO = {
1527
+ anthropic: {
1528
+ label: 'Anthropic',
1529
+ placeholder: 'sk-ant-api03-...',
1530
+ url: 'https://console.anthropic.com/settings/keys',
1531
+ urlLabel: 'console.anthropic.com',
1532
+ },
1533
+ openai: {
1534
+ label: 'OpenAI',
1535
+ placeholder: 'sk-...',
1536
+ url: 'https://platform.openai.com/api-keys',
1537
+ urlLabel: 'platform.openai.com',
1538
+ },
1539
+ } as const;
1540
+
1541
+ function InlineKeyPrompt({ provider }: { provider: 'anthropic' | 'openai' }) {
1542
+ const [value, setValue] = useState('');
1543
+ const [show, setShow] = useState(false);
1544
+ const [saved, setSaved] = useState(false);
1545
+ const info = PROVIDER_INFO[provider];
1546
+
1547
+ const handleSave = useCallback(() => {
1548
+ const trimmed = value.trim();
1549
+ if (!trimmed) return;
1550
+ if (provider === 'anthropic') {
1551
+ updateApiKeys({ anthropicKey: trimmed });
1552
+ } else {
1553
+ updateApiKeys({ openaiKey: trimmed });
1554
+ }
1555
+ setSaved(true);
1556
+ }, [value, provider]);
1557
+
1558
+ // Brief success state before the parent unmounts this component
1559
+ if (saved) {
1560
+ return (
1561
+ <div className="border-b bg-emerald-500/10 px-3 py-2 flex items-center gap-2 text-xs text-emerald-700 dark:text-emerald-400">
1562
+ <Check className="h-3.5 w-3.5" />
1563
+ <span>{info.label} key saved — ready to chat</span>
1564
+ </div>
1565
+ );
1566
+ }
1567
+
1568
+ return (
1569
+ <div className="border-b bg-muted/30 px-3 py-2.5 space-y-1.5">
1570
+ <div className="flex items-center gap-1.5 text-xs font-medium">
1571
+ <Key className="h-3.5 w-3.5" />
1572
+ {info.label} API key required
1573
+ </div>
1574
+ <p className="text-[11px] text-muted-foreground">
1575
+ Paste your key below — stored in your browser only, sent directly to {info.label}.{' '}
1576
+ <a href={info.url} target="_blank" rel="noopener noreferrer" className="underline inline-flex items-center gap-0.5">
1577
+ Get a key <ExternalLink className="h-2.5 w-2.5" />
1578
+ </a>
1579
+ </p>
1580
+ <div className="flex gap-1.5">
1581
+ <div className="relative flex-1">
1582
+ <input
1583
+ type={show ? 'text' : 'password'}
1584
+ value={value}
1585
+ onChange={(e) => setValue(e.target.value)}
1586
+ onKeyDown={(e) => { if (e.key === 'Enter') handleSave(); }}
1587
+ placeholder={info.placeholder}
1588
+ autoComplete="off"
1589
+ spellCheck={false}
1590
+ className="w-full rounded border border-input bg-background px-2 py-1 text-xs font-mono focus:outline-none focus:ring-1 focus:ring-ring pr-7"
1591
+ />
1592
+ <button
1593
+ type="button"
1594
+ onClick={() => setShow(!show)}
1595
+ className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
1596
+ >
1597
+ {show ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
1598
+ </button>
1599
+ </div>
1600
+ <Button size="sm" className="h-7 px-3 text-xs" onClick={handleSave} disabled={!value.trim()}>
1601
+ Save
1602
+ </Button>
1603
+ </div>
1604
+ </div>
1605
+ );
1606
+ }
@@ -53,6 +53,11 @@ import {
53
53
  FolderOpen,
54
54
  Clock,
55
55
  Save,
56
+ CalendarClock,
57
+ CalendarPlus,
58
+ Sparkles,
59
+ Eraser,
60
+ MapPin,
56
61
  } from 'lucide-react';
57
62
  import { cn } from '@/lib/utils';
58
63
  import { useViewerStore } from '@/store';
@@ -207,23 +212,28 @@ function activateRightPanel(panel: 'bcf' | 'ids' | 'lens') {
207
212
  // If was active → all closed → falls back to Properties
208
213
  }
209
214
 
210
- /** Exclusively activate a bottom panel (Script / List).
215
+ /** Exclusively activate a bottom panel (Script / List / Gantt).
211
216
  * Closes the other first so the if-else chain in ViewerLayout renders it.
212
217
  * If the target is already active, closes it. */
213
- function activateBottomPanel(panel: 'script' | 'list') {
218
+ function activateBottomPanel(panel: 'script' | 'list' | 'gantt') {
214
219
  const s = useViewerStore.getState();
215
- const isActive = panel === 'script' ? s.scriptPanelVisible : s.listPanelVisible;
220
+ const isActive =
221
+ panel === 'script' ? s.scriptPanelVisible
222
+ : panel === 'list' ? s.listPanelVisible
223
+ : s.ganttPanelVisible;
216
224
 
217
225
  closeActiveAnalysisExtension();
218
226
 
219
- // Close all bottom panels
227
+ // Close all bottom panels — only one slots into the bottom strip at a time.
220
228
  s.setScriptPanelVisible(false);
221
229
  s.setListPanelVisible(false);
230
+ s.setGanttPanelVisible(false);
222
231
 
223
232
  if (!isActive) {
224
233
  s.setRightPanelCollapsed(false);
225
234
  if (panel === 'script') s.setScriptPanelVisible(true);
226
- else s.setListPanelVisible(true);
235
+ else if (panel === 'list') s.setListPanelVisible(true);
236
+ else s.setGanttPanelVisible(true);
227
237
  }
228
238
  }
229
239
 
@@ -322,6 +332,10 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
322
332
  action: () => { useViewerStore.getState().setActiveTool('measure'); } },
323
333
  { id: 'tool:section', label: 'Section', keywords: 'clip cut plane', category: 'Tools', icon: Scissors, shortcut: 'X',
324
334
  action: () => { useViewerStore.getState().setActiveTool('section'); } },
335
+ { id: 'tool:annotate', label: 'Annotate', keywords: 'pin note comment marker', category: 'Tools', icon: MapPin, shortcut: 'P',
336
+ action: () => { useViewerStore.getState().setActiveTool('annotate'); } },
337
+ { id: 'tool:add-element', label: 'Add Element', keywords: 'wall slab beam column place drop new add element generic', category: 'Tools', icon: Box,
338
+ action: () => { useViewerStore.getState().setActiveTool('addElement'); } },
325
339
  );
326
340
 
327
341
  // ── Visibility ──
@@ -362,7 +376,7 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
362
376
 
363
377
  // ── Panels ──
364
378
  c.push(
365
- { id: 'panel:properties', label: 'Properties', keywords: 'attributes panel right', category: 'Panels', icon: Layout,
379
+ { id: 'panel:properties', label: 'Inspector', keywords: 'properties attributes material classification schedule task panel right', category: 'Panels', icon: Layout,
366
380
  action: () => { const s = useViewerStore.getState(); s.setRightPanelCollapsed(!s.rightPanelCollapsed); } },
367
381
  { id: 'panel:tree', label: 'Spatial Tree', keywords: 'hierarchy left panel', category: 'Panels', icon: TreeDeciduous,
368
382
  action: () => { const s = useViewerStore.getState(); s.setLeftPanelCollapsed(!s.leftPanelCollapsed); } },
@@ -372,12 +386,47 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
372
386
  action: () => { activateRightPanel('bcf'); } },
373
387
  { id: 'panel:ids', label: 'IDS Validation', keywords: 'information delivery specification check', category: 'Panels', icon: ClipboardCheck,
374
388
  action: () => { activateRightPanel('ids'); } },
375
- { id: 'panel:lists', label: 'Entity Lists', keywords: 'table spreadsheet schedule', category: 'Panels', icon: FileSpreadsheet,
389
+ { id: 'panel:lists', label: 'Entity Lists', keywords: 'table spreadsheet', category: 'Panels', icon: FileSpreadsheet,
376
390
  action: () => { activateBottomPanel('list'); } },
391
+ { id: 'panel:gantt', label: 'Construction Schedule (Gantt)', keywords: '4d timeline tasks ifctask sequence playback animation', category: 'Panels', icon: CalendarClock,
392
+ action: () => { activateBottomPanel('gantt'); } },
377
393
  { id: 'panel:lens', label: 'Lens Rules', keywords: 'color filter highlight', category: 'Panels', icon: Palette,
378
394
  action: () => { activateRightPanel('lens'); } },
379
395
  );
380
396
 
397
+ // ── Schedule / 4D (Tools) ─────────────────────────────
398
+ c.push(
399
+ { id: 'schedule:generate', label: 'Generate Schedule from Storeys…',
400
+ keywords: '4d ifctask construction sequence storey building create gantt',
401
+ category: 'Tools', icon: CalendarPlus,
402
+ action: () => {
403
+ // Make sure the Gantt panel is mounted so the dialog has a host
404
+ // before flipping the dialog flag. Order matters — closing other
405
+ // panels first prevents the bottom strip from rendering them.
406
+ const s = useViewerStore.getState();
407
+ if (!s.ganttPanelVisible) activateBottomPanel('gantt');
408
+ // Same tick is fine — the dialog is portalled via Radix and doesn't
409
+ // depend on GanttPanel finishing its first render.
410
+ useViewerStore.getState().setGenerateScheduleDialogOpen(true);
411
+ } },
412
+ { id: 'schedule:toggle-animation', label: 'Toggle 4D Construction Animation',
413
+ keywords: 'play pause schedule task gantt simulation',
414
+ category: 'Visibility', icon: Sparkles,
415
+ action: () => {
416
+ const s = useViewerStore.getState();
417
+ s.setAnimationEnabled(!s.animationEnabled);
418
+ } },
419
+ { id: 'schedule:reset', label: 'Reset Schedule (Clear 4D Data)',
420
+ keywords: 'remove gantt tasks ifctask delete clear',
421
+ category: 'Tools', icon: Eraser,
422
+ action: () => {
423
+ const s = useViewerStore.getState();
424
+ s.setScheduleData(null);
425
+ s.setAnimationEnabled(false);
426
+ s.pauseSchedule();
427
+ } },
428
+ );
429
+
381
430
  // ── Export ──
382
431
  c.push(
383
432
  { id: 'export:screenshot', label: 'Screenshot', keywords: 'capture png image viewport', category: 'Export', icon: Camera,