@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.
Files changed (32) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/dist/assets/{Arrow.dom-_vGzMMKs.js → Arrow.dom--gdrQd-q.js} +1 -1
  3. package/dist/assets/{basketViewActivator-BZcoCL3V.js → basketViewActivator-CI3y6VYQ.js} +1 -1
  4. package/dist/assets/{browser-Czmf34bo.js → browser-vWDubxDI.js} +1 -1
  5. package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
  6. package/dist/assets/index-BImINgzG.js +187371 -0
  7. package/dist/assets/{index-D7nEDctQ.js → index-RXIK18da.js} +4 -4
  8. package/dist/assets/index-ax1X2WPd.css +1 -0
  9. package/dist/assets/{native-bridge-DAOWftxE.js → native-bridge-4rLidc3f.js} +1 -1
  10. package/dist/assets/{wasm-bridge-D7jYpn8a.js → wasm-bridge-BkfXfw8O.js} +1 -1
  11. package/dist/index.html +7 -2
  12. package/index.html +5 -0
  13. package/package.json +9 -9
  14. package/src/components/viewer/ExportDialog.tsx +40 -2
  15. package/src/components/viewer/HierarchyPanel.tsx +99 -22
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +184 -82
  17. package/src/components/viewer/ViewportContainer.tsx +30 -25
  18. package/src/components/viewer/hierarchy/HierarchyNode.tsx +26 -20
  19. package/src/components/viewer/hierarchy/ifc-icons.ts +90 -0
  20. package/src/hooks/useIfcCache.ts +9 -9
  21. package/src/hooks/useKeyboardShortcuts.ts +28 -2
  22. package/src/sdk/adapters/visibility-adapter.ts +82 -2
  23. package/src/store/basketVisibleSet.ts +72 -4
  24. package/src/store/index.ts +11 -1
  25. package/src/store/slices/pinboardSlice.ts +46 -45
  26. package/src/store/slices/visibilitySlice.ts +28 -2
  27. package/src/utils/spatialHierarchy.ts +1 -1
  28. package/src/vite-env.d.ts +6 -2
  29. package/vite.config.ts +75 -23
  30. package/dist/assets/ifc-lite_bg-DyBKoGgk.wasm +0 -0
  31. package/dist/assets/index-CMQ_Dgkr.css +0 -1
  32. 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
- const TYPE_ICONS: Record<string, React.ElementType> = {
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
- const Icon = TYPE_ICONS[resolvedType] || TYPE_ICONS[node.type] || TYPE_ICONS.default;
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
- <Icon className="h-3.5 w-3.5 shrink-0 text-zinc-500 dark:text-zinc-400" />
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';
@@ -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 entityIndex = {
106
- byId: new Map<number, any>(),
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
- entityIndex.byId.set(ref.expressId, {
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 = entityIndex.byType.get(ref.type);
116
+ let typeList = byType.get(ref.type);
119
117
  if (!typeList) {
120
118
  typeList = [];
121
- entityIndex.byType.set(ref.type, typeList);
119
+ byType.set(ref.type, typeList);
122
120
  }
123
121
  typeList.push(ref.expressId);
124
122
  }
125
- dataStore.entityIndex = entityIndex;
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
- // Selection - Escape clears selection and switches to select tool
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
- globalIds.push(ref.expressId + model.idOffset);
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 storeyElementIds = hierarchy.byStorey.get(storeyId) || hierarchy.byStorey.get(storeyId - offset);
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 = state.ifcDataStore.spatialHierarchy.byStorey.get(storeyId);
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
- const globalIsolation = state.isolatedEntities ?? computeStoreyIsolation(state);
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) {
@@ -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
- /** Cross-slice state that pinboard reads/writes via the combined store */
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 isolatedEntities = basketToGlobalIds(next, state.models);
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
- isolatedEntities,
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 hiddenEntities = new Set<number>(s.hiddenEntities);
266
- // Unhide basket entities
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 hiddenEntities = new Set<number>(s.hiddenEntities);
315
- // Unhide basket entities
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
- for (const ref of refs) {
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
- isolatedEntities: basketToGlobalIds(nextPinboard, current.models),
426
- hiddenEntities,
426
+ pinboardEntities: nextPinboard.size === 0 ? new Set() : nextPinboard,
427
+ ...visibility,
427
428
  activeBasketViewId: viewId,
428
429
  };
429
430
  });