@ifc-lite/viewer 1.9.0 → 1.11.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 (42) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/assets/{Arrow.dom-CusgkT03.js → Arrow.dom-IIkrrCZ0.js} +1 -1
  3. package/dist/assets/{browser-BXNIkE8a.js → browser-BoonPy8d.js} +1 -1
  4. package/dist/assets/ifc-lite_bg-B6s-pcv0.wasm +0 -0
  5. package/dist/assets/{index-huvR-kGC.js → index-CQkEOlYf.js} +49090 -46453
  6. package/dist/assets/{index-6Mr3byM-.js → index-ClZCG7KA.js} +4 -4
  7. package/dist/assets/index-qxIHWl_B.css +1 -0
  8. package/dist/assets/{native-bridge-DsHOKdgD.js → native-bridge-Beg4Kf9O.js} +1 -1
  9. package/dist/assets/{wasm-bridge-Bd73HXn-.js → wasm-bridge-CY8jkr7u.js} +1 -1
  10. package/dist/index.html +2 -2
  11. package/package.json +19 -19
  12. package/src/components/viewer/BasketPresentationDock.tsx +422 -0
  13. package/src/components/viewer/CommandPalette.tsx +29 -32
  14. package/src/components/viewer/EntityContextMenu.tsx +37 -22
  15. package/src/components/viewer/HierarchyPanel.tsx +19 -1
  16. package/src/components/viewer/MainToolbar.tsx +32 -89
  17. package/src/components/viewer/Section2DPanel.tsx +8 -1
  18. package/src/components/viewer/Viewport.tsx +107 -98
  19. package/src/components/viewer/ViewportContainer.tsx +2 -0
  20. package/src/components/viewer/ViewportOverlays.tsx +9 -3
  21. package/src/components/viewer/hierarchy/treeDataBuilder.ts +3 -1
  22. package/src/components/viewer/useAnimationLoop.ts +4 -1
  23. package/src/components/viewer/useKeyboardControls.ts +2 -2
  24. package/src/components/viewer/useRenderUpdates.ts +16 -4
  25. package/src/hooks/useKeyboardShortcuts.ts +51 -84
  26. package/src/hooks/useViewerSelectors.ts +22 -0
  27. package/src/index.css +6 -0
  28. package/src/store/basket/basketCommands.ts +81 -0
  29. package/src/store/basket/basketViewActivator.ts +54 -0
  30. package/src/store/basketSave.ts +122 -0
  31. package/src/store/basketVisibleSet.test.ts +161 -0
  32. package/src/store/basketVisibleSet.ts +487 -0
  33. package/src/store/constants.ts +20 -0
  34. package/src/store/homeView.ts +21 -0
  35. package/src/store/index.ts +17 -0
  36. package/src/store/slices/drawing2DSlice.ts +5 -0
  37. package/src/store/slices/pinboardSlice.test.ts +160 -0
  38. package/src/store/slices/pinboardSlice.ts +248 -18
  39. package/src/store/slices/uiSlice.ts +41 -0
  40. package/src/store/types.ts +11 -0
  41. package/dist/assets/ifc-lite_bg-DyIN_nBM.wasm +0 -0
  42. package/dist/assets/index-CGbokkQ9.css +0 -1
@@ -36,6 +36,7 @@ export function HierarchyPanel() {
36
36
  } = useIfc();
37
37
  const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
38
38
  const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
39
+ const setSelectedEntityIds = useViewerStore((s) => s.setSelectedEntityIds);
39
40
  const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity);
40
41
  const setSelectedEntities = useViewerStore((s) => s.setSelectedEntities);
41
42
  const setSelectedModelId = useViewerStore((s) => s.setSelectedModelId);
