@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
@@ -8,7 +8,7 @@
8
8
  */
9
9
 
10
10
  import { useState, useCallback, useMemo } from 'react';
11
- import { Globe, MapPin, PenLine, Check, X, Search, ChevronRight, Mountain } from 'lucide-react';
11
+ import { Globe, MapPin, PenLine, Check, X, Search, ChevronRight, Mountain, AlertTriangle } from 'lucide-react';
12
12
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
13
13
  import { Badge } from '@/components/ui/badge';
14
14
  import { computeAngleToGridNorth, type GeoreferenceInfo, type MapConversion, type ProjectedCRS } from '@ifc-lite/parser';
@@ -16,7 +16,8 @@ import { useViewerStore } from '@/store';
16
16
  import type { CoordinateInfo, GeometryResult } from '@ifc-lite/geometry';
17
17
  import { EpsgLookupDialog, type EpsgResult } from './EpsgLookupDialog';
18
18
  import { LocationMap, type PickedPosition } from './LocationMap';
19
- import { mergeMapConversion, mergeProjectedCRS } from '@/lib/geo/effective-georef';
19
+ import { findClampAnchorY } from '@/lib/geo/clamp-anchor';
20
+ import { detectScaleUnitMismatch, mergeMapConversion, mergeProjectedCRS } from '@/lib/geo/effective-georef';
20
21
  import { useIfc } from '@/hooks/useIfc';
21
22
  import { toast } from '@/components/ui/toast';
22
23
 
@@ -324,9 +325,12 @@ export interface GeoreferencingPanelProps {
324
325
  geometryResult?: GeometryResult | null;
325
326
  /** IFC project length unit → metres (e.g. 0.001 for mm models). Default 1. */
326
327
  lengthUnitScale?: number;
328
+ /** IfcBuildingStorey elevations (express id → metres, viewer-Y aligned).
329
+ * Used to anchor the model's ground floor to terrain. */
330
+ storeyElevations?: Map<number, number>;
327
331
  }
328
332
 
329
- export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVersion, coordinateInfo, geometryResult, lengthUnitScale }: GeoreferencingPanelProps) {
333
+ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVersion, coordinateInfo, geometryResult, lengthUnitScale, storeyElevations }: GeoreferencingPanelProps) {
330
334
  const georefMutations = useViewerStore(s => s.georefMutations);
331
335
  const setGeorefField = useViewerStore(s => s.setGeorefField);
332
336
  const setGeorefFields = useViewerStore(s => s.setGeorefFields);
@@ -361,6 +365,15 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
361
365
  return computeAngleToGridNorth(mergedConversion?.xAxisAbscissa, mergedConversion?.xAxisOrdinate);
362
366
  }, [mergedConversion]);
363
367
 
368
+ const scaleMismatch = useMemo(() => {
369
+ if (!mergedConversion) return null;
370
+ return detectScaleUnitMismatch(
371
+ mergedConversion.scale,
372
+ mergedCRS?.mapUnitScale,
373
+ lengthUnitScale,
374
+ );
375
+ }, [mergedConversion, mergedCRS?.mapUnitScale, lengthUnitScale]);
376
+
364
377
  const mapUnitSuffix = useMemo(() => {
365
378
  const mapUnit = mergedCRS?.mapUnit?.toUpperCase();
366
379
  if (!mapUnit) return 'm';
@@ -376,6 +389,26 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
376
389
  return meters; // already meters
377
390
  }, [mapUnitSuffix]);
378
391
 
