@ifc-lite/viewer 1.1.7 → 1.6.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 (164) hide show
  1. package/LICENSE +373 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/Arrow.dom-BjDQoB2M.js +20 -0
  4. package/dist/assets/arrow2-bb-jcVEo.js +2 -0
  5. package/dist/assets/arrow2_bg-4Y7xYo54.wasm +0 -0
  6. package/dist/assets/arrow2_bg-BlXl-cSQ.js +1 -0
  7. package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
  8. package/dist/assets/desktop-cache-oPzaWXYE.js +1 -0
  9. package/dist/assets/event-DIOks52T.js +1 -0
  10. package/dist/assets/ifc-cache-BAN4vcd4.js +1 -0
  11. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  12. package/dist/assets/index-YBtrHPu3.js +65252 -0
  13. package/dist/assets/index-v3mcCUPN.css +1 -0
  14. package/dist/assets/native-bridge-CULtTDX3.js +111 -0
  15. package/dist/assets/wasm-bridge-CjL-lSak.js +1 -0
  16. package/dist/favicon-16x16-cropped.png +0 -0
  17. package/dist/favicon-16x16.png +0 -0
  18. package/dist/favicon-192x192-cropped.png +0 -0
  19. package/dist/favicon-192x192.png +0 -0
  20. package/dist/favicon-32x32-cropped.png +0 -0
  21. package/dist/favicon-32x32.png +0 -0
  22. package/dist/favicon-48x48-cropped.png +0 -0
  23. package/dist/favicon-48x48.png +0 -0
  24. package/dist/favicon-512x512-cropped.png +0 -0
  25. package/dist/favicon-512x512.png +0 -0
  26. package/dist/favicon-64x64-cropped.png +0 -0
  27. package/dist/favicon-64x64.png +0 -0
  28. package/dist/favicon-96x96-cropped.png +0 -0
  29. package/dist/favicon-96x96.png +0 -0
  30. package/dist/favicon-square-512.png +0 -0
  31. package/dist/favicon.ico +0 -0
  32. package/dist/favicon.png +0 -0
  33. package/dist/favicon.svg +3 -0
  34. package/dist/index.html +44 -0
  35. package/dist/logo.png +0 -0
  36. package/dist/manifest.json +48 -0
  37. package/index.html +33 -2
  38. package/package.json +34 -17
  39. package/public/apple-touch-icon.png +0 -0
  40. package/public/favicon-16x16-cropped.png +0 -0
  41. package/public/favicon-16x16.png +0 -0
  42. package/public/favicon-192x192-cropped.png +0 -0
  43. package/public/favicon-192x192.png +0 -0
  44. package/public/favicon-32x32-cropped.png +0 -0
  45. package/public/favicon-32x32.png +0 -0
  46. package/public/favicon-48x48-cropped.png +0 -0
  47. package/public/favicon-48x48.png +0 -0
  48. package/public/favicon-512x512-cropped.png +0 -0
  49. package/public/favicon-512x512.png +0 -0
  50. package/public/favicon-64x64-cropped.png +0 -0
  51. package/public/favicon-64x64.png +0 -0
  52. package/public/favicon-96x96-cropped.png +0 -0
  53. package/public/favicon-96x96.png +0 -0
  54. package/public/favicon-square-512.png +0 -0
  55. package/public/favicon.ico +0 -0
  56. package/public/favicon.png +0 -0
  57. package/public/favicon.svg +3 -0
  58. package/public/logo.png +0 -0
  59. package/public/manifest.json +48 -0
  60. package/src/App.tsx +2 -0
  61. package/src/components/ui/alert.tsx +62 -0
  62. package/src/components/ui/badge.tsx +39 -0
  63. package/src/components/ui/dialog.tsx +120 -0
  64. package/src/components/ui/label.tsx +27 -0
  65. package/src/components/ui/select.tsx +151 -0
  66. package/src/components/ui/switch.tsx +30 -0
  67. package/src/components/ui/table.tsx +120 -0
  68. package/src/components/ui/tabs.tsx +1 -1
  69. package/src/components/viewer/BCFPanel.tsx +1164 -0
  70. package/src/components/viewer/BulkPropertyEditor.tsx +875 -0
  71. package/src/components/viewer/DataConnector.tsx +840 -0
  72. package/src/components/viewer/DrawingSettingsPanel.tsx +536 -0
  73. package/src/components/viewer/EntityContextMenu.tsx +45 -17
  74. package/src/components/viewer/ExportChangesButton.tsx +195 -0
  75. package/src/components/viewer/ExportDialog.tsx +402 -0
  76. package/src/components/viewer/HierarchyPanel.tsx +1132 -218
  77. package/src/components/viewer/IDSPanel.tsx +661 -0
  78. package/src/components/viewer/KeyboardShortcutsDialog.tsx +245 -39
  79. package/src/components/viewer/MainToolbar.tsx +418 -94
  80. package/src/components/viewer/PropertiesPanel.tsx +1355 -91
  81. package/src/components/viewer/PropertyEditor.tsx +611 -0
  82. package/src/components/viewer/Section2DPanel.tsx +3313 -0
  83. package/src/components/viewer/SheetSetupPanel.tsx +502 -0
  84. package/src/components/viewer/StatusBar.tsx +27 -16
  85. package/src/components/viewer/TitleBlockEditor.tsx +437 -0
  86. package/src/components/viewer/ToolOverlays.tsx +935 -127
  87. package/src/components/viewer/ViewerLayout.tsx +40 -11
  88. package/src/components/viewer/Viewport.tsx +1276 -336
  89. package/src/components/viewer/ViewportContainer.tsx +554 -18
  90. package/src/components/viewer/ViewportOverlays.tsx +24 -7
  91. package/src/hooks/useBCF.ts +504 -0
  92. package/src/hooks/useIDS.ts +1065 -0
  93. package/src/hooks/useIfc.ts +1534 -205
  94. package/src/hooks/useIfcCache.ts +279 -0
  95. package/src/hooks/useKeyboardShortcuts.ts +50 -8
  96. package/src/hooks/useModelSelection.ts +61 -0
  97. package/src/hooks/useViewerSelectors.ts +218 -0
  98. package/src/hooks/useWebGPU.ts +80 -0
  99. package/src/index.css +265 -27
  100. package/src/lib/platform.ts +23 -0
  101. package/src/services/cacheService.ts +142 -0
  102. package/src/services/desktop-cache.ts +143 -0
  103. package/src/services/fs-cache.ts +212 -0
  104. package/src/services/ifc-cache.ts +14 -6
  105. package/src/store/constants.ts +85 -0
  106. package/src/store/index.ts +214 -0
  107. package/src/store/slices/bcfSlice.ts +372 -0
  108. package/src/store/slices/cameraSlice.ts +63 -0
  109. package/src/store/slices/dataSlice.test.ts +226 -0
  110. package/src/store/slices/dataSlice.ts +112 -0
  111. package/src/store/slices/drawing2DSlice.ts +340 -0
  112. package/src/store/slices/hoverSlice.ts +40 -0
  113. package/src/store/slices/idsSlice.ts +310 -0
  114. package/src/store/slices/loadingSlice.ts +33 -0
  115. package/src/store/slices/measurementSlice.test.ts +217 -0
  116. package/src/store/slices/measurementSlice.ts +293 -0
  117. package/src/store/slices/modelSlice.test.ts +271 -0
  118. package/src/store/slices/modelSlice.ts +211 -0
  119. package/src/store/slices/mutationSlice.ts +502 -0
  120. package/src/store/slices/sectionSlice.test.ts +125 -0
  121. package/src/store/slices/sectionSlice.ts +58 -0
  122. package/src/store/slices/selectionSlice.test.ts +286 -0
  123. package/src/store/slices/selectionSlice.ts +263 -0
  124. package/src/store/slices/sheetSlice.ts +565 -0
  125. package/src/store/slices/uiSlice.ts +58 -0
  126. package/src/store/slices/visibilitySlice.test.ts +304 -0
  127. package/src/store/slices/visibilitySlice.ts +277 -0
  128. package/src/store/types.test.ts +135 -0
  129. package/src/store/types.ts +248 -0
  130. package/src/store.ts +40 -515
  131. package/src/utils/ifcConfig.ts +82 -0
  132. package/src/utils/localParsingUtils.ts +287 -0
  133. package/src/utils/serverDataModel.ts +783 -0
  134. package/src/utils/spatialHierarchy.ts +283 -0
  135. package/src/utils/viewportUtils.ts +334 -0
  136. package/src/vite-env.d.ts +23 -0
  137. package/src/webgpu-types.d.ts +128 -0
  138. package/src-tauri/Cargo.toml +29 -0
  139. package/src-tauri/build.rs +7 -0
  140. package/src-tauri/capabilities/default.json +18 -0
  141. package/src-tauri/icons/128x128.png +0 -0
  142. package/src-tauri/icons/128x128@2x.png +0 -0
  143. package/src-tauri/icons/32x32.png +0 -0
  144. package/src-tauri/icons/Square107x107Logo.png +0 -0
  145. package/src-tauri/icons/Square142x142Logo.png +0 -0
  146. package/src-tauri/icons/Square150x150Logo.png +0 -0
  147. package/src-tauri/icons/Square284x284Logo.png +0 -0
  148. package/src-tauri/icons/Square30x30Logo.png +0 -0
  149. package/src-tauri/icons/Square310x310Logo.png +0 -0
  150. package/src-tauri/icons/Square44x44Logo.png +0 -0
  151. package/src-tauri/icons/Square71x71Logo.png +0 -0
  152. package/src-tauri/icons/Square89x89Logo.png +0 -0
  153. package/src-tauri/icons/StoreLogo.png +0 -0
  154. package/src-tauri/icons/icon.icns +0 -0
  155. package/src-tauri/icons/icon.ico +0 -0
  156. package/src-tauri/icons/icon.png +0 -0
  157. package/src-tauri/src/lib.rs +21 -0
  158. package/src-tauri/src/main.rs +10 -0
  159. package/src-tauri/tauri.conf.json +39 -0
  160. package/vite.config.ts +174 -26
  161. package/public/ifc-lite_bg.wasm +0 -0
  162. package/public/web-ifc.wasm +0 -0
  163. package/src/components/Viewport.tsx +0 -723
  164. package/src/components/viewer/BoxSelectionOverlay.tsx +0 -53
