@ifc-lite/viewer 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/assets/{Arrow.dom-BjDQoB2M.js → Arrow.dom-BGPQieQQ.js} +1 -1
  3. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  4. package/dist/assets/{index-YBtrHPu3.js → index-dgdgiQ9p.js} +40212 -30008
  5. package/dist/assets/index-yTqs8kgX.css +1 -0
  6. package/dist/assets/{native-bridge-CULtTDX3.js → native-bridge-DD0SNyQ5.js} +1 -1
  7. package/dist/assets/{wasm-bridge-CjL-lSak.js → wasm-bridge-D54YMO7X.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 +1048 -0
  12. package/src/components/viewer/DrawingSettingsPanel.tsx +3 -3
  13. package/src/components/viewer/HierarchyPanel.tsx +110 -842
  14. package/src/components/viewer/IDSExportDialog.tsx +281 -0
  15. package/src/components/viewer/IDSPanel.tsx +126 -17
  16. package/src/components/viewer/KeyboardShortcutsDialog.tsx +9 -0
  17. package/src/components/viewer/LensPanel.tsx +603 -0
  18. package/src/components/viewer/MainToolbar.tsx +188 -21
  19. package/src/components/viewer/PropertiesPanel.tsx +171 -663
  20. package/src/components/viewer/PropertyEditor.tsx +866 -77
  21. package/src/components/viewer/Section2DPanel.tsx +76 -2648
  22. package/src/components/viewer/ToolOverlays.tsx +3 -1097
  23. package/src/components/viewer/ViewerLayout.tsx +132 -45
  24. package/src/components/viewer/Viewport.tsx +237 -1659
  25. package/src/components/viewer/ViewportContainer.tsx +11 -3
  26. package/src/components/viewer/bcf/BCFCreateTopicForm.tsx +134 -0
  27. package/src/components/viewer/bcf/BCFTopicDetail.tsx +388 -0
  28. package/src/components/viewer/bcf/BCFTopicList.tsx +239 -0
  29. package/src/components/viewer/bcf/bcfHelpers.tsx +109 -0
  30. package/src/components/viewer/hierarchy/HierarchyNode.tsx +328 -0
  31. package/src/components/viewer/hierarchy/treeDataBuilder.ts +464 -0
  32. package/src/components/viewer/hierarchy/types.ts +54 -0
  33. package/src/components/viewer/hierarchy/useHierarchyTree.ts +280 -0
  34. package/src/components/viewer/lists/ListBuilder.tsx +486 -0
  35. package/src/components/viewer/lists/ListPanel.tsx +540 -0
  36. package/src/components/viewer/lists/ListResultsTable.tsx +193 -0
  37. package/src/components/viewer/properties/ClassificationCard.tsx +70 -0
  38. package/src/components/viewer/properties/CoordinateDisplay.tsx +49 -0
  39. package/src/components/viewer/properties/DocumentCard.tsx +89 -0
  40. package/src/components/viewer/properties/MaterialCard.tsx +201 -0
  41. package/src/components/viewer/properties/ModelMetadataPanel.tsx +335 -0
  42. package/src/components/viewer/properties/PropertySetCard.tsx +132 -0
  43. package/src/components/viewer/properties/QuantitySetCard.tsx +79 -0
  44. package/src/components/viewer/properties/RelationshipsCard.tsx +100 -0
  45. package/src/components/viewer/properties/encodingUtils.ts +29 -0
  46. package/src/components/viewer/tools/MeasurePanel.tsx +218 -0
  47. package/src/components/viewer/tools/MeasurementVisuals.tsx +644 -0
  48. package/src/components/viewer/tools/SectionPanel.tsx +183 -0
  49. package/src/components/viewer/tools/SectionVisualization.tsx +78 -0
  50. package/src/components/viewer/tools/formatDistance.ts +18 -0
  51. package/src/components/viewer/tools/sectionConstants.ts +14 -0
  52. package/src/components/viewer/useAnimationLoop.ts +166 -0
  53. package/src/components/viewer/useGeometryStreaming.ts +398 -0
  54. package/src/components/viewer/useKeyboardControls.ts +221 -0
  55. package/src/components/viewer/useMouseControls.ts +1009 -0
  56. package/src/components/viewer/useRenderUpdates.ts +165 -0
  57. package/src/components/viewer/useTouchControls.ts +245 -0
  58. package/src/hooks/ids/idsColorSystem.ts +125 -0
  59. package/src/hooks/ids/idsDataAccessor.ts +237 -0
  60. package/src/hooks/ids/idsExportService.ts +444 -0
  61. package/src/hooks/useBCF.ts +7 -0
  62. package/src/hooks/useDrawingExport.ts +627 -0
  63. package/src/hooks/useDrawingGeneration.ts +627 -0
  64. package/src/hooks/useFloorplanView.ts +108 -0
  65. package/src/hooks/useIDS.ts +270 -463
  66. package/src/hooks/useIfc.ts +26 -1628
  67. package/src/hooks/useIfcFederation.ts +803 -0
  68. package/src/hooks/useIfcLoader.ts +508 -0
  69. package/src/hooks/useIfcServer.ts +465 -0
  70. package/src/hooks/useKeyboardShortcuts.ts +1 -1
  71. package/src/hooks/useLens.ts +129 -0
  72. package/src/hooks/useMeasure2D.ts +365 -0
  73. package/src/hooks/useViewControls.ts +218 -0
  74. package/src/lib/ifc4-pset-definitions.test.ts +161 -0
  75. package/src/lib/ifc4-pset-definitions.ts +621 -0
  76. package/src/lib/ifc4-qto-definitions.ts +315 -0
  77. package/src/lib/lens/adapter.ts +138 -0
  78. package/src/lib/lens/index.ts +5 -0
  79. package/src/lib/lists/adapter.ts +69 -0
  80. package/src/lib/lists/index.ts +28 -0
  81. package/src/lib/lists/persistence.ts +64 -0
  82. package/src/services/fs-cache.ts +1 -1
  83. package/src/services/tauri-modules.d.ts +25 -0
  84. package/src/store/index.ts +38 -2
  85. package/src/store/slices/cameraSlice.ts +14 -1
  86. package/src/store/slices/dataSlice.ts +14 -1
  87. package/src/store/slices/lensSlice.ts +184 -0
  88. package/src/store/slices/listSlice.ts +74 -0
  89. package/src/store/slices/pinboardSlice.ts +114 -0
  90. package/src/store/types.ts +5 -0
  91. package/src/utils/ifcConfig.ts +16 -3
  92. package/src/utils/serverDataModel.ts +64 -101
  93. package/src/vite-env.d.ts +3 -0
  94. package/dist/assets/ifc-lite_bg-C6kblxf9.wasm +0 -0
  95. 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;
@@ -177,7 +177,7 @@ export const KEYBOARD_SHORTCUTS = [
177
177
  { key: 'H', description: 'Home (Isometric view)', category: 'Camera' },
178
178
  { key: 'Z', description: 'Fit all (zoom extents)', category: 'Camera' },
179
179
  { key: 'F', description: 'Frame selection', category: 'Camera' },
180
- { key: '0-6', description: 'Preset views', category: 'Camera' },
180
+ { key: '1-6', description: 'Preset views', category: 'Camera' },
181
181
  { key: 'T', description: 'Toggle theme', category: 'UI' },
182
182
  { key: 'Esc', description: 'Reset all (clear selection, filters, isolation)', category: 'Selection' },
183
183
  { key: '?', description: 'Show info panel', category: 'Help' },
@@ -0,0 +1,129 @@
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
+ * Lens evaluation hook
7
+ *
8
+ * Evaluates active lens rules against all entities across all models,
9
+ * producing a color map and hidden IDs set that are applied to the renderer.
10
+ * Unmatched entities with geometry are ghosted (semi-transparent).
11
+ *
12
+ * The pure evaluation logic lives in @ifc-lite/lens — this hook handles
13
+ * React lifecycle, original-color capture/restore, and Zustand integration.
14
+ *
15
+ * Performance notes:
16
+ * - Does NOT subscribe to `models` or `ifcDataStore` — reads them from
17
+ * getState() only when the active lens changes. This prevents re-evaluation
18
+ * during model loading.
19
+ * - Uses `setPendingColorUpdates` instead of `updateMeshColors` to avoid
20
+ * cloning the entire mesh array (O(n) mesh copies) on every lens switch.
21
+ * - Original mesh colors are captured once and restored on deactivation.
22
+ */
23
+
24
+ import { useEffect, useRef, useCallback } from 'react';
25
+ import { evaluateLens, rgbaToHex, isGhostColor } from '@ifc-lite/lens';
26
+ import type { RGBAColor } from '@ifc-lite/lens';
27
+ import { useViewerStore } from '@/store';
28
+ import { createLensDataProvider } from '@/lib/lens';
29
+
30
+ export function useLens() {
31
+ const activeLensId = useViewerStore((s) => s.activeLensId);
32
+ const savedLenses = useViewerStore((s) => s.savedLenses);
33
+
34
+ // Track the previously active lens to detect deactivation
35
+ const prevLensIdRef = useRef<string | null>(null);
36
+ // Track original colors to restore when lens is deactivated
37
+ const originalColorsRef = useRef<Map<number, RGBAColor> | null>(null);
38
+
39
+ /** Collect original mesh colors from all geometry sources (federation + legacy) */
40
+ const captureOriginalColors = useCallback(() => {
41
+ const state = useViewerStore.getState();
42
+ const originals = new Map<number, RGBAColor>();
43
+
44
+ // Federation mode: collect from all model geometries
45
+ if (state.models.size > 0) {
46
+ for (const [, model] of state.models) {
47
+ if (model.geometryResult?.meshes) {
48
+ for (const mesh of model.geometryResult.meshes) {
49
+ if (mesh.color) {
50
+ originals.set(mesh.expressId, mesh.color as RGBAColor);
51
+ }
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ // Legacy mode: collect from store geometryResult
58
+ if (state.geometryResult?.meshes) {
59
+ for (const mesh of state.geometryResult.meshes) {
60
+ if (mesh.color) {
61
+ originals.set(mesh.expressId, mesh.color as RGBAColor);
62
+ }
63
+ }
64
+ }
65
+
66
+ return originals;
67
+ }, []);
68
+
69
+ useEffect(() => {
70
+ const activeLens = savedLenses.find(l => l.id === activeLensId) ?? null;
71
+
72
+ // Lens deactivated — restore original colors
73
+ if (!activeLens && prevLensIdRef.current !== null) {
74
+ prevLensIdRef.current = null;
75
+ useViewerStore.getState().setLensColorMap(new Map());
76
+ useViewerStore.getState().setLensHiddenIds(new Set());
77
+ useViewerStore.getState().setLensRuleCounts(new Map());
78
+ useViewerStore.getState().setLensRuleEntityIds(new Map());
79
+
80
+ // Restore original mesh colors via lightweight pending path
81
+ if (originalColorsRef.current && originalColorsRef.current.size > 0) {
82
+ useViewerStore.getState().setPendingColorUpdates(originalColorsRef.current);
83
+ }
84
+ originalColorsRef.current = null;
85
+ return;
86
+ }
87
+
88
+ if (!activeLens) return;
89
+
90
+ // Read data sources from getState() — NOT subscribed, so model loading
91
+ // doesn't trigger re-evaluation
92
+ const { models, ifcDataStore } = useViewerStore.getState();
93
+ if (models.size === 0 && !ifcDataStore) return;
94
+
95
+ // Save original colors before first lens application
96
+ if (prevLensIdRef.current === null) {
97
+ originalColorsRef.current = captureOriginalColors();
98
+ }
99
+
100
+ prevLensIdRef.current = activeLensId;
101
+
102
+ // Create data provider and evaluate lens using @ifc-lite/lens package
103
+ const provider = createLensDataProvider(models, ifcDataStore);
104
+ const { colorMap, hiddenIds, ruleCounts, ruleEntityIds } = evaluateLens(activeLens, provider);
105
+
106
+ // Build hex color map for UI legend (exclude ghost entries)
107
+ const hexColorMap = new Map<number, string>();
108
+ for (const [id, rgba] of colorMap) {
109
+ if (!isGhostColor(rgba)) {
110
+ hexColorMap.set(id, rgbaToHex(rgba));
111
+ }
112
+ }
113
+ useViewerStore.getState().setLensColorMap(hexColorMap);
114
+ useViewerStore.getState().setLensHiddenIds(hiddenIds);
115
+ useViewerStore.getState().setLensRuleCounts(ruleCounts);
116
+ useViewerStore.getState().setLensRuleEntityIds(ruleEntityIds);
117
+
118
+ // Apply ALL colors to renderer via pendingColorUpdates only —
119
+ // no mesh cloning needed, the renderer picks these up directly
120
+ if (colorMap.size > 0) {
121
+ useViewerStore.getState().setPendingColorUpdates(colorMap);
122
+ }
123
+ }, [activeLensId, savedLenses, captureOriginalColors]);
124
+
125
+ return {
126
+ activeLensId,
127
+ savedLenses,
128
+ };
129
+ }