@ifc-lite/viewer 1.16.0 → 1.17.1

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 (63) hide show
  1. package/.turbo/turbo-build.log +42 -0
  2. package/.turbo/turbo-typecheck.log +44 -0
  3. package/CHANGELOG.md +25 -0
  4. package/dist/assets/{Arrow.dom--gdrQd-q.js → Arrow.dom-DuPUrOxJ.js} +1 -1
  5. package/dist/assets/{basketViewActivator-CI3y6VYQ.js → basketViewActivator-DetjPnvt.js} +1 -1
  6. package/dist/assets/{browser-vWDubxDI.js → browser-BQdwnOUt.js} +1 -1
  7. package/dist/assets/geometry.worker-Bjm-ukng.js +1 -0
  8. package/dist/assets/ifc-lite_bg-DD0A7Yow.wasm +0 -0
  9. package/dist/assets/{index-RXIK18da.js → index-B3X21yXA.js} +4 -4
  10. package/dist/assets/index-Ba4eoTe7.css +1 -0
  11. package/dist/assets/{index-BImINgzG.js → index-BybGZJTW.js} +29281 -27174
  12. package/dist/assets/{native-bridge-4rLidc3f.js → native-bridge-CN0ZMR2t.js} +1 -1
  13. package/dist/assets/{wasm-bridge-BkfXfw8O.js → wasm-bridge-D0bALkma.js} +1 -1
  14. package/dist/index.html +2 -2
  15. package/package.json +14 -13
  16. package/src/components/viewer/BCFPanel.tsx +12 -0
  17. package/src/components/viewer/BulkPropertyEditor.tsx +315 -154
  18. package/src/components/viewer/CommandPalette.tsx +0 -6
  19. package/src/components/viewer/DataConnector.tsx +489 -284
  20. package/src/components/viewer/ExportDialog.tsx +66 -6
  21. package/src/components/viewer/KeyboardShortcutsDialog.tsx +44 -1
  22. package/src/components/viewer/MainToolbar.tsx +1 -5
  23. package/src/components/viewer/PropertiesPanel.tsx +6 -7
  24. package/src/components/viewer/Viewport.tsx +42 -56
  25. package/src/components/viewer/ViewportContainer.tsx +3 -0
  26. package/src/components/viewer/ViewportOverlays.tsx +12 -10
  27. package/src/components/viewer/bcf/BCFOverlay.tsx +254 -0
  28. package/src/components/viewer/hierarchy/treeDataBuilder.test.ts +70 -0
  29. package/src/components/viewer/hierarchy/treeDataBuilder.ts +35 -10
  30. package/src/components/viewer/hierarchy/types.ts +24 -2
  31. package/src/components/viewer/lists/ListPanel.tsx +0 -21
  32. package/src/components/viewer/lists/ListResultsTable.tsx +93 -5
  33. package/src/components/viewer/measureHandlers.ts +558 -0
  34. package/src/components/viewer/mouseHandlerTypes.ts +108 -0
  35. package/src/components/viewer/selectionHandlers.ts +86 -0
  36. package/src/components/viewer/useAnimationLoop.ts +116 -44
  37. package/src/components/viewer/useGeometryStreaming.ts +155 -367
  38. package/src/components/viewer/useKeyboardControls.ts +30 -46
  39. package/src/components/viewer/useMouseControls.ts +169 -695
  40. package/src/components/viewer/useRenderUpdates.ts +9 -59
  41. package/src/components/viewer/useTouchControls.ts +55 -40
  42. package/src/hooks/bcfIdLookup.ts +70 -0
  43. package/src/hooks/useBCF.ts +12 -31
  44. package/src/hooks/useIfcCache.ts +2 -20
  45. package/src/hooks/useIfcFederation.ts +5 -11
  46. package/src/hooks/useIfcLoader.ts +47 -56
  47. package/src/hooks/useIfcServer.ts +9 -1
  48. package/src/hooks/useKeyboardShortcuts.ts +0 -10
  49. package/src/hooks/useLatestRef.ts +24 -0
  50. package/src/sdk/adapters/export-adapter.ts +2 -2
  51. package/src/sdk/adapters/model-adapter.ts +1 -0
  52. package/src/sdk/adapters/visibility-adapter.ts +7 -49
  53. package/src/sdk/local-backend.ts +2 -0
  54. package/src/store/basketVisibleSet.test.ts +73 -3
  55. package/src/store/basketVisibleSet.ts +58 -75
  56. package/src/store/slices/bcfSlice.ts +9 -0
  57. package/src/utils/loadingUtils.ts +46 -0
  58. package/src/utils/serverDataModel.test.ts +90 -0
  59. package/src/utils/serverDataModel.ts +26 -37
  60. package/src/utils/spatialHierarchy.test.ts +38 -0
  61. package/src/utils/spatialHierarchy.ts +13 -23
  62. package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
  63. package/dist/assets/index-ax1X2WPd.css +0 -1
