@ifc-lite/viewer 1.10.0 → 1.11.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 (44) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/assets/{Arrow.dom-Bw5JMdDs.js → Arrow.dom-p9ppgFLr.js} +1 -1
  3. package/dist/assets/{browser-DdRf3aWl.js → browser-lKzgHsnJ.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-BoYyWYAu.css +1 -0
  6. package/dist/assets/{index-1ff6P0kc.js → index-CF854G-8.js} +42703 -41097
  7. package/dist/assets/{index-Bz7vHRxl.js → index-DQlpY6aJ.js} +4 -4
  8. package/dist/assets/{native-bridge-C5hD5vae.js → native-bridge-BgRWyawy.js} +1 -1
  9. package/dist/assets/{wasm-bridge-CaNKXFGM.js → wasm-bridge-BZxGtE7z.js} +1 -1
  10. package/dist/index.html +2 -2
  11. package/package.json +20 -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 +56 -113
  17. package/src/components/viewer/Section2DPanel.tsx +8 -1
  18. package/src/components/viewer/ThemeSwitch.tsx +55 -0
  19. package/src/components/viewer/Viewport.tsx +66 -105
  20. package/src/components/viewer/ViewportContainer.tsx +2 -0
  21. package/src/components/viewer/ViewportOverlays.tsx +9 -3
  22. package/src/components/viewer/useGeometryStreaming.ts +25 -0
  23. package/src/components/viewer/useKeyboardControls.ts +2 -2
  24. package/src/components/viewer/useRenderUpdates.ts +10 -3
  25. package/src/hooks/meshColorUpdates.test.ts +56 -0
  26. package/src/hooks/meshColorUpdates.ts +20 -0
  27. package/src/hooks/useIDS.ts +7 -8
  28. package/src/hooks/useIfcLoader.ts +25 -1
  29. package/src/hooks/useKeyboardShortcuts.ts +51 -84
  30. package/src/hooks/useViewerSelectors.ts +4 -0
  31. package/src/store/basket/basketCommands.ts +81 -0
  32. package/src/store/basket/basketViewActivator.ts +54 -0
  33. package/src/store/basketSave.ts +122 -0
  34. package/src/store/basketVisibleSet.test.ts +161 -0
  35. package/src/store/basketVisibleSet.ts +487 -0
  36. package/src/store/homeView.ts +21 -0
  37. package/src/store/index.ts +8 -0
  38. package/src/store/slices/dataSlice.test.ts +53 -4
  39. package/src/store/slices/dataSlice.ts +13 -5
  40. package/src/store/slices/drawing2DSlice.ts +5 -0
  41. package/src/store/slices/pinboardSlice.test.ts +160 -0
  42. package/src/store/slices/pinboardSlice.ts +248 -18
  43. package/src/store/types.ts +11 -0
  44. package/dist/assets/index-mvbV6NHd.css +0 -1
@@ -17,30 +17,34 @@ import {
17
17
  Copy,
18
18
  Maximize2,
19
19
  Building2,
20
+ Save,
20
21
  } from 'lucide-react';
21
22
  import { useViewerStore, resolveEntityRef } from '@/store';
23
+ import { resetVisibilityForHomeFromStore } from '@/store/homeView';
24
+ import {
25
+ executeBasketSet,
26
+ executeBasketAdd,
27
+ executeBasketRemove,
28
+ executeBasketSaveView,
29
+ } from '@/store/basket/basketCommands';
22
30
  import { useIfc } from '@/hooks/useIfc';
23
31
 
24
32
  export function EntityContextMenu() {
25
33
  const contextMenu = useViewerStore((s) => s.contextMenu);
26
34
  const closeContextMenu = useViewerStore((s) => s.closeContextMenu);
27
35
  const hideEntity = useViewerStore((s) => s.hideEntity);
28
- const showAll = useViewerStore((s) => s.showAll);
29
36
  const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
30
37
  const setSelectedEntityIds = useViewerStore((s) => s.setSelectedEntityIds);
31
38
  const cameraCallbacks = useViewerStore((s) => s.cameraCallbacks);
32
39
  // Basket actions
33
- const setBasket = useViewerStore((s) => s.setBasket);
34
- const addToBasket = useViewerStore((s) => s.addToBasket);
35
- const removeFromBasket = useViewerStore((s) => s.removeFromBasket);
36
40
  const menuRef = useRef<HTMLDivElement>(null);
37
41
  const { ifcDataStore, models } = useIfc();
38
42
 
39
43
  // Resolve contextMenu.entityId (globalId) to original expressId and model
40
44
  // This is needed because IfcDataStore uses original expressIds, not globalIds
41
- const { resolvedExpressId, resolvedModelId, activeDataStore } = useMemo(() => {
45
+ const { resolvedExpressId, activeDataStore, contextEntityRef } = useMemo(() => {
42
46
  if (!contextMenu.entityId) {
43
- return { resolvedExpressId: null, resolvedModelId: null, activeDataStore: ifcDataStore };
47
+ return { resolvedExpressId: null, activeDataStore: ifcDataStore, contextEntityRef: null };
44
48
  }
45
49
 
46
50
  // Single source of truth for globalId → EntityRef resolution
@@ -49,12 +53,16 @@ export function EntityContextMenu() {
49
53
  const model = models.get(ref.modelId);
50
54
  return {
51
55
  resolvedExpressId: ref.expressId,
52
- resolvedModelId: ref.modelId,
53
56
  activeDataStore: model?.ifcDataStore ?? ifcDataStore,
57
+ contextEntityRef: ref,
54
58
  };
55
59
  }
56
60
 
57
- return { resolvedExpressId: contextMenu.entityId, resolvedModelId: null, activeDataStore: ifcDataStore };
61
+ return {
62
+ resolvedExpressId: contextMenu.entityId,
63
+ activeDataStore: ifcDataStore,
64
+ contextEntityRef: null,
65
+ };
58
66
  }, [contextMenu.entityId, models, ifcDataStore]);
59
67
 
60
68
  // Close menu when clicking outside
@@ -95,27 +103,33 @@ export function EntityContextMenu() {
95
103
 
96
104
  // Basket: = Set basket to this entity
97
105
  const handleSetBasket = useCallback(() => {
98
- if (resolvedExpressId !== null && resolvedModelId !== null) {
99
- setBasket([{ modelId: resolvedModelId, expressId: resolvedExpressId }]);
100
- }
106
+ executeBasketSet(contextEntityRef);
101
107
  closeContextMenu();
102
- }, [resolvedExpressId, resolvedModelId, setBasket, closeContextMenu]);
108
+ }, [contextEntityRef, closeContextMenu]);
103
109
 
104
110
  // Basket: + Add to basket
105
111
  const handleAddToBasket = useCallback(() => {
106
- if (resolvedExpressId !== null && resolvedModelId !== null) {
107
- addToBasket([{ modelId: resolvedModelId, expressId: resolvedExpressId }]);
108
- }
112
+ executeBasketAdd(contextEntityRef);
109
113
  closeContextMenu();
110
- }, [resolvedExpressId, resolvedModelId, addToBasket, closeContextMenu]);
114
+ }, [contextEntityRef, closeContextMenu]);
111
115
 
112
116
  // Basket: − Remove from basket
113
117
  const handleRemoveFromBasket = useCallback(() => {
114
- if (resolvedExpressId !== null && resolvedModelId !== null) {
115
- removeFromBasket([{ modelId: resolvedModelId, expressId: resolvedExpressId }]);
118
+ executeBasketRemove(contextEntityRef);
119
+ closeContextMenu();
120
+ }, [contextEntityRef, closeContextMenu]);
121
+
122
+ const handleSaveBasketView = useCallback(() => {
123
+ const state = useViewerStore.getState();
124
+ if (state.pinboardEntities.size === 0) {
125
+ closeContextMenu();
126
+ return;
116
127
  }
128
+ executeBasketSaveView().catch((err) => {
129
+ console.error('[EntityContextMenu] Failed to save basket view:', err);
130
+ });
117
131
  closeContextMenu();
118
- }, [resolvedExpressId, resolvedModelId, removeFromBasket, closeContextMenu]);
132
+ }, [closeContextMenu]);
119
133
 
