@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.
Files changed (62) hide show
  1. package/.turbo/turbo-build.log +46 -0
  2. package/.turbo/turbo-typecheck.log +4 -0
  3. package/CHANGELOG.md +35 -0
  4. package/dist/assets/{Arrow.dom-OVBBPqOB.js → Arrow.dom-CcoDLP6E.js} +1 -1
  5. package/dist/assets/{basketViewActivator-Bx6QU4ma.js → basketViewActivator-FtbS__bG.js} +1 -1
  6. package/dist/assets/{browser-BMqEoJw4.js → browser-CXd3z0DO.js} +1 -1
  7. package/dist/assets/ifc-lite-TI3u_Zyw.js +7 -0
  8. package/dist/assets/ifc-lite_bg-DeZrXTKQ.wasm +0 -0
  9. package/dist/assets/index-Ba4eoTe7.css +1 -0
  10. package/dist/assets/{index-DZY6uD8A.js → index-D99fzcwI.js} +32109 -28671
  11. package/dist/assets/{index-DsX-NCtx.js → index-DqNiuQep.js} +4 -4
  12. package/dist/assets/{native-bridge-D6tKFqGO.js → native-bridge-DjDj2M6p.js} +1 -1
  13. package/dist/assets/{wasm-bridge-D4kvZVDw.js → wasm-bridge-CDTF4ZQc.js} +1 -1
  14. package/dist/assets/workerHelpers-G7llXNMi.js +36 -0
  15. package/dist/index.html +7 -2
  16. package/index.html +5 -0
  17. package/package.json +15 -14
  18. package/src/components/viewer/BCFPanel.tsx +12 -0
  19. package/src/components/viewer/BulkPropertyEditor.tsx +315 -154
  20. package/src/components/viewer/CommandPalette.tsx +0 -6
  21. package/src/components/viewer/DataConnector.tsx +489 -284
  22. package/src/components/viewer/ExportDialog.tsx +66 -6
  23. package/src/components/viewer/KeyboardShortcutsDialog.tsx +227 -82
  24. package/src/components/viewer/MainToolbar.tsx +1 -5
  25. package/src/components/viewer/Viewport.tsx +42 -56
  26. package/src/components/viewer/ViewportContainer.tsx +3 -0
  27. package/src/components/viewer/ViewportOverlays.tsx +12 -10
  28. package/src/components/viewer/bcf/BCFOverlay.tsx +254 -0
  29. package/src/components/viewer/hierarchy/HierarchyNode.tsx +26 -20
  30. package/src/components/viewer/hierarchy/ifc-icons.ts +90 -0
  31. package/src/components/viewer/lists/ListPanel.tsx +0 -21
  32. package/src/components/viewer/lists/ListResultsTable.tsx +93 -5
  33. package/src/components/viewer/measureHandlers.ts +558 -0
  34. package/src/components/viewer/mouseHandlerTypes.ts +108 -0
  35. package/src/components/viewer/selectionHandlers.ts +86 -0
  36. package/src/components/viewer/useAnimationLoop.ts +116 -44
  37. package/src/components/viewer/useGeometryStreaming.ts +155 -367
  38. package/src/components/viewer/useKeyboardControls.ts +30 -46
  39. package/src/components/viewer/useMouseControls.ts +169 -695
  40. package/src/components/viewer/useRenderUpdates.ts +9 -59
  41. package/src/components/viewer/useTouchControls.ts +55 -40
  42. package/src/hooks/bcfIdLookup.ts +70 -0
  43. package/src/hooks/useBCF.ts +12 -31
  44. package/src/hooks/useIfcCache.ts +11 -29
  45. package/src/hooks/useIfcFederation.ts +5 -11
  46. package/src/hooks/useIfcLoader.ts +47 -56
  47. package/src/hooks/useIfcServer.ts +9 -1
  48. package/src/hooks/useKeyboardShortcuts.ts +28 -12
  49. package/src/hooks/useLatestRef.ts +24 -0
  50. package/src/sdk/adapters/export-adapter.ts +2 -2
  51. package/src/sdk/adapters/model-adapter.ts +1 -0
  52. package/src/sdk/local-backend.ts +2 -0
  53. package/src/store/basketVisibleSet.ts +12 -0
  54. package/src/store/slices/bcfSlice.ts +9 -0
  55. package/src/store/slices/pinboardSlice.ts +46 -45
  56. package/src/utils/loadingUtils.ts +46 -0
  57. package/src/utils/serverDataModel.ts +4 -3
  58. package/src/utils/spatialHierarchy.ts +1 -1
  59. package/src/vite-env.d.ts +6 -2
  60. package/vite.config.ts +75 -23
  61. package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
  62. 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
- // Coordinate info ref for camera callbacks (to access latest buildingRotation)
341
- const coordinateInfoRef = useRef<CoordinateInfo | undefined>(coordinateInfo);
342
-
343
- // Visibility state refs for animation loop
344
- const hiddenEntitiesRef = useRef<Set<number>>(hiddenEntities);
345
- const isolatedEntitiesRef = useRef<Set<number> | null>(isolatedEntities);
346
- const selectedEntityIdRef = useRef<number | null>(selectedEntityId);
347
- const selectedEntityIdsRef = useRef<Set<number> | undefined>(selectedEntityIds);
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 = useRef<MeasurePoint | null>(pendingMeasurePoint);
351
- const activeMeasurementRef = useRef(activeMeasurement);
352
- const snapEnabledRef = useRef(snapEnabled);
353
- const edgeLockStateRef = useRef(edgeLockState);
354
- const measurementConstraintEdgeRef = useRef(measurementConstraintEdge);
355
- const sectionPlaneRef = useRef(sectionPlane);
356
- const sectionRangeRef = useRef<{ min: number; max: number } | null>(null);
357
- const geometryRef = useRef<MeshData[] | null>(geometry);
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 = useRef(hoverTooltipsEnabled);
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
- // Keep refs in sync
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
- geometryRef.current = geometry;
409
- }, [geometry]);
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.render({
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
- <div className="absolute top-6 right-6">
184
- <ViewCube
185
- ref={viewCubeRef}
186
- onViewChange={handleViewChange}
187
- onDrag={(deltaX, deltaY) => cameraCallbacks.orbit?.(deltaX, deltaY)}
188
- rotationX={initialRotationX}
189
- rotationY={initialRotationY}
190
- />
191
- </div>
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
- const TYPE_ICONS: Record<string, React.ElementType> = {
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
- const Icon = TYPE_ICONS[resolvedType] || TYPE_ICONS[node.type] || TYPE_ICONS.default;
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
- <Icon className="h-3.5 w-3.5 shrink-0 text-zinc-500 dark:text-zinc-400" />
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';