@ifc-lite/viewer 1.10.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 (35) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/assets/{Arrow.dom-Bw5JMdDs.js → Arrow.dom-IIkrrCZ0.js} +1 -1
  3. package/dist/assets/{browser-DdRf3aWl.js → browser-BoonPy8d.js} +1 -1
  4. package/dist/assets/{ifc-lite_bg-C1-gLAHo.wasm → ifc-lite_bg-B6s-pcv0.wasm} +0 -0
  5. package/dist/assets/{index-1ff6P0kc.js → index-CQkEOlYf.js} +40975 -40044
  6. package/dist/assets/{index-Bz7vHRxl.js → index-ClZCG7KA.js} +4 -4
  7. package/dist/assets/index-qxIHWl_B.css +1 -0
  8. package/dist/assets/{native-bridge-C5hD5vae.js → native-bridge-Beg4Kf9O.js} +1 -1
  9. package/dist/assets/{wasm-bridge-CaNKXFGM.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 +58 -104
  19. package/src/components/viewer/ViewportContainer.tsx +2 -0
  20. package/src/components/viewer/ViewportOverlays.tsx +9 -3
  21. package/src/components/viewer/useKeyboardControls.ts +2 -2
  22. package/src/components/viewer/useRenderUpdates.ts +10 -3
  23. package/src/hooks/useKeyboardShortcuts.ts +51 -84
  24. package/src/store/basket/basketCommands.ts +81 -0
  25. package/src/store/basket/basketViewActivator.ts +54 -0
  26. package/src/store/basketSave.ts +122 -0
  27. package/src/store/basketVisibleSet.test.ts +161 -0
  28. package/src/store/basketVisibleSet.ts +487 -0
  29. package/src/store/homeView.ts +21 -0
  30. package/src/store/index.ts +7 -0
  31. package/src/store/slices/drawing2DSlice.ts +5 -0
  32. package/src/store/slices/pinboardSlice.test.ts +160 -0
  33. package/src/store/slices/pinboardSlice.ts +248 -18
  34. package/src/store/types.ts +11 -0
  35. package/dist/assets/index-mvbV6NHd.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
@@ -490,6 +490,21 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
490
490
  setIsInitialized(true);
491
491
 
492
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
+ };
493
508
 
494
509
  // Register camera callbacks for ViewCube and other controls
