@ifc-lite/viewer 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/assets/{Arrow.dom-CusgkT03.js → Arrow.dom-IIkrrCZ0.js} +1 -1
  3. package/dist/assets/{browser-BXNIkE8a.js → browser-BoonPy8d.js} +1 -1
  4. package/dist/assets/ifc-lite_bg-B6s-pcv0.wasm +0 -0
  5. package/dist/assets/{index-huvR-kGC.js → index-CQkEOlYf.js} +49090 -46453
  6. package/dist/assets/{index-6Mr3byM-.js → index-ClZCG7KA.js} +4 -4
  7. package/dist/assets/index-qxIHWl_B.css +1 -0
  8. package/dist/assets/{native-bridge-DsHOKdgD.js → native-bridge-Beg4Kf9O.js} +1 -1
  9. package/dist/assets/{wasm-bridge-Bd73HXn-.js → wasm-bridge-CY8jkr7u.js} +1 -1
  10. package/dist/index.html +2 -2
  11. package/package.json +19 -19
  12. package/src/components/viewer/BasketPresentationDock.tsx +422 -0
  13. package/src/components/viewer/CommandPalette.tsx +29 -32
  14. package/src/components/viewer/EntityContextMenu.tsx +37 -22
  15. package/src/components/viewer/HierarchyPanel.tsx +19 -1
  16. package/src/components/viewer/MainToolbar.tsx +32 -89
  17. package/src/components/viewer/Section2DPanel.tsx +8 -1
  18. package/src/components/viewer/Viewport.tsx +107 -98
  19. package/src/components/viewer/ViewportContainer.tsx +2 -0
  20. package/src/components/viewer/ViewportOverlays.tsx +9 -3
  21. package/src/components/viewer/hierarchy/treeDataBuilder.ts +3 -1
  22. package/src/components/viewer/useAnimationLoop.ts +4 -1
  23. package/src/components/viewer/useKeyboardControls.ts +2 -2
  24. package/src/components/viewer/useRenderUpdates.ts +16 -4
  25. package/src/hooks/useKeyboardShortcuts.ts +51 -84
  26. package/src/hooks/useViewerSelectors.ts +22 -0
  27. package/src/index.css +6 -0
  28. package/src/store/basket/basketCommands.ts +81 -0
  29. package/src/store/basket/basketViewActivator.ts +54 -0
  30. package/src/store/basketSave.ts +122 -0
  31. package/src/store/basketVisibleSet.test.ts +161 -0
  32. package/src/store/basketVisibleSet.ts +487 -0
  33. package/src/store/constants.ts +20 -0
  34. package/src/store/homeView.ts +21 -0
  35. package/src/store/index.ts +17 -0
  36. package/src/store/slices/drawing2DSlice.ts +5 -0
  37. package/src/store/slices/pinboardSlice.test.ts +160 -0
  38. package/src/store/slices/pinboardSlice.ts +248 -18
  39. package/src/store/slices/uiSlice.ts +41 -0
  40. package/src/store/types.ts +11 -0
  41. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  42. package/dist/assets/index-CGbokkQ9.css +0 -1
