@ifc-lite/viewer 1.6.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/dist/assets/{Arrow.dom-Be1tgmo6.js → Arrow.dom-CwcRxist.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/index-7WoQ-qVC.css +1 -0
  5. package/dist/assets/{index-D1Du89Pa.js → index-BSANf7-H.js} +44948 -31410
  6. package/dist/assets/{native-bridge-A6zNnTfi.js → native-bridge-5LbrYh3R.js} +1 -1
  7. package/dist/assets/{wasm-bridge-DkRhgSvE.js → wasm-bridge-CgpLtj1h.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +18 -15
  10. package/src/components/viewer/BCFPanel.tsx +7 -789
  11. package/src/components/viewer/Drawing2DCanvas.tsx +1411 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/EntityContextMenu.tsx +47 -20
  14. package/src/components/viewer/ExportDialog.tsx +166 -17
  15. package/src/components/viewer/HierarchyPanel.tsx +113 -843
  16. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  17. package/src/components/viewer/IDSPanel.tsx +126 -17
  18. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  19. package/src/components/viewer/LensPanel.tsx +1366 -0
  20. package/src/components/viewer/MainToolbar.tsx +237 -37
  21. package/src/components/viewer/PropertiesPanel.tsx +171 -652
  22. package/src/components/viewer/PropertyEditor.tsx +866 -77
  23. package/src/components/viewer/Section2DPanel.tsx +329 -2661
  24. package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
  25. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  26. package/src/components/viewer/ViewerLayout.tsx +132 -45
  27. package/src/components/viewer/Viewport.tsx +290 -1678
  28. package/src/components/viewer/ViewportContainer.tsx +13 -3
  29. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  30. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  31. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  32. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  33. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  34. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  35. package/src/components/viewer/hierarchy/types.ts +54 -0
  36. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  37. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  38. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  39. package/src/components/viewer/lists/ListResultsTable.tsx +227 -0
  40. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  41. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  42. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  43. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  44. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  45. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  46. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  47. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  48. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  49. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  50. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  51. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  52. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  53. package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
  54. package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
  55. package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
  56. package/src/components/viewer/tools/computePolygonArea.ts +72 -0
  57. package/src/components/viewer/tools/formatDistance.ts +18 -0
  58. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  59. package/src/components/viewer/useAnimationLoop.ts +166 -0
  60. package/src/components/viewer/useGeometryStreaming.ts +406 -0
  61. package/src/components/viewer/useKeyboardControls.ts +221 -0
  62. package/src/components/viewer/useMouseControls.ts +1009 -0
  63. package/src/components/viewer/useRenderUpdates.ts +165 -0
  64. package/src/components/viewer/useTouchControls.ts +245 -0
  65. package/src/hooks/ids/idsColorSystem.ts +125 -0
  66. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  67. package/src/hooks/ids/idsExportService.ts +444 -0
  68. package/src/hooks/useAnnotation2D.ts +551 -0
  69. package/src/hooks/useBCF.ts +7 -0
  70. package/src/hooks/useDrawingExport.ts +709 -0
  71. package/src/hooks/useDrawingGeneration.ts +627 -0
  72. package/src/hooks/useFloorplanView.ts +108 -0
  73. package/src/hooks/useIDS.ts +270 -463
  74. package/src/hooks/useIfc.ts +26 -1628
  75. package/src/hooks/useIfcFederation.ts +803 -0
  76. package/src/hooks/useIfcLoader.ts +508 -0
  77. package/src/hooks/useIfcServer.ts +465 -0
  78. package/src/hooks/useKeyboardShortcuts.ts +114 -15
  79. package/src/hooks/useLens.ts +113 -0
  80. package/src/hooks/useLensDiscovery.ts +46 -0
  81. package/src/hooks/useMeasure2D.ts +365 -0
  82. package/src/hooks/useModelSelection.ts +5 -22
  83. package/src/hooks/useViewControls.ts +218 -0
  84. package/src/index.css +7 -1
  85. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  86. package/src/lib/ifc4-pset-definitions.ts +621 -0
  87. package/src/lib/ifc4-qto-definitions.ts +315 -0
  88. package/src/lib/lens/adapter.ts +264 -0
  89. package/src/lib/lens/index.ts +5 -0
  90. package/src/lib/lists/adapter.ts +69 -0
  91. package/src/lib/lists/columnToAutoColor.ts +33 -0
  92. package/src/lib/lists/index.ts +28 -0
  93. package/src/lib/lists/persistence.ts +64 -0
  94. package/src/services/fs-cache.ts +1 -1
  95. package/src/services/tauri-modules.d.ts +25 -0
  96. package/src/store/index.ts +52 -3
  97. package/src/store/resolveEntityRef.ts +44 -0
  98. package/src/store/slices/cameraSlice.ts +14 -1
  99. package/src/store/slices/dataSlice.ts +14 -1
  100. package/src/store/slices/drawing2DSlice.ts +321 -0
  101. package/src/store/slices/lensSlice.ts +226 -0
  102. package/src/store/slices/listSlice.ts +74 -0
  103. package/src/store/slices/pinboardSlice.ts +247 -0
  104. package/src/store/types.ts +5 -0
  105. package/src/store.ts +3 -0
  106. package/src/utils/ifcConfig.ts +16 -3
  107. package/src/utils/serverDataModel.ts +64 -101
  108. package/src/vite-env.d.ts +3 -0
  109. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  110. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -2,107 +2,31 @@
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, useEffect } from 'react';
5
+ import { useState, useCallback, useRef, useEffect } from 'react';
6
6
  import { useVirtualizer } from '@tanstack/react-virtual';
7
7
  import {
8
8
  Search,
9
- ChevronRight,
10
9
  Building2,
11
10
  Layers,
12
- MapPin,
13
- FolderKanban,
14
- Square,
15
- Box,
16
- DoorOpen,
17
- Eye,
18
- EyeOff,
19
11
  LayoutTemplate,
20
12
  FileBox,
21
- X,
22
13
  GripHorizontal,
23
14
  } from 'lucide-react';
24
15
  import { Input } from '@/components/ui/input';
25
16
  import { Button } from '@/components/ui/button';
26
- import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
27
17
  import { cn } from '@/lib/utils';
28
- import { useViewerStore } from '@/store';
18
+ import { useViewerStore, resolveEntityRef } from '@/store';
29
19
  import { useIfc } from '@/hooks/useIfc';
30
- import { IfcTypeEnum, type SpatialNode } from '@ifc-lite/data';
31
- import type { IfcDataStore } from '@ifc-lite/parser';
32
20
 
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
42
-
43
- interface TreeNode {
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[];
49
- name: string;
50
- type: NodeType;
51
- depth: number;
52
- hasChildren: boolean;
53
- isExpanded: boolean;
54
- isVisible: boolean; // Note: For storeys, computed lazily during render for performance
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;
77
- }
78
-
79
- const TYPE_ICONS: Record<string, React.ElementType> = {
80
- 'unified-storey': Layers,
81
- 'model-header': FileBox,
82
- IfcProject: FolderKanban,
83
- IfcSite: MapPin,
84
- IfcBuilding: Building2,
85
- IfcBuildingStorey: Layers,
86
- IfcSpace: Box,
87
- IfcWall: Square,
88
- IfcWallStandardCase: Square,
89
- IfcDoor: DoorOpen,
90
- element: Box,
91
- default: Box,
92
- };
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
- }
21
+ import type { TreeNode } from './hierarchy/types';
22
+ import { isSpatialContainer } from './hierarchy/types';
23
+ import { useHierarchyTree } from './hierarchy/useHierarchyTree';
24
+ import { HierarchyNode, SectionHeader } from './hierarchy/HierarchyNode';
102
25
 
