@ifc-lite/viewer 1.7.0 → 1.9.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 (95) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CusgkT03.js} +1 -1
  3. package/dist/assets/browser-BXNIkE8a.js +694 -0
  4. package/dist/assets/emscripten-module-BTRCZGcB.wasm +0 -0
  5. package/dist/assets/emscripten-module-CGIn_cMh.wasm +0 -0
  6. package/dist/assets/emscripten-module-DYvzWiHh.wasm +0 -0
  7. package/dist/assets/emscripten-module-NWak2PoB.wasm +0 -0
  8. package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +1 -0
  9. package/dist/assets/esbuild-COv63sf-.js +1 -0
  10. package/dist/assets/esbuild-Cpd5nU_H.wasm +0 -0
  11. package/dist/assets/ffi-DlhRHxHv.js +1 -0
  12. package/dist/assets/index-6Mr3byM-.js +216 -0
  13. package/dist/assets/index-CGbokkQ9.css +1 -0
  14. package/dist/assets/index-huvR-kGC.js +98305 -0
  15. package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +6 -0
  16. package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-DsHOKdgD.js} +1 -1
  17. package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-Bd73HXn-.js} +1 -1
  18. package/dist/index.html +12 -3
  19. package/index.html +10 -1
  20. package/package.json +30 -21
  21. package/src/App.tsx +6 -1
  22. package/src/components/ui/dialog.tsx +8 -6
  23. package/src/components/viewer/CodeEditor.tsx +309 -0
  24. package/src/components/viewer/CommandPalette.tsx +597 -0
  25. package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
  26. package/src/components/viewer/EntityContextMenu.tsx +47 -20
  27. package/src/components/viewer/ExportDialog.tsx +166 -17
  28. package/src/components/viewer/HierarchyPanel.tsx +3 -1
  29. package/src/components/viewer/LensPanel.tsx +848 -85
  30. package/src/components/viewer/MainToolbar.tsx +145 -84
  31. package/src/components/viewer/ScriptPanel.tsx +416 -0
  32. package/src/components/viewer/Section2DPanel.tsx +269 -29
  33. package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
  34. package/src/components/viewer/ViewerLayout.tsx +63 -11
  35. package/src/components/viewer/Viewport.tsx +58 -23
  36. package/src/components/viewer/ViewportContainer.tsx +2 -0
  37. package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
  38. package/src/components/viewer/hierarchy/types.ts +1 -1
  39. package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
  40. package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
  41. package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
  42. package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
  43. package/src/components/viewer/tools/computePolygonArea.ts +72 -0
  44. package/src/components/viewer/useGeometryStreaming.ts +25 -5
  45. package/src/hooks/ids/idsExportService.ts +1 -1
  46. package/src/hooks/useAnnotation2D.ts +551 -0
  47. package/src/hooks/useDrawingExport.ts +83 -1
  48. package/src/hooks/useKeyboardShortcuts.ts +114 -14
  49. package/src/hooks/useLens.ts +40 -55
  50. package/src/hooks/useLensDiscovery.ts +46 -0
  51. package/src/hooks/useModelSelection.ts +5 -22
  52. package/src/hooks/useSandbox.ts +113 -0
  53. package/src/index.css +7 -1
  54. package/src/lib/lens/adapter.ts +127 -1
  55. package/src/lib/lists/columnToAutoColor.ts +33 -0
  56. package/src/lib/recent-files.ts +122 -0
  57. package/src/lib/scripts/persistence.ts +132 -0
  58. package/src/lib/scripts/templates/bim-globals.d.ts +111 -0
  59. package/src/lib/scripts/templates/data-quality-audit.ts +149 -0
  60. package/src/lib/scripts/templates/envelope-check.ts +164 -0
  61. package/src/lib/scripts/templates/federation-compare.ts +189 -0
  62. package/src/lib/scripts/templates/fire-safety-check.ts +161 -0
  63. package/src/lib/scripts/templates/mep-equipment-schedule.ts +175 -0
  64. package/src/lib/scripts/templates/quantity-takeoff.ts +145 -0
  65. package/src/lib/scripts/templates/reset-view.ts +6 -0
  66. package/src/lib/scripts/templates/space-validation.ts +189 -0
  67. package/src/lib/scripts/templates/tsconfig.json +13 -0
  68. package/src/lib/scripts/templates.ts +86 -0
  69. package/src/sdk/BimProvider.tsx +50 -0
  70. package/src/sdk/adapters/export-adapter.ts +283 -0
  71. package/src/sdk/adapters/lens-adapter.ts +44 -0
  72. package/src/sdk/adapters/model-adapter.ts +32 -0
  73. package/src/sdk/adapters/model-compat.ts +80 -0
  74. package/src/sdk/adapters/mutate-adapter.ts +45 -0
  75. package/src/sdk/adapters/query-adapter.ts +241 -0
  76. package/src/sdk/adapters/selection-adapter.ts +29 -0
  77. package/src/sdk/adapters/spatial-adapter.ts +37 -0
  78. package/src/sdk/adapters/types.ts +11 -0
  79. package/src/sdk/adapters/viewer-adapter.ts +103 -0
  80. package/src/sdk/adapters/visibility-adapter.ts +61 -0
  81. package/src/sdk/local-backend.ts +144 -0
  82. package/src/sdk/useBimHost.ts +69 -0
  83. package/src/store/constants.ts +10 -2
  84. package/src/store/index.ts +28 -2
  85. package/src/store/resolveEntityRef.ts +44 -0
  86. package/src/store/slices/drawing2DSlice.ts +321 -0
  87. package/src/store/slices/lensSlice.ts +46 -4
  88. package/src/store/slices/pinboardSlice.ts +171 -42
  89. package/src/store/slices/scriptSlice.ts +218 -0
  90. package/src/store/slices/uiSlice.ts +2 -0
  91. package/src/store.ts +3 -0
  92. package/tsconfig.json +5 -2
  93. package/vite.config.ts +8 -0
  94. package/dist/assets/index-dgdgiQ9p.js +0 -75456
  95. package/dist/assets/index-yTqs8kgX.css +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 } 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">
