@emasoft/svg-matrix 1.0.5 → 1.0.6
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/README.md +317 -396
- package/package.json +19 -1
- package/src/browser-verify.js +463 -0
- package/src/clip-path-resolver.js +759 -0
- package/src/geometry-to-path.js +348 -0
- package/src/index.js +413 -6
- package/src/marker-resolver.js +1006 -0
- package/src/mask-resolver.js +1407 -0
- package/src/mesh-gradient.js +1215 -0
- package/src/pattern-resolver.js +844 -0
- package/src/polygon-clip.js +1491 -0
- package/src/svg-flatten.js +1264 -105
- package/src/text-to-path.js +820 -0
- package/src/transforms2d.js +493 -37
- package/src/transforms3d.js +418 -47
- package/src/use-symbol-resolver.js +1126 -0
- package/samples/preserveAspectRatio_SVG.svg +0 -63
- package/samples/test.svg +0 -39
|
@@ -0,0 +1,1006 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marker Resolver Module - Resolve and apply SVG markers to paths
|
|
3
|
+
*
|
|
4
|
+
* Resolves SVG marker elements and applies them to path endpoints and vertices.
|
|
5
|
+
* Markers are symbols placed at vertices of paths, lines, polylines, and polygons.
|
|
6
|
+
*
|
|
7
|
+
* Supports:
|
|
8
|
+
* - marker-start, marker-mid, marker-end attributes
|
|
9
|
+
* - markerWidth, markerHeight, refX, refY
|
|
10
|
+
* - orient (auto, auto-start-reverse, fixed angles)
|
|
11
|
+
* - markerUnits (strokeWidth, userSpaceOnUse)
|
|
12
|
+
* - viewBox and preserveAspectRatio
|
|
13
|
+
* - Transform calculation and application
|
|
14
|
+
*
|
|
15
|
+
* @module marker-resolver
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import Decimal from 'decimal.js';
|
|
19
|
+
import { Matrix } from './matrix.js';
|
|
20
|
+
import * as Transforms2D from './transforms2d.js';
|
|
21
|
+
|
|
22
|
+
Decimal.set({ precision: 80 });
|
|
23
|
+
|
|
24
|
+
const D = x => (x instanceof Decimal ? x : new Decimal(x));
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse an SVG marker element to extract all marker properties.
|
|
28
|
+
*
|
|
29
|
+
* SVG markers are symbols that can be attached to the vertices of paths, lines,
|
|
30
|
+
* polylines, and polygons. The marker element defines the appearance and how
|
|
31
|
+
* the marker should be positioned and oriented at each vertex.
|
|
32
|
+
*
|
|
33
|
+
* The refX and refY attributes define the reference point within the marker
|
|
34
|
+
* coordinate system that should be aligned with the path vertex.
|
|
35
|
+
*
|
|
36
|
+
* The orient attribute controls marker rotation:
|
|
37
|
+
* - "auto": Marker is rotated to align with the path direction at the vertex
|
|
38
|
+
* - "auto-start-reverse": Like "auto" but rotated 180° for start markers
|
|
39
|
+
* - Angle value (e.g., "45"): Fixed rotation angle in degrees
|
|
40
|
+
*
|
|
41
|
+
* @param {Element} markerElement - SVG marker DOM element
|
|
42
|
+
* @returns {Object} Parsed marker definition with properties:
|
|
43
|
+
* - id {string} - Marker element id
|
|
44
|
+
* - markerWidth {number} - Marker viewport width (default: 3)
|
|
45
|
+
* - markerHeight {number} - Marker viewport height (default: 3)
|
|
46
|
+
* - refX {number} - Reference point X coordinate (default: 0)
|
|
47
|
+
* - refY {number} - Reference point Y coordinate (default: 0)
|
|
48
|
+
* - orient {string|number} - Orientation: "auto", "auto-start-reverse", or angle in degrees
|
|
49
|
+
* - markerUnits {string} - Coordinate system: "strokeWidth" or "userSpaceOnUse" (default: "strokeWidth")
|
|
50
|
+
* - viewBox {Object|null} - Parsed viewBox {x, y, width, height} or null
|
|
51
|
+
* - preserveAspectRatio {string} - preserveAspectRatio value (default: "xMidYMid meet")
|
|
52
|
+
* - children {Array} - Array of child element data
|
|
53
|
+
*
|
|
54
|
+
* @example
|
|
55
|
+
* // Parse an arrow marker
|
|
56
|
+
* const marker = document.querySelector('marker#arrow');
|
|
57
|
+
* // <marker id="arrow" markerWidth="10" markerHeight="10" refX="5" refY="5" orient="auto">
|
|
58
|
+
* // <path d="M 0 0 L 10 5 L 0 10 z" fill="black"/>
|
|
59
|
+
* // </marker>
|
|
60
|
+
* const parsed = parseMarkerElement(marker);
|
|
61
|
+
* // {
|
|
62
|
+
* // id: 'arrow',
|
|
63
|
+
* // markerWidth: 10, markerHeight: 10,
|
|
64
|
+
* // refX: 5, refY: 5,
|
|
65
|
+
* // orient: 'auto',
|
|
66
|
+
* // markerUnits: 'strokeWidth',
|
|
67
|
+
* // viewBox: null,
|
|
68
|
+
* // preserveAspectRatio: 'xMidYMid meet',
|
|
69
|
+
* // children: [{ type: 'path', d: 'M 0 0 L 10 5 L 0 10 z', ... }]
|
|
70
|
+
* // }
|
|
71
|
+
*/
|
|
72
|
+
export function parseMarkerElement(markerElement) {
|
|
73
|
+
const data = {
|
|
74
|
+
id: markerElement.getAttribute('id') || '',
|
|
75
|
+
markerWidth: parseFloat(markerElement.getAttribute('markerWidth') || '3'),
|
|
76
|
+
markerHeight: parseFloat(markerElement.getAttribute('markerHeight') || '3'),
|
|
77
|
+
refX: parseFloat(markerElement.getAttribute('refX') || '0'),
|
|
78
|
+
refY: parseFloat(markerElement.getAttribute('refY') || '0'),
|
|
79
|
+
orient: markerElement.getAttribute('orient') || 'auto',
|
|
80
|
+
markerUnits: markerElement.getAttribute('markerUnits') || 'strokeWidth',
|
|
81
|
+
viewBox: null,
|
|
82
|
+
preserveAspectRatio: markerElement.getAttribute('preserveAspectRatio') || 'xMidYMid meet',
|
|
83
|
+
children: []
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
// Parse viewBox if present
|
|
87
|
+
const viewBoxStr = markerElement.getAttribute('viewBox');
|
|
88
|
+
if (viewBoxStr) {
|
|
89
|
+
const parts = viewBoxStr.trim().split(/[\s,]+/).map(Number);
|
|
90
|
+
if (parts.length === 4) {
|
|
91
|
+
data.viewBox = {
|
|
92
|
+
x: parts[0],
|
|
93
|
+
y: parts[1],
|
|
94
|
+
width: parts[2],
|
|
95
|
+
height: parts[3]
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Parse orient attribute
|
|
101
|
+
if (data.orient !== 'auto' && data.orient !== 'auto-start-reverse') {
|
|
102
|
+
// Parse as angle in degrees
|
|
103
|
+
const angle = parseFloat(data.orient);
|
|
104
|
+
if (!isNaN(angle)) {
|
|
105
|
+
data.orient = angle;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Parse child elements
|
|
110
|
+
for (const child of markerElement.children) {
|
|
111
|
+
data.children.push(parseMarkerChild(child));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return data;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parse a child element within a marker.
|
|
119
|
+
*
|
|
120
|
+
* @param {Element} element - SVG DOM element
|
|
121
|
+
* @returns {Object} Parsed element data
|
|
122
|
+
*/
|
|
123
|
+
export function parseMarkerChild(element) {
|
|
124
|
+
const tagName = element.tagName.toLowerCase();
|
|
125
|
+
const data = {
|
|
126
|
+
type: tagName,
|
|
127
|
+
id: element.getAttribute('id') || null,
|
|
128
|
+
transform: element.getAttribute('transform') || null,
|
|
129
|
+
fill: element.getAttribute('fill') || null,
|
|
130
|
+
stroke: element.getAttribute('stroke') || null,
|
|
131
|
+
strokeWidth: element.getAttribute('stroke-width') || null,
|
|
132
|
+
opacity: element.getAttribute('opacity') || null
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
switch (tagName) {
|
|
136
|
+
case 'path':
|
|
137
|
+
data.d = element.getAttribute('d') || '';
|
|
138
|
+
break;
|
|
139
|
+
case 'rect':
|
|
140
|
+
data.x = parseFloat(element.getAttribute('x') || '0');
|
|
141
|
+
data.y = parseFloat(element.getAttribute('y') || '0');
|
|
142
|
+
data.width = parseFloat(element.getAttribute('width') || '0');
|
|
143
|
+
data.height = parseFloat(element.getAttribute('height') || '0');
|
|
144
|
+
break;
|
|
145
|
+
case 'circle':
|
|
146
|
+
data.cx = parseFloat(element.getAttribute('cx') || '0');
|
|
147
|
+
data.cy = parseFloat(element.getAttribute('cy') || '0');
|
|
148
|
+
data.r = parseFloat(element.getAttribute('r') || '0');
|
|
149
|
+
break;
|
|
150
|
+
case 'ellipse':
|
|
151
|
+
data.cx = parseFloat(element.getAttribute('cx') || '0');
|
|
152
|
+
data.cy = parseFloat(element.getAttribute('cy') || '0');
|
|
153
|
+
data.rx = parseFloat(element.getAttribute('rx') || '0');
|
|
154
|
+
data.ry = parseFloat(element.getAttribute('ry') || '0');
|
|
155
|
+
break;
|
|
156
|
+
case 'line':
|
|
157
|
+
data.x1 = parseFloat(element.getAttribute('x1') || '0');
|
|
158
|
+
data.y1 = parseFloat(element.getAttribute('y1') || '0');
|
|
159
|
+
data.x2 = parseFloat(element.getAttribute('x2') || '0');
|
|
160
|
+
data.y2 = parseFloat(element.getAttribute('y2') || '0');
|
|
161
|
+
break;
|
|
162
|
+
case 'polygon':
|
|
163
|
+
case 'polyline':
|
|
164
|
+
data.points = element.getAttribute('points') || '';
|
|
165
|
+
break;
|
|
166
|
+
default:
|
|
167
|
+
// Store any additional attributes for unknown elements
|
|
168
|
+
data.attributes = {};
|
|
169
|
+
for (const attr of element.attributes) {
|
|
170
|
+
data.attributes[attr.name] = attr.value;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return data;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Calculate the transformation matrix for a marker instance.
|
|
179
|
+
*
|
|
180
|
+
* The marker transform combines several transformations in order:
|
|
181
|
+
* 1. Translate to the path vertex position
|
|
182
|
+
* 2. Rotate according to orient attribute (auto, auto-start-reverse, or fixed angle)
|
|
183
|
+
* 3. Scale according to markerUnits (strokeWidth or userSpaceOnUse)
|
|
184
|
+
* 4. Apply viewBox transformation if present
|
|
185
|
+
* 5. Translate by -refX, -refY to align the reference point
|
|
186
|
+
*
|
|
187
|
+
* For markerUnits="strokeWidth", the marker is scaled by the stroke width.
|
|
188
|
+
* For markerUnits="userSpaceOnUse", no additional scaling is applied.
|
|
189
|
+
*
|
|
190
|
+
* @param {Object} markerDef - Parsed marker definition from parseMarkerElement
|
|
191
|
+
* @param {Object} position - Vertex position {x, y} in user coordinates
|
|
192
|
+
* @param {number} tangentAngle - Tangent angle at vertex in radians (for orient="auto")
|
|
193
|
+
* @param {number} strokeWidth - Stroke width for scaling (default: 1)
|
|
194
|
+
* @param {boolean} [isStart=false] - Whether this is a start marker (for auto-start-reverse)
|
|
195
|
+
* @returns {Matrix} 3x3 transformation matrix in homogeneous coordinates
|
|
196
|
+
*
|
|
197
|
+
* @example
|
|
198
|
+
* // Calculate transform for an arrow marker at path start
|
|
199
|
+
* const markerDef = parseMarkerElement(markerEl);
|
|
200
|
+
* const position = { x: 100, y: 200 };
|
|
201
|
+
* const tangentAngle = Math.PI / 4; // 45 degrees
|
|
202
|
+
* const strokeWidth = 2;
|
|
203
|
+
* const transform = getMarkerTransform(markerDef, position, tangentAngle, strokeWidth, true);
|
|
204
|
+
*/
|
|
205
|
+
export function getMarkerTransform(markerDef, position, tangentAngle, strokeWidth = 1, isStart = false) {
|
|
206
|
+
const { markerWidth, markerHeight, refX, refY, orient, markerUnits, viewBox } = markerDef;
|
|
207
|
+
|
|
208
|
+
// Start with identity matrix
|
|
209
|
+
let transform = Matrix.identity(3);
|
|
210
|
+
|
|
211
|
+
// Step 1: Translate to position
|
|
212
|
+
const translateToPosition = Transforms2D.translation(position.x, position.y);
|
|
213
|
+
transform = transform.mul(translateToPosition);
|
|
214
|
+
|
|
215
|
+
// Step 2: Calculate rotation angle
|
|
216
|
+
let rotationAngle = 0;
|
|
217
|
+
if (orient === 'auto') {
|
|
218
|
+
rotationAngle = tangentAngle;
|
|
219
|
+
} else if (orient === 'auto-start-reverse' && isStart) {
|
|
220
|
+
rotationAngle = tangentAngle + Math.PI; // Add 180 degrees
|
|
221
|
+
} else if (typeof orient === 'number') {
|
|
222
|
+
rotationAngle = orient * (Math.PI / 180); // Convert degrees to radians
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Apply rotation
|
|
226
|
+
if (rotationAngle !== 0) {
|
|
227
|
+
const rotation = Transforms2D.rotate(rotationAngle);
|
|
228
|
+
transform = transform.mul(rotation);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Step 3: Apply markerUnits scaling
|
|
232
|
+
let scaleX = 1;
|
|
233
|
+
let scaleY = 1;
|
|
234
|
+
if (markerUnits === 'strokeWidth') {
|
|
235
|
+
scaleX = strokeWidth;
|
|
236
|
+
scaleY = strokeWidth;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Step 4: Apply viewBox transformation if present
|
|
240
|
+
if (viewBox) {
|
|
241
|
+
// Calculate scale factors to fit viewBox into marker viewport
|
|
242
|
+
const vbWidth = viewBox.width;
|
|
243
|
+
const vbHeight = viewBox.height;
|
|
244
|
+
|
|
245
|
+
if (vbWidth > 0 && vbHeight > 0) {
|
|
246
|
+
// Calculate uniform scale factor based on preserveAspectRatio
|
|
247
|
+
const scaleFactorX = markerWidth / vbWidth;
|
|
248
|
+
const scaleFactorY = markerHeight / vbHeight;
|
|
249
|
+
|
|
250
|
+
// For now, use uniform scaling (can be enhanced with full preserveAspectRatio parsing)
|
|
251
|
+
const scaleFactor = Math.min(scaleFactorX, scaleFactorY);
|
|
252
|
+
|
|
253
|
+
scaleX *= scaleFactor;
|
|
254
|
+
scaleY *= scaleFactor;
|
|
255
|
+
|
|
256
|
+
// Translate to account for viewBox origin
|
|
257
|
+
const viewBoxTranslate = Transforms2D.translation(-viewBox.x, -viewBox.y);
|
|
258
|
+
transform = transform.mul(viewBoxTranslate);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Apply combined scaling
|
|
263
|
+
if (scaleX !== 1 || scaleY !== 1) {
|
|
264
|
+
const scale = Transforms2D.scale(scaleX, scaleY);
|
|
265
|
+
transform = transform.mul(scale);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Step 5: Translate by -refX, -refY
|
|
269
|
+
const refTranslate = Transforms2D.translation(-refX, -refY);
|
|
270
|
+
transform = transform.mul(refTranslate);
|
|
271
|
+
|
|
272
|
+
return transform;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Extract vertices and tangent angles from SVG path data.
|
|
277
|
+
*
|
|
278
|
+
* Analyzes path commands to identify all vertices (points where markers can be placed)
|
|
279
|
+
* and calculates the incoming and outgoing tangent angles at each vertex.
|
|
280
|
+
*
|
|
281
|
+
* Vertices are identified at:
|
|
282
|
+
* - M (moveto) commands - path starts
|
|
283
|
+
* - L (lineto) endpoints
|
|
284
|
+
* - C (cubic bezier) endpoints
|
|
285
|
+
* - Q (quadratic bezier) endpoints
|
|
286
|
+
* - A (arc) endpoints
|
|
287
|
+
* - Z (closepath) creates a vertex at the path start
|
|
288
|
+
*
|
|
289
|
+
* For marker-start: use first vertex
|
|
290
|
+
* For marker-end: use last vertex
|
|
291
|
+
* For marker-mid: use all vertices except first and last
|
|
292
|
+
*
|
|
293
|
+
* @param {string} pathData - SVG path d attribute
|
|
294
|
+
* @returns {Array<Object>} Array of vertex objects, each with:
|
|
295
|
+
* - x {number} - X coordinate
|
|
296
|
+
* - y {number} - Y coordinate
|
|
297
|
+
* - tangentIn {number} - Incoming tangent angle in radians
|
|
298
|
+
* - tangentOut {number} - Outgoing tangent angle in radians
|
|
299
|
+
* - index {number} - Vertex index in path
|
|
300
|
+
*
|
|
301
|
+
* @example
|
|
302
|
+
* // Extract vertices from a path
|
|
303
|
+
* const vertices = getPathVertices("M 0 0 L 100 0 L 100 100 L 0 100 Z");
|
|
304
|
+
* // Returns 5 vertices (including closing vertex):
|
|
305
|
+
* // [
|
|
306
|
+
* // { x: 0, y: 0, tangentIn: 0, tangentOut: 0, index: 0 },
|
|
307
|
+
* // { x: 100, y: 0, tangentIn: 0, tangentOut: Math.PI/2, index: 1 },
|
|
308
|
+
* // { x: 100, y: 100, tangentIn: Math.PI/2, tangentOut: Math.PI, index: 2 },
|
|
309
|
+
* // { x: 0, y: 100, tangentIn: Math.PI, tangentOut: -Math.PI/2, index: 3 },
|
|
310
|
+
* // { x: 0, y: 0, tangentIn: -Math.PI/2, tangentOut: 0, index: 4 }
|
|
311
|
+
* // ]
|
|
312
|
+
*/
|
|
313
|
+
export function getPathVertices(pathData) {
|
|
314
|
+
const vertices = [];
|
|
315
|
+
let currentX = 0;
|
|
316
|
+
let currentY = 0;
|
|
317
|
+
let startX = 0;
|
|
318
|
+
let startY = 0;
|
|
319
|
+
let lastControlX = 0;
|
|
320
|
+
let lastControlY = 0;
|
|
321
|
+
|
|
322
|
+
// Parse path commands
|
|
323
|
+
const commands = parsePathCommands(pathData);
|
|
324
|
+
|
|
325
|
+
for (let i = 0; i < commands.length; i++) {
|
|
326
|
+
const cmd = commands[i];
|
|
327
|
+
const prevX = currentX;
|
|
328
|
+
const prevY = currentY;
|
|
329
|
+
|
|
330
|
+
switch (cmd.type) {
|
|
331
|
+
case 'M': // moveto
|
|
332
|
+
currentX = cmd.x;
|
|
333
|
+
currentY = cmd.y;
|
|
334
|
+
startX = currentX;
|
|
335
|
+
startY = currentY;
|
|
336
|
+
|
|
337
|
+
// Calculate tangent for previous vertex if exists
|
|
338
|
+
if (vertices.length > 0) {
|
|
339
|
+
const prev = vertices[vertices.length - 1];
|
|
340
|
+
const angle = Math.atan2(currentY - prevY, currentX - prevX);
|
|
341
|
+
prev.tangentOut = angle;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Add new vertex (tangents will be calculated later)
|
|
345
|
+
vertices.push({
|
|
346
|
+
x: currentX,
|
|
347
|
+
y: currentY,
|
|
348
|
+
tangentIn: 0,
|
|
349
|
+
tangentOut: 0,
|
|
350
|
+
index: vertices.length
|
|
351
|
+
});
|
|
352
|
+
break;
|
|
353
|
+
|
|
354
|
+
case 'L': // lineto
|
|
355
|
+
currentX = cmd.x;
|
|
356
|
+
currentY = cmd.y;
|
|
357
|
+
|
|
358
|
+
// Calculate tangent angle
|
|
359
|
+
const lineAngle = Math.atan2(currentY - prevY, currentX - prevX);
|
|
360
|
+
|
|
361
|
+
// Update previous vertex's outgoing tangent
|
|
362
|
+
if (vertices.length > 0) {
|
|
363
|
+
vertices[vertices.length - 1].tangentOut = lineAngle;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Add new vertex
|
|
367
|
+
vertices.push({
|
|
368
|
+
x: currentX,
|
|
369
|
+
y: currentY,
|
|
370
|
+
tangentIn: lineAngle,
|
|
371
|
+
tangentOut: lineAngle,
|
|
372
|
+
index: vertices.length
|
|
373
|
+
});
|
|
374
|
+
break;
|
|
375
|
+
|
|
376
|
+
case 'C': // cubic bezier
|
|
377
|
+
lastControlX = cmd.x2;
|
|
378
|
+
lastControlY = cmd.y2;
|
|
379
|
+
currentX = cmd.x;
|
|
380
|
+
currentY = cmd.y;
|
|
381
|
+
|
|
382
|
+
// Calculate tangent at start (direction to first control point)
|
|
383
|
+
const startTangent = Math.atan2(cmd.y1 - prevY, cmd.x1 - prevX);
|
|
384
|
+
|
|
385
|
+
// Calculate tangent at end (direction from last control point)
|
|
386
|
+
const endTangent = Math.atan2(currentY - cmd.y2, currentX - cmd.x2);
|
|
387
|
+
|
|
388
|
+
// Update previous vertex's outgoing tangent
|
|
389
|
+
if (vertices.length > 0) {
|
|
390
|
+
vertices[vertices.length - 1].tangentOut = startTangent;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Add new vertex
|
|
394
|
+
vertices.push({
|
|
395
|
+
x: currentX,
|
|
396
|
+
y: currentY,
|
|
397
|
+
tangentIn: endTangent,
|
|
398
|
+
tangentOut: endTangent,
|
|
399
|
+
index: vertices.length
|
|
400
|
+
});
|
|
401
|
+
break;
|
|
402
|
+
|
|
403
|
+
case 'Q': // quadratic bezier
|
|
404
|
+
lastControlX = cmd.x1;
|
|
405
|
+
lastControlY = cmd.y1;
|
|
406
|
+
currentX = cmd.x;
|
|
407
|
+
currentY = cmd.y;
|
|
408
|
+
|
|
409
|
+
// Calculate tangent at start
|
|
410
|
+
const qStartTangent = Math.atan2(cmd.y1 - prevY, cmd.x1 - prevX);
|
|
411
|
+
|
|
412
|
+
// Calculate tangent at end
|
|
413
|
+
const qEndTangent = Math.atan2(currentY - cmd.y1, currentX - cmd.x1);
|
|
414
|
+
|
|
415
|
+
// Update previous vertex's outgoing tangent
|
|
416
|
+
if (vertices.length > 0) {
|
|
417
|
+
vertices[vertices.length - 1].tangentOut = qStartTangent;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Add new vertex
|
|
421
|
+
vertices.push({
|
|
422
|
+
x: currentX,
|
|
423
|
+
y: currentY,
|
|
424
|
+
tangentIn: qEndTangent,
|
|
425
|
+
tangentOut: qEndTangent,
|
|
426
|
+
index: vertices.length
|
|
427
|
+
});
|
|
428
|
+
break;
|
|
429
|
+
|
|
430
|
+
case 'A': // arc
|
|
431
|
+
currentX = cmd.x;
|
|
432
|
+
currentY = cmd.y;
|
|
433
|
+
|
|
434
|
+
// Simplified tangent calculation (could be improved with arc geometry)
|
|
435
|
+
const arcAngle = Math.atan2(currentY - prevY, currentX - prevX);
|
|
436
|
+
|
|
437
|
+
// Update previous vertex's outgoing tangent
|
|
438
|
+
if (vertices.length > 0) {
|
|
439
|
+
vertices[vertices.length - 1].tangentOut = arcAngle;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Add new vertex
|
|
443
|
+
vertices.push({
|
|
444
|
+
x: currentX,
|
|
445
|
+
y: currentY,
|
|
446
|
+
tangentIn: arcAngle,
|
|
447
|
+
tangentOut: arcAngle,
|
|
448
|
+
index: vertices.length
|
|
449
|
+
});
|
|
450
|
+
break;
|
|
451
|
+
|
|
452
|
+
case 'Z': // closepath
|
|
453
|
+
currentX = startX;
|
|
454
|
+
currentY = startY;
|
|
455
|
+
|
|
456
|
+
// Calculate tangent from last point to start
|
|
457
|
+
const closeAngle = Math.atan2(currentY - prevY, currentX - prevX);
|
|
458
|
+
|
|
459
|
+
// Update previous vertex's outgoing tangent
|
|
460
|
+
if (vertices.length > 0) {
|
|
461
|
+
vertices[vertices.length - 1].tangentOut = closeAngle;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Update first vertex's incoming tangent
|
|
465
|
+
if (vertices.length > 0) {
|
|
466
|
+
vertices[0].tangentIn = closeAngle;
|
|
467
|
+
}
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
return vertices;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Parse SVG path data into structured commands.
|
|
477
|
+
*
|
|
478
|
+
* @param {string} pathData - SVG path d attribute
|
|
479
|
+
* @returns {Array<Object>} Array of command objects
|
|
480
|
+
*/
|
|
481
|
+
export function parsePathCommands(pathData) {
|
|
482
|
+
const commands = [];
|
|
483
|
+
|
|
484
|
+
// Normalize path data: add spaces around command letters
|
|
485
|
+
const normalized = pathData
|
|
486
|
+
.replace(/([MmLlHhVvCcSsQqTtAaZz])/g, ' $1 ')
|
|
487
|
+
.trim();
|
|
488
|
+
|
|
489
|
+
// Split into tokens
|
|
490
|
+
const tokens = normalized.split(/[\s,]+/).filter(t => t.length > 0);
|
|
491
|
+
|
|
492
|
+
let i = 0;
|
|
493
|
+
let currentX = 0;
|
|
494
|
+
let currentY = 0;
|
|
495
|
+
|
|
496
|
+
while (i < tokens.length) {
|
|
497
|
+
const cmdType = tokens[i];
|
|
498
|
+
|
|
499
|
+
switch (cmdType.toUpperCase()) {
|
|
500
|
+
case 'M': // moveto
|
|
501
|
+
const mx = parseFloat(tokens[i + 1]);
|
|
502
|
+
const my = parseFloat(tokens[i + 2]);
|
|
503
|
+
commands.push({
|
|
504
|
+
type: 'M',
|
|
505
|
+
x: cmdType === 'M' ? mx : currentX + mx,
|
|
506
|
+
y: cmdType === 'M' ? my : currentY + my
|
|
507
|
+
});
|
|
508
|
+
currentX = commands[commands.length - 1].x;
|
|
509
|
+
currentY = commands[commands.length - 1].y;
|
|
510
|
+
i += 3;
|
|
511
|
+
break;
|
|
512
|
+
|
|
513
|
+
case 'L': // lineto
|
|
514
|
+
const lx = parseFloat(tokens[i + 1]);
|
|
515
|
+
const ly = parseFloat(tokens[i + 2]);
|
|
516
|
+
commands.push({
|
|
517
|
+
type: 'L',
|
|
518
|
+
x: cmdType === 'L' ? lx : currentX + lx,
|
|
519
|
+
y: cmdType === 'L' ? ly : currentY + ly
|
|
520
|
+
});
|
|
521
|
+
currentX = commands[commands.length - 1].x;
|
|
522
|
+
currentY = commands[commands.length - 1].y;
|
|
523
|
+
i += 3;
|
|
524
|
+
break;
|
|
525
|
+
|
|
526
|
+
case 'H': // horizontal lineto
|
|
527
|
+
const hx = parseFloat(tokens[i + 1]);
|
|
528
|
+
commands.push({
|
|
529
|
+
type: 'L',
|
|
530
|
+
x: cmdType === 'H' ? hx : currentX + hx,
|
|
531
|
+
y: currentY
|
|
532
|
+
});
|
|
533
|
+
currentX = commands[commands.length - 1].x;
|
|
534
|
+
i += 2;
|
|
535
|
+
break;
|
|
536
|
+
|
|
537
|
+
case 'V': // vertical lineto
|
|
538
|
+
const vy = parseFloat(tokens[i + 1]);
|
|
539
|
+
commands.push({
|
|
540
|
+
type: 'L',
|
|
541
|
+
x: currentX,
|
|
542
|
+
y: cmdType === 'V' ? vy : currentY + vy
|
|
543
|
+
});
|
|
544
|
+
currentY = commands[commands.length - 1].y;
|
|
545
|
+
i += 2;
|
|
546
|
+
break;
|
|
547
|
+
|
|
548
|
+
case 'C': // cubic bezier
|
|
549
|
+
const c1x = parseFloat(tokens[i + 1]);
|
|
550
|
+
const c1y = parseFloat(tokens[i + 2]);
|
|
551
|
+
const c2x = parseFloat(tokens[i + 3]);
|
|
552
|
+
const c2y = parseFloat(tokens[i + 4]);
|
|
553
|
+
const cx = parseFloat(tokens[i + 5]);
|
|
554
|
+
const cy = parseFloat(tokens[i + 6]);
|
|
555
|
+
commands.push({
|
|
556
|
+
type: 'C',
|
|
557
|
+
x1: cmdType === 'C' ? c1x : currentX + c1x,
|
|
558
|
+
y1: cmdType === 'C' ? c1y : currentY + c1y,
|
|
559
|
+
x2: cmdType === 'C' ? c2x : currentX + c2x,
|
|
560
|
+
y2: cmdType === 'C' ? c2y : currentY + c2y,
|
|
561
|
+
x: cmdType === 'C' ? cx : currentX + cx,
|
|
562
|
+
y: cmdType === 'C' ? cy : currentY + cy
|
|
563
|
+
});
|
|
564
|
+
currentX = commands[commands.length - 1].x;
|
|
565
|
+
currentY = commands[commands.length - 1].y;
|
|
566
|
+
i += 7;
|
|
567
|
+
break;
|
|
568
|
+
|
|
569
|
+
case 'Q': // quadratic bezier
|
|
570
|
+
const q1x = parseFloat(tokens[i + 1]);
|
|
571
|
+
const q1y = parseFloat(tokens[i + 2]);
|
|
572
|
+
const qx = parseFloat(tokens[i + 3]);
|
|
573
|
+
const qy = parseFloat(tokens[i + 4]);
|
|
574
|
+
commands.push({
|
|
575
|
+
type: 'Q',
|
|
576
|
+
x1: cmdType === 'Q' ? q1x : currentX + q1x,
|
|
577
|
+
y1: cmdType === 'Q' ? q1y : currentY + q1y,
|
|
578
|
+
x: cmdType === 'Q' ? qx : currentX + qx,
|
|
579
|
+
y: cmdType === 'Q' ? qy : currentY + qy
|
|
580
|
+
});
|
|
581
|
+
currentX = commands[commands.length - 1].x;
|
|
582
|
+
currentY = commands[commands.length - 1].y;
|
|
583
|
+
i += 5;
|
|
584
|
+
break;
|
|
585
|
+
|
|
586
|
+
case 'A': // arc
|
|
587
|
+
const rx = parseFloat(tokens[i + 1]);
|
|
588
|
+
const ry = parseFloat(tokens[i + 2]);
|
|
589
|
+
const xAxisRotation = parseFloat(tokens[i + 3]);
|
|
590
|
+
const largeArcFlag = parseInt(tokens[i + 4]);
|
|
591
|
+
const sweepFlag = parseInt(tokens[i + 5]);
|
|
592
|
+
const ax = parseFloat(tokens[i + 6]);
|
|
593
|
+
const ay = parseFloat(tokens[i + 7]);
|
|
594
|
+
commands.push({
|
|
595
|
+
type: 'A',
|
|
596
|
+
rx,
|
|
597
|
+
ry,
|
|
598
|
+
xAxisRotation,
|
|
599
|
+
largeArcFlag,
|
|
600
|
+
sweepFlag,
|
|
601
|
+
x: cmdType === 'A' ? ax : currentX + ax,
|
|
602
|
+
y: cmdType === 'A' ? ay : currentY + ay
|
|
603
|
+
});
|
|
604
|
+
currentX = commands[commands.length - 1].x;
|
|
605
|
+
currentY = commands[commands.length - 1].y;
|
|
606
|
+
i += 8;
|
|
607
|
+
break;
|
|
608
|
+
|
|
609
|
+
case 'Z': // closepath
|
|
610
|
+
commands.push({ type: 'Z' });
|
|
611
|
+
i += 1;
|
|
612
|
+
break;
|
|
613
|
+
|
|
614
|
+
default:
|
|
615
|
+
// Skip unknown commands
|
|
616
|
+
i += 1;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
return commands;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Resolve markers for a path element.
|
|
625
|
+
*
|
|
626
|
+
* Finds marker-start, marker-mid, and marker-end references on the path,
|
|
627
|
+
* extracts path vertices, and creates marker instances with appropriate
|
|
628
|
+
* transforms for each vertex.
|
|
629
|
+
*
|
|
630
|
+
* The defsMap should contain parsed marker definitions keyed by id.
|
|
631
|
+
*
|
|
632
|
+
* @param {Element} pathElement - SVG path DOM element with marker attributes
|
|
633
|
+
* @param {Object} defsMap - Map of marker id to parsed marker definition
|
|
634
|
+
* @returns {Array<Object>} Array of resolved marker instances, each with:
|
|
635
|
+
* - markerDef {Object} - The marker definition
|
|
636
|
+
* - position {Object} - Vertex position {x, y}
|
|
637
|
+
* - transform {Matrix} - Transform matrix for this marker instance
|
|
638
|
+
* - type {string} - Marker type: "start", "mid", or "end"
|
|
639
|
+
* - vertex {Object} - Full vertex data with tangent angles
|
|
640
|
+
*
|
|
641
|
+
* @example
|
|
642
|
+
* // Resolve markers on a path
|
|
643
|
+
* const path = document.querySelector('path[marker-end]');
|
|
644
|
+
* const defs = document.querySelector('defs');
|
|
645
|
+
* const defsMap = {};
|
|
646
|
+
* for (const marker of defs.querySelectorAll('marker')) {
|
|
647
|
+
* const parsed = parseMarkerElement(marker);
|
|
648
|
+
* defsMap[parsed.id] = parsed;
|
|
649
|
+
* }
|
|
650
|
+
* const instances = resolveMarkers(path, defsMap);
|
|
651
|
+
* // Returns array of marker instances ready to be rendered
|
|
652
|
+
*/
|
|
653
|
+
export function resolveMarkers(pathElement, defsMap) {
|
|
654
|
+
const instances = [];
|
|
655
|
+
|
|
656
|
+
// Get marker references
|
|
657
|
+
const markerStart = pathElement.getAttribute('marker-start');
|
|
658
|
+
const markerMid = pathElement.getAttribute('marker-mid');
|
|
659
|
+
const markerEnd = pathElement.getAttribute('marker-end');
|
|
660
|
+
|
|
661
|
+
// Get stroke width for scaling
|
|
662
|
+
const strokeWidth = parseFloat(pathElement.getAttribute('stroke-width') || '1');
|
|
663
|
+
|
|
664
|
+
// Get path data and extract vertices
|
|
665
|
+
const pathData = pathElement.getAttribute('d') || '';
|
|
666
|
+
const vertices = getPathVertices(pathData);
|
|
667
|
+
|
|
668
|
+
if (vertices.length === 0) {
|
|
669
|
+
return instances;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// Helper to extract marker id from url() reference
|
|
673
|
+
const getMarkerId = (markerRef) => {
|
|
674
|
+
if (!markerRef) return null;
|
|
675
|
+
const match = markerRef.match(/url\(#([^)]+)\)/);
|
|
676
|
+
return match ? match[1] : null;
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
// Apply marker-start to first vertex
|
|
680
|
+
if (markerStart) {
|
|
681
|
+
const markerId = getMarkerId(markerStart);
|
|
682
|
+
const markerDef = markerId ? defsMap[markerId] : null;
|
|
683
|
+
|
|
684
|
+
if (markerDef && vertices.length > 0) {
|
|
685
|
+
const vertex = vertices[0];
|
|
686
|
+
const tangent = vertices.length > 1 ? vertex.tangentOut : vertex.tangentIn;
|
|
687
|
+
|
|
688
|
+
instances.push({
|
|
689
|
+
markerDef,
|
|
690
|
+
position: { x: vertex.x, y: vertex.y },
|
|
691
|
+
transform: getMarkerTransform(markerDef, { x: vertex.x, y: vertex.y }, tangent, strokeWidth, true),
|
|
692
|
+
type: 'start',
|
|
693
|
+
vertex
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
// Apply marker-mid to all middle vertices
|
|
699
|
+
if (markerMid && vertices.length > 2) {
|
|
700
|
+
const markerId = getMarkerId(markerMid);
|
|
701
|
+
const markerDef = markerId ? defsMap[markerId] : null;
|
|
702
|
+
|
|
703
|
+
if (markerDef) {
|
|
704
|
+
for (let i = 1; i < vertices.length - 1; i++) {
|
|
705
|
+
const vertex = vertices[i];
|
|
706
|
+
// Use average of incoming and outgoing tangents for mid markers
|
|
707
|
+
const tangent = (vertex.tangentIn + vertex.tangentOut) / 2;
|
|
708
|
+
|
|
709
|
+
instances.push({
|
|
710
|
+
markerDef,
|
|
711
|
+
position: { x: vertex.x, y: vertex.y },
|
|
712
|
+
transform: getMarkerTransform(markerDef, { x: vertex.x, y: vertex.y }, tangent, strokeWidth, false),
|
|
713
|
+
type: 'mid',
|
|
714
|
+
vertex
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Apply marker-end to last vertex
|
|
721
|
+
if (markerEnd) {
|
|
722
|
+
const markerId = getMarkerId(markerEnd);
|
|
723
|
+
const markerDef = markerId ? defsMap[markerId] : null;
|
|
724
|
+
|
|
725
|
+
if (markerDef && vertices.length > 0) {
|
|
726
|
+
const vertex = vertices[vertices.length - 1];
|
|
727
|
+
const tangent = vertex.tangentIn;
|
|
728
|
+
|
|
729
|
+
instances.push({
|
|
730
|
+
markerDef,
|
|
731
|
+
position: { x: vertex.x, y: vertex.y },
|
|
732
|
+
transform: getMarkerTransform(markerDef, { x: vertex.x, y: vertex.y }, tangent, strokeWidth, false),
|
|
733
|
+
type: 'end',
|
|
734
|
+
vertex
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return instances;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Convert a marker instance to polygon representations.
|
|
744
|
+
*
|
|
745
|
+
* Transforms marker content (paths, shapes) into polygon arrays by:
|
|
746
|
+
* 1. Converting marker children to path commands or polygons
|
|
747
|
+
* 2. Applying the marker transform to all coordinates
|
|
748
|
+
* 3. Returning array of polygons (each polygon is array of {x, y} points)
|
|
749
|
+
*
|
|
750
|
+
* This is useful for rendering markers or performing geometric operations.
|
|
751
|
+
*
|
|
752
|
+
* @param {Object} markerInstance - Marker instance from resolveMarkers()
|
|
753
|
+
* @param {Object} [options={}] - Conversion options
|
|
754
|
+
* @param {number} [options.precision=2] - Decimal precision for coordinates
|
|
755
|
+
* @param {number} [options.curveSegments=10] - Number of segments for curve approximation
|
|
756
|
+
* @returns {Array<Array<Object>>} Array of polygons, each polygon is array of {x, y} points
|
|
757
|
+
*
|
|
758
|
+
* @example
|
|
759
|
+
* // Convert marker to polygons
|
|
760
|
+
* const instances = resolveMarkers(pathEl, defsMap);
|
|
761
|
+
* const polygons = markerToPolygons(instances[0], { precision: 3 });
|
|
762
|
+
* // Returns: [[{x: 100.123, y: 200.456}, ...], ...]
|
|
763
|
+
*/
|
|
764
|
+
export function markerToPolygons(markerInstance, options = {}) {
|
|
765
|
+
const { precision = 2, curveSegments = 10 } = options;
|
|
766
|
+
const polygons = [];
|
|
767
|
+
const { markerDef, transform } = markerInstance;
|
|
768
|
+
|
|
769
|
+
// Process each child element
|
|
770
|
+
for (const child of markerDef.children) {
|
|
771
|
+
let points = [];
|
|
772
|
+
|
|
773
|
+
switch (child.type) {
|
|
774
|
+
case 'path':
|
|
775
|
+
// Parse path and convert to points
|
|
776
|
+
points = pathToPoints(child.d, curveSegments);
|
|
777
|
+
break;
|
|
778
|
+
|
|
779
|
+
case 'rect':
|
|
780
|
+
// Convert rect to 4 corner points
|
|
781
|
+
points = [
|
|
782
|
+
{ x: child.x, y: child.y },
|
|
783
|
+
{ x: child.x + child.width, y: child.y },
|
|
784
|
+
{ x: child.x + child.width, y: child.y + child.height },
|
|
785
|
+
{ x: child.x, y: child.y + child.height }
|
|
786
|
+
];
|
|
787
|
+
break;
|
|
788
|
+
|
|
789
|
+
case 'circle':
|
|
790
|
+
// Approximate circle with polygon
|
|
791
|
+
points = circleToPoints(child.cx, child.cy, child.r, curveSegments * 4);
|
|
792
|
+
break;
|
|
793
|
+
|
|
794
|
+
case 'ellipse':
|
|
795
|
+
// Approximate ellipse with polygon
|
|
796
|
+
points = ellipseToPoints(child.cx, child.cy, child.rx, child.ry, curveSegments * 4);
|
|
797
|
+
break;
|
|
798
|
+
|
|
799
|
+
case 'line':
|
|
800
|
+
points = [
|
|
801
|
+
{ x: child.x1, y: child.y1 },
|
|
802
|
+
{ x: child.x2, y: child.y2 }
|
|
803
|
+
];
|
|
804
|
+
break;
|
|
805
|
+
|
|
806
|
+
case 'polygon':
|
|
807
|
+
case 'polyline':
|
|
808
|
+
points = parsePoints(child.points);
|
|
809
|
+
break;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// Apply transform to all points
|
|
813
|
+
if (points.length > 0) {
|
|
814
|
+
const transformedPoints = points.map(p => {
|
|
815
|
+
const result = Transforms2D.applyTransform(transform, p.x, p.y);
|
|
816
|
+
// result is an array [x, y] with Decimal values
|
|
817
|
+
return {
|
|
818
|
+
x: parseFloat(result[0].toFixed(precision)),
|
|
819
|
+
y: parseFloat(result[1].toFixed(precision))
|
|
820
|
+
};
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
polygons.push(transformedPoints);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
return polygons;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* Convert path data to array of points by linearizing curves.
|
|
832
|
+
*
|
|
833
|
+
* @param {string} pathData - SVG path d attribute
|
|
834
|
+
* @param {number} segments - Number of segments for curve approximation
|
|
835
|
+
* @returns {Array<Object>} Array of {x, y} points
|
|
836
|
+
*/
|
|
837
|
+
export function pathToPoints(pathData, segments = 10) {
|
|
838
|
+
const points = [];
|
|
839
|
+
const commands = parsePathCommands(pathData);
|
|
840
|
+
let currentX = 0;
|
|
841
|
+
let currentY = 0;
|
|
842
|
+
|
|
843
|
+
for (const cmd of commands) {
|
|
844
|
+
switch (cmd.type) {
|
|
845
|
+
case 'M':
|
|
846
|
+
currentX = cmd.x;
|
|
847
|
+
currentY = cmd.y;
|
|
848
|
+
points.push({ x: currentX, y: currentY });
|
|
849
|
+
break;
|
|
850
|
+
|
|
851
|
+
case 'L':
|
|
852
|
+
currentX = cmd.x;
|
|
853
|
+
currentY = cmd.y;
|
|
854
|
+
points.push({ x: currentX, y: currentY });
|
|
855
|
+
break;
|
|
856
|
+
|
|
857
|
+
case 'C':
|
|
858
|
+
// Approximate cubic bezier with line segments
|
|
859
|
+
for (let i = 1; i <= segments; i++) {
|
|
860
|
+
const t = i / segments;
|
|
861
|
+
const t1 = 1 - t;
|
|
862
|
+
const x = t1 * t1 * t1 * currentX +
|
|
863
|
+
3 * t1 * t1 * t * cmd.x1 +
|
|
864
|
+
3 * t1 * t * t * cmd.x2 +
|
|
865
|
+
t * t * t * cmd.x;
|
|
866
|
+
const y = t1 * t1 * t1 * currentY +
|
|
867
|
+
3 * t1 * t1 * t * cmd.y1 +
|
|
868
|
+
3 * t1 * t * t * cmd.y2 +
|
|
869
|
+
t * t * t * cmd.y;
|
|
870
|
+
points.push({ x, y });
|
|
871
|
+
}
|
|
872
|
+
currentX = cmd.x;
|
|
873
|
+
currentY = cmd.y;
|
|
874
|
+
break;
|
|
875
|
+
|
|
876
|
+
case 'Q':
|
|
877
|
+
// Approximate quadratic bezier with line segments
|
|
878
|
+
for (let i = 1; i <= segments; i++) {
|
|
879
|
+
const t = i / segments;
|
|
880
|
+
const t1 = 1 - t;
|
|
881
|
+
const x = t1 * t1 * currentX +
|
|
882
|
+
2 * t1 * t * cmd.x1 +
|
|
883
|
+
t * t * cmd.x;
|
|
884
|
+
const y = t1 * t1 * currentY +
|
|
885
|
+
2 * t1 * t * cmd.y1 +
|
|
886
|
+
t * t * cmd.y;
|
|
887
|
+
points.push({ x, y });
|
|
888
|
+
}
|
|
889
|
+
currentX = cmd.x;
|
|
890
|
+
currentY = cmd.y;
|
|
891
|
+
break;
|
|
892
|
+
|
|
893
|
+
case 'A':
|
|
894
|
+
// Simplified arc approximation
|
|
895
|
+
currentX = cmd.x;
|
|
896
|
+
currentY = cmd.y;
|
|
897
|
+
points.push({ x: currentX, y: currentY });
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return points;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* Convert circle to array of points.
|
|
907
|
+
*
|
|
908
|
+
* @param {number} cx - Center X
|
|
909
|
+
* @param {number} cy - Center Y
|
|
910
|
+
* @param {number} r - Radius
|
|
911
|
+
* @param {number} segments - Number of segments
|
|
912
|
+
* @returns {Array<Object>} Array of {x, y} points
|
|
913
|
+
*/
|
|
914
|
+
export function circleToPoints(cx, cy, r, segments = 32) {
|
|
915
|
+
const points = [];
|
|
916
|
+
for (let i = 0; i < segments; i++) {
|
|
917
|
+
const angle = (i / segments) * 2 * Math.PI;
|
|
918
|
+
points.push({
|
|
919
|
+
x: cx + r * Math.cos(angle),
|
|
920
|
+
y: cy + r * Math.sin(angle)
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
return points;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/**
|
|
927
|
+
* Convert ellipse to array of points.
|
|
928
|
+
*
|
|
929
|
+
* @param {number} cx - Center X
|
|
930
|
+
* @param {number} cy - Center Y
|
|
931
|
+
* @param {number} rx - X radius
|
|
932
|
+
* @param {number} ry - Y radius
|
|
933
|
+
* @param {number} segments - Number of segments
|
|
934
|
+
* @returns {Array<Object>} Array of {x, y} points
|
|
935
|
+
*/
|
|
936
|
+
export function ellipseToPoints(cx, cy, rx, ry, segments = 32) {
|
|
937
|
+
const points = [];
|
|
938
|
+
for (let i = 0; i < segments; i++) {
|
|
939
|
+
const angle = (i / segments) * 2 * Math.PI;
|
|
940
|
+
points.push({
|
|
941
|
+
x: cx + rx * Math.cos(angle),
|
|
942
|
+
y: cy + ry * Math.sin(angle)
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
return points;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* Parse SVG points attribute (for polygon/polyline).
|
|
950
|
+
*
|
|
951
|
+
* @param {string} pointsStr - Points attribute value
|
|
952
|
+
* @returns {Array<Object>} Array of {x, y} points
|
|
953
|
+
*/
|
|
954
|
+
export function parsePoints(pointsStr) {
|
|
955
|
+
const points = [];
|
|
956
|
+
const coords = pointsStr.trim().split(/[\s,]+/).map(Number);
|
|
957
|
+
|
|
958
|
+
for (let i = 0; i < coords.length - 1; i += 2) {
|
|
959
|
+
points.push({ x: coords[i], y: coords[i + 1] });
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
return points;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Convert marker instances to SVG path data.
|
|
967
|
+
*
|
|
968
|
+
* Generates SVG path commands from all marker polygons, useful for
|
|
969
|
+
* exporting markers as standalone paths.
|
|
970
|
+
*
|
|
971
|
+
* @param {Array<Object>} markerInstances - Array of marker instances from resolveMarkers()
|
|
972
|
+
* @param {number} [precision=2] - Decimal precision for coordinates
|
|
973
|
+
* @returns {string} SVG path data (d attribute value)
|
|
974
|
+
*
|
|
975
|
+
* @example
|
|
976
|
+
* // Convert all markers to a single path
|
|
977
|
+
* const instances = resolveMarkers(pathEl, defsMap);
|
|
978
|
+
* const pathData = markersToPathData(instances, 3);
|
|
979
|
+
* // Returns: "M 100.000 200.000 L 105.000 205.000 ... Z M ..."
|
|
980
|
+
*/
|
|
981
|
+
export function markersToPathData(markerInstances, precision = 2) {
|
|
982
|
+
const pathParts = [];
|
|
983
|
+
|
|
984
|
+
for (const instance of markerInstances) {
|
|
985
|
+
const polygons = markerToPolygons(instance, { precision });
|
|
986
|
+
|
|
987
|
+
for (const polygon of polygons) {
|
|
988
|
+
if (polygon.length === 0) continue;
|
|
989
|
+
|
|
990
|
+
// Start with M (moveto) command
|
|
991
|
+
const first = polygon[0];
|
|
992
|
+
pathParts.push(`M ${first.x.toFixed(precision)} ${first.y.toFixed(precision)}`);
|
|
993
|
+
|
|
994
|
+
// Add L (lineto) commands for remaining points
|
|
995
|
+
for (let i = 1; i < polygon.length; i++) {
|
|
996
|
+
const pt = polygon[i];
|
|
997
|
+
pathParts.push(`L ${pt.x.toFixed(precision)} ${pt.y.toFixed(precision)}`);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// Close path
|
|
1001
|
+
pathParts.push('Z');
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
return pathParts.join(' ');
|
|
1006
|
+
}
|