@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.
- package/.turbo/turbo-build.log +30 -27
- package/CHANGELOG.md +81 -0
- package/dist/assets/{basketViewActivator-CTgyKI3U.js → basketViewActivator-ZpTYWE3K.js} +6 -6
- package/dist/assets/{bcf-7jQby1qi.js → bcf-Ctcu_Sc2.js} +5 -5
- package/dist/assets/{deflate-Cfp9t1Df.js → deflate-Cnx0il6E.js} +1 -1
- package/dist/assets/{exporters-DfSvJPi4.js → exporters-DSq76AVM.js} +272 -245
- package/dist/assets/geometry.worker-0Q9qEa6p.js +1 -0
- package/dist/assets/{geotiff-xZoE8BkO.js → geotiff-A5UjhI6L.js} +10 -10
- package/dist/assets/{ids-Cu73hD0Y.js → ids-DiLcGTer.js} +21 -21
- package/dist/assets/{ifc-lite_bg-ksLBP5cA.wasm → ifc-lite_bg-CEZnhM2e.wasm} +0 -0
- package/dist/assets/index-B9Ug2EqU.css +1 -0
- package/dist/assets/{index-WSbA5iy6.js → index-BAH8IJVR.js} +35946 -33456
- package/dist/assets/{jpeg-DhwFEbqb.js → jpeg-BzSkwo5D.js} +1 -1
- package/dist/assets/{lerc-Dz6BXOVb.js → lerc-Cg2Rz-D5.js} +1 -1
- package/dist/assets/{lzw-C9z0fG2o.js → lzw-BBPPLW-0.js} +1 -1
- package/dist/assets/{native-bridge-RvDmzO-2.js → native-bridge-CPojOeGE.js} +1 -1
- package/dist/assets/{packbits-jfwifz7C.js → packbits-yLSpjW-V.js} +1 -1
- package/dist/assets/{parser.worker-C594dWxH.js → parser.worker-8md211IW.js} +2 -2
- package/dist/assets/raw-BQrAgxwT.js +1 -0
- package/dist/assets/{sandbox-DDSZ7rek.js → sandbox-CsRXlgCO.js} +4102 -2658
- package/dist/assets/{server-client-Ctk8_Bof.js → server-client-Bk4c1CPO.js} +1 -1
- package/dist/assets/{webimage-XFHVyVtC.js → webimage-YafxjjGr.js} +1 -1
- package/dist/assets/{zstd-3q5qcl5V.js → zstd-CkSLOiuu.js} +1 -1
- package/dist/index.html +7 -7
- package/package.json +7 -6
- package/src/components/extensions/FlavorDialog.tsx +18 -2
- package/src/components/extensions/FlavorListView.tsx +12 -3
- package/src/components/viewer/ClashBcfExportDialog.tsx +271 -0
- package/src/components/viewer/ClashPanel.tsx +370 -0
- package/src/components/viewer/ClashSettingsDialog.tsx +407 -0
- package/src/components/viewer/CommandPalette.tsx +14 -15
- package/src/components/viewer/MainToolbar.tsx +155 -175
- package/src/components/viewer/ViewerLayout.tsx +5 -0
- package/src/components/viewer/Viewport.tsx +49 -9
- package/src/components/viewer/ViewportContainer.tsx +45 -3
- package/src/components/viewer/bcf/BCFOverlay.tsx +5 -4
- package/src/components/viewer/useGeometryStreaming.ts +21 -1
- package/src/hooks/ingest/streamCleanup.test.ts +41 -0
- package/src/hooks/ingest/streamCleanup.ts +45 -0
- package/src/hooks/ingest/viewerModelIngest.ts +64 -42
- package/src/hooks/ingest/watchedGeometryStream.test.ts +78 -0
- package/src/hooks/ingest/watchedGeometryStream.ts +76 -0
- package/src/hooks/useAlignmentLines3D.ts +164 -0
- package/src/hooks/useClash.ts +420 -0
- package/src/hooks/useIfcFederation.ts +16 -2
- package/src/hooks/useIfcLoader.ts +5 -7
- package/src/lib/clash/persistence.ts +308 -0
- package/src/lib/geo/effective-georef.test.ts +66 -0
- package/src/services/extensions/host.ts +13 -0
- package/src/store/constants.ts +33 -25
- package/src/store/index.ts +29 -8
- package/src/store/slices/clashSlice.ts +251 -0
- package/src/store/slices/visibilitySlice.test.ts +23 -5
- package/src/store/slices/visibilitySlice.ts +18 -8
- package/dist/assets/geometry.worker-Cyn5BybV.js +0 -1
- package/dist/assets/index-Bws3UAkj.css +0 -1
- package/dist/assets/raw-R2QfzPAR.js +0 -1
|
@@ -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
|
+
}
|