@ifc-lite/viewer 1.25.2 → 1.27.0

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.
Files changed (116) hide show
  1. package/.turbo/turbo-build.log +40 -30
  2. package/CHANGELOG.md +110 -0
  3. package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-B3CdrLsb.js} +7 -7
  4. package/dist/assets/{bcf-7jQby1qi.js → bcf-QeHK_Aud.js} +5 -5
  5. package/dist/assets/{browser-DXS29_v9.js → browser-BIoDDfBW.js} +1 -1
  6. package/dist/assets/{cesium-BoVuJvTC.js → cesium-CzZn5yVA.js} +319 -319
  7. package/dist/assets/{deflate-Cfp9t1Df.js → deflate-B-d0SYQM.js} +1 -1
  8. package/dist/assets/exceljs.min-DsuzKYnj.js +29 -0
  9. package/dist/assets/{exporters-DfSvJPi4.js → exporters-B4LbZFeT.js} +1434 -1179
  10. package/dist/assets/geometry.worker-BdH-E6NB.js +1 -0
  11. package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-CrVtDRFq.js} +10 -10
  12. package/dist/assets/html2canvas.esm-Ge7aVWlp.js +5 -0
  13. package/dist/assets/{ids-Cu73hD0Y.js → ids-DjsGFN10.js} +21 -21
  14. package/dist/assets/ifc-lite_bg-DsYUIHm3.wasm +0 -0
  15. package/dist/assets/{index-WSbA5iy6.js → index-COYokSKc.js} +44122 -38782
  16. package/dist/assets/index-ajK6D32J.css +1 -0
  17. package/dist/assets/index.es-CY202jA3.js +6866 -0
  18. package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-D4wOkf5h.js} +1 -1
  19. package/dist/assets/jspdf.es.min-DIGb9BHN.js +19571 -0
  20. package/dist/assets/jspdf.plugin.autotable-BBLUVd7n.js +2 -0
  21. package/dist/assets/{lerc-Dz6BXOVb.js → lerc-DmW0_tgf.js} +1 -1
  22. package/dist/assets/{lzw-C9z0fG2o.js → lzw-oWetY-d6.js} +1 -1
  23. package/dist/assets/{maplibre-gl-Do6O5tDc.js → maplibre-gl-BF3Z0idw.js} +1 -1
  24. package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-BX8_tHXE.js} +1 -1
  25. package/dist/assets/{packbits-jfwifz7C.js → packbits-F8Nkp4NY.js} +1 -1
  26. package/dist/assets/{pako.esm-Cram60i4.js → pako.esm-n3Pgozwg.js} +1 -1
  27. package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-D591Zu_-.js} +3 -3
  28. package/dist/assets/pdf-Dsh3HPZB.js +135 -0
  29. package/dist/assets/raw-D9iw0tmc.js +1 -0
  30. package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-BAC3a-eN.js} +4235 -2716
  31. package/dist/assets/server-client-Cjwnm7il.js +706 -0
  32. package/dist/assets/{webimage-XFHVyVtC.js → webimage-BLV1dgmd.js} +1 -1
  33. package/dist/assets/xlsx-Bc2HTrjC.js +142 -0
  34. package/dist/assets/{zip-BJqVbRkU.js → zip-DFgP-l20.js} +1 -1
  35. package/dist/assets/{zstd-3q5qcl5V.js → zstd-C_1HxVrA.js} +1 -1
  36. package/dist/index.html +8 -8
  37. package/package.json +13 -9
  38. package/src/components/extensions/FlavorDialog.tsx +18 -2
  39. package/src/components/extensions/FlavorListView.tsx +12 -3
  40. package/src/components/mcp/PlaygroundChat.tsx +1 -0
  41. package/src/components/mcp/data.ts +6 -0
  42. package/src/components/mcp/playground-dispatcher.ts +277 -0
  43. package/src/components/mcp/types.ts +2 -1
  44. package/src/components/ui/combo-input.tsx +163 -0
  45. package/src/components/ui/tabs.tsx +1 -1
  46. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  47. package/src/components/viewer/ClashPanel.tsx +370 -0
  48. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  49. package/src/components/viewer/CommandPalette.tsx +14 -15
  50. package/src/components/viewer/MainToolbar.tsx +155 -175
  51. package/src/components/viewer/PropertiesPanel.tsx +13 -6
  52. package/src/components/viewer/SearchInline.tsx +62 -2
  53. package/src/components/viewer/SearchModal.filter.builder.tsx +24 -393
  54. package/src/components/viewer/SearchModal.filter.editors.tsx +503 -0
  55. package/src/components/viewer/SearchModal.filter.tsx +64 -1
  56. package/src/components/viewer/SearchModal.tsx +19 -6
  57. package/src/components/viewer/ViewerLayout.tsx +5 -0
  58. package/src/components/viewer/Viewport.tsx +64 -9
  59. package/src/components/viewer/ViewportContainer.tsx +45 -3
  60. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  61. package/src/components/viewer/lists/ColumnHeaderMenu.tsx +84 -0
  62. package/src/components/viewer/lists/ListBuilder.tsx +789 -280
  63. package/src/components/viewer/lists/ListGroupingBar.tsx +72 -0
  64. package/src/components/viewer/lists/ListPanel.tsx +49 -5
  65. package/src/components/viewer/lists/ListResultsTable.tsx +270 -176
  66. package/src/components/viewer/lists/list-table-utils.ts +123 -0
  67. package/src/components/viewer/useGeometryStreaming.ts +21 -1
  68. package/src/generated/mcp-catalog.json +4 -0
  69. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  70. package/src/hooks/ingest/streamCleanup.ts +45 -0
  71. package/src/hooks/ingest/viewerModelIngest.ts +64 -42
  72. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  73. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  74. package/src/hooks/source-key.ts +35 -0
  75. package/src/hooks/useAlignmentLines3D.ts +139 -0
  76. package/src/hooks/useClash.ts +420 -0
  77. package/src/hooks/useGridLines3D.ts +140 -0
  78. package/src/hooks/useIfcFederation.ts +16 -2
  79. package/src/hooks/useIfcLoader.ts +5 -7
  80. package/src/lib/clash/persistence.ts +308 -0
  81. package/src/lib/geo/effective-georef.test.ts +66 -0
  82. package/src/lib/length-unit-scale.ts +41 -0
  83. package/src/lib/lists/adapter.ts +136 -11
  84. package/src/lib/lists/export/csv.ts +47 -0
  85. package/src/lib/lists/export/index.ts +49 -0
  86. package/src/lib/lists/export/model.ts +111 -0
  87. package/src/lib/lists/export/pdf.ts +67 -0
  88. package/src/lib/lists/export/xlsx.ts +83 -0
  89. package/src/lib/lists/index.ts +2 -0
  90. package/src/lib/search/filter-evaluate.test.ts +81 -0
  91. package/src/lib/search/filter-evaluate.ts +59 -87
  92. package/src/lib/search/filter-match.ts +167 -0
  93. package/src/lib/search/filter-rules.test.ts +25 -0
  94. package/src/lib/search/filter-rules.ts +75 -2
  95. package/src/lib/search/filter-schema.ts +0 -0
  96. package/src/lib/slab-edit.test.ts +72 -0
  97. package/src/lib/slab-edit.ts +159 -19
  98. package/src/sdk/adapters/export-adapter.ts +3 -3
  99. package/src/sdk/adapters/query-adapter.ts +3 -3
  100. package/src/services/extensions/host.ts +13 -0
  101. package/src/store/constants.ts +33 -25
  102. package/src/store/index.ts +29 -8
  103. package/src/store/slices/clashSlice.ts +251 -0
  104. package/src/store/slices/listSlice.ts +6 -0
  105. package/src/store/slices/mutationSlice.ts +14 -6
  106. package/src/store/slices/searchSlice.ts +29 -3
  107. package/src/store/slices/visibilitySlice.test.ts +23 -5
  108. package/src/store/slices/visibilitySlice.ts +18 -8
  109. package/src/utils/nativeSpatialDataStore.ts +6 -0
  110. package/src/utils/serverDataModel.test.ts +6 -0
  111. package/src/utils/serverDataModel.ts +7 -0
  112. package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
  113. package/dist/assets/ifc-lite_bg-ksLBP5cA.wasm +0 -0
  114. package/dist/assets/index-Bws3UAkj.css +0 -1
  115. package/dist/assets/raw-R2QfzPAR.js +0 -1
  116. package/dist/assets/server-client-Ctk8_Bof.js +0 -626
