@ifc-lite/viewer 1.14.4 → 1.16.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 +41 -0
- package/dist/assets/{Arrow.dom-_vGzMMKs.js → Arrow.dom--gdrQd-q.js} +1 -1
- package/dist/assets/{basketViewActivator-BZcoCL3V.js → basketViewActivator-CI3y6VYQ.js} +1 -1
- package/dist/assets/{browser-Czmf34bo.js → browser-vWDubxDI.js} +1 -1
- package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
- package/dist/assets/index-BImINgzG.js +187371 -0
- package/dist/assets/{index-D7nEDctQ.js → index-RXIK18da.js} +4 -4
- package/dist/assets/index-ax1X2WPd.css +1 -0
- package/dist/assets/{native-bridge-DAOWftxE.js → native-bridge-4rLidc3f.js} +1 -1
- package/dist/assets/{wasm-bridge-D7jYpn8a.js → wasm-bridge-BkfXfw8O.js} +1 -1
- package/dist/index.html +7 -2
- package/index.html +5 -0
- package/package.json +9 -9
- package/src/components/viewer/ExportDialog.tsx +40 -2
- package/src/components/viewer/HierarchyPanel.tsx +99 -22
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +184 -82
- package/src/components/viewer/ViewportContainer.tsx +30 -25
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +26 -20
- package/src/components/viewer/hierarchy/ifc-icons.ts +90 -0
- package/src/hooks/useIfcCache.ts +9 -9
- package/src/hooks/useKeyboardShortcuts.ts +28 -2
- package/src/sdk/adapters/visibility-adapter.ts +82 -2
- package/src/store/basketVisibleSet.ts +72 -4
- package/src/store/index.ts +11 -1
- package/src/store/slices/pinboardSlice.ts +46 -45
- package/src/store/slices/visibilitySlice.ts +28 -2
- package/src/utils/spatialHierarchy.ts +1 -1
- package/src/vite-env.d.ts +6 -2
- package/vite.config.ts +75 -23
- package/dist/assets/ifc-lite_bg-DyBKoGgk.wasm +0 -0
- package/dist/assets/index-CMQ_Dgkr.css +0 -1
- package/dist/assets/index-DX-Qf5fA.js +0 -116950
|
@@ -4,13 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
ChevronRight,
|
|
7
|
-
Building2,
|
|
8
7
|
Layers,
|
|
9
|
-
MapPin,
|
|
10
|
-
FolderKanban,
|
|
11
|
-
Square,
|
|
12
|
-
Box,
|
|
13
|
-
DoorOpen,
|
|
14
8
|
Eye,
|
|
15
9
|
EyeOff,
|
|
16
10
|
FileBox,
|
|
@@ -20,21 +14,21 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
|
|
20
14
|
import { cn } from '@/lib/utils';
|
|
21
15
|
import type { TreeNode } from './types';
|
|
22
16
|
import { isSpatialContainer } from './types';
|
|
17
|
+
import { IFC_ICON_CODEPOINTS, IFC_ICON_DEFAULT } from './ifc-icons';
|
|
23
18
|
|
|
24
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the Material Symbols code point for a given IFC type string.
|
|
21
|
+
* Falls back to the generic product icon for unmapped classes.
|
|
22
|
+
*/
|
|
23
|
+
function getIfcIconCodepoint(ifcType: string | undefined): string {
|
|
24
|
+
if (!ifcType) return IFC_ICON_DEFAULT;
|
|
25
|
+
return IFC_ICON_CODEPOINTS[ifcType] ?? IFC_ICON_DEFAULT;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Lucide fallback icons for non-IFC node types */
|
|
29
|
+
const NODE_TYPE_ICONS: Record<string, React.ElementType> = {
|
|
25
30
|
'unified-storey': Layers,
|
|
26
31
|
'model-header': FileBox,
|
|
27
|
-
'ifc-type': Building2,
|
|
28
|
-
IfcProject: FolderKanban,
|
|
29
|
-
IfcSite: MapPin,
|
|
30
|
-
IfcBuilding: Building2,
|
|
31
|
-
IfcBuildingStorey: Layers,
|
|
32
|
-
IfcSpace: Box,
|
|
33
|
-
IfcWall: Square,
|
|
34
|
-
IfcWallStandardCase: Square,
|
|
35
|
-
IfcDoor: DoorOpen,
|
|
36
|
-
element: Box,
|
|
37
|
-
default: Box,
|
|
38
32
|
};
|
|
39
33
|
|
|
40
34
|
export interface HierarchyNodeProps {
|
|
@@ -69,7 +63,9 @@ export function HierarchyNode({
|
|
|
69
63
|
onModelHeaderClick,
|
|
70
64
|
}: HierarchyNodeProps) {
|
|
71
65
|
const resolvedType = node.ifcType || node.type;
|
|
72
|
-
|
|
66
|
+
// Use Lucide icon for non-IFC structural nodes, Material Symbols for IFC classes
|
|
67
|
+
const LucideIcon = NODE_TYPE_ICONS[node.type];
|
|
68
|
+
const iconCodepoint = getIfcIconCodepoint(resolvedType);
|
|
73
69
|
|
|
74
70
|
// Model header nodes (for visibility control and expansion)
|
|
75
71
|
if (node.type === 'model-header' && node.id.startsWith('model-')) {
|
|
@@ -259,7 +255,17 @@ export function HierarchyNode({
|
|
|
259
255
|
{/* Type Icon */}
|
|
260
256
|
<Tooltip>
|
|
261
257
|
<TooltipTrigger asChild>
|
|
262
|
-
|
|
258
|
+
{LucideIcon ? (
|
|
259
|
+
<LucideIcon className="h-3.5 w-3.5 shrink-0 text-zinc-500 dark:text-zinc-400" />
|
|
260
|
+
) : (
|
|
261
|
+
<span
|
|
262
|
+
className="material-symbols-outlined shrink-0 leading-none text-zinc-500 dark:text-zinc-400"
|
|
263
|
+
style={{ fontSize: '14px' }}
|
|
264
|
+
aria-hidden="true"
|
|
265
|
+
>
|
|
266
|
+
{iconCodepoint}
|
|
267
|
+
</span>
|
|
268
|
+
)}
|
|
263
269
|
</TooltipTrigger>
|
|
264
270
|
<TooltipContent>
|
|
265
271
|
<p className="text-xs">{resolvedType}</p>
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
* IFC class to Material Symbols icon code point mapping.
|
|
7
|
+
* Based on https://github.com/AECgeeks/ifc-icons (MIT license).
|
|
8
|
+
*
|
|
9
|
+
* Values are Unicode code points for the Material Symbols Outlined font.
|
|
10
|
+
*/
|
|
11
|
+
export const IFC_ICON_CODEPOINTS: Record<string, string> = {
|
|
12
|
+
// Spatial / context
|
|
13
|
+
IfcContext: '\uf1c4',
|
|
14
|
+
IfcProject: '\uf1c4',
|
|
15
|
+
IfcProjectLibrary: '\uf1c4',
|
|
16
|
+
IfcSite: '\ue80b',
|
|
17
|
+
IfcBuilding: '\uea40',
|
|
18
|
+
IfcBuildingStorey: '\ue8fe',
|
|
19
|
+
IfcSpace: '\ueff4',
|
|
20
|
+
|
|
21
|
+
// Structural
|
|
22
|
+
IfcBeam: '\uf108',
|
|
23
|
+
IfcBeamStandardCase: '\uf108',
|
|
24
|
+
IfcColumn: '\ue233',
|
|
25
|
+
IfcColumnStandardCase: '\ue233',
|
|
26
|
+
IfcWall: '\ue3c0',
|
|
27
|
+
IfcWallStandardCase: '\ue3c0',
|
|
28
|
+
IfcWallElementedCase: '\ue3c0',
|
|
29
|
+
IfcSlab: '\ue229',
|
|
30
|
+
IfcSlabStandardCase: '\ue229',
|
|
31
|
+
IfcSlabElementedCase: '\ue229',
|
|
32
|
+
IfcRoof: '\uf201',
|
|
33
|
+
IfcFooting: '\uf200',
|
|
34
|
+
IfcPile: '\ue047',
|
|
35
|
+
IfcPlate: '\ue047',
|
|
36
|
+
IfcPlateStandardCase: '\ue047',
|
|
37
|
+
IfcMember: '\ue047',
|
|
38
|
+
IfcMemberStandardCase: '\ue047',
|
|
39
|
+
|
|
40
|
+
// Openings & access
|
|
41
|
+
IfcDoor: '\ueb4f',
|
|
42
|
+
IfcDoorStandardCase: '\ueb4f',
|
|
43
|
+
IfcWindow: '\uf088',
|
|
44
|
+
IfcWindowStandardCase: '\uf088',
|
|
45
|
+
IfcOpeningElement: '\ue3c6',
|
|
46
|
+
IfcOpeningStandardCase: '\ue3c6',
|
|
47
|
+
IfcCurtainWall: '\ue047',
|
|
48
|
+
|
|
49
|
+
// Vertical circulation
|
|
50
|
+
IfcStair: '\uf1a9',
|
|
51
|
+
IfcStairFlight: '\uf1a9',
|
|
52
|
+
IfcRamp: '\ue86b',
|
|
53
|
+
IfcRampFlight: '\ue86b',
|
|
54
|
+
IfcRailing: '\ue58f',
|
|
55
|
+
|
|
56
|
+
// Furnishing
|
|
57
|
+
IfcFurnishingElement: '\uea45',
|
|
58
|
+
IfcFurniture: '\uea45',
|
|
59
|
+
IfcSystemFurnitureElement: '\uea45',
|
|
60
|
+
|
|
61
|
+
// MEP terminals
|
|
62
|
+
IfcAirTerminal: '\uefd8',
|
|
63
|
+
IfcLamp: '\uf02a',
|
|
64
|
+
IfcLightFixture: '\uf02a',
|
|
65
|
+
IfcSanitaryTerminal: '\uea41',
|
|
66
|
+
IfcSpaceHeater: '\uf076',
|
|
67
|
+
IfcAudioVisualAppliance: '\ue333',
|
|
68
|
+
IfcSensor: '\ue51e',
|
|
69
|
+
|
|
70
|
+
// Assemblies & misc
|
|
71
|
+
IfcElementAssembly: '\ue9b0',
|
|
72
|
+
IfcTransportElement: '\uf1a0',
|
|
73
|
+
IfcGrid: '\uf015',
|
|
74
|
+
IfcPort: '\ue8c0',
|
|
75
|
+
IfcDistributionPort: '\ue8c0',
|
|
76
|
+
IfcAnnotation: '\ue3c9',
|
|
77
|
+
|
|
78
|
+
// Civil / geographic
|
|
79
|
+
IfcCivilElement: '\uea99',
|
|
80
|
+
IfcGeographicElement: '\uea99',
|
|
81
|
+
IfcLinearElement: '\uebaa',
|
|
82
|
+
|
|
83
|
+
// Proxy / generic fallback
|
|
84
|
+
IfcProduct: '\ue047',
|
|
85
|
+
IfcBuildingElementProxy: '\ue047',
|
|
86
|
+
IfcProxy: '\ue047',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Default code point for unmapped IFC classes (Material Symbols "widgets" / generic product) */
|
|
90
|
+
export const IFC_ICON_DEFAULT = '\ue047';
|
package/src/hooks/useIfcCache.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
type IfcDataStore as CacheDataStore,
|
|
17
17
|
type GeometryData,
|
|
18
18
|
} from '@ifc-lite/cache';
|
|
19
|
-
import { SpatialHierarchyBuilder, StepTokenizer, extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
|
|
19
|
+
import { SpatialHierarchyBuilder, StepTokenizer, buildCompactEntityIndex, extractLengthUnitScale, type IfcDataStore } from '@ifc-lite/parser';
|
|
20
20
|
import { buildSpatialIndex } from '@ifc-lite/spatial';
|
|
21
21
|
import type { MeshData } from '@ifc-lite/geometry';
|
|
22
22
|
|
|
@@ -102,27 +102,27 @@ export function useIfcCache() {
|
|
|
102
102
|
|
|
103
103
|
// Quick scan to rebuild entity index with byte offsets (needed for on-demand extraction)
|
|
104
104
|
const tokenizer = new StepTokenizer(dataStore.source);
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
byType: new Map<string, number[]>(),
|
|
108
|
-
};
|
|
105
|
+
const entityRefs: Array<{ expressId: number; type: string; byteOffset: number; byteLength: number; lineNumber: number }> = [];
|
|
106
|
+
const byType = new Map<string, number[]>();
|
|
109
107
|
|
|
110
108
|
for (const ref of tokenizer.scanEntitiesFast()) {
|
|
111
|
-
|
|
109
|
+
entityRefs.push({
|
|
112
110
|
expressId: ref.expressId,
|
|
113
111
|
type: ref.type,
|
|
114
112
|
byteOffset: ref.offset,
|
|
115
113
|
byteLength: ref.length,
|
|
116
114
|
lineNumber: ref.line,
|
|
117
115
|
});
|
|
118
|
-
let typeList =
|
|
116
|
+
let typeList = byType.get(ref.type);
|
|
119
117
|
if (!typeList) {
|
|
120
118
|
typeList = [];
|
|
121
|
-
|
|
119
|
+
byType.set(ref.type, typeList);
|
|
122
120
|
}
|
|
123
121
|
typeList.push(ref.expressId);
|
|
124
122
|
}
|
|
125
|
-
|
|
123
|
+
// Use compact entity index (typed arrays) for lower memory usage
|
|
124
|
+
const compactByIdIndex = buildCompactEntityIndex(entityRefs);
|
|
125
|
+
dataStore.entityIndex = { byId: compactByIdIndex, byType };
|
|
126
126
|
|
|
127
127
|
// Rebuild on-demand maps from relationships
|
|
128
128
|
// Pass entityIndex which contains ALL entity types including IfcPropertySet/IfcElementQuantity
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* Global keyboard shortcuts for the viewer
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { useEffect, useCallback } from 'react';
|
|
9
|
+
import { useEffect, useCallback, useRef } from 'react';
|
|
10
10
|
import { useViewerStore } from '@/store';
|
|
11
11
|
import { resetVisibilityForHomeFromStore } from '@/store/homeView';
|
|
12
12
|
import {
|
|
@@ -33,9 +33,14 @@ function getAllSelectedGlobalIds(): number[] {
|
|
|
33
33
|
return [];
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Double-escape threshold in milliseconds */
|
|
37
|
+
const DOUBLE_ESCAPE_MS = 500;
|
|
38
|
+
|
|
36
39
|
export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
37
40
|
const { enabled = true } = options;
|
|
38
41
|
|
|
42
|
+
const lastEscapeRef = useRef<number>(0);
|
|
43
|
+
|
|
39
44
|
const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
|
|
40
45
|
const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
|
|
41
46
|
const activeTool = useViewerStore((s) => s.activeTool);
|
|
@@ -181,9 +186,29 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
|
|
|
181
186
|
}
|
|
182
187
|
}
|
|
183
188
|
|
|
184
|
-
//
|
|
189
|
+
// Escape: first press clears selection/tool, double-press closes all panels
|
|
185
190
|
if (key === 'escape') {
|
|
186
191
|
e.preventDefault();
|
|
192
|
+
const now = Date.now();
|
|
193
|
+
const timeSinceLastEscape = now - lastEscapeRef.current;
|
|
194
|
+
lastEscapeRef.current = now;
|
|
195
|
+
|
|
196
|
+
if (timeSinceLastEscape < DOUBLE_ESCAPE_MS) {
|
|
197
|
+
// Double-escape: close all panels, return to starting view
|
|
198
|
+
const state = useViewerStore.getState();
|
|
199
|
+
state.setBcfPanelVisible(false);
|
|
200
|
+
state.setIdsPanelVisible(false);
|
|
201
|
+
state.setLensPanelVisible(false);
|
|
202
|
+
state.setScriptPanelVisible(false);
|
|
203
|
+
state.setListPanelVisible(false);
|
|
204
|
+
state.setDrawing2DPanelVisible(false);
|
|
205
|
+
state.setOverridesPanelVisible(false);
|
|
206
|
+
state.setChatPanelVisible(false);
|
|
207
|
+
state.setSheetPanelVisible(false);
|
|
208
|
+
state.setLeftPanelCollapsed(false);
|
|
209
|
+
state.setRightPanelCollapsed(false);
|
|
210
|
+
}
|
|
211
|
+
|
|
187
212
|
setSelectedEntityId(null);
|
|
188
213
|
resetVisibilityForHomeFromStore();
|
|
189
214
|
setActiveTool('select');
|
|
@@ -246,6 +271,7 @@ export const KEYBOARD_SHORTCUTS = [
|
|
|
246
271
|
{ key: '1-6', description: 'Preset views', category: 'Camera' },
|
|
247
272
|
{ key: 'T', description: 'Toggle theme', category: 'UI' },
|
|
248
273
|
{ key: 'Esc', description: 'Reset all (clear selection, basket, isolation)', category: 'Selection' },
|
|
274
|
+
{ key: 'Esc Esc', description: 'Close all panels (return to starting view)', category: 'UI' },
|
|
249
275
|
{ key: 'Ctrl+K', description: 'Command palette', category: 'UI' },
|
|
250
276
|
{ key: '?', description: 'Show info panel', category: 'Help' },
|
|
251
277
|
] as const;
|
|
@@ -4,7 +4,84 @@
|
|
|
4
4
|
|
|
5
5
|
import type { EntityRef, VisibilityBackendMethods } from '@ifc-lite/sdk';
|
|
6
6
|
import type { StoreApi } from './types.js';
|
|
7
|
-
import { getModelForRef } from './model-compat.js';
|
|
7
|
+
import { getModelForRef, type ModelLike } from './model-compat.js';
|
|
8
|
+
import { collectIfcBuildingStoreyElementsWithIfcSpace } from '../../store/basketVisibleSet.js';
|
|
9
|
+
import { IfcTypeEnum, type SpatialNode } from '@ifc-lite/data';
|
|
10
|
+
|
|
11
|
+
const SPATIAL_TYPES = new Set([
|
|
12
|
+
'IfcBuildingStorey',
|
|
13
|
+
'IfcBuilding',
|
|
14
|
+
'IfcSite',
|
|
15
|
+
'IfcProject',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
function findDescendantNode(root: SpatialNode, expressId: number): SpatialNode | null {
|
|
19
|
+
const stack: SpatialNode[] = [root];
|
|
20
|
+
while (stack.length > 0) {
|
|
21
|
+
const node = stack.pop()!;
|
|
22
|
+
if (node.expressId === expressId) return node;
|
|
23
|
+
for (const child of node.children) {
|
|
24
|
+
stack.push(child);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function collectDescendantStoreyIds(node: SpatialNode): number[] {
|
|
31
|
+
const storeyIds: number[] = [];
|
|
32
|
+
const stack: SpatialNode[] = [node];
|
|
33
|
+
while (stack.length > 0) {
|
|
34
|
+
const current = stack.pop()!;
|
|
35
|
+
if (current.type === IfcTypeEnum.IfcBuildingStorey) {
|
|
36
|
+
storeyIds.push(current.expressId);
|
|
37
|
+
}
|
|
38
|
+
for (const child of current.children) {
|
|
39
|
+
stack.push(child);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return storeyIds;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* If `ref` points to a spatial structure element (storey, building, etc.),
|
|
47
|
+
* expand it to the local expressIds of all contained elements.
|
|
48
|
+
* Otherwise return the original expressId as-is.
|
|
49
|
+
*/
|
|
50
|
+
function expandSpatialRef(ref: EntityRef, model: ModelLike): number[] {
|
|
51
|
+
const dataStore = model.ifcDataStore;
|
|
52
|
+
const typeName = dataStore.entities.getTypeName(ref.expressId) || '';
|
|
53
|
+
if (!SPATIAL_TYPES.has(typeName)) return [ref.expressId];
|
|
54
|
+
|
|
55
|
+
const hierarchy = dataStore.spatialHierarchy;
|
|
56
|
+
if (!hierarchy) return [ref.expressId];
|
|
57
|
+
|
|
58
|
+
if (typeName === 'IfcBuildingStorey') {
|
|
59
|
+
const ids = collectIfcBuildingStoreyElementsWithIfcSpace(hierarchy, ref.expressId);
|
|
60
|
+
return ids && ids.length > 0 ? ids : [ref.expressId];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// For higher-level containers (IfcBuilding, IfcSite, IfcProject),
|
|
64
|
+
// walk the spatial tree from ref.expressId to find descendant storeys only
|
|
65
|
+
const startNode = findDescendantNode(hierarchy.project, ref.expressId);
|
|
66
|
+
if (!startNode) return [ref.expressId];
|
|
67
|
+
|
|
68
|
+
const descendantStoreyIds = collectDescendantStoreyIds(startNode);
|
|
69
|
+
|
|
70
|
+
const allIds: number[] = [];
|
|
71
|
+
const seen = new Set<number>();
|
|
72
|
+
for (const storeyId of descendantStoreyIds) {
|
|
73
|
+
const storeyIds = collectIfcBuildingStoreyElementsWithIfcSpace(hierarchy, storeyId);
|
|
74
|
+
if (storeyIds) {
|
|
75
|
+
for (const id of storeyIds) {
|
|
76
|
+
if (!seen.has(id)) {
|
|
77
|
+
seen.add(id);
|
|
78
|
+
allIds.push(id);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return allIds.length > 0 ? allIds : [ref.expressId];
|
|
84
|
+
}
|
|
8
85
|
|
|
9
86
|
export function createVisibilityAdapter(store: StoreApi): VisibilityBackendMethods {
|
|
10
87
|
return {
|
|
@@ -44,7 +121,10 @@ export function createVisibilityAdapter(store: StoreApi): VisibilityBackendMetho
|
|
|
44
121
|
for (const ref of refs) {
|
|
45
122
|
const model = getModelForRef(state, ref.modelId);
|
|
46
123
|
if (model) {
|
|
47
|
-
|
|
124
|
+
const expanded = expandSpatialRef(ref, model);
|
|
125
|
+
for (const id of expanded) {
|
|
126
|
+
globalIds.push(id + model.idOffset);
|
|
127
|
+
}
|
|
48
128
|
}
|
|
49
129
|
}
|
|
50
130
|
if (globalIds.length > 0) {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
4
|
|
|
5
|
-
import { IfcTypeEnum, type SpatialNode } from '@ifc-lite/data';
|
|
5
|
+
import { IfcTypeEnum, type SpatialNode, type SpatialHierarchy } from '@ifc-lite/data';
|
|
6
6
|
import type { IfcDataStore } from '@ifc-lite/parser';
|
|
7
7
|
import type { EntityRef } from './types.js';
|
|
8
8
|
import { entityRefToString, stringToEntityRef } from './types.js';
|
|
@@ -57,6 +57,7 @@ function visibilityFingerprint(state: ViewerStateSnapshot): string {
|
|
|
57
57
|
return [
|
|
58
58
|
digestNumberSet(state.hiddenEntities),
|
|
59
59
|
state.isolatedEntities ? digestNumberSet(state.isolatedEntities) : 'none',
|
|
60
|
+
state.classFilter ? digestNumberSet(state.classFilter.ids) : 'none',
|
|
60
61
|
digestNumberSet(state.lensHiddenIds),
|
|
61
62
|
digestModelEntityMap(state.hiddenEntitiesByModel),
|
|
62
63
|
digestModelEntityMap(state.isolatedEntitiesByModel),
|
|
@@ -273,6 +274,52 @@ function getExpandedSelectionRefs(state: ViewerStateSnapshot): EntityRef[] {
|
|
|
273
274
|
return dedupeRefs(baseRefs.flatMap((ref) => expandRefToElements(state, ref)));
|
|
274
275
|
}
|
|
275
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Collect all descendant IfcSpace expressIds from a spatial node.
|
|
279
|
+
*/
|
|
280
|
+
function collectDescendantSpaceIds(node: SpatialNode): number[] {
|
|
281
|
+
const spaceIds: number[] = [];
|
|
282
|
+
for (const child of node.children || []) {
|
|
283
|
+
if (child.type === IfcTypeEnum.IfcSpace) {
|
|
284
|
+
spaceIds.push(child.expressId);
|
|
285
|
+
}
|
|
286
|
+
// Recurse into all children (spaces can nest under other spatial nodes)
|
|
287
|
+
spaceIds.push(...collectDescendantSpaceIds(child));
|
|
288
|
+
}
|
|
289
|
+
return spaceIds;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Collect all element IDs for an IfcBuildingStorey, including elements
|
|
294
|
+
* contained in descendant IfcSpace nodes and the space geometry itself.
|
|
295
|
+
*/
|
|
296
|
+
export function collectIfcBuildingStoreyElementsWithIfcSpace(
|
|
297
|
+
hierarchy: SpatialHierarchy,
|
|
298
|
+
storeyId: number
|
|
299
|
+
): number[] | null {
|
|
300
|
+
const storeyElements = hierarchy.byStorey.get(storeyId);
|
|
301
|
+
if (!storeyElements) return null;
|
|
302
|
+
|
|
303
|
+
const storeyNode = findSpatialNode(hierarchy.project, storeyId);
|
|
304
|
+
if (!storeyNode) return storeyElements;
|
|
305
|
+
|
|
306
|
+
const spaceIds = collectDescendantSpaceIds(storeyNode);
|
|
307
|
+
if (spaceIds.length === 0) return storeyElements;
|
|
308
|
+
|
|
309
|
+
// Combine storey elements + space expressIds + elements inside spaces
|
|
310
|
+
const combined = [...storeyElements];
|
|
311
|
+
for (const spaceId of spaceIds) {
|
|
312
|
+
combined.push(spaceId); // The space geometry itself
|
|
313
|
+
const spaceElements = hierarchy.bySpace.get(spaceId);
|
|
314
|
+
if (spaceElements) {
|
|
315
|
+
for (const elemId of spaceElements) {
|
|
316
|
+
combined.push(elemId);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return combined;
|
|
321
|
+
}
|
|
322
|
+
|
|
276
323
|
function computeStoreyIsolation(state: ViewerStateSnapshot): Set<number> | null {
|
|
277
324
|
if (state.selectedStoreys.size === 0) return null;
|
|
278
325
|
|
|
@@ -284,7 +331,8 @@ function computeStoreyIsolation(state: ViewerStateSnapshot): Set<number> | null
|
|
|
284
331
|
if (!hierarchy) continue;
|
|
285
332
|
const offset = model.idOffset ?? 0;
|
|
286
333
|
for (const storeyId of state.selectedStoreys) {
|
|
287
|
-
const
|
|
334
|
+
const localStoreyId = hierarchy.byStorey.has(storeyId) ? storeyId : storeyId - offset;
|
|
335
|
+
const storeyElementIds = collectIfcBuildingStoreyElementsWithIfcSpace(hierarchy, localStoreyId);
|
|
288
336
|
if (!storeyElementIds) continue;
|
|
289
337
|
for (const localId of storeyElementIds) {
|
|
290
338
|
ids.add(localId + offset);
|
|
@@ -292,8 +340,9 @@ function computeStoreyIsolation(state: ViewerStateSnapshot): Set<number> | null
|
|
|
292
340
|
}
|
|
293
341
|
}
|
|
294
342
|
} else if (state.ifcDataStore?.spatialHierarchy) {
|
|
343
|
+
const hierarchy = state.ifcDataStore.spatialHierarchy;
|
|
295
344
|
for (const storeyId of state.selectedStoreys) {
|
|
296
|
-
const storeyElementIds =
|
|
345
|
+
const storeyElementIds = collectIfcBuildingStoreyElementsWithIfcSpace(hierarchy, storeyId);
|
|
297
346
|
if (!storeyElementIds) continue;
|
|
298
347
|
for (const id of storeyElementIds) {
|
|
299
348
|
ids.add(id);
|
|
@@ -345,7 +394,26 @@ function getVisibleGlobalIds(state: ViewerStateSnapshot): Set<number> {
|
|
|
345
394
|
globalHidden.add(id);
|
|
346
395
|
}
|
|
347
396
|
|
|
348
|
-
|
|
397
|
+
// Collect all active filter sets and intersect them
|
|
398
|
+
const filters: Set<number>[] = [];
|
|
399
|
+
const storeyIsolation = computeStoreyIsolation(state);
|
|
400
|
+
if (storeyIsolation !== null) filters.push(storeyIsolation);
|
|
401
|
+
if (state.classFilter !== null) filters.push(state.classFilter.ids);
|
|
402
|
+
if (state.isolatedEntities !== null) filters.push(state.isolatedEntities);
|
|
403
|
+
|
|
404
|
+
let globalIsolation: Set<number> | null = null;
|
|
405
|
+
if (filters.length === 1) {
|
|
406
|
+
globalIsolation = filters[0];
|
|
407
|
+
} else if (filters.length > 1) {
|
|
408
|
+
// Intersect all active filters — start from smallest for efficiency
|
|
409
|
+
const sorted = filters.sort((a, b) => a.size - b.size);
|
|
410
|
+
globalIsolation = new Set<number>();
|
|
411
|
+
for (const id of sorted[0]) {
|
|
412
|
+
if (sorted.every(s => s.has(id))) {
|
|
413
|
+
globalIsolation.add(id);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
349
417
|
|
|
350
418
|
const visible = new Set<number>();
|
|
351
419
|
for (const candidate of candidates) {
|
package/src/store/index.ts
CHANGED
|
@@ -130,7 +130,7 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
|
|
|
130
130
|
// Note: Does NOT clear models - use clearAllModels() for that
|
|
131
131
|
resetViewerState: () => {
|
|
132
132
|
invalidateVisibleBasketCache();
|
|
133
|
-
const [set] = args;
|
|
133
|
+
const [set, get] = args;
|
|
134
134
|
set({
|
|
135
135
|
// Selection (legacy)
|
|
136
136
|
selectedEntityId: null,
|
|
@@ -144,6 +144,7 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
|
|
|
144
144
|
// Visibility (legacy)
|
|
145
145
|
hiddenEntities: new Set(),
|
|
146
146
|
isolatedEntities: null,
|
|
147
|
+
classFilter: null,
|
|
147
148
|
typeVisibility: {
|
|
148
149
|
spaces: TYPE_VISIBILITY_DEFAULTS.SPACES,
|
|
149
150
|
openings: TYPE_VISIBILITY_DEFAULTS.OPENINGS,
|
|
@@ -303,6 +304,15 @@ export const useViewerStore = create<ViewerState>()((...args) => ({
|
|
|
303
304
|
chatStreamingContent: '',
|
|
304
305
|
chatError: null,
|
|
305
306
|
chatAbortController: null,
|
|
307
|
+
|
|
308
|
+
// Mutations - clear all mutation state so stale changes don't carry over
|
|
309
|
+
mutationViews: new Map(),
|
|
310
|
+
changeSets: new Map(),
|
|
311
|
+
activeChangeSetId: null,
|
|
312
|
+
undoStacks: new Map(),
|
|
313
|
+
redoStacks: new Map(),
|
|
314
|
+
dirtyModels: new Set(),
|
|
315
|
+
mutationVersion: get().mutationVersion + 1,
|
|
306
316
|
});
|
|
307
317
|
},
|
|
308
318
|
}));
|
|
@@ -53,7 +53,14 @@ export interface SaveBasketViewOptions {
|
|
|
53
53
|
section?: BasketSectionSnapshot | null;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/**
|
|
56
|
+
/**
|
|
57
|
+
* Cross-slice state that pinboard reads/writes via the combined store.
|
|
58
|
+
*
|
|
59
|
+
* When the basket is non-empty, pinboard owns `isolatedEntities` and
|
|
60
|
+
* `hiddenEntities` — it is the isolation mechanism. The visibility slice
|
|
61
|
+
* also writes these fields for non-basket isolation (direct UI isolation).
|
|
62
|
+
* They share the same state fields by design.
|
|
63
|
+
*/
|
|
57
64
|
interface PinboardCrossSliceState {
|
|
58
65
|
isolatedEntities: Set<number> | null;
|
|
59
66
|
hiddenEntities: Set<number>;
|
|
@@ -166,6 +173,35 @@ function entityKeysToRefs(keys: Iterable<string>): EntityRef[] {
|
|
|
166
173
|
return refs;
|
|
167
174
|
}
|
|
168
175
|
|
|
176
|
+
/**
|
|
177
|
+
* Compute isolation + hidden state from basket entities, unhiding any newly added refs.
|
|
178
|
+
*
|
|
179
|
+
* This is the single source of truth for the "basket → visibility" sync that
|
|
180
|
+
* several pinboard actions need. The incremental add/remove methods bypass
|
|
181
|
+
* this for performance and maintain their own logic.
|
|
182
|
+
*/
|
|
183
|
+
function computeBasketVisibility(
|
|
184
|
+
nextBasket: Set<string>,
|
|
185
|
+
models: Map<string, { idOffset: number }>,
|
|
186
|
+
currentHidden: Set<number>,
|
|
187
|
+
unhideRefs?: EntityRef[],
|
|
188
|
+
): { isolatedEntities: Set<number> | null; hiddenEntities: Set<number> } {
|
|
189
|
+
if (nextBasket.size === 0) {
|
|
190
|
+
return { isolatedEntities: null, hiddenEntities: currentHidden };
|
|
191
|
+
}
|
|
192
|
+
const isolatedEntities = basketToGlobalIds(nextBasket, models);
|
|
193
|
+
if (!unhideRefs || unhideRefs.length === 0) {
|
|
194
|
+
return { isolatedEntities, hiddenEntities: currentHidden };
|
|
195
|
+
}
|
|
196
|
+
const hiddenEntities = new Set<number>(currentHidden);
|
|
197
|
+
for (const ref of unhideRefs) {
|
|
198
|
+
const model = models.get(ref.modelId);
|
|
199
|
+
const offset = model?.idOffset ?? 0;
|
|
200
|
+
hiddenEntities.delete(ref.expressId + offset);
|
|
201
|
+
}
|
|
202
|
+
return { isolatedEntities, hiddenEntities };
|
|
203
|
+
}
|
|
204
|
+
|
|
169
205
|
function createViewId(): string {
|
|
170
206
|
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
|
|
171
207
|
return crypto.randomUUID();
|
|
@@ -218,18 +254,10 @@ export const createPinboardSlice: StateCreator<
|
|
|
218
254
|
for (const ref of refs) {
|
|
219
255
|
next.add(entityRefToString(ref));
|
|
220
256
|
}
|
|
221
|
-
const
|
|
222
|
-
const hiddenEntities = new Set<number>(state.hiddenEntities);
|
|
223
|
-
// Unhide any entities being added to basket
|
|
224
|
-
for (const ref of refs) {
|
|
225
|
-
const model = state.models.get(ref.modelId);
|
|
226
|
-
const offset = model?.idOffset ?? 0;
|
|
227
|
-
hiddenEntities.delete(ref.expressId + offset);
|
|
228
|
-
}
|
|
257
|
+
const visibility = computeBasketVisibility(next, state.models, state.hiddenEntities, refs);
|
|
229
258
|
return {
|
|
230
259
|
pinboardEntities: next,
|
|
231
|
-
|
|
232
|
-
hiddenEntities,
|
|
260
|
+
...visibility,
|
|
233
261
|
activeBasketViewId: null,
|
|
234
262
|
};
|
|
235
263
|
});
|
|
@@ -257,20 +285,9 @@ export const createPinboardSlice: StateCreator<
|
|
|
257
285
|
for (const ref of refs) {
|
|
258
286
|
next.add(entityRefToString(ref));
|
|
259
287
|
}
|
|
260
|
-
if (next.size === 0) {
|
|
261
|
-
set({ pinboardEntities: next, isolatedEntities: null, activeBasketViewId: null });
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
288
|
const s = get();
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
for (const ref of refs) {
|
|
268
|
-
const model = s.models.get(ref.modelId);
|
|
269
|
-
const offset = model?.idOffset ?? 0;
|
|
270
|
-
hiddenEntities.delete(ref.expressId + offset);
|
|
271
|
-
}
|
|
272
|
-
const isolatedEntities = basketToGlobalIds(next, s.models);
|
|
273
|
-
set({ pinboardEntities: next, isolatedEntities, hiddenEntities, activeBasketViewId: null });
|
|
289
|
+
const visibility = computeBasketVisibility(next, s.models, s.hiddenEntities, refs);
|
|
290
|
+
set({ pinboardEntities: next, ...visibility, activeBasketViewId: null });
|
|
274
291
|
},
|
|
275
292
|
|
|
276
293
|
clearPinboard: () => set({ pinboardEntities: new Set(), isolatedEntities: null, activeBasketViewId: null }),
|
|
@@ -311,15 +328,8 @@ export const createPinboardSlice: StateCreator<
|
|
|
311
328
|
next.add(entityRefToString(ref));
|
|
312
329
|
}
|
|
313
330
|
const s = get();
|
|
314
|
-
const
|
|
315
|
-
|
|
316
|
-
for (const ref of refs) {
|
|
317
|
-
const model = s.models.get(ref.modelId);
|
|
318
|
-
const offset = model?.idOffset ?? 0;
|
|
319
|
-
hiddenEntities.delete(ref.expressId + offset);
|
|
320
|
-
}
|
|
321
|
-
const isolatedEntities = basketToGlobalIds(next, s.models);
|
|
322
|
-
set({ pinboardEntities: next, isolatedEntities, hiddenEntities, activeBasketViewId: null });
|
|
331
|
+
const visibility = computeBasketVisibility(next, s.models, s.hiddenEntities, refs);
|
|
332
|
+
set({ pinboardEntities: next, ...visibility, activeBasketViewId: null });
|
|
323
333
|
},
|
|
324
334
|
|
|
325
335
|
/** + Add entities to basket and update isolation (incremental — avoids re-parsing all strings) */
|
|
@@ -410,20 +420,11 @@ export const createPinboardSlice: StateCreator<
|
|
|
410
420
|
get().clearEntitySelection?.();
|
|
411
421
|
set((current) => {
|
|
412
422
|
const nextPinboard = new Set<string>(entityRefs);
|
|
413
|
-
if (nextPinboard.size === 0) {
|
|
414
|
-
return { pinboardEntities: new Set(), isolatedEntities: null, activeBasketViewId: viewId };
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const hiddenEntities = new Set<number>(current.hiddenEntities);
|
|
418
423
|
const refs = entityKeysToRefs(nextPinboard);
|
|
419
|
-
|
|
420
|
-
hiddenEntities.delete(refToGlobalId(ref, current.models));
|
|
421
|
-
}
|
|
422
|
-
|
|
424
|
+
const visibility = computeBasketVisibility(nextPinboard, current.models, current.hiddenEntities, refs);
|
|
423
425
|
return {
|
|
424
|
-
pinboardEntities: nextPinboard,
|
|
425
|
-
|
|
426
|
-
hiddenEntities,
|
|
426
|
+
pinboardEntities: nextPinboard.size === 0 ? new Set() : nextPinboard,
|
|
427
|
+
...visibility,
|
|
427
428
|
activeBasketViewId: viewId,
|
|
428
429
|
};
|
|
429
430
|
});
|