@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
@@ -9,7 +9,7 @@
9
9
  import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
10
10
  import { Renderer } from '@ifc-lite/renderer';
11
11
  import type { MeshData, CoordinateInfo } from '@ifc-lite/geometry';
12
- import { useViewerStore, type MeasurePoint, type SnapVisualization } from '@/store';
12
+ import { useViewerStore, resolveEntityRef, type MeasurePoint, type SnapVisualization } from '@/store';
13
13
  import {
14
14
  useSelectionState,
15
15
  useVisibilityState,
@@ -53,8 +53,8 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
53
53
  // Selection state
54
54
  const { selectedEntityId, selectedEntityIds, setSelectedEntityId, setSelectedEntity, toggleSelection, models } = useSelectionState();
55
55
  const selectedEntity = useViewerStore((s) => s.selectedEntity);
56
- // Get the bulletproof store-based resolver (more reliable than singleton)
57
- const resolveGlobalIdFromModels = useViewerStore((s) => s.resolveGlobalIdFromModels);
56
+ const addEntityToSelection = useViewerStore((s) => s.addEntityToSelection);
57
+ const toggleEntitySelection = useViewerStore((s) => s.toggleEntitySelection);
58
58
 
59
59
  // Sync selectedEntityId with model-aware selectedEntity for PropertiesPanel
60
60
  useModelSelection();
@@ -76,9 +76,14 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
76
76
 
77
77
  // Helper to handle pick result and set selection properly
78
78
  // IMPORTANT: pickResult.expressId is now a globalId (transformed at load time)
79
- // We use the store-based resolver to find (modelId, originalExpressId)
80
- // This is more reliable than the singleton registry which can have bundling issues
79
+ // resolveEntityRef is the single source of truth for globalId → EntityRef
81
80
  const handlePickForSelection = useCallback((pickResult: import('@ifc-lite/renderer').PickResult | null) => {
81
+ // Normal click clears multi-select set (fresh single-selection)
82
+ const currentState = useViewerStore.getState();
83
+ if (currentState.selectedEntitiesSet.size > 0) {
84
+ useViewerStore.setState({ selectedEntitiesSet: new Set(), selectedEntityIds: new Set() });
85
+ }
86
+
82
87
  if (!pickResult) {
83
88
  setSelectedEntityId(null);
84
89
  return;
@@ -89,29 +94,58 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
89
94
  // Set globalId for renderer (highlighting uses globalIds directly)
90
95
  setSelectedEntityId(globalId);
91
96
 
92
- // Resolve globalId -> (modelId, originalExpressId) for property panel
93
- // Use store-based resolver instead of singleton for reliability
94
- const resolved = resolveGlobalIdFromModels(globalId);
95
- if (resolved) {
96
- // Set the EntityRef with ORIGINAL expressId (for property lookup in IfcDataStore)
97
- setSelectedEntity({ modelId: resolved.modelId, expressId: resolved.expressId });
98
- } else {
99
- // Fallback for single-model mode (offset = 0, globalId = expressId)
100
- // Try to find model from the old modelIndex if available
101
- if (pickResult.modelIndex !== undefined && modelIndexToId) {
102
- const modelId = modelIndexToId.get(pickResult.modelIndex);
103
- if (modelId) {
104
- setSelectedEntity({ modelId, expressId: globalId });
105
- }
106
- }
107
- }
108
- }, [setSelectedEntityId, setSelectedEntity, resolveGlobalIdFromModels, modelIndexToId]);
97
+ // Resolve globalId EntityRef for property panel (single source of truth, never null)
98
+ setSelectedEntity(resolveEntityRef(globalId));
99
+ }, [setSelectedEntityId, setSelectedEntity]);
109
100
 
110
101
  // Ref to always access latest handlePickForSelection from event handlers
111
102
  // (useMouseControls/useTouchControls capture this at effect setup time)
112
103
  const handlePickForSelectionRef = useRef(handlePickForSelection);