@@ -0,0 +1,112 @@
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
+ * Inline text editor overlay for text annotations on the 2D drawing.
7
+ * Positioned absolutely over the canvas at the annotation's screen coordinates.
8
+ */
9
+
10
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
11
+ import type { TextAnnotation2D } from '@/store/slices/drawing2DSlice';
12
+
13
+ interface TextAnnotationEditorProps {
14
+ /** The text annotation being edited */
15
+ annotation: TextAnnotation2D;
16
+ /** Screen position (top-left of the editor) */
17
+ screenX: number;
18
+ screenY: number;
19
+ /** Called with the new text when user confirms (Enter) */
20
+ onConfirm: (id: string, text: string) => void;
21
+ /** Called when user cancels (Escape) or submits empty text */
22
+ onCancel: (id: string) => void;
23
+ }
24
+
25
+ export function TextAnnotationEditor({
26
+ annotation,
27
+ screenX,
28
+ screenY,
29
+ onConfirm,
30
+ onCancel,
31
+ }: TextAnnotationEditorProps): React.ReactElement {
32
+ const [text, setText] = useState(annotation.text);
33
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
34
+ // Guard against blur firing during the initial click that created this editor.
35
+ // Without this, the mouseup from the placement click can steal focus from the
36
+ // textarea before the user has a chance to type, causing an immediate cancel.
37
+ const readyRef = useRef(false);
38
+
39
+ // Auto-focus on mount, but defer slightly so the originating mouseup
40
+ // from the placement click doesn't immediately steal focus / trigger blur.
41
+ useEffect(() => {
42
+ const timer = requestAnimationFrame(() => {
43
+ textareaRef.current?.focus();
44
+ // Mark ready after focus is established so blur handler is enabled
45
+ readyRef.current = true;
46
+ });
47
+ return () => cancelAnimationFrame(timer);
48
+ }, []);
49
+
50
+ const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
51
+ if (e.key === 'Enter' && !e.shiftKey) {
52
+ e.preventDefault();
53
+ const trimmed = text.trim();
54
+ if (trimmed) {
55
+ onConfirm(annotation.id, trimmed);
56
+ } else {
57
+ onCancel(annotation.id);
58
+ }
59
+ } else if (e.key === 'Escape') {
60
+ e.preventDefault();
61
+ onCancel(annotation.id);
62
+ }
63
+ // Stop propagation so the canvas doesn't receive these events
64
+ e.stopPropagation();
65
+ }, [text, annotation.id, onConfirm, onCancel]);
66
+
67
+ const handleBlur = useCallback(() => {
68
+ // Ignore blur events that fire before the editor is fully ready
69
+ // (e.g. from the originating click's mouseup stealing focus)
70
+ if (!readyRef.current) return;
71
+
72
+ const trimmed = text.trim();
73
+ if (trimmed) {
74
+ onConfirm(annotation.id, trimmed);
75
+ } else {
76
+ onCancel(annotation.id);
77
+ }
78
+ }, [text, annotation.id, onConfirm, onCancel]);
79
+
80
+ return (
81
+ <div
82
+ className="absolute z-20 pointer-events-auto"
83
+ style={{
84
+ left: screenX,
85
+ top: screenY,
86
+ }}
87
+ // Prevent mousedown from propagating to canvas (which would place another annotation)
88
+ onMouseDown={(e) => e.stopPropagation()}
89
+ onClick={(e) => e.stopPropagation()}
90
+ >
91
+ <textarea
92
+ ref={textareaRef}
93
+ value={text}
94
+ onChange={(e) => setText(e.target.value)}
95
+ onKeyDown={handleKeyDown}
96
+ onBlur={handleBlur}
97
+ placeholder="Type annotation text..."
98
+ className="min-w-[120px] max-w-[300px] min-h-[32px] px-2 py-1 text-sm border-2 border-blue-500 rounded resize shadow-lg outline-none"
99
+ rows={2}
100
+ style={{
101
+ fontSize: annotation.fontSize,
102
+ backgroundColor: '#ffffff',
103
+ color: '#000000',
104
+ caretColor: '#000000',
105
+ }}
106
+ />
107
+ <div className="text-[10px] text-muted-foreground mt-0.5 bg-white/80 px-1 rounded">
108
+ Enter to confirm · Shift+Enter for newline · Esc to cancel
109
+ </div>
110
+ </div>
111
+ );
112
+ }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { useCallback, useEffect, useRef, useState } from 'react';
6
6
  import { Panel, Group as PanelGroup, Separator as PanelResizeHandle } from 'react-resizable-panels';
