@ifc-lite/viewer 1.14.0 → 1.14.1

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.
@@ -15,6 +15,9 @@ export interface UseGeometryStreamingParams {
15
15
  rendererRef: MutableRefObject<Renderer | null>;
16
16
  isInitialized: boolean;
17
17
  geometry: MeshData[] | null;
18
+ /** Monotonic counter — triggers the streaming effect even when the geometry
19
+ * array reference is stable (incremental filtering reuses the same array). */
20
+ geometryVersion?: number;
18
21
  coordinateInfo?: CoordinateInfo;
19
22
  isStreaming: boolean;
20
23
  geometryBoundsRef: MutableRefObject<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } }>;
@@ -31,6 +34,7 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
31
34
  rendererRef,
32
35
  isInitialized,
33
36
  geometry,
37
+ geometryVersion,
34
38
  coordinateInfo,
35
39
  isStreaming,
36
40
  geometryBoundsRef,
@@ -49,6 +53,11 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
49
53
  const cameraFittedRef = useRef<boolean>(false);
50
54
  const finalBoundsRefittedRef = useRef<boolean>(false); // Track if we've refitted after streaming
51
55
 
56
+ // Track camera state after initial fit to detect user interaction during streaming.
57
+ // If user orbits/pans/zooms during streaming, we preserve their position at completion
58
+ // instead of snapping back to the computed view. Bounds still update for "home" etc.
59
+ const cameraStateAfterFitRef = useRef<{ px: number; py: number; pz: number; tx: number; ty: number; tz: number } | null>(null);
60
+
52
61
  // Render throttling during streaming
53
62
  const lastStreamRenderTimeRef = useRef<number>(0);
54
63
  const STREAM_RENDER_THROTTLE_MS = 200; // Render at most every 200ms during streaming
@@ -65,6 +74,7 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
65
74
  processedMeshIdsRef.current.clear();
66
75
  cameraFittedRef.current = false;
67
76
  finalBoundsRefittedRef.current = false;
77
+ cameraStateAfterFitRef.current = null;
68
78
  // Clear scene if renderer is ready
69
79
  if (renderer && isInitialized) {
70
80
  renderer.getScene().clear();
@@ -110,6 +120,7 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
110
120
  processedMeshIdsRef.current.clear();
111
121
  cameraFittedRef.current = false;
112
122
  finalBoundsRefittedRef.current = false;
123
+ cameraStateAfterFitRef.current = null;
113
124
  lastGeometryLengthRef.current = 0;
114
125
  lastGeometryRef.current = geometry;
115
126
  // Reset camera state (clear orbit pivot, stop inertia, cancel animations)
@@ -139,6 +150,7 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
139
150
  processedMeshIdsRef.current.clear();
140
151
  cameraFittedRef.current = false;
141
152
  finalBoundsRefittedRef.current = false;
153
+ cameraStateAfterFitRef.current = null;
142
154
  lastGeometryLengthRef.current = 0;
143
155
  lastGeometryRef.current = geometry;
144
156
  // Reset camera state
@@ -150,45 +162,86 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
150
162
  };
151
163
  }
