@ifc-lite/viewer 1.9.0 → 1.11.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/CHANGELOG.md +48 -0
- package/dist/assets/{Arrow.dom-CusgkT03.js → Arrow.dom-IIkrrCZ0.js} +1 -1
- package/dist/assets/{browser-BXNIkE8a.js → browser-BoonPy8d.js} +1 -1
- package/dist/assets/ifc-lite_bg-B6s-pcv0.wasm +0 -0
- package/dist/assets/{index-huvR-kGC.js → index-CQkEOlYf.js} +49090 -46453
- package/dist/assets/{index-6Mr3byM-.js → index-ClZCG7KA.js} +4 -4
- package/dist/assets/index-qxIHWl_B.css +1 -0
- package/dist/assets/{native-bridge-DsHOKdgD.js → native-bridge-Beg4Kf9O.js} +1 -1
- package/dist/assets/{wasm-bridge-Bd73HXn-.js → wasm-bridge-CY8jkr7u.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +19 -19
- package/src/components/viewer/BasketPresentationDock.tsx +422 -0
- package/src/components/viewer/CommandPalette.tsx +29 -32
- package/src/components/viewer/EntityContextMenu.tsx +37 -22
- package/src/components/viewer/HierarchyPanel.tsx +19 -1
- package/src/components/viewer/MainToolbar.tsx +32 -89
- package/src/components/viewer/Section2DPanel.tsx +8 -1
- package/src/components/viewer/Viewport.tsx +107 -98
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/ViewportOverlays.tsx +9 -3
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +3 -1
- package/src/components/viewer/useAnimationLoop.ts +4 -1
- package/src/components/viewer/useKeyboardControls.ts +2 -2
- package/src/components/viewer/useRenderUpdates.ts +16 -4
- package/src/hooks/useKeyboardShortcuts.ts +51 -84
- package/src/hooks/useViewerSelectors.ts +22 -0
- package/src/index.css +6 -0
- package/src/store/basket/basketCommands.ts +81 -0
- package/src/store/basket/basketViewActivator.ts +54 -0
- package/src/store/basketSave.ts +122 -0
- package/src/store/basketVisibleSet.test.ts +161 -0
- package/src/store/basketVisibleSet.ts +487 -0
- package/src/store/constants.ts +20 -0
- package/src/store/homeView.ts +21 -0
- package/src/store/index.ts +17 -0
- package/src/store/slices/drawing2DSlice.ts +5 -0
- package/src/store/slices/pinboardSlice.test.ts +160 -0
- package/src/store/slices/pinboardSlice.ts +248 -18
- package/src/store/slices/uiSlice.ts +41 -0
- package/src/store/types.ts +11 -0
- package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
- package/dist/assets/index-CGbokkQ9.css +0 -1
|
@@ -0,0 +1,422 @@
|
|
|
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 { useCallback, useMemo, useRef, useState } from 'react';
|
|
6
|
+
import {
|
|
7
|
+
ChevronLeft,
|
|
8
|
+
ChevronRight,
|
|
9
|
+
Equal,
|
|
10
|
+
Eye,
|
|
11
|
+
EyeOff,
|
|
12
|
+
Minus,
|
|
13
|
+
Pencil,
|
|
14
|
+
Play,
|
|
15
|
+
Plus,
|
|
16
|
+
RotateCcw,
|
|
17
|
+
Save,
|
|
18
|
+
Square,
|
|
19
|
+
Timer,
|
|
20
|
+
Trash2,
|
|
21
|
+
} from 'lucide-react';
|
|
22
|
+
import { Button } from '@/components/ui/button';
|
|
23
|
+
import { Input } from '@/components/ui/input';
|
|
24
|
+
import { cn } from '@/lib/utils';
|
|
25
|
+
import { useViewerStore } from '@/store';
|
|
26
|
+
import {
|
|
27
|
+
executeBasketSet,
|
|
28
|
+
executeBasketAdd,
|
|
29
|
+
executeBasketRemove,
|
|
30
|
+
executeBasketSaveView,
|
|
31
|
+
executeBasketClear,
|
|
32
|
+
} from '@/store/basket/basketCommands';
|
|
33
|
+
import { activateBasketViewFromStore } from '@/store/basket/basketViewActivator';
|
|
34
|
+
import { getSmartBasketInputFromStore, isBasketIsolationActiveFromStore } from '@/store/basketVisibleSet';
|
|
35
|
+
|
|
36
|
+
export function BasketPresentationDock() {
|
|
37
|
+
const [savingThumbnail, setSavingThumbnail] = useState(false);
|
|
38
|
+
const [editingViewId, setEditingViewId] = useState<string | null>(null);
|
|
39
|
+
const [editingName, setEditingName] = useState('');
|
|
40
|
+
const [playingAll, setPlayingAll] = useState(false);
|
|
41
|
+
const stripRef = useRef<HTMLDivElement>(null);
|
|
42
|
+
const stopPlayRef = useRef(false);
|
|
43
|
+
const loopPlayRef = useRef(false);
|
|
44
|
+
|
|
45
|
+
const pinboardEntities = useViewerStore((s) => s.pinboardEntities);
|
|
46
|
+
const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
|
|
47
|
+
const basketViews = useViewerStore((s) => s.basketViews);
|
|
48
|
+
const activeBasketViewId = useViewerStore((s) => s.activeBasketViewId);
|
|
49
|
+
const basketPresentationVisible = useViewerStore((s) => s.basketPresentationVisible);
|
|
50
|
+
|
|
51
|
+
const showPinboard = useViewerStore((s) => s.showPinboard);
|
|
52
|
+
const clearIsolation = useViewerStore((s) => s.clearIsolation);
|
|
53
|
+
const setBasketPresentationVisible = useViewerStore((s) => s.setBasketPresentationVisible);
|
|
54
|
+
|
|
55
|
+
const removeBasketView = useViewerStore((s) => s.removeBasketView);
|
|
56
|
+
const renameBasketView = useViewerStore((s) => s.renameBasketView);
|
|
57
|
+
const setBasketViewTransitionMs = useViewerStore((s) => s.setBasketViewTransitionMs);
|
|
58
|
+
|
|
59
|
+
const basketIsVisible = useMemo(
|
|
60
|
+
() => pinboardEntities.size > 0 && isolatedEntities !== null && isBasketIsolationActiveFromStore(),
|
|
61
|
+
[pinboardEntities, isolatedEntities],
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
const applySource = useCallback((mode: 'set' | 'add' | 'remove') => {
|
|
65
|
+
if (mode === 'set') executeBasketSet();
|
|
66
|
+
else if (mode === 'add') executeBasketAdd();
|
|
67
|
+
else executeBasketRemove();
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
const handleSaveCurrent = useCallback(async () => {
|
|
71
|
+
if (pinboardEntities.size === 0 || savingThumbnail) return;
|
|
72
|
+
|
|
73
|
+
setSavingThumbnail(true);
|
|
74
|
+
try {
|
|
75
|
+
const { source } = getSmartBasketInputFromStore();
|
|
76
|
+
await executeBasketSaveView(source === 'empty' ? 'manual' : source);
|
|
77
|
+
} finally {
|
|
78
|
+
setSavingThumbnail(false);
|
|
79
|
+
}
|
|
80
|
+
}, [pinboardEntities, savingThumbnail]);
|
|
81
|
+
|
|
82
|
+
const startRename = useCallback((viewId: string, name: string) => {
|
|
83
|
+
setEditingViewId(viewId);
|
|
84
|
+
setEditingName(name);
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const cancelRename = useCallback(() => {
|
|
88
|
+
setEditingViewId(null);
|
|
89
|
+
setEditingName('');
|
|
90
|
+
}, []);
|
|
91
|
+
|
|
92
|
+
const commitRename = useCallback(() => {
|
|
93
|
+
if (!editingViewId) return;
|
|
94
|
+
const nextName = editingName.trim();
|
|
95
|
+
if (nextName.length > 0) {
|
|
96
|
+
renameBasketView(editingViewId, nextName);
|
|
97
|
+
}
|
|
98
|
+
setEditingViewId(null);
|
|
99
|
+
setEditingName('');
|
|
100
|
+
}, [editingViewId, editingName, renameBasketView]);
|
|
101
|
+
|
|
102
|
+
const scrollStrip = useCallback((delta: number) => {
|
|
103
|
+
stripRef.current?.scrollBy({ left: delta, behavior: 'smooth' });
|
|
104
|
+
}, []);
|
|
105
|
+
|
|
106
|
+
const toTransitionMs = useCallback((value: number | null | undefined) => {
|
|
107
|
+
if (!value || !Number.isFinite(value) || value <= 0) return 700;
|
|
108
|
+
return Math.max(150, Math.min(15000, Math.round(value)));
|
|
109
|
+
}, []);
|
|
110
|
+
|
|
111
|
+
const wait = useCallback((ms: number) => new Promise<void>((resolve) => {
|
|
112
|
+
window.setTimeout(resolve, ms);
|
|
113
|
+
}), []);
|
|
114
|
+
|
|
115
|
+
const stopPlayAll = useCallback(() => {
|
|
116
|
+
stopPlayRef.current = true;
|
|
117
|
+
loopPlayRef.current = false;
|
|
118
|
+
setPlayingAll(false);
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
const startPlayAll = useCallback(async (loop = false) => {
|
|
122
|
+
if (playingAll || basketViews.length === 0) return;
|
|
123
|
+
stopPlayRef.current = false;
|
|
124
|
+
loopPlayRef.current = loop;
|
|
125
|
+
setPlayingAll(true);
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
const orderedViews = [...basketViews];
|
|
129
|
+
do {
|
|
130
|
+
for (const view of orderedViews) {
|
|
131
|
+
if (stopPlayRef.current) break;
|
|
132
|
+
activateBasketViewFromStore(view.id);
|
|
133
|
+
const transitionMs = toTransitionMs(view.transitionMs);
|
|
134
|
+
await wait(transitionMs + 180);
|
|
135
|
+
}
|
|
136
|
+
} while (loopPlayRef.current && !stopPlayRef.current && orderedViews.length > 0);
|
|
137
|
+
} finally {
|
|
138
|
+
loopPlayRef.current = false;
|
|
139
|
+
setPlayingAll(false);
|
|
140
|
+
}
|
|
141
|
+
}, [basketViews, playingAll, toTransitionMs, wait]);
|
|
142
|
+
|
|
143
|
+
const setViewTransitionDuration = useCallback((viewId: string, currentTransitionMs: number | null) => {
|
|
144
|
+
const defaultSeconds = currentTransitionMs && currentTransitionMs > 0
|
|
145
|
+
? (currentTransitionMs / 1000).toFixed(1)
|
|
146
|
+
: '';
|
|
147
|
+
const input = window.prompt(
|
|
148
|
+
'Transition duration in seconds (optional). Leave empty for default smooth transition.',
|
|
149
|
+
defaultSeconds,
|
|
150
|
+
);
|
|
151
|
+
if (input === null) return;
|
|
152
|
+
|
|
153
|
+
const trimmed = input.trim();
|
|
154
|
+
if (!trimmed) {
|
|
155
|
+
setBasketViewTransitionMs(viewId, null);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const seconds = Number(trimmed);
|
|
160
|
+
if (!Number.isFinite(seconds) || seconds <= 0) return;
|
|
161
|
+
setBasketViewTransitionMs(viewId, Math.round(seconds * 1000));
|
|
162
|
+
}, [setBasketViewTransitionMs]);
|
|
163
|
+
|
|
164
|
+
if (!basketPresentationVisible) {
|
|
165
|
+
return (
|
|
166
|
+
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-30 pointer-events-none">
|
|
167
|
+
<Button
|
|
168
|
+
type="button"
|
|
169
|
+
variant="secondary"
|
|
170
|
+
size="sm"
|
|
171
|
+
className="pointer-events-auto shadow-lg gap-2"
|
|
172
|
+
onClick={() => setBasketPresentationVisible(true)}
|
|
173
|
+
>
|
|
174
|
+
Presentation
|
|
175
|
+
<span className="rounded-full bg-primary/15 px-1.5 py-0.5 text-[10px] font-semibold text-primary">
|
|
176
|
+
{basketViews.length}
|
|
177
|
+
</span>
|
|
178
|
+
</Button>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return (
|
|
184
|
+
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-30 w-[min(980px,calc(100%-2rem))] pointer-events-none">
|
|
185
|
+
<div className="pointer-events-auto rounded-xl border bg-background/90 backdrop-blur-sm shadow-lg p-3 space-y-3">
|
|
186
|
+
<div className="flex items-center justify-between gap-3">
|
|
187
|
+
<div className="flex items-center gap-2 min-w-0">
|
|
188
|
+
<div className="text-sm font-semibold">Presentation</div>
|
|
189
|
+
<span className="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground">
|
|
190
|
+
{pinboardEntities.size} in basket
|
|
191
|
+
</span>
|
|
192
|
+
<span className="rounded-full border px-2 py-0.5 text-[11px] text-muted-foreground">
|
|
193
|
+
{basketViews.length} views
|
|
194
|
+
</span>
|
|
195
|
+
</div>
|
|
196
|
+
|
|
197
|
+
<div className="flex items-center gap-1.5">
|
|
198
|
+
<div className="flex items-center gap-1 rounded-md border bg-background/70 p-1">
|
|
199
|
+
<Button type="button" variant="outline" size="icon-sm" onClick={() => applySource('set')} title="Set basket from current context">
|
|
200
|
+
<Equal className="h-4 w-4" />
|
|
201
|
+
</Button>
|
|
202
|
+
<Button type="button" variant="outline" size="icon-sm" onClick={() => applySource('add')} title="Add current context to basket">
|
|
203
|
+
<Plus className="h-4 w-4" />
|
|
204
|
+
</Button>
|
|
205
|
+
<Button
|
|
206
|
+
type="button"
|
|
207
|
+
variant="outline"
|
|
208
|
+
size="icon-sm"
|
|
209
|
+
onClick={() => applySource('remove')}
|
|
210
|
+
disabled={pinboardEntities.size === 0}
|
|
211
|
+
title="Remove current context from basket"
|
|
212
|
+
>
|
|
213
|
+
<Minus className="h-4 w-4" />
|
|
214
|
+
</Button>
|
|
215
|
+
</div>
|
|
216
|
+
<div className="flex items-center gap-1 rounded-md border bg-background/70 p-1">
|
|
217
|
+
<Button
|
|
218
|
+
type="button"
|
|
219
|
+
variant="outline"
|
|
220
|
+
size="icon-sm"
|
|
221
|
+
onClick={() => {
|
|
222
|
+
if (basketIsVisible) clearIsolation();
|
|
223
|
+
else showPinboard();
|
|
224
|
+
}}
|
|
225
|
+
disabled={pinboardEntities.size === 0}
|
|
226
|
+
title={basketIsVisible ? 'Hide active basket' : 'Show active basket'}
|
|
227
|
+
>
|
|
228
|
+
{basketIsVisible ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
|
229
|
+
</Button>
|
|
230
|
+
<Button
|
|
231
|
+
type="button"
|
|
232
|
+
variant="outline"
|
|
233
|
+
size="icon-sm"
|
|
234
|
+
onClick={executeBasketClear}
|
|
235
|
+
disabled={pinboardEntities.size === 0}
|
|
236
|
+
title="Clear active basket"
|
|
237
|
+
>
|
|
238
|
+
<RotateCcw className="h-4 w-4" />
|
|
239
|
+
</Button>
|
|
240
|
+
</div>
|
|
241
|
+
<Button
|
|
242
|
+
type="button"
|
|
243
|
+
variant="default"
|
|
244
|
+
size="icon-sm"
|
|
245
|
+
onClick={handleSaveCurrent}
|
|
246
|
+
disabled={pinboardEntities.size === 0 || savingThumbnail}
|
|
247
|
+
title="Save current basket as presentation view"
|
|
248
|
+
>
|
|
249
|
+
<Save className="h-4 w-4" />
|
|
250
|
+
</Button>
|
|
251
|
+
<Button
|
|
252
|
+
type="button"
|
|
253
|
+
variant={playingAll ? 'secondary' : 'outline'}
|
|
254
|
+
size="icon-sm"
|
|
255
|
+
onClick={playingAll ? stopPlayAll : (e) => { void startPlayAll(e.shiftKey); }}
|
|
256
|
+
disabled={basketViews.length === 0}
|
|
257
|
+
title={playingAll ? 'Stop playback' : 'Play all saved views (Shift+Click to loop)'}
|
|
258
|
+
>
|
|
259
|
+
{playingAll ? <Square className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
|
260
|
+
</Button>
|
|
261
|
+
<Button
|
|
262
|
+
type="button"
|
|
263
|
+
variant="ghost"
|
|
264
|
+
size="sm"
|
|
265
|
+
className="ml-1 text-xs"
|
|
266
|
+
onClick={() => setBasketPresentationVisible(false)}
|
|
267
|
+
>
|
|
268
|
+
Hide
|
|
269
|
+
</Button>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
|
|
273
|
+
<div className="flex items-center gap-2">
|
|
274
|
+
<Button
|
|
275
|
+
type="button"
|
|
276
|
+
variant="outline"
|
|
277
|
+
size="icon-sm"
|
|
278
|
+
onClick={() => scrollStrip(-280)}
|
|
279
|
+
disabled={basketViews.length <= 1}
|
|
280
|
+
title="Scroll left"
|
|
281
|
+
>
|
|
282
|
+
<ChevronLeft className="h-4 w-4" />
|
|
283
|
+
</Button>
|
|
284
|
+
|
|
285
|
+
<div
|
|
286
|
+
ref={stripRef}
|
|
287
|
+
className="flex-1 min-w-0 overflow-x-auto scrollbar-thin scrollbar-thumb-border scrollbar-track-transparent snap-x snap-mandatory"
|
|
288
|
+
>
|
|
289
|
+
<div className="flex items-stretch gap-2 pr-1">
|
|
290
|
+
{basketViews.length === 0 && (
|
|
291
|
+
<div className="h-[102px] min-w-[340px] rounded-md border border-dashed text-xs text-muted-foreground px-3 py-2 flex items-center">
|
|
292
|
+
Save basket views here. Click any card to restore both visibility and viewpoint.
|
|
293
|
+
</div>
|
|
294
|
+
)}
|
|
295
|
+
|
|
296
|
+
{basketViews.map((view) => (
|
|
297
|
+
<div key={view.id} className="relative w-[186px] h-[102px] shrink-0 snap-start">
|
|
298
|
+
<button
|
|
299
|
+
type="button"
|
|
300
|
+
onClick={() => {
|
|
301
|
+
if (editingViewId) return;
|
|
302
|
+
activateBasketViewFromStore(view.id);
|
|
303
|
+
}}
|
|
304
|
+
className={cn(
|
|
305
|
+
'h-full w-full rounded-md border bg-card text-left overflow-hidden transition-colors',
|
|
306
|
+
activeBasketViewId === view.id && 'ring-2 ring-primary border-primary',
|
|
307
|
+
)}
|
|
308
|
+
>
|
|
309
|
+
{view.thumbnailDataUrl ? (
|
|
310
|
+
<img
|
|
311
|
+
src={view.thumbnailDataUrl}
|
|
312
|
+
alt={view.name}
|
|
313
|
+
className="absolute inset-0 h-full w-full object-cover"
|
|
314
|
+
/>
|
|
315
|
+
) : (
|
|
316
|
+
<div className="absolute inset-0 bg-muted" />
|
|
317
|
+
)}
|
|
318
|
+
|
|
319
|
+
{activeBasketViewId === view.id && (
|
|
320
|
+
<div className="absolute left-1 top-1 rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
|
|
321
|
+
Active
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
|
|
325
|
+
</button>
|
|
326
|
+
|
|
327
|
+
<div
|
|
328
|
+
className={cn(
|
|
329
|
+
'absolute inset-x-0 bottom-0 bg-black/60 text-white px-2 py-1',
|
|
330
|
+
editingViewId !== view.id && 'pointer-events-none',
|
|
331
|
+
)}
|
|
332
|
+
onClick={(e) => e.stopPropagation()}
|
|
333
|
+
>
|
|
334
|
+
{editingViewId === view.id ? (
|
|
335
|
+
<Input
|
|
336
|
+
autoFocus
|
|
337
|
+
value={editingName}
|
|
338
|
+
onChange={(e) => setEditingName(e.target.value)}
|
|
339
|
+
onBlur={commitRename}
|
|
340
|
+
onKeyDown={(e) => {
|
|
341
|
+
if (e.key === 'Enter') {
|
|
342
|
+
e.preventDefault();
|
|
343
|
+
commitRename();
|
|
344
|
+
} else if (e.key === 'Escape') {
|
|
345
|
+
e.preventDefault();
|
|
346
|
+
cancelRename();
|
|
347
|
+
}
|
|
348
|
+
}}
|
|
349
|
+
className="h-6 bg-black/40 text-xs border-white/30 text-white placeholder:text-white/60"
|
|
350
|
+
/>
|
|
351
|
+
) : (
|
|
352
|
+
<>
|
|
353
|
+
<div className="text-[12px] font-medium truncate">{view.name}</div>
|
|
354
|
+
<div className="text-[10px] opacity-80">
|
|
355
|
+
{view.entityRefs.length} objects
|
|
356
|
+
{view.transitionMs ? ` · ${(view.transitionMs / 1000).toFixed(1)}s` : ''}
|
|
357
|
+
</div>
|
|
358
|
+
</>
|
|
359
|
+
)}
|
|
360
|
+
</div>
|
|
361
|
+
|
|
362
|
+
<Button
|
|
363
|
+
type="button"
|
|
364
|
+
variant="secondary"
|
|
365
|
+
size="icon-xs"
|
|
366
|
+
className="absolute top-1 right-7"
|
|
367
|
+
title="Rename view"
|
|
368
|
+
onClick={(e) => {
|
|
369
|
+
e.stopPropagation();
|
|
370
|
+
startRename(view.id, view.name);
|
|
371
|
+
}}
|
|
372
|
+
>
|
|
373
|
+
<Pencil className="h-3 w-3" />
|
|
374
|
+
</Button>
|
|
375
|
+
<Button
|
|
376
|
+
type="button"
|
|
377
|
+
variant="secondary"
|
|
378
|
+
size="icon-xs"
|
|
379
|
+
className="absolute top-1 right-[3.25rem]"
|
|
380
|
+
title="Set transition duration"
|
|
381
|
+
onClick={(e) => {
|
|
382
|
+
e.stopPropagation();
|
|
383
|
+
setViewTransitionDuration(view.id, view.transitionMs);
|
|
384
|
+
}}
|
|
385
|
+
>
|
|
386
|
+
<Timer className="h-3 w-3" />
|
|
387
|
+
</Button>
|
|
388
|
+
<Button
|
|
389
|
+
type="button"
|
|
390
|
+
variant="secondary"
|
|
391
|
+
size="icon-xs"
|
|
392
|
+
className="absolute top-1 right-1"
|
|
393
|
+
title="Delete view"
|
|
394
|
+
onClick={(e) => {
|
|
395
|
+
e.stopPropagation();
|
|
396
|
+
if (playingAll) stopPlayAll();
|
|
397
|
+
if (editingViewId === view.id) cancelRename();
|
|
398
|
+
removeBasketView(view.id);
|
|
399
|
+
}}
|
|
400
|
+
>
|
|
401
|
+
<Trash2 className="h-3 w-3" />
|
|
402
|
+
</Button>
|
|
403
|
+
</div>
|
|
404
|
+
))}
|
|
405
|
+
</div>
|
|
406
|
+
</div>
|
|
407
|
+
|
|
408
|
+
<Button
|
|
409
|
+
type="button"
|
|
410
|
+
variant="outline"
|
|
411
|
+
size="icon-sm"
|
|
412
|
+
onClick={() => scrollStrip(280)}
|
|
413
|
+
disabled={basketViews.length <= 1}
|
|
414
|
+
title="Scroll right"
|
|
415
|
+
>
|
|
416
|
+
<ChevronRight className="h-4 w-4" />
|
|
417
|
+
</Button>
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
</div>
|
|
421
|
+
);
|
|
422
|
+
}
|
|
@@ -54,10 +54,19 @@ import {
|
|
|
54
54
|
Orbit,
|
|
55
55
|
FolderOpen,
|
|
56
56
|
Clock,
|
|
57
|
+
Save,
|
|
57
58
|
} from 'lucide-react';
|
|
58
59
|
import { cn } from '@/lib/utils';
|
|
59
|
-
import { useViewerStore
|
|
60
|
-
import
|
|
60
|
+
import { useViewerStore } from '@/store';
|
|
61
|
+
import { goHomeFromStore, resetVisibilityForHomeFromStore } from '@/store/homeView';
|
|
62
|
+
import {
|
|
63
|
+
executeBasketSet,
|
|
64
|
+
executeBasketAdd,
|
|
65
|
+
executeBasketRemove,
|
|
66
|
+
executeBasketToggleVisibility,
|
|
67
|
+
executeBasketSaveView,
|
|
68
|
+
executeBasketClear,
|
|
69
|
+
} from '@/store/basket/basketCommands';
|
|
61
70
|
import { useSandbox } from '@/hooks/useSandbox';
|
|
62
71
|
import { SCRIPT_TEMPLATES } from '@/lib/scripts/templates';
|
|
63
72
|
import { GLTFExporter, CSVExporter } from '@ifc-lite/export';
|
|
@@ -172,22 +181,6 @@ function downloadBlob(data: BlobPart, name: string, mime: string) {
|
|
|
172
181
|
URL.revokeObjectURL(url);
|
|
173
182
|
}
|
|
174
183
|
|
|
175
|
-
function getSelectionRefs(): EntityRef[] {
|
|
176
|
-
const s = useViewerStore.getState();
|
|
177
|
-
if (s.selectedEntitiesSet.size > 0) {
|
|
178
|
-
const refs: EntityRef[] = [];
|
|
179
|
-
for (const str of s.selectedEntitiesSet) refs.push(stringToEntityRef(str));
|
|
180
|
-
return refs;
|
|
181
|
-
}
|
|
182
|
-
return s.selectedEntity ? [s.selectedEntity] : [];
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
function clearMultiSelect() {
|
|
186
|
-
const s = useViewerStore.getState();
|
|
187
|
-
if (s.selectedEntitiesSet.size > 0)
|
|
188
|
-
useViewerStore.setState({ selectedEntitiesSet: new Set(), selectedEntityIds: new Set() });
|
|
189
|
-
}
|
|
190
|
-
|
|
191
184
|
/** Exclusively activate a right-panel content panel (BCF / IDS / Lens).
|
|
192
185
|
* Closes all others first so the if-else chain in ViewerLayout renders it.
|
|
193
186
|
* If the target is already active, closes it (back to Properties). */
|
|
@@ -294,7 +287,7 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
|
|
294
287
|
// ── View ──
|
|
295
288
|
c.push(
|
|
296
289
|
{ id: 'view:home', label: 'Home', keywords: 'isometric reset camera', category: 'View', icon: Home, shortcut: 'H',
|
|
297
|
-
action: () => {
|
|
290
|
+
action: () => { goHomeFromStore(); } },
|
|
298
291
|
{ id: 'view:fit', label: 'Fit All', keywords: 'zoom extents entire model', category: 'View', icon: Maximize2, shortcut: 'Z',
|
|
299
292
|
action: () => { useViewerStore.getState().cameraCallbacks.fitAll?.(); } },
|
|
300
293
|
{ id: 'view:frame', label: 'Frame Selection', keywords: 'zoom focus selected', category: 'View', icon: Crosshair, shortcut: 'F',
|
|
@@ -340,19 +333,23 @@ export function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
|
|
340
333
|
if (ids.length > 0) { s.hideEntities(ids); s.clearSelection(); }
|
|
341
334
|
} },
|
|
342
335
|
{ id: 'vis:show', label: 'Show All', keywords: 'unhide reset visible', category: 'Visibility', icon: Eye, shortcut: 'A',
|
|
343
|
-
action: () => {
|
|
344
|
-
{ id: 'vis:
|
|
345
|
-
action: () =>
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
{ id: 'vis:
|
|
351
|
-
action: () =>
|
|
352
|
-
{ id: 'vis:
|
|
353
|
-
action: () =>
|
|
354
|
-
|
|
355
|
-
|
|
336
|
+
action: () => { resetVisibilityForHomeFromStore(); } },
|
|
337
|
+
{ id: 'vis:set-iso', label: 'Isolate (Set Basket)', keywords: 'basket isolate set selection hierarchy view equals', category: 'Visibility', icon: Equal, shortcut: 'I',
|
|
338
|
+
action: () => executeBasketSet() },
|
|
339
|
+
{ id: 'vis:add-iso', label: 'Add to Basket', keywords: 'basket plus selection hierarchy view', category: 'Visibility', icon: Plus, shortcut: '+',
|
|
340
|
+
action: () => executeBasketAdd() },
|
|
341
|
+
{ id: 'vis:remove-iso', label: 'Remove from Basket', keywords: 'basket minus selection hierarchy view', category: 'Visibility', icon: Minus, shortcut: '−',
|
|
342
|
+
action: () => executeBasketRemove() },
|
|
343
|
+
{ id: 'vis:toggle-iso', label: 'Toggle Basket Visibility', keywords: 'basket show hide', category: 'Visibility', icon: Eye,
|
|
344
|
+
action: () => executeBasketToggleVisibility() },
|
|
345
|
+
{ id: 'vis:save-view', label: 'Save Basket as View', keywords: 'basket presentation thumbnail', category: 'Visibility', icon: Save,
|
|
346
|
+
action: () => executeBasketSaveView().catch((err) => {
|
|
347
|
+
console.error('[CommandPalette] Failed to save basket view:', err);
|
|
348
|
+
}) },
|
|
349
|
+
{ id: 'vis:toggle-presentation', label: 'Toggle Basket Presentation Dock', keywords: 'basket panel carousel thumbnails', category: 'Visibility', icon: Layout,
|
|
350
|
+
action: () => { useViewerStore.getState().toggleBasketPresentationVisible(); } },
|
|
351
|
+
{ id: 'vis:clear-iso', label: 'Clear Basket', keywords: 'basket clear reset', category: 'Visibility', icon: RotateCcw,
|
|
352
|
+
action: () => executeBasketClear() },
|
|
356
353
|
{ id: 'vis:spaces', label: 'Spaces', keywords: 'IfcSpace rooms show hide', category: 'Visibility', icon: Box,
|
|
357
354
|
action: () => { useViewerStore.getState().toggleTypeVisibility('spaces'); } },
|
|
358
355
|
{ id: 'vis:openings', label: 'Openings', keywords: 'IfcOpeningElement show hide', category: 'Visibility', icon: SquareX,
|
|
@@ -17,30 +17,34 @@ import {
|
|
|
17
17
|
Copy,
|
|
18
18
|
Maximize2,
|
|
19
19
|
Building2,
|
|
20
|
+
Save,
|
|
20
21
|
} from 'lucide-react';
|
|
21
22
|
import { useViewerStore, resolveEntityRef } from '@/store';
|
|
23
|
+
import { resetVisibilityForHomeFromStore } from '@/store/homeView';
|
|
24
|
+
import {
|
|
25
|
+
executeBasketSet,
|
|
26
|
+
executeBasketAdd,
|
|
27
|
+
executeBasketRemove,
|
|
28
|
+
executeBasketSaveView,
|
|
29
|
+
} from '@/store/basket/basketCommands';
|
|
22
30
|
import { useIfc } from '@/hooks/useIfc';
|
|
23
31
|
|
|
24
32
|
export function EntityContextMenu() {
|
|
25
33
|
const contextMenu = useViewerStore((s) => s.contextMenu);
|
|
26
34
|
const closeContextMenu = useViewerStore((s) => s.closeContextMenu);
|
|
27
35
|
const hideEntity = useViewerStore((s) => s.hideEntity);
|
|
28
|
-
const showAll = useViewerStore((s) => s.showAll);
|
|
29
36
|
const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
|
|
30
37
|
const setSelectedEntityIds = useViewerStore((s) => s.setSelectedEntityIds);
|
|
31
38
|
const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
|
|
32
39
|
// Basket actions
|
|
33
|
-
const setBasket = useViewerStore((s) => s.setBasket);
|
|
34
|
-
const addToBasket = useViewerStore((s) => s.addToBasket);
|
|
35
|
-
const removeFromBasket = useViewerStore((s) => s.removeFromBasket);
|
|
36
40
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
37
41
|
const { ifcDataStore, models } = useIfc();
|
|
38
42
|
|
|
39
43
|
// Resolve contextMenu.entityId (globalId) to original expressId and model
|
|
40
44
|
// This is needed because IfcDataStore uses original expressIds, not globalIds
|
|
41
|
-
const { resolvedExpressId,
|
|
45
|
+
const { resolvedExpressId, activeDataStore, contextEntityRef } = useMemo(() => {
|
|
42
46
|
if (!contextMenu.entityId) {
|
|
43
|
-
return { resolvedExpressId: null,
|
|
47
|
+
return { resolvedExpressId: null, activeDataStore: ifcDataStore, contextEntityRef: null };
|
|
44
48
|
}
|
|
45
49
|
|
|
46
50
|
// Single source of truth for globalId → EntityRef resolution
|
|
@@ -49,12 +53,16 @@ export function EntityContextMenu() {
|
|
|
49
53
|
const model = models.get(ref.modelId);
|
|
50
54
|
return {
|
|
51
55
|
resolvedExpressId: ref.expressId,
|
|
52
|
-
resolvedModelId: ref.modelId,
|
|
53
56
|
activeDataStore: model?.ifcDataStore ?? ifcDataStore,
|
|
57
|
+
contextEntityRef: ref,
|
|
54
58
|
};
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
return {
|
|
61
|
+
return {
|
|
62
|
+
resolvedExpressId: contextMenu.entityId,
|
|
63
|
+
activeDataStore: ifcDataStore,
|
|
64
|
+
contextEntityRef: null,
|
|
65
|
+
};
|
|
58
66
|
}, [contextMenu.entityId, models, ifcDataStore]);
|
|
59
67
|
|
|
60
68
|
// Close menu when clicking outside
|
|
@@ -95,27 +103,33 @@ export function EntityContextMenu() {
|
|
|
95
103
|
|
|
96
104
|
// Basket: = Set basket to this entity
|
|
97
105
|
const handleSetBasket = useCallback(() => {
|
|
98
|
-
|
|
99
|
-
setBasket([{ modelId: resolvedModelId, expressId: resolvedExpressId }]);
|
|
100
|
-
}
|
|
106
|
+
executeBasketSet(contextEntityRef);
|
|
101
107
|
closeContextMenu();
|
|
102
|
-
}, [
|
|
108
|
+
}, [contextEntityRef, closeContextMenu]);
|
|
103
109
|
|
|
104
110
|
// Basket: + Add to basket
|
|
105
111
|
const handleAddToBasket = useCallback(() => {
|
|
106
|
-
|
|
107
|
-
addToBasket([{ modelId: resolvedModelId, expressId: resolvedExpressId }]);
|
|
108
|
-
}
|
|
112
|
+
executeBasketAdd(contextEntityRef);
|
|
109
113
|
closeContextMenu();
|
|
110
|
-
}, [
|
|
114
|
+
}, [contextEntityRef, closeContextMenu]);
|
|
111
115
|
|
|
112
116
|
// Basket: − Remove from basket
|
|
113
117
|
const handleRemoveFromBasket = useCallback(() => {
|
|
114
|
-
|
|
115
|
-
|
|
118
|
+
executeBasketRemove(contextEntityRef);
|
|
119
|
+
closeContextMenu();
|
|
120
|
+
}, [contextEntityRef, closeContextMenu]);
|
|
121
|
+
|
|
122
|
+
const handleSaveBasketView = useCallback(() => {
|
|
123
|
+
const state = useViewerStore.getState();
|
|
124
|
+
if (state.pinboardEntities.size === 0) {
|
|
125
|
+
closeContextMenu();
|
|
126
|
+
return;
|
|
116
127
|
}
|
|
128
|
+
executeBasketSaveView().catch((err) => {
|
|
129
|
+
console.error('[EntityContextMenu] Failed to save basket view:', err);
|
|
130
|
+
});
|
|
117
131
|
closeContextMenu();
|
|
118
|
-
}, [
|
|
132
|
+
}, [closeContextMenu]);
|
|
119
133
|
|
|
120
134
|
const handleHide = useCallback(() => {
|
|
121
135
|
if (contextMenu.entityId) {
|
|
@@ -125,9 +139,9 @@ export function EntityContextMenu() {
|
|
|
125
139
|
}, [contextMenu.entityId, hideEntity, closeContextMenu]);
|
|
126
140
|
|
|
127
141
|
const handleShowAll = useCallback(() => {
|
|
128
|
-
|
|
142
|
+
resetVisibilityForHomeFromStore();
|
|
129
143
|
closeContextMenu();
|
|
130
|
-
}, [
|
|
144
|
+
}, [closeContextMenu]);
|
|
131
145
|
|
|
132
146
|
const handleSelectSimilar = useCallback(() => {
|
|
133
147
|
// Use resolvedExpressId (original ID) for IfcDataStore lookups
|
|
@@ -230,9 +244,10 @@ export function EntityContextMenu() {
|
|
|
230
244
|
<div className="h-px bg-border my-1" />
|
|
231
245
|
|
|
232
246
|
{/* Basket operations */}
|
|
233
|
-
<MenuItem icon={Equal} label="Set
|
|
247
|
+
<MenuItem icon={Equal} label="Set Basket (=)" onClick={handleSetBasket} />
|
|
234
248
|
<MenuItem icon={Plus} label="Add to Basket (+)" onClick={handleAddToBasket} />
|
|
235
249
|
<MenuItem icon={Minus} label="Remove from Basket (−)" onClick={handleRemoveFromBasket} />
|
|
250
|
+
<MenuItem icon={Save} label="Save Basket View (B)" onClick={handleSaveBasketView} />
|
|
236
251
|
|
|
237
252
|
<div className="h-px bg-border my-1" />
|
|
238
253
|
|