113
104
  useEffect(() => { handlePickForSelectionRef.current = handlePickForSelection; }, [handlePickForSelection]);
114
105
 
106
+ // Multi-select handler: Ctrl+Click adds/removes from multi-selection
107
+ // Properly populates both selectedEntitiesSet (multi-model) and selectedEntityIds (legacy)
108
+ const handleMultiSelect = useCallback((globalId: number) => {
109
+ // Resolve globalId → EntityRef (single source of truth, never null)
110
+ const entityRef = resolveEntityRef(globalId);
111
+
112
+ // If this is the first Ctrl+click and there's already a single-selected entity,
113
+ // add it to the multi-select set first (so it's not lost)
114
+ const state = useViewerStore.getState();
115
+ if (state.selectedEntitiesSet.size === 0 && state.selectedEntity) {
116
+ addEntityToSelection(state.selectedEntity);
117
+ // Also seed legacy selectedEntityIds with previous entity's globalId
118
+ // so the renderer highlights both the old and new entity
119
+ if (state.selectedEntityId !== null) {
120
+ toggleSelection(state.selectedEntityId);
121
+ }
122
+ }
123
+
124
+ // Toggle the clicked entity in multi-select
125
+ toggleEntitySelection(entityRef);
126
+
127
+ // Also sync legacy selectedEntityIds and selectedEntityId
128
+ toggleSelection(globalId);
129
+
130
+ // Read post-toggle state to keep renderer highlighting in sync:
131
+ // If the entity was toggled OFF, don't force-highlight it.
132
+ const updated = useViewerStore.getState();
133
+ if (updated.selectedEntityIds.has(globalId)) {
134
+ // Entity was toggled ON — highlight it
135
+ setSelectedEntityId(globalId);
136
+ } else if (updated.selectedEntityIds.size > 0) {
137
+ // Entity was toggled OFF but others remain — highlight the last remaining
138
+ const remaining = Array.from(updated.selectedEntityIds);
139
+ setSelectedEntityId(remaining[remaining.length - 1]);
140
+ } else {
141
+ // Nothing left selected
142
+ setSelectedEntityId(null);
143
+ }
144
+ }, [addEntityToSelection, toggleEntitySelection, toggleSelection, setSelectedEntityId]);
145
+
146
+ const handleMultiSelectRef = useRef(handleMultiSelect);
147
+ useEffect(() => { handleMultiSelectRef.current = handleMultiSelect; }, [handleMultiSelect]);
148
+
115
149
  // Visibility state - use computedIsolatedIds from parent (includes storey selection)
116
150
  // Fall back to store isolation if computedIsolatedIds is not provided
117
151
  const { hiddenEntities, isolatedEntities: storeIsolatedEntities } = useVisibilityState();
@@ -668,7 +702,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
668
702
  updateConstraintActiveAxis,
669
703
  updateMeasurementScreenCoords,
670
704
  updateCameraRotationRealtime,
671
- toggleSelection,
705
+ toggleSelection: (entityId: number) => handleMultiSelectRef.current(entityId),
672
706
  calculateScale,
673
707
  getPickOptions,
674
708
  hasPendingMeasurements,
@@ -749,6 +783,7 @@ export function Viewport({ geometry, coordinateInfo, computedIsolatedIds, modelI
749
783
  geometryBoundsRef,
750
784
  pendingColorUpdates,
751
785
  clearPendingColorUpdates,
786
+ clearColorRef,
752
787
  });
753
788
 
