@ifc-lite/viewer 1.6.0 → 1.7.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 (95) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
  5. package/dist/assets/index-yTqs8kgX.css +1 -0
  6. package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
  7. package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.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 +1048 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/HierarchyPanel.tsx +110 -842
  14. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  15. package/src/components/viewer/IDSPanel.tsx +126 -17
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  17. package/src/components/viewer/LensPanel.tsx +603 -0
  18. package/src/components/viewer/MainToolbar.tsx +188 -21
  19. package/src/components/viewer/PropertiesPanel.tsx +171 -663
  20. package/src/components/viewer/PropertyEditor.tsx +866 -77
  21. package/src/components/viewer/Section2DPanel.tsx +76 -2648
  22. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  23. package/src/components/viewer/ViewerLayout.tsx +132 -45
  24. package/src/components/viewer/Viewport.tsx +237 -1659
  25. package/src/components/viewer/ViewportContainer.tsx +11 -3
  26. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  27. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  28. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  29. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  30. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  31. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  32. package/src/components/viewer/hierarchy/types.ts +54 -0
  33. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  34. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  35. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  36. package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
  37. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  38. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  39. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  40. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  41. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  42. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  43. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  44. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  45. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  46. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  47. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  48. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  49. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  50. package/src/components/viewer/tools/formatDistance.ts +18 -0
  51. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  52. package/src/components/viewer/useAnimationLoop.ts +166 -0
  53. package/src/components/viewer/useGeometryStreaming.ts +398 -0
  54. package/src/components/viewer/useKeyboardControls.ts +221 -0
  55. package/src/components/viewer/useMouseControls.ts +1009 -0
  56. package/src/components/viewer/useRenderUpdates.ts +165 -0
  57. package/src/components/viewer/useTouchControls.ts +245 -0
  58. package/src/hooks/ids/idsColorSystem.ts +125 -0
  59. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  60. package/src/hooks/ids/idsExportService.ts +444 -0
  61. package/src/hooks/useBCF.ts +7 -0
  62. package/src/hooks/useDrawingExport.ts +627 -0
  63. package/src/hooks/useDrawingGeneration.ts +627 -0
  64. package/src/hooks/useFloorplanView.ts +108 -0
  65. package/src/hooks/useIDS.ts +270 -463
  66. package/src/hooks/useIfc.ts +26 -1628
  67. package/src/hooks/useIfcFederation.ts +803 -0
  68. package/src/hooks/useIfcLoader.ts +508 -0
  69. package/src/hooks/useIfcServer.ts +465 -0
  70. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  71. package/src/hooks/useLens.ts +129 -0
  72. package/src/hooks/useMeasure2D.ts +365 -0
  73. package/src/hooks/useViewControls.ts +218 -0
  74. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  75. package/src/lib/ifc4-pset-definitions.ts +621 -0
  76. package/src/lib/ifc4-qto-definitions.ts +315 -0
  77. package/src/lib/lens/adapter.ts +138 -0
  78. package/src/lib/lens/index.ts +5 -0
  79. package/src/lib/lists/adapter.ts +69 -0
  80. package/src/lib/lists/index.ts +28 -0
  81. package/src/lib/lists/persistence.ts +64 -0
  82. package/src/services/fs-cache.ts +1 -1
  83. package/src/services/tauri-modules.d.ts +25 -0
  84. package/src/store/index.ts +38 -2
  85. package/src/store/slices/cameraSlice.ts +14 -1
  86. package/src/store/slices/dataSlice.ts +14 -1
  87. package/src/store/slices/lensSlice.ts +184 -0
  88. package/src/store/slices/listSlice.ts +74 -0
  89. package/src/store/slices/pinboardSlice.ts +114 -0
  90. package/src/store/types.ts +5 -0
  91. package/src/utils/ifcConfig.ts +16 -3
  92. package/src/utils/serverDataModel.ts +64 -101
  93. package/src/vite-env.d.ts +3 -0
  94. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  95. 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
18
  import { useViewerStore } 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];
@@ -816,31 +282,10 @@ export function HierarchyPanel() {
816
282
  setSelectedEntityId(elementId);
817
283
  }