103
26
  export function HierarchyPanel() {
104
27
  const {
105
28
  ifcDataStore,
29
+ geometryResult,
106
30
  models,
107
31
  activeModelId,
108
32
  setActiveModel,
@@ -127,10 +51,6 @@ export function HierarchyPanel() {
127
51
  const toggleEntityVisibility = useViewerStore((s) => s.toggleEntityVisibility);
128
52
  const clearSelection = useViewerStore((s) => s.clearSelection);
129
53
 
130
- const [searchQuery, setSearchQuery] = useState('');
131
- const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
132
- const [hasInitializedExpansion, setHasInitializedExpansion] = useState(false);
133
-
134
54
  // Resizable panel split (percentage for storeys section, 0.5 = 50%)
135
55
  const [splitRatio, setSplitRatio] = useState(0.5);
136
56
  const [isDragging, setIsDragging] = useState(false);
@@ -139,432 +59,19 @@ export function HierarchyPanel() {
139
59
  // Check if we have multiple models loaded
140
60
  const isMultiModel = models.size > 1;
141
61
 
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
- }, []);
152
-
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 [];
156
-
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())
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);
223
-
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);
342
-
343
- nodes.push({
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,
474
- isVisible: true,
475
- });
476
-
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
- );
507
- }
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
- );
534
- }
535
-
536
- return nodes;
537
- // Note: hiddenEntities intentionally NOT in deps - visibility computed lazily for performance
538
- }, [models, ifcDataStore, expandedNodes, isMultiModel, getNodeType, unifiedStoreys, getUnifiedStoreyElements]);
539
-
540
- // Filter nodes based on search
541
- const filteredNodes = useMemo(() => {
542
- if (!searchQuery.trim()) return treeData;
543
- const query = searchQuery.toLowerCase();
544
- return treeData.filter(node =>
545
- node.name.toLowerCase().includes(query) ||
546
- node.type.toLowerCase().includes(query)
547
- );
548
- }, [treeData, searchQuery]);
549
-
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]);
62
+ // Use extracted hook for tree data management
63
+ const {
64
+ searchQuery,
65
+ setSearchQuery,
66
+ groupingMode,
67
+ setGroupingMode,
68
+ unifiedStoreys,
69
+ filteredNodes,
70
+ storeysNodes,
71
+ modelsNodes,
72
+ toggleExpand,
73
+ getNodeElements,
74
+ } = useHierarchyTree({ models, ifcDataStore, isMultiModel, geometryResult });
568
75
 
