@ifc-lite/viewer 1.7.0 → 1.8.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 +35 -0
- package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CwcRxist.js} +1 -1
- package/dist/assets/index-7WoQ-qVC.css +1 -0
- package/dist/assets/{index-dgdgiQ9p.js → index-BSANf7-H.js} +20926 -17587
- package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-5LbrYh3R.js} +1 -1
- package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-CgpLtj1h.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -18
- package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
- package/src/components/viewer/EntityContextMenu.tsx +47 -20
- package/src/components/viewer/ExportDialog.tsx +166 -17
- package/src/components/viewer/HierarchyPanel.tsx +3 -1
- package/src/components/viewer/LensPanel.tsx +848 -85
- package/src/components/viewer/MainToolbar.tsx +114 -81
- package/src/components/viewer/Section2DPanel.tsx +269 -29
- package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
- package/src/components/viewer/Viewport.tsx +57 -23
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
- package/src/components/viewer/hierarchy/types.ts +1 -1
- package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
- package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
- package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
- package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
- package/src/components/viewer/tools/computePolygonArea.ts +72 -0
- package/src/components/viewer/useGeometryStreaming.ts +12 -4
- package/src/hooks/ids/idsExportService.ts +1 -1
- package/src/hooks/useAnnotation2D.ts +551 -0
- package/src/hooks/useDrawingExport.ts +83 -1
- package/src/hooks/useKeyboardShortcuts.ts +113 -14
- package/src/hooks/useLens.ts +39 -55
- package/src/hooks/useLensDiscovery.ts +46 -0
- package/src/hooks/useModelSelection.ts +5 -22
- package/src/index.css +7 -1
- package/src/lib/lens/adapter.ts +127 -1
- package/src/lib/lists/columnToAutoColor.ts +33 -0
- package/src/store/index.ts +14 -1
- package/src/store/resolveEntityRef.ts +44 -0
- package/src/store/slices/drawing2DSlice.ts +321 -0
- package/src/store/slices/lensSlice.ts +46 -4
- package/src/store/slices/pinboardSlice.ts +171 -38
- package/src/store.ts +3 -0
- package/dist/assets/index-yTqs8kgX.css +0 -1
|
@@ -7,12 +7,49 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { useEffect, useCallback } from 'react';
|
|
10
|
-
import { useViewerStore } from '@/store';
|
|
10
|
+
import { useViewerStore, stringToEntityRef } from '@/store';
|
|
11
|
+
import type { EntityRef } from '@/store';
|
|
11
12
|
|
|
12
13
|
interface KeyboardShortcutsOptions {
|
|
13
14
|
enabled?: boolean;
|
|
14
15
|
}
|
|
15
16
|
|
|
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
|
+
/** Get all selected global IDs — multi-select if available, else single selectedEntityId */
|
|
26
|
+
function getAllSelectedGlobalIds(): number[] {
|
|
27
|
+
const state = useViewerStore.getState();
|
|
28
|
+
if (state.selectedEntityIds.size > 0) {
|
|
29
|
+
return Array.from(state.selectedEntityIds);
|
|
30
|
+
}
|
|
31
|
+
if (state.selectedEntityId !== null) {
|
|
32
|
+
return [state.selectedEntityId];
|
|
33
|
+
}
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
|
|
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
|
+
|
|
16
53
|
export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
17
54
|
const { enabled = true } = options;
|
|
18
55
|
|
|
@@ -20,12 +57,18 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
20
57
|
const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
|
|
21
58
|
const activeTool = useViewerStore((s) => s.activeTool);
|
|
22
59
|
const setActiveTool = useViewerStore((s) => s.setActiveTool);
|
|
23
|
-
const
|
|
24
|
-
const hideEntity = useViewerStore((s) => s.hideEntity);
|
|
60
|
+
const hideEntities = useViewerStore((s) => s.hideEntities);
|
|
25
61
|
const showAll = useViewerStore((s) => s.showAll);
|
|
26
62
|
const clearStoreySelection = useViewerStore((s) => s.clearStoreySelection);
|
|
27
63
|
const toggleTheme = useViewerStore((s) => s.toggleTheme);
|
|
28
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);
|
|
71
|
+
|
|
29
72
|
// Measure tool specific actions
|
|
30
73
|
const activeMeasurement = useViewerStore((s) => s.activeMeasurement);
|
|
31
74
|
const cancelMeasurement = useViewerStore((s) => s.cancelMeasurement);
|
|
@@ -74,18 +117,67 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
74
117
|
setActiveTool('section');
|
|
75
118
|
}
|
|
76
119
|
|
|
77
|
-
// Visibility controls
|
|
78
|
-
|
|
120
|
+
// Basket / Visibility controls
|
|
121
|
+
// I = Set basket (isolate selection as basket), or re-apply basket if no selection
|
|
122
|
+
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
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// + or = (with shift) = Add to basket
|
|
141
|
+
if ((e.key === '+' || (e.key === '=' && shift)) && !ctrl) {
|
|
142
|
+
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
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// - or _ = Remove from basket
|
|
152
|
+
if ((e.key === '-' || e.key === '_') && !ctrl) {
|
|
79
153
|
e.preventDefault();
|
|
80
|
-
|
|
154
|
+
const refs = getSelectionRefsFromStore();
|
|
155
|
+
if (refs.length > 0) {
|
|
156
|
+
removeFromBasket(refs);
|
|
157
|
+
// Consume multi-select after removal
|
|
158
|
+
clearMultiSelect();
|
|
159
|
+
}
|
|
81
160
|
}
|
|
161
|
+
|
|
82
162
|
if ((key === 'delete' || key === 'backspace') && !ctrl && !shift && selectedEntityId) {
|
|
83
163
|
e.preventDefault();
|
|
84
|
-
|
|
164
|
+
const ids = getAllSelectedGlobalIds();
|
|
165
|
+
hideEntities(ids);
|
|
166
|
+
clearMultiSelect();
|
|
167
|
+
}
|
|
168
|
+
// Space to hide — skip when focused on buttons/selects/links where Space has native behavior
|
|
169
|
+
if (key === ' ' && !ctrl && !shift && selectedEntityId) {
|
|
170
|
+
const tag = document.activeElement?.tagName;
|
|
171
|
+
if (tag !== 'BUTTON' && tag !== 'SELECT' && tag !== 'A') {
|
|
172
|
+
e.preventDefault();
|
|
173
|
+
const ids = getAllSelectedGlobalIds();
|
|
174
|
+
hideEntities(ids);
|
|
175
|
+
clearMultiSelect();
|
|
176
|
+
}
|
|
85
177
|
}
|
|
86
178
|
if (key === 'a' && !ctrl && !shift) {
|
|
87
179
|
e.preventDefault();
|
|
88
|
-
showAll();
|
|
180
|
+
showAll(); // Clear hiddenEntities + isolatedEntities (basket preserved)
|
|
89
181
|
clearStoreySelection(); // Also clear storey filtering
|
|
90
182
|
}
|
|
91
183
|
|
|
@@ -121,6 +213,7 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
121
213
|
if (key === 'escape') {
|
|
122
214
|
e.preventDefault();
|
|
123
215
|
setSelectedEntityId(null);
|
|
216
|
+
clearBasket();
|
|
124
217
|
showAll();
|
|
125
218
|
clearStoreySelection(); // Also clear storey filtering
|
|
126
219
|
setActiveTool('select');
|
|
@@ -139,8 +232,12 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
139
232
|
setSelectedEntityId,
|
|
140
233
|
activeTool,
|
|
141
234
|
setActiveTool,
|
|
142
|
-
|
|
143
|
-
|
|
235
|
+
setBasket,
|
|
236
|
+
addToBasket,
|
|
237
|
+
removeFromBasket,
|
|
238
|
+
clearBasket,
|
|
239
|
+
showPinboard,
|
|
240
|
+
hideEntities,
|
|
144
241
|
showAll,
|
|
145
242
|
clearStoreySelection,
|
|
146
243
|
toggleTheme,
|
|
@@ -171,14 +268,16 @@ export const KEYBOARD_SHORTCUTS = [
|
|
|
171
268
|
{ key: 'S', description: 'Toggle snapping (Measure tool)', category: 'Tools' },
|
|
172
269
|
{ key: 'Esc', description: 'Cancel measurement (Measure tool)', category: 'Tools' },
|
|
173
270
|
{ key: 'Ctrl+C', description: 'Clear measurements (Measure tool)', category: 'Tools' },
|
|
174
|
-
{ key: 'I', description: '
|
|
175
|
-
{ key: '
|
|
176
|
-
{ key: '
|
|
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' },
|
|
274
|
+
{ key: 'Del / Space', description: 'Hide selection', category: 'Visibility' },
|
|
275
|
+
{ key: 'A', description: 'Show all (clear filters, keep basket)', category: 'Visibility' },
|
|
177
276
|
{ key: 'H', description: 'Home (Isometric view)', category: 'Camera' },
|
|
178
277
|
{ key: 'Z', description: 'Fit all (zoom extents)', category: 'Camera' },
|
|
179
278
|
{ key: 'F', description: 'Frame selection', category: 'Camera' },
|
|
180
279
|
{ key: '1-6', description: 'Preset views', category: 'Camera' },
|
|
181
280
|
{ key: 'T', description: 'Toggle theme', category: 'UI' },
|
|
182
|
-
{ key: 'Esc', description: 'Reset all (clear selection,
|
|
281
|
+
{ key: 'Esc', description: 'Reset all (clear selection, basket, isolation)', category: 'Selection' },
|
|
183
282
|
{ key: '?', description: 'Show info panel', category: 'Help' },
|
|
184
283
|
] as const;
|
package/src/hooks/useLens.ts
CHANGED
|
@@ -10,78 +10,54 @@
|
|
|
10
10
|
* Unmatched entities with geometry are ghosted (semi-transparent).
|
|
11
11
|
*
|
|
12
12
|
* The pure evaluation logic lives in @ifc-lite/lens — this hook handles
|
|
13
|
-
* React lifecycle
|
|
13
|
+
* React lifecycle and Zustand integration.
|
|
14
14
|
*
|
|
15
15
|
* Performance notes:
|
|
16
16
|
* - Does NOT subscribe to `models` or `ifcDataStore` — reads them from
|
|
17
17
|
* getState() only when the active lens changes. This prevents re-evaluation
|
|
18
18
|
* during model loading.
|
|
19
|
-
* - Uses
|
|
20
|
-
*
|
|
21
|
-
*
|
|
19
|
+
* - Uses color overlay system: pendingColorUpdates triggers
|
|
20
|
+
* scene.setColorOverrides() which builds overlay batches rendered on top
|
|
21
|
+
* of original geometry. Original batches are NEVER modified — clearing
|
|
22
|
+
* lens is instant (no batch rebuild).
|
|
22
23
|
*/
|
|
23
24
|
|
|
24
|
-
import { useEffect, useRef,
|
|
25
|
-
import { evaluateLens, rgbaToHex, isGhostColor } from '@ifc-lite/lens';
|
|
26
|
-
import type { RGBAColor } from '@ifc-lite/lens';
|
|
25
|
+
import { useEffect, useRef, useMemo } from 'react';
|
|
26
|
+
import { evaluateLens, evaluateAutoColorLens, rgbaToHex, isGhostColor } from '@ifc-lite/lens';
|
|
27
27
|
import { useViewerStore } from '@/store';
|
|
28
28
|
import { createLensDataProvider } from '@/lib/lens';
|
|
29
|
+
import { useLensDiscovery } from './useLensDiscovery';
|
|
29
30
|
|
|
30
31
|
export function useLens() {
|
|
31
32
|
const activeLensId = useViewerStore((s) => s.activeLensId);
|
|
32
33
|
const savedLenses = useViewerStore((s) => s.savedLenses);
|
|
33
34
|
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const captureOriginalColors = useCallback(() => {
|
|
41
|
-
const state = useViewerStore.getState();
|
|
42
|
-
const originals = new Map<number, RGBAColor>();
|
|
43
|
-
|
|
44
|
-
// Federation mode: collect from all model geometries
|
|
45
|
-
if (state.models.size > 0) {
|
|
46
|
-
for (const [, model] of state.models) {
|
|
47
|
-
if (model.geometryResult?.meshes) {
|
|
48
|
-
for (const mesh of model.geometryResult.meshes) {
|
|
49
|
-
if (mesh.color) {
|
|
50
|
-
originals.set(mesh.expressId, mesh.color as RGBAColor);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
}
|
|
35
|
+
// Derive the active lens object — only re-evaluates when activeLensId or
|
|
36
|
+
// the active lens entry itself changes, not when unrelated lenses are edited.
|
|
37
|
+
const activeLens = useMemo(
|
|
38
|
+
() => savedLenses.find(l => l.id === activeLensId) ?? null,
|
|
39
|
+
[activeLensId, savedLenses],
|
|
40
|
+
);
|
|
56
41
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
for (const mesh of state.geometryResult.meshes) {
|
|
60
|
-
if (mesh.color) {
|
|
61
|
-
originals.set(mesh.expressId, mesh.color as RGBAColor);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
}
|
|
42
|
+
// Run data discovery when models change (populates discoveredLensData in store)
|
|
43
|
+
useLensDiscovery();
|
|
65
44
|
|
|
66
|
-
|
|
67
|
-
|
|
45
|
+
// Track the previously active lens to detect deactivation
|
|
46
|
+
const prevLensIdRef = useRef<string | null>(null);
|
|
68
47
|
|
|
69
48
|
useEffect(() => {
|
|
70
|
-
const activeLens = savedLenses.find(l => l.id === activeLensId) ?? null;
|
|
71
49
|
|
|
72
|
-
// Lens deactivated —
|
|
50
|
+
// Lens deactivated — clear overlay (instant, no batch rebuild)
|
|
73
51
|
if (!activeLens && prevLensIdRef.current !== null) {
|
|
74
52
|
prevLensIdRef.current = null;
|
|
75
53
|
useViewerStore.getState().setLensColorMap(new Map());
|
|
76
54
|
useViewerStore.getState().setLensHiddenIds(new Set());
|
|
77
55
|
useViewerStore.getState().setLensRuleCounts(new Map());
|
|
78
56
|
useViewerStore.getState().setLensRuleEntityIds(new Map());
|
|
57
|
+
useViewerStore.getState().setLensAutoColorLegend([]);
|
|
79
58
|
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
useViewerStore.getState().setPendingColorUpdates(originalColorsRef.current);
|
|
83
|
-
}
|
|
84
|
-
originalColorsRef.current = null;
|
|
59
|
+
// Send empty map to signal "clear overlays" to useGeometryStreaming
|
|
60
|
+
useViewerStore.getState().setPendingColorUpdates(new Map());
|
|
85
61
|
return;
|
|
86
62
|
}
|
|
87
63
|
|
|
@@ -92,16 +68,18 @@ export function useLens() {
|
|
|
92
68
|
const { models, ifcDataStore } = useViewerStore.getState();
|
|
93
69
|
if (models.size === 0 && !ifcDataStore) return;
|
|
94
70
|
|
|
95
|
-
// Save original colors before first lens application
|
|
96
|
-
if (prevLensIdRef.current === null) {
|
|
97
|
-
originalColorsRef.current = captureOriginalColors();
|
|
98
|
-
}
|
|
99
|
-
|
|
100
71
|
prevLensIdRef.current = activeLensId;
|
|
101
72
|
|
|
102
73
|
// Create data provider and evaluate lens using @ifc-lite/lens package
|
|
103
74
|
const provider = createLensDataProvider(models, ifcDataStore);
|
|
104
|
-
|
|
75
|
+
|
|
76
|
+
// Dispatch: auto-color mode vs. rule-based mode
|
|
77
|
+
const isAutoColor = !!activeLens.autoColor;
|
|
78
|
+
const result = isAutoColor
|
|
79
|
+
? evaluateAutoColorLens(activeLens.autoColor!, provider)
|
|
80
|
+
: evaluateLens(activeLens, provider);
|
|
81
|
+
|
|
82
|
+
const { colorMap, hiddenIds, ruleCounts, ruleEntityIds } = result;
|
|
105
83
|
|
|
106
84
|
// Build hex color map for UI legend (exclude ghost entries)
|
|
107
85
|
const hexColorMap = new Map<number, string>();
|
|
@@ -115,12 +93,18 @@ export function useLens() {
|
|
|
115
93
|
useViewerStore.getState().setLensRuleCounts(ruleCounts);
|
|
116
94
|
useViewerStore.getState().setLensRuleEntityIds(ruleEntityIds);
|
|
117
95
|
|
|
118
|
-
//
|
|
119
|
-
|
|
96
|
+
// Store auto-color legend entries for UI display
|
|
97
|
+
if (isAutoColor && 'legend' in result) {
|
|
98
|
+
useViewerStore.getState().setLensAutoColorLegend(result.legend);
|
|
99
|
+
} else {
|
|
100
|
+
useViewerStore.getState().setLensAutoColorLegend([]);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Apply colors via overlay system — original batches are never modified
|
|
120
104
|
if (colorMap.size > 0) {
|
|
121
105
|
useViewerStore.getState().setPendingColorUpdates(colorMap);
|
|
122
106
|
}
|
|
123
|
-
}, [activeLensId,
|
|
107
|
+
}, [activeLensId, activeLens]);
|
|
124
108
|
|
|
125
109
|
return {
|
|
126
110
|
activeLensId,
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* Lens class discovery hook — INSTANT, zero loading impact.
|
|
7
|
+
*
|
|
8
|
+
* Only discovers IFC class names from the entity table (O(n) array scan,
|
|
9
|
+
* no STEP buffer parsing). Property sets, quantities, materials, and
|
|
10
|
+
* classifications are discovered lazily on-demand when the user opens
|
|
11
|
+
* a dropdown that needs them — see `useLazyDiscovery` in LensPanel.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useEffect } from 'react';
|
|
15
|
+
import { discoverClasses } from '@ifc-lite/lens';
|
|
16
|
+
import { useViewerStore } from '@/store';
|
|
17
|
+
import { createLensDataProvider } from '@/lib/lens';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Discover IFC classes when models change (instant).
|
|
21
|
+
* Stores result in `discoveredLensData.classes` on the lens slice.
|
|
22
|
+
*/
|
|
23
|
+
export function useLensDiscovery(): void {
|
|
24
|
+
const modelCount = useViewerStore((s) => s.models.size);
|
|
25
|
+
const ifcDataStore = useViewerStore((s) => s.ifcDataStore);
|
|
26
|
+
const setDiscoveredLensData = useViewerStore((s) => s.setDiscoveredLensData);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
const { models, ifcDataStore: ds } = useViewerStore.getState();
|
|
30
|
+
if (models.size === 0 && !ds) {
|
|
31
|
+
setDiscoveredLensData(null);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Instant: just reads type names from entity arrays, no STEP parsing
|
|
36
|
+
const provider = createLensDataProvider(models, ds);
|
|
37
|
+
const classes = discoverClasses(provider);
|
|
38
|
+
setDiscoveredLensData({
|
|
39
|
+
classes,
|
|
40
|
+
propertySets: null, // lazy — discovered on-demand
|
|
41
|
+
quantitySets: null, // lazy — discovered on-demand
|
|
42
|
+
classificationSystems: null, // lazy — discovered on-demand
|
|
43
|
+
materials: null, // lazy — discovered on-demand
|
|
44
|
+
});
|
|
45
|
+
}, [modelCount, ifcDataStore, setDiscoveredLensData]);
|
|
46
|
+
}
|
|
@@ -20,14 +20,13 @@
|
|
|
20
20
|
|
|
21
21
|
import { useEffect } from 'react';
|
|
22
22
|
import { useViewerStore } from '../store.js';
|
|
23
|
+
import { resolveEntityRef } from '../store/resolveEntityRef.js';
|
|
23
24
|
|
|
24
25
|
export function useModelSelection() {
|
|
25
26
|
const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
|
|
26
27
|
const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity);
|
|
27
28
|
// Subscribe to models for reactivity (when models are added/removed)
|
|
28
29
|
const models = useViewerStore((s) => s.models);
|
|
29
|
-
// Use the bulletproof store-based resolver
|
|
30
|
-
const resolveGlobalIdFromModels = useViewerStore((s) => s.resolveGlobalIdFromModels);
|
|
31
30
|
|
|
32
31
|
useEffect(() => {
|
|
33
32
|
if (selectedEntityId === null) {
|
|
@@ -38,24 +37,8 @@ export function useModelSelection() {
|
|
|
38
37
|
return;
|
|
39
38
|
}
|
|
40
39
|
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (resolved) {
|
|
46
|
-
// Set EntityRef with ORIGINAL expressId (for property lookup in IfcDataStore)
|
|
47
|
-
setSelectedEntity({ modelId: resolved.modelId, expressId: resolved.expressId });
|
|
48
|
-
} else {
|
|
49
|
-
// Fallback for single-model mode (offset = 0, globalId = expressId)
|
|
50
|
-
// In this case, try to find the first model and use the globalId as expressId
|
|
51
|
-
if (models.size > 0) {
|
|
52
|
-
const firstModelId = Array.from(models.keys())[0];
|
|
53
|
-
setSelectedEntity({ modelId: firstModelId, expressId: selectedEntityId });
|
|
54
|
-
} else {
|
|
55
|
-
// Legacy single-model mode: use 'legacy' as modelId
|
|
56
|
-
// This allows PropertiesPanel to fall back to the legacy query
|
|
57
|
-
setSelectedEntity({ modelId: 'legacy', expressId: selectedEntityId });
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
}, [selectedEntityId, setSelectedEntity, models, resolveGlobalIdFromModels]);
|
|
40
|
+
// Single source of truth: resolveEntityRef handles globalId → EntityRef
|
|
41
|
+
// including fallback for single-model mode (offset 0). Always returns an EntityRef.
|
|
42
|
+
setSelectedEntity(resolveEntityRef(selectedEntityId));
|
|
43
|
+
}, [selectedEntityId, setSelectedEntity, models]);
|
|
61
44
|
}
|
package/src/index.css
CHANGED
|
@@ -187,10 +187,16 @@ body {
|
|
|
187
187
|
color: var(--tokyo-comment) !important;
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
-
.dark .text-zinc-200
|
|
190
|
+
.dark .text-zinc-200,
|
|
191
|
+
.dark .text-zinc-300 {
|
|
191
192
|
color: var(--tokyo-fg-dark) !important;
|
|
192
193
|
}
|
|
193
194
|
|
|
195
|
+
.dark .text-zinc-700,
|
|
196
|
+
.dark .text-zinc-800 {
|
|
197
|
+
color: var(--tokyo-fg) !important;
|
|
198
|
+
}
|
|
199
|
+
|
|
194
200
|
/* Primary accent */
|
|
195
201
|
.dark .bg-primary {
|
|
196
202
|
background-color: var(--tokyo-blue) !important;
|
package/src/lib/lens/adapter.ts
CHANGED
|
@@ -10,8 +10,14 @@
|
|
|
10
10
|
* - Legacy single-model: uses offset = 0
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import type { LensDataProvider, PropertySetInfo } from '@ifc-lite/lens';
|
|
13
|
+
import type { LensDataProvider, PropertySetInfo, ClassificationInfo } from '@ifc-lite/lens';
|
|
14
14
|
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
15
|
+
import {
|
|
16
|
+
extractEntityAttributesOnDemand,
|
|
17
|
+
extractQuantitiesOnDemand,
|
|
18
|
+
extractClassificationsOnDemand,
|
|
19
|
+
extractMaterialsOnDemand,
|
|
20
|
+
} from '@ifc-lite/parser';
|
|
15
21
|
import type { FederatedModel } from '@/store/types';
|
|
16
22
|
|
|
17
23
|
interface ModelEntry {
|
|
@@ -111,6 +117,126 @@ export function createLensDataProvider(
|
|
|
111
117
|
if (!psets) return [];
|
|
112
118
|
return psets as PropertySetInfo[];
|
|
113
119
|
},
|
|
120
|
+
|
|
121
|
+
getEntityAttribute(globalId: number, attrName: string): string | undefined {
|
|
122
|
+
const resolved = resolveGlobalId(globalId, entries);
|
|
123
|
+
if (!resolved) return undefined;
|
|
124
|
+
const store = resolved.entry.ifcDataStore;
|
|
125
|
+
const id = resolved.expressId;
|
|
126
|
+
|
|
127
|
+
// Fast path: columnar attributes stored during initial parse
|
|
128
|
+
switch (attrName) {
|
|
129
|
+
case 'Name':
|
|
130
|
+
return store.entities.getName(id) || undefined;
|
|
131
|
+
case 'Description': {
|
|
132
|
+
const desc = store.entities.getDescription?.(id);
|
|
133
|
+
if (desc) return desc;
|
|
134
|
+
break;
|
|
135
|
+
}
|
|
136
|
+
case 'ObjectType': {
|
|
137
|
+
const ot = store.entities.getObjectType?.(id);
|
|
138
|
+
if (ot) return ot;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'Tag':
|
|
142
|
+
// Tag is not stored in columnar — always on-demand
|
|
143
|
+
break;
|
|
144
|
+
case 'GlobalId':
|
|
145
|
+
return store.entities.getGlobalId(id) || undefined;
|
|
146
|
+
case 'Type':
|
|
147
|
+
return store.entities.getTypeName?.(id) || undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Slow path: on-demand extraction from source buffer
|
|
151
|
+
if (store.source?.length > 0 && store.entityIndex) {
|
|
152
|
+
const attrs = extractEntityAttributesOnDemand(store, id);
|
|
153
|
+
switch (attrName) {
|
|
154
|
+
case 'Name': return attrs.name || undefined;
|
|
155
|
+
case 'Description': return attrs.description || undefined;
|
|
156
|
+
case 'ObjectType': return attrs.objectType || undefined;
|
|
157
|
+
case 'Tag': return attrs.tag || undefined;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return undefined;
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
getQuantityValue(
|
|
164
|
+
globalId: number,
|
|
165
|
+
qsetName: string,
|
|
166
|
+
quantName: string,
|
|
167
|
+
): number | string | undefined {
|
|
168
|
+
const resolved = resolveGlobalId(globalId, entries);
|
|
169
|
+
if (!resolved) return undefined;
|
|
170
|
+
const store = resolved.entry.ifcDataStore;
|
|
171
|
+
const id = resolved.expressId;
|
|
172
|
+
|
|
173
|
+
// On-demand quantity extraction
|
|
174
|
+
if (store.onDemandQuantityMap && store.source?.length > 0) {
|
|
175
|
+
const qsets = extractQuantitiesOnDemand(store, id);
|
|
176
|
+
for (const qset of qsets) {
|
|
177
|
+
if (qset.name === qsetName) {
|
|
178
|
+
for (const q of qset.quantities) {
|
|
179
|
+
if (q.name === quantName) return q.value;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Fallback: pre-built quantity tables
|
|
187
|
+
const qsets = store.quantities?.getForEntity?.(id);
|
|
188
|
+
if (!qsets) return undefined;
|
|
189
|
+
for (const qset of qsets) {
|
|
190
|
+
if (qset.name === qsetName) {
|
|
191
|
+
for (const q of qset.quantities) {
|
|
192
|
+
if (q.name === quantName) return q.value;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
getClassifications(globalId: number): ClassificationInfo[] {
|
|
200
|
+
const resolved = resolveGlobalId(globalId, entries);
|
|
201
|
+
if (!resolved) return [];
|
|
202
|
+
const store = resolved.entry.ifcDataStore;
|
|
203
|
+
return extractClassificationsOnDemand(store, resolved.expressId);
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
getQuantitySets(globalId: number): ReadonlyArray<{
|
|
207
|
+
name: string;
|
|
208
|
+
quantities: ReadonlyArray<{ name: string }>;
|
|
209
|
+
}> {
|
|
210
|
+
const resolved = resolveGlobalId(globalId, entries);
|
|
211
|
+
if (!resolved) return [];
|
|
212
|
+
const store = resolved.entry.ifcDataStore;
|
|
213
|
+
const id = resolved.expressId;
|
|
214
|
+
|
|
215
|
+
// On-demand quantity extraction
|
|
216
|
+
if (store.onDemandQuantityMap && store.source?.length > 0) {
|
|
217
|
+
return extractQuantitiesOnDemand(store, id);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Fallback: pre-built quantity tables
|
|
221
|
+
const qsets = store.quantities?.getForEntity?.(id);
|
|
222
|
+
if (!qsets) return [];
|
|
223
|
+
return qsets as ReadonlyArray<{ name: string; quantities: ReadonlyArray<{ name: string }> }>;
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
getMaterialName(globalId: number): string | undefined {
|
|
227
|
+
const resolved = resolveGlobalId(globalId, entries);
|
|
228
|
+
if (!resolved) return undefined;
|
|
229
|
+
const store = resolved.entry.ifcDataStore;
|
|
230
|
+
const info = extractMaterialsOnDemand(store, resolved.expressId);
|
|
231
|
+
if (!info) return undefined;
|
|
232
|
+
// Return the top-level material name, or first layer/constituent name
|
|
233
|
+
if (info.name) return info.name;
|
|
234
|
+
if (info.layers?.length) return info.layers[0].materialName;
|
|
235
|
+
if (info.constituents?.length) return info.constituents[0].materialName;
|
|
236
|
+
if (info.profiles?.length) return info.profiles[0].materialName;
|
|
237
|
+
if (info.materials?.length) return info.materials[0];
|
|
238
|
+
return undefined;
|
|
239
|
+
},
|
|
114
240
|
};
|
|
115
241
|
}
|
|
116
242
|
|
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
/**
|
|
6
|
+
* Maps a list ColumnDefinition to a lens AutoColorSpec.
|
|
7
|
+
* This bridges the lists feature (column-based data tables) with
|
|
8
|
+
* the lens feature (3D coloring) by converting column metadata
|
|
9
|
+
* into the auto-color specification used by the lens engine.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { ColumnDefinition } from '@ifc-lite/lists';
|
|
13
|
+
import type { AutoColorSpec } from '@ifc-lite/lens';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Convert a list column definition to an auto-color spec.
|
|
17
|
+
*
|
|
18
|
+
* @param col - Column definition from a list configuration
|
|
19
|
+
* @returns AutoColorSpec for the lens engine
|
|
20
|
+
*/
|
|
21
|
+
export function columnToAutoColor(col: ColumnDefinition): AutoColorSpec {
|
|
22
|
+
switch (col.source) {
|
|
23
|
+
case 'attribute':
|
|
24
|
+
if (col.propertyName === 'Class') return { source: 'ifcType' };
|
|
25
|
+
return { source: 'attribute', propertyName: col.propertyName };
|
|
26
|
+
case 'property':
|
|
27
|
+
return { source: 'property', psetName: col.psetName, propertyName: col.propertyName };
|
|
28
|
+
case 'quantity':
|
|
29
|
+
return { source: 'quantity', psetName: col.psetName, propertyName: col.propertyName };
|
|
30
|
+
default:
|
|
31
|
+
return { source: 'ifcType' };
|
|
32
|
+
}
|
|
33
|
+
}
|
package/src/store/index.ts
CHANGED
|
@@ -43,8 +43,11 @@ export type { EntityRef, SchemaVersion, FederatedModel, MeasurementConstraintEdg
|
|
|
43
43
|
// Re-export utility functions for entity references
|
|
44
44
|
export { entityRefToString, stringToEntityRef, entityRefEquals, isIfcxDataStore } from './types.js';
|
|
45
45
|
|
|
46
|
+
// Re-export single source of truth for globalId → EntityRef resolution
|
|
47
|
+
export { resolveEntityRef } from './resolveEntityRef.js';
|
|
48
|
+
|
|
46
49
|
// Re-export Drawing2D types
|
|
47
|
-
export type { Drawing2DState, Drawing2DStatus } from './slices/drawing2DSlice.js';
|
|
50
|
+
export type { Drawing2DState, Drawing2DStatus, Annotation2DTool, PolygonArea2DResult, TextAnnotation2D, CloudAnnotation2D, SelectedAnnotation2D } from './slices/drawing2DSlice.js';
|
|
48
51
|
|
|
49
52
|
// Re-export Sheet types
|
|
50
53
|
export type { SheetState } from './slices/sheetSlice.js';
|
|
@@ -205,6 +208,16 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
|
|
|
205
208
|
measure2DLockedAxis: null,
|
|
206
209
|
measure2DResults: [],
|
|
207
210
|
measure2DSnapPoint: null,
|
|
211
|
+
// Annotation tools
|
|
212
|
+
annotation2DActiveTool: 'none' as const,
|
|
213
|
+
annotation2DCursorPos: null,
|
|
214
|
+
polygonArea2DPoints: [],
|
|
215
|
+
polygonArea2DResults: [],
|
|
216
|
+
textAnnotations2D: [],
|
|
217
|
+
textAnnotation2DEditing: null,
|
|
218
|
+
cloudAnnotation2DPoints: [],
|
|
219
|
+
cloudAnnotations2D: [],
|
|
220
|
+
selectedAnnotation2D: null,
|
|
208
221
|
// Drawing Sheet
|
|
209
222
|
activeSheet: null,
|
|
210
223
|
sheetEnabled: false,
|