@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.
- package/CHANGELOG.md +88 -0
- package/dist/assets/{Arrow.dom-BGPQieQQ.js → Arrow.dom-CusgkT03.js} +1 -1
- package/dist/assets/browser-BXNIkE8a.js +694 -0
- package/dist/assets/emscripten-module-BTRCZGcB.wasm +0 -0
- package/dist/assets/emscripten-module-CGIn_cMh.wasm +0 -0
- package/dist/assets/emscripten-module-DYvzWiHh.wasm +0 -0
- package/dist/assets/emscripten-module-NWak2PoB.wasm +0 -0
- package/dist/assets/emscripten-module.browser-CY5t0Vfq.js +1 -0
- package/dist/assets/esbuild-COv63sf-.js +1 -0
- package/dist/assets/esbuild-Cpd5nU_H.wasm +0 -0
- package/dist/assets/ffi-DlhRHxHv.js +1 -0
- package/dist/assets/index-6Mr3byM-.js +216 -0
- package/dist/assets/index-CGbokkQ9.css +1 -0
- package/dist/assets/index-huvR-kGC.js +98305 -0
- package/dist/assets/module-6F3E5H7Y-tx0BadV3.js +6 -0
- package/dist/assets/{native-bridge-DD0SNyQ5.js → native-bridge-DsHOKdgD.js} +1 -1
- package/dist/assets/{wasm-bridge-D54YMO7X.js → wasm-bridge-Bd73HXn-.js} +1 -1
- package/dist/index.html +12 -3
- package/index.html +10 -1
- package/package.json +30 -21
- package/src/App.tsx +6 -1
- package/src/components/ui/dialog.tsx +8 -6
- package/src/components/viewer/CodeEditor.tsx +309 -0
- package/src/components/viewer/CommandPalette.tsx +597 -0
- package/src/components/viewer/Drawing2DCanvas.tsx +364 -1
- package/src/components/viewer/EntityContextMenu.tsx +47 -20
- package/src/components/viewer/ExportDialog.tsx +166 -17
- package/src/components/viewer/HierarchyPanel.tsx +3 -1
- package/src/components/viewer/LensPanel.tsx +848 -85
- package/src/components/viewer/MainToolbar.tsx +145 -84
- package/src/components/viewer/ScriptPanel.tsx +416 -0
- package/src/components/viewer/Section2DPanel.tsx +269 -29
- package/src/components/viewer/TextAnnotationEditor.tsx +112 -0
- package/src/components/viewer/ViewerLayout.tsx +63 -11
- package/src/components/viewer/Viewport.tsx +58 -23
- package/src/components/viewer/ViewportContainer.tsx +2 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +1 -1
- package/src/components/viewer/hierarchy/types.ts +1 -1
- package/src/components/viewer/lists/ListResultsTable.tsx +53 -19
- package/src/components/viewer/tools/cloudPathGenerator.test.ts +118 -0
- package/src/components/viewer/tools/cloudPathGenerator.ts +275 -0
- package/src/components/viewer/tools/computePolygonArea.test.ts +165 -0
- package/src/components/viewer/tools/computePolygonArea.ts +72 -0
- package/src/components/viewer/useGeometryStreaming.ts +25 -5
- package/src/hooks/ids/idsExportService.ts +1 -1
- package/src/hooks/useAnnotation2D.ts +551 -0
- package/src/hooks/useDrawingExport.ts +83 -1
- package/src/hooks/useKeyboardShortcuts.ts +114 -14
- package/src/hooks/useLens.ts +40 -55
- package/src/hooks/useLensDiscovery.ts +46 -0
- package/src/hooks/useModelSelection.ts +5 -22
- package/src/hooks/useSandbox.ts +113 -0
- package/src/index.css +7 -1
- package/src/lib/lens/adapter.ts +127 -1
- package/src/lib/lists/columnToAutoColor.ts +33 -0
- package/src/lib/recent-files.ts +122 -0
- package/src/lib/scripts/persistence.ts +132 -0
- package/src/lib/scripts/templates/bim-globals.d.ts +111 -0
- package/src/lib/scripts/templates/data-quality-audit.ts +149 -0
- package/src/lib/scripts/templates/envelope-check.ts +164 -0
- package/src/lib/scripts/templates/federation-compare.ts +189 -0
- package/src/lib/scripts/templates/fire-safety-check.ts +161 -0
- package/src/lib/scripts/templates/mep-equipment-schedule.ts +175 -0
- package/src/lib/scripts/templates/quantity-takeoff.ts +145 -0
- package/src/lib/scripts/templates/reset-view.ts +6 -0
- package/src/lib/scripts/templates/space-validation.ts +189 -0
- package/src/lib/scripts/templates/tsconfig.json +13 -0
- package/src/lib/scripts/templates.ts +86 -0
- package/src/sdk/BimProvider.tsx +50 -0
- package/src/sdk/adapters/export-adapter.ts +283 -0
- package/src/sdk/adapters/lens-adapter.ts +44 -0
- package/src/sdk/adapters/model-adapter.ts +32 -0
- package/src/sdk/adapters/model-compat.ts +80 -0
- package/src/sdk/adapters/mutate-adapter.ts +45 -0
- package/src/sdk/adapters/query-adapter.ts +241 -0
- package/src/sdk/adapters/selection-adapter.ts +29 -0
- package/src/sdk/adapters/spatial-adapter.ts +37 -0
- package/src/sdk/adapters/types.ts +11 -0
- package/src/sdk/adapters/viewer-adapter.ts +103 -0
- package/src/sdk/adapters/visibility-adapter.ts +61 -0
- package/src/sdk/local-backend.ts +144 -0
- package/src/sdk/useBimHost.ts +69 -0
- package/src/store/constants.ts +10 -2
- package/src/store/index.ts +28 -2
- package/src/store/resolveEntityRef.ts +44 -0
- package/src/store/slices/drawing2DSlice.ts +321 -0
- package/src/store/slices/lensSlice.ts +46 -4
- package/src/store/slices/pinboardSlice.ts +171 -42
- package/src/store/slices/scriptSlice.ts +218 -0
- package/src/store/slices/uiSlice.ts +2 -0
- package/src/store.ts +3 -0
- package/tsconfig.json +5 -2
- package/vite.config.ts +8 -0
- package/dist/assets/index-dgdgiQ9p.js +0 -75456
- 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
|
-
|
|
57
|
-
const
|
|
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
|
-
//
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
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
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
+
}
|