@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,48 +2,584 @@
|
|
|
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 } from 'react';
|
|
5
|
+
import { useMemo, useRef, useState, useCallback } from 'react';
|
|
6
6
|
import { Viewport } from './Viewport';
|
|
7
7
|
import { ViewportOverlays } from './ViewportOverlays';
|
|
8
8
|
import { ToolOverlays } from './ToolOverlays';
|
|
9
|
+
import { Section2DPanel } from './Section2DPanel';
|
|
9
10
|
import { useViewerStore } from '@/store';
|
|
10
11
|
import { useIfc } from '@/hooks/useIfc';
|
|
12
|
+
import { useWebGPU } from '@/hooks/useWebGPU';
|
|
13
|
+
import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus } from 'lucide-react';
|
|
14
|
+
import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
|
|
11
15
|
|
|
12
16
|
export function ViewportContainer() {
|
|
13
|
-
const { geometryResult, ifcDataStore } = useIfc();
|
|
14
|
-
const
|
|
17
|
+
const { geometryResult, ifcDataStore, loadFile, loading, models, clearAllModels, loadFilesSequentially } = useIfc();
|
|
18
|
+
const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
|
|
19
|
+
const typeVisibility = useViewerStore((s) => s.typeVisibility);
|
|
20
|
+
const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
|
|
21
|
+
// Multi-model support: get all loaded models from store (for merged geometry)
|
|
22
|
+
const storeModels = useViewerStore((s) => s.models);
|
|
23
|
+
const resetViewerState = useViewerStore((s) => s.resetViewerState);
|
|
24
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
25
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
26
|
+
const [showTroubleshooting, setShowTroubleshooting] = useState(false);
|
|
27
|
+
const webgpu = useWebGPU();
|
|
15
28
|
|
|
16
|
-
//
|
|
29
|
+
// Check if we have models loaded (for determining add vs replace behavior)
|
|
30
|
+
const hasModelsLoaded = models.size > 0 || (geometryResult?.meshes && geometryResult.meshes.length > 0);
|
|
31
|
+
|
|
32
|
+
// Multi-model: create mapping from modelId to modelIndex (stable order)
|
|
33
|
+
const modelIdToIndex = useMemo(() => {
|
|
34
|
+
const map = new Map<string, number>();
|
|
35
|
+
let index = 0;
|
|
36
|
+
for (const modelId of storeModels.keys()) {
|
|
37
|
+
map.set(modelId, index++);
|
|
38
|
+
}
|
|
39
|
+
return map;
|
|
40
|
+
}, [storeModels]);
|
|
41
|
+
|
|
42
|
+
// Multi-model: merge geometries from all visible models
|
|
43
|
+
const mergedGeometryResult = useMemo(() => {
|
|
44
|
+
// If we have federated models, merge their visible geometries
|
|
45
|
+
if (storeModels.size > 0) {
|
|
46
|
+
const allMeshes: MeshData[] = [];
|
|
47
|
+
let totalVertices = 0;
|
|
48
|
+
let totalTriangles = 0;
|
|
49
|
+
let mergedCoordinateInfo: CoordinateInfo | undefined;
|
|
50
|
+
|
|
51
|
+
for (const [modelId, model] of storeModels) {
|
|
52
|
+
// Skip hidden models - this is how model visibility works
|
|
53
|
+
if (!model.visible) continue;
|
|
54
|
+
|
|
55
|
+
const modelGeometry = model.geometryResult;
|
|
56
|
+
const modelIndex = modelIdToIndex.get(modelId) ?? 0;
|
|
57
|
+
if (modelGeometry?.meshes) {
|
|
58
|
+
// Tag each mesh with its modelIndex for selection/highlighting
|
|
59
|
+
for (const mesh of modelGeometry.meshes) {
|
|
60
|
+
allMeshes.push({ ...mesh, modelIndex });
|
|
61
|
+
}
|
|
62
|
+
totalVertices += modelGeometry.totalVertices || 0;
|
|
63
|
+
totalTriangles += modelGeometry.totalTriangles || 0;
|
|
64
|
+
|
|
65
|
+
// Use first model's coordinate info as base (could be improved to compute union)
|
|
66
|
+
if (!mergedCoordinateInfo && modelGeometry.coordinateInfo) {
|
|
67
|
+
mergedCoordinateInfo = modelGeometry.coordinateInfo;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Return merged result (may be empty if all models hidden)
|
|
73
|
+
return {
|
|
74
|
+
meshes: allMeshes,
|
|
75
|
+
totalVertices,
|
|
76
|
+
totalTriangles,
|
|
77
|
+
coordinateInfo: mergedCoordinateInfo,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Legacy mode (no federation): use original geometryResult
|
|
82
|
+
return geometryResult;
|
|
83
|
+
}, [storeModels, geometryResult, modelIdToIndex]);
|
|
84
|
+
|
|
85
|
+
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
86
|
+
e.preventDefault();
|
|
87
|
+
e.stopPropagation();
|
|
88
|
+
// Only show drag state if WebGPU is supported
|
|
89
|
+
if (webgpu.supported) {
|
|
90
|
+
setIsDragging(true);
|
|
91
|
+
}
|
|
92
|
+
}, [webgpu.supported]);
|
|
93
|
+
|
|
94
|
+
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
|
95
|
+
e.preventDefault();
|
|
96
|
+
e.stopPropagation();
|
|
97
|
+
setIsDragging(false);
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const handleDrop = useCallback((e: React.DragEvent) => {
|
|
101
|
+
e.preventDefault();
|
|
102
|
+
e.stopPropagation();
|
|
103
|
+
setIsDragging(false);
|
|
104
|
+
|
|
105
|
+
// Block file loading if WebGPU not supported
|
|
106
|
+
if (!webgpu.supported) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Filter to supported files (IFC, IFCX, GLB)
|
|
111
|
+
const supportedFiles = Array.from(e.dataTransfer.files).filter(
|
|
112
|
+
f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
if (supportedFiles.length === 0) return;
|
|
116
|
+
|
|
117
|
+
if (hasModelsLoaded) {
|
|
118
|
+
// Models already loaded - add new files sequentially
|
|
119
|
+
loadFilesSequentially(supportedFiles);
|
|
120
|
+
} else if (supportedFiles.length === 1) {
|
|
121
|
+
// Single file, no models loaded - use loadFile
|
|
122
|
+
loadFile(supportedFiles[0]);
|
|
123
|
+
} else {
|
|
124
|
+
// Multiple files, no models loaded - use federation
|
|
125
|
+
resetViewerState();
|
|
126
|
+
clearAllModels();
|
|
127
|
+
loadFilesSequentially(supportedFiles);
|
|
128
|
+
}
|
|
129
|
+
}, [loadFile, loadFilesSequentially, resetViewerState, clearAllModels, webgpu.supported, hasModelsLoaded]);
|
|
130
|
+
|
|
131
|
+
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
132
|
+
// Block file loading if WebGPU not supported
|
|
133
|
+
if (!webgpu.supported) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const files = e.target.files;
|
|
138
|
+
if (!files || files.length === 0) return;
|
|
139
|
+
|
|
140
|
+
// Filter to supported files (IFC, IFCX, GLB)
|
|
141
|
+
const supportedFiles = Array.from(files).filter(
|
|
142
|
+
f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
if (supportedFiles.length === 0) return;
|
|
146
|
+
|
|
147
|
+
if (supportedFiles.length === 1) {
|
|
148
|
+
// Single file - use loadFile (simpler single-model path)
|
|
149
|
+
loadFile(supportedFiles[0]);
|
|
150
|
+
} else {
|
|
151
|
+
// Multiple files selected - use federation from the start
|
|
152
|
+
// Clear everything and start fresh, then load sequentially
|
|
153
|
+
resetViewerState();
|
|
154
|
+
clearAllModels();
|
|
155
|
+
loadFilesSequentially(supportedFiles);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Reset input so same file can be selected again
|
|
159
|
+
e.target.value = '';
|
|
160
|
+
}, [loadFile, loadFilesSequentially, resetViewerState, clearAllModels, webgpu.supported]);
|
|
161
|
+
|
|
162
|
+
const hasGeometry = mergedGeometryResult?.meshes && mergedGeometryResult.meshes.length > 0;
|
|
163
|
+
|
|
164
|
+
// Check if any models are loaded (even if hidden) - used to show empty 3D vs starting UI
|
|
165
|
+
const hasLoadedModels = storeModels.size > 0 || (geometryResult?.meshes && geometryResult.meshes.length > 0);
|
|
166
|
+
|
|
167
|
+
// Filter geometry based on type visibility only
|
|
168
|
+
// PERFORMANCE FIX: Don't filter by storey or hiddenEntities here
|
|
169
|
+
// Instead, let the renderer handle visibility filtering at the batch level
|
|
170
|
+
// This avoids expensive batch rebuilding when visibility changes
|
|
17
171
|
const filteredGeometry = useMemo(() => {
|
|
18
|
-
if (!
|
|
19
|
-
return
|
|
172
|
+
if (!mergedGeometryResult?.meshes) {
|
|
173
|
+
return null;
|
|
20
174
|
}
|
|
21
175
|
|
|
22
|
-
|
|
23
|
-
|
|
176
|
+
let meshes = mergedGeometryResult.meshes;
|
|
177
|
+
|
|
178
|
+
// Filter by type visibility (spatial elements)
|
|
179
|
+
meshes = meshes.filter(mesh => {
|
|
180
|
+
const ifcType = mesh.ifcType;
|
|
181
|
+
|
|
182
|
+
// Check type visibility
|
|
183
|
+
if (ifcType === 'IfcSpace' && !typeVisibility.spaces) {
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
if (ifcType === 'IfcOpeningElement' && !typeVisibility.openings) {
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
if (ifcType === 'IfcSite' && !typeVisibility.site) {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return true;
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// Apply transparency for spatial elements
|
|
197
|
+
meshes = meshes.map(mesh => {
|
|
198
|
+
const ifcType = mesh.ifcType;
|
|
199
|
+
const isSpace = ifcType === 'IfcSpace';
|
|
200
|
+
const isOpening = ifcType === 'IfcOpeningElement';
|
|
201
|
+
|
|
202
|
+
if (isSpace || isOpening) {
|
|
203
|
+
// Create a new color array with reduced opacity
|
|
204
|
+
const newColor: [number, number, number, number] = [
|
|
205
|
+
mesh.color[0],
|
|
206
|
+
mesh.color[1],
|
|
207
|
+
mesh.color[2],
|
|
208
|
+
Math.min(mesh.color[3] * 0.3, 0.3), // Semi-transparent (30% opacity max)
|
|
209
|
+
];
|
|
210
|
+
return { ...mesh, color: newColor };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return mesh;
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
return meshes;
|
|
217
|
+
}, [mergedGeometryResult, typeVisibility]);
|
|
218
|
+
|
|
219
|
+
// Compute combined isolation set (storeys + manual isolation)
|
|
220
|
+
// This is passed to the renderer for batch-level visibility filtering
|
|
221
|
+
// Now supports multi-model: aggregates elements from all models for selected storeys
|
|
222
|
+
// IMPORTANT: Returns globalIds (meshes use globalIds after federation registry transformation)
|
|
223
|
+
const computedIsolatedIds = useMemo(() => {
|
|
224
|
+
// If manual isolation is active, use that (already contains globalIds)
|
|
225
|
+
if (isolatedEntities !== null) {
|
|
226
|
+
return isolatedEntities;
|
|
24
227
|
}
|
|
25
228
|
|
|
26
|
-
|
|
27
|
-
|
|
229
|
+
// If storeys are selected, compute combined element IDs from all selected storeys
|
|
230
|
+
// across ALL models (multi-model support)
|
|
231
|
+
// NOTE: Storey hierarchy uses original expressIds, but meshes use globalIds
|
|
232
|
+
// We must transform expressIds -> globalIds using the model's offset
|
|
233
|
+
if (selectedStoreys.size > 0) {
|
|
234
|
+
const combinedGlobalIds = new Set<number>();
|
|
235
|
+
|
|
236
|
+
// Check each federated model's storeys
|
|
237
|
+
for (const [, model] of storeModels) {
|
|
238
|
+
const hierarchy = model.ifcDataStore?.spatialHierarchy;
|
|
239
|
+
if (!hierarchy) continue;
|
|
28
240
|
|
|
29
|
-
|
|
30
|
-
|
|
241
|
+
// Get this model's offset directly from the model (no need for registry)
|
|
242
|
+
const offset = model.idOffset ?? 0;
|
|
243
|
+
|
|
244
|
+
for (const storeyId of selectedStoreys) {
|
|
245
|
+
// Note: storeyId itself might be a globalId if the user selected via mesh click,
|
|
246
|
+
// or an original ID if selected via hierarchy panel. The byStorey map uses original IDs.
|
|
247
|
+
// For now, try both the storeyId and storeyId - offset
|
|
248
|
+
const storeyElementIds = hierarchy.byStorey.get(storeyId) || hierarchy.byStorey.get(storeyId - offset);
|
|
249
|
+
if (storeyElementIds) {
|
|
250
|
+
for (const originalExpressId of storeyElementIds) {
|
|
251
|
+
// Transform to globalId
|
|
252
|
+
const globalId = originalExpressId + offset;
|
|
253
|
+
combinedGlobalIds.add(globalId);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Also check legacy ifcDataStore (for single-model mode without federation)
|
|
260
|
+
// In this case, offset is 0, so globalId = expressId
|
|
261
|
+
if (ifcDataStore?.spatialHierarchy && storeModels.size === 0) {
|
|
262
|
+
const hierarchy = ifcDataStore.spatialHierarchy;
|
|
263
|
+
for (const storeyId of selectedStoreys) {
|
|
264
|
+
const storeyElementIds = hierarchy.byStorey.get(storeyId);
|
|
265
|
+
if (storeyElementIds) {
|
|
266
|
+
for (const id of storeyElementIds) {
|
|
267
|
+
combinedGlobalIds.add(id); // offset = 0 for legacy single-model
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
if (combinedGlobalIds.size > 0) {
|
|
274
|
+
return combinedGlobalIds;
|
|
275
|
+
}
|
|
31
276
|
}
|
|
32
277
|
|
|
33
|
-
|
|
34
|
-
return
|
|
35
|
-
|
|
278
|
+
// No isolation active
|
|
279
|
+
return null;
|
|
280
|
+
}, [storeModels, ifcDataStore, selectedStoreys, isolatedEntities]);
|
|
281
|
+
|
|
282
|
+
// Grid Pattern
|
|
283
|
+
const GridPattern = () => (
|
|
284
|
+
<>
|
|
285
|
+
{/* Light mode grid - subtle gray */}
|
|
286
|
+
<div
|
|
287
|
+
className="absolute inset-0 z-0 pointer-events-none opacity-[0.06] dark:hidden"
|
|
288
|
+
style={{
|
|
289
|
+
backgroundImage: `linear-gradient(#3b4261 1px, transparent 1px), linear-gradient(90deg, #3b4261 1px, transparent 1px)`,
|
|
290
|
+
backgroundSize: '32px 32px',
|
|
291
|
+
backgroundPosition: '-1px -1px'
|
|
292
|
+
}}
|
|
293
|
+
/>
|
|
294
|
+
{/* Dark mode grid - subtle blue/cyan tint */}
|
|
295
|
+
<div
|
|
296
|
+
className="absolute inset-0 z-0 pointer-events-none opacity-[0.12] hidden dark:block"
|
|
297
|
+
style={{
|
|
298
|
+
backgroundImage: `linear-gradient(#3b4261 1px, transparent 1px), linear-gradient(90deg, #3b4261 1px, transparent 1px)`,
|
|
299
|
+
backgroundSize: '32px 32px',
|
|
300
|
+
backgroundPosition: '-1px -1px'
|
|
301
|
+
}}
|
|
302
|
+
/>
|
|
303
|
+
</>
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
// Empty state when no file is loaded at all (show starting UI)
|
|
307
|
+
// But NOT when models are loaded but just hidden - in that case show empty 3D canvas
|
|
308
|
+
if (!hasLoadedModels && !loading) {
|
|
309
|
+
return (
|
|
310
|
+
<div
|
|
311
|
+
className="relative h-full w-full bg-white dark:bg-black text-zinc-900 dark:text-zinc-50 overflow-hidden"
|
|
312
|
+
onDragOver={handleDragOver}
|
|
313
|
+
onDragLeave={handleDragLeave}
|
|
314
|
+
onDrop={handleDrop}
|
|
315
|
+
>
|
|
316
|
+
<GridPattern />
|
|
317
|
+
|
|
318
|
+
<input
|
|
319
|
+
ref={fileInputRef}
|
|
320
|
+
type="file"
|
|
321
|
+
accept=".ifc,.ifcx,.glb"
|
|
322
|
+
multiple
|
|
323
|
+
onChange={handleFileSelect}
|
|
324
|
+
className="hidden"
|
|
325
|
+
/>
|
|
326
|
+
|
|
327
|
+
{/* Drop overlay */}
|
|
328
|
+
{isDragging && (
|
|
329
|
+
<div className="absolute inset-0 z-50 bg-primary/10 backdrop-blur-[2px] flex items-center justify-center p-8">
|
|
330
|
+
<div className="border-4 border-dashed border-primary bg-white/90 dark:bg-black/90 p-12 max-w-2xl w-full text-center shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] dark:shadow-[8px_8px_0px_0px_rgba(255,255,255,1)] transition-all">
|
|
331
|
+
<Upload className="h-20 w-20 mx-auto text-primary mb-6" />
|
|
332
|
+
<p className="text-3xl font-black uppercase tracking-tight text-primary">Drop File to Load</p>
|
|
333
|
+
</div>
|
|
334
|
+
</div>
|
|
335
|
+
)}
|
|
336
|
+
|
|
337
|
+
{/* WebGPU Not Supported Banner */}
|
|
338
|
+
{!webgpu.checking && !webgpu.supported && (
|
|
339
|
+
<div className="absolute top-0 left-0 right-0 z-40">
|
|
340
|
+
{/* Hazard stripes background */}
|
|
341
|
+
<div
|
|
342
|
+
className="absolute inset-0 opacity-10"
|
|
343
|
+
style={{
|
|
344
|
+
backgroundImage: `repeating-linear-gradient(
|
|
345
|
+
-45deg,
|
|
346
|
+
transparent,
|
|
347
|
+
transparent 10px,
|
|
348
|
+
#f7768e 10px,
|
|
349
|
+
#f7768e 20px
|
|
350
|
+
)`
|
|
351
|
+
}}
|
|
352
|
+
/>
|
|
353
|
+
<div className="relative border-b-4 border-[#f7768e] bg-[#1a1b26] dark:bg-[#1a1b26] px-4 py-5">
|
|
354
|
+
<div className="max-w-3xl mx-auto flex items-start gap-4">
|
|
355
|
+
{/* Icon container with brutalist frame */}
|
|
356
|
+
<div className="flex-shrink-0 border-2 border-[#f7768e] p-2 bg-[#f7768e]/10">
|
|
357
|
+
<AlertTriangle className="h-6 w-6 text-[#f7768e]" />
|
|
358
|
+
</div>
|
|
359
|
+
|
|
360
|
+
<div className="flex-1 min-w-0">
|
|
361
|
+
<h3 className="font-black text-lg uppercase tracking-wider text-[#f7768e] mb-1">
|
|
362
|
+
WebGPU Not Available
|
|
363
|
+
</h3>
|
|
364
|
+
<p className="font-mono text-sm text-[#a9b1d6] leading-relaxed">
|
|
365
|
+
This viewer requires WebGPU which is not supported by your browser or device.
|
|
366
|
+
{webgpu.reason && (
|
|
367
|
+
<span className="block mt-1 text-[#565f89]">
|
|
368
|
+
{webgpu.reason}
|
|
369
|
+
</span>
|
|
370
|
+
)}
|
|
371
|
+
</p>
|
|
372
|
+
<div className="mt-3 flex flex-wrap gap-2">
|
|
373
|
+
<a
|
|
374
|
+
href="https://caniuse.com/webgpu"
|
|
375
|
+
target="_blank"
|
|
376
|
+
rel="noopener noreferrer"
|
|
377
|
+
className="inline-flex items-center gap-1.5 px-3 py-1 text-xs font-mono uppercase tracking-wide border border-[#3b4261] text-[#7aa2f7] hover:border-[#7aa2f7] hover:bg-[#7aa2f7]/10 transition-colors"
|
|
378
|
+
>
|
|
379
|
+
Check Browser Support
|
|
380
|
+
<ExternalLink className="h-3 w-3" />
|
|
381
|
+
</a>
|
|
382
|
+
<span className="inline-flex items-center px-3 py-1 text-xs font-mono text-[#565f89] border border-[#3b4261]">
|
|
383
|
+
Chrome 113+ / Edge 113+ / Firefox 141+ / Safari 18+
|
|
384
|
+
</span>
|
|
385
|
+
</div>
|
|
386
|
+
|
|
387
|
+
{/* Troubleshooting Section */}
|
|
388
|
+
<button
|
|
389
|
+
onClick={() => setShowTroubleshooting(!showTroubleshooting)}
|
|
390
|
+
className="mt-4 flex items-center gap-2 text-xs font-mono uppercase tracking-wide text-[#ff9e64] hover:text-[#e0af68] transition-colors"
|
|
391
|
+
>
|
|
392
|
+
<ChevronDown className={`h-4 w-4 transition-transform ${showTroubleshooting ? 'rotate-180' : ''}`} />
|
|
393
|
+
{showTroubleshooting ? 'Hide' : 'Show'} Troubleshooting
|
|
394
|
+
</button>
|
|
395
|
+
|
|
396
|
+
{showTroubleshooting && (
|
|
397
|
+
<div className="mt-4 p-4 bg-[#1f2335] border border-[#3b4261] text-xs font-mono space-y-4">
|
|
398
|
+
<div>
|
|
399
|
+
<h4 className="font-bold text-[#ff9e64] uppercase tracking-wide mb-2">Blocklist Override</h4>
|
|
400
|
+
<p className="text-[#a9b1d6] mb-2">
|
|
401
|
+
WebGPU may be disabled due to GPU/driver blocklist. Try these flags:
|
|
402
|
+
</p>
|
|
403
|
+
<div className="space-y-1 text-[#7dcfff]">
|
|
404
|
+
<p><code className="bg-[#16161e] px-1.5 py-0.5">chrome://flags/#enable-unsafe-webgpu</code> → Enable</p>
|
|
405
|
+
<p><code className="bg-[#16161e] px-1.5 py-0.5">chrome://flags/#ignore-gpu-blocklist</code> → Enable</p>
|
|
406
|
+
</div>
|
|
407
|
+
</div>
|
|
408
|
+
|
|
409
|
+
<div>
|
|
410
|
+
<h4 className="font-bold text-[#bb9af7] uppercase tracking-wide mb-2">Firefox</h4>
|
|
411
|
+
<p className="text-[#a9b1d6] mb-2">
|
|
412
|
+
WebGPU enabled by default in Firefox 141+. For older versions:
|
|
413
|
+
</p>
|
|
414
|
+
<p className="text-[#7dcfff]">
|
|
415
|
+
<code className="bg-[#16161e] px-1.5 py-0.5">about:config</code> → <code className="bg-[#16161e] px-1.5 py-0.5">dom.webgpu.enabled</code> → true
|
|
416
|
+
</p>
|
|
417
|
+
</div>
|
|
418
|
+
|
|
419
|
+
<div>
|
|
420
|
+
<h4 className="font-bold text-[#9ece6a] uppercase tracking-wide mb-2">Safari</h4>
|
|
421
|
+
<p className="text-[#a9b1d6]">
|
|
422
|
+
Safari → Settings → Feature Flags → Enable "WebGPU"
|
|
423
|
+
</p>
|
|
424
|
+
</div>
|
|
425
|
+
|
|
426
|
+
<div>
|
|
427
|
+
<h4 className="font-bold text-[#7aa2f7] uppercase tracking-wide mb-2">Verify Status</h4>
|
|
428
|
+
<p className="text-[#a9b1d6] mb-2">Check your GPU status page:</p>
|
|
429
|
+
<div className="space-y-1 text-[#7dcfff]">
|
|
430
|
+
<p>Chrome/Edge: <code className="bg-[#16161e] px-1.5 py-0.5">chrome://gpu</code></p>
|
|
431
|
+
<p>Firefox: <code className="bg-[#16161e] px-1.5 py-0.5">about:support</code></p>
|
|
432
|
+
</div>
|
|
433
|
+
</div>
|
|
434
|
+
|
|
435
|
+
<a
|
|
436
|
+
href="https://developer.chrome.com/docs/web-platform/webgpu/troubleshooting-tips"
|
|
437
|
+
target="_blank"
|
|
438
|
+
rel="noopener noreferrer"
|
|
439
|
+
className="inline-flex items-center gap-1.5 text-[#7aa2f7] hover:underline"
|
|
440
|
+
>
|
|
441
|
+
Full Troubleshooting Guide
|
|
442
|
+
<ExternalLink className="h-3 w-3" />
|
|
443
|
+
</a>
|
|
444
|
+
</div>
|
|
445
|
+
)}
|
|
446
|
+
</div>
|
|
447
|
+
</div>
|
|
448
|
+
</div>
|
|
449
|
+
</div>
|
|
450
|
+
)}
|
|
451
|
+
|
|
452
|
+
{/* Empty state content */}
|
|
453
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center p-8 z-10">
|
|
454
|
+
|
|
455
|
+
{/* Main Card */}
|
|
456
|
+
<div className="max-w-md w-full bg-white dark:bg-[#16161e] border border-zinc-300 dark:border-[#3b4261] p-8 flex flex-col items-center transition-transform hover:-translate-y-1 duration-200 shadow-lg">
|
|
457
|
+
|
|
458
|
+
<style>{`
|
|
459
|
+
@keyframes float-slow {
|
|
460
|
+
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
|
461
|
+
50% { transform: translateY(-6px) rotate(1deg); }
|
|
462
|
+
}
|
|
463
|
+
.animate-float-slow {
|
|
464
|
+
animation: float-slow 5s ease-in-out infinite;
|
|
465
|
+
}
|
|
466
|
+
`}</style>
|
|
467
|
+
|
|
468
|
+
{/* Logo Section */}
|
|
469
|
+
<div className="mb-10 relative group/logo cursor-pointer">
|
|
470
|
+
{/* Back Layer */}
|
|
471
|
+
<div className="absolute -inset-6 bg-zinc-100 dark:bg-[#1f2335] -rotate-3 z-0 border border-zinc-300 dark:border-[#3b4261] transition-all duration-500 group-hover/logo:rotate-0 group-hover/logo:scale-110" />
|
|
472
|
+
|
|
473
|
+
{/* Middle Layer - accent on hover */}
|
|
474
|
+
<div className="absolute -inset-6 border border-primary z-0 opacity-0 scale-95 rotate-3 transition-all duration-500 delay-75 group-hover/logo:opacity-40 group-hover/logo:rotate-6 group-hover/logo:scale-105" />
|
|
475
|
+
|
|
476
|
+
{/* Logo Container */}
|
|
477
|
+
<div className="relative z-10 animate-float-slow transition-transform duration-300 group-hover/logo:scale-110">
|
|
478
|
+
<img
|
|
479
|
+
src="/logo.png"
|
|
480
|
+
alt="IFClite Logo"
|
|
481
|
+
className="h-28 w-auto drop-shadow-lg"
|
|
482
|
+
/>
|
|
483
|
+
</div>
|
|
484
|
+
</div>
|
|
485
|
+
|
|
486
|
+
<h2 className="text-3xl font-black tracking-tighter text-center mb-2 text-zinc-900 dark:text-[#a9b1d6]">
|
|
487
|
+
IFClite
|
|
488
|
+
</h2>
|
|
489
|
+
<p className="text-zinc-500 dark:text-[#565f89] font-mono text-sm text-center mb-8 border-b border-zinc-200 dark:border-[#3b4261] pb-4 w-full">
|
|
490
|
+
High-performance web viewer demo
|
|
491
|
+
</p>
|
|
492
|
+
|
|
493
|
+
{/* Action */}
|
|
494
|
+
<button
|
|
495
|
+
onClick={() => webgpu.supported && fileInputRef.current?.click()}
|
|
496
|
+
disabled={!webgpu.supported || webgpu.checking}
|
|
497
|
+
className={`group w-full flex items-center justify-center gap-3 px-6 py-3 font-mono text-sm border transition-all ${
|
|
498
|
+
!webgpu.supported || webgpu.checking
|
|
499
|
+
? 'border-zinc-200 dark:border-[#3b4261]/50 text-zinc-300 dark:text-[#565f89]/50 cursor-not-allowed'
|
|
500
|
+
: 'border-zinc-300 dark:border-[#3b4261] text-zinc-600 dark:text-[#a9b1d6] hover:border-primary hover:text-primary cursor-pointer'
|
|
501
|
+
}`}
|
|
502
|
+
>
|
|
503
|
+
<Upload className={`h-4 w-4 transition-transform ${webgpu.supported ? 'group-hover:-translate-y-0.5' : ''}`} />
|
|
504
|
+
<span>{webgpu.checking ? 'Checking WebGPU...' : webgpu.supported ? 'Open .ifc file' : 'WebGPU Required'}</span>
|
|
505
|
+
</button>
|
|
506
|
+
|
|
507
|
+
<p className="mt-3 text-xs font-mono text-zinc-400 dark:text-[#565f89]">
|
|
508
|
+
{webgpu.supported ? 'or drag & drop anywhere' : 'file upload disabled'}
|
|
509
|
+
</p>
|
|
510
|
+
</div>
|
|
511
|
+
|
|
512
|
+
{/* Feature Grid */}
|
|
513
|
+
<div className="mt-16 grid grid-cols-1 md:grid-cols-3 gap-6 max-w-3xl w-full">
|
|
514
|
+
{[
|
|
515
|
+
{ icon: MousePointer, label: "Select", desc: "Inspect elements", accentClass: 'text-blue-500 dark:text-[#7aa2f7]' },
|
|
516
|
+
{ icon: Layers, label: "Filter", desc: "Isolate storeys", accentClass: 'text-purple-500 dark:text-[#bb9af7]' },
|
|
517
|
+
{ icon: Info, label: "Analyze", desc: "View properties", accentClass: 'text-cyan-500 dark:text-[#7dcfff]' }
|
|
518
|
+
].map((feature, i) => (
|
|
519
|
+
<div
|
|
520
|
+
key={i}
|
|
521
|
+
className="p-4 flex items-center gap-4 bg-zinc-100 dark:bg-[#1f2335] border border-zinc-300 dark:border-[#3b4261]"
|
|
522
|
+
>
|
|
523
|
+
<div className={`p-2 bg-white dark:bg-[#16161e] border border-zinc-300 dark:border-[#3b4261] ${feature.accentClass}`}>
|
|
524
|
+
<feature.icon className="h-5 w-5" />
|
|
525
|
+
</div>
|
|
526
|
+
<div>
|
|
527
|
+
<h3 className="font-bold uppercase text-sm tracking-wide text-zinc-900 dark:text-[#a9b1d6]">{feature.label}</h3>
|
|
528
|
+
<p className="text-xs font-mono text-zinc-500 dark:text-[#565f89]">{feature.desc}</p>
|
|
529
|
+
</div>
|
|
530
|
+
</div>
|
|
531
|
+
))}
|
|
532
|
+
</div>
|
|
533
|
+
|
|
534
|
+
{/* Footer */}
|
|
535
|
+
<div className="absolute bottom-8 right-8 hidden md:block">
|
|
536
|
+
<div className="flex items-center gap-2 text-xs font-mono px-3 py-1.5 bg-zinc-100 dark:bg-[#1f2335] border border-zinc-300 dark:border-[#3b4261] text-zinc-500 dark:text-[#565f89]">
|
|
537
|
+
<Command className="h-3 w-3" />
|
|
538
|
+
<span>SHORTCUTS</span>
|
|
539
|
+
<span className="px-1.5 ml-1 font-bold text-primary bg-primary/20">?</span>
|
|
540
|
+
</div>
|
|
541
|
+
</div>
|
|
542
|
+
|
|
543
|
+
</div>
|
|
544
|
+
</div>
|
|
36
545
|
);
|
|
37
|
-
}
|
|
546
|
+
}
|
|
38
547
|
|
|
39
548
|
return (
|
|
40
|
-
<div
|
|
549
|
+
<div
|
|
550
|
+
className="relative h-full w-full bg-zinc-50 dark:bg-black overflow-hidden"
|
|
551
|
+
onDragOver={handleDragOver}
|
|
552
|
+
onDragLeave={handleDragLeave}
|
|
553
|
+
onDrop={handleDrop}
|
|
554
|
+
>
|
|
555
|
+
{/* Drop overlay for when a file is already loaded - shows "Add Model" */}
|
|
556
|
+
{isDragging && (
|
|
557
|
+
<div className="absolute inset-0 z-50 bg-[#9ece6a]/10 backdrop-blur-[2px] flex items-center justify-center">
|
|
558
|
+
<div className="bg-white dark:bg-[#1a1b26] border-4 border-dashed border-[#9ece6a] p-8 shadow-2xl">
|
|
559
|
+
<div className="text-center">
|
|
560
|
+
<Plus className="h-12 w-12 mx-auto text-[#9ece6a] mb-4" />
|
|
561
|
+
<p className="text-xl font-black uppercase text-[#9ece6a]">Add Model to Scene</p>
|
|
562
|
+
<p className="text-sm font-mono text-zinc-500 dark:text-[#565f89] mt-2">
|
|
563
|
+
Drop to federate with {models.size} existing model{models.size !== 1 ? 's' : ''}
|
|
564
|
+
</p>
|
|
565
|
+
</div>
|
|
566
|
+
</div>
|
|
567
|
+
</div>
|
|
568
|
+
)}
|
|
569
|
+
|
|
41
570
|
<Viewport
|
|
42
571
|
geometry={filteredGeometry}
|
|
43
|
-
coordinateInfo={
|
|
572
|
+
coordinateInfo={mergedGeometryResult?.coordinateInfo}
|
|
573
|
+
computedIsolatedIds={computedIsolatedIds}
|
|
574
|
+
modelIdToIndex={modelIdToIndex}
|
|
44
575
|
/>
|
|
45
576
|
<ViewportOverlays />
|
|
46
577
|
<ToolOverlays />
|
|
578
|
+
<Section2DPanel
|
|
579
|
+
mergedGeometry={mergedGeometryResult}
|
|
580
|
+
computedIsolatedIds={computedIsolatedIds}
|
|
581
|
+
modelIdToIndex={modelIdToIndex}
|
|
582
|
+
/>
|
|
47
583
|
</div>
|
|
48
584
|
);
|
|
49
585
|
}
|
|
@@ -17,7 +17,7 @@ import { ViewCube, type ViewCubeRef } from './ViewCube';
|
|
|
17
17
|
import { AxisHelper } from './AxisHelper';
|
|
18
18
|
|
|
19
19
|
export function ViewportOverlays() {
|
|
20
|
-
const
|
|
20
|
+
const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
|
|
21
21
|
const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
|
|
22
22
|
const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
|
|
23
23
|
const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
|
|
@@ -31,6 +31,7 @@ export function ViewportOverlays() {
|
|
|
31
31
|
|
|
32
32
|
// Local state for scale - updated via callback, no global re-renders
|
|
33
33
|
const [scale, setScale] = useState(10);
|
|
34
|
+
const lastScaleRef = useRef(10);
|
|
34
35
|
|
|
35
36
|
// Register callback for real-time rotation updates - updates ViewCube directly
|
|
36
37
|
useEffect(() => {
|
|
@@ -48,13 +49,25 @@ export function ViewportOverlays() {
|
|
|
48
49
|
}, [setOnCameraRotationChange]);
|
|
49
50
|
|
|
50
51
|
// Register callback for real-time scale updates
|
|
52
|
+
// Only update state if scale changed significantly (>1%) to avoid unnecessary re-renders
|
|
51
53
|
useEffect(() => {
|
|
52
|
-
|
|
54
|
+
const handleScaleChange = (newScale: number) => {
|
|
55
|
+
const lastScale = lastScaleRef.current;
|
|
56
|
+
// Only update if scale changed by more than 1%
|
|
57
|
+
if (Math.abs(newScale - lastScale) / lastScale > 0.01) {
|
|
58
|
+
lastScaleRef.current = newScale;
|
|
59
|
+
setScale(newScale);
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
setOnScaleChange(handleScaleChange);
|
|
53
63
|
return () => setOnScaleChange(null);
|
|
54
64
|
}, [setOnScaleChange]);
|
|
55
65
|
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
// Get names of selected storeys
|
|
67
|
+
const storeyNames = selectedStoreys.size > 0 && ifcDataStore
|
|
68
|
+
? Array.from(selectedStoreys).map(id =>
|
|
69
|
+
ifcDataStore.entities.getName(id) || `Storey #${id}`
|
|
70
|
+
)
|
|
58
71
|
: null;
|
|
59
72
|
|
|
60
73
|
// Calculate visible count considering visibility filters
|
|
@@ -146,12 +159,16 @@ export function ViewportOverlays() {
|
|
|
146
159
|
</Tooltip>
|
|
147
160
|
</div>
|
|
148
161
|
|
|
149
|
-
{/* Context Info (bottom-center) - Storey
|
|
150
|
-
{
|
|
162
|
+
{/* Context Info (bottom-center) - Storey names */}
|
|
163
|
+
{storeyNames && storeyNames.length > 0 && (
|
|
151
164
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-4 py-2 bg-background/80 backdrop-blur-sm rounded-full border shadow-sm">
|
|
152
165
|
<div className="flex items-center gap-2 text-sm">
|
|
153
166
|
<Layers className="h-4 w-4 text-primary" />
|
|
154
|
-
<span className="font-medium">
|
|
167
|
+
<span className="font-medium">
|
|
168
|
+
{storeyNames.length === 1
|
|
169
|
+
? storeyNames[0]
|
|
170
|
+
: `${storeyNames.length} storeys`}
|
|
171
|
+
</span>
|
|
155
172
|
</div>
|
|
156
173
|
</div>
|
|
157
174
|
)}
|