@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
|
@@ -132,7 +132,9 @@ function buildSpatialNodes(
|
|
|
132
132
|
id: nodeId,
|
|
133
133
|
expressIds: [spatialNode.expressId],
|
|
134
134
|
modelIds: [modelId],
|
|
135
|
-
name: spatialNode.name
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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' },
|
|
@@ -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
|
+
}
|