@ifc-lite/viewer 1.23.0 → 1.25.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 (109) hide show
  1. package/.turbo/turbo-build.log +34 -31
  2. package/CHANGELOG.md +96 -0
  3. package/dist/assets/{basketViewActivator-Dn_bHUl2.js → basketViewActivator-CU8_toGq.js} +7 -7
  4. package/dist/assets/{bcf-B9SFl84i.js → bcf-DXGDhw56.js} +23 -23
  5. package/dist/assets/{deflate-yMpdCIqk.js → deflate-Bb1_H2Yf.js} +1 -1
  6. package/dist/assets/{exporters-D-BvrNIg.js → exporters-DZhLN0ux.js} +1861 -1658
  7. package/dist/assets/geometry-controller.worker-DQOSYqtw.js +7 -0
  8. package/dist/assets/geometry.worker-B62e03Ao.js +1 -0
  9. package/dist/assets/{geotiff-D1tvcDCb.js → geotiff-y0ZxbRJd.js} +10 -10
  10. package/dist/assets/{ids-DZLs0snJ.js → ids-DruUNtfD.js} +4 -4
  11. package/dist/assets/ifc-lite-Ch2T9pP9.js +7 -0
  12. package/dist/assets/{ifc-lite_bg-DyHX37GQ.wasm → ifc-lite_bg-D7O1WHgP.wasm} +0 -0
  13. package/dist/assets/{ifc-lite_bg-BIryVCXQ.wasm → ifc-lite_bg-iH_07wf8.wasm} +0 -0
  14. package/dist/assets/index-Bws3UAkj.css +1 -0
  15. package/dist/assets/{index-CXSBhkcJ.js → index-Dr88ZlSY.js} +64100 -47030
  16. package/dist/assets/{jpeg-DUMcZp24.js → jpeg-B3_loqFe.js} +1 -1
  17. package/dist/assets/lens-PYsLu_MA.js +1 -0
  18. package/dist/assets/{lerc-IN4uWojP.js → lerc-nkwS8ZUe.js} +1 -1
  19. package/dist/assets/{lzw-Cnw0hH-m.js → lzw-D3cW5Wpg.js} +1 -1
  20. package/dist/assets/{native-bridge-BVf2uzoH.js → native-bridge-BcYJooq8.js} +2 -2
  21. package/dist/assets/{packbits-BskJCwk0.js → packbits-DDN4xzB5.js} +1 -1
  22. package/dist/assets/{parser.worker-BdtkkaGf.js → parser.worker-BW1IMUed.js} +3 -3
  23. package/dist/assets/raw-CoIXstQ-.js +1 -0
  24. package/dist/assets/{sandbox-VLI_y7cl.js → sandbox-DETNEyQb.js} +498 -470
  25. package/dist/assets/{server-client-BLcKaWQB.js → server-client-CmzJOeS7.js} +1 -1
  26. package/dist/assets/{wasm-bridge-BAfZh7YT.js → wasm-bridge-CT7mK9W0.js} +1 -1
  27. package/dist/assets/{webimage-Db2xzze3.js → webimage-CBjgg4up.js} +1 -1
  28. package/dist/assets/{workerHelpers--sAYm9yN.js → workerHelpers-IEQDo8r3.js} +1 -1
  29. package/dist/assets/{zstd-BDToOQyD.js → zstd-C8oQ6qdS.js} +1 -1
  30. package/dist/index.html +8 -8
  31. package/package.json +11 -9
  32. package/src/App.tsx +5 -2
  33. package/src/components/extensions/AuditLogPanel.tsx +259 -0
  34. package/src/components/extensions/BundlePreview.tsx +102 -0
  35. package/src/components/extensions/CapabilityReview.tsx +333 -0
  36. package/src/components/extensions/ExtensionDockHost.tsx +192 -0
  37. package/src/components/extensions/ExtensionToolbarSlot.tsx +106 -0
  38. package/src/components/extensions/ExtensionsPanel.tsx +481 -0
  39. package/src/components/extensions/FlavorDialog.tsx +398 -0
  40. package/src/components/extensions/FlavorImportPreview.tsx +79 -0
  41. package/src/components/extensions/FlavorIndicator.tsx +81 -0
  42. package/src/components/extensions/FlavorListView.tsx +318 -0
  43. package/src/components/extensions/FlavorMergeDialog.tsx +326 -0
  44. package/src/components/extensions/HelpHint.tsx +182 -0
  45. package/src/components/extensions/IdeasPanel.tsx +344 -0
  46. package/src/components/extensions/PlanCard.tsx +227 -0
  47. package/src/components/extensions/PrivacyPanel.tsx +312 -0
  48. package/src/components/extensions/PromoteToolDialog.tsx +313 -0
  49. package/src/components/extensions/RepairQueuePanel.tsx +222 -0
  50. package/src/components/extensions/icon-registry.ts +92 -0
  51. package/src/components/extensions/toast-helpers.ts +49 -0
  52. package/src/components/extensions/widget/WidgetErrorBoundary.tsx +62 -0
  53. package/src/components/extensions/widget/WidgetRenderer.tsx +428 -0
  54. package/src/components/viewer/ChatPanel.tsx +251 -3
  55. package/src/components/viewer/CommandPalette.tsx +74 -4
  56. package/src/components/viewer/Drawing2DCanvas.tsx +178 -1
  57. package/src/components/viewer/EntityContextMenu.tsx +70 -0
  58. package/src/components/viewer/ExportDialog.tsx +9 -1
  59. package/src/components/viewer/KeyboardShortcutsDialog.tsx +21 -6
  60. package/src/components/viewer/LensPanel.tsx +50 -0
  61. package/src/components/viewer/MainToolbar.tsx +170 -87
  62. package/src/components/viewer/ScriptPanel.tsx +105 -1
  63. package/src/components/viewer/Section2DPanel.tsx +58 -2
  64. package/src/components/viewer/StatusBar.tsx +18 -0
  65. package/src/components/viewer/ViewerLayout.tsx +53 -4
  66. package/src/components/viewer/Viewport.tsx +72 -0
  67. package/src/hooks/useActionLogger.test.ts +161 -0
  68. package/src/hooks/useActionLogger.ts +141 -0
  69. package/src/hooks/useForkExtension.ts +51 -0
  70. package/src/hooks/useIfcFederation.ts +7 -1
  71. package/src/hooks/useInstalledExtensions.ts +43 -0
  72. package/src/hooks/usePrivacyDisclosure.ts +48 -0
  73. package/src/hooks/useRunExtensionTests.ts +67 -0
  74. package/src/hooks/useSlotContributions.ts +38 -0
  75. package/src/hooks/useSymbolicAnnotations.test.ts +124 -0
  76. package/src/hooks/useSymbolicAnnotations.ts +776 -0
  77. package/src/lib/desktop-product.ts +7 -1
  78. package/src/lib/lens/adapter.ts +14 -0
  79. package/src/lib/llm/prompt-cache.ts +77 -0
  80. package/src/lib/llm/stream-client.ts +20 -2
  81. package/src/lib/llm/stream-direct.ts +11 -1
  82. package/src/lib/llm/system-prompt.ts +42 -0
  83. package/src/lib/safe-mode.ts +30 -0
  84. package/src/sdk/ExtensionHostProvider.tsx +103 -0
  85. package/src/services/extensions/flavor-service.ts +183 -0
  86. package/src/services/extensions/host-commands.ts +112 -0
  87. package/src/services/extensions/host-installer.ts +289 -0
  88. package/src/services/extensions/host.ts +514 -0
  89. package/src/services/extensions/idb-flavor-storage.test.ts +140 -0
  90. package/src/services/extensions/idb-flavor-storage.ts +241 -0
  91. package/src/services/extensions/idb-log-storage.test.ts +110 -0
  92. package/src/services/extensions/idb-log-storage.ts +171 -0
  93. package/src/services/extensions/idb-storage.ts +228 -0
  94. package/src/services/extensions/runtime-errors.ts +26 -0
  95. package/src/services/extensions/sandbox-factory.ts +217 -0
  96. package/src/store/constants.ts +48 -6
  97. package/src/store/index.ts +6 -1
  98. package/src/store/slices/drawing2DSlice.ts +8 -0
  99. package/src/store/slices/extensionsSlice.ts +90 -0
  100. package/src/store/slices/lensSlice.ts +28 -0
  101. package/src/store/slices/visibilitySlice.test.ts +6 -0
  102. package/src/store/slices/visibilitySlice.ts +17 -8
  103. package/src/store/types.ts +2 -0
  104. package/dist/assets/geometry-controller.worker-Cm5pvyR6.js +0 -7
  105. package/dist/assets/geometry.worker-ClNvXIrj.js +0 -1
  106. package/dist/assets/ifc-lite-BDg0iIbj.js +0 -7
  107. package/dist/assets/index-DS_xJQfP.css +0 -1
  108. package/dist/assets/lens-CpjUdqpw.js +0 -1
  109. package/dist/assets/raw-DzTtEZIY.js +0 -1