152
164
  } else if (currentLength === lastLength) {
153
- // No geometry change - but check if we need to update bounds when streaming completes
154
- if (cameraFittedRef.current && !isStreaming && !finalBoundsRefittedRef.current && coordinateInfo?.shiftedBounds) {
155
- const shiftedBounds = coordinateInfo.shiftedBounds;
156
- const newMaxSize = Math.max(
157
- shiftedBounds.max.x - shiftedBounds.min.x,
158
- shiftedBounds.max.y - shiftedBounds.min.y,
159
- shiftedBounds.max.z - shiftedBounds.min.z
165
+ // No geometry change update bounds when streaming completes.
166
+ // Two behaviours depending on user camera interaction:
167
+ // • User hasn't touched the camera → refit to final bounds (fixes models
168
+ // whose first-batch bounds were too tight / off-centre).
169
+ // • User HAS orbited/panned/zoomed → preserve their position; just store
170
+ // the final bounds so "Home" / zoom-to-fit still work correctly.
171
+ if (cameraFittedRef.current && !isStreaming && !finalBoundsRefittedRef.current) {
172
+ // Compute EXACT bounds from all geometry vertices.
173
+ // coordinateInfo.shiftedBounds uses fast vertex-sampling (first+last vertex per mesh)
174
+ // which can miss the true extremes — e.g., a 22-storey building whose highest vertices
175
+ // are mid-buffer gets truncated bounds. Scanning all vertices here is ~15 ms for 3 M
176
+ // vertices and only runs once at streaming completion.
177
+ const MAX_VALID_COORD = 10000;
178
+ const exactBounds = {
179
+ min: { x: Infinity, y: Infinity, z: Infinity },
180
+ max: { x: -Infinity, y: -Infinity, z: -Infinity },
181
+ };
182
+ for (let gi = 0; gi < geometry.length; gi++) {
183
+ const positions = geometry[gi].positions;
184
+ for (let i = 0; i < positions.length; i += 3) {
185
+ const x = positions[i];
186
+ const y = positions[i + 1];
187
+ const z = positions[i + 2];
188
+ if (Math.abs(x) < MAX_VALID_COORD && Math.abs(y) < MAX_VALID_COORD && Math.abs(z) < MAX_VALID_COORD) {
189
+ if (x < exactBounds.min.x) exactBounds.min.x = x;
190
+ if (y < exactBounds.min.y) exactBounds.min.y = y;
191
+ if (z < exactBounds.min.z) exactBounds.min.z = z;
192
+ if (x > exactBounds.max.x) exactBounds.max.x = x;
193
+ if (y > exactBounds.max.y) exactBounds.max.y = y;
194
+ if (z > exactBounds.max.z) exactBounds.max.z = z;
195
+ }
196
+ }
197
+ }
198
+
199
+ const exactMaxSize = Math.max(
200
+ exactBounds.max.x - exactBounds.min.x,
201
+ exactBounds.max.y - exactBounds.min.y,
202
+ exactBounds.max.z - exactBounds.min.z
160
203
  );
161
204
 
162
- if (newMaxSize > 0 && Number.isFinite(newMaxSize)) {
163
- // Only refit camera for LARGE models (>1000 meshes) where geometry streamed in multiple batches
164
- // Small models complete in one batch, so their initial camera fit is already correct
165
- const isLargeModel = geometry.length > 1000;
166
-
167
- if (isLargeModel) {
168
- const oldBounds = geometryBoundsRef.current;
169
- const oldMaxSize = Math.max(
170
- oldBounds.max.x - oldBounds.min.x,
171
- oldBounds.max.y - oldBounds.min.y,
172
- oldBounds.max.z - oldBounds.min.z
173
- );
174
-
175
- // Refit camera if bounds expanded significantly (>10% larger)
176
- // This handles skyscrapers where upper floors arrive in later batches
177
- const boundsExpanded = newMaxSize > oldMaxSize * 1.1;
178
-
179
- if (boundsExpanded) {
180
- renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
181
- }
205
+ if (exactBounds.min.x !== Infinity && exactMaxSize > 0 && Number.isFinite(exactMaxSize)) {
206
+ // Detect whether the user moved the camera during streaming
207
+ const snap = cameraStateAfterFitRef.current;
208
+ let userMovedCamera = false;
209
+ if (snap) {
210
+ const pos = renderer.getCamera().getPosition();
211
+ const tgt = renderer.getCamera().getTarget();
212
+ const EPS = 0.5; // half a metre — ignores floating-point jitter
213
+ userMovedCamera =
214
+ Math.abs(pos.x - snap.px) > EPS || Math.abs(pos.y - snap.py) > EPS || Math.abs(pos.z - snap.pz) > EPS ||
215
+ Math.abs(tgt.x - snap.tx) > EPS || Math.abs(tgt.y - snap.ty) > EPS || Math.abs(tgt.z - snap.tz) > EPS;
182
216
  }
183
217
 
184
- // Always update bounds for accurate zoom-to-fit, home view, etc.
185
- geometryBoundsRef.current = { min: { ...shiftedBounds.min }, max: { ...shiftedBounds.max } };
218
+ if (!userMovedCamera) {
219
+ // User hasn't interacted refit to exact full bounds
220
+ renderer.getCamera().fitToBounds(exactBounds.min, exactBounds.max);
221
+ }
222
+
223
+ // Always update stored bounds for Home / zoom-to-fit
224
+ geometryBoundsRef.current = { min: { ...exactBounds.min }, max: { ...exactBounds.max } };
186
225
  finalBoundsRefittedRef.current = true;
187
226
  }
188
227
  }
189
228
  return;
190
229
  }
191
230
 
231
+ // Detect post-streaming type visibility toggle: geometry grew while NOT streaming.
232
+ // The filtered array was rebuilt from scratch (spaces/openings/site toggled ON),
233
+ // with new meshes interleaved throughout — not just appended at the end.
234
+ // We must clear the scene and re-add ALL meshes.
235
+ // Distinguish from streaming-completion (prevIsStreaming→!isStreaming) where new
236
+ // meshes ARE appended at the end and scene should NOT be cleared.
237
+ if (isIncremental && !isStreaming && !prevIsStreamingRef.current) {
238
+ scene.clear();
239
+ processedMeshIdsRef.current.clear();
240
+ // Don't reset camera or bounds — user just toggled visibility
241
+ lastGeometryLengthRef.current = 0;
242
+ lastGeometryRef.current = geometry;
243
+ }
244
+
192
245
  // For incremental batches: update reference and continue to add new meshes
193
246
  if (isIncremental) {
194
247
  lastGeometryRef.current = geometry;
@@ -196,28 +249,37 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
196
249
  lastGeometryRef.current = geometry;
197
250
  }
198
251
 
199
- // FIX: When not streaming (type visibility toggle), new meshes can be ANYWHERE in the array,
200
- // not just at the end. During streaming, new meshes ARE appended, so slice is safe.
201
- // After streaming completes, filter changes can insert meshes at any position.
202
- const meshesToAdd = isStreaming
203
- ? geometry.slice(lastGeometryLengthRef.current) // Streaming: new meshes at end
204
- : geometry; // Post-streaming: scan entire array for unprocessed meshes
205
-
206
- // Filter out already processed meshes
207
- // NOTE: Multiple meshes can share the same expressId AND same color (e.g., door inner framing pieces),
208
- // so we use expressId + array index as a compound key to ensure all submeshes are processed.
209
- const newMeshes: MeshData[] = [];
210
- const startIndex = isStreaming ? lastGeometryLengthRef.current : 0;
211
- for (let i = 0; i < meshesToAdd.length; i++) {
212
- const meshData = meshesToAdd[i];
213
- // Use expressId + global array index as key to ensure each mesh is unique
214
- // (same expressId can have multiple submeshes with same color, e.g., door framing)
215
- const globalIndex = startIndex + i;
216
- const compoundKey = `${meshData.expressId}:${globalIndex}`;
217
-
218
- if (!processedMeshIdsRef.current.has(compoundKey)) {
219
- newMeshes.push(meshData);
220
- processedMeshIdsRef.current.add(compoundKey);
252
+ // PERF: During streaming, new meshes are ALWAYS appended at the end.
253
+ // Skip the compound key dedup (208K string allocations + Set lookups)
254
+ // and array copy (.slice()). Use index-based iteration directly.
255
+ //
256
+ // FIX: Use fast path for incremental appends too (not just streaming).
257
+ // When streaming completes, isStreaming becomes false in the same render as the
258
+ // final appendGeometryBatch. The slow path would re-add ALL meshes because
259
+ // processedMeshIdsRef was never populated during streaming, causing double geometry.
260
+ // For visibility toggles, lastGeometryLengthRef was reset to 0 above, so the
261
+ // fast path naturally starts from 0 (adding ALL meshes after scene.clear).
262
+ let newMeshes: MeshData[];
263
+ if (isStreaming || isIncremental) {
264
+ // Fast path: iterate from lastLength directly
265
+ // During streaming: new meshes appended at end, start = previous length
266
+ // After visibility toggle: scene was cleared, start = 0, adds everything
267
+ const start = lastGeometryLengthRef.current;
268
+ newMeshes = [];
269
+ for (let i = start; i < geometry.length; i++) {
270
+ newMeshes.push(geometry[i]);
271
+ }
272
+ } else {
273
+ // Slow path: scan entire array for unprocessed meshes
274
+ // Only used when array was fully rebuilt (not incremental)
275
+ newMeshes = [];
276
+ for (let i = 0; i < geometry.length; i++) {
277
+ const meshData = geometry[i];
278
+ const compoundKey = `${meshData.expressId}:${i}`;
279
+ if (!processedMeshIdsRef.current.has(compoundKey)) {
280
+ newMeshes.push(meshData);
281
+ processedMeshIdsRef.current.add(compoundKey);
282
+ }
221
283
  }
222
284
  }
223
285
 
@@ -294,6 +356,10 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
294
356
  renderer.getCamera().fitToBounds(shiftedBounds.min, shiftedBounds.max);
295
357
  geometryBoundsRef.current = { min: { ...shiftedBounds.min }, max: { ...shiftedBounds.max } };
296
358
  cameraFittedRef.current = true;
359
+ // Snapshot camera state so we can detect user interaction during streaming
360
+ const pos = renderer.getCamera().getPosition();
361
+ const tgt = renderer.getCamera().getTarget();
362
+ cameraStateAfterFitRef.current = { px: pos.x, py: pos.y, pz: pos.z, tx: tgt.x, ty: tgt.y, tz: tgt.z };
297
363
  }
298
364
  } else if (!cameraFittedRef.current && geometry.length > 0 && !isStreaming) {
299
365
  // Fallback: calculate bounds from geometry array (only when streaming is complete)
@@ -336,6 +402,9 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
336
402
  renderer.getCamera().fitToBounds(fallbackBounds.min, fallbackBounds.max);
337
403
  geometryBoundsRef.current = fallbackBounds;
338
404
  cameraFittedRef.current = true;
405
+ const pos = renderer.getCamera().getPosition();
406
+ const tgt = renderer.getCamera().getTarget();
407
+ cameraStateAfterFitRef.current = { px: pos.x, py: pos.y, pz: pos.z, tx: tgt.x, ty: tgt.y, tz: tgt.z };
339
408
  }
340
409
  }
341
410
 
@@ -351,10 +420,12 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
351
420
  const shouldRender = !isStreaming || timeSinceLastRender >= STREAM_RENDER_THROTTLE_MS;
352
421
 
353
422
  if (shouldRender) {
354
- renderer.render();
423
+ renderer.render({
424
+ clearColor: clearColorRef.current,
425
+ });
355
426
  lastStreamRenderTimeRef.current = now;
356
427
  }
357
- }, [geometry, coordinateInfo, isInitialized, isStreaming]);
428
+ }, [geometry, geometryVersion, coordinateInfo, isInitialized, isStreaming]);
358
429
 