@@ -44,6 +45,7 @@ export function HierarchyPanel() {
44
45
  const setStoreysSelection = useViewerStore((s) => s.setStoreysSelection);
45
46
  const clearStoreySelection = useViewerStore((s) => s.clearStoreySelection);
46
47
  const isolateEntities = useViewerStore((s) => s.isolateEntities);
48
+ const setHierarchyBasketSelection = useViewerStore((s) => s.setHierarchyBasketSelection);
47
49
 
48
50
  const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
49
51
  const hideEntities = useViewerStore((s) => s.hideEntities);
@@ -180,10 +182,26 @@ export function HierarchyPanel() {
180
182
  return;
181
183
  }
182
184
 
185
+ const hierarchyRefs: Array<{ modelId: string; expressId: number }> = [];
186
+ for (const globalId of getNodeElements(node)) {
187
+ const ref = resolveEntityRef(globalId);
188
+ if (ref) hierarchyRefs.push(ref);
189
+ }
190
+ if (hierarchyRefs.length > 0) {
191
+ setHierarchyBasketSelection(hierarchyRefs);
192
+ } else if (isSpatialContainer(node.type) && node.expressIds.length > 0) {
193
+ setHierarchyBasketSelection([{
194
+ modelId: node.modelIds[0] || 'legacy',
195
+ expressId: node.expressIds[0],
196
+ }]);
197
+ }
198
+
183
199
  // Type group nodes - click to isolate entities, expand via chevron only
184
200
  if (node.type === 'type-group') {
185
201
  const elements = getNodeElements(node);
186
202
  if (elements.length > 0) {
203
+ setSelectedEntityIds(elements);
204
+ setSelectedEntity(resolveEntityRef(elements[0]));
187
205
  isolateEntities(elements);
188
206
  }
189
207
  return;
@@ -284,7 +302,7 @@ export function HierarchyPanel() {
284
302
  setSelectedEntity(resolveEntityRef(elementId));
285
303
  }
286
304
  }
287
- }, [selectedStoreys, setStoreysSelection, clearStoreySelection, setSelectedEntityId, setSelectedEntity, setSelectedEntities, setActiveModel, toggleExpand, unifiedStoreys, models, isolateEntities, getNodeElements]);
305
+ }, [selectedStoreys, setStoreysSelection, clearStoreySelection, setSelectedEntityId, setSelectedEntityIds, setSelectedEntity, setSelectedEntities, setActiveModel, toggleExpand, unifiedStoreys, models, isolateEntities, getNodeElements, setHierarchyBasketSelection]);
288
306
 
289
307
  // Compute selection and visibility state for a node
290
308
  const computeNodeState = useCallback((node: TreeNode): { isSelected: boolean; nodeHidden: boolean; modelVisible?: boolean } => {
@@ -34,11 +34,11 @@ import {
34
34
  SquareX,
35
35
  Building2,
36
36
  Plus,
37
- Minus,
38
37
  MessageSquare,
39
38
  ClipboardCheck,
40
39
  Palette,
41
40
  Orbit,
41
+ LayoutTemplate,
42
42
  } from 'lucide-react';
43
43
  import { Button } from '@/components/ui/button';
44
44
  import { Separator } from '@/components/ui/separator';
@@ -55,8 +55,9 @@ import {
55
55
  DropdownMenuSubContent,
56
56
  } from '@/components/ui/dropdown-menu';
57
57
  import { Progress } from '@/components/ui/progress';
58
- import { useViewerStore, isIfcxDataStore, stringToEntityRef } from '@/store';
59
- import type { EntityRef } from '@/store';
58
+ import { useViewerStore, isIfcxDataStore } from '@/store';
59
+ import { goHomeFromStore, resetVisibilityForHomeFromStore } from '@/store/homeView';
60
+ import { executeBasketIsolate } from '@/store/basket/basketCommands';
60
61
  import { useIfc } from '@/hooks/useIfc';
61
62
  import { cn } from '@/lib/utils';
62
63
  import { GLTFExporter, CSVExporter } from '@ifc-lite/export';
@@ -169,8 +170,6 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
169
170
  const toggleTheme = useViewerStore((state) => state.toggleTheme);
170
171
  const selectedEntityId = useViewerStore((state) => state.selectedEntityId);
171
172
  const hideEntities = useViewerStore((state) => state.hideEntities);
172
- const showAll = useViewerStore((state) => state.showAll);
173
- const clearStoreySelection = useViewerStore((state) => state.clearStoreySelection);
174
173
  const error = useViewerStore((state) => state.error);
175
174
  const cameraCallbacks = useViewerStore((state) => state.cameraCallbacks);
176
175
  const hoverTooltipsEnabled = useViewerStore((state) => state.hoverTooltipsEnabled);
@@ -189,14 +188,11 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
189
188
  const setRightPanelCollapsed = useViewerStore((state) => state.setRightPanelCollapsed);
190
189
  const projectionMode = useViewerStore((state) => state.projectionMode);
191
190
  const toggleProjectionMode = useViewerStore((state) => state.toggleProjectionMode);
192
- // Basket state (= + − isolation basket)
191
+ // Basket presentation state
193
192
  const pinboardEntities = useViewerStore((state) => state.pinboardEntities);
194
- const setBasket = useViewerStore((state) => state.setBasket);
195
- const addToBasket = useViewerStore((state) => state.addToBasket);
196
- const removeFromBasket = useViewerStore((state) => state.removeFromBasket);
197
- const clearBasket = useViewerStore((state) => state.clearBasket);
198
- // NOTE: selectedEntity and selectedEntitiesSet accessed via getState() in callbacks
199
- // to avoid re-rendering MainToolbar on every Cmd+Click selection change.
193
+ const basketViewCount = useViewerStore((state) => state.basketViews.length);
194
+ const basketPresentationVisible = useViewerStore((state) => state.basketPresentationVisible);
195
+ const toggleBasketPresentationVisible = useViewerStore((state) => state.toggleBasketPresentationVisible);
200
196
  // Lens state
201
197
  const lensPanelVisible = useViewerStore((state) => state.lensPanelVisible);
202
198
  const toggleLensPanel = useViewerStore((state) => state.toggleLensPanel);
@@ -306,68 +302,8 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
306
302
  e.target.value = '';
307
303
  }, [loadFilesSequentially, addIfcxOverlays, ifcDataStore]);
