@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.
- package/CHANGELOG.md +24 -0
- package/dist/assets/{Arrow.dom-Bw5JMdDs.js → Arrow.dom-IIkrrCZ0.js} +1 -1
- package/dist/assets/{browser-DdRf3aWl.js → browser-BoonPy8d.js} +1 -1
- package/dist/assets/{ifc-lite_bg-C1-gLAHo.wasm → ifc-lite_bg-B6s-pcv0.wasm} +0 -0
- package/dist/assets/{index-1ff6P0kc.js → index-CQkEOlYf.js} +40975 -40044
- package/dist/assets/{index-Bz7vHRxl.js → index-ClZCG7KA.js} +4 -4
- package/dist/assets/index-qxIHWl_B.css +1 -0
- package/dist/assets/{native-bridge-C5hD5vae.js → native-bridge-Beg4Kf9O.js} +1 -1
- package/dist/assets/{wasm-bridge-CaNKXFGM.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 +58 -104
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/ViewportOverlays.tsx +9 -3
- package/src/components/viewer/useKeyboardControls.ts +2 -2
- package/src/components/viewer/useRenderUpdates.ts +10 -3
- package/src/hooks/useKeyboardShortcuts.ts +51 -84
- 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/homeView.ts +21 -0
- package/src/store/index.ts +7 -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/types.ts +11 -0
- package/dist/assets/index-mvbV6NHd.css +0 -1
|
@@ -7,21 +7,20 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { useEffect, useCallback } from 'react';
|
|
10
|
-
import { useViewerStore
|
|
11
|
-
import
|
|
10
|
+
import { useViewerStore } from '@/store';
|
|
11
|
+
import { resetVisibilityForHomeFromStore } from '@/store/homeView';
|
|
12
|
+
import {
|
|
13
|
+
executeBasketIsolate,
|
|
14
|
+
executeBasketSet,
|
|
15
|
+
executeBasketAdd,
|
|
16
|
+
executeBasketRemove,
|
|
17
|
+
executeBasketSaveView,
|
|
18
|
+
} from '@/store/basket/basketCommands';
|
|
12
19
|
|
|
13
20
|
interface KeyboardShortcutsOptions {
|
|
14
21
|
enabled?: boolean;
|
|
15
22
|
}
|
|
16
23
|
|
|
17
|
-
/** Clear multi-select state so subsequent operations use single-entity selectedEntity */
|
|
18
|
-
function clearMultiSelect(): void {
|
|
19
|
-
const state = useViewerStore.getState();
|
|
20
|
-
if (state.selectedEntitiesSet.size > 0) {
|
|
21
|
-
useViewerStore.setState({ selectedEntitiesSet: new Set(), selectedEntityIds: new Set() });
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
24
|
/** Get all selected global IDs — multi-select if available, else single selectedEntityId */
|
|
26
25
|
function getAllSelectedGlobalIds(): number[] {
|
|
27
26
|
const state = useViewerStore.getState();
|
|
@@ -34,22 +33,6 @@ function getAllSelectedGlobalIds(): number[] {
|
|
|
34
33
|
return [];
|
|
35
34
|
}
|
|
36
35
|
|
|
37
|
-
/** Get current selection as EntityRef[] — multi-select if available, else single */
|
|
38
|
-
function getSelectionRefsFromStore(): EntityRef[] {
|
|
39
|
-
const state = useViewerStore.getState();
|
|
40
|
-
if (state.selectedEntitiesSet.size > 0) {
|
|
41
|
-
const refs: EntityRef[] = [];
|
|
42
|
-
for (const str of state.selectedEntitiesSet) {
|
|
43
|
-
refs.push(stringToEntityRef(str));
|
|
44
|
-
}
|
|
45
|
-
return refs;
|
|
46
|
-
}
|
|
47
|
-
if (state.selectedEntity) {
|
|
48
|
-
return [state.selectedEntity];
|
|
49
|
-
}
|
|
50
|
-
return [];
|
|
51
|
-
}
|
|
52
|
-
|
|
53
36
|
export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
54
37
|
const { enabled = true } = options;
|
|
55
38
|
|
|
@@ -58,16 +41,8 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
58
41
|
const activeTool = useViewerStore((s) => s.activeTool);
|
|
59
42
|
const setActiveTool = useViewerStore((s) => s.setActiveTool);
|
|
60
43
|
const hideEntities = useViewerStore((s) => s.hideEntities);
|
|
61
|
-
const showAll = useViewerStore((s) => s.showAll);
|
|
62
|
-
const clearStoreySelection = useViewerStore((s) => s.clearStoreySelection);
|
|
63
44
|
const toggleTheme = useViewerStore((s) => s.toggleTheme);
|
|
64
|
-
|
|
65
|
-
// Basket actions
|
|
66
|
-
const setBasket = useViewerStore((s) => s.setBasket);
|
|
67
|
-
const addToBasket = useViewerStore((s) => s.addToBasket);
|
|
68
|
-
const removeFromBasket = useViewerStore((s) => s.removeFromBasket);
|
|
69
|
-
const clearBasket = useViewerStore((s) => s.clearBasket);
|
|
70
|
-
const showPinboard = useViewerStore((s) => s.showPinboard);
|
|
45
|
+
const toggleBasketPresentationVisible = useViewerStore((s) => s.toggleBasketPresentationVisible);
|
|
71
46
|
|
|
72
47
|
// Measure tool specific actions
|
|
73
48
|
const activeMeasurement = useViewerStore((s) => s.activeMeasurement);
|
|
@@ -117,45 +92,45 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
117
92
|
setActiveTool('section');
|
|
118
93
|
}
|
|
119
94
|
|
|
120
|
-
// Basket
|
|
121
|
-
// I =
|
|
95
|
+
// Basket controls (automatic context source)
|
|
96
|
+
// I = Isolate from current context
|
|
122
97
|
if (key === 'i' && !ctrl && !shift) {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
// re-apply the basket instead of replacing it with a stale single selection.
|
|
126
|
-
if (state.pinboardEntities.size > 0 && state.selectedEntitiesSet.size === 0) {
|
|
127
|
-
e.preventDefault();
|
|
128
|
-
showPinboard();
|
|
129
|
-
} else {
|
|
130
|
-
const refs = getSelectionRefsFromStore();
|
|
131
|
-
if (refs.length > 0) {
|
|
132
|
-
e.preventDefault();
|
|
133
|
-
setBasket(refs);
|
|
134
|
-
// Consume multi-select so subsequent − removes a single entity
|
|
135
|
-
clearMultiSelect();
|
|
136
|
-
}
|
|
137
|
-
}
|
|
98
|
+
e.preventDefault();
|
|
99
|
+
executeBasketIsolate();
|
|
138
100
|
}
|
|
139
101
|
|
|
140
|
-
//
|
|
102
|
+
// = Set basket from active context
|
|
103
|
+
if (e.key === '=' && !ctrl && !shift) {
|
|
104
|
+
e.preventDefault();
|
|
105
|
+
executeBasketSet();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// + Add active context to basket
|
|
141
109
|
if ((e.key === '+' || (e.key === '=' && shift)) && !ctrl) {
|
|
142
110
|
e.preventDefault();
|
|
143
|
-
|
|
144
|
-
if (refs.length > 0) {
|
|
145
|
-
addToBasket(refs);
|
|
146
|
-
// Consume multi-select so subsequent − removes a single entity
|
|
147
|
-
clearMultiSelect();
|
|
148
|
-
}
|
|
111
|
+
executeBasketAdd();
|
|
149
112
|
}
|
|
150
113
|
|
|
151
|
-
// -
|
|
114
|
+
// - Remove active context from basket
|
|
152
115
|
if ((e.key === '-' || e.key === '_') && !ctrl) {
|
|
153
116
|
e.preventDefault();
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
117
|
+
executeBasketRemove();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// D Toggle basket presentation dock
|
|
121
|
+
if (key === 'd' && !ctrl && !shift) {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
toggleBasketPresentationVisible();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// B Save current basket as presentation view with thumbnail
|
|
127
|
+
if (key === 'b' && !ctrl && !shift) {
|
|
128
|
+
const state = useViewerStore.getState();
|
|
129
|
+
if (state.pinboardEntities.size > 0) {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
executeBasketSaveView().catch((err) => {
|
|
132
|
+
console.error('[useKeyboardShortcuts] Failed to save basket view:', err);
|
|
133
|
+
});
|
|
159
134
|
}
|
|
160
135
|
}
|
|
161
136
|
|
|
@@ -163,7 +138,6 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
163
138
|
e.preventDefault();
|
|
164
139
|
const ids = getAllSelectedGlobalIds();
|
|
165
140
|
hideEntities(ids);
|
|
166
|
-
clearMultiSelect();
|
|
167
141
|
}
|
|
168
142
|
// Space to hide — skip when focused on buttons/selects/links where Space has native behavior
|
|
169
143
|
if (key === ' ' && !ctrl && !shift && selectedEntityId) {
|
|
@@ -172,13 +146,11 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
172
146
|
e.preventDefault();
|
|
173
147
|
const ids = getAllSelectedGlobalIds();
|
|
174
148
|
hideEntities(ids);
|
|
175
|
-
clearMultiSelect();
|
|
176
149
|
}
|
|
177
150
|
}
|
|
178
151
|
if (key === 'a' && !ctrl && !shift) {
|
|
179
152
|
e.preventDefault();
|
|
180
|
-
|
|
181
|
-
clearStoreySelection(); // Also clear storey filtering
|
|
153
|
+
resetVisibilityForHomeFromStore();
|
|
182
154
|
}
|
|
183
155
|
|
|
184
156
|
// Measure tool shortcuts
|
|
@@ -213,9 +185,7 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
213
185
|
if (key === 'escape') {
|
|
214
186
|
e.preventDefault();
|
|
215
187
|
setSelectedEntityId(null);
|
|
216
|
-
|
|
217
|
-
showAll();
|
|
218
|
-
clearStoreySelection(); // Also clear storey filtering
|
|
188
|
+
resetVisibilityForHomeFromStore();
|
|
219
189
|
setActiveTool('select');
|
|
220
190
|
}
|
|
221
191
|
|
|
@@ -232,15 +202,9 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
232
202
|
setSelectedEntityId,
|
|
233
203
|
activeTool,
|
|
234
204
|
setActiveTool,
|
|
235
|
-
setBasket,
|
|
236
|
-
addToBasket,
|
|
237
|
-
removeFromBasket,
|
|
238
|
-
clearBasket,
|
|
239
|
-
showPinboard,
|
|
240
205
|
hideEntities,
|
|
241
|
-
showAll,
|
|
242
|
-
clearStoreySelection,
|
|
243
206
|
toggleTheme,
|
|
207
|
+
toggleBasketPresentationVisible,
|
|
244
208
|
activeMeasurement,
|
|
245
209
|
cancelMeasurement,
|
|
246
210
|
clearMeasurements,
|
|
@@ -268,12 +232,15 @@ export const KEYBOARD_SHORTCUTS = [
|
|
|
268
232
|
{ key: 'S', description: 'Toggle snapping (Measure tool)', category: 'Tools' },
|
|
269
233
|
{ key: 'Esc', description: 'Cancel measurement (Measure tool)', category: 'Tools' },
|
|
270
234
|
{ key: 'Ctrl+C', description: 'Clear measurements (Measure tool)', category: 'Tools' },
|
|
271
|
-
{ key: 'I', description: '
|
|
272
|
-
{ key: '
|
|
273
|
-
{ key: '
|
|
235
|
+
{ key: 'I', description: 'Isolate (set basket from current context)', category: 'Visibility' },
|
|
236
|
+
{ key: '=', description: 'Set basket from current context', category: 'Visibility' },
|
|
237
|
+
{ key: '+', description: 'Add current context to basket', category: 'Visibility' },
|
|
238
|
+
{ key: '−', description: 'Remove current context from basket', category: 'Visibility' },
|
|
239
|
+
{ key: 'D', description: 'Toggle basket presentation dock', category: 'Visibility' },
|
|
240
|
+
{ key: 'B', description: 'Save basket as presentation view', category: 'Visibility' },
|
|
274
241
|
{ key: 'Del / Space', description: 'Hide selection', category: 'Visibility' },
|
|
275
|
-
{ key: 'A', description: 'Show all (clear filters
|
|
276
|
-
{ key: 'H', description: 'Home (
|
|
242
|
+
{ key: 'A', description: 'Show all (clear filters and basket)', category: 'Visibility' },
|
|
243
|
+
{ key: 'H', description: 'Home (isometric + reset visibility)', category: 'Camera' },
|
|
277
244
|
{ key: 'Z', description: 'Fit all (zoom extents)', category: 'Camera' },
|
|
278
245
|
{ key: 'F', description: 'Frame selection', category: 'Camera' },
|
|
279
246
|
{ key: '1-6', description: 'Preset views', category: 'Camera' },
|
|
@@ -0,0 +1,81 @@
|
|
|
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 type { EntityRef } from '../types.js';
|
|
6
|
+
import { useViewerStore } from '../index.js';
|
|
7
|
+
import { saveBasketViewWithThumbnailFromStore } from '../basketSave.js';
|
|
8
|
+
import {
|
|
9
|
+
getSmartBasketInputFromStore,
|
|
10
|
+
isBasketIsolationActiveFromStore,
|
|
11
|
+
} from '../basketVisibleSet.js';
|
|
12
|
+
|
|
13
|
+
type BasketViewSource = 'selection' | 'visible' | 'hierarchy' | 'manual';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve basket refs: smart input first, optional context entity as fallback.
|
|
17
|
+
*/
|
|
18
|
+
function getBasketRefs(contextEntityRef?: EntityRef | null): EntityRef[] {
|
|
19
|
+
const { refs } = getSmartBasketInputFromStore();
|
|
20
|
+
if (refs.length > 0) return refs;
|
|
21
|
+
if (contextEntityRef) return [contextEntityRef];
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function executeBasketSet(contextEntityRef?: EntityRef | null): void {
|
|
26
|
+
const refs = getBasketRefs(contextEntityRef);
|
|
27
|
+
if (refs.length > 0) {
|
|
28
|
+
useViewerStore.getState().setBasket(refs);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Isolate: set basket from context if refs available, else re-show existing basket.
|
|
34
|
+
*/
|
|
35
|
+
export function executeBasketIsolate(contextEntityRef?: EntityRef | null): void {
|
|
36
|
+
const refs = getBasketRefs(contextEntityRef);
|
|
37
|
+
if (refs.length > 0) {
|
|
38
|
+
useViewerStore.getState().setBasket(refs);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const state = useViewerStore.getState();
|
|
42
|
+
if (state.pinboardEntities.size > 0) {
|
|
43
|
+
state.showPinboard();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function executeBasketAdd(contextEntityRef?: EntityRef | null): void {
|
|
48
|
+
const refs = getBasketRefs(contextEntityRef);
|
|
49
|
+
if (refs.length > 0) {
|
|
50
|
+
useViewerStore.getState().addToBasket(refs);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function executeBasketRemove(contextEntityRef?: EntityRef | null): void {
|
|
55
|
+
const refs = getBasketRefs(contextEntityRef);
|
|
56
|
+
if (refs.length > 0) {
|
|
57
|
+
useViewerStore.getState().removeFromBasket(refs);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function executeBasketToggleVisibility(): void {
|
|
62
|
+
const state = useViewerStore.getState();
|
|
63
|
+
if (state.pinboardEntities.size === 0) return;
|
|
64
|
+
if (isBasketIsolationActiveFromStore()) {
|
|
65
|
+
state.clearIsolation();
|
|
66
|
+
} else {
|
|
67
|
+
state.showPinboard();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function executeBasketSaveView(source: BasketViewSource = 'manual'): Promise<string | null> {
|
|
72
|
+
const state = useViewerStore.getState();
|
|
73
|
+
if (state.pinboardEntities.size === 0) return null;
|
|
74
|
+
const id = await saveBasketViewWithThumbnailFromStore(source);
|
|
75
|
+
state.setBasketPresentationVisible(true);
|
|
76
|
+
return id;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function executeBasketClear(): void {
|
|
80
|
+
useViewerStore.getState().clearBasket();
|
|
81
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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 { useViewerStore } from '../index.js';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TRANSITION_MS = 700;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Coordinator for activating a saved basket view.
|
|
11
|
+
* Owns camera + section + drawing side effects; delegates entity/isolation to pinboard.
|
|
12
|
+
*/
|
|
13
|
+
export function activateBasketViewFromStore(viewId: string): void {
|
|
14
|
+
const state = useViewerStore.getState();
|
|
15
|
+
const view = state.basketViews.find((v) => v.id === viewId);
|
|
16
|
+
if (!view) return;
|
|
17
|
+
|
|
18
|
+
// Basket activation must never restore or keep 2D profile overlays.
|
|
19
|
+
// Basket views should only affect 3D model geometry visibility/sectioning.
|
|
20
|
+
state.setDrawing2D(null);
|
|
21
|
+
state.setDrawing2DPanelVisible(false);
|
|
22
|
+
state.updateDrawing2DDisplayOptions({ show3DOverlay: false });
|
|
23
|
+
|
|
24
|
+
state.clearEntitySelection();
|
|
25
|
+
state.restoreBasketEntities(view.entityRefs, viewId);
|
|
26
|
+
|
|
27
|
+
if (view.viewpoint) {
|
|
28
|
+
const transitionMs = view.transitionMs ?? DEFAULT_TRANSITION_MS;
|
|
29
|
+
state.cameraCallbacks.applyViewpoint?.(view.viewpoint, true, transitionMs);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (view.section) {
|
|
33
|
+
const sectionSnapshot = view.section;
|
|
34
|
+
useViewerStore.setState({
|
|
35
|
+
sectionPlane: { ...sectionSnapshot.plane },
|
|
36
|
+
drawing2DPanelVisible: false,
|
|
37
|
+
});
|
|
38
|
+
if (sectionSnapshot.plane.enabled) {
|
|
39
|
+
if (state.activeTool !== 'section') {
|
|
40
|
+
state.setSuppressNextSection2DPanelAutoOpen(true);
|
|
41
|
+
}
|
|
42
|
+
state.setActiveTool('section');
|
|
43
|
+
} else if (state.activeTool === 'section') {
|
|
44
|
+
state.setActiveTool('select');
|
|
45
|
+
}
|
|
46
|
+
} else {
|
|
47
|
+
// This view has no section snapshot: ensure previously active cutting is cleared.
|
|
48
|
+
const current = useViewerStore.getState().sectionPlane;
|
|
49
|
+
useViewerStore.setState({ sectionPlane: { ...current, enabled: false } });
|
|
50
|
+
if (state.activeTool === 'section') {
|
|
51
|
+
state.setActiveTool('select');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
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 { useViewerStore } from './index.js';
|
|
6
|
+
import { getGlobalRenderer } from '../hooks/useBCF.js';
|
|
7
|
+
|
|
8
|
+
type BasketViewSource = 'selection' | 'visible' | 'hierarchy' | 'manual';
|
|
9
|
+
|
|
10
|
+
interface SelectionSnapshot {
|
|
11
|
+
selectedEntityId: number | null;
|
|
12
|
+
selectedEntityIds: Set<number>;
|
|
13
|
+
selectedEntity: ReturnType<typeof useViewerStore.getState>['selectedEntity'];
|
|
14
|
+
selectedEntitiesSet: Set<string>;
|
|
15
|
+
selectedEntities: ReturnType<typeof useViewerStore.getState>['selectedEntities'];
|
|
16
|
+
selectedModelId: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hasSelection(snapshot: SelectionSnapshot): boolean {
|
|
20
|
+
return (
|
|
21
|
+
snapshot.selectedEntityId !== null ||
|
|
22
|
+
snapshot.selectedEntityIds.size > 0 ||
|
|
23
|
+
snapshot.selectedEntity !== null ||
|
|
24
|
+
snapshot.selectedEntitiesSet.size > 0 ||
|
|
25
|
+
snapshot.selectedEntities.length > 0
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function snapshotSelectionState(): SelectionSnapshot {
|
|
30
|
+
const state = useViewerStore.getState();
|
|
31
|
+
return {
|
|
32
|
+
selectedEntityId: state.selectedEntityId,
|
|
33
|
+
selectedEntityIds: new Set(state.selectedEntityIds),
|
|
34
|
+
selectedEntity: state.selectedEntity ? { ...state.selectedEntity } : null,
|
|
35
|
+
selectedEntitiesSet: new Set(state.selectedEntitiesSet),
|
|
36
|
+
selectedEntities: state.selectedEntities.map((ref) => ({ ...ref })),
|
|
37
|
+
selectedModelId: state.selectedModelId,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function restoreSelectionState(snapshot: SelectionSnapshot): void {
|
|
42
|
+
useViewerStore.setState({
|
|
43
|
+
selectedEntityId: snapshot.selectedEntityId,
|
|
44
|
+
selectedEntityIds: new Set(snapshot.selectedEntityIds),
|
|
45
|
+
selectedEntity: snapshot.selectedEntity ? { ...snapshot.selectedEntity } : null,
|
|
46
|
+
selectedEntitiesSet: new Set(snapshot.selectedEntitiesSet),
|
|
47
|
+
selectedEntities: snapshot.selectedEntities.map((ref) => ({ ...ref })),
|
|
48
|
+
selectedModelId: snapshot.selectedModelId,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function captureCanvasThumbnail(): Promise<string | null> {
|
|
53
|
+
const src = document.querySelector('canvas[data-viewport="main"]') as HTMLCanvasElement | null;
|
|
54
|
+
if (!src) return null;
|
|
55
|
+
|
|
56
|
+
// Ensure submitted GPU work is complete before sampling the canvas.
|
|
57
|
+
const renderer = getGlobalRenderer();
|
|
58
|
+
const device = renderer?.getGPUDevice();
|
|
59
|
+
if (device) {
|
|
60
|
+
await device.queue.onSubmittedWorkDone();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
await new Promise<void>((resolve) =>
|
|
64
|
+
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
// Capture from the WebGPU canvas first (reliable), then downscale.
|
|
69
|
+
const fullFrameDataUrl = src.toDataURL('image/png');
|
|
70
|
+
|
|
71
|
+
const thumb = document.createElement('canvas');
|
|
72
|
+
thumb.width = 320;
|
|
73
|
+
thumb.height = 180;
|
|
74
|
+
const ctx = thumb.getContext('2d');
|
|
75
|
+
if (!ctx) return fullFrameDataUrl;
|
|
76
|
+
|
|
77
|
+
// Preserve viewport aspect ratio while filling thumbnail bounds (crop, no stretch).
|
|
78
|
+
ctx.fillStyle = '#0f0f12';
|
|
79
|
+
ctx.fillRect(0, 0, thumb.width, thumb.height);
|
|
80
|
+
|
|
81
|
+
const img = new Image();
|
|
82
|
+
await new Promise<void>((resolve, reject) => {
|
|
83
|
+
img.onload = () => resolve();
|
|
84
|
+
img.onerror = () => reject(new Error('Failed to decode snapshot image'));
|
|
85
|
+
img.src = fullFrameDataUrl;
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const srcW = img.naturalWidth || src.width || src.clientWidth;
|
|
89
|
+
const srcH = img.naturalHeight || src.height || src.clientHeight;
|
|
90
|
+
if (srcW <= 0 || srcH <= 0) return null;
|
|
91
|
+
|
|
92
|
+
const scale = Math.max(thumb.width / srcW, thumb.height / srcH);
|
|
93
|
+
const drawW = Math.round(srcW * scale);
|
|
94
|
+
const drawH = Math.round(srcH * scale);
|
|
95
|
+
const offsetX = Math.floor((thumb.width - drawW) / 2);
|
|
96
|
+
const offsetY = Math.floor((thumb.height - drawH) / 2);
|
|
97
|
+
ctx.drawImage(img, offsetX, offsetY, drawW, drawH);
|
|
98
|
+
return thumb.toDataURL('image/jpeg', 0.75);
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function saveBasketViewWithThumbnailFromStore(
|
|
105
|
+
source: BasketViewSource = 'manual',
|
|
106
|
+
): Promise<string | null> {
|
|
107
|
+
const before = snapshotSelectionState();
|
|
108
|
+
const hadSelection = hasSelection(before);
|
|
109
|
+
|
|
110
|
+
if (hadSelection) {
|
|
111
|
+
useViewerStore.getState().clearEntitySelection();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
try {
|
|
115
|
+
const thumbnailDataUrl = await captureCanvasThumbnail();
|
|
116
|
+
return useViewerStore.getState().saveCurrentBasketView({ source, thumbnailDataUrl });
|
|
117
|
+
} finally {
|
|
118
|
+
if (hadSelection) {
|
|
119
|
+
restoreSelectionState(before);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
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 { useViewerStore } from './index.js';
|
|
8
|
+
import {
|
|
9
|
+
getSmartBasketInputFromStore,
|
|
10
|
+
getBasketSelectionRefsFromStore,
|
|
11
|
+
getVisibleBasketEntityRefsFromStore,
|
|
12
|
+
isBasketIsolationActiveFromStore,
|
|
13
|
+
invalidateVisibleBasketCache,
|
|
14
|
+
} from './basketVisibleSet.js';
|
|
15
|
+
import { entityRefToString } from './types.js';
|
|
16
|
+
|
|
17
|
+
describe('basketVisibleSet', () => {
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
invalidateVisibleBasketCache();
|
|
20
|
+
useViewerStore.getState().resetViewerState();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe('source priority', () => {
|
|
24
|
+
it('returns selection refs when selectedEntitiesSet has items', () => {
|
|
25
|
+
useViewerStore.setState({
|
|
26
|
+
selectedEntitiesSet: new Set(['legacy:100', 'legacy:200']),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const result = getSmartBasketInputFromStore();
|
|
30
|
+
assert.strictEqual(result.source, 'selection');
|
|
31
|
+
assert.strictEqual(result.refs.length, 2);
|
|
32
|
+
assert.ok(result.refs.some((r) => entityRefToString(r) === 'legacy:100'));
|
|
33
|
+
assert.ok(result.refs.some((r) => entityRefToString(r) === 'legacy:200'));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('returns hierarchy refs when hierarchyBasketSelection has items and no selection', () => {
|
|
37
|
+
useViewerStore.setState({
|
|
38
|
+
selectedEntitiesSet: new Set(),
|
|
39
|
+
selectedEntity: null,
|
|
40
|
+
selectedEntityIds: new Set(),
|
|
41
|
+
hierarchyBasketSelection: new Set(['legacy:300']),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const result = getSmartBasketInputFromStore();
|
|
45
|
+
assert.strictEqual(result.source, 'hierarchy');
|
|
46
|
+
assert.ok(result.refs.length >= 1);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('returns visible refs when only geometry is available', () => {
|
|
50
|
+
useViewerStore.setState({
|
|
51
|
+
selectedEntitiesSet: new Set(),
|
|
52
|
+
selectedEntity: null,
|
|
53
|
+
selectedEntityIds: new Set(),
|
|
54
|
+
hierarchyBasketSelection: new Set(),
|
|
55
|
+
geometryResult: {
|
|
56
|
+
meshes: [
|
|
57
|
+
{ expressId: 1, ifcType: 'IfcWall' },
|
|
58
|
+
{ expressId: 2, ifcType: 'IfcSlab' },
|
|
59
|
+
],
|
|
60
|
+
} as any,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const result = getSmartBasketInputFromStore();
|
|
64
|
+
assert.ok(result.source === 'visible' || result.source === 'empty');
|
|
65
|
+
if (result.source === 'visible') {
|
|
66
|
+
assert.ok(result.refs.length >= 1);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('returns empty when no source has refs', () => {
|
|
71
|
+
useViewerStore.setState({
|
|
72
|
+
selectedEntitiesSet: new Set(),
|
|
73
|
+
selectedEntity: null,
|
|
74
|
+
selectedEntityIds: new Set(),
|
|
75
|
+
hierarchyBasketSelection: new Set(),
|
|
76
|
+
geometryResult: null,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const result = getSmartBasketInputFromStore();
|
|
80
|
+
assert.strictEqual(result.source, 'empty');
|
|
81
|
+
assert.strictEqual(result.refs.length, 0);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('isBasketIsolationActiveFromStore', () => {
|
|
86
|
+
it('returns true when isolated equals basket', () => {
|
|
87
|
+
useViewerStore.setState({
|
|
88
|
+
pinboardEntities: new Set(['legacy:100', 'legacy:200']),
|
|
89
|
+
isolatedEntities: new Set([100, 200]),
|
|
90
|
+
models: new Map(),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
assert.strictEqual(isBasketIsolationActiveFromStore(), true);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('returns false when pinboard is empty', () => {
|
|
97
|
+
useViewerStore.setState({
|
|
98
|
+
pinboardEntities: new Set(),
|
|
99
|
+
isolatedEntities: new Set([100]),
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
assert.strictEqual(isBasketIsolationActiveFromStore(), false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('returns false when isolated is null', () => {
|
|
106
|
+
useViewerStore.setState({
|
|
107
|
+
pinboardEntities: new Set(['legacy:100']),
|
|
108
|
+
isolatedEntities: null,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
assert.strictEqual(isBasketIsolationActiveFromStore(), false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('returns false when isolated size differs from basket', () => {
|
|
115
|
+
useViewerStore.setState({
|
|
116
|
+
pinboardEntities: new Set(['legacy:100', 'legacy:200']),
|
|
117
|
+
isolatedEntities: new Set([100]),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
assert.strictEqual(isBasketIsolationActiveFromStore(), false);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe('visibility cache', () => {
|
|
125
|
+
it('invalidateVisibleBasketCache clears cache', () => {
|
|
126
|
+
useViewerStore.setState({
|
|
127
|
+
geometryResult: { meshes: [{ expressId: 1, ifcType: 'IfcWall' }] } as any,
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const first = getVisibleBasketEntityRefsFromStore();
|
|
131
|
+
invalidateVisibleBasketCache();
|
|
132
|
+
const second = getVisibleBasketEntityRefsFromStore();
|
|
133
|
+
|
|
134
|
+
assert.deepStrictEqual(first, second);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('returns consistent result on repeated calls with same state', () => {
|
|
138
|
+
useViewerStore.setState({
|
|
139
|
+
geometryResult: { meshes: [{ expressId: 1, ifcType: 'IfcWall' }] } as any,
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
const a = getVisibleBasketEntityRefsFromStore();
|
|
143
|
+
const b = getVisibleBasketEntityRefsFromStore();
|
|
144
|
+
|
|
145
|
+
assert.deepStrictEqual(a, b);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('federation: unresolved globalId in multi-model', () => {
|
|
150
|
+
it('getBasketSelectionRefsFromStore returns array when models exist', () => {
|
|
151
|
+
useViewerStore.setState({
|
|
152
|
+
selectedEntityIds: new Set([99999]),
|
|
153
|
+
selectedEntitiesSet: new Set(),
|
|
154
|
+
selectedEntity: null,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
const refs = getBasketSelectionRefsFromStore();
|
|
158
|
+
assert.ok(Array.isArray(refs));
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
});
|