@ifc-lite/viewer 1.27.0 → 1.28.1

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 (162) hide show
  1. package/.turbo/turbo-build.log +35 -42
  2. package/CHANGELOG.md +74 -0
  3. package/dist/assets/{basketViewActivator-B3CdrLsb.js → basketViewActivator-Ce38DhXd.js} +8 -8
  4. package/dist/assets/{bcf-QeHK_Aud.js → bcf-Cv_O3JfD.js} +56 -56
  5. package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
  6. package/dist/assets/{deflate-B-d0SYQM.js → deflate-HbyMq59o.js} +1 -1
  7. package/dist/assets/drawing-2d-DW98umlt.js +257 -0
  8. package/dist/assets/e57-source-2wI9jkCA.js +1 -0
  9. package/dist/assets/{exporters-B4LbZFeT.js → exporters-BuD3XRzB.js} +1309 -1153
  10. package/dist/assets/geometry.worker-TH3fCCoY.js +1 -0
  11. package/dist/assets/{geotiff-CrVtDRFq.js → geotiff-B2HA8Bwm.js} +10 -10
  12. package/dist/assets/{ids-DjsGFN10.js → ids-DYUFMd5f.js} +952 -945
  13. package/dist/assets/{ifc-lite_bg-DsYUIHm3.wasm → ifc-lite_bg-BEA5DLmg.wasm} +0 -0
  14. package/dist/assets/index-E9wB0zWt.css +1 -0
  15. package/dist/assets/{index-COYokSKc.js → index-n5O1QJMM.js} +37877 -38126
  16. package/dist/assets/{index.es-CY202jA3.js → index.es-BKVIpZgL.js} +9 -9
  17. package/dist/assets/{jpeg-D4wOkf5h.js → jpeg-C7hjKjPX.js} +1 -1
  18. package/dist/assets/{jspdf.es.min-DIGb9BHN.js → jspdf.es.min-oWlFc42Y.js} +4 -4
  19. package/dist/assets/lens-C4p1kQ0p.js +1 -0
  20. package/dist/assets/{lerc-DmW0_tgf.js → lerc-BfIOGhQz.js} +1 -1
  21. package/dist/assets/{lzw-oWetY-d6.js → lzw-B0jRuuW5.js} +1 -1
  22. package/dist/assets/{native-bridge-BX8_tHXE.js → native-bridge-DpB-dtEn.js} +6 -3
  23. package/dist/assets/{packbits-F8Nkp4NY.js → packbits-DVvBTC39.js} +1 -1
  24. package/dist/assets/parser.worker-BDsWQ6rc.js +182 -0
  25. package/dist/assets/{pdf-Dsh3HPZB.js → pdf-dVIqI5ac.js} +10 -10
  26. package/dist/assets/raw-C0ZJYGmN.js +1 -0
  27. package/dist/assets/{sandbox-BAC3a-eN.js → sandbox-qpJlrNN0.js} +2962 -2554
  28. package/dist/assets/server-client-DVZ2huNS.js +719 -0
  29. package/dist/assets/{webimage-BLV1dgmd.js → webimage-B394g0Tw.js} +1 -1
  30. package/dist/assets/{xlsx-Bc2HTrjC.js → xlsx-D-oHO76J.js} +8 -8
  31. package/dist/assets/{zstd-C_1HxVrA.js → zstd-Bf38MwV2.js} +1 -1
  32. package/dist/index.html +9 -9
  33. package/package.json +24 -23
  34. package/src/App.tsx +1 -3
  35. package/src/components/mcp/playground-dispatcher.ts +3 -0
  36. package/src/components/mcp/playground-files.ts +33 -1
  37. package/src/components/viewer/BCFPanel.tsx +1 -16
  38. package/src/components/viewer/ChatPanel.tsx +11 -46
  39. package/src/components/viewer/CommandPalette.tsx +6 -1
  40. package/src/components/viewer/ComparePanel.tsx +420 -0
  41. package/src/components/viewer/HierarchyPanel.tsx +48 -183
  42. package/src/components/viewer/IDSPanel.tsx +1 -26
  43. package/src/components/viewer/MainToolbar.tsx +94 -187
  44. package/src/components/viewer/MobileToolbar.tsx +1 -9
  45. package/src/components/viewer/PropertiesPanel.tsx +98 -127
  46. package/src/components/viewer/ScriptPanel.tsx +8 -34
  47. package/src/components/viewer/Section2DPanel.tsx +32 -1
  48. package/src/components/viewer/ViewerLayout.tsx +5 -2
  49. package/src/components/viewer/Viewport.tsx +3 -0
  50. package/src/components/viewer/ViewportContainer.tsx +24 -42
  51. package/src/components/viewer/ViewportOverlays.tsx +1 -4
  52. package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
  53. package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
  54. package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
  55. package/src/components/viewer/hierarchy/types.ts +1 -0
  56. package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
  57. package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
  58. package/src/components/viewer/useGeometryStreaming.ts +0 -2
  59. package/src/hooks/federationLoadGate.test.ts +12 -2
  60. package/src/hooks/federationLoadGate.ts +9 -2
  61. package/src/hooks/ingest/federationAlign.ts +488 -0
  62. package/src/hooks/ingest/viewerModelIngest.ts +3 -212
  63. package/src/hooks/useCompare.ts +0 -0
  64. package/src/hooks/useCompareOverlay.ts +119 -0
  65. package/src/hooks/useDrawingGeneration.ts +234 -14
  66. package/src/hooks/useIfc.ts +1 -1
  67. package/src/hooks/useIfcCache.ts +100 -24
  68. package/src/hooks/useIfcFederation.ts +42 -811
  69. package/src/hooks/useIfcLoader.ts +349 -1517
  70. package/src/hooks/useIfcServer.ts +3 -0
  71. package/src/hooks/useLens.ts +5 -1
  72. package/src/hooks/useSymbolicAnnotations.ts +70 -38
  73. package/src/lib/compare/buildFingerprints.ts +173 -0
  74. package/src/lib/compare/describeChange.ts +0 -0
  75. package/src/lib/compare/geometricData.test.ts +54 -0
  76. package/src/lib/compare/geometricData.ts +37 -0
  77. package/src/lib/compare/overlay.test.ts +99 -0
  78. package/src/lib/compare/overlay.ts +91 -0
  79. package/src/lib/geo/cesium-placement.ts +1 -1
  80. package/src/lib/geo/reproject.ts +4 -1
  81. package/src/lib/llm/script-edit-ops.ts +23 -0
  82. package/src/lib/llm/stream-client.ts +8 -1
  83. package/src/lib/search/result-export.ts +7 -1
  84. package/src/sdk/adapters/export-adapter.ts +6 -1
  85. package/src/services/cacheService.ts +9 -25
  86. package/src/services/desktop-export.ts +2 -59
  87. package/src/services/file-dialog.ts +8 -142
  88. package/src/store/constants.ts +23 -0
  89. package/src/store/globalId.ts +15 -13
  90. package/src/store/index.ts +19 -6
  91. package/src/store/slices/cesiumSlice.ts +8 -1
  92. package/src/store/slices/compareSlice.ts +96 -0
  93. package/src/store/slices/drawing2DSlice.ts +8 -0
  94. package/src/store/slices/lensSlice.ts +8 -0
  95. package/src/store/slices/visibilitySlice.ts +22 -1
  96. package/src/store/types.ts +1 -71
  97. package/src/utils/acquireFileBuffer.test.ts +12 -4
  98. package/src/utils/ifcConfig.ts +0 -12
  99. package/src/utils/loadingUtils.ts +32 -0
  100. package/src/utils/spatialHierarchy.test.ts +53 -1
  101. package/src/utils/spatialHierarchy.ts +42 -2
  102. package/src/vite-env.d.ts +2 -0
  103. package/vite.config.ts +6 -3
  104. package/DESKTOP_CONTRACT_VERSION +0 -1
  105. package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
  106. package/dist/assets/e57-source-CQHxE8n3.js +0 -1
  107. package/dist/assets/event-B0kAzHa-.js +0 -1
  108. package/dist/assets/geometry.worker-BdH-E6NB.js +0 -1
  109. package/dist/assets/index-ajK6D32J.css +0 -1
  110. package/dist/assets/lens-PYsLu_MA.js +0 -1
  111. package/dist/assets/parser.worker-D591Zu_-.js +0 -182
  112. package/dist/assets/raw-D9iw0tmc.js +0 -1
  113. package/dist/assets/server-client-Cjwnm7il.js +0 -706
  114. package/dist/assets/tauri-core-stub-D8Fa-u43.js +0 -1
  115. package/dist/assets/tauri-dialog-stub-r7Wksg7o.js +0 -1
  116. package/dist/assets/tauri-fs-stub-BdeRC7aK.js +0 -1
  117. package/src/components/viewer/DesktopEntitlementBanner.tsx +0 -74
  118. package/src/components/viewer/SettingsPage.tsx +0 -581
  119. package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
  120. package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
  121. package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
  122. package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
  123. package/src/lib/desktop/desktopEntitlementEvents.ts +0 -39
  124. package/src/lib/desktop-entitlement.ts +0 -43
  125. package/src/lib/desktop-product.ts +0 -130
  126. package/src/lib/platform.ts +0 -23
  127. package/src/services/desktop-cache.ts +0 -186
  128. package/src/services/desktop-harness.ts +0 -196
  129. package/src/services/desktop-logger.ts +0 -20
  130. package/src/services/desktop-native-metadata.ts +0 -230
  131. package/src/services/desktop-panel-actions.ts +0 -43
  132. package/src/services/desktop-preferences.ts +0 -44
  133. package/src/services/fs-cache.ts +0 -212
  134. package/src/services/tauri-core-stub.ts +0 -7
  135. package/src/services/tauri-dialog-stub.ts +0 -7
  136. package/src/services/tauri-fs-stub.ts +0 -7
  137. package/src/services/tauri-modules.d.ts +0 -50
  138. package/src/store/slices/desktopEntitlementSlice.ts +0 -86
  139. package/src/utils/desktopModelSnapshot.ts +0 -358
  140. package/src/utils/nativeSpatialDataStore.ts +0 -277
  141. package/src-tauri/Cargo.toml +0 -29
  142. package/src-tauri/build.rs +0 -7
  143. package/src-tauri/capabilities/default.json +0 -18
  144. package/src-tauri/icons/128x128.png +0 -0
  145. package/src-tauri/icons/128x128@2x.png +0 -0
  146. package/src-tauri/icons/32x32.png +0 -0
  147. package/src-tauri/icons/Square107x107Logo.png +0 -0
  148. package/src-tauri/icons/Square142x142Logo.png +0 -0
  149. package/src-tauri/icons/Square150x150Logo.png +0 -0
  150. package/src-tauri/icons/Square284x284Logo.png +0 -0
  151. package/src-tauri/icons/Square30x30Logo.png +0 -0
  152. package/src-tauri/icons/Square310x310Logo.png +0 -0
  153. package/src-tauri/icons/Square44x44Logo.png +0 -0
  154. package/src-tauri/icons/Square71x71Logo.png +0 -0
  155. package/src-tauri/icons/Square89x89Logo.png +0 -0
  156. package/src-tauri/icons/StoreLogo.png +0 -0
  157. package/src-tauri/icons/icon.icns +0 -0
  158. package/src-tauri/icons/icon.ico +0 -0
  159. package/src-tauri/icons/icon.png +0 -0
  160. package/src-tauri/src/lib.rs +0 -21
  161. package/src-tauri/src/main.rs +0 -10
  162. package/src-tauri/tauri.conf.json +0 -39