308
304
 
309
- /** Get current selection as EntityRef[] — uses getState() to avoid reactive subscriptions */
310
- const getSelectionRefs = useCallback((): EntityRef[] => {
311
- const state = useViewerStore.getState();
312
- if (state.selectedEntitiesSet.size > 0) {
313
- const refs: EntityRef[] = [];
314
- for (const str of state.selectedEntitiesSet) {
315
- refs.push(stringToEntityRef(str));
316
- }
317
- return refs;
318
- }
319
- if (state.selectedEntity) {
320
- return [state.selectedEntity];
321
- }
322
- return [];
323
- }, []);
324
-
325
305
  const hasSelection = selectedEntityId !== null;
326
306
 
327
- // Basket state
328
- const showPinboard = useViewerStore((state) => state.showPinboard);
329
-
330
- // Clear multi-select state after basket operations so subsequent − targets a single entity
331
- const clearMultiSelect = useCallback(() => {
332
- const state = useViewerStore.getState();
333
- if (state.selectedEntitiesSet.size > 0) {
334
- useViewerStore.setState({ selectedEntitiesSet: new Set(), selectedEntityIds: new Set() });
335
- }
336
- }, []);
337
-
338
- // Basket operations
339
- const handleSetBasket = useCallback(() => {
340
- const state = useViewerStore.getState();
341
- // If basket already exists and user hasn't explicitly multi-selected,
342
- // re-apply the basket instead of replacing it with a stale single selection.
343
- // Only an explicit multi-selection (Ctrl+Click) should replace an existing basket.
344
- if (state.pinboardEntities.size > 0 && state.selectedEntitiesSet.size === 0) {
345
- showPinboard();
346
- return;
347
- }
348
- const refs = getSelectionRefs();
349
- if (refs.length > 0) {
350
- setBasket(refs);
351
- clearMultiSelect();
352
- }
353
- }, [getSelectionRefs, setBasket, showPinboard, clearMultiSelect]);
354
-
355
- const handleAddToBasket = useCallback(() => {
356
- const refs = getSelectionRefs();
357
- if (refs.length > 0) {
358
- addToBasket(refs);
359
- clearMultiSelect();
360
- }
361
- }, [getSelectionRefs, addToBasket, clearMultiSelect]);
362
-
363
- const handleRemoveFromBasket = useCallback(() => {
364
- const refs = getSelectionRefs();
365
- if (refs.length > 0) {
366
- removeFromBasket(refs);
367
- clearMultiSelect();
368
- }
369
- }, [getSelectionRefs, removeFromBasket, clearMultiSelect]);
370
-
371
307
  const clearSelection = useViewerStore((state) => state.clearSelection);
372
308
 
373
309
  const handleHide = useCallback(() => {
@@ -383,9 +319,16 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
383
319
  }, [selectedEntityId, hideEntities, clearSelection]);
384
320
 
385
321
  const handleShowAll = useCallback(() => {
386
- showAll(); // Clear hiddenEntities + isolatedEntities (basket contents preserved)
387
- clearStoreySelection(); // Also clear storey filtering
388
- }, [showAll, clearStoreySelection]);
322
+ resetVisibilityForHomeFromStore();
323
+ }, []);
324
+
325
+ const handleIsolate = useCallback(() => {
326
+ executeBasketIsolate();
327
+ }, []);
328
+
329
+ const handleHome = useCallback(() => {
330
+ goHomeFromStore();
331
+ }, []);
389
332
 