120
134
  const handleHide = useCallback(() => {
121
135
  if (contextMenu.entityId) {
@@ -125,9 +139,9 @@ export function EntityContextMenu() {
125
139
  }, [contextMenu.entityId, hideEntity, closeContextMenu]);
126
140
 
127
141
  const handleShowAll = useCallback(() => {
128
- showAll(); // Clear hidden + isolation (basket preserved)
142
+ resetVisibilityForHomeFromStore();
129
143
  closeContextMenu();
130
- }, [showAll, closeContextMenu]);
144
+ }, [closeContextMenu]);
131
145
 
132
146
  const handleSelectSimilar = useCallback(() => {
133
147
  // Use resolvedExpressId (original ID) for IfcDataStore lookups
@@ -230,9 +244,10 @@ export function EntityContextMenu() {
230
244
  <div className="h-px bg-border my-1" />
231
245
 
232
246
  {/* Basket operations */}
233
- <MenuItem icon={Equal} label="Set as Basket (=)" onClick={handleSetBasket} />
247
+ <MenuItem icon={Equal} label="Set Basket (=)" onClick={handleSetBasket} />
234
248
  <MenuItem icon={Plus} label="Add to Basket (+)" onClick={handleAddToBasket} />
235
249
  <MenuItem icon={Minus} label="Remove from Basket (−)" onClick={handleRemoveFromBasket} />
250
+ <MenuItem icon={Save} label="Save Basket View (B)" onClick={handleSaveBasketView} />
236
251
 
237
252
  <div className="h-px bg-border my-1" />
238
253
 
@@ -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 } => {
@@ -24,8 +24,6 @@ import {
24
24
  ArrowLeft,
25
25
  ArrowRight,
26
26
  Box,
27
- Sun,
28
- Moon,
29
27
  HelpCircle,
30
28
  Loader2,
31
29
  Camera,
@@ -34,11 +32,11 @@ import {
34
32
  SquareX,
35
33
  Building2,
36
34
  Plus,
37
- Minus,
38
35
  MessageSquare,
39
36
  ClipboardCheck,
40
37
  Palette,
41
38
  Orbit,
39
+ LayoutTemplate,
42
40
  } from 'lucide-react';
43
41
  import { Button } from '@/components/ui/button';
44
42
  import { Separator } from '@/components/ui/separator';
@@ -55,8 +53,9 @@ import {
55
53
  DropdownMenuSubContent,
56
54
  } from '@/components/ui/dropdown-menu';
57
55
  import { Progress } from '@/components/ui/progress';
58
- import { useViewerStore, isIfcxDataStore, stringToEntityRef } from '@/store';
59
- import type { EntityRef } from '@/store';
56
+ import { useViewerStore, isIfcxDataStore } from '@/store';
57
+ import { goHomeFromStore, resetVisibilityForHomeFromStore } from '@/store/homeView';
58
+ import { executeBasketIsolate } from '@/store/basket/basketCommands';
60
59
  import { useIfc } from '@/hooks/useIfc';
61
60
  import { cn } from '@/lib/utils';
62
61
  import { GLTFExporter, CSVExporter } from '@ifc-lite/export';
@@ -67,6 +66,7 @@ import { DataConnector } from './DataConnector';
67
66
  import { ExportChangesButton } from './ExportChangesButton';
68
67
  import { useFloorplanView } from '@/hooks/useFloorplanView';
69
68
  import { recordRecentFiles, cacheFileBlobs } from '@/lib/recent-files';
69
+ import { ThemeSwitch } from './ThemeSwitch';
70
70
 
71
71
  type Tool = 'select' | 'pan' | 'orbit' | 'walk' | 'measure' | 'section';
72
72
 
@@ -165,12 +165,8 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
165
165
  const hasModelsLoaded = models.size > 0 || (geometryResult?.meshes && geometryResult.meshes.length > 0);
166
166
  const activeTool = useViewerStore((state) => state.activeTool);
167
167
  const setActiveTool = useViewerStore((state) => state.setActiveTool);
168
- const theme = useViewerStore((state) => state.theme);
169
- const toggleTheme = useViewerStore((state) => state.toggleTheme);
170
168
  const selectedEntityId = useViewerStore((state) => state.selectedEntityId);
171
169
  const hideEntities = useViewerStore((state) => state.hideEntities);
172
- const showAll = useViewerStore((state) => state.showAll);
173
- const clearStoreySelection = useViewerStore((state) => state.clearStoreySelection);
174
170
  const error = useViewerStore((state) => state.error);
175
171
  const cameraCallbacks = useViewerStore((state) => state.cameraCallbacks);
176
172
  const hoverTooltipsEnabled = useViewerStore((state) => state.hoverTooltipsEnabled);
@@ -189,14 +185,11 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
189
185
  const setRightPanelCollapsed = useViewerStore((state) => state.setRightPanelCollapsed);
190
186
  const projectionMode = useViewerStore((state) => state.projectionMode);
191
187
  const toggleProjectionMode = useViewerStore((state) => state.toggleProjectionMode);
192
- // Basket state (= + − isolation basket)
188
+ // Basket presentation state
193
189
  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.
190
+ const basketViewCount = useViewerStore((state) => state.basketViews.length);
191
+ const basketPresentationVisible = useViewerStore((state) => state.basketPresentationVisible);
192
+ const toggleBasketPresentationVisible = useViewerStore((state) => state.toggleBasketPresentationVisible);
200
193
  // Lens state
201
194
  const lensPanelVisible = useViewerStore((state) => state.lensPanelVisible);
202
195
  const toggleLensPanel = useViewerStore((state) => state.toggleLensPanel);
@@ -306,68 +299,8 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
306
299
  e.target.value = '';
307
300
  }, [loadFilesSequentially, addIfcxOverlays, ifcDataStore]);
308
301
 
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
302
  const hasSelection = selectedEntityId !== null;
326
303
 
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
304
  const clearSelection = useViewerStore((state) => state.clearSelection);
372
305
 
373
306
  const handleHide = useCallback(() => {
@@ -383,9 +316,16 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
383
316
  }, [selectedEntityId, hideEntities, clearSelection]);
384
317
 
385
318
  const handleShowAll = useCallback(() => {
386
- showAll(); // Clear hiddenEntities + isolatedEntities (basket contents preserved)
387
- clearStoreySelection(); // Also clear storey filtering
388
- }, [showAll, clearStoreySelection]);
319
+ resetVisibilityForHomeFromStore();
320
+ }, []);
321
+
322
+ const handleIsolate = useCallback(() => {
323
+ executeBasketIsolate();
324
+ }, []);
325
+
326
+ const handleHome = useCallback(() => {
327
+ goHomeFromStore();
328
+ }, []);
389
329
 
