@ifc-lite/viewer 1.7.0 → 1.8.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 (43) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CwcRxist.js} +1 -1
  3. package/dist/assets/index-7WoQ-qVC.css +1 -0
  4. package/dist/assets/{index-dgdgiQ9p.js → index-BSANf7-H.js} +20926 -17587
  5. package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-5LbrYh3R.js} +1 -1
  6. package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-CgpLtj1h.js} +1 -1
  7. package/dist/index.html +2 -2
  8. package/package.json +18 -18
  9. package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
  10. package/src/components/viewer/EntityContextMenu.tsx +47 -20
  11. package/src/components/viewer/ExportDialog.tsx +166 -17
  12. package/src/components/viewer/HierarchyPanel.tsx +3 -1
  13. package/src/components/viewer/LensPanel.tsx +848 -85
  14. package/src/components/viewer/MainToolbar.tsx +114 -81
  15. package/src/components/viewer/Section2DPanel.tsx +269 -29
  16. package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
  17. package/src/components/viewer/Viewport.tsx +57 -23
  18. package/src/components/viewer/ViewportContainer.tsx +2 -0
  19. package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
  20. package/src/components/viewer/hierarchy/types.ts +1 -1
  21. package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
  22. package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
  23. package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
  24. package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
  25. package/src/components/viewer/tools/computePolygonArea.ts +72 -0
  26. package/src/components/viewer/useGeometryStreaming.ts +12 -4
  27. package/src/hooks/ids/idsExportService.ts +1 -1
  28. package/src/hooks/useAnnotation2D.ts +551 -0
  29. package/src/hooks/useDrawingExport.ts +83 -1
  30. package/src/hooks/useKeyboardShortcuts.ts +113 -14
  31. package/src/hooks/useLens.ts +39 -55
  32. package/src/hooks/useLensDiscovery.ts +46 -0
  33. package/src/hooks/useModelSelection.ts +5 -22
  34. package/src/index.css +7 -1
  35. package/src/lib/lens/adapter.ts +127 -1
  36. package/src/lib/lists/columnToAutoColor.ts +33 -0
  37. package/src/store/index.ts +14 -1
  38. package/src/store/resolveEntityRef.ts +44 -0
  39. package/src/store/slices/drawing2DSlice.ts +321 -0
  40. package/src/store/slices/lensSlice.ts +46 -4
  41. package/src/store/slices/pinboardSlice.ts +171 -38
  42. package/src/store.ts +3 -0
  43. package/dist/assets/index-yTqs8kgX.css +0 -1
@@ -14,7 +14,7 @@ import {
14
14
  Scissors,
15
15
  Eye,
16
16
  EyeOff,
17
- Focus,
17
+ Equal,
18
18
  Crosshair,
19
19
  Home,
20
20
  Maximize2,
@@ -34,13 +34,11 @@ import {
34
34
  SquareX,
35
35
  Building2,
36
36
  Plus,
37
+ Minus,
37
38
  MessageSquare,
38
39
  ClipboardCheck,
39
- Pin,
40
- PinOff,
41
40
  Palette,
42
41
  Orbit,
43
- Trash2,
44
42
  } from 'lucide-react';
45
43
  import { Button } from '@/components/ui/button';
46
44
  import { Separator } from '@/components/ui/separator';
@@ -57,7 +55,8 @@ import {
57
55
  DropdownMenuSubContent,
58
56
  } from '@/components/ui/dropdown-menu';
59
57
  import { Progress } from '@/components/ui/progress';
60
- import { useViewerStore, isIfcxDataStore } from '@/store';
58
+ import { useViewerStore, isIfcxDataStore, stringToEntityRef } from '@/store';
59
+ import type { EntityRef } from '@/store';
61
60
  import { useIfc } from '@/hooks/useIfc';
62
61
  import { cn } from '@/lib/utils';
63
62
  import { GLTFExporter, CSVExporter } from '@ifc-lite/export';
@@ -158,8 +157,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
158
157
  const theme = useViewerStore((state) => state.theme);
159
158
  const toggleTheme = useViewerStore((state) => state.toggleTheme);
160
159
  const selectedEntityId = useViewerStore((state) => state.selectedEntityId);
161
- const isolateEntity = useViewerStore((state) => state.isolateEntity);
162
- const hideEntity = useViewerStore((state) => state.hideEntity);
160
+ const hideEntities = useViewerStore((state) => state.hideEntities);
163
161
  const showAll = useViewerStore((state) => state.showAll);
164
162
  const clearStoreySelection = useViewerStore((state) => state.clearStoreySelection);
165
163
  const error = useViewerStore((state) => state.error);
@@ -178,13 +176,14 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
178
176
  const setRightPanelCollapsed = useViewerStore((state) => state.setRightPanelCollapsed);
179
177
  const projectionMode = useViewerStore((state) => state.projectionMode);
180
178
  const toggleProjectionMode = useViewerStore((state) => state.toggleProjectionMode);
181
- // Pinboard state
179
+ // Basket state (= + − isolation basket)
182
180
  const pinboardEntities = useViewerStore((state) => state.pinboardEntities);
183
- const addToPinboard = useViewerStore((state) => state.addToPinboard);
184
- const removeFromPinboard = useViewerStore((state) => state.removeFromPinboard);
185
- const showPinboard = useViewerStore((state) => state.showPinboard);
186
- const clearPinboard = useViewerStore((state) => state.clearPinboard);
187
- const selectedEntity = useViewerStore((state) => state.selectedEntity);
181
+ const setBasket = useViewerStore((state) => state.setBasket);
182
+ const addToBasket = useViewerStore((state) => state.addToBasket);
183
+ const removeFromBasket = useViewerStore((state) => state.removeFromBasket);
184
+ const clearBasket = useViewerStore((state) => state.clearBasket);
185
+ // NOTE: selectedEntity and selectedEntitiesSet accessed via getState() in callbacks
186
+ // to avoid re-rendering MainToolbar on every Cmd+Click selection change.
188
187
  // Lens state
189
188
  const lensPanelVisible = useViewerStore((state) => state.lensPanelVisible);
190
189
  const toggleLensPanel = useViewerStore((state) => state.toggleLensPanel);
@@ -289,25 +288,85 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
289
288
  e.target.value = '';
290
289
  }, [loadFilesSequentially, addIfcxOverlays, ifcDataStore]);