754
789
  useRenderUpdates({
@@ -317,6 +317,7 @@ export function ViewportContainer() {
317
317
  return (
318
318
  <div
319
319
  className="relative h-full w-full bg-white dark:bg-black text-zinc-900 dark:text-zinc-50 overflow-hidden"
320
+ data-viewport
320
321
  onDragOver={handleDragOver}
321
322
  onDragLeave={handleDragLeave}
322
323
  onDrop={handleDrop}
@@ -556,6 +557,7 @@ export function ViewportContainer() {
556
557
  return (
557
558
  <div
558
559
  className="relative h-full w-full bg-zinc-50 dark:bg-black overflow-hidden"
560
+ data-viewport
559
561
  onDragOver={handleDragOver}
560
562
  onDragLeave={handleDragLeave}
561
563
  onDrop={handleDrop}
@@ -267,7 +267,7 @@ export function HierarchyNode({
267
267
  {/* Name */}
268
268
  <span className={cn(
269
269
  'flex-1 text-sm truncate ml-1.5',
270
- isSpatialContainer(node.type)
270
+ isSpatialContainer(node.type) || node.type === 'IfcBuildingStorey' || node.type === 'unified-storey' || node.type === 'type-group'
271
271
  ? 'font-medium text-zinc-900 dark:text-zinc-100'
272
272
  : 'text-zinc-700 dark:text-zinc-300',
273
273
  nodeHidden && 'line-through decoration-zinc-400 dark:decoration-zinc-600'
@@ -10,7 +10,7 @@ export type NodeType =
10
10
  | 'IfcSite' // Site node
11
11
  | 'IfcBuilding' // Building node
12
12
  | 'IfcBuildingStorey' // Storey node
13
- | 'type-group' // IFC type grouping header (e.g., "IfcWall (47)")
13
+ | 'type-group' // IFC class grouping header (e.g., "IfcWall (47)")
14
14
  | 'element'; // Individual element
15
15
 
16
16
  export interface TreeNode {
@@ -12,11 +12,13 @@
12
12
 
13
13
  import React, { useCallback, useMemo, useRef, useState } from 'react';
14
14
  import { useVirtualizer } from '@tanstack/react-virtual';
15
- import { ArrowUp, ArrowDown, Search } from 'lucide-react';
15
+ import { ArrowUp, ArrowDown, Search, Palette } from 'lucide-react';
16
16
  import { Input } from '@/components/ui/input';
17
17
  import { useViewerStore } from '@/store';
18
- import type { ListResult, ListRow, CellValue } from '@ifc-lite/lists';
18
+ import type { ListResult, ListRow, CellValue, ColumnDefinition } from '@ifc-lite/lists';
19
19
  import { cn } from '@/lib/utils';
20
+ import { columnToAutoColor } from '@/lib/lists/columnToAutoColor';
21
+ import { AUTO_COLOR_FROM_LIST_ID } from '@/store/slices/lensSlice';
20
22
 
21
23
  interface ListResultsTableProps {
22
24
  result: ListResult;
@@ -31,6 +33,9 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
31
33
  const setSelectedEntityId = useViewerStore((s) => s.setSelectedEntityId);
32
34
  const setSelectedEntity = useViewerStore((s) => s.setSelectedEntity);
33
35
  const selectedEntityId = useViewerStore((s) => s.selectedEntityId);
36
+ const activateAutoColorFromColumn = useViewerStore((s) => s.activateAutoColorFromColumn);
37
+ const activeLensId = useViewerStore((s) => s.activeLensId);
38
+ const [colorByColIdx, setColorByColIdx] = useState<number | null>(null);
34
39
 
35
40
  // Filter rows by search query
36
41
  const filteredRows = useMemo(() => {
@@ -62,6 +67,13 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
62
67
  }
63
68
  }, [sortCol]);
64
69
 
70
+ const handleColorByColumn = useCallback((col: ColumnDefinition, colIdx: number) => {
71
+ const spec = columnToAutoColor(col);
72
+ const label = col.label ?? col.propertyName;
73
+ activateAutoColorFromColumn(spec, label);
74
+ setColorByColIdx(colIdx);
75
+ }, [activateAutoColorFromColumn]);
76
+
65
77
  const handleRowClick = useCallback((row: ListRow) => {
66
78
  setSelectedEntity({ modelId: row.modelId, expressId: row.entityId });
67
79
  // For single-model, selectedEntityId is the expressId
@@ -108,23 +120,45 @@ export function ListResultsTable({ result }: ListResultsTableProps) {
108
120
  <div style={{ minWidth: totalWidth }}>
109
121
  {/* Header */}
110
122
  <div className="flex sticky top-0 bg-muted/80 backdrop-blur-sm border-b z-10">
111
- {result.columns.map((col, colIdx) => (
112
- <button
113
- key={col.id}
114
- className="flex items-center gap-1 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border-r border-border/50 shrink-0"
115
- style={{ width: columnWidths[colIdx] }}
116
- onClick={() => handleHeaderClick(colIdx)}
117
- >
118
- <span className="truncate">
119
- {col.label ?? col.propertyName}
120
- </span>
121
- {sortCol === colIdx && (
122
- sortDir === 'asc'
123
- ? <ArrowUp className="h-3 w-3 shrink-0" />
124
- : <ArrowDown className="h-3 w-3 shrink-0" />
125
- )}
126
- </button>
127
- ))}
123
+ {result.columns.map((col, colIdx) => {
124
+ const isColoredCol = activeLensId === AUTO_COLOR_FROM_LIST_ID && colorByColIdx === colIdx;
125
+ return (
126
+ <div
127
+ key={col.id}
128
+ className={cn(
129
+ 'flex items-center gap-0.5 px-2 py-1.5 text-xs font-medium text-muted-foreground border-r border-border/50 shrink-0 group/col',
130
+ isColoredCol && 'bg-primary/10',
131
+ )}
132
+ style={{ width: columnWidths[colIdx] }}
133
+ >
134
+ <button
135
+ className="flex items-center gap-1 flex-1 min-w-0 hover:text-foreground"
136
+ onClick={() => handleHeaderClick(colIdx)}
137
+ >
138
+ <span className="truncate">
139
+ {col.label ?? col.propertyName}
140
+ </span>
141
+ {sortCol === colIdx && (
142
+ sortDir === 'asc'
143
+ ? <ArrowUp className="h-3 w-3 shrink-0" />
144
+ : <ArrowDown className="h-3 w-3 shrink-0" />
145
+ )}
146
+ </button>
147
+ <button
148
+ className={cn(
149
+ 'shrink-0 p-0.5 rounded-sm transition-opacity',
150
+ isColoredCol
151
+ ? 'text-primary opacity-100'
152
+ : 'opacity-0 group-hover/col:opacity-100 text-muted-foreground hover:text-primary',
153
+ )}
154
+ onClick={(e) => { e.stopPropagation(); handleColorByColumn(col, colIdx); }}
155
+ title={`Color by ${col.label ?? col.propertyName}`}
156
+ >
157
+ <Palette className="h-3 w-3" />
158
+ </button>
159
+ </div>
160
+ );
161
+ })}
128
162
  </div>
129
163
 
130
164
  {/* Virtualized rows */}
@@ -0,0 +1,118 @@
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 { describe, it } from 'node:test';
6
+ import assert from 'node:assert';
7
+ import { generateCloudArcs, generateCloudSVGPath } from './cloudPathGenerator.js';
8
+
9
+ describe('generateCloudArcs', () => {
10
+ it('generates arcs for a simple rectangle', () => {
11
+ const arcs = generateCloudArcs(
12
+ { x: 0, y: 0 },
13
+ { x: 4, y: 2 },
14
+ 0.5 // arcRadius
15
+ );
16
+ // Should produce arcs along all 4 edges
17
+ assert.ok(arcs.length > 0, 'Should generate at least some arcs');
18
+ });
19
+
20
+ it('produces arcs proportional to edge length', () => {
21
+ // A 4x2 rectangle with arcRadius=1 (diameter=2)
22
+ // Top edge (4 units): ~2 arcs
23
+ // Right edge (2 units): ~1 arc
24
+ // Bottom edge (4 units): ~2 arcs
25
+ // Left edge (2 units): ~1 arc
26
+ // Total: ~6 arcs
27
+ const arcs = generateCloudArcs(
28
+ { x: 0, y: 0 },
29
+ { x: 4, y: 2 },
30
+ 1.0
31
+ );
32
+ assert.strictEqual(arcs.length, 6);
33
+ });
34
+
35
+ it('handles equal corners (zero-size rectangle) gracefully', () => {
36
+ const arcs = generateCloudArcs(
37
+ { x: 1, y: 1 },
38
+ { x: 1, y: 1 },
39
+ 0.5
40
+ );
41
+ // All edges are zero-length, should skip them
42
+ assert.strictEqual(arcs.length, 0);
43
+ });
44
+
45
+ it('handles reversed corner order', () => {
46
+ const arcs1 = generateCloudArcs({ x: 0, y: 0 }, { x: 4, y: 2 }, 1.0);
47
+ const arcs2 = generateCloudArcs({ x: 4, y: 2 }, { x: 0, y: 0 }, 1.0);
48
+ // Should produce same number of arcs regardless of corner order
49
+ assert.strictEqual(arcs1.length, arcs2.length);
50
+ });
51
+
52
+ it('each arc has valid start, end, center, and radius', () => {
53
+ const arcs = generateCloudArcs({ x: 0, y: 0 }, { x: 2, y: 2 }, 0.5);
54
+ for (const arc of arcs) {
55
+ assert.ok(isFinite(arc.start.x) && isFinite(arc.start.y), 'Start should be finite');
56
+ assert.ok(isFinite(arc.end.x) && isFinite(arc.end.y), 'End should be finite');
57
+ assert.ok(isFinite(arc.center.x) && isFinite(arc.center.y), 'Center should be finite');
58
+ assert.ok(arc.radius > 0, 'Radius should be positive');
59
+ assert.ok(isFinite(arc.startAngle), 'Start angle should be finite');
60
+ assert.ok(isFinite(arc.endAngle), 'End angle should be finite');
61
+ }
62
+ });
63
+ });
64
+
65
+ describe('generateCloudSVGPath', () => {
66
+ it('generates a valid SVG path string', () => {
67
+ const path = generateCloudSVGPath(
68
+ { x: 0, y: 0 },
69
+ { x: 4, y: 2 },
70
+ 1.0,
71
+ (x) => x,
72
+ (y) => y,
73
+ );
74
+ assert.ok(path.startsWith('M'), 'Path should start with M command');
75
+ assert.ok(path.includes('A'), 'Path should contain arc commands');
76
+ assert.ok(path.endsWith('Z'), 'Path should end with Z (close)');
77
+ });
78
+
79
+ it('uses clockwise sweep flag (1) for outward-bulging arcs', () => {
80
+ const path = generateCloudSVGPath(
81
+ { x: 0, y: 0 },
82
+ { x: 4, y: 2 },
83
+ 1.0,
84
+ (x) => x,
85
+ (y) => y,
86
+ );
87
+ // All arcs should use sweep-flag=1 (clockwise = outward bulge)
88
+ const arcMatches = path.match(/A\s+[\d.]+\s+[\d.]+\s+0\s+0\s+(\d)/g);
89
+ assert.ok(arcMatches && arcMatches.length > 0, 'Should have arc commands');
90
+ for (const match of arcMatches!) {
91
+ assert.ok(match.endsWith('1'), `Arc sweep flag should be 1 (clockwise/outward), got: ${match}`);
92
+ }
93
+ });
94
+
95
+ it('applies coordinate transforms', () => {
96
+ // Flip X
97
+ const path = generateCloudSVGPath(
98
+ { x: 1, y: 0 },
99
+ { x: 3, y: 2 },
100
+ 0.5,
101
+ (x) => -x,
102
+ (y) => y,
103
+ );
104
+ assert.ok(path.includes('-'), 'Flipped X should produce negative coordinates');
105
+ });
106
+
107
+ it('handles small rectangles', () => {
108
+ const path = generateCloudSVGPath(
109
+ { x: 0, y: 0 },
110
+ { x: 0.1, y: 0.1 },
111
+ 0.02,
112
+ (x) => x,
113
+ (y) => y,
114
+ );
115
+ assert.ok(path.length > 0, 'Should produce a non-empty path');
116
+ assert.ok(path.endsWith('Z'), 'Path should be closed');
117
+ });
118
+ });
@@ -0,0 +1,275 @@
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
+ * Revision cloud (scalloped border) path generation.
7
+ * Generates arc data for drawing cloud annotations on a canvas or SVG.
8
+ */
9
+
10
+ interface Point2D {
11
+ x: number;
12
+ y: number;
13
+ }
14
+
15
+ /** A single arc segment in the cloud border */
16
+ export interface CloudArc {
17
+ /** Start point of the arc */
18
+ start: Point2D;
19
+ /** End point of the arc */
20
+ end: Point2D;
21
+ /** Center of the arc circle */
22
+ center: Point2D;
23
+ /** Radius of the arc */
24
+ radius: number;
25
+ /** Start angle in radians */
26
+ startAngle: number;
27
+ /** End angle in radians */
28
+ endAngle: number;
29
+ }
30
+
31
+ /**
32
+ * Generate cloud arc data from two rectangle corner points.
33
+ * The two points define opposite corners of the rectangle.
34
+ * Arcs bulge outward from the rectangle edges.
35
+ *
36
+ * @param p1 First corner (e.g. top-left)
37
+ * @param p2 Second corner (e.g. bottom-right)
38
+ * @param arcRadius Radius of each scallop arc in drawing coords
39
+ * @returns Array of arc segments forming the cloud border
40
+ */
41
+ export function generateCloudArcs(
42
+ p1: Point2D,
43
+ p2: Point2D,
44
+ arcRadius: number
45
+ ): CloudArc[] {
46
+ // Build rectangle corners in clockwise order
47
+ const minX = Math.min(p1.x, p2.x);
48
+ const maxX = Math.max(p1.x, p2.x);
49
+ const minY = Math.min(p1.y, p2.y);
50
+ const maxY = Math.max(p1.y, p2.y);
51
+
52
+ const corners: Point2D[] = [
53
+ { x: minX, y: minY }, // top-left (in drawing coords, Y increases downward on screen)
54
+ { x: maxX, y: minY }, // top-right
55
+ { x: maxX, y: maxY }, // bottom-right
56
+ { x: minX, y: maxY }, // bottom-left
57
+ ];
58
+
59
+ const arcs: CloudArc[] = [];
60
+
61
+ // For each edge, generate scallop arcs
62
+ for (let i = 0; i < corners.length; i++) {
63
+ const edgeStart = corners[i];
64
+ const edgeEnd = corners[(i + 1) % corners.length];
65
+
66
+ const dx = edgeEnd.x - edgeStart.x;
67
+ const dy = edgeEnd.y - edgeStart.y;
68
+ const edgeLength = Math.sqrt(dx * dx + dy * dy);
69
+
70
+ if (edgeLength < 0.001) continue;
71
+
72
+ // Number of arcs along this edge
73
+ const arcDiameter = arcRadius * 2;
74
+ const arcCount = Math.max(1, Math.round(edgeLength / arcDiameter));
75
+ const segmentLength = edgeLength / arcCount;
76
+ const actualRadius = segmentLength / 2;
77
+
78
+ // Unit direction along edge
79
+ const ux = dx / edgeLength;
80
+ const uy = dy / edgeLength;
81
+
82
+ // Outward normal (perpendicular, pointing outward from rectangle)
83
+ // For clockwise winding, outward normal is to the right of the edge direction
84
+ const nx = uy;
85
+ const ny = -ux;
86
+
87
+ for (let j = 0; j < arcCount; j++) {
88
+ const t0 = j / arcCount;
89
+ const t1 = (j + 1) / arcCount;
90
+
91
+ const arcStart: Point2D = {
92
+ x: edgeStart.x + dx * t0,
93
+ y: edgeStart.y + dy * t0,
94
+ };
95
+ const arcEnd: Point2D = {
96
+ x: edgeStart.x + dx * t1,
97
+ y: edgeStart.y + dy * t1,
98
+ };
99
+
100
+ // Center of the arc is offset outward from the midpoint
101
+ const midX = (arcStart.x + arcEnd.x) / 2;
102
+ const midY = (arcStart.y + arcEnd.y) / 2;
103
+
104
+ // The arc center is at the midpoint of the segment (on the edge)
105
+ // The arc bulges outward by the radius amount
106
+ const center: Point2D = {
107
+ x: midX,
108
+ y: midY,
109
+ };
110
+
111
+ // Compute angles from center to start and end
112
+ const startAngle = Math.atan2(arcStart.y - center.y, arcStart.x - center.x);
113
+ const endAngle = Math.atan2(arcEnd.y - center.y, arcEnd.x - center.x);
114
+
115
+ arcs.push({
116
+ start: arcStart,
117
+ end: arcEnd,
118
+ center,
119
+ radius: actualRadius,
120
+ startAngle,
121
+ endAngle,
122
+ });
123
+ }
124
+ }
125
+
126
+ return arcs;
127
+ }
128
+
129
+ /**
130
+ * Draw cloud arcs on a Canvas 2D context.
131
+ * Arcs are drawn as semicircular bumps bulging outward.
132
+ */
133
+ export function drawCloudOnCanvas(
134
+ ctx: CanvasRenderingContext2D,
135
+ p1: Point2D,
136
+ p2: Point2D,
137
+ arcRadius: number,
138
+ toScreenX: (x: number) => number,
139
+ toScreenY: (y: number) => number,
140
+ screenScale: number
141
+ ): void {
142
+ const minX = Math.min(p1.x, p2.x);
143
+ const maxX = Math.max(p1.x, p2.x);
144
+ const minY = Math.min(p1.y, p2.y);
145
+ const maxY = Math.max(p1.y, p2.y);
146
+
147
+ const corners: Point2D[] = [
148
+ { x: minX, y: minY },
149
+ { x: maxX, y: minY },
150
+ { x: maxX, y: maxY },
151
+ { x: minX, y: maxY },
152
+ ];
153
+
154
+ ctx.beginPath();
155
+
156
+ for (let i = 0; i < corners.length; i++) {
157
+ const edgeStart = corners[i];
158
+ const edgeEnd = corners[(i + 1) % corners.length];
159
+
160
+ const dx = edgeEnd.x - edgeStart.x;
161
+ const dy = edgeEnd.y - edgeStart.y;
162
+ const edgeLength = Math.sqrt(dx * dx + dy * dy);
163
+
164
+ if (edgeLength < 0.001) continue;
165
+
166
+ const arcDiameter = arcRadius * 2;
167
+ const arcCount = Math.max(1, Math.round(edgeLength / arcDiameter));
168
+ const actualRadius = (edgeLength / arcCount) / 2;
169
+
170
+ // Unit direction along edge
171
+ const ux = dx / edgeLength;
172
+ const uy = dy / edgeLength;
173
+
174
+ // Outward normal for clockwise winding
175
+ const nx = uy;
176
+ const ny = -ux;
177
+
178
+ for (let j = 0; j < arcCount; j++) {
179
+ const t0 = j / arcCount;
180
+ const t1 = (j + 1) / arcCount;
181
+
182
+ const sx = edgeStart.x + dx * t0;
183
+ const sy = edgeStart.y + dy * t0;
184
+ const ex = edgeStart.x + dx * t1;
185
+ const ey = edgeStart.y + dy * t1;
186
+
187
+ // Arc center is on the edge at midpoint
188
+ const cx = (sx + ex) / 2;
189
+ const cy = (sy + ey) / 2;
190
+
191
+ // Convert to screen coords
192
+ const scx = toScreenX(cx);
193
+ const scy = toScreenY(cy);
194
+ const screenRadius = actualRadius * screenScale;
195
+
196
+ // Angles in screen space (Y may be flipped)
197
+ const ssx = toScreenX(sx);
198
+ const ssy = toScreenY(sy);
199
+ const sex = toScreenX(ex);
200
+ const sey = toScreenY(ey);
201
+
202
+ const startAngle = Math.atan2(ssy - scy, ssx - scx);
203
+ const endAngle = Math.atan2(sey - scy, sex - scx);
204
+
205
+ // Draw arc clockwise (false) so the semicircle bulges outward from the rectangle
206
+ ctx.arc(scx, scy, screenRadius, startAngle, endAngle, false);
207
+ }
208
+ }
209
+
210
+ ctx.closePath();
211
+ }
212
+
213
+ /**
214
+ * Generate SVG path data for a cloud annotation.
215
+ */
216
+ export function generateCloudSVGPath(
217
+ p1: Point2D,
218
+ p2: Point2D,
219
+ arcRadius: number,
220
+ transformX: (x: number) => number,
221
+ transformY: (y: number) => number,
222
+ ): string {
223
+ const minX = Math.min(p1.x, p2.x);
224
+ const maxX = Math.max(p1.x, p2.x);
225
+ const minY = Math.min(p1.y, p2.y);
226
+ const maxY = Math.max(p1.y, p2.y);
227
+
228
+ const corners: Point2D[] = [
229
+ { x: minX, y: minY },
230
+ { x: maxX, y: minY },
231
+ { x: maxX, y: maxY },
232
+ { x: minX, y: maxY },
233
+ ];
234
+
235
+ let path = '';
236
+
237
+ for (let i = 0; i < corners.length; i++) {
238
+ const edgeStart = corners[i];
239
+ const edgeEnd = corners[(i + 1) % corners.length];
240
+
241
+ const dx = edgeEnd.x - edgeStart.x;
242
+ const dy = edgeEnd.y - edgeStart.y;
243
+ const edgeLength = Math.sqrt(dx * dx + dy * dy);
244
+
245
+ if (edgeLength < 0.001) continue;
246
+
247
+ const arcDiameter = arcRadius * 2;
248
+ const arcCount = Math.max(1, Math.round(edgeLength / arcDiameter));
249
+ const segmentLength = edgeLength / arcCount;
250
+ const r = segmentLength / 2;
251
+
252
+ for (let j = 0; j < arcCount; j++) {
253
+ const t0 = j / arcCount;
254
+ const t1 = (j + 1) / arcCount;
255
+
256
+ const sx = transformX(edgeStart.x + (edgeEnd.x - edgeStart.x) * t0);
257
+ const sy = transformY(edgeStart.y + (edgeEnd.y - edgeStart.y) * t0);
258
+ const ex = transformX(edgeStart.x + (edgeEnd.x - edgeStart.x) * t1);
259
+ const ey = transformY(edgeStart.y + (edgeEnd.y - edgeStart.y) * t1);
260
+
261
+ // Move to start of first arc
262
+ if (i === 0 && j === 0) {
263
+ path += `M ${sx.toFixed(4)} ${sy.toFixed(4)}`;
264
+ }
265
+
266
+ // SVG arc: A rx ry x-rotation large-arc-flag sweep-flag x y
267
+ // sweep-flag=1 for clockwise (outward bulge from rectangle)
268
+ const trR = Math.sqrt((ex - sx) ** 2 + (ey - sy) ** 2) / 2;
269
+ path += ` A ${trR.toFixed(4)} ${trR.toFixed(4)} 0 0 1 ${ex.toFixed(4)} ${ey.toFixed(4)}`;
270
+ }
271
+ }
272
+
273
+ path += ' Z';
274
+ return path;
275
+ }