@@ -0,0 +1,254 @@
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
+ * BCFOverlay — renders BCF topic markers as 3D-positioned overlays in the viewport.
7
+ *
8
+ * Connects:
9
+ * - Zustand store (BCF topics, active topic)
10
+ * - Renderer (camera projection, entity bounds)
11
+ * - BCFOverlayRenderer (pure DOM marker rendering)
12
+ * - BCF panel (click marker → open topic, bidirectional sync)
13
+ *
14
+ * KEY DESIGN: Bounds lookup queries the renderer Scene directly via a
15
+ * mutable ref (not React state). Marker computation is triggered by an
16
+ * `overlayReady` counter that bumps once the renderer is available AND
17
+ * when loading completes (ensuring bounding boxes are cached).
18
+ * The camera's current distance is passed as `targetDistance` so fallback
19
+ * markers land at the orbit center — not at hardcoded 10 units.
20
+ */
21
+
22
+ import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
23
+ import { useViewerStore } from '@/store';
24
+ import { getGlobalRenderer } from '@/hooks/useBCF';
25
+ import { globalIdToExpressId as globalIdToExpressIdLookup } from '@/hooks/bcfIdLookup';
26
+ import {
27
+ computeMarkerPositions,
28
+ BCFOverlayRenderer,
29
+ type BCFOverlayProjection,
30
+ type OverlayBBox,
31
+ type OverlayPoint3D,
32
+ type EntityBoundsLookup,
33
+ } from '@ifc-lite/bcf';
34
+ import type { Renderer } from '@ifc-lite/renderer';
35
+
36
+ // ============================================================================
37
+ // WebGPU projection adapter
38
+ // ============================================================================
39
+
40
+ function createWebGPUProjection(
41
+ renderer: Renderer,
42
+ canvas: HTMLCanvasElement,
43
+ ): BCFOverlayProjection {
44
+ let prevPosX = NaN;
45
+ let prevPosY = NaN;
46
+ let prevPosZ = NaN;
47
+ let prevTgtX = NaN;
48
+ let prevTgtY = NaN;
49
+ let prevTgtZ = NaN;
50
+ let prevWidth = 0;
51
+ let prevHeight = 0;
52
+
53
+ const listeners = new Set<() => void>();
54
+ let rafId: number | null = null;
55
+ let listenerCount = 0;
56
+
57
+ function poll() {
58
+ rafId = requestAnimationFrame(poll);
59
+ const cam = renderer.getCamera();
60
+ const pos = cam.getPosition();
61
+ const tgt = cam.getTarget();
62
+ const w = canvas.clientWidth;
63
+ const h = canvas.clientHeight;
64
+
65
+ if (
66
+ pos.x !== prevPosX || pos.y !== prevPosY || pos.z !== prevPosZ ||
67
+ tgt.x !== prevTgtX || tgt.y !== prevTgtY || tgt.z !== prevTgtZ ||
68
+ w !== prevWidth || h !== prevHeight
69
+ ) {
70
+ prevPosX = pos.x; prevPosY = pos.y; prevPosZ = pos.z;
71
+ prevTgtX = tgt.x; prevTgtY = tgt.y; prevTgtZ = tgt.z;
72
+ prevWidth = w; prevHeight = h;
73
+ for (const cb of listeners) cb();
74
+ }
75
+ }
76
+
77
+ return {
78
+ projectToScreen(worldPos: OverlayPoint3D) {
79
+ return renderer.getCamera().projectToScreen(
80
+ worldPos,
81
+ canvas.clientWidth,
82
+ canvas.clientHeight,
83
+ );
84
+ },
85
+
86
+ getEntityBounds(expressId: number): OverlayBBox | null {
87
+ return renderer.getScene().getEntityBoundingBox(expressId);
88
+ },
89
+
90
+ getCanvasSize() {
91
+ return { width: canvas.clientWidth, height: canvas.clientHeight };
92
+ },
93
+
94
+ getCameraPosition(): OverlayPoint3D {
95
+ return renderer.getCamera().getPosition();
96
+ },
97
+
98
+ onCameraChange(callback: () => void) {
99
+ listeners.add(callback);
100
+ listenerCount++;
101
+ if (listenerCount === 1) rafId = requestAnimationFrame(poll);
102
+ return () => {
103
+ listeners.delete(callback);
104
+ listenerCount--;
105
+ if (listenerCount === 0 && rafId !== null) {
106
+ cancelAnimationFrame(rafId);
107
+ rafId = null;
108
+ }
109
+ };
110
+ },
111
+ };
112
+ }
113
+
114
+ // ============================================================================
115
+ // React Component
116
+ // ============================================================================
117
+
118
+ export function BCFOverlay() {
119
+ const containerRef = useRef<HTMLDivElement>(null);
120
+ const overlayRef = useRef<BCFOverlayRenderer | null>(null);
121
+ const rendererRef = useRef<Renderer | null>(null);
122
+
123
+ // Bumped when overlay/renderer is ready or geometry finishes loading,
124
+ // triggering marker recomputation with real bounding boxes.
125
+ const [overlayReady, setOverlayReady] = useState(0);
126
+
127
+ // Store selectors
128
+ const bcfProject = useViewerStore((s) => s.bcfProject);
129
+ const activeTopicId = useViewerStore((s) => s.activeTopicId);
130
+ const setActiveTopic = useViewerStore((s) => s.setActiveTopic);
131
+ const setBcfPanelVisible = useViewerStore((s) => s.setBcfPanelVisible);
132
+ const models = useViewerStore((s) => s.models);
133
+ const loading = useViewerStore((s) => s.loading);
134
+ const ifcDataStore = useViewerStore((s) => s.ifcDataStore);
135
+
136
+ // GlobalId → expressId lookup (delegates to shared utility)
137
+ const globalIdToExpressId = useCallback(
138
+ (globalIdString: string) =>
139
+ globalIdToExpressIdLookup(globalIdString, models, ifcDataStore),
140
+ [models, ifcDataStore],
141
+ );
142
+
143
+ // Bounds lookup — queries the renderer Scene directly
144
+ const boundsLookup: EntityBoundsLookup = useCallback(
145
+ (ifcGuid: string): OverlayBBox | null => {
146
+ const r = rendererRef.current;
147
+ if (!r) return null;
148
+ const result = globalIdToExpressId(ifcGuid);
149
+ if (!result) return null;
150
+ return r.getScene().getEntityBoundingBox(result.expressId);
151
+ },
152
+ [globalIdToExpressId],
153
+ );
154
+
155
+ // Get current camera distance (for proper fallback marker placement)
156
+ const getCameraDistance = useCallback((): number => {
157
+ const r = rendererRef.current;
158
+ if (!r) return 50; // safe default
159
+ return r.getCamera().getDistance();
160
+ }, []);
161
+
162
+ // Topics list
163
+ const topics = (() => {
164
+ if (!bcfProject) return [];
165
+ return Array.from(bcfProject.topics.values());
166
+ })();
167
+
168
+ // Compute markers — recomputes when topics, bounds, loading, or readiness changes
169
+ const markers = useMemo(
170
+ () => computeMarkerPositions(topics, boundsLookup, {
171
+ targetDistance: getCameraDistance(),
172
+ }),
173
+ // eslint-disable-next-line react-hooks/exhaustive-deps
174
+ [topics, boundsLookup, overlayReady, loading],
175
+ );
176
+
177
+ // Initialize overlay renderer
178
+ useEffect(() => {
179
+ const container = containerRef.current;
180
+ if (!container) return;
181
+
182
+ const renderer = getGlobalRenderer();
183
+ if (!renderer) return;
184
+
185
+ const canvas = container.closest('[data-viewport]')?.querySelector('canvas') as HTMLCanvasElement | null;
186
+ if (!canvas) return;
187
+
188
+ rendererRef.current = renderer;
189
+
190
+ const projection = createWebGPUProjection(renderer, canvas);
191
+ const overlay = new BCFOverlayRenderer(container, projection, {
192
+ showConnectors: true,
193
+ showTooltips: true,
194
+ verticalOffset: 36,
195
+ });
196
+ overlayRef.current = overlay;
197
+
198
+ // Trigger marker recomputation now that renderer is available
199
+ setOverlayReady((n) => n + 1);
200
+
201
+ return () => {
202
+ overlay.dispose();
203
+ overlayRef.current = null;
204
+ rendererRef.current = null;
205
+ };
206
+ }, [models]);
207
+
208
+ // Recompute markers when loading finishes (bounding boxes get cached)
209
+ useEffect(() => {
210
+ if (!loading && rendererRef.current) {
211
+ setOverlayReady((n) => n + 1);
212
+ }
213
+ }, [loading]);
214
+
215
+ // Push markers to overlay renderer
216
+ useEffect(() => {
217
+ overlayRef.current?.setMarkers(markers);
218
+ }, [markers, overlayReady]);
219
+
220
+ // Sync active marker
221
+ useEffect(() => {
222
+ overlayRef.current?.setActiveMarker(activeTopicId);
223
+ }, [activeTopicId, overlayReady]);
224
+
225
+ // Visibility — reproject markers when becoming visible so they don't
226
+ // sit at stale positions until the next camera move.
227
+ useEffect(() => {
228
+ const overlay = overlayRef.current;
229
+ if (!overlay) return;
230
+ const hasTopics = bcfProject !== null && bcfProject.topics.size > 0;
231
+ overlay.setVisible(hasTopics);
232
+ if (hasTopics) overlay.updatePositions();
233
+ }, [bcfProject, overlayReady]);
234
+
235
+ // Click handler — read bcfPanelVisible from store inside callback to
236
+ // avoid re-registering the handler on every panel toggle.
237
+ useEffect(() => {
238
+ const overlay = overlayRef.current;
239
+ if (!overlay) return;
240
+ return overlay.onMarkerClick((topicGuid) => {
241
+ setActiveTopic(topicGuid);
242
+ const panelVisible = useViewerStore.getState().bcfPanelVisible;
243
+ if (!panelVisible) setBcfPanelVisible(true);
244
+ });
245
+ }, [overlayReady, setActiveTopic, setBcfPanelVisible]);
246
+
247
+ return (
248
+ <div
249
+ ref={containerRef}
250
+ className="absolute inset-0 pointer-events-none z-20"
251
+ data-bcf-overlay
252
+ />
253
+ );
254
+ }
@@ -65,6 +65,40 @@ function createDataStore(): IfcDataStore {
65
65
  } as unknown as IfcDataStore;
