@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.
- package/.turbo/turbo-build.log +59 -44
- package/.turbo/turbo-typecheck.log +1 -1
- package/CHANGELOG.md +488 -0
- package/dist/assets/{basketViewActivator-CA2CTcVo.js → basketViewActivator-Bzw51jhm.js} +6 -6
- package/dist/assets/decode-worker-t2EGKAxO.js +1708 -0
- package/dist/assets/drawing-2d-Bjy8YPrg.js +257 -0
- package/dist/assets/exporters-u0sz2Upj.js +259119 -0
- package/dist/assets/geometry-controller.worker-NH8pZmrU.js +7 -0
- package/dist/assets/geometry.worker-Bp4rW_R1.js +1 -0
- package/dist/assets/ids-B7AXEv7h.js +4067 -0
- package/dist/assets/ifc-lite-DfZHk36-.js +7 -0
- package/dist/assets/ifc-lite_bg-DlKs5-yM.wasm +0 -0
- package/dist/assets/ifc-lite_bg-PqmRe3Ph.wasm +0 -0
- package/dist/assets/index-CSWgTe1s.css +1 -0
- package/dist/assets/{index-D8Epw-e7.js → index-DVNSvEMh.js} +40146 -35823
- package/dist/assets/laz-perf-Cvr_Lepg.js +1 -0
- package/dist/assets/laz-perf-DnSyzVYH.wasm +0 -0
- package/dist/assets/{native-bridge-DKmx1z95.js → native-bridge-BiD01jI9.js} +1 -1
- package/dist/assets/parser.worker-Bnbrl6gy.js +182 -0
- package/dist/assets/{sandbox-tccwm5Bo.js → sandbox-DPD1ROr0.js} +4 -4
- package/dist/assets/{server-client-LoWPK1N2.js → server-client-DP8fMPY9.js} +1 -1
- package/dist/assets/{wasm-bridge-BsJGgPMs.js → wasm-bridge-CErti6zX.js} +1 -1
- package/dist/assets/workerHelpers-CBbWSJmd.js +36 -0
- package/dist/index.html +8 -8
- package/index.html +1 -1
- package/package.json +10 -10
- package/src/components/viewer/BasketPresentationDock.tsx +3 -0
- package/src/components/viewer/CesiumOverlay.tsx +165 -120
- package/src/components/viewer/DeviationPanel.tsx +172 -0
- package/src/components/viewer/HierarchyPanel.tsx +29 -3
- package/src/components/viewer/HoverTooltip.tsx +5 -0
- package/src/components/viewer/IDSAuditSummary.tsx +389 -0
- package/src/components/viewer/IDSPanel.tsx +80 -26
- package/src/components/viewer/MainToolbar.tsx +60 -7
- package/src/components/viewer/MergeLayersBanner.tsx +108 -0
- package/src/components/viewer/MobileToolbar.tsx +326 -0
- package/src/components/viewer/PointCloudClasses.tsx +111 -0
- package/src/components/viewer/PointCloudLegend.tsx +119 -0
- package/src/components/viewer/PointCloudPanel.tsx +52 -1
- package/src/components/viewer/PropertiesPanel.tsx +37 -6
- package/src/components/viewer/RectSelectionOverlay.tsx +48 -0
- package/src/components/viewer/StatusBar.tsx +14 -0
- package/src/components/viewer/ViewerLayout.tsx +288 -95
- package/src/components/viewer/Viewport.tsx +86 -18
- package/src/components/viewer/ViewportContainer.tsx +25 -11
- package/src/components/viewer/ViewportOverlays.tsx +41 -26
- package/src/components/viewer/mouseHandlerTypes.ts +22 -0
- package/src/components/viewer/properties/GeoreferencingPanel.tsx +77 -8
- package/src/components/viewer/properties/MaterialCard.tsx +2 -2
- package/src/components/viewer/selectionHandlers.ts +41 -0
- package/src/components/viewer/tools/SectionPanel.tsx +181 -24
- package/src/components/viewer/tools/SectionVisualization.tsx +384 -3
- package/src/components/viewer/useAnimationLoop.ts +22 -0
- package/src/components/viewer/useMouseControls.ts +296 -3
- package/src/components/viewer/usePointCloudSync.ts +8 -1
- package/src/components/viewer/useRenderUpdates.ts +21 -1
- package/src/components/viewer/useTouchControls.ts +100 -41
- package/src/hooks/federationLoadGate.test.ts +90 -0
- package/src/hooks/federationLoadGate.ts +127 -0
- package/src/hooks/ids/idsDataAccessor.ts +11 -259
- package/src/hooks/ingest/pointCloudIngest.ts +127 -16
- package/src/hooks/useDrawingGeneration.ts +81 -8
- package/src/hooks/useIDS.ts +90 -10
- package/src/hooks/useIfcFederation.ts +94 -16
- package/src/hooks/useIfcLoader.ts +289 -64
- package/src/hooks/useViewerSelectors.ts +10 -0
- package/src/lib/geo/cesium-bridge.ts +84 -67
- package/src/lib/geo/clamp-anchor.test.ts +80 -0
- package/src/lib/geo/clamp-anchor.ts +57 -0
- package/src/lib/geo/effective-georef.test.ts +79 -1
- package/src/lib/geo/effective-georef.ts +83 -0
- package/src/lib/geo/reproject.ts +26 -13
- package/src/lib/geo/terrain-elevation.ts +166 -0
- package/src/lib/lens/adapter.ts +1 -1
- package/src/lib/llm/context-builder.ts +1 -1
- package/src/lib/perf/memoryAccounting.test.ts +92 -0
- package/src/lib/perf/memoryAccounting.ts +235 -0
- package/src/sdk/adapters/mutation-view.ts +1 -1
- package/src/store/constants.ts +39 -2
- package/src/store/index.ts +6 -1
- package/src/store/slices/cesiumSlice.ts +1 -1
- package/src/store/slices/idsSlice.ts +24 -0
- package/src/store/slices/loadingSlice.ts +12 -0
- package/src/store/slices/pointCloudSlice.ts +72 -1
- package/src/store/slices/sectionSlice.test.ts +590 -1
- package/src/store/slices/sectionSlice.ts +344 -17
- package/src/store/slices/uiSlice.merge-layers.test.ts +217 -0
- package/src/store/slices/uiSlice.ts +60 -2
- package/src/store/types.ts +42 -0
- package/src/store.ts +13 -0
- package/src/utils/acquireFileBuffer.test.ts +231 -0
- package/src/utils/acquireFileBuffer.ts +128 -0
- package/src/utils/ifcConfig.ts +24 -0
- package/src/utils/nativeSpatialDataStore.ts +20 -2
- package/src/utils/spatialHierarchy.test.ts +116 -0
- package/src/utils/spatialHierarchy.ts +23 -0
- package/tailwind.config.js +5 -0
- package/tsconfig.json +1 -0
- package/vite.config.ts +6 -0
- package/dist/assets/decode-worker-Collf_X_.js +0 -1320
- package/dist/assets/drawing-2d-DoxKMqbO.js +0 -257
- package/dist/assets/exporters-xbXqEDlO.js +0 -81590
- package/dist/assets/geometry.worker-DQEZB2rB.js +0 -1
- package/dist/assets/ids-2WdONLlu.js +0 -2033
- package/dist/assets/ifc-lite_bg-4yUkDRD8.wasm +0 -0
- package/dist/assets/index-BXeEKqJG.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 {
|
|
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:
|
|
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',
|
|
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',
|
|
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((
|
|
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
|
-
{
|
|
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
|
-
<
|
|
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 ? '
|
|
206
|
+
variant={!isCustom && sectionPlane.axis === axis ? 'secondary' : 'ghost'}
|
|
97
207
|
size="sm"
|
|
98
|
-
className="flex-1
|
|
208
|
+
className="flex-1 h-7 px-2 text-[11px]"
|
|
99
209
|
onClick={() => handleAxisChange(axis)}
|
|
100
210
|
>
|
|
101
|
-
<span className="
|
|
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
|
|
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">
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
{
|
|
178
|
-
?
|
|
179
|
-
:
|
|
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
|
|