@@ -13,7 +13,7 @@
13
13
  * - Import/export BCF files
14
14
  */
15
15
 
16
- import React, { useCallback, useEffect, useState, useMemo, useRef } from 'react';
16
+ import React, { useCallback, useState, useMemo, useRef } from 'react';
17
17
  import {
18
18
  X,
19
19
  MessageSquare,
@@ -39,7 +39,6 @@ import { BCFTopicList } from './bcf/BCFTopicList';
39
39
  import { BCFTopicDetail } from './bcf/BCFTopicDetail';
40
40
  import { BCFCreateTopicForm } from './bcf/BCFCreateTopicForm';
41
41
  import { openGenericFileDialog } from '@/services/file-dialog';
42
- import { claimNextDesktopPanelAction, subscribeDesktopPanelActions } from '@/services/desktop-panel-actions';
43
42
 
44
43
  // ============================================================================
45
44
  // Main BCF Panel Component
@@ -298,20 +297,6 @@ export function BCFPanel({ onClose }: BCFPanelProps) {
298
297
  setShowAuthorDialog(false);
299
298
  }, [tempAuthor, setBcfAuthor]);
300
299
 
301
- useEffect(() => {
302
- const drainDesktopActions = () => {
303
- if (claimNextDesktopPanelAction('bcf-import')) {
304
- void importFromDialog();
305
- }
306
- if (claimNextDesktopPanelAction('bcf-export')) {
307
- void handleExport();
308
- }
309
- };
310
-
311
- drainDesktopActions();
312
- return subscribeDesktopPanelActions(drainDesktopActions);
313
- }, [handleExport, importFromDialog]);
314
-
315
300
  return (
316
301
  <div className="flex flex-col h-full bg-background">
317
302
  {/* Header */}
@@ -52,7 +52,6 @@ import { buildRepairSessionKey, getEscalatedRepairScope, pruneMessagesForRepair
52
52
  import type { ChatMessage, ChatRepairRequest, FileAttachment } from '@/lib/llm/types';
53
53
  import { canUsePlainCodeBlockFallback, type ScriptMutationIntent } from '@/lib/llm/script-preservation';
54
54
  import { Check, Image as ImageIcon, KeyRound } from 'lucide-react';
55
- import { hasDesktopFeatureAccess } from '@/lib/desktop-product';
56
55
  import { getModelById } from '@/lib/llm/models';
57
56
  import { resolveStreamRoute } from '@/lib/llm/byok-guard';
58
57
  import { getApiKeys, hasAnthropicKey, hasOpenaiKey, subscribeApiKeys } from '@/services/api-keys';
@@ -300,9 +299,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
300
299
  const setChatHasByokKey = useViewerStore((s) => s.setChatHasByokKey);
301
300
  const usage = useViewerStore((s) => s.chatUsage);
302
301
  const setChatUsage = useViewerStore((s) => s.setChatUsage);
303
- const desktopEntitlement = useViewerStore((s) => s.desktopEntitlement);
304
302
  const { execute } = useSandbox();
305
- const canUseAiAssistant = hasDesktopFeatureAccess(desktopEntitlement, 'ai_assistant');
306
303
 
307
304
  // Sync BYOK key availability into the store and track per-provider state
308
305
  const [keyStateAnthropic, setKeyStateAnthropic] = useState(hasAnthropicKey);
@@ -349,10 +346,6 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
349
346
  const [showScrollBtn, setShowScrollBtn] = useState(false);
350
347
  const [userScrolledUp, setUserScrolledUp] = useState(false);
351
348
  const [lastFinishReason, setLastFinishReason] = useState<string | null>(null);
352
- const promptAiUpgrade = useCallback(() => {
353
- setChatError('AI assistant is available with Desktop Pro.');
354
- toast.info('AI assistant is available with Desktop Pro');
355
- }, [setChatError]);
356
349
 
357
350
  const inputRef = useRef<HTMLTextAreaElement>(null);
358
351
  const scrollRef = useRef<HTMLDivElement>(null);
@@ -505,10 +498,6 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
505
498
  // ── Core send logic ──
506
499
  const doSend = useCallback(async (text: string, options?: ChatSendOptions) => {
507
500
  if (!text.trim() || status === 'streaming' || status === 'sending') return;
508
- if (!canUseAiAssistant) {
509
- setChatError('AI assistant is available with Desktop Pro.');
510
- return;
511
- }
512
501
  // Clear any stale post-authoring CTA — this turn re-establishes it
513
502
  // on completion if it's another authoring turn.
514
503
  setChatToolReady(null);
@@ -1056,7 +1045,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1056
1045
  }
1057
1046
  }
1058
1047
  }, [
1059
- canUseAiAssistant, status, activeModel, attachments,
1048
+ status, activeModel, attachments,
1060
1049
  addMessage, setChatStatus, updateStreaming, finalizeAssistant,
1061
1050
  setChatError, setChatAbortController, clearAttachments, setChatUsage, resizeInput,
1062
1051
  buildRepairPromptFromLiveState, triggerAutoRepair, execute, extensionHost,
@@ -1064,12 +1053,8 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1064
1053
  ]);
