@ifc-lite/viewer 1.15.0 → 1.17.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 +46 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/CHANGELOG.md +35 -0
- package/dist/assets/{Arrow.dom-OVBBPqOB.js → Arrow.dom-CcoDLP6E.js} +1 -1
- package/dist/assets/{basketViewActivator-Bx6QU4ma.js → basketViewActivator-FtbS__bG.js} +1 -1
- package/dist/assets/{browser-BMqEoJw4.js → browser-CXd3z0DO.js} +1 -1
- package/dist/assets/ifc-lite-TI3u_Zyw.js +7 -0
- package/dist/assets/ifc-lite_bg-DeZrXTKQ.wasm +0 -0
- package/dist/assets/index-Ba4eoTe7.css +1 -0
- package/dist/assets/{index-DZY6uD8A.js → index-D99fzcwI.js} +32109 -28671
- package/dist/assets/{index-DsX-NCtx.js → index-DqNiuQep.js} +4 -4
- package/dist/assets/{native-bridge-D6tKFqGO.js → native-bridge-DjDj2M6p.js} +1 -1
- package/dist/assets/{wasm-bridge-D4kvZVDw.js → wasm-bridge-CDTF4ZQc.js} +1 -1
- package/dist/assets/workerHelpers-G7llXNMi.js +36 -0
- package/dist/index.html +7 -2
- package/index.html +5 -0
- package/package.json +15 -14
- package/src/components/viewer/BCFPanel.tsx +12 -0
- package/src/components/viewer/BulkPropertyEditor.tsx +315 -154
- package/src/components/viewer/CommandPalette.tsx +0 -6
- package/src/components/viewer/DataConnector.tsx +489 -284
- package/src/components/viewer/ExportDialog.tsx +66 -6
- package/src/components/viewer/KeyboardShortcutsDialog.tsx +227 -82
- package/src/components/viewer/MainToolbar.tsx +1 -5
- package/src/components/viewer/Viewport.tsx +42 -56
- package/src/components/viewer/ViewportContainer.tsx +3 -0
- package/src/components/viewer/ViewportOverlays.tsx +12 -10
- package/src/components/viewer/bcf/BCFOverlay.tsx +254 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +26 -20
- package/src/components/viewer/hierarchy/ifc-icons.ts +90 -0
- package/src/components/viewer/lists/ListPanel.tsx +0 -21
- package/src/components/viewer/lists/ListResultsTable.tsx +93 -5
- package/src/components/viewer/measureHandlers.ts +558 -0
- package/src/components/viewer/mouseHandlerTypes.ts +108 -0
- package/src/components/viewer/selectionHandlers.ts +86 -0
- package/src/components/viewer/useAnimationLoop.ts +116 -44
- package/src/components/viewer/useGeometryStreaming.ts +155 -367
- package/src/components/viewer/useKeyboardControls.ts +30 -46
- package/src/components/viewer/useMouseControls.ts +169 -695
- package/src/components/viewer/useRenderUpdates.ts +9 -59
- package/src/components/viewer/useTouchControls.ts +55 -40
- package/src/hooks/bcfIdLookup.ts +70 -0
- package/src/hooks/useBCF.ts +12 -31
- package/src/hooks/useIfcCache.ts +11 -29
- package/src/hooks/useIfcFederation.ts +5 -11
- package/src/hooks/useIfcLoader.ts +47 -56
- package/src/hooks/useIfcServer.ts +9 -1
- package/src/hooks/useKeyboardShortcuts.ts +28 -12
- package/src/hooks/useLatestRef.ts +24 -0
- package/src/sdk/adapters/export-adapter.ts +2 -2
- package/src/sdk/adapters/model-adapter.ts +1 -0
- package/src/sdk/local-backend.ts +2 -0
- package/src/store/basketVisibleSet.ts +12 -0
- package/src/store/slices/bcfSlice.ts +9 -0
- package/src/store/slices/pinboardSlice.ts +46 -45
- package/src/utils/loadingUtils.ts +46 -0
- package/src/utils/serverDataModel.ts +4 -3
- package/src/utils/spatialHierarchy.ts +1 -1
- package/src/vite-env.d.ts +6 -2
- package/vite.config.ts +75 -23
- package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
- package/dist/assets/index-CJr7Itua.css +0 -1
|
@@ -23,9 +23,9 @@ import {
|
|
|
23
23
|
useIfcDataState,
|
|
24
24
|
} from '../../hooks/useViewerSelectors.js';
|
|
25
25
|
import { useModelSelection } from '../../hooks/useModelSelection.js';
|
|
26
|
+
import { useLatestRef } from '../../hooks/useLatestRef.js';
|
|
26
27
|
import {
|
|
27
28
|
getEntityBounds,
|
|
28
|
-
getEntityCenter,
|
|
29
29
|
getThemeClearColor,
|
|
30
30
|
type ViewportStateRefs,
|
|
31
31
|
} from '../../utils/viewportUtils.js';
|
|
@@ -127,6 +127,10 @@ export function Viewport({ geometry, geometryVersion, coordinateInfo, computedIs
|
|
|
127
127
|
const handlePickForSelectionRef = useRef(handlePickForSelection);
|
|
128
128
|
useEffect(() => { handlePickForSelectionRef.current = handlePickForSelection; }, [handlePickForSelection]);
|
|
129
129
|
|
|
130
|
+
// Orbit pivot is now set dynamically at the start of each orbit drag by
|
|
131
|
+
// raycasting under the cursor (see useMouseControls/useTouchControls).
|
|
132
|
+
// No need for selection-based orbit center — cursor-based is always better.
|
|
133
|
+
|
|
130
134
|
// Multi-select handler: Ctrl+Click adds/removes from multi-selection
|
|
131
135
|
// Properly populates both selectedEntitiesSet (multi-model) and selectedEntityIds (legacy)
|
|
132
136
|
const handleMultiSelect = useCallback((globalId: number) => {
|
|
@@ -287,8 +291,6 @@ export function Viewport({ geometry, geometryVersion, coordinateInfo, computedIs
|
|
|
287
291
|
separationLinesIntensity,
|
|
288
292
|
separationLinesRadius,
|
|
289
293
|
]);
|
|
290
|
-
const visualEnhancementRef = useRef<VisualEnhancementOptions>(visualEnhancement);
|
|
291
|
-
|
|
292
294
|
// Animation frame ref
|
|
293
295
|
const animationFrameRef = useRef<number | null>(null);
|
|
294
296
|
const lastFrameTimeRef = useRef<number>(0);
|
|
@@ -337,29 +339,29 @@ export function Viewport({ geometry, geometryVersion, coordinateInfo, computedIs
|
|
|
337
339
|
max: { x: 100, y: 100, z: 100 },
|
|
338
340
|
});
|
|
339
341
|
|
|
340
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
const
|
|
346
|
-
const
|
|
347
|
-
const
|
|
348
|
-
const selectedModelIndexRef = useRef<number | undefined>(selectedModelIndex);
|
|
342
|
+
// Refs that stay in sync with props/state automatically (no useEffect needed).
|
|
343
|
+
// Event handlers and the animation loop read .current to get the latest value.
|
|
344
|
+
const coordinateInfoRef = useLatestRef(coordinateInfo);
|
|
345
|
+
const hiddenEntitiesRef = useLatestRef(hiddenEntities);
|
|
346
|
+
const isolatedEntitiesRef = useLatestRef(isolatedEntities);
|
|
347
|
+
const selectedEntityIdRef = useLatestRef(selectedEntityId);
|
|
348
|
+
const selectedEntityIdsRef = useLatestRef(selectedEntityIds);
|
|
349
|
+
const selectedModelIndexRef = useLatestRef(selectedModelIndex);
|
|
349
350
|
const activeToolRef = useRef<string>(activeTool);
|
|
350
|
-
const pendingMeasurePointRef =
|
|
351
|
-
const activeMeasurementRef =
|
|
352
|
-
const snapEnabledRef =
|
|
353
|
-
const edgeLockStateRef =
|
|
354
|
-
const measurementConstraintEdgeRef =
|
|
355
|
-
const sectionPlaneRef =
|
|
356
|
-
const sectionRangeRef =
|
|
357
|
-
const
|
|
351
|
+
const pendingMeasurePointRef = useLatestRef(pendingMeasurePoint);
|
|
352
|
+
const activeMeasurementRef = useLatestRef(activeMeasurement);
|
|
353
|
+
const snapEnabledRef = useLatestRef(snapEnabled);
|
|
354
|
+
const edgeLockStateRef = useLatestRef(edgeLockState);
|
|
355
|
+
const measurementConstraintEdgeRef = useLatestRef(measurementConstraintEdge);
|
|
356
|
+
const sectionPlaneRef = useLatestRef(sectionPlane);
|
|
357
|
+
const sectionRangeRef = useLatestRef(sectionRange);
|
|
358
|
+
const visualEnhancementRef = useLatestRef(visualEnhancement);
|
|
359
|
+
const geometryRef = useLatestRef(geometry);
|
|
358
360
|
|
|
359
361
|
// Hover throttling
|
|
360
362
|
const lastHoverCheckRef = useRef<number>(0);
|
|
361
363
|
const hoverThrottleMs = 50; // Check hover every 50ms
|
|
362
|
-
const hoverTooltipsEnabledRef =
|
|
364
|
+
const hoverTooltipsEnabledRef = useLatestRef(hoverTooltipsEnabled);
|
|
363
365
|
|
|
364
366
|
// Measure tool throttling (adaptive based on raycast performance)
|
|
365
367
|
const measureRaycastPendingRef = useRef(false);
|
|
@@ -388,29 +390,18 @@ export function Viewport({ geometry, geometryVersion, coordinateInfo, computedIs
|
|
|
388
390
|
canvasHeight: number;
|
|
389
391
|
} | null>(null);
|
|
390
392
|
|
|
391
|
-
//
|
|
392
|
-
useEffect(() => { coordinateInfoRef.current = coordinateInfo; }, [coordinateInfo]);
|
|
393
|
-
useEffect(() => { hiddenEntitiesRef.current = hiddenEntities; }, [hiddenEntities]);
|
|
394
|
-
useEffect(() => { isolatedEntitiesRef.current = isolatedEntities; }, [isolatedEntities]);
|
|
395
|
-
useEffect(() => { selectedEntityIdRef.current = selectedEntityId; }, [selectedEntityId]);
|
|
396
|
-
useEffect(() => { selectedEntityIdsRef.current = selectedEntityIds; }, [selectedEntityIds]);
|
|
397
|
-
useEffect(() => { selectedModelIndexRef.current = selectedModelIndex; }, [selectedModelIndex]);
|
|
398
|
-
useEffect(() => { activeToolRef.current = activeTool; }, [activeTool]);
|
|
399
|
-
useEffect(() => { pendingMeasurePointRef.current = pendingMeasurePoint; }, [pendingMeasurePoint]);
|
|
400
|
-
useEffect(() => { activeMeasurementRef.current = activeMeasurement; }, [activeMeasurement]);
|
|
401
|
-
useEffect(() => { snapEnabledRef.current = snapEnabled; }, [snapEnabled]);
|
|
402
|
-
useEffect(() => { edgeLockStateRef.current = edgeLockState; }, [edgeLockState]);
|
|
403
|
-
useEffect(() => { measurementConstraintEdgeRef.current = measurementConstraintEdge; }, [measurementConstraintEdge]);
|
|
404
|
-
useEffect(() => { sectionPlaneRef.current = sectionPlane; }, [sectionPlane]);
|
|
405
|
-
useEffect(() => { sectionRangeRef.current = sectionRange; }, [sectionRange]);
|
|
406
|
-
useEffect(() => { visualEnhancementRef.current = visualEnhancement; }, [visualEnhancement]);
|
|
393
|
+
// activeTool has a side effect (first-person mode), so keep as useEffect
|
|
407
394
|
useEffect(() => {
|
|
408
|
-
|
|
409
|
-
|
|
395
|
+
activeToolRef.current = activeTool;
|
|
396
|
+
const renderer = rendererRef.current;
|
|
397
|
+
if (renderer) {
|
|
398
|
+
const isWalk = activeTool === 'walk';
|
|
399
|
+
firstPersonModeRef.current = isWalk;
|
|
400
|
+
renderer.getCamera().enableFirstPersonMode(isWalk);
|
|
401
|
+
}
|
|
402
|
+
}, [activeTool]);
|
|
410
403
|
useEffect(() => {
|
|
411
|
-
hoverTooltipsEnabledRef.current = hoverTooltipsEnabled;
|
|
412
404
|
if (!hoverTooltipsEnabled) {
|
|
413
|
-
// Clear hover state when disabled
|
|
414
405
|
clearHover();
|
|
415
406
|
}
|
|
416
407
|
}, [hoverTooltipsEnabled, clearHover]);
|
|
@@ -436,8 +427,6 @@ export function Viewport({ geometry, geometryVersion, coordinateInfo, computedIs
|
|
|
436
427
|
// Set cursor based on active tool
|
|
437
428
|
if (activeTool === 'measure') {
|
|
438
429
|
canvas.style.cursor = 'crosshair';
|
|
439
|
-
} else if (activeTool === 'pan' || activeTool === 'orbit') {
|
|
440
|
-
canvas.style.cursor = 'grab';
|
|
441
430
|
} else {
|
|
442
431
|
canvas.style.cursor = 'default';
|
|
443
432
|
}
|
|
@@ -520,19 +509,7 @@ export function Viewport({ geometry, geometryVersion, coordinateInfo, computedIs
|
|
|
520
509
|
|
|
521
510
|
const camera = renderer.getCamera();
|
|
522
511
|
const renderCurrent = () => {
|
|
523
|
-
renderer.
|
|
524
|
-
hiddenIds: hiddenEntitiesRef.current,
|
|
525
|
-
isolatedIds: isolatedEntitiesRef.current,
|
|
526
|
-
selectedId: selectedEntityIdRef.current,
|
|
527
|
-
selectedModelIndex: selectedModelIndexRef.current,
|
|
528
|
-
clearColor: clearColorRef.current,
|
|
529
|
-
visualEnhancement: visualEnhancementRef.current,
|
|
530
|
-
sectionPlane: activeToolRef.current === 'section' ? {
|
|
531
|
-
...sectionPlaneRef.current,
|
|
532
|
-
min: sectionRangeRef.current?.min,
|
|
533
|
-
max: sectionRangeRef.current?.max,
|
|
534
|
-
} : undefined,
|
|
535
|
-
});
|
|
512
|
+
renderer.requestRender();
|
|
536
513
|
};
|
|
537
514
|
|
|
538
515
|
// Register camera callbacks for ViewCube and other controls
|
|
@@ -689,6 +666,10 @@ export function Viewport({ geometry, geometryVersion, coordinateInfo, computedIs
|
|
|
689
666
|
// Sync on every render since mouseState is mutated directly by event handlers
|
|
690
667
|
mouseIsDraggingRef.current = mouseStateRef.current.isDragging;
|
|
691
668
|
|
|
669
|
+
// isInteracting: set by mouse/touch controls during drag, cleared on mouseup/touchend.
|
|
670
|
+
// The animation loop reads this to skip post-processing during rapid camera movement.
|
|
671
|
+
const isInteractingRef = useRef(false);
|
|
672
|
+
|
|
692
673
|
// ===== Extracted hooks =====
|
|
693
674
|
useMouseControls({
|
|
694
675
|
canvasRef,
|
|
@@ -716,6 +697,7 @@ export function Viewport({ geometry, geometryVersion, coordinateInfo, computedIs
|
|
|
716
697
|
hoverTooltipsEnabledRef,
|
|
717
698
|
lastRenderTimeRef,
|
|
718
699
|
renderPendingRef,
|
|
700
|
+
isInteractingRef,
|
|
719
701
|
lastClickTimeRef,
|
|
720
702
|
lastClickPosRef,
|
|
721
703
|
lastCameraStateRef,
|
|
@@ -762,6 +744,7 @@ export function Viewport({ geometry, geometryVersion, coordinateInfo, computedIs
|
|
|
762
744
|
sectionPlaneRef,
|
|
763
745
|
sectionRangeRef,
|
|
764
746
|
geometryRef,
|
|
747
|
+
isInteractingRef,
|
|
765
748
|
handlePickForSelection: (pickResult) => handlePickForSelectionRef.current(pickResult),
|
|
766
749
|
getPickOptions,
|
|
767
750
|
});
|
|
@@ -802,6 +785,9 @@ export function Viewport({ geometry, geometryVersion, coordinateInfo, computedIs
|
|
|
802
785
|
sectionPlaneRef,
|
|
803
786
|
sectionRangeRef,
|
|
804
787
|
visualEnhancementRef,
|
|
788
|
+
selectedEntityIdsRef,
|
|
789
|
+
coordinateInfoRef,
|
|
790
|
+
isInteractingRef,
|
|
805
791
|
lastCameraStateRef,
|
|
806
792
|
updateCameraRotationRealtime,
|
|
807
793
|
calculateScale,
|
|
@@ -8,6 +8,7 @@ import { ViewportOverlays } from './ViewportOverlays';
|
|
|
8
8
|
import { ToolOverlays } from './ToolOverlays';
|
|
9
9
|
import { Section2DPanel } from './Section2DPanel';
|
|
10
10
|
import { BasketPresentationDock } from './BasketPresentationDock';
|
|
11
|
+
import { BCFOverlay } from './bcf/BCFOverlay';
|
|
11
12
|
import { useViewerStore } from '@/store';
|
|
12
13
|
import { collectIfcBuildingStoreyElementsWithIfcSpace } from '@/store/basketVisibleSet';
|
|
13
14
|
import { useIfc } from '@/hooks/useIfc';
|
|
@@ -32,6 +33,7 @@ export function ViewportContainer() {
|
|
|
32
33
|
// Multi-model support: get all loaded models from store (for merged geometry)
|
|
33
34
|
const storeModels = useViewerStore((s) => s.models);
|
|
34
35
|
const resetViewerState = useViewerStore((s) => s.resetViewerState);
|
|
36
|
+
const bcfOverlayVisible = useViewerStore((s) => s.bcfOverlayVisible);
|
|
35
37
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
36
38
|
const [isDragging, setIsDragging] = useState(false);
|
|
37
39
|
const [showTroubleshooting, setShowTroubleshooting] = useState(false);
|
|
@@ -611,6 +613,7 @@ export function ViewportContainer() {
|
|
|
611
613
|
computedIsolatedIds={computedIsolatedIds}
|
|
612
614
|
modelIdToIndex={modelIdToIndex}
|
|
613
615
|
/>
|
|
616
|
+
{bcfOverlayVisible && <BCFOverlay />}
|
|
614
617
|
<ViewportOverlays />
|
|
615
618
|
<ToolOverlays />
|
|
616
619
|
<BasketPresentationDock />
|
|
@@ -18,7 +18,7 @@ import { cn } from '@/lib/utils';
|
|
|
18
18
|
import { ViewCube, type ViewCubeRef } from './ViewCube';
|
|
19
19
|
import { AxisHelper, type AxisHelperRef } from './AxisHelper';
|
|
20
20
|
|
|
21
|
-
export function ViewportOverlays() {
|
|
21
|
+
export function ViewportOverlays({ hideViewCube = false }: { hideViewCube?: boolean } = {}) {
|
|
22
22
|
const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
|
|
23
23
|
const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
|
|
24
24
|
const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
|
|
@@ -180,15 +180,17 @@ export function ViewportOverlays() {
|
|
|
180
180
|
)}
|
|
181
181
|
|
|
182
182
|
{/* ViewCube (top-right) */}
|
|
183
|
-
|
|
184
|
-
<
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
183
|
+
{!hideViewCube && (
|
|
184
|
+
<div className="absolute top-6 right-6">
|
|
185
|
+
<ViewCube
|
|
186
|
+
ref={viewCubeRef}
|
|
187
|
+
onViewChange={handleViewChange}
|
|
188
|
+
onDrag={(deltaX, deltaY) => cameraCallbacks.orbit?.(deltaX, deltaY)}
|
|
189
|
+
rotationX={initialRotationX}
|
|
190
|
+
rotationY={initialRotationY}
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
)}
|
|
192
194
|
|
|
193
195
|
{/* Axis Helper (bottom-left, above scale bar) - IFC Z-up convention */}
|
|
194
196
|
<div className="absolute bottom-16 left-4">
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* BCFOverlay — renders BCF topic markers as 3D-positioned overlays in the viewport.
|
|
7
|
+
*
|
|
8
|
+
* Connects:
|
|
9
|
+
* - Zustand store (BCF topics, active topic)
|
|
10
|
+
* - Renderer (camera projection, entity bounds)
|
|
11
|
+
* - BCFOverlayRenderer (pure DOM marker rendering)
|
|
12
|
+
* - BCF panel (click marker → open topic, bidirectional sync)
|
|
13
|
+
*
|
|
14
|
+
* KEY DESIGN: Bounds lookup queries the renderer Scene directly via a
|
|
15
|
+
* mutable ref (not React state). Marker computation is triggered by an
|
|
16
|
+
* `overlayReady` counter that bumps once the renderer is available AND
|
|
17
|
+
* when loading completes (ensuring bounding boxes are cached).
|
|
18
|
+
* The camera's current distance is passed as `targetDistance` so fallback
|
|
19
|
+
* markers land at the orbit center — not at hardcoded 10 units.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { useEffect, useRef, useCallback, useMemo, useState } from 'react';
|
|
23
|
+
import { useViewerStore } from '@/store';
|
|
24
|
+
import { getGlobalRenderer } from '@/hooks/useBCF';
|
|
25
|
+
import { globalIdToExpressId as globalIdToExpressIdLookup } from '@/hooks/bcfIdLookup';
|
|
26
|
+
import {
|
|
27
|
+
computeMarkerPositions,
|
|
28
|
+
BCFOverlayRenderer,
|
|
29
|
+
type BCFOverlayProjection,
|
|
30
|
+
type OverlayBBox,
|
|
31
|
+
type OverlayPoint3D,
|
|
32
|
+
type EntityBoundsLookup,
|
|
33
|
+
} from '@ifc-lite/bcf';
|
|
34
|
+
import type { Renderer } from '@ifc-lite/renderer';
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// WebGPU projection adapter
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
function createWebGPUProjection(
|
|
41
|
+
renderer: Renderer,
|
|
42
|
+
canvas: HTMLCanvasElement,
|
|
43
|
+
): BCFOverlayProjection {
|
|
44
|
+
let prevPosX = NaN;
|
|
45
|
+
let prevPosY = NaN;
|
|
46
|
+
let prevPosZ = NaN;
|
|
47
|
+
let prevTgtX = NaN;
|
|
48
|
+
let prevTgtY = NaN;
|
|
49
|
+
let prevTgtZ = NaN;
|
|
50
|
+
let prevWidth = 0;
|
|
51
|
+
let prevHeight = 0;
|
|
52
|
+
|
|
53
|
+
const listeners = new Set<() => void>();
|
|
54
|
+
let rafId: number | null = null;
|
|
55
|
+
let listenerCount = 0;
|
|
56
|
+
|
|
57
|
+
function poll() {
|
|
58
|
+
rafId = requestAnimationFrame(poll);
|
|
59
|
+
const cam = renderer.getCamera();
|
|
60
|
+
const pos = cam.getPosition();
|
|
61
|
+
const tgt = cam.getTarget();
|
|
62
|
+
const w = canvas.clientWidth;
|
|
63
|
+
const h = canvas.clientHeight;
|
|
64
|
+
|
|
65
|
+
if (
|
|
66
|
+
pos.x !== prevPosX || pos.y !== prevPosY || pos.z !== prevPosZ ||
|
|
67
|
+
tgt.x !== prevTgtX || tgt.y !== prevTgtY || tgt.z !== prevTgtZ ||
|
|
68
|
+
w !== prevWidth || h !== prevHeight
|
|
69
|
+
) {
|
|
70
|
+
prevPosX = pos.x; prevPosY = pos.y; prevPosZ = pos.z;
|
|
71
|
+
prevTgtX = tgt.x; prevTgtY = tgt.y; prevTgtZ = tgt.z;
|
|
72
|
+
prevWidth = w; prevHeight = h;
|
|
73
|
+
for (const cb of listeners) cb();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
projectToScreen(worldPos: OverlayPoint3D) {
|
|
79
|
+
return renderer.getCamera().projectToScreen(
|
|
80
|
+
worldPos,
|
|
81
|
+
canvas.clientWidth,
|
|
82
|
+
canvas.clientHeight,
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
getEntityBounds(expressId: number): OverlayBBox | null {
|
|
87
|
+
return renderer.getScene().getEntityBoundingBox(expressId);
|
|
88
|
+
},
|
|
89
|
+
|
|
90
|
+
getCanvasSize() {
|
|
91
|
+
return { width: canvas.clientWidth, height: canvas.clientHeight };
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
getCameraPosition(): OverlayPoint3D {
|
|
95
|
+
return renderer.getCamera().getPosition();
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
onCameraChange(callback: () => void) {
|
|
99
|
+
listeners.add(callback);
|
|
100
|
+
listenerCount++;
|
|
101
|
+
if (listenerCount === 1) rafId = requestAnimationFrame(poll);
|
|
102
|
+
return () => {
|
|
103
|
+
listeners.delete(callback);
|
|
104
|
+
listenerCount--;
|
|
105
|
+
if (listenerCount === 0 && rafId !== null) {
|
|
106
|
+
cancelAnimationFrame(rafId);
|
|
107
|
+
rafId = null;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// ============================================================================
|
|
115
|
+
// React Component
|
|
116
|
+
// ============================================================================
|
|
117
|
+
|
|
118
|
+
export function BCFOverlay() {
|
|
119
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
120
|
+
const overlayRef = useRef<BCFOverlayRenderer | null>(null);
|
|
121
|
+
const rendererRef = useRef<Renderer | null>(null);
|
|
122
|
+
|
|
123
|
+
// Bumped when overlay/renderer is ready or geometry finishes loading,
|
|
124
|
+
// triggering marker recomputation with real bounding boxes.
|
|
125
|
+
const [overlayReady, setOverlayReady] = useState(0);
|
|
126
|
+
|
|
127
|
+
// Store selectors
|
|
128
|
+
const bcfProject = useViewerStore((s) => s.bcfProject);
|
|
129
|
+
const activeTopicId = useViewerStore((s) => s.activeTopicId);
|
|
130
|
+
const setActiveTopic = useViewerStore((s) => s.setActiveTopic);
|
|
131
|
+
const setBcfPanelVisible = useViewerStore((s) => s.setBcfPanelVisible);
|
|
132
|
+
const models = useViewerStore((s) => s.models);
|
|
133
|
+
const loading = useViewerStore((s) => s.loading);
|
|
134
|
+
const ifcDataStore = useViewerStore((s) => s.ifcDataStore);
|
|
135
|
+
|
|
136
|
+
// GlobalId → expressId lookup (delegates to shared utility)
|
|
137
|
+
const globalIdToExpressId = useCallback(
|
|
138
|
+
(globalIdString: string) =>
|
|
139
|
+
globalIdToExpressIdLookup(globalIdString, models, ifcDataStore),
|
|
140
|
+
[models, ifcDataStore],
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Bounds lookup — queries the renderer Scene directly
|
|
144
|
+
const boundsLookup: EntityBoundsLookup = useCallback(
|
|
145
|
+
(ifcGuid: string): OverlayBBox | null => {
|
|
146
|
+
const r = rendererRef.current;
|
|
147
|
+
if (!r) return null;
|
|
148
|
+
const result = globalIdToExpressId(ifcGuid);
|
|
149
|
+
if (!result) return null;
|
|
150
|
+
return r.getScene().getEntityBoundingBox(result.expressId);
|
|
151
|
+
},
|
|
152
|
+
[globalIdToExpressId],
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
// Get current camera distance (for proper fallback marker placement)
|
|
156
|
+
const getCameraDistance = useCallback((): number => {
|
|
157
|
+
const r = rendererRef.current;
|
|
158
|
+
if (!r) return 50; // safe default
|
|
159
|
+
return r.getCamera().getDistance();
|
|
160
|
+
}, []);
|
|
161
|
+
|
|
162
|
+
// Topics list
|
|
163
|
+
const topics = (() => {
|
|
164
|
+
if (!bcfProject) return [];
|
|
165
|
+
return Array.from(bcfProject.topics.values());
|
|
166
|
+
})();
|
|
167
|
+
|
|
168
|
+
// Compute markers — recomputes when topics, bounds, loading, or readiness changes
|
|
169
|
+
const markers = useMemo(
|
|
170
|
+
() => computeMarkerPositions(topics, boundsLookup, {
|
|
171
|
+
targetDistance: getCameraDistance(),
|
|
172
|
+
}),
|
|
173
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
174
|
+
[topics, boundsLookup, overlayReady, loading],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
// Initialize overlay renderer
|
|
178
|
+
useEffect(() => {
|
|
179
|
+
const container = containerRef.current;
|
|
180
|
+
if (!container) return;
|
|
181
|
+
|
|
182
|
+
const renderer = getGlobalRenderer();
|
|
183
|
+
if (!renderer) return;
|
|
184
|
+
|
|
185
|
+
const canvas = container.closest('[data-viewport]')?.querySelector('canvas') as HTMLCanvasElement | null;
|
|
186
|
+
if (!canvas) return;
|
|
187
|
+
|
|
188
|
+
rendererRef.current = renderer;
|
|
189
|
+
|
|
190
|
+
const projection = createWebGPUProjection(renderer, canvas);
|
|
191
|
+
const overlay = new BCFOverlayRenderer(container, projection, {
|
|
192
|
+
showConnectors: true,
|
|
193
|
+
showTooltips: true,
|
|
194
|
+
verticalOffset: 36,
|
|
195
|
+
});
|
|
196
|
+
overlayRef.current = overlay;
|
|
197
|
+
|
|
198
|
+
// Trigger marker recomputation now that renderer is available
|
|
199
|
+
setOverlayReady((n) => n + 1);
|
|
200
|
+
|
|
201
|
+
return () => {
|
|
202
|
+
overlay.dispose();
|
|
203
|
+
overlayRef.current = null;
|
|
204
|
+
rendererRef.current = null;
|
|
205
|
+
};
|
|
206
|
+
}, [models]);
|
|
207
|
+
|
|
208
|
+
// Recompute markers when loading finishes (bounding boxes get cached)
|
|
209
|
+
useEffect(() => {
|
|
210
|
+
if (!loading && rendererRef.current) {
|
|
211
|
+
setOverlayReady((n) => n + 1);
|
|
212
|
+
}
|
|
213
|
+
}, [loading]);
|
|
214
|
+
|
|
215
|
+
// Push markers to overlay renderer
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
overlayRef.current?.setMarkers(markers);
|
|
218
|
+
}, [markers, overlayReady]);
|
|
219
|
+
|
|
220
|
+
// Sync active marker
|
|
221
|
+
useEffect(() => {
|
|
222
|
+
overlayRef.current?.setActiveMarker(activeTopicId);
|
|
223
|
+
}, [activeTopicId, overlayReady]);
|
|
224
|
+
|
|
225
|
+
// Visibility — reproject markers when becoming visible so they don't
|
|
226
|
+
// sit at stale positions until the next camera move.
|
|
227
|
+
useEffect(() => {
|
|
228
|
+
const overlay = overlayRef.current;
|
|
229
|
+
if (!overlay) return;
|
|
230
|
+
const hasTopics = bcfProject !== null && bcfProject.topics.size > 0;
|
|
231
|
+
overlay.setVisible(hasTopics);
|
|
232
|
+
if (hasTopics) overlay.updatePositions();
|
|
233
|
+
}, [bcfProject, overlayReady]);
|
|
234
|
+
|
|
235
|
+
// Click handler — read bcfPanelVisible from store inside callback to
|
|
236
|
+
// avoid re-registering the handler on every panel toggle.
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
const overlay = overlayRef.current;
|
|
239
|
+
if (!overlay) return;
|
|
240
|
+
return overlay.onMarkerClick((topicGuid) => {
|
|
241
|
+
setActiveTopic(topicGuid);
|
|
242
|
+
const panelVisible = useViewerStore.getState().bcfPanelVisible;
|
|
243
|
+
if (!panelVisible) setBcfPanelVisible(true);
|
|
244
|
+
});
|
|
245
|
+
}, [overlayReady, setActiveTopic, setBcfPanelVisible]);
|
|
246
|
+
|
|
247
|
+
return (
|
|
248
|
+
<div
|
|
249
|
+
ref={containerRef}
|
|
250
|
+
className="absolute inset-0 pointer-events-none z-20"
|
|
251
|
+
data-bcf-overlay
|
|
252
|
+
/>
|
|
253
|
+
);
|
|
254
|
+
}
|
|
@@ -4,13 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
ChevronRight,
|
|
7
|
-
Building2,
|
|
8
7
|
Layers,
|
|
9
|
-
MapPin,
|
|
10
|
-
FolderKanban,
|
|
11
|
-
Square,
|
|
12
|
-
Box,
|
|
13
|
-
DoorOpen,
|
|
14
8
|
Eye,
|
|
15
9
|
EyeOff,
|
|
16
10
|
FileBox,
|
|
@@ -20,21 +14,21 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
|
|
20
14
|
import { cn } from '@/lib/utils';
|
|
21
15
|
import type { TreeNode } from './types';
|
|
22
16
|
import { isSpatialContainer } from './types';
|
|
17
|
+
import { IFC_ICON_CODEPOINTS, IFC_ICON_DEFAULT } from './ifc-icons';
|
|
23
18
|
|
|
24
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the Material Symbols code point for a given IFC type string.
|
|
21
|
+
* Falls back to the generic product icon for unmapped classes.
|
|
22
|
+
*/
|
|
23
|
+
function getIfcIconCodepoint(ifcType: string | undefined): string {
|
|
24
|
+
if (!ifcType) return IFC_ICON_DEFAULT;
|
|
25
|
+
return IFC_ICON_CODEPOINTS[ifcType] ?? IFC_ICON_DEFAULT;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Lucide fallback icons for non-IFC node types */
|
|
29
|
+
const NODE_TYPE_ICONS: Record<string, React.ElementType> = {
|
|
25
30
|
'unified-storey': Layers,
|
|
26
31
|
'model-header': FileBox,
|
|
27
|
-
'ifc-type': Building2,
|
|
28
|
-
IfcProject: FolderKanban,
|
|
29
|
-
IfcSite: MapPin,
|
|
30
|
-
IfcBuilding: Building2,
|
|
31
|
-
IfcBuildingStorey: Layers,
|
|
32
|
-
IfcSpace: Box,
|
|
33
|
-
IfcWall: Square,
|
|
34
|
-
IfcWallStandardCase: Square,
|
|
35
|
-
IfcDoor: DoorOpen,
|
|
36
|
-
element: Box,
|
|
37
|
-
default: Box,
|
|
38
32
|
};
|
|
39
33
|
|
|
40
34
|
export interface HierarchyNodeProps {
|
|
@@ -69,7 +63,9 @@ export function HierarchyNode({
|
|
|
69
63
|
onModelHeaderClick,
|
|
70
64
|
}: HierarchyNodeProps) {
|
|
71
65
|
const resolvedType = node.ifcType || node.type;
|
|
72
|
-
|
|
66
|
+
// Use Lucide icon for non-IFC structural nodes, Material Symbols for IFC classes
|
|
67
|
+
const LucideIcon = NODE_TYPE_ICONS[node.type];
|
|
68
|
+
const iconCodepoint = getIfcIconCodepoint(resolvedType);
|
|
73
69
|
|
|
74
70
|
// Model header nodes (for visibility control and expansion)
|
|
75
71
|
if (node.type === 'model-header' && node.id.startsWith('model-')) {
|
|
@@ -259,7 +255,17 @@ export function HierarchyNode({
|
|
|
259
255
|
{/* Type Icon */}
|
|
260
256
|
<Tooltip>
|
|
261
257
|
<TooltipTrigger asChild>
|
|
262
|
-
|
|
258
|
+
{LucideIcon ? (
|
|
259
|
+
<LucideIcon className="h-3.5 w-3.5 shrink-0 text-zinc-500 dark:text-zinc-400" />
|
|
260
|
+
) : (
|
|
261
|
+
<span
|
|
262
|
+
className="material-symbols-outlined shrink-0 leading-none text-zinc-500 dark:text-zinc-400"
|
|
263
|
+
style={{ fontSize: '14px' }}
|
|
264
|
+
aria-hidden="true"
|
|
265
|
+
>
|
|
266
|
+
{iconCodepoint}
|
|
267
|
+
</span>
|
|
268
|
+
)}
|
|
263
269
|
</TooltipTrigger>
|
|
264
270
|
<TooltipContent>
|
|
265
271
|
<p className="text-xs">{resolvedType}</p>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* IFC class to Material Symbols icon code point mapping.
|
|
7
|
+
* Based on https://github.com/AECgeeks/ifc-icons (MIT license).
|
|
8
|
+
*
|
|
9
|
+
* Values are Unicode code points for the Material Symbols Outlined font.
|
|
10
|
+
*/
|
|
11
|
+
export const IFC_ICON_CODEPOINTS: Record<string, string> = {
|
|
12
|
+
// Spatial / context
|
|
13
|
+
IfcContext: '\uf1c4',
|
|
14
|
+
IfcProject: '\uf1c4',
|
|
15
|
+
IfcProjectLibrary: '\uf1c4',
|
|
16
|
+
IfcSite: '\ue80b',
|
|
17
|
+
IfcBuilding: '\uea40',
|
|
18
|
+
IfcBuildingStorey: '\ue8fe',
|
|
19
|
+
IfcSpace: '\ueff4',
|
|
20
|
+
|
|
21
|
+
// Structural
|
|
22
|
+
IfcBeam: '\uf108',
|
|
23
|
+
IfcBeamStandardCase: '\uf108',
|
|
24
|
+
IfcColumn: '\ue233',
|
|
25
|
+
IfcColumnStandardCase: '\ue233',
|
|
26
|
+
IfcWall: '\ue3c0',
|
|
27
|
+
IfcWallStandardCase: '\ue3c0',
|
|
28
|
+
IfcWallElementedCase: '\ue3c0',
|
|
29
|
+
IfcSlab: '\ue229',
|
|
30
|
+
IfcSlabStandardCase: '\ue229',
|
|
31
|
+
IfcSlabElementedCase: '\ue229',
|
|
32
|
+
IfcRoof: '\uf201',
|
|
33
|
+
IfcFooting: '\uf200',
|
|
34
|
+
IfcPile: '\ue047',
|
|
35
|
+
IfcPlate: '\ue047',
|
|
36
|
+
IfcPlateStandardCase: '\ue047',
|
|
37
|
+
IfcMember: '\ue047',
|
|
38
|
+
IfcMemberStandardCase: '\ue047',
|
|
39
|
+
|
|
40
|
+
// Openings & access
|
|
41
|
+
IfcDoor: '\ueb4f',
|
|
42
|
+
IfcDoorStandardCase: '\ueb4f',
|
|
43
|
+
IfcWindow: '\uf088',
|
|
44
|
+
IfcWindowStandardCase: '\uf088',
|
|
45
|
+
IfcOpeningElement: '\ue3c6',
|
|
46
|
+
IfcOpeningStandardCase: '\ue3c6',
|
|
47
|
+
IfcCurtainWall: '\ue047',
|
|
48
|
+
|
|
49
|
+
// Vertical circulation
|
|
50
|
+
IfcStair: '\uf1a9',
|
|
51
|
+
IfcStairFlight: '\uf1a9',
|
|
52
|
+
IfcRamp: '\ue86b',
|
|
53
|
+
IfcRampFlight: '\ue86b',
|
|
54
|
+
IfcRailing: '\ue58f',
|
|
55
|
+
|
|
56
|
+
// Furnishing
|
|
57
|
+
IfcFurnishingElement: '\uea45',
|
|
58
|
+
IfcFurniture: '\uea45',
|
|
59
|
+
IfcSystemFurnitureElement: '\uea45',
|
|
60
|
+
|
|
61
|
+
// MEP terminals
|
|
62
|
+
IfcAirTerminal: '\uefd8',
|
|
63
|
+
IfcLamp: '\uf02a',
|
|
64
|
+
IfcLightFixture: '\uf02a',
|
|
65
|
+
IfcSanitaryTerminal: '\uea41',
|
|
66
|
+
IfcSpaceHeater: '\uf076',
|
|
67
|
+
IfcAudioVisualAppliance: '\ue333',
|
|
68
|
+
IfcSensor: '\ue51e',
|
|
69
|
+
|
|
70
|
+
// Assemblies & misc
|
|
71
|
+
IfcElementAssembly: '\ue9b0',
|
|
72
|
+
IfcTransportElement: '\uf1a0',
|
|
73
|
+
IfcGrid: '\uf015',
|
|
74
|
+
IfcPort: '\ue8c0',
|
|
75
|
+
IfcDistributionPort: '\ue8c0',
|
|
76
|
+
IfcAnnotation: '\ue3c9',
|
|
77
|
+
|
|
78
|
+
// Civil / geographic
|
|
79
|
+
IfcCivilElement: '\uea99',
|
|
80
|
+
IfcGeographicElement: '\uea99',
|
|
81
|
+
IfcLinearElement: '\uebaa',
|
|
82
|
+
|
|
83
|
+
// Proxy / generic fallback
|
|
84
|
+
IfcProduct: '\ue047',
|
|
85
|
+
IfcBuildingElementProxy: '\ue047',
|
|
86
|
+
IfcProxy: '\ue047',
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Default code point for unmapped IFC classes (Material Symbols "widgets" / generic product) */
|
|
90
|
+
export const IFC_ICON_DEFAULT = '\ue047';
|