@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.
- package/CHANGELOG.md +78 -0
- package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
- package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
- package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
- package/dist/assets/index-yTqs8kgX.css +1 -0
- package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
- package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +18 -15
- package/src/components/viewer/BCFPanel.tsx +7 -789
- package/src/components/viewer/Drawing2DCanvas.tsx +1048 -0
- package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
- package/src/components/viewer/HierarchyPanel.tsx +110 -842
- package/src/components/viewer/IDSExportDialog.tsx +281 -0
- package/src/components/viewer/IDSPanel.tsx +126 -17
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
- package/src/components/viewer/LensPanel.tsx +603 -0
- package/src/components/viewer/MainToolbar.tsx +188 -21
- package/src/components/viewer/PropertiesPanel.tsx +171 -663
- package/src/components/viewer/PropertyEditor.tsx +866 -77
- package/src/components/viewer/Section2DPanel.tsx +76 -2648
- package/src/components/viewer/ToolOverlays.tsx +3 -1097
- package/src/components/viewer/ViewerLayout.tsx +132 -45
- package/src/components/viewer/Viewport.tsx +237 -1659
- package/src/components/viewer/ViewportContainer.tsx +11 -3
- package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
- package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
- package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
- package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
- package/src/components/viewer/hierarchy/types.ts +54 -0
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
- package/src/components/viewer/lists/ListBuilder.tsx +486 -0
- package/src/components/viewer/lists/ListPanel.tsx +540 -0
- package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
- package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
- package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
- package/src/components/viewer/properties/DocumentCard.tsx +89 -0
- package/src/components/viewer/properties/MaterialCard.tsx +201 -0
- package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
- package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
- package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
- package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
- package/src/components/viewer/properties/encodingUtils.ts +29 -0
- package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
- package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
- package/src/components/viewer/tools/SectionPanel.tsx +183 -0
- package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
- package/src/components/viewer/tools/formatDistance.ts +18 -0
- package/src/components/viewer/tools/sectionConstants.ts +14 -0
- package/src/components/viewer/useAnimationLoop.ts +166 -0
- package/src/components/viewer/useGeometryStreaming.ts +398 -0
- package/src/components/viewer/useKeyboardControls.ts +221 -0
- package/src/components/viewer/useMouseControls.ts +1009 -0
- package/src/components/viewer/useRenderUpdates.ts +165 -0
- package/src/components/viewer/useTouchControls.ts +245 -0
- package/src/hooks/ids/idsColorSystem.ts +125 -0
- package/src/hooks/ids/idsDataAccessor.ts +237 -0
- package/src/hooks/ids/idsExportService.ts +444 -0
- package/src/hooks/useBCF.ts +7 -0
- package/src/hooks/useDrawingExport.ts +627 -0
- package/src/hooks/useDrawingGeneration.ts +627 -0
- package/src/hooks/useFloorplanView.ts +108 -0
- package/src/hooks/useIDS.ts +270 -463
- package/src/hooks/useIfc.ts +26 -1628
- package/src/hooks/useIfcFederation.ts +803 -0
- package/src/hooks/useIfcLoader.ts +508 -0
- package/src/hooks/useIfcServer.ts +465 -0
- package/src/hooks/useKeyboardShortcuts.ts +1 -1
- package/src/hooks/useLens.ts +129 -0
- package/src/hooks/useMeasure2D.ts +365 -0
- package/src/hooks/useViewControls.ts +218 -0
- package/src/lib/ifc4-pset-definitions.test.ts +161 -0
- package/src/lib/ifc4-pset-definitions.ts +621 -0
- package/src/lib/ifc4-qto-definitions.ts +315 -0
- package/src/lib/lens/adapter.ts +138 -0
- package/src/lib/lens/index.ts +5 -0
- package/src/lib/lists/adapter.ts +69 -0
- package/src/lib/lists/index.ts +28 -0
- package/src/lib/lists/persistence.ts +64 -0
- package/src/services/fs-cache.ts +1 -1
- package/src/services/tauri-modules.d.ts +25 -0
- package/src/store/index.ts +38 -2
- package/src/store/slices/cameraSlice.ts +14 -1
- package/src/store/slices/dataSlice.ts +14 -1
- package/src/store/slices/lensSlice.ts +184 -0
- package/src/store/slices/listSlice.ts +74 -0
- package/src/store/slices/pinboardSlice.ts +114 -0
- package/src/store/types.ts +5 -0
- package/src/utils/ifcConfig.ts +16 -3
- package/src/utils/serverDataModel.ts +64 -101
- package/src/vite-env.d.ts +3 -0
- package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
- 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 {
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
//
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
|
867
|
-
|
|
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
|
-
|
|
919
|
-
|
|
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
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
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
|
-
<
|
|
343
|
+
<HierarchyNode
|
|
965
344
|
key={node.id}
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
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
|
-
//
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
<
|
|
1111
|
-
{
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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=
|
|
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
|
|
513
|
+
return renderNode(node, virtualRow);
|
|
1246
514
|
})}
|
|
1247
515
|
</div>
|
|
1248
516
|
</div>
|