/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ /** * Model comparison panel (issue #924). Pick two loaded models as A (base) and * B (head), choose a data/geometry/both scope, run the `@ifc-lite/diff` engine, * and review added / modified / deleted elements — colour-coded in 3D (via * `useCompareOverlay`) and listed here. Row click selects + frames the element. */ import { useEffect, useMemo } from 'react'; import { GitCompareArrows, Loader2, Play, X, Trash2, Download, ChevronLeft } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { useViewerStore } from '@/store'; import { useCompare } from '@/hooks/useCompare'; import { useCompareOverlay } from '@/hooks/useCompareOverlay'; import { posthog } from '@/lib/analytics'; import { COMPARE_COLORS } from '@/lib/compare/overlay'; import type { CompareRef } from '@/lib/compare/buildFingerprints'; import { describeChange, type ChangeDetail } from '@/lib/compare/describeChange'; import { downloadCompareReport } from '@/lib/compare/exportReport'; import { ChangeDetailView } from './compare/ChangeDetailView'; import { BcfFromChange } from './compare/BcfFromChange'; import { useBcfFromChange } from './compare/useBcfFromChange'; import { CompareResultsList, CountBadge, LISTED_STATES, type CompareBucket } from './compare/CompareResultsList'; import type { CompareRow } from './compare/changeRow'; import type { DiffScope, DiffState, DiffEntry } from '@ifc-lite/diff'; interface ComparePanelProps { onClose?: () => void; } const SCOPES: { id: DiffScope; label: string }[] = [ { id: 'both', label: 'Both' }, { id: 'data', label: 'Data' }, { id: 'geometry', label: 'Geometry' }, ]; /** Cap rows rendered per group so a huge diff can't stall the DOM. */ const MAX_ROWS_PER_GROUP = 1000; /** The side actually drawn for an entry: base for deletions, head otherwise. */ function renderRef(entry: DiffEntry): CompareRef | undefined { return (entry.state === 'deleted' ? entry.base?.ref : entry.head?.ref) ?? entry.base?.ref; } export function ComparePanel({ onClose }: ComparePanelProps) { useCompareOverlay(); const models = useViewerStore((s) => s.models); const baseModelId = useViewerStore((s) => s.compareBaseModelId); const headModelId = useViewerStore((s) => s.compareHeadModelId); const scope = useViewerStore((s) => s.compareScope); const showUnchanged = useViewerStore((s) => s.compareShowUnchanged); const selectedKey = useViewerStore((s) => s.compareSelectedKey); const setBaseModelId = useViewerStore((s) => s.setCompareBaseModelId); const setHeadModelId = useViewerStore((s) => s.setCompareHeadModelId); const setScope = useViewerStore((s) => s.setCompareScope); const setShowUnchanged = useViewerStore((s) => s.setCompareShowUnchanged); const clearCompare = useViewerStore((s) => s.clearCompare); const bcfAuthor = useViewerStore((s) => s.bcfAuthor); const { running, result, error, runComparison } = useCompare(); const modelList = useMemo(() => Array.from(models.values()), [models]); // BCF-from-change flow (form state, viewpoint capture, topic creation). const bcf = useBcfFromChange(modelList, selectedKey); // Default the A/B selection to the first two loaded models, and repair the // selection if a chosen model was removed. useEffect(() => { const ids = modelList.map((m) => m.id); // A comparison computed against a model that's since been removed leaves a // stale overlay on the survivor (the overlay hook keys off the result, not // the model list) — drop it so the scene is restored. const ran = useViewerStore.getState().compareResult; if (ran && (!ids.includes(ran.baseModelId) || !ids.includes(ran.headModelId))) { clearCompare(); } if (ids.length === 0) return; if (!baseModelId || !ids.includes(baseModelId)) { setBaseModelId(ids[0]); } if (ids.length > 1 && (!headModelId || !ids.includes(headModelId) || headModelId === ids[0])) { const other = ids.find((id) => id !== ids[0]); if (other) setHeadModelId(other); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [modelList]); // Resolve a display name + grouped rows from the diff result. Names live in // the per-model store (the engine result carries only type + key), so we // look them up here via each entry's ref. const groups = useMemo(() => { const empty = new Map(); if (!result) return empty; const out = new Map(); for (const { state } of LISTED_STATES) out.set(state, { rows: [], truncated: 0 }); for (const entry of result.diff.entries) { const bucket = out.get(entry.state); if (!bucket) continue; // skip unchanged const ref = renderRef(entry); if (!ref) continue; if (bucket.rows.length >= MAX_ROWS_PER_GROUP) { bucket.truncated++; continue; } const store = models.get(ref.modelId)?.ifcDataStore; const name = store?.entities.getName(ref.localId) || ''; const ifcType = (entry.head ?? entry.base)?.ifcType ?? 'IfcProduct'; bucket.rows.push({ key: entry.key, ifcType, name, state: entry.state, changeKinds: entry.changeKinds, ref }); } return out; }, [result, models]); const counts = result?.diff.counts; const canRun = !!baseModelId && !!headModelId && baseModelId !== headModelId && !running; // "What changed" detail for the selected entry — computed lazily from both // stores so a huge diff stays cheap (only the selection is described). const detail = useMemo(() => { if (!result || !selectedKey) return null; const entry = result.diff.byKey.get(selectedKey); return entry ? describeChange(entry, models) : null; }, [result, selectedKey, models]); const selectedRow = useMemo(() => { if (!selectedKey) return null; for (const bucket of groups.values()) { const row = bucket.rows.find((r) => r.key === selectedKey); if (row) return row; } return null; }, [groups, selectedKey]); const focusEntry = (row: CompareRow) => { const state = useViewerStore.getState(); state.clearEntitySelection(); state.setSelectedEntityIds([row.ref.globalId]); state.addEntitiesToSelection([{ modelId: row.ref.modelId, expressId: row.ref.localId }]); state.setCompareSelectedKey(row.key); requestAnimationFrame(() => state.cameraCallbacks.frameSelection?.()); }; // Select every element in a state bucket at once (section-header click). // Iterates the full diff entries — not the display-capped `groups` rows — so // the selection matches the header count, including "+N more not shown" rows. const focusGroup = (groupState: DiffState) => { if (!result) return; const refs = result.diff.entries .filter((e) => e.state === groupState) .map(renderRef) .filter((r): r is CompareRef => !!r); if (refs.length === 0) return; const state = useViewerStore.getState(); state.clearEntitySelection(); state.setSelectedEntityIds(refs.map((r) => r.globalId)); state.addEntitiesToSelection(refs.map((r) => ({ modelId: r.modelId, expressId: r.localId }))); state.setCompareSelectedKey(null); // bulk select → no single-row "what changed" detail requestAnimationFrame(() => state.cameraCallbacks.frameSelection?.()); }; const downloadReport = (format: 'csv' | 'json') => { if (!result) return; downloadCompareReport(format, result, models); const c = result.diff.counts; posthog.capture('model_compare_export', { format, scope: result.scope, row_count: c.added + c.modified + c.deleted, }); }; // Composing a BCF issue: collapse the diff chrome so the form owns the panel. // Gate on the selected row too, so a vanished selection can never leave the // panel empty (chrome hidden but no form to show). const bcfComposing = bcf.formOpen && !!selectedRow; return (
{/* Header */}
Compare models
{result && !bcfComposing && ( )} {onClose && ( )}
{modelList.length < 2 ? (
Load a second model to compare. Open two IFC files (federation), then pick version A and version B here.
) : ( <> {/* Diff chrome (run controls, counts, report, results, detail) — hidden while composing a BCF issue so the form owns the panel. The user has committed to raising an issue and the change context is already in the pre-filled form, so re-running / exports / browsing only get in the way. */} {!bcfComposing && ( <> {/* Run controls */}
A B
{baseModelId === headModelId && (

Pick two different models.

)}
{SCOPES.map((s) => ( ))}
{error &&

{error}

} {result?.geometryUnavailable && scope !== 'data' && (

One model has no geometry fingerprints (loaded outside the WASM mesh path), so geometry changes can’t be detected. Data changes are still accurate — switch to the Data scope for reliable results.

)}
{/* Counts */} {counts && (
)} {/* Export the full change report (#1202) */} {result && counts && counts.added + counts.modified + counts.deleted > 0 && (
Download report
)} {/* Results list */} {/* What-changed detail for the selected element */} {detail && selectedRow && } )} {/* Compose context — slim strip naming the target element while the BCF form is open, with a way back to the change list. */} {bcfComposing && (
Issue for {selectedRow.name || selectedRow.ifcType} {selectedRow.ifcType.replace(/^Ifc/, '')}
)} {/* Raise a BCF issue from the focused change (#1199) */} {selectedRow && ( { bcf.setCreatedTitle(null); bcf.setFormOpen(true); }} onCancel={() => bcf.setFormOpen(false)} onSubmit={bcf.submit} onOpenBcfPanel={() => useViewerStore.getState().openWorkspacePanel('bcf')} snapshot={bcf.viewpoint?.snapshot ?? null} onCaptureSnapshot={() => void bcf.captureViewpoint()} capturingSnapshot={bcf.capturingSnapshot} /> )} )}
); }