@@ -24,11 +24,9 @@ import {
24
24
  ArrowRight,
25
25
  Box,
26
26
  HelpCircle,
27
- Sparkles,
28
27
  Loader2,
29
28
  Camera,
30
29
  Info,
31
- Layers,
32
30
  Layers2,
33
31
  SquareX,
34
32
  Building2,
@@ -36,6 +34,7 @@ import {
36
34
  PackagePlus,
37
35
  MessageSquare,
38
36
  ClipboardCheck,
37
+ Puzzle,
39
38
  Palette,
40
39
  Orbit,
41
40
  Layout,
@@ -85,6 +84,7 @@ import { useFloorplanView } from '@/hooks/useFloorplanView';
85
84
  import { buildDesktopUpgradeUrl, hasDesktopFeatureAccess, type DesktopFeature } from '@/lib/desktop-product';
86
85
  import { recordRecentFiles, cacheFileBlobs } from '@/lib/recent-files';
87
86
  import { ThemeSwitch } from './ThemeSwitch';
87
+ import { ExtensionToolbarSlot } from '@/components/extensions/ExtensionToolbarSlot';
88
88
  import { toast } from '@/components/ui/toast';
89
89
  import { navigateToPath } from '@/services/app-navigation';
90
90
  import { getStartupHarnessRequest, setActiveHarnessRequest, tryClaimStartupHarnessRequest } from '@/services/desktop-harness';
@@ -497,6 +497,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
497
497
  const editEnabled = useViewerStore((state) => state.editEnabled);
498
498
  const toggleEditEnabled = useViewerStore((state) => state.toggleEditEnabled);
499
499
  const selectedEntityId = useViewerStore((state) => state.selectedEntityId);
500
+ const selectedEntityIds = useViewerStore((state) => state.selectedEntityIds);
500
501
  const hideEntities = useViewerStore((state) => state.hideEntities);
501
502
  const error = useViewerStore((state) => state.error);
502
503
  const cameraCallbacks = useViewerStore((state) => state.cameraCallbacks);
@@ -528,6 +529,8 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
528
529
  // Lens state
529
530
  const lensPanelVisible = useViewerStore((state) => state.lensPanelVisible);
530
531
  const setLensPanelVisible = useViewerStore((state) => state.setLensPanelVisible);
532
+ const extensionsPanelVisible = useViewerStore((state) => state.extensionsPanelVisible);
533
+ const setExtensionsPanelVisible = useViewerStore((state) => state.setExtensionsPanelVisible);
531
534
  const scriptPanelVisible = useViewerStore((state) => state.scriptPanelVisible);
532
535
  const setScriptPanelVisible = useViewerStore((state) => state.setScriptPanelVisible);
533
536
  const ganttPanelVisible = useViewerStore((state) => state.ganttPanelVisible);
@@ -624,6 +627,30 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
624
627
  // eslint-disable-next-line react-hooks/exhaustive-deps -- meshLen is a stable proxy for geometryResult
625
628
  }, [models, meshLen]);