291
290
 
292
- const handleIsolate = useCallback(() => {
293
- if (selectedEntityId) {
294
- isolateEntity(selectedEntityId);
291
+ /** Get current selection as EntityRef[] — uses getState() to avoid reactive subscriptions */
292
+ const getSelectionRefs = useCallback((): EntityRef[] => {
293
+ const state = useViewerStore.getState();
294
+ if (state.selectedEntitiesSet.size > 0) {
295
+ const refs: EntityRef[] = [];
296
+ for (const str of state.selectedEntitiesSet) {
297
+ refs.push(stringToEntityRef(str));
298
+ }
299
+ return refs;
300
+ }
301
+ if (state.selectedEntity) {
302
+ return [state.selectedEntity];
303
+ }
304
+ return [];
305
+ }, []);
306
+
307
+ const hasSelection = selectedEntityId !== null;
308
+
309
+ // Basket state
310
+ const showPinboard = useViewerStore((state) => state.showPinboard);
311
+
312
+ // Clear multi-select state after basket operations so subsequent − targets a single entity
313
+ const clearMultiSelect = useCallback(() => {
314
+ const state = useViewerStore.getState();
315
+ if (state.selectedEntitiesSet.size > 0) {
316
+ useViewerStore.setState({ selectedEntitiesSet: new Set(), selectedEntityIds: new Set() });
317
+ }
318
+ }, []);
319
+
320
+ // Basket operations
321
+ const handleSetBasket = useCallback(() => {
322
+ const state = useViewerStore.getState();
323
+ // If basket already exists and user hasn't explicitly multi-selected,
324
+ // re-apply the basket instead of replacing it with a stale single selection.
325
+ // Only an explicit multi-selection (Ctrl+Click) should replace an existing basket.
326
+ if (state.pinboardEntities.size > 0 && state.selectedEntitiesSet.size === 0) {
327
+ showPinboard();
328
+ return;
329
+ }
330
+ const refs = getSelectionRefs();
331
+ if (refs.length > 0) {
332
+ setBasket(refs);
333
+ clearMultiSelect();
334
+ }
335
+ }, [getSelectionRefs, setBasket, showPinboard, clearMultiSelect]);
336
+
337
+ const handleAddToBasket = useCallback(() => {
338
+ const refs = getSelectionRefs();
339
+ if (refs.length > 0) {
340
+ addToBasket(refs);
341
+ clearMultiSelect();
342
+ }
343
+ }, [getSelectionRefs, addToBasket, clearMultiSelect]);
344
+
345
+ const handleRemoveFromBasket = useCallback(() => {
346
+ const refs = getSelectionRefs();
347
+ if (refs.length > 0) {
348
+ removeFromBasket(refs);
349
+ clearMultiSelect();
295
350
  }
296
- }, [selectedEntityId, isolateEntity]);
351
+ }, [getSelectionRefs, removeFromBasket, clearMultiSelect]);
297
352
 
298
353
  const clearSelection = useViewerStore((state) => state.clearSelection);
299
354
 
300
355
  const handleHide = useCallback(() => {
301
- if (selectedEntityId) {
302
- hideEntity(selectedEntityId);
303
- // Clear selection after hiding - element is no longer visible
356
+ // Hide ALL selected entities (multi-select or single)
357
+ const state = useViewerStore.getState();
358
+ const ids: number[] = state.selectedEntityIds.size > 0
359
+ ? Array.from(state.selectedEntityIds)
360
+ : selectedEntityId !== null ? [selectedEntityId] : [];
361
+ if (ids.length > 0) {
362
+ hideEntities(ids);
304
363
  clearSelection();
305
364
  }
306
- }, [selectedEntityId, hideEntity, clearSelection]);
365
+ }, [selectedEntityId, hideEntities, clearSelection]);
307
366
 
