@ifc-lite/viewer 1.16.0 → 1.17.1

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 (63) hide show
  1. package/.turbo/turbo-build.log +42 -0
  2. package/.turbo/turbo-typecheck.log +44 -0
  3. package/CHANGELOG.md +25 -0
  4. package/dist/assets/{Arrow.dom--gdrQd-q.js → Arrow.dom-DuPUrOxJ.js} +1 -1
  5. package/dist/assets/{basketViewActivator-CI3y6VYQ.js → basketViewActivator-DetjPnvt.js} +1 -1
  6. package/dist/assets/{browser-vWDubxDI.js → browser-BQdwnOUt.js} +1 -1
  7. package/dist/assets/geometry.worker-Bjm-ukng.js +1 -0
  8. package/dist/assets/ifc-lite_bg-DD0A7Yow.wasm +0 -0
  9. package/dist/assets/{index-RXIK18da.js → index-B3X21yXA.js} +4 -4
  10. package/dist/assets/index-Ba4eoTe7.css +1 -0
  11. package/dist/assets/{index-BImINgzG.js → index-BybGZJTW.js} +29281 -27174
  12. package/dist/assets/{native-bridge-4rLidc3f.js → native-bridge-CN0ZMR2t.js} +1 -1
  13. package/dist/assets/{wasm-bridge-BkfXfw8O.js → wasm-bridge-D0bALkma.js} +1 -1
  14. package/dist/index.html +2 -2
  15. package/package.json +14 -13
  16. package/src/components/viewer/BCFPanel.tsx +12 -0
  17. package/src/components/viewer/BulkPropertyEditor.tsx +315 -154
  18. package/src/components/viewer/CommandPalette.tsx +0 -6
  19. package/src/components/viewer/DataConnector.tsx +489 -284
  20. package/src/components/viewer/ExportDialog.tsx +66 -6
  21. package/src/components/viewer/KeyboardShortcutsDialog.tsx +44 -1
  22. package/src/components/viewer/MainToolbar.tsx +1 -5
  23. package/src/components/viewer/PropertiesPanel.tsx +6 -7
  24. package/src/components/viewer/Viewport.tsx +42 -56
  25. package/src/components/viewer/ViewportContainer.tsx +3 -0
  26. package/src/components/viewer/ViewportOverlays.tsx +12 -10
  27. package/src/components/viewer/bcf/BCFOverlay.tsx +254 -0
  28. package/src/components/viewer/hierarchy/treeDataBuilder.test.ts +70 -0
  29. package/src/components/viewer/hierarchy/treeDataBuilder.ts +35 -10
  30. package/src/components/viewer/hierarchy/types.ts +24 -2
  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 +2 -20
  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 +0 -10
  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/adapters/visibility-adapter.ts +7 -49
  53. package/src/sdk/local-backend.ts +2 -0
  54. package/src/store/basketVisibleSet.test.ts +73 -3
  55. package/src/store/basketVisibleSet.ts +58 -75
  56. package/src/store/slices/bcfSlice.ts +9 -0
  57. package/src/utils/loadingUtils.ts +46 -0
  58. package/src/utils/serverDataModel.test.ts +90 -0
  59. package/src/utils/serverDataModel.ts +26 -37
  60. package/src/utils/spatialHierarchy.test.ts +38 -0
  61. package/src/utils/spatialHierarchy.ts +13 -23
  62. package/dist/assets/ifc-lite_bg-CyWQTvp5.wasm +0 -0
  63. package/dist/assets/index-ax1X2WPd.css +0 -1
@@ -14,7 +14,7 @@
14
14
  * - IFC5 → .ifcx
15
15
  */
16
16
 