495
510
  setCameraCallbacks({
@@ -498,19 +513,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
498
513
  const rotation = coordinateInfoRef.current?.buildingRotation;
499
514
  camera.setPresetView(view, geometryBoundsRef.current, rotation);
500
515
  // Initial render - animation loop will continue rendering during animation
501
- renderer.render({
502
- hiddenIds: hiddenEntitiesRef.current,
503
- isolatedIds: isolatedEntitiesRef.current,
504
- selectedId: selectedEntityIdRef.current,
505
- selectedModelIndex: selectedModelIndexRef.current,
506
- clearColor: clearColorRef.current,
507
- visualEnhancement: visualEnhancementRef.current,
508
- sectionPlane: activeToolRef.current === 'section' ? {
509
- ...sectionPlaneRef.current,
510
- min: sectionRangeRef.current?.min,
511
- max: sectionRangeRef.current?.max,
512
- } : undefined,
513
- });
516
+ renderCurrent();
514
517
  calculateScale();
515
518
  },
516
519
  fitAll: () => {
@@ -525,36 +528,12 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
525
528
  },
526
529
  zoomIn: () => {
527
530
  camera.zoom(-50, false);
528
- renderer.render({
529
- hiddenIds: hiddenEntitiesRef.current,
530
- isolatedIds: isolatedEntitiesRef.current,
531
- selectedId: selectedEntityIdRef.current,
532
- selectedModelIndex: selectedModelIndexRef.current,
533
- clearColor: clearColorRef.current,
534
- visualEnhancement: visualEnhancementRef.current,
535
- sectionPlane: activeToolRef.current === 'section' ? {
536
- ...sectionPlaneRef.current,
537
- min: sectionRangeRef.current?.min,
538
- max: sectionRangeRef.current?.max,
539
- } : undefined,
540
- });
531
+ renderCurrent();
541
532
  calculateScale();
542
533
  },
543
534
  zoomOut: () => {
544
535
  camera.zoom(50, false);
545
- renderer.render({
546
- hiddenIds: hiddenEntitiesRef.current,
547
- isolatedIds: isolatedEntitiesRef.current,
548
- selectedId: selectedEntityIdRef.current,
549
- selectedModelIndex: selectedModelIndexRef.current,
550
- clearColor: clearColorRef.current,
551
- visualEnhancement: visualEnhancementRef.current,
552
- sectionPlane: activeToolRef.current === 'section' ? {
553
- ...sectionPlaneRef.current,
554
- min: sectionRangeRef.current?.min,
555
- max: sectionRangeRef.current?.max,
556
- } : undefined,
557
- });
536
+ renderCurrent();
558
537
  calculateScale();
559
538
  },
560
539
  frameSelection: () => {
@@ -576,19 +555,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
576
555
  orbit: (deltaX: number, deltaY: number) => {
577
556
  // Orbit camera from ViewCube drag
578
557
  camera.orbit(deltaX, deltaY, false);
579
- renderer.render({
580
- hiddenIds: hiddenEntitiesRef.current,
581
- isolatedIds: isolatedEntitiesRef.current,
582
- selectedId: selectedEntityIdRef.current,
583
- selectedModelIndex: selectedModelIndexRef.current,
584
- clearColor: clearColorRef.current,
585
- visualEnhancement: visualEnhancementRef.current,
586
- sectionPlane: activeToolRef.current === 'section' ? {
587
- ...sectionPlaneRef.current,
588
- min: sectionRangeRef.current?.min,
589
- max: sectionRangeRef.current?.max,
590
- } : undefined,
591
- });
558
+ renderCurrent();
592
559
  updateCameraRotationRealtime(camera.getRotation());
593
560
  calculateScale();
594
561
  },
@@ -600,39 +567,47 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
600
567
  },
601
568
  setProjectionMode: (mode) => {
602
569
  camera.setProjectionMode(mode);
603
- renderer.render({
604
- hiddenIds: hiddenEntitiesRef.current,
605
- isolatedIds: isolatedEntitiesRef.current,
606
- selectedId: selectedEntityIdRef.current,
607
- selectedModelIndex: selectedModelIndexRef.current,
608
- clearColor: clearColorRef.current,
609
- visualEnhancement: visualEnhancementRef.current,
610
- sectionPlane: activeToolRef.current === 'section' ? {
611
- ...sectionPlaneRef.current,
612
- min: sectionRangeRef.current?.min,
613
- max: sectionRangeRef.current?.max,
614
- } : undefined,
615
- });
570
+ renderCurrent();
616
571
  calculateScale();
617
572
  },
618
573
  toggleProjectionMode: () => {
619
574
  camera.toggleProjectionMode();
620
- renderer.render({
621
- hiddenIds: hiddenEntitiesRef.current,
622
- isolatedIds: isolatedEntitiesRef.current,
623
- selectedId: selectedEntityIdRef.current,
624
- selectedModelIndex: selectedModelIndexRef.current,
625
- clearColor: clearColorRef.current,
626
- visualEnhancement: visualEnhancementRef.current,
627
- sectionPlane: activeToolRef.current === 'section' ? {
628
- ...sectionPlaneRef.current,
629
- min: sectionRangeRef.current?.min,
630
- max: sectionRangeRef.current?.max,
631
- } : undefined,
632
- });
575
+ renderCurrent();
633
576
  calculateScale();
634
577
  },
635
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
+ },
636
611
  });
637
612
 
638
613
  // ResizeObserver
@@ -643,36 +618,12 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
643
618
  const w = alignToWebGPU(Math.max(1, Math.floor(rect.width)));
644
619
  const h = Math.max(1, Math.floor(rect.height));
645
620
  renderer.resize(w, h);
646
- renderer.render({
647
- hiddenIds: hiddenEntitiesRef.current,
648
- isolatedIds: isolatedEntitiesRef.current,
649
- selectedId: selectedEntityIdRef.current,
650
- selectedModelIndex: selectedModelIndexRef.current,
651
- clearColor: clearColorRef.current,
652
- visualEnhancement: visualEnhancementRef.current,
653
- sectionPlane: activeToolRef.current === 'section' ? {
654
- ...sectionPlaneRef.current,
655
- min: sectionRangeRef.current?.min,
656
- max: sectionRangeRef.current?.max,
657
- } : undefined,
658
- });
621
+ renderCurrent();
659
622
  });
660
623
  resizeObserver.observe(canvas);
661
624
 
662
625
  // Initial render