392
+ /**
393
+ * Given a target world altitude (metres) for the model's ground floor
394
+ * (the storey nearest elevation 0, falling back to bounds.min.y when
395
+ * no storeys are present), return the IfcMapConversion.OrthogonalHeight
396
+ * value (in map units, rounded to 0.01) that would put the ground floor
397
+ * there — accounting for any RTC / origin shifts the geometry pipeline
398
+ * applied. This mirrors the auto-clamp formula so the "Set
399
+ * OrthogonalHeight to Cesium terrain elevation" button produces the same
400
+ * world position as toggling the clamp.
401
+ */
402
+ const oHeightForBaseAltitude = useCallback((targetBaseAltitude: number): number => {
403
+ const bounds = coordinateInfo?.originalBounds;
404
+ const anchorY = findClampAnchorY(bounds, storeyElevations);
405
+ const shiftY = coordinateInfo?.originShift?.y ?? 0;
406
+ // RTC offset is in IFC Z-up; viewer Y-up takes its Z component.
407
+ const rtcYupY = coordinateInfo?.wasmRtcOffset?.z ?? 0;
408
+ const targetOHeightMeters = targetBaseAltitude - shiftY - rtcYupY - anchorY;
409
+ return Math.round(metersToMapUnit(targetOHeightMeters) * 100) / 100;
410
+ }, [coordinateInfo, storeyElevations, metersToMapUnit]);
411
+
379
412
  const isMutated = useCallback((entity: 'projectedCRS' | 'mapConversion', field: string): boolean => {
380
413
  if (!mutations) return false;
381
414
  const entityMuts = mutations[entity];
@@ -434,8 +467,14 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
434
467
  ? mergedCRS?.[field as keyof ProjectedCRS]
435
468
  : mergedConversion?.[field as keyof MapConversion];
436
469
  setGeorefField(modelId, entity, field, value, oldValue as string | number | undefined);
470
+ // Editing OrthogonalHeight implies "I want this exact altitude" — auto
471
+ // -release the terrain clamp so the new value actually takes effect
472
+ // (with clamp on, placement is locked to terrain regardless of oHeight).
473
+ if (entity === 'mapConversion' && field === 'orthogonalHeight' && terrainClamp) {
474
+ setCesiumTerrainClamp(false);
475
+ }
437
476
  requestAlignmentReload();
438
- }, [modelId, setGeorefField, mergedCRS, mergedConversion, requestAlignmentReload]);
477
+ }, [modelId, setGeorefField, mergedCRS, mergedConversion, requestAlignmentReload, terrainClamp, setCesiumTerrainClamp]);
439
478
 
440
479
  // Handle angle edit: compute and set both XAxisAbscissa and XAxisOrdinate
441
480
  const handleAngleChange = useCallback((abscissa: number, ordinate: number) => {
@@ -455,16 +494,19 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
455
494
  { field: 'northings', value: position.northing, oldValue: mergedConversion?.northings },
456
495
  ];
457
496
  if (position.terrainHeight !== null) {
497
+ // position.terrainHeight is the world altitude where the user wants the
498
+ // base of the model — translate to OrthogonalHeight using the same
499
+ // bounds/shift accounting as the auto-clamp path.
458
500
  fields.push({
459
501
  field: 'orthogonalHeight',
460
- value: Math.round(position.terrainHeight * 10) / 10,
502
+ value: oHeightForBaseAltitude(position.terrainHeight),
461
503
  oldValue: mergedConversion?.orthogonalHeight,
462
504
  });
463
505
  }
464
506
  setGeorefFields(modelId, 'mapConversion', fields);
465
507
  setConversionOpen(true);
466
508
  requestAlignmentReload();
467
- }, [modelId, setGeorefFields, mergedConversion, requestAlignmentReload]);
509
+ }, [modelId, setGeorefFields, mergedConversion, requestAlignmentReload, oHeightForBaseAltitude]);
468
510
 