@@ -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 { useMemo, useState, useCallback, useRef } from 'react';
5
+ import { useMemo, useState, useCallback, useRef, useEffect } from 'react';
6
6
  import { useVirtualizer } from '@tanstack/react-virtual';
7
7
  import {
8
8
  Search,
@@ -16,25 +16,69 @@ import {
16
16
  DoorOpen,
17
17
  Eye,
18
18
  EyeOff,
19
+ LayoutTemplate,
20
+ FileBox,
21
+ X,
22
+ GripHorizontal,
19
23
  } from 'lucide-react';
20
24
  import { Input } from '@/components/ui/input';
21
25
  import { Button } from '@/components/ui/button';
26
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
22
27
  import { cn } from '@/lib/utils';
23
28
  import { useViewerStore } from '@/store';
24
29
  import { useIfc } from '@/hooks/useIfc';
30
+ import { IfcTypeEnum, type SpatialNode } from '@ifc-lite/data';
31
+ import type { IfcDataStore } from '@ifc-lite/parser';
32
+
33
+ // Node types for the tree
34
+ type NodeType =
35
+ | 'unified-storey' // Grouped storey across models (multi-model only)
36
+ | 'model-header' // Model visibility control (section header or individual model)
37
+ | 'IfcProject' // Project node
38
+ | 'IfcSite' // Site node
39
+ | 'IfcBuilding' // Building node
40
+ | 'IfcBuildingStorey' // Storey node
41
+ | 'element'; // Individual element
25
42
 
26
43
  interface TreeNode {
27
- id: number;
44
+ id: string; // Unique ID for the node (can be composite)
45
+ /** Express IDs this node represents (for elements/storeys) */
46
+ expressIds: number[];
47
+ /** Model IDs this node belongs to */
48
+ modelIds: string[];
28
49
  name: string;
29
- type: string;
50
+ type: NodeType;
30
51
  depth: number;
31
52
  hasChildren: boolean;
32
53
  isExpanded: boolean;
33
- isVisible: boolean;
54
+ isVisible: boolean; // Note: For storeys, computed lazily during render for performance
34
55
  elementCount?: number;
56
+ storeyElevation?: number;
57
+ /** Internal: ID offset for lazy visibility computation */
58
+ _idOffset?: number;
59
+ }
60
+
61
+ /** Data for a storey from a single model */
62
+ interface StoreyData {
63
+ modelId: string;
64
+ storeyId: number;
65
+ name: string;
66
+ elevation: number;
67
+ elements: number[];
68
+ }
69
+
70
+ /** Unified storey grouping storeys from multiple models */
71
+ interface UnifiedStorey {
72
+ key: string; // Elevation-based key for matching
73
+ name: string;
74
+ elevation: number;
75
+ storeys: StoreyData[];
76
+ totalElements: number;
35
77
  }
36
78
 
37
79
  const TYPE_ICONS: Record<string, React.ElementType> = {
80
+ 'unified-storey': Layers,
81
+ 'model-header': FileBox,
38
82
  IfcProject: FolderKanban,
39
83
  IfcSite: MapPin,
40
84
  IfcBuilding: Building2,
@@ -43,94 +87,455 @@ const TYPE_ICONS: Record<string, React.ElementType> = {
43
87
  IfcWall: Square,
44
88
  IfcWallStandardCase: Square,
45
89
  IfcDoor: DoorOpen,
90
+ element: Box,
46
91
  default: Box,
47
92
  };
48
93
 
94
+ // Spatial container types (Project/Site/Building) - these don't have direct visibility toggles
95
+ const SPATIAL_CONTAINER_TYPES: Set<NodeType> = new Set(['IfcProject', 'IfcSite', 'IfcBuilding']);
96
+ const isSpatialContainer = (type: NodeType): boolean => SPATIAL_CONTAINER_TYPES.has(type);
97
+
98
+ // Helper to create elevation key (with 0.5m tolerance for matching)
99
+ function elevationKey(elevation: number): string {
100
+ return (Math.round(elevation * 2) / 2).toFixed(2);
101
+ }
102
+
49
103
  export function HierarchyPanel() {
50
- const { ifcDataStore } = useIfc();
104
+ const {
105
+ ifcDataStore,
106
+ models,
107
+ activeModelId,
108
+ setActiveModel,
109
+ setModelVisibility,
110
+ setModelCollapsed,
111
+ removeModel,
112
+ } = useIfc();
51
113
  const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
52
114
  const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
53
- const selectedStorey = useViewerStore((s) => s.selectedStorey);
54
- const setSelectedStorey = useViewerStore((s) => s.setSelectedStorey);
115
+ const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity);
116
+ const setSelectedEntities = useViewerStore((s) => s.setSelectedEntities);
117
+ const setSelectedModelId = useViewerStore((s) => s.setSelectedModelId);
118
+ const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
119
+ const setStoreySelection = useViewerStore((s) => s.setStoreySelection);
120
+ const setStoreysSelection = useViewerStore((s) => s.setStoreysSelection);
121
+ const clearStoreySelection = useViewerStore((s) => s.clearStoreySelection);
122
+ const isolateEntities = useViewerStore((s) => s.isolateEntities);
123
+
55
124
  const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
56
125
  const hideEntities = useViewerStore((s) => s.hideEntities);
57
126
  const showEntities = useViewerStore((s) => s.showEntities);
58
127
  const toggleEntityVisibility = useViewerStore((s) => s.toggleEntityVisibility);
59
- const isEntityVisible = useViewerStore((s) => s.isEntityVisible);
128
+ const clearSelection = useViewerStore((s) => s.clearSelection);
60
129
 
61
130
  const [searchQuery, setSearchQuery] = useState('');
62
- const [expandedNodes, setExpandedNodes] = useState<Set<number>>(new Set());
131
+ const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
132
+ const [hasInitializedExpansion, setHasInitializedExpansion] = useState(false);
63
133
 
64
- // Get storey elements mapping for visibility toggle
65
- const storeyElementsMap = useMemo(() => {
66
- if (!ifcDataStore?.spatialHierarchy) return new Map<number, number[]>();
67
- return ifcDataStore.spatialHierarchy.byStorey;
68
- }, [ifcDataStore]);
134
+ // Resizable panel split (percentage for storeys section, 0.5 = 50%)
135
+ const [splitRatio, setSplitRatio] = useState(0.5);
136
+ const [isDragging, setIsDragging] = useState(false);
137
+ const containerRef = useRef<HTMLDivElement>(null);
69
138
 
70
- // Build spatial tree data
71
- const treeData = useMemo((): TreeNode[] => {
72
- if (!ifcDataStore?.spatialHierarchy) return [];
139
+ // Check if we have multiple models loaded
140
+ const isMultiModel = models.size > 1;
73
141
 
74
- const hierarchy = ifcDataStore.spatialHierarchy;
75
- const nodes: TreeNode[] = [];
142
+ // Helper to convert IfcTypeEnum to NodeType string
143
+ const getNodeType = useCallback((ifcType: IfcTypeEnum): NodeType => {
144
+ switch (ifcType) {
145
+ case IfcTypeEnum.IfcProject: return 'IfcProject';
146
+ case IfcTypeEnum.IfcSite: return 'IfcSite';
147
+ case IfcTypeEnum.IfcBuilding: return 'IfcBuilding';
148
+ case IfcTypeEnum.IfcBuildingStorey: return 'IfcBuildingStorey';
149
+ default: return 'element';
150
+ }
151
+ }, []);
76
152
 
77
- // Add project
78
- nodes.push({
79
- id: hierarchy.project.expressId,
80
- name: hierarchy.project.name || 'Project',
81
- type: 'IfcProject',
82
- depth: 0,
83
- hasChildren: hierarchy.byStorey.size > 0,
84
- isExpanded: true,
85
- isVisible: true,
86
- });
153
+ // Build unified storey data for multi-model mode (moved before useEffect that depends on it)
154
+ const unifiedStoreys = useMemo((): UnifiedStorey[] => {
155
+ if (models.size <= 1) return [];
87
156
 
88
- // Add storeys sorted by elevation
89
- const storeysArray = Array.from(hierarchy.byStorey.entries()) as [number, number[]][];
90
- const storeys = storeysArray
91
- .map(([id, elements]: [number, number[]]) => ({
92
- id,
93
- name: ifcDataStore.entities.getName(id) || `Storey #${id}`,
94
- elevation: hierarchy.storeyElevations.get(id) ?? 0,
95
- elements,
96
- }))
157
+ const storeysByElevation = new Map<string, UnifiedStorey>();
158
+
159
+ for (const [modelId, model] of models) {
160
+ const dataStore = model.ifcDataStore;
161
+ if (!dataStore?.spatialHierarchy) continue;
162
+
163
+ const hierarchy = dataStore.spatialHierarchy;
164
+ const { byStorey, storeyElevations } = hierarchy;
165
+
166
+ for (const [storeyId, elements] of byStorey.entries()) {
167
+ const elevation = storeyElevations.get(storeyId) ?? 0;
168
+ const name = dataStore.entities.getName(storeyId) || `Storey #${storeyId}`;
169
+ const key = elevationKey(elevation);
170
+
171
+ const storeyData: StoreyData = {
172
+ modelId,
173
+ storeyId,
174
+ name,
175
+ elevation,
176
+ elements: elements as number[],
177
+ };
178
+
179
+ if (storeysByElevation.has(key)) {
180
+ const unified = storeysByElevation.get(key)!;
181
+ unified.storeys.push(storeyData);
182
+ unified.totalElements += elements.length;
183
+ if (name.length < unified.name.length) {
184
+ unified.name = name;
185
+ }
186
+ } else {
187
+ storeysByElevation.set(key, {
188
+ key,
189
+ name,
190
+ elevation,
191
+ storeys: [storeyData],
192
+ totalElements: elements.length,
193
+ });
194
+ }
195
+ }
196
+ }
197
+
198
+ return Array.from(storeysByElevation.values())
97
199
  .sort((a, b) => b.elevation - a.elevation);
200
+ }, [models]);
201
+
202
+ // Auto-expand nodes on initial load based on model count
203
+ useEffect(() => {
204
+ // Only run once when data is first loaded
205
+ if (hasInitializedExpansion) return;
206
+
207
+ const newExpanded = new Set<string>();
208
+
209
+ if (models.size === 1) {
210
+ // Single model in federation: expand full hierarchy to show all storeys
211
+ const [, model] = Array.from(models.entries())[0];
212
+ const hierarchy = model.ifcDataStore?.spatialHierarchy;
213
+
214
+ // Wait until spatial hierarchy is computed before initializing
215
+ if (!hierarchy?.project) {
216
+ return; // Don't mark as initialized - will retry when hierarchy is ready
217
+ }
218
+
219
+ // Expand Project → Site → Building to reveal storeys
220
+ const project = hierarchy.project;
221
+ const projectNodeId = `root-${project.expressId}`;
222
+ newExpanded.add(projectNodeId);
98
223
 
99
- for (const storey of storeys) {
100
- const isStoreyExpanded = expandedNodes.has(storey.id);
224
+ for (const site of project.children || []) {
225
+ const siteNodeId = `${projectNodeId}-${site.expressId}`;
226
+ newExpanded.add(siteNodeId);
227
+
228
+ for (const building of site.children || []) {
229
+ const buildingNodeId = `${siteNodeId}-${building.expressId}`;
230
+ newExpanded.add(buildingNodeId);
231
+ }
232
+ }
233
+ } else if (models.size > 1) {
234
+ // Multi-model: expand all model entries in Models section
235
+ // But collapse if there are too many items (rough estimate based on viewport)
236
+ const totalItems = unifiedStoreys.length + models.size;
237
+ const estimatedRowHeight = 36;
238
+ const availableHeight = window.innerHeight * 0.6; // Estimate panel takes ~60% of viewport
239
+ const maxVisibleItems = Math.floor(availableHeight / estimatedRowHeight);
240
+
241
+ if (totalItems <= maxVisibleItems) {
242
+ // Enough space - expand all model entries
243
+ for (const [modelId] of models) {
244
+ newExpanded.add(`model-${modelId}`);
245
+ }
246
+ }
247
+ // If not enough space, leave collapsed (newExpanded stays empty for models)
248
+ } else if (models.size === 0 && ifcDataStore?.spatialHierarchy?.project) {
249
+ // Legacy single-model mode (loaded via loadFile, not in models Map)
250
+ const hierarchy = ifcDataStore.spatialHierarchy;
251
+ const project = hierarchy.project;
252
+ const projectNodeId = `root-${project.expressId}`;
253
+ newExpanded.add(projectNodeId);
254
+
255
+ for (const site of project.children || []) {
256
+ const siteNodeId = `${projectNodeId}-${site.expressId}`;
257
+ newExpanded.add(siteNodeId);
258
+
259
+ for (const building of site.children || []) {
260
+ const buildingNodeId = `${siteNodeId}-${building.expressId}`;
261
+ newExpanded.add(buildingNodeId);
262
+ }
263
+ }
264
+ } else {
265
+ // No data loaded yet
266
+ return;
267
+ }
268
+
269
+ if (newExpanded.size > 0) {
270
+ setExpandedNodes(newExpanded);
271
+ }
272
+ setHasInitializedExpansion(true);
273
+ }, [models, ifcDataStore, hasInitializedExpansion, unifiedStoreys.length]);
274
+
275
+ // Reset expansion state when all data is cleared
276
+ useEffect(() => {
277
+ if (models.size === 0 && !ifcDataStore) {
278
+ setHasInitializedExpansion(false);
279
+ setExpandedNodes(new Set());
280
+ }
281
+ }, [models.size, ifcDataStore]);
282
+
283
+ // Get all element IDs for a unified storey (as global IDs) - optimized to avoid spread operator
284
+ const getUnifiedStoreyElements = useCallback((unifiedStorey: UnifiedStorey): number[] => {
285
+ // Pre-calculate total length for single allocation
286
+ const totalLength = unifiedStorey.storeys.reduce((sum, s) => sum + s.elements.length, 0);
287
+ const allElements = new Array<number>(totalLength);
288
+ let idx = 0;
289
+ for (const storey of unifiedStorey.storeys) {
290
+ const model = models.get(storey.modelId);
291
+ const offset = model?.idOffset ?? 0;
292
+ // Direct assignment instead of spread for better performance
293
+ for (const id of storey.elements) {
294
+ allElements[idx++] = id + offset;
295
+ }
296
+ }
297
+ return allElements;
298
+ }, [models]);
299
+
300
+ // Build the tree data structure
301
+ const treeData = useMemo((): TreeNode[] => {
302
+ const nodes: TreeNode[] = [];
303
+
304
+ // Helper to recursively build spatial nodes (Project → Site → Building)
305
+ // stopAtBuilding: if true, don't include storeys (for multi-model mode)
306
+ const buildSpatialNodes = (
307
+ spatialNode: SpatialNode,
308
+ modelId: string,
309
+ dataStore: IfcDataStore,
310
+ depth: number,
311
+ parentNodeId: string,
312
+ stopAtBuilding: boolean,
313
+ idOffset: number = 0
314
+ ) => {
315
+ const nodeId = `${parentNodeId}-${spatialNode.expressId}`;
316
+ const nodeType = getNodeType(spatialNode.type);
317
+ const isNodeExpanded = expandedNodes.has(nodeId);
318
+
319
+ // Skip storeys in multi-model mode (they're shown in unified list)
320
+ if (stopAtBuilding && nodeType === 'IfcBuildingStorey') {
321
+ return;
322
+ }
323
+
324
+ // For storeys, get elements from byStorey map
325
+ let elements: number[] = [];
326
+ if (nodeType === 'IfcBuildingStorey') {
327
+ elements = (dataStore.spatialHierarchy?.byStorey.get(spatialNode.expressId) as number[]) || [];
328
+ }
329
+
330
+ // Note: isVisible is computed lazily during render for performance
331
+ // We just need to know if there ARE elements (for empty check)
332
+ const hasElements = elements.length > 0;
333
+
334
+ // Check if has children
335
+ // In stopAtBuilding mode, buildings have no children (storeys shown separately)
336
+ const hasNonStoreyChildren = spatialNode.children?.some(
337
+ (c: SpatialNode) => getNodeType(c.type) !== 'IfcBuildingStorey'
338
+ );
339
+ const hasChildren = stopAtBuilding
340
+ ? (nodeType !== 'IfcBuilding' && hasNonStoreyChildren)
341
+ : (spatialNode.children?.length > 0) || (nodeType === 'IfcBuildingStorey' && elements.length > 0);
101
342
 
102
343
  nodes.push({
103
- id: storey.id,
104
- name: storey.name,
105
- type: 'IfcBuildingStorey',
106
- depth: 1,
107
- hasChildren: storey.elements.length > 0,
108
- isExpanded: isStoreyExpanded,
344
+ id: nodeId,
345
+ expressIds: [spatialNode.expressId],
346
+ modelIds: [modelId],
347
+ name: spatialNode.name || `${nodeType} #${spatialNode.expressId}`,
348
+ type: nodeType,
349
+ depth,
350
+ hasChildren,
351
+ isExpanded: isNodeExpanded,
352
+ isVisible: true, // Visibility computed lazily during render
353
+ elementCount: nodeType === 'IfcBuildingStorey' ? elements.length : undefined,
354
+ storeyElevation: spatialNode.elevation,
355
+ // Store idOffset for lazy visibility computation
356
+ _idOffset: idOffset,
357
+ });
358
+
359
+ if (isNodeExpanded) {
360
+ // Sort storeys by elevation descending
361
+ const sortedChildren = nodeType === 'IfcBuilding'
362
+ ? [...(spatialNode.children || [])].sort((a, b) => (b.elevation || 0) - (a.elevation || 0))
363
+ : spatialNode.children || [];
364
+
365
+ for (const child of sortedChildren) {
366
+ buildSpatialNodes(child, modelId, dataStore, depth + 1, nodeId, stopAtBuilding, idOffset);
367
+ }
368
+
369
+ // For storeys (single-model only), add elements
370
+ if (!stopAtBuilding && nodeType === 'IfcBuildingStorey' && elements.length > 0) {
371
+ for (const elementId of elements) {
372
+ const globalId = elementId + idOffset;
373
+ const entityType = dataStore.entities?.getTypeName(elementId) || 'Unknown';
374
+ const entityName = dataStore.entities?.getName(elementId) || `${entityType} #${elementId}`;
375
+
376
+ nodes.push({
377
+ id: `element-${modelId}-${elementId}`,
378
+ expressIds: [globalId], // Store global ID for visibility operations
379
+ modelIds: [modelId],
380
+ name: entityName,
381
+ type: 'element',
382
+ depth: depth + 1,
383
+ hasChildren: false,
384
+ isExpanded: false,
385
+ isVisible: true, // Computed lazily during render
386
+ });
387
+ }
388
+ }
389
+ }
390
+ };
391
+
392
+ // Multi-model mode: unified storeys + MODELS section
393
+ if (isMultiModel) {
394
+ // 1. Add unified storeys at the top
395
+ for (const unified of unifiedStoreys) {
396
+ const storeyNodeId = `unified-${unified.key}`;
397
+ const isExpanded = expandedNodes.has(storeyNodeId);
398
+ const allStoreyIds = unified.storeys.map(s => s.storeyId);
399
+
400
+ nodes.push({
401
+ id: storeyNodeId,
402
+ expressIds: allStoreyIds,
403
+ modelIds: unified.storeys.map(s => s.modelId),
404
+ name: unified.name,
405
+ type: 'unified-storey',
406
+ depth: 0,
407
+ hasChildren: unified.totalElements > 0,
408
+ isExpanded,
409
+ isVisible: true, // Computed lazily during render
410
+ elementCount: unified.totalElements,
411
+ storeyElevation: unified.elevation,
412
+ });
413
+
414
+ // If expanded, show elements grouped by model
415
+ if (isExpanded) {
416
+ for (const storey of unified.storeys) {
417
+ const model = models.get(storey.modelId);
418
+ const modelName = model?.name || storey.modelId;
419
+ const offset = model?.idOffset ?? 0;
420
+
421
+ // Add model contribution header
422
+ const contribNodeId = `contrib-${storey.modelId}-${storey.storeyId}`;
423
+ const contribExpanded = expandedNodes.has(contribNodeId);
424
+
425
+ nodes.push({
426
+ id: contribNodeId,
427
+ expressIds: [storey.storeyId],
428
+ modelIds: [storey.modelId],
429
+ name: modelName,
430
+ type: 'model-header',
431
+ depth: 1,
432
+ hasChildren: storey.elements.length > 0,
433
+ isExpanded: contribExpanded,
434
+ isVisible: true, // Computed lazily during render
435
+ elementCount: storey.elements.length,
436
+ _idOffset: offset,
437
+ });
438
+
439
+ // If contribution expanded, show elements
440
+ if (contribExpanded) {
441
+ const dataStore = model?.ifcDataStore;
442
+ for (const elementId of storey.elements) {
443
+ const globalId = elementId + offset;
444
+ const entityType = dataStore?.entities?.getTypeName(elementId) || 'Unknown';
445
+ const entityName = dataStore?.entities?.getName(elementId) || `${entityType} #${elementId}`;
446
+
447
+ nodes.push({
448
+ id: `element-${storey.modelId}-${elementId}`,
449
+ expressIds: [globalId], // Store global ID for visibility operations
450
+ modelIds: [storey.modelId],
451
+ name: entityName,
452
+ type: 'element',
453
+ depth: 2,
454
+ hasChildren: false,
455
+ isExpanded: false,
456
+ isVisible: true, // Computed lazily during render
457
+ });
458
+ }
459
+ }
460
+ }
461
+ }
462
+ }
463
+
464
+ // 2. Add MODELS section header
465
+ nodes.push({
466
+ id: 'models-header',
467
+ expressIds: [],
468
+ modelIds: [],
469
+ name: 'Models',
470
+ type: 'model-header',
471
+ depth: 0,
472
+ hasChildren: false,
473
+ isExpanded: false,
109
474
  isVisible: true,
110
- elementCount: storey.elements.length,
111
475
  });
112
476
 
113
- // Add storey elements if expanded
114
- if (isStoreyExpanded && storey.elements.length > 0) {
115
- for (const elementId of storey.elements) {
116
- const entityType = ifcDataStore.entities.getTypeName(elementId) || 'Unknown';
117
- const entityName = ifcDataStore.entities.getName(elementId) || `${entityType} #${elementId}`;
118
-
119
- nodes.push({
120
- id: elementId,
121
- name: entityName,
122
- type: entityType,
123
- depth: 2,
124
- hasChildren: false,
125
- isExpanded: false,
126
- isVisible: isEntityVisible(elementId),
127
- });
477
+ // 3. Add each model with Project → Site → Building (NO storeys)
478
+ for (const [modelId, model] of models) {
479
+ const modelNodeId = `model-${modelId}`;
480
+ const isModelExpanded = expandedNodes.has(modelNodeId);
481
+ const hasSpatialHierarchy = model.ifcDataStore?.spatialHierarchy?.project !== undefined;
482
+
483
+ nodes.push({
484
+ id: modelNodeId,
485
+ expressIds: [],
486
+ modelIds: [modelId],
487
+ name: model.name,
488
+ type: 'model-header',
489
+ depth: 0,
490
+ hasChildren: hasSpatialHierarchy,
491
+ isExpanded: isModelExpanded,
492
+ isVisible: model.visible,
493
+ elementCount: model.ifcDataStore?.entityCount,
494
+ });
495
+
496
+ // If expanded, show Project → Site → Building (stop at building, no storeys)
497
+ if (isModelExpanded && model.ifcDataStore?.spatialHierarchy?.project) {
498
+ buildSpatialNodes(
499
+ model.ifcDataStore.spatialHierarchy.project,
500
+ modelId,
501
+ model.ifcDataStore,
502
+ 1,
503
+ modelNodeId,
504
+ true, // stopAtBuilding = true
505
+ model.idOffset ?? 0
506
+ );
128
507
  }
129
508
  }
509
+ } else if (models.size === 1) {
510
+ // Single model: show full spatial hierarchy (including storeys)
511
+ const [modelId, model] = Array.from(models.entries())[0];
512
+ if (model.ifcDataStore?.spatialHierarchy?.project) {
513
+ buildSpatialNodes(
514
+ model.ifcDataStore.spatialHierarchy.project,
515
+ modelId,
516
+ model.ifcDataStore,
517
+ 0,
518
+ 'root',
519
+ false, // stopAtBuilding = false (show full hierarchy)
520
+ model.idOffset ?? 0
521
+ );
522
+ }
523
+ } else if (ifcDataStore?.spatialHierarchy?.project) {
524
+ // Legacy single-model mode (no offset)
525
+ buildSpatialNodes(
526
+ ifcDataStore.spatialHierarchy.project,
527
+ 'legacy',
528
+ ifcDataStore,
529
+ 0,
530
+ 'root',
531
+ false,
532
+ 0
533
+ );
130
534
  }
131
535
 
132
536
  return nodes;
133
- }, [ifcDataStore, expandedNodes, isEntityVisible, hiddenEntities]);
537
+ // Note: hiddenEntities intentionally NOT in deps - visibility computed lazily for performance
538
+ }, [models, ifcDataStore, expandedNodes, isMultiModel, getNodeType, unifiedStoreys, getUnifiedStoreyElements]);
134
539
 
135
540
  // Filter nodes based on search
136
541
  const filteredNodes = useMemo(() => {
@@ -142,8 +547,46 @@ export function HierarchyPanel() {
142
547
  );
143
548
  }, [treeData, searchQuery]);
144
549
 
145
- const parentRef = useRef<HTMLDivElement>(null);
550
+ // Split filtered nodes into storeys and models sections (for multi-model mode)
551
+ const { storeysNodes, modelsNodes } = useMemo(() => {
552
+ if (!isMultiModel) {
553
+ // Single model mode - all nodes go in storeys section (which is the full hierarchy)
554
+ return { storeysNodes: filteredNodes, modelsNodes: [] };
555
+ }
556
+
557
+ // Find the models-header index to split
558
+ const modelsHeaderIdx = filteredNodes.findIndex(n => n.id === 'models-header');
559
+ if (modelsHeaderIdx === -1) {
560
+ return { storeysNodes: filteredNodes, modelsNodes: [] };
561
+ }
562
+
563
+ return {
564
+ storeysNodes: filteredNodes.slice(0, modelsHeaderIdx),
565
+ modelsNodes: filteredNodes.slice(modelsHeaderIdx + 1), // Skip the models-header itself
566
+ };
567
+ }, [filteredNodes, isMultiModel]);
568
+
569
+ // Refs for both scroll areas
570
+ const storeysRef = useRef<HTMLDivElement>(null);
571
+ const modelsRef = useRef<HTMLDivElement>(null);
572
+ const parentRef = useRef<HTMLDivElement>(null); // Legacy single-model mode
573
+
574
+ // Virtualizers for both sections
575
+ const storeysVirtualizer = useVirtualizer({
576
+ count: storeysNodes.length,
577
+ getScrollElement: () => storeysRef.current,
578
+ estimateSize: () => 36,
579
+ overscan: 10,
580
+ });
581
+
582
+ const modelsVirtualizer = useVirtualizer({
583
+ count: modelsNodes.length,
584
+ getScrollElement: () => modelsRef.current,
585
+ estimateSize: () => 36,
586
+ overscan: 10,
587
+ });
146
588
 
589
+ // Legacy virtualizer for single-model mode
147
590
  const virtualizer = useVirtualizer({
148
591
  count: filteredNodes.length,
149
592
  getScrollElement: () => parentRef.current,
@@ -151,212 +594,683 @@ export function HierarchyPanel() {
151
594
  overscan: 10,
152
595
  });
153
596
 
154
- const toggleExpand = useCallback((id: number) => {
597
+ // Resize handler for draggable divider
598
+ const handleResizeStart = useCallback((e: React.MouseEvent) => {
599
+ e.preventDefault();
600
+ setIsDragging(true);
601
+ }, []);
602
+
603
+ useEffect(() => {
604
+ if (!isDragging) return;
605
+
606
+ const handleMouseMove = (e: MouseEvent) => {
607
+ if (!containerRef.current) return;
608
+ const containerRect = containerRef.current.getBoundingClientRect();
609
+ const relativeY = e.clientY - containerRect.top;
610
+ // Account for the search header height (~70px)
611
+ const headerHeight = 70;
612
+ const availableHeight = containerRect.height - headerHeight;
613
+ const newRatio = Math.max(0.15, Math.min(0.85, (relativeY - headerHeight) / availableHeight));
614
+ setSplitRatio(newRatio);
615
+ };
616
+
617
+ const handleMouseUp = () => {
618
+ setIsDragging(false);
619
+ };
620
+
621
+ document.addEventListener('mousemove', handleMouseMove);
622
+ document.addEventListener('mouseup', handleMouseUp);
623
+
624
+ return () => {
625
+ document.removeEventListener('mousemove', handleMouseMove);
626
+ document.removeEventListener('mouseup', handleMouseUp);
627
+ };
628
+ }, [isDragging]);
629
+
630
+ const toggleExpand = useCallback((nodeId: string) => {
155
631
  setExpandedNodes(prev => {
156
632
  const next = new Set(prev);
157
- if (next.has(id)) {
158
- next.delete(id);
633
+ if (next.has(nodeId)) {
634
+ next.delete(nodeId);
159
635
  } else {
160
- next.add(id);
636
+ next.add(nodeId);
161
637
  }
162
638
  return next;
163
639
  });
164
640
  }, []);
165
641
 
166
- // Toggle visibility for a node - if storey, toggle all elements
642
+ // Get all elements for a node (handles unified storeys, single storeys, model contributions, and elements)
643
+ const getNodeElements = useCallback((node: TreeNode): number[] => {
644
+ if (node.type === 'unified-storey') {
645
+ // Get all elements from all models for this unified storey
646
+ const unified = unifiedStoreys.find(u => `unified-${u.key}` === node.id);
647
+ if (unified) {
648
+ return getUnifiedStoreyElements(unified);
649
+ }
650
+ } else if (node.type === 'model-header' && node.id.startsWith('contrib-')) {
651
+ // Model contribution header inside a unified storey - get elements for this model's storey
652
+ const storeyId = node.expressIds[0];
653
+ const modelId = node.modelIds[0];
654
+ const model = models.get(modelId);
655
+ if (model?.ifcDataStore?.spatialHierarchy) {
656
+ const localIds = (model.ifcDataStore.spatialHierarchy.byStorey.get(storeyId) as number[]) || [];
657
+ // Convert local expressIds to global IDs using model's idOffset
658
+ const offset = model.idOffset ?? 0;
659
+ return localIds.map(id => id + offset);
660
+ }
661
+ } else if (node.type === 'IfcBuildingStorey') {
662
+ // Get storey elements
663
+ const storeyId = node.expressIds[0];
664
+ const modelId = node.modelIds[0];
665
+
666
+ // Try legacy dataStore first (no offset needed, IDs are already global)
667
+ if (ifcDataStore?.spatialHierarchy) {
668
+ const elements = ifcDataStore.spatialHierarchy.byStorey.get(storeyId);
669
+ if (elements) return elements as number[];
670
+ }
671
+
672
+ // Or from the model in federation - need to apply idOffset
673
+ const model = models.get(modelId);
674
+ if (model?.ifcDataStore?.spatialHierarchy) {
675
+ const localIds = (model.ifcDataStore.spatialHierarchy.byStorey.get(storeyId) as number[]) || [];
676
+ const offset = model.idOffset ?? 0;
677
+ return localIds.map(id => id + offset);
678
+ }
679
+ } else if (node.type === 'element') {
680
+ return node.expressIds;
681
+ }
682
+ // Spatial containers (Project, Site, Building) and top-level models don't have direct element visibility toggle
683
+ return [];
684
+ }, [models, ifcDataStore, unifiedStoreys, getUnifiedStoreyElements]);
685
+
686
+ // Toggle visibility for a node
167
687
  const handleVisibilityToggle = useCallback((node: TreeNode) => {
168
- if (node.type === 'IfcBuildingStorey') {
169
- const elements = storeyElementsMap.get(node.id) || [];
170
- if (elements.length === 0) return;
688
+ const elements = getNodeElements(node);
689
+ if (elements.length === 0) return;
171
690
 
172
- // Check if all elements are visible
173
- const allVisible = elements.every(id => isEntityVisible(id));
691
+ // Check if all elements are currently visible (not hidden)
692
+ const allVisible = elements.every(id => !hiddenEntities.has(id));
174
693
 
175
- if (allVisible) {
176
- // Hide all elements in storey
177
- hideEntities(elements);
178
- } else {
179
- // Show all elements in storey
180
- showEntities(elements);
694
+ if (allVisible) {
695
+ hideEntities(elements);
696
+ if (selectedEntityId !== null && elements.includes(selectedEntityId)) {
697
+ clearSelection();
181
698
  }
182
699
  } else {
183
- // Single element toggle
184
- toggleEntityVisibility(node.id);
700
+ showEntities(elements);
185
701
  }
186
- }, [storeyElementsMap, isEntityVisible, hideEntities, showEntities, toggleEntityVisibility]);
187
-
188
- // Check if storey is fully visible (all elements visible)
189
- const isStoreyVisible = useCallback((storeyId: number) => {
190
- const elements = storeyElementsMap.get(storeyId) || [];
191
- if (elements.length === 0) return true;
192
- return elements.every(id => isEntityVisible(id));
193
- }, [storeyElementsMap, isEntityVisible]);
194
-
195
- const handleNodeClick = useCallback((node: TreeNode) => {
196
- if (node.type === 'IfcBuildingStorey') {
197
- setSelectedStorey(selectedStorey === node.id ? null : node.id);
198
- } else {
199
- setSelectedEntityId(node.id);
702
+ }, [getNodeElements, hiddenEntities, hideEntities, showEntities, selectedEntityId, clearSelection]);
703
+
704
+ // Handle model visibility toggle
705
+ const handleModelVisibilityToggle = useCallback((modelId: string, e: React.MouseEvent) => {
706
+ e.stopPropagation();
707
+ const model = models.get(modelId);
708
+ if (model) {
709
+ setModelVisibility(modelId, !model.visible);
710
+ }
711
+ }, [models, setModelVisibility]);
712
+
713
+ // Remove model
714
+ const handleRemoveModel = useCallback((modelId: string, e: React.MouseEvent) => {
715
+ e.stopPropagation();
716
+ removeModel(modelId);
717
+ }, [removeModel]);
718
+
719
+ // Handle node click - for selection/isolation or expand/collapse
720
+ const handleNodeClick = useCallback((node: TreeNode, e: React.MouseEvent) => {
721
+ if (node.type === 'model-header' && node.id !== 'models-header') {
722
+ // Model header click handled by its own onClick (expand/collapse)
723
+ return;
724
+ }
725
+
726
+ // Spatial container nodes (IfcProject/IfcSite/IfcBuilding) - select for property panel + expand
727
+ if (isSpatialContainer(node.type)) {
728
+ const entityId = node.expressIds[0];
729
+ const modelId = node.modelIds[0];
730
+
731
+ if (modelId && modelId !== 'legacy') {
732
+ // Multi-model: convert to globalId for renderer, set entity for property panel
733
+ const model = models.get(modelId);
734
+ const globalId = entityId + (model?.idOffset ?? 0);
735
+ setSelectedEntityId(globalId);
736
+ setSelectedEntity({ modelId, expressId: entityId });
737
+ setActiveModel(modelId);
738
+ } else if (entityId) {
739
+ // Legacy single-model
740
+ setSelectedEntityId(entityId);
741
+ setSelectedEntity({ modelId: 'legacy', expressId: entityId });
742
+ }
743
+
744
+ // Also toggle expand if has children
745
+ if (node.hasChildren) {
746
+ toggleExpand(node.id);
747
+ }
748
+ return;
749
+ }
750
+
751
+ if (node.type === 'unified-storey' || node.type === 'IfcBuildingStorey') {
752
+ // Storey click - select/isolate (unified or single)
753
+ const unified = node.type === 'unified-storey'
754
+ ? unifiedStoreys.find(u => `unified-${u.key}` === node.id)
755
+ : null;
756
+ const storeyIds = unified
757
+ ? unified.storeys.map(s => s.storeyId)
758
+ : node.expressIds;
759
+
760
+ // Set entity refs for property panel display
761
+ if (unified && unified.storeys.length > 1) {
762
+ // Multi-model unified storey: show all storeys combined in property panel
763
+ const entityRefs = unified.storeys.map(s => ({
764
+ modelId: s.modelId,
765
+ expressId: s.storeyId,
766
+ }));
767
+ setSelectedEntities(entityRefs);
768
+ // Clear single entity selection (property panel will use selectedEntities)
769
+ setSelectedEntityId(null);
770
+ } else {
771
+ // Single storey: show in property panel like any entity
772
+ const storeyId = storeyIds[0];
773
+ const modelId = node.modelIds[0];
774
+ if (modelId && modelId !== 'legacy') {
775
+ const model = models.get(modelId);
776
+ const globalId = storeyId + (model?.idOffset ?? 0);
777
+ setSelectedEntityId(globalId);
778
+ setSelectedEntity({ modelId, expressId: storeyId });
779
+ } else {
780
+ setSelectedEntityId(storeyId);
781
+ setSelectedEntity({ modelId: 'legacy', expressId: storeyId });
782
+ }
783
+ }
784
+
785
+ if (e.ctrlKey || e.metaKey) {
786
+ // Add to storey filter selection
787
+ setStoreysSelection([...Array.from(selectedStoreys), ...storeyIds]);
788
+ } else {
789
+ // Single selection - toggle if already selected
790
+ const allAlreadySelected = storeyIds.length > 0 &&
791
+ storeyIds.every(id => selectedStoreys.has(id)) &&
792
+ selectedStoreys.size === storeyIds.length;
793
+
794
+ if (allAlreadySelected) {
795
+ // Toggle off - clear selection to show all
796
+ clearStoreySelection();
797
+ } else {
798
+ // Select this storey (replaces any existing selection)
799
+ setStoreysSelection(storeyIds);
800
+ }
801
+ }
802
+ } else if (node.type === 'element') {
803
+ // Element click - select it
804
+ const elementId = node.expressIds[0]; // Original expressId
805
+ const modelId = node.modelIds[0];
806
+
807
+ if (modelId !== 'legacy') {
808
+ // Multi-model: need to convert to globalId for renderer
809
+ const model = models.get(modelId);
810
+ const globalId = elementId + (model?.idOffset ?? 0);
811
+ setSelectedEntityId(globalId);
812
+ setSelectedEntity({ modelId, expressId: elementId });
813
+ setActiveModel(modelId);
814
+ } else {
815
+ // Legacy single-model: expressId = globalId (offset is 0)
816
+ setSelectedEntityId(elementId);
817
+ }
200
818
  }
201
- }, [selectedStorey, setSelectedStorey, setSelectedEntityId]);
819
+ }, [selectedStoreys, setStoreysSelection, clearStoreySelection, setSelectedEntityId, setSelectedEntity, setSelectedEntities, setActiveModel, toggleExpand, unifiedStoreys, models]);
202
820
 
203
- if (!ifcDataStore) {
821
+ if (!ifcDataStore && models.size === 0) {
204
822
  return (
205
- <div className="h-full flex flex-col border-r bg-card">
206
- <div className="p-3 border-b">
207
- <h2 className="font-semibold text-sm">Model Hierarchy</h2>
823
+ <div className="h-full flex flex-col border-r-2 border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black">
824
+ <div className="p-3 border-b-2 border-zinc-200 dark:border-zinc-800 bg-white dark:bg-black">
825
+ <h2 className="font-bold uppercase tracking-wider text-xs text-zinc-900 dark:text-zinc-100">Hierarchy</h2>
208
826
  </div>
209
- <div className="flex-1 flex items-center justify-center text-muted-foreground text-sm">
210
- Load an IFC file to view hierarchy
827
+ <div className="flex-1 flex flex-col items-center justify-center text-center p-6 bg-white dark:bg-black">
828
+ <div className="w-16 h-16 border-2 border-dashed border-zinc-300 dark:border-zinc-800 flex items-center justify-center mb-4 bg-zinc-100 dark:bg-zinc-950">
829
+ <LayoutTemplate className="h-8 w-8 text-zinc-400 dark:text-zinc-500" />
830
+ </div>
831
+ <p className="font-bold uppercase text-zinc-900 dark:text-zinc-100 mb-2">No Model</p>
832
+ <p className="text-xs font-mono text-zinc-500 dark:text-zinc-400 max-w-[150px]">
833
+ Structure will appear here when loaded
834
+ </p>
211
835
  </div>
212
836
  </div>
213
837
  );
214
838
  }
215
839
 
216
- return (
217
- <div className="h-full flex flex-col border-r bg-card">
218
- {/* Header */}
219
- <div className="p-3 border-b space-y-2">
220
- <h2 className="font-semibold text-sm">Model Hierarchy</h2>
221
- <Input
222
- placeholder="Search elements..."
223
- value={searchQuery}
224
- onChange={(e) => setSearchQuery(e.target.value)}
225
- leftIcon={<Search className="h-4 w-4" />}
226
- className="h-8 text-sm"
227
- />
228
- </div>
840
+ // Helper function to render a node
841
+ const renderNode = (node: TreeNode, virtualRow: { index: number; size: number; start: number }, nodeList: TreeNode[]) => {
842
+ const Icon = TYPE_ICONS[node.type] || TYPE_ICONS.default;
229
843
 
230
- {/* Tree */}
231
- <div ref={parentRef} className="flex-1 overflow-auto scrollbar-thin">
844
+ // Determine if node is selected
845
+ const isSelected = node.type === 'unified-storey'
846
+ ? node.expressIds.some(id => selectedStoreys.has(id))
847
+ : node.type === 'IfcBuildingStorey'
848
+ ? selectedStoreys.has(node.expressIds[0])
849
+ : node.type === 'element'
850
+ ? selectedEntityId === node.expressIds[0]
851
+ : false;
852
+
853
+ // Compute visibility inline - for elements check directly, for storeys use getNodeElements
854
+ // This avoids a useCallback dependency that was causing infinite re-renders
855
+ let nodeHidden = false;
856
+ if (node.type === 'element') {
857
+ nodeHidden = hiddenEntities.has(node.expressIds[0]);
858
+ } else if (node.type === 'IfcBuildingStorey' || node.type === 'unified-storey' ||
859
+ (node.type === 'model-header' && node.id.startsWith('contrib-'))) {
860
+ const elements = getNodeElements(node);
861
+ nodeHidden = elements.length > 0 && elements.every(id => hiddenEntities.has(id));
862
+ }
863
+
864
+ // Model header nodes (for visibility control and expansion)
865
+ if (node.type === 'model-header' && node.id.startsWith('model-')) {
866
+ const modelId = node.modelIds[0];
867
+ const model = models.get(modelId);
868
+
869
+ return (
232
870
  <div
871
+ key={node.id}
233
872
  style={{
234
- height: `${virtualizer.getTotalSize()}px`,
873
+ position: 'absolute',
874
+ top: 0,
875
+ left: 0,
235
876
  width: '100%',
236
- position: 'relative',
877
+ height: `${virtualRow.size}px`,
878
+ transform: `translateY(${virtualRow.start}px)`,
237
879
  }}
238
880
  >
239
- {virtualizer.getVirtualItems().map((virtualRow) => {
240
- const node = filteredNodes[virtualRow.index];
241
- const Icon = TYPE_ICONS[node.type] || TYPE_ICONS.default;
242
- const isSelected = node.type === 'IfcBuildingStorey'
243
- ? selectedStorey === node.id
244
- : selectedEntityId === node.id;
245
- // For storeys, check if all elements are visible
246
- const nodeVisible = node.type === 'IfcBuildingStorey'
247
- ? isStoreyVisible(node.id)
248
- : isEntityVisible(node.id);
249
- const nodeHidden = !nodeVisible;
250
-
251
- return (
252
- <div
253
- key={node.id}
254
- style={{
255
- position: 'absolute',
256
- top: 0,
257
- left: 0,
258
- width: '100%',
259
- height: `${virtualRow.size}px`,
260
- transform: `translateY(${virtualRow.start}px)`,
261
- }}
262
- >
263
- <div
264
- className={cn(
265
- 'flex items-center gap-1 px-2 py-1.5 cursor-pointer hover:bg-muted/50 border-l-2 border-transparent transition-colors group',
266
- isSelected && 'bg-primary/10 border-l-primary',
267
- nodeHidden && 'opacity-50'
268
- )}
269
- style={{ paddingLeft: `${node.depth * 16 + 8}px` }}
881
+ <div
882
+ className={cn(
883
+ 'flex items-center gap-1 px-2 py-1.5 border-l-4 transition-all group',
884
+ 'hover:bg-zinc-50 dark:hover:bg-zinc-900',
885
+ 'border-transparent',
886
+ !model?.visible && 'opacity-50',
887
+ node.hasChildren && 'cursor-pointer'
888
+ )}
889
+ style={{ paddingLeft: '8px' }}
890
+ onClick={() => {
891
+ setSelectedModelId(modelId);
892
+ if (node.hasChildren) toggleExpand(node.id);
893
+ }}
894
+ >
895
+ {/* Expand/collapse chevron */}
896
+ {node.hasChildren ? (
897
+ <ChevronRight
898
+ className={cn(
899
+ 'h-3.5 w-3.5 text-zinc-400 transition-transform shrink-0',
900
+ node.isExpanded && 'rotate-90'
901
+ )}
902
+ />
903
+ ) : (
904
+ <div className="w-3.5" />
905
+ )}
906
+
907
+ <FileBox className="h-3.5 w-3.5 text-primary shrink-0" />
908
+ <span className="flex-1 text-sm truncate ml-1.5 text-zinc-900 dark:text-zinc-100">
909
+ {node.name}
910
+ </span>
911
+
912
+ {node.elementCount !== undefined && (
913
+ <span className="text-[10px] font-mono bg-zinc-100 dark:bg-zinc-800 px-1.5 py-0.5 text-zinc-500 dark:text-zinc-400 rounded-none">
914
+ {node.elementCount.toLocaleString()}
915
+ </span>
916
+ )}
917
+
918
+ <Tooltip>
919
+ <TooltipTrigger asChild>
920
+ <button
270
921
  onClick={(e) => {
271
- // Only handle click if not clicking on a button
272
- if ((e.target as HTMLElement).closest('button') === null) {
273
- handleNodeClick(node);
274
- }
275
- }}
276
- onMouseDown={(e) => {
277
- // Prevent text selection when clicking
278
- if ((e.target as HTMLElement).closest('button') === null) {
279
- e.preventDefault();
280
- }
922
+ e.stopPropagation();
923
+ handleModelVisibilityToggle(modelId, e);
281
924
  }}
925
+ className="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
282
926
  >
283
- {/* Expand/Collapse */}
284
- {node.hasChildren ? (
285
- <button
286
- onClick={(e) => {
287
- e.stopPropagation();
288
- toggleExpand(node.id);
289
- }}
290
- className="p-0.5 hover:bg-muted rounded"
291
- >
292
- <ChevronRight
293
- className={cn(
294
- 'h-3.5 w-3.5 transition-transform',
295
- node.isExpanded && 'rotate-90'
296
- )}
297
- />
298
- </button>
927
+ {model?.visible ? (
928
+ <Eye className="h-3.5 w-3.5 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100" />
299
929
  ) : (
300
- <div className="w-4.5" />
930
+ <EyeOff className="h-3.5 w-3.5 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100" />
301
931
  )}
932
+ </button>
933
+ </TooltipTrigger>
934
+ <TooltipContent>
935
+ <p className="text-xs">{model?.visible ? 'Hide model' : 'Show model'}</p>
936
+ </TooltipContent>
937
+ </Tooltip>
302
938
 
303
- {/* Visibility Toggle */}
939
+ {models.size > 1 && (
940
+ <Tooltip>
941
+ <TooltipTrigger asChild>
304
942
  <button
305
943
  onClick={(e) => {
306
944
  e.stopPropagation();
307
- handleVisibilityToggle(node);
945
+ handleRemoveModel(modelId, e);
308
946
  }}
309
- className={cn(
310
- 'p-0.5 rounded opacity-0 group-hover:opacity-100 transition-opacity',
311
- nodeHidden && 'opacity-100'
312
- )}
947
+ className="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
313
948
  >
314
- {nodeVisible ? (
315
- <Eye className="h-3 w-3 text-muted-foreground" />
316
- ) : (
317
- <EyeOff className="h-3 w-3 text-muted-foreground" />
318
- )}
949
+ <X className="h-3.5 w-3.5 text-zinc-400 hover:text-red-500" />
319
950
  </button>
951
+ </TooltipTrigger>
952
+ <TooltipContent>
953
+ <p className="text-xs">Remove model</p>
954
+ </TooltipContent>
955
+ </Tooltip>
956
+ )}
957
+ </div>
958
+ </div>
959
+ );
960
+ }
320
961
 
321
- {/* Type Icon */}
322
- <Icon className="h-4 w-4 text-muted-foreground shrink-0" />
323
-
324
- {/* Name */}
325
- <span className={cn(
326
- 'flex-1 text-sm truncate',
327
- nodeHidden && 'line-through'
328
- )}>{node.name}</span>
962
+ // Regular node rendering (spatial hierarchy nodes and elements)
963
+ return (
964
+ <div
965
+ key={node.id}
966
+ style={{
967
+ position: 'absolute',
968
+ top: 0,
969
+ left: 0,
970
+ width: '100%',
971
+ height: `${virtualRow.size}px`,
972
+ transform: `translateY(${virtualRow.start}px)`,
973
+ }}
974
+ >
975
+ <div
976
+ className={cn(
977
+ 'flex items-center gap-1 px-2 py-1.5 border-l-4 transition-all group hierarchy-item',
978
+ // No selection styling for spatial containers in multi-model mode
979
+ isMultiModel && isSpatialContainer(node.type)
980
+ ? 'border-transparent cursor-default'
981
+ : cn(
982
+ 'cursor-pointer',
983
+ isSelected ? 'border-l-primary font-medium selected' : 'border-transparent'
984
+ ),
985
+ nodeHidden && 'opacity-50 grayscale'
986
+ )}
987
+ style={{
988
+ paddingLeft: `${node.depth * 16 + 8}px`,
989
+ // No selection highlighting for spatial containers in multi-model mode
990
+ backgroundColor: isSelected && !(isMultiModel && isSpatialContainer(node.type))
991
+ ? 'var(--hierarchy-selected-bg)' : undefined,
992
+ color: isSelected && !(isMultiModel && isSpatialContainer(node.type))
993
+ ? 'var(--hierarchy-selected-text)' : undefined,
994
+ }}
995
+ onClick={(e) => {
996
+ if ((e.target as HTMLElement).closest('button') === null) {
997
+ handleNodeClick(node, e);
998
+ }
999
+ }}
1000
+ onMouseDown={(e) => {
1001
+ if ((e.target as HTMLElement).closest('button') === null) {
1002
+ e.preventDefault();
1003
+ }
1004
+ }}
1005
+ >
1006
+ {/* Expand/Collapse */}
1007
+ {node.hasChildren ? (
1008
+ <button
1009
+ onClick={(e) => {
1010
+ e.stopPropagation();
1011
+ toggleExpand(node.id);
1012
+ }}
1013
+ className="p-0.5 hover:bg-zinc-200 dark:hover:bg-zinc-700 rounded-none mr-1"
1014
+ >
1015
+ <ChevronRight
1016
+ className={cn(
1017
+ 'h-3.5 w-3.5 transition-transform duration-200',
1018
+ node.isExpanded && 'rotate-90'
1019
+ )}
1020
+ />
1021
+ </button>
1022
+ ) : (
1023
+ <div className="w-5" />
1024
+ )}
329
1025
 
330
- {/* Element Count */}
331
- {node.elementCount !== undefined && (
332
- <span className="text-xs text-muted-foreground">
333
- {node.elementCount}
334
- </span>
1026
+ {/* Visibility Toggle - hide for spatial containers (Project/Site/Building) in multi-model mode */}
1027
+ {!(isMultiModel && isSpatialContainer(node.type)) && (
1028
+ <Tooltip>
1029
+ <TooltipTrigger asChild>
1030
+ <button
1031
+ onClick={(e) => {
1032
+ e.stopPropagation();
1033
+ handleVisibilityToggle(node);
1034
+ }}
1035
+ className={cn(
1036
+ 'p-0.5 opacity-0 group-hover:opacity-100 transition-opacity mr-1',
1037
+ nodeHidden && 'opacity-100'
335
1038
  )}
336
- </div>
1039
+ >
1040
+ {node.isVisible ? (
1041
+ <Eye className="h-3 w-3 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100" />
1042
+ ) : (
1043
+ <EyeOff className="h-3 w-3 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100" />
1044
+ )}
1045
+ </button>
1046
+ </TooltipTrigger>
1047
+ <TooltipContent>
1048
+ <p className="text-xs">
1049
+ {node.isVisible ? 'Hide' : 'Show'}
1050
+ </p>
1051
+ </TooltipContent>
1052
+ </Tooltip>
1053
+ )}
1054
+
1055
+ {/* Type Icon */}
1056
+ <Tooltip>
1057
+ <TooltipTrigger asChild>
1058
+ <Icon className="h-3.5 w-3.5 shrink-0 text-zinc-500 dark:text-zinc-400" />
1059
+ </TooltipTrigger>
1060
+ <TooltipContent>
1061
+ <p className="text-xs">{node.type}</p>
1062
+ </TooltipContent>
1063
+ </Tooltip>
1064
+
1065
+ {/* Name */}
1066
+ <span className={cn(
1067
+ 'flex-1 text-sm truncate ml-1.5',
1068
+ isSpatialContainer(node.type)
1069
+ ? 'font-medium text-zinc-900 dark:text-zinc-100'
1070
+ : 'text-zinc-700 dark:text-zinc-300',
1071
+ nodeHidden && 'line-through decoration-zinc-400 dark:decoration-zinc-600'
1072
+ )}>{node.name}</span>
1073
+
1074
+ {/* Storey Elevation */}
1075
+ {node.storeyElevation !== undefined && (
1076
+ <Tooltip>
1077
+ <TooltipTrigger asChild>
1078
+ <span className="text-[10px] font-mono bg-emerald-100 dark:bg-emerald-950 px-1.5 py-0.5 border border-emerald-200 dark:border-emerald-800 text-emerald-600 dark:text-emerald-400 rounded-none">
1079
+ {node.storeyElevation >= 0 ? '+' : ''}{node.storeyElevation.toFixed(2)}m
1080
+ </span>
1081
+ </TooltipTrigger>
1082
+ <TooltipContent>
1083
+ <p className="text-xs">Elevation: {node.storeyElevation >= 0 ? '+' : ''}{node.storeyElevation.toFixed(2)}m</p>
1084
+ </TooltipContent>
1085
+ </Tooltip>
1086
+ )}
1087
+
1088
+ {/* Element Count */}
1089
+ {node.elementCount !== undefined && (
1090
+ <Tooltip>
1091
+ <TooltipTrigger asChild>
1092
+ <span className="text-[10px] font-mono bg-zinc-100 dark:bg-zinc-950 px-1.5 py-0.5 border border-zinc-200 dark:border-zinc-800 text-zinc-500 dark:text-zinc-400 rounded-none">
1093
+ {node.elementCount}
1094
+ </span>
1095
+ </TooltipTrigger>
1096
+ <TooltipContent>
1097
+ <p className="text-xs">{node.elementCount} {node.elementCount === 1 ? 'element' : 'elements'}</p>
1098
+ </TooltipContent>
1099
+ </Tooltip>
1100
+ )}
1101
+ </div>
1102
+ </div>
1103
+ );
1104
+ };
1105
+
1106
+ // Section header component
1107
+ const SectionHeader = ({ icon: IconComponent, title, count }: { icon: React.ElementType; title: string; count?: number }) => (
1108
+ <div className="flex items-center gap-2 px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border-b border-zinc-200 dark:border-zinc-800">
1109
+ <IconComponent className="h-3.5 w-3.5 text-zinc-500" />
1110
+ <span className="text-[10px] font-bold uppercase tracking-wider text-zinc-600 dark:text-zinc-400">
1111
+ {title}
1112
+ </span>
1113
+ {count !== undefined && (
1114
+ <span className="text-[10px] font-mono text-zinc-400 dark:text-zinc-500 ml-auto">
1115
+ {count}
1116
+ </span>
1117
+ )}
1118
+ </div>
1119
+ );
1120
+
1121
+ // Multi-model layout with resizable split
1122
+ if (isMultiModel) {
1123
+ return (
1124
+ <div ref={containerRef} className="h-full flex flex-col border-r-2 border-zinc-200 dark:border-zinc-800 bg-white dark:bg-black">
1125
+ {/* Search Header */}
1126
+ <div className="p-3 border-b-2 border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black">
1127
+ <Input
1128
+ placeholder="Search..."
1129
+ value={searchQuery}
1130
+ onChange={(e) => setSearchQuery(e.target.value)}
1131
+ leftIcon={<Search className="h-4 w-4" />}
1132
+ className="h-9 text-sm rounded-none border-2 border-zinc-200 dark:border-zinc-800 focus:border-primary focus:ring-0 bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-400 dark:placeholder:text-zinc-600"
1133
+ />
1134
+ </div>
1135
+
1136
+ {/* Resizable content area */}
1137
+ <div className="flex-1 flex flex-col min-h-0">
1138
+ {/* Storeys Section */}
1139
+ <div style={{ height: `${splitRatio * 100}%` }} className="flex flex-col min-h-0">
1140
+ <SectionHeader icon={Layers} title="Building Storeys" count={storeysNodes.length} />
1141
+ <div ref={storeysRef} className="flex-1 overflow-auto scrollbar-thin bg-white dark:bg-black">
1142
+ <div
1143
+ style={{
1144
+ height: `${storeysVirtualizer.getTotalSize()}px`,
1145
+ width: '100%',
1146
+ position: 'relative',
1147
+ }}
1148
+ >
1149
+ {storeysVirtualizer.getVirtualItems().map((virtualRow) => {
1150
+ const node = storeysNodes[virtualRow.index];
1151
+ return renderNode(node, virtualRow, storeysNodes);
1152
+ })}
1153
+ </div>
1154
+ </div>
1155
+ </div>
1156
+
1157
+ {/* Resizable Divider */}
1158
+ <div
1159
+ className={cn(
1160
+ 'flex items-center justify-center h-2 cursor-ns-resize border-y border-zinc-200 dark:border-zinc-800 bg-zinc-100 dark:bg-zinc-900 hover:bg-zinc-200 dark:hover:bg-zinc-800 transition-colors',
1161
+ isDragging && 'bg-primary/20'
1162
+ )}
1163
+ onMouseDown={handleResizeStart}
1164
+ >
1165
+ <GripHorizontal className="h-3 w-3 text-zinc-400" />
1166
+ </div>
1167
+
1168
+ {/* Models Section */}
1169
+ <div style={{ height: `${(1 - splitRatio) * 100}%` }} className="flex flex-col min-h-0">
1170
+ <SectionHeader icon={FileBox} title="Models" count={models.size} />
1171
+ <div ref={modelsRef} className="flex-1 overflow-auto scrollbar-thin bg-white dark:bg-black">
1172
+ <div
1173
+ style={{
1174
+ height: `${modelsVirtualizer.getTotalSize()}px`,
1175
+ width: '100%',
1176
+ position: 'relative',
1177
+ }}
1178
+ >
1179
+ {modelsVirtualizer.getVirtualItems().map((virtualRow) => {
1180
+ const node = modelsNodes[virtualRow.index];
1181
+ return renderNode(node, virtualRow, modelsNodes);
1182
+ })}
1183
+ </div>
1184
+ </div>
1185
+ </div>
1186
+ </div>
1187
+
1188
+ {/* Footer status */}
1189
+ {selectedStoreys.size > 0 ? (
1190
+ <div className="p-2 border-t-2 border-zinc-200 dark:border-zinc-800 bg-primary text-white dark:bg-primary">
1191
+ <div className="flex items-center justify-between text-xs font-medium">
1192
+ <span className="uppercase tracking-wide">
1193
+ {selectedStoreys.size} {selectedStoreys.size === 1 ? 'STOREY' : 'STOREYS'} FILTERED
1194
+ </span>
1195
+ <div className="flex items-center gap-2">
1196
+ <span className="opacity-70 text-[10px] font-mono">ESC</span>
1197
+ <Button
1198
+ variant="ghost"
1199
+ size="sm"
1200
+ className="h-6 text-[10px] uppercase border border-white/20 hover:bg-white/20 hover:text-white rounded-none px-2"
1201
+ onClick={clearStoreySelection}
1202
+ >
1203
+ Clear
1204
+ </Button>
337
1205
  </div>
338
- );
1206
+ </div>
1207
+ </div>
1208
+ ) : (
1209
+ <div className="p-2 border-t-2 border-zinc-200 dark:border-zinc-800 text-[10px] uppercase tracking-wide text-zinc-500 dark:text-zinc-500 text-center bg-zinc-50 dark:bg-black font-mono">
1210
+ {models.size} models · Drag divider to resize
1211
+ </div>
1212
+ )}
1213
+ </div>
1214
+ );
1215
+ }
1216
+
1217
+ // Single model layout
1218
+ return (
1219
+ <div className="h-full flex flex-col border-r-2 border-zinc-200 dark:border-zinc-800 bg-white dark:bg-black">
1220
+ {/* Header */}
1221
+ <div className="p-3 border-b-2 border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black">
1222
+ <Input
1223
+ placeholder="Search..."
1224
+ value={searchQuery}
1225
+ onChange={(e) => setSearchQuery(e.target.value)}
1226
+ leftIcon={<Search className="h-4 w-4" />}
1227
+ className="h-9 text-sm rounded-none border-2 border-zinc-200 dark:border-zinc-800 focus:border-primary focus:ring-0 bg-white dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100 placeholder:text-zinc-400 dark:placeholder:text-zinc-600"
1228
+ />
1229
+ </div>
1230
+
1231
+ {/* Section Header */}
1232
+ <SectionHeader icon={Building2} title="Hierarchy" count={filteredNodes.length} />
1233
+
1234
+ {/* Tree */}
1235
+ <div ref={parentRef} className="flex-1 overflow-auto scrollbar-thin bg-white dark:bg-black">
1236
+ <div
1237
+ style={{
1238
+ height: `${virtualizer.getTotalSize()}px`,
1239
+ width: '100%',
1240
+ position: 'relative',
1241
+ }}
1242
+ >
1243
+ {virtualizer.getVirtualItems().map((virtualRow) => {
1244
+ const node = filteredNodes[virtualRow.index];
1245
+ return renderNode(node, virtualRow, filteredNodes);
339
1246
  })}
340
1247
  </div>
341
1248
  </div>
342
1249
 
343
- {/* Quick Filter */}
344
- {selectedStorey && (
345
- <div className="p-2 border-t bg-primary/5">
346
- <div className="flex items-center justify-between text-xs">
347
- <span className="text-muted-foreground">
348
- Filtered to storey
1250
+ {/* Footer status */}
1251
+ {selectedStoreys.size > 0 ? (
1252
+ <div className="p-2 border-t-2 border-zinc-200 dark:border-zinc-800 bg-primary text-white dark:bg-primary">
1253
+ <div className="flex items-center justify-between text-xs font-medium">
1254
+ <span className="uppercase tracking-wide">
1255
+ {selectedStoreys.size} {selectedStoreys.size === 1 ? 'STOREY' : 'STOREYS'} FILTERED
349
1256
  </span>
350
- <Button
351
- variant="ghost"
352
- size="sm"
353
- className="h-6 text-xs"
354
- onClick={() => setSelectedStorey(null)}
355
- >
356
- Clear filter
357
- </Button>
1257
+ <div className="flex items-center gap-2">
1258
+ <span className="opacity-70 text-[10px] font-mono">ESC</span>
1259
+ <Button
1260
+ variant="ghost"
1261
+ size="sm"
1262
+ className="h-6 text-[10px] uppercase border border-white/20 hover:bg-white/20 hover:text-white rounded-none px-2"
1263
+ onClick={clearStoreySelection}
1264
+ >
1265
+ Clear
1266
+ </Button>
1267
+ </div>
358
1268
  </div>
359
1269
  </div>
1270
+ ) : (
1271
+ <div className="p-2 border-t-2 border-zinc-200 dark:border-zinc-800 text-[10px] uppercase tracking-wide text-zinc-500 dark:text-zinc-500 text-center bg-zinc-50 dark:bg-black font-mono">
1272
+ Click to filter · Ctrl toggle
1273
+ </div>
360
1274
  )}
361
1275
  </div>
362
1276
  );