@ifc-lite/viewer 1.25.2 → 1.26.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 (57) hide show
  1. package/.turbo/turbo-build.log +30 -27
  2. package/CHANGELOG.md +81 -0
  3. package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-ZpTYWE3K.js} +6 -6
  4. package/dist/assets/{bcf-7jQby1qi.js → bcf-Ctcu_Sc2.js} +5 -5
  5. package/dist/assets/{deflate-Cfp9t1Df.js → deflate-Cnx0il6E.js} +1 -1
  6. package/dist/assets/{exporters-DfSvJPi4.js → exporters-DSq76AVM.js} +272 -245
  7. package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
  8. package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-A5UjhI6L.js} +10 -10
  9. package/dist/assets/{ids-Cu73hD0Y.js → ids-DiLcGTer.js} +21 -21
  10. package/dist/assets/{ifc-lite_bg-ksLBP5cA.wasm → ifc-lite_bg-CEZnhM2e.wasm} +0 -0
  11. package/dist/assets/index-B9Ug2EqU.css +1 -0
  12. package/dist/assets/{index-WSbA5iy6.js → index-BAH8IJVR.js} +35946 -33456
  13. package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-BzSkwo5D.js} +1 -1
  14. package/dist/assets/{lerc-Dz6BXOVb.js → lerc-Cg2Rz-D5.js} +1 -1
  15. package/dist/assets/{lzw-C9z0fG2o.js → lzw-BBPPLW-0.js} +1 -1
  16. package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-CPojOeGE.js} +1 -1
  17. package/dist/assets/{packbits-jfwifz7C.js → packbits-yLSpjW-V.js} +1 -1
  18. package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-8md211IW.js} +2 -2
  19. package/dist/assets/raw-BQrAgxwT.js +1 -0
  20. package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-CsRXlgCO.js} +4102 -2658
  21. package/dist/assets/{server-client-Ctk8_Bof.js → server-client-Bk4c1CPO.js} +1 -1
  22. package/dist/assets/{webimage-XFHVyVtC.js → webimage-YafxjjGr.js} +1 -1
  23. package/dist/assets/{zstd-3q5qcl5V.js → zstd-CkSLOiuu.js} +1 -1
  24. package/dist/index.html +7 -7
  25. package/package.json +7 -6
  26. package/src/components/extensions/FlavorDialog.tsx +18 -2
  27. package/src/components/extensions/FlavorListView.tsx +12 -3
  28. package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
  29. package/src/components/viewer/ClashPanel.tsx +370 -0
  30. package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
  31. package/src/components/viewer/CommandPalette.tsx +14 -15
  32. package/src/components/viewer/MainToolbar.tsx +155 -175
  33. package/src/components/viewer/ViewerLayout.tsx +5 -0
  34. package/src/components/viewer/Viewport.tsx +49 -9
  35. package/src/components/viewer/ViewportContainer.tsx +45 -3
  36. package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
  37. package/src/components/viewer/useGeometryStreaming.ts +21 -1
  38. package/src/hooks/ingest/streamCleanup.test.ts +41 -0
  39. package/src/hooks/ingest/streamCleanup.ts +45 -0
  40. package/src/hooks/ingest/viewerModelIngest.ts +64 -42
  41. package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
  42. package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
  43. package/src/hooks/useAlignmentLines3D.ts +164 -0
  44. package/src/hooks/useClash.ts +420 -0
  45. package/src/hooks/useIfcFederation.ts +16 -2
  46. package/src/hooks/useIfcLoader.ts +5 -7
  47. package/src/lib/clash/persistence.ts +308 -0
  48. package/src/lib/geo/effective-georef.test.ts +66 -0
  49. package/src/services/extensions/host.ts +13 -0
  50. package/src/store/constants.ts +33 -25
  51. package/src/store/index.ts +29 -8
  52. package/src/store/slices/clashSlice.ts +251 -0
  53. package/src/store/slices/visibilitySlice.test.ts +23 -5
  54. package/src/store/slices/visibilitySlice.ts +18 -8
  55. package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
  56. package/dist/assets/index-Bws3UAkj.css +0 -1
  57. package/dist/assets/raw-R2QfzPAR.js +0 -1