1065
1054
 
1066
1055
  const handleSend = useCallback(() => {
1067
- if (!canUseAiAssistant) {
1068
- promptAiUpgrade();
1069
- return;
1070
- }
1071
1056
  doSend(inputText);
1072
- }, [canUseAiAssistant, doSend, inputText, promptAiUpgrade]);
1057
+ }, [doSend, inputText]);
1073
1058
 
1074
1059
  // Allow other panels (e.g. ScriptPanel errors) to trigger a chat repair turn.
1075
1060
  useEffect(() => {
@@ -1098,10 +1083,6 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1098
1083
  }, [pendingRepairRequest, status, consumePendingRepairRequest, buildRepairPromptFromLiveState, doSend]);
1099
1084
 
1100
1085
  const handleContinue = useCallback(() => {
1101
- if (!canUseAiAssistant) {
1102
- promptAiUpgrade();
1103
- return;
1104
- }
1105
1086
  const state = useViewerStore.getState();
1106
1087
  const partial = state.chatStreamingContent.trim();
1107
1088
  const lastAssistant = [...state.chatMessages].reverse().find((m) => m.role === 'assistant');
@@ -1114,7 +1095,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1114
1095
  }
1115
1096
  setChatError(null);
1116
1097
  doSend(CONTINUE_PROMPT, { continuationBase });
1117
- }, [canUseAiAssistant, doSend, finalizeAssistant, promptAiUpgrade, setChatError]);
1098
+ }, [doSend, finalizeAssistant, setChatError]);
1118
1099
 
