@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/assets/{Arrow.dom-CusgkT03.js → Arrow.dom-IIkrrCZ0.js} +1 -1
  3. package/dist/assets/{browser-BXNIkE8a.js → browser-BoonPy8d.js} +1 -1
  4. package/dist/assets/ifc-lite_bg-B6s-pcv0.wasm +0 -0
  5. package/dist/assets/{index-huvR-kGC.js → index-CQkEOlYf.js} +49090 -46453
  6. package/dist/assets/{index-6Mr3byM-.js → index-ClZCG7KA.js} +4 -4
  7. package/dist/assets/index-qxIHWl_B.css +1 -0
  8. package/dist/assets/{native-bridge-DsHOKdgD.js → native-bridge-Beg4Kf9O.js} +1 -1
  9. package/dist/assets/{wasm-bridge-Bd73HXn-.js → wasm-bridge-CY8jkr7u.js} +1 -1
  10. package/dist/index.html +2 -2
  11. package/package.json +19 -19
  12. package/src/components/viewer/BasketPresentationDock.tsx +422 -0
  13. package/src/components/viewer/CommandPalette.tsx +29 -32
  14. package/src/components/viewer/EntityContextMenu.tsx +37 -22
  15. package/src/components/viewer/HierarchyPanel.tsx +19 -1
  16. package/src/components/viewer/MainToolbar.tsx +32 -89
  17. package/src/components/viewer/Section2DPanel.tsx +8 -1
  18. package/src/components/viewer/Viewport.tsx +107 -98
  19. package/src/components/viewer/ViewportContainer.tsx +2 -0
  20. package/src/components/viewer/ViewportOverlays.tsx +9 -3
  21. package/src/components/viewer/hierarchy/treeDataBuilder.ts +3 -1
  22. package/src/components/viewer/useAnimationLoop.ts +4 -1
  23. package/src/components/viewer/useKeyboardControls.ts +2 -2
  24. package/src/components/viewer/useRenderUpdates.ts +16 -4
  25. package/src/hooks/useKeyboardShortcuts.ts +51 -84
  26. package/src/hooks/useViewerSelectors.ts +22 -0
  27. package/src/index.css +6 -0
  28. package/src/store/basket/basketCommands.ts +81 -0
  29. package/src/store/basket/basketViewActivator.ts +54 -0
  30. package/src/store/basketSave.ts +122 -0
  31. package/src/store/basketVisibleSet.test.ts +161 -0
  32. package/src/store/basketVisibleSet.ts +487 -0
  33. package/src/store/constants.ts +20 -0
  34. package/src/store/homeView.ts +21 -0
  35. package/src/store/index.ts +17 -0
  36. package/src/store/slices/drawing2DSlice.ts +5 -0
  37. package/src/store/slices/pinboardSlice.test.ts +160 -0
  38. package/src/store/slices/pinboardSlice.ts +248 -18
  39. package/src/store/slices/uiSlice.ts +41 -0
  40. package/src/store/types.ts +11 -0
  41. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  42. 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, stringToEntityRef } from '@/store';
60
- import type { EntityRef } from '@/store';
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: () => { useViewerStore.getState().cameraCallbacks.home?.(); } },
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: () => { const s = useViewerStore.getState(); s.showAll(); s.clearStoreySelection(); } },
344
- { id: 'vis:isolate', label: 'Isolate Selection', keywords: 'basket set pinboard', category: 'Visibility', icon: Equal, shortcut: 'I',
345
- action: () => {
346
- const s = useViewerStore.getState();
347
- if (s.pinboardEntities.size > 0 && s.selectedEntitiesSet.size === 0) { s.showPinboard(); }
348
- else { const r = getSelectionRefs(); if (r.length > 0) { s.setBasket(r); clearMultiSelect(); } }
349
- } },
350
- { id: 'vis:add-iso', label: 'Add to Isolation', keywords: 'basket plus', category: 'Visibility', icon: Plus, shortcut: '+',
351
- action: () => { const r = getSelectionRefs(); if (r.length > 0) { useViewerStore.getState().addToBasket(r); clearMultiSelect(); } } },
352
- { id: 'vis:remove-iso', label: 'Remove from Isolation', keywords: 'basket minus', category: 'Visibility', icon: Minus, shortcut: '−',
353
- action: () => { const r = getSelectionRefs(); if (r.length > 0) { useViewerStore.getState().removeFromBasket(r); clearMultiSelect(); } } },
354
- { id: 'vis:clear-iso', label: 'Clear Isolation', keywords: 'basket reset', category: 'Visibility', icon: RotateCcw,
355
- action: () => { useViewerStore.getState().clearBasket(); } },
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, resolvedModelId, activeDataStore } = useMemo(() => {
45
+ const { resolvedExpressId, activeDataStore, contextEntityRef } = useMemo(() => {
42
46
  if (!contextMenu.entityId) {
43
- return { resolvedExpressId: null, resolvedModelId: null, activeDataStore: ifcDataStore };
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 { resolvedExpressId: contextMenu.entityId, resolvedModelId: null, activeDataStore: ifcDataStore };
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
- if (resolvedExpressId !== null && resolvedModelId !== null) {
99
- setBasket([{ modelId: resolvedModelId, expressId: resolvedExpressId }]);
100
- }
106
+ executeBasketSet(contextEntityRef);
101
107
  closeContextMenu();
102
- }, [resolvedExpressId, resolvedModelId, setBasket, closeContextMenu]);
108
+ }, [contextEntityRef, closeContextMenu]);
103
109
 
104
110
  // Basket: + Add to basket
105
111
  const handleAddToBasket = useCallback(() => {
106
- if (resolvedExpressId !== null && resolvedModelId !== null) {
107
- addToBasket([{ modelId: resolvedModelId, expressId: resolvedExpressId }]);
108
- }
112
+ executeBasketAdd(contextEntityRef);
109
113
  closeContextMenu();
110
- }, [resolvedExpressId, resolvedModelId, addToBasket, closeContextMenu]);
114
+ }, [contextEntityRef, closeContextMenu]);
111
115
 
112
116
  // Basket: − Remove from basket
113
117
  const handleRemoveFromBasket = useCallback(() => {
114
- if (resolvedExpressId !== null && resolvedModelId !== null) {
115
- removeFromBasket([{ modelId: resolvedModelId, expressId: resolvedExpressId }]);
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
- }, [resolvedExpressId, resolvedModelId, removeFromBasket, closeContextMenu]);
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
- showAll(); // Clear hidden + isolation (basket preserved)
142
+ resetVisibilityForHomeFromStore();
129
143
  closeContextMenu();
130
- }, [showAll, closeContextMenu]);
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 as Basket (=)" onClick={handleSetBasket} />
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