@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
@@ -7,21 +7,20 @@
7
7
  */
8
8
 
9
9
  import { useEffect, useCallback } from 'react';
10
- import { useViewerStore, stringToEntityRef } from '@/store';
11
- import type { EntityRef } from '@/store';
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 / Visibility controls
121
- // I = Set basket (isolate selection as basket), or re-apply basket if no selection
95
+ // Basket controls (automatic context source)
96
+ // I = Isolate from current context
122
97
  if (key === 'i' && !ctrl && !shift) {
123
- const state = useViewerStore.getState();
124
- // If basket already exists and user hasn't explicitly multi-selected,
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
- // + or = (with shift) = Add to basket
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
- const refs = getSelectionRefsFromStore();
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
- // - or _ = Remove from basket
114
+ // - Remove active context from basket
152
115
  if ((e.key === '-' || e.key === '_') && !ctrl) {
153
116
  e.preventDefault();
154
- const refs = getSelectionRefsFromStore();
155
- if (refs.length > 0) {
156
- removeFromBasket(refs);
157
- // Consume multi-select after removal
158
- clearMultiSelect();
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
- showAll(); // Clear hiddenEntities + isolatedEntities (basket preserved)
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
- clearBasket();
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: 'Set basket (isolate selection)', category: 'Visibility' },
272
- { key: '+', description: 'Add selection to basket', category: 'Visibility' },
273
- { key: '', description: 'Remove selection from basket', category: 'Visibility' },
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, keep basket)', category: 'Visibility' },
276
- { key: 'H', description: 'Home (Isometric view)', category: 'Camera' },
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
+ });