/* 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/. */ /** * "Export to BCF" dialog for clash results. * * The headline requirement (see docs/architecture/clash-detection-plan.md §6) is * a *manageable* BCF: 1,000 clashes must never become 1,000 topics. This dialog * puts that control in the user's hands — choose how clashes collapse into * topics, filter by severity, cap the count, pick the initial status, and * optionally embed a rendered snapshot per topic — with a live readout of * exactly how many topics the current settings will produce *before* exporting. */ import { useCallback, useMemo, useState } from 'react'; import { Download, Crosshair, Loader2, ArrowRight, Camera, Layers } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Switch } from '@/components/ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { cn } from '@/lib/utils'; import { toast } from '@/components/ui/toast'; import { useClash, type ClashBcfConfig, type ClashBcfGroupBy } from '@/hooks/useClash'; import type { ClashSeverity } from '@ifc-lite/clash'; interface ClashBcfExportDialogProps { trigger?: React.ReactNode; } const SEVERITIES: { key: ClashSeverity; label: string; color: string }[] = [ { key: 'critical', label: 'Critical', color: '#f7768e' }, { key: 'major', label: 'Major', color: '#ff9e64' }, { key: 'minor', label: 'Minor', color: '#e0af68' }, { key: 'info', label: 'Info', color: '#7aa2f7' }, ]; const GROUPINGS: { key: ClashBcfGroupBy; label: string; hint: string }[] = [ { key: 'cluster', label: 'Spatial cluster', hint: 'Nearby clashes of the same kind merge into one topic — the sensible default.' }, { key: 'rule', label: 'Discipline rule', hint: 'One topic per rule (MEP × Structure, HVAC × Architecture, …).' }, { key: 'typePair', label: 'Element-type pair', hint: 'One topic per type pair (IfcDuct × IfcWall, …).' }, { key: 'element', label: 'Affected element', hint: "One topic per element — all of an element's clashes in one place." }, ]; const STATUSES = ['Open', 'In Progress', 'Closed'] as const; const DEFAULT_CONFIG: ClashBcfConfig = { groupBy: 'cluster', severities: ['critical', 'major', 'minor', 'info'], includeSnapshots: false, status: 'Open', maxTopics: 500, }; export function ClashBcfExportDialog({ trigger }: ClashBcfExportDialogProps) { const { result, exportBcf, bcfPreview } = useClash(); const [open, setOpen] = useState(false); const [config, setConfig] = useState(DEFAULT_CONFIG); const [exporting, setExporting] = useState(false); const [progress, setProgress] = useState<{ done: number; total: number } | null>(null); const bySeverity = result?.summary.bySeverity; const preview = useMemo(() => bcfPreview(config), [bcfPreview, config, result]); const toggleSeverity = useCallback((sev: ClashSeverity) => { setConfig((prev) => { const has = prev.severities.includes(sev); const severities = has ? prev.severities.filter((s) => s !== sev) : [...prev.severities, sev]; return { ...prev, severities }; }); }, []); const grouping = GROUPINGS.find((g) => g.key === config.groupBy) ?? GROUPINGS[0]; const canExport = preview.topics > 0 && !exporting; const handleExport = useCallback(async () => { setExporting(true); setProgress(config.includeSnapshots ? { done: 0, total: preview.topics } : null); try { await exportBcf(config, (done, total) => setProgress({ done, total })); toast.success(`Exported ${preview.topics} BCF topic${preview.topics === 1 ? '' : 's'}`); setOpen(false); } catch (err) { console.error('[clash] BCF export failed', err); toast.error(`BCF export failed: ${err instanceof Error ? err.message : 'unknown error'}`); } finally { setExporting(false); setProgress(null); } }, [config, exportBcf, preview.topics]); return ( { // Don't let Esc / backdrop close the dialog mid-export: the snapshot loop // is driving the live renderer (camera + isolation), and there's no UI to // resume into if the dialog vanishes. Mirrors the IDS export dialog. if (exporting) return; setOpen(v); }} > {trigger ?? ( )} Export to BCF Turn clashes into a manageable set of BCF topics. Control how they group, which to include, and whether to embed snapshots.
{/* Grouping */}

{grouping.hint}

{/* Severity filter */}
{SEVERITIES.map((s) => { const on = config.severities.includes(s.key); const count = bySeverity?.[s.key] ?? 0; return ( ); })}
{/* Live preview — the hero readout */}
{preview.clashes}
clashes
{preview.topics}
topic{preview.topics === 1 ? '' : 's'}
{/* Status + cap, side by side */}
setConfig((p) => ({ ...p, maxTopics: Math.max(1, Number(e.target.value) || 1) }))} className="h-8 w-full rounded-md border border-border bg-transparent px-2.5 text-sm tabular-nums" />
{/* Snapshots */}

Render each topic's viewpoint and embed a PNG. Slower for many topics.

setConfig((p) => ({ ...p, includeSnapshots: v }))} />
{progress && ( Capturing snapshots {progress.done}/{progress.total}… )}
); }