359
430
  // Force render when streaming completes (progress goes from <100% to 100% or null)
360
431
  const prevIsStreamingRef = useRef(isStreaming);
@@ -368,12 +439,75 @@ export function useGeometryStreaming(params: UseGeometryStreamingParams): void {
368
439
  const pipeline = renderer.getPipeline();
369
440
  const scene = renderer.getScene();
370
441
 
371
- // Finalize streaming: destroy temporary fragments and do one O(N) full merge
442
+ // Finalize streaming: destroy temporary fragments and do one O(N) full merge.
443
+ // Must run synchronously BEFORE pendingMeshColorUpdates effect — otherwise
444
+ // fragment batches with stale colors render alongside new proper batches.
372
445
  if (device && pipeline) {
373
446
  scene.finalizeStreaming(device, pipeline);
374
447
  }
375
448
 
376
- renderer.render();
449
+ // Compute exact bounds and refit camera if not already done.
450
+ // This MUST happen here rather than only in the main geometry effect's
451
+ // `currentLength === lastLength` branch, because React may batch the
452
+ // final appendGeometryBatch (geometry grows) and setIsStreaming(false)
453
+ // into the SAME render — making the main effect take the incremental
454
+ // `currentLength > lastLength` path and skip the bounds refit entirely.
455
+ // This effect reliably fires on the isStreaming true→false transition.
456
+ if (cameraFittedRef.current && !finalBoundsRefittedRef.current && geometry && geometry.length > 0) {
457
+ const MAX_VALID_COORD = 10000;
458
+ const exactBounds = {
459
+ min: { x: Infinity, y: Infinity, z: Infinity },
460
+ max: { x: -Infinity, y: -Infinity, z: -Infinity },
461
+ };
462
+ for (let gi = 0; gi < geometry.length; gi++) {
463
+ const positions = geometry[gi].positions;
464
+ for (let i = 0; i < positions.length; i += 3) {
465
+ const x = positions[i];
466
+ const y = positions[i + 1];
467
+ const z = positions[i + 2];
468
+ if (Math.abs(x) < MAX_VALID_COORD && Math.abs(y) < MAX_VALID_COORD && Math.abs(z) < MAX_VALID_COORD) {
469
+ if (x < exactBounds.min.x) exactBounds.min.x = x;
470
+ if (y < exactBounds.min.y) exactBounds.min.y = y;
471
+ if (z < exactBounds.min.z) exactBounds.min.z = z;
472
+ if (x > exactBounds.max.x) exactBounds.max.x = x;
473
+ if (y > exactBounds.max.y) exactBounds.max.y = y;
474
+ if (z > exactBounds.max.z) exactBounds.max.z = z;
475
+ }
476
+ }
477
+ }
478
+
479
+ const exactMaxSize = Math.max(
480
+ exactBounds.max.x - exactBounds.min.x,
481
+ exactBounds.max.y - exactBounds.min.y,
482
+ exactBounds.max.z - exactBounds.min.z
483
+ );
484
+
485
+ if (exactBounds.min.x !== Infinity && exactMaxSize > 0 && Number.isFinite(exactMaxSize)) {
486
+ // Detect whether the user moved the camera during streaming
487
+ const snap = cameraStateAfterFitRef.current;
488
+ let userMovedCamera = false;
489
+ if (snap) {
490
+ const pos = renderer.getCamera().getPosition();
491
+ const tgt = renderer.getCamera().getTarget();
492
+ const EPS = 0.5; // half a metre — ignores floating-point jitter
493
+ userMovedCamera =
494
+ Math.abs(pos.x - snap.px) > EPS || Math.abs(pos.y - snap.py) > EPS || Math.abs(pos.z - snap.pz) > EPS ||
495
+ Math.abs(tgt.x - snap.tx) > EPS || Math.abs(tgt.y - snap.ty) > EPS || Math.abs(tgt.z - snap.tz) > EPS;
496
+ }
497
+
498
+ if (!userMovedCamera) {
499
+ renderer.getCamera().fitToBounds(exactBounds.min, exactBounds.max);
500
+ }
501
+
502
+ // Always update stored bounds for Home / zoom-to-fit
503
+ geometryBoundsRef.current = { min: { ...exactBounds.min }, max: { ...exactBounds.max } };
504
+ finalBoundsRefittedRef.current = true;
505
+ }
506
+ }
507
+
508
+ renderer.render({
509
+ clearColor: clearColorRef.current,
510
+ });
377
511
  lastStreamRenderTimeRef.current = Date.now();
378
512
  }