@@ -132,7 +132,9 @@ function buildSpatialNodes(
132
132
  id: nodeId,
133
133
  expressIds: [spatialNode.expressId],
134
134
  modelIds: [modelId],
135
- name: spatialNode.name || `${nodeType} #${spatialNode.expressId}`,
135
+ name: (spatialNode.name && spatialNode.name.toLowerCase() !== 'unknown')
136
+ ? spatialNode.name
137
+ : nodeType,
136
138
  type: nodeType,
137
139
  depth,
138
140
  hasChildren,
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { useEffect, type MutableRefObject, type RefObject } from 'react';
11
- import type { Renderer } from '@ifc-lite/renderer';
11
+ import type { Renderer, VisualEnhancementOptions } from '@ifc-lite/renderer';
12
12
  import type { SectionPlane } from '@/store';
13
13
 
14
14
  export interface UseAnimationLoopParams {
@@ -24,6 +24,7 @@ export interface UseAnimationLoopParams {
24
24
  selectedEntityIdRef: MutableRefObject<number | null>;
25
25
  selectedModelIndexRef: MutableRefObject<number | undefined>;
26
26
  clearColorRef: MutableRefObject<[number, number, number, number]>;
27
+ visualEnhancementRef: MutableRefObject<VisualEnhancementOptions>;
27
28
  sectionPlaneRef: MutableRefObject<SectionPlane>;
28
29
  sectionRangeRef: MutableRefObject<{ min: number; max: number } | null>;
29
30
  lastCameraStateRef: MutableRefObject<{
@@ -53,6 +54,7 @@ export function useAnimationLoop(params: UseAnimationLoopParams): void {
53
54
  selectedEntityIdRef,
54
55
  selectedModelIndexRef,
55
56
  clearColorRef,
57
+ visualEnhancementRef,
56
58
  sectionPlaneRef,
57
59
  sectionRangeRef,
58
60
  lastCameraStateRef,
@@ -87,6 +89,7 @@ export function useAnimationLoop(params: UseAnimationLoopParams): void {
87
89
  selectedId: selectedEntityIdRef.current,
88
90
  selectedModelIndex: selectedModelIndexRef.current,
89
91
  clearColor: clearColorRef.current,
92
+ visualEnhancement: visualEnhancementRef.current,
90
93
  sectionPlane: activeToolRef.current === 'section' ? {
91
94
  ...sectionPlaneRef.current,
92
95
  min: sectionRangeRef.current?.min,
@@ -11,6 +11,7 @@ import { useEffect, type MutableRefObject } from 'react';
11
11
  import type { Renderer } from '@ifc-lite/renderer';
12
12
  import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
13
13
  import type { SectionPlane } from '@/store';
14
+ import { goHomeFromStore } from '@/store/homeView';
14
15
  import { getEntityBounds } from '../../utils/viewportUtils.js';
15
16
 
16
17
  export interface UseKeyboardControlsParams {
@@ -132,8 +133,7 @@ export function useKeyboardControls(params: UseKeyboardControlsParams): void {
132
133
 
133
134
  // Home view (H) - reset to isometric
134
135
  if (e.key === 'h' || e.key === 'H') {
135
- camera.zoomToFit(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 500);
136
- calculateScale();
136
+ goHomeFromStore();
137
137
  }
138
138
 
139
139
  // Fit all / Zoom extents (Z)
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { useEffect, type MutableRefObject } from 'react';
11
- import type { Renderer, CutPolygon2D, DrawingLine2D } from '@ifc-lite/renderer';
11
+ import type { Renderer, CutPolygon2D, DrawingLine2D, VisualEnhancementOptions } from '@ifc-lite/renderer';
12
12
  import type { CoordinateInfo } from '@ifc-lite/geometry';
13
13
  import type { Drawing2D } from '@ifc-lite/drawing-2d';
14
14
  import type { SectionPlane } from '@/store';
@@ -21,6 +21,7 @@ export interface UseRenderUpdatesParams {
21
21
  // Theme
22
22
  theme: string;
23
23
  clearColorRef: MutableRefObject<[number, number, number, number]>;
24
+ visualEnhancementRef: MutableRefObject<VisualEnhancementOptions>;
24
25
 
25
26
  // Visibility/selection state (reactive values, not refs)
26
27
  hiddenEntities: Set<number>;
@@ -46,6 +47,7 @@ export interface UseRenderUpdatesParams {
46
47
  // Drawing 2D
47
48
  drawing2D: Drawing2D | null;
48
49
  show3DOverlay: boolean;
50
+ showHiddenLines: boolean;
49
51
  }
50
52
 
51
53
  export function useRenderUpdates(params: UseRenderUpdatesParams): void {
@@ -54,6 +56,7 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
54
56
  isInitialized,
55
57
  theme,
56
58
  clearColorRef,
59
+ visualEnhancementRef,
57
60
  hiddenEntities,
58
61
  isolatedEntities,
59
62
  selectedEntityId,
@@ -73,6 +76,7 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
73
76
  activeToolRef,
74
77
  drawing2D,
75
78
  show3DOverlay,
79
+ showHiddenLines,
76
80
  } = params;
77
81
 
78
82
  // Theme-aware clear color update
@@ -88,6 +92,7 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
88
92
  selectedId: selectedEntityIdRef.current,
89
93
  selectedModelIndex: selectedModelIndexRef.current,
90
94
  clearColor: clearColorRef.current,
95
+ visualEnhancement: visualEnhancementRef.current,
91
96
  });
92
97
  }
93
98
  }, [theme, isInitialized]);
@@ -106,8 +111,13 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
106
111
  expressId: cp.entityId, // DrawingPolygon uses entityId
107
112
  }));
108
113
 
109
- // No hatching lines for 3D overlay (too dense)
110
- const lines: DrawingLine2D[] = [];
114
+ // Include linework from the generated drawing on the section plane overlay.
115
+ const lines: DrawingLine2D[] = drawing2D.lines
116
+ .filter((line) => showHiddenLines || line.visibility !== 'hidden')
117
+ .map((line) => ({
118
+ line: line.line,
119
+ category: line.category,
120
+ }));
111
121
 
112
122
  // Upload to renderer - will be drawn on the section plane
113
123
  // Pass sectionRange to match exactly what render() uses for section plane position
@@ -132,13 +142,14 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
132
142
  selectedIds: selectedEntityIdsRef.current,
133
143
  selectedModelIndex: selectedModelIndexRef.current,
134
144
  clearColor: clearColorRef.current,
145
+ visualEnhancement: visualEnhancementRef.current,
135
146
  sectionPlane: activeTool === 'section' ? {
136
147
  ...sectionPlane,
137
148
  min: sectionRangeRef.current?.min,
138
149
  max: sectionRangeRef.current?.max,
139
150
  } : undefined,
140
151
  });
