@ifc-lite/viewer 1.10.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 (35) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/assets/{Arrow.dom-Bw5JMdDs.js → Arrow.dom-IIkrrCZ0.js} +1 -1
  3. package/dist/assets/{browser-DdRf3aWl.js → browser-BoonPy8d.js} +1 -1
  4. package/dist/assets/{ifc-lite_bg-C1-gLAHo.wasm → ifc-lite_bg-B6s-pcv0.wasm} +0 -0
  5. package/dist/assets/{index-1ff6P0kc.js → index-CQkEOlYf.js} +40975 -40044
  6. package/dist/assets/{index-Bz7vHRxl.js → index-ClZCG7KA.js} +4 -4
  7. package/dist/assets/index-qxIHWl_B.css +1 -0
  8. package/dist/assets/{native-bridge-C5hD5vae.js → native-bridge-Beg4Kf9O.js} +1 -1
  9. package/dist/assets/{wasm-bridge-CaNKXFGM.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 +58 -104
  19. package/src/components/viewer/ViewportContainer.tsx +2 -0
  20. package/src/components/viewer/ViewportOverlays.tsx +9 -3
  21. package/src/components/viewer/useKeyboardControls.ts +2 -2
  22. package/src/components/viewer/useRenderUpdates.ts +10 -3
  23. package/src/hooks/useKeyboardShortcuts.ts +51 -84
  24. package/src/store/basket/basketCommands.ts +81 -0
  25. package/src/store/basket/basketViewActivator.ts +54 -0
  26. package/src/store/basketSave.ts +122 -0
  27. package/src/store/basketVisibleSet.test.ts +161 -0
  28. package/src/store/basketVisibleSet.ts +487 -0
  29. package/src/store/homeView.ts +21 -0
  30. package/src/store/index.ts +7 -0
  31. package/src/store/slices/drawing2DSlice.ts +5 -0
  32. package/src/store/slices/pinboardSlice.test.ts +160 -0
  33. package/src/store/slices/pinboardSlice.ts +248 -18
  34. package/src/store/types.ts +11 -0
  35. package/dist/assets/index-mvbV6NHd.css +0 -1
@@ -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
  });
@@ -151,6 +151,15 @@ export interface CameraRotation {
151
151
 
152
152
  export type ProjectionMode = 'perspective' | 'orthographic';
153
153
 
154
+ export interface CameraViewpoint {
155
+ position: { x: number; y: number; z: number };
156
+ target: { x: number; y: number; z: number };
157
+ up: { x: number; y: number; z: number };
158
+ fov: number;
159
+ projectionMode: ProjectionMode;
160
+ orthoSize?: number;
161
+ }
162
+
154
163
  export interface CameraCallbacks {
155
164
  setPresetView?: (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right') => void;
156
165
  fitAll?: () => void;
@@ -163,6 +172,8 @@ export interface CameraCallbacks {
163
172
  setProjectionMode?: (mode: ProjectionMode) => void;
164
173
  toggleProjectionMode?: () => void;
165
174
  getProjectionMode?: () => ProjectionMode;
175
+ getViewpoint?: () => CameraViewpoint | null;
176
+ applyViewpoint?: (viewpoint: CameraViewpoint, animate?: boolean, durationMs?: number) => void;
166
177
  }
167
178
 
168
179
  // ============================================================================