818
284
  }
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;
285
+ }, [selectedStoreys, setStoreysSelection, clearStoreySelection, setSelectedEntityId, setSelectedEntity, setSelectedEntities, setActiveModel, toggleExpand, unifiedStoreys, models, isolateEntities, getNodeElements]);
843
286
 
287
+ // Compute selection and visibility state for a node
288
+ const computeNodeState = useCallback((node: TreeNode): { isSelected: boolean; nodeHidden: boolean; modelVisible?: boolean } => {
844
289
  // Determine if node is selected
845
290
  const isSelected = node.type === 'unified-storey'
846
291
  ? node.expressIds.some(id => selectedStoreys.has(id))
@@ -851,275 +296,96 @@ export function HierarchyPanel() {
851
296
  : false;
852
297
 
853
298
  // Compute visibility inline - for elements check directly, for storeys use getNodeElements
854
- // This avoids a useCallback dependency that was causing infinite re-renders
855
299
  let nodeHidden = false;
856
300
  if (node.type === 'element') {
857
301
  nodeHidden = hiddenEntities.has(node.expressIds[0]);
858
302
  } else if (node.type === 'IfcBuildingStorey' || node.type === 'unified-storey' ||
303
+ node.type === 'type-group' ||
859
304
  (node.type === 'model-header' && node.id.startsWith('contrib-'))) {
860
305
  const elements = getNodeElements(node);
861
306
  nodeHidden = elements.length > 0 && elements.every(id => hiddenEntities.has(id));
862
307
  }
863
308
 
864
- // Model header nodes (for visibility control and expansion)
309
+ // Model visibility for model-header nodes
310
+ let modelVisible: boolean | undefined;
865
311
  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
- )}
312
+ const model = models.get(node.modelIds[0]);
313
+ modelVisible = model?.visible;
314
+ }
917
315
 
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>
316
+ return { isSelected, nodeHidden, modelVisible };
317
+ }, [selectedStoreys, selectedEntityId, hiddenEntities, getNodeElements, models]);
938
318
 
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
- )}
319
+ if (!ifcDataStore && models.size === 0) {
320
+ return (
321
+ <div className="h-full flex flex-col border-r-2 border-zinc-200 dark:border-zinc-800 bg-zinc-50 dark:bg-black">
322
+ <div className="p-3 border-b-2 border-zinc-200 dark:border-zinc-800 bg-white dark:bg-black">
323
+ <h2 className="font-bold uppercase tracking-wider text-xs text-zinc-900 dark:text-zinc-100">Hierarchy</h2>
324
+ </div>
325
+ <div className="flex-1 flex flex-col items-center justify-center text-center p-6 bg-white dark:bg-black">
326
+ <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">
327
+ <LayoutTemplate className="h-8 w-8 text-zinc-400 dark:text-zinc-500" />
957
328
  </div>
329
+ <p className="font-bold uppercase text-zinc-900 dark:text-zinc-100 mb-2">No Model</p>
330
+ <p className="text-xs font-mono text-zinc-500 dark:text-zinc-400 max-w-[150px]">
331
+ Structure will appear here when loaded
332
+ </p>
958
333
  </div>
959
- );
960
- }
334
+ </div>
335
+ );
336
+ }
337
+
338
+ // Helper to render a node via the extracted HierarchyNode component
339
+ const renderNode = (node: TreeNode, virtualRow: { index: number; size: number; start: number }) => {
340
+ const { isSelected, nodeHidden, modelVisible } = computeNodeState(node);
961
341
 
962
- // Regular node rendering (spatial hierarchy nodes and elements)
963
342
  return (
964
- <div
343
+ <HierarchyNode
965
344
  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>
345
+ node={node}
346
+ virtualRow={virtualRow}
347
+ isSelected={isSelected}
348
+ nodeHidden={nodeHidden}
349
+ isMultiModel={isMultiModel}
350
+ modelsCount={models.size}
351
+ modelVisible={modelVisible}
352
+ onNodeClick={handleNodeClick}
353
+ onToggleExpand={toggleExpand}
354
+ onVisibilityToggle={handleVisibilityToggle}
355
+ onModelVisibilityToggle={handleModelVisibilityToggle}
356
+ onRemoveModel={handleRemoveModel}
357
+ onModelHeaderClick={handleModelHeaderClick}
358
+ />
1103
359
  );
1104
360
  };