308
367
  const handleShowAll = useCallback(() => {
309
- showAll();
310
- clearStoreySelection(); // Also clear storey filtering (matches 'A' keyboard shortcut)
368
+ showAll(); // Clear hiddenEntities + isolatedEntities (basket contents preserved)
369
+ clearStoreySelection(); // Also clear storey filtering
311
370
  }, [showAll, clearStoreySelection]);
312
371
 
313
372
  const handleExportGLB = useCallback(() => {
@@ -675,9 +734,37 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
675
734
 
676
735
  <Separator orientation="vertical" className="h-6 mx-1" />
677
736
 
678
- {/* ── Visibility & Filtering (all together) ── */}
679
- <ActionButton icon={Focus} label="Isolate Selection" onClick={handleIsolate} shortcut="I" disabled={!selectedEntityId} />
680
- <ActionButton icon={EyeOff} label="Hide Selection" onClick={handleHide} shortcut="Del" disabled={!selectedEntityId} />
737
+ {/* ── Basket Isolation (= + −) ── */}
738
+ <Tooltip>
739
+ <TooltipTrigger asChild>
740
+ <Button
741
+ variant={pinboardEntities.size > 0 ? 'default' : 'ghost'}
742
+ size="icon-sm"
743
+ onClick={(e) => {
744
+ (e.currentTarget as HTMLButtonElement).blur();
745
+ handleSetBasket();
746
+ }}
747
+ disabled={!hasSelection && pinboardEntities.size === 0}
748
+ className={cn(
749
+ pinboardEntities.size > 0 && 'bg-primary text-primary-foreground relative',
750
+ )}
751
+ >
752
+ <Equal className="h-4 w-4" />
753
+ {pinboardEntities.size > 0 && (
754
+ <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">
755
+ {pinboardEntities.size}
756
+ </span>
757
+ )}
758
+ </Button>
759
+ </TooltipTrigger>
760
+ <TooltipContent>
761
+ Set Basket — isolate selection <span className="ml-2 text-xs opacity-60">(I)</span>
762
+ </TooltipContent>
763
+ </Tooltip>
764
+ <ActionButton icon={Plus} label="Add to Basket" onClick={handleAddToBasket} shortcut="+" disabled={!hasSelection} />
765
+ <ActionButton icon={Minus} label="Remove from Basket" onClick={handleRemoveFromBasket} shortcut="−" disabled={!hasSelection} />
766
+
767
+ <ActionButton icon={EyeOff} label="Hide Selection" onClick={handleHide} shortcut="Del / Space" disabled={!hasSelection} />
681
768
  <ActionButton icon={Eye} label="Show All (Reset Filters)" onClick={handleShowAll} shortcut="A" />
682
769
  <ActionButton icon={Maximize2} label="Fit All" onClick={() => cameraCallbacks.fitAll?.()} shortcut="Z" />
683
770
  <ActionButton
@@ -685,7 +772,7 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
685
772
  label="Frame Selection"
686
773
  onClick={() => cameraCallbacks.frameSelection?.()}
687
774
  shortcut="F"
688
- disabled={!selectedEntityId}
775
+ disabled={!hasSelection}
689
776
  />
690
777
 
691
778
  <DropdownMenu>
@@ -730,60 +817,6 @@ export function MainToolbar({ onShowShortcuts }: MainToolbarProps = {} as MainTo
730
817
  </DropdownMenuContent>
731
818
  </DropdownMenu>
732
819
 
733
- {/* Pinboard dropdown */}
734
- <DropdownMenu>
735
- <Tooltip>
736
- <TooltipTrigger asChild>
737
- <DropdownMenuTrigger asChild>
738
- <Button
739
- variant={pinboardEntities.size > 0 ? 'default' : 'ghost'}
740
- size="icon-sm"
741
- className={cn(pinboardEntities.size > 0 && 'bg-primary text-primary-foreground relative')}
742
- >
743
- <Pin className="h-4 w-4" />
744
- {pinboardEntities.size > 0 && (
745
- <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">
746
- {pinboardEntities.size}
747
- </span>
748
- )}
749
- </Button>
750
- </DropdownMenuTrigger>
751
- </TooltipTrigger>
752
- <TooltipContent>Pinboard ({pinboardEntities.size})</TooltipContent>
753
- </Tooltip>
754
- <DropdownMenuContent>
755
- <DropdownMenuItem
756
- onClick={() => { if (selectedEntity) addToPinboard([selectedEntity]); }}
757
- disabled={!selectedEntity}
758
- >
759
- <Pin className="h-4 w-4 mr-2" />
760
- Pin Selection
761
- </DropdownMenuItem>
762
- <DropdownMenuItem
763
- onClick={() => { if (selectedEntity) removeFromPinboard([selectedEntity]); }}
764
- disabled={!selectedEntity}
765
- >
766
- <PinOff className="h-4 w-4 mr-2" />
767
- Unpin Selection
768
- </DropdownMenuItem>
769
- <DropdownMenuSeparator />
770
- <DropdownMenuItem
771
- onClick={() => showPinboard()}
772
- disabled={pinboardEntities.size === 0}
773
- >
774
- <Eye className="h-4 w-4 mr-2" />
775
- Show Pinboard
776
- </DropdownMenuItem>
777
- <DropdownMenuItem
778
- onClick={() => clearPinboard()}
779
- disabled={pinboardEntities.size === 0}
780
- >
781
- <Trash2 className="h-4 w-4 mr-2" />
782
- Clear Pinboard
783
- </DropdownMenuItem>
784
- </DropdownMenuContent>
785
- </DropdownMenu>
786
-
787
820
  {/* Lens (rule-based filtering) */}
788
821
  <Tooltip>
789
822
  <TooltipTrigger asChild>
@@ -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 } 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 } from 'lucide-react';
16
16
  import { Button } from '@/components/ui/button';
17
17
  import {
18
18
  DropdownMenu,
@@ -28,9 +28,11 @@ import { type GeometryResult } from '@ifc-lite/geometry';
28
28
  import { DrawingSettingsPanel } from './DrawingSettingsPanel';
29
29
  import { SheetSetupPanel } from './SheetSetupPanel';
30
30
  import { TitleBlockEditor } from './TitleBlockEditor';
31
+ import { TextAnnotationEditor } from './TextAnnotationEditor';
31
32
  import { Drawing2DCanvas } from './Drawing2DCanvas';
32
33
  import { useDrawingGeneration } from '@/hooks/useDrawingGeneration';
33
34
  import { useMeasure2D } from '@/hooks/useMeasure2D';
35
+ import { useAnnotation2D } from '@/hooks/useAnnotation2D';
34
36
  import { useViewControls } from '@/hooks/useViewControls';
35
37
  import { useDrawingExport } from '@/hooks/useDrawingExport';
36
38
 
@@ -98,6 +100,39 @@ export function Section2DPanel({
98
100
  const measure2DSnapPoint = useViewerStore((s) => s.measure2DSnapPoint);
99
101
  const setMeasure2DSnapPoint = useViewerStore((s) => s.setMeasure2DSnapPoint);
100
102
 
103
+ // Annotation tool state
104
+ const annotation2DActiveTool = useViewerStore((s) => s.annotation2DActiveTool);
105
+ const setAnnotation2DActiveTool = useViewerStore((s) => s.setAnnotation2DActiveTool);
106
+ const annotation2DCursorPos = useViewerStore((s) => s.annotation2DCursorPos);
107
+ const setAnnotation2DCursorPos = useViewerStore((s) => s.setAnnotation2DCursorPos);
108
+ // Polygon area state
109
+ const polygonArea2DPoints = useViewerStore((s) => s.polygonArea2DPoints);
110
+ const polygonArea2DResults = useViewerStore((s) => s.polygonArea2DResults);
111
+ const addPolygonArea2DPoint = useViewerStore((s) => s.addPolygonArea2DPoint);
112
+ const completePolygonArea2D = useViewerStore((s) => s.completePolygonArea2D);
113
+ const cancelPolygonArea2D = useViewerStore((s) => s.cancelPolygonArea2D);
114
+ const clearPolygonArea2DResults = useViewerStore((s) => s.clearPolygonArea2DResults);
115
+ // Text annotation state
116
+ const textAnnotations2D = useViewerStore((s) => s.textAnnotations2D);
117
+ const textAnnotation2DEditing = useViewerStore((s) => s.textAnnotation2DEditing);
118
+ const addTextAnnotation2D = useViewerStore((s) => s.addTextAnnotation2D);
119
+ const updateTextAnnotation2D = useViewerStore((s) => s.updateTextAnnotation2D);
120
+ const removeTextAnnotation2D = useViewerStore((s) => s.removeTextAnnotation2D);
121
+ const setTextAnnotation2DEditing = useViewerStore((s) => s.setTextAnnotation2DEditing);
122
+ // Cloud annotation state
123
+ const cloudAnnotation2DPoints = useViewerStore((s) => s.cloudAnnotation2DPoints);
124
+ const cloudAnnotations2D = useViewerStore((s) => s.cloudAnnotations2D);
125
+ const addCloudAnnotation2DPoint = useViewerStore((s) => s.addCloudAnnotation2DPoint);
126
+ const completeCloudAnnotation2D = useViewerStore((s) => s.completeCloudAnnotation2D);
127
+ const cancelCloudAnnotation2D = useViewerStore((s) => s.cancelCloudAnnotation2D);
128
+ // Selection
129
+ const selectedAnnotation2D = useViewerStore((s) => s.selectedAnnotation2D);
130
+ const setSelectedAnnotation2D = useViewerStore((s) => s.setSelectedAnnotation2D);
131
+ const deleteSelectedAnnotation2D = useViewerStore((s) => s.deleteSelectedAnnotation2D);
132
+ const moveAnnotation2D = useViewerStore((s) => s.moveAnnotation2D);
133
+ // Bulk
134
+ const clearAllAnnotations2D = useViewerStore((s) => s.clearAllAnnotations2D);
135
+
101
136
  const sectionPlane = useViewerStore((s) => s.sectionPlane);
102
137
  const activeTool = useViewerStore((s) => s.activeTool);
103
138
  const models = useViewerStore((s) => s.models);
@@ -228,7 +263,7 @@ export function Section2DPanel({
228
263
  isPinned, cachedSheetTransformRef,
229
264
  });
230
265
 
231
- const { handleMouseDown, handleMouseMove, handleMouseUp, handleMouseLeave, handleMouseEnter } = useMeasure2D({
266
+ const measureHandlers = useMeasure2D({
232
267
  drawing, viewTransform, setViewTransform, sectionAxis: sectionPlane.axis, containerRef,
233
268
  measure2DMode, measure2DStart, measure2DCurrent,
234
269
  measure2DShiftLocked, measure2DLockedAxis,
@@ -236,10 +271,67 @@ export function Section2DPanel({
236
271
  setMeasure2DSnapPoint, cancelMeasure2D, completeMeasure2D,
237
272
  });
238
273
 
274
+ const annotationHandlers = useAnnotation2D({
275
+ drawing, viewTransform, sectionAxis: sectionPlane.axis, containerRef,
276
+ activeTool: annotation2DActiveTool, setActiveTool: setAnnotation2DActiveTool,
277
+ polygonArea2DPoints, addPolygonArea2DPoint, completePolygonArea2D, cancelPolygonArea2D,
278
+ textAnnotations2D, addTextAnnotation2D, setTextAnnotation2DEditing,
279
+ cloudAnnotation2DPoints, cloudAnnotations2D, addCloudAnnotation2DPoint, completeCloudAnnotation2D, cancelCloudAnnotation2D,
280
+ measure2DResults, polygonArea2DResults,
281
+ selectedAnnotation2D, setSelectedAnnotation2D, deleteSelectedAnnotation2D, moveAnnotation2D,
282
+ setAnnotation2DCursorPos, setMeasure2DSnapPoint,
283
+ });
284
+
285
+ // Unified mouse handlers that dispatch to the right tool
286
+ const handleMouseDown = useCallback((e: React.MouseEvent) => {
287
+ if (annotation2DActiveTool === 'measure') {
288
+ measureHandlers.handleMouseDown(e);
289
+ } else if (annotation2DActiveTool === 'none') {
290
+ // Try annotation selection/drag first; if it consumed the click, don't pan
291
+ const consumed = annotationHandlers.handleMouseDown(e);
292
+ if (!consumed) {
293
+ measureHandlers.handleMouseDown(e);
294
+ }
295
+ } else {
296
+ annotationHandlers.handleMouseDown(e);
297
+ }
298
+ }, [annotation2DActiveTool, measureHandlers, annotationHandlers]);
299
+
300
+ const handleMouseMove = useCallback((e: React.MouseEvent) => {
301
+ // If dragging an annotation, let the annotation handler handle it
302
+ if (annotationHandlers.isDraggingRef.current) {
303
+ annotationHandlers.handleMouseMove(e);
304
+ return;
305
+ }
306
+ if (annotation2DActiveTool === 'measure' || annotation2DActiveTool === 'none') {
307
+ measureHandlers.handleMouseMove(e);
308
+ } else {
309
+ annotationHandlers.handleMouseMove(e);
310
+ }
311
+ }, [annotation2DActiveTool, measureHandlers, annotationHandlers]);
312
+
313
+ const handleMouseUp = useCallback((e: React.MouseEvent) => {
314
+ annotationHandlers.handleMouseUp(e);
315
+ measureHandlers.handleMouseUp();
316
+ }, [measureHandlers, annotationHandlers]);
317
+
318
+ const handleMouseLeave = useCallback(() => {
319
+ measureHandlers.handleMouseLeave();
320
+ }, [measureHandlers]);
321
+
322
+ const handleMouseEnter = useCallback((e: React.MouseEvent) => {
323
+ measureHandlers.handleMouseEnter(e);
324
+ }, [measureHandlers]);
325
+
326
+ const handleDoubleClick = useCallback((e: React.MouseEvent) => {
327
+ annotationHandlers.handleDoubleClick(e);
328
+ }, [annotationHandlers]);
329
+
239
330
  const { formatDistance, handleExportSVG, handlePrint } = useDrawingExport({
240
331
  drawing, displayOptions, sectionPlane, activePresetId,
241
332
  entityColorMap, overridesEnabled, overrideEngine,
242
- measure2DResults, sheetEnabled, activeSheet,
333
+ measure2DResults, polygonArea2DResults, textAnnotations2D, cloudAnnotations2D,
334
+ sheetEnabled, activeSheet,
243
335
  });
244
336
 
245
337
  // ═══════════════════════════════════════════════════════════════════════════
@@ -271,6 +363,42 @@ export function Section2DPanel({
271
363
  setIsPinned((prev) => !prev);
272
364
  }, []);
273
365
 
366
+ // Text editor handlers
367
+ const handleTextConfirm = useCallback((id: string, text: string) => {
368
+ updateTextAnnotation2D(id, { text });
369
+ setTextAnnotation2DEditing(null);
370
+ }, [updateTextAnnotation2D, setTextAnnotation2DEditing]);
371
+
372
+ const handleTextCancel = useCallback((id: string) => {
373
+ // If text is empty (just created), remove it
374
+ const annotation = textAnnotations2D.find((a) => a.id === id);
375
+ if (annotation && !annotation.text.trim()) {
376
+ removeTextAnnotation2D(id);
377
+ }
378
+ setTextAnnotation2DEditing(null);
379
+ }, [textAnnotations2D, removeTextAnnotation2D, setTextAnnotation2DEditing]);
380
+
381
+ // Check if any annotations exist
382
+ const hasAnnotations = measure2DResults.length > 0 ||
383
+ polygonArea2DResults.length > 0 ||
384
+ textAnnotations2D.length > 0 ||
385
+ cloudAnnotations2D.length > 0;
386
+
387
+ // Cursor style based on active tool
388
+ const cursorClass = useMemo(() => {
389
+ if (selectedAnnotation2D && annotation2DActiveTool === 'none') return 'cursor-move';
390
+ switch (annotation2DActiveTool) {
391
+ case 'measure':
392
+ case 'polygon-area':
393
+ case 'cloud':
394
+ return 'cursor-crosshair';
395
+ case 'text':
396
+ return 'cursor-text';
397
+ default:
398
+ return 'cursor-grab active:cursor-grabbing';
399
+ }
400
+ }, [annotation2DActiveTool, selectedAnnotation2D]);
401
+
274
402
  // ═══════════════════════════════════════════════════════════════════════════
275
403
  // RESIZE HANDLING
276
404
  // ═══════════════════════════════════════════════════════════════════════════
@@ -400,25 +528,55 @@ export function Section2DPanel({
400
528
  {displayOptions.useSymbolicRepresentations ? <Shapes className="h-4 w-4" /> : <Box className="h-4 w-4" />}
401
529
  </Button>
402
530
 
403
- {/* 2D Measure Tool */}
404
- <Button
405
- variant={measure2DMode ? 'default' : 'ghost'}
406
- size="icon-sm"
407
- onClick={toggleMeasure2DMode}
408
- title={measure2DMode ? 'Exit measure mode' : 'Measure distance'}
409
- >
410
- <Ruler className="h-4 w-4" />
411
- </Button>
412
- {measure2DResults.length > 0 && (
413
- <Button
414
- variant="ghost"
415
- size="icon-sm"
416
- onClick={clearMeasure2DResults}
417
- title="Clear measurements"
418
- >
419
- <Trash2 className="h-4 w-4" />
420
- </Button>
421
- )}
531
+ {/* Annotation Tools Dropdown */}
532
+ <DropdownMenu>
533
+ <DropdownMenuTrigger asChild>
534
+ <Button
535
+ variant={annotation2DActiveTool !== 'none' ? 'default' : 'ghost'}
536
+ size="icon-sm"
537
+ title="Annotation tools"
538
+ >
539
+ <PenTool className="h-4 w-4" />
540
+ </Button>
541
+ </DropdownMenuTrigger>
542
+ <DropdownMenuContent align="start">
543
+ <DropdownMenuItem onClick={() => setAnnotation2DActiveTool('none')}>
544
+ <MousePointer2 className="h-4 w-4 mr-2" />
545
+ Select / Pan
546
+ {annotation2DActiveTool === 'none' && <span className="ml-auto text-xs text-primary">Active</span>}
547
+ </DropdownMenuItem>
548
+ <DropdownMenuSeparator />
549
+ <DropdownMenuItem onClick={() => setAnnotation2DActiveTool(annotation2DActiveTool === 'measure' ? 'none' : 'measure')}>
550
+ <Ruler className="h-4 w-4 mr-2" />
551
+ Distance Measure
552
+ {annotation2DActiveTool === 'measure' && <span className="ml-auto text-xs text-primary">Active</span>}
553
+ </DropdownMenuItem>
554
+ <DropdownMenuItem onClick={() => setAnnotation2DActiveTool(annotation2DActiveTool === 'polygon-area' ? 'none' : 'polygon-area')}>
555
+ <Hexagon className="h-4 w-4 mr-2" />
556
+ Area Measure
557
+ {annotation2DActiveTool === 'polygon-area' && <span className="ml-auto text-xs text-primary">Active</span>}
558
+ </DropdownMenuItem>
559
+ <DropdownMenuItem onClick={() => setAnnotation2DActiveTool(annotation2DActiveTool === 'text' ? 'none' : 'text')}>
560
+ <Type className="h-4 w-4 mr-2" />
561
+ Text Box
562
+ {annotation2DActiveTool === 'text' && <span className="ml-auto text-xs text-primary">Active</span>}
563
+ </DropdownMenuItem>
564
+ <DropdownMenuItem onClick={() => setAnnotation2DActiveTool(annotation2DActiveTool === 'cloud' ? 'none' : 'cloud')}>
565
+ <Cloud className="h-4 w-4 mr-2" />
566
+ Revision Cloud
567
+ {annotation2DActiveTool === 'cloud' && <span className="ml-auto text-xs text-primary">Active</span>}
568
+ </DropdownMenuItem>
569
+ {hasAnnotations && (
570
+ <>
571
+ <DropdownMenuSeparator />
572
+ <DropdownMenuItem onClick={clearAllAnnotations2D}>
573
+ <Trash2 className="h-4 w-4 mr-2" />
574
+ Clear All Annotations
575
+ </DropdownMenuItem>
576
+ </>
577
+ )}
578
+ </DropdownMenuContent>
579
+ </DropdownMenu>
422
580
 
423
581
  {/* Graphic Override Settings */}
424
582
  <Button
@@ -537,14 +695,30 @@ export function Section2DPanel({
537
695
  {displayOptions.useSymbolicRepresentations ? <Shapes className="h-4 w-4 mr-2" /> : <Box className="h-4 w-4 mr-2" />}
538
696
  {displayOptions.useSymbolicRepresentations ? 'Symbolic (Plan)' : 'Section Cut (Body)'}
539
697
  </DropdownMenuItem>
540
- <DropdownMenuItem onClick={toggleMeasure2DMode}>
698
+ <DropdownMenuItem onClick={() => setAnnotation2DActiveTool('none')}>
699
+ <MousePointer2 className="h-4 w-4 mr-2" />
700
+ Select / Pan {annotation2DActiveTool === 'none' ? '(On)' : ''}
701
+ </DropdownMenuItem>
702
+ <DropdownMenuItem onClick={() => setAnnotation2DActiveTool(annotation2DActiveTool === 'measure' ? 'none' : 'measure')}>
541
703
  <Ruler className="h-4 w-4 mr-2" />
542
- Measure {measure2DMode ? 'On' : 'Off'}
704
+ Distance Measure {annotation2DActiveTool === 'measure' ? '(On)' : ''}
705
+ </DropdownMenuItem>
706
+ <DropdownMenuItem onClick={() => setAnnotation2DActiveTool(annotation2DActiveTool === 'polygon-area' ? 'none' : 'polygon-area')}>
707
+ <Hexagon className="h-4 w-4 mr-2" />
708
+ Area Measure {annotation2DActiveTool === 'polygon-area' ? '(On)' : ''}
709
+ </DropdownMenuItem>
710
+ <DropdownMenuItem onClick={() => setAnnotation2DActiveTool(annotation2DActiveTool === 'text' ? 'none' : 'text')}>
711
+ <Type className="h-4 w-4 mr-2" />
712
+ Text Box {annotation2DActiveTool === 'text' ? '(On)' : ''}
713
+ </DropdownMenuItem>
714
+ <DropdownMenuItem onClick={() => setAnnotation2DActiveTool(annotation2DActiveTool === 'cloud' ? 'none' : 'cloud')}>
715
+ <Cloud className="h-4 w-4 mr-2" />
716
+ Revision Cloud {annotation2DActiveTool === 'cloud' ? '(On)' : ''}
543
717
  </DropdownMenuItem>
544
- {measure2DResults.length > 0 && (
545
- <DropdownMenuItem onClick={clearMeasure2DResults}>
718
+ {hasAnnotations && (
719
+ <DropdownMenuItem onClick={clearAllAnnotations2D}>
546
720
  <Trash2 className="h-4 w-4 mr-2" />
547
- Clear Measurements
721
+ Clear All Annotations
548
722
  </DropdownMenuItem>
549
723
  )}
550
724
  <DropdownMenuSeparator />
@@ -602,13 +776,13 @@ export function Section2DPanel({
602
776
  {/* Drawing Canvas */}
603
777
  <div
604
778
  ref={containerRef}
605
- className={`relative flex-1 overflow-hidden bg-white dark:bg-zinc-950 rounded-b-lg ${measure2DMode ? 'cursor-crosshair' : 'cursor-grab active:cursor-grabbing'
606
- }`}
779
+ className={`relative flex-1 overflow-hidden bg-white dark:bg-zinc-950 rounded-b-lg ${cursorClass}`}
607
780
  onMouseDown={handleMouseDown}
608
781
  onMouseMove={handleMouseMove}
609
782
  onMouseUp={handleMouseUp}
610
783
  onMouseEnter={handleMouseEnter}
611
784
  onMouseLeave={handleMouseLeave}
785
+ onDoubleClick={handleDoubleClick}
612
786
  >
613
787
  {status === 'generating' && (
614
788
  <div className="absolute inset-0 flex flex-col items-center justify-center bg-background/80">
@@ -658,6 +832,15 @@ export function Section2DPanel({
658
832
  sectionAxis={sectionPlane.axis}
659
833
  isPinned={isPinned}
660
834
  cachedSheetTransformRef={cachedSheetTransformRef}
835
+ annotation2DActiveTool={annotation2DActiveTool}
836
+ annotation2DCursorPos={annotation2DCursorPos}
837
+ polygonAreaPoints={polygonArea2DPoints}
838
+ polygonAreaResults={polygonArea2DResults}
839
+ textAnnotations={textAnnotations2D}
840
+ textAnnotationEditing={textAnnotation2DEditing}
841
+ cloudAnnotationPoints={cloudAnnotation2DPoints}
842
+ cloudAnnotations={cloudAnnotations2D}
843
+ selectedAnnotation={selectedAnnotation2D}
661
844
  />
662
845
  {/* Subtle updating indicator - shows while regenerating without hiding the drawing */}
663
846
  {isRegenerating && (
@@ -669,6 +852,25 @@ export function Section2DPanel({
669
852
  </>
670
853
  )}
671
854
 
855
+ {/* Text Annotation Editor Overlay */}
856
+ {textAnnotation2DEditing && (() => {
857
+ const editingAnnotation = textAnnotations2D.find((a) => a.id === textAnnotation2DEditing);
858
+ if (!editingAnnotation) return null;
859
+ const scaleX = sectionPlane.axis === 'side' ? -viewTransform.scale : viewTransform.scale;
860
+ const scaleY = sectionPlane.axis === 'down' ? viewTransform.scale : -viewTransform.scale;
861
+ const screenX = editingAnnotation.position.x * scaleX + viewTransform.x;
862
+ const screenY = editingAnnotation.position.y * scaleY + viewTransform.y;
863
+ return (
864
+ <TextAnnotationEditor
865
+ annotation={editingAnnotation}
866
+ screenX={screenX}
867
+ screenY={screenY}
868
+ onConfirm={handleTextConfirm}
869
+ onCancel={handleTextCancel}
870
+ />
871
+ );
872
+ })()}
873
+
672
874
  {/* Measure mode tip - bottom right */}
673
875
  {measure2DMode && measure2DStart && (
674
876
  <div className="absolute bottom-2 right-2 pointer-events-none z-10">
@@ -679,6 +881,44 @@ export function Section2DPanel({
679
881
  </div>
680
882
  )}
681
883
 
884
+ {/* Polygon area tip */}
885
+ {annotation2DActiveTool === 'polygon-area' && (
886
+ <div className="absolute bottom-2 right-2 pointer-events-none z-10">
887
+ <div className="text-[10px] text-black bg-white/80 px-1.5 py-0.5 rounded">
888
+ {polygonArea2DPoints.length === 0 ? 'Click to place first vertex · Hold Shift to constrain' :
889
+ polygonArea2DPoints.length < 3 ? `${polygonArea2DPoints.length} vertices — need at least 3 · Shift = constrain` :
890
+ 'Double-click or click first vertex to close · Shift = constrain'}
891
+ </div>
892
+ </div>
893
+ )}
894
+
895
+ {/* Cloud tool tip */}
896
+ {annotation2DActiveTool === 'cloud' && (
897
+ <div className="absolute bottom-2 right-2 pointer-events-none z-10">
898
+ <div className="text-[10px] text-black bg-white/80 px-1.5 py-0.5 rounded">
899
+ {cloudAnnotation2DPoints.length === 0 ? 'Click to place first corner' : 'Click to place second corner · Shift = square'}
900
+ </div>
901
+ </div>
902
+ )}
903
+
904
+ {/* Text tool tip */}
905
+ {annotation2DActiveTool === 'text' && !textAnnotation2DEditing && (
906
+ <div className="absolute bottom-2 right-2 pointer-events-none z-10">
907
+ <div className="text-[10px] text-black bg-white/80 px-1.5 py-0.5 rounded">
908
+ Click to place text box
909
+ </div>
910
+ </div>
911
+ )}
912
+
913
+ {/* Selection tip */}
914
+ {selectedAnnotation2D && annotation2DActiveTool === 'none' && (
915
+ <div className="absolute bottom-2 right-2 pointer-events-none z-10">
916
+ <div className="text-[10px] text-black bg-white/80 px-1.5 py-0.5 rounded">
917
+ {selectedAnnotation2D.type === 'text' ? 'Del = delete · Drag to move · Double-click to edit' : 'Del = delete · Drag to move'} · Esc = deselect
918
+ </div>
919
+ </div>
920
+ )}
921
+
682
922
  {status === 'ready' && drawing && drawing.cutPolygons.length === 0 && (!drawing.lines || drawing.lines.length === 0) && (
683
923
  <div className="absolute inset-0 flex items-center justify-center">
684
924
  <div className="text-center text-muted-foreground">