469
511
  const initializeMapConversionDefaults = useCallback(() => {
470
512
  if (!modelId || !setGeorefFields) return;
@@ -646,6 +688,19 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
646
688
  <ChevronRight className={`h-3 w-3 text-teal-500 shrink-0 transition-transform ${conversionOpen ? 'rotate-90' : ''}`} />
647
689
  <MapPin className="h-3 w-3 text-teal-500 shrink-0" />
648
690
  <span className="font-bold text-[11px] text-zinc-700 dark:text-zinc-300 uppercase tracking-wide flex-1 text-left">Coordinate Operation</span>
691
+ {scaleMismatch && (
692
+ <Tooltip>
693
+ <TooltipTrigger asChild>
694
+ <AlertTriangle
695
+ className="h-3 w-3 text-amber-500 shrink-0"
696
+ aria-label="Scale inconsistent with project/map units"
697
+ />
698
+ </TooltipTrigger>
699
+ <TooltipContent>
700
+ Scale inconsistent with project/map units — expand to view details
701
+ </TooltipContent>
702
+ </Tooltip>
703
+ )}
649
704
  {!conversionOpen && (
650
705
  <span className="text-[10px] font-mono text-teal-600/70 dark:text-teal-500/60">
651
706
  E {mergedConversion.eastings.toFixed(0)} N {mergedConversion.northings.toFixed(0)}
@@ -658,12 +713,26 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
658
713
  <GeorefRow label="Eastings" value={mergedConversion.eastings} suffix={mapUnitSuffix} isNumber editable={editable} isMutated={isMutated('mapConversion', 'eastings')} fieldEntity="mapConversion" fieldName="eastings" onSave={v => handleSave('mapConversion', 'eastings', v)} />
659
714
  <GeorefRow label="Northings" value={mergedConversion.northings} suffix={mapUnitSuffix} isNumber editable={editable} isMutated={isMutated('mapConversion', 'northings')} fieldEntity="mapConversion" fieldName="northings" onSave={v => handleSave('mapConversion', 'northings', v)} />
660
715
  <GeorefRow label="OrthogonalHeight" value={mergedConversion.orthogonalHeight} suffix={mapUnitSuffix} isNumber editable={editable} isMutated={isMutated('mapConversion', 'orthogonalHeight')} fieldEntity="mapConversion" fieldName="orthogonalHeight" onSave={v => handleSave('mapConversion', 'orthogonalHeight', v)}>
661
- <TerrainHeightButton modelId={modelId} editable={editable} onApply={(h) => handleSave('mapConversion', 'orthogonalHeight', Math.round(metersToMapUnit(h) * 100) / 100)} />
716
+ <TerrainHeightButton modelId={modelId} editable={editable} onApply={(h) => handleSave('mapConversion', 'orthogonalHeight', oHeightForBaseAltitude(h))} />
662
717
  </GeorefRow>
663
718
  <GeorefRow label="XAxisAbscissa" value={mergedConversion.xAxisAbscissa} isNumber editable={editable} isMutated={isMutated('mapConversion', 'xAxisAbscissa')} fieldEntity="mapConversion" fieldName="xAxisAbscissa" onSave={v => handleSave('mapConversion', 'xAxisAbscissa', v)} />
664
719
  <GeorefRow label="XAxisOrdinate" value={mergedConversion.xAxisOrdinate} isNumber editable={editable} isMutated={isMutated('mapConversion', 'xAxisOrdinate')} fieldEntity="mapConversion" fieldName="xAxisOrdinate" onSave={v => handleSave('mapConversion', 'xAxisOrdinate', v)} />
665
720
  <AngleRow angle={angleToGridNorth} editable={editable} onAngleChange={handleAngleChange} />
666
721
  <GeorefRow label="Scale" value={mergedConversion.scale} isNumber editable={editable} isMutated={isMutated('mapConversion', 'scale')} fieldEntity="mapConversion" fieldName="scale" onSave={v => handleSave('mapConversion', 'scale', v)} />
722
+ {scaleMismatch && (
723
+ <div className="px-3 py-2 flex items-start gap-1.5 text-[10px] text-amber-600 dark:text-amber-400 bg-amber-50/50 dark:bg-amber-950/20">
724
+ <AlertTriangle className="h-3 w-3 mt-0.5 shrink-0" />
725
+ <span className="leading-snug">
726
+ <strong>Scale inconsistent with project/map units.</strong>{' '}
727
+ Per IFC schema, IfcMapConversion.Scale should bridge the unit
728
+ difference between the project length unit and map CRS unit.
729
+ Current Scale = {scaleMismatch.rawScale}; expected ≈{' '}
730
+ {scaleMismatch.expectedScale.toPrecision(4)}. Geometry is
731
+ being placed at {scaleMismatch.effectiveScale.toPrecision(4)}×
732
+ its physical size — adjust Scale (or MapUnit) to fix.
733
+ </span>
734
+ </div>
735
+ )}
667
736
  </div>
668
737
  )}
669
738
  </div>
@@ -707,7 +776,7 @@ export function GeoreferencingPanel({ georef, modelId, enableEditing, schemaVers
707
776
  {cesiumTerrainHeight !== null && editable && modelId && (
708
777
  <div className="flex items-center gap-1 ml-5">
709
778
  <button
710
- onClick={() => handleSave('mapConversion', 'orthogonalHeight', Math.round(metersToMapUnit(cesiumTerrainHeight) * 100) / 100)}
779
+ onClick={() => handleSave('mapConversion', 'orthogonalHeight', oHeightForBaseAltitude(cesiumTerrainHeight))}
711
780
  className="text-[9px] text-teal-500 hover:text-teal-700 dark:hover:text-teal-300 transition-colors flex items-center gap-0.5"
712
781
  >
713
782
  <Mountain className="h-2.5 w-2.5" />
@@ -170,8 +170,8 @@ export function MaterialCard({ material }: { material: MaterialInfo }) {
170
170
  {/* Material List */}
171
171
  {material.type === 'MaterialList' && material.materials && (
172
172
  <>
173
- {material.materials.map((name, i) => (
174
- <MaterialRow key={i} label={`Material ${i + 1}`} value={name} />
173
+ {material.materials.map((m, i) => (
174
+ <MaterialRow key={i} label={`Material ${i + 1}`} value={m.name} />
175
175
  ))}
176
176
  </>
177
177
  )}
@@ -39,6 +39,47 @@ export async function handleSelectionClick(ctx: MouseHandlerContext, e: MouseEve
39
39
  return; // Skip click handling for measure tool
40
40
  }
41
41
 
42
+ // Section-tool face-pick (issue #243): clicking any visible face places
43
+ // the clip plane through it. Intercept BEFORE the generic select path
44
+ // so the click doesn't also flip the selection.
45
+ //
46
+ // Camera-aware orientation: we flip the picked normal if it faces away
47
+ // from the camera, so the kept half-space is the one the user is looking
48
+ // at by default (the most common expectation; if the cut goes the wrong
49
+ // way the existing Flip button still works). This addresses the
50
+ // CodeRabbit minor on PR #581 about face-pick not being camera-aware.
51
+ if (tool === 'section' && ctx.sectionPickModeRef?.current) {
52
+ const hit = renderer.raycastScene(x, y, {
53
+ hiddenIds: ctx.hiddenEntitiesRef.current,
54
+ isolatedIds: ctx.isolatedEntitiesRef.current,
55
+ });
56
+ if (hit?.intersection) {
57
+ const n = hit.intersection.normal;
58
+ const p = hit.intersection.point;
59
+ const cam = renderer.getCamera().getPosition();
60
+ // View vector = camera → hit. If `dot(view, normal) > 0` the normal
61
+ // points away from the camera; invert so the cut keeps the side
62
+ // facing the user.
63
+ const vx = cam.x - p.x, vy = cam.y - p.y, vz = cam.z - p.z;
64
+ const dot = vx * n.x + vy * n.y + vz * n.z;
65
+ const sign = dot < 0 ? -1 : 1;
66
+ const bounds = ctx.modelBoundsRef?.current;
67
+ ctx.setSectionPlaneFromFace?.(
68
+ [sign * n.x, sign * n.y, sign * n.z],
69
+ [p.x, p.y, p.z],
70
+ bounds ? {
71
+ min: [bounds.min.x, bounds.min.y, bounds.min.z],
72
+ max: [bounds.max.x, bounds.max.y, bounds.max.z],
73
+ } : undefined,
74
+ );
75
+ } else {
76
+ // Missed geometry — disarm so the user isn't stuck in pick mode
77
+ // after an errant background click.
78
+ ctx.setSectionPickMode?.(false);
79
+ }
80
+ return;
81
+ }
82
+
42
83
  // Add-element tool — multi-click placement (start→end for walls/beams,
43
84
  // corner→opposite for slab rectangle, N+Enter for slab polygon, single
44
85
  // for columns). Uses magnetic snap so points lock to vertices/edges
@@ -6,10 +6,10 @@
6
6
  * Section plane controls panel
7
7
  */
8
8
 
9
- import React, { useCallback, useState } from 'react';
10
- import { X, Slice, ChevronDown, FileImage, FlipHorizontal2 } from 'lucide-react';
9
+ import React, { useCallback, useEffect, useState } from 'react';
10
+ import { X, Slice, ChevronDown, FileImage, FlipHorizontal2, MousePointerClick, RotateCcw } from 'lucide-react';
11
11
  import { Button } from '@/components/ui/button';
12
- import { useViewerStore } from '@/store';
12
+ import { useViewerStore, loadLastSectionMode } from '@/store';
13
13
  import { AXIS_INFO } from './sectionConstants';
14
14
  import { SectionPlaneVisualization } from './SectionVisualization';
15
15
  import { SectionCapControls } from './SectionCapControls';
@@ -20,11 +20,18 @@ export function SectionOverlay() {
20
20
  const setSectionPlanePosition = useViewerStore((s) => s.setSectionPlanePosition);
21
21
  const toggleSectionPlane = useViewerStore((s) => s.toggleSectionPlane);
22
22
  const flipSectionPlane = useViewerStore((s) => s.flipSectionPlane);
23
+ // Face-pick + custom plane actions (issue #243).
24
+ const sectionPickMode = useViewerStore((s) => s.sectionPickMode);
25
+ const setSectionPickMode = useViewerStore((s) => s.setSectionPickMode);
26
+ const setSectionCustomDistance = useViewerStore((s) => s.setSectionCustomDistance);
27
+ const setPreviewStride = useViewerStore((s) => s.setPointCloudPreviewStride);
28
+ const pointCloudAssetCount = useViewerStore((s) => s.pointCloudAssetCount);
23
29
  const setActiveTool = useViewerStore((s) => s.setActiveTool);
24
30
  const setDrawingPanelVisible = useViewerStore((s) => s.setDrawing2DPanelVisible);
25
31
  const drawingPanelVisible = useViewerStore((s) => s.drawing2DPanelVisible);
26
32
  const clearDrawing = useViewerStore((s) => s.clearDrawing2D);
27
33
  const [isPanelCollapsed, setIsPanelCollapsed] = useState(true);
34
+ const isCustom = sectionPlane.custom !== undefined;
28
35
 
29
36
  const handleClose = useCallback(() => {
30
37
  setActiveTool('select');
@@ -34,6 +41,26 @@ export function SectionOverlay() {
34
41
  setSectionPlaneAxis(axis);
35
42
  }, [setSectionPlaneAxis]);
36
43
 
44
+ // Toggle the "next click picks a face" arming. The actual click is
45
+ // intercepted in `selectionHandlers.ts`, which calls
46
+ // `setSectionPlaneFromFace` and clears pick mode for us. (issue #243)
47
+ const handleTogglePickMode = useCallback(() => {
48
+ setSectionPickMode(!sectionPickMode);
49
+ }, [sectionPickMode, setSectionPickMode]);
50
+
51
+ // "Reset to axis" in custom mode — clearing the custom field via
52
+ // setSectionPlaneAxis re-uses the existing cardinal pathway. We pick
53
+ // the nearest cardinal that's already in `axis` (kept in sync at pick
54
+ // time) so the user lands on the closest preset they had before.
55
+ const handleResetToAxis = useCallback(() => {
56
+ setSectionPlaneAxis(sectionPlane.axis);
57
+ }, [sectionPlane.axis, setSectionPlaneAxis]);
58
+
59
+ const handleCustomDistanceChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
60
+ const v = Number(e.target.value);
61
+ if (Number.isFinite(v)) setSectionCustomDistance(v);
62
+ }, [setSectionCustomDistance]);
63
+
37
64
  const handlePositionChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
38
65
  const value = Number(e.target.value);
39
66
  if (!Number.isNaN(value)) {
@@ -41,6 +68,65 @@ export function SectionOverlay() {
41
68
  }
42
69
  }, [setSectionPlanePosition]);
43
70
 
71
+ // Section-plane drag preview: while the user is actively dragging
72
+ // the position slider, render the splat shader at 1/4 density so
73
+ // huge scans (>10M points) keep up. Restored on release.
74
+ const handleSliderDragStart = useCallback(() => {
75
+ if (pointCloudAssetCount > 0) setPreviewStride(4);
76
+ }, [setPreviewStride, pointCloudAssetCount]);
77
+ const handleSliderDragEnd = useCallback(() => {
78
+ setPreviewStride(1);
79
+ }, [setPreviewStride]);
80
+ // Reset stride if the panel disappears mid-drag (e.g. user closes
81
+ // the section tool without releasing the slider). Without this the
82
+ // store can stay stuck at 4 and keep scans thinned indefinitely.
83
+ useEffect(() => {
84
+ return () => setPreviewStride(1);
85
+ }, [setPreviewStride]);
86
+
87
+ // Restore the user's last-used section mode when the panel mounts
88
+ // (issue #243 follow-up). Two modes round-trip via localStorage:
89
+ //
90
+ // • 'pick' — face-pick is the default for first-time users and
91
+ // anyone whose last action was a face pick. The 200ms
92
+ // debounce stops the click that opened the tool from
93
+ // bleeding through to the canvas pick handler and
94
+ // accidentally sectioning the floor on the same frame
95
+ // the panel mounts.
96
+ // • 'cardinal' — restore axis + position + flipped so the cut
97
+ // appears exactly where the user left it. Section is
98
+ // enabled by these setters so the cut is immediately
99
+ // visible — matches the user's mental model of
100
+ // "opening the panel where I left it".
101
+ //
102
+ // Cleanup disarms pick mode on unmount so leaving the tool doesn't
103
+ // leave pick mode armed for the next tool.
104
+ useEffect(() => {
105
+ const mode = loadLastSectionMode();
106
+ let armTimer: ReturnType<typeof setTimeout> | null = null;
107
+
108
+ if (mode.kind === 'cardinal') {
109
+ // Read current flipped via getState() so we don't pull the live
110
+ // store value into the dep array (which would re-run the effect
111
+ // every flip and clobber the restore on each interaction).
112
+ const currentFlipped = useViewerStore.getState().sectionPlane.flipped;
113
+ setSectionPlaneAxis(mode.axis);
114
+ setSectionPlanePosition(mode.position);
115
+ if (currentFlipped !== mode.flipped) flipSectionPlane();
116
+ } else {
117
+ armTimer = setTimeout(() => setSectionPickMode(true), 200);
118
+ }
119
+
120
+ return () => {
121
+ if (armTimer !== null) clearTimeout(armTimer);
122
+ setSectionPickMode(false);
123
+ };
124
+ // The setters are stable refs from zustand; flipSectionPlane reads
125
+ // current state via getState() so it's intentionally NOT in the dep
126
+ // array (would cause the restore to re-run on every flip).
127
+ // eslint-disable-next-line react-hooks/exhaustive-deps
128
+ }, [setSectionPickMode, setSectionPlaneAxis, setSectionPlanePosition, flipSectionPlane]);
129
+
44
130
  const togglePanel = useCallback(() => {
45
131
  setIsPanelCollapsed(prev => !prev);
46
132
  }, []);
@@ -65,7 +151,10 @@ export function SectionOverlay() {
65
151
  <span className="font-medium text-sm">Section</span>
66
152
  {sectionPlane.enabled && (
67
153
  <span className="text-xs text-primary font-mono">
68
- {AXIS_INFO[sectionPlane.axis].label} <span className="inline-block w-12 text-right tabular-nums">{sectionPlane.position.toFixed(1)}%</span>
154
+ {isCustom
155
+ ? <>Custom <span className="inline-block w-16 text-right tabular-nums">{sectionPlane.custom!.distance.toFixed(2)}m</span></>
156
+ : <>{AXIS_INFO[sectionPlane.axis].label} <span className="inline-block w-12 text-right tabular-nums">{sectionPlane.position.toFixed(1)}%</span></>
157
+ }
69
158
  </span>
70
159
  )}
71
160
  <ChevronDown className={`h-3 w-3 transition-transform ${isPanelCollapsed ? '-rotate-90' : ''}`} />
@@ -86,28 +175,71 @@ export function SectionOverlay() {
86
175
  {/* Expandable content */}
87
176
  {!isPanelCollapsed && (
88
177
  <div className="border-t px-3 pb-3 min-w-72">
89
- {/* Direction Selection */}
178
+ {/* Direction Selection. "Pick face" is the primary affordance —
179
+ face-pick auto-arms on tool open (issue #243 follow-up) and
180
+ matches Bonsai/Revit point-and-cut UX. Cardinal presets are
181
+ demoted to a secondary row below for power users who want
182
+ an axis-aligned cut without picking a surface. */}
90
183
  <div className="mt-3">
91
- <div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1.5">Direction</div>
184
+ <Button
185
+ variant={sectionPickMode || isCustom ? 'default' : 'outline'}
186
+ size="sm"
187
+ className="w-full flex-col h-auto py-1.5"
188
+ onClick={handleTogglePickMode}
189
+ aria-pressed={sectionPickMode}
190
+ title={
191
+ sectionPickMode
192
+ ? 'Click any face in the viewport to cut through it'
193
+ : 'Pick a face to cut through (Bonsai-style)'
194
+ }
195
+ >
196
+ <span className="text-xs font-medium flex items-center gap-1">
197
+ <MousePointerClick className="h-3 w-3" />
198
+ {sectionPickMode ? 'Click a face to cut…' : isCustom ? 'Custom (pick again)' : 'Pick face'}
199
+ </span>
200
+ </Button>
201
+ <div className="mt-2 text-[10px] uppercase tracking-wider text-muted-foreground/70 mb-1">or pick an axis</div>
92
202
  <div className="flex gap-1">
93
203
  {(['down', 'front', 'side'] as const).map((axis) => (
94
204
  <Button
95
205
  key={axis}
96
- variant={sectionPlane.axis === axis ? 'default' : 'outline'}
206
+ variant={!isCustom && sectionPlane.axis === axis ? 'secondary' : 'ghost'}
97
207
  size="sm"
98
- className="flex-1 flex-col h-auto py-1.5"
208
+ className="flex-1 h-7 px-2 text-[11px]"
99
209
  onClick={() => handleAxisChange(axis)}
100
210
  >
101
- <span className="text-xs font-medium">{AXIS_INFO[axis].label}</span>
211
+ <span className="font-normal">{AXIS_INFO[axis].label}</span>
102
212
  </Button>
103
213
  ))}
104
214
  </div>
215
+ {isCustom && (
216
+ <div className="mt-2 flex items-center justify-between text-[10px] font-mono text-muted-foreground bg-muted/50 rounded px-2 py-1">
217
+ <span title="Custom plane normal (world-space unit vector)">
218
+ n=({sectionPlane.custom!.normal.map((v) => v.toFixed(2)).join(', ')})
219
+ </span>
220
+ <Button
221
+ variant="ghost"
222
+ size="icon-sm"
223
+ onClick={handleResetToAxis}
224
+ title="Reset to nearest cardinal axis"
225
+ className="h-5 w-5"
226
+ >
227
+ <RotateCcw className="h-3 w-3" />
228
+ </Button>
229
+ </div>
230
+ )}
105
231
  </div>
106
232
 
107
- {/* Position Slider */}
233
+ {/* Position. In cardinal mode this is a 0..100% slider along the
234
+ axis. In custom mode (issue #243) the numeric input becomes
235
+ a precise signed distance in world units along the picked
236
+ normal; the slider still works (it shifts the plane by a
237
+ small amount along the normal — see sectionSlice). */}
108
238
  <div className="mt-3">
109
239
  <div className="flex items-center justify-between mb-1">
110
- <div className="text-[10px] uppercase tracking-wider text-muted-foreground">Position</div>
240
+ <div className="text-[10px] uppercase tracking-wider text-muted-foreground">
241
+ {isCustom ? 'Distance (m)' : 'Position'}
242
+ </div>
111
243
  <div className="flex items-center gap-1">
112
244
  <Button
113
245
  variant={sectionPlane.flipped ? 'default' : 'ghost'}
@@ -119,16 +251,27 @@ export function SectionOverlay() {
119
251
  >
120
252
  <FlipHorizontal2 className="h-3 w-3" />
121
253
  </Button>
122
- <input
123
- type="number"
124
- min="0"
125
- max="100"
126
- step="0.1"
127
- value={sectionPlane.position}
128
- onChange={handlePositionChange}
129
- aria-label="Section plane position percentage"
130
- className="w-16 text-xs font-mono bg-muted px-1.5 py-0.5 rounded border-none text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
131
- />
254
+ {isCustom ? (
255
+ <input
256
+ type="number"
257
+ step="0.05"
258
+ value={sectionPlane.custom!.distance.toFixed(3)}
259
+ onChange={handleCustomDistanceChange}
260
+ aria-label="Section plane distance along picked normal (world units)"
261
+ className="w-20 text-xs font-mono bg-muted px-1.5 py-0.5 rounded border-none text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
262
+ />
263
+ ) : (
264
+ <input
265
+ type="number"
266
+ min="0"
267
+ max="100"
268
+ step="0.1"
269
+ value={sectionPlane.position}
270
+ onChange={handlePositionChange}
271
+ aria-label="Section plane position percentage"
272
+ className="w-16 text-xs font-mono bg-muted px-1.5 py-0.5 rounded border-none text-right [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
273
+ />
274
+ )}
132
275
  </div>
133
276
  </div>
134
277
  <input
@@ -138,6 +281,16 @@ export function SectionOverlay() {
138
281
  step="0.1"
139
282
  value={sectionPlane.position}
140
283
  onChange={handlePositionChange}
284
+ onPointerDown={handleSliderDragStart}
285
+ onPointerUp={handleSliderDragEnd}
286
+ // pointercancel + blur cover the cases where the
287
+ // browser steals capture (touch scroll, OS gesture)
288
+ // or the user tabs away without releasing — the
289
+ // store would otherwise stay at stride 4.
290
+ onPointerCancel={handleSliderDragEnd}
291
+ onBlur={handleSliderDragEnd}
292
+ onKeyDown={handleSliderDragStart}
293
+ onKeyUp={handleSliderDragEnd}
141
294
  aria-label="Section plane position slider"
142
295
  className="w-full h-2 bg-muted rounded-lg appearance-none cursor-pointer accent-primary"
143
296
  />
@@ -174,9 +327,13 @@ export function SectionOverlay() {
174
327
  }}
175
328
  >
176
329
  <span className="font-mono text-xs uppercase tracking-wide">
177
- {sectionPlane.enabled
178
- ? `Cut ${AXIS_INFO[sectionPlane.axis].label.toLowerCase()} at ${sectionPlane.position.toFixed(1)}%${sectionPlane.flipped ? ' (flipped)' : ''}`
179
- : 'Clip off — drag slider to cut'}
330
+ {sectionPickMode
331
+ ? 'Hover a surface to preview, click to cut'
332
+ : sectionPlane.enabled
333
+ ? isCustom
334
+ ? `Custom cut at d=${sectionPlane.custom!.distance.toFixed(2)}m${sectionPlane.flipped ? ' (flipped)' : ''}`
335
+ : `Cut ${AXIS_INFO[sectionPlane.axis].label.toLowerCase()} at ${sectionPlane.position.toFixed(1)}%${sectionPlane.flipped ? ' (flipped)' : ''}`
336
+ : 'Clip off — drag slider to cut'}
180
337
  </span>
181
338
  </div>
182
339