390
333
  const handleExportGLB = useCallback(() => {
391
334
  if (!geometryResult) return;
@@ -759,36 +702,35 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
759
702
 
760
703
  <Separator orientation="vertical" className="h-6 mx-1" />
761
704
 
762
- {/* ── Basket Isolation (= + −) ── */}
705
+ {/* ── Basket Presentation ── */}
763
706
  <Tooltip>
764
707
  <TooltipTrigger asChild>
765
708
  <Button
766
- variant={pinboardEntities.size > 0 ? 'default' : 'ghost'}
709
+ variant={basketPresentationVisible ? 'default' : 'ghost'}
767
710
  size="icon-sm"
768
711
  onClick={(e) => {
769
712
  (e.currentTarget as HTMLButtonElement).blur();
770
- handleSetBasket();
713
+ toggleBasketPresentationVisible();
771
714
  }}
772
- disabled={!hasSelection && pinboardEntities.size === 0}
715
+ disabled={models.size === 0 && !geometryResult}
773
716
  className={cn(
774
- pinboardEntities.size > 0 && 'bg-primary text-primary-foreground relative',
717
+ (basketPresentationVisible || pinboardEntities.size > 0) && 'relative',
775
718
  )}
776
719
  >
777
- <Equal className="h-4 w-4" />
778
- {pinboardEntities.size > 0 && (
720
+ <LayoutTemplate className="h-4 w-4" />
721
+ {(basketViewCount > 0 || pinboardEntities.size > 0) && (
779
722
  <span className="absolute -top-1 -right-1 bg-primary text-primary-foreground text-[9px] font-bold rounded-full min-w-[14px] h-[14px] flex items-center justify-center px-0.5 border border-background">
780
- {pinboardEntities.size}
723
+ {basketViewCount > 0 ? `${basketViewCount}/${pinboardEntities.size}` : pinboardEntities.size}
781
724
  </span>
782
725
  )}
783
726
  </Button>
784
727
  </TooltipTrigger>
785
728
  <TooltipContent>
786
- Set Basket isolate selection <span className="ml-2 text-xs opacity-60">(I)</span>
729
+ Basket Presentation Dock (Views: {basketViewCount}, Entities: {pinboardEntities.size})
787
730
  </TooltipContent>
788
731
  </Tooltip>
789
- <ActionButton icon={Plus} label="Add to Basket" onClick={handleAddToBasket} shortcut="+" disabled={!hasSelection} />
790
- <ActionButton icon={Minus} label="Remove from Basket" onClick={handleRemoveFromBasket} shortcut="−" disabled={!hasSelection} />
791
732
 
733
+ <ActionButton icon={Equal} label="Isolate (Set Basket)" onClick={handleIsolate} shortcut="I / =" />
792
734
  <ActionButton icon={EyeOff} label="Hide Selection" onClick={handleHide} shortcut="Del / Space" disabled={!hasSelection} />
793
735
  <ActionButton icon={Eye} label="Show All (Reset Filters)" onClick={handleShowAll} shortcut="A" />
794
736
  <ActionButton icon={Maximize2} label="Fit All" onClick={() => cameraCallbacks.fitAll?.()} shortcut="Z" />
@@ -869,7 +811,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
869
811
  <Separator orientation="vertical" className="h-6 mx-1" />
870
812
 
871
813
  {/* ── Camera & View ── */}
872
- <ActionButton icon={Home} label="Home (Isometric)" onClick={() => cameraCallbacks.home?.()} shortcut="H" />
814
+ <ActionButton icon={Home} label="Home (Isometric + Reset Visibility)" onClick={handleHome} shortcut="H" />
873
815
 
874
816
  {/* Orthographic / Perspective toggle */}
875
817
  <Tooltip>
@@ -924,8 +866,8 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
924
866
  <TooltipContent>Preset Views</TooltipContent>
925
867
  </Tooltip>
926
868
  <DropdownMenuContent>
927
- <DropdownMenuItem onClick={() => cameraCallbacks.home?.()}>
928
- <Box className="h-4 w-4 mr-2" /> Isometric <span className="ml-auto text-xs opacity-60">0</span>
869
+ <DropdownMenuItem onClick={handleHome}>
870
+ <Box className="h-4 w-4 mr-2" /> Isometric <span className="ml-auto text-xs opacity-60">H</span>
929
871
  </DropdownMenuItem>
930
872
  <DropdownMenuSeparator />
931
873
  <DropdownMenuItem onClick={() => cameraCallbacks.setPresetView?.('top')}>
@@ -988,6 +930,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
988
930
  </TooltipTrigger>
989
931
  <TooltipContent>Info (?)</TooltipContent>
990
932
  </Tooltip>
933
+
991
934
  </div>
992
935
  );
993
936
  }
@@ -52,6 +52,8 @@ export function Section2DPanel({
52
52
  // ═══════════════════════════════════════════════════════════════════════════
53
53
  const panelVisible = useViewerStore((s) => s.drawing2DPanelVisible);
54
54
  const setDrawingPanelVisible = useViewerStore((s) => s.setDrawing2DPanelVisible);
55
+ const suppressNextSection2DPanelAutoOpen = useViewerStore((s) => s.suppressNextSection2DPanelAutoOpen);
56
+ const setSuppressNextSection2DPanelAutoOpen = useViewerStore((s) => s.setSuppressNextSection2DPanelAutoOpen);
55
57
  const drawing = useViewerStore((s) => s.drawing2D);
56
58
  const setDrawing = useViewerStore((s) => s.setDrawing2D);
57
59
  const status = useViewerStore((s) => s.drawing2DStatus);
@@ -148,10 +150,15 @@ export function Section2DPanel({
148
150
  useEffect(() => {
149
151
  // Section tool was just activated
150
152
  if (activeTool === 'section' && prevActiveToolRef.current !== 'section' && geometryResult?.meshes) {
153
+ if (suppressNextSection2DPanelAutoOpen) {
154
+ setSuppressNextSection2DPanelAutoOpen(false);
155
+ prevActiveToolRef.current = activeTool;
156
+ return;
157
+ }
151
158
  setDrawingPanelVisible(true);
152
159
  }
153
160
  prevActiveToolRef.current = activeTool;
154
- }, [activeTool, geometryResult, setDrawingPanelVisible]);
161
+ }, [activeTool, geometryResult, setDrawingPanelVisible, suppressNextSection2DPanelAutoOpen, setSuppressNextSection2DPanelAutoOpen]);
155
162
 
156
163
  // ═══════════════════════════════════════════════════════════════════════════
157
164
  // LOCAL STATE
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
10
- import { Renderer } from '@ifc-lite/renderer';
10
+ import { Renderer, type VisualEnhancementOptions } from '@ifc-lite/renderer';
11
11
  import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
12
12
  import { useViewerStore, resolveEntityRef, type MeasurePoint, type SnapVisualization } from '@/store';
13
13
  import {
@@ -158,7 +158,20 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
158
158
  const { updateCameraRotationRealtime, updateScaleRealtime, setCameraCallbacks } = useCameraState();
159
159
 
160
160
  // Theme state
161
- const { theme } = useThemeState();
161
+ const {
162
+ theme,
163
+ isMobile,
164
+ visualEnhancementsEnabled,
165
+ edgeContrastEnabled,
166
+ edgeContrastIntensity,
167
+ contactShadingQuality,
168
+ contactShadingIntensity,
169
+ contactShadingRadius,
170
+ separationLinesEnabled,
171
+ separationLinesQuality,
172
+ separationLinesIntensity,
173
+ separationLinesRadius,
174
+ } = useThemeState();
162
175
 
163
176
  // Hover state
164
177
  const { hoverTooltipsEnabled, setHoverState, clearHover } = useHoverState();
@@ -215,6 +228,37 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
215
228
  // Theme-aware clear color ref (updated when theme changes)
216
229
  // Tokyo Night storm: #1a1b26 = rgb(26, 27, 38)
217
230
  const clearColorRef = useRef<[number, number, number, number]>([0.102, 0.106, 0.149, 1]);
231
+ const visualEnhancement = useMemo<VisualEnhancementOptions>(() => ({
232
+ enabled: visualEnhancementsEnabled,
233
+ edgeContrast: {
234
+ enabled: edgeContrastEnabled,
235
+ intensity: edgeContrastIntensity,
236
+ },
237
+ contactShading: {
238
+ quality: isMobile ? 'off' : contactShadingQuality,
239
+ intensity: contactShadingIntensity,
240
+ radius: contactShadingRadius,
241
+ },
242
+ separationLines: {
243
+ enabled: separationLinesEnabled,
244
+ quality: isMobile ? 'low' : separationLinesQuality,
245
+ intensity: isMobile ? Math.min(0.4, separationLinesIntensity) : separationLinesIntensity,
246
+ radius: isMobile ? 1.0 : separationLinesRadius,
247
+ },
248
+ }), [
249
+ visualEnhancementsEnabled,
250
+ edgeContrastEnabled,
251
+ edgeContrastIntensity,
252
+ isMobile,
253
+ contactShadingQuality,
254
+ contactShadingIntensity,
255
+ contactShadingRadius,
256
+ separationLinesEnabled,
257
+ separationLinesQuality,
258
+ separationLinesIntensity,
259
+ separationLinesRadius,
260
+ ]);
261
+ const visualEnhancementRef = useRef<VisualEnhancementOptions>(visualEnhancement);
218
262
 
219
263
  // Animation frame ref
220
264
  const animationFrameRef = useRef<number | null>(null);
@@ -330,6 +374,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
330
374
  useEffect(() => { measurementConstraintEdgeRef.current = measurementConstraintEdge; }, [measurementConstraintEdge]);
331
375
  useEffect(() => { sectionPlaneRef.current = sectionPlane; }, [sectionPlane]);
332
376
  useEffect(() => { sectionRangeRef.current = sectionRange; }, [sectionRange]);
377
+ useEffect(() => { visualEnhancementRef.current = visualEnhancement; }, [visualEnhancement]);
333
378
  useEffect(() => {
334
379
  geometryRef.current = geometry;
335
380
  }, [geometry]);
@@ -445,6 +490,21 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
445
490
  setIsInitialized(true);
446
491
 
447
492
  const camera = renderer.getCamera();
493
+ const renderCurrent = () => {
494
+ renderer.render({
495
+ hiddenIds: hiddenEntitiesRef.current,
496
+ isolatedIds: isolatedEntitiesRef.current,
497
+ selectedId: selectedEntityIdRef.current,
498
+ selectedModelIndex: selectedModelIndexRef.current,
499
+ clearColor: clearColorRef.current,
500
+ visualEnhancement: visualEnhancementRef.current,
501
+ sectionPlane: activeToolRef.current === 'section' ? {
502
+ ...sectionPlaneRef.current,
503
+ min: sectionRangeRef.current?.min,
504
+ max: sectionRangeRef.current?.max,
505
+ } : undefined,
506
+ });
507
+ };
448
508
 
449
509
  // Register camera callbacks for ViewCube and other controls
450
510
  setCameraCallbacks({
@@ -453,18 +513,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
453
513
  const rotation = coordinateInfoRef.current?.buildingRotation;
454
514
  camera.setPresetView(view, geometryBoundsRef.current, rotation);
455
515
  // Initial render - animation loop will continue rendering during animation
456
- renderer.render({
457
- hiddenIds: hiddenEntitiesRef.current,
458
- isolatedIds: isolatedEntitiesRef.current,
459
- selectedId: selectedEntityIdRef.current,
460
- selectedModelIndex: selectedModelIndexRef.current,
461
- clearColor: clearColorRef.current,
462
- sectionPlane: activeToolRef.current === 'section' ? {
463
- ...sectionPlaneRef.current,
464
- min: sectionRangeRef.current?.min,
465
- max: sectionRangeRef.current?.max,
466
- } : undefined,
467
- });
516
+ renderCurrent();
468
517
  calculateScale();
469
518
  },
470
519
  fitAll: () => {
@@ -479,34 +528,12 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
479
528
  },
480
529
  zoomIn: () => {
481
530
  camera.zoom(-50, false);
482
- renderer.render({
483
- hiddenIds: hiddenEntitiesRef.current,
484
- isolatedIds: isolatedEntitiesRef.current,
485
- selectedId: selectedEntityIdRef.current,
486
- selectedModelIndex: selectedModelIndexRef.current,
487
- clearColor: clearColorRef.current,
488
- sectionPlane: activeToolRef.current === 'section' ? {
489
- ...sectionPlaneRef.current,
490
- min: sectionRangeRef.current?.min,
491
- max: sectionRangeRef.current?.max,
492
- } : undefined,
493
- });
531
+ renderCurrent();
494
532
  calculateScale();
495
533
  },
496
534
  zoomOut: () => {
497
535
  camera.zoom(50, false);
498
- renderer.render({
499
- hiddenIds: hiddenEntitiesRef.current,
500
- isolatedIds: isolatedEntitiesRef.current,
501
- selectedId: selectedEntityIdRef.current,
502
- selectedModelIndex: selectedModelIndexRef.current,
503
- clearColor: clearColorRef.current,
504
- sectionPlane: activeToolRef.current === 'section' ? {
505
- ...sectionPlaneRef.current,
506
- min: sectionRangeRef.current?.min,
507
- max: sectionRangeRef.current?.max,
508
- } : undefined,
509
- });
536
+ renderCurrent();
510
537
  calculateScale();
511
538
  },
512
539
  frameSelection: () => {
@@ -528,18 +555,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
528
555
  orbit: (deltaX: number, deltaY: number) => {
529
556
  // Orbit camera from ViewCube drag
530
557
  camera.orbit(deltaX, deltaY, false);
531
- renderer.render({
532
- hiddenIds: hiddenEntitiesRef.current,
533
- isolatedIds: isolatedEntitiesRef.current,
534
- selectedId: selectedEntityIdRef.current,
535
- selectedModelIndex: selectedModelIndexRef.current,
536
- clearColor: clearColorRef.current,
537
- sectionPlane: activeToolRef.current === 'section' ? {
538
- ...sectionPlaneRef.current,
539
- min: sectionRangeRef.current?.min,
540
- max: sectionRangeRef.current?.max,
541
- } : undefined,
542
- });
558
+ renderCurrent();
543
559
  updateCameraRotationRealtime(camera.getRotation());
544
560
  calculateScale();
545
561
  },
@@ -551,37 +567,47 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
551
567
  },
552
568
  setProjectionMode: (mode) => {
553
569
  camera.setProjectionMode(mode);
554
- renderer.render({
555
- hiddenIds: hiddenEntitiesRef.current,
556
- isolatedIds: isolatedEntitiesRef.current,
557
- selectedId: selectedEntityIdRef.current,
558
- selectedModelIndex: selectedModelIndexRef.current,
559
- clearColor: clearColorRef.current,
560
- sectionPlane: activeToolRef.current === 'section' ? {
561
- ...sectionPlaneRef.current,
562
- min: sectionRangeRef.current?.min,
563
- max: sectionRangeRef.current?.max,
564
- } : undefined,
565
- });
570
+ renderCurrent();
566
571
  calculateScale();
567
572
  },
568
573
  toggleProjectionMode: () => {
569
574
  camera.toggleProjectionMode();
570
- renderer.render({
571
- hiddenIds: hiddenEntitiesRef.current,
572
- isolatedIds: isolatedEntitiesRef.current,
573
- selectedId: selectedEntityIdRef.current,
574
- selectedModelIndex: selectedModelIndexRef.current,
575
- clearColor: clearColorRef.current,
576
- sectionPlane: activeToolRef.current === 'section' ? {
577
- ...sectionPlaneRef.current,
578
- min: sectionRangeRef.current?.min,
579
- max: sectionRangeRef.current?.max,
580
- } : undefined,
581
- });
575
+ renderCurrent();
582
576
  calculateScale();
583
577
  },
584
578
  getProjectionMode: () => camera.getProjectionMode(),
579
+ getViewpoint: () => ({
580
+ position: camera.getPosition(),
581
+ target: camera.getTarget(),
582
+ up: camera.getUp(),
583
+ fov: camera.getFOV(),
584
+ projectionMode: camera.getProjectionMode(),
585
+ orthoSize: camera.getProjectionMode() === 'orthographic' ? camera.getOrthoSize() : undefined,
586
+ }),
587
+ applyViewpoint: (viewpoint, animate = true, durationMs = 300) => {
588
+ camera.setProjectionMode(viewpoint.projectionMode);
589
+ useViewerStore.setState({ projectionMode: viewpoint.projectionMode });
590
+ camera.setFOV(viewpoint.fov);
591
+ if (
592
+ viewpoint.projectionMode === 'orthographic' &&
593
+ typeof viewpoint.orthoSize === 'number' &&
594
+ Number.isFinite(viewpoint.orthoSize)
595
+ ) {
596
+ camera.setOrthoSize(viewpoint.orthoSize);
597
+ }
598
+
599
+ if (animate) {
600
+ camera.animateToWithUp(viewpoint.position, viewpoint.target, viewpoint.up, durationMs);
601
+ } else {
602
+ camera.setPosition(viewpoint.position.x, viewpoint.position.y, viewpoint.position.z);
603
+ camera.setTarget(viewpoint.target.x, viewpoint.target.y, viewpoint.target.z);
604
+ camera.setUp(viewpoint.up.x, viewpoint.up.y, viewpoint.up.z);
605
+ }
606
+
607
+ renderCurrent();
608
+ updateCameraRotationRealtime(camera.getRotation());
609
+ calculateScale();
610
+ },
585
611
  });
586
612
 
587
613
  // ResizeObserver
@@ -592,34 +618,12 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
592
618
  const w = alignToWebGPU(Math.max(1, Math.floor(rect.width)));
593
619
  const h = Math.max(1, Math.floor(rect.height));
594
620
  renderer.resize(w, h);
595
- renderer.render({
596
- hiddenIds: hiddenEntitiesRef.current,
597
- isolatedIds: isolatedEntitiesRef.current,
598
- selectedId: selectedEntityIdRef.current,
599
- selectedModelIndex: selectedModelIndexRef.current,
600
- clearColor: clearColorRef.current,
601
- sectionPlane: activeToolRef.current === 'section' ? {
602
- ...sectionPlaneRef.current,
603
- min: sectionRangeRef.current?.min,
604
- max: sectionRangeRef.current?.max,
605
- } : undefined,
606
- });
621
+ renderCurrent();
607
622
  });
608
623
  resizeObserver.observe(canvas);
609
624
 
610
625
  // Initial render
611
- renderer.render({
612
- hiddenIds: hiddenEntitiesRef.current,
613
- isolatedIds: isolatedEntitiesRef.current,
614
- selectedId: selectedEntityIdRef.current,
615
- selectedModelIndex: selectedModelIndexRef.current,
616
- clearColor: clearColorRef.current,
617
- sectionPlane: activeToolRef.current === 'section' ? {
618
- ...sectionPlaneRef.current,
619
- min: sectionRangeRef.current?.min,
620
- max: sectionRangeRef.current?.max,
621
- } : undefined,
622
- });
626
+ renderCurrent();
623
627
  });