569
76
  // Refs for both scroll areas
570
77
  const storeysRef = useRef<HTMLDivElement>(null);
@@ -627,62 +134,6 @@ export function HierarchyPanel() {
627
134
  };
628
135
  }, [isDragging]);
629
136
 
630
- const toggleExpand = useCallback((nodeId: string) => {
631
- setExpandedNodes(prev => {
632
- const next = new Set(prev);
633
- if (next.has(nodeId)) {
634
- next.delete(nodeId);
635
- } else {
636
- next.add(nodeId);
637
- }
638
- return next;
639
- });
640
- }, []);
641
-
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
137
  // Toggle visibility for a node
687
138
  const handleVisibilityToggle = useCallback((node: TreeNode) => {
688
139
  const elements = getNodeElements(node);
@@ -716,6 +167,12 @@ export function HierarchyPanel() {
716
167
  removeModel(modelId);
717
168
  }, [removeModel]);
718
169
 
170
+ // Handle model header click (select model + toggle expand)
171
+ const handleModelHeaderClick = useCallback((modelId: string, nodeId: string, hasChildren: boolean) => {
172
+ setSelectedModelId(modelId);
173
+ if (hasChildren) toggleExpand(nodeId);
174
+ }, [setSelectedModelId, toggleExpand]);
175
+
719
176
  // Handle node click - for selection/isolation or expand/collapse
720
177
  const handleNodeClick = useCallback((node: TreeNode, e: React.MouseEvent) => {
721
178
  if (node.type === 'model-header' && node.id !== 'models-header') {
@@ -723,6 +180,15 @@ export function HierarchyPanel() {
723
180
  return;
724
181
  }
725
182
 
183
+ // Type group nodes - click to isolate entities, expand via chevron only
184
+ if (node.type === 'type-group') {
185
+ const elements = getNodeElements(node);
186
+ if (elements.length > 0) {
187
+ isolateEntities(elements);
188
+ }
189
+ return;
190
+ }
191
+
726
192
  // Spatial container nodes (IfcProject/IfcSite/IfcBuilding) - select for property panel + expand
727
193
  if (isSpatialContainer(node.type)) {
728
194
  const entityId = node.expressIds[0];
@@ -814,33 +280,14 @@ export function HierarchyPanel() {
814
280
  } else {
815
281
  // Legacy single-model: expressId = globalId (offset is 0)
816
282
  setSelectedEntityId(elementId);
283
+ // Also set selectedEntity for property panel (was missing, causing blank panel)
284
+ setSelectedEntity(resolveEntityRef(elementId));
817
285
  }
818
286
  }
819
- }, [selectedStoreys, setStoreysSelection, clearStoreySelection, setSelectedEntityId, setSelectedEntity, setSelectedEntities, setActiveModel, toggleExpand, unifiedStoreys, models]);
820
-
821
- if (!ifcDataStore && models.size === 0) {
822
- return (
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>
826
- </div>
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>
835
- </div>
836
- </div>
837
- );
838
- }
839
-
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;
287
+ }, [selectedStoreys, setStoreysSelection, clearStoreySelection, setSelectedEntityId, setSelectedEntity, setSelectedEntities, setActiveModel, toggleExpand, unifiedStoreys, models, isolateEntities, getNodeElements]);
843
288
 
