@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.
- package/.turbo/turbo-build.log +35 -42
- package/CHANGELOG.md +74 -0
- package/dist/assets/{basketViewActivator-B3CdrLsb.js → basketViewActivator-Ce38DhXd.js} +8 -8
- package/dist/assets/{bcf-QeHK_Aud.js → bcf-Cv_O3JfD.js} +56 -56
- package/dist/assets/{decode-worker-CgM1iNSK.js → decode-worker-Cjign7Zh.js} +1 -1
- package/dist/assets/{deflate-B-d0SYQM.js → deflate-HbyMq59o.js} +1 -1
- package/dist/assets/drawing-2d-DW98umlt.js +257 -0
- package/dist/assets/e57-source-2wI9jkCA.js +1 -0
- package/dist/assets/{exporters-B4LbZFeT.js → exporters-BuD3XRzB.js} +1309 -1153
- package/dist/assets/geometry.worker-TH3fCCoY.js +1 -0
- package/dist/assets/{geotiff-CrVtDRFq.js → geotiff-B2HA8Bwm.js} +10 -10
- package/dist/assets/{ids-DjsGFN10.js → ids-DYUFMd5f.js} +952 -945
- package/dist/assets/{ifc-lite_bg-DsYUIHm3.wasm → ifc-lite_bg-BEA5DLmg.wasm} +0 -0
- package/dist/assets/index-E9wB0zWt.css +1 -0
- package/dist/assets/{index-COYokSKc.js → index-n5O1QJMM.js} +37877 -38126
- package/dist/assets/{index.es-CY202jA3.js → index.es-BKVIpZgL.js} +9 -9
- package/dist/assets/{jpeg-D4wOkf5h.js → jpeg-C7hjKjPX.js} +1 -1
- package/dist/assets/{jspdf.es.min-DIGb9BHN.js → jspdf.es.min-oWlFc42Y.js} +4 -4
- package/dist/assets/lens-C4p1kQ0p.js +1 -0
- package/dist/assets/{lerc-DmW0_tgf.js → lerc-BfIOGhQz.js} +1 -1
- package/dist/assets/{lzw-oWetY-d6.js → lzw-B0jRuuW5.js} +1 -1
- package/dist/assets/{native-bridge-BX8_tHXE.js → native-bridge-DpB-dtEn.js} +6 -3
- package/dist/assets/{packbits-F8Nkp4NY.js → packbits-DVvBTC39.js} +1 -1
- package/dist/assets/parser.worker-BDsWQ6rc.js +182 -0
- package/dist/assets/{pdf-Dsh3HPZB.js → pdf-dVIqI5ac.js} +10 -10
- package/dist/assets/raw-C0ZJYGmN.js +1 -0
- package/dist/assets/{sandbox-BAC3a-eN.js → sandbox-qpJlrNN0.js} +2962 -2554
- package/dist/assets/server-client-DVZ2huNS.js +719 -0
- package/dist/assets/{webimage-BLV1dgmd.js → webimage-B394g0Tw.js} +1 -1
- package/dist/assets/{xlsx-Bc2HTrjC.js → xlsx-D-oHO76J.js} +8 -8
- package/dist/assets/{zstd-C_1HxVrA.js → zstd-Bf38MwV2.js} +1 -1
- package/dist/index.html +9 -9
- package/package.json +24 -23
- package/src/App.tsx +1 -3
- package/src/components/mcp/playground-dispatcher.ts +3 -0
- package/src/components/mcp/playground-files.ts +33 -1
- package/src/components/viewer/BCFPanel.tsx +1 -16
- package/src/components/viewer/ChatPanel.tsx +11 -46
- package/src/components/viewer/CommandPalette.tsx +6 -1
- package/src/components/viewer/ComparePanel.tsx +420 -0
- package/src/components/viewer/HierarchyPanel.tsx +48 -183
- package/src/components/viewer/IDSPanel.tsx +1 -26
- package/src/components/viewer/MainToolbar.tsx +94 -187
- package/src/components/viewer/MobileToolbar.tsx +1 -9
- package/src/components/viewer/PropertiesPanel.tsx +98 -127
- package/src/components/viewer/ScriptPanel.tsx +8 -34
- package/src/components/viewer/Section2DPanel.tsx +32 -1
- package/src/components/viewer/ViewerLayout.tsx +5 -2
- package/src/components/viewer/Viewport.tsx +3 -0
- package/src/components/viewer/ViewportContainer.tsx +24 -42
- package/src/components/viewer/ViewportOverlays.tsx +1 -4
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +3 -3
- package/src/components/viewer/hierarchy/ifc-icons.ts +9 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +87 -0
- package/src/components/viewer/hierarchy/types.ts +1 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +6 -2
- package/src/components/viewer/properties/MaterialTotalsPanel.tsx +283 -0
- package/src/components/viewer/useGeometryStreaming.ts +0 -2
- package/src/hooks/federationLoadGate.test.ts +12 -2
- package/src/hooks/federationLoadGate.ts +9 -2
- package/src/hooks/ingest/federationAlign.ts +488 -0
- package/src/hooks/ingest/viewerModelIngest.ts +3 -212
- package/src/hooks/useCompare.ts +0 -0
- package/src/hooks/useCompareOverlay.ts +119 -0
- package/src/hooks/useDrawingGeneration.ts +234 -14
- package/src/hooks/useIfc.ts +1 -1
- package/src/hooks/useIfcCache.ts +100 -24
- package/src/hooks/useIfcFederation.ts +42 -811
- package/src/hooks/useIfcLoader.ts +349 -1517
- package/src/hooks/useIfcServer.ts +3 -0
- package/src/hooks/useLens.ts +5 -1
- package/src/hooks/useSymbolicAnnotations.ts +70 -38
- package/src/lib/compare/buildFingerprints.ts +173 -0
- package/src/lib/compare/describeChange.ts +0 -0
- package/src/lib/compare/geometricData.test.ts +54 -0
- package/src/lib/compare/geometricData.ts +37 -0
- package/src/lib/compare/overlay.test.ts +99 -0
- package/src/lib/compare/overlay.ts +91 -0
- package/src/lib/geo/cesium-placement.ts +1 -1
- package/src/lib/geo/reproject.ts +4 -1
- package/src/lib/llm/script-edit-ops.ts +23 -0
- package/src/lib/llm/stream-client.ts +8 -1
- package/src/lib/search/result-export.ts +7 -1
- package/src/sdk/adapters/export-adapter.ts +6 -1
- package/src/services/cacheService.ts +9 -25
- package/src/services/desktop-export.ts +2 -59
- package/src/services/file-dialog.ts +8 -142
- package/src/store/constants.ts +23 -0
- package/src/store/globalId.ts +15 -13
- package/src/store/index.ts +19 -6
- package/src/store/slices/cesiumSlice.ts +8 -1
- package/src/store/slices/compareSlice.ts +96 -0
- package/src/store/slices/drawing2DSlice.ts +8 -0
- package/src/store/slices/lensSlice.ts +8 -0
- package/src/store/slices/visibilitySlice.ts +22 -1
- package/src/store/types.ts +1 -71
- package/src/utils/acquireFileBuffer.test.ts +12 -4
- package/src/utils/ifcConfig.ts +0 -12
- package/src/utils/loadingUtils.ts +32 -0
- package/src/utils/spatialHierarchy.test.ts +53 -1
- package/src/utils/spatialHierarchy.ts +42 -2
- package/src/vite-env.d.ts +2 -0
- package/vite.config.ts +6 -3
- package/DESKTOP_CONTRACT_VERSION +0 -1
- package/dist/assets/drawing-2d-C71b8Ugx.js +0 -257
- package/dist/assets/e57-source-CQHxE8n3.js +0 -1
- package/dist/assets/event-B0kAzHa-.js +0 -1
- package/dist/assets/geometry.worker-BdH-E6NB.js +0 -1
- package/dist/assets/index-ajK6D32J.css +0 -1
- package/dist/assets/lens-PYsLu_MA.js +0 -1
- package/dist/assets/parser.worker-D591Zu_-.js +0 -182
- package/dist/assets/raw-D9iw0tmc.js +0 -1
- package/dist/assets/server-client-Cjwnm7il.js +0 -706
- package/dist/assets/tauri-core-stub-D8Fa-u43.js +0 -1
- package/dist/assets/tauri-dialog-stub-r7Wksg7o.js +0 -1
- package/dist/assets/tauri-fs-stub-BdeRC7aK.js +0 -1
- package/src/components/viewer/DesktopEntitlementBanner.tsx +0 -74
- package/src/components/viewer/SettingsPage.tsx +0 -581
- package/src/hooks/ingest/resolveDataStoreOrAbort.test.ts +0 -61
- package/src/hooks/ingest/resolveDataStoreOrAbort.ts +0 -28
- package/src/hooks/ingest/watchedGeometryStream.test.ts +0 -78
- package/src/hooks/ingest/watchedGeometryStream.ts +0 -76
- package/src/lib/desktop/desktopEntitlementEvents.ts +0 -39
- package/src/lib/desktop-entitlement.ts +0 -43
- package/src/lib/desktop-product.ts +0 -130
- package/src/lib/platform.ts +0 -23
- package/src/services/desktop-cache.ts +0 -186
- package/src/services/desktop-harness.ts +0 -196
- package/src/services/desktop-logger.ts +0 -20
- package/src/services/desktop-native-metadata.ts +0 -230
- package/src/services/desktop-panel-actions.ts +0 -43
- package/src/services/desktop-preferences.ts +0 -44
- package/src/services/fs-cache.ts +0 -212
- package/src/services/tauri-core-stub.ts +0 -7
- package/src/services/tauri-dialog-stub.ts +0 -7
- package/src/services/tauri-fs-stub.ts +0 -7
- package/src/services/tauri-modules.d.ts +0 -50
- package/src/store/slices/desktopEntitlementSlice.ts +0 -86
- package/src/utils/desktopModelSnapshot.ts +0 -358
- package/src/utils/nativeSpatialDataStore.ts +0 -277
- package/src-tauri/Cargo.toml +0 -29
- package/src-tauri/build.rs +0 -7
- package/src-tauri/capabilities/default.json +0 -18
- package/src-tauri/icons/128x128.png +0 -0
- package/src-tauri/icons/128x128@2x.png +0 -0
- package/src-tauri/icons/32x32.png +0 -0
- package/src-tauri/icons/Square107x107Logo.png +0 -0
- package/src-tauri/icons/Square142x142Logo.png +0 -0
- package/src-tauri/icons/Square150x150Logo.png +0 -0
- package/src-tauri/icons/Square284x284Logo.png +0 -0
- package/src-tauri/icons/Square30x30Logo.png +0 -0
- package/src-tauri/icons/Square310x310Logo.png +0 -0
- package/src-tauri/icons/Square44x44Logo.png +0 -0
- package/src-tauri/icons/Square71x71Logo.png +0 -0
- package/src-tauri/icons/Square89x89Logo.png +0 -0
- package/src-tauri/icons/StoreLogo.png +0 -0
- package/src-tauri/icons/icon.icns +0 -0
- package/src-tauri/icons/icon.ico +0 -0
- package/src-tauri/icons/icon.png +0 -0
- package/src-tauri/src/lib.rs +0 -21
- package/src-tauri/src/main.rs +0 -10
- 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,
|
|
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
|
-
|
|
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
|
-
}, [
|
|
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
|
-
}, [
|
|
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,
|
|
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,
|
|
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 &&
|
|
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
|
|
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
|
-
{
|
|
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={
|
|
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={
|
|
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() ||
|
|
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
|
+
}
|