@@ -0,0 +1,271 @@
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
+ * "Export to BCF" dialog for clash results.
7
+ *
8
+ * The headline requirement (see docs/architecture/clash-detection-plan.md §6) is
9
+ * a *manageable* BCF: 1,000 clashes must never become 1,000 topics. This dialog
10
+ * puts that control in the user's hands — choose how clashes collapse into
11
+ * topics, filter by severity, cap the count, pick the initial status, and
12
+ * optionally embed a rendered snapshot per topic — with a live readout of
13
+ * exactly how many topics the current settings will produce *before* exporting.
14
+ */
15
+
16
+ import { useCallback, useMemo, useState } from 'react';
17
+ import { Download, Crosshair, Loader2, ArrowRight, Camera, Layers } from 'lucide-react';
18
+ import { Button } from '@/components/ui/button';
19
+ import { Label } from '@/components/ui/label';
20
+ import { Switch } from '@/components/ui/switch';
21
+ import {
22
+ Select,
23
+ SelectContent,
24
+ SelectItem,
25
+ SelectTrigger,
26
+ SelectValue,
27
+ } from '@/components/ui/select';
28
+ import {
29
+ Dialog,
30
+ DialogContent,
31
+ DialogDescription,
32
+ DialogFooter,
33
+ DialogHeader,
34
+ DialogTitle,
35
+ DialogTrigger,
36
+ } from '@/components/ui/dialog';
37
+ import { cn } from '@/lib/utils';
38
+ import { toast } from '@/components/ui/toast';
39
+ import { useClash, type ClashBcfConfig, type ClashBcfGroupBy } from '@/hooks/useClash';
40
+ import type { ClashSeverity } from '@ifc-lite/clash';
41
+
42
+ interface ClashBcfExportDialogProps {
43
+ trigger?: React.ReactNode;
44
+ }
45
+
46
+ const SEVERITIES: { key: ClashSeverity; label: string; color: string }[] = [
47
+ { key: 'critical', label: 'Critical', color: '#f7768e' },
48
+ { key: 'major', label: 'Major', color: '#ff9e64' },
49
+ { key: 'minor', label: 'Minor', color: '#e0af68' },
50
+ { key: 'info', label: 'Info', color: '#7aa2f7' },
51
+ ];
52
+
53
+ const GROUPINGS: { key: ClashBcfGroupBy; label: string; hint: string }[] = [
54
+ { key: 'cluster', label: 'Spatial cluster', hint: 'Nearby clashes of the same kind merge into one topic — the sensible default.' },
55
+ { key: 'rule', label: 'Discipline rule', hint: 'One topic per rule (MEP × Structure, HVAC × Architecture, …).' },
56
+ { key: 'typePair', label: 'Element-type pair', hint: 'One topic per type pair (IfcDuct × IfcWall, …).' },
57
+ { key: 'element', label: 'Affected element', hint: "One topic per element — all of an element's clashes in one place." },
58
+ ];
59
+
60
+ const STATUSES = ['Open', 'In Progress', 'Closed'] as const;
61
+
62
+ const DEFAULT_CONFIG: ClashBcfConfig = {
63
+ groupBy: 'cluster',
64
+ severities: ['critical', 'major', 'minor', 'info'],
65
+ includeSnapshots: false,
66
+ status: 'Open',
67
+ maxTopics: 500,
68
+ };
69
+
70
+ export function ClashBcfExportDialog({ trigger }: ClashBcfExportDialogProps) {
71
+ const { result, exportBcf, bcfPreview } = useClash();
72
+
73
+ const [open, setOpen] = useState(false);
74
+ const [config, setConfig] = useState<ClashBcfConfig>(DEFAULT_CONFIG);
75
+ const [exporting, setExporting] = useState(false);
76
+ const [progress, setProgress] = useState<{ done: number; total: number } | null>(null);
77
+
78
+ const bySeverity = result?.summary.bySeverity;
79
+ const preview = useMemo(() => bcfPreview(config), [bcfPreview, config, result]);
80
+
81
+ const toggleSeverity = useCallback((sev: ClashSeverity) => {
82
+ setConfig((prev) => {
83
+ const has = prev.severities.includes(sev);
84
+ const severities = has ? prev.severities.filter((s) => s !== sev) : [...prev.severities, sev];
85
+ return { ...prev, severities };
86
+ });
87
+ }, []);
88
+
89
+ const grouping = GROUPINGS.find((g) => g.key === config.groupBy) ?? GROUPINGS[0];
90
+ const canExport = preview.topics > 0 && !exporting;
91
+
92
+ const handleExport = useCallback(async () => {
93
+ setExporting(true);
94
+ setProgress(config.includeSnapshots ? { done: 0, total: preview.topics } : null);
95
+ try {
96
+ await exportBcf(config, (done, total) => setProgress({ done, total }));
97
+ toast.success(`Exported ${preview.topics} BCF topic${preview.topics === 1 ? '' : 's'}`);
98
+ setOpen(false);
99
+ } catch (err) {
100
+ console.error('[clash] BCF export failed', err);
101
+ toast.error(`BCF export failed: ${err instanceof Error ? err.message : 'unknown error'}`);
102
+ } finally {
103
+ setExporting(false);
104
+ setProgress(null);
105
+ }
106
+ }, [config, exportBcf, preview.topics]);
107
+
108
+ return (
109
+ <Dialog
110
+ open={open}
111
+ onOpenChange={(v) => {
112
+ // Don't let Esc / backdrop close the dialog mid-export: the snapshot loop
113
+ // is driving the live renderer (camera + isolation), and there's no UI to
114
+ // resume into if the dialog vanishes. Mirrors the IDS export dialog.
115
+ if (exporting) return;
116
+ setOpen(v);
117
+ }}
118
+ >
119
+ <DialogTrigger asChild>
120
+ {trigger ?? (
121
+ <Button variant="ghost" size="sm" className="h-6 px-2 text-xs">
122
+ <Download className="h-3.5 w-3.5 mr-1" />
123
+ BCF
124
+ </Button>
125
+ )}
126
+ </DialogTrigger>
127
+ <DialogContent className="sm:max-w-[460px] overflow-hidden">
128
+ <DialogHeader>
129
+ <DialogTitle className="flex items-center gap-2">
130
+ <Crosshair className="h-4 w-4 text-[#f7768e]" />
131
+ Export to BCF
132
+ </DialogTitle>
133
+ <DialogDescription>
134
+ Turn clashes into a manageable set of BCF topics. Control how they group,
135
+ which to include, and whether to embed snapshots.
136
+ </DialogDescription>
137
+ </DialogHeader>
138
+
139
+ <div className="grid gap-4 py-1 max-h-[62vh] overflow-y-auto pr-1">
140
+ {/* Grouping */}
141
+ <div className="space-y-1.5">
142
+ <Label className="text-[11px] uppercase tracking-wide text-muted-foreground flex items-center gap-1.5">
143
+ <Layers className="h-3 w-3" /> Group into topics by
144
+ </Label>
145
+ <Select
146
+ value={config.groupBy}
147
+ onValueChange={(v) => setConfig((p) => ({ ...p, groupBy: v as ClashBcfGroupBy }))}
148
+ >
149
+ <SelectTrigger className="h-8">
150
+ <SelectValue />
151
+ </SelectTrigger>
152
+ <SelectContent>
153
+ {GROUPINGS.map((g) => (
154
+ <SelectItem key={g.key} value={g.key}>{g.label}</SelectItem>
155
+ ))}
156
+ </SelectContent>
157
+ </Select>
158
+ <p className="text-xs text-muted-foreground leading-snug">{grouping.hint}</p>
159
+ </div>
160
+
161
+ {/* Severity filter */}
162
+ <div className="space-y-1.5">
163
+ <Label className="text-[11px] uppercase tracking-wide text-muted-foreground">
164
+ Include severities
165
+ </Label>
166
+ <div className="flex flex-wrap gap-1.5">
167
+ {SEVERITIES.map((s) => {
168
+ const on = config.severities.includes(s.key);
169
+ const count = bySeverity?.[s.key] ?? 0;
170
+ return (
171
+ <button
172
+ key={s.key}
173
+ type="button"
174
+ onClick={() => toggleSeverity(s.key)}
175
+ className={cn(
176
+ 'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs transition-colors',
177
+ on ? 'border-transparent text-foreground' : 'border-border text-muted-foreground opacity-60 hover:opacity-100',
178
+ )}
179
+ style={on ? { background: `${s.color}1f`, borderColor: `${s.color}66` } : undefined}
180
+ >
181
+ <span className="h-2 w-2 rounded-full" style={{ background: s.color }} />
182
+ {s.label}
183
+ <span className="tabular-nums opacity-70">{count}</span>
184
+ </button>
185
+ );
186
+ })}
187
+ </div>
188
+ </div>
189
+
190
+ {/* Live preview — the hero readout */}
191
+ <div className="flex items-center justify-center gap-4 rounded-lg border border-border bg-muted/30 px-4 py-3">
192
+ <div className="text-center">
193
+ <div className="text-2xl font-semibold tabular-nums leading-none">{preview.clashes}</div>
194
+ <div className="mt-1 text-[10px] uppercase tracking-wide text-muted-foreground">clashes</div>
195
+ </div>
196
+ <ArrowRight className="h-4 w-4 text-muted-foreground shrink-0" />
197
+ <div className="text-center">
198
+ <div className="text-2xl font-semibold tabular-nums leading-none text-[#f7768e]">{preview.topics}</div>
199
+ <div className="mt-1 text-[10px] uppercase tracking-wide text-muted-foreground">
200
+ topic{preview.topics === 1 ? '' : 's'}
201
+ </div>
202
+ </div>
203
+ </div>
204
+
205
+ {/* Status + cap, side by side */}
206
+ <div className="grid grid-cols-2 gap-3">
207
+ <div className="space-y-1.5">
208
+ <Label className="text-[11px] uppercase tracking-wide text-muted-foreground">Initial status</Label>
209
+ <Select value={config.status} onValueChange={(v) => setConfig((p) => ({ ...p, status: v }))}>
210
+ <SelectTrigger className="h-8">
211
+ <SelectValue />
212
+ </SelectTrigger>
213
+ <SelectContent>
214
+ {STATUSES.map((s) => (
215
+ <SelectItem key={s} value={s}>{s}</SelectItem>
216
+ ))}
217
+ </SelectContent>
218
+ </Select>
219
+ </div>
220
+ <div className="space-y-1.5">
221
+ <Label className="text-[11px] uppercase tracking-wide text-muted-foreground">Max topics</Label>
222
+ <input
223
+ type="number"
224
+ min={1}
225
+ step={50}
226
+ value={config.maxTopics}
227
+ onChange={(e) => setConfig((p) => ({ ...p, maxTopics: Math.max(1, Number(e.target.value) || 1) }))}
228
+ className="h-8 w-full rounded-md border border-border bg-transparent px-2.5 text-sm tabular-nums"
229
+ />
230
+ </div>
231
+ </div>
232
+
233
+ {/* Snapshots */}
234
+ <div className="flex items-start justify-between gap-3 rounded-md border border-border px-3 py-2.5">
235
+ <div className="min-w-0">
236
+ <Label className="flex items-center gap-1.5 text-sm">
237
+ <Camera className="h-3.5 w-3.5" /> Include snapshots
238
+ </Label>
239
+ <p className="mt-0.5 text-xs text-muted-foreground leading-snug">
240
+ Render each topic's viewpoint and embed a PNG. Slower for many topics.
241
+ </p>
242
+ </div>
243
+ <Switch
244
+ checked={config.includeSnapshots}
245
+ onCheckedChange={(v) => setConfig((p) => ({ ...p, includeSnapshots: v }))}
246
+ />
247
+ </div>
248
+ </div>
249
+
250
+ <DialogFooter className="items-center">
251
+ {progress && (
252
+ <span className="mr-auto text-xs text-muted-foreground tabular-nums">
253
+ Capturing snapshots {progress.done}/{progress.total}…
254
+ </span>
255
+ )}
256
+ <Button variant="outline" onClick={() => setOpen(false)} disabled={exporting}>
257
+ Cancel
258
+ </Button>
259
+ <Button onClick={() => void handleExport()} disabled={!canExport}>
260
+ {exporting ? (
261
+ <Loader2 className="h-4 w-4 mr-1.5 animate-spin" />
262
+ ) : (
263
+ <Download className="h-4 w-4 mr-1.5" />
264
+ )}
265
+ {exporting ? 'Exporting…' : `Export ${preview.topics} topic${preview.topics === 1 ? '' : 's'}`}
266
+ </Button>
267
+ </DialogFooter>
268
+ </DialogContent>
269
+ </Dialog>
270
+ );
271
+ }
@@ -0,0 +1,370 @@
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
+ import { useMemo, useState } from 'react';
6
+ import {
7
+ X,
8
+ Play,
9
+ Loader2,
10
+ Trash2,
11
+ Crosshair,
12
+ AlertTriangle,
13
+ ChevronDown,
14
+ ChevronRight,
15
+ Layers,
16
+ } from 'lucide-react';
17
+ import { Button } from '@/components/ui/button';
18
+ import { ScrollArea } from '@/components/ui/scroll-area';
19
+ import { cn } from '@/lib/utils';
20
+ import { useClash } from '@/hooks/useClash';
21
+ import { ClashBcfExportDialog } from '@/components/viewer/ClashBcfExportDialog';
22
+ import { ClashSettingsDialog } from '@/components/viewer/ClashSettingsDialog';
23
+ import type { Clash, ClashSeverity } from '@ifc-lite/clash';
24
+
25
+ interface ClashPanelProps {
26
+ onClose?: () => void;
27
+ }
28
+
29
+ const SEVERITY_ORDER: ClashSeverity[] = ['critical', 'major', 'minor', 'info'];
30
+
31
+ const SEVERITY: Record<ClashSeverity, { label: string; color: string }> = {
32
+ critical: { label: 'Critical', color: '#f7768e' },
33
+ major: { label: 'Major', color: '#ff9e64' },
34
+ minor: { label: 'Minor', color: '#e0af68' },
35
+ info: { label: 'Info', color: '#7aa2f7' },
36
+ };
37
+
38
+ function shortName(key: string): string {
39
+ return key.length > 10 ? `${key.slice(0, 8)}…` : key;
40
+ }
41
+
42
+ export function ClashPanel({ onClose }: ClashPanelProps) {
43
+ const {
44
+ result,
45
+ running,
46
+ error,
47
+ progress,
48
+ mode,
49
+ tolerance,
50
+ clearance,
51
+ groupBy,
52
+ selectedId,
53
+ presets,
54
+ setMode,
55
+ setTolerance,
56
+ setClearance,
57
+ setGroupBy,
58
+ runAll,
59
+ runMatrix,
60
+ runPreset,
61
+ focusClash,
62
+ highlightAll,
63
+ clearHighlight,
64
+ clearAll,
65
+ } = useClash();
66
+
67
+ const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
68
+ const toggleSection = (key: string) =>
69
+ setCollapsed((prev) => {
70
+ const next = new Set(prev);
71
+ if (next.has(key)) next.delete(key);
72
+ else next.add(key);
73
+ return next;
74
+ });
75
+
76
+ // Group the flat clash list for display along the selected dimension.
77
+ const sections = useMemo(() => {
78
+ if (!result) return [] as Array<{ key: string; label: string; color?: string; items: Clash[] }>;
79
+ const buckets = new Map<string, Clash[]>();
80
+ for (const c of result.clashes) {
81
+ const key =
82
+ groupBy === 'severity'
83
+ ? c.severity
84
+ : groupBy === 'rule'
85
+ ? c.rule
86
+ : [c.a.tag, c.b.tag].sort().join(' × ');
87
+ const list = buckets.get(key);
88
+ if (list) list.push(c);
89
+ else buckets.set(key, [c]);
90
+ }
91
+ const entries = [...buckets.entries()];
92
+ if (groupBy === 'severity') {
93
+ entries.sort((a, b) => SEVERITY_ORDER.indexOf(a[0] as ClashSeverity) - SEVERITY_ORDER.indexOf(b[0] as ClashSeverity));
94
+ } else {
95
+ entries.sort((a, b) => b[1].length - a[1].length);
96
+ }
97
+ // Map rule id → human name for "By rule" labels. rulesRun covers every rule
98
+ // that actually ran — discipline presets, custom presets, and the synthetic
99
+ // "all-clashes" — so no hardcoding or preset lookup is needed.
100
+ const ruleNames = new Map(result.rulesRun.map((r) => [r.id, r.name]));
101
+ return entries.map(([key, items]) => ({
102
+ key,
103
+ label:
104
+ groupBy === 'severity'
105
+ ? SEVERITY[key as ClashSeverity].label
106
+ : groupBy === 'rule'
107
+ ? ruleNames.get(key) ?? key
108
+ : key,
109
+ color: groupBy === 'severity' ? SEVERITY[key as ClashSeverity].color : undefined,
110
+ items,
111
+ }));
112
+ }, [result, groupBy]);
113
+
114
+ const total = result?.summary.total ?? 0;
115
+ const bySeverity = result?.summary.bySeverity;
116
+
117
+ return (
118
+ <div className="h-full flex flex-col bg-background text-foreground overflow-hidden min-w-0">
119
+ {/* Header */}
120
+ <div className="flex items-center gap-2 p-3 border-b border-border">
121
+ <Crosshair className="h-4 w-4 text-[#f7768e] shrink-0" />
122
+ <span className="text-sm font-semibold tracking-tight min-w-0">Clash detection</span>
123
+ <div className="ml-auto flex items-center gap-1 shrink-0">
124
+ <ClashSettingsDialog />
125
+ {result && (
126
+ <Button variant="ghost" size="icon" className="h-7 w-7" title="Clear results" onClick={clearAll}>
127
+ <Trash2 className="h-4 w-4" />
128
+ </Button>
129
+ )}
130
+ {onClose && (
131
+ <Button variant="ghost" size="icon" className="h-7 w-7" title="Close" onClick={onClose}>
132
+ <X className="h-4 w-4" />
133
+ </Button>
134
+ )}
135
+ </div>
136
+ </div>
137
+
138
+ {/* Run controls */}
139
+ <div className="p-3 space-y-3 border-b border-border">
140
+ <div className="flex flex-wrap items-center gap-2">
141
+ <div className="inline-flex rounded-md border border-border overflow-hidden text-xs shrink-0">
142
+ {(['hard', 'clearance'] as const).map((m) => (
143
+ <button
144
+ key={m}
145
+ onClick={() => setMode(m)}
146
+ className={cn(
147
+ 'px-2.5 py-1 capitalize transition-colors',
148
+ mode === m ? 'bg-primary text-primary-foreground' : 'hover:bg-muted',
149
+ )}
150
+ >
151
+ {m}
152
+ </button>
153
+ ))}
154
+ </div>
155
+ <label className="flex items-center gap-1 text-xs text-muted-foreground">
156
+ tol
157
+ <input
158
+ type="number"
159
+ step={0.001}
160
+ min={0}
161
+ value={tolerance}
162
+ onChange={(e) => setTolerance(Number(e.target.value))}
163
+ className="w-16 rounded border border-border bg-transparent px-1.5 py-0.5 text-foreground"
164
+ />
165
+ </label>
166
+ {mode === 'clearance' && (
167
+ <label className="flex items-center gap-1 text-xs text-muted-foreground">
168
+ gap
169
+ <input
170
+ type="number"
171
+ step={0.01}
172
+ min={0}
173
+ value={clearance}
174
+ onChange={(e) => setClearance(Number(e.target.value))}
175
+ className="w-16 rounded border border-border bg-transparent px-1.5 py-0.5 text-foreground"
176
+ />
177
+ </label>
178
+ )}
179
+ </div>
180
+
181
+ <Button className="w-full h-8" disabled={running} onClick={() => void runAll()}>
182
+ {running ? <Loader2 className="h-4 w-4 mr-1.5 animate-spin" /> : <Crosshair className="h-4 w-4 mr-1.5" />}
183
+ {running ? 'Detecting…' : 'Detect all clashes'}
184
+ </Button>
185
+ <Button
186
+ variant="outline"
187
+ className="w-full h-7 text-xs"
188
+ disabled={running}
189
+ onClick={() => void runMatrix()}
190
+ >
191
+ <Play className="h-3.5 w-3.5 mr-1.5" />
192
+ Run discipline matrix
193
+ </Button>
194
+
195
+ <div className="flex flex-wrap gap-1.5">
196
+ {presets.map((p) => (
197
+ <button
198
+ key={p.id}
199
+ disabled={running}
200
+ onClick={() => void runPreset(p.id)}
201
+ title={p.description}
202
+ className={cn(
203
+ 'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] transition-colors',
204
+ 'border-border hover:bg-muted disabled:opacity-50',
205
+ )}
206
+ >
207
+ <span className="h-1.5 w-1.5 rounded-full" style={{ background: SEVERITY[p.severity].color }} />
208
+ {p.name}
209
+ </button>
210
+ ))}
211
+ </div>
212
+
213
+ {/* Live progress — the engine yields between chunks so this paints even
214
+ on large models that take a while. */}
215
+ {running && progress && (() => {
216
+ const determinate = progress.total > 0;
217
+ const pct = determinate ? Math.min(100, Math.round((progress.done / progress.total) * 100)) : 0;
218
+ const label = determinate
219
+ ? `Checking ${progress.done.toLocaleString()} / ${progress.total.toLocaleString()} pairs`
220
+ : 'Preparing geometry…';
221
+ return (
222
+ <div className="space-y-1">
223
+ <div className="flex items-center justify-between text-[11px] text-muted-foreground">
224
+ <span className="truncate">{label}</span>
225
+ {determinate && <span className="tabular-nums">{pct}%</span>}
226
+ </div>
227
+ <div className="h-1 w-full overflow-hidden rounded-full bg-muted">
228
+ <div
229
+ className={cn('h-full bg-[#f7768e]', determinate ? 'transition-[width] duration-150' : 'w-2/5 animate-pulse')}
230
+ style={determinate ? { width: `${pct}%` } : undefined}
231
+ />
232
+ </div>
233
+ </div>
234
+ );
235
+ })()}
236
+ </div>
237
+
238
+ {/* Error */}
239
+ {error && (
240
+ <div className="flex items-start gap-2 m-3 p-2 rounded-md bg-[#f7768e]/10 text-[#f7768e] text-xs">
241
+ <AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
242
+ <span>{error}</span>
243
+ </div>
244
+ )}
245
+
246
+ {/* Summary */}
247
+ {result && (
248
+ <div className="px-3 py-2.5 border-b border-border">
249
+ <div className="flex items-baseline justify-between mb-1.5">
250
+ <span className="text-2xl font-semibold tabular-nums">{total}</span>
251
+ <span className="text-xs text-muted-foreground">{total === 1 ? 'clash' : 'clashes'}</span>
252
+ </div>
253
+ {total > 0 && bySeverity && (
254
+ <>
255
+ <div className="flex h-1.5 w-full overflow-hidden rounded-full bg-muted">
256
+ {SEVERITY_ORDER.map((s) =>
257
+ bySeverity[s] > 0 ? (
258
+ <div
259
+ key={s}
260
+ style={{ width: `${(bySeverity[s] / total) * 100}%`, background: SEVERITY[s].color }}
261
+ />
262
+ ) : null,
263
+ )}
264
+ </div>
265
+ <div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px]">
266
+ {SEVERITY_ORDER.filter((s) => bySeverity[s] > 0).map((s) => (
267
+ <span key={s} className="inline-flex items-center gap-1 text-muted-foreground">
268
+ <span className="h-2 w-2 rounded-full" style={{ background: SEVERITY[s].color }} />
269
+ {SEVERITY[s].label} {bySeverity[s]}
270
+ </span>
271
+ ))}
272
+ </div>
273
+ </>
274
+ )}
275
+ </div>
276
+ )}
277
+
278
+ {/* Toolbar: group-by + actions */}
279
+ {result && total > 0 && (
280
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-3 py-2 border-b border-border text-xs">
281
+ <Layers className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
282
+ <select
283
+ value={groupBy}
284
+ onChange={(e) => setGroupBy(e.target.value as typeof groupBy)}
285
+ className="min-w-0 rounded border border-border bg-transparent px-1.5 py-0.5"
286
+ >
287
+ <option value="severity">By severity</option>
288
+ <option value="rule">By rule</option>
289
+ <option value="typePair">By type pair</option>
290
+ </select>
291
+ <div className="ml-auto flex items-center gap-1 shrink-0">
292
+ <Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={highlightAll}>
293
+ Highlight
294
+ </Button>
295
+ <Button variant="ghost" size="sm" className="h-6 px-2 text-xs" onClick={clearHighlight}>
296
+ Clear
297
+ </Button>
298
+ <ClashBcfExportDialog />
299
+ </div>
300
+ </div>
301
+ )}
302
+
303
+ {/* Results */}
304
+ <ScrollArea className="flex-1">
305
+ {!result && !running && (
306
+ <div className="flex flex-col items-center justify-center h-full p-8 text-center text-muted-foreground">
307
+ <Crosshair className="h-8 w-8 mb-3 opacity-40" />
308
+ <p className="text-sm">Detect all clashes, run the discipline matrix, or pick a preset to find conflicts in the loaded models. Click any result to highlight both elements and frame the camera on it.</p>
309
+ </div>
310
+ )}
311
+
312
+ {result && total === 0 && (
313
+ <div className="flex flex-col items-center justify-center p-8 text-center text-muted-foreground">
314
+ <p className="text-sm">No clashes found for this rule set. 🎉</p>
315
+ </div>
316
+ )}
317
+
318
+ {sections.map((section) => {
319
+ const isCollapsed = collapsed.has(section.key);
320
+ return (
321
+ <div key={section.key} className="border-b border-border/60">
322
+ <button
323
+ onClick={() => toggleSection(section.key)}
324
+ className="flex w-full items-center gap-1.5 px-3 py-1.5 text-xs font-medium hover:bg-muted/50"
325
+ >
326
+ {isCollapsed ? <ChevronRight className="h-3.5 w-3.5" /> : <ChevronDown className="h-3.5 w-3.5" />}
327
+ {section.color && (
328
+ <span className="h-2 w-2 rounded-full" style={{ background: section.color }} />
329
+ )}
330
+ <span className="truncate">{section.label}</span>
331
+ <span className="ml-auto tabular-nums text-muted-foreground">{section.items.length}</span>
332
+ </button>
333
+ {!isCollapsed &&
334
+ section.items.map((clash) => (
335
+ <button
336
+ key={clash.id}
337
+ onClick={() => focusClash(clash)}
338
+ className={cn(
339
+ 'flex w-full items-center gap-2 py-1.5 pr-3 pl-2 text-left text-xs hover:bg-muted/50',
340
+ selectedId === clash.id && 'bg-primary/10',
341
+ )}
342
+ >
343
+ <span
344
+ className="self-stretch w-0.5 rounded-full shrink-0"
345
+ style={{ background: SEVERITY[clash.severity].color }}
346
+ />
347
+ <div className="min-w-0 flex-1">
348
+ <div className="truncate">
349
+ <span className="text-foreground">{clash.a.tag}</span>
350
+ <span className="text-muted-foreground"> × </span>
351
+ <span className="text-foreground">{clash.b.tag}</span>
352
+ </div>
353
+ <div className="truncate text-[10px] text-muted-foreground">
354
+ {clash.a.name ?? shortName(clash.a.key)} ↔ {clash.b.name ?? shortName(clash.b.key)}
355
+ </div>
356
+ </div>
357
+ <span className="shrink-0 tabular-nums text-muted-foreground">
358
+ {clash.distance < 0
359
+ ? `−${Math.abs(clash.distance).toFixed(3)}m`
360
+ : `${clash.distance.toFixed(3)}m`}
361
+ </span>
362
+ </button>
363
+ ))}
364
+ </div>
365
+ );
366
+ })}
367
+ </ScrollArea>
368
+ </div>
369
+ );
370
+ }