7
+ import type { PanelImperativeHandle } from 'react-resizable-panels';
7
8
  import { TooltipProvider } from '@/components/ui/tooltip';
8
9
  import { MainToolbar } from './MainToolbar';
9
10
  import { HierarchyPanel } from './HierarchyPanel';
@@ -19,6 +20,8 @@ import { BCFPanel } from './BCFPanel';
19
20
  import { IDSPanel } from './IDSPanel';
20
21
  import { LensPanel } from './LensPanel';
21
22
  import { ListPanel } from './lists/ListPanel';
23
+ import { ScriptPanel } from './ScriptPanel';
24
+ import { CommandPalette } from './CommandPalette';
22
25
 
23
26
  const BOTTOM_PANEL_MIN_HEIGHT = 120;
24
27
  const BOTTOM_PANEL_DEFAULT_HEIGHT = 300;
@@ -29,6 +32,21 @@ export function ViewerLayout() {
29
32
  useKeyboardShortcuts();
30
33
  const shortcutsDialog = useKeyboardShortcutsDialog();
31
34
 
35
+ // Command palette state
36
+ const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
37
+
38
+ // Ctrl+K / Cmd+K to open command palette
39
+ useEffect(() => {
40
+ const handler = (e: globalThis.KeyboardEvent) => {
41
+ if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
42
+ e.preventDefault();
43
+ setCommandPaletteOpen((prev) => !prev);
44
+ }
45
+ };
46
+ window.addEventListener('keydown', handler);
47
+ return () => window.removeEventListener('keydown', handler);
48
+ }, []);
49
+
32
50
  // Initialize theme on mount
