@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.
Files changed (164) hide show
  1. package/LICENSE +373 -0
  2. package/dist/apple-touch-icon.png +0 -0
  3. package/dist/assets/Arrow.dom-BjDQoB2M.js +20 -0
  4. package/dist/assets/arrow2-bb-jcVEo.js +2 -0
  5. package/dist/assets/arrow2_bg-4Y7xYo54.wasm +0 -0
  6. package/dist/assets/arrow2_bg-BlXl-cSQ.js +1 -0
  7. package/dist/assets/arrow2_bg-BoXCojjR.wasm +0 -0
  8. package/dist/assets/desktop-cache-oPzaWXYE.js +1 -0
  9. package/dist/assets/event-DIOks52T.js +1 -0
  10. package/dist/assets/ifc-cache-BAN4vcd4.js +1 -0
  11. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  12. package/dist/assets/index-YBtrHPu3.js +65252 -0
  13. package/dist/assets/index-v3mcCUPN.css +1 -0
  14. package/dist/assets/native-bridge-CULtTDX3.js +111 -0
  15. package/dist/assets/wasm-bridge-CjL-lSak.js +1 -0
  16. package/dist/favicon-16x16-cropped.png +0 -0
  17. package/dist/favicon-16x16.png +0 -0
  18. package/dist/favicon-192x192-cropped.png +0 -0
  19. package/dist/favicon-192x192.png +0 -0
  20. package/dist/favicon-32x32-cropped.png +0 -0
  21. package/dist/favicon-32x32.png +0 -0
  22. package/dist/favicon-48x48-cropped.png +0 -0
  23. package/dist/favicon-48x48.png +0 -0
  24. package/dist/favicon-512x512-cropped.png +0 -0
  25. package/dist/favicon-512x512.png +0 -0
  26. package/dist/favicon-64x64-cropped.png +0 -0
  27. package/dist/favicon-64x64.png +0 -0
  28. package/dist/favicon-96x96-cropped.png +0 -0
  29. package/dist/favicon-96x96.png +0 -0
  30. package/dist/favicon-square-512.png +0 -0
  31. package/dist/favicon.ico +0 -0
  32. package/dist/favicon.png +0 -0
  33. package/dist/favicon.svg +3 -0
  34. package/dist/index.html +44 -0
  35. package/dist/logo.png +0 -0
  36. package/dist/manifest.json +48 -0
  37. package/index.html +33 -2
  38. package/package.json +34 -17
  39. package/public/apple-touch-icon.png +0 -0
  40. package/public/favicon-16x16-cropped.png +0 -0
  41. package/public/favicon-16x16.png +0 -0
  42. package/public/favicon-192x192-cropped.png +0 -0
  43. package/public/favicon-192x192.png +0 -0
  44. package/public/favicon-32x32-cropped.png +0 -0
  45. package/public/favicon-32x32.png +0 -0
  46. package/public/favicon-48x48-cropped.png +0 -0
  47. package/public/favicon-48x48.png +0 -0
  48. package/public/favicon-512x512-cropped.png +0 -0
  49. package/public/favicon-512x512.png +0 -0
  50. package/public/favicon-64x64-cropped.png +0 -0
  51. package/public/favicon-64x64.png +0 -0
  52. package/public/favicon-96x96-cropped.png +0 -0
  53. package/public/favicon-96x96.png +0 -0
  54. package/public/favicon-square-512.png +0 -0
  55. package/public/favicon.ico +0 -0
  56. package/public/favicon.png +0 -0
  57. package/public/favicon.svg +3 -0
  58. package/public/logo.png +0 -0
  59. package/public/manifest.json +48 -0
  60. package/src/App.tsx +2 -0
  61. package/src/components/ui/alert.tsx +62 -0
  62. package/src/components/ui/badge.tsx +39 -0
  63. package/src/components/ui/dialog.tsx +120 -0
  64. package/src/components/ui/label.tsx +27 -0
  65. package/src/components/ui/select.tsx +151 -0
  66. package/src/components/ui/switch.tsx +30 -0
  67. package/src/components/ui/table.tsx +120 -0
  68. package/src/components/ui/tabs.tsx +1 -1
  69. package/src/components/viewer/BCFPanel.tsx +1164 -0
  70. package/src/components/viewer/BulkPropertyEditor.tsx +875 -0
  71. package/src/components/viewer/DataConnector.tsx +840 -0
  72. package/src/components/viewer/DrawingSettingsPanel.tsx +536 -0
  73. package/src/components/viewer/EntityContextMenu.tsx +45 -17
  74. package/src/components/viewer/ExportChangesButton.tsx +195 -0
  75. package/src/components/viewer/ExportDialog.tsx +402 -0
  76. package/src/components/viewer/HierarchyPanel.tsx +1132 -218
  77. package/src/components/viewer/IDSPanel.tsx +661 -0
  78. package/src/components/viewer/KeyboardShortcutsDialog.tsx +245 -39
  79. package/src/components/viewer/MainToolbar.tsx +418 -94
  80. package/src/components/viewer/PropertiesPanel.tsx +1355 -91
  81. package/src/components/viewer/PropertyEditor.tsx +611 -0
  82. package/src/components/viewer/Section2DPanel.tsx +3313 -0
  83. package/src/components/viewer/SheetSetupPanel.tsx +502 -0
  84. package/src/components/viewer/StatusBar.tsx +27 -16
  85. package/src/components/viewer/TitleBlockEditor.tsx +437 -0
  86. package/src/components/viewer/ToolOverlays.tsx +935 -127
  87. package/src/components/viewer/ViewerLayout.tsx +40 -11
  88. package/src/components/viewer/Viewport.tsx +1276 -336
  89. package/src/components/viewer/ViewportContainer.tsx +554 -18
  90. package/src/components/viewer/ViewportOverlays.tsx +24 -7
  91. package/src/hooks/useBCF.ts +504 -0
  92. package/src/hooks/useIDS.ts +1065 -0
  93. package/src/hooks/useIfc.ts +1534 -205
  94. package/src/hooks/useIfcCache.ts +279 -0
  95. package/src/hooks/useKeyboardShortcuts.ts +50 -8
  96. package/src/hooks/useModelSelection.ts +61 -0
  97. package/src/hooks/useViewerSelectors.ts +218 -0
  98. package/src/hooks/useWebGPU.ts +80 -0
  99. package/src/index.css +265 -27
  100. package/src/lib/platform.ts +23 -0
  101. package/src/services/cacheService.ts +142 -0
  102. package/src/services/desktop-cache.ts +143 -0
  103. package/src/services/fs-cache.ts +212 -0
  104. package/src/services/ifc-cache.ts +14 -6
  105. package/src/store/constants.ts +85 -0
  106. package/src/store/index.ts +214 -0
  107. package/src/store/slices/bcfSlice.ts +372 -0
  108. package/src/store/slices/cameraSlice.ts +63 -0
  109. package/src/store/slices/dataSlice.test.ts +226 -0
  110. package/src/store/slices/dataSlice.ts +112 -0
  111. package/src/store/slices/drawing2DSlice.ts +340 -0
  112. package/src/store/slices/hoverSlice.ts +40 -0
  113. package/src/store/slices/idsSlice.ts +310 -0
  114. package/src/store/slices/loadingSlice.ts +33 -0
  115. package/src/store/slices/measurementSlice.test.ts +217 -0
  116. package/src/store/slices/measurementSlice.ts +293 -0
  117. package/src/store/slices/modelSlice.test.ts +271 -0
  118. package/src/store/slices/modelSlice.ts +211 -0
  119. package/src/store/slices/mutationSlice.ts +502 -0
  120. package/src/store/slices/sectionSlice.test.ts +125 -0
  121. package/src/store/slices/sectionSlice.ts +58 -0
  122. package/src/store/slices/selectionSlice.test.ts +286 -0
  123. package/src/store/slices/selectionSlice.ts +263 -0
  124. package/src/store/slices/sheetSlice.ts +565 -0
  125. package/src/store/slices/uiSlice.ts +58 -0
  126. package/src/store/slices/visibilitySlice.test.ts +304 -0
  127. package/src/store/slices/visibilitySlice.ts +277 -0
  128. package/src/store/types.test.ts +135 -0
  129. package/src/store/types.ts +248 -0
  130. package/src/store.ts +40 -515
  131. package/src/utils/ifcConfig.ts +82 -0
  132. package/src/utils/localParsingUtils.ts +287 -0
  133. package/src/utils/serverDataModel.ts +783 -0
  134. package/src/utils/spatialHierarchy.ts +283 -0
  135. package/src/utils/viewportUtils.ts +334 -0
  136. package/src/vite-env.d.ts +23 -0
  137. package/src/webgpu-types.d.ts +128 -0
  138. package/src-tauri/Cargo.toml +29 -0
  139. package/src-tauri/build.rs +7 -0
  140. package/src-tauri/capabilities/default.json +18 -0
  141. package/src-tauri/icons/128x128.png +0 -0
  142. package/src-tauri/icons/128x128@2x.png +0 -0
  143. package/src-tauri/icons/32x32.png +0 -0
  144. package/src-tauri/icons/Square107x107Logo.png +0 -0
  145. package/src-tauri/icons/Square142x142Logo.png +0 -0
  146. package/src-tauri/icons/Square150x150Logo.png +0 -0
  147. package/src-tauri/icons/Square284x284Logo.png +0 -0
  148. package/src-tauri/icons/Square30x30Logo.png +0 -0
  149. package/src-tauri/icons/Square310x310Logo.png +0 -0
  150. package/src-tauri/icons/Square44x44Logo.png +0 -0
  151. package/src-tauri/icons/Square71x71Logo.png +0 -0
  152. package/src-tauri/icons/Square89x89Logo.png +0 -0
  153. package/src-tauri/icons/StoreLogo.png +0 -0
  154. package/src-tauri/icons/icon.icns +0 -0
  155. package/src-tauri/icons/icon.ico +0 -0
  156. package/src-tauri/icons/icon.png +0 -0
  157. package/src-tauri/src/lib.rs +21 -0
  158. package/src-tauri/src/main.rs +10 -0
  159. package/src-tauri/tauri.conf.json +39 -0
  160. package/vite.config.ts +174 -26
  161. package/public/ifc-lite_bg.wasm +0 -0
  162. package/public/web-ifc.wasm +0 -0
  163. package/src/components/Viewport.tsx +0 -723
  164. 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 selectedStorey = useViewerStore((s) => s.selectedStorey);
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
- // Filter geometry based on selected storey (for non-instanced fallback)
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 (!geometryResult?.meshes || !ifcDataStore?.spatialHierarchy) {
19
- return geometryResult?.meshes || null;
172
+ if (!mergedGeometryResult?.meshes) {
173
+ return null;
20
174
  }
21
175
 
22
- if (selectedStorey === null) {
23
- return geometryResult.meshes;
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
- const hierarchy = ifcDataStore.spatialHierarchy;
27
- const storeyElementIds = hierarchy.byStorey.get(selectedStorey);
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
- if (!storeyElementIds || storeyElementIds.length === 0) {
30
- return geometryResult.meshes;
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
- const storeyElementIdSet = new Set(storeyElementIds);
34
- return geometryResult.meshes.filter(mesh =>
35
- storeyElementIdSet.has(mesh.expressId)
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
- }, [geometryResult, ifcDataStore, selectedStorey]);
546
+ }
38
547
 
39
548
  return (
40
- <div className="relative h-full w-full bg-gradient-to-br from-slate-100 to-slate-200 dark:from-slate-900 dark:to-slate-800">
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={geometryResult?.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 selectedStorey = useViewerStore((s) => s.selectedStorey);
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
- setOnScaleChange(setScale);
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
- const storeyName = selectedStorey && ifcDataStore
57
- ? ifcDataStore.entities.getName(selectedStorey) || `Storey #${selectedStorey}`
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 name only */}
150
- {storeyName && (
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">{storeyName}</span>
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
  )}