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