@emasoft/svg-matrix 1.0.5 → 1.0.7

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.
@@ -0,0 +1,760 @@
1
+ /**
2
+ * ClipPath Resolver - Flatten clipPath operations with arbitrary precision
3
+ *
4
+ * This module resolves SVG clipPath elements by performing boolean intersection
5
+ * operations using Decimal.js-backed polygon clipping.
6
+ *
7
+ * ## SVG clipPath Coordinate Systems
8
+ *
9
+ * SVG clipPath elements support two coordinate systems via the `clipPathUnits` attribute:
10
+ *
11
+ * - **userSpaceOnUse** (default): The clipPath coordinates are in the same coordinate
12
+ * system as the element being clipped. This is the user coordinate system established
13
+ * by the viewport.
14
+ *
15
+ * - **objectBoundingBox**: The clipPath coordinates are expressed as fractions (0-1)
16
+ * of the bounding box of the element being clipped. A point (0.5, 0.5) would be
17
+ * the center of the target element's bounding box.
18
+ *
19
+ * ## Clipping Process
20
+ *
21
+ * 1. Convert clipPath shapes to polygons by sampling curves (Bezier, arcs)
22
+ * 2. Apply transforms (element transform, CTM, and coordinate system transforms)
23
+ * 3. Perform boolean union of multiple clipPath children into unified clip polygon
24
+ * 4. Intersect target element polygon with clip polygon to get final clipped geometry
25
+ * 5. Handle nested clipPaths by recursively resolving and intersecting
26
+ *
27
+ * @module clip-path-resolver
28
+ */
29
+
30
+ import Decimal from 'decimal.js';
31
+ import { Matrix } from './matrix.js';
32
+ import * as Transforms2D from './transforms2d.js';
33
+ import * as PolygonClip from './polygon-clip.js';
34
+ import {
35
+ circleToPath,
36
+ ellipseToPath,
37
+ rectToPath,
38
+ lineToPath,
39
+ polygonToPath,
40
+ polylineToPath,
41
+ parseTransformAttribute,
42
+ transformPathData
43
+ } from './svg-flatten.js';
44
+ import { Logger } from './logger.js';
45
+
46
+ // Alias for cleaner code
47
+ const parseTransform = parseTransformAttribute;
48
+
49
+ Decimal.set({ precision: 80 });
50
+
51
+ /**
52
+ * Helper function to convert a value to Decimal.
53
+ * @private
54
+ * @param {number|string|Decimal} x - Value to convert
55
+ * @returns {Decimal} Decimal instance
56
+ */
57
+ const D = x => (x instanceof Decimal ? x : new Decimal(x));
58
+
59
+ /**
60
+ * Default number of sample points per curve segment (for Bezier curves and arcs).
61
+ * Higher values produce smoother polygons but increase computation time.
62
+ * @constant {number}
63
+ */
64
+ const DEFAULT_CURVE_SAMPLES = 20;
65
+
66
+ /**
67
+ * Convert SVG path data to a polygon (array of points) by sampling curves.
68
+ *
69
+ * This function parses SVG path commands and converts curves (cubic Bezier, quadratic
70
+ * Bezier, and elliptical arcs) into polyline segments by sampling points along the curve.
71
+ * Straight line commands (M, L, H, V) are converted directly to polygon vertices.
72
+ *
73
+ * All numeric values are converted to Decimal for arbitrary precision.
74
+ *
75
+ * @param {string} pathData - SVG path d attribute string (e.g., "M 0 0 L 100 0 L 100 100 Z")
76
+ * @param {number} [samplesPerCurve=20] - Number of sample points to generate per curve segment.
77
+ * Higher values produce smoother approximations of curves but increase vertex count.
78
+ * @returns {Array<{x: Decimal, y: Decimal}>} Array of polygon vertices with Decimal coordinates.
79
+ * Consecutive duplicate points are removed.
80
+ *
81
+ * @example
82
+ * // Convert a simple path to polygon
83
+ * const polygon = pathToPolygon("M 0 0 L 100 0 L 100 100 L 0 100 Z");
84
+ * // Returns: [{x: Decimal(0), y: Decimal(0)}, {x: Decimal(100), y: Decimal(0)}, ...]
85
+ *
86
+ * @example
87
+ * // Convert a path with curves using custom sampling
88
+ * const smoothPolygon = pathToPolygon("M 0 0 C 50 0 50 100 100 100", 50);
89
+ * // Higher sampling (50 points) creates smoother curve approximation
90
+ */
91
+ export function pathToPolygon(pathData, samplesPerCurve = DEFAULT_CURVE_SAMPLES) {
92
+ const points = [];
93
+ let currentX = D(0), currentY = D(0);
94
+ let startX = D(0), startY = D(0);
95
+
96
+ const commands = parsePathCommands(pathData);
97
+
98
+ for (const cmd of commands) {
99
+ const { type, args } = cmd;
100
+
101
+ switch (type) {
102
+ case 'M':
103
+ currentX = D(args[0]); currentY = D(args[1]);
104
+ startX = currentX; startY = currentY;
105
+ points.push(PolygonClip.point(currentX, currentY));
106
+ break;
107
+ case 'm':
108
+ currentX = currentX.plus(args[0]); currentY = currentY.plus(args[1]);
109
+ startX = currentX; startY = currentY;
110
+ points.push(PolygonClip.point(currentX, currentY));
111
+ break;
112
+ case 'L':
113
+ currentX = D(args[0]); currentY = D(args[1]);
114
+ points.push(PolygonClip.point(currentX, currentY));
115
+ break;
116
+ case 'l':
117
+ currentX = currentX.plus(args[0]); currentY = currentY.plus(args[1]);
118
+ points.push(PolygonClip.point(currentX, currentY));
119
+ break;
120
+ case 'H':
121
+ currentX = D(args[0]);
122
+ points.push(PolygonClip.point(currentX, currentY));
123
+ break;
124
+ case 'h':
125
+ currentX = currentX.plus(args[0]);
126
+ points.push(PolygonClip.point(currentX, currentY));
127
+ break;
128
+ case 'V':
129
+ currentY = D(args[0]);
130
+ points.push(PolygonClip.point(currentX, currentY));
131
+ break;
132
+ case 'v':
133
+ currentY = currentY.plus(args[0]);
134
+ points.push(PolygonClip.point(currentX, currentY));
135
+ break;
136
+ case 'C':
137
+ sampleCubicBezier(points, currentX, currentY,
138
+ D(args[0]), D(args[1]), D(args[2]), D(args[3]), D(args[4]), D(args[5]),
139
+ samplesPerCurve);
140
+ currentX = D(args[4]); currentY = D(args[5]);
141
+ break;
142
+ case 'c':
143
+ sampleCubicBezier(points, currentX, currentY,
144
+ currentX.plus(args[0]), currentY.plus(args[1]),
145
+ currentX.plus(args[2]), currentY.plus(args[3]),
146
+ currentX.plus(args[4]), currentY.plus(args[5]),
147
+ samplesPerCurve);
148
+ currentX = currentX.plus(args[4]); currentY = currentY.plus(args[5]);
149
+ break;
150
+ case 'Q':
151
+ sampleQuadraticBezier(points, currentX, currentY,
152
+ D(args[0]), D(args[1]), D(args[2]), D(args[3]),
153
+ samplesPerCurve);
154
+ currentX = D(args[2]); currentY = D(args[3]);
155
+ break;
156
+ case 'q':
157
+ sampleQuadraticBezier(points, currentX, currentY,
158
+ currentX.plus(args[0]), currentY.plus(args[1]),
159
+ currentX.plus(args[2]), currentY.plus(args[3]),
160
+ samplesPerCurve);
161
+ currentX = currentX.plus(args[2]); currentY = currentY.plus(args[3]);
162
+ break;
163
+ case 'A':
164
+ sampleArc(points, currentX, currentY,
165
+ D(args[0]), D(args[1]), D(args[2]), args[3], args[4],
166
+ D(args[5]), D(args[6]), samplesPerCurve);
167
+ currentX = D(args[5]); currentY = D(args[6]);
168
+ break;
169
+ case 'a':
170
+ sampleArc(points, currentX, currentY,
171
+ D(args[0]), D(args[1]), D(args[2]), args[3], args[4],
172
+ currentX.plus(args[5]), currentY.plus(args[6]), samplesPerCurve);
173
+ currentX = currentX.plus(args[5]); currentY = currentY.plus(args[6]);
174
+ break;
175
+ case 'Z': case 'z':
176
+ currentX = startX; currentY = startY;
177
+ break;
178
+ }
179
+ }
180
+
181
+ return removeDuplicateConsecutive(points);
182
+ }
183
+
184
+ /**
185
+ * Parse SVG path data into individual commands with arguments.
186
+ *
187
+ * Extracts path commands (M, L, C, Q, A, Z, etc.) and their numeric arguments from
188
+ * an SVG path data string.
189
+ *
190
+ * @private
191
+ * @param {string} pathData - SVG path d attribute string
192
+ * @returns {Array<{type: string, args: Array<number>}>} Array of command objects,
193
+ * each containing a command type (single letter) and array of numeric arguments
194
+ *
195
+ * @example
196
+ * parsePathCommands("M 10 20 L 30 40")
197
+ * // Returns: [{type: 'M', args: [10, 20]}, {type: 'L', args: [30, 40]}]
198
+ */
199
+ function parsePathCommands(pathData) {
200
+ const commands = [];
201
+ const regex = /([MmLlHhVvCcSsQqTtAaZz])([^MmLlHhVvCcSsQqTtAaZz]*)/g;
202
+ let match;
203
+ while ((match = regex.exec(pathData)) !== null) {
204
+ const type = match[1];
205
+ const argsStr = match[2].trim();
206
+ const args = argsStr.length > 0
207
+ ? argsStr.split(/[\s,]+/).filter(s => s.length > 0).map(Number)
208
+ : [];
209
+ commands.push({ type, args });
210
+ }
211
+ return commands;
212
+ }
213
+
214
+ /**
215
+ * Sample points along a cubic Bezier curve and append them to the points array.
216
+ *
217
+ * Uses the cubic Bezier formula: B(t) = (1-t)³P₀ + 3(1-t)²tP₁ + 3(1-t)t²P₂ + t³P₃
218
+ * where t ranges from 0 to 1.
219
+ *
220
+ * @private
221
+ * @param {Array<{x: Decimal, y: Decimal}>} points - Array to append sampled points to
222
+ * @param {Decimal} x0 - Start point x coordinate
223
+ * @param {Decimal} y0 - Start point y coordinate
224
+ * @param {Decimal} x1 - First control point x coordinate
225
+ * @param {Decimal} y1 - First control point y coordinate
226
+ * @param {Decimal} x2 - Second control point x coordinate
227
+ * @param {Decimal} y2 - Second control point y coordinate
228
+ * @param {Decimal} x3 - End point x coordinate
229
+ * @param {Decimal} y3 - End point y coordinate
230
+ * @param {number} samples - Number of points to sample along the curve
231
+ *
232
+ * @example
233
+ * const points = [];
234
+ * sampleCubicBezier(points, D(0), D(0), D(50), D(0), D(50), D(100), D(100), D(100), 20);
235
+ * // points now contains 20 sampled points along the cubic Bezier curve
236
+ */
237
+ function sampleCubicBezier(points, x0, y0, x1, y1, x2, y2, x3, y3, samples) {
238
+ for (let i = 1; i <= samples; i++) {
239
+ const t = D(i).div(samples);
240
+ const mt = D(1).minus(t);
241
+ const mt2 = mt.mul(mt), mt3 = mt2.mul(mt);
242
+ const t2 = t.mul(t), t3 = t2.mul(t);
243
+ const x = mt3.mul(x0).plus(D(3).mul(mt2).mul(t).mul(x1))
244
+ .plus(D(3).mul(mt).mul(t2).mul(x2)).plus(t3.mul(x3));
245
+ const y = mt3.mul(y0).plus(D(3).mul(mt2).mul(t).mul(y1))
246
+ .plus(D(3).mul(mt).mul(t2).mul(y2)).plus(t3.mul(y3));
247
+ points.push(PolygonClip.point(x, y));
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Sample points along a quadratic Bezier curve and append them to the points array.
253
+ *
254
+ * Uses the quadratic Bezier formula: B(t) = (1-t)²P₀ + 2(1-t)tP₁ + t²P₂
255
+ * where t ranges from 0 to 1.
256
+ *
257
+ * @private
258
+ * @param {Array<{x: Decimal, y: Decimal}>} points - Array to append sampled points to
259
+ * @param {Decimal} x0 - Start point x coordinate
260
+ * @param {Decimal} y0 - Start point y coordinate
261
+ * @param {Decimal} x1 - Control point x coordinate
262
+ * @param {Decimal} y1 - Control point y coordinate
263
+ * @param {Decimal} x2 - End point x coordinate
264
+ * @param {Decimal} y2 - End point y coordinate
265
+ * @param {number} samples - Number of points to sample along the curve
266
+ *
267
+ * @example
268
+ * const points = [];
269
+ * sampleQuadraticBezier(points, D(0), D(0), D(50), D(50), D(100), D(0), 20);
270
+ * // points now contains 20 sampled points along the quadratic Bezier curve
271
+ */
272
+ function sampleQuadraticBezier(points, x0, y0, x1, y1, x2, y2, samples) {
273
+ for (let i = 1; i <= samples; i++) {
274
+ const t = D(i).div(samples);
275
+ const mt = D(1).minus(t);
276
+ const mt2 = mt.mul(mt), t2 = t.mul(t);
277
+ const x = mt2.mul(x0).plus(D(2).mul(mt).mul(t).mul(x1)).plus(t2.mul(x2));
278
+ const y = mt2.mul(y0).plus(D(2).mul(mt).mul(t).mul(y1)).plus(t2.mul(y2));
279
+ points.push(PolygonClip.point(x, y));
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Sample points along an elliptical arc and append them to the points array.
285
+ *
286
+ * Converts SVG arc parameters to center parameterization and samples points along
287
+ * the arc. Implements the SVG arc conversion algorithm from the SVG specification.
288
+ *
289
+ * @private
290
+ * @param {Array<{x: Decimal, y: Decimal}>} points - Array to append sampled points to
291
+ * @param {Decimal} x0 - Start point x coordinate
292
+ * @param {Decimal} y0 - Start point y coordinate
293
+ * @param {Decimal} rx - X-axis radius of the ellipse
294
+ * @param {Decimal} ry - Y-axis radius of the ellipse
295
+ * @param {Decimal} xAxisRotation - Rotation angle of the ellipse x-axis (in degrees)
296
+ * @param {number} largeArc - Large arc flag (0 or 1): whether to take the larger arc
297
+ * @param {number} sweep - Sweep flag (0 or 1): direction of the arc (0=counterclockwise, 1=clockwise)
298
+ * @param {Decimal} x1 - End point x coordinate
299
+ * @param {Decimal} y1 - End point y coordinate
300
+ * @param {number} samples - Number of points to sample along the arc
301
+ *
302
+ * @example
303
+ * const points = [];
304
+ * // Sample an arc from (0,0) to (100,100) with radii 50,50
305
+ * sampleArc(points, D(0), D(0), D(50), D(50), D(0), 0, 1, D(100), D(100), 20);
306
+ */
307
+ function sampleArc(points, x0, y0, rx, ry, xAxisRotation, largeArc, sweep, x1, y1, samples) {
308
+ if (rx.eq(0) || ry.eq(0)) { points.push(PolygonClip.point(x1, y1)); return; }
309
+ rx = rx.abs(); ry = ry.abs();
310
+
311
+ const phi = xAxisRotation.mul(Math.PI).div(180);
312
+ const cosPhi = phi.cos(), sinPhi = phi.sin();
313
+ const dx = x0.minus(x1).div(2), dy = y0.minus(y1).div(2);
314
+ const x1p = cosPhi.mul(dx).plus(sinPhi.mul(dy));
315
+ const y1p = cosPhi.mul(dy).minus(sinPhi.mul(dx));
316
+
317
+ let rx2 = rx.mul(rx), ry2 = ry.mul(ry);
318
+ const x1p2 = x1p.mul(x1p), y1p2 = y1p.mul(y1p);
319
+ const lambda = x1p2.div(rx2).plus(y1p2.div(ry2));
320
+ if (lambda.gt(1)) {
321
+ const sqrtLambda = lambda.sqrt();
322
+ rx = rx.mul(sqrtLambda); ry = ry.mul(sqrtLambda);
323
+ rx2 = rx.mul(rx); ry2 = ry.mul(ry);
324
+ }
325
+
326
+ let sq = rx2.mul(ry2).minus(rx2.mul(y1p2)).minus(ry2.mul(x1p2));
327
+ sq = sq.div(rx2.mul(y1p2).plus(ry2.mul(x1p2)));
328
+ if (sq.lt(0)) sq = D(0);
329
+ sq = sq.sqrt();
330
+ if (largeArc === sweep) sq = sq.neg();
331
+
332
+ const cxp = sq.mul(rx).mul(y1p).div(ry);
333
+ const cyp = sq.neg().mul(ry).mul(x1p).div(rx);
334
+ const midX = x0.plus(x1).div(2), midY = y0.plus(y1).div(2);
335
+ const cx = cosPhi.mul(cxp).minus(sinPhi.mul(cyp)).plus(midX);
336
+ const cy = sinPhi.mul(cxp).plus(cosPhi.mul(cyp)).plus(midY);
337
+
338
+ const ux = x1p.minus(cxp).div(rx), uy = y1p.minus(cyp).div(ry);
339
+ const vx = x1p.neg().minus(cxp).div(rx), vy = y1p.neg().minus(cyp).div(ry);
340
+ const n1 = ux.mul(ux).plus(uy.mul(uy)).sqrt();
341
+ let theta1 = Decimal.acos(ux.div(n1));
342
+ if (uy.lt(0)) theta1 = theta1.neg();
343
+
344
+ const n2 = n1.mul(vx.mul(vx).plus(vy.mul(vy)).sqrt());
345
+ let dtheta = Decimal.acos(ux.mul(vx).plus(uy.mul(vy)).div(n2));
346
+ if (ux.mul(vy).minus(uy.mul(vx)).lt(0)) dtheta = dtheta.neg();
347
+
348
+ const PI = D(Math.PI), TWO_PI = PI.mul(2);
349
+ if (sweep && dtheta.lt(0)) dtheta = dtheta.plus(TWO_PI);
350
+ else if (!sweep && dtheta.gt(0)) dtheta = dtheta.minus(TWO_PI);
351
+
352
+ for (let i = 1; i <= samples; i++) {
353
+ const t = D(i).div(samples);
354
+ const theta = theta1.plus(dtheta.mul(t));
355
+ const cosTheta = theta.cos(), sinTheta = theta.sin();
356
+ const x = cosPhi.mul(rx.mul(cosTheta)).minus(sinPhi.mul(ry.mul(sinTheta))).plus(cx);
357
+ const y = sinPhi.mul(rx.mul(cosTheta)).plus(cosPhi.mul(ry.mul(sinTheta))).plus(cy);
358
+ points.push(PolygonClip.point(x, y));
359
+ }
360
+ }
361
+
362
+ /**
363
+ * Remove consecutive duplicate points from a polygon.
364
+ *
365
+ * Compares each point with the previous point and removes duplicates that appear
366
+ * consecutively. This is necessary because curve sampling may produce identical
367
+ * consecutive points at curve endpoints.
368
+ *
369
+ * @private
370
+ * @param {Array<{x: Decimal, y: Decimal}>} points - Array of polygon vertices
371
+ * @returns {Array<{x: Decimal, y: Decimal}>} Array with consecutive duplicates removed
372
+ *
373
+ * @example
374
+ * const points = [{x: D(0), y: D(0)}, {x: D(0), y: D(0)}, {x: D(1), y: D(1)}];
375
+ * const clean = removeDuplicateConsecutive(points);
376
+ * // Returns: [{x: D(0), y: D(0)}, {x: D(1), y: D(1)}]
377
+ */
378
+ function removeDuplicateConsecutive(points) {
379
+ if (points.length < 2) return points;
380
+ const result = [points[0]];
381
+ for (let i = 1; i < points.length; i++) {
382
+ if (!PolygonClip.pointsEqual(points[i], result[result.length - 1])) {
383
+ result.push(points[i]);
384
+ }
385
+ }
386
+ return result;
387
+ }
388
+
389
+ /**
390
+ * Convert an SVG shape element to a polygon by sampling its geometry.
391
+ *
392
+ * Supports various SVG shape elements (circle, ellipse, rect, line, polygon, polyline, path)
393
+ * and converts them to polygons with Decimal-precision coordinates. Applies element transforms
394
+ * and optional current transformation matrix (CTM).
395
+ *
396
+ * Shape elements are first converted to path data, then transforms are applied, and finally
397
+ * the path is sampled into a polygon.
398
+ *
399
+ * @param {Object} element - SVG element object with properties like type, cx, cy, r, d, etc.
400
+ * @param {Object} element.type - Shape type: 'circle', 'ellipse', 'rect', 'line', 'polygon', 'polyline', or 'path'
401
+ * @param {Matrix|null} [ctm=null] - Current transformation matrix (3x3) to apply to the shape.
402
+ * If null, no additional transform is applied beyond the element's own transform.
403
+ * @param {number} [samples=20] - Number of sample points per curve segment for path sampling
404
+ * @returns {Array<{x: Decimal, y: Decimal}>} Polygon vertices representing the shape
405
+ *
406
+ * @example
407
+ * // Convert a circle to polygon
408
+ * const circle = { type: 'circle', cx: 50, cy: 50, r: 25 };
409
+ * const polygon = shapeToPolygon(circle);
410
+ *
411
+ * @example
412
+ * // Convert a rect with transform and CTM
413
+ * const rect = { type: 'rect', x: 0, y: 0, width: 100, height: 50, transform: 'rotate(45)' };
414
+ * const ctm = Transforms2D.translation(10, 20);
415
+ * const polygon = shapeToPolygon(rect, ctm, 30);
416
+ */
417
+ export function shapeToPolygon(element, ctm = null, samples = DEFAULT_CURVE_SAMPLES) {
418
+ let pathData;
419
+ switch (element.type) {
420
+ case 'circle':
421
+ pathData = circleToPath(D(element.cx || 0), D(element.cy || 0), D(element.r || 0));
422
+ break;
423
+ case 'ellipse':
424
+ pathData = ellipseToPath(D(element.cx || 0), D(element.cy || 0), D(element.rx || 0), D(element.ry || 0));
425
+ break;
426
+ case 'rect':
427
+ pathData = rectToPath(D(element.x || 0), D(element.y || 0), D(element.width || 0), D(element.height || 0),
428
+ D(element.rx || 0), element.ry !== undefined ? D(element.ry) : null);
429
+ break;
430
+ case 'line':
431
+ pathData = lineToPath(D(element.x1 || 0), D(element.y1 || 0), D(element.x2 || 0), D(element.y2 || 0));
432
+ break;
433
+ case 'polygon':
434
+ pathData = polygonToPath(element.points || '');
435
+ break;
436
+ case 'polyline':
437
+ pathData = polylineToPath(element.points || '');
438
+ break;
439
+ case 'path':
440
+ pathData = element.d || '';
441
+ break;
442
+ default:
443
+ return [];
444
+ }
445
+
446
+ if (element.transform) {
447
+ const elementTransform = parseTransform(element.transform);
448
+ pathData = transformPathData(pathData, elementTransform);
449
+ }
450
+ if (ctm) {
451
+ pathData = transformPathData(pathData, ctm);
452
+ }
453
+
454
+ return pathToPolygon(pathData, samples);
455
+ }
456
+
457
+ /**
458
+ * Resolve a clipPath element into a unified clipping polygon.
459
+ *
460
+ * Takes a clipPath definition and converts all its child shapes into a single polygon
461
+ * by performing boolean union operations. Handles both coordinate systems (userSpaceOnUse
462
+ * and objectBoundingBox) and applies appropriate transforms.
463
+ *
464
+ * ## Coordinate System Handling
465
+ *
466
+ * - **userSpaceOnUse** (default): clipPath coordinates are in the same coordinate system
467
+ * as the element being clipped. The CTM and clipPath transform are applied directly.
468
+ *
469
+ * - **objectBoundingBox**: clipPath coordinates are fractions (0-1) of the target element's
470
+ * bounding box. A bounding box transform is computed and applied to scale/translate the
471
+ * clipPath into the target element's space.
472
+ *
473
+ * ## Transform Application Order
474
+ *
475
+ * 1. Start with CTM (current transformation matrix)
476
+ * 2. Apply clipPath's own transform attribute
477
+ * 3. If objectBoundingBox, apply bounding box scaling/translation
478
+ * 4. Apply to each child shape
479
+ *
480
+ * @param {Object} clipPathDef - clipPath definition object
481
+ * @param {string} [clipPathDef.clipPathUnits='userSpaceOnUse'] - Coordinate system: 'userSpaceOnUse' or 'objectBoundingBox'
482
+ * @param {string} [clipPathDef.transform] - Optional transform attribute for the clipPath
483
+ * @param {Array<Object>} [clipPathDef.children=[]] - Child shape elements to clip with
484
+ * @param {Object|null} targetElement - The element being clipped (needed for objectBoundingBox)
485
+ * @param {Matrix|null} [ctm=null] - Current transformation matrix (3x3)
486
+ * @param {Object} [options={}] - Additional options
487
+ * @param {number} [options.samples=20] - Number of sample points per curve segment
488
+ * @returns {Array<{x: Decimal, y: Decimal}>} Unified clipping polygon (union of all child shapes)
489
+ *
490
+ * @example
491
+ * // Resolve clipPath with userSpaceOnUse (default)
492
+ * const clipDef = {
493
+ * clipPathUnits: 'userSpaceOnUse',
494
+ * children: [
495
+ * { type: 'circle', cx: 50, cy: 50, r: 40 },
496
+ * { type: 'rect', x: 40, y: 40, width: 20, height: 20 }
497
+ * ]
498
+ * };
499
+ * const clipPolygon = resolveClipPath(clipDef, null);
500
+ *
501
+ * @example
502
+ * // Resolve clipPath with objectBoundingBox
503
+ * const clipDef = {
504
+ * clipPathUnits: 'objectBoundingBox',
505
+ * children: [{ type: 'circle', cx: 0.5, cy: 0.5, r: 0.4 }]
506
+ * };
507
+ * const target = { type: 'rect', x: 0, y: 0, width: 200, height: 100 };
508
+ * const clipPolygon = resolveClipPath(clipDef, target);
509
+ * // Circle will be scaled to bbox: center at (100,50), radius 40 (0.4 * min(200,100))
510
+ */
511
+ export function resolveClipPath(clipPathDef, targetElement, ctm = null, options = {}) {
512
+ const { samples = DEFAULT_CURVE_SAMPLES } = options;
513
+ const clipPathUnits = clipPathDef.clipPathUnits || 'userSpaceOnUse';
514
+ let clipTransform = ctm ? ctm.clone() : Matrix.identity(3);
515
+
516
+ if (clipPathDef.transform) {
517
+ const clipPathTransformMatrix = parseTransform(clipPathDef.transform);
518
+ clipTransform = clipTransform.mul(clipPathTransformMatrix);
519
+ }
520
+
521
+ if (clipPathUnits === 'objectBoundingBox' && targetElement) {
522
+ const bbox = getElementBoundingBox(targetElement);
523
+ if (bbox) {
524
+ const bboxTransform = Transforms2D.translation(bbox.x, bbox.y)
525
+ .mul(Transforms2D.scale(bbox.width, bbox.height));
526
+ clipTransform = clipTransform.mul(bboxTransform);
527
+ }
528
+ }
529
+
530
+ const clipPolygons = [];
531
+ for (const child of (clipPathDef.children || [])) {
532
+ const polygon = shapeToPolygon(child, clipTransform, samples);
533
+ if (polygon.length >= 3) clipPolygons.push(polygon);
534
+ }
535
+
536
+ if (clipPolygons.length === 0) return [];
537
+ if (clipPolygons.length === 1) return clipPolygons[0];
538
+
539
+ let unified = clipPolygons[0];
540
+ for (let i = 1; i < clipPolygons.length; i++) {
541
+ const unionResult = PolygonClip.polygonUnion(unified, clipPolygons[i]);
542
+ if (unionResult.length > 0) unified = unionResult[0];
543
+ }
544
+ return unified;
545
+ }
546
+
547
+ /**
548
+ * Apply a clipPath to an element, returning the clipped geometry.
549
+ *
550
+ * Performs the complete clipping operation by:
551
+ * 1. Resolving the clipPath definition into a clipping polygon
552
+ * 2. Converting the target element to a polygon
553
+ * 3. Computing the intersection of the two polygons
554
+ *
555
+ * This is the main function for applying clip paths to elements. The result is a
556
+ * polygon representing the visible portion of the element after clipping.
557
+ *
558
+ * @param {Object} element - SVG element to be clipped (rect, circle, path, etc.)
559
+ * @param {Object} clipPathDef - clipPath definition object (see resolveClipPath)
560
+ * @param {Matrix|null} [ctm=null] - Current transformation matrix (3x3) to apply
561
+ * @param {Object} [options={}] - Additional options
562
+ * @param {number} [options.samples=20] - Number of sample points per curve segment
563
+ * @returns {Array<{x: Decimal, y: Decimal}>} Clipped polygon representing the intersection
564
+ * of the element and clipPath. Empty array if no intersection or invalid input.
565
+ *
566
+ * @example
567
+ * // Clip a rectangle with a circular clipPath
568
+ * const rect = { type: 'rect', x: 0, y: 0, width: 100, height: 100 };
569
+ * const clipDef = {
570
+ * children: [{ type: 'circle', cx: 50, cy: 50, r: 40 }]
571
+ * };
572
+ * const clipped = applyClipPath(rect, clipDef);
573
+ * // Returns polygon approximating the intersection (rounded corners of rect)
574
+ *
575
+ * @example
576
+ * // Clip with objectBoundingBox coordinate system
577
+ * const ellipse = { type: 'ellipse', cx: 100, cy: 100, rx: 80, ry: 60 };
578
+ * const clipDef = {
579
+ * clipPathUnits: 'objectBoundingBox',
580
+ * children: [{ type: 'rect', x: 0.25, y: 0.25, width: 0.5, height: 0.5 }]
581
+ * };
582
+ * const clipped = applyClipPath(ellipse, clipDef, null, { samples: 50 });
583
+ */
584
+ export function applyClipPath(element, clipPathDef, ctm = null, options = {}) {
585
+ const { samples = DEFAULT_CURVE_SAMPLES } = options;
586
+ const clipPolygon = resolveClipPath(clipPathDef, element, ctm, options);
587
+ if (clipPolygon.length < 3) return [];
588
+
589
+ const elementPolygon = shapeToPolygon(element, ctm, samples);
590
+ if (elementPolygon.length < 3) return [];
591
+
592
+ return PolygonClip.polygonIntersection(elementPolygon, clipPolygon);
593
+ }
594
+
595
+ /**
596
+ * Compute the bounding box of an SVG element.
597
+ *
598
+ * For simple shapes (rect, circle, ellipse), the bounding box is computed analytically.
599
+ * For complex shapes (path, polygon, etc.), the element is converted to a polygon and
600
+ * the bounding box is computed from the polygon vertices.
601
+ *
602
+ * This is used when clipPathUnits='objectBoundingBox' to determine the scaling and
603
+ * translation needed to map fractional coordinates to the element's actual bounds.
604
+ *
605
+ * @private
606
+ * @param {Object} element - SVG element object
607
+ * @returns {Object|null} Bounding box {x, y, width, height} with Decimal values, or null if invalid
608
+ *
609
+ * @example
610
+ * const rect = { type: 'rect', x: 10, y: 20, width: 100, height: 50 };
611
+ * const bbox = getElementBoundingBox(rect);
612
+ * // Returns: {x: Decimal(10), y: Decimal(20), width: Decimal(100), height: Decimal(50)}
613
+ *
614
+ * @example
615
+ * const circle = { type: 'circle', cx: 50, cy: 50, r: 25 };
616
+ * const bbox = getElementBoundingBox(circle);
617
+ * // Returns: {x: Decimal(25), y: Decimal(25), width: Decimal(50), height: Decimal(50)}
618
+ */
619
+ function getElementBoundingBox(element) {
620
+ switch (element.type) {
621
+ case 'rect':
622
+ return { x: D(element.x || 0), y: D(element.y || 0),
623
+ width: D(element.width || 0), height: D(element.height || 0) };
624
+ case 'circle': {
625
+ const cx = D(element.cx || 0), cy = D(element.cy || 0), r = D(element.r || 0);
626
+ return { x: cx.minus(r), y: cy.minus(r), width: r.mul(2), height: r.mul(2) };
627
+ }
628
+ case 'ellipse': {
629
+ const cx = D(element.cx || 0), cy = D(element.cy || 0);
630
+ const rx = D(element.rx || 0), ry = D(element.ry || 0);
631
+ return { x: cx.minus(rx), y: cy.minus(ry), width: rx.mul(2), height: ry.mul(2) };
632
+ }
633
+ default: {
634
+ const polygon = shapeToPolygon(element, null, 10);
635
+ if (polygon.length > 0) {
636
+ const bbox = PolygonClip.boundingBox(polygon);
637
+ return { x: bbox.minX, y: bbox.minY,
638
+ width: bbox.maxX.minus(bbox.minX), height: bbox.maxY.minus(bbox.minY) };
639
+ }
640
+ return null;
641
+ }
642
+ }
643
+ }
644
+
645
+ /**
646
+ * Convert a polygon back to SVG path data string.
647
+ *
648
+ * Takes an array of polygon vertices with Decimal coordinates and generates an SVG
649
+ * path data string (d attribute) representing the closed polygon. Coordinates are
650
+ * formatted with the specified precision and trailing zeros are removed.
651
+ *
652
+ * The generated path starts with M (moveto) to the first point, followed by L (lineto)
653
+ * commands for each subsequent point, and ends with Z (closepath).
654
+ *
655
+ * @param {Array<{x: Decimal, y: Decimal}>} polygon - Array of polygon vertices
656
+ * @param {number} [precision=6] - Number of decimal places for coordinate formatting
657
+ * @returns {string} SVG path data string (e.g., "M 0 0 L 100 0 L 100 100 L 0 100 Z")
658
+ *
659
+ * @example
660
+ * const polygon = [
661
+ * {x: D(0), y: D(0)},
662
+ * {x: D(100), y: D(0)},
663
+ * {x: D(100), y: D(100)},
664
+ * {x: D(0), y: D(100)}
665
+ * ];
666
+ * const pathData = polygonToPathData(polygon);
667
+ * // Returns: "M 0 0 L 100 0 L 100 100 L 0 100 Z"
668
+ *
669
+ * @example
670
+ * // Higher precision for decimal coordinates
671
+ * const polygon = [{x: D('0.123456789'), y: D('0.987654321')}];
672
+ * const pathData = polygonToPathData(polygon, 8);
673
+ * // Returns: "M 0.12345679 0.98765432 Z"
674
+ */
675
+ export function polygonToPathData(polygon, precision = 6) {
676
+ if (polygon.length < 2) return '';
677
+ const fmt = n => (n instanceof Decimal ? n : D(n)).toFixed(precision).replace(/\.?0+$/, '');
678
+ let d = `M ${fmt(polygon[0].x)} ${fmt(polygon[0].y)}`;
679
+ for (let i = 1; i < polygon.length; i++) {
680
+ d += ` L ${fmt(polygon[i].x)} ${fmt(polygon[i].y)}`;
681
+ }
682
+ return d + ' Z';
683
+ }
684
+
685
+ /**
686
+ * Resolve nested clipPaths recursively with cycle detection.
687
+ *
688
+ * SVG allows clipPath elements to reference other clipPaths via the clip-path attribute.
689
+ * This function resolves such nested references by:
690
+ * 1. Resolving the current clipPath into a polygon
691
+ * 2. Checking if this clipPath has a clip-path reference
692
+ * 3. If yes, recursively resolving the referenced clipPath
693
+ * 4. Intersecting the two polygons to get the final clip region
694
+ *
695
+ * Cycle detection prevents infinite recursion if clipPaths reference each other circularly.
696
+ *
697
+ * @param {Object} clipPathDef - clipPath definition object
698
+ * @param {string} [clipPathDef.id] - ID of this clipPath (for cycle detection)
699
+ * @param {string} [clipPathDef['clip-path']] - Reference to another clipPath (e.g., "url(#otherClip)")
700
+ * @param {Map<string, Object>} defsMap - Map of clipPath IDs to their definitions
701
+ * @param {Object|null} targetElement - The element being clipped
702
+ * @param {Matrix|null} [ctm=null] - Current transformation matrix (3x3)
703
+ * @param {Set<string>} [visited=new Set()] - Set of visited clipPath IDs for cycle detection
704
+ * @param {Object} [options={}] - Additional options (see resolveClipPath)
705
+ * @returns {Array<{x: Decimal, y: Decimal}>} Resolved clipping polygon (intersection of nested clips)
706
+ *
707
+ * @example
708
+ * // Define two clipPaths where one references the other
709
+ * const defsMap = new Map([
710
+ * ['clip1', {
711
+ * id: 'clip1',
712
+ * children: [{ type: 'circle', cx: 50, cy: 50, r: 40 }],
713
+ * 'clip-path': 'url(#clip2)'
714
+ * }],
715
+ * ['clip2', {
716
+ * id: 'clip2',
717
+ * children: [{ type: 'rect', x: 0, y: 0, width: 100, height: 100 }]
718
+ * }]
719
+ * ]);
720
+ * const target = { type: 'rect', x: 0, y: 0, width: 100, height: 100 };
721
+ * const polygon = resolveNestedClipPath(defsMap.get('clip1'), defsMap, target);
722
+ * // Returns intersection of circle and rect
723
+ *
724
+ * @example
725
+ * // Circular reference detection
726
+ * const defsMap = new Map([
727
+ * ['clip1', { id: 'clip1', children: [...], 'clip-path': 'url(#clip2)' }],
728
+ * ['clip2', { id: 'clip2', children: [...], 'clip-path': 'url(#clip1)' }]
729
+ * ]);
730
+ * const polygon = resolveNestedClipPath(defsMap.get('clip1'), defsMap, target);
731
+ * // Logs warning about circular reference and returns clip1 polygon only
732
+ */
733
+ export function resolveNestedClipPath(clipPathDef, defsMap, targetElement, ctm = null, visited = new Set(), options = {}) {
734
+ const clipId = clipPathDef.id;
735
+ if (clipId && visited.has(clipId)) {
736
+ Logger.warn(`Circular clipPath reference detected: ${clipId}`);
737
+ return [];
738
+ }
739
+ if (clipId) visited.add(clipId);
740
+
741
+ let clipPolygon = resolveClipPath(clipPathDef, targetElement, ctm, options);
742
+
743
+ if (clipPathDef['clip-path'] && clipPolygon.length >= 3) {
744
+ const nestedRef = clipPathDef['clip-path'].replace(/^url\(#?|[)'"]/g, '');
745
+ const nestedClipDef = defsMap.get(nestedRef);
746
+ if (nestedClipDef) {
747
+ const nestedClip = resolveNestedClipPath(nestedClipDef, defsMap, targetElement, ctm, visited, options);
748
+ if (nestedClip.length >= 3) {
749
+ const intersection = PolygonClip.polygonIntersection(clipPolygon, nestedClip);
750
+ clipPolygon = intersection.length > 0 ? intersection[0] : [];
751
+ }
752
+ }
753
+ }
754
+ return clipPolygon;
755
+ }
756
+
757
+ export default {
758
+ pathToPolygon, shapeToPolygon, resolveClipPath, applyClipPath,
759
+ polygonToPathData, resolveNestedClipPath, DEFAULT_CURVE_SAMPLES
760
+ };