624
628
 
625
629
  return () => {
@@ -643,6 +647,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
643
647
  // ===== Drawing 2D state for render updates =====
644
648
  const drawing2D = useViewerStore((s) => s.drawing2D);
645
649
  const show3DOverlay = useViewerStore((s) => s.drawing2DDisplayOptions.show3DOverlay);
650
+ const showHiddenLines = useViewerStore((s) => s.drawing2DDisplayOptions.showHiddenLines);
646
651
 
647
652
  // ===== Streaming progress =====
648
653
  const progress = useViewerStore((state) => state.progress);
@@ -767,6 +772,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
767
772
  clearColorRef,
768
773
  sectionPlaneRef,
769
774
  sectionRangeRef,
775
+ visualEnhancementRef,
770
776
  lastCameraStateRef,
771
777
  updateCameraRotationRealtime,
772
778
  calculateScale,
@@ -791,6 +797,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
791
797
  isInitialized,
792
798
  theme,
793
799
  clearColorRef,
800
+ visualEnhancementRef,
794
801
  hiddenEntities,
795
802
  isolatedEntities,
796
803
  selectedEntityId,
@@ -810,11 +817,13 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
810
817
  activeToolRef,
811
818
  drawing2D,
812
819
  show3DOverlay,
820
+ showHiddenLines,
813
821
  });