33
51
  const theme = useViewerStore((s) => s.theme);
34
52
  const isMobile = useViewerStore((s) => s.isMobile);
@@ -45,6 +63,27 @@ export function ViewerLayout() {
45
63
  const setListPanelVisible = useViewerStore((s) => s.setListPanelVisible);
46
64
  const lensPanelVisible = useViewerStore((s) => s.lensPanelVisible);
47
65
  const setLensPanelVisible = useViewerStore((s) => s.setLensPanelVisible);
66
+ const scriptPanelVisible = useViewerStore((s) => s.scriptPanelVisible);
67
+ const setScriptPanelVisible = useViewerStore((s) => s.setScriptPanelVisible);
68
+
69
+ // Panel refs for programmatic collapse/expand (command palette, keyboard shortcuts)
70
+ const leftPanelRef = useRef<PanelImperativeHandle>(null);
71
+ const rightPanelRef = useRef<PanelImperativeHandle>(null);
72
+
73
+ // Sync store state → Panel collapse/expand on desktop
74
+ useEffect(() => {
75
+ const panel = leftPanelRef.current;
76
+ if (!panel) return;
77
+ if (leftPanelCollapsed && !panel.isCollapsed()) panel.collapse();
78
+ else if (!leftPanelCollapsed && panel.isCollapsed()) panel.expand();
79
+ }, [leftPanelCollapsed]);
80
+
81
+ useEffect(() => {
82
+ const panel = rightPanelRef.current;
83
+ if (!panel) return;
84
+ if (rightPanelCollapsed && !panel.isCollapsed()) panel.collapse();
85
+ else if (!rightPanelCollapsed && panel.isCollapsed()) panel.expand();
86
+ }, [rightPanelCollapsed]);
48
87
 
49
88
  // Bottom panel resize state (pixel height, persisted in ref to avoid re-renders during drag)
50
89
  const [bottomHeight, setBottomHeight] = useState(BOTTOM_PANEL_DEFAULT_HEIGHT);
@@ -113,12 +152,7 @@ export function ViewerLayout() {
113
152
  return () => window.removeEventListener('resize', checkMobile);
114
153
  }, [setIsMobile, setLeftPanelCollapsed, setRightPanelCollapsed]);
115
154
 
116
- // Initialize theme on mount and sync with store
117
- useEffect(() => {
118
- const currentTheme = useViewerStore.getState().theme;
119
- document.documentElement.classList.toggle('dark', currentTheme === 'dark');
120
- }, []);
121
-
155
+ // Keep DOM class in sync when theme changes (initial class is set by inline script in index.html)
122
156
  useEffect(() => {
123
157
  document.documentElement.classList.toggle('dark', theme === 'dark');
124
158
  }, [theme]);
