kritzel-stencil 0.0.161 → 0.0.162
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/dist/cjs/{default-text-tool.config-zB3FPuXq.js → default-line-tool.config-D1Ns0NmM.js} +3156 -1052
- package/dist/cjs/default-line-tool.config-D1Ns0NmM.js.map +1 -0
- package/dist/cjs/index.cjs.js +131 -127
- package/dist/cjs/index.cjs.js.map +1 -1
- package/dist/cjs/kritzel-color_22.cjs.entry.js +480 -147
- package/dist/collection/classes/core/core.class.js +140 -3
- package/dist/collection/classes/core/core.class.js.map +1 -1
- package/dist/collection/classes/core/reviver.class.js +8 -0
- package/dist/collection/classes/core/reviver.class.js.map +1 -1
- package/dist/collection/classes/core/store.class.js +5 -0
- package/dist/collection/classes/core/store.class.js.map +1 -1
- package/dist/collection/classes/handlers/line-handle.handler.js +383 -0
- package/dist/collection/classes/handlers/line-handle.handler.js.map +1 -0
- package/dist/collection/classes/handlers/move.handler.js +2 -2
- package/dist/collection/classes/handlers/move.handler.js.map +1 -1
- package/dist/collection/classes/managers/anchor.manager.js +874 -0
- package/dist/collection/classes/managers/anchor.manager.js.map +1 -0
- package/dist/collection/classes/managers/cursor.manager.js +117 -0
- package/dist/collection/classes/managers/cursor.manager.js.map +1 -0
- package/dist/collection/classes/objects/base-object.class.js +4 -2
- package/dist/collection/classes/objects/base-object.class.js.map +1 -1
- package/dist/collection/classes/objects/line.class.js +564 -0
- package/dist/collection/classes/objects/line.class.js.map +1 -0
- package/dist/collection/classes/objects/selection-group.class.js +4 -0
- package/dist/collection/classes/objects/selection-group.class.js.map +1 -1
- package/dist/collection/classes/registries/icon-registry.class.js +1 -0
- package/dist/collection/classes/registries/icon-registry.class.js.map +1 -1
- package/dist/collection/classes/tools/line-tool.class.js +172 -0
- package/dist/collection/classes/tools/line-tool.class.js.map +1 -0
- package/dist/collection/classes/tools/selection-tool.class.js +41 -8
- package/dist/collection/classes/tools/selection-tool.class.js.map +1 -1
- package/dist/collection/components/core/kritzel-editor/kritzel-editor.js +11 -2
- package/dist/collection/components/core/kritzel-editor/kritzel-editor.js.map +1 -1
- package/dist/collection/components/core/kritzel-engine/kritzel-engine.js +130 -58
- package/dist/collection/components/core/kritzel-engine/kritzel-engine.js.map +1 -1
- package/dist/collection/configs/default-engine-config.js +4 -0
- package/dist/collection/configs/default-engine-config.js.map +1 -1
- package/dist/collection/configs/default-line-tool.config.js +34 -0
- package/dist/collection/configs/default-line-tool.config.js.map +1 -0
- package/dist/collection/helpers/geometry.helper.js +42 -0
- package/dist/collection/helpers/geometry.helper.js.map +1 -1
- package/dist/collection/index.js +5 -0
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/interfaces/anchor.interface.js +2 -0
- package/dist/collection/interfaces/anchor.interface.js.map +1 -0
- package/dist/collection/interfaces/arrow-head.interface.js +2 -0
- package/dist/collection/interfaces/arrow-head.interface.js.map +1 -0
- package/dist/collection/interfaces/engine-state.interface.js.map +1 -1
- package/dist/collection/interfaces/line-options.interface.js +2 -0
- package/dist/collection/interfaces/line-options.interface.js.map +1 -0
- package/dist/collection/interfaces/toolbar-control.interface.js.map +1 -1
- package/dist/components/index.js +4 -4
- package/dist/components/kritzel-brush-style.js +1 -1
- package/dist/components/kritzel-context-menu.js +1 -1
- package/dist/components/kritzel-control-brush-config.js +1 -1
- package/dist/components/kritzel-control-text-config.js +1 -1
- package/dist/components/kritzel-controls.js +1 -1
- package/dist/components/kritzel-editor.js +54 -13
- package/dist/components/kritzel-editor.js.map +1 -1
- package/dist/components/kritzel-engine.js +1 -1
- package/dist/components/kritzel-icon.js +1 -1
- package/dist/components/kritzel-menu-item.js +1 -1
- package/dist/components/kritzel-menu.js +1 -1
- package/dist/components/kritzel-split-button.js +1 -1
- package/dist/components/kritzel-utility-panel.js +1 -1
- package/dist/components/kritzel-workspace-manager.js +1 -1
- package/dist/components/{p-BdZKPKnx.js → p-7_lwv0zQ.js} +4 -4
- package/dist/components/{p-BdZKPKnx.js.map → p-7_lwv0zQ.js.map} +1 -1
- package/dist/components/{p-DbKKCHKd.js → p-BixlbUD7.js} +3 -2
- package/dist/components/p-BixlbUD7.js.map +1 -0
- package/dist/components/{p-Doixm8-N.js → p-CDteBYm9.js} +3 -3
- package/dist/components/{p-Doixm8-N.js.map → p-CDteBYm9.js.map} +1 -1
- package/dist/components/{p-58y59Acb.js → p-CkD1PQQX.js} +5 -5
- package/dist/components/{p-58y59Acb.js.map → p-CkD1PQQX.js.map} +1 -1
- package/dist/components/{p-DxNbcUzt.js → p-Cqr0Bah5.js} +3 -3
- package/dist/components/{p-DxNbcUzt.js.map → p-Cqr0Bah5.js.map} +1 -1
- package/dist/components/{p-D7BLVRXX.js → p-CuhOrcET.js} +2726 -380
- package/dist/components/p-CuhOrcET.js.map +1 -0
- package/dist/components/{p-i0IlGLv2.js → p-CvLFRlQU.js} +3 -3
- package/dist/components/{p-i0IlGLv2.js.map → p-CvLFRlQU.js.map} +1 -1
- package/dist/components/{p-BpXgwgnV.js → p-DKwJJuFb.js} +7 -7
- package/dist/components/{p-BpXgwgnV.js.map → p-DKwJJuFb.js.map} +1 -1
- package/dist/components/{p-CC8KFHSe.js → p-DZ7kxJUx.js} +3 -3
- package/dist/components/{p-CC8KFHSe.js.map → p-DZ7kxJUx.js.map} +1 -1
- package/dist/components/{p-D_ygcWSz.js → p-dMCB4tJA.js} +3 -3
- package/dist/components/{p-D_ygcWSz.js.map → p-dMCB4tJA.js.map} +1 -1
- package/dist/components/{p-CBYBurdY.js → p-sokRZ7Vn.js} +49 -5
- package/dist/components/p-sokRZ7Vn.js.map +1 -0
- package/dist/esm/{default-text-tool.config-BvCgOiKA.js → default-line-tool.config-C35m-d1Y.js} +3152 -1053
- package/dist/esm/default-line-tool.config-C35m-d1Y.js.map +1 -0
- package/dist/esm/index.js +2 -2
- package/dist/esm/kritzel-color_22.entry.js +399 -66
- package/dist/stencil/index.esm.js +1 -1
- package/dist/stencil/p-C35m-d1Y.js +2 -0
- package/dist/stencil/p-C35m-d1Y.js.map +1 -0
- package/dist/stencil/p-d142ef46.entry.js +10 -0
- package/dist/stencil/p-d142ef46.entry.js.map +1 -0
- package/dist/stencil/stencil.esm.js +1 -1
- package/dist/types/classes/core/core.class.d.ts +18 -0
- package/dist/types/classes/core/store.class.d.ts +2 -0
- package/dist/types/classes/handlers/line-handle.handler.d.ts +34 -0
- package/dist/types/classes/managers/anchor.manager.d.ts +160 -0
- package/dist/types/classes/managers/cursor.manager.d.ts +43 -0
- package/dist/types/classes/objects/line.class.d.ts +98 -0
- package/dist/types/classes/tools/line-tool.class.d.ts +17 -0
- package/dist/types/classes/tools/selection-tool.class.d.ts +4 -0
- package/dist/types/components/core/kritzel-engine/kritzel-engine.d.ts +2 -4
- package/dist/types/components.d.ts +5 -5
- package/dist/types/configs/default-line-tool.config.d.ts +2 -0
- package/dist/types/helpers/geometry.helper.d.ts +10 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/interfaces/anchor.interface.d.ts +137 -0
- package/dist/types/interfaces/arrow-head.interface.d.ts +26 -0
- package/dist/types/interfaces/engine-state.interface.d.ts +8 -0
- package/dist/types/interfaces/line-options.interface.d.ts +21 -0
- package/dist/types/interfaces/toolbar-control.interface.d.ts +17 -1
- package/package.json +1 -1
- package/dist/cjs/default-text-tool.config-zB3FPuXq.js.map +0 -1
- package/dist/components/p-CBYBurdY.js.map +0 -1
- package/dist/components/p-D7BLVRXX.js.map +0 -1
- package/dist/components/p-DbKKCHKd.js.map +0 -1
- package/dist/esm/default-text-tool.config-BvCgOiKA.js.map +0 -1
- package/dist/stencil/p-6d9756d9.entry.js +0 -10
- package/dist/stencil/p-6d9756d9.entry.js.map +0 -1
- package/dist/stencil/p-BvCgOiKA.js +0 -2
- package/dist/stencil/p-BvCgOiKA.js.map +0 -1
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
import { KritzelLine } from "../objects/line.class";
|
|
2
|
+
import { KritzelSelectionBox } from "../objects/selection-box.class";
|
|
3
|
+
import { KritzelSelectionGroup } from "../objects/selection-group.class";
|
|
4
|
+
import { KritzelGeometryHelper } from "../../helpers/geometry.helper";
|
|
5
|
+
import { KritzelClassHelper } from "../../helpers/class.helper";
|
|
6
|
+
/**
|
|
7
|
+
* Manages anchor connections between line endpoints and other objects.
|
|
8
|
+
* Maintains a runtime index for efficient reverse lookups and handles
|
|
9
|
+
* snap detection during line endpoint dragging.
|
|
10
|
+
*/
|
|
11
|
+
export class KritzelAnchorManager {
|
|
12
|
+
_core;
|
|
13
|
+
/**
|
|
14
|
+
* Runtime index mapping objectId to the lines anchored to it.
|
|
15
|
+
* This is derived from the anchor properties on lines and rebuilt as needed.
|
|
16
|
+
*/
|
|
17
|
+
_anchorIndex = new Map();
|
|
18
|
+
constructor(core) {
|
|
19
|
+
this._core = core;
|
|
20
|
+
}
|
|
21
|
+
// ============================================
|
|
22
|
+
// Anchor CRUD Operations
|
|
23
|
+
// ============================================
|
|
24
|
+
/**
|
|
25
|
+
* Sets an anchor from a line endpoint to a target object's center.
|
|
26
|
+
* Updates both the line's anchor property and the internal index.
|
|
27
|
+
*/
|
|
28
|
+
setAnchor(lineId, endpoint, targetObjectId) {
|
|
29
|
+
const line = this.getLineById(lineId);
|
|
30
|
+
if (!line) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
// Prevent anchoring both endpoints to the same object
|
|
34
|
+
if (endpoint === 'start' && line.endAnchor?.objectId === targetObjectId) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (endpoint === 'end' && line.startAnchor?.objectId === targetObjectId) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
// Remove existing anchor if any
|
|
41
|
+
this.removeAnchor(lineId, endpoint);
|
|
42
|
+
// Set the anchor on the line
|
|
43
|
+
const anchor = { objectId: targetObjectId };
|
|
44
|
+
if (endpoint === 'start') {
|
|
45
|
+
line.startAnchor = anchor;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
line.endAnchor = anchor;
|
|
49
|
+
}
|
|
50
|
+
// Update the index
|
|
51
|
+
this.addToIndex(targetObjectId, lineId, endpoint);
|
|
52
|
+
// Snap the endpoint to the target's center
|
|
53
|
+
this.snapEndpointToObject(line, endpoint, targetObjectId);
|
|
54
|
+
// Persist the change
|
|
55
|
+
this._core.store.state.objects.update(line);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Removes an anchor from a line endpoint.
|
|
59
|
+
* Updates both the line's anchor property and the internal index.
|
|
60
|
+
*/
|
|
61
|
+
removeAnchor(lineId, endpoint) {
|
|
62
|
+
const line = this.getLineById(lineId);
|
|
63
|
+
if (!line) {
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const anchor = endpoint === 'start' ? line.startAnchor : line.endAnchor;
|
|
67
|
+
if (!anchor) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// Remove from index
|
|
71
|
+
this.removeFromIndex(anchor.objectId, lineId, endpoint);
|
|
72
|
+
// Clear the anchor on the line
|
|
73
|
+
if (endpoint === 'start') {
|
|
74
|
+
line.startAnchor = undefined;
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
line.endAnchor = undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Gets the anchor for a specific line endpoint.
|
|
82
|
+
*/
|
|
83
|
+
getAnchor(lineId, endpoint) {
|
|
84
|
+
const line = this.getLineById(lineId);
|
|
85
|
+
if (!line) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const anchor = endpoint === 'start' ? line.startAnchor : line.endAnchor;
|
|
89
|
+
return anchor ?? null;
|
|
90
|
+
}
|
|
91
|
+
// ============================================
|
|
92
|
+
// Query Operations
|
|
93
|
+
// ============================================
|
|
94
|
+
/**
|
|
95
|
+
* Gets all lines that have an endpoint anchored to the specified object.
|
|
96
|
+
*/
|
|
97
|
+
getLinesAnchoredTo(objectId) {
|
|
98
|
+
const entries = this._anchorIndex.get(objectId);
|
|
99
|
+
return entries ? Array.from(entries) : [];
|
|
100
|
+
}
|
|
101
|
+
// ============================================
|
|
102
|
+
// Position Updates
|
|
103
|
+
// ============================================
|
|
104
|
+
/**
|
|
105
|
+
* Updates all line endpoints that are anchored to the specified object.
|
|
106
|
+
* Should be called when an object moves.
|
|
107
|
+
*/
|
|
108
|
+
updateAnchorsForObject(objectId) {
|
|
109
|
+
const entries = this.getLinesAnchoredTo(objectId);
|
|
110
|
+
if (entries.length === 0) {
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const targetObject = this.getObjectById(objectId);
|
|
114
|
+
if (!targetObject) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
for (const entry of entries) {
|
|
118
|
+
const line = this.getLineById(entry.lineId);
|
|
119
|
+
if (!line) {
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
this.snapEndpointToObject(line, entry.endpoint, objectId);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Snaps a line endpoint to an object's center.
|
|
127
|
+
*/
|
|
128
|
+
snapEndpointToObject(line, endpoint, targetObjectId) {
|
|
129
|
+
const targetObject = this.getObjectById(targetObjectId);
|
|
130
|
+
if (!targetObject) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// Get target center in world coordinates
|
|
134
|
+
const targetCenterX = targetObject.centerX;
|
|
135
|
+
const targetCenterY = targetObject.centerY;
|
|
136
|
+
// Convert world coordinates to line's local coordinate system
|
|
137
|
+
const localCoords = this.worldToLineLocal(line, targetCenterX, targetCenterY);
|
|
138
|
+
// Update the endpoint
|
|
139
|
+
line.updateEndpoint(endpoint, localCoords.x, localCoords.y);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Converts world coordinates to a line's local coordinate system.
|
|
143
|
+
*/
|
|
144
|
+
worldToLineLocal(line, worldX, worldY) {
|
|
145
|
+
// Get line center in world coordinates
|
|
146
|
+
const cx = line.centerX;
|
|
147
|
+
const cy = line.centerY;
|
|
148
|
+
// Translate to center
|
|
149
|
+
const dx = worldX - cx;
|
|
150
|
+
const dy = worldY - cy;
|
|
151
|
+
// Apply inverse rotation
|
|
152
|
+
const cos = Math.cos(-line.rotation);
|
|
153
|
+
const sin = Math.sin(-line.rotation);
|
|
154
|
+
const rotatedX = dx * cos - dy * sin;
|
|
155
|
+
const rotatedY = dx * sin + dy * cos;
|
|
156
|
+
// Calculate local coordinates relative to the unrotated bounding box top-left
|
|
157
|
+
const relativeX = rotatedX + (line.totalWidth / 2) / line.scale;
|
|
158
|
+
const relativeY = rotatedY + (line.totalHeight / 2) / line.scale;
|
|
159
|
+
// Convert to internal coordinates
|
|
160
|
+
const localX = relativeX * line.scale + line.x;
|
|
161
|
+
const localY = relativeY * line.scale + line.y;
|
|
162
|
+
return { x: localX, y: localY };
|
|
163
|
+
}
|
|
164
|
+
// ============================================
|
|
165
|
+
// Snap Detection
|
|
166
|
+
// ============================================
|
|
167
|
+
/**
|
|
168
|
+
* Finds the nearest object that can be snapped to within the snap threshold.
|
|
169
|
+
* Returns null if no suitable snap target is found.
|
|
170
|
+
*
|
|
171
|
+
* @param worldX - X coordinate in world space
|
|
172
|
+
* @param worldY - Y coordinate in world space
|
|
173
|
+
* @param excludeLineId - ID of the line being edited (to exclude from snap targets)
|
|
174
|
+
* @param otherEndpointAnchorId - ID of object anchored to the other endpoint (to prevent same-object anchoring)
|
|
175
|
+
*/
|
|
176
|
+
findSnapTarget(worldX, worldY, excludeLineId, otherEndpointAnchorId) {
|
|
177
|
+
let bestTarget = null;
|
|
178
|
+
let highestZIndex = -Infinity;
|
|
179
|
+
const objects = this._core.store.allNonSelectionObjects;
|
|
180
|
+
for (const object of objects) {
|
|
181
|
+
// Skip the line being edited
|
|
182
|
+
if (object.id === excludeLineId) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
// Skip if this is the object anchored to the other endpoint
|
|
186
|
+
if (otherEndpointAnchorId && object.id === otherEndpointAnchorId) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
// Skip non-anchorable objects
|
|
190
|
+
if (!this.isAnchorable(object)) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
// Check if point is inside the object's rotated polygon
|
|
194
|
+
const polygon = object.rotatedPolygon;
|
|
195
|
+
const points = [polygon.topLeft, polygon.topRight, polygon.bottomRight, polygon.bottomLeft];
|
|
196
|
+
if (KritzelGeometryHelper.isPointInPolygon({ x: worldX, y: worldY }, points)) {
|
|
197
|
+
// If inside, check if this object is "above" the current best target
|
|
198
|
+
if (object.zIndex > highestZIndex) {
|
|
199
|
+
highestZIndex = object.zIndex;
|
|
200
|
+
bestTarget = {
|
|
201
|
+
objectId: object.id,
|
|
202
|
+
centerX: object.centerX,
|
|
203
|
+
centerY: object.centerY,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return bestTarget;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Sets the current snap candidate for visual feedback.
|
|
212
|
+
*/
|
|
213
|
+
setSnapCandidate(candidate) {
|
|
214
|
+
this._core.store.state.snapCandidate = candidate;
|
|
215
|
+
this._core.rerender();
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Gets the current snap candidate.
|
|
219
|
+
*/
|
|
220
|
+
getSnapCandidate() {
|
|
221
|
+
return this._core.store.state.snapCandidate ?? null;
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* Clears the snap candidate.
|
|
225
|
+
*/
|
|
226
|
+
clearSnapCandidate() {
|
|
227
|
+
this._core.store.state.snapCandidate = null;
|
|
228
|
+
this._core.rerender();
|
|
229
|
+
}
|
|
230
|
+
// ============================================
|
|
231
|
+
// Render Data
|
|
232
|
+
// ============================================
|
|
233
|
+
/**
|
|
234
|
+
* Gets render data for anchor lines visualization.
|
|
235
|
+
* Returns null if there's no selected line with anchors or if snap is in progress.
|
|
236
|
+
*/
|
|
237
|
+
getAnchorLinesRenderData() {
|
|
238
|
+
const selectionGroup = this._core.store.selectionGroup;
|
|
239
|
+
if (!selectionGroup || selectionGroup.objects.length !== 1)
|
|
240
|
+
return null;
|
|
241
|
+
const selectedObject = selectionGroup.objects[0];
|
|
242
|
+
if (!KritzelClassHelper.isInstanceOf(selectedObject, 'KritzelLine'))
|
|
243
|
+
return null;
|
|
244
|
+
const line = selectedObject;
|
|
245
|
+
const startAnchorViz = this.computeAnchorVisualization(line, 'start');
|
|
246
|
+
const endAnchorViz = this.computeAnchorVisualization(line, 'end');
|
|
247
|
+
if (!startAnchorViz && !endAnchorViz)
|
|
248
|
+
return null;
|
|
249
|
+
const scale = this._core.store.state.scale;
|
|
250
|
+
const lineStrokeWidth = line.strokeWidth / line.scale;
|
|
251
|
+
const indicatorStrokeWidth = `${2 / scale}`;
|
|
252
|
+
const dashLength = Math.max(lineStrokeWidth * 2, 4 / scale);
|
|
253
|
+
const dashArray = `${dashLength} ${dashLength}`;
|
|
254
|
+
const indicatorRadius = 8 / scale;
|
|
255
|
+
return {
|
|
256
|
+
lineStrokeWidth,
|
|
257
|
+
indicatorStrokeWidth,
|
|
258
|
+
dashArray,
|
|
259
|
+
indicatorRadius,
|
|
260
|
+
startAnchorViz,
|
|
261
|
+
endAnchorViz,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Gets render data for snap indicator visualization.
|
|
266
|
+
* Returns null if there's no snap candidate.
|
|
267
|
+
*/
|
|
268
|
+
getSnapIndicatorRenderData() {
|
|
269
|
+
const snapCandidate = this.getSnapCandidate();
|
|
270
|
+
if (!snapCandidate)
|
|
271
|
+
return null;
|
|
272
|
+
const scale = this._core.store.state.scale;
|
|
273
|
+
const indicatorRadius = 8 / scale;
|
|
274
|
+
const indicatorStrokeWidth = `${2 / scale}`;
|
|
275
|
+
const lineStrokeWidth = snapCandidate.lineStrokeWidth
|
|
276
|
+
? `${snapCandidate.lineStrokeWidth}`
|
|
277
|
+
: `${4 / scale}`;
|
|
278
|
+
const lineStrokeWidthNum = snapCandidate.lineStrokeWidth || (4 / scale);
|
|
279
|
+
const dashLength = Math.max(lineStrokeWidthNum * 2, 4 / scale);
|
|
280
|
+
const dashArray = `${dashLength} ${dashLength}`;
|
|
281
|
+
const lineStroke = snapCandidate.lineStroke || '#000000';
|
|
282
|
+
let solidLineEndX = snapCandidate.edgeX;
|
|
283
|
+
let solidLineEndY = snapCandidate.edgeY;
|
|
284
|
+
let arrowPoints;
|
|
285
|
+
if (snapCandidate.arrowOffset && snapCandidate.edgeX !== undefined && snapCandidate.edgeY !== undefined) {
|
|
286
|
+
const dx = snapCandidate.lineEndpointX - snapCandidate.edgeX;
|
|
287
|
+
const dy = snapCandidate.lineEndpointY - snapCandidate.edgeY;
|
|
288
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
|
289
|
+
if (length > snapCandidate.arrowOffset) {
|
|
290
|
+
solidLineEndX = snapCandidate.edgeX + (dx / length) * snapCandidate.arrowOffset;
|
|
291
|
+
solidLineEndY = snapCandidate.edgeY + (dy / length) * snapCandidate.arrowOffset;
|
|
292
|
+
}
|
|
293
|
+
// Calculate arrow head points
|
|
294
|
+
// Direction from line endpoint to edge (arrow direction)
|
|
295
|
+
const arrowDx = snapCandidate.edgeX - snapCandidate.lineEndpointX;
|
|
296
|
+
const arrowDy = snapCandidate.edgeY - snapCandidate.lineEndpointY;
|
|
297
|
+
const arrowLengthTotal = Math.sqrt(arrowDx * arrowDx + arrowDy * arrowDy);
|
|
298
|
+
if (arrowLengthTotal > 0) {
|
|
299
|
+
const ux = arrowDx / arrowLengthTotal;
|
|
300
|
+
const uy = arrowDy / arrowLengthTotal;
|
|
301
|
+
// Perpendicular vector
|
|
302
|
+
const px = -uy;
|
|
303
|
+
const py = ux;
|
|
304
|
+
// Arrow dimensions
|
|
305
|
+
const arrowLength = snapCandidate.arrowOffset;
|
|
306
|
+
const arrowWidth = arrowLength; // 1:1 ratio
|
|
307
|
+
// Arrow tip at edge
|
|
308
|
+
const tipX = snapCandidate.edgeX;
|
|
309
|
+
const tipY = snapCandidate.edgeY;
|
|
310
|
+
// Arrow base
|
|
311
|
+
const baseX = tipX - ux * arrowLength;
|
|
312
|
+
const baseY = tipY - uy * arrowLength;
|
|
313
|
+
// Arrow wings
|
|
314
|
+
const leftX = baseX + px * arrowWidth / 2;
|
|
315
|
+
const leftY = baseY + py * arrowWidth / 2;
|
|
316
|
+
const rightX = baseX - px * arrowWidth / 2;
|
|
317
|
+
const rightY = baseY - py * arrowWidth / 2;
|
|
318
|
+
arrowPoints = `${tipX},${tipY} ${leftX},${leftY} ${rightX},${rightY}`;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return {
|
|
322
|
+
indicatorRadius,
|
|
323
|
+
indicatorStrokeWidth,
|
|
324
|
+
lineStrokeWidth,
|
|
325
|
+
dashArray,
|
|
326
|
+
lineStroke,
|
|
327
|
+
centerX: snapCandidate.centerX,
|
|
328
|
+
centerY: snapCandidate.centerY,
|
|
329
|
+
lineEndpointX: snapCandidate.lineEndpointX,
|
|
330
|
+
lineEndpointY: snapCandidate.lineEndpointY,
|
|
331
|
+
edgeX: snapCandidate.edgeX,
|
|
332
|
+
edgeY: snapCandidate.edgeY,
|
|
333
|
+
arrowOffset: snapCandidate.arrowOffset,
|
|
334
|
+
arrowStyle: snapCandidate.arrowStyle,
|
|
335
|
+
arrowFill: snapCandidate.arrowFill,
|
|
336
|
+
solidLineEndX,
|
|
337
|
+
solidLineEndY,
|
|
338
|
+
arrowPoints,
|
|
339
|
+
snapLinePath: (() => {
|
|
340
|
+
if (snapCandidate.controlX !== undefined &&
|
|
341
|
+
snapCandidate.controlY !== undefined &&
|
|
342
|
+
snapCandidate.t !== undefined) {
|
|
343
|
+
const startT = snapCandidate.endpoint === 'start' ? 1 - snapCandidate.t : snapCandidate.t;
|
|
344
|
+
// Ensure meaningful range
|
|
345
|
+
if (startT >= 1)
|
|
346
|
+
return undefined;
|
|
347
|
+
const segment = this.extractQuadraticSegment({ x: snapCandidate.lineEndpointX, y: snapCandidate.lineEndpointY }, { x: snapCandidate.controlX, y: snapCandidate.controlY }, { x: snapCandidate.centerX, y: snapCandidate.centerY }, startT, 1);
|
|
348
|
+
return `M ${segment.start.x} ${segment.start.y} Q ${segment.control.x} ${segment.control.y} ${segment.end.x} ${segment.end.y}`;
|
|
349
|
+
}
|
|
350
|
+
return undefined;
|
|
351
|
+
})(),
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
// ============================================
|
|
355
|
+
// Cleanup Operations
|
|
356
|
+
// ============================================
|
|
357
|
+
/**
|
|
358
|
+
* Handles cleanup when an object is deleted.
|
|
359
|
+
* Detaches all lines that were anchored to the deleted object.
|
|
360
|
+
*/
|
|
361
|
+
handleObjectDeleted(objectId) {
|
|
362
|
+
const entries = this.getLinesAnchoredTo(objectId);
|
|
363
|
+
for (const entry of entries) {
|
|
364
|
+
this.removeAnchor(entry.lineId, entry.endpoint);
|
|
365
|
+
// Update the line to persist the change
|
|
366
|
+
const line = this.getLineById(entry.lineId);
|
|
367
|
+
if (line) {
|
|
368
|
+
this._core.store.state.objects.update(line);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
// Remove the object from the index
|
|
372
|
+
this._anchorIndex.delete(objectId);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Handles cleanup when a line is deleted.
|
|
376
|
+
* Removes all anchor index entries for the line.
|
|
377
|
+
*/
|
|
378
|
+
handleLineDeleted(lineId) {
|
|
379
|
+
const line = this.getLineById(lineId);
|
|
380
|
+
if (!line) {
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (line.startAnchor) {
|
|
384
|
+
this.removeFromIndex(line.startAnchor.objectId, lineId, 'start');
|
|
385
|
+
}
|
|
386
|
+
if (line.endAnchor) {
|
|
387
|
+
this.removeFromIndex(line.endAnchor.objectId, lineId, 'end');
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// ============================================
|
|
391
|
+
// Index Management
|
|
392
|
+
// ============================================
|
|
393
|
+
/**
|
|
394
|
+
* Rebuilds the anchor index from all lines in the object map.
|
|
395
|
+
* Should be called after loading/deserializing objects.
|
|
396
|
+
*/
|
|
397
|
+
rebuildIndex() {
|
|
398
|
+
this._anchorIndex.clear();
|
|
399
|
+
const objects = this._core.store.allObjects;
|
|
400
|
+
for (const object of objects) {
|
|
401
|
+
if (object instanceof KritzelLine) {
|
|
402
|
+
if (object.startAnchor) {
|
|
403
|
+
this.addToIndex(object.startAnchor.objectId, object.id, 'start');
|
|
404
|
+
}
|
|
405
|
+
if (object.endAnchor) {
|
|
406
|
+
this.addToIndex(object.endAnchor.objectId, object.id, 'end');
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Adds an entry to the anchor index.
|
|
413
|
+
*/
|
|
414
|
+
addToIndex(objectId, lineId, endpoint) {
|
|
415
|
+
if (!this._anchorIndex.has(objectId)) {
|
|
416
|
+
this._anchorIndex.set(objectId, new Set());
|
|
417
|
+
}
|
|
418
|
+
const entries = this._anchorIndex.get(objectId);
|
|
419
|
+
// Check if entry already exists
|
|
420
|
+
for (const entry of entries) {
|
|
421
|
+
if (entry.lineId === lineId && entry.endpoint === endpoint) {
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
entries.add({ lineId, endpoint });
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Removes an entry from the anchor index.
|
|
429
|
+
*/
|
|
430
|
+
removeFromIndex(objectId, lineId, endpoint) {
|
|
431
|
+
const entries = this._anchorIndex.get(objectId);
|
|
432
|
+
if (!entries) {
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
for (const entry of entries) {
|
|
436
|
+
if (entry.lineId === lineId && entry.endpoint === endpoint) {
|
|
437
|
+
entries.delete(entry);
|
|
438
|
+
break;
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
// Clean up empty sets
|
|
442
|
+
if (entries.size === 0) {
|
|
443
|
+
this._anchorIndex.delete(objectId);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// ============================================
|
|
447
|
+
// Helper Methods
|
|
448
|
+
// ============================================
|
|
449
|
+
/**
|
|
450
|
+
* Gets a line by its ID.
|
|
451
|
+
*/
|
|
452
|
+
getLineById(lineId) {
|
|
453
|
+
const objects = this._core.store.state.objects.filter(o => o.id === lineId);
|
|
454
|
+
if (objects.length === 0) {
|
|
455
|
+
return null;
|
|
456
|
+
}
|
|
457
|
+
const object = objects[0];
|
|
458
|
+
return object instanceof KritzelLine ? object : null;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Gets an object by its ID.
|
|
462
|
+
*/
|
|
463
|
+
getObjectById(objectId) {
|
|
464
|
+
const objects = this._core.store.state.objects.filter(o => o.id === objectId);
|
|
465
|
+
return objects.length > 0 ? objects[0] : null;
|
|
466
|
+
}
|
|
467
|
+
findAnchorTarget(line, endpoint) {
|
|
468
|
+
const anchor = endpoint === 'start' ? line.startAnchor : line.endAnchor;
|
|
469
|
+
if (!anchor) {
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
return this._core.store.allNonSelectionObjects.find(obj => obj.id === anchor.objectId) ?? null;
|
|
473
|
+
}
|
|
474
|
+
// ============================================
|
|
475
|
+
// Visualization Helpers
|
|
476
|
+
// ============================================
|
|
477
|
+
/**
|
|
478
|
+
* Computes anchor visualization data for a line with anchors.
|
|
479
|
+
* Returns edge intersection points for rendering dashed lines from edge to center.
|
|
480
|
+
*/
|
|
481
|
+
computeAnchorVisualization(line, endpoint) {
|
|
482
|
+
const anchor = endpoint === 'start' ? line.startAnchor : line.endAnchor;
|
|
483
|
+
if (!anchor)
|
|
484
|
+
return null;
|
|
485
|
+
const targetObject = this.findAnchorTarget(line, endpoint);
|
|
486
|
+
if (!targetObject)
|
|
487
|
+
return null;
|
|
488
|
+
const clipInfo = this.computeAnchorClipInfo(line, endpoint, targetObject);
|
|
489
|
+
if (!clipInfo)
|
|
490
|
+
return null;
|
|
491
|
+
const centerX = targetObject.centerX;
|
|
492
|
+
const centerY = targetObject.centerY;
|
|
493
|
+
return {
|
|
494
|
+
edgeX: clipInfo.worldX,
|
|
495
|
+
edgeY: clipInfo.worldY,
|
|
496
|
+
centerX,
|
|
497
|
+
centerY,
|
|
498
|
+
pathD: this.buildAnchorPath(line, endpoint, clipInfo, targetObject) ?? undefined,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Computes a clipped line path that stops at anchor edges instead of going to anchor centers.
|
|
503
|
+
* When arrows are present on anchored ends, the line is shortened so the arrow tip appears at the edge.
|
|
504
|
+
* Returns the SVG path 'd' attribute string, or the original path if no clipping is needed.
|
|
505
|
+
* @param line The line object
|
|
506
|
+
* @param offsetByViewBox If true, subtracts line.x and line.y from coordinates (for selection UI)
|
|
507
|
+
*/
|
|
508
|
+
computeClippedLinePath(line, offsetByViewBox = false) {
|
|
509
|
+
// Check for snap candidate first - this applies during drag operations even without anchors
|
|
510
|
+
const snapCandidate = this.getSnapCandidate();
|
|
511
|
+
const selectionGroup = this._core.store.selectionGroup;
|
|
512
|
+
const hasActiveSnapCandidate = snapCandidate && selectionGroup && selectionGroup.objects.length === 1 && selectionGroup.objects[0].id === line.id;
|
|
513
|
+
// If no anchors and no active snap candidate, return original path (with or without offset)
|
|
514
|
+
if (!line.startAnchor && !line.endAnchor && !hasActiveSnapCandidate) {
|
|
515
|
+
if (offsetByViewBox) {
|
|
516
|
+
if (line.controlX !== undefined && line.controlY !== undefined) {
|
|
517
|
+
return `M ${line.startX - line.x} ${line.startY - line.y} Q ${line.controlX - line.x} ${line.controlY - line.y} ${line.endX - line.x} ${line.endY - line.y}`;
|
|
518
|
+
}
|
|
519
|
+
return `M ${line.startX - line.x} ${line.startY - line.y} L ${line.endX - line.x} ${line.endY - line.y}`;
|
|
520
|
+
}
|
|
521
|
+
return line.d;
|
|
522
|
+
}
|
|
523
|
+
const startTarget = line.startAnchor ? this.findAnchorTarget(line, 'start') : null;
|
|
524
|
+
const endTarget = line.endAnchor ? this.findAnchorTarget(line, 'end') : null;
|
|
525
|
+
let startClip = startTarget ? this.computeAnchorClipInfo(line, 'start', startTarget) : null;
|
|
526
|
+
let endClip = endTarget ? this.computeAnchorClipInfo(line, 'end', endTarget) : null;
|
|
527
|
+
// Apply snap candidate if present and matches this line
|
|
528
|
+
if (hasActiveSnapCandidate) {
|
|
529
|
+
if (snapCandidate.edgeX !== undefined && snapCandidate.edgeY !== undefined) {
|
|
530
|
+
const localEdge = this.lineWorldToLocal(line, snapCandidate.edgeX, snapCandidate.edgeY);
|
|
531
|
+
const clipInfo = {
|
|
532
|
+
worldX: snapCandidate.edgeX,
|
|
533
|
+
worldY: snapCandidate.edgeY,
|
|
534
|
+
localX: localEdge.x,
|
|
535
|
+
localY: localEdge.y,
|
|
536
|
+
t: snapCandidate.t
|
|
537
|
+
};
|
|
538
|
+
if (snapCandidate.endpoint === 'start') {
|
|
539
|
+
startClip = clipInfo;
|
|
540
|
+
}
|
|
541
|
+
else {
|
|
542
|
+
endClip = clipInfo;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// Apply offset if requested (for selection UI which uses coordinates relative to 0,0)
|
|
547
|
+
const offsetX = offsetByViewBox ? line.x : 0;
|
|
548
|
+
const offsetY = offsetByViewBox ? line.y : 0;
|
|
549
|
+
// Handle curved lines by splitting the quadratic at the clipped parameters
|
|
550
|
+
if (line.controlX !== undefined && line.controlY !== undefined) {
|
|
551
|
+
let startT = startClip?.t ?? 0;
|
|
552
|
+
let endT = endClip?.t ?? 1;
|
|
553
|
+
// Adjust for start arrow
|
|
554
|
+
if (startClip && line.hasStartArrow) {
|
|
555
|
+
const arrowOffset = line.getArrowSize('start');
|
|
556
|
+
const speed = this.evaluateDerivativeSpeedAtT(line, startT);
|
|
557
|
+
if (speed > 0) {
|
|
558
|
+
startT += arrowOffset / speed;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
// Adjust for end arrow
|
|
562
|
+
if (endClip && line.hasEndArrow) {
|
|
563
|
+
const arrowOffset = line.getArrowSize('end');
|
|
564
|
+
const speed = this.evaluateDerivativeSpeedAtT(line, endT);
|
|
565
|
+
if (speed > 0) {
|
|
566
|
+
endT -= arrowOffset / speed;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
// Ensure valid range
|
|
570
|
+
if (startT < 0)
|
|
571
|
+
startT = 0;
|
|
572
|
+
if (endT > 1)
|
|
573
|
+
endT = 1;
|
|
574
|
+
// Handle overlap or invalid range
|
|
575
|
+
if (endT <= startT) {
|
|
576
|
+
// If the arrow adjustment caused them to cross, or they were already crossed/close
|
|
577
|
+
// We can either effectively hide the line, or just clamp them.
|
|
578
|
+
// If we clamp them to the midpoint, the line becomes a point.
|
|
579
|
+
// Let's ensure a minimal gap or just set them equal.
|
|
580
|
+
const mid = (startT + endT) / 2;
|
|
581
|
+
startT = mid;
|
|
582
|
+
endT = mid;
|
|
583
|
+
}
|
|
584
|
+
const segment = this.extractQuadraticSegment({ x: line.startX, y: line.startY }, { x: line.controlX, y: line.controlY }, { x: line.endX, y: line.endY }, startT, endT);
|
|
585
|
+
return `M ${segment.start.x - offsetX} ${segment.start.y - offsetY} Q ${segment.control.x - offsetX} ${segment.control.y - offsetY} ${segment.end.x - offsetX} ${segment.end.y - offsetY}`;
|
|
586
|
+
}
|
|
587
|
+
// Straight lines fall back to linear interpolation
|
|
588
|
+
let startX = startClip?.localX ?? line.startX;
|
|
589
|
+
let startY = startClip?.localY ?? line.startY;
|
|
590
|
+
let endX = endClip?.localX ?? line.endX;
|
|
591
|
+
let endY = endClip?.localY ?? line.endY;
|
|
592
|
+
if (startClip && line.hasStartArrow) {
|
|
593
|
+
const arrowOffset = line.getArrowSize('start');
|
|
594
|
+
const dx = endX - startX;
|
|
595
|
+
const dy = endY - startY;
|
|
596
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
|
597
|
+
if (length > arrowOffset) {
|
|
598
|
+
startX += (dx / length) * arrowOffset;
|
|
599
|
+
startY += (dy / length) * arrowOffset;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (endClip && line.hasEndArrow) {
|
|
603
|
+
const arrowOffset = line.getArrowSize('end');
|
|
604
|
+
const dx = startX - endX;
|
|
605
|
+
const dy = startY - endY;
|
|
606
|
+
const length = Math.sqrt(dx * dx + dy * dy);
|
|
607
|
+
if (length > arrowOffset) {
|
|
608
|
+
endX += (dx / length) * arrowOffset;
|
|
609
|
+
endY += (dy / length) * arrowOffset;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
return `M ${startX - offsetX} ${startY - offsetY} L ${endX - offsetX} ${endY - offsetY}`;
|
|
613
|
+
}
|
|
614
|
+
computeAnchorClipInfo(line, endpoint, targetObject) {
|
|
615
|
+
if (line.controlX !== undefined && line.controlY !== undefined) {
|
|
616
|
+
return this.computeCurvedClipInfo(line, endpoint, targetObject);
|
|
617
|
+
}
|
|
618
|
+
return this.computeStraightClipInfo(line, endpoint, targetObject);
|
|
619
|
+
}
|
|
620
|
+
computeStraightClipInfo(line, endpoint, targetObject) {
|
|
621
|
+
const reference = endpoint === 'start'
|
|
622
|
+
? this.lineLocalToWorld(line, line.endX, line.endY)
|
|
623
|
+
: this.lineLocalToWorld(line, line.startX, line.startY);
|
|
624
|
+
const edgeIntersection = KritzelGeometryHelper.getLinePolygonIntersection(reference, { x: targetObject.centerX, y: targetObject.centerY }, targetObject.rotatedPolygon);
|
|
625
|
+
if (!edgeIntersection) {
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
const localEdge = this.lineWorldToLocal(line, edgeIntersection.x, edgeIntersection.y);
|
|
629
|
+
const totalLength = Math.sqrt(Math.pow(line.endX - line.startX, 2) +
|
|
630
|
+
Math.pow(line.endY - line.startY, 2));
|
|
631
|
+
const distanceFromStart = Math.sqrt(Math.pow(localEdge.x - line.startX, 2) +
|
|
632
|
+
Math.pow(localEdge.y - line.startY, 2));
|
|
633
|
+
const t = totalLength > 0 ? distanceFromStart / totalLength : endpoint === 'start' ? 0 : 1;
|
|
634
|
+
return {
|
|
635
|
+
localX: localEdge.x,
|
|
636
|
+
localY: localEdge.y,
|
|
637
|
+
worldX: edgeIntersection.x,
|
|
638
|
+
worldY: edgeIntersection.y,
|
|
639
|
+
t,
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
computeCurvedClipInfo(line, endpoint, targetObject) {
|
|
643
|
+
const polygonPoints = this.getPolygonPoints(targetObject.rotatedPolygon);
|
|
644
|
+
const exitPoint = this.findCurveExitPoint(line, endpoint, polygonPoints);
|
|
645
|
+
if (exitPoint) {
|
|
646
|
+
return exitPoint;
|
|
647
|
+
}
|
|
648
|
+
const reference = endpoint === 'start'
|
|
649
|
+
? this.lineLocalToWorld(line, line.endX, line.endY)
|
|
650
|
+
: this.lineLocalToWorld(line, line.startX, line.startY);
|
|
651
|
+
const fallbackIntersection = KritzelGeometryHelper.getLinePolygonIntersection(reference, { x: targetObject.centerX, y: targetObject.centerY }, targetObject.rotatedPolygon);
|
|
652
|
+
if (!fallbackIntersection) {
|
|
653
|
+
return null;
|
|
654
|
+
}
|
|
655
|
+
const localEdge = this.lineWorldToLocal(line, fallbackIntersection.x, fallbackIntersection.y);
|
|
656
|
+
const approxT = this.approximateParameterForWorldPoint(line, fallbackIntersection);
|
|
657
|
+
return {
|
|
658
|
+
localX: localEdge.x,
|
|
659
|
+
localY: localEdge.y,
|
|
660
|
+
worldX: fallbackIntersection.x,
|
|
661
|
+
worldY: fallbackIntersection.y,
|
|
662
|
+
t: approxT,
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
findCurveExitPoint(line, endpoint, polygonPoints) {
|
|
666
|
+
const steps = 64;
|
|
667
|
+
const initialT = endpoint === 'start' ? 0 : 1;
|
|
668
|
+
const initialSample = this.evaluateLineAtT(line, initialT);
|
|
669
|
+
let prevInside = KritzelGeometryHelper.isPointInPolygon({ x: initialSample.worldX, y: initialSample.worldY }, polygonPoints);
|
|
670
|
+
let prevT = initialT;
|
|
671
|
+
for (let i = 1; i <= steps; i++) {
|
|
672
|
+
const t = endpoint === 'start' ? i / steps : 1 - i / steps;
|
|
673
|
+
const sample = this.evaluateLineAtT(line, t);
|
|
674
|
+
const inside = KritzelGeometryHelper.isPointInPolygon({ x: sample.worldX, y: sample.worldY }, polygonPoints);
|
|
675
|
+
if (prevInside && !inside) {
|
|
676
|
+
const refinedT = this.refineCurveExitParameter(line, polygonPoints, prevT, t);
|
|
677
|
+
const refinedPoint = this.evaluateLineAtT(line, refinedT);
|
|
678
|
+
return {
|
|
679
|
+
localX: refinedPoint.localX,
|
|
680
|
+
localY: refinedPoint.localY,
|
|
681
|
+
worldX: refinedPoint.worldX,
|
|
682
|
+
worldY: refinedPoint.worldY,
|
|
683
|
+
t: refinedT,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
prevInside = inside;
|
|
687
|
+
prevT = t;
|
|
688
|
+
}
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
refineCurveExitParameter(line, polygonPoints, insideT, outsideT) {
|
|
692
|
+
let tInside = insideT;
|
|
693
|
+
let tOutside = outsideT;
|
|
694
|
+
for (let i = 0; i < 8; i++) {
|
|
695
|
+
const mid = (tInside + tOutside) / 2;
|
|
696
|
+
const sample = this.evaluateLineAtT(line, mid);
|
|
697
|
+
const inside = KritzelGeometryHelper.isPointInPolygon({ x: sample.worldX, y: sample.worldY }, polygonPoints);
|
|
698
|
+
if (inside) {
|
|
699
|
+
tInside = mid;
|
|
700
|
+
}
|
|
701
|
+
else {
|
|
702
|
+
tOutside = mid;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return (tInside + tOutside) / 2;
|
|
706
|
+
}
|
|
707
|
+
approximateParameterForWorldPoint(line, target) {
|
|
708
|
+
const steps = 80;
|
|
709
|
+
let bestT = 0;
|
|
710
|
+
let bestDistance = Infinity;
|
|
711
|
+
for (let i = 0; i <= steps; i++) {
|
|
712
|
+
const t = i / steps;
|
|
713
|
+
const sample = this.evaluateLineAtT(line, t);
|
|
714
|
+
const distance = Math.hypot(sample.worldX - target.x, sample.worldY - target.y);
|
|
715
|
+
if (distance < bestDistance) {
|
|
716
|
+
bestDistance = distance;
|
|
717
|
+
bestT = t;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
return bestT;
|
|
721
|
+
}
|
|
722
|
+
evaluateLineAtT(line, t) {
|
|
723
|
+
const clampedT = Math.max(0, Math.min(1, t));
|
|
724
|
+
let localX;
|
|
725
|
+
let localY;
|
|
726
|
+
if (line.controlX !== undefined && line.controlY !== undefined) {
|
|
727
|
+
const oneMinusT = 1 - clampedT;
|
|
728
|
+
localX = oneMinusT * oneMinusT * line.startX + 2 * oneMinusT * clampedT * line.controlX + clampedT * clampedT * line.endX;
|
|
729
|
+
localY = oneMinusT * oneMinusT * line.startY + 2 * oneMinusT * clampedT * line.controlY + clampedT * clampedT * line.endY;
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
localX = line.startX + (line.endX - line.startX) * clampedT;
|
|
733
|
+
localY = line.startY + (line.endY - line.startY) * clampedT;
|
|
734
|
+
}
|
|
735
|
+
const world = this.lineLocalToWorld(line, localX, localY);
|
|
736
|
+
return { t: clampedT, localX, localY, worldX: world.x, worldY: world.y };
|
|
737
|
+
}
|
|
738
|
+
evaluateDerivativeSpeedAtT(line, t) {
|
|
739
|
+
const clampedT = Math.max(0, Math.min(1, t));
|
|
740
|
+
if (line.controlX !== undefined && line.controlY !== undefined) {
|
|
741
|
+
// Quadratic Bezier derivative: B'(t) = 2(1-t)(P1-P0) + 2t(P2-P1)
|
|
742
|
+
const p0x = line.startX;
|
|
743
|
+
const p0y = line.startY;
|
|
744
|
+
const p1x = line.controlX;
|
|
745
|
+
const p1y = line.controlY;
|
|
746
|
+
const p2x = line.endX;
|
|
747
|
+
const p2y = line.endY;
|
|
748
|
+
const dx = 2 * (1 - clampedT) * (p1x - p0x) + 2 * clampedT * (p2x - p1x);
|
|
749
|
+
const dy = 2 * (1 - clampedT) * (p1y - p0y) + 2 * clampedT * (p2y - p1y);
|
|
750
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
751
|
+
}
|
|
752
|
+
else {
|
|
753
|
+
// Straight line speed is constant length
|
|
754
|
+
const dx = line.endX - line.startX;
|
|
755
|
+
const dy = line.endY - line.startY;
|
|
756
|
+
return Math.sqrt(dx * dx + dy * dy);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
extractQuadraticSegment(start, control, end, tStart, tEnd) {
|
|
760
|
+
let normalizedStart = Math.max(0, Math.min(1, tStart));
|
|
761
|
+
let normalizedEnd = Math.max(0, Math.min(1, tEnd));
|
|
762
|
+
if (normalizedEnd < normalizedStart) {
|
|
763
|
+
const swap = normalizedStart;
|
|
764
|
+
normalizedStart = normalizedEnd;
|
|
765
|
+
normalizedEnd = swap;
|
|
766
|
+
}
|
|
767
|
+
let segment = { start, control, end };
|
|
768
|
+
if (normalizedStart > 0) {
|
|
769
|
+
const split = this.splitQuadraticSegment(segment, normalizedStart);
|
|
770
|
+
segment = split.right;
|
|
771
|
+
const remainingRange = 1 - normalizedStart;
|
|
772
|
+
normalizedEnd = remainingRange > 0 ? (normalizedEnd - normalizedStart) / remainingRange : 1;
|
|
773
|
+
}
|
|
774
|
+
if (normalizedEnd < 1) {
|
|
775
|
+
const split = this.splitQuadraticSegment(segment, normalizedEnd);
|
|
776
|
+
segment = split.left;
|
|
777
|
+
}
|
|
778
|
+
return segment;
|
|
779
|
+
}
|
|
780
|
+
splitQuadraticSegment(segment, t) {
|
|
781
|
+
const clampedT = Math.max(0, Math.min(1, t));
|
|
782
|
+
const p0 = segment.start;
|
|
783
|
+
const p1 = segment.control;
|
|
784
|
+
const p2 = segment.end;
|
|
785
|
+
const p01 = this.lerpPoint(p0, p1, clampedT);
|
|
786
|
+
const p12 = this.lerpPoint(p1, p2, clampedT);
|
|
787
|
+
const p012 = this.lerpPoint(p01, p12, clampedT);
|
|
788
|
+
return {
|
|
789
|
+
left: { start: p0, control: p01, end: p012 },
|
|
790
|
+
right: { start: p012, control: p12, end: p2 },
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
lerpPoint(a, b, t) {
|
|
794
|
+
return {
|
|
795
|
+
x: a.x + (b.x - a.x) * t,
|
|
796
|
+
y: a.y + (b.y - a.y) * t,
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
buildAnchorPath(line, endpoint, clipInfo, targetObject) {
|
|
800
|
+
if (line.controlX === undefined || line.controlY === undefined || clipInfo.t === undefined) {
|
|
801
|
+
return `M ${clipInfo.worldX} ${clipInfo.worldY} L ${targetObject.centerX} ${targetObject.centerY}`;
|
|
802
|
+
}
|
|
803
|
+
const startT = endpoint === 'start' ? 0 : clipInfo.t;
|
|
804
|
+
const endT = endpoint === 'start' ? clipInfo.t : 1;
|
|
805
|
+
if (endT <= startT) {
|
|
806
|
+
return `M ${clipInfo.worldX} ${clipInfo.worldY} L ${targetObject.centerX} ${targetObject.centerY}`;
|
|
807
|
+
}
|
|
808
|
+
const segment = this.extractQuadraticSegment({ x: line.startX, y: line.startY }, { x: line.controlX, y: line.controlY }, { x: line.endX, y: line.endY }, startT, endT);
|
|
809
|
+
const reverse = endpoint === 'start';
|
|
810
|
+
return this.buildWorldQuadraticPath(line, segment, reverse);
|
|
811
|
+
}
|
|
812
|
+
buildWorldQuadraticPath(line, segment, reverse = false) {
|
|
813
|
+
const startPoint = reverse ? segment.end : segment.start;
|
|
814
|
+
const endPoint = reverse ? segment.start : segment.end;
|
|
815
|
+
const controlPoint = segment.control;
|
|
816
|
+
const startWorld = this.lineLocalToWorld(line, startPoint.x, startPoint.y);
|
|
817
|
+
const controlWorld = this.lineLocalToWorld(line, controlPoint.x, controlPoint.y);
|
|
818
|
+
const endWorld = this.lineLocalToWorld(line, endPoint.x, endPoint.y);
|
|
819
|
+
return `M ${startWorld.x} ${startWorld.y} Q ${controlWorld.x} ${controlWorld.y} ${endWorld.x} ${endWorld.y}`;
|
|
820
|
+
}
|
|
821
|
+
getPolygonPoints(polygon) {
|
|
822
|
+
return [polygon.topLeft, polygon.topRight, polygon.bottomRight, polygon.bottomLeft];
|
|
823
|
+
}
|
|
824
|
+
/**
|
|
825
|
+
* Converts local line coordinates to world coordinates.
|
|
826
|
+
*/
|
|
827
|
+
lineLocalToWorld(line, localX, localY) {
|
|
828
|
+
const px = localX - line.x;
|
|
829
|
+
const py = localY - line.y;
|
|
830
|
+
const cx = line.totalWidth / 2;
|
|
831
|
+
const cy = line.totalHeight / 2;
|
|
832
|
+
const cos = Math.cos(line.rotation);
|
|
833
|
+
const sin = Math.sin(line.rotation);
|
|
834
|
+
const rotatedX = (px - cx) * cos - (py - cy) * sin + cx;
|
|
835
|
+
const rotatedY = (px - cx) * sin + (py - cy) * cos + cy;
|
|
836
|
+
return {
|
|
837
|
+
x: rotatedX / line.scale + line.translateX,
|
|
838
|
+
y: rotatedY / line.scale + line.translateY,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Converts world coordinates to line's local SVG coordinates.
|
|
843
|
+
*/
|
|
844
|
+
lineWorldToLocal(line, worldX, worldY) {
|
|
845
|
+
const dx = (worldX - line.translateX) * line.scale;
|
|
846
|
+
const dy = (worldY - line.translateY) * line.scale;
|
|
847
|
+
const cx = line.totalWidth / 2;
|
|
848
|
+
const cy = line.totalHeight / 2;
|
|
849
|
+
const cos = Math.cos(-line.rotation);
|
|
850
|
+
const sin = Math.sin(-line.rotation);
|
|
851
|
+
const rotatedX = (dx - cx) * cos - (dy - cy) * sin + cx;
|
|
852
|
+
const rotatedY = (dx - cx) * sin + (dy - cy) * cos + cy;
|
|
853
|
+
return {
|
|
854
|
+
x: rotatedX + line.x,
|
|
855
|
+
y: rotatedY + line.y,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
/**
|
|
859
|
+
* Checks if an object can be used as an anchor target.
|
|
860
|
+
*/
|
|
861
|
+
isAnchorable(object) {
|
|
862
|
+
// Exclude selection-related objects
|
|
863
|
+
if (object instanceof KritzelSelectionBox || object instanceof KritzelSelectionGroup) {
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
// Exclude line objects - lines cannot be anchored to other lines
|
|
867
|
+
if (object instanceof KritzelLine) {
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
// All other visible objects can be anchored to
|
|
871
|
+
return object.isVisible;
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
//# sourceMappingURL=anchor.manager.js.map
|