/* 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/. */ import { useCallback, useMemo, useState } from 'react'; import { X, Play, Loader2, Trash2, Crosshair, Copy, Info, Focus, ArrowUpDown, AlertTriangle, ChevronDown, ChevronRight, Layers, FilePlus, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; import { useClash, type ClashFocusMode } from '@/hooks/useClash'; import { useBCF } from '@/hooks/useBCF'; import { useViewerStore } from '@/store'; import { ClashBcfExportDialog } from '@/components/viewer/ClashBcfExportDialog'; import { ClashSettingsDialog } from '@/components/viewer/ClashSettingsDialog'; import { createBCFProject, createBCFTopic } from '@ifc-lite/bcf'; import { isTouching, penetrationDepth, sortClashes, DUPLICATES_RULE, type Clash, type ClashElementRef, type ClashSeverity, type ClashSortBy, } from '@ifc-lite/clash'; interface ClashPanelProps { onClose?: () => void; } const SEVERITY_ORDER: ClashSeverity[] = ['critical', 'major', 'minor', 'info']; const SEVERITY: Record = { critical: { label: 'Critical', color: '#f7768e' }, major: { label: 'Major', color: '#ff9e64' }, minor: { label: 'Minor', color: '#e0af68' }, info: { label: 'Info', color: '#7aa2f7' }, }; const SORT_LABEL: Record = { severity: 'Sort: severity', depth: 'Sort: overlap depth', distance: 'Sort: distance', }; /** Distinct colours for the two sides of a pair so each is identifiable when * stepping through (#1277). The 3D view highlights both via the selection * channel; these dots label which row is which side. */ const SIDE_COLOR = ['#7dcfff', '#bb9af7'] as const; function shortName(key: string): string { return key.length > 10 ? `${key.slice(0, 8)}…` : key; } function formatDistance(distance: number): string { return distance < 0 ? `−${Math.abs(distance).toFixed(3)}m` : `${distance.toFixed(3)}m`; } /** A plain-language description of what a clash is and why it was flagged (#1276). */ function describeClash(c: Clash): string { if (c.rule === DUPLICATES_RULE.id) { return c.severity === 'major' ? 'Exact duplicate — coincident geometry with the same shape' : 'Overlapping — near-coincident objects in the same place'; } if (c.status === 'clearance') { return `Clearance violation — ${c.distance.toFixed(3)} m gap, closer than required`; } if (isTouching(c)) { return 'Touching contact (≈0 m) — surfaces meet but barely overlap'; } return `Hard clash — ${penetrationDepth(c).toFixed(3)} m interpenetration`; } export function ClashPanel({ onClose }: ClashPanelProps) { const { result, running, error, progress, mode, tolerance, clearance, groupBy, selectedId, presets, modelCount, setMode, setTolerance, setClearance, setGroupBy, runAll, runMatrix, runPreset, runDuplicates, focusClash, selectElement, highlightAll, clearHighlight, clearAll, } = useClash(); // In-app BCF: create a topic from a clash without leaving the tool (#1279). const { createViewpointFromState } = useBCF(); const bcfProject = useViewerStore((s) => s.bcfProject); const bcfAuthor = useViewerStore((s) => s.bcfAuthor); const setBcfProject = useViewerStore((s) => s.setBcfProject); const addTopic = useViewerStore((s) => s.addTopic); const addViewpoint = useViewerStore((s) => s.addViewpoint); const setBcfPanelVisible = useViewerStore((s) => s.setBcfPanelVisible); const [collapsed, setCollapsed] = useState>(new Set()); const [expanded, setExpanded] = useState>(new Set()); const [sortBy, setSortBy] = useState('severity'); const [hideTouching, setHideTouching] = useState(false); /** How the rest of the model is shown when a clash is focused (#1275). */ const [focusMode, setFocusMode] = useState('highlight'); const [showHelp, setShowHelp] = useState(false); const [creatingTopic, setCreatingTopic] = useState(false); const toggleSection = (key: string) => setCollapsed((prev) => { const next = new Set(prev); if (next.has(key)) next.delete(key); else next.add(key); return next; }); const toggleExpand = (id: string) => setExpanded((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; }); /** Touching (≈0 m contact) count — surfaced so the user can choose to hide them. */ const touchingCount = useMemo( () => (result ? result.clashes.filter((c) => isTouching(c)).length : 0), [result], ); /** The clashes actually shown: touching filter applied, then ordered by `sortBy`. */ const visibleClashes = useMemo(() => { if (!result) return [] as Clash[]; const list = hideTouching ? result.clashes.filter((c) => !isTouching(c)) : result.clashes; return sortClashes(list, sortBy); }, [result, hideTouching, sortBy]); // Group the (filtered, sorted) clash list for display along the selected dimension. // Items keep their sorted order within each bucket. const sections = useMemo(() => { if (!result) return [] as Array<{ key: string; label: string; color?: string; items: Clash[] }>; const buckets = new Map(); for (const c of visibleClashes) { const key = groupBy === 'severity' ? c.severity : groupBy === 'rule' ? c.rule : [c.a.tag, c.b.tag].sort().join(' × '); const list = buckets.get(key); if (list) list.push(c); else buckets.set(key, [c]); } const entries = [...buckets.entries()]; if (groupBy === 'severity') { entries.sort((a, b) => SEVERITY_ORDER.indexOf(a[0] as ClashSeverity) - SEVERITY_ORDER.indexOf(b[0] as ClashSeverity)); } else { entries.sort((a, b) => b[1].length - a[1].length); } // Map rule id → human name for "By rule" labels. rulesRun covers every rule // that actually ran — discipline presets, custom presets, the synthetic // "all-clashes" and the duplicate scan — so no hardcoding is needed. const ruleNames = new Map(result.rulesRun.map((r) => [r.id, r.name])); return entries.map(([key, items]) => ({ key, label: groupBy === 'severity' ? SEVERITY[key as ClashSeverity].label : groupBy === 'rule' ? ruleNames.get(key) ?? key : key, color: groupBy === 'severity' ? SEVERITY[key as ClashSeverity].color : undefined, items, })); }, [result, visibleClashes, groupBy]); const total = result?.summary.total ?? 0; const shown = visibleClashes.length; const bySeverity = result?.summary.bySeverity; /** * Create a BCF topic from the selected clash (or the whole result) directly in * the in-app issue tracker — no download/re-import round-trip (#1279). The * clash is framed + selected first so the captured viewpoint shows it. */ const createBcfTopic = useCallback(async (): Promise => { if (!result || creatingTopic) return; const clash = selectedId ? result.clashes.find((c) => c.id === selectedId) ?? null : null; setCreatingTopic(true); try { if (clash) { focusClash(clash, focusMode); // Wait for the camera move + a render before grabbing the snapshot. await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); } if (!bcfProject) setBcfProject(createBCFProject({ name: 'Clash report' })); const title = clash ? `Clash: ${clash.a.tag} × ${clash.b.tag}` : `Clash report — ${total} ${total === 1 ? 'clash' : 'clashes'}`; const description = clash ? `${describeClash(clash)}\n${clash.a.name ?? clash.a.key} ↔ ${clash.b.name ?? clash.b.key}` : `${total} ${total === 1 ? 'clash' : 'clashes'} detected across the loaded model(s).`; const topic = createBCFTopic({ title, description, author: bcfAuthor, topicType: 'Clash', topicStatus: 'Open' }); addTopic(topic); const vp = await createViewpointFromState({ includeSnapshot: true, includeSelection: true, includeHidden: true }); if (vp) addViewpoint(topic.guid, vp); setBcfPanelVisible(true); } catch (err) { console.error('[clash] BCF topic creation failed', err); } finally { setCreatingTopic(false); } }, [result, creatingTopic, selectedId, focusClash, focusMode, bcfProject, setBcfProject, total, bcfAuthor, addTopic, createViewpointFromState, addViewpoint, setBcfPanelVisible]); /** Switch the focus mode and immediately re-apply it to the selected clash so * the change is visible without re-clicking the row (#1275). */ const changeFocusMode = useCallback( (mode: ClashFocusMode): void => { setFocusMode(mode); const current = selectedId ? result?.clashes.find((c) => c.id === selectedId) : undefined; if (current) focusClash(current, mode); }, [selectedId, result, focusClash], ); /** One side (A or B) of a clash inside the expanded row (#1276). */ const ElementRow = ({ el, side }: { el: ClashElementRef; side: 0 | 1 }) => ( ); return (
{/* Header */}
Clash detection
{result && ( )} {onClose && ( )}
{/* Help / explanation (#1272, #1274) */} {showHelp && (

Hard finds interpenetrations (overlap beyond tol).{' '} Clearance additionally flags elements closer than the required{' '} gap — so raising the gap adds more results, it does not filter existing ones.

tol is the touch band (m) — how much bare surface contact is ignored.{' '} gap (clearance mode) is the minimum required separation.

Severity comes from the element-type pair (e.g. pipe vs structure = critical), not from overlap depth. Sort by overlap depth to surface the worst interpenetrations first.

Touching results sit at ≈0 m — coincident faces such as a wall meeting a slab. Hide them to focus on genuine overlaps.

)} {/* Run controls */}
{(['hard', 'clearance'] as const).map((m) => ( ))}
{mode === 'clearance' && ( )}
{presets.map((p) => ( ))}
{/* Live progress — the engine yields between chunks so this paints even on large models that take a while (#1281). */} {running && progress && (() => { const determinate = progress.total > 0; const pct = determinate ? Math.min(100, Math.round((progress.done / progress.total) * 100)) : 0; const label = determinate ? `Checking ${progress.done.toLocaleString()} / ${progress.total.toLocaleString()} pairs` : 'Preparing geometry…'; return (
{label} {determinate && {pct}%}
); })()}
{/* Error */} {error && (
{error}
)} {/* Summary */} {result && (
{total} {total === 1 ? 'clash' : 'clashes'} {hideTouching && touchingCount > 0 && ` · ${shown} shown`}
{total > 0 && bySeverity && ( <>
{SEVERITY_ORDER.map((s) => bySeverity[s] > 0 ? (
) : null, )}
{SEVERITY_ORDER.filter((s) => bySeverity[s] > 0).map((s) => ( {SEVERITY[s].label} {bySeverity[s]} ))}
)}
)} {/* Toolbar: group-by + sort + actions */} {result && total > 0 && (
On select:
{([ ['highlight', 'Highlight', 'Keep the whole model visible'], ['isolate', 'Isolate', 'Hide everything except the clashing pair'], ['ghost', 'Ghost', 'Fade the rest to translucent context (X-Ray)'], ] as [ClashFocusMode, string, string][]).map(([m, label, tip]) => ( ))}
)} {/* Results */} {!result && !running && (

{modelCount <= 1 ? 'Check this model in seconds: “Detect all clashes” finds every overlap inside it, and “Find duplicates” catches coincident objects — no discipline setup needed.' : 'Detect all clashes, run the discipline matrix, or pick a preset to find conflicts across the loaded models.'}

Click any result to highlight both elements; expand a row to step through each object.

)} {result && total === 0 && (

No clashes found for this rule set. 🎉

)} {result && total > 0 && shown === 0 && (

All {total} results are ≈0 m touching contacts. Untick “Hide touching” to see them.

)} {sections.map((section) => { const isCollapsed = collapsed.has(section.key); return (
{!isCollapsed && section.items.map((clash) => { const isExpanded = expanded.has(clash.id); const touch = isTouching(clash); return (
{isExpanded && (
{describeClash(clash)}
)}
); })}
); })}
); }