1119
1100
  const handleStop = useCallback(() => {
1120
1101
  const controller = useViewerStore.getState().chatAbortController;
@@ -1139,10 +1120,6 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1139
1120
 
1140
1121
  // ── Error feedback (Fix this) ──
1141
1122
  const handleFixError = useCallback((code: string, errorMsg: string) => {
1142
- if (!canUseAiAssistant) {
1143
- promptAiUpgrade();
1144
- return;
1145
- }
1146
1123
  const diagnostics = useViewerStore.getState().scriptLastDiagnostics;
1147
1124
  const liveCode = useViewerStore.getState().scriptEditorContent;
1148
1125
  const staleCode = code.trim() !== liveCode.trim() ? code : undefined;
@@ -1157,7 +1134,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1157
1134
  requestedRepairScope: getPrimaryRootCause(diagnostics)?.repairScope,
1158
1135
  rootCauseKey: getPrimaryRootCause(diagnostics)?.rootCauseKey,
1159
1136
  });
1160
- }, [buildRepairPromptFromLiveState, canUseAiAssistant, doSend, promptAiUpgrade]);
1137
+ }, [buildRepairPromptFromLiveState, doSend]);
1161
1138
 
1162
1139
  // ── Clickable example prompts ──
1163
1140
  const handleExampleClick = useCallback((prompt: string) => {
@@ -1191,10 +1168,6 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1191
1168
 
1192
1169
  // ── File upload (button + drag-drop + paste) ──
1193
1170
  const processFiles = useCallback(async (files: FileList | File[]) => {
1194
- if (!canUseAiAssistant) {
1195
- promptAiUpgrade();
1196
- return;
1197
- }
1198
1171
  const model = getModelById(activeModel);
1199
1172
  const supportsImages = model?.supportsImages ?? false;
1200
1173
  const supportsFileAttachments = model?.supportsFileAttachments ?? true;
@@ -1309,7 +1282,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1309
1282
  setChatError(`Could not read ${file.name}: ${error instanceof Error ? error.message : String(error)}`);
1310
1283
  }
1311
1284
  }
1312
- }, [activeModel, addAttachment, attachments.length, canUseAiAssistant, promptAiUpgrade, setChatError]);
1285
+ }, [activeModel, addAttachment, attachments.length, setChatError]);
1313
1286
 
1314
1287
  const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
1315
1288
  const files = e.target.files;
@@ -1490,15 +1463,9 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1490
1463
  )}
1491
1464
  </div>
1492
1465
 
1493
- {!canUseAiAssistant && (
1494
- <div className="border-b bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
1495
- AI assistant requires Desktop Pro. Core viewing and scripting stay available without it.
1496
- </div>
1497
- )}
1498
-
1499
1466
  {/* Slim CTA banner — appears when the modal has been dismissed but the
1500
1467
  selected model still needs a key. Re-opens the modal on click. */}
