@ifc-lite/viewer 1.19.1 → 1.21.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 (106) hide show
  1. package/.turbo/turbo-build.log +59 -44
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +488 -0
  4. package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
  5. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  6. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  7. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  8. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  9. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  10. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  11. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  12. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  13. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  14. package/dist/assets/index-CSWgTe1s.css +1 -0
  15. package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
  16. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  17. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  18. package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
  19. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  20. package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
  21. package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
  22. package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
  23. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  24. package/dist/index.html +8 -8
  25. package/index.html +1 -1
  26. package/package.json +10 -10
  27. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  28. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  29. package/src/components/viewer/DeviationPanel.tsx +172 -0
  30. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  31. package/src/components/viewer/HoverTooltip.tsx +5 -0
  32. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  33. package/src/components/viewer/IDSPanel.tsx +80 -26
  34. package/src/components/viewer/MainToolbar.tsx +60 -7
  35. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  36. package/src/components/viewer/MobileToolbar.tsx +326 -0
  37. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  38. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  39. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  40. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  41. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  42. package/src/components/viewer/StatusBar.tsx +14 -0
  43. package/src/components/viewer/ViewerLayout.tsx +288 -95
  44. package/src/components/viewer/Viewport.tsx +86 -18
  45. package/src/components/viewer/ViewportContainer.tsx +25 -11
  46. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  47. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  48. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  49. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  50. package/src/components/viewer/selectionHandlers.ts +41 -0
  51. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  52. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  53. package/src/components/viewer/useAnimationLoop.ts +22 -0
  54. package/src/components/viewer/useMouseControls.ts +296 -3
  55. package/src/components/viewer/usePointCloudSync.ts +8 -1
  56. package/src/components/viewer/useRenderUpdates.ts +21 -1
  57. package/src/components/viewer/useTouchControls.ts +100 -41
  58. package/src/hooks/federationLoadGate.test.ts +90 -0
  59. package/src/hooks/federationLoadGate.ts +127 -0
  60. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  61. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  62. package/src/hooks/useDrawingGeneration.ts +81 -8
  63. package/src/hooks/useIDS.ts +90 -10
  64. package/src/hooks/useIfcFederation.ts +94 -16
  65. package/src/hooks/useIfcLoader.ts +289 -64
  66. package/src/hooks/useViewerSelectors.ts +10 -0
  67. package/src/lib/geo/cesium-bridge.ts +84 -67
  68. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  69. package/src/lib/geo/clamp-anchor.ts +57 -0
  70. package/src/lib/geo/effective-georef.test.ts +79 -1
  71. package/src/lib/geo/effective-georef.ts +83 -0
  72. package/src/lib/geo/reproject.ts +26 -13
  73. package/src/lib/geo/terrain-elevation.ts +166 -0
  74. package/src/lib/lens/adapter.ts +1 -1
  75. package/src/lib/llm/context-builder.ts +1 -1
  76. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  77. package/src/lib/perf/memoryAccounting.ts +235 -0
  78. package/src/sdk/adapters/mutation-view.ts +1 -1
  79. package/src/store/constants.ts +39 -2
  80. package/src/store/index.ts +6 -1
  81. package/src/store/slices/cesiumSlice.ts +1 -1
  82. package/src/store/slices/idsSlice.ts +24 -0
  83. package/src/store/slices/loadingSlice.ts +12 -0
  84. package/src/store/slices/pointCloudSlice.ts +72 -1
  85. package/src/store/slices/sectionSlice.test.ts +590 -1
  86. package/src/store/slices/sectionSlice.ts +344 -17
  87. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  88. package/src/store/slices/uiSlice.ts +60 -2
  89. package/src/store/types.ts +42 -0
  90. package/src/store.ts +13 -0
  91. package/src/utils/acquireFileBuffer.test.ts +231 -0
  92. package/src/utils/acquireFileBuffer.ts +128 -0
  93. package/src/utils/ifcConfig.ts +24 -0
  94. package/src/utils/nativeSpatialDataStore.ts +20 -2
  95. package/src/utils/spatialHierarchy.test.ts +116 -0
  96. package/src/utils/spatialHierarchy.ts +23 -0
  97. package/tailwind.config.js +5 -0
  98. package/tsconfig.json +1 -0
  99. package/vite.config.ts +6 -0
  100. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  101. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  102. package/dist/assets/exporters-xbXqEDlO.js +0 -81590
  103. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  104. package/dist/assets/ids-2WdONLlu.js +0 -2033
  105. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  106. package/dist/assets/index-BXeEKqJG.css +0 -1