17
- import { useState, useCallback, useMemo, useEffect } from 'react';
17
+ import { useState, useCallback, useMemo, useEffect, useRef } from 'react';
18
18
  import {
19
19
  Download,
20
20
  AlertCircle,
@@ -48,10 +48,11 @@ import {
48
48
  AlertDescription,
49
49
  AlertTitle,
50
50
  } from '@/components/ui/alert';
51
+ import { Progress } from '@/components/ui/progress';
51
52
  import { useViewerStore } from '@/store';
52
53
  import { configureMutationView } from '@/utils/configureMutationView';
53
54
  import { toast } from '@/components/ui/toast';
54
- import { StepExporter, MergedExporter, Ifc5Exporter, IFC5_KNOWN_PROP_NAMES, type MergeModelInput } from '@ifc-lite/export';
55
+ import { StepExporter, MergedExporter, Ifc5Exporter, IFC5_KNOWN_PROP_NAMES, type MergeModelInput, type ExportProgress, type StepExportProgress } from '@ifc-lite/export';
55
56
  import { MutablePropertyView } from '@ifc-lite/mutations';
56
57
  import type { IfcDataStore } from '@ifc-lite/parser';
57
58
 
@@ -87,6 +88,27 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
87
88
  const [onlyKnownProperties, setOnlyKnownProperties] = useState(true);
88
89
  const [isExporting, setIsExporting] = useState(false);
89
90
  const [exportResult, setExportResult] = useState<{ success: boolean; message: string } | null>(null);
91
+ const [exportProgress, setExportProgress] = useState<{
92
+ phase: string;
93
+ percent: number;
94
+ entitiesProcessed: number;
95
+ entitiesTotal: number;
96
+ currentModel?: string;
97
+ } | null>(null);
98
+ const scrollAreaRef = useRef<HTMLDivElement>(null);
99
+ const prevProgressRef = useRef<typeof exportProgress>(null);
100
+
101
+ const scrollToBottom = useCallback(() => {
102
+ if (scrollAreaRef.current) {
103
+ scrollAreaRef.current.scrollTop = scrollAreaRef.current.scrollHeight;
104
+ }
105
+ }, []);
106
+
107
+ // Auto-scroll when progress first appears
108
+ useEffect(() => {
109
+ if (exportProgress && !prevProgressRef.current) scrollToBottom();
110
+ prevProgressRef.current = exportProgress;
111
+ }, [exportProgress, scrollToBottom]);
90
112
 
91
113
  // Derived: is this an IFC5/IFCX export?
92
114
  const isIfc5 = schema === 'IFC5';
@@ -286,6 +308,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
286
308
 
287
309
  setIsExporting(true);
288
310
  setExportResult(null);
311
+ setExportProgress(null);
289
312
 
290
313
  try {
291
314
  // Handle merged export of all models (STEP only, not IFC5)
@@ -308,7 +331,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
308
331
  }
309
332
  }
310
333
 
