@ifc-lite/viewer 1.19.0 → 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 (129) hide show
  1. package/.turbo/turbo-build.log +59 -43
  2. package/.turbo/turbo-typecheck.log +1 -1
  3. package/CHANGELOG.md +496 -0
  4. package/dist/assets/basketViewActivator-Bzw51jhm.js +71 -0
  5. package/dist/assets/{bcf-DOG9_WPX.js → bcf-4K724hw0.js} +18 -18
  6. package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
  7. package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
  8. package/dist/assets/exporters-u0sz2Upj.js +259119 -0
  9. package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
  10. package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
  11. package/dist/assets/ids-B7AXEv7h.js +4067 -0
  12. package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
  13. package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
  14. package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
  15. package/dist/assets/index-CSWgTe1s.css +1 -0
  16. package/dist/assets/{index-BOi3BuUI.js → index-DVNSvEMh.js} +49877 -28410
  17. package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
  18. package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
  19. package/dist/assets/{native-bridge-CpBeOPQa.js → native-bridge-BiD01jI9.js} +2 -2
  20. package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
  21. package/dist/assets/{sandbox-Baez7n-t.js → sandbox-DPD1ROr0.js} +548 -530
  22. package/dist/assets/{server-client-BB6cMAXE.js → server-client-DP8fMPY9.js} +1 -1
  23. package/dist/assets/three-CDRZThFA.js +4057 -0
  24. package/dist/assets/{wasm-bridge-CAYCUHbE.js → wasm-bridge-CErti6zX.js} +1 -1
  25. package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
  26. package/dist/index.html +10 -9
  27. package/dist/samples/building-architecture.ifc +453 -0
  28. package/dist/samples/hello-wall.ifc +1054 -0
  29. package/dist/samples/infra-bridge.ifc +962 -0
  30. package/index.html +1 -1
  31. package/package.json +15 -10
  32. package/public/samples/building-architecture.ifc +453 -0
  33. package/public/samples/hello-wall.ifc +1054 -0
  34. package/public/samples/infra-bridge.ifc +962 -0
  35. package/src/App.tsx +37 -3
  36. package/src/components/mcp/HeroScene.tsx +876 -0
  37. package/src/components/mcp/McpLanding.tsx +1318 -0
  38. package/src/components/mcp/McpPlayground.tsx +524 -0
  39. package/src/components/mcp/PlaygroundChat.tsx +1097 -0
  40. package/src/components/mcp/PlaygroundViewer.tsx +815 -0
  41. package/src/components/mcp/README.md +171 -0
  42. package/src/components/mcp/data.ts +659 -0
  43. package/src/components/mcp/playground-dispatcher.ts +1649 -0
  44. package/src/components/mcp/playground-files.ts +107 -0
  45. package/src/components/mcp/playground-uploads.ts +122 -0
  46. package/src/components/mcp/types.ts +65 -0
  47. package/src/components/mcp/use-mcp-page.ts +109 -0
  48. package/src/components/viewer/BasketPresentationDock.tsx +3 -0
  49. package/src/components/viewer/CesiumOverlay.tsx +165 -120
  50. package/src/components/viewer/DeviationPanel.tsx +172 -0
  51. package/src/components/viewer/HierarchyPanel.tsx +29 -3
  52. package/src/components/viewer/HoverTooltip.tsx +5 -0
  53. package/src/components/viewer/IDSAuditSummary.tsx +389 -0
  54. package/src/components/viewer/IDSPanel.tsx +80 -26
  55. package/src/components/viewer/MainToolbar.tsx +79 -7
  56. package/src/components/viewer/MergeLayersBanner.tsx +108 -0
  57. package/src/components/viewer/MobileToolbar.tsx +326 -0
  58. package/src/components/viewer/PointCloudClasses.tsx +111 -0
  59. package/src/components/viewer/PointCloudLegend.tsx +119 -0
  60. package/src/components/viewer/PointCloudPanel.tsx +52 -1
  61. package/src/components/viewer/PropertiesPanel.tsx +37 -6
  62. package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
  63. package/src/components/viewer/StatusBar.tsx +14 -0
  64. package/src/components/viewer/ViewerLayout.tsx +288 -95
  65. package/src/components/viewer/Viewport.tsx +86 -18
  66. package/src/components/viewer/ViewportContainer.tsx +60 -15
  67. package/src/components/viewer/ViewportOverlays.tsx +41 -26
  68. package/src/components/viewer/mouseHandlerTypes.ts +22 -0
  69. package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
  70. package/src/components/viewer/properties/MaterialCard.tsx +2 -2
  71. package/src/components/viewer/selectionHandlers.ts +41 -0
  72. package/src/components/viewer/tools/SectionPanel.tsx +181 -24
  73. package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
  74. package/src/components/viewer/useAnimationLoop.ts +22 -0
  75. package/src/components/viewer/useMouseControls.ts +296 -3
  76. package/src/components/viewer/usePointCloudSync.ts +8 -1
  77. package/src/components/viewer/useRenderUpdates.ts +21 -1
  78. package/src/components/viewer/useTouchControls.ts +100 -41
  79. package/src/generated/mcp-catalog.json +82 -0
  80. package/src/hooks/federationLoadGate.test.ts +90 -0
  81. package/src/hooks/federationLoadGate.ts +127 -0
  82. package/src/hooks/ids/idsDataAccessor.ts +11 -259
  83. package/src/hooks/ingest/pointCloudIngest.ts +127 -16
  84. package/src/hooks/useDrawingGeneration.ts +81 -8
  85. package/src/hooks/useIDS.ts +90 -10
  86. package/src/hooks/useIfcFederation.ts +94 -16
  87. package/src/hooks/useIfcLoader.ts +289 -64
  88. package/src/hooks/useViewerSelectors.ts +10 -0
  89. package/src/lib/geo/cesium-bridge.ts +84 -67
  90. package/src/lib/geo/clamp-anchor.test.ts +80 -0
  91. package/src/lib/geo/clamp-anchor.ts +57 -0
  92. package/src/lib/geo/effective-georef.test.ts +79 -1
  93. package/src/lib/geo/effective-georef.ts +83 -0
  94. package/src/lib/geo/reproject.ts +26 -13
  95. package/src/lib/geo/terrain-elevation.ts +166 -0
  96. package/src/lib/lens/adapter.ts +1 -1
  97. package/src/lib/llm/context-builder.ts +1 -1
  98. package/src/lib/perf/memoryAccounting.test.ts +92 -0
  99. package/src/lib/perf/memoryAccounting.ts +235 -0
  100. package/src/sdk/adapters/mutation-view.ts +1 -1
  101. package/src/store/constants.ts +39 -2
  102. package/src/store/index.ts +6 -1
  103. package/src/store/slices/cesiumSlice.ts +1 -1
  104. package/src/store/slices/idsSlice.ts +24 -0
  105. package/src/store/slices/loadingSlice.ts +12 -0
  106. package/src/store/slices/pointCloudSlice.ts +72 -1
  107. package/src/store/slices/sectionSlice.test.ts +590 -1
  108. package/src/store/slices/sectionSlice.ts +344 -17
  109. package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
  110. package/src/store/slices/uiSlice.ts +60 -2
  111. package/src/store/types.ts +42 -0
  112. package/src/store.ts +13 -0
  113. package/src/utils/acquireFileBuffer.test.ts +231 -0
  114. package/src/utils/acquireFileBuffer.ts +128 -0
  115. package/src/utils/ifcConfig.ts +24 -0
  116. package/src/utils/nativeSpatialDataStore.ts +20 -2
  117. package/src/utils/spatialHierarchy.test.ts +116 -0
  118. package/src/utils/spatialHierarchy.ts +23 -0
  119. package/tailwind.config.js +5 -0
  120. package/tsconfig.json +1 -0
  121. package/vite.config.ts +12 -0
  122. package/dist/assets/basketViewActivator-RZy5c3Td.js +0 -1
  123. package/dist/assets/decode-worker-Collf_X_.js +0 -1320
  124. package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
  125. package/dist/assets/exporters-BraHBeoi.js +0 -81583
  126. package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
  127. package/dist/assets/ids-DQ5jY0E8.js +0 -1
  128. package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
  129. package/dist/assets/index-0XpVr_S5.css +0 -1