663
- renderer.render({
664
- hiddenIds: hiddenEntitiesRef.current,
665
- isolatedIds: isolatedEntitiesRef.current,
666
- selectedId: selectedEntityIdRef.current,
667
- selectedModelIndex: selectedModelIndexRef.current,
668
- clearColor: clearColorRef.current,
669
- visualEnhancement: visualEnhancementRef.current,
670
- sectionPlane: activeToolRef.current === 'section' ? {
671
- ...sectionPlaneRef.current,
672
- min: sectionRangeRef.current?.min,
673
- max: sectionRangeRef.current?.max,
674
- } : undefined,
675
- });
626
+ renderCurrent();
676
627
  });
677
628
 
678
629
  return () => {
@@ -696,6 +647,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
696
647
  // ===== Drawing 2D state for render updates =====
697
648
  const drawing2D = useViewerStore((s) => s.drawing2D);
698
649
  const show3DOverlay = useViewerStore((s) => s.drawing2DDisplayOptions.show3DOverlay);
650
+ const showHiddenLines = useViewerStore((s) => s.drawing2DDisplayOptions.showHiddenLines);
699
651
 
700
652
  // ===== Streaming progress =====
701
653
  const progress = useViewerStore((state) => state.progress);
@@ -865,11 +817,13 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
865
817
  activeToolRef,
866
818
  drawing2D,
867
819
  show3DOverlay,
820
+ showHiddenLines,
868
821
  });
869
822
 
870
823
  return (
871
824
  <canvas
872
825
  ref={canvasRef}
826
+ data-viewport="main"
873
827
  className="w-full h-full block"
874
828
  />
875
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">
@@ -11,6 +11,7 @@ import { useEffect, type MutableRefObject } from 'react';
11
11
  import type { Renderer } from '@ifc-lite/renderer';
12
12
  import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
13
13
  import type { SectionPlane } from '@/store';
14
+ import { goHomeFromStore } from '@/store/homeView';
14
15
  import { getEntityBounds } from '../../utils/viewportUtils.js';
15
16
 
16
17
  export interface UseKeyboardControlsParams {
@@ -132,8 +133,7 @@ export function useKeyboardControls(params: UseKeyboardControlsParams): void {
132
133
 
133
134
  // Home view (H) - reset to isometric
134
135
  if (e.key === 'h' || e.key === 'H') {
135
- camera.zoomToFit(geometryBoundsRef.current.min, geometryBoundsRef.current.max, 500);
136
- calculateScale();
136
+ goHomeFromStore();
137
137
  }
138
138
 
139
139
  // Fit all / Zoom extents (Z)
@@ -47,6 +47,7 @@ export interface UseRenderUpdatesParams {
47
47
  // Drawing 2D
48
48
  drawing2D: Drawing2D | null;
49
49
  show3DOverlay: boolean;
50
+ showHiddenLines: boolean;
50
51
  }
51
52
 
52
53
  export function useRenderUpdates(params: UseRenderUpdatesParams): void {
@@ -75,6 +76,7 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
75
76
  activeToolRef,
76
77
  drawing2D,
77
78
  show3DOverlay,
79
+ showHiddenLines,
78
80
  } = params;
79
81
 
80
82
  // Theme-aware clear color update
@@ -109,8 +111,13 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
109
111
  expressId: cp.entityId, // DrawingPolygon uses entityId
110
112
  }));
111
113
 
112
- // No hatching lines for 3D overlay (too dense)
113
- const lines: DrawingLine2D[] = [];
114
+ // Include linework from the generated drawing on the section plane overlay.
115
+ const lines: DrawingLine2D[] = drawing2D.lines
116
+ .filter((line) => showHiddenLines || line.visibility !== 'hidden')
117
+ .map((line) => ({
118
+ line: line.line,
119
+ category: line.category,
120
+ }));
114
121
 
115
122
  // Upload to renderer - will be drawn on the section plane
116
123
  // Pass sectionRange to match exactly what render() uses for section plane position
@@ -142,7 +149,7 @@ export function useRenderUpdates(params: UseRenderUpdatesParams): void {
142
149
  max: sectionRangeRef.current?.max,
143
150
  } : undefined,
144
151
  });
145
- }, [drawing2D, activeTool, sectionPlane, isInitialized, coordinateInfo, show3DOverlay]);
152
+ }, [drawing2D, activeTool, sectionPlane, isInitialized, coordinateInfo, show3DOverlay, showHiddenLines]);
146
153
 
147
154
  // Re-render when visibility, selection, or section plane changes
148
155
  useEffect(() => {