289
+ // Compute selection and visibility state for a node
290
+ const computeNodeState = useCallback((node: TreeNode): { isSelected: boolean; nodeHidden: boolean; modelVisible?: boolean } => {
844
291
  // Determine if node is selected
845
292
  const isSelected = node.type === 'unified-storey'
846
293
  ? node.expressIds.some(id => selectedStoreys.has(id))
@@ -851,275 +298,96 @@ export function HierarchyPanel() {
851
298
  : false;
852
299
 
853
300
  // Compute visibility inline - for elements check directly, for storeys use getNodeElements
854
- // This avoids a useCallback dependency that was causing infinite re-renders
855
301
  let nodeHidden = false;
856
302
  if (node.type === 'element') {
857
303
  nodeHidden = hiddenEntities.has(node.expressIds[0]);
858
304
  } else if (node.type === 'IfcBuildingStorey' || node.type === 'unified-storey' ||
305
+ node.type === 'type-group' ||
859
306
  (node.type === 'model-header' && node.id.startsWith('contrib-'))) {
860
307
  const elements = getNodeElements(node);
861
308
  nodeHidden = elements.length > 0 && elements.every(id => hiddenEntities.has(id));
862
309
  }
863
310
 
864
- // Model header nodes (for visibility control and expansion)
311
+ // Model visibility for model-header nodes
312
+ let modelVisible: boolean | undefined;
865
313
  if (node.type === 'model-header' && node.id.startsWith('model-')) {
866
- const modelId = node.modelIds[0];
867
- const model = models.get(modelId);
868
-
869
- return (
870
- <div
871
- key={node.id}
872
- style={{
873
- position: 'absolute',
874
- top: 0,
875
- left: 0,
876
- width: '100%',
877
- height: `${virtualRow.size}px`,
878
- transform: `translateY(${virtualRow.start}px)`,
879
- }}
880
- >
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
- )}
314
+ const model = models.get(node.modelIds[0]);
315
+ modelVisible = model?.visible;
316
+ }
917
317
 
918
- <Tooltip>
919
- <TooltipTrigger asChild>
920
- <button
921
- onClick={(e) => {
922
- e.stopPropagation();
923
- handleModelVisibilityToggle(modelId, e);
924
- }}
925
- className="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
926
- >
927
- {model?.visible ? (
928
- <Eye className="h-3.5 w-3.5 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100" />
929
- ) : (
930
- <EyeOff className="h-3.5 w-3.5 text-zinc-400 hover:text-zinc-900 dark:hover:text-zinc-100" />
931
- )}
932
- </button>
933
- </TooltipTrigger>
934
- <TooltipContent>
935
- <p className="text-xs">{model?.visible ? 'Hide model' : 'Show model'}</p>
936
- </TooltipContent>
937
- </Tooltip>
318
+ return { isSelected, nodeHidden, modelVisible };
319
+ }, [selectedStoreys, selectedEntityId, hiddenEntities, getNodeElements, models]);
938
320
 