66
66
  }
67
67
 
68
+ function createFacilityDataStore(): IfcDataStore {
69
+ const partNode = createSpatialNode(3, IfcTypeEnum.IfcBridgePart, 'DECK');
70
+ partNode.elements = [4];
71
+ const bridgeNode = createSpatialNode(2, IfcTypeEnum.IfcBridge, 'BRIDGE', [partNode]);
72
+ const projectNode = createSpatialNode(1, IfcTypeEnum.IfcProject, 'INFRA_PROJECT', [bridgeNode]);
73
+
74
+ const spatialHierarchy: SpatialHierarchy = {
75
+ project: projectNode,
76
+ byStorey: new Map(),
77
+ byBuilding: new Map([[2, []]]),
78
+ bySite: new Map(),
79
+ bySpace: new Map(),
80
+ storeyElevations: new Map(),
81
+ storeyHeights: new Map(),
82
+ elementToStorey: new Map(),
83
+ getStoreyElements: () => [],
84
+ getStoreyByElevation: () => null,
85
+ getContainingSpace: () => null,
86
+ getPath: () => [],
87
+ };
88
+
89
+ return {
90
+ spatialHierarchy,
91
+ entities: {
92
+ count: 0,
93
+ getName: (id: number) => (id === 4 ? 'Barrier' : ''),
94
+ getTypeName: (id: number) => {
95
+ if (id === 4) return 'IfcWall';
96
+ return 'Unknown';
97
+ },
98
+ },
99
+ } as unknown as IfcDataStore;
100
+ }
101
+
68
102
  function createModel(idOffset: number): FederatedModel {
69
103
  return {
70
104
  id: 'model-1',
@@ -123,4 +157,40 @@ describe('buildTreeData', () => {
123
157
  assert.strictEqual(nodes.filter((node) => node.id === 'element-model-1-6').length, 1);
124
158
  assert.strictEqual(nodes.filter((node) => node.id === 'element-model-1-7').length, 1);
125
159
  });
160
+
161
+ it('keeps IFC4.3 facility and facility-part nodes as spatial hierarchy rows', () => {
162
+ useViewerStore.setState({ models: new Map() });
163
+ useViewerStore.getState().registerModelOffset('tree-test-infra-padding', 199);
164
+ const idOffset = useViewerStore.getState().registerModelOffset('model-infra', 4);
165
+ const model = {
166
+ ...createModel(idOffset),
167
+ id: 'model-infra',
168
+ name: 'Infra Model',
169
+ ifcDataStore: createFacilityDataStore(),
170
+ maxExpressId: 4,
171
+ };
172
+ useViewerStore.setState({ models: new Map([['model-infra', model]]) });
173
+
174
+ const nodes = buildTreeData(
175
+ new Map<string, FederatedModel>([['model-infra', model]]),
176
+ null,
177
+ new Set(['root-1', 'root-1-2', 'root-1-2-3']),
178
+ false,
179
+ [],
180
+ );
181
+
182
+ const bridgeNode = nodes.find((node) => node.id === 'root-1-2');
183
+ assert.ok(bridgeNode);
184
+ assert.strictEqual(bridgeNode.type, 'IfcBridge');
185
+
186
+ const partNode = nodes.find((node) => node.id === 'root-1-2-3');
187
+ assert.ok(partNode);
188
+ assert.strictEqual(partNode.type, 'IfcBridgePart');
189
+ assert.strictEqual(partNode.elementCount, 1);
190
+
191
+ const barrierNode = nodes.find((node) => node.id === 'element-model-infra-4');
192
+ assert.ok(barrierNode);
193
+ assert.strictEqual(barrierNode.type, 'element');
194
+ assert.strictEqual(barrierNode.ifcType, 'IfcWall');
195
+ });
126
196
  });