814
822
 
815
823
  return (
816
824
  <canvas
817
825
  ref={canvasRef}
826
+ data-viewport="main"
818
827
  className="w-full h-full block"
819
828
  />
820
829
  );
@@ -7,6 +7,7 @@ import { Viewport } from './Viewport';
7
7
  import { ViewportOverlays } from './ViewportOverlays';
8
8
  import { ToolOverlays } from './ToolOverlays';
9
9
  import { Section2DPanel } from './Section2DPanel';
10
+ import { BasketPresentationDock } from './BasketPresentationDock';
10
11
  import { useViewerStore } from '@/store';
11
12
  import { useIfc } from '@/hooks/useIfc';
12
13
  import { useWebGPU } from '@/hooks/useWebGPU';
@@ -585,6 +586,7 @@ export function ViewportContainer() {
585
586
  />
586
587
  <ViewportOverlays />
587
588
  <ToolOverlays />
589
+ <BasketPresentationDock />
588
590
  <Section2DPanel
589
591
  mergedGeometry={mergedGeometryResult}
590
592
  computedIsolatedIds={computedIsolatedIds}
@@ -12,7 +12,9 @@ import {
12
12
  import { Button } from '@/components/ui/button';
13
13
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
14
14
  import { useViewerStore } from '@/store';
15
+ import { goHomeFromStore } from '@/store/homeView';
15
16
  import { useIfc } from '@/hooks/useIfc';
17
+ import { cn } from '@/lib/utils';
16
18
  import { ViewCube, type ViewCubeRef } from './ViewCube';
17
19
  import { AxisHelper } from './AxisHelper';
18
20
 
@@ -20,6 +22,7 @@ export function ViewportOverlays() {
20
22
  const selectedStoreys = useViewerStore((s) => s.selectedStoreys);
21
23
  const hiddenEntities = useViewerStore((s) => s.hiddenEntities);
22
24
  const isolatedEntities = useViewerStore((s) => s.isolatedEntities);
25
+ const basketPresentationVisible = useViewerStore((s) => s.basketPresentationVisible);
23
26
  const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
24
27
  const setOnCameraRotationChange = useViewerStore((s) => s.setOnCameraRotationChange);
25
28
  const setOnScaleChange = useViewerStore((s) => s.setOnScaleChange);
@@ -99,8 +102,8 @@ export function ViewportOverlays() {
99
102
  }, [cameraCallbacks]);
100
103
 
101
104
  const handleHome = useCallback(() => {
102
- cameraCallbacks.home?.();
103
- }, [cameraCallbacks]);
105
+ goHomeFromStore();
106
+ }, []);
104
107
 
105
108
  const handleFitAll = useCallback(() => {
106
109
  cameraCallbacks.fitAll?.();
@@ -161,7 +164,10 @@ export function ViewportOverlays() {
161
164
 
162
165
  {/* Context Info (bottom-center) - Storey names */}
163
166
  {storeyNames && storeyNames.length > 0 && (
164
- <div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-4 py-2 bg-background/80 backdrop-blur-sm rounded-full border shadow-sm">
167
+ <div className={cn(
168
+ 'absolute left-1/2 -translate-x-1/2 px-4 py-2 bg-background/80 backdrop-blur-sm rounded-full border shadow-sm',
169
+ basketPresentationVisible ? 'bottom-28' : 'bottom-4',
170
+ )}>
165
171
  <div className="flex items-center gap-2 text-sm">
166
172
  <Layers className="h-4 w-4 text-primary" />
167
173
  <span className="font-medium">