390
330
  const handleExportGLB = useCallback(() => {
391
331
  if (!geometryResult) return;
@@ -759,36 +699,35 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
759
699
 
760
700
  <Separator orientation="vertical" className="h-6 mx-1" />
761
701
 
762
- {/* ── Basket Isolation (= + −) ── */}
702
+ {/* ── Basket Presentation ── */}
763
703
  <Tooltip>
764
704
  <TooltipTrigger asChild>
765
705
  <Button
766
- variant={pinboardEntities.size > 0 ? 'default' : 'ghost'}
706
+ variant={basketPresentationVisible ? 'default' : 'ghost'}
767
707
  size="icon-sm"
768
708
  onClick={(e) => {
769
709
  (e.currentTarget as HTMLButtonElement).blur();
770
- handleSetBasket();
710
+ toggleBasketPresentationVisible();
771
711
  }}
772
- disabled={!hasSelection && pinboardEntities.size === 0}
712
+ disabled={models.size === 0 && !geometryResult}
773
713
  className={cn(
774
- pinboardEntities.size > 0 && 'bg-primary text-primary-foreground relative',
714
+ (basketPresentationVisible || pinboardEntities.size > 0) && 'relative',
775
715
  )}
776
716
  >
777
- <Equal className="h-4 w-4" />
778
- {pinboardEntities.size > 0 && (
717
+ <LayoutTemplate className="h-4 w-4" />
718
+ {(basketViewCount > 0 || pinboardEntities.size > 0) && (
779
719
  <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}
720
+ {basketViewCount > 0 ? `${basketViewCount}/${pinboardEntities.size}` : pinboardEntities.size}
781
721
  </span>
782
722
  )}
783
723
  </Button>
784
724
  </TooltipTrigger>
785
725
  <TooltipContent>
786
- Set Basket isolate selection <span className="ml-2 text-xs opacity-60">(I)</span>
726
+ Basket Presentation Dock (Views: {basketViewCount}, Entities: {pinboardEntities.size})
787
727
  </TooltipContent>
788
728
  </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
729
 
730
+ <ActionButton icon={Equal} label="Isolate (Set Basket)" onClick={handleIsolate} shortcut="I / =" />
792
731
  <ActionButton icon={EyeOff} label="Hide Selection" onClick={handleHide} shortcut="Del / Space" disabled={!hasSelection} />
793
732
  <ActionButton icon={Eye} label="Show All (Reset Filters)" onClick={handleShowAll} shortcut="A" />
794
733
  <ActionButton icon={Maximize2} label="Fit All" onClick={() => cameraCallbacks.fitAll?.()} shortcut="Z" />
@@ -869,7 +808,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
869
808
  <Separator orientation="vertical" className="h-6 mx-1" />
870
809
 
871
810
  {/* ── Camera & View ── */}
872
- <ActionButton icon={Home} label="Home (Isometric)" onClick={() => cameraCallbacks.home?.()} shortcut="H" />
811
+ <ActionButton icon={Home} label="Home (Isometric + Reset Visibility)" onClick={handleHome} shortcut="H" />
873
812
 
874
813
  {/* Orthographic / Perspective toggle */}
875
814
  <Tooltip>
@@ -924,8 +863,8 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
924
863
  <TooltipContent>Preset Views</TooltipContent>
925
864
  </Tooltip>
926
865
  <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>
866
+ <DropdownMenuItem onClick={handleHome}>
867
+ <Box className="h-4 w-4 mr-2" /> Isometric <span className="ml-auto text-xs opacity-60">H</span>
929
868
  </DropdownMenuItem>
930
869
  <DropdownMenuSeparator />
931
870
  <DropdownMenuItem onClick={() => cameraCallbacks.setPresetView?.('top')}>
@@ -967,27 +906,31 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
967
906
  )}
968
907
 
969
908
  {/* Right Side Actions */}
970
- <Tooltip>
971
- <TooltipTrigger asChild>
972
- <Button variant="ghost" size="icon-sm" onClick={toggleTheme}>
973
- {theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
974
- </Button>
975
- </TooltipTrigger>
976
- <TooltipContent>Toggle Theme</TooltipContent>
977
- </Tooltip>
909
+ <div className="flex items-center gap-2 ml-2 pl-2 border-l border-zinc-200 dark:border-zinc-700/60">
910
+ <Tooltip>
911
+ <TooltipTrigger asChild>
912
+ <div>
913
+ <ThemeSwitch />
914
+ </div>
915
+ </TooltipTrigger>
916
+ <TooltipContent>Toggle theme</TooltipContent>
917
+ </Tooltip>
918
+
919
+ <Tooltip>
920
+ <TooltipTrigger asChild>
921
+ <Button
922
+ variant="ghost"
923
+ size="icon"
924
+ className="rounded-full"
925
+ onClick={() => onShowShortcuts?.()}
926
+ >
927
+ <HelpCircle className="!h-[22px] !w-[22px]" />
928
+ </Button>
929
+ </TooltipTrigger>
930
+ <TooltipContent>Info (?)</TooltipContent>
931
+ </Tooltip>
932
+ </div>
978
933
 
979
- <Tooltip>
980
- <TooltipTrigger asChild>
981
- <Button
982
- variant="ghost"
983
- size="icon-sm"
984
- onClick={() => onShowShortcuts?.()}
985
- >
986
- <HelpCircle className="h-4 w-4" />
987
- </Button>
988
- </TooltipTrigger>
989
- <TooltipContent>Info (?)</TooltipContent>
990
- </Tooltip>
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
@@ -0,0 +1,55 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ import { useEffect, useRef } from 'react';
6
+ import { ThemeToggle } from 'beautiful-theme-toggle';
7
+ import { useViewerStore } from '@/store';
8
+
9
+ /**
10
+ * Animated SVG theme toggle (sun/moon) powered by beautiful-theme-toggle.
11
+ *
12
+ * Bidirectional sync:
13
+ * - User clicks the widget → onChange → store.setTheme
14
+ * - External change (keyboard shortcut / command palette) → store updates → widget.setTheme
15
+ */
16
+ export function ThemeSwitch() {
17
+ const containerRef = useRef<HTMLDivElement>(null);
18
+ const toggleRef = useRef<ThemeToggle | null>(null);
19
+
20
+ useEffect(() => {
21
+ if (!containerRef.current) return;
22
+
23
+ const currentTheme = useViewerStore.getState().theme;
24
+
25
+ const toggle = new ThemeToggle({
26
+ element: containerRef.current,
27
+ size: 80,
28
+ initialState: currentTheme,
29
+ onChange: (state) => {
30
+ useViewerStore.getState().setTheme(state);
31
+ },
32
+ });
33
+
34
+ toggleRef.current = toggle;
35
+
36
+ // Subscribe to external theme changes so the widget stays in sync
37
+ let prevTheme = currentTheme;
38
+ const unsub = useViewerStore.subscribe((state) => {
39
+ if (state.theme !== prevTheme) {
40
+ prevTheme = state.theme;
41
+ if (toggleRef.current && toggleRef.current.getTheme() !== state.theme) {
42
+ toggleRef.current.setTheme(state.theme, false);
43
+ }
44
+ }
45
+ });
46
+
47
+ return () => {
48
+ unsub();
49
+ toggle.destroy();
50
+ toggleRef.current = null;
51
+ };
52
+ }, []);
53
+
54
+ return <div ref={containerRef} className="flex items-center cursor-pointer opacity-80 hover:opacity-100 transition-opacity" />;
55
+ }