626
629
 
630
+ // IfcAnnotation has no body mesh, so it can't be detected via the mesh scan.
631
+ // Look up the entity table directly. byType keys are uppercase STEP names
632
+ // ('IFCANNOTATION') but cache loads sometimes preserve PascalCase too.
633
+ // Symbolic 2D overlays cover BOTH IfcAnnotation (text, dimensions, leader
634
+ // lines, filled regions) AND IfcGrid (axis lines + synthesized bubble +
635
+ // tag). Some files ship only grids (Snowdon Towers Structural is the
636
+ // canonical example — no IfcAnnotation at all), so the toggle must
637
+ // surface for either entity type or grid-only models get no way to hide
638
+ // the overlay.
639
+ const hasIfcAnnotations = useMemo(() => {
640
+ const has = (store: typeof ifcDataStore | undefined) => {
641
+ const byType = store?.entityIndex?.byType;
642
+ if (!byType) return false;
643
+ return (byType.get('IFCANNOTATION')?.length ?? 0) > 0
644
+ || (byType.get('IfcAnnotation')?.length ?? 0) > 0
645
+ || (byType.get('IFCGRID')?.length ?? 0) > 0
646
+ || (byType.get('IfcGrid')?.length ?? 0) > 0;
647
+ };
648
+ if (models.size > 0) {
649
+ for (const [, m] of models) if (has(m.ifcDataStore)) return true;
650
+ }
651
+ return has(ifcDataStore);
652
+ }, [models, ifcDataStore]);
653
+
627
654
  const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
628
655
  const files = e.target.files;
629
656
  if (!files || files.length === 0) return;
@@ -699,6 +726,12 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
699
726
  }, [loadFilesSequentially, addIfcxOverlays, ifcDataStore]);
700
727
 
701
728
  const hasSelection = selectedEntityId !== null;
729
+ // Selection chip uses the multi-select size when present; falls back
730
+ // to the single legacy `selectedEntityId` so the chip still says
731
+ // "1 selected" for the click-to-pick flow that hasn't migrated.
732
+ const selectionCount = selectedEntityIds.size > 0
733
+ ? selectedEntityIds.size
734
+ : (selectedEntityId !== null ? 1 : 0);
702
735
 
703
736
  const clearSelection = useViewerStore((state) => state.clearSelection);
704
737
 
@@ -765,7 +798,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
765
798
  setScriptPanelVisible,
766
799
  ]);
767
800
 