1105
361
 
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
- )}
362
+ // Multi-model layout with resizable split
363
+ // Grouping mode toggle component (shared by both layouts)
364
+ const groupingToggle = (
365
+ <div className="flex gap-1 mt-2">
366
+ <Button
367
+ variant={groupingMode === 'spatial' ? 'default' : 'outline'}
368
+ size="sm"
369
+ className="h-6 text-[10px] flex-1 rounded-none uppercase tracking-wider"
370
+ onClick={() => setGroupingMode('spatial')}
371
+ >
372
+ <Building2 className="h-3 w-3 mr-1" />
373
+ Spatial
374
+ </Button>
375
+ <Button
376
+ variant={groupingMode === 'type' ? 'default' : 'outline'}
377
+ size="sm"
378
+ className="h-6 text-[10px] flex-1 rounded-none uppercase tracking-wider"
379
+ onClick={() => setGroupingMode('type')}
380
+ >
381
+ <Layers className="h-3 w-3 mr-1" />
382
+ By Class
383
+ </Button>
1118
384
  </div>
1119
385
  );
1120
386
 
1121
- // Multi-model layout with resizable split
1122
- if (isMultiModel) {
387
+ // In type grouping mode, always use flat tree layout (even for multi-model)
388
+ if (isMultiModel && groupingMode === 'spatial') {
1123
389
  return (
1124
390
  <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
391
  {/* Search Header */}
@@ -1131,6 +397,7 @@ export function HierarchyPanel() {
1131
397
  leftIcon={<Search className="h-4 w-4" />}
1132
398
  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
399
  />
400
+ {groupingToggle}
1134
401
  </div>
1135
402
 
1136
403
  {/* Resizable content area */}
@@ -1148,7 +415,7 @@ export function HierarchyPanel() {
1148
415
  >
1149
416
  {storeysVirtualizer.getVirtualItems().map((virtualRow) => {
1150
417
  const node = storeysNodes[virtualRow.index];
1151
- return renderNode(node, virtualRow, storeysNodes);
418
+ return renderNode(node, virtualRow);
1152
419
  })}
1153
420
  </div>
1154
421
  </div>
@@ -1178,7 +445,7 @@ export function HierarchyPanel() {
1178
445
  >
1179
446
  {modelsVirtualizer.getVirtualItems().map((virtualRow) => {
1180
447
  const node = modelsNodes[virtualRow.index];
1181
- return renderNode(node, virtualRow, modelsNodes);
448
+ return renderNode(node, virtualRow);
1182
449
  })}
1183
450
  </div>
1184
451
  </div>
@@ -1226,10 +493,11 @@ export function HierarchyPanel() {
1226
493
  leftIcon={<Search className="h-4 w-4" />}
1227
494
  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
495
  />
496
+ {groupingToggle}
1229
497
  </div>
1230
498
 
1231
499
  {/* Section Header */}
1232
- <SectionHeader icon={Building2} title="Hierarchy" count={filteredNodes.length} />
500
+ <SectionHeader icon={groupingMode === 'type' ? Layers : Building2} title={groupingMode === 'type' ? 'By Class' : 'Hierarchy'} count={filteredNodes.length} />
1233
501
 
1234
502
  {/* Tree */}
1235
503
  <div ref={parentRef} className="flex-1 overflow-auto scrollbar-thin bg-white dark:bg-black">
@@ -1242,7 +510,7 @@ export function HierarchyPanel() {
1242
510
  >
1243
511
  {virtualizer.getVirtualItems().map((virtualRow) => {
1244
512
  const node = filteredNodes[virtualRow.index];
1245
- return renderNode(node, virtualRow, filteredNodes);
513
+ return renderNode(node, virtualRow);
1246
514
  })}
1247
515
  </div>
1248
516
  </div>