@@ -9,7 +9,7 @@
9
9
  * selection/context-menu interactions to selectionHandlers.ts.
10
10
  */
11
11
 
12
- import { useEffect, type MutableRefObject, type RefObject } from 'react';
12
+ import { useEffect, useRef, type MutableRefObject, type RefObject } from 'react';
13
13
  import type { Renderer, PickResult, SnapTarget } from '@ifc-lite/renderer';
14
14
  import type { MeshData } from '@ifc-lite/geometry';
15
15
  import type {
@@ -41,6 +41,12 @@ export interface MouseState {
41
41
  startX: number;
42
42
  startY: number;
43
43
  didDrag: boolean;
44
+ /**
45
+ * True while the user is mid-drag in rectangle-select mode (Ctrl/⌘
46
+ * held over the canvas in select tool). Suppresses orbit/pan in
47
+ * the drag handlers and triggers `pickRect` on mouseup.
48
+ */
49
+ isRectSelecting?: boolean;
44
50
  }
45
51
 
46
52
  export interface UseMouseControlsParams {
@@ -57,6 +63,10 @@ export interface UseMouseControlsParams {
57
63
  snapEnabledRef: MutableRefObject<boolean>;
58
64
  edgeLockStateRef: MutableRefObject<EdgeLockState>;
59
65
  measurementConstraintEdgeRef: MutableRefObject<MeasurementConstraintEdge | null>;
66
+ /** Section tool: when true, the next click picks a face for the clip plane (issue #243). */
67
+ sectionPickModeRef: MutableRefObject<boolean>;
68
+ /** Renderer model bounds; passed to face-pick so the cardinal-fallback `position` % is correct. */
69
+ modelBoundsRef: MutableRefObject<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } } | null>;
60
70
 
61
71
  // Visibility/selection refs
62
72
  hiddenEntitiesRef: MutableRefObject<Set<number>>;
@@ -102,7 +112,20 @@ export interface UseMouseControlsParams {
102
112
 
103
113
  // Callbacks
104
114
  handlePickForSelection: (pickResult: PickResult | null) => void;
105
- setHoverState: (state: { entityId: number; screenX: number; screenY: number }) => void;
115
+ setHoverState: (state: {
116
+ entityId: number;
117
+ screenX: number;
118
+ screenY: number;
119
+ worldXYZ?: { x: number; y: number; z: number };
120
+ }) => void;
121
+ /**
122
+ * Called during a rectangle-selection drag with the current rect
123
+ * (CSS pixels, canvas-relative). Passed `null` on drag end to clear
124
+ * any visual overlay. The hook handles the actual `pickRect` call
125
+ * + selection update internally; this callback is only for the
126
+ * overlay visual.
127
+ */
128
+ setRectSelection?: (rect: { x0: number; y0: number; x1: number; y1: number } | null) => void;
106
129
  clearHover: () => void;
107
130
  openContextMenu: (entityId: number | null, screenX: number, screenY: number) => void;
108
131
  startMeasurement: (point: MeasurePoint) => void;
@@ -122,6 +145,24 @@ export interface UseMouseControlsParams {
122
145
  calculateScale: () => void;
123
146
  getPickOptions: () => { isStreaming: boolean; hiddenIds: Set<number>; isolatedIds: Set<number> | null };
124
147
  hasPendingMeasurements: () => boolean;
148
+ /** Section face-pick: set the clip plane through a world-space face (issue #243). */
149
+ setSectionPlaneFromFace: (
150
+ normal: [number, number, number],
151
+ point: [number, number, number],
152
+ bounds?: { min: [number, number, number]; max: [number, number, number] },
153
+ ) => void;
154
+ /** Section face-pick: arm/disarm the "next click picks a face" mode. */
155
+ setSectionPickMode: (enabled: boolean) => void;
156
+ /**
157
+ * Section face-pick hover preview (issue #243 follow-up). Set by the
158
+ * dwell handler when the cursor pauses ~200ms over a face; cleared
159
+ * (passed `null`) when the cursor leaves the canvas, moves to a
160
+ * different face, or pick mode is disarmed. Purely visual — does not
161
+ * touch `sectionPlane`.
162
+ */
163
+ setSectionPickPreview: (
164
+ preview: { normal: [number, number, number]; point: [number, number, number]; faceKey: string } | null,
165
+ ) => void;
125
166
 
126
167
  // Constants
127
168
  HOVER_SNAP_THROTTLE_MS: number;
@@ -145,6 +186,8 @@ export function useMouseControls(params: UseMouseControlsParams): void {
145
186
  snapEnabledRef,
146
187
  edgeLockStateRef,
147
188
  measurementConstraintEdgeRef,
189
+ sectionPickModeRef,
190
+ modelBoundsRef,
148
191
  hiddenEntitiesRef,
149
192
  isolatedEntitiesRef,
150
193
  selectedEntityIdRef,
@@ -186,6 +229,10 @@ export function useMouseControls(params: UseMouseControlsParams): void {
186
229
  calculateScale,
187
230
  getPickOptions,
188
231
  hasPendingMeasurements,
232
+ setSectionPlaneFromFace,
233
+ setSectionPickMode,
234
+ setSectionPickPreview,
235
+ setRectSelection,
189
236
  HOVER_SNAP_THROTTLE_MS,
190
237
  SLOW_RAYCAST_THRESHOLD_MS,
191
238
  hoverThrottleMs,
@@ -194,6 +241,37 @@ export function useMouseControls(params: UseMouseControlsParams): void {
194
241
  RENDER_THROTTLE_MS_HUGE,
195
242
  } = params;
196
243
 
244
+ // ─── Section face-pick hover preview (issue #243 follow-up) ──────────
245
+ // Refs persist across render so the dwell timer + sticky-face state
246
+ // survive the throttled mousemove path. Critical for the anti-jitter
247
+ // contract: cursor wobble within the same triangle/face must NOT
248
+ // restart the dwell or repaint the overlay. See `handleSectionPickHover`
249
+ // in this file for the full UX rules.
250
+ const sectionDwellTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
251
+ const sectionLastFaceKeyRef = useRef<string | null>(null);
252
+ const sectionLastCastPosRef = useRef<{ x: number; y: number } | null>(null);
253
+ const sectionLastCastTsRef = useRef<number>(0);
254
+
255
+ // When `sectionPickMode` flips off (Esc, second toggle press, tool
256
+ // change), make sure any in-flight dwell timer is cancelled so it
257
+ // can't call `setSectionPickPreview(...)` after the slice has
258
+ // already been disarmed. The slice's own guard would no-op the
259
+ // call, but it's clearer to stop the timer at the source rather
260
+ // than relying on the late guard.
261
+ useEffect(() => {
262
+ const unsub = useViewerStore.subscribe((s, prev) => {
263
+ if (prev.sectionPickMode && !s.sectionPickMode) {
264
+ if (sectionDwellTimerRef.current) {
265
+ clearTimeout(sectionDwellTimerRef.current);
266
+ sectionDwellTimerRef.current = null;
267
+ }
268
+ sectionLastFaceKeyRef.current = null;
269
+ sectionLastCastPosRef.current = null;
270
+ }
271
+ });
272
+ return unsub;
273
+ }, []);
274
+
197
275
  useEffect(() => {
198
276
  const canvas = canvasRef.current;
199
277
  const renderer = rendererRef.current;
@@ -213,6 +291,8 @@ export function useMouseControls(params: UseMouseControlsParams): void {
213
291
  snapEnabledRef,
214
292
  edgeLockStateRef,
215
293
  measurementConstraintEdgeRef,
294
+ sectionPickModeRef,
295
+ modelBoundsRef,
216
296
  hiddenEntitiesRef,
217
297
  isolatedEntitiesRef,
218
298
  geometryRef,
@@ -240,10 +320,129 @@ export function useMouseControls(params: UseMouseControlsParams): void {
240
320
  openContextMenu,
241
321
  hasPendingMeasurements,
242
322
  getPickOptions,
323
+ setSectionPlaneFromFace,
324
+ setSectionPickMode,
325
+ setSectionPickPreview,
243
326
  HOVER_SNAP_THROTTLE_MS,
244
327
  SLOW_RAYCAST_THRESHOLD_MS,
245
328
  };
246
329
 
330
+ /**
331
+ * Section face-pick hover preview (issue #243 follow-up).
332
+ *
333
+ * Anti-jitter contract — these are the rules the dwell handler
334
+ * MUST honour, in order:
335
+ * 1. < 16ms since last raycast → skip (60fps cap).
336
+ * 2. < 2px movement since last raycast → skip (cheap throttle).
337
+ * 3. No hit OR degenerate normal → cancel timer + clear preview.
338
+ * 4. Hit on the SAME face as last cast → no-op (don't restart
339
+ * dwell, don't repaint — this is the critical rule that keeps
340
+ * cursor wobble inside a flat wall from flickering).
341
+ * 5. Hit on a NEW face → cancel old timer + clear preview, start
342
+ * a fresh 200ms dwell.
343
+ * 6. Dwell elapses → camera-orient the normal (matches the click
344
+ * commit policy in `selectionHandlers.ts` so the previewed
345
+ * arrow always points the same direction the actual cut will
346
+ * keep), then publish to the slice.
347
+ *
348
+ * `faceKey` heuristic: we use the closed-form
349
+ * `${expressId}:${meshIndex}:${triangleIndex}` from the renderer's
350
+ * `Intersection`. That uniquely identifies the triangle and is
351
+ * stable under cursor wobble within a single triangle. For two
352
+ * adjacent triangles of the same flat wall the keys differ but the
353
+ * normals are nearly equal — that yields a brief reset of the
354
+ * dwell timer when crossing the diagonal, which is acceptable
355
+ * (matches the "moved to a new triangle" intuition and avoids the
356
+ * complexity of clustering coplanar triangles). The user only
357
+ * waits a fresh 200ms once per crossing; the per-triangle key
358
+ * still suppresses the in-triangle wobble that drove the
359
+ * jitter complaint.
360
+ */
361
+ const handleSectionPickHover = (e: MouseEvent, x: number, y: number): void => {
362
+ const now = performance.now();
363
+ // 60fps cap — keeps the raycast off the hot path of high-Hz
364
+ // pointer devices. Reading-clock rate doesn't have to align
365
+ // with the display refresh; the dwell timer below paints at
366
+ // 200ms regardless.
367
+ if (now - sectionLastCastTsRef.current < 16) return;
368
+ // 2px deadband — fights spurious mousemove events from drift /
369
+ // touchpad jitter so we don't burn raycasts when the cursor is
370
+ // effectively still.
371
+ const last = sectionLastCastPosRef.current;
372
+ if (last) {
373
+ const dx = e.clientX - last.x;
374
+ const dy = e.clientY - last.y;
375
+ if (dx * dx + dy * dy < 4) return;
376
+ }
377
+ sectionLastCastPosRef.current = { x: e.clientX, y: e.clientY };
378
+ sectionLastCastTsRef.current = now;
379
+
380
+ const hit = renderer.raycastScene(x, y, {
381
+ hiddenIds: hiddenEntitiesRef.current,
382
+ isolatedIds: isolatedEntitiesRef.current,
383
+ });
384
+
385
+ // Reject misses and degenerate normals. The renderer's
386
+ // raycaster *should* always hand back a unit-length normal but
387
+ // BVH meshes occasionally yield tiny-magnitude normals on
388
+ // co-planar triangle pairs; the slice would warn and refuse a
389
+ // commit anyway, so don't waste a preview on it.
390
+ const nLen = hit ? Math.hypot(hit.intersection.normal.x, hit.intersection.normal.y, hit.intersection.normal.z) : 0;
391
+ if (!hit || nLen < 1e-6) {
392
+ if (sectionDwellTimerRef.current) {
393
+ clearTimeout(sectionDwellTimerRef.current);
394
+ sectionDwellTimerRef.current = null;
395
+ }
396
+ sectionLastFaceKeyRef.current = null;
397
+ setSectionPickPreview(null);
398
+ return;
399
+ }
400
+
401
+ const ix = hit.intersection;
402
+ // Triangle-stable face key — see the JSDoc above for the
403
+ // adjacent-triangle behaviour.
404
+ const faceKey = `${ix.expressId}:${ix.meshIndex}:${ix.triangleIndex}`;
405
+ if (faceKey === sectionLastFaceKeyRef.current) {
406
+ // Same face — cursor is just wobbling within the triangle.
407
+ // The preview (if any) is already painted in the right place;
408
+ // the dwell timer (if any) is already counting down for this
409
+ // face. Doing nothing here is the entire point of the sticky
410
+ // faceKey rule.
411
+ return;
412
+ }
413
+ sectionLastFaceKeyRef.current = faceKey;
414
+
415
+ // New face — cancel the previous face's pending dwell + drop
416
+ // any preview still pinned to it so the user doesn't see the
417
+ // overlay linger on the wrong surface during the new face's
418
+ // 200ms wait.
419
+ if (sectionDwellTimerRef.current) clearTimeout(sectionDwellTimerRef.current);
420
+ setSectionPickPreview(null);
421
+
422
+ // Snapshot what we need so the timer closure doesn't capture
423
+ // a hit object that the raycaster will mutate on the next cast.
424
+ const px = ix.point.x, py = ix.point.y, pz = ix.point.z;
425
+ const nx = ix.normal.x / nLen, ny = ix.normal.y / nLen, nz = ix.normal.z / nLen;
426
+
427
+ sectionDwellTimerRef.current = setTimeout(() => {
428
+ sectionDwellTimerRef.current = null;
429
+ // Camera-aware normal flip — mirrors the commit logic in
430
+ // `selectionHandlers.ts` so the previewed arrow direction
431
+ // matches what the click will actually produce. Without this
432
+ // the preview would point one way and the cap (post-click)
433
+ // could end up the other, which the user would read as a
434
+ // bug.
435
+ const cam = renderer.getCamera().getPosition();
436
+ const vx = cam.x - px, vy = cam.y - py, vz = cam.z - pz;
437
+ const sign = (vx * nx + vy * ny + vz * nz) < 0 ? -1 : 1;
438
+ setSectionPickPreview({
439
+ normal: [sign * nx, sign * ny, sign * nz],
440
+ point: [px, py, pz],
441
+ faceKey,
442
+ });
443
+ }, 200);
444
+ };
445
+
247
446
  // Mouse controls - respect active tool
248
447
  // Uses pointer events + setPointerCapture so pointerup always fires,
249
448
  // even when the pointer leaves the canvas (e.g. dragging across panels).
@@ -258,10 +457,23 @@ export function useMouseControls(params: UseMouseControlsParams): void {
258
457
  mouseState.startX = e.clientX;
259
458
  mouseState.startY = e.clientY;
260
459
  mouseState.didDrag = false;
460
+ mouseState.isRectSelecting = false;
261
461
 
262
462
  // Determine action based on active tool and mouse button
263
463
  const tool = activeToolRef.current;
264
464
 
465
+ // Rectangle-select gesture: Ctrl/⌘ + LMB drag while in the
466
+ // select tool. Suppresses orbit/pan; the rect is finalised
467
+ // and pick happens on mouseup.
468
+ if (tool === 'select' && e.button === 0 && (e.ctrlKey || e.metaKey)) {
469
+ mouseState.isRectSelecting = true;
470
+ const rect = canvas.getBoundingClientRect();
471
+ const cx = e.clientX - rect.left;
472
+ const cy = e.clientY - rect.top;
473
+ setRectSelection?.({ x0: cx, y0: cy, x1: cx, y1: cy });
474
+ return;
475
+ }
476
+
265
477
  // Will this mousedown lead to an orbit drag?
266
478
  const isPanGesture = tool === 'pan' || e.button === 1 || e.button === 2 ||
267
479
  (tool === 'select' && e.shiftKey);
@@ -357,6 +569,18 @@ export function useMouseControls(params: UseMouseControlsParams): void {
357
569
  const y = e.clientY - rect.top;
358
570
  const tool = activeToolRef.current;
359
571
 
572
+ // Rectangle-select drag: just update the visual; no orbit / pan
573
+ // / pick / hover work happens in this branch.
574
+ if (mouseState.isRectSelecting) {
575
+ setRectSelection?.({
576
+ x0: mouseState.startX - rect.left,
577
+ y0: mouseState.startY - rect.top,
578
+ x1: x,
579
+ y1: y,
580
+ });
581
+ return;
582
+ }
583
+
360
584
  // Handle measure tool live preview while dragging
361
585
  // IMPORTANT: Check tool first, not activeMeasurement, to prevent orbit conflict
362
586
  if (tool === 'measure' && mouseState.isDragging && activeMeasurementRef.current) {
@@ -376,6 +600,17 @@ export function useMouseControls(params: UseMouseControlsParams): void {
376
600
  if (handleAddElementHover(ctx, x, y)) return;
377
601
  }
378
602
 
603
+ // Section tool face-pick: dwell-aware hover preview (issue #243
604
+ // follow-up). Runs INSTEAD of the generic tooltip path while
605
+ // pick mode is armed so the overlay stays the only signal under
606
+ // the cursor — the tooltip would just compete visually with the
607
+ // violet quad. See `handleSectionPickHover` for the full
608
+ // anti-jitter rules.
609
+ if (tool === 'section' && !mouseState.isDragging && sectionPickModeRef.current) {
610
+ handleSectionPickHover(e, x, y);
611
+ return;
612
+ }
613
+
379
614
  // Handle orbit/pan for other tools (or measure tool with shift+drag or no active measurement)
380
615
  if (mouseState.isDragging && (tool !== 'measure' || !activeMeasurementRef.current)) {
381
616
  const dx = e.clientX - mouseState.lastX;
@@ -421,7 +656,12 @@ export function useMouseControls(params: UseMouseControlsParams): void {
421
656
  // Uses visibility filtering so hidden elements don't show hover tooltips
422
657
  const pickResult = await renderer.pick(x, y, getPickOptions());
423
658
  if (pickResult) {
424
- setHoverState({ entityId: pickResult.expressId, screenX: e.clientX, screenY: e.clientY });
659
+ setHoverState({
660
+ entityId: pickResult.expressId,
661
+ screenX: e.clientX,
662
+ screenY: e.clientY,
663
+ worldXYZ: pickResult.worldXYZ,
664
+ });
425
665
  } else {
426
666
  clearHover();
427
667
  }
@@ -441,6 +681,39 @@ export function useMouseControls(params: UseMouseControlsParams): void {
441
681
 
442
682
  const tool = activeToolRef.current;
443
683
 
684
+ // Rectangle-select finalisation: run pickRect against the
685
+ // dragged rect, replace the current selection with the result,
686
+ // then clear the visual.
687
+ if (mouseState.isRectSelecting) {
688
+ const canvasRect = canvas.getBoundingClientRect();
689
+ const x0 = mouseState.startX - canvasRect.left;
690
+ const y0 = mouseState.startY - canvasRect.top;
691
+ const x1 = e.clientX - canvasRect.left;
692
+ const y1 = e.clientY - canvasRect.top;
693
+ // Tiny rect (just a click + tiny twitch) → no-op so we don't
694
+ // accidentally clear selection on a missed Ctrl-click.
695
+ const rectSize = Math.max(Math.abs(x1 - x0), Math.abs(y1 - y0));
696
+ if (rectSize >= 4) {
697
+ // pickRect can reject on WebGPU validation / device-loss
698
+ // paths — swallow the error so the pointer event doesn't
699
+ // surface an unhandled rejection. Selection stays
700
+ // untouched on failure (better UX than clearing it).
701
+ void renderer
702
+ .pickRect(x0, y0, x1, y1, getPickOptions())
703
+ .then((ids) => {
704
+ useViewerStore.getState().setSelectedEntityIds(Array.from(ids));
705
+ })
706
+ .catch((error) => {
707
+ console.warn('[useMouseControls] Rectangle selection failed:', error);
708
+ });
709
+ }
710
+ setRectSelection?.(null);
711
+ mouseState.isRectSelecting = false;
712
+ mouseState.isDragging = false;
713
+ mouseState.isPanning = false;
714
+ return;
715
+ }
716
+
444
717
  // Handle measure tool completion
445
718
  if (tool === 'measure' && activeMeasurementRef.current) {
446
719
  if (handleMeasureUp(ctx, e)) return;
@@ -456,6 +729,17 @@ export function useMouseControls(params: UseMouseControlsParams): void {
456
729
  mouseState.isDragging = false;
457
730
  mouseState.isPanning = false;
458
731
  camera.stopInertia();
732
+ // Section face-pick preview: cursor left the canvas, so any
733
+ // pending dwell timer would otherwise commit a stale hover
734
+ // when the user returns. Drop the overlay too so we don't leave
735
+ // a violet quad orphaned on the last-seen face after leaving.
736
+ if (sectionDwellTimerRef.current) {
737
+ clearTimeout(sectionDwellTimerRef.current);
738
+ sectionDwellTimerRef.current = null;
739
+ }
740
+ sectionLastFaceKeyRef.current = null;
741
+ sectionLastCastPosRef.current = null;
742
+ setSectionPickPreview(null);
459
743
  // Restore cursor based on active tool
460
744
  if (tool === 'measure') {
461
745
  canvas.style.cursor = 'crosshair';
@@ -528,6 +812,15 @@ export function useMouseControls(params: UseMouseControlsParams): void {
528
812
  cancelAnimationFrame(measureRaycastFrameRef.current);
529
813
  measureRaycastFrameRef.current = null;
530
814
  }
815
+
816
+ // Section face-pick: drop any pending dwell so the timer
817
+ // doesn't fire after unmount and call into a stale renderer.
818
+ if (sectionDwellTimerRef.current) {
819
+ clearTimeout(sectionDwellTimerRef.current);
820
+ sectionDwellTimerRef.current = null;
821
+ }
822
+ sectionLastFaceKeyRef.current = null;
823
+ sectionLastCastPosRef.current = null;
531
824
  };
532
825
  }, [isInitialized]);
533
826
  }
@@ -37,6 +37,10 @@ export function usePointCloudSync(params: UsePointCloudSyncParams): void {
37
37
  const roundShape = useViewerStore((s) => s.pointCloudRoundShape);
38
38
  const edlEnabled = useViewerStore((s) => s.pointCloudEdlEnabled);
39
39
  const edlStrength = useViewerStore((s) => s.pointCloudEdlStrength);
40
+ const classMask = useViewerStore((s) => s.pointCloudClassMask);
41
+ const previewStride = useViewerStore((s) => s.pointCloudPreviewStride);
42
+ const deviationCenter = useViewerStore((s) => s.pointCloudDeviationCenterOffset);
43
+ const deviationHalf = useViewerStore((s) => s.pointCloudDeviationHalfRange);
40
44
  const setAssetCount = useViewerStore((s) => s.setPointCloudAssetCount);
41
45
  const fittedRef = useRef(false);
42
46
 
@@ -82,9 +86,12 @@ export function usePointCloudSync(params: UsePointCloudSyncParams): void {
82
86
  pointSize,
83
87
  worldRadius,
84
88
  roundShape,
89
+ classMask,
90
+ previewStride,
91
+ deviationRange: { centerOffset: deviationCenter, halfRange: deviationHalf },
85
92
  });
86
93
  renderer.requestRender();
87
- }, [colorMode, fixedColor, sizeMode, pointSize, worldRadius, roundShape, isInitialized, rendererRef]);
94
+ }, [colorMode, fixedColor, sizeMode, pointSize, worldRadius, roundShape, classMask, previewStride, deviationCenter, deviationHalf, isInitialized, rendererRef]);
88
95
 
89
96
  // Push EDL toggle + strength to the renderer.
90
97
  useEffect(() => {
@@ -15,6 +15,7 @@ import type { Renderer, CutPolygon2D, DrawingLine2D, VisualEnhancementOptions }
15
15
  import type { CoordinateInfo } from '@ifc-lite/geometry';
16
16
  import type { Drawing2D } from '@ifc-lite/drawing-2d';
17
17
  import type { SectionPlane } from '@/store';
18
+ import { customPlaneCenter } from '@/store';
18
19
  import { getThemeClearColor } from '../../utils/viewportUtils.js';
19
20
 
20
21
  export interface UseRenderUpdatesParams {
@@ -100,13 +101,32 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
100
101
  category: line.category,
101
102
  }));
102
103
 
104
+ // For face-picked custom planes (issue #243), forward the plane
105
+ // basis so `uploadDrawing` can lift 2D polygons back to 3D using
106
+ // the same axes the cutter projected with — without that the cap
107
+ // silhouette lands off the actual cutting plane (PR #581's bug).
108
+ // The basis origin is `pickedAt` projected onto the LIVE plane
109
+ // (`customPlaneCenter`), not `pickedAt` directly: as the user
110
+ // drags the gizmo only `distance` changes, and pickedAt sits off
111
+ // the live plane — using it here makes the lift drop the normal-
112
+ // component, freezing the cap at the original pick location.
113
+ const custom = sectionPlane.custom;
114
+ const customPlane = custom
115
+ ? {
116
+ origin: customPlaneCenter(custom),
117
+ tangent: custom.tangent,
118
+ bitangent: custom.bitangent,
119
+ }
120
+ : undefined;
121
+
103
122
  renderer.uploadSection2DOverlay(
104
123
  polygons,
105
124
  lines,
106
125
  sectionPlane.axis,
107
126
  sectionPlane.position,
108
127
  sectionRangeRef.current ?? undefined,
109
- sectionPlane.flipped
128
+ sectionPlane.flipped,
129
+ customPlane,
110
130
  );
111
131
  } else {
112
132
  renderer.clearSection2DOverlay();