379
513
  prevIsStreamingRef.current = isStreaming;
@@ -716,6 +716,7 @@ export function useMouseControls(params: UseMouseControlsParams): void {
716
716
  selectedId: selectedEntityIdRef.current,
717
717
  selectedModelIndex: selectedModelIndexRef.current,
718
718
  clearColor: clearColorRef.current,
719
+ isInteracting: true,
719
720
  sectionPlane: activeToolRef.current === 'section' ? {
720
721
  ...sectionPlaneRef.current,
721
722
  min: sectionRangeRef.current?.min,
@@ -727,7 +728,9 @@ export function useMouseControls(params: UseMouseControlsParams): void {
727
728
  calculateScale();
728
729
  } else if (!renderPendingRef.current) {
729
730
  // Schedule a final render for when throttle expires
730
- // This ensures we always render the final position
731
+ // IMPORTANT: Keep isInteracting: true during drag to prevent flickering
732
+ // caused by post-processing toggling on/off between throttled frames.
733
+ // Post-processing is restored on mouseup (non-interacting render).
731
734
  renderPendingRef.current = true;
732
735
  requestAnimationFrame(() => {
733
736
  renderPendingRef.current = false;
@@ -737,6 +740,7 @@ export function useMouseControls(params: UseMouseControlsParams): void {
737
740
  selectedId: selectedEntityIdRef.current,
738
741
  selectedModelIndex: selectedModelIndexRef.current,
739
742
  clearColor: clearColorRef.current,
743
+ isInteracting: true,
740
744
  sectionPlane: activeToolRef.current === 'section' ? {
741
745
  ...sectionPlaneRef.current,
742
746
  min: sectionRangeRef.current?.min,
@@ -890,18 +894,56 @@ export function useMouseControls(params: UseMouseControlsParams): void {
890
894
  const mouseX = e.clientX - rect.left;
891
895
  const mouseY = e.clientY - rect.top;
892
896
  camera.zoom(e.deltaY, false, mouseX, mouseY, canvas.width, canvas.height);
893
- renderer.render({
894
- hiddenIds: hiddenEntitiesRef.current,
895
- isolatedIds: isolatedEntitiesRef.current,
896
- selectedId: selectedEntityIdRef.current,
897
- selectedModelIndex: selectedModelIndexRef.current,
898
- clearColor: clearColorRef.current,
899
- sectionPlane: activeToolRef.current === 'section' ? {
900
- ...sectionPlaneRef.current,
901
- min: sectionRangeRef.current?.min,
902
- max: sectionRangeRef.current?.max,
903
- } : undefined,
904
- });
897
+
898
+ // PERFORMANCE: Adaptive throttle for wheel zoom (same as orbit)
899
+ // Without this, every wheel event triggers a synchronous render —
900
+ // wheel events fire at 60-120Hz which overwhelms the GPU on large models.
901
+ const meshCount = geometryRef.current?.length ?? 0;
902
+ const throttleMs = meshCount > 50000 ? RENDER_THROTTLE_MS_HUGE
903
+ : meshCount > 10000 ? RENDER_THROTTLE_MS_LARGE
904
+ : RENDER_THROTTLE_MS_SMALL;
905
+
906
+ const now = performance.now();
907
+ if (now - lastRenderTimeRef.current >= throttleMs) {
908
+ lastRenderTimeRef.current = now;
909
+ renderer.render({
910
+ hiddenIds: hiddenEntitiesRef.current,
911
+ isolatedIds: isolatedEntitiesRef.current,
912
+ selectedId: selectedEntityIdRef.current,
913
+ selectedModelIndex: selectedModelIndexRef.current,
914
+ clearColor: clearColorRef.current,
915
+ isInteracting: true,
916
+ sectionPlane: activeToolRef.current === 'section' ? {
917
+ ...sectionPlaneRef.current,
918
+ min: sectionRangeRef.current?.min,
919
+ max: sectionRangeRef.current?.max,
920
+ } : undefined,
921
+ });
922
+ calculateScale();
923
+ } else if (!renderPendingRef.current) {
924
+ // Schedule a final render to ensure we always render the last zoom position
925
+ // IMPORTANT: Keep isInteracting: true to prevent flickering from post-processing
926
+ // toggling. Post-processing is restored by the zoom idle timer below.
927
+ renderPendingRef.current = true;
928
+ requestAnimationFrame(() => {
929
+ renderPendingRef.current = false;
930
+ renderer.render({
931
+ hiddenIds: hiddenEntitiesRef.current,
932
+ isolatedIds: isolatedEntitiesRef.current,
933
+ selectedId: selectedEntityIdRef.current,
934
+ selectedModelIndex: selectedModelIndexRef.current,
935
+ clearColor: clearColorRef.current,
936
+ isInteracting: true,
937
+ sectionPlane: activeToolRef.current === 'section' ? {
938
+ ...sectionPlaneRef.current,
939
+ min: sectionRangeRef.current?.min,
940
+ max: sectionRangeRef.current?.max,
941
+ } : undefined,
942
+ });
943
+ calculateScale();
944
+ });
945
+ }
946
+
905
947
  // Update measurement screen coordinates immediately during zoom (only in measure mode)
906
948
  if (activeToolRef.current === 'measure') {
907
949
  if (hasPendingMeasurements()) {
@@ -921,7 +963,6 @@ export function useMouseControls(params: UseMouseControlsParams): void {
921
963
  };
922
964
  }
923
965
  }
924
- calculateScale();
925
966
  };
926
967
 
927
968
  // Click handling
@@ -150,6 +150,7 @@ export function useTouchControls(params: UseTouchControlsParams): void {
150
150
  selectedId: selectedEntityIdRef.current,
151
151
  selectedModelIndex: selectedModelIndexRef.current,
152
152
  clearColor: clearColorRef.current,
153
+ isInteracting: true,
153
154
  sectionPlane: activeToolRef.current === 'section' ? {
154
155
  ...sectionPlaneRef.current,
155
156
  min: sectionRangeRef.current?.min,
@@ -179,6 +180,7 @@ export function useTouchControls(params: UseTouchControlsParams): void {
179
180
  selectedId: selectedEntityIdRef.current,
180
181
  selectedModelIndex: selectedModelIndexRef.current,
181
182
  clearColor: clearColorRef.current,
183
+ isInteracting: true,
182
184
  sectionPlane: activeToolRef.current === 'section' ? {
183
185
  ...sectionPlaneRef.current,
184
186
  min: sectionRangeRef.current?.min,
@@ -13,6 +13,7 @@
13
13
  */
14
14
 
15
15
  import { useMemo, useRef } from 'react';
16
+ import { useShallow } from 'zustand/react/shallow';
16
17
  import { useViewerStore } from '../store.js';
17
18
  import { IfcQuery } from '@ifc-lite/query';
18
19
  import type { IfcDataStore } from '@ifc-lite/parser';
@@ -41,7 +42,24 @@ export function useIfc() {
41
42
  hasModels,
42
43
  // Federation Registry helpers
43
44
  toGlobalId,
44
- } = useViewerStore();
45
+ } = useViewerStore(useShallow((s) => ({
46
+ loading: s.loading,
47
+ progress: s.progress,
48
+ error: s.error,
49
+ ifcDataStore: s.ifcDataStore,
50
+ geometryResult: s.geometryResult,
51
+ models: s.models,
52
+ activeModelId: s.activeModelId,
53
+ clearAllModels: s.clearAllModels,
54
+ setActiveModel: s.setActiveModel,
55
+ setModelVisibility: s.setModelVisibility,
56
+ setModelCollapsed: s.setModelCollapsed,
57
+ getModel: s.getModel,
58
+ getActiveModel: s.getActiveModel,
59
+ getAllVisibleModels: s.getAllVisibleModels,
60
+ hasModels: s.hasModels,
61
+ toGlobalId: s.toGlobalId,
62
+ })));
45
63
 
46
64
  // Track if we've already logged for this ifcDataStore
47
65
  const lastLoggedDataStoreRef = useRef<typeof ifcDataStore>(null);
@@ -20,6 +20,7 @@ import { SpatialHierarchyBuilder, extractLengthUnitScale, type IfcDataStore } fr
20
20
  import { buildSpatialIndex } from '@ifc-lite/spatial';
21
21
  import type { MeshData } from '@ifc-lite/geometry';
22
22
 
23
+ import { useShallow } from 'zustand/react/shallow';
23
24
  import { useViewerStore } from '../store.js';
24
25
  import { getCached, setCached, deleteCached, type CacheResult } from '../services/cacheService.js';
25
26
  import { rebuildSpatialHierarchy, rebuildOnDemandMaps } from '../utils/spatialHierarchy.js';
@@ -66,7 +67,11 @@ export function useIfcCache() {
66
67
  setProgress,
67
68
  setIfcDataStore,
68
69
  setGeometryResult,
69
- } = useViewerStore();
70
+ } = useViewerStore(useShallow((s) => ({
71
+ setProgress: s.setProgress,
72
+ setIfcDataStore: s.setIfcDataStore,
73
+ setGeometryResult: s.setGeometryResult,
74
+ })));
70
75
 
71
76
  /**
72
77
  * Load from binary cache - INSTANT load for maximum speed
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { useCallback } from 'react';
14
+ import { useShallow } from 'zustand/react/shallow';
14
15
  import { useViewerStore, type FederatedModel, type SchemaVersion } from '../store.js';
15
16
  import { IfcParser, detectFormat, parseIfcx, parseFederatedIfcx, type IfcDataStore, type FederatedIfcxParseResult } from '@ifc-lite/parser';
16
17
  import { GeometryProcessor, GeometryQuality, type MeshData, type CoordinateInfo } from '@ifc-lite/geometry';
@@ -99,7 +100,21 @@ export function useIfcFederation() {
99
100
  registerModelOffset,
100
101
  fromGlobalId,
101
102
  findModelForGlobalId,
102
- } = useViewerStore();
103
+ } = useViewerStore(useShallow((s) => ({
104
+ setLoading: s.setLoading,
105
+ setError: s.setError,
106
+ setProgress: s.setProgress,
107
+ setIfcDataStore: s.setIfcDataStore,
108
+ setGeometryResult: s.setGeometryResult,
109
+ addModel: s.addModel,
110
+ removeModel: s.removeModel,
111
+ clearAllModels: s.clearAllModels,
112
+ getModel: s.getModel,
113
+ hasModels: s.hasModels,
114
+ registerModelOffset: s.registerModelOffset,
115
+ fromGlobalId: s.fromGlobalId,
116
+ findModelForGlobalId: s.findModelForGlobalId,
117
+ })));
103
118
 
104
119
  /**
105
120
  * Add a model to the federation (multi-model support)
@@ -11,6 +11,7 @@
11
11
  */
12
12
 
13
13
  import { useCallback } from 'react';
14
+ import { useShallow } from 'zustand/react/shallow';
14
15
  import { useViewerStore } from '../store.js';
15
16
  import { IfcParser, detectFormat, parseIfcx, type IfcDataStore } from '@ifc-lite/parser';
16
17
  import { GeometryProcessor, GeometryQuality, type MeshData, type CoordinateInfo } from '@ifc-lite/geometry';
@@ -77,7 +78,16 @@ export function useIfcLoader() {
77
78
  appendGeometryBatch,
78
79
  updateMeshColors,
79
80
  updateCoordinateInfo,
80
- } = useViewerStore();
81
+ } = useViewerStore(useShallow((s) => ({
82
+ setLoading: s.setLoading,
83
+ setError: s.setError,
84
+ setProgress: s.setProgress,
85
+ setIfcDataStore: s.setIfcDataStore,
86
+ setGeometryResult: s.setGeometryResult,
87
+ appendGeometryBatch: s.appendGeometryBatch,
88
+ updateMeshColors: s.updateMeshColors,
89
+ updateCoordinateInfo: s.updateCoordinateInfo,
90
+ })));
81
91
 
82
92
  // Cache operations from extracted hook
83
93
  const { loadFromCache, saveToCache } = useIfcCache();
@@ -450,9 +460,11 @@ export function useIfcLoader() {
450
460
 
451
461
  finalCoordinateInfo = event.coordinateInfo ?? null;
452
462
 
453
- // Start data model parsing NOW after geometry streaming is done
454
- // so the parser doesn't compete with WASM for main-thread CPU.
455
- startDataModelParsing();
463
+ // PERF: Defer data model parsing to next macrotask so the browser
464
+ // can paint the streaming-complete state first. parseColumnar()
465
+ // synchronously calls scanEntitiesFast() which blocks the main
466
+ // thread for ~7s on large files (487MB → 8.4M entities).
467
+ setTimeout(startDataModelParsing, 0);
456
468
 
457
469
  // Apply all accumulated color updates in a single store update
458
470
  // instead of one updateMeshColors() call per colorUpdate event.
@@ -81,13 +81,18 @@ export const createDataSlice: StateCreator<DataSlice, [], [], DataSlice> = (set)
81
81
  };
82
82
  }
83
83
 
84
- // New array reference (required for React/Zustand change detection) but
85
- // only O(n) pointer copies the expensive part was the .reduce() calls
86
- // which are now replaced by the incremental counters above.
84
+ // PERF FIX: Push into existing array O(batch_size) instead of O(total).
85
+ // The old [...old, ...new] spread copied ALL accumulated meshes every batch,
86
+ // causing O(N²) total work (e.g., 176K meshes × 350 batches = 31M copies).
87
+ // Zustand detects changes via the new geometryResult object reference below.
88
+ const existing = state.geometryResult.meshes;
89
+ for (let i = 0; i < meshes.length; i++) {
90
+ existing.push(meshes[i]);
91
+ }
92
+
87
93
  return {
88
94
  geometryResult: {
89
95
  ...state.geometryResult,
90
- meshes: [...state.geometryResult.meshes, ...meshes],
91
96
  totalTriangles: state.geometryResult.totalTriangles + batchTriangles,
92
97
  totalVertices: state.geometryResult.totalVertices + batchVertices,
93
98
  coordinateInfo: coordinateInfo || state.geometryResult.coordinateInfo,
@@ -171,7 +171,9 @@ export function createCoordinateInfo(
171
171
  * @returns Render interval in milliseconds
172
172
  */
173
173
  export function getRenderIntervalMs(fileSizeMB: number): number {
174
- if (fileSizeMB > 100) {
174
+ if (fileSizeMB > 300) {
175
+ return 500; // Very large files: 2 updates/sec (fewer GPU fragment creations)
176
+ } else if (fileSizeMB > 100) {
175
177
  return 200; // Huge files: 5 updates/sec
176
178
  } else if (fileSizeMB > 50) {
177
179
  return 100; // Large files: 10 updates/sec
package/tsconfig.json CHANGED
@@ -15,7 +15,18 @@
15
15
  "@ifc-lite/export": ["../../packages/export/src"],
16
16
  "@ifc-lite/cache": ["../../packages/cache/src"],
17
17
  "@ifc-lite/sdk": ["../../packages/sdk/src"],
18
- "@ifc-lite/lens": ["../../packages/lens/src"]
18
+ "@ifc-lite/lens": ["../../packages/lens/src"],
19
+ "@ifc-lite/bcf": ["../../packages/bcf/src"],
20
+ "@ifc-lite/mutations": ["../../packages/mutations/src"],
21
+ "@ifc-lite/drawing-2d": ["../../packages/drawing-2d/src"],
22
+ "@ifc-lite/server-client": ["../../packages/server-client/src"],
23
+ "@ifc-lite/ifcx": ["../../packages/ifcx/src"],
24
+ "@ifc-lite/create": ["../../packages/create/src"],
25
+ "@ifc-lite/ids": ["../../packages/ids/src"],
26
+ "@ifc-lite/encoding": ["../../packages/encoding/src"],
27
+ "@ifc-lite/lists": ["../../packages/lists/src"],
28
+ "@ifc-lite/sandbox": ["../../packages/sandbox/src"],
29
+ "@ifc-lite/sandbox/schema": ["../../packages/sandbox/src/bridge-schema"]
19
30
  }
20
31
  },
21
32
  "include": ["src/**/*", "../../packages/renderer/src/webgpu-types.d.ts"],