939
- {models.size > 1 && (
940
- <Tooltip>
941
- <TooltipTrigger asChild>
942
- <button
943
- onClick={(e) => {
944
- e.stopPropagation();
945
- handleRemoveModel(modelId, e);
946
- }}
947
- className="p-0.5 opacity-0 group-hover:opacity-100 transition-opacity"
948
- >
949
- <X className="h-3.5 w-3.5 text-zinc-400 hover:text-red-500" />
950
- </button>
951
- </TooltipTrigger>
952
- <TooltipContent>
953
- <p className="text-xs">Remove model</p>
954
- </TooltipContent>
955
- </Tooltip>
956
- )}
321
+ if (!ifcDataStore && models.size === 0) {
322
+ return (
323
+ <div className="h-full flex flex-col border-r-2 border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black">
324
+ <div className="p-3 border-b-2 border-zinc-200 dark:border-zinc-800 bg-white dark:bg-black">
325
+ <h2 className="font-bold uppercase tracking-wider text-xs text-zinc-900 dark:text-zinc-100">Hierarchy</h2>
326
+ </div>
327
+ <div className="flex-1 flex flex-col items-center justify-center text-center p-6 bg-white dark:bg-black">
328
+ <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">
329
+ <LayoutTemplate className="h-8 w-8 text-zinc-400 dark:text-zinc-500" />
957
330
  </div>
331
+ <p className="font-bold uppercase text-zinc-900 dark:text-zinc-100 mb-2">No Model</p>
332
+ <p className="text-xs font-mono text-zinc-500 dark:text-zinc-400 max-w-[150px]">
333
+ Structure will appear here when loaded
334
+ </p>
958
335
  </div>
959
- );
960
- }
336
+ </div>
337
+ );
338
+ }
339
+
340
+ // Helper to render a node via the extracted HierarchyNode component
341
+ const renderNode = (node: TreeNode, virtualRow: { index: number; size: number; start: number }) => {
342
+ const { isSelected, nodeHidden, modelVisible } = computeNodeState(node);
961
343
 
962
- // Regular node rendering (spatial hierarchy nodes and elements)
963
344
  return (
964
- <div
345
+ <HierarchyNode
965
346
  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
- )}
1025
-
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'
1038
- )}
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>
347
+ node={node}
348
+ virtualRow={virtualRow}
349
+ isSelected={isSelected}
350
+ nodeHidden={nodeHidden}
351
+ isMultiModel={isMultiModel}
352
+ modelsCount={models.size}
353
+ modelVisible={modelVisible}
354
+ onNodeClick={handleNodeClick}
355
+ onToggleExpand={toggleExpand}
356
+ onVisibilityToggle={handleVisibilityToggle}
357
+ onModelVisibilityToggle={handleModelVisibilityToggle}
358
+ onRemoveModel={handleRemoveModel}
359
+ onModelHeaderClick={handleModelHeaderClick}
360
+ />
1103
361
  );
