@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
@@ -12,7 +12,7 @@
12
12
  */
13
13
 
14
14
  import React, { useCallback, useRef, useState, useEffect, useMemo } from 'react';
15
- import { X, Download, Eye, EyeOff, Maximize2, ZoomIn, ZoomOut, Loader2, Printer, GripVertical, MoreHorizontal, RefreshCw, Pin, PinOff, Palette, Ruler, Trash2, FileText, Shapes, Box, PenTool, Hexagon, Type, Cloud, MousePointer2 } from 'lucide-react';
15
+ import { X, Download, Eye, EyeOff, Maximize2, ZoomIn, ZoomOut, Loader2, Printer, GripVertical, MoreHorizontal, RefreshCw, Pin, PinOff, Palette, Ruler, Trash2, FileText, Shapes, Box, PenTool, Hexagon, Type, Cloud, MousePointer2, Tag } from 'lucide-react';
16
16
  import { Button } from '@/components/ui/button';
17
17
  import {
18
18
  DropdownMenu,
@@ -31,11 +31,12 @@ import { SheetSetupPanel } from './SheetSetupPanel';
31
31
  import { TitleBlockEditor } from './TitleBlockEditor';
32
32
  import { TextAnnotationEditor } from './TextAnnotationEditor';
33
33
  import { Drawing2DCanvas } from './Drawing2DCanvas';
34
- import { useDrawingGeneration } from '@/hooks/useDrawingGeneration';
34
+ import { useDrawingGeneration, AXIS_MAP } from '@/hooks/useDrawingGeneration';
35
35
  import { useMeasure2D } from '@/hooks/useMeasure2D';
36
36
  import { useAnnotation2D } from '@/hooks/useAnnotation2D';
37
37
  import { useViewControls } from '@/hooks/useViewControls';
38
38
  import { useDrawingExport } from '@/hooks/useDrawingExport';
39
+ import { useSymbolicAnnotationsForDrawing } from '@/hooks/useSymbolicAnnotations';
39
40
 
40
41
  interface Section2DPanelProps {
41
42
  mergedGeometry?: GeometryResult | null;
@@ -279,6 +280,43 @@ export function Section2DPanel({
279
280
  setMeasure2DSnapPoint, cancelMeasure2D, completeMeasure2D,
280
281
  });
281
282
 
283
+ // ─── IFC annotation overlay (issue #812) ──────────────────────────────────
284
+ // Re-derive the section's world-coord cut position from the same bounds
285
+ // useDrawingGeneration uses, so the annotation filter stays in lockstep
286
+ // with the cut. Empty/missing bounds collapse to an inert range → hook
287
+ // returns empty, the overlay simply does nothing.
288
+ const ifcAnnotationsForDrawing = useMemo(() => {
289
+ const bounds = geometryResult?.coordinateInfo?.shiftedBounds;
290
+ if (!bounds) {
291
+ return { sectionPosWorld: 0, viewDepth: 0, fallbackY: 0 };
292
+ }
293
+ const axis = AXIS_MAP[sectionPlane.axis];
294
+ const axisMin = bounds.min[axis];
295
+ const axisMax = bounds.max[axis];
296
+ const sectionPosWorld = axisMin + (sectionPlane.position / 100) * (axisMax - axisMin);
297
+ const viewDepth = (axisMax - axisMin) * 0.5; // matches useDrawingGeneration's maxDepth
298
+ // For loose annotations (no resolvable storey), fall back to mid-Y like
299
+ // the 3D viewport does. This lets storeyless models still surface their
300
+ // annotations on the relevant section.
301
+ const yMin = bounds.min.y;
302
+ const yMax = bounds.max.y;
303
+ const fallbackY = Number.isFinite(yMin) && Number.isFinite(yMax) ? (yMin + yMax) * 0.5 : 0;
304
+ return { sectionPosWorld, viewDepth, fallbackY };
305
+ }, [geometryResult, sectionPlane.axis, sectionPlane.position]);
306
+
307
+ const ifcAnnotationData = useSymbolicAnnotationsForDrawing({
308
+ enabled: displayOptions.showIfcAnnotations && status === 'ready',
309
+ axis: sectionPlane.axis,
310
+ sectionPosWorld: ifcAnnotationsForDrawing.sectionPosWorld,
311
+ viewDepth: ifcAnnotationsForDrawing.viewDepth,
312
+ flipped: sectionPlane.flipped,
313
+ fallbackY: ifcAnnotationsForDrawing.fallbackY,
314
+ });
315
+
316
+ const toggleIfcAnnotations = useCallback(() => {
317
+ updateDisplayOptions({ showIfcAnnotations: !displayOptions.showIfcAnnotations });
318
+ }, [displayOptions.showIfcAnnotations, updateDisplayOptions]);
319
+
282
320
  const annotationHandlers = useAnnotation2D({
283
321
  drawing, viewTransform, sectionAxis: sectionPlane.axis, containerRef,
284
322
  activeTool: annotation2DActiveTool, setActiveTool: setAnnotation2DActiveTool,
@@ -536,6 +574,17 @@ export function Section2DPanel({
536
574
  {displayOptions.useSymbolicRepresentations ? <Shapes className="h-4 w-4" /> : <Box className="h-4 w-4" />}
537
575
  </Button>
538
576
 
577
+ {/* IFC Annotations overlay toggle (issue #812) */}
578
+ <Button
579
+ variant={displayOptions.showIfcAnnotations ? 'default' : 'ghost'}
580
+ size="icon-sm"
581
+ onClick={toggleIfcAnnotations}
582
+ title={displayOptions.showIfcAnnotations ? 'Hide IFC annotations on this section' : 'Show IFC annotations on this section'}
583
+ disabled={sectionPlane.axis !== 'down'}
584
+ >
585
+ <Tag className="h-4 w-4" />
586
+ </Button>
587
+
539
588
  {/* Annotation Tools Dropdown */}
540
589
  <DropdownMenu>
541
590
  <DropdownMenuTrigger asChild>
@@ -703,6 +752,10 @@ export function Section2DPanel({
703
752
  {displayOptions.useSymbolicRepresentations ? <Shapes className="h-4 w-4 mr-2" /> : <Box className="h-4 w-4 mr-2" />}
704
753
  {displayOptions.useSymbolicRepresentations ? 'Symbolic (Plan)' : 'Section Cut (Body)'}
705
754
  </DropdownMenuItem>
755
+ <DropdownMenuItem onClick={toggleIfcAnnotations} disabled={sectionPlane.axis !== 'down'}>
756
+ <Tag className="h-4 w-4 mr-2" />
757
+ IFC Annotations {displayOptions.showIfcAnnotations ? 'On' : 'Off'}
758
+ </DropdownMenuItem>
706
759
  <DropdownMenuItem onClick={() => setAnnotation2DActiveTool('none')}>
707
760
  <MousePointer2 className="h-4 w-4 mr-2" />
708
761
  Select / Pan {annotation2DActiveTool === 'none' ? '(On)' : ''}
@@ -849,6 +902,9 @@ export function Section2DPanel({
849
902
  cloudAnnotationPoints={cloudAnnotation2DPoints}
850
903
  cloudAnnotations={cloudAnnotations2D}
851
904
  selectedAnnotation={selectedAnnotation2D}
905
+ ifcAnnotationLines={ifcAnnotationData.lines}
906
+ ifcAnnotationTexts={ifcAnnotationData.texts}
907
+ ifcAnnotationFills={ifcAnnotationData.fills}
852
908
  />
853
909
  {/* Subtle updating indicator - shows while regenerating without hiding the drawing */}
854
910
  {isRegenerating && (
@@ -9,6 +9,8 @@ import { formatNumber, formatBytes } from '@/lib/utils';
9
9
  import { useViewerStore } from '@/store';
10
10
  import { useIfc } from '@/hooks/useIfc';
11
11
  import { useWebGPU } from '@/hooks/useWebGPU';
12
+ import { FlavorIndicator } from '@/components/extensions/FlavorIndicator';
13
+ import { FlavorDialog } from '@/components/extensions/FlavorDialog';
12
14
 
13
15
  export function StatusBar() {
14
16
  const { loading, geometryResult, ifcDataStore } = useIfc();
@@ -20,6 +22,16 @@ export function StatusBar() {
20
22
 
21
23
  const [fps, setFps] = useState(60);
22
24
  const [memory, setMemory] = useState(0);
25
+ const [flavorDialogOpen, setFlavorDialogOpen] = useState(false);
26
+ /** Deep-link from Command Palette → "Manage flavors…". */
27
+ const flavorDialogRequested = useViewerStore((s) => s.flavorDialogRequested);
28
+ const setFlavorDialogRequested = useViewerStore((s) => s.setFlavorDialogRequested);
29
+ useEffect(() => {
30
+ if (flavorDialogRequested) {
31
+ setFlavorDialogOpen(true);
32
+ setFlavorDialogRequested(false);
33
+ }
34
+ }, [flavorDialogRequested, setFlavorDialogRequested]);
23
35
 
24
36
  // FPS counter (simplified)
25
37
  useEffect(() => {
@@ -175,6 +187,10 @@ export function StatusBar() {
175
187
 
176
188
  <Separator orientation="vertical" className="h-3.5" />
177
189
 
190
+ <FlavorIndicator onClick={() => setFlavorDialogOpen(true)} />
191
+
192
+ <Separator orientation="vertical" className="h-3.5" />
193
+
178
194
  <span className="opacity-60">v{__APP_VERSION__}</span>
179
195
 
180
196
  <Separator orientation="vertical" className="h-3.5" />
@@ -189,6 +205,8 @@ export function StatusBar() {
189
205
  ifclite.dev →
190
206
  </a>
191
207
  </div>
208
+
209
+ <FlavorDialog open={flavorDialogOpen} onClose={() => setFlavorDialogOpen(false)} />
192
210
  </div>
193
211
  );
194
212
  }
@@ -15,6 +15,11 @@ import { StatusBar } from './StatusBar';
15
15
  import { ViewportContainer } from './ViewportContainer';
16
16
  import { KeyboardShortcutsDialog, useKeyboardShortcutsDialog } from './KeyboardShortcutsDialog';
17
17
  import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
18
+ import { useActionLogger } from '@/hooks/useActionLogger';
19
+ import { usePrivacyDisclosure } from '@/hooks/usePrivacyDisclosure';
20
+ import { isSafeMode } from '@/lib/safe-mode';
21
+ import { ShieldAlert } from 'lucide-react';
22
+ import { ExtensionDockHost } from '@/components/extensions/ExtensionDockHost';
18
23
  import { useIfc } from '@/hooks/useIfc';
19
24
  import { useViewerStore } from '@/store';
20
25
  import { EntityContextMenu } from './EntityContextMenu';
@@ -26,6 +31,7 @@ import { LensPanel } from './LensPanel';
26
31
  import { ListPanel } from './lists/ListPanel';
27
32
  import { ScriptPanel } from './ScriptPanel';
28
33
  import { GanttPanel } from './schedule/GanttPanel';
34
+ import { ExtensionsPanel } from '@/components/extensions/ExtensionsPanel';
29
35
  import { CommandPalette } from './CommandPalette';
30
36
  import { SearchModal } from './SearchModal';
31
37
  import { DesktopEntitlementBanner } from './DesktopEntitlementBanner';
@@ -45,6 +51,11 @@ export function ViewerLayout() {
45
51
  useKeyboardShortcuts();
46
52
  // ⌘D / Ctrl+D to duplicate the current selection.
47
53
  useDuplicateShortcut();
54
+ // Bridge viewer state transitions into the extension action log
55
+ // so the idle pattern miner can surface one-click tool suggestions.
56
+ useActionLogger();
57
+ // Show the RFC §06 §7 privacy disclosure on first launch.
58
+ usePrivacyDisclosure();
48
59
  const shortcutsDialog = useKeyboardShortcutsDialog();
49
60
 
50
61
  // Auto-load a model from ?model=<URL>. Used by the landing-page iframe to drop a
@@ -115,6 +126,8 @@ export function ViewerLayout() {
115
126
  const setActiveTool = useViewerStore((s) => s.setActiveTool);
116
127
  const idsPanelVisible = useViewerStore((s) => s.idsPanelVisible);
117
128
  const setIdsPanelVisible = useViewerStore((s) => s.setIdsPanelVisible);
129
+ const extensionsPanelVisible = useViewerStore((s) => s.extensionsPanelVisible);
130
+ const setExtensionsPanelVisible = useViewerStore((s) => s.setExtensionsPanelVisible);
118
131
  const listPanelVisible = useViewerStore((s) => s.listPanelVisible);
119
132
  const setListPanelVisible = useViewerStore((s) => s.setListPanelVisible);
120
133
  const lensPanelVisible = useViewerStore((s) => s.lensPanelVisible);
@@ -241,9 +254,21 @@ export function ViewerLayout() {
241
254
  }, [theme]);
242
255
 
243
256
 
257
+ const safeMode = isSafeMode();
258
+
244
259
  return (
245
260
  <TooltipProvider delayDuration={300}>
246
261
  <div className="flex flex-col h-screen h-[100dvh] w-screen overflow-hidden bg-background text-foreground">
262
+ {safeMode && (
263
+ <div className="flex items-center gap-2 border-b border-amber-500/40 bg-amber-500/10 px-3 py-1 text-[11px] text-amber-700 dark:text-amber-300">
264
+ <ShieldAlert className="h-3.5 w-3.5 shrink-0" />
265
+ <span>
266
+ Safe mode: extensions and the active flavor are not loaded for this
267
+ session. Append <code className="font-mono">?safe=0</code> or reload
268
+ without the flag to resume.
269
+ </span>
270
+ </div>
271
+ )}
247
272
  {/* Keyboard Shortcuts Dialog */}
248
273
  <KeyboardShortcutsDialog open={shortcutsDialog.open} onClose={shortcutsDialog.close} />
249
274
 
@@ -276,8 +301,13 @@ export function ViewerLayout() {
276
301
  if (collapsed !== leftPanelCollapsed) setLeftPanelCollapsed(collapsed);
277
302
  }}
278
303
  >
279
- <div className="h-full w-full overflow-hidden panel-container">
280
- <HierarchyPanel />
304
+ <div className="h-full w-full overflow-hidden panel-container flex flex-col">
305
+ <div className="flex-1 min-h-0 overflow-hidden">
306
+ <HierarchyPanel />
307
+ </div>
308
+ {/* Extension dock.left — collapses when no extension
309
+ contributes. Sits beneath the hierarchy panel. */}
310
+ <ExtensionDockHost slot="dock.left" className="max-h-[40%] border-t" />
281
311
  </div>
282
312
  </Panel>
283
313
 
@@ -316,8 +346,16 @@ export function ViewerLayout() {
316
346
  <IDSPanel onClose={() => setIdsPanelVisible(false)} />
317
347
  ) : bcfPanelVisible ? (
318
348
  <BCFPanel onClose={() => setBcfPanelVisible(false)} />
349
+ ) : extensionsPanelVisible ? (
350
+ <ExtensionsPanel onClose={() => setExtensionsPanelVisible(false)} />
319
351
  ) : (
320
- <PropertiesPanel />
352
+ <div className="h-full flex flex-col">
353
+ <div className="flex-1 min-h-0 overflow-hidden">
354
+ <PropertiesPanel />
355
+ </div>
356
+ {/* Extension dock.right — collapses when empty. */}
357
+ <ExtensionDockHost slot="dock.right" className="max-h-[40%] border-t" />
358
+ </div>
321
359
  )}
322
360
  </div>
323
361
  </Panel>
@@ -381,7 +419,7 @@ export function ViewerLayout() {
381
419
  {/* Mobile Bottom Sheet - Properties, BCF, IDS, or Lists */}
382
420
  {!rightPanelCollapsed && (
383
421
  <MobileBottomSheet
384
- title={activeAnalysisExtension ? activeAnalysisExtension.label : ganttPanelVisible ? 'Schedule' : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : activeTool === 'addElement' ? 'Add element' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Properties'}
422
+ title={activeAnalysisExtension ? activeAnalysisExtension.label : ganttPanelVisible ? 'Schedule' : scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : activeTool === 'addElement' ? 'Add element' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : extensionsPanelVisible ? 'Extensions' : 'Properties'}
385
423
  bottomInset={bottomViewportInset}
386
424
  onClose={() => {
387
425
  setRightPanelCollapsed(true);
@@ -391,6 +429,7 @@ export function ViewerLayout() {
391
429
  if (bcfPanelVisible) setBcfPanelVisible(false);
392
430
  if (lensPanelVisible) setLensPanelVisible(false);
393
431
  if (idsPanelVisible) setIdsPanelVisible(false);
432
+ if (extensionsPanelVisible) setExtensionsPanelVisible(false);
394
433
  if (activeAnalysisExtension) closeActiveAnalysisExtension();
395
434
  if (activeTool === 'addElement') setActiveTool('select');
396
435
  }}
@@ -413,6 +452,8 @@ export function ViewerLayout() {
413
452
  <IDSPanel onClose={() => setIdsPanelVisible(false)} />
414
453
  ) : bcfPanelVisible ? (
415
454
  <BCFPanel onClose={() => setBcfPanelVisible(false)} />
455
+ ) : extensionsPanelVisible ? (
456
+ <ExtensionsPanel onClose={() => setExtensionsPanelVisible(false)} />
416
457
  ) : (
417
458
  <PropertiesPanel />
418
459
  )}
@@ -455,6 +496,14 @@ export function ViewerLayout() {
455
496
  </div>
456
497
  )}
457
498
 
499
+ {/* Extension dock.bottom slot — collapses to nothing when no
500
+ extension contributes here. */}
501
+ {!isMobile && (
502
+ <div className="max-h-[40vh]">
503
+ <ExtensionDockHost slot="dock.bottom" />
504
+ </div>
505
+ )}
506
+
458
507
  {/* Status Bar — hidden on mobile to maximize viewport space */}
459
508
  {!isMobile && <StatusBar />}
460
509
  </div>
@@ -40,6 +40,7 @@ import { useGeometryStreaming } from './useGeometryStreaming.js';
40
40
  import { usePointCloudSync } from './usePointCloudSync.js';
41
41
  import { usePointCloudLifecycle } from './usePointCloudLifecycle.js';
42
42
  import { useRenderUpdates } from './useRenderUpdates.js';
43
+ import { useSymbolicAnnotations, useSymbolicAnnotationsRichData } from '../../hooks/useSymbolicAnnotations.js';
43
44
 
44
45
  interface ViewportProps {
45
46
  geometry: MeshData[] | null;
@@ -779,6 +780,77 @@ export function Viewport({
779
780
  const show3DOverlay = useViewerStore((s) => s.drawing2DDisplayOptions.show3DOverlay);
780
781
  const showHiddenLines = useViewerStore((s) => s.drawing2DDisplayOptions.showHiddenLines);
781
782
 
783
+ // ===== IfcAnnotation symbolic overlay =====
784
+ // Renders IfcAnnotation 2D drawing curves as a standalone 3D line overlay
785
+ // that's visible regardless of whether a section cut is active. Each
786
+ // segment is lifted to its containing storey's elevation, so a multi-
787
+ // storey model shows all storeys' annotations layered correctly in 3D
788
+ // (issue #653). Parsing is lazy and only runs while the toggle is on.
789
+ const ifcAnnotationsVisible = useViewerStore((s) => s.typeVisibility.ifcAnnotations);
790
+ // For annotations whose storey can't be resolved (or whose authored
791
+ // elevation is 0 because the storey Z lives on the placement instead),
792
+ // lift to the middle of the model's vertical span so they don't end up
793
+ // buried inside ground-floor geometry.
794
+ const annotationFallbackY = useMemo(() => {
795
+ const bounds = coordinateInfo?.shiftedBounds;
796
+ if (!bounds) return 0;
797
+ const min = bounds.min.y;
798
+ const max = bounds.max.y;
799
+ if (!Number.isFinite(min) || !Number.isFinite(max) || max <= min) return 0;
800
+ return (min + max) * 0.5;
801
+ }, [coordinateInfo]);
802
+ const annotationVertices3D = useSymbolicAnnotations({
803
+ enabled: ifcAnnotationsVisible,
804
+ fallbackY: annotationFallbackY,
805
+ });
806
+ const { texts: annotationTexts3D, fills: annotationFills3D } = useSymbolicAnnotationsRichData({
807
+ enabled: ifcAnnotationsVisible,
808
+ fallbackY: annotationFallbackY,
809
+ });
810
+ useEffect(() => {
811
+ const renderer = rendererRef.current;
812
+ if (!renderer || !isInitialized) return;
813
+ if (annotationVertices3D.length === 0) {
814
+ renderer.clearAnnotationLines3D();
815
+ } else {
816
+ renderer.uploadAnnotationLines3D(annotationVertices3D);
817
+ }
818
+ }, [annotationVertices3D, isInitialized]);
819
+
820
+ // Upload IfcAnnotation text + fill data for the WebGPU symbolic overlay
821
+ // pipelines. Map the hook's per-annotation records into the SymbolicFillInput
822
+ // / SymbolicTextInput shape the renderer expects. Empty arrays clear cleanly.
823
+ useEffect(() => {
824
+ const renderer = rendererRef.current;
825
+ if (!renderer || !isInitialized) return;
826
+ renderer.uploadAnnotationFills3D(
827
+ annotationFills3D.map((f) => ({
828
+ points: f.points,
829
+ holesOffsets: f.holesOffsets,
830
+ worldY: f.worldY,
831
+ color: f.color,
832
+ })),
833
+ );
834
+ }, [annotationFills3D, isInitialized]);
835
+
836
+ useEffect(() => {
837
+ const renderer = rendererRef.current;
838
+ if (!renderer || !isInitialized) return;
839
+ renderer.uploadAnnotationTexts3D(
840
+ annotationTexts3D.map((t) => ({
841
+ worldPos: t.worldPos,
842
+ dirX: t.dirX,
843
+ dirZ: t.dirZ,
844
+ height: t.height,
845
+ content: t.content,
846
+ alignment: t.alignment,
847
+ billboard: t.billboard,
848
+ color: t.color,
849
+ targetPx: t.targetPx,
850
+ })),
851
+ );
852
+ }, [annotationTexts3D, isInitialized]);
853
+
782
854
  // ===== Streaming progress =====
783
855
  const isStreaming = useViewerStore((state) => state.geometryStreamingActive);
784
856
 
@@ -0,0 +1,161 @@
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 assert from 'node:assert/strict';
6
+ import { describe, it } from 'node:test';
7
+ import {
8
+ detectActions,
9
+ hashLensId,
10
+ type ActionLoggerStateShape,
11
+ } from './useActionLogger.js';
12
+
13
+ function makeState(over: Partial<ActionLoggerStateShape> = {}): ActionLoggerStateShape {
14
+ return {
15
+ models: over.models ?? new Map(),
16
+ activeLensId: over.activeLensId ?? null,
17
+ selectedEntities: over.selectedEntities ?? [],
18
+ sectionPlane: over.sectionPlane,
19
+ drawing2DPanelVisible: over.drawing2DPanelVisible,
20
+ };
21
+ }
22
+
23
+ describe('hashLensId', () => {
24
+ it('produces a stable 8-char hex token', () => {
25
+ const h1 = hashLensId('by-fire-rating');
26
+ const h2 = hashLensId('by-fire-rating');
27
+ assert.strictEqual(h1, h2);
28
+ assert.match(h1, /^lens-[0-9a-f]{8}$/);
29
+ });
30
+
31
+ it('distinguishes distinct ids', () => {
32
+ assert.notStrictEqual(hashLensId('foo'), hashLensId('bar'));
33
+ });
34
+
35
+ it('does not leak the original string', () => {
36
+ const h = hashLensId("John's basement check");
37
+ assert.ok(!h.includes('John'));
38
+ assert.ok(!h.includes('basement'));
39
+ });
40
+ });
41
+
42
+ describe('detectActions', () => {
43
+ it('emits model.load when a model is added', () => {
44
+ const prev = makeState();
45
+ const next = makeState({
46
+ models: new Map([['m1', { schemaVersion: 'IFC4', ifcDataStore: { entityCount: 100 }, fileSize: 5000 }]]),
47
+ });
48
+ const events = detectActions(prev, next);
49
+ assert.strictEqual(events.length, 1);
50
+ assert.strictEqual(events[0].intent, 'model.load');
51
+ if (events[0].intent === 'model.load') {
52
+ assert.strictEqual(events[0].params.schema, 'IFC4');
53
+ assert.strictEqual(events[0].params.entityCount, 100);
54
+ assert.strictEqual(events[0].params.sizeBytes, 5000);
55
+ }
56
+ });
57
+
58
+ it('emits model.unload when a model is removed', () => {
59
+ const prev = makeState({
60
+ models: new Map([['m1', { schemaVersion: 'IFC4', ifcDataStore: null, fileSize: 0 }]]),
61
+ });
62
+ const next = makeState();
63
+ const events = detectActions(prev, next);
64
+ assert.strictEqual(events.length, 1);
65
+ assert.strictEqual(events[0].intent, 'model.unload');
66
+ });
67
+
68
+ it('emits both load and unload on a same-size swap', () => {
69
+ const prev = makeState({
70
+ models: new Map([['old', { schemaVersion: 'IFC4', ifcDataStore: null, fileSize: 0 }]]),
71
+ });
72
+ const next = makeState({
73
+ models: new Map([['new', { schemaVersion: 'IFC4', ifcDataStore: null, fileSize: 0 }]]),
74
+ });
75
+ const events = detectActions(prev, next);
76
+ const intents = events.map((e) => e.intent).sort();
77
+ assert.deepStrictEqual(intents, ['model.load', 'model.unload']);
78
+ });
79
+
80
+ it('hashes lens ids before emitting lens.apply', () => {
81
+ const prev = makeState();
82
+ const next = makeState({ activeLensId: "John's basement" });
83
+ const events = detectActions(prev, next);
84
+ assert.strictEqual(events.length, 1);
85
+ assert.strictEqual(events[0].intent, 'lens.apply');
86
+ if (events[0].intent === 'lens.apply') {
87
+ assert.ok(!events[0].params.id?.includes('John'));
88
+ assert.match(events[0].params.id ?? '', /^lens-/);
89
+ }
90
+ });
91
+
92
+ it('emits lens.clear when lens is unset', () => {
93
+ const prev = makeState({ activeLensId: 'foo' });
94
+ const next = makeState({ activeLensId: null });
95
+ const events = detectActions(prev, next);
96
+ assert.strictEqual(events.length, 1);
97
+ assert.strictEqual(events[0].intent, 'lens.clear');
98
+ });
99
+
100
+ it('emits selection.change only on count delta', () => {
101
+ const prev = makeState({ selectedEntities: [1, 2, 3] });
102
+ const next = makeState({ selectedEntities: [1, 2, 3] }); // same count
103
+ assert.strictEqual(detectActions(prev, next).length, 0);
104
+
105
+ const next2 = makeState({ selectedEntities: [1, 2] });
106
+ const events = detectActions(prev, next2);
107
+ assert.strictEqual(events.length, 1);
108
+ if (events[0].intent === 'selection.change') {
109
+ assert.strictEqual(events[0].params.count, 2);
110
+ }
111
+ });
112
+
113
+ it('emits section.apply on enable transition only', () => {
114
+ // false → true emits.
115
+ const prev = makeState({ sectionPlane: { enabled: false } });
116
+ const next = makeState({ sectionPlane: { enabled: true } });
117
+ assert.strictEqual(detectActions(prev, next).length, 1);
118
+ // true → true does not emit.
119
+ const events = detectActions(next, next);
120
+ assert.strictEqual(events.length, 0);
121
+ });
122
+
123
+ it('emits view.change on 2d/3d mode flip', () => {
124
+ const prev = makeState({ drawing2DPanelVisible: false });
125
+ const next = makeState({ drawing2DPanelVisible: true });
126
+ const events = detectActions(prev, next);
127
+ assert.strictEqual(events.length, 1);
128
+ assert.strictEqual(events[0].intent, 'view.change');
129
+ if (events[0].intent === 'view.change') {
130
+ assert.strictEqual(events[0].params.mode, '2d');
131
+ }
132
+ });
133
+
134
+ it('emits exactly one event per unique transition (no duplicates)', () => {
135
+ const prev = makeState();
136
+ const next = makeState({
137
+ models: new Map([['m1', { schemaVersion: 'IFC4', ifcDataStore: null, fileSize: 0 }]]),
138
+ activeLensId: 'lens-1',
139
+ selectedEntities: [1],
140
+ sectionPlane: { enabled: true },
141
+ drawing2DPanelVisible: true,
142
+ });
143
+ const events = detectActions(prev, next);
144
+ const intents = events.map((e) => e.intent).sort();
145
+ assert.deepStrictEqual(
146
+ intents,
147
+ ['lens.apply', 'model.load', 'section.apply', 'selection.change', 'view.change'],
148
+ );
149
+ });
150
+
151
+ it('returns an empty array when state is identical', () => {
152
+ const state = makeState({
153
+ models: new Map([['m1', { schemaVersion: 'IFC4', ifcDataStore: null, fileSize: 0 }]]),
154
+ activeLensId: 'lens-1',
155
+ selectedEntities: [1],
156
+ sectionPlane: { enabled: true },
157
+ drawing2DPanelVisible: true,
158
+ });
159
+ assert.strictEqual(detectActions(state, state).length, 0);
160
+ });
161
+ });
@@ -0,0 +1,141 @@
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
+ /**
6
+ * `useActionLogger` — bridge the viewer store to the extension action log.
7
+ *
8
+ * Subscribes to store transitions on a handful of key fields and emits
9
+ * content-free `ActionEvent`s through `host.emitAction(...)`. The
10
+ * miner consumes these to suggest one-click tools.
11
+ *
12
+ * Wired once at the top of the app (under `<ExtensionHostProvider>`).
13
+ * Intentionally selective — we only log intents the miner can act on,
14
+ * never raw payload content (no entity names, no chat text, no file
15
+ * names). Per spec §06 §7, the action log NEVER sees user content.
16
+ *
17
+ * Spec: docs/architecture/ai-customization/06-self-improvement.md §2.
18
+ */
19
+
20
+ import { useEffect } from 'react';
21
+ import type { ActionIntent, ActionParams } from '@ifc-lite/extensions';
22
+ import { useOptionalExtensionHost } from '@/sdk/ExtensionHostProvider';
23
+ import { useViewerStore } from '@/store';
24
+
25
+ /**
26
+ * Shape we read from the viewer store. Narrowed to just the fields
27
+ * `detectActions` consumes so the function is unit-testable without
28
+ * importing the full store type.
29
+ */
30
+ export interface ActionLoggerStateShape {
31
+ models: ReadonlyMap<string, { schemaVersion: string; ifcDataStore: { entityCount?: number } | null; fileSize: number }>;
32
+ activeLensId: string | null;
33
+ selectedEntities: readonly unknown[];
34
+ sectionPlane?: { enabled?: boolean };
35
+ drawing2DPanelVisible?: boolean;
36
+ }
37
+
38
+ export type EmittedAction = {
39
+ [K in ActionIntent]: { intent: K; params: ActionParams[K] };
40
+ }[ActionIntent];
41
+
42
+ /**
43
+ * Pure transition detector. Given the prior and next store states,
44
+ * returns the list of action events to emit. Extracted so the
45
+ * transition logic can be tested without a React renderer.
46
+ */
47
+ export function detectActions(
48
+ prev: ActionLoggerStateShape,
49
+ state: ActionLoggerStateShape,
50
+ ): EmittedAction[] {
51
+ const out: EmittedAction[] = [];
52
+ // model.load / model.unload — compare by id so a swap (same size)
53
+ // still emits both events.
54
+ for (const [id, model] of state.models) {
55
+ if (!prev.models.has(id)) {
56
+ out.push({
57
+ intent: 'model.load',
58
+ params: {
59
+ schema: model.schemaVersion,
60
+ entityCount: model.ifcDataStore?.entityCount,
61
+ sizeBytes: model.fileSize,
62
+ },
63
+ });
64
+ }
65
+ }
66
+ for (const id of prev.models.keys()) {
67
+ if (!state.models.has(id)) {
68
+ out.push({ intent: 'model.unload', params: {} });
69
+ }
70
+ }
71
+ // lens.apply / lens.clear — hashed id only.
72
+ if (state.activeLensId !== prev.activeLensId) {
73
+ if (state.activeLensId) {
74
+ out.push({ intent: 'lens.apply', params: { id: hashLensId(state.activeLensId) } });
75
+ } else {
76
+ out.push({ intent: 'lens.clear', params: {} });
77
+ }
78
+ }
79
+ // selection.change — count delta only.
80
+ const prevCount = prev.selectedEntities?.length ?? 0;
81
+ const nextCount = state.selectedEntities?.length ?? 0;
82
+ if (prevCount !== nextCount) {
83
+ out.push({ intent: 'selection.change', params: { count: nextCount } });
84
+ }
85
+ // section.apply — enable transition only.
86
+ const prevSection = prev.sectionPlane?.enabled ?? false;
87
+ const nextSection = state.sectionPlane?.enabled ?? false;
88
+ if (!prevSection && nextSection) {
89
+ out.push({ intent: 'section.apply', params: {} });
90
+ }
91
+ // view.change — 2d/3d mode flip (proxied via drawing2D panel).
92
+ const prevMode = prev.drawing2DPanelVisible ? '2d' : '3d';
93
+ const nextMode = state.drawing2DPanelVisible ? '2d' : '3d';
94
+ if (prevMode !== nextMode) {
95
+ out.push({ intent: 'view.change', params: { mode: nextMode } });
96
+ }
97
+ return out;
98
+ }
99
+
100
+ export function useActionLogger(): void {
101
+ const host = useOptionalExtensionHost();
102
+
103
+ useEffect(() => {
104
+ if (!host) return;
105
+
106
+ // Snapshot the prior state per field so we can detect transitions
107
+ // rather than emit on every render. We use the public subscribe
108
+ // method since the store is a vanilla zustand instance.
109
+ let prev = useViewerStore.getState() as unknown as ActionLoggerStateShape;
110
+
111
+ const unsubscribe = useViewerStore.subscribe((state) => {
112
+ const next = state as unknown as ActionLoggerStateShape;
113
+ for (const event of detectActions(prev, next)) {
114
+ // TypeScript can't pair `event.intent` (discriminated union)
115
+ // with emitAction's `<K extends ActionIntent>` generic across
116
+ // an iterator. The pairing is provably correct — every
117
+ // EmittedAction is built from a matching `(intent, params)`
118
+ // pair in detectActions — so the runtime call is type-safe
119
+ // even though the inferencer can't see it.
120
+ host.emitAction(event.intent, event.params as never);
121
+ }
122
+ prev = next;
123
+ });
124
+
125
+ return unsubscribe;
126
+ }, [host]);
127
+ }
128
+
129
+ /**
130
+ * Project a lens id into a short stable token so the action log never
131
+ * carries the original string. We only need identity for the miner;
132
+ * djb2 is plenty for 30-50 distinct lenses per user.
133
+ */
134
+ export function hashLensId(id: string): string {
135
+ let hash = 5381;
136
+ for (let i = 0; i < id.length; i++) {
137
+ hash = ((hash << 5) + hash + id.charCodeAt(i)) | 0;
138
+ }
139
+ // Unsigned 32-bit hex — 8 chars, stable, opaque.
140
+ return `lens-${(hash >>> 0).toString(16).padStart(8, '0')}`;
141
+ }