@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
package/src/store/index.ts
CHANGED
|
@@ -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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
});
|