@@ -133,6 +167,7 @@ export function ViewerLayout() {
133
167
  {/* Global Overlays */}
134
168
  <EntityContextMenu />
135
169
  <HoverTooltip />
170
+ <CommandPalette open={commandPaletteOpen} onOpenChange={setCommandPaletteOpen} />
136
171
 
137
172
  {/* Main Toolbar */}
138
173
  <MainToolbar onShowShortcuts={shortcutsDialog.toggle} />
@@ -150,6 +185,11 @@ export function ViewerLayout() {
150
185
  minSize={10}
151
186
  collapsible
152
187
  collapsedSize={0}
188
+ panelRef={leftPanelRef}
189
+ onResize={() => {
190
+ const collapsed = leftPanelRef.current?.isCollapsed() ?? false;
191
+ if (collapsed !== leftPanelCollapsed) setLeftPanelCollapsed(collapsed);
192
+ }}
153
193
  >
154
194
  <div className="h-full w-full overflow-hidden">
155
195
  <HierarchyPanel />
@@ -174,6 +214,11 @@ export function ViewerLayout() {
174
214
  minSize={15}
175
215
  collapsible
176
216
  collapsedSize={0}
217
+ panelRef={rightPanelRef}
218
+ onResize={() => {
219
+ const collapsed = rightPanelRef.current?.isCollapsed() ?? false;
220
+ if (collapsed !== rightPanelCollapsed) setRightPanelCollapsed(collapsed);
221
+ }}
177
222
  >
178
223
  <div className="h-full w-full overflow-hidden">
179
224
  {lensPanelVisible ? (
@@ -190,8 +235,8 @@ export function ViewerLayout() {
190
235
  </PanelGroup>
191
236
  </div>
192
237
 
193
- {/* Bottom Panel - Lists (custom resizable, outside PanelGroup) */}
194
- {listPanelVisible && (
238
+ {/* Bottom Panel - Lists or Script (custom resizable, outside PanelGroup) */}
239
+ {(listPanelVisible || scriptPanelVisible) && (
195
240
  <div style={{ height: bottomHeight, flexShrink: 0 }} className="relative">
196
241
  {/* Drag handle */}
197
242
  <div
@@ -199,7 +244,11 @@ export function ViewerLayout() {
199
244
  onMouseDown={handleResizeStart}
200
245
  />
201
246
  <div className="h-full w-full overflow-hidden border-t pt-1.5">
202
- <ListPanel onClose={() => setListPanelVisible(false)} />
247
+ {scriptPanelVisible ? (
248
+ <ScriptPanel onClose={() => setScriptPanelVisible(false)} />
249
+ ) : (
250
+ <ListPanel onClose={() => setListPanelVisible(false)} />
251
+ )}
203
252
  </div>
204
253
  </div>
205
254
  )}
@@ -240,12 +289,13 @@ export function ViewerLayout() {
240
289
  <div className="absolute inset-x-0 bottom-0 h-[50vh] bg-background border-t rounded-t-xl shadow-xl z-40 animate-in slide-in-from-bottom">
241
290
  <div className="flex items-center justify-between p-2 border-b">
242
291
  <span className="font-medium text-sm">
243
- {listPanelVisible ? 'Lists' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Properties'}
292
+ {scriptPanelVisible ? 'Script' : listPanelVisible ? 'Lists' : lensPanelVisible ? 'Lens' : idsPanelVisible ? 'IDS Validation' : bcfPanelVisible ? 'BCF Issues' : 'Properties'}
244
293
  </span>
245
294
  <button
246
295
  className="p-1 hover:bg-muted rounded"
247
296
  onClick={() => {
248
297
  setRightPanelCollapsed(true);
298
+ if (scriptPanelVisible) setScriptPanelVisible(false);
249
299
  if (listPanelVisible) setListPanelVisible(false);
250
300
  if (bcfPanelVisible) setBcfPanelVisible(false);
251
301
  if (lensPanelVisible) setLensPanelVisible(false);
@@ -259,7 +309,9 @@ export function ViewerLayout() {
259
309
  </button>
260
310
  </div>
261
311
  <div className="h-[calc(50vh-48px)] overflow-auto">
262
- {listPanelVisible ? (
312
+ {scriptPanelVisible ? (
313
+ <ScriptPanel onClose={() => setScriptPanelVisible(false)} />
314
+ ) : listPanelVisible ? (
263
315
  <ListPanel onClose={() => setListPanelVisible(false)} />
264
316
  ) : lensPanelVisible ? (
265
317
  <LensPanel onClose={() => setLensPanelVisible(false)} />