141
- }, [drawing2D, activeTool, sectionPlane, isInitialized, coordinateInfo, show3DOverlay]);
152
+ }, [drawing2D, activeTool, sectionPlane, isInitialized, coordinateInfo, show3DOverlay, showHiddenLines]);
142
153
 
143
154
  // Re-render when visibility, selection, or section plane changes
144
155
  useEffect(() => {
@@ -152,6 +163,7 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
152
163
  selectedIds: selectedEntityIds,
153
164
  selectedModelIndex,
154
165
  clearColor: clearColorRef.current,
166
+ visualEnhancement: visualEnhancementRef.current,
155
167
  sectionPlane: activeTool === 'section' ? {
156
168
  ...sectionPlane,
157
169
  min: sectionRange?.min,
@@ -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' },
@@ -157,9 +157,31 @@ export function useHoverState() {
157
157
  */
158
158
  export function useThemeState() {
159
159
  const theme = useViewerStore((state) => state.theme);
160
+ const isMobile = useViewerStore((state) => state.isMobile);
161
+ const visualEnhancementsEnabled = useViewerStore((state) => state.visualEnhancementsEnabled);
162
+ const edgeContrastEnabled = useViewerStore((state) => state.edgeContrastEnabled);
163
+ const edgeContrastIntensity = useViewerStore((state) => state.edgeContrastIntensity);
164
+ const contactShadingQuality = useViewerStore((state) => state.contactShadingQuality);
165
+ const contactShadingIntensity = useViewerStore((state) => state.contactShadingIntensity);
166
+ const contactShadingRadius = useViewerStore((state) => state.contactShadingRadius);
167
+ const separationLinesEnabled = useViewerStore((state) => state.separationLinesEnabled);
168
+ const separationLinesQuality = useViewerStore((state) => state.separationLinesQuality);
169
+ const separationLinesIntensity = useViewerStore((state) => state.separationLinesIntensity);
170
+ const separationLinesRadius = useViewerStore((state) => state.separationLinesRadius);
160
171
 
161
172
  return {
162
173
  theme,
174
+ isMobile,
175
+ visualEnhancementsEnabled,
176
+ edgeContrastEnabled,
177
+ edgeContrastIntensity,
178
+ contactShadingQuality,
179
+ contactShadingIntensity,
180
+ contactShadingRadius,
181
+ separationLinesEnabled,
182
+ separationLinesQuality,
183
+ separationLinesIntensity,
184
+ separationLinesRadius,
163
185
  };
164
186
  }
165
187
 
package/src/index.css CHANGED
@@ -4,6 +4,12 @@
4
4
 
5
5
  @import "tailwindcss";
6
6
 
7
+ /* Override Tailwind v4's default media-query dark variant so all dark: utilities
8
+ respond to the .dark class on <html> instead of prefers-color-scheme.
9
+ Without this, toggling light mode while the OS is in dark mode leaves all
10
+ dark: Tailwind utilities active, creating a mixed dark/light UI. */
11
+ @custom-variant dark (&:where(.dark, .dark *));
12
+
7
13
  /* ═══════════════════════════════════════════════════════════════════════════
8
14
  TOKYO NIGHT THEME - Dark Stormy Cyberpunk Vibes
9
15
  ═══════════════════════════════════════════════════════════════════════════ */
@@ -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
+ }