@ifc-lite/viewer 1.6.1 → 1.8.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 (110) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/dist/assets/{Arrow.dom-Be1tgmo6.js → Arrow.dom-CwcRxist.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/index-7WoQ-qVC.css +1 -0
  5. package/dist/assets/{index-D1Du89Pa.js → index-BSANf7-H.js} +44948 -31410
  6. package/dist/assets/{native-bridge-A6zNnTfi.js → native-bridge-5LbrYh3R.js} +1 -1
  7. package/dist/assets/{wasm-bridge-DkRhgSvE.js → wasm-bridge-CgpLtj1h.js} +1 -1
  8. package/dist/index.html +2 -2
  9. package/package.json +18 -15
  10. package/src/components/viewer/BCFPanel.tsx +7 -789
  11. package/src/components/viewer/Drawing2DCanvas.tsx +1411 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/EntityContextMenu.tsx +47 -20
  14. package/src/components/viewer/ExportDialog.tsx +166 -17
  15. package/src/components/viewer/HierarchyPanel.tsx +113 -843
  16. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  17. package/src/components/viewer/IDSPanel.tsx +126 -17
  18. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  19. package/src/components/viewer/LensPanel.tsx +1366 -0
  20. package/src/components/viewer/MainToolbar.tsx +237 -37
  21. package/src/components/viewer/PropertiesPanel.tsx +171 -652
  22. package/src/components/viewer/PropertyEditor.tsx +866 -77
  23. package/src/components/viewer/Section2DPanel.tsx +329 -2661
  24. package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
  25. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  26. package/src/components/viewer/ViewerLayout.tsx +132 -45
  27. package/src/components/viewer/Viewport.tsx +290 -1678
  28. package/src/components/viewer/ViewportContainer.tsx +13 -3
  29. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  30. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  31. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  32. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  33. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  34. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  35. package/src/components/viewer/hierarchy/types.ts +54 -0
  36. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  37. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  38. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  39. package/src/components/viewer/lists/ListResultsTable.tsx +227 -0
  40. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  41. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  42. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  43. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  44. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  45. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  46. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  47. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  48. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  49. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  50. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  51. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  52. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  53. package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
  54. package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
  55. package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
  56. package/src/components/viewer/tools/computePolygonArea.ts +72 -0
  57. package/src/components/viewer/tools/formatDistance.ts +18 -0
  58. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  59. package/src/components/viewer/useAnimationLoop.ts +166 -0
  60. package/src/components/viewer/useGeometryStreaming.ts +406 -0
  61. package/src/components/viewer/useKeyboardControls.ts +221 -0
  62. package/src/components/viewer/useMouseControls.ts +1009 -0
  63. package/src/components/viewer/useRenderUpdates.ts +165 -0
  64. package/src/components/viewer/useTouchControls.ts +245 -0
  65. package/src/hooks/ids/idsColorSystem.ts +125 -0
  66. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  67. package/src/hooks/ids/idsExportService.ts +444 -0
  68. package/src/hooks/useAnnotation2D.ts +551 -0
  69. package/src/hooks/useBCF.ts +7 -0
  70. package/src/hooks/useDrawingExport.ts +709 -0
  71. package/src/hooks/useDrawingGeneration.ts +627 -0
  72. package/src/hooks/useFloorplanView.ts +108 -0
  73. package/src/hooks/useIDS.ts +270 -463
  74. package/src/hooks/useIfc.ts +26 -1628
  75. package/src/hooks/useIfcFederation.ts +803 -0
  76. package/src/hooks/useIfcLoader.ts +508 -0
  77. package/src/hooks/useIfcServer.ts +465 -0
  78. package/src/hooks/useKeyboardShortcuts.ts +114 -15
  79. package/src/hooks/useLens.ts +113 -0
  80. package/src/hooks/useLensDiscovery.ts +46 -0
  81. package/src/hooks/useMeasure2D.ts +365 -0
  82. package/src/hooks/useModelSelection.ts +5 -22
  83. package/src/hooks/useViewControls.ts +218 -0
  84. package/src/index.css +7 -1
  85. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  86. package/src/lib/ifc4-pset-definitions.ts +621 -0
  87. package/src/lib/ifc4-qto-definitions.ts +315 -0
  88. package/src/lib/lens/adapter.ts +264 -0
  89. package/src/lib/lens/index.ts +5 -0
  90. package/src/lib/lists/adapter.ts +69 -0
  91. package/src/lib/lists/columnToAutoColor.ts +33 -0
  92. package/src/lib/lists/index.ts +28 -0
  93. package/src/lib/lists/persistence.ts +64 -0
  94. package/src/services/fs-cache.ts +1 -1
  95. package/src/services/tauri-modules.d.ts +25 -0
  96. package/src/store/index.ts +52 -3
  97. package/src/store/resolveEntityRef.ts +44 -0
  98. package/src/store/slices/cameraSlice.ts +14 -1
  99. package/src/store/slices/dataSlice.ts +14 -1
  100. package/src/store/slices/drawing2DSlice.ts +321 -0
  101. package/src/store/slices/lensSlice.ts +226 -0
  102. package/src/store/slices/listSlice.ts +74 -0
  103. package/src/store/slices/pinboardSlice.ts +247 -0
  104. package/src/store/types.ts +5 -0
  105. package/src/store.ts +3 -0
  106. package/src/utils/ifcConfig.ts +16 -3
  107. package/src/utils/serverDataModel.ts +64 -101
  108. package/src/vite-env.d.ts +3 -0
  109. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  110. package/dist/assets/index-v3mcCUPN.css +0 -1
@@ -0,0 +1,465 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * Hook for server-side IFC parsing
7
+ * Manages ServerClient instance, server reachability checking,
8
+ * and streaming/Parquet/JSON parsing paths
9
+ *
10
+ * Extracted from useIfc.ts for better separation of concerns
11
+ */
12
+
13
+ import { useCallback } from 'react';
14
+ import { useViewerStore } from '../store.js';
15
+ import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
16
+ import {
17
+ IfcServerClient,
18
+ decodeDataModel,
19
+ type ParquetBatch,
20
+ type DataModel,
21
+ type ParquetParseResponse,
22
+ type ParquetStreamResult,
23
+ type ParseResponse,
24
+ type ModelMetadata,
25
+ type ProcessingStats,
26
+ type MeshData as ServerMeshData,
27
+ } from '@ifc-lite/server-client';
28
+
29
+ import { SERVER_URL } from '../utils/ifcConfig.js';
30
+ import {
31
+ createEmptyBounds,
32
+ updateBoundsFromPositions,
33
+ calculateMeshBounds,
34
+ createCoordinateInfo,
35
+ getServerStreamIntervalMs,
36
+ } from '../utils/localParsingUtils.js';
37
+
38
+ // Server data model conversion
39
+ import { convertServerDataModel, type ServerParseResult } from '../utils/serverDataModel.js';
40
+
41
+ /** Convert server mesh data (snake_case) to viewer format (camelCase) */
42
+ function convertServerMesh(m: ServerMeshData): MeshData {
43
+ return {
44
+ expressId: m.express_id,
45
+ positions: new Float32Array(m.positions),
46
+ indices: new Uint32Array(m.indices),
47
+ normals: m.normals ? new Float32Array(m.normals) : new Float32Array(0),
48
+ color: m.color,
49
+ ifcType: m.ifc_type,
50
+ };
51
+ }
52
+
53
+ /** Server parse result type - union of streaming and non-streaming responses */
54
+ type ServerParseResultType = ParquetParseResponse | ParquetStreamResult | ParseResponse;
55
+
56
+ // Module-level server availability cache - avoids repeated failed connection attempts
57
+ let serverAvailabilityCache: { available: boolean; checkedAt: number } | null = null;
58
+ const SERVER_CHECK_CACHE_MS = 30000; // Re-check server availability every 30 seconds
59
+
60
+ /**
61
+ * Check if server URL is reachable from current origin
62
+ * Returns false immediately if localhost server from non-localhost origin (would cause CORS)
63
+ */
64
+ function isServerReachable(serverUrl: string): boolean {
65
+ try {
66
+ const server = new URL(serverUrl);
67
+ const isServerLocalhost = server.hostname === 'localhost' || server.hostname === '127.0.0.1';
68
+
69
+ // In browser, check if we're on localhost
70
+ if (typeof window !== 'undefined') {
71
+ const isClientLocalhost = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1';
72
+
73
+ // Skip localhost server when running from remote origin (avoids CORS error in console)
74
+ if (isServerLocalhost && !isClientLocalhost) {
75
+ return false;
76
+ }
77
+ }
78
+ return true;
79
+ } catch {
80
+ return false;
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Silently check if server is available (no console logging on failure)
86
+ * Returns cached result if recently checked
87
+ */
88
+ async function isServerAvailable(serverUrl: string, client: IfcServerClient): Promise<boolean> {
89
+ // First check if server is even reachable (prevents CORS errors)
90
+ if (!isServerReachable(serverUrl)) {
91
+ return false;
92
+ }
93
+
94
+ const now = Date.now();
95
+
96
+ // Use cached result if recent
97
+ if (serverAvailabilityCache && (now - serverAvailabilityCache.checkedAt) < SERVER_CHECK_CACHE_MS) {
98
+ return serverAvailabilityCache.available;
99
+ }
100
+
101
+ // Perform silent health check
102
+ try {
103
+ await client.health();
104
+ serverAvailabilityCache = { available: true, checkedAt: now };
105
+ return true;
106
+ } catch {
107
+ // Silent failure - don't log network errors for unavailable server
108
+ serverAvailabilityCache = { available: false, checkedAt: now };
109
+ return false;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Hook for server-side IFC file parsing
115
+ * Handles server reachability, streaming/Parquet/JSON parsing paths,
116
+ * and ServerClient lifecycle
117
+ */
118
+ export function useIfcServer() {
119
+ /**
120
+ * Load from server - uses server-side PARALLEL parsing for maximum speed
121
+ * Uses full parse endpoint (not streaming) for all-at-once parallel processing
122
+ *
123
+ * Store actions are retrieved via getState() inside the callback to avoid
124
+ * subscribing the hook to the entire store (which would cause unnecessary re-renders).
125
+ */
126
+ const loadFromServer = useCallback(async (
127
+ file: File,
128
+ buffer: ArrayBuffer
129
+ ): Promise<boolean> => {
130
+ const { setProgress, setIfcDataStore, setGeometryResult } = useViewerStore.getState();
131
+ try {
132
+ const serverStart = performance.now();
133
+ setProgress({ phase: 'Connecting to server', percent: 5 });
134
+
135
+ const client = new IfcServerClient({ baseUrl: SERVER_URL });
136
+
137
+ // Silent server availability check (cached, no error logging)
138
+ const serverAvailable = await isServerAvailable(SERVER_URL, client);
139
+ if (!serverAvailable) {
140
+ return false; // Silently fall back - caller handles logging
141
+ }
142
+
143
+ setProgress({ phase: 'Processing on server (parallel)', percent: 15 });
144
+
145
+ // Check if Parquet is supported (requires parquet-wasm)
146
+ const parquetSupported = await client.isParquetSupported();
147
+
148
+ let allMeshes: MeshData[];
149
+ let result: ServerParseResultType;
150
+ let parseTime: number;
151
+ let convertTime: number;
152
+
153
+ // Use streaming for large files (>150MB) for progressive rendering
154
+ // Smaller files use non-streaming path (faster - avoids ~1.1s background re-processing overhead)
155
+ // Streaming overhead: ~67 batch serializations + background re-processing (~1100ms)
156
+ // Non-streaming: single serialization (~218ms for 60k meshes)
157
+ // Threshold chosen to balance UX (progressive rendering) vs performance (overhead)
158
+ const fileSizeMB = buffer.byteLength / (1024 * 1024);
159
+ const USE_STREAMING_THRESHOLD_MB = 150;
160
+
161
+ if (parquetSupported && fileSizeMB > USE_STREAMING_THRESHOLD_MB) {
162
+ // STREAMING PATH - for large files, render progressively
163
+ console.log(`[useIfc] Using STREAMING endpoint for large file (${fileSizeMB.toFixed(1)}MB)`);
164
+
165
+ allMeshes = [];
166
+ let totalVertices = 0;
167
+ let totalTriangles = 0;
168
+ let cacheKey = '';
169
+ let streamMetadata: ModelMetadata | null = null;
170
+ let streamStats: ProcessingStats | null = null;
171
+ let batchCount = 0;
172
+
173
+ // Progressive bounds calculation
174
+ const bounds = createEmptyBounds();
175
+
176
+ const parseStart = performance.now();
177
+
178
+ // Throttle server streaming updates - large files get less frequent UI updates
179
+ let lastServerStreamRenderTime = 0;
180
+ const SERVER_STREAM_INTERVAL_MS = getServerStreamIntervalMs(fileSizeMB);
181
+
182
+ // Use streaming endpoint with batch callback
183
+ const streamResult = await client.parseParquetStream(file, (batch: ParquetBatch) => {
184
+ batchCount++;
185
+
186
+ // Convert batch meshes to viewer format (snake_case to camelCase, number[] to TypedArray)
187
+ const batchMeshes: MeshData[] = batch.meshes.map(convertServerMesh);
188
+
189
+ // Update bounds incrementally
190
+ for (const mesh of batchMeshes) {
191
+ updateBoundsFromPositions(bounds, mesh.positions);
192
+ totalVertices += mesh.positions.length / 3;
193
+ totalTriangles += mesh.indices.length / 3;
194
+ }
195
+
196
+ // Add to collection (use loop to avoid stack overflow with large batches)
197
+ for (let i = 0; i < batchMeshes.length; i++) allMeshes.push(batchMeshes[i]);
198
+
199
+ // THROTTLED PROGRESSIVE RENDERING: Update UI at controlled rate
200
+ // First batch renders immediately, subsequent batches throttled
201
+ const now = performance.now();
202
+ const shouldRender = batchCount === 1 || (now - lastServerStreamRenderTime >= SERVER_STREAM_INTERVAL_MS);
203
+
204
+ if (shouldRender) {
205
+ lastServerStreamRenderTime = now;
206
+
207
+ // Update progress
208
+ setProgress({
209
+ phase: `Streaming batch ${batchCount}`,
210
+ percent: Math.min(15 + (batchCount * 5), 85)
211
+ });
212
+
213
+ // PROGRESSIVE RENDERING: Set geometry after each batch
214
+ // This allows the user to see geometry appearing progressively
215
+ const coordinateInfo = {
216
+ originShift: { x: 0, y: 0, z: 0 },
217
+ originalBounds: bounds,
218
+ shiftedBounds: bounds,
219
+ hasLargeCoordinates: false,
220
+ };
221
+
222
+ setGeometryResult({
223
+ meshes: [...allMeshes], // Clone to trigger re-render
224
+ totalVertices,
225
+ totalTriangles,
226
+ coordinateInfo,
227
+ });
228
+ }
229
+ });
230
+
231
+ parseTime = performance.now() - parseStart;
232
+ cacheKey = streamResult.cache_key;
233
+ streamMetadata = streamResult.metadata;
234
+ streamStats = streamResult.stats;
235
+
236
+ console.log(`[useIfc] Streaming complete in ${parseTime.toFixed(0)}ms`);
237
+ console.log(` ${batchCount} batches, ${allMeshes.length} meshes`);
238
+ console.log(` Cache key: ${cacheKey}`);
239
+
240
+ // Build final result object for data model fetching
241
+ // Note: meshes field is omitted - allMeshes is passed separately to convertServerDataModel
242
+ result = {
243
+ cache_key: cacheKey,
244
+ metadata: streamMetadata,
245
+ stats: streamStats,
246
+ } as ParquetStreamResult;
247
+ convertTime = 0; // Already converted inline
248
+
249
+ // Final geometry set with complete bounds
250
+ // Server already applies RTC shift to mesh positions, so bounds are shifted
251
+ // Reconstruct originalBounds by adding originShift back to shifted bounds
252
+ const originShift = streamMetadata?.coordinate_info?.origin_shift
253
+ ? { x: streamMetadata.coordinate_info.origin_shift[0], y: streamMetadata.coordinate_info.origin_shift[1], z: streamMetadata.coordinate_info.origin_shift[2] }
254
+ : { x: 0, y: 0, z: 0 };
255
+ const finalCoordinateInfo = {
256
+ originShift,
257
+ // Original bounds = shifted bounds + originShift (reconstruct world coordinates)
258
+ originalBounds: {
259
+ min: {
260
+ x: bounds.min.x + originShift.x,
261
+ y: bounds.min.y + originShift.y,
262
+ z: bounds.min.z + originShift.z,
263
+ },
264
+ max: {
265
+ x: bounds.max.x + originShift.x,
266
+ y: bounds.max.y + originShift.y,
267
+ z: bounds.max.z + originShift.z,
268
+ },
269
+ },
270
+ // Shifted bounds = bounds as-is (server already applied shift)
271
+ shiftedBounds: bounds,
272
+ // Note: server returns is_geo_referenced but it really means "had large coordinates"
273
+ hasLargeCoordinates: streamMetadata?.coordinate_info?.is_geo_referenced ?? false,
274
+ };
275
+
276
+ setGeometryResult({
277
+ meshes: allMeshes,
278
+ totalVertices,
279
+ totalTriangles,
280
+ coordinateInfo: finalCoordinateInfo,
281
+ });
282
+
283
+ } else if (parquetSupported) {
284
+ // NON-STREAMING PATH - for smaller files, use batch request (with cache check)
285
+ console.log(`[useIfc] Using PARQUET endpoint - 15x smaller payload, faster transfer`);
286
+
287
+ // Use Parquet endpoint - much smaller payload (~15x compression)
288
+ const parseStart = performance.now();
289
+ const parquetResult = await client.parseParquet(file);
290
+ result = parquetResult;
291
+ parseTime = performance.now() - parseStart;
292
+
293
+ console.log(`[useIfc] Server parse response received in ${parseTime.toFixed(0)}ms`);
294
+ console.log(` Server stats: ${parquetResult.stats.total_time_ms}ms total (parse: ${parquetResult.stats.parse_time_ms}ms, geometry: ${parquetResult.stats.geometry_time_ms}ms)`);
295
+ console.log(` Parquet payload: ${(parquetResult.parquet_stats.payload_size / 1024 / 1024).toFixed(2)}MB, decode: ${parquetResult.parquet_stats.decode_time_ms}ms`);
296
+ console.log(` Meshes: ${parquetResult.meshes.length}, Vertices: ${parquetResult.stats.total_vertices}, Triangles: ${parquetResult.stats.total_triangles}`);
297
+ console.log(` Cache key: ${parquetResult.cache_key}`);
298
+
299
+ setProgress({ phase: 'Converting meshes', percent: 70 });
300
+
301
+ // Convert server mesh format to viewer format (TypedArrays)
302
+ const convertStart = performance.now();
303
+ allMeshes = parquetResult.meshes.map(convertServerMesh);
304
+ convertTime = performance.now() - convertStart;
305
+ console.log(`[useIfc] Mesh conversion: ${convertTime.toFixed(0)}ms for ${allMeshes.length} meshes`);
306
+ } else {
307
+ console.log(`[useIfc] Parquet not available, using JSON endpoint (install parquet-wasm for 15x faster transfer)`);
308
+ console.log(`[useIfc] Using FULL PARSE (parallel) - all geometry processed at once`);
309
+
310
+ // Fallback to JSON endpoint
311
+ const parseStart = performance.now();
312
+ result = await client.parse(file);
313
+ parseTime = performance.now() - parseStart;
314
+
315
+ console.log(`[useIfc] Server parse response received in ${parseTime.toFixed(0)}ms`);
316
+ console.log(` Server stats: ${result.stats.total_time_ms}ms total (parse: ${result.stats.parse_time_ms}ms, geometry: ${result.stats.geometry_time_ms}ms)`);
317
+ console.log(` Meshes: ${result.meshes.length}, Vertices: ${result.stats.total_vertices}, Triangles: ${result.stats.total_triangles}`);
318
+ console.log(` Cache key: ${result.cache_key}`);
319
+
320
+ setProgress({ phase: 'Converting meshes', percent: 70 });
321
+
322
+ // Convert server mesh format to viewer format
323
+ const convertStart = performance.now();
324
+ const jsonResult = result as ParseResponse;
325
+ allMeshes = jsonResult.meshes.map(convertServerMesh);
326
+ convertTime = performance.now() - convertStart;
327
+ console.log(`[useIfc] Mesh conversion: ${convertTime.toFixed(0)}ms for ${allMeshes.length} meshes`);
328
+ }
329
+
330
+ // For non-streaming paths, calculate bounds and set geometry
331
+ // (Streaming path already handled this progressively)
332
+ const wasStreaming = parquetSupported && fileSizeMB > USE_STREAMING_THRESHOLD_MB;
333
+
334
+ if (!wasStreaming) {
335
+ // Calculate bounds from mesh positions for camera fitting
336
+ // IMPORTANT: Server already applies RTC shift to mesh positions, so bounds calculated
337
+ // from mesh positions are ALREADY in shifted coordinates (small values near origin).
338
+ // We must NOT subtract originShift again - that would give huge negative bounds!
339
+ const { bounds } = calculateMeshBounds(allMeshes);
340
+
341
+ // Build CoordinateInfo correctly for server-shifted meshes:
342
+ // - shiftedBounds = bounds (already shifted by server)
343
+ // - originalBounds = bounds + originShift (reconstruct original world coordinates)
344
+ const serverCoordInfo = result.metadata.coordinate_info;
345
+ const originShift = serverCoordInfo?.origin_shift
346
+ ? { x: serverCoordInfo.origin_shift[0], y: serverCoordInfo.origin_shift[1], z: serverCoordInfo.origin_shift[2] }
347
+ : { x: 0, y: 0, z: 0 };
348
+
349
+ // When server already shifted meshes, shiftedBounds IS the calculated bounds
350
+ // (don't use createCoordinateInfo which would subtract originShift again)
351
+ const coordinateInfo: CoordinateInfo = {
352
+ originShift,
353
+ // Original bounds = shifted bounds + originShift (reconstruct world coordinates)
354
+ originalBounds: {
355
+ min: {
356
+ x: bounds.min.x + originShift.x,
357
+ y: bounds.min.y + originShift.y,
358
+ z: bounds.min.z + originShift.z,
359
+ },
360
+ max: {
361
+ x: bounds.max.x + originShift.x,
362
+ y: bounds.max.y + originShift.y,
363
+ z: bounds.max.z + originShift.z,
364
+ },
365
+ },
366
+ // Shifted bounds = bounds as-is (server already applied shift)
367
+ shiftedBounds: {
368
+ min: { x: bounds.min.x, y: bounds.min.y, z: bounds.min.z },
369
+ max: { x: bounds.max.x, y: bounds.max.y, z: bounds.max.z },
370
+ },
371
+ // Note: server returns is_geo_referenced but it really means "had large coordinates"
372
+ hasLargeCoordinates: serverCoordInfo?.is_geo_referenced ?? false,
373
+ };
374
+
375
+ console.log(`[useIfc] Calculated bounds:`, {
376
+ min: `(${bounds.min.x.toFixed(1)}, ${bounds.min.y.toFixed(1)}, ${bounds.min.z.toFixed(1)})`,
377
+ max: `(${bounds.max.x.toFixed(1)}, ${bounds.max.y.toFixed(1)}, ${bounds.max.z.toFixed(1)})`,
378
+ size: `${(bounds.max.x - bounds.min.x).toFixed(1)} x ${(bounds.max.y - bounds.min.y).toFixed(1)} x ${(bounds.max.z - bounds.min.z).toFixed(1)}`,
379
+ });
380
+
381
+ // Set all geometry at once
382
+ setProgress({ phase: 'Rendering geometry', percent: 80 });
383
+ const renderStart = performance.now();
384
+ setGeometryResult({
385
+ meshes: allMeshes,
386
+ totalVertices: result.stats.total_vertices,
387
+ totalTriangles: result.stats.total_triangles,
388
+ coordinateInfo,
389
+ });
390
+ const renderTime = performance.now() - renderStart;
391
+ console.log(`[useIfc] Geometry set: ${renderTime.toFixed(0)}ms`);
392
+ }
393
+
394
+ // Fetch and decode data model asynchronously (geometry already displayed)
395
+ // Data model is processed on server in background, fetch via separate endpoint
396
+ const cacheKey = result.cache_key;
397
+
398
+ // Start data model fetch in background - don't block rendering
399
+ (async () => {
400
+ setProgress({ phase: 'Fetching data model', percent: 85 });
401
+ const dataModelStart = performance.now();
402
+
403
+ try {
404
+ // If data model was included in response (ParquetParseResponse), use it directly
405
+ // Otherwise, fetch from the data model endpoint
406
+ let dataModelBuffer: ArrayBuffer | null = null;
407
+ if ('data_model' in result && result.data_model) {
408
+ dataModelBuffer = result.data_model;
409
+ }
410
+
411
+ if (!dataModelBuffer || dataModelBuffer.byteLength === 0) {
412
+ console.log('[useIfc] Fetching data model from server (background processing)...');
413
+ dataModelBuffer = await client.fetchDataModel(cacheKey);
414
+ }
415
+
416
+ if (!dataModelBuffer) {
417
+ console.log('[useIfc] ⚡ Data model not available - property panel disabled');
418
+ return;
419
+ }
420
+
421
+ const dataModel: DataModel = await decodeDataModel(dataModelBuffer);
422
+
423
+ console.log(`[useIfc] Data model decoded in ${(performance.now() - dataModelStart).toFixed(0)}ms`);
424
+ console.log(` Entities: ${dataModel.entities.size}`);
425
+ console.log(` PropertySets: ${dataModel.propertySets.size}`);
426
+ const quantitySetsSize = (dataModel as { quantitySets?: Map<number, unknown> }).quantitySets?.size ?? 0;
427
+ console.log(` QuantitySets: ${quantitySetsSize}`);
428
+ console.log(` Relationships: ${dataModel.relationships.length}`);
429
+ console.log(` Spatial nodes: ${dataModel.spatialHierarchy.nodes.length}`);
430
+
431
+ // Convert server data model directly to IfcDataStore format
432
+ const dataStore = convertServerDataModel(
433
+ dataModel,
434
+ result as ServerParseResult,
435
+ file,
436
+ allMeshes
437
+ );
438
+
439
+ setIfcDataStore(dataStore);
440
+ console.log('[useIfc] ✅ Property panel ready with server data model');
441
+ console.log(`[useIfc] Data model loaded in ${(performance.now() - dataModelStart).toFixed(0)}ms (background)`);
442
+ } catch (err) {
443
+ console.warn('[useIfc] Failed to decode data model:', err);
444
+ console.log('[useIfc] ⚡ Skipping data model (decoding failed)');
445
+ }
446
+ })(); // End of async data model fetch block - runs in background, doesn't block
447
+
448
+ // Geometry is ready - mark complete immediately (data model loads in background)
449
+ setProgress({ phase: 'Complete', percent: 100 });
450
+ const totalServerTime = performance.now() - serverStart;
451
+ console.log(`[useIfc] SERVER PARALLEL complete: ${file.name}`);
452
+ console.log(` Total time: ${totalServerTime.toFixed(0)}ms`);
453
+ console.log(` Breakdown: parse=${parseTime.toFixed(0)}ms, convert=${convertTime.toFixed(0)}ms`);
454
+
455
+ return true;
456
+ } catch (err) {
457
+ console.error('[useIfc] Server parse failed:', err);
458
+ return false;
459
+ }
460
+ }, []);
461
+
462
+ return { loadFromServer };
463
+ }
464
+
465
+ export default useIfcServer;
@@ -7,12 +7,49 @@
7
7
  */
8
8
 
9
9
  import { useEffect, useCallback } from 'react';
10
- import { useViewerStore } from '@/store';
10
+ import { useViewerStore, stringToEntityRef } from '@/store';
11
+ import type { EntityRef } from '@/store';
11
12
 
12
13
  interface KeyboardShortcutsOptions {
13
14
  enabled?: boolean;
14
15
  }
15
16
 
17
+ /** Clear multi-select state so subsequent operations use single-entity selectedEntity */
18
+ function clearMultiSelect(): void {
19
+ const state = useViewerStore.getState();
20
+ if (state.selectedEntitiesSet.size > 0) {
21
+ useViewerStore.setState({ selectedEntitiesSet: new Set(), selectedEntityIds: new Set() });
22
+ }
23
+ }
24
+
25
+ /** Get all selected global IDs — multi-select if available, else single selectedEntityId */
26
+ function getAllSelectedGlobalIds(): number[] {
27
+ const state = useViewerStore.getState();
28
+ if (state.selectedEntityIds.size > 0) {
29
+ return Array.from(state.selectedEntityIds);
30
+ }
31
+ if (state.selectedEntityId !== null) {
32
+ return [state.selectedEntityId];
33
+ }
34
+ return [];
35
+ }
36
+
37
+ /** Get current selection as EntityRef[] — multi-select if available, else single */
38
+ function getSelectionRefsFromStore(): EntityRef[] {
39
+ const state = useViewerStore.getState();
40
+ if (state.selectedEntitiesSet.size > 0) {
41
+ const refs: EntityRef[] = [];
42
+ for (const str of state.selectedEntitiesSet) {
43
+ refs.push(stringToEntityRef(str));
44
+ }
45
+ return refs;
46
+ }
47
+ if (state.selectedEntity) {
48
+ return [state.selectedEntity];
49
+ }
50
+ return [];
51
+ }
52
+
16
53
  export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
17
54
  const { enabled = true } = options;
18
55
 
@@ -20,12 +57,18 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
20
57
  const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
21
58
  const activeTool = useViewerStore((s) => s.activeTool);
22
59
  const setActiveTool = useViewerStore((s) => s.setActiveTool);
23
- const isolateEntity = useViewerStore((s) => s.isolateEntity);
24
- const hideEntity = useViewerStore((s) => s.hideEntity);
60
+ const hideEntities = useViewerStore((s) => s.hideEntities);
25
61
  const showAll = useViewerStore((s) => s.showAll);
26
62
  const clearStoreySelection = useViewerStore((s) => s.clearStoreySelection);
27
63
  const toggleTheme = useViewerStore((s) => s.toggleTheme);
28
64
 
65
+ // Basket actions
66
+ const setBasket = useViewerStore((s) => s.setBasket);
67
+ const addToBasket = useViewerStore((s) => s.addToBasket);
68
+ const removeFromBasket = useViewerStore((s) => s.removeFromBasket);
69
+ const clearBasket = useViewerStore((s) => s.clearBasket);
70
+ const showPinboard = useViewerStore((s) => s.showPinboard);
71
+
29
72
  // Measure tool specific actions
30
73
  const activeMeasurement = useViewerStore((s) => s.activeMeasurement);
31
74
  const cancelMeasurement = useViewerStore((s) => s.cancelMeasurement);
@@ -74,18 +117,67 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
74
117
  setActiveTool('section');
75
118
  }
76
119
 
77
- // Visibility controls
78
- if (key === 'i' && !ctrl && !shift && selectedEntityId) {
120
+ // Basket / Visibility controls
121
+ // I = Set basket (isolate selection as basket), or re-apply basket if no selection
122
+ if (key === 'i' && !ctrl && !shift) {
123
+ const state = useViewerStore.getState();
124
+ // If basket already exists and user hasn't explicitly multi-selected,
125
+ // re-apply the basket instead of replacing it with a stale single selection.
126
+ if (state.pinboardEntities.size > 0 && state.selectedEntitiesSet.size === 0) {
127
+ e.preventDefault();
128
+ showPinboard();
129
+ } else {
130
+ const refs = getSelectionRefsFromStore();
131
+ if (refs.length > 0) {
132
+ e.preventDefault();
133
+ setBasket(refs);
134
+ // Consume multi-select so subsequent − removes a single entity
135
+ clearMultiSelect();
136
+ }
137
+ }
138
+ }
139
+
140
+ // + or = (with shift) = Add to basket
141
+ if ((e.key === '+' || (e.key === '=' && shift)) && !ctrl) {
142
+ e.preventDefault();
143
+ const refs = getSelectionRefsFromStore();
144
+ if (refs.length > 0) {
145
+ addToBasket(refs);
146
+ // Consume multi-select so subsequent − removes a single entity
147
+ clearMultiSelect();
148
+ }
149
+ }
150
+
151
+ // - or _ = Remove from basket
152
+ if ((e.key === '-' || e.key === '_') && !ctrl) {
79
153
  e.preventDefault();
80
- isolateEntity(selectedEntityId);
154
+ const refs = getSelectionRefsFromStore();
155
+ if (refs.length > 0) {
156
+ removeFromBasket(refs);
157
+ // Consume multi-select after removal
158
+ clearMultiSelect();
159
+ }
81
160
  }
161
+
82
162
  if ((key === 'delete' || key === 'backspace') && !ctrl && !shift && selectedEntityId) {
83
163
  e.preventDefault();
84
- hideEntity(selectedEntityId);
164
+ const ids = getAllSelectedGlobalIds();
165
+ hideEntities(ids);
166
+ clearMultiSelect();
167
+ }
168
+ // Space to hide — skip when focused on buttons/selects/links where Space has native behavior
169
+ if (key === ' ' && !ctrl && !shift && selectedEntityId) {
170
+ const tag = document.activeElement?.tagName;
171
+ if (tag !== 'BUTTON' && tag !== 'SELECT' && tag !== 'A') {
172
+ e.preventDefault();
173
+ const ids = getAllSelectedGlobalIds();
174
+ hideEntities(ids);
175
+ clearMultiSelect();
176
+ }
85
177
  }
86
178
  if (key === 'a' && !ctrl && !shift) {
87
179
  e.preventDefault();
88
- showAll();
180
+ showAll(); // Clear hiddenEntities + isolatedEntities (basket preserved)
89
181
  clearStoreySelection(); // Also clear storey filtering
90
182
  }
91
183
 
@@ -121,6 +213,7 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
121
213
  if (key === 'escape') {
122
214
  e.preventDefault();
123
215
  setSelectedEntityId(null);
216
+ clearBasket();
124
217
  showAll();
125
218
  clearStoreySelection(); // Also clear storey filtering
126
219
  setActiveTool('select');
@@ -139,8 +232,12 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
139
232
  setSelectedEntityId,
140
233
  activeTool,
141
234
  setActiveTool,
142
- isolateEntity,
143
- hideEntity,
235
+ setBasket,
236
+ addToBasket,
237
+ removeFromBasket,
238
+ clearBasket,
239
+ showPinboard,
240
+ hideEntities,
144
241
  showAll,
145
242
  clearStoreySelection,
146
243
  toggleTheme,
@@ -171,14 +268,16 @@ export const KEYBOARD_SHORTCUTS = [
171
268
  { key: 'S', description: 'Toggle snapping (Measure tool)', category: 'Tools' },
172
269
  { key: 'Esc', description: 'Cancel measurement (Measure tool)', category: 'Tools' },
173
270
  { key: 'Ctrl+C', description: 'Clear measurements (Measure tool)', category: 'Tools' },
174
- { key: 'I', description: 'Isolate selection', category: 'Visibility' },
175
- { key: 'Del', description: 'Hide selection', category: 'Visibility' },
176
- { key: 'A', description: 'Show all (reset filters)', category: 'Visibility' },
271
+ { key: 'I', description: 'Set basket (isolate selection)', category: 'Visibility' },
272
+ { key: '+', description: 'Add selection to basket', category: 'Visibility' },
273
+ { key: '', description: 'Remove selection from basket', category: 'Visibility' },
274
+ { key: 'Del / Space', description: 'Hide selection', category: 'Visibility' },
275
+ { key: 'A', description: 'Show all (clear filters, keep basket)', category: 'Visibility' },
177
276
  { key: 'H', description: 'Home (Isometric view)', category: 'Camera' },
178
277
  { key: 'Z', description: 'Fit all (zoom extents)', category: 'Camera' },
179
278
  { key: 'F', description: 'Frame selection', category: 'Camera' },
180
- { key: '0-6', description: 'Preset views', category: 'Camera' },
279
+ { key: '1-6', description: 'Preset views', category: 'Camera' },
181
280
  { key: 'T', description: 'Toggle theme', category: 'UI' },
182
- { key: 'Esc', description: 'Reset all (clear selection, filters, isolation)', category: 'Selection' },
281
+ { key: 'Esc', description: 'Reset all (clear selection, basket, isolation)', category: 'Selection' },
183
282
  { key: '?', description: 'Show info panel', category: 'Help' },
184
283
  ] as const;