@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,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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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,
|