1501
- {needsByokKey && canUseAiAssistant && !byokModal.open && (
1468
+ {needsByokKey && !byokModal.open && (
1502
1469
  <button
1503
1470
  type="button"
1504
1471
  onClick={() => openByokModal(needsAnthropicKey ? 'anthropic' : 'openai')}
@@ -1734,16 +1701,14 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1734
1701
  variant="ghost"
1735
1702
  size="icon-xs"
1736
1703
  onClick={() => fileInputRef.current?.click()}
1737
- disabled={!canAttachInput || !canUseAiAssistant}
1704
+ disabled={!canAttachInput}
1738
1705
  className="shrink-0 mb-0.5"
1739
1706
  >
1740
1707
  <Paperclip className="h-3.5 w-3.5" />
1741
1708
  </Button>
1742
1709
  </TooltipTrigger>
1743
1710
  <TooltipContent>
1744
- {!canUseAiAssistant
1745
- ? 'AI assistant not available'
1746
- : canAttachInput
1711
+ {canAttachInput
1747
1712
  ? 'Attach file or image (paste, drag & drop)'
1748
1713
  : 'Selected model does not support attachments'}
1749
1714
  </TooltipContent>
@@ -1758,11 +1723,11 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1758
1723
  }}
1759
1724
  onKeyDown={handleKeyDown}
1760
1725
  onPaste={handlePaste}
1761
- placeholder={!canUseAiAssistant ? 'AI assistant not available' : needsByokKey ? `Add your ${needsAnthropicKey ? 'Anthropic' : 'OpenAI'} key to chat with this model` : 'Ask anything...'}
1726
+ placeholder={needsByokKey ? `Add your ${needsAnthropicKey ? 'Anthropic' : 'OpenAI'} key to chat with this model` : 'Ask anything...'}
1762
1727
  rows={1}
1763
1728
  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"
1764
1729
  style={{ height: 'auto', overflow: 'hidden' }}
1765
- disabled={!canUseAiAssistant || needsByokKey}
1730
+ disabled={needsByokKey}
1766
1731
  />
1767
1732
 
1768
1733
  {isActive ? (
@@ -1786,7 +1751,7 @@ export function ChatPanel({ onClose }: ChatPanelProps) {
1786
1751
  variant="default"
1787
1752
  size="icon-xs"
1788
1753
  onClick={handleSend}
1789
- disabled={!inputText.trim() || !canUseAiAssistant || needsByokKey}
1754
+ disabled={!inputText.trim() || needsByokKey}
1790
1755
  className="shrink-0 mb-0.5"
1791
1756
  >
1792
1757
  <Send className="h-3.5 w-3.5" />
@@ -24,6 +24,7 @@ import {
24
24
  Home,
25
25
  Maximize2,
26
26
  Crosshair,
27
+ GitCompareArrows,
27
28
  ArrowUp,
28
29
  ArrowDown,
29
30
  ArrowLeft,
@@ -203,12 +204,13 @@ function downloadBlob(data: BlobPart, name: string, mime: string) {
203
204
  * Closes all others first so the if-else chain in ViewerLayout renders it.
204
205
  * If the target is already active, closes it (back to Properties). */
205
206
 
206
- function activateRightPanel(panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'extensions') {
207
+ function activateRightPanel(panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'compare' | 'extensions') {
207
208
  const s = useViewerStore.getState();
208
209
  const isActive =
209
210
  panel === 'bcf' ? s.bcfPanelVisible :
210
211
  panel === 'ids' ? s.idsPanelVisible :
211
212
  panel === 'clash' ? s.clashPanelVisible :
213
+ panel === 'compare' ? s.comparePanelVisible :
212
214
  panel === 'extensions' ? s.extensionsPanelVisible :
213
215
  s.lensPanelVisible;
214
216
 
@@ -220,6 +222,7 @@ function activateRightPanel(panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'extension
220
222
  s.setIdsPanelVisible(false);
221
223
  s.setLensPanelVisible(false);
222
224
  s.setClashPanelVisible(false);
225
+ s.setComparePanelVisible(false);
223
226
  s.setExtensionsPanelVisible(false);
224
227
  } else {
225
228
  // Open exclusively (closes every sibling, including clash) and un-collapse.
@@ -431,6 +434,8 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
431
434
  action: () => { activateRightPanel('ids'); } },
432
435
  { id: 'panel:clash', label: 'Clash Detection', keywords: 'collision interference clearance coordination clash matrix mep', category: 'Panels', icon: Crosshair,
433
436
  action: () => { activateRightPanel('clash'); } },
437
+ { id: 'panel:compare', label: 'Compare Models', keywords: 'diff revision version change added deleted modified geometry data', category: 'Panels', icon: GitCompareArrows,
438
+ action: () => { activateRightPanel('compare'); } },
434
439
  { id: 'panel:lists', label: 'Entity Lists', keywords: 'table spreadsheet', category: 'Panels', icon: FileSpreadsheet,
435
440
  action: () => { activateBottomPanel('list'); } },
436
441
  { id: 'panel:gantt', label: 'Construction Schedule (Gantt)', keywords: '4d timeline tasks ifctask sequence playback animation', category: 'Panels', icon: CalendarClock,
@@ -0,0 +1,420 @@
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
+ * Model comparison panel (issue #924). Pick two loaded models as A (base) and
7
+ * B (head), choose a data/geometry/both scope, run the `@ifc-lite/diff` engine,
8
+ * and review added / modified / deleted elements — colour-coded in 3D (via
9
+ * `useCompareOverlay`) and listed here. Row click selects + frames the element.
10
+ */
11
+
12
+ import { useEffect, useMemo } from 'react';
13
+ import { GitCompareArrows, Plus, Minus, PencilLine, Loader2, Play, X, Trash2 } from 'lucide-react';
14
+ import { Button } from '@/components/ui/button';
15
+ import { ScrollArea } from '@/components/ui/scroll-area';
16
+ import { cn } from '@/lib/utils';
17
+ import { useViewerStore } from '@/store';
18
+ import { useCompare } from '@/hooks/useCompare';
19
+ import { useCompareOverlay } from '@/hooks/useCompareOverlay';
20
+ import { COMPARE_COLORS, type RGBA } from '@/lib/compare/overlay';
21
+ import type { CompareRef } from '@/lib/compare/buildFingerprints';
22
+ import { describeChange, type ChangeDetail, type FieldDelta, type GeometrySummary } from '@/lib/compare/describeChange';
23
+ import type { DiffScope, DiffState, DiffEntry } from '@ifc-lite/diff';
24
+
25
+ interface ComparePanelProps {
26
+ onClose?: () => void;
27
+ }
28
+
29
+ const SCOPES: { id: DiffScope; label: string }[] = [
30
+ { id: 'both', label: 'Both' },
31
+ { id: 'data', label: 'Data' },
32
+ { id: 'geometry', label: 'Geometry' },
33
+ ];
34
+
35
+ /** States listed in the panel (unchanged only affects 3D ghosting). */
36
+ const LISTED_STATES: { state: Exclude<DiffState, 'unchanged'>; label: string; color: RGBA; Icon: typeof Plus }[] = [
37
+ { state: 'modified', label: 'Changed', color: COMPARE_COLORS.modified, Icon: PencilLine },
38
+ { state: 'added', label: 'Added', color: COMPARE_COLORS.added, Icon: Plus },
39
+ { state: 'deleted', label: 'Deleted', color: COMPARE_COLORS.deleted, Icon: Minus },
40
+ ];
41
+
42
+ /** Cap rows rendered per group so a huge diff can't stall the DOM. */
43
+ const MAX_ROWS_PER_GROUP = 1000;
44
+
45
+ function rgbaCss([r, g, b, a]: RGBA): string {
46
+ return `rgba(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)}, ${a})`;
47
+ }
48
+
49
+ interface CompareRow {
50
+ key: string;
51
+ ifcType: string;
52
+ name: string;
53
+ changeKinds: string[];
54
+ ref: CompareRef;
55
+ }
56
+
57
+ /** The side actually drawn for an entry: base for deletions, head otherwise. */
58
+ function renderRef(entry: DiffEntry<CompareRef>): CompareRef | undefined {
59
+ return (entry.state === 'deleted' ? entry.base?.ref : entry.head?.ref) ?? entry.base?.ref;
60
+ }
61
+
62
+ export function ComparePanel({ onClose }: ComparePanelProps) {
63
+ useCompareOverlay();
64
+
65
+ const models = useViewerStore((s) => s.models);
66
+ const baseModelId = useViewerStore((s) => s.compareBaseModelId);
67
+ const headModelId = useViewerStore((s) => s.compareHeadModelId);
68
+ const scope = useViewerStore((s) => s.compareScope);
69
+ const showUnchanged = useViewerStore((s) => s.compareShowUnchanged);
70
+ const selectedKey = useViewerStore((s) => s.compareSelectedKey);
71
+ const setBaseModelId = useViewerStore((s) => s.setCompareBaseModelId);
72
+ const setHeadModelId = useViewerStore((s) => s.setCompareHeadModelId);
73
+ const setScope = useViewerStore((s) => s.setCompareScope);
74
+ const setShowUnchanged = useViewerStore((s) => s.setCompareShowUnchanged);
75
+ const clearCompare = useViewerStore((s) => s.clearCompare);
76
+
77
+ const { running, result, error, runComparison } = useCompare();
78
+
79
+ const modelList = useMemo(() => Array.from(models.values()), [models]);
80
+
81
+ // Default the A/B selection to the first two loaded models, and repair the
82
+ // selection if a chosen model was removed.
83
+ useEffect(() => {
84
+ const ids = modelList.map((m) => m.id);
85
+ // A comparison computed against a model that's since been removed leaves a
86
+ // stale overlay on the survivor (the overlay hook keys off the result, not
87
+ // the model list) — drop it so the scene is restored.
88
+ const ran = useViewerStore.getState().compareResult;
89
+ if (ran && (!ids.includes(ran.baseModelId) || !ids.includes(ran.headModelId))) {
90
+ clearCompare();
91
+ }
92
+ if (ids.length === 0) return;
93
+ if (!baseModelId || !ids.includes(baseModelId)) {
94
+ setBaseModelId(ids[0]);
95
+ }
96
+ if (ids.length > 1 && (!headModelId || !ids.includes(headModelId) || headModelId === ids[0])) {
97
+ const other = ids.find((id) => id !== ids[0]);
98
+ if (other) setHeadModelId(other);
99
+ }
100
+ // eslint-disable-next-line react-hooks/exhaustive-deps
101
+ }, [modelList]);
102
+
103
+ // Resolve a display name + grouped rows from the diff result. Names live in
104
+ // the per-model store (the engine result carries only type + key), so we
105
+ // look them up here via each entry's ref.
106
+ const groups = useMemo(() => {
107
+ const empty = new Map<DiffState, { rows: CompareRow[]; truncated: number }>();
108
+ if (!result) return empty;
109
+ const out = new Map<DiffState, { rows: CompareRow[]; truncated: number }>();
110
+ for (const { state } of LISTED_STATES) out.set(state, { rows: [], truncated: 0 });
111
+
112
+ for (const entry of result.diff.entries) {
113
+ const bucket = out.get(entry.state);
114
+ if (!bucket) continue; // skip unchanged
115
+ const ref = renderRef(entry);
116
+ if (!ref) continue;
117
+ if (bucket.rows.length >= MAX_ROWS_PER_GROUP) {
118
+ bucket.truncated++;
119
+ continue;
120
+ }
121
+ const store = models.get(ref.modelId)?.ifcDataStore;
122
+ const name = store?.entities.getName(ref.localId) || '';
123
+ const ifcType = (entry.head ?? entry.base)?.ifcType ?? 'IfcProduct';
124
+ bucket.rows.push({ key: entry.key, ifcType, name, changeKinds: entry.changeKinds, ref });
125
+ }
126
+ return out;
127
+ }, [result, models]);
128
+
129
+ const counts = result?.diff.counts;
130
+ const canRun = !!baseModelId && !!headModelId && baseModelId !== headModelId && !running;
131
+
132
+ // "What changed" detail for the selected entry — computed lazily from both
133
+ // stores so a huge diff stays cheap (only the selection is described).
134
+ const detail = useMemo<ChangeDetail | null>(() => {
135
+ if (!result || !selectedKey) return null;
136
+ const entry = result.diff.byKey.get(selectedKey);
137
+ return entry ? describeChange(entry, models) : null;
138
+ }, [result, selectedKey, models]);
139
+
140
+ const selectedRow = useMemo<CompareRow | null>(() => {
141
+ if (!selectedKey) return null;
142
+ for (const bucket of groups.values()) {
143
+ const row = bucket.rows.find((r) => r.key === selectedKey);
144
+ if (row) return row;
145
+ }
146
+ return null;
147
+ }, [groups, selectedKey]);
148
+
149
+ const focusEntry = (row: CompareRow) => {
150
+ const state = useViewerStore.getState();
151
+ state.clearEntitySelection();
152
+ state.setSelectedEntityIds([row.ref.globalId]);
153
+ state.addEntitiesToSelection([{ modelId: row.ref.modelId, expressId: row.ref.localId }]);
154
+ state.setCompareSelectedKey(row.key);
155
+ requestAnimationFrame(() => state.cameraCallbacks.frameSelection?.());
156
+ };
157
+
158
+ return (
159
+ <div className="h-full flex flex-col bg-background text-foreground overflow-hidden min-w-0">
160
+ {/* Header */}
161
+ <div className="flex items-center gap-2 p-3 border-b border-border">
162
+ <GitCompareArrows className="h-4 w-4 text-primary shrink-0" />
163
+ <span className="text-sm font-semibold tracking-tight min-w-0">Compare models</span>
164
+ <div className="ml-auto flex items-center gap-1 shrink-0">
165
+ {result && (
166
+ <Button variant="ghost" size="icon" className="h-7 w-7" title="Clear results" onClick={clearCompare}>
167
+ <Trash2 className="h-4 w-4" />
168
+ </Button>
169
+ )}
170
+ {onClose && (
171
+ <Button variant="ghost" size="icon" className="h-7 w-7" title="Close" onClick={onClose}>
172
+ <X className="h-4 w-4" />
173
+ </Button>
174
+ )}
175
+ </div>
176
+ </div>
177
+
178
+ {modelList.length < 2 ? (
179
+ <div className="p-4 text-sm text-muted-foreground">
180
+ Load a second model to compare. Open two IFC files (federation), then pick
181
+ version A and version B here.
182
+ </div>
183
+ ) : (
184
+ <>
185
+ {/* Run controls */}
186
+ <div className="p-3 space-y-3 border-b border-border">
187
+ <div className="grid grid-cols-[1.25rem_1fr] items-center gap-x-2 gap-y-2 text-xs">
188
+ <span className="text-muted-foreground">A</span>
189
+ <select
190
+ value={baseModelId ?? ''}
191
+ onChange={(e) => setBaseModelId(e.target.value)}
192
+ className="w-full rounded border border-border bg-transparent px-2 py-1 text-foreground min-w-0"
193
+ >
194
+ {modelList.map((m) => (
195
+ <option key={m.id} value={m.id}>{m.name}</option>
196
+ ))}
197
+ </select>
198
+ <span className="text-muted-foreground">B</span>
199
+ <select
200
+ value={headModelId ?? ''}
201
+ onChange={(e) => setHeadModelId(e.target.value)}
202
+ className="w-full rounded border border-border bg-transparent px-2 py-1 text-foreground min-w-0"
203
+ >
204
+ {modelList.map((m) => (
205
+ <option key={m.id} value={m.id}>{m.name}</option>
206
+ ))}
207
+ </select>
208
+ </div>
209
+
210
+ {baseModelId === headModelId && (
211
+ <p className="text-xs text-[#e0af68]">Pick two different models.</p>
212
+ )}
213
+
214
+ <div className="flex flex-wrap items-center gap-2">
215
+ <div className="inline-flex rounded-md border border-border overflow-hidden text-xs shrink-0">
216
+ {SCOPES.map((s) => (
217
+ <button
218
+ key={s.id}
219
+ onClick={() => setScope(s.id)}
220
+ className={cn(
221
+ 'px-2.5 py-1 transition-colors',
222
+ scope === s.id ? 'bg-primary text-primary-foreground' : 'hover:bg-muted',
223
+ )}
224
+ >
225
+ {s.label}
226
+ </button>
227
+ ))}
228
+ </div>
229
+ <label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
230
+ <input
231
+ type="checkbox"
232
+ checked={showUnchanged}
233
+ onChange={(e) => setShowUnchanged(e.target.checked)}
234
+ />
235
+ Show unchanged
236
+ </label>
237
+ </div>
238
+
239
+ <Button size="sm" className="w-full gap-1.5" disabled={!canRun} onClick={() => void runComparison()}>
240
+ {running ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
241
+ {running ? 'Comparing…' : 'Run comparison'}
242
+ </Button>
243
+
244
+ {error && <p className="text-xs text-[#f7768e]">{error}</p>}
245
+
246
+ {result?.geometryUnavailable && scope !== 'data' && (
247
+ <p className="text-xs text-[#e0af68]">
248
+ One model has no geometry fingerprints (loaded outside the WASM
249
+ mesh path), so geometry changes can’t be detected. Data changes
250
+ are still accurate — switch to the Data scope for reliable results.
251
+ </p>
252
+ )}
253
+ </div>
254
+
255
+ {/* Counts */}
256
+ {counts && (
257
+ <div className="grid grid-cols-4 gap-1 p-3 border-b border-border text-center">
258
+ <CountBadge label="Changed" value={counts.modified} color={COMPARE_COLORS.modified} />
259
+ <CountBadge label="Added" value={counts.added} color={COMPARE_COLORS.added} />
260
+ <CountBadge label="Deleted" value={counts.deleted} color={COMPARE_COLORS.deleted} />
261
+ <CountBadge label="Unchanged" value={counts.unchanged} color={COMPARE_COLORS.unchanged} />
262
+ </div>
263
+ )}
264
+
265
+ {/* Results list */}
266
+ <ScrollArea className="flex-1 min-h-0">
267
+ {!result ? (
268
+ <div className="p-4 text-sm text-muted-foreground">
269
+ Run a comparison to see added, changed, and deleted elements.
270
+ </div>
271
+ ) : (
272
+ <div className="p-2 space-y-3">
273
+ {LISTED_STATES.map(({ state, label, color, Icon }) => {
274
+ const bucket = groups.get(state);
275
+ if (!bucket || bucket.rows.length === 0) return null;
276
+ return (
277
+ <div key={state}>
278
+ <div className="flex items-center gap-1.5 px-1 py-1 text-xs font-medium">
279
+ <Icon className="h-3.5 w-3.5" style={{ color: rgbaCss(color) }} />
280
+ <span>{label}</span>
281
+ <span className="text-muted-foreground">({bucket.rows.length + bucket.truncated})</span>
282
+ </div>
283
+ <div className="space-y-0.5">
284
+ {bucket.rows.map((row) => (
285
+ <button
286
+ key={row.key}
287
+ onClick={() => focusEntry(row)}
288
+ className={cn(
289
+ 'w-full text-left rounded px-2 py-1 flex items-center gap-2 hover:bg-muted transition-colors min-w-0',
290
+ selectedKey === row.key && 'bg-muted',
291
+ )}
292
+ >
293
+ <span
294
+ className="h-2.5 w-2.5 rounded-sm shrink-0"
295
+ style={{ backgroundColor: rgbaCss(color) }}
296
+ />
297
+ <span className="min-w-0 flex-1 truncate text-xs">
298
+ {row.name || row.ifcType}
299
+ </span>
300
+ <span className="shrink-0 text-[10px] text-muted-foreground">
301
+ {state === 'modified' && row.changeKinds.length > 0
302
+ ? row.changeKinds.join(' · ')
303
+ : row.ifcType.replace(/^Ifc/, '')}
304
+ </span>
305
+ </button>
306
+ ))}
307
+ {bucket.truncated > 0 && (
308
+ <p className="px-2 py-1 text-[10px] text-muted-foreground">
309
+ +{bucket.truncated} more not shown
310
+ </p>
311
+ )}
312
+ </div>
313
+ </div>
314
+ );
315
+ })}
316
+ {counts && counts.added + counts.modified + counts.deleted === 0 && (
317
+ <div className="p-3 text-sm text-muted-foreground">
318
+ No differences in scope “{result.scope}”. The models match.
319
+ </div>
320
+ )}
321
+ </div>
322
+ )}
323
+ </ScrollArea>
324
+
325
+ {/* What-changed detail for the selected element */}
326
+ {detail && selectedRow && <ChangeDetailView row={selectedRow} detail={detail} />}
327
+ </>
328
+ )}
329
+ </div>
330
+ );
331
+ }
332
+
333
+ /** Per-element "what changed" — geometry move/reshape + data field deltas. */
334
+ function ChangeDetailView({ row, detail }: { row: CompareRow; detail: ChangeDetail }) {
335
+ return (
336
+ <div className="border-t border-border shrink-0 max-h-[42%] overflow-auto">
337
+ <div className="px-3 pt-2.5 pb-1.5 flex items-center gap-1.5 sticky top-0 bg-background">
338
+ <PencilLine className="h-3.5 w-3.5 text-primary shrink-0" />
339
+ <span className="text-xs font-semibold truncate">{row.name || row.ifcType}</span>
340
+ <span className="ml-auto text-[10px] text-muted-foreground shrink-0">{row.ifcType.replace(/^Ifc/, '')}</span>
341
+ </div>
342
+ <div className="px-3 pb-3 space-y-2.5 text-xs">
343
+ {detail.geometry && (
344
+ <div className="space-y-1">
345
+ <div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">Geometry</div>
346
+ <GeometryDetail summary={detail.geometry} />
347
+ </div>
348
+ )}
349
+ {detail.data.length > 0 ? (
350
+ <div className="space-y-1">
351
+ <div className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide">
352
+ Data <span className="text-muted-foreground/70">({detail.data.length})</span>
353
+ </div>
354
+ <div className="space-y-1">
355
+ {detail.data.map((d, i) => <FieldDeltaRow key={i} delta={d} />)}
356
+ </div>
357
+ </div>
358
+ ) : detail.dataOnlyGeometric ? (
359
+ <div className="text-[11px] text-muted-foreground italic">
360
+ Data fingerprint differs but no field-level change could be pinpointed.
361
+ </div>
362
+ ) : !detail.geometry ? (
363
+ <div className="text-[11px] text-muted-foreground italic">No field-level detail available.</div>
364
+ ) : null}
365
+ </div>
366
+ </div>
367
+ );
368
+ }
369
+
370
+ function GeometryDetail({ summary }: { summary: GeometrySummary }) {
371
+ const moved = summary.movedDistance >= 1e-3;
372
+ const fmt = (n: number) => (Math.abs(n) < 1e-3 ? '0' : n.toFixed(n >= 1 ? 2 : 3));
373
+ const headline = summary.reshaped ? (moved ? 'Reshaped + moved' : 'Reshaped') : moved ? 'Moved' : 'Geometry changed';
374
+ return (
375
+ <div className="rounded border border-border/60 px-2 py-1.5 space-y-0.5">
376
+ <div className="font-medium">{headline}</div>
377
+ {moved && (
378
+ <div className="text-muted-foreground tabular-nums">
379
+ {fmt(summary.movedDistance)} m
380
+ <span className="text-muted-foreground/70">
381
+ {' '}(Δx {fmt(summary.delta.x)}, Δy {fmt(summary.delta.y)}, Δz {fmt(summary.delta.z)})
382
+ </span>
383
+ </div>
384
+ )}
385
+ </div>
386
+ );
387
+ }
388
+
389
+ function FieldDeltaRow({ delta }: { delta: FieldDelta }) {
390
+ const kindColor: Record<FieldDelta['kind'], string> = {
391
+ changed: 'text-[#e0af68]',
392
+ added: 'text-[#9ece6a]',
393
+ removed: 'text-[#f7768e]',
394
+ };
395
+ return (
396
+ <div className="rounded border border-border/40 px-2 py-1">
397
+ <div className="flex items-baseline gap-1.5 min-w-0">
398
+ {delta.group && <span className="text-[10px] text-muted-foreground shrink-0 truncate max-w-[40%]">{delta.group}</span>}
399
+ <span className="text-[11px] font-medium truncate">{delta.name}</span>
400
+ <span className={cn('ml-auto text-[10px] shrink-0', kindColor[delta.kind])}>{delta.kind}</span>
401
+ </div>
402
+ <div className="flex items-center gap-1.5 text-[11px] tabular-nums mt-0.5 min-w-0">
403
+ <span className="text-muted-foreground line-through truncate max-w-[45%]">{delta.before ?? '—'}</span>
404
+ <span className="text-muted-foreground/60 shrink-0">→</span>
405
+ <span className="truncate max-w-[45%]">{delta.after ?? '—'}</span>
406
+ </div>
407
+ </div>
408
+ );
409
+ }
410
+
411
+ function CountBadge({ label, value, color }: { label: string; value: number; color: RGBA }) {
412
+ return (
413
+ <div className="flex flex-col items-center gap-0.5">
414
+ <span className="text-sm font-semibold tabular-nums" style={{ color: rgbaCss([color[0], color[1], color[2], 1]) }}>
415
+ {value.toLocaleString()}
416
+ </span>
417
+ <span className="text-[10px] text-muted-foreground">{label}</span>
418
+ </div>
419
+ );
420
+ }