@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
@@ -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
+ }
@@ -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,
@@ -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
+ });