1104
362
  };
1105
363
 
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
- )}
364
+ // Multi-model layout with resizable split
365
+ // Grouping mode toggle component (shared by both layouts)
366
+ const groupingToggle = (
367
+ <div className="flex gap-1 mt-2">
368
+ <Button
369
+ variant={groupingMode === 'spatial' ? 'default' : 'outline'}
370
+ size="sm"
371
+ className="h-6 text-[10px] flex-1 rounded-none uppercase tracking-wider"
372
+ onClick={() => setGroupingMode('spatial')}
373
+ >
374
+ <Building2 className="h-3 w-3 mr-1" />
375
+ Spatial
376
+ </Button>
377
+ <Button
378
+ variant={groupingMode === 'type' ? 'default' : 'outline'}
379
+ size="sm"
380
+ className="h-6 text-[10px] flex-1 rounded-none uppercase tracking-wider"
381
+ onClick={() => setGroupingMode('type')}
382
+ >
383
+ <Layers className="h-3 w-3 mr-1" />
384
+ By Class
385
+ </Button>
1118
386
  </div>
1119
387
  );
1120
388
 
1121
- // Multi-model layout with resizable split
1122
- if (isMultiModel) {
389
+ // In type grouping mode, always use flat tree layout (even for multi-model)
390
+ if (isMultiModel && groupingMode === 'spatial') {
1123
391
  return (
1124
392
  <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
393
  {/* Search Header */}
@@ -1131,6 +399,7 @@ export function HierarchyPanel() {
1131
399
  leftIcon={<Search className="h-4 w-4" />}
1132
400
  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
401
  />
402
+ {groupingToggle}
1134
403
  </div>
1135
404
 
1136
405
  {/* Resizable content area */}
@@ -1148,7 +417,7 @@ export function HierarchyPanel() {
1148
417
  >
1149
418
  {storeysVirtualizer.getVirtualItems().map((virtualRow) => {
1150
419
  const node = storeysNodes[virtualRow.index];
1151
- return renderNode(node, virtualRow, storeysNodes);
420
+ return renderNode(node, virtualRow);
1152
421
  })}
1153
422
  </div>
1154
423
  </div>
@@ -1178,7 +447,7 @@ export function HierarchyPanel() {
1178
447
  >
1179
448
  {modelsVirtualizer.getVirtualItems().map((virtualRow) => {
1180
449
  const node = modelsNodes[virtualRow.index];
1181
- return renderNode(node, virtualRow, modelsNodes);
450
+ return renderNode(node, virtualRow);
1182
451
  })}
1183
452
  </div>
1184
453
  </div>
@@ -1226,10 +495,11 @@ export function HierarchyPanel() {
1226
495
  leftIcon={<Search className="h-4 w-4" />}
1227
496
  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
497
  />
498
+ {groupingToggle}
1229
499
  </div>
1230
500
 
1231
501
  {/* Section Header */}
1232
- <SectionHeader icon={Building2} title="Hierarchy" count={filteredNodes.length} />
502
+ <SectionHeader icon={groupingMode === 'type' ? Layers : Building2} title={groupingMode === 'type' ? 'By Class' : 'Hierarchy'} count={filteredNodes.length} />
1233
503
 
1234
504
  {/* Tree */}
1235
505
  <div ref={parentRef} className="flex-1 overflow-auto scrollbar-thin bg-white dark:bg-black">
@@ -1242,7 +512,7 @@ export function HierarchyPanel() {
1242
512
  >
1243
513
  {virtualizer.getVirtualItems().map((virtualRow) => {
1244
514
  const node = filteredNodes[virtualRow.index];
1245
- return renderNode(node, virtualRow, filteredNodes);
515
+ return renderNode(node, virtualRow);
1246
516
  })}
1247
517
  </div>
1248
518
  </div>