311
- const result = mergedExporter.export({
334
+ const result = await mergedExporter.exportAsync({
312
335
  schema,
313
336
  projectStrategy: 'keep-first',
314
337
  visibleOnly,
@@ -316,8 +339,19 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
316
339
  isolatedEntityIdsByModel: isolatedByModel,
317
340
  description: `Merged export of ${mergeInputs.length} models from ifc-lite`,
318
341
  application: 'ifc-lite',
342
+ onProgress: (p: ExportProgress) => setExportProgress({
343
+ phase: p.phase === 'preparing' ? 'Preparing models...'
344
+ : p.phase === 'entities' ? `Processing entities${p.currentModel ? ` (${p.currentModel})` : ''}...`
345
+ : 'Assembling file...',
346
+ percent: p.percent,
347
+ entitiesProcessed: p.entitiesProcessed,
348
+ entitiesTotal: p.entitiesTotal,
349
+ currentModel: p.currentModel,
350
+ }),
319
351
  });
320
352
 
353
+ setExportProgress(null);
354
+
321
355
  const blob = new Blob([result.content], { type: 'text/plain' });
322
356
  const url = URL.createObjectURL(blob);
323
357
  const a = document.createElement('a');
@@ -328,7 +362,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
328
362
  document.body.removeChild(a);
329
363
  URL.revokeObjectURL(url);
330
364
 
331
- const msg = `Merged ${result.stats.modelCount} models, ${result.stats.totalEntityCount} entities`;
365
+ const msg = `Merged ${result.stats.modelCount} models, ${result.stats.totalEntityCount.toLocaleString()} entities`;
332
366
  setExportResult({ success: true, message: msg });
333
367
  toast.success(msg);
334
368
  return;
@@ -431,7 +465,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
431
465
  const localHidden = visibleOnly ? getLocalHiddenIds(selectedModelId) : undefined;
432
466
  const localIsolated = visibleOnly ? getLocalIsolatedIds(selectedModelId) : undefined;
433
467
 
434
- const result = exporter.export({
468
+ const result = await exporter.exportAsync({
435
469
  schema,
436
470
  includeGeometry,
437
471
  applyMutations,
@@ -440,8 +474,18 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
440
474
  isolatedEntityIds: localIsolated,
441
475
  description: `Exported from ifc-lite with ${modifiedCount} modifications`,
442
476
  application: 'ifc-lite',
477
+ onProgress: (p: StepExportProgress) => setExportProgress({
478
+ phase: p.phase === 'preparing' ? 'Preparing export...'
479
+ : p.phase === 'entities' ? 'Processing entities...'
480
+ : 'Assembling file...',
481
+ percent: p.percent,
482
+ entitiesProcessed: p.entitiesProcessed,
483
+ entitiesTotal: p.entitiesTotal,
484
+ }),
443
485
  });
444
486
 
487
+ setExportProgress(null);
488
+
445
489
  const blob = new Blob([result.content], { type: 'text/plain' });
446
490
  const url = URL.createObjectURL(blob);
447
491
  const a = document.createElement('a');
@@ -488,7 +532,7 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
488
532
  </DialogDescription>
489
533
  </DialogHeader>
490
534
 
491
- <div className="grid gap-4 py-4">
535
+ <div ref={scrollAreaRef} className="grid gap-4 py-4 max-h-[60vh] overflow-y-auto">
492
536
  {/* Scope selector (only for STEP schemas with multiple models) */}
493
537
  {!isIfc5 && !changesOnly && modelList.length > 1 && (
494
538
  <div className="flex items-center gap-4">
@@ -632,6 +676,22 @@ export function ExportDialog({ trigger }: ExportDialogProps) {
632
676
  </Alert>
633
677
  )}
634
678
 
679
+ {/* Export Progress */}
680
+ {isExporting && exportProgress && (
681
+ <div className="space-y-2">
682
+ <div className="flex items-center justify-between text-sm text-muted-foreground">
683
+ <span className="flex items-center gap-2">
684
+ <Loader2 className="h-4 w-4 animate-spin" />
685
+ {exportProgress.phase}
686
+ </span>
687
+ <span>
688
+ {exportProgress.entitiesProcessed.toLocaleString()} / {exportProgress.entitiesTotal.toLocaleString()} entities
689
+ </span>
690
+ </div>
691
+ <Progress value={exportProgress.percent * 100} />
692
+ </div>
693
+ )}
694
+
635
695
  {/* Export result */}
636
696
  {exportResult && (
637
697
  <Alert variant={exportResult.success ? 'default' : 'destructive'}>
@@ -3,7 +3,7 @@
3
3
  * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
4
 
5
5
  import { useState, useEffect, useCallback, useMemo } from 'react';
6
- import { X, Info, Keyboard, Github, ExternalLink, Sparkles, ChevronDown, ChevronRight, Zap, Wrench, Plus, Package } from 'lucide-react';
6
+ import { X, Info, Keyboard, Github, ExternalLink, Sparkles, ChevronDown, ChevronRight, Zap, Wrench, Plus, Package, ShieldCheck } from 'lucide-react';
7
7
  import { Button } from '@/components/ui/button';
8
8
  import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
9
9
  import { KEYBOARD_SHORTCUTS } from '@/hooks/useKeyboardShortcuts';
@@ -33,6 +33,46 @@ const TYPE_CONFIG = {
33
33
  perf: { icon: Zap, className: 'text-blue-500' },
34
34
  } as const;
35
35
 
36
+ function PrivacyBanner() {
37
+ const [expanded, setExpanded] = useState(false);
38
+
39
+ return (
40
+ <div className="pt-2 border-t">
41
+ <button
42
+ onClick={() => setExpanded(!expanded)}
43
+ className="flex items-center gap-2 w-full rounded-md bg-emerald-500/10 px-2.5 py-1.5 text-left transition-colors hover:bg-emerald-500/15"
44
+ >
45
+ <ShieldCheck className="h-3.5 w-3.5 text-emerald-500 shrink-0" />
46
+ <span className="text-xs font-medium">Your IFC data never leaves your device.</span>
47
+ {expanded ? (
48
+ <ChevronDown className="h-3 w-3 ml-auto shrink-0 text-muted-foreground" />
49
+ ) : (
50
+ <ChevronRight className="h-3 w-3 ml-auto shrink-0 text-muted-foreground" />
51
+ )}
52
+ </button>
53
+ {expanded && (
54
+ <div className="mt-1.5 ml-1 space-y-1 text-xs text-muted-foreground">
55
+ <p>
56
+ All files are processed locally in the browser with{' '}
57
+ <a
58
+ href="https://webassembly.org/"
59
+ target="_blank"
60
+ rel="noopener noreferrer"
61
+ className="underline hover:text-foreground transition-colors"
62
+ >
63
+ WebAssembly (WASM)
64
+ </a>
65
+ {' '}&ndash; no server upload, near-native speed.
66
+ </p>
67
+ <p className="text-[11px] italic">
68
+ Verify: press <kbd className="px-1 py-0.5 bg-muted rounded border font-mono text-[10px]">F12</kbd> &rarr; Network tab &rarr; no IFC data transmitted.
69
+ </p>
70
+ </div>
71
+ )}
72
+ </div>
73
+ );
74
+ }
75
+
36
76
  function AboutTab() {
37
77
  const [showPackages, setShowPackages] = useState(false);
38
78
  const packageVersions = __PACKAGE_VERSIONS__;
@@ -88,6 +128,9 @@ function AboutTab() {
88
128
  ))}
89
129
  </div>
90
130
 
131
+ {/* Privacy & Security */}
132
+ <PrivacyBanner />
133
+
91
134
  {/* Package Versions */}
92
135
  {packageVersions.length > 0 && (
93
136
  <div className="pt-2 border-t">
@@ -7,8 +7,6 @@ import {
7
7
  FolderOpen,
8
8
  Download,
9
9
  MousePointer2,
10
- Hand,
11
- Rotate3d,
12
10
  PersonStanding,
13
11
  Ruler,
14
12
  Scissors,
@@ -71,7 +69,7 @@ import { recordRecentFiles, cacheFileBlobs } from '@/lib/recent-files';
71
69
  import { ThemeSwitch } from './ThemeSwitch';
72
70
  import { toast } from '@/components/ui/toast';
73
71
 
74
- type Tool = 'select' | 'pan' | 'orbit' | 'walk' | 'measure' | 'section';
72
+ type Tool = 'select' | 'walk' | 'measure' | 'section';
75
73
  type WorkspacePanel = 'script' | 'list' | 'bcf' | 'ids' | 'lens';
76
74
 
77
75
  // #region FIX: Move ToolButton OUTSIDE MainToolbar to prevent recreation on every render
@@ -741,8 +739,6 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
741
739
 
742
740
  {/* ── Navigation Tools ── */}
743
741
  <ToolButton tool="select" icon={MousePointer2} label="Select" shortcut="V" activeTool={activeTool} onToolChange={setActiveTool} />
744
- <ToolButton tool="pan" icon={Hand} label="Pan" shortcut="P" activeTool={activeTool} onToolChange={setActiveTool} />
745
- <ToolButton tool="orbit" icon={Rotate3d} label="Orbit" shortcut="O" activeTool={activeTool} onToolChange={setActiveTool} />
746
742
  <ToolButton tool="walk" icon={PersonStanding} label="Walk Mode" shortcut="C" activeTool={activeTool} onToolChange={setActiveTool} />
747
743
 
748
744
  <Separator orientation="vertical" className="h-6 mx-1" />
@@ -32,7 +32,7 @@ import { configureMutationView } from '@/utils/configureMutationView';
32
32
  import { IfcQuery } from '@ifc-lite/query';
33
33
  import { MutablePropertyView } from '@ifc-lite/mutations';
34
34
  import { extractClassificationsOnDemand, extractMaterialsOnDemand, extractTypePropertiesOnDemand, extractTypeEntityOwnProperties, extractDocumentsOnDemand, extractRelationshipsOnDemand, type IfcDataStore } from '@ifc-lite/parser';
35
- import { EntityFlags, RelationshipType } from '@ifc-lite/data';
35
+ import { EntityFlags, RelationshipType, isSpatialStructureTypeName, isStoreyLikeSpatialTypeName } from '@ifc-lite/data';
36
36
  import type { EntityRef, FederatedModel } from '@/store/types';
37
37
 
38
38
  import { CoordVal, CoordRow } from './properties/CoordinateDisplay';
@@ -573,7 +573,7 @@ export function PropertiesPanel() {
573
573
  };
574
574
  }, [selectedEntity, model, ifcDataStore, mutationViews, mutationVersion]);
575
575
 
576
- // Spatial containment info for spatial containers (Project, Site, Building, Storey)
576
+ // Spatial containment info for spatial containers (Project, Facility, Part, Storey, Space)
577
577
  const spatialContainment = useMemo(() => {
578
578
  if (!selectedEntity) return null;
579
579
  const dataStore = model?.ifcDataStore ?? ifcDataStore;
@@ -583,9 +583,8 @@ export function PropertiesPanel() {
583
583
  const hierarchy = dataStore.spatialHierarchy;
584
584
  const typeName = dataStore.entities.getTypeName(expressId);
585
585
 
586
- // Only show for spatial containers
587
- const spatialTypes = ['IfcProject', 'IfcSite', 'IfcBuilding', 'IfcBuildingStorey', 'IfcSpace'];
588
- if (!spatialTypes.includes(typeName)) return null;
586
+ // Only show for spatial structure elements.
587
+ if (!isSpatialStructureTypeName(typeName)) return null;
589
588
 
590
589
  const stats: Array<{ label: string; value: string | number }> = [];
591
590
 
@@ -621,7 +620,7 @@ export function PropertiesPanel() {
621
620
  // Also count from containment maps
622
621
  const mapSources: Array<[string, Map<number, number[]> | undefined]> = [
623
622
  ['Elements (Site)', hierarchy.bySite],
624
- ['Elements (Building)', hierarchy.byBuilding],
623
+ ['Elements (Building-like)', hierarchy.byBuilding],
625
624
  ['Elements (Storey)', hierarchy.byStorey],
626
625
  ['Elements (Space)', hierarchy.bySpace],
627
626
  ];
@@ -633,7 +632,7 @@ export function PropertiesPanel() {
633
632
  }
634
633
 
635
634
  // Elevation for storeys
636
- if (typeName === 'IfcBuildingStorey') {
635
+ if (isStoreyLikeSpatialTypeName(typeName)) {
637
636
  const elevation = hierarchy.storeyElevations.get(expressId);
638
637
  if (elevation !== undefined) {
639
638
  stats.push({ label: 'Elevation', value: `${elevation.toFixed(2)} m` });
@@ -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">