@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,398 @@
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
+ * Geometry streaming hook for the 3D viewport
7
+ * Handles mesh batching, incremental loading, dedup, camera fitting
8
+ */
9
+
10
+ import { useEffect, useRef, type MutableRefObject } from 'react';
11
+ import { Renderer, MathUtils, type Scene, type RenderPipeline } from '@ifc-lite/renderer';
12
+ import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
13
+
14
+ export interface UseGeometryStreamingParams {
15
+ rendererRef: MutableRefObject<Renderer | null>;
16
+ isInitialized: boolean;
17
+ geometry: MeshData[] | null;
18
+ coordinateInfo?: CoordinateInfo;
19
+ isStreaming: boolean;
20
+ geometryBoundsRef: MutableRefObject<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }>;
21
+ pendingColorUpdates: Map<number, [number, number, number, number]> | null;
22
+ clearPendingColorUpdates: () => void;
23
+ }
24
+
25
+ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
26
+ const {
27
+ rendererRef,
28
+ isInitialized,
29
+ geometry,
30
+ coordinateInfo,
31
+ isStreaming,
32
+ geometryBoundsRef,
33
+ pendingColorUpdates,
34
+ clearPendingColorUpdates,
35
+ } = params;
36
+
37
+ // Track processed meshes for incremental updates
38
+ // Uses string keys to support compound keys (expressId:color) for submeshes
39
+ const processedMeshIdsRef = useRef<Set<string>>(new Set());
40
+ const lastGeometryLengthRef = useRef<number>(0);
41
+ const lastGeometryRef = useRef<MeshData[] | null>(null);
42
+ const cameraFittedRef = useRef<boolean>(false);
43
+ const finalBoundsRefittedRef = useRef<boolean>(false); // Track if we've refitted after streaming
44
+
45
+ // Render throttling during streaming
46
+ const lastStreamRenderTimeRef = useRef<number>(0);
47
+ const STREAM_RENDER_THROTTLE_MS = 200; // Render at most every 200ms during streaming
48
+
49
+ useEffect(() => {
50
+ const renderer = rendererRef.current;
51
+
52
+ // Handle geometry cleared/null - reset refs so next load is treated as new file
53
+ if (!geometry) {
54
+ if (lastGeometryLengthRef.current > 0 || lastGeometryRef.current !== null) {
55
+ // Geometry was cleared - reset tracking refs
56
+ lastGeometryLengthRef.current = 0;
57
+ lastGeometryRef.current = null;
58
+ processedMeshIdsRef.current.clear();
59
+ cameraFittedRef.current = false;
60
+ finalBoundsRefittedRef.current = false;
61
+ // Clear scene if renderer is ready
62
+ if (renderer && isInitialized) {
63
+ renderer.getScene().clear();
64
+ renderer.getCamera().reset();
65
+ geometryBoundsRef.current = {
66
+ min: { x: -100, y: -100, z: -100 },
67
+ max: { x: 100, y: 100, z: 100 },
68
+ };
69
+ }
70
+ }
71
+ return;
72
+ }
73
+
74
+ if (!renderer || !isInitialized) return;
75
+
76
+ const device = renderer.getGPUDevice();
77
+ if (!device) return;
78
+
79
+ const scene = renderer.getScene();
80
+ const currentLength = geometry.length;
81
+ const lastLength = lastGeometryLengthRef.current;
82
+
83
+ // Use length-based detection instead of reference comparison
84
+ // React creates new array references on every appendGeometryBatch call,
85
+ // so reference comparison would always trigger scene.clear()
86
+ const isIncremental = currentLength > lastLength && lastLength > 0;
87
+ const isNewFile = currentLength > 0 && lastLength === 0;
88
+ const isCleared = currentLength === 0;
89
+
90
+ if (isCleared) {
91
+ // Geometry cleared (could be visibility change or file unload)
92
+ // Clear scene but DON'T reset camera - user may just be hiding models
93
+ scene.clear();
94
+ processedMeshIdsRef.current.clear();
95
+ // Keep cameraFittedRef to preserve camera position when models are shown again
96
+ lastGeometryLengthRef.current = 0;
97
+ lastGeometryRef.current = null;
98
+ // Note: Don't reset camera or bounds - preserve user's view
99
+ return;
100
+ } else if (isNewFile) {
101
+ // New file loaded - reset camera and bounds
102
+ scene.clear();
103
+ processedMeshIdsRef.current.clear();
104
+ cameraFittedRef.current = false;
105
+ finalBoundsRefittedRef.current = false;
106
+ lastGeometryLengthRef.current = 0;
107
+ lastGeometryRef.current = geometry;
108
+ // Reset camera state (clear orbit pivot, stop inertia, cancel animations)
109
+ renderer.getCamera().reset();
110
+ // Reset geometry bounds to default
111
+ geometryBoundsRef.current = {
112
+ min: { x: -100, y: -100, z: -100 },
113
+ max: { x: 100, y: 100, z: 100 },
114
+ };
115
+ } else if (!isIncremental && currentLength !== lastLength) {
116
+ // Length changed but not incremental - could be:
117
+ // 1. Length decreased (model hidden) - DON'T reset camera
118
+ // 2. Length increased but lastLength > 0 (new file loaded while another was open) - DO reset
119
+ const isLengthDecrease = currentLength < lastLength;
120
+
121
+ if (isLengthDecrease) {
122
+ // Model visibility changed (hidden) - rebuild scene but keep camera
123
+ scene.clear();
124
+ processedMeshIdsRef.current.clear();
125
+ // Don't reset cameraFittedRef - keep current camera position
126
+ lastGeometryLengthRef.current = 0; // Reset so meshes get re-added
127
+ lastGeometryRef.current = geometry;
128
+ // Note: Don't reset camera or bounds - user wants to keep their view
129
+ } else {
130
+ // New file loaded while another was open - full reset
131
+ scene.clear();
132
+ processedMeshIdsRef.current.clear();
133
+ cameraFittedRef.current = false;
134
+ finalBoundsRefittedRef.current = false;
135
+ lastGeometryLengthRef.current = 0;
136
+ lastGeometryRef.current = geometry;
137
+ // Reset camera state
138
+ renderer.getCamera().reset();
139
+ // Reset geometry bounds to default
140
+ geometryBoundsRef.current = {
141
+ min: { x: -100, y: -100, z: -100 },
142
+ max: { x: 100, y: 100, z: 100 },
143
+ };
144
+ }
145
+ } else if (currentLength === lastLength) {
146
+ // No geometry change - but check if we need to update bounds when streaming completes
147
+ if (cameraFittedRef.current && !isStreaming && !finalBoundsRefittedRef.current && coordinateInfo?.shiftedBounds) {
148
+ const shiftedBounds = coordinateInfo.shiftedBounds;
149
+ const newMaxSize = Math.max(
150
+ shiftedBounds.max.x - shiftedBounds.min.x,
151
+ shiftedBounds.max.y - shiftedBounds.min.y,
152
+ shiftedBounds.max.z - shiftedBounds.min.z
153
+ );
154
+
155
+ if (newMaxSize > 0 && Number.isFinite(newMaxSize)) {
156
+ // Only refit camera for LARGE models (>1000 meshes) where geometry streamed in multiple batches
157
+ // Small models complete in one batch, so their initial camera fit is already correct
158
+ const isLargeModel = geometry.length > 1000;
159
+
160
+ if (isLargeModel) {
161
+ const oldBounds = geometryBoundsRef.current;
162
+ const oldMaxSize = Math.max(
163
+ oldBounds.max.x - oldBounds.min.x,
164
+ oldBounds.max.y - oldBounds.min.y,
165
+ oldBounds.max.z - oldBounds.min.z
166
+ );
167
+
168
+ // Refit camera if bounds expanded significantly (>10% larger)
169
+ // This handles skyscrapers where upper floors arrive in later batches
170
+ const boundsExpanded = newMaxSize > oldMaxSize * 1.1;
171
+
172
+ if (boundsExpanded) {
173
+ renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
174
+ }
175
+ }
176
+
177
+ // Always update bounds for accurate zoom-to-fit, home view, etc.
178
+ geometryBoundsRef.current = { min: { ...shiftedBounds.min }, max: { ...shiftedBounds.max } };
179
+ finalBoundsRefittedRef.current = true;
180
+ }
181
+ }
182
+ return;
183
+ }
184
+
185
+ // For incremental batches: update reference and continue to add new meshes
186
+ if (isIncremental) {
187
+ lastGeometryRef.current = geometry;
188
+ } else if (lastGeometryRef.current === null) {
189
+ lastGeometryRef.current = geometry;
190
+ }
191
+
192
+ // FIX: When not streaming (type visibility toggle), new meshes can be ANYWHERE in the array,
193
+ // not just at the end. During streaming, new meshes ARE appended, so slice is safe.
194
+ // After streaming completes, filter changes can insert meshes at any position.
195
+ const meshesToAdd = isStreaming
196
+ ? geometry.slice(lastGeometryLengthRef.current) // Streaming: new meshes at end
197
+ : geometry; // Post-streaming: scan entire array for unprocessed meshes
198
+
199
+ // Filter out already processed meshes
200
+ // NOTE: Multiple meshes can share the same expressId AND same color (e.g., door inner framing pieces),
201
+ // so we use expressId + array index as a compound key to ensure all submeshes are processed.
202
+ const newMeshes: MeshData[] = [];
203
+ const startIndex = isStreaming ? lastGeometryLengthRef.current : 0;
204
+ for (let i = 0; i < meshesToAdd.length; i++) {
205
+ const meshData = meshesToAdd[i];
206
+ // Use expressId + global array index as key to ensure each mesh is unique
207
+ // (same expressId can have multiple submeshes with same color, e.g., door framing)
208
+ const globalIndex = startIndex + i;
209
+ const compoundKey = `${meshData.expressId}:${globalIndex}`;
210
+
211
+ if (!processedMeshIdsRef.current.has(compoundKey)) {
212
+ newMeshes.push(meshData);
213
+ processedMeshIdsRef.current.add(compoundKey);
214
+ }
215
+ }
216
+
217
+ if (newMeshes.length > 0) {
218
+ // Batch meshes by color for efficient rendering (reduces draw calls from N to ~100-500)
219
+ // This dramatically improves performance for large models (50K+ meshes)
220
+ const pipeline = renderer.getPipeline();
221
+ if (pipeline) {
222
+ // Use batched rendering - groups meshes by color into single draw calls
223
+ // Pass isStreaming flag to enable throttled batch rebuilding (reduces O(N^2) cost)
224
+ scene.appendToBatches(newMeshes, device, pipeline, isStreaming);
225
+
226
+ // Note: addMeshData is now called inside appendToBatches, no need to duplicate
227
+ } else {
228
+ // Fallback: add individual meshes if pipeline not ready
229
+ for (const meshData of newMeshes) {
230
+ const vertexCount = meshData.positions.length / 3;
231
+ const interleaved = new Float32Array(vertexCount * 6);
232
+ for (let i = 0; i < vertexCount; i++) {
233
+ const base = i * 6;
234
+ const posBase = i * 3;
235
+ interleaved[base] = meshData.positions[posBase];
236
+ interleaved[base + 1] = meshData.positions[posBase + 1];
237
+ interleaved[base + 2] = meshData.positions[posBase + 2];
238
+ interleaved[base + 3] = meshData.normals[posBase];
239
+ interleaved[base + 4] = meshData.normals[posBase + 1];
240
+ interleaved[base + 5] = meshData.normals[posBase + 2];
241
+ }
242
+
243
+ const vertexBuffer = device.createBuffer({
244
+ size: interleaved.byteLength,
245
+ usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
246
+ });
247
+ device.queue.writeBuffer(vertexBuffer, 0, interleaved);
248
+
249
+ const indexBuffer = device.createBuffer({
250
+ size: meshData.indices.byteLength,
251
+ usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
252
+ });
253
+ device.queue.writeBuffer(indexBuffer, 0, meshData.indices);
254
+
255
+ scene.addMesh({
256
+ expressId: meshData.expressId,
257
+ vertexBuffer,
258
+ indexBuffer,
259
+ indexCount: meshData.indices.length,
260
+ transform: MathUtils.identity(),
261
+ color: meshData.color,
262
+ });
263
+ }
264
+ }
265
+
266
+ // Invalidate caches when new geometry is added
267
+ renderer.clearCaches();
268
+ }
269
+
270
+ lastGeometryLengthRef.current = currentLength;
271
+
272
+ // Fit camera and store bounds
273
+ // IMPORTANT: Fit camera immediately when we have valid bounds to avoid starting inside model
274
+ // The default camera position (50, 50, 100) is inside most models that are shifted to origin
275
+ if (!cameraFittedRef.current && coordinateInfo?.shiftedBounds) {
276
+ const shiftedBounds = coordinateInfo.shiftedBounds;
277
+ const maxSize = Math.max(
278
+ shiftedBounds.max.x - shiftedBounds.min.x,
279
+ shiftedBounds.max.y - shiftedBounds.min.y,
280
+ shiftedBounds.max.z - shiftedBounds.min.z
281
+ );
282
+ // Fit camera immediately when we have valid bounds
283
+ // For streaming: the first batch already has complete bounds from coordinate handler
284
+ // (bounds are calculated from ALL geometry before streaming starts)
285
+ // Waiting for streaming to complete causes the camera to start inside the model
286
+ if (maxSize > 0 && Number.isFinite(maxSize)) {
287
+ renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
288
+ geometryBoundsRef.current = { min: { ...shiftedBounds.min }, max: { ...shiftedBounds.max } };
289
+ cameraFittedRef.current = true;
290
+ }
291
+ } else if (!cameraFittedRef.current && geometry.length > 0 && !isStreaming) {
292
+ // Fallback: calculate bounds from geometry array (only when streaming is complete)
293
+ // This ensures we have complete bounds before fitting camera
294
+ const fallbackBounds = {
295
+ min: { x: Infinity, y: Infinity, z: Infinity },
296
+ max: { x: -Infinity, y: -Infinity, z: -Infinity },
297
+ };
298
+
299
+ // Max coordinate threshold - matches CoordinateHandler's NORMAL_COORD_THRESHOLD
300
+ // Coordinates beyond this are likely corrupted or unshifted original coordinates
301
+ const MAX_VALID_COORD = 10000;
302
+
303
+ for (const meshData of geometry) {
304
+ for (let i = 0; i < meshData.positions.length; i += 3) {
305
+ const x = meshData.positions[i];
306
+ const y = meshData.positions[i + 1];
307
+ const z = meshData.positions[i + 2];
308
+ // Filter out corrupted/unshifted vertices (> 10km from origin)
309
+ const isValid = Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(z) &&
310
+ Math.abs(x) < MAX_VALID_COORD && Math.abs(y) < MAX_VALID_COORD && Math.abs(z) < MAX_VALID_COORD;
311
+ if (isValid) {
312
+ fallbackBounds.min.x = Math.min(fallbackBounds.min.x, x);
313
+ fallbackBounds.min.y = Math.min(fallbackBounds.min.y, y);
314
+ fallbackBounds.min.z = Math.min(fallbackBounds.min.z, z);
315
+ fallbackBounds.max.x = Math.max(fallbackBounds.max.x, x);
316
+ fallbackBounds.max.y = Math.max(fallbackBounds.max.y, y);
317
+ fallbackBounds.max.z = Math.max(fallbackBounds.max.z, z);
318
+ }
319
+ }
320
+ }
321
+
322
+ const maxSize = Math.max(
323
+ fallbackBounds.max.x - fallbackBounds.min.x,
324
+ fallbackBounds.max.y - fallbackBounds.min.y,
325
+ fallbackBounds.max.z - fallbackBounds.min.z
326
+ );
327
+
328
+ if (fallbackBounds.min.x !== Infinity && maxSize > 0 && Number.isFinite(maxSize)) {
329
+ renderer.getCamera().fitToBounds(fallbackBounds.min, fallbackBounds.max);
330
+ geometryBoundsRef.current = fallbackBounds;
331
+ cameraFittedRef.current = true;
332
+ }
333
+ }
334
+
335
+ // Note: Background instancing conversion removed
336
+ // Regular MeshData meshes are rendered directly with their correct positions
337
+ // Instancing conversion would require preserving actual mesh transforms, which is complex
338
+ // For now, we render regular meshes directly (fast enough for most cases)
339
+
340
+ // Render throttling: During streaming, only render every STREAM_RENDER_THROTTLE_MS
341
+ // This prevents rendering 28K+ meshes from blocking WASM batch processing
342
+ const now = Date.now();
343
+ const timeSinceLastRender = now - lastStreamRenderTimeRef.current;
344
+ const shouldRender = !isStreaming || timeSinceLastRender >= STREAM_RENDER_THROTTLE_MS;
345
+
346
+ if (shouldRender) {
347
+ renderer.render();
348
+ lastStreamRenderTimeRef.current = now;
349
+ }
350
+ }, [geometry, coordinateInfo, isInitialized, isStreaming]);
351
+
352
+ // Force render when streaming completes (progress goes from <100% to 100% or null)
353
+ const prevIsStreamingRef = useRef(isStreaming);
354
+ useEffect(() => {
355
+ const renderer = rendererRef.current;
356
+ if (!renderer || !isInitialized) return;
357
+
358
+ // If streaming just completed (was streaming, now not), rebuild pending batches and render
359
+ if (prevIsStreamingRef.current && !isStreaming) {
360
+ const device = renderer.getGPUDevice();
361
+ const pipeline = renderer.getPipeline();
362
+ const scene = renderer.getScene();
363
+
364
+ // Rebuild any pending batches that were deferred during streaming
365
+ if (device && pipeline && scene.hasPendingBatches()) {
366
+ scene.rebuildPendingBatches(device, pipeline);
367
+ }
368
+
369
+ renderer.render();
370
+ lastStreamRenderTimeRef.current = Date.now();
371
+ }
372
+ prevIsStreamingRef.current = isStreaming;
373
+ }, [isStreaming, isInitialized]);
374
+
375
+ // Apply pending color updates to WebGPU scene
376
+ // Note: Color updates may arrive before viewport is initialized, so we wait
377
+ useEffect(() => {
378
+ if (!pendingColorUpdates || pendingColorUpdates.size === 0) return;
379
+
380
+ // Wait until viewport is initialized before applying color updates
381
+ if (!isInitialized) return;
382
+
383
+ const renderer = rendererRef.current;
384
+ if (!renderer) return;
385
+
386
+ const device = renderer.getGPUDevice();
387
+ const pipeline = renderer.getPipeline();
388
+ const scene = renderer.getScene();
389
+
390
+ if (device && pipeline) {
391
+ scene.updateMeshColors(pendingColorUpdates, device, pipeline);
392
+ renderer.render();
393
+ clearPendingColorUpdates();
394
+ }
395
+ }, [pendingColorUpdates, isInitialized, clearPendingColorUpdates]);
396
+ }
397
+
398
+ export default useGeometryStreaming;
@@ -0,0 +1,221 @@
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
+ * Keyboard controls hook for the 3D viewport
7
+ * Handles keyboard shortcuts, first-person mode, continuous movement
8
+ */
9
+
10
+ import { useEffect, type MutableRefObject } from 'react';
11
+ import type { Renderer } from '@ifc-lite/renderer';
12
+ import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
13
+ import type { SectionPlane } from '@/store';
14
+ import { getEntityBounds } from '../../utils/viewportUtils.js';
15
+
16
+ export interface UseKeyboardControlsParams {
17
+ rendererRef: MutableRefObject<Renderer | null>;
18
+ isInitialized: boolean;
19
+ keyboardHandlersRef: MutableRefObject<{
20
+ handleKeyDown: ((e: KeyboardEvent) => void) | null;
21
+ handleKeyUp: ((e: KeyboardEvent) => void) | null;
22
+ }>;
23
+ firstPersonModeRef: MutableRefObject<boolean>;
24
+ geometryBoundsRef: MutableRefObject<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }>;
25
+ coordinateInfoRef: MutableRefObject<CoordinateInfo | undefined>;
26
+ geometryRef: MutableRefObject<MeshData[] | null>;
27
+ selectedEntityIdRef: MutableRefObject<number | null>;
28
+ hiddenEntitiesRef: MutableRefObject<Set<number>>;
29
+ isolatedEntitiesRef: MutableRefObject<Set<number> | null>;
30
+ selectedModelIndexRef: MutableRefObject<number | undefined>;
31
+ clearColorRef: MutableRefObject<[number, number, number, number]>;
32
+ activeToolRef: MutableRefObject<string>;
33
+ sectionPlaneRef: MutableRefObject<SectionPlane>;
34
+ sectionRangeRef: MutableRefObject<{ min: number; max: number } | null>;
35
+ updateCameraRotationRealtime: (rotation: { azimuth: number; elevation: number }) => void;
36
+ calculateScale: () => void;
37
+ }
38
+
39
+ export function useKeyboardControls(params: UseKeyboardControlsParams): void {
40
+ const {
41
+ rendererRef,
42
+ isInitialized,
43
+ keyboardHandlersRef,
44
+ firstPersonModeRef,
45
+ geometryBoundsRef,
46
+ coordinateInfoRef,
47
+ geometryRef,
48
+ selectedEntityIdRef,
49
+ hiddenEntitiesRef,
50
+ isolatedEntitiesRef,
51
+ selectedModelIndexRef,
52
+ clearColorRef,
53
+ activeToolRef,
54
+ sectionPlaneRef,
55
+ sectionRangeRef,
56
+ updateCameraRotationRealtime,
57
+ calculateScale,
58
+ } = params;
59
+
60
+ useEffect(() => {
61
+ const renderer = rendererRef.current;
62
+ if (!renderer || !isInitialized) return;
63
+
64
+ const camera = renderer.getCamera();
65
+ let aborted = false;
66
+
67
+ const keyState: { [key: string]: boolean } = {};
68
+ let moveLoopRunning = false;
69
+ let moveFrameId: number | null = null;
70
+
71
+ const handleKeyDown = (e: KeyboardEvent) => {
72
+ const target = e.target as HTMLElement;
73
+ if (
74
+ target.tagName === 'INPUT' ||
75
+ target.tagName === 'TEXTAREA' ||
76
+ target.isContentEditable
77
+ ) {
78
+ return;
79
+ }
80
+
81
+ keyState[e.key.toLowerCase()] = true;
82
+
83
+ // Start movement loop when a movement key is pressed
84
+ const isMovementKey = ['arrowup', 'arrowdown', 'arrowleft', 'arrowright'].includes(e.key.toLowerCase());
85
+ if (isMovementKey && !moveLoopRunning) {
86
+ moveLoopRunning = true;
87
+ keyboardMove();
88
+ }
89
+
90
+ // Preset views - set view and re-render
91
+ const setViewAndRender = (view: 'top' | 'bottom' | 'front' | 'back' | 'left' | 'right') => {
92
+ const rotation = coordinateInfoRef.current?.buildingRotation;
93
+ camera.setPresetView(view, geometryBoundsRef.current, rotation);
94
+ renderer.render({
95
+ hiddenIds: hiddenEntitiesRef.current,
96
+ isolatedIds: isolatedEntitiesRef.current,
97
+ selectedId: selectedEntityIdRef.current,
98
+ selectedModelIndex: selectedModelIndexRef.current,
99
+ clearColor: clearColorRef.current,
100
+ sectionPlane: activeToolRef.current === 'section' ? {
101
+ ...sectionPlaneRef.current,
102
+ min: sectionRangeRef.current?.min,
103
+ max: sectionRangeRef.current?.max,
104
+ } : undefined,
105
+ });
106
+ updateCameraRotationRealtime(camera.getRotation());
107
+ calculateScale();
108
+ };
109
+
110
+ if (e.key === '1') setViewAndRender('top');
111
+ if (e.key === '2') setViewAndRender('bottom');
112
+ if (e.key === '3') setViewAndRender('front');
113
+ if (e.key === '4') setViewAndRender('back');
114
+ if (e.key === '5') setViewAndRender('left');
115
+ if (e.key === '6') setViewAndRender('right');
116
+
117
+ // Frame selection (F) - zoom to fit selection, or fit all if nothing selected
118
+ if (e.key === 'f' || e.key === 'F') {
119
+ const selectedId = selectedEntityIdRef.current;
120
+ if (selectedId !== null) {
121
+ // Frame selection - zoom to fit selected element
122
+ const bounds = getEntityBounds(geometryRef.current, selectedId);
123
+ if (bounds) {
124
+ camera.frameBounds(bounds.min, bounds.max, 300);
125
+ }
126
+ } else {
127
+ // No selection - fit all
128
+ camera.zoomExtent(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 300);
129
+ }
130
+ calculateScale();
131
+ }
132
+
133
+ // Home view (H) - reset to isometric
134
+ if (e.key === 'h' || e.key === 'H') {
135
+ camera.zoomToFit(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 500);
136
+ calculateScale();
137
+ }
138
+
139
+ // Fit all / Zoom extents (Z)
140
+ if (e.key === 'z' || e.key === 'Z') {
141
+ camera.zoomExtent(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 300);
142
+ calculateScale();
143
+ }
144
+
145
+ // Toggle first-person mode
146
+ if (e.key === 'c' || e.key === 'C') {
147
+ firstPersonModeRef.current = !firstPersonModeRef.current;
148
+ camera.enableFirstPersonMode(firstPersonModeRef.current);
149
+ }
150
+ };
151
+
152
+ const handleKeyUp = (e: KeyboardEvent) => {
153
+ keyState[e.key.toLowerCase()] = false;
154
+
155
+ // Stop movement loop when no movement keys are held
156
+ const anyMovementKey = keyState['arrowup'] || keyState['arrowdown'] || keyState['arrowleft'] || keyState['arrowright'];
157
+ if (!anyMovementKey && moveLoopRunning) {
158
+ moveLoopRunning = false;
159
+ if (moveFrameId !== null) {
160
+ cancelAnimationFrame(moveFrameId);
161
+ moveFrameId = null;
162
+ }
163
+ }
164
+ };
165
+
166
+ keyboardHandlersRef.current.handleKeyDown = handleKeyDown;
167
+ keyboardHandlersRef.current.handleKeyUp = handleKeyUp;
168
+
169
+ const keyboardMove = () => {
170
+ if (aborted || !moveLoopRunning) return;
171
+
172
+ let moved = false;
173
+ const panSpeed = 5;
174
+
175
+ if (firstPersonModeRef.current) {
176
+ // Arrow keys for first-person navigation (camera-relative)
177
+ if (keyState['arrowup']) { camera.moveFirstPerson(-1, 0, 0); moved = true; }
178
+ if (keyState['arrowdown']) { camera.moveFirstPerson(1, 0, 0); moved = true; }
179
+ if (keyState['arrowleft']) { camera.moveFirstPerson(0, 1, 0); moved = true; }
180
+ if (keyState['arrowright']) { camera.moveFirstPerson(0, -1, 0); moved = true; }
181
+ } else {
182
+ // Arrow keys for panning (camera-relative: arrow direction = camera movement)
183
+ if (keyState['arrowup']) { camera.pan(0, -panSpeed, false); moved = true; }
184
+ if (keyState['arrowdown']) { camera.pan(0, panSpeed, false); moved = true; }
185
+ if (keyState['arrowleft']) { camera.pan(panSpeed, 0, false); moved = true; }
186
+ if (keyState['arrowright']) { camera.pan(-panSpeed, 0, false); moved = true; }
187
+ }
188
+
189
+ if (moved) {
190
+ renderer.render({
191
+ hiddenIds: hiddenEntitiesRef.current,
192
+ isolatedIds: isolatedEntitiesRef.current,
193
+ selectedId: selectedEntityIdRef.current,
194
+ selectedModelIndex: selectedModelIndexRef.current,
195
+ clearColor: clearColorRef.current,
196
+ sectionPlane: activeToolRef.current === 'section' ? {
197
+ ...sectionPlaneRef.current,
198
+ min: sectionRangeRef.current?.min,
199
+ max: sectionRangeRef.current?.max,
200
+ } : undefined,
201
+ });
202
+ }
203
+ moveFrameId = requestAnimationFrame(keyboardMove);
204
+ };
205
+
206
+ window.addEventListener('keydown', handleKeyDown);
207
+ window.addEventListener('keyup', handleKeyUp);
208
+
209
+ return () => {
210
+ aborted = true;
211
+ moveLoopRunning = false;
212
+ if (moveFrameId !== null) {
213
+ cancelAnimationFrame(moveFrameId);
214
+ }
215
+ window.removeEventListener('keydown', handleKeyDown);
216
+ window.removeEventListener('keyup', handleKeyUp);
217
+ };
218
+ }, [isInitialized]);
219
+ }
220
+
221
+ export default useKeyboardControls;