@@ -2,7 +2,15 @@
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, EntityFlags, RelationshipType, type SpatialNode } from '@ifc-lite/data';
5
+ import {
6
+ IfcTypeEnum,
7
+ EntityFlags,
8
+ RelationshipType,
9
+ isSpaceLikeSpatialType,
10
+ isSpatialStructureType,
11
+ isStoreyLikeSpatialType,
12
+ type SpatialNode,
13
+ } from '@ifc-lite/data';
6
14
  import type { IfcDataStore } from '@ifc-lite/parser';
7
15
  import { useViewerStore, type FederatedModel } from '@/store';
8
16
  import type { TreeNode, NodeType, StoreyData, UnifiedStorey } from './types';
@@ -18,7 +26,16 @@ export function getNodeType(ifcType: IfcTypeEnum): NodeType {
18
26
  case IfcTypeEnum.IfcProject: return 'IfcProject';
19
27
  case IfcTypeEnum.IfcSite: return 'IfcSite';
20
28
  case IfcTypeEnum.IfcBuilding: return 'IfcBuilding';
29
+ case IfcTypeEnum.IfcFacility: return 'IfcFacility';
30
+ case IfcTypeEnum.IfcBridge: return 'IfcBridge';
31
+ case IfcTypeEnum.IfcRoad: return 'IfcRoad';
32
+ case IfcTypeEnum.IfcRailway: return 'IfcRailway';
33
+ case IfcTypeEnum.IfcMarineFacility: return 'IfcMarineFacility';
21
34
  case IfcTypeEnum.IfcBuildingStorey: return 'IfcBuildingStorey';
35
+ case IfcTypeEnum.IfcFacilityPart: return 'IfcFacilityPart';
36
+ case IfcTypeEnum.IfcBridgePart: return 'IfcBridgePart';
37
+ case IfcTypeEnum.IfcRoadPart: return 'IfcRoadPart';
38
+ case IfcTypeEnum.IfcRailwayPart: return 'IfcRailwayPart';
22
39
  case IfcTypeEnum.IfcSpace: return 'IfcSpace';
23
40
  default: return 'element';
24
41
  }