768
- const handleToggleRightPanel = useCallback((panel: 'bcf' | 'ids' | 'lens' | 'addElement') => {
801
+ const handleToggleRightPanel = useCallback((panel: 'bcf' | 'ids' | 'lens' | 'addElement' | 'extensions') => {
769
802
  if (activeAnalysisExtension?.placement !== 'bottom') {
770
803
  closeActiveAnalysisExtension();
771
804
  }
@@ -775,16 +808,21 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
775
808
  if (panel === 'ids' && !requireDesktopFeature('ids_validation', 'IDS validation')) {
776
809
  return;
777
810
  }
811
+ if (panel === 'extensions' && !requireDesktopFeature('extensions', 'Extensions')) {
812
+ return;
813
+ }
778
814
 
779
815
  const nextBcfVisible = panel === 'bcf' ? !bcfPanelVisible : false;
780
816
  const nextIdsVisible = panel === 'ids' ? !idsPanelVisible : false;
781
817
  const nextLensVisible = panel === 'lens' ? !lensPanelVisible : false;
818
+ const nextExtensionsVisible = panel === 'extensions' ? !extensionsPanelVisible : false;
782
819
  const isAddElementActive = activeTool === 'addElement';
783
820
  const nextAddElementActive = panel === 'addElement' ? !isAddElementActive : false;
784
821
 
785
822
  setBcfPanelVisible(nextBcfVisible);
786
823
  setIdsPanelVisible(nextIdsVisible);
787
824
  setLensPanelVisible(nextLensVisible);
825
+ setExtensionsPanelVisible(nextExtensionsVisible);
788
826
 
789
827
  if (panel === 'addElement') {
790
828
  setActiveTool(nextAddElementActive ? 'addElement' : 'select');
@@ -792,18 +830,20 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
792
830
  setActiveTool('select');
793
831
  }
794
832
 
795
- if (nextBcfVisible || nextIdsVisible || nextLensVisible || nextAddElementActive) {
833
+ if (nextBcfVisible || nextIdsVisible || nextLensVisible || nextExtensionsVisible || nextAddElementActive) {
796
834
  setRightPanelCollapsed(false);
797
835
  }
798
836
  }, [
799
837
  activeAnalysisExtension?.placement,
800
838
  activeTool,
801
839
  bcfPanelVisible,
840
+ extensionsPanelVisible,
802
841
  idsPanelVisible,
803
842
  lensPanelVisible,
804
843
  requireDesktopFeature,
805
844
  setActiveTool,
806
845
  setBcfPanelVisible,
846
+ setExtensionsPanelVisible,
807
847
  setIdsPanelVisible,
808
848
  setLensPanelVisible,
809
849
  setRightPanelCollapsed,
@@ -836,6 +876,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
836
876
  setBcfPanelVisible(false);
837
877
  setIdsPanelVisible(false);
838
878
  setLensPanelVisible(false);
879
+ setExtensionsPanelVisible(false);
839
880
  // The right slot is single-tenant: when an analysis extension takes
840
881
  // it over, the AddElement tool must release it too, otherwise its 3D
841
882
  // click handler keeps placing elements behind the extension panel.
@@ -849,6 +890,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
849
890
  analysisExtensionState.extensions,
850
891
  setActiveTool,
851
892
  setBcfPanelVisible,
893
+ setExtensionsPanelVisible,
852
894
  setGanttPanelVisible,
853
895
  setIdsPanelVisible,
854
896
  setLensPanelVisible,
@@ -865,6 +907,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
865
907
  if (bcfPanelVisible) panels.add('bcf');
866
908
  if (idsPanelVisible) panels.add('ids');
867
909
  if (lensPanelVisible) panels.add('lens');
910
+ if (extensionsPanelVisible) panels.add('extensions');
868
911
  if (activeTool === 'addElement') panels.add('addElement');
869
912
  if (analysisExtensionState.activeId) panels.add(analysisExtensionState.activeId);
870
913
  return panels;
@@ -872,6 +915,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
872
915
  activeTool,
873
916
  analysisExtensionState.activeId,
874
917
  bcfPanelVisible,
918
+ extensionsPanelVisible,
875
919
  ganttPanelVisible,
876
920
  idsPanelVisible,
877
921
  lensPanelVisible,
@@ -888,6 +932,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
888
932
  if (activeWorkspacePanels.has('bcf')) return 'BCF Issues';
889
933
  if (activeWorkspacePanels.has('ids')) return 'IDS Validation';
890
934
  if (activeWorkspacePanels.has('lens')) return 'Lens Rules';
935
+ if (activeWorkspacePanels.has('extensions')) return 'Extensions';
891
936
  if (activeWorkspacePanels.has('addElement')) return 'Add Element';
892
937
  return activeAnalysisExtension?.label ?? 'Analysis';
893
938
  }, [activeAnalysisExtension?.label, activeWorkspacePanels]);
@@ -1192,6 +1237,9 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1192
1237
  <TooltipContent>{workspacePanelLabel ? `Panels: ${workspacePanelLabel}` : 'Panels'}</TooltipContent>
1193
1238
  </Tooltip>
1194
1239
  <DropdownMenuContent align="start" className="w-56">
1240
+ <DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
1241
+ Workspace
1242
+ </DropdownMenuLabel>
1195
1243
  <DropdownMenuCheckboxItem
1196
1244
  checked={activeWorkspacePanels.has('script')}
1197
1245
  onCheckedChange={() => handleToggleBottomPanel('script')}
@@ -1214,6 +1262,9 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1214
1262
  Schedule (Gantt)
1215
1263
  </DropdownMenuCheckboxItem>
1216
1264
  <DropdownMenuSeparator />
1265
+ <DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
1266
+ Inspect & validate
1267
+ </DropdownMenuLabel>
1217
1268
  <DropdownMenuCheckboxItem
1218
1269
  checked={activeWorkspacePanels.has('bcf')}
1219
1270
  onCheckedChange={() => handleToggleRightPanel('bcf')}
@@ -1235,6 +1286,10 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1235
1286
  <Palette className="h-4 w-4 mr-2" />
1236
1287
  Lens Rules
1237
1288
  </DropdownMenuCheckboxItem>
1289
+ <DropdownMenuSeparator />
1290
+ <DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
1291
+ Author
1292
+ </DropdownMenuLabel>
1238
1293
  <DropdownMenuCheckboxItem
1239
1294
  checked={activeWorkspacePanels.has('addElement')}
1240
1295
  onCheckedChange={() => handleToggleRightPanel('addElement')}
@@ -1242,9 +1297,19 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1242
1297
  <PackagePlus className="h-4 w-4 mr-2" />
1243
1298
  Add Element
1244
1299
  </DropdownMenuCheckboxItem>
1245
- {rightAnalysisExtensions.length > 0 && (
1300
+ <DropdownMenuCheckboxItem
1301
+ checked={activeWorkspacePanels.has('extensions')}
1302
+ onCheckedChange={() => handleToggleRightPanel('extensions')}
1303
+ >
1304
+ <Puzzle className="h-4 w-4 mr-2" />
1305
+ Extensions
1306
+ </DropdownMenuCheckboxItem>
1307
+ {(rightAnalysisExtensions.length > 0 || bottomAnalysisExtensions.length > 0) && (
1246
1308
  <>
1247
1309
  <DropdownMenuSeparator />
1310
+ <DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
1311
+ Analysis extensions
1312
+ </DropdownMenuLabel>
1248
1313
  {rightAnalysisExtensions.map((extension) => {
1249
1314
  const Icon = extension.icon;
1250
1315
  return (
@@ -1258,11 +1323,6 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1258
1323
  </DropdownMenuCheckboxItem>
1259
1324
  );
1260
1325
  })}
1261
- </>
1262
- )}
1263
- {bottomAnalysisExtensions.length > 0 && (
1264
- <>
1265
- <DropdownMenuSeparator />
1266
1326
  {bottomAnalysisExtensions.map((extension) => {
1267
1327
  const Icon = extension.icon;
1268
1328
  return (
@@ -1414,17 +1474,42 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1414
1474
  </TooltipContent>
1415
1475
  </Tooltip>
1416
1476
 
1417
- <ActionButton icon={Equal} label="Isolate (Set Basket)" onClick={handleIsolate} shortcut="I / =" />
1418
- <ActionButton icon={EyeOff} label="Hide Selection" onClick={handleHide} shortcut="Del / Space" disabled={!hasSelection} />
1477
+ {/*
1478
+ Selection action cluster — Hide / Frame / Isolate only make
1479
+ sense with a selection, so they don't get to live in the
1480
+ toolbar chrome at rest. When a user selects anything, the
1481
+ slot opens with a "N selected" pill + the three actions next
1482
+ to it. Hotkeys (Del / F / I / =) keep working regardless of
1483
+ whether the chip is rendered, so power users feel no change.
1484
+
1485
+ The chip lives in the same separator zone the buttons used to
1486
+ occupy so the spatial location is familiar to muscle memory.
1487
+ */}
1488
+ {selectionCount > 0 && (
1489
+ <div
1490
+ className="flex items-center gap-0.5 pl-1.5 pr-0.5 rounded-md border border-primary/30 bg-primary/5 transition-opacity duration-150"
1491
+ role="group"
1492
+ aria-label={`Selection actions — ${selectionCount} selected`}
1493
+ >
1494
+ <span
1495
+ className="text-[10px] font-semibold tabular-nums text-primary uppercase tracking-wide whitespace-nowrap pr-1.5"
1496
+ aria-hidden="true"
1497
+ >
1498
+ {selectionCount} sel
1499
+ </span>
1500
+ <ActionButton icon={Equal} label="Isolate Selection (Set Basket)" onClick={handleIsolate} shortcut="I / =" />
1501
+ <ActionButton icon={EyeOff} label="Hide Selection" onClick={handleHide} shortcut="Del / Space" />
1502
+ <ActionButton
1503
+ icon={Crosshair}
1504
+ label="Frame Selection"
1505
+ onClick={() => cameraCallbacks.frameSelection?.()}
1506
+ shortcut="F"
1507
+ />
1508
+ </div>
1509
+ )}
1510
+
1419
1511
  <ActionButton icon={Eye} label="Show All (Reset Filters)" onClick={handleShowAll} shortcut="A" />
1420
1512
  <ActionButton icon={Maximize2} label="Fit All" onClick={() => cameraCallbacks.fitAll?.()} shortcut="Z" />
1421
- <ActionButton
1422
- icon={Crosshair}
1423
- label="Frame Selection"
1424
- onClick={() => cameraCallbacks.frameSelection?.()}
1425
- shortcut="F"
1426
- disabled={!hasSelection}
1427
- />
1428
1513
 
1429
1514
  <DropdownMenu>
1430
1515
  <Tooltip>
@@ -1441,7 +1526,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1441
1526
  aria-label={mergeLayers ? 'Class Visibility (Merge Multilayer Walls is on)' : 'Class Visibility'}
1442
1527
  className="relative"
1443
1528
  >
1444
- <Layers className="h-4 w-4" />
1529
+ <Filter className="h-4 w-4" />
1445
1530
  {mergeLayers && (
1446
1531
  // Tiny accent dot announcing that a non-default load
1447
1532
  // setting is active. Decorative — semantics live on
@@ -1486,6 +1571,15 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1486
1571
  Show Site
1487
1572
  </DropdownMenuCheckboxItem>
1488
1573
  )}
1574
+ {hasIfcAnnotations && (
1575
+ <DropdownMenuCheckboxItem
1576
+ checked={typeVisibility.ifcAnnotations}
1577
+ onCheckedChange={() => toggleTypeVisibility('ifcAnnotations')}
1578
+ >
1579
+ <Pencil className="h-4 w-4 mr-2" style={{ color: '#e4b400' }} />
1580
+ Show Annotations & Grids
1581
+ </DropdownMenuCheckboxItem>
1582
+ )}
1489
1583
 
1490
1584
  {/* Load-time toggles live below the runtime visibility
1491
1585
  switches — they apply on next model open rather than
@@ -1518,29 +1612,15 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1518
1612
  {/* ── Camera & View ── */}
1519
1613
  <ActionButton icon={Home} label="Home (Isometric + Reset Visibility)" onClick={handleHome} shortcut="H" />
1520
1614
 
1521
- {/* Orthographic / Perspective toggle */}
1522
- <Tooltip>
1523
- <TooltipTrigger asChild>
1524
- <Button
1525
- variant={projectionMode === 'orthographic' ? 'default' : 'ghost'}
1526
- size="icon-sm"
1527
- onClick={(e) => {
1528
- (e.currentTarget as HTMLButtonElement).blur();
1529
- toggleProjectionMode();
1530
- }}
1531
- className={cn(projectionMode === 'orthographic' && 'bg-primary text-primary-foreground')}
1532
- >
1533
- <Orbit className="h-4 w-4" />
1534
- </Button>
1535
- </TooltipTrigger>
1536
- <TooltipContent>
1537
- {projectionMode === 'orthographic' ? 'Switch to Perspective' : 'Switch to Orthographic'}
1538
- </TooltipContent>
1539
- </Tooltip>
1540
-
1541
- {/* Cesium 3D Context toggle — web only, only when model has georeferencing */}
1615
+ {/*
1616
+ Cesium 3D World Context — sits next to Home as a raw button so
1617
+ the world-context affordance is one click away when a model has
1618
+ georeferencing. When active, the "Move georeference" sub-toggle
1619
+ appears beside it (its amber tint signals a modal pose whose
1620
+ exit affordance must stay visible).
1621
+ */}
1542
1622
  {cesiumAvailable && !desktopShell && (
1543
- <div className="flex items-center gap-1">
1623
+ <>
1544
1624
  <Tooltip>
1545
1625
  <TooltipTrigger asChild>
1546
1626
  <Button
@@ -1589,46 +1669,40 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1589
1669
  </TooltipContent>
1590
1670
  </Tooltip>
1591
1671
  )}
1592
- </div>
1672
+ </>
1593
1673
  )}
1594
1674
 
1595
- {/* Hover Tooltips toggle */}
1596
- <Tooltip>
1597
- <TooltipTrigger asChild>
1598
- <Button
1599
- variant={hoverTooltipsEnabled ? 'default' : 'ghost'}
1600
- size="icon-sm"
1601
- onClick={(e) => {
1602
- (e.currentTarget as HTMLButtonElement).blur();
1603
- toggleHoverTooltips();
1604
- }}
1605
- className={cn(hoverTooltipsEnabled && 'bg-primary text-primary-foreground')}
1606
- >
1607
- <Info className="h-4 w-4" />
1608
- </Button>
1609
- </TooltipTrigger>
1610
- <TooltipContent>
1611
- {hoverTooltipsEnabled ? 'Disable' : 'Enable'} Hover Tooltips
1612
- </TooltipContent>
1613
- </Tooltip>
1614
-
1615
- {/* Preset Views dropdown */}
1675
+ {/*
1676
+ Consolidated View dropdown — holds projection toggle, preset
1677
+ views, and hover tooltips. These are "view options" the user
1678
+ reaches for occasionally, and rendering each as a raw icon
1679
+ button used to dominate the toolbar's right half. Cesium stayed
1680
+ inline (above) because the world-context overlay is a primary
1681
+ affordance, not a tucked-away view setting.
1682
+ */}
1616
1683
  <DropdownMenu>
1617
1684
  <Tooltip>
1618
1685
  <TooltipTrigger asChild>
1619
1686
  <DropdownMenuTrigger asChild>
1620
- <Button variant="ghost" size="icon-sm">
1687
+ <Button
1688
+ variant={(projectionMode === 'orthographic' || hoverTooltipsEnabled) ? 'default' : 'ghost'}
1689
+ size="icon-sm"
1690
+ aria-label="View options"
1691
+ className={cn((projectionMode === 'orthographic' || hoverTooltipsEnabled) && 'bg-primary text-primary-foreground')}
1692
+ >
1621
1693
  <Grid3x3 className="h-4 w-4" />
1622
1694
  </Button>
1623
1695
  </DropdownMenuTrigger>
1624
1696
  </TooltipTrigger>
1625
- <TooltipContent>Preset Views</TooltipContent>
1697
+ <TooltipContent>View options</TooltipContent>
1626
1698
  </Tooltip>
1627
- <DropdownMenuContent>
1699
+ <DropdownMenuContent align="end" className="w-56">
1700
+ <DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
1701
+ Preset views
1702
+ </DropdownMenuLabel>
1628
1703
  <DropdownMenuItem onClick={handleHome}>
1629
1704
  <Box className="h-4 w-4 mr-2" /> Isometric <span className="ml-auto text-xs opacity-60">H</span>
1630
1705
  </DropdownMenuItem>
1631
- <DropdownMenuSeparator />
1632
1706
  <DropdownMenuItem onClick={() => cameraCallbacks.setPresetView?.('top')}>
1633
1707
  <ArrowUp className="h-4 w-4 mr-2" /> Top <span className="ml-auto text-xs opacity-60">1</span>
1634
1708
  </DropdownMenuItem>
@@ -1647,12 +1721,37 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1647
1721
  <DropdownMenuItem onClick={() => cameraCallbacks.setPresetView?.('right')}>
1648
1722
  <ArrowRight className="h-4 w-4 mr-2" /> Right <span className="ml-auto text-xs opacity-60">6</span>
1649
1723
  </DropdownMenuItem>
1724
+ <DropdownMenuSeparator />
1725
+ <DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
1726
+ Projection
1727
+ </DropdownMenuLabel>
1728
+ <DropdownMenuCheckboxItem
1729
+ checked={projectionMode === 'orthographic'}
1730
+ onCheckedChange={() => toggleProjectionMode()}
1731
+ >
1732
+ <Orbit className="h-4 w-4 mr-2" />
1733
+ Orthographic
1734
+ </DropdownMenuCheckboxItem>
1735
+ <DropdownMenuSeparator />
1736
+ <DropdownMenuLabel className="text-[10px] uppercase tracking-wide text-muted-foreground">
1737
+ Helpers
1738
+ </DropdownMenuLabel>
1739
+ <DropdownMenuCheckboxItem
1740
+ checked={hoverTooltipsEnabled}
1741
+ onCheckedChange={() => toggleHoverTooltips()}
1742
+ >
1743
+ <Info className="h-4 w-4 mr-2" />
1744
+ Hover tooltips
1745
+ </DropdownMenuCheckboxItem>
1650
1746
  </DropdownMenuContent>
1651
1747
  </DropdownMenu>
1652
1748
 
1653
1749
  {/* Spacer */}
1654
1750
  <div className="flex-1" />
1655
1751
 
1752
+ {/* Extension toolbar contributions (right-aligned) */}
1753
+ <ExtensionToolbarSlot slot="toolbar.right" />
1754
+
1656
1755
  {/* Loading Progress */}
1657
1756
  {loading && (geometryProgress || metadataProgress || progress) && (
1658
1757
  <div className="flex items-center gap-2 mr-4">
@@ -1678,26 +1777,10 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
1678
1777
  <span className="text-xs text-destructive mr-4">{error}</span>
1679
1778
  )}
1680
1779
 
1681
- {/* Right Side Actions */}
1780
+ {/* Right Side Actions — /mcp moved to the Info dialog header so
1781
+ the toolbar's meta cluster stays focused on shell chrome
1782
+ (Settings · Theme · Help). */}
1682
1783
  <div className="flex items-center gap-2 ml-2 pl-2 border-l border-zinc-200 dark:border-zinc-700/60">
1683
- {/* /mcp cross-link — lives in the meta cluster (Settings / Theme /
1684
- Help) so it shares space with shell-level navigation rather
1685
- than competing with the modeling tools to its left. */}
1686
- <Tooltip>
1687
- <TooltipTrigger asChild>
1688
- <Button
1689
- variant="ghost"
1690
- size="icon"
1691
- className="rounded-full"
1692
- onClick={() => navigateToPath('/mcp')}
1693
- aria-label="Open ifc-lite MCP"
1694
- >
1695
- <Sparkles className="!h-[20px] !w-[20px]" />
1696
- </Button>
1697
- </TooltipTrigger>
1698
- <TooltipContent>Drive ifc-lite from any LLM (MCP)</TooltipContent>
1699
- </Tooltip>
1700
-
1701
1784
  {desktopShell ? (
1702
1785
  <Tooltip>
1703
1786
  <TooltipTrigger asChild>
@@ -29,6 +29,7 @@ import {
29
29
  PanelRightOpen,
30
30
  Undo2,
31
31
  Redo2,
32
+ Wrench,
32
33
  } from 'lucide-react';
33
34
  import { Button } from '@/components/ui/button';
34
35
  import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
@@ -57,6 +58,8 @@ import { SCRIPT_TEMPLATES } from '@/lib/scripts/templates';
57
58
  import { navigateToPath } from '@/services/app-navigation';
58
59
  import { CodeEditor } from './CodeEditor';
59
60
  import { ChatPanel } from './ChatPanel';
61
+ import { PromoteToolDialog } from '@/components/extensions/PromoteToolDialog';
62
+ import { useOptionalExtensionHost } from '@/sdk/ExtensionHostProvider';
60
63
  import type { LogEntry } from '@/store/slices/scriptSlice';
61
64
 
62
65
  interface ScriptPanelProps {
@@ -87,6 +90,8 @@ function useScriptState() {
87
90
  const undoScriptEditor = useViewerStore((s) => s.undoScriptEditor);
88
91
  const redoScriptEditor = useViewerStore((s) => s.redoScriptEditor);
89
92
  const queueChatRepairRequest = useViewerStore((s) => s.queueChatRepairRequest);
93
+ const chatToolReady = useViewerStore((s) => s.chatToolReady);
94
+ const setChatToolReady = useViewerStore((s) => s.setChatToolReady);
90
95
 
91
96
  return {
92
97
  editorContent,
@@ -111,6 +116,8 @@ function useScriptState() {
111
116
  undoScriptEditor,
112
117
  redoScriptEditor,
113
118
  queueChatRepairRequest,
119
+ chatToolReady,
120
+ setChatToolReady,
114
121
  };
115
122
  }
116
123
 
@@ -138,9 +145,12 @@ export function ScriptPanel({ onClose }: ScriptPanelProps) {
138
145
  undoScriptEditor,
139
146
  redoScriptEditor,
140
147
  queueChatRepairRequest,
148
+ chatToolReady,
149
+ setChatToolReady,
141
150
  } = useScriptState();
142
151
 
143
152
  const { execute, reset } = useSandbox();
153
+ const extensionHost = useOptionalExtensionHost();
144
154
  const [outputCollapsed, setOutputCollapsed] = useState(false);
145
155
  const chatPanelVisible = useViewerStore((s) => s.chatPanelVisible);
146
156
  const setChatPanelVisible = useViewerStore((s) => s.setChatPanelVisible);
@@ -218,8 +228,13 @@ export function ScriptPanel({ onClose }: ScriptPanelProps) {
218
228
 
219
229
  const handleRun = useCallback(async () => {
220
230
  if (executionState === 'running') return;
231
+ const startedAt = performance.now();
221
232
  await execute(editorContent);
222
- }, [execute, editorContent, executionState]);
233
+ extensionHost?.emitAction('script.execute', {
234
+ templateId: activeScriptId ?? undefined,
235
+ durationMs: Math.round(performance.now() - startedAt),
236
+ });
237
+ }, [execute, editorContent, executionState, extensionHost, activeScriptId]);
223
238
 
224
239
  const handleSave = useCallback(() => {
225
240
  if (activeScriptId) {
@@ -233,6 +248,9 @@ export function ScriptPanel({ onClose }: ScriptPanelProps) {
233
248
  createScript(name, code);
234
249
  }, [createScript]);
235
250
 
251
+ const [promoteOpen, setPromoteOpen] = useState(false);
252
+ const canPromote = !!extensionHost && editorContent.trim().length > 0;
253
+
236
254
  const handleDeleteConfirm = useCallback(() => {
237
255
  if (deleteConfirmId) {
238
256
  deleteScript(deleteConfirmId);
@@ -330,6 +348,47 @@ export function ScriptPanel({ onClose }: ScriptPanelProps) {
330
348
  )}
331
349
  </div>
332
350
 
351
+ {/* Post-authoring "install as tool" banner — surfaces right
352
+ where the AI-written code lands so the user never has to
353
+ hunt for the Promote button. Highlighted (accent fill +
354
+ ring) so the install step reads as the obvious next move,
355
+ not a faint afterthought. */}
356
+ {chatToolReady?.kind === 'script' && (
357
+ <div className="shrink-0 border-b bg-primary/15 px-3 py-2.5 ring-1 ring-inset ring-primary/40">
358
+ <div className="flex items-center gap-2.5">
359
+ <div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-primary text-primary-foreground">
360
+ <Wrench className="h-4 w-4" />
361
+ </div>
362
+ <div className="flex-1 min-w-0">
363
+ <div className="text-xs font-semibold">This script is ready</div>
364
+ <div className="text-[11px] text-muted-foreground">
365
+ Install it as a one-click button in your toolbar.
366
+ </div>
367
+ </div>
368
+ <Button
369
+ size="sm"
370
+ onClick={() => {
371
+ setPromoteOpen(true);
372
+ setChatToolReady(null);
373
+ }}
374
+ className="shrink-0"
375
+ >
376
+ <Wrench className="mr-1 h-3.5 w-3.5" />
377
+ Install as tool
378
+ </Button>
379
+ <Button
380
+ size="icon-xs"
381
+ variant="ghost"
382
+ onClick={() => setChatToolReady(null)}
383
+ aria-label="Dismiss"
384
+ className="shrink-0"
385
+ >
386
+ <X className="h-3.5 w-3.5" />
387
+ </Button>
388
+ </div>
389
+ </div>
390
+ )}
391
+
333
392
  {/* Toolbar */}
334
393
  <div className="flex items-center gap-1 px-2 py-1 border-b shrink-0">
335
394
  <Tooltip>
@@ -357,6 +416,29 @@ export function ScriptPanel({ onClose }: ScriptPanelProps) {
357
416
  <TooltipContent>Save (Ctrl+S)</TooltipContent>
358
417
  </Tooltip>
359
418
 
419
+ {/* Save-as-tool — the explicit, always-visible bridge from a
420
+ one-shot script to a persistent toolbar button. A labelled
421
+ outline button (not a buried icon) so the "keep this"
422
+ step is discoverable without nagging. */}
423
+ <Tooltip>
424
+ <TooltipTrigger asChild>
425
+ <Button
426
+ variant="outline"
427
+ size="sm"
428
+ onClick={() => setPromoteOpen(true)}
429
+ disabled={!canPromote}
430
+ aria-label="Save this script as a persistent tool"
431
+ className="gap-1"
432
+ >
433
+ <Wrench className="h-3.5 w-3.5" />
434
+ Save as tool
435
+ </Button>
436
+ </TooltipTrigger>
437
+ <TooltipContent>
438
+ Turn this script into a permanent one-click button in your toolbar
439
+ </TooltipContent>
440
+ </Tooltip>
441
+
360
442
  <Tooltip>
361
443
  <TooltipTrigger asChild>
362
444
  <Button
@@ -479,6 +561,16 @@ export function ScriptPanel({ onClose }: ScriptPanelProps) {
479
561
  <AlertCircle className="h-3 w-3 mt-0.5 shrink-0" />
480
562
  <div className="min-w-0">
481
563
  <span className="whitespace-pre-wrap break-all">{lastError}</span>
564
+ {/* Sandbox-globals hint — when the error names a
565
+ browser-context API the sandbox doesn't expose,
566
+ surface a one-line cue so the user understands
567
+ why the rewrite is needed before clicking Fix. */}
568
+ {/(document|window|navigator|location|fetch|XMLHttpRequest|localStorage|indexedDB|setTimeout|setInterval) is not defined/.test(lastError) && (
569
+ <div className="mt-1 text-[11px] text-muted-foreground font-sans">
570
+ Scripts run in a QuickJS sandbox — no DOM, no <code className="font-mono">fetch</code>, no browser globals.
571
+ Use <code className="font-mono">bim.*</code> APIs for viewer / data / export side-effects.
572
+ </div>
573
+ )}
482
574
  <div className="mt-1">
483
575
  <Button
484
576
  variant="outline"
@@ -555,6 +647,18 @@ export function ScriptPanel({ onClose }: ScriptPanelProps) {
555
647
  </DialogFooter>
556
648
  </DialogContent>
557
649
  </Dialog>
650
+
651
+ {promoteOpen && extensionHost && (
652
+ <PromoteToolDialog
653
+ open={promoteOpen}
654
+ source={editorContent}
655
+ initialName={
656
+ savedScripts.find((s) => s.id === activeScriptId)?.name
657
+ ?? 'My tool'
658
+ }
659
+ onClose={() => setPromoteOpen(false)}
660
+ />
661
+ )}
558
662
  </div>
559
663
  );
560
664
  }