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