@@ -32,6 +32,7 @@ import {
32
32
  import { setGlobalCanvasRef, setGlobalRendererRef, clearGlobalRefs } from '../../hooks/useBCF.js';
33
33
 
34
34
  import { useMouseControls, type MouseState } from './useMouseControls.js';
35
+ import { RectSelectionOverlay, type RectSelectionRect } from './RectSelectionOverlay.js';
35
36
  import { useTouchControls, type TouchState } from './useTouchControls.js';
36
37
  import { useKeyboardControls } from './useKeyboardControls.js';
37
38
  import { useAnimationLoop } from './useAnimationLoop.js';
@@ -71,6 +72,7 @@ export function Viewport({
71
72
  const canvasRef = useRef<HTMLCanvasElement>(null);
72
73
  const rendererRef = useRef<Renderer | null>(null);
73
74
  const [isInitialized, setIsInitialized] = useState(false);
75
+ const [initError, setInitError] = useState<string | null>(null);
74
76
 
75
77
  const focusViewportForKeyboardShortcuts = useCallback(() => {
76
78
  const canvas = canvasRef.current;
@@ -199,8 +201,17 @@ export function Viewport({
199
201
  const { hiddenEntities, isolatedEntities: storeIsolatedEntities } = useVisibilityState();
200
202
  const isolatedEntities = computedIsolatedIds ?? storeIsolatedEntities ?? null;
201
203
 
202
- // Tool state
203
- const { activeTool, sectionPlane } = useToolState();
204
+ // Tool state — `sectionPickMode` arms a face-pick on the next click for
205
+ // the section tool (issue #243); the action setters are forwarded into
206
+ // the mouse-controls context.
207
+ const {
208
+ activeTool,
209
+ sectionPlane,
210
+ sectionPickMode,
211
+ setSectionPlaneFromFace,
212
+ setSectionPickMode,
213
+ setSectionPickPreview,
214
+ } = useToolState();
204
215
 
205
216
  // Camera state
206
217
  const { updateCameraRotationRealtime, updateScaleRealtime, setCameraCallbacks } = useCameraState();
@@ -282,9 +293,9 @@ export function Viewport({
282
293
  // Tokyo Night storm: #1a1b26 = rgb(26, 27, 38)
283
294
  const clearColorRef = useRef<[number, number, number, number]>([0.102, 0.106, 0.149, 1]);
284
295
  const visualEnhancement = useMemo<VisualEnhancementOptions>(() => ({
285
- enabled: visualEnhancementsEnabled,
296
+ enabled: isMobile ? false : visualEnhancementsEnabled,
286
297
  edgeContrast: {
287
- enabled: edgeContrastEnabled,
298
+ enabled: isMobile ? false : edgeContrastEnabled,
288
299
  intensity: edgeContrastIntensity,
289
300
  },
290
301
  contactShading: {
@@ -293,7 +304,7 @@ export function Viewport({
293
304
  radius: contactShadingRadius,
294
305
  },
295
306
  separationLines: {
296
- enabled: separationLinesEnabled,
307
+ enabled: isMobile ? false : separationLinesEnabled,
297
308
  quality: isMobile ? 'low' : separationLinesQuality,
298
309
  intensity: isMobile ? Math.min(0.4, separationLinesIntensity) : separationLinesIntensity,
299
310
  radius: isMobile ? 1.0 : separationLinesRadius,
@@ -349,6 +360,10 @@ export function Viewport({
349
360
  didMove: false,
350
361
  // Track if multi-touch occurred (prevents false tap-select after pinch/zoom)
351
362
  multiTouch: false,
363
+ // 2-finger gesture detection
364
+ twoFingerGesture: 'none',
365
+ gestureDistanceAccum: 0,
366
+ gesturePanAccum: 0,
352
367
  });
353
368
 
354
369
  // Double-click detection
@@ -386,7 +401,12 @@ export function Viewport({
386
401
  const measurementConstraintEdgeRef = useLatestRef(measurementConstraintEdge);
387
402
  const sectionPlaneRef = useLatestRef(sectionPlane);
388
403
  const sectionRangeRef = useLatestRef(sectionRange);
404
+ const sectionPickModeRef = useLatestRef(sectionPickMode);
389
405
  const visualEnhancementRef = useLatestRef(visualEnhancement);
406
+ // Renderer model bounds, kept fresh per-render. The face-pick handler
407
+ // forwards these to the slice so the cardinal-fallback `position` % is
408
+ // computed against the actual model extents at click time.
409
+ const modelBoundsRef = useRef<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } } | null>(null);
390
410
 
391
411
  // Terrain clip Y from Cesium store (read as ref for animation loop)
392
412
  const cesiumTerrainClipY = useViewerStore((s) => s.cesiumTerrainClipY);
@@ -460,9 +480,18 @@ export function Viewport({
460
480
  }
461
481
  }
462
482
 
463
- // Set cursor based on active tool
483
+ // Leaving the section tool disarms face-pick so it doesn't ambush the
484
+ // user on re-entry to a different tool (issue #243).
485
+ if (activeTool !== 'section' && sectionPickMode) {
486
+ setSectionPickMode(false);
487
+ }
488
+
489
+ // Set cursor based on active tool. Section + pick-armed gets a
490
+ // crosshair to telegraph "click a face".
464
491
  if (activeTool === 'measure' || activeTool === 'annotate' || activeTool === 'addElement') {
465
492
  canvas.style.cursor = 'crosshair';
493
+ } else if (activeTool === 'section' && sectionPickMode) {
494
+ canvas.style.cursor = 'crosshair';
466
495
  } else {
467
496
  canvas.style.cursor = 'default';
468
497
  }
@@ -476,7 +505,7 @@ export function Viewport({
476
505
  state.clearAddElementPending();
477
506
  }
478
507
  }
479
- }, [activeTool, activeMeasurement, cancelMeasurement]);
508
+ }, [activeTool, activeMeasurement, cancelMeasurement, sectionPickMode, setSectionPickMode]);
480
509
 
481
510
  // Helper: calculate scale bar value (world-space size for 96px scale bar)
482
511
  const calculateScale = () => {
@@ -526,6 +555,7 @@ export function Viewport({
526
555
  if (!canvas) return;
527
556
 
528
557
  setIsInitialized(false);
558
+ setInitError(null);
529
559
 
530
560
  let aborted = false;
531
561
  let resizeObserver: ResizeObserver | null = null;
@@ -537,6 +567,9 @@ export function Viewport({
537
567
  return Math.max(64, Math.floor(size / 64) * 64);
538
568
  };
539
569
 
570
+ // Use CSS pixel dimensions for canvas. The Renderer.render() method manages
571
+ // its own dimension alignment via getBoundingClientRect() — do NOT apply DPR
572
+ // here as it creates a mismatch that causes constant context reconfiguration.
540
573
  const rect = canvas.getBoundingClientRect();
541
574
  const width = alignToWebGPU(Math.max(1, Math.floor(rect.width)));
542
575
  const height = Math.max(1, Math.floor(rect.height));
@@ -552,7 +585,6 @@ export function Viewport({
552
585
 
553
586
  renderer.init().then(() => {
554
587
  if (aborted) return;
555
-
556
588
  setIsInitialized(true);
557
589
 
558
590
  const camera = renderer.getCamera();
@@ -664,11 +696,10 @@ export function Viewport({
664
696
  },
665
697
  });
666
698
 
667
- // ResizeObserver
699
+ // ResizeObserver — let renderer handle its own dimension alignment
668
700
  resizeObserver = new ResizeObserver(() => {
669
701
  if (aborted) return;
670
702
  const rect = canvas.getBoundingClientRect();
671
- // Use same WebGPU alignment as initialization
672
703
  const w = alignToWebGPU(Math.max(1, Math.floor(rect.width)));
673
704
  const h = Math.max(1, Math.floor(rect.height));
674
705
  renderer.resize(w, h);
@@ -678,6 +709,11 @@ export function Viewport({
678
709
 
679
710
  // Initial render
680
711
  renderCurrent();
712
+ }).catch((err) => {
713
+ if (aborted) return;
714
+ const message = err instanceof Error ? err.message : 'Failed to initialize 3D renderer';
715
+ console.error('[Viewport] Renderer init failed:', message);
716
+ setInitError(message);
681
717
  });
682
718
 
683
719
  return () => {
@@ -717,6 +753,10 @@ export function Viewport({
717
753
  // The animation loop reads this to skip post-processing during rapid camera movement.
718
754
  const isInteractingRef = useRef(false);
719
755
 
756
+ // Rectangle-select drag state — populated by useMouseControls during
757
+ // a Ctrl/⌘ + LMB drag, consumed by RectSelectionOverlay below.
758
+ const [rectSelection, setRectSelection] = useState<RectSelectionRect | null>(null);
759
+
720
760
  // ===== Extracted hooks =====
721
761
  useMouseControls({
722
762
  canvasRef,
@@ -728,6 +768,8 @@ export function Viewport({
728
768
  snapEnabledRef,
729
769
  edgeLockStateRef,
730
770
  measurementConstraintEdgeRef,
771
+ sectionPickModeRef,
772
+ modelBoundsRef,
731
773
  hiddenEntitiesRef,
732
774
  isolatedEntitiesRef,
733
775
  selectedEntityIdRef,
@@ -751,6 +793,7 @@ export function Viewport({
751
793
  handlePickForSelection: (pickResult) => handlePickForSelectionRef.current(pickResult),
752
794
  setHoverState,
753
795
  clearHover,
796
+ setRectSelection,
754
797
  openContextMenu,
755
798
  startMeasurement,
756
799
  updateMeasurement,
@@ -769,6 +812,9 @@ export function Viewport({
769
812
  calculateScale,
770
813
  getPickOptions,
771
814
  hasPendingMeasurements,
815
+ setSectionPlaneFromFace,
816
+ setSectionPickMode,
817
+ setSectionPickPreview,
772
818
  HOVER_SNAP_THROTTLE_MS,
773
819
  SLOW_RAYCAST_THRESHOLD_MS,
774
820
  hoverThrottleMs,
@@ -833,6 +879,7 @@ export function Viewport({
833
879
  clearColorRef,
834
880
  sectionPlaneRef,
835
881
  sectionRangeRef,
882
+ modelBoundsRef,
836
883
  visualEnhancementRef,
837
884
  selectedEntityIdsRef,
838
885
  coordinateInfoRef,
@@ -918,13 +965,34 @@ export function Viewport({
918
965
  : undefined;
919
966
 
920
967
  return (
921
- <canvas
922
- ref={canvasRef}
923
- data-viewport="main"
924
- tabIndex={-1}
925
- className={`w-full h-full block ${cesiumActive ? 'relative z-[1]' : ''}`}
926
- style={canvasStyle}
927
- onPointerDown={focusViewportForKeyboardShortcuts}
928
- />
968
+ <div className="relative w-full h-full">
969
+ <canvas
970
+ ref={canvasRef}
971
+ data-viewport="main"
972
+ tabIndex={-1}
973
+ className={`w-full h-full block ${cesiumActive ? 'relative z-[1]' : ''}`}
974
+ style={{ touchAction: 'none', ...canvasStyle }}
975
+ onPointerDown={focusViewportForKeyboardShortcuts}
976
+ />
977
+ {initError && (
978
+ <div className="absolute inset-0 flex items-center justify-center bg-background/90 z-50 p-4">
979
+ <div className="text-center max-w-sm space-y-3">
980
+ <div className="mx-auto w-12 h-12 rounded-full bg-destructive/10 flex items-center justify-center">
981
+ <svg className="h-6 w-6 text-destructive" fill="none" viewBox="0 0 24 24" stroke="currentColor">
982
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
983
+ </svg>
984
+ </div>
985
+ <p className="font-semibold text-sm">3D Rendering Failed</p>
986
+ <p className="text-xs text-muted-foreground">{initError}</p>
987
+ <p className="text-xs text-muted-foreground">
988
+ Try using Chrome 113+, Edge 113+, or Safari 18+ with WebGPU support.
989
+ </p>
990
+ </div>
991
+ </div>
992
+ )}
993
+ {/* Rectangle-select drag visual. Pointer-events:none so the
994
+ canvas keeps receiving pointer events during the drag. */}
995
+ <RectSelectionOverlay rect={rectSelection} />
996
+ </div>
929
997
  );
930
998
  }
@@ -5,6 +5,7 @@
5
5
  import { useMemo, useRef, useState, useCallback, useEffect, useSyncExternalStore } from 'react';
6
6
  import { Viewport } from './Viewport';
7
7
  import { ViewportOverlays } from './ViewportOverlays';
8
+ import { MergeLayersBanner } from './MergeLayersBanner';
8
9
  import { ToolOverlays } from './ToolOverlays';
9
10
  import { AnnotationLayer } from './annotations/AnnotationLayer';
10
11
  import { Section2DPanel } from './Section2DPanel';
@@ -22,7 +23,7 @@ import { cacheFileBlobs, formatFileSize, getCachedFile, getRecentFiles, recordRe
22
23
  import { isTauri } from '@/lib/platform';
23
24
  import { toast } from '@/components/ui/toast';
24
25
  import { describeUnsupportedFormat } from '@/hooks/ingest/pointCloudIngest';
25
- import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus, Clock3 } from 'lucide-react';
26
+ import { Upload, MousePointer, Layers, Info, Command, AlertTriangle, ChevronDown, ExternalLink, Plus, Clock3, Sparkles, ArrowUpRight } from 'lucide-react';
26
27
  import type { MeshData, CoordinateInfo, GeometryResult, PointCloudAsset } from '@ifc-lite/geometry';
27
28
  import { type IfcDataStore } from '@ifc-lite/parser';
28
29
  import { getEffectiveGeoreference } from '@/lib/geo/effective-georef';
@@ -215,7 +216,11 @@ export function ViewportContainer() {
215
216
  georefMutations.get(modelId),
216
217
  );
217
218
  if (effective?.projectedCRS?.name && effective.mapConversion) {
218
- return { ...effective, sourceModelId: modelId };
219
+ return {
220
+ ...effective,
221
+ sourceModelId: modelId,
222
+ storeyElevations: ds.spatialHierarchy?.storeyElevations,
223
+ };
219
224
  }
220
225
  }
221
226
 
@@ -227,7 +232,11 @@ export function ViewportContainer() {
227
232
  georefMutations.get('__legacy__'),
228
233
  );
229
234
  if (effective?.projectedCRS?.name && effective.mapConversion) {
230
- return { ...effective, sourceModelId: '__legacy__' };
235
+ return {
236
+ ...effective,
237
+ sourceModelId: '__legacy__',
238
+ storeyElevations: ifcDataStore.spatialHierarchy?.storeyElevations,
239
+ };
231
240
  }
232
241
  }
233
242
 
@@ -311,7 +320,7 @@ export function ViewportContainer() {
311
320
  const allDropped = Array.from(e.dataTransfer.files);
312
321
  const supportedFiles = allDropped.filter(
313
322
  f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
314
- || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57')
323
+ || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57') || f.name.toLowerCase().endsWith('.pts') || f.name.toLowerCase().endsWith('.xyz')
315
324
  );
316
325
 
317
326
  if (supportedFiles.length === 0) {
@@ -354,7 +363,7 @@ export function ViewportContainer() {
354
363
  // Filter to supported files (IFC, IFCX, GLB)
355
364
  const supportedFiles = Array.from(files).filter(
356
365
  f => f.name.endsWith('.ifc') || f.name.endsWith('.ifcx') || f.name.endsWith('.glb')
357
- || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57')
366
+ || f.name.toLowerCase().endsWith('.las') || f.name.toLowerCase().endsWith('.laz') || f.name.toLowerCase().endsWith('.ply') || f.name.toLowerCase().endsWith('.pcd') || f.name.toLowerCase().endsWith('.e57') || f.name.toLowerCase().endsWith('.pts') || f.name.toLowerCase().endsWith('.xyz')
358
367
  );
359
368
 
360
369
  if (supportedFiles.length === 0) return;
@@ -566,7 +575,7 @@ export function ViewportContainer() {
566
575
  <input
567
576
  ref={fileInputRef}
568
577
  type="file"
569
- accept=".ifc,.ifcx,.glb,.las,.laz,.ply,.pcd,.e57"
578
+ accept=".ifc,.ifcx,.glb,.las,.laz,.ply,.pcd,.e57,.pts,.xyz"
570
579
  multiple
571
580
  onChange={handleFileSelect}
572
581
  className="hidden"
@@ -582,9 +591,9 @@ export function ViewportContainer() {
582
591
  </div>
583
592
  )}
584
593
 
585
- {/* WebGPU Not Supported Banner */}
594
+ {/* WebGPU Not Supported Banner — compact on mobile */}
586
595
  {!webgpu.checking && !webgpu.supported && (
587
- <div className="absolute top-0 left-0 right-0 z-40">
596
+ <div className="absolute top-0 left-0 right-0 z-40 max-h-[40vh] overflow-auto">
588
597
  {/* Hazard stripes background */}
589
598
  <div
590
599
  className="absolute inset-0 opacity-10"
@@ -697,8 +706,8 @@ export function ViewportContainer() {
697
706
  </div>
698
707
  )}
699
708
 
700
- {/* Empty state content */}
701
- <div className="absolute inset-0 flex flex-col items-center justify-center p-8 z-10">
709
+ {/* Empty state content — mobile-optimized padding and scrollable */}
710
+ <div className="absolute inset-0 flex flex-col items-center justify-center p-4 md:p-8 z-10 overflow-auto">
702
711
 
703
712
  {/* Main Card */}
704
713
  <div className="max-w-md w-full bg-white dark:bg-[#16161e] border border-zinc-300 dark:border-[#3b4261] p-8 flex flex-col items-center transition-transform hover:-translate-y-1 duration-200 shadow-lg">
@@ -735,10 +744,18 @@ export function ViewportContainer() {
735
744
  IFClite
736
745
  </h2>
737
746
  <p className="text-zinc-500 dark:text-[#565f89] font-mono text-sm text-center mb-8 border-b border-zinc-200 dark:border-[#3b4261] pb-4 w-full">
738
- High-performance web viewer demo
747
+ IFC toolkit for the open web
739
748
  </p>
740
749
 
741
- {/* Action */}
750
+ {/*
751
+ Two-track action area: a primary "open file" track and a
752
+ secondary "drive with LLM" track sit in mirrored slots — same
753
+ width, same vertical rhythm, each followed by its own caption
754
+ line. Reads as one balanced composition instead of a primary
755
+ CTA + a tacked-on link, while keeping the file-open path
756
+ visually dominant via the filled-on-hover treatment.
757
+ */}
758
+ {/* Track 1 — open / drag */}
742
759
  <button
743
760
  onClick={async () => {
744
761
  if (!webgpu.supported) {
@@ -774,10 +791,33 @@ export function ViewportContainer() {
774
791
  <span>{webgpu.checking ? 'Checking WebGPU...' : webgpu.supported ? 'Open .ifc file' : 'WebGPU Required'}</span>
775
792
  </button>
776
793
 
777
- <p className="mt-3 text-xs font-mono text-zinc-400 dark:text-[#565f89]">
794
+ <p className="mt-2.5 text-[11px] font-mono text-center text-zinc-400 dark:text-[#565f89]">
778
795
  {webgpu.supported ? 'or drag & drop anywhere' : 'file upload disabled'}
779
796
  </p>
780
797
 
798
+ {/* Subtle "or" rule — anchors the symmetry between the two tracks */}
799
+ <div className="mt-5 mb-5 w-full flex items-center gap-3 text-[10px] font-mono uppercase tracking-[0.22em] text-zinc-400 dark:text-[#565f89]">
800
+ <span className="h-px flex-1 bg-zinc-200 dark:bg-[#3b4261]" />
801
+ <span>or</span>
802
+ <span className="h-px flex-1 bg-zinc-200 dark:bg-[#3b4261]" />
803
+ </div>
804
+
805
+ {/* Track 2 — agent / MCP. Compact inline pill, self-centred so
806
+ it reads as a meta-link sibling to the primary file-open
807
+ CTA, not a competing full-width button. */}
808
+ <a
809
+ href="/mcp"
810
+ className="group inline-flex self-center items-center gap-1.5 px-3 py-1.5 font-mono text-[11px] border border-dashed border-zinc-300 dark:border-[#3b4261] text-zinc-500 dark:text-[#7a82a5] hover:border-primary hover:text-primary transition-all cursor-pointer"
811
+ >
812
+ <Sparkles className="h-3 w-3 transition-transform group-hover:-translate-y-0.5" />
813
+ <span>Drive with any LLM</span>
814
+ <ArrowUpRight className="h-2.5 w-2.5 opacity-60 transition-transform group-hover:translate-x-0.5 group-hover:-translate-y-0.5" />
815
+ </a>
816
+
817
+ <p className="mt-1.5 text-[10px] font-mono text-center text-zinc-400 dark:text-[#565f89]">
818
+ via MCP · install or try the playground
819
+ </p>
820
+
781
821
  {recentFiles.length > 0 && (
782
822
  <div className="mt-6 w-full border-t border-zinc-200 dark:border-[#3b4261] pt-4">
783
823
  <div className="mb-3 flex items-center gap-2 text-xs font-mono uppercase tracking-[0.2em] text-zinc-400 dark:text-[#565f89]">
@@ -810,8 +850,8 @@ export function ViewportContainer() {
810
850
  )}
811
851
  </div>
812
852
 
813
- {/* Feature Grid */}
814
- <div className="mt-16 grid grid-cols-1 md:grid-cols-3 gap-6 max-w-3xl w-full">
853
+ {/* Feature Grid — hidden on mobile to save viewport space */}
854
+ <div className="mt-16 hidden md:grid grid-cols-1 md:grid-cols-3 gap-6 max-w-3xl w-full">
815
855
  {[
816
856
  { icon: MousePointer, label: "Select", desc: "Inspect elements", accentClass: 'text-blue-500 dark:text-[#7aa2f7]' },
817
857
  { icon: Layers, label: "Filter", desc: "Isolate storeys", accentClass: 'text-purple-500 dark:text-[#bb9af7]' },
@@ -877,6 +917,7 @@ export function ViewportContainer() {
877
917
  coordinateInfo={georef.coordinateInfo}
878
918
  geometryResult={mergedGeometryResult}
879
919
  lengthUnitScale={georef.lengthUnitScale}
920
+ storeyElevations={georef.storeyElevations}
880
921
  />
881
922
  )}
882
923
  <Viewport
@@ -893,6 +934,10 @@ export function ViewportContainer() {
893
934
  <AnnotationLayer />
894
935
  {bcfOverlayVisible && <BCFOverlay />}
895
936
  <ViewportOverlays />
937
+ {/* Issue #540: non-modal "reload to apply" banner anchored to the
938
+ top of the canvas. Only renders when the user has flipped the
939
+ merge-layers toggle while a model is in scope. */}
940
+ <MergeLayersBanner />
896
941
  <ToolOverlays />
897
942
  <BasketPresentationDock />
898
943
  <Section2DPanel
@@ -34,6 +34,7 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
34
34
  const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
35
35
  const basketPresentationVisible = useViewerStore((s) => s.basketPresentationVisible);
36
36
  const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
37
+ const isMobile = useViewerStore((s) => s.isMobile);
37
38
  const setOnCameraRotationChange = useViewerStore((s) => s.setOnCameraRotationChange);
38
39
  const setOnScaleChange = useViewerStore((s) => s.setOnScaleChange);
39
40
  const { ifcDataStore, geometryResult } = useIfc();
@@ -161,11 +162,19 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
161
162
  onClose={toggleCesium}
162
163
  />
163
164
  ) : (
164
- <div className="absolute bottom-4 right-4 flex flex-col gap-1 bg-background/80 backdrop-blur-sm rounded-lg border shadow-sm p-1">
165
+ <div
166
+ className={cn(
167
+ 'absolute flex flex-col gap-1 bg-background/90 backdrop-blur-sm border p-1',
168
+ // Mobile: bottom-left at ~15% up from lower edge — thumb-reachable on
169
+ // portrait phones and well clear of the URL bar. Tight radii + flat
170
+ // background match the codebase's brutalist panel-chrome vocabulary.
171
+ isMobile ? 'left-4 bottom-[15%] rounded-md' : 'bottom-4 right-4 rounded-lg shadow-sm',
172
+ )}
173
+ >
165
174
  <Tooltip>
166
175
  <TooltipTrigger asChild>
167
- <Button variant="ghost" size="icon-sm" onClick={handleHome}>
168
- <Home className="h-4 w-4" />
176
+ <Button variant="ghost" size="icon-sm" className={cn(isMobile && 'min-h-[44px] min-w-[44px]')} onClick={handleHome}>
177
+ <Home className={cn(isMobile ? 'h-5 w-5' : 'h-4 w-4')} />
169
178
  </Button>
170
179
  </TooltipTrigger>
171
180
  <TooltipContent side="left">Home (H)</TooltipContent>
@@ -173,8 +182,8 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
173
182
 
174
183
  <Tooltip>
175
184
  <TooltipTrigger asChild>
176
- <Button variant="ghost" size="icon-sm" onClick={handleZoomIn}>
177
- <ZoomIn className="h-4 w-4" />
185
+ <Button variant="ghost" size="icon-sm" className={cn(isMobile && 'min-h-[44px] min-w-[44px]')} onClick={handleZoomIn}>
186
+ <ZoomIn className={cn(isMobile ? 'h-5 w-5' : 'h-4 w-4')} />
178
187
  </Button>
179
188
  </TooltipTrigger>
180
189
  <TooltipContent side="left">Zoom In (+)</TooltipContent>
@@ -182,8 +191,8 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
182
191
 
183
192
  <Tooltip>
184
193
  <TooltipTrigger asChild>
185
- <Button variant="ghost" size="icon-sm" onClick={handleZoomOut}>
186
- <ZoomOut className="h-4 w-4" />
194
+ <Button variant="ghost" size="icon-sm" className={cn(isMobile && 'min-h-[44px] min-w-[44px]')} onClick={handleZoomOut}>
195
+ <ZoomOut className={cn(isMobile ? 'h-5 w-5' : 'h-4 w-4')} />
187
196
  </Button>
188
197
  </TooltipTrigger>
189
198
  <TooltipContent side="left">Zoom Out (-)</TooltipContent>
@@ -191,17 +200,17 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
191
200
  </div>
192
201
  )}
193
202
 
194
- {/* Context Info (bottom-center) - Storey names */}
203
+ {/* Context Info — Storey names. Top-center on mobile (URL bar steals the bottom). */}
195
204
  {storeyNames && storeyNames.length > 0 && (
196
205
  <div className={cn(
197
206
  'absolute left-1/2 -translate-x-1/2 px-4 py-2 bg-background/80 backdrop-blur-sm rounded-full border shadow-sm',
198
- basketPresentationVisible ? 'bottom-28' : 'bottom-4',
207
+ isMobile ? 'top-4' : basketPresentationVisible ? 'bottom-28' : 'bottom-4',
199
208
  )}>
200
209
  <div className="flex items-center gap-2 text-sm">
201
210
  <Layers className="h-4 w-4 text-primary" />
202
211
  <span className="font-medium">
203
- {storeyNames.length === 1
204
- ? storeyNames[0]
212
+ {storeyNames.length === 1
213
+ ? storeyNames[0]
205
214
  : `${storeyNames.length} storeys`}
206
215
  </span>
207
216
  </div>
@@ -221,20 +230,22 @@ export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: bool
221
230
  </div>
222
231
  )}
223
232
 
224
- {/* Axis Helper (bottom-left, above scale bar) - IFC Z-up convention */}
225
- <div className="absolute bottom-16 left-4">
226
- <AxisHelper
227
- ref={axisHelperRef}
228
- rotationX={initialRotationX}
229
- rotationY={initialRotationY}
230
- />
231
- </div>
232
-
233
- {/* Scale Bar (bottom-left) */}
234
- <div className="absolute bottom-4 left-4 flex flex-col items-start gap-1">
235
- <div className="h-1 w-24 bg-foreground/80 rounded-full" />
236
- <span className="text-xs text-foreground/80">{formatScale(scale)}</span>
237
- </div>
233
+ {/* Axis Helper + Scale Bar desktop only; mobile keeps the viewport unobstructed */}
234
+ {!isMobile && (
235
+ <>
236
+ <div className="absolute bottom-16 left-4">
237
+ <AxisHelper
238
+ ref={axisHelperRef}
239
+ rotationX={initialRotationX}
240
+ rotationY={initialRotationY}
241
+ />
242
+ </div>
243
+ <div className="absolute bottom-4 left-4 flex flex-col items-start gap-1">
244
+ <div className="h-1 w-24 bg-foreground/80 rounded-full" />
245
+ <span className="text-xs text-foreground/80">{formatScale(scale)}</span>
246
+ </div>
247
+ </>
248
+ )}
238
249
  </>
239
250
  );
240
251
  }
@@ -323,5 +334,9 @@ function CesiumSettingsOverlay({
323
334
  */
324
335
  function PointCloudPanelMount() {
325
336
  const count = useViewerStore((s) => s.pointCloudAssetCount);
326
- return <PointCloudPanel assetCount={count} />;
337
+ // Triangle total comes from the merged geometry result. The panel
338
+ // gates the BIM↔scan deviation compute button on triangleCount > 0
339
+ // so the user can't trigger an empty-BVH compute pass.
340
+ const triangleCount = useViewerStore((s) => s.geometryResult?.totalTriangles ?? 0);
341
+ return <PointCloudPanel assetCount={count} triangleCount={triangleCount} />;
327
342
  }
@@ -56,6 +56,10 @@ export interface MouseHandlerContext {
56
56
  snapEnabledRef: MutableRefObject<boolean>;
57
57
  edgeLockStateRef: MutableRefObject<EdgeLockState>;
58
58
  measurementConstraintEdgeRef: MutableRefObject<MeasurementConstraintEdge | null>;
59
+ /** Section tool: when true, the next click picks a face for the clip plane (issue #243). */
60
+ sectionPickModeRef?: MutableRefObject<boolean>;
61
+ /** Renderer model bounds at click time — passed to `setSectionPlaneFromFace` so the cardinal-fallback `position` percentage is correct. */
62
+ modelBoundsRef?: MutableRefObject<{ min: { x: number; y: number; z: number }; max: { x: number; y: number; z: number } } | null>;
59
63
 
60
64
  // Visibility refs
61
65
  hiddenEntitiesRef: MutableRefObject<Set<number>>;
@@ -101,6 +105,24 @@ export interface MouseHandlerContext {
101
105
  openContextMenu: (entityId: number | null, screenX: number, screenY: number) => void;
102
106
  hasPendingMeasurements: () => boolean;
103
107
  getPickOptions: () => { isStreaming: boolean; hiddenIds: Set<number>; isolatedIds: Set<number> | null };
108
+ /** Section face-pick: set the clip plane through a world-space face (issue #243). */
109
+ setSectionPlaneFromFace?: (
110
+ normal: [number, number, number],
111
+ point: [number, number, number],
112
+ bounds?: { min: [number, number, number]; max: [number, number, number] },
113
+ ) => void;
114
+ /** Section face-pick: arm/disarm the "next click picks a face" mode. */
115
+ setSectionPickMode?: (enabled: boolean) => void;
116
+ /**
117
+ * Section face-pick: set the live hover-preview overlay (issue #243
118
+ * follow-up). Called by the dwell-aware hover handler in
119
+ * `useMouseControls.ts` when the cursor pauses ~200ms over a surface,
120
+ * and with `null` when the preview should hide (cursor leaves the
121
+ * canvas, moves to a different face, or pick mode is disarmed).
122
+ */
123
+ setSectionPickPreview?: (
124
+ preview: { normal: [number, number, number]; point: [number, number, number]; faceKey: string } | null,
125
+ ) => void;
104
126
 
105
127
  // Constants
106
128
  HOVER_SNAP_THROTTLE_MS: number;