@@ -68,10 +85,17 @@ function getSpatialNodeElements(
68
85
  nodeType: NodeType,
69
86
  descendantSpaceCache: Map<number, Set<number>>
70
87
  ): number[] {
71
- if (nodeType === 'IfcSpace') {
88
+ if (isSpaceLikeSpatialType(spatialNode.type)) {
72
89
  return (dataStore.spatialHierarchy?.bySpace.get(spatialNode.expressId) as number[]) || [];
73
90
  }
74
91
 
92
+ if (!isStoreyLikeSpatialType(spatialNode.type)) {
93
+ if (!isSpatialStructureType(spatialNode.type)) {
94
+ return [];
95
+ }
96
+ return spatialNode.elements || [];
97
+ }
98
+
75
99
  if (nodeType !== 'IfcBuildingStorey') {
76
100
  return [];
77
101
  }
@@ -177,16 +201,16 @@ function buildSpatialNodes(
177
201
  }
178
202
 
179
203
  const elements = getSpatialNodeElements(spatialNode, dataStore, nodeType, descendantSpaceCache);
204
+ const hasDirectElements = elements.length > 0;
180
205
 
181
206
  // Check if has children
182
207
  // In stopAtBuilding mode, buildings have no children (storeys shown separately)
183
208
  const hasNonStoreyChildren = spatialNode.children?.some(
184
- (c: SpatialNode) => getNodeType(c.type) !== 'IfcBuildingStorey'
209
+ (c: SpatialNode) => !isStoreyLikeSpatialType(c.type)
185
210
  );
186
211
  const hasChildren = stopAtBuilding
187
- ? (nodeType !== 'IfcBuilding' && hasNonStoreyChildren)
188
- : (spatialNode.children?.length > 0) ||
189
- ((nodeType === 'IfcBuildingStorey' || nodeType === 'IfcSpace') && elements.length > 0);
212
+ ? Boolean(hasNonStoreyChildren || hasDirectElements)
213
+ : (spatialNode.children?.length > 0) || hasDirectElements;
190
214
 
191
215
  nodes.push({
192
216
  id: nodeId,
@@ -201,7 +225,7 @@ function buildSpatialNodes(
201
225
  hasChildren,
202
226
  isExpanded: isNodeExpanded,
203
227
  isVisible: true, // Visibility computed lazily during render
204
- elementCount: nodeType === 'IfcBuildingStorey' || nodeType === 'IfcSpace' ? elements.length : undefined,
228
+ elementCount: hasDirectElements ? elements.length : undefined,
205
229
  storeyElevation: spatialNode.elevation,
206
230
  // Store idOffset for lazy visibility computation
207
231
  _idOffset: idOffset,
@@ -209,7 +233,8 @@ function buildSpatialNodes(
209
233
 
210
234
  if (isNodeExpanded) {
211
235
  // Sort storeys by elevation descending
212
- const sortedChildren = nodeType === 'IfcBuilding'
236
+ const shouldSortByElevation = (spatialNode.children || []).some((child) => isStoreyLikeSpatialType(child.type));
237
+ const sortedChildren = shouldSortByElevation
213
238
  ? [...(spatialNode.children || [])].sort((a, b) => (b.elevation || 0) - (a.elevation || 0))
214
239
  : spatialNode.children || [];
215
240
 
@@ -229,8 +254,8 @@ function buildSpatialNodes(
229
254
  );
230
255
  }
231
256
 
232
- // For storeys (single-model only), add elements
233
- if (!stopAtBuilding && (nodeType === 'IfcBuildingStorey' || nodeType === 'IfcSpace') && elements.length > 0) {
257
+ // Add direct spatial children elements for expanded nodes.
258
+ if (hasDirectElements) {
234
259
  for (const elementId of elements) {
235
260
  const globalId = resolveTreeGlobalId(modelId, elementId, models);
236
261
  const entityType = dataStore.entities?.getTypeName(elementId) || 'Unknown';
@@ -9,7 +9,16 @@ export type NodeType =
9
9
  | 'IfcProject' // Project node
10
10
  | 'IfcSite' // Site node
11
11
  | 'IfcBuilding' // Building node
12
+ | 'IfcFacility' // IFC4.3 facility root
13
+ | 'IfcBridge' // IFC4.3 bridge root
14
+ | 'IfcRoad' // IFC4.3 road root
15
+ | 'IfcRailway' // IFC4.3 railway root
16
+ | 'IfcMarineFacility' // IFC4.3 marine facility root
12
17
  | 'IfcBuildingStorey' // Storey node
18
+ | 'IfcFacilityPart' // IFC4.3 facility part
19
+ | 'IfcBridgePart' // IFC4.3 bridge part
20
+ | 'IfcRoadPart' // IFC4.3 road part
21
+ | 'IfcRailwayPart' // IFC4.3 railway part
13
22
  | 'IfcSpace' // Space node
14
23
  | 'type-group' // IFC class grouping header (e.g., "IfcWall (47)")
15
24
  | 'ifc-type' // IFC type entity node (e.g., "IfcWallType/W01")
@@ -57,6 +66,19 @@ export interface UnifiedStorey {
57
66
  totalElements: number;
58
67
  }
59
68
 
60
- // Spatial container types (Project/Site/Building) - these don't have direct visibility toggles
61
- const SPATIAL_CONTAINER_TYPES: Set<NodeType> = new Set(['IfcProject', 'IfcSite', 'IfcBuilding']);
69
+ // Spatial container types (all non-leaf spatial nodes) - these don't participate in storey filters.
70
+ const SPATIAL_CONTAINER_TYPES: Set<NodeType> = new Set([
71
+ 'IfcProject',
72
+ 'IfcSite',
73
+ 'IfcBuilding',
74
+ 'IfcFacility',
75
+ 'IfcBridge',
76
+ 'IfcRoad',
77
+ 'IfcRailway',
78
+ 'IfcMarineFacility',
79
+ 'IfcFacilityPart',
80
+ 'IfcBridgePart',
81
+ 'IfcRoadPart',
82
+ 'IfcRailwayPart',
83
+ ]);
62
84
  export const isSpatialContainer = (type: NodeType): boolean => SPATIAL_CONTAINER_TYPES.has(type);
@@ -34,7 +34,6 @@ import { useViewerStore } from '@/store';
34
34
  import { useIfc } from '@/hooks/useIfc';
35
35
  import {
36
36
  executeList,
37
- listResultToCSV,
38
37
  LIST_PRESETS,
39
38
  importListDefinition,
40
39
  exportListDefinition,
@@ -172,18 +171,6 @@ export function ListPanel({ onClose }: ListPanelProps) {
172
171
  }
173
172
  }, [editingList]);
174
173
 
175
- const handleExportCSV = useCallback(() => {
176
- if (!listResult) return;
177
- const csv = listResultToCSV(listResult);
178
- const blob = new Blob([csv], { type: 'text/csv' });
179
- const url = URL.createObjectURL(blob);
180
- const a = document.createElement('a');
181
- a.href = url;
182
- a.download = 'list-export.csv';
183
- a.click();
184
- setTimeout(() => URL.revokeObjectURL(url), 1000);
185
- }, [listResult]);
186
-
187
174
  const handleImport = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
188
175
  const file = e.target.files?.[0];
189
176
  if (!file) return;
@@ -228,14 +215,6 @@ export function ListPanel({ onClose }: ListPanelProps) {
228
215
  </TooltipTrigger>
229
216
  <TooltipContent>Edit Configuration</TooltipContent>
230
217
  </Tooltip>
231
- <Tooltip>
232
- <TooltipTrigger asChild>
233
- <Button variant="ghost" size="icon-sm" onClick={handleExportCSV}>
234
- <Download className="h-3.5 w-3.5" />
235
- </Button>
236
- </TooltipTrigger>
237
- <TooltipContent>Export CSV</TooltipContent>
238
- </Tooltip>
239
218
  <Tooltip>
240
219
  <TooltipTrigger asChild>
241
220
  <Button variant="ghost" size="icon-sm" onClick={() => setView('library')}>
@@ -12,10 +12,14 @@
12
12
 
13
13
  import React, { useCallback, useMemo, useRef, useState } from 'react';
14
14
  import { useVirtualizer } from '@tanstack/react-virtual';
15
- import { ArrowUp, ArrowDown, Search, Palette } from 'lucide-react';
15
+ import { ArrowUp, ArrowDown, Search, Palette, Eye, EyeOff, Download } from 'lucide-react';
16
16
  import { Input } from '@/components/ui/input';
17
+ import { Button } from '@/components/ui/button';
18
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
17
19
  import { useViewerStore } from '@/store';
20
+ import { getVisibleBasketEntityRefsFromStore } from '@/store/basketVisibleSet';
18
21
  import type { ListResult, ListRow, CellValue, ColumnDefinition } from '@ifc-lite/lists';
22
+ import { listResultToCSV } from '@ifc-lite/lists';
19
23
  import { cn } from '@/lib/utils';
20
24
  import { columnToAutoColor } from '@/lib/lists/columnToAutoColor';
21
25
  import { AUTO_COLOR_FROM_LIST_ID } from '@/store/slices/lensSlice';
@@ -29,6 +33,7 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
29
33
  const [searchQuery, setSearchQuery] = useState('');
30
34
  const [sortCol, setSortCol] = useState<number | null>(null);
31
35
  const [sortDir, setSortDir] = useState<'asc' | 'desc'>('asc');
36
+ const [filterByVisibility, setFilterByVisibility] = useState(true);
32
37
 
33
38
  const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
34
39
  const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity);
@@ -37,14 +42,49 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
37
42
  const activeLensId = useViewerStore((s) => s.activeLensId);
38
43
  const [colorByColIdx, setColorByColIdx] = useState<number | null>(null);
39
44
 
45
+ // Subscribe to visibility state so we re-filter when 3D visibility changes
46
+ const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
47
+ const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
48
+ const classFilter = useViewerStore((s) => s.classFilter);
49
+ const lensHiddenIds = useViewerStore((s) => s.lensHiddenIds);
50
+ const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
51
+ const typeVisibility = useViewerStore((s) => s.typeVisibility);
52
+ const hiddenEntitiesByModel = useViewerStore((s) => s.hiddenEntitiesByModel);
53
+ const isolatedEntitiesByModel = useViewerStore((s) => s.isolatedEntitiesByModel);
54
+ const models = useViewerStore((s) => s.models);
55
+ const activeBasketViewId = useViewerStore((s) => s.activeBasketViewId);
56
+ const geometryResult = useViewerStore((s) => s.geometryResult);
57
+
58
+ // Filter rows by 3D visibility
59
+ const visibilityFilteredRows = useMemo(() => {
60
+ if (!filterByVisibility) return result.rows;
61
+
62
+ const visibleRefs = getVisibleBasketEntityRefsFromStore();
63
+ const visibleSet = new Set<string>();
64
+ for (const ref of visibleRefs) {
65
+ visibleSet.add(`${ref.modelId}:${ref.expressId}`);
66
+ }
67
+
68
+ return result.rows.filter(row => {
69
+ // List uses 'default' for single-model, visibility uses 'legacy'
70
+ const modelId = row.modelId === 'default' ? 'legacy' : row.modelId;
71
+ return visibleSet.has(`${modelId}:${row.entityId}`);
72
+ });
73
+ }, [
74
+ result.rows, filterByVisibility,
75
+ hiddenEntities, isolatedEntities, classFilter, lensHiddenIds,
76
+ selectedStoreys, typeVisibility, hiddenEntitiesByModel,
77
+ isolatedEntitiesByModel, models, activeBasketViewId, geometryResult,
78
+ ]);
79
+
40
80
  // Filter rows by search query
41
81
  const filteredRows = useMemo(() => {
42
- if (!searchQuery) return result.rows;
82
+ if (!searchQuery) return visibilityFilteredRows;
43
83
  const q = searchQuery.toLowerCase();
44
- return result.rows.filter(row =>
84
+ return visibilityFilteredRows.filter(row =>
45
85
  row.values.some(v => v !== null && String(v).toLowerCase().includes(q))
46
86
  );
47
- }, [result.rows, searchQuery]);
87
+ }, [visibilityFilteredRows, searchQuery]);
48
88
 
49
89
  // Sort rows
50
90
  const sortedRows = useMemo(() => {
@@ -74,6 +114,23 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
74
114
  setColorByColIdx(colIdx);
75
115
  }, [activateAutoColorFromColumn]);
76
116
 
117
+ const handleExportCSV = useCallback(() => {
118
+ const exportResult: ListResult = {
119
+ columns: result.columns,
120
+ rows: sortedRows,
121
+ totalCount: sortedRows.length,
122
+ executionTime: result.executionTime,
123
+ };
124
+ const csv = listResultToCSV(exportResult);
125
+ const blob = new Blob([csv], { type: 'text/csv' });
126
+ const url = URL.createObjectURL(blob);
127
+ const a = document.createElement('a');
128
+ a.href = url;
129
+ a.download = 'list-export.csv';
130
+ a.click();
131
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
132
+ }, [result.columns, result.executionTime, sortedRows]);
133
+
77
134
  const handleRowClick = useCallback((row: ListRow) => {
78
135
  setSelectedEntity({ modelId: row.modelId, expressId: row.entityId });
79
136
  // For single-model, selectedEntityId is the expressId
@@ -111,8 +168,39 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
111
168
  className="h-7 text-xs border-0 shadow-none focus-visible:ring-0 px-0"
112
169
  />
113
170
  <span className="text-xs text-muted-foreground whitespace-nowrap">
114
- {sortedRows.length}{searchQuery ? ` / ${result.rows.length}` : ''} rows
171
+ {sortedRows.length}{(searchQuery || filterByVisibility) ? ` / ${result.rows.length}` : ''} rows
115
172
  </span>
173
+ <Tooltip>
174
+ <TooltipTrigger asChild>
175
+ <Button
176
+ variant="ghost"
177
+ size="icon-sm"
178
+ className={cn(
179
+ 'h-6 w-6 shrink-0',
180
+ filterByVisibility && 'text-primary',
181
+ )}
182
+ onClick={() => setFilterByVisibility(prev => !prev)}
183
+ >
184
+ {filterByVisibility ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
185
+ </Button>
186
+ </TooltipTrigger>
187
+ <TooltipContent>
188
+ {filterByVisibility ? 'Showing visible objects only' : 'Showing all objects'}
189
+ </TooltipContent>
190
+ </Tooltip>
191
+ <Tooltip>
192
+ <TooltipTrigger asChild>
193
+ <Button
194
+ variant="ghost"
195
+ size="icon-sm"
196
+ className="h-6 w-6 shrink-0"
197
+ onClick={handleExportCSV}
198
+ >
199
+ <Download className="h-3.5 w-3.5" />
200
+ </Button>
201
+ </TooltipTrigger>
202
+ <TooltipContent>Export CSV</TooltipContent>
203
+ </Tooltip>
116
204
  </div>
117
205
 
118
206
  {/* Table */}