@@ -0,0 +1,407 @@
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
+ * Clash settings dialog — opened from the gear in the clash panel header.
7
+ *
8
+ * Two tabs:
9
+ * - Detection: the global knobs (mode, tolerance, clearance, cluster radius,
10
+ * report-touch, default grouping), each persisted on change.
11
+ * - Rules: the discipline-matrix preset set. Toggle / edit / reset the built-ins
12
+ * and add your own custom rules (type-selector A × B + severity), with a live
13
+ * "matches N classes" preview against the loaded model. Persisted to
14
+ * localStorage; shareable via export / import.
15
+ */
16
+
17
+ import { useCallback, useMemo, useRef, useState } from 'react';
18
+ import {
19
+ Settings2, Plus, Pencil, Trash2, RotateCcw, Upload, Download, Check, X,
20
+ } from 'lucide-react';
21
+ import { Button } from '@/components/ui/button';
22
+ import { Label } from '@/components/ui/label';
23
+ import { Switch } from '@/components/ui/switch';
24
+ import {
25
+ Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
26
+ } from '@/components/ui/select';
27
+ import {
28
+ Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger,
29
+ } from '@/components/ui/dialog';
30
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
31
+ import { ScrollArea } from '@/components/ui/scroll-area';
32
+ import { cn } from '@/lib/utils';
33
+ import { toast } from '@/components/ui/toast';
34
+ import { useViewerStore } from '@/store';
35
+ import { matchesSelector, type ClashSeverity } from '@ifc-lite/clash';
36
+ import { exportPresets, importPresets, type ClashPreset } from '@/lib/clash/persistence';
37
+
38
+ const SEVERITY: Record<ClashSeverity, { label: string; color: string }> = {
39
+ critical: { label: 'Critical', color: '#f7768e' },
40
+ major: { label: 'Major', color: '#ff9e64' },
41
+ minor: { label: 'Minor', color: '#e0af68' },
42
+ info: { label: 'Info', color: '#7aa2f7' },
43
+ };
44
+ const SEVERITIES: ClashSeverity[] = ['critical', 'major', 'minor', 'info'];
45
+
46
+ interface Draft {
47
+ id: string | null; // null = new custom rule
48
+ name: string;
49
+ selectorA: string;
50
+ selectorB: string;
51
+ severity: ClashSeverity;
52
+ }
53
+
54
+ interface ClashSettingsDialogProps {
55
+ trigger?: React.ReactNode;
56
+ }
57
+
58
+ export function ClashSettingsDialog({ trigger }: ClashSettingsDialogProps) {
59
+ const mode = useViewerStore((s) => s.clashMode);
60
+ const tolerance = useViewerStore((s) => s.clashTolerance);
61
+ const clearance = useViewerStore((s) => s.clashClearance);
62
+ const clusterEpsilon = useViewerStore((s) => s.clashClusterEpsilon);
63
+ const reportTouch = useViewerStore((s) => s.clashReportTouch);
64
+ const groupBy = useViewerStore((s) => s.clashGroupBy);
65
+ const presets = useViewerStore((s) => s.clashPresets);
66
+ const classes = useViewerStore((s) => s.discoveredLensData?.classes ?? null);
67
+
68
+ const setMode = useViewerStore((s) => s.setClashMode);
69
+ const setTolerance = useViewerStore((s) => s.setClashTolerance);
70
+ const setClearance = useViewerStore((s) => s.setClashClearance);
71
+ const setClusterEpsilon = useViewerStore((s) => s.setClashClusterEpsilon);
72
+ const setReportTouch = useViewerStore((s) => s.setClashReportTouch);
73
+ const setGroupBy = useViewerStore((s) => s.setClashGroupBy);
74
+ const resetSettings = useViewerStore((s) => s.resetClashSettings);
75
+ const createPreset = useViewerStore((s) => s.createClashPreset);
76
+ const updatePreset = useViewerStore((s) => s.updateClashPreset);
77
+ const deletePreset = useViewerStore((s) => s.deleteClashPreset);
78
+ const setPresetEnabled = useViewerStore((s) => s.setClashPresetEnabled);
79
+ const resetPresets = useViewerStore((s) => s.resetClashPresets);
80
+ const importClashPresets = useViewerStore((s) => s.importClashPresets);
81
+
82
+ const [draft, setDraft] = useState<Draft | null>(null);
83
+ const fileRef = useRef<HTMLInputElement>(null);
84
+
85
+ const matchCount = useCallback(
86
+ (selector: string): number | null => {
87
+ if (!classes) return null;
88
+ const s = selector.trim();
89
+ if (!s) return null;
90
+ return classes.filter((c) => matchesSelector(c, s)).length;
91
+ },
92
+ [classes],
93
+ );
94
+
95
+ const startAdd = () =>
96
+ setDraft({ id: null, name: '', selectorA: '', selectorB: '', severity: 'major' });
97
+ const startEdit = (p: ClashPreset) =>
98
+ setDraft({ id: p.id, name: p.name, selectorA: p.selectorA, selectorB: p.selectorB, severity: p.severity });
99
+
100
+ const saveDraft = useCallback(() => {
101
+ if (!draft) return;
102
+ const result = draft.id
103
+ ? updatePreset(draft.id, {
104
+ name: draft.name,
105
+ selectorA: draft.selectorA,
106
+ selectorB: draft.selectorB,
107
+ severity: draft.severity,
108
+ })
109
+ : createPreset({
110
+ name: draft.name,
111
+ severity: draft.severity,
112
+ selectorA: draft.selectorA,
113
+ selectorB: draft.selectorB,
114
+ });
115
+ if (result.ok) {
116
+ setDraft(null);
117
+ } else {
118
+ toast.error(result.message);
119
+ }
120
+ }, [draft, createPreset, updatePreset]);
121
+
122
+ const draftValid =
123
+ !!draft && draft.name.trim().length > 0 && draft.selectorA.trim().length > 0 && draft.selectorB.trim().length > 0;
124
+
125
+ const onImport = useCallback(
126
+ async (file: File) => {
127
+ try {
128
+ const imported = await importPresets(file);
129
+ if (imported.length === 0) {
130
+ toast.error('No valid rules found in that file.');
131
+ return;
132
+ }
133
+ const result = importClashPresets(imported);
134
+ if (result.ok) toast.success(`Imported ${imported.length} rule${imported.length === 1 ? '' : 's'}`);
135
+ else toast.error(result.message);
136
+ } catch {
137
+ toast.error('Could not read that file as clash rules.');
138
+ }
139
+ },
140
+ [importClashPresets],
141
+ );
142
+
143
+ const enabledCount = useMemo(() => presets.filter((p) => p.enabled).length, [presets]);
144
+
145
+ return (
146
+ <Dialog>
147
+ <DialogTrigger asChild>
148
+ {trigger ?? (
149
+ <Button variant="ghost" size="icon" className="h-7 w-7" title="Clash settings">
150
+ <Settings2 className="h-4 w-4" />
151
+ </Button>
152
+ )}
153
+ </DialogTrigger>
154
+ <DialogContent className="sm:max-w-[540px] overflow-hidden">
155
+ <DialogHeader>
156
+ <DialogTitle className="flex items-center gap-2">
157
+ <Settings2 className="h-4 w-4 text-[#f7768e]" />
158
+ Clash settings
159
+ </DialogTitle>
160
+ <DialogDescription>
161
+ Tune detection and curate the rule set. {enabledCount} of {presets.length} rules enabled.
162
+ </DialogDescription>
163
+ </DialogHeader>
164
+
165
+ <Tabs defaultValue="detection" className="mt-1">
166
+ <TabsList className="grid w-full grid-cols-2">
167
+ {/* ui/tabs TabsTrigger ships no active styling — add it per-usage,
168
+ matching KeyboardShortcutsDialog / ByokKeyModal, so the active tab
169
+ reads clearly. */}
170
+ <TabsTrigger
171
+ value="detection"
172
+ className="data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=active]:font-semibold"
173
+ >
174
+ Detection
175
+ </TabsTrigger>
176
+ <TabsTrigger
177
+ value="rules"
178
+ className="data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=active]:font-semibold"
179
+ >
180
+ Rules
181
+ </TabsTrigger>
182
+ </TabsList>
183
+
184
+ {/* ---- Detection ---------------------------------------------------- */}
185
+ <TabsContent value="detection" className="space-y-3 max-h-[58vh] overflow-y-auto pr-1">
186
+ <SettingRow label="Default mode" hint="Hard finds interpenetrations; clearance finds gaps smaller than the required distance.">
187
+ <Select value={mode} onValueChange={(v) => setMode(v as 'hard' | 'clearance')}>
188
+ <SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
189
+ <SelectContent>
190
+ <SelectItem value="hard">Hard</SelectItem>
191
+ <SelectItem value="clearance">Clearance</SelectItem>
192
+ </SelectContent>
193
+ </Select>
194
+ </SettingRow>
195
+
196
+ <SettingRow label="Tolerance" hint="Touching band (m). Surfaces within this distance count as contact, not penetration.">
197
+ <NumberField value={tolerance} step={0.001} min={0} onCommit={setTolerance} suffix="m" />
198
+ </SettingRow>
199
+
200
+ <SettingRow label="Clearance gap" hint="Required gap (m) in clearance mode. Anything closer than this is a violation.">
201
+ <NumberField value={clearance} step={0.01} min={0} onCommit={setClearance} suffix="m" />
202
+ </SettingRow>
203
+
204
+ <SettingRow label="Cluster radius" hint="How far apart clashes can be and still merge into one BCF topic (m).">
205
+ <NumberField value={clusterEpsilon} step={0.1} min={0.01} onCommit={setClusterEpsilon} suffix="m" />
206
+ </SettingRow>
207
+
208
+ <SettingRow label="Report grazing contacts" hint="Include touch-classified results (surfaces that just graze) in detection.">
209
+ <Switch checked={reportTouch} onCheckedChange={setReportTouch} />
210
+ </SettingRow>
211
+
212
+ <SettingRow label="Default grouping" hint="How the results list is organized in the panel.">
213
+ <Select value={groupBy} onValueChange={(v) => setGroupBy(v as typeof groupBy)}>
214
+ <SelectTrigger className="h-8 w-36"><SelectValue /></SelectTrigger>
215
+ <SelectContent>
216
+ <SelectItem value="severity">By severity</SelectItem>
217
+ <SelectItem value="rule">By rule</SelectItem>
218
+ <SelectItem value="typePair">By type pair</SelectItem>
219
+ </SelectContent>
220
+ </Select>
221
+ </SettingRow>
222
+
223
+ <div className="pt-1">
224
+ <Button variant="ghost" size="sm" className="h-7 px-2 text-xs text-muted-foreground" onClick={resetSettings}>
225
+ <RotateCcw className="h-3.5 w-3.5 mr-1.5" /> Reset detection settings
226
+ </Button>
227
+ </div>
228
+ </TabsContent>
229
+
230
+ {/* ---- Rules -------------------------------------------------------- */}
231
+ <TabsContent value="rules" className="space-y-2">
232
+ <div className="flex items-center gap-1.5">
233
+ <Button size="sm" className="h-7 px-2 text-xs" onClick={startAdd}>
234
+ <Plus className="h-3.5 w-3.5 mr-1" /> Add rule
235
+ </Button>
236
+ <div className="ml-auto flex items-center gap-1">
237
+ <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" title="Reset to the built-in rules" onClick={resetPresets}>
238
+ <RotateCcw className="h-3.5 w-3.5" />
239
+ </Button>
240
+ <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" title="Export rules" onClick={() => exportPresets(presets)}>
241
+ <Download className="h-3.5 w-3.5" />
242
+ </Button>
243
+ <Button variant="ghost" size="sm" className="h-7 px-2 text-xs" title="Import rules" onClick={() => fileRef.current?.click()}>
244
+ <Upload className="h-3.5 w-3.5" />
245
+ </Button>
246
+ <input
247
+ ref={fileRef}
248
+ type="file"
249
+ accept=".json,.clash-presets.json,application/json"
250
+ className="hidden"
251
+ onChange={(e) => {
252
+ const f = e.target.files?.[0];
253
+ if (f) void onImport(f);
254
+ e.target.value = '';
255
+ }}
256
+ />
257
+ </div>
258
+ </div>
259
+
260
+ <ScrollArea className="max-h-[42vh] pr-1">
261
+ <div className="space-y-1">
262
+ {presets.map((p) => (
263
+ <div
264
+ key={p.id}
265
+ className={cn(
266
+ 'flex items-center gap-2 rounded-md border border-border px-2 py-1.5',
267
+ !p.enabled && 'opacity-55',
268
+ )}
269
+ >
270
+ <Switch checked={p.enabled} onCheckedChange={(v) => setPresetEnabled(p.id, v)} />
271
+ <span className="h-2 w-2 shrink-0 rounded-full" style={{ background: SEVERITY[p.severity].color }} />
272
+ <div className="min-w-0 flex-1">
273
+ <div className="truncate text-xs font-medium">
274
+ {p.name}
275
+ {!p.builtin && <span className="ml-1.5 text-[10px] text-muted-foreground">custom</span>}
276
+ </div>
277
+ <div className="truncate text-[10px] text-muted-foreground">
278
+ {p.selectorA} <span className="opacity-60">×</span> {p.selectorB}
279
+ </div>
280
+ </div>
281
+ <Button variant="ghost" size="icon" className="h-6 w-6" title="Edit" onClick={() => startEdit(p)}>
282
+ <Pencil className="h-3 w-3" />
283
+ </Button>
284
+ {p.builtin ? (
285
+ <span className="w-6" />
286
+ ) : (
287
+ <Button variant="ghost" size="icon" className="h-6 w-6" title="Delete" onClick={() => deletePreset(p.id)}>
288
+ <Trash2 className="h-3 w-3" />
289
+ </Button>
290
+ )}
291
+ </div>
292
+ ))}
293
+ </div>
294
+ </ScrollArea>
295
+
296
+ {draft && (
297
+ <div className="rounded-md border border-[#f7768e]/40 bg-muted/30 p-2.5 space-y-2">
298
+ <div className="flex items-center justify-between">
299
+ <span className="text-xs font-medium">{draft.id ? 'Edit rule' : 'New rule'}</span>
300
+ <button onClick={() => setDraft(null)} className="text-muted-foreground hover:text-foreground" title="Cancel">
301
+ <X className="h-3.5 w-3.5" />
302
+ </button>
303
+ </div>
304
+ <input
305
+ value={draft.name}
306
+ onChange={(e) => setDraft({ ...draft, name: e.target.value })}
307
+ placeholder="Rule name (e.g. Ducts vs Beams)"
308
+ className="h-8 w-full rounded-md border border-border bg-transparent px-2.5 text-sm"
309
+ />
310
+ <div className="grid grid-cols-[1fr_auto_1fr] items-center gap-2">
311
+ <SelectorField
312
+ value={draft.selectorA}
313
+ onChange={(v) => setDraft({ ...draft, selectorA: v })}
314
+ count={matchCount(draft.selectorA)}
315
+ hasModel={classes !== null}
316
+ placeholder="IfcDuct*|IfcPipe*"
317
+ />
318
+ <span className="text-xs text-muted-foreground">×</span>
319
+ <SelectorField
320
+ value={draft.selectorB}
321
+ onChange={(v) => setDraft({ ...draft, selectorB: v })}
322
+ count={matchCount(draft.selectorB)}
323
+ hasModel={classes !== null}
324
+ placeholder="IfcWall*|IfcSlab"
325
+ />
326
+ </div>
327
+ <div className="flex items-center gap-2">
328
+ <Select value={draft.severity} onValueChange={(v) => setDraft({ ...draft, severity: v as ClashSeverity })}>
329
+ <SelectTrigger className="h-8 w-32"><SelectValue /></SelectTrigger>
330
+ <SelectContent>
331
+ {SEVERITIES.map((s) => (
332
+ <SelectItem key={s} value={s}>{SEVERITY[s].label}</SelectItem>
333
+ ))}
334
+ </SelectContent>
335
+ </Select>
336
+ <Button size="sm" className="ml-auto h-8" disabled={!draftValid} onClick={saveDraft}>
337
+ <Check className="h-3.5 w-3.5 mr-1" /> {draft.id ? 'Save' : 'Add'}
338
+ </Button>
339
+ </div>
340
+ <p className="text-[10px] text-muted-foreground leading-snug">
341
+ Selectors: <code>IfcWall</code>, <code>IfcPipe*</code>, <code>IfcWall|IfcSlab</code>, <code>!IfcSpace</code>, <code>*</code>.
342
+ Leave B equal to A for a self-clash within one group.
343
+ </p>
344
+ </div>
345
+ )}
346
+ </TabsContent>
347
+ </Tabs>
348
+ </DialogContent>
349
+ </Dialog>
350
+ );
351
+ }
352
+
353
+ function SettingRow({ label, hint, children }: { label: string; hint: string; children: React.ReactNode }) {
354
+ return (
355
+ <div className="flex items-start justify-between gap-4 rounded-md border border-border px-3 py-2.5">
356
+ <div className="min-w-0">
357
+ <Label className="text-sm">{label}</Label>
358
+ <p className="mt-0.5 text-xs text-muted-foreground leading-snug">{hint}</p>
359
+ </div>
360
+ <div className="shrink-0">{children}</div>
361
+ </div>
362
+ );
363
+ }
364
+
365
+ /** Numeric input that commits on change, clamped by the store setter. */
366
+ function NumberField({
367
+ value, step, min, suffix, onCommit,
368
+ }: { value: number; step: number; min: number; suffix?: string; onCommit: (v: number) => void }) {
369
+ return (
370
+ <div className="inline-flex items-center gap-1">
371
+ <input
372
+ type="number"
373
+ step={step}
374
+ min={min}
375
+ value={value}
376
+ onChange={(e) => onCommit(Number(e.target.value))}
377
+ className="h-8 w-24 rounded-md border border-border bg-transparent px-2 text-sm tabular-nums text-right"
378
+ />
379
+ {suffix && <span className="text-xs text-muted-foreground">{suffix}</span>}
380
+ </div>
381
+ );
382
+ }
383
+
384
+ /** Type-selector input with a live "matches N classes" hint. */
385
+ function SelectorField({
386
+ value, onChange, count, hasModel, placeholder,
387
+ }: { value: string; onChange: (v: string) => void; count: number | null; hasModel: boolean; placeholder: string }) {
388
+ return (
389
+ <div className="min-w-0">
390
+ <input
391
+ value={value}
392
+ onChange={(e) => onChange(e.target.value)}
393
+ placeholder={placeholder}
394
+ className="h-8 w-full rounded-md border border-border bg-transparent px-2 text-xs font-mono"
395
+ />
396
+ <div className="mt-0.5 h-3 text-[10px] text-muted-foreground truncate">
397
+ {!hasModel
398
+ ? 'load a model to preview'
399
+ : count === null
400
+ ? ' '
401
+ : count > 0
402
+ ? `✓ matches ${count} class${count === 1 ? '' : 'es'}`
403
+ : 'matches no classes'}
404
+ </div>
405
+ </div>
406
+ );
407
+ }
@@ -203,31 +203,28 @@ function downloadBlob(data: BlobPart, name: string, mime: string) {
203
203
  * Closes all others first so the if-else chain in ViewerLayout renders it.
204
204
  * If the target is already active, closes it (back to Properties). */
205
205
 
206
- function activateRightPanel(panel: 'bcf' | 'ids' | 'lens' | 'extensions') {
206
+ function activateRightPanel(panel: 'bcf' | 'ids' | 'lens' | 'clash' | 'extensions') {
207
207
  const s = useViewerStore.getState();
208
208
  const isActive =
209
209
  panel === 'bcf' ? s.bcfPanelVisible :
210
210
  panel === 'ids' ? s.idsPanelVisible :
211
+ panel === 'clash' ? s.clashPanelVisible :
211
212
  panel === 'extensions' ? s.extensionsPanelVisible :
212
213
  s.lensPanelVisible;
213
214
 
214
215
  closeActiveAnalysisExtension();
215
216
 
216
- // Close all content panels
217
- s.setBcfPanelVisible(false);
218
- s.setIdsPanelVisible(false);
219
- s.setLensPanelVisible(false);
220
- s.setExtensionsPanelVisible(false);
221
-
222
- if (!isActive) {
223
- // Open the target, expand right panel
224
- s.setRightPanelCollapsed(false);
225
- if (panel === 'bcf') s.setBcfPanelVisible(true);
226
- else if (panel === 'ids') s.setIdsPanelVisible(true);
227
- else if (panel === 'extensions') s.setExtensionsPanelVisible(true);
228
- else s.setLensPanelVisible(true);
217
+ if (isActive) {
218
+ // Toggle off → close it (and the rest of the group) → falls back to Properties.
219
+ s.setBcfPanelVisible(false);
220
+ s.setIdsPanelVisible(false);
221
+ s.setLensPanelVisible(false);
222
+ s.setClashPanelVisible(false);
223
+ s.setExtensionsPanelVisible(false);
224
+ } else {
225
+ // Open exclusively (closes every sibling, including clash) and un-collapse.
226
+ s.openWorkspacePanel(panel);
229
227
  }
230
- // If was active → all closed → falls back to Properties
231
228
  }
232
229
 
233
230
  /** Exclusively activate a bottom panel (Script / List / Gantt).
@@ -432,6 +429,8 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
432
429
  action: () => { activateRightPanel('bcf'); } },
433
430
  { id: 'panel:ids', label: 'IDS Validation', keywords: 'information delivery specification check', category: 'Panels', icon: ClipboardCheck,
434
431
  action: () => { activateRightPanel('ids'); } },
432
+ { id: 'panel:clash', label: 'Clash Detection', keywords: 'collision interference clearance coordination clash matrix mep', category: 'Panels', icon: Crosshair,
433
+ action: () => { activateRightPanel('clash'); } },
435
434
  { id: 'panel:lists', label: 'Entity Lists', keywords: 'table spreadsheet', category: 'Panels', icon: FileSpreadsheet,
436
435
  action: () => { activateBottomPanel('list'); } },
437
436
  { id: 'panel:gantt', label: 'Construction Schedule (Gantt)', keywords: '4d timeline tasks ifctask sequence playback animation', category: 'Panels', icon: CalendarClock,