/* 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/. */ /** * Clash settings dialog — opened from the gear in the clash panel header. * * Two tabs: * - Detection: the global knobs (mode, tolerance, clearance, cluster radius, * report-touch, default grouping), each persisted on change. * - Rules: the discipline-matrix preset set. Toggle / edit / reset the built-ins * and add your own custom rules (type-selector A × B + severity), with a live * "matches N classes" preview against the loaded model. Persisted to * localStorage; shareable via export / import. */ import { useCallback, useMemo, useRef, useState } from 'react'; import { Settings2, Plus, Pencil, Trash2, RotateCcw, Upload, Download, Check, X, } 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, DialogHeader, DialogTitle, DialogTrigger, } from '@/components/ui/dialog'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { ScrollArea } from '@/components/ui/scroll-area'; import { cn } from '@/lib/utils'; import { toast } from '@/components/ui/toast'; import { useViewerStore } from '@/store'; import { matchesSelector, type ClashSeverity } from '@ifc-lite/clash'; import { exportPresets, importPresets, type ClashPreset } from '@/lib/clash/persistence'; const SEVERITY: Record = { critical: { label: 'Critical', color: '#f7768e' }, major: { label: 'Major', color: '#ff9e64' }, minor: { label: 'Minor', color: '#e0af68' }, info: { label: 'Info', color: '#7aa2f7' }, }; const SEVERITIES: ClashSeverity[] = ['critical', 'major', 'minor', 'info']; interface Draft { id: string | null; // null = new custom rule name: string; selectorA: string; selectorB: string; severity: ClashSeverity; } interface ClashSettingsDialogProps { trigger?: React.ReactNode; } export function ClashSettingsDialog({ trigger }: ClashSettingsDialogProps) { const mode = useViewerStore((s) => s.clashMode); const tolerance = useViewerStore((s) => s.clashTolerance); const clearance = useViewerStore((s) => s.clashClearance); const clusterEpsilon = useViewerStore((s) => s.clashClusterEpsilon); const reportTouch = useViewerStore((s) => s.clashReportTouch); const groupBy = useViewerStore((s) => s.clashGroupBy); const presets = useViewerStore((s) => s.clashPresets); const classes = useViewerStore((s) => s.discoveredLensData?.classes ?? null); const setMode = useViewerStore((s) => s.setClashMode); const setTolerance = useViewerStore((s) => s.setClashTolerance); const setClearance = useViewerStore((s) => s.setClashClearance); const setClusterEpsilon = useViewerStore((s) => s.setClashClusterEpsilon); const setReportTouch = useViewerStore((s) => s.setClashReportTouch); const setGroupBy = useViewerStore((s) => s.setClashGroupBy); const resetSettings = useViewerStore((s) => s.resetClashSettings); const createPreset = useViewerStore((s) => s.createClashPreset); const updatePreset = useViewerStore((s) => s.updateClashPreset); const deletePreset = useViewerStore((s) => s.deleteClashPreset); const setPresetEnabled = useViewerStore((s) => s.setClashPresetEnabled); const resetPresets = useViewerStore((s) => s.resetClashPresets); const importClashPresets = useViewerStore((s) => s.importClashPresets); const [draft, setDraft] = useState(null); const fileRef = useRef(null); const matchCount = useCallback( (selector: string): number | null => { if (!classes) return null; const s = selector.trim(); if (!s) return null; return classes.filter((c) => matchesSelector(c, s)).length; }, [classes], ); const startAdd = () => setDraft({ id: null, name: '', selectorA: '', selectorB: '', severity: 'major' }); const startEdit = (p: ClashPreset) => setDraft({ id: p.id, name: p.name, selectorA: p.selectorA, selectorB: p.selectorB, severity: p.severity }); const saveDraft = useCallback(() => { if (!draft) return; const result = draft.id ? updatePreset(draft.id, { name: draft.name, selectorA: draft.selectorA, selectorB: draft.selectorB, severity: draft.severity, }) : createPreset({ name: draft.name, severity: draft.severity, selectorA: draft.selectorA, selectorB: draft.selectorB, }); if (result.ok) { setDraft(null); } else { toast.error(result.message); } }, [draft, createPreset, updatePreset]); const draftValid = !!draft && draft.name.trim().length > 0 && draft.selectorA.trim().length > 0 && draft.selectorB.trim().length > 0; const onImport = useCallback( async (file: File) => { try { const imported = await importPresets(file); if (imported.length === 0) { toast.error('No valid rules found in that file.'); return; } const result = importClashPresets(imported); if (result.ok) toast.success(`Imported ${imported.length} rule${imported.length === 1 ? '' : 's'}`); else toast.error(result.message); } catch { toast.error('Could not read that file as clash rules.'); } }, [importClashPresets], ); const enabledCount = useMemo(() => presets.filter((p) => p.enabled).length, [presets]); return ( {trigger ?? ( )} Clash settings Tune detection and curate the rule set. {enabledCount} of {presets.length} rules enabled. {/* ui/tabs TabsTrigger ships no active styling — add it per-usage, matching KeyboardShortcutsDialog / ByokKeyModal, so the active tab reads clearly. */} Detection Rules {/* ---- Detection ---------------------------------------------------- */}
{/* ---- Rules -------------------------------------------------------- */}
{ const f = e.target.files?.[0]; if (f) void onImport(f); e.target.value = ''; }} />
{presets.map((p) => (
setPresetEnabled(p.id, v)} />
{p.name} {!p.builtin && custom}
{p.selectorA} × {p.selectorB}
{p.builtin ? ( ) : ( )}
))}
{draft && (
{draft.id ? 'Edit rule' : 'New rule'}
setDraft({ ...draft, name: e.target.value })} placeholder="Rule name (e.g. Ducts vs Beams)" className="h-8 w-full rounded-md border border-border bg-transparent px-2.5 text-sm" />
setDraft({ ...draft, selectorA: v })} count={matchCount(draft.selectorA)} hasModel={classes !== null} placeholder="IfcDuct*|IfcPipe*" /> × setDraft({ ...draft, selectorB: v })} count={matchCount(draft.selectorB)} hasModel={classes !== null} placeholder="IfcWall*|IfcSlab" />

Selectors: IfcWall, IfcPipe*, IfcWall|IfcSlab, !IfcSpace, *. Leave B equal to A for a self-clash within one group.

)}
); } function SettingRow({ label, hint, children }: { label: string; hint: string; children: React.ReactNode }) { return (

{hint}

{children}
); } /** Numeric input that commits on change, clamped by the store setter. */ function NumberField({ value, step, min, suffix, onCommit, }: { value: number; step: number; min: number; suffix?: string; onCommit: (v: number) => void }) { return (
onCommit(Number(e.target.value))} className="h-8 w-24 rounded-md border border-border bg-transparent px-2 text-sm tabular-nums text-right" /> {suffix && {suffix}}
); } /** Type-selector input with a live "matches N classes" hint. */ function SelectorField({ value, onChange, count, hasModel, placeholder, }: { value: string; onChange: (v: string) => void; count: number | null; hasModel: boolean; placeholder: string }) { return (
onChange(e.target.value)} placeholder={placeholder} className="h-8 w-full rounded-md border border-border bg-transparent px-2 text-xs font-mono" />
{!hasModel ? 'load a model to preview' : count === null ? ' ' : count > 0 ? `✓ matches ${count} class${count === 1 ? '' : 'es'}` : 'matches no classes'}
); }