@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
@@ -31,6 +31,7 @@ import { createListSlice, type ListSlice } from './slices/listSlice.js';
31
31
  import { createPinboardSlice, type PinboardSlice } from './slices/pinboardSlice.js';
32
32
  import { createLensSlice, type LensSlice } from './slices/lensSlice.js';
33
33
  import { createScriptSlice, type ScriptSlice } from './slices/scriptSlice.js';
34
+ import { invalidateVisibleBasketCache } from './basketVisibleSet.js';
34
35
 
35
36
  // Import constants for reset function
36
37
  import { CAMERA_DEFAULTS, SECTION_PLANE_DEFAULTS, UI_DEFAULTS, TYPE_VISIBILITY_DEFAULTS } from './constants.js';
@@ -122,6 +123,7 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
122
123
  // Reset all viewer state when loading new file
123
124
  // Note: Does NOT clear models - use clearAllModels() for that
124
125
  resetViewerState: () => {
126
+ invalidateVisibleBasketCache();
125
127
  const [set] = args;
126
128
  set({
127
129
  // Selection (legacy)
@@ -184,6 +186,16 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
184
186
 
185
187
  // UI
186
188
  activeTool: UI_DEFAULTS.ACTIVE_TOOL,
189
+ visualEnhancementsEnabled: UI_DEFAULTS.VISUAL_ENHANCEMENTS_ENABLED,
190
+ edgeContrastEnabled: UI_DEFAULTS.EDGE_CONTRAST_ENABLED,
191
+ edgeContrastIntensity: UI_DEFAULTS.EDGE_CONTRAST_INTENSITY,
192
+ contactShadingQuality: UI_DEFAULTS.CONTACT_SHADING_QUALITY,
193
+ contactShadingIntensity: UI_DEFAULTS.CONTACT_SHADING_INTENSITY,
194
+ contactShadingRadius: UI_DEFAULTS.CONTACT_SHADING_RADIUS,
195
+ separationLinesEnabled: UI_DEFAULTS.SEPARATION_LINES_ENABLED,
196
+ separationLinesQuality: UI_DEFAULTS.SEPARATION_LINES_QUALITY,
197
+ separationLinesIntensity: UI_DEFAULTS.SEPARATION_LINES_INTENSITY,
198
+ separationLinesRadius: UI_DEFAULTS.SEPARATION_LINES_RADIUS,
187
199
 
188
200
  // Drawing 2D
189
201
  drawing2D: null,
@@ -192,6 +204,7 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
192
204
  drawing2DPhase: '',
193
205
  drawing2DError: null,
194
206
  drawing2DPanelVisible: false,
207
+ suppressNextSection2DPanelAutoOpen: false,
195
208
  drawing2DSvgContent: null,
196
209
  drawing2DDisplayOptions: {
197
210
  showHiddenLines: true,
@@ -256,6 +269,10 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
256
269
 
257
270
  // Pinboard - clear pinned entities on new file
258
271
  pinboardEntities: new Set<string>(),
272
+ basketViews: [],
273
+ activeBasketViewId: null,
274
+ basketPresentationVisible: false,
275
+ hierarchyBasketSelection: new Set<string>(),
259
276
 
260
277
  // Script - reset execution state but keep saved scripts and editor content
261
278
  scriptPanelVisible: false,
@@ -78,6 +78,8 @@ export interface Drawing2DState {
78
78
  drawing2DError: string | null;
79
79
  /** Whether the 2D panel is visible */
80
80
  drawing2DPanelVisible: boolean;
81
+ /** Suppress auto-opening 2D panel on next section tool activation */
82
+ suppressNextSection2DPanelAutoOpen: boolean;
81
83
  /** SVG content for export (cached) */
82
84
  drawing2DSvgContent: string | null;
83
85
  /** Display options */
@@ -153,6 +155,7 @@ export interface Drawing2DSlice extends Drawing2DState {
153
155
  setDrawing2DProgress: (progress: number, phase: string) => void;
154
156
  setDrawing2DError: (error: string | null) => void;
155
157
  setDrawing2DPanelVisible: (visible: boolean) => void;
158
+ setSuppressNextSection2DPanelAutoOpen: (suppress: boolean) => void;
156
159
  toggleDrawing2DPanel: () => void;
157
160
  setDrawing2DSvgContent: (svg: string | null) => void;
158
161
  updateDrawing2DDisplayOptions: (options: Partial<Drawing2DState['drawing2DDisplayOptions']>) => void;
@@ -242,6 +245,7 @@ const getDefaultState = (): Drawing2DState => ({
242
245
  drawing2DPhase: '',
243
246
  drawing2DError: null,
244
247
  drawing2DPanelVisible: false,
248
+ suppressNextSection2DPanelAutoOpen: false,
245
249
  drawing2DSvgContent: null,
246
250
  drawing2DDisplayOptions: getDefaultDisplayOptions(),
247
251
  // Graphic overrides
@@ -295,6 +299,7 @@ export const createDrawing2DSlice: StateCreator<Drawing2DSlice, [], [], Drawing2
295
299
  }),
296
300
 
297
301
  setDrawing2DPanelVisible: (visible) => set({ drawing2DPanelVisible: visible }),
302
+ setSuppressNextSection2DPanelAutoOpen: (suppress) => set({ suppressNextSection2DPanelAutoOpen: suppress }),
298
303
 
299
304
  toggleDrawing2DPanel: () => set((state) => ({ drawing2DPanelVisible: !state.drawing2DPanelVisible })),
300
305
 
@@ -0,0 +1,160 @@
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 { describe, it, beforeEach } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { createPinboardSlice, type PinboardSlice } from './pinboardSlice.js';
8
+ import type { EntityRef } from '../types.js';
9
+
10
+ function createMockCrossSlice() {
11
+ return {
12
+ isolatedEntities: null as Set<number> | null,
13
+ hiddenEntities: new Set<number>(),
14
+ models: new Map<string, { idOffset: number }>([['legacy', { idOffset: 0 }]]),
15
+ cameraCallbacks: { getViewpoint: () => null },
16
+ sectionPlane: { axis: 'front' as const, position: 50, enabled: false, flipped: false },
17
+ drawing2D: null,
18
+ drawing2DDisplayOptions: { show3DOverlay: true, showHiddenLines: true },
19
+ setDrawing2D: () => {},
20
+ updateDrawing2DDisplayOptions: () => {},
21
+ setActiveTool: () => {},
22
+ clearEntitySelection: () => {},
23
+ activeTool: 'select',
24
+ };
25
+ }
26
+
27
+ describe('PinboardSlice', () => {
28
+ let state: PinboardSlice & ReturnType<typeof createMockCrossSlice>;
29
+ let setState: (partial: Partial<typeof state> | ((s: typeof state) => Partial<typeof state>)) => void;
30
+
31
+ beforeEach(() => {
32
+ const cross = createMockCrossSlice();
33
+ setState = (partial) => {
34
+ if (typeof partial === 'function') {
35
+ const updates = partial(state);
36
+ state = { ...state, ...updates };
37
+ } else {
38
+ state = { ...state, ...partial };
39
+ }
40
+ };
41
+
42
+ state = {
43
+ ...cross,
44
+ ...createPinboardSlice(setState, () => state, cross as any),
45
+ };
46
+ });
47
+
48
+ describe('setBasket / addToBasket / removeFromBasket isolation sync', () => {
49
+ it('setBasket syncs pinboardEntities and isolatedEntities', () => {
50
+ const refs: EntityRef[] = [
51
+ { modelId: 'legacy', expressId: 100 },
52
+ { modelId: 'legacy', expressId: 200 },
53
+ ];
54
+ state.setBasket(refs);
55
+
56
+ assert.strictEqual(state.pinboardEntities.size, 2);
57
+ assert.ok(state.pinboardEntities.has('legacy:100'));
58
+ assert.ok(state.pinboardEntities.has('legacy:200'));
59
+ assert.ok(state.isolatedEntities !== null);
60
+ assert.strictEqual(state.isolatedEntities!.size, 2);
61
+ assert.ok(state.isolatedEntities!.has(100));
62
+ assert.ok(state.isolatedEntities!.has(200));
63
+ });
64
+
65
+ it('addToBasket adds to existing basket and updates isolation', () => {
66
+ state.setBasket([{ modelId: 'legacy', expressId: 100 }]);
67
+ state.addToBasket([{ modelId: 'legacy', expressId: 200 }]);
68
+
69
+ assert.strictEqual(state.pinboardEntities.size, 2);
70
+ assert.ok(state.isolatedEntities !== null);
71
+ assert.strictEqual(state.isolatedEntities!.size, 2);
72
+ });
73
+
74
+ it('removeFromBasket removes and clears isolation when empty', () => {
75
+ state.setBasket([{ modelId: 'legacy', expressId: 100 }]);
76
+ state.removeFromBasket([{ modelId: 'legacy', expressId: 100 }]);
77
+
78
+ assert.strictEqual(state.pinboardEntities.size, 0);
79
+ assert.strictEqual(state.isolatedEntities, null);
80
+ });
81
+ });
82
+
83
+ describe('saveCurrentBasketView', () => {
84
+ it('creates view with unique id and sets activeBasketViewId', () => {
85
+ state.setBasket([{ modelId: 'legacy', expressId: 100 }]);
86
+ const id = state.saveCurrentBasketView();
87
+
88
+ assert.ok(id !== null);
89
+ assert.strictEqual(state.basketViews.length, 1);
90
+ assert.strictEqual(state.activeBasketViewId, id);
91
+ assert.strictEqual(state.basketViews[0].entityRefs.length, 1);
92
+ });
93
+
94
+ it('auto-increments view name', () => {
95
+ state.setBasket([{ modelId: 'legacy', expressId: 100 }]);
96
+ state.saveCurrentBasketView();
97
+ state.saveCurrentBasketView();
98
+
99
+ assert.strictEqual(state.basketViews.length, 2);
100
+ assert.strictEqual(state.basketViews[0].name, 'Basket 1');
101
+ assert.strictEqual(state.basketViews[1].name, 'Basket 2');
102
+ });
103
+
104
+ it('returns null when basket is empty', () => {
105
+ const id = state.saveCurrentBasketView();
106
+ assert.strictEqual(id, null);
107
+ });
108
+
109
+ it('captures section plane but not 2D drawing payload', () => {
110
+ state.setBasket([{ modelId: 'legacy', expressId: 100 }]);
111
+ state.activeTool = 'section';
112
+ state.sectionPlane = { axis: 'front', position: 42, enabled: true, flipped: false };
113
+ state.drawing2D = {
114
+ lines: [{ line: { start: { x: 0, y: 0 }, end: { x: 1, y: 1 } }, visibility: 'visible', category: 'solid' }],
115
+ cutPolygons: [],
116
+ } as unknown as typeof state.drawing2D;
117
+
118
+ const id = state.saveCurrentBasketView();
119
+ assert.ok(id !== null);
120
+ const saved = state.basketViews[0];
121
+ assert.ok(saved.section !== null);
122
+ assert.strictEqual(saved.section!.plane.enabled, true);
123
+ assert.strictEqual(saved.section!.drawing2D, null);
124
+ });
125
+ });
126
+
127
+ describe('restoreBasketEntities', () => {
128
+ it('restores basket and isolation state only', () => {
129
+ state.restoreBasketEntities(['legacy:100', 'legacy:200'], 'view-1');
130
+
131
+ assert.strictEqual(state.pinboardEntities.size, 2);
132
+ assert.ok(state.pinboardEntities.has('legacy:100'));
133
+ assert.ok(state.pinboardEntities.has('legacy:200'));
134
+ assert.strictEqual(state.activeBasketViewId, 'view-1');
135
+ assert.ok(state.isolatedEntities !== null);
136
+ assert.strictEqual(state.isolatedEntities!.size, 2);
137
+ });
138
+
139
+ it('handles empty entityRefs', () => {
140
+ state.restoreBasketEntities([], 'view-empty');
141
+
142
+ assert.strictEqual(state.pinboardEntities.size, 0);
143
+ assert.strictEqual(state.isolatedEntities, null);
144
+ assert.strictEqual(state.activeBasketViewId, 'view-empty');
145
+ });
146
+ });
147
+
148
+ describe('clearBasket', () => {
149
+ it('resets activeBasketViewId', () => {
150
+ state.setBasket([{ modelId: 'legacy', expressId: 100 }]);
151
+ state.saveCurrentBasketView();
152
+ assert.ok(state.activeBasketViewId !== null);
153
+
154
+ state.clearBasket();
155
+ assert.strictEqual(state.activeBasketViewId, null);
156
+ assert.strictEqual(state.pinboardEntities.size, 0);
157
+ assert.strictEqual(state.isolatedEntities, null);
158
+ });
159
+ });
160
+ });
@@ -5,30 +5,82 @@
5
5
  /**
6
6
  * Pinboard (Basket) state slice
7
7
  *
8
- * The basket is an incremental isolation set. Users build it with:
9
- * = (set) — replace basket with current selection
10
- * + (add) — add current selection to basket
11
- * (remove) remove current selection from basket
8
+ * The basket is an incremental isolation set. Users can build it from
9
+ * selection / visible scene / hierarchy sources via presentation controls:
10
+ * = (set) — replace basket with source set
11
+ * + (add) add source set to basket
12
+ * − (remove) — remove source set from basket
12
13
  *
13
14
  * When the basket is non-empty, only basket entities are visible (isolation).
14
15
  * The basket also syncs to isolatedEntities for renderer consumption.
16
+ * Users can persist any basket as a saved "view" with a thumbnail preview.
15
17
  */
16
18
 
17
19
  import type { StateCreator } from 'zustand';
18
- import type { EntityRef } from '../types.js';
20
+ import type { Drawing2D } from '@ifc-lite/drawing-2d';
21
+ import type { CameraCallbacks, CameraViewpoint, EntityRef, SectionPlane } from '../types.js';
19
22
  import { entityRefToString, stringToEntityRef } from '../types.js';
20
23
 
24
+ export type BasketSource = 'selection' | 'visible' | 'hierarchy' | 'manual';
25
+
26
+ export interface BasketSectionSnapshot {
27
+ plane: SectionPlane;
28
+ drawing2D: Drawing2D | null;
29
+ show3DOverlay: boolean;
30
+ showHiddenLines: boolean;
31
+ }
32
+
33
+ export interface BasketView {
34
+ id: string;
35
+ name: string;
36
+ entityRefs: string[];
37
+ thumbnailDataUrl: string | null;
38
+ /** Optional camera transition override for this view (ms). */
39
+ transitionMs: number | null;
40
+ viewpoint: CameraViewpoint | null;
41
+ section: BasketSectionSnapshot | null;
42
+ source: BasketSource;
43
+ createdAt: number;
44
+ updatedAt: number;
45
+ }
46
+
47
+ export interface SaveBasketViewOptions {
48
+ name?: string;
49
+ thumbnailDataUrl?: string | null;
50
+ transitionMs?: number | null;
51
+ source?: BasketSource;
52
+ viewpoint?: CameraViewpoint | null;
53
+ section?: BasketSectionSnapshot | null;
54
+ }
55
+
21
56
  /** Cross-slice state that pinboard reads/writes via the combined store */
22
57
  interface PinboardCrossSliceState {
23
58
  isolatedEntities: Set<number> | null;
24
59
  hiddenEntities: Set<number>;
25
60
  models: Map<string, { idOffset: number }>;
61
+ cameraCallbacks: CameraCallbacks;
62
+ sectionPlane: SectionPlane;
63
+ drawing2D: Drawing2D | null;
64
+ drawing2DDisplayOptions: { show3DOverlay: boolean; showHiddenLines: boolean };
65
+ setDrawing2D: (drawing: Drawing2D | null) => void;
66
+ updateDrawing2DDisplayOptions: (options: { show3DOverlay?: boolean; showHiddenLines?: boolean }) => void;
67
+ setActiveTool: (tool: string) => void;
68
+ clearEntitySelection: () => void;
69
+ activeTool: string;
26
70
  }
27
71
 
28
72
  export interface PinboardSlice {
29
73
  // State
30
74
  /** Serialized EntityRef strings for O(1) membership check */
31
75
  pinboardEntities: Set<string>;
76
+ /** Saved basket presets with optional viewport thumbnails */
77
+ basketViews: BasketView[];
78
+ /** Active saved view currently restored into the live basket */
79
+ activeBasketViewId: string | null;
80
+ /** Floating presentation dock visibility */
81
+ basketPresentationVisible: boolean;
82
+ /** Last hierarchy-derived set used for "Hierarchy" basket source */
83
+ hierarchyBasketSelection: Set<string>;
32
84
 
33
85
  // Actions
34
86
  /** Add entities to pinboard/basket */
@@ -57,6 +109,28 @@ export interface PinboardSlice {
57
109
  removeFromBasket: (refs: EntityRef[]) => void;
58
110
  /** Clear basket and clear isolation */
59
111
  clearBasket: () => void;
112
+ /** Set hierarchy-derived basket source */
113
+ setHierarchyBasketSelection: (refs: EntityRef[]) => void;
114
+ /** Clear hierarchy-derived basket source */
115
+ clearHierarchyBasketSelection: () => void;
116
+ /** Show/hide presentation dock */
117
+ setBasketPresentationVisible: (visible: boolean) => void;
118
+ /** Toggle presentation dock */
119
+ toggleBasketPresentationVisible: () => void;
120
+ /** Save current basket as a reusable view preset */
121
+ saveCurrentBasketView: (options?: SaveBasketViewOptions) => string | null;
122
+ /** Restore basket entities and isolation only (no camera/section). Use activateBasketViewFromStore for full restore. */
123
+ restoreBasketEntities: (entityRefs: string[], viewId: string) => void;
124
+ /** Restore a saved basket view into the live basket (delegates to activateBasketViewFromStore) */
125
+ activateBasketView: (viewId: string) => void;
126
+ /** Remove a saved basket view */
127
+ removeBasketView: (viewId: string) => void;
128
+ /** Rename a saved basket view */
129
+ renameBasketView: (viewId: string, name: string) => void;
130
+ /** Refresh thumbnail and viewpoint capture for a saved basket view */
131
+ refreshBasketViewThumbnail: (viewId: string, thumbnailDataUrl: string | null, viewpoint?: CameraViewpoint | null) => void;
132
+ /** Set optional transition duration for a saved basket view (ms). */
133
+ setBasketViewTransitionMs: (viewId: string, transitionMs: number | null) => void;
60
134
  }
61
135
 
62
136
  /** Convert basket EntityRefs to global IDs using model offsets */
@@ -80,6 +154,47 @@ function refToGlobalId(ref: EntityRef, models: Map<string, { idOffset: number }>
80
154
  return ref.expressId + (model?.idOffset ?? 0);
81
155
  }
82
156
 
157
+ function refsToEntityKeySet(refs: EntityRef[]): Set<string> {
158
+ const keys = new Set<string>();
159
+ for (const ref of refs) keys.add(entityRefToString(ref));
160
+ return keys;
161
+ }
162
+
163
+ function entityKeysToRefs(keys: Iterable<string>): EntityRef[] {
164
+ const refs: EntityRef[] = [];
165
+ for (const key of keys) refs.push(stringToEntityRef(key));
166
+ return refs;
167
+ }
168
+
169
+ function createViewId(): string {
170
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
171
+ return crypto.randomUUID();
172
+ }
173
+ return `basket-${Date.now()}-${Math.floor(Math.random() * 1_000_000)}`;
174
+ }
175
+
176
+ function createNextViewName(views: BasketView[]): string {
177
+ let idx = 1;
178
+ const names = new Set(views.map((v) => v.name));
179
+ while (names.has(`Basket ${idx}`)) idx++;
180
+ return `Basket ${idx}`;
181
+ }
182
+
183
+ function captureSectionSnapshot(state: PinboardCrossSliceState): BasketSectionSnapshot | null {
184
+ if (state.activeTool !== 'section' || !state.sectionPlane.enabled) {
185
+ return null;
186
+ }
187
+
188
+ return {
189
+ plane: { ...state.sectionPlane },
190
+ // Basket views restore 3D section state only. 2D drawings are derived, mutable,
191
+ // and global in store state; persisting them per-view causes cross-view leakage.
192
+ drawing2D: null,
193
+ show3DOverlay: state.drawing2DDisplayOptions.show3DOverlay,
194
+ showHiddenLines: state.drawing2DDisplayOptions.showHiddenLines,
195
+ };
196
+ }
197
+
83
198
  export const createPinboardSlice: StateCreator<
84
199
  PinboardSlice & PinboardCrossSliceState,
85
200
  [],
@@ -88,9 +203,16 @@ export const createPinboardSlice: StateCreator<
88
203
  > = (set, get) => ({
89
204
  // Initial state
90
205
  pinboardEntities: new Set(),
206
+ basketViews: [],
207
+ activeBasketViewId: null,
208
+ basketPresentationVisible: false,
209
+ hierarchyBasketSelection: new Set(),
91
210
 
92
211
  // Legacy actions (kept for backward compat, but now they also sync isolation)
93
212
  addToPinboard: (refs) => {
213
+ if (refs.length > 0) {
214
+ get().clearEntitySelection();
215
+ }
94
216
  set((state) => {
95
217
  const next = new Set<string>(state.pinboardEntities);
96
218
  for (const ref of refs) {
@@ -104,7 +226,12 @@ export const createPinboardSlice: StateCreator<
104
226
  const offset = model?.idOffset ?? 0;
105
227
  hiddenEntities.delete(ref.expressId + offset);
106
228
  }
107
- return { pinboardEntities: next, isolatedEntities, hiddenEntities };
229
+ return {
230
+ pinboardEntities: next,
231
+ isolatedEntities,
232
+ hiddenEntities,
233
+ activeBasketViewId: null,
234
+ };
108
235
  });
109
236
  },
110
237
 
@@ -115,20 +242,23 @@ export const createPinboardSlice: StateCreator<
115
242
  next.delete(entityRefToString(ref));
116
243
  }
117
244
  if (next.size === 0) {
118
- return { pinboardEntities: next, isolatedEntities: null };
245
+ return { pinboardEntities: next, isolatedEntities: null, activeBasketViewId: null };
119
246
  }
120
247
  const isolatedEntities = basketToGlobalIds(next, state.models);
121
- return { pinboardEntities: next, isolatedEntities };
248
+ return { pinboardEntities: next, isolatedEntities, activeBasketViewId: null };
122
249
  });
123
250
  },
124
251
 
125
252
  setPinboard: (refs) => {
253
+ if (refs.length > 0) {
254
+ get().clearEntitySelection();
255
+ }
126
256
  const next = new Set<string>();
127
257
  for (const ref of refs) {
128
258
  next.add(entityRefToString(ref));
129
259
  }
130
260
  if (next.size === 0) {
131
- set({ pinboardEntities: next, isolatedEntities: null });
261
+ set({ pinboardEntities: next, isolatedEntities: null, activeBasketViewId: null });
132
262
  return;
133
263
  }
134
264
  const s = get();
@@ -140,10 +270,10 @@ export const createPinboardSlice: StateCreator<
140
270
  hiddenEntities.delete(ref.expressId + offset);
141
271
  }
142
272
  const isolatedEntities = basketToGlobalIds(next, s.models);
143
- set({ pinboardEntities: next, isolatedEntities, hiddenEntities });
273
+ set({ pinboardEntities: next, isolatedEntities, hiddenEntities, activeBasketViewId: null });
144
274
  },
145
275
 
146
- clearPinboard: () => set({ pinboardEntities: new Set(), isolatedEntities: null }),
276
+ clearPinboard: () => set({ pinboardEntities: new Set(), isolatedEntities: null, activeBasketViewId: null }),
147
277
 
148
278
  showPinboard: () => {
149
279
  const state = get();
@@ -172,9 +302,10 @@ export const createPinboardSlice: StateCreator<
172
302
  /** = Set basket to exactly these entities and isolate them */
173
303
  setBasket: (refs) => {
174
304
  if (refs.length === 0) {
175
- set({ pinboardEntities: new Set(), isolatedEntities: null });
305
+ set({ pinboardEntities: new Set(), isolatedEntities: null, activeBasketViewId: null });
176
306
  return;
177
307
  }
308
+ get().clearEntitySelection();
178
309
  const next = new Set<string>();
179
310
  for (const ref of refs) {
180
311
  next.add(entityRefToString(ref));
@@ -188,12 +319,13 @@ export const createPinboardSlice: StateCreator<
188
319
  hiddenEntities.delete(ref.expressId + offset);
189
320
  }
190
321
  const isolatedEntities = basketToGlobalIds(next, s.models);
191
- set({ pinboardEntities: next, isolatedEntities, hiddenEntities });
322
+ set({ pinboardEntities: next, isolatedEntities, hiddenEntities, activeBasketViewId: null });
192
323
  },
193
324
 
194
325
  /** + Add entities to basket and update isolation (incremental — avoids re-parsing all strings) */
195
326
  addToBasket: (refs) => {
196
327
  if (refs.length === 0) return;
328
+ get().clearEntitySelection();
197
329
  set((state) => {
198
330
  const next = new Set<string>(state.pinboardEntities);
199
331
  for (const ref of refs) {
@@ -208,7 +340,7 @@ export const createPinboardSlice: StateCreator<
208
340
  isolatedEntities.add(gid);
209
341
  hiddenEntities.delete(gid);
210
342
  }
211
- return { pinboardEntities: next, isolatedEntities, hiddenEntities };
343
+ return { pinboardEntities: next, isolatedEntities, hiddenEntities, activeBasketViewId: null };
212
344
  });
213
345
  },
214
346
 
@@ -221,7 +353,7 @@ export const createPinboardSlice: StateCreator<
221
353
  next.delete(entityRefToString(ref));
222
354
  }
223
355
  if (next.size === 0) {
224
- return { pinboardEntities: next, isolatedEntities: null };
356
+ return { pinboardEntities: next, isolatedEntities: null, activeBasketViewId: null };
225
357
  }
226
358
  // Incrementally remove globalIds from existing isolation set instead of re-parsing all
227
359
  const prevIsolated = state.isolatedEntities;
@@ -230,14 +362,112 @@ export const createPinboardSlice: StateCreator<
230
362
  for (const ref of refs) {
231
363
  isolatedEntities.delete(refToGlobalId(ref, state.models));
232
364
  }
233
- return { pinboardEntities: next, isolatedEntities };
365
+ return { pinboardEntities: next, isolatedEntities, activeBasketViewId: null };
234
366
  }
235
367
  // Fallback: full recompute if no existing isolation set
236
368
  const isolatedEntities = basketToGlobalIds(next, state.models);
237
- return { pinboardEntities: next, isolatedEntities };
369
+ return { pinboardEntities: next, isolatedEntities, activeBasketViewId: null };
238
370
  });
239
371
  },
240
372
 
241
373
  /** Clear basket and clear isolation */
242
- clearBasket: () => set({ pinboardEntities: new Set(), isolatedEntities: null }),
374
+ clearBasket: () => set({ pinboardEntities: new Set(), isolatedEntities: null, activeBasketViewId: null }),
375
+
376
+ setHierarchyBasketSelection: (refs) => set({ hierarchyBasketSelection: refsToEntityKeySet(refs) }),
377
+ clearHierarchyBasketSelection: () => set({ hierarchyBasketSelection: new Set() }),
378
+
379
+ setBasketPresentationVisible: (basketPresentationVisible) => set({ basketPresentationVisible }),
380
+ toggleBasketPresentationVisible: () =>
381
+ set((state) => ({ basketPresentationVisible: !state.basketPresentationVisible })),
382
+
383
+ saveCurrentBasketView: (options) => {
384
+ const state = get();
385
+ if (state.pinboardEntities.size === 0) return null;
386
+
387
+ const id = createViewId();
388
+ const now = Date.now();
389
+ const view: BasketView = {
390
+ id,
391
+ name: options?.name?.trim() || createNextViewName(state.basketViews),
392
+ entityRefs: Array.from(state.pinboardEntities),
393
+ thumbnailDataUrl: options?.thumbnailDataUrl ?? null,
394
+ transitionMs: options?.transitionMs ?? null,
395
+ viewpoint: options?.viewpoint ?? state.cameraCallbacks.getViewpoint?.() ?? null,
396
+ section: options?.section ?? captureSectionSnapshot(state),
397
+ source: options?.source ?? 'manual',
398
+ createdAt: now,
399
+ updatedAt: now,
400
+ };
401
+
402
+ set((current) => ({
403
+ basketViews: [...current.basketViews, view],
404
+ activeBasketViewId: id,
405
+ }));
406
+ return id;
407
+ },
408
+
409
+ restoreBasketEntities: (entityRefs, viewId) => {
410
+ get().clearEntitySelection?.();
411
+ set((current) => {
412
+ const nextPinboard = new Set<string>(entityRefs);
413
+ if (nextPinboard.size === 0) {
414
+ return { pinboardEntities: new Set(), isolatedEntities: null, activeBasketViewId: viewId };
415
+ }
416
+
417
+ const hiddenEntities = new Set<number>(current.hiddenEntities);
418
+ const refs = entityKeysToRefs(nextPinboard);
419
+ for (const ref of refs) {
420
+ hiddenEntities.delete(refToGlobalId(ref, current.models));
421
+ }
422
+
423
+ return {
424
+ pinboardEntities: nextPinboard,
425
+ isolatedEntities: basketToGlobalIds(nextPinboard, current.models),
426
+ hiddenEntities,
427
+ activeBasketViewId: viewId,
428
+ };
429
+ });
430
+ },
431
+
432
+ activateBasketView: (viewId) => {
433
+ void import('../basket/basketViewActivator.js').then(({ activateBasketViewFromStore }) => {
434
+ activateBasketViewFromStore(viewId);
435
+ });
436
+ },
437
+
438
+ removeBasketView: (viewId) => {
439
+ set((state) => ({
440
+ basketViews: state.basketViews.filter((view) => view.id !== viewId),
441
+ activeBasketViewId: state.activeBasketViewId === viewId ? null : state.activeBasketViewId,
442
+ }));
443
+ },
444
+
445
+ renameBasketView: (viewId, name) => {
446
+ const nextName = name.trim();
447
+ if (!nextName) return;
448
+ set((state) => ({
449
+ basketViews: state.basketViews.map((view) =>
450
+ view.id === viewId ? { ...view, name: nextName, updatedAt: Date.now() } : view,
451
+ ),
452
+ }));
453
+ },
454
+
455
+ refreshBasketViewThumbnail: (viewId, thumbnailDataUrl, viewpoint) => {
456
+ set((state) => {
457
+ const nextViewpoint = viewpoint === undefined ? state.cameraCallbacks.getViewpoint?.() ?? null : viewpoint;
458
+ return {
459
+ basketViews: state.basketViews.map((view) =>
460
+ view.id === viewId ? { ...view, thumbnailDataUrl, viewpoint: nextViewpoint, updatedAt: Date.now() } : view,
461
+ ),
462
+ };
463
+ });
464
+ },
465
+
466
+ setBasketViewTransitionMs: (viewId, transitionMs) => {
467
+ set((state) => ({
468
+ basketViews: state.basketViews.map((view) =>
469
+ view.id === viewId ? { ...view, transitionMs, updatedAt: Date.now() } : view,
470
+ ),
471
+ }));
472
+ },
243
473
  });