@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,844 @@
1
+ /**
2
+ * Pattern Resolver Module - Flatten SVG pattern elements
3
+ *
4
+ * Resolves SVG pattern elements by expanding pattern tiles into
5
+ * concrete geometry with Decimal.js precision.
6
+ *
7
+ * Supports:
8
+ * - patternUnits (userSpaceOnUse, objectBoundingBox)
9
+ * - patternContentUnits (userSpaceOnUse, objectBoundingBox)
10
+ * - patternTransform
11
+ * - viewBox on patterns
12
+ * - Nested patterns (pattern referencing another pattern via href)
13
+ *
14
+ * @module pattern-resolver
15
+ */
16
+
17
+ import Decimal from 'decimal.js';
18
+ import { Matrix } from './matrix.js';
19
+ import * as Transforms2D from './transforms2d.js';
20
+ import * as PolygonClip from './polygon-clip.js';
21
+ import * as ClipPathResolver from './clip-path-resolver.js';
22
+
23
+ Decimal.set({ precision: 80 });
24
+
25
+ const D = x => (x instanceof Decimal ? x : new Decimal(x));
26
+
27
+ /**
28
+ * Parse pattern element to structured data
29
+ *
30
+ * Extracts all relevant attributes from an SVG `<pattern>` element and its children,
31
+ * preparing them for pattern resolution. Handles nested patterns via href references.
32
+ *
33
+ * SVG Pattern Concepts:
34
+ * - **patternUnits**: Defines coordinate system for x, y, width, height
35
+ * - 'objectBoundingBox' (default): Values are fractions (0-1) of target element bbox
36
+ * - 'userSpaceOnUse': Values are absolute coordinates in user space
37
+ *
38
+ * - **patternContentUnits**: Defines coordinate system for pattern children
39
+ * - 'userSpaceOnUse' (default): Children use absolute coordinates
40
+ * - 'objectBoundingBox': Children coordinates scaled by target bbox
41
+ *
42
+ * - **patternTransform**: Additional transformation applied to the pattern
43
+ *
44
+ * - **viewBox**: Establishes coordinate system for pattern content, allowing
45
+ * scaling and aspect ratio control independent of pattern tile size
46
+ *
47
+ * @param {Element} patternElement - SVG pattern DOM element to parse
48
+ * @returns {Object} Parsed pattern data containing:
49
+ * - id {string}: Pattern element ID
50
+ * - patternUnits {string}: 'objectBoundingBox' or 'userSpaceOnUse'
51
+ * - patternContentUnits {string}: 'objectBoundingBox' or 'userSpaceOnUse'
52
+ * - patternTransform {string|null}: Transform attribute string
53
+ * - x {number}: Pattern tile x offset (default 0)
54
+ * - y {number}: Pattern tile y offset (default 0)
55
+ * - width {number}: Pattern tile width
56
+ * - height {number}: Pattern tile height
57
+ * - viewBox {string|null}: ViewBox attribute string
58
+ * - viewBoxParsed {Object|undefined}: Parsed viewBox {x, y, width, height}
59
+ * - preserveAspectRatio {string}: Aspect ratio preservation mode
60
+ * - href {string|null}: Reference to another pattern element
61
+ * - children {Array}: Array of parsed child element data
62
+ *
63
+ * @example
64
+ * const patternEl = document.getElementById('myPattern');
65
+ * const data = parsePatternElement(patternEl);
66
+ * // {
67
+ * // id: 'myPattern',
68
+ * // patternUnits: 'objectBoundingBox',
69
+ * // x: 0, y: 0, width: 0.2, height: 0.2,
70
+ * // children: [{ type: 'rect', fill: 'blue', ... }]
71
+ * // }
72
+ */
73
+ export function parsePatternElement(patternElement) {
74
+ const data = {
75
+ id: patternElement.getAttribute('id') || '',
76
+ patternUnits: patternElement.getAttribute('patternUnits') || 'objectBoundingBox',
77
+ patternContentUnits: patternElement.getAttribute('patternContentUnits') || 'userSpaceOnUse',
78
+ patternTransform: patternElement.getAttribute('patternTransform') || null,
79
+ x: patternElement.getAttribute('x'),
80
+ y: patternElement.getAttribute('y'),
81
+ width: patternElement.getAttribute('width'),
82
+ height: patternElement.getAttribute('height'),
83
+ viewBox: patternElement.getAttribute('viewBox') || null,
84
+ preserveAspectRatio: patternElement.getAttribute('preserveAspectRatio') || 'xMidYMid meet',
85
+ href: patternElement.getAttribute('href') ||
86
+ patternElement.getAttribute('xlink:href') || null,
87
+ children: []
88
+ };
89
+
90
+ // Parse numeric values
91
+ data.x = data.x !== null ? parseFloat(data.x) : 0;
92
+ data.y = data.y !== null ? parseFloat(data.y) : 0;
93
+ data.width = data.width !== null ? parseFloat(data.width) : 0;
94
+ data.height = data.height !== null ? parseFloat(data.height) : 0;
95
+
96
+ // Parse viewBox if present
97
+ if (data.viewBox) {
98
+ const parts = data.viewBox.trim().split(/[\s,]+/).map(Number);
99
+ if (parts.length === 4) {
100
+ data.viewBoxParsed = {
101
+ x: parts[0],
102
+ y: parts[1],
103
+ width: parts[2],
104
+ height: parts[3]
105
+ };
106
+ }
107
+ }
108
+
109
+ // Parse child elements
110
+ for (const child of patternElement.children) {
111
+ const tagName = child.tagName.toLowerCase();
112
+ const childData = {
113
+ type: tagName,
114
+ fill: child.getAttribute('fill') || 'black',
115
+ stroke: child.getAttribute('stroke') || 'none',
116
+ strokeWidth: parseFloat(child.getAttribute('stroke-width') || '1'),
117
+ opacity: parseFloat(child.getAttribute('opacity') || '1'),
118
+ transform: child.getAttribute('transform') || null
119
+ };
120
+
121
+ // Parse shape-specific attributes
122
+ switch (tagName) {
123
+ case 'rect':
124
+ childData.x = parseFloat(child.getAttribute('x') || '0');
125
+ childData.y = parseFloat(child.getAttribute('y') || '0');
126
+ childData.width = parseFloat(child.getAttribute('width') || '0');
127
+ childData.height = parseFloat(child.getAttribute('height') || '0');
128
+ childData.rx = parseFloat(child.getAttribute('rx') || '0');
129
+ childData.ry = parseFloat(child.getAttribute('ry') || '0');
130
+ break;
131
+ case 'circle':
132
+ childData.cx = parseFloat(child.getAttribute('cx') || '0');
133
+ childData.cy = parseFloat(child.getAttribute('cy') || '0');
134
+ childData.r = parseFloat(child.getAttribute('r') || '0');
135
+ break;
136
+ case 'ellipse':
137
+ childData.cx = parseFloat(child.getAttribute('cx') || '0');
138
+ childData.cy = parseFloat(child.getAttribute('cy') || '0');
139
+ childData.rx = parseFloat(child.getAttribute('rx') || '0');
140
+ childData.ry = parseFloat(child.getAttribute('ry') || '0');
141
+ break;
142
+ case 'path':
143
+ childData.d = child.getAttribute('d') || '';
144
+ break;
145
+ case 'polygon':
146
+ childData.points = child.getAttribute('points') || '';
147
+ break;
148
+ case 'polyline':
149
+ childData.points = child.getAttribute('points') || '';
150
+ break;
151
+ case 'line':
152
+ childData.x1 = parseFloat(child.getAttribute('x1') || '0');
153
+ childData.y1 = parseFloat(child.getAttribute('y1') || '0');
154
+ childData.x2 = parseFloat(child.getAttribute('x2') || '0');
155
+ childData.y2 = parseFloat(child.getAttribute('y2') || '0');
156
+ break;
157
+ case 'use':
158
+ childData.href = child.getAttribute('href') ||
159
+ child.getAttribute('xlink:href') || '';
160
+ childData.x = parseFloat(child.getAttribute('x') || '0');
161
+ childData.y = parseFloat(child.getAttribute('y') || '0');
162
+ break;
163
+ case 'g':
164
+ // Groups can contain nested shapes
165
+ childData.children = [];
166
+ for (const gc of child.children) {
167
+ childData.children.push({
168
+ type: gc.tagName.toLowerCase(),
169
+ fill: gc.getAttribute('fill') || 'inherit'
170
+ });
171
+ }
172
+ break;
173
+ }
174
+
175
+ data.children.push(childData);
176
+ }
177
+
178
+ return data;
179
+ }
180
+
181
+ /**
182
+ * Calculate the pattern tile dimensions in user space
183
+ *
184
+ * Converts pattern tile coordinates from patternUnits space to absolute user space
185
+ * coordinates. The tile defines the repeating unit of the pattern.
186
+ *
187
+ * Pattern Tiling Mechanics:
188
+ * - Pattern tiles repeat infinitely in both x and y directions
189
+ * - The tile origin (x, y) defines where the first tile is positioned
190
+ * - Tiles are placed at (x + n*width, y + m*height) for all integers n, m
191
+ * - When patternUnits='objectBoundingBox', dimensions are fractions of target bbox
192
+ * - When patternUnits='userSpaceOnUse', dimensions are absolute coordinates
193
+ *
194
+ * @param {Object} patternData - Parsed pattern data from parsePatternElement()
195
+ * @param {Object} patternData.patternUnits - 'objectBoundingBox' or 'userSpaceOnUse'
196
+ * @param {number} patternData.x - Pattern tile x offset
197
+ * @param {number} patternData.y - Pattern tile y offset
198
+ * @param {number} patternData.width - Pattern tile width
199
+ * @param {number} patternData.height - Pattern tile height
200
+ * @param {Object} targetBBox - Target element bounding box in user space
201
+ * @param {number} targetBBox.x - Bounding box x coordinate
202
+ * @param {number} targetBBox.y - Bounding box y coordinate
203
+ * @param {number} targetBBox.width - Bounding box width
204
+ * @param {number} targetBBox.height - Bounding box height
205
+ * @returns {Object} Tile dimensions in user space containing:
206
+ * - x {Decimal}: Tile x origin in user space
207
+ * - y {Decimal}: Tile y origin in user space
208
+ * - width {Decimal}: Tile width in user space
209
+ * - height {Decimal}: Tile height in user space
210
+ *
211
+ * @example
212
+ * // objectBoundingBox pattern (default)
213
+ * const data = { patternUnits: 'objectBoundingBox', x: 0, y: 0, width: 0.25, height: 0.25 };
214
+ * const bbox = { x: 100, y: 200, width: 400, height: 300 };
215
+ * const tile = getPatternTile(data, bbox);
216
+ * // Result: { x: 100, y: 200, width: 100, height: 75 }
217
+ * // Tile is 25% of target width and height
218
+ *
219
+ * @example
220
+ * // userSpaceOnUse pattern
221
+ * const data = { patternUnits: 'userSpaceOnUse', x: 10, y: 20, width: 50, height: 50 };
222
+ * const bbox = { x: 100, y: 200, width: 400, height: 300 };
223
+ * const tile = getPatternTile(data, bbox);
224
+ * // Result: { x: 10, y: 20, width: 50, height: 50 }
225
+ * // Tile uses absolute coordinates, bbox is ignored
226
+ */
227
+ export function getPatternTile(patternData, targetBBox) {
228
+ if (patternData.patternUnits === 'objectBoundingBox') {
229
+ // Dimensions are fractions of target bbox
230
+ return {
231
+ x: D(targetBBox.x).plus(D(patternData.x).mul(targetBBox.width)),
232
+ y: D(targetBBox.y).plus(D(patternData.y).mul(targetBBox.height)),
233
+ width: D(patternData.width).mul(targetBBox.width),
234
+ height: D(patternData.height).mul(targetBBox.height)
235
+ };
236
+ }
237
+
238
+ // userSpaceOnUse - use values directly
239
+ return {
240
+ x: D(patternData.x),
241
+ y: D(patternData.y),
242
+ width: D(patternData.width),
243
+ height: D(patternData.height)
244
+ };
245
+ }
246
+
247
+ /**
248
+ * Calculate transform matrix for pattern content
249
+ *
250
+ * Builds the transformation matrix that positions and scales pattern children
251
+ * within each pattern tile. Handles viewBox scaling and patternContentUnits.
252
+ *
253
+ * Transform Chain:
254
+ * 1. If viewBox is present: scale and translate to fit viewBox into tile
255
+ * 2. If patternContentUnits='objectBoundingBox': scale by target bbox dimensions
256
+ *
257
+ * ViewBox Handling:
258
+ * - viewBox establishes a coordinate system independent of tile size
259
+ * - Content in viewBox coordinates is scaled to fit tile dimensions
260
+ * - preserveAspectRatio controls scaling (default 'xMidYMid meet' uses uniform scale)
261
+ * - Content is centered within tile when aspect ratios differ
262
+ *
263
+ * @param {Object} patternData - Parsed pattern data from parsePatternElement()
264
+ * @param {Object} patternData.patternContentUnits - 'objectBoundingBox' or 'userSpaceOnUse'
265
+ * @param {Object} [patternData.viewBoxParsed] - Parsed viewBox {x, y, width, height}
266
+ * @param {string} [patternData.preserveAspectRatio] - Aspect ratio mode (default 'xMidYMid meet')
267
+ * @param {Object} tile - Pattern tile dimensions from getPatternTile()
268
+ * @param {Decimal} tile.x - Tile x origin
269
+ * @param {Decimal} tile.y - Tile y origin
270
+ * @param {Decimal} tile.width - Tile width
271
+ * @param {Decimal} tile.height - Tile height
272
+ * @param {Object} targetBBox - Target element bounding box in user space
273
+ * @param {number} targetBBox.x - Bounding box x coordinate
274
+ * @param {number} targetBBox.y - Bounding box y coordinate
275
+ * @param {number} targetBBox.width - Bounding box width
276
+ * @param {number} targetBBox.height - Bounding box height
277
+ * @returns {Matrix} 3x3 transformation matrix for pattern content
278
+ *
279
+ * @example
280
+ * // Pattern with viewBox
281
+ * const data = {
282
+ * patternContentUnits: 'userSpaceOnUse',
283
+ * viewBoxParsed: { x: 0, y: 0, width: 100, height: 100 },
284
+ * preserveAspectRatio: 'xMidYMid meet'
285
+ * };
286
+ * const tile = { x: D(0), y: D(0), width: D(50), height: D(50) };
287
+ * const bbox = { x: 0, y: 0, width: 200, height: 200 };
288
+ * const M = getPatternContentTransform(data, tile, bbox);
289
+ * // M scales viewBox (100x100) to fit tile (50x50), scale = 0.5
290
+ *
291
+ * @example
292
+ * // Pattern with objectBoundingBox content units
293
+ * const data = {
294
+ * patternContentUnits: 'objectBoundingBox',
295
+ * viewBoxParsed: null
296
+ * };
297
+ * const tile = { x: D(0), y: D(0), width: D(100), height: D(100) };
298
+ * const bbox = { x: 50, y: 50, width: 400, height: 300 };
299
+ * const M = getPatternContentTransform(data, tile, bbox);
300
+ * // M translates to bbox origin and scales by bbox dimensions
301
+ */
302
+ export function getPatternContentTransform(patternData, tile, targetBBox) {
303
+ let M = Matrix.identity(3);
304
+
305
+ // Apply viewBox transform if present
306
+ if (patternData.viewBoxParsed) {
307
+ const vb = patternData.viewBoxParsed;
308
+ const tileWidth = Number(tile.width);
309
+ const tileHeight = Number(tile.height);
310
+
311
+ // Scale from viewBox to tile
312
+ const scaleX = tileWidth / vb.width;
313
+ const scaleY = tileHeight / vb.height;
314
+
315
+ // For 'xMidYMid meet', use uniform scale
316
+ const scale = Math.min(scaleX, scaleY);
317
+
318
+ // Center the content
319
+ const offsetX = (tileWidth - vb.width * scale) / 2;
320
+ const offsetY = (tileHeight - vb.height * scale) / 2;
321
+
322
+ M = M.mul(Transforms2D.translation(offsetX - vb.x * scale, offsetY - vb.y * scale));
323
+ M = M.mul(Transforms2D.scale(scale, scale));
324
+ }
325
+
326
+ // Apply objectBoundingBox scaling if needed
327
+ if (patternData.patternContentUnits === 'objectBoundingBox') {
328
+ M = M.mul(Transforms2D.translation(targetBBox.x, targetBBox.y));
329
+ M = M.mul(Transforms2D.scale(targetBBox.width, targetBBox.height));
330
+ }
331
+
332
+ return M;
333
+ }
334
+
335
+ /**
336
+ * Convert pattern child element to polygon representation
337
+ *
338
+ * Transforms a pattern's child shape (rect, circle, path, etc.) into a polygon
339
+ * by applying the appropriate transformation matrix. Uses sampling for curves.
340
+ *
341
+ * @param {Object} child - Pattern child element data from parsePatternElement()
342
+ * @param {string} child.type - Element type ('rect', 'circle', 'ellipse', 'path', etc.)
343
+ * @param {string} [child.transform] - Element's transform attribute
344
+ * @param {Matrix} [transform=null] - Additional transform matrix to apply (e.g., tile position + content transform)
345
+ * @param {number} [samples=20] - Number of samples for approximating curves as line segments
346
+ * @returns {Array<{x: number, y: number}>} Array of polygon vertices in user space
347
+ *
348
+ * @example
349
+ * const child = {
350
+ * type: 'rect',
351
+ * x: 0, y: 0,
352
+ * width: 10, height: 10,
353
+ * fill: 'red'
354
+ * };
355
+ * const M = Transforms2D.translation(100, 50);
356
+ * const polygon = patternChildToPolygon(child, M);
357
+ * // Returns rectangle vertices translated to (100, 50)
358
+ */
359
+ export function patternChildToPolygon(child, transform = null, samples = 20) {
360
+ // Create element-like object for ClipPathResolver
361
+ const element = {
362
+ type: child.type,
363
+ ...child,
364
+ transform: child.transform
365
+ };
366
+
367
+ // Get polygon using ClipPathResolver
368
+ let polygon = ClipPathResolver.shapeToPolygon(element, null, samples);
369
+
370
+ // Apply additional transform if provided
371
+ if (transform && polygon.length > 0) {
372
+ polygon = polygon.map(p => {
373
+ const [x, y] = Transforms2D.applyTransform(transform, p.x, p.y);
374
+ return { x, y };
375
+ });
376
+ }
377
+
378
+ return polygon;
379
+ }
380
+
381
+ /**
382
+ * Generate pattern tile positions that cover a bounding box
383
+ *
384
+ * Calculates all tile origin positions needed to cover the target area.
385
+ * Tiles are arranged in a grid, with each tile positioned at:
386
+ * (tile.x + i * tile.width, tile.y + j * tile.height)
387
+ * for integer indices i, j.
388
+ *
389
+ * Pattern Tile Generation:
390
+ * - Determines which tile indices (i, j) are needed to cover coverBBox
391
+ * - Generates positions for all tiles that overlap or touch the bbox
392
+ * - Returns array of tile origins in user space coordinates
393
+ * - Used by resolvePattern() to instantiate pattern content across target
394
+ *
395
+ * @param {Object} tile - Pattern tile dimensions from getPatternTile()
396
+ * @param {Decimal} tile.x - Tile x origin
397
+ * @param {Decimal} tile.y - Tile y origin
398
+ * @param {Decimal} tile.width - Tile width (must be > 0)
399
+ * @param {Decimal} tile.height - Tile height (must be > 0)
400
+ * @param {Object} coverBBox - Bounding box area to cover with tiles
401
+ * @param {number} coverBBox.x - Area x coordinate
402
+ * @param {number} coverBBox.y - Area y coordinate
403
+ * @param {number} coverBBox.width - Area width
404
+ * @param {number} coverBBox.height - Area height
405
+ * @returns {Array<{x: Decimal, y: Decimal}>} Array of tile origin positions in user space
406
+ *
407
+ * @example
408
+ * const tile = { x: D(0), y: D(0), width: D(50), height: D(50) };
409
+ * const bbox = { x: 25, y: 25, width: 150, height: 100 };
410
+ * const positions = getTilePositions(tile, bbox);
411
+ * // Returns positions for tiles covering the bbox:
412
+ * // [{ x: 0, y: 0 }, { x: 50, y: 0 }, { x: 100, y: 0 }, { x: 150, y: 0 },
413
+ * // { x: 0, y: 50 }, { x: 50, y: 50 }, { x: 100, y: 50 }, { x: 150, y: 50 }]
414
+ */
415
+ export function getTilePositions(tile, coverBBox) {
416
+ const positions = [];
417
+
418
+ const tileX = Number(tile.x);
419
+ const tileY = Number(tile.y);
420
+ const tileW = Number(tile.width);
421
+ const tileH = Number(tile.height);
422
+
423
+ if (tileW <= 0 || tileH <= 0) return positions;
424
+
425
+ // Calculate start and end indices
426
+ const startI = Math.floor((coverBBox.x - tileX) / tileW);
427
+ const endI = Math.ceil((coverBBox.x + coverBBox.width - tileX) / tileW);
428
+ const startJ = Math.floor((coverBBox.y - tileY) / tileH);
429
+ const endJ = Math.ceil((coverBBox.y + coverBBox.height - tileY) / tileH);
430
+
431
+ for (let i = startI; i < endI; i++) {
432
+ for (let j = startJ; j < endJ; j++) {
433
+ positions.push({
434
+ x: D(tileX).plus(D(tileW).mul(i)),
435
+ y: D(tileY).plus(D(tileH).mul(j))
436
+ });
437
+ }
438
+ }
439
+
440
+ return positions;
441
+ }
442
+
443
+ /**
444
+ * Resolve pattern to set of polygons with associated styles
445
+ *
446
+ * Expands a pattern definition into concrete geometry by:
447
+ * 1. Computing tile dimensions based on patternUnits
448
+ * 2. Generating tile positions to cover the target area
449
+ * 3. For each tile, transforming pattern children to polygons
450
+ * 4. Returning all polygons with their fill, stroke, and opacity
451
+ *
452
+ * This is the main pattern resolution function that converts the declarative
453
+ * SVG pattern into explicit polygon geometry suitable for rendering or further
454
+ * processing (e.g., clipping against target shapes).
455
+ *
456
+ * @param {Object} patternData - Parsed pattern data from parsePatternElement()
457
+ * @param {Array} patternData.children - Array of child shape data
458
+ * @param {Object} targetBBox - Target element bounding box to fill with pattern
459
+ * @param {number} targetBBox.x - Bounding box x coordinate
460
+ * @param {number} targetBBox.y - Bounding box y coordinate
461
+ * @param {number} targetBBox.width - Bounding box width
462
+ * @param {number} targetBBox.height - Bounding box height
463
+ * @param {Object} [options={}] - Resolution options
464
+ * @param {number} [options.samples=20] - Number of samples for curve approximation
465
+ * @param {number} [options.maxTiles=1000] - Maximum number of tiles to generate (performance limit)
466
+ * @returns {Array<Object>} Array of styled polygons, each containing:
467
+ * - polygon {Array<{x, y}>}: Polygon vertices in user space
468
+ * - fill {string}: Fill color (e.g., 'red', '#ff0000')
469
+ * - stroke {string}: Stroke color
470
+ * - strokeWidth {number}: Stroke width
471
+ * - opacity {number}: Opacity value (0-1)
472
+ *
473
+ * @example
474
+ * const patternData = parsePatternElement(patternEl);
475
+ * const targetBBox = { x: 0, y: 0, width: 200, height: 200 };
476
+ * const polygons = resolvePattern(patternData, targetBBox);
477
+ * // Returns array of polygons representing the pattern content
478
+ * // Each polygon can be rendered with its associated fill and stroke
479
+ */
480
+ export function resolvePattern(patternData, targetBBox, options = {}) {
481
+ const { samples = 20, maxTiles = 1000 } = options;
482
+ const result = [];
483
+
484
+ // Get tile dimensions
485
+ const tile = getPatternTile(patternData, targetBBox);
486
+
487
+ if (Number(tile.width) <= 0 || Number(tile.height) <= 0) {
488
+ return result;
489
+ }
490
+
491
+ // Get content transform
492
+ const contentTransform = getPatternContentTransform(patternData, tile, targetBBox);
493
+
494
+ // Get tile positions
495
+ const positions = getTilePositions(tile, targetBBox);
496
+
497
+ // Limit number of tiles
498
+ const limitedPositions = positions.slice(0, maxTiles);
499
+
500
+ // Convert each child in each tile
501
+ for (const pos of limitedPositions) {
502
+ const tileTranslate = Transforms2D.translation(pos.x, pos.y);
503
+
504
+ for (const child of patternData.children) {
505
+ // Combine transforms: tile position + content transform + child transform
506
+ let M = tileTranslate.mul(contentTransform);
507
+
508
+ let polygon = patternChildToPolygon(child, M, samples);
509
+
510
+ if (polygon.length >= 3) {
511
+ result.push({
512
+ polygon,
513
+ fill: child.fill,
514
+ stroke: child.stroke,
515
+ strokeWidth: child.strokeWidth,
516
+ opacity: child.opacity
517
+ });
518
+ }
519
+ }
520
+ }
521
+
522
+ return result;
523
+ }
524
+
525
+ /**
526
+ * Apply pattern fill to a target polygon
527
+ *
528
+ * Resolves the pattern and clips it against the target polygon, returning only
529
+ * the visible portions of the pattern. This is the key function for applying
530
+ * patterns to arbitrary shapes.
531
+ *
532
+ * Pattern Application Process:
533
+ * 1. Resolve pattern to polygons using resolvePattern()
534
+ * 2. For each pattern polygon, compute intersection with target polygon
535
+ * 3. Return only the intersecting portions with their styles
536
+ * 4. Results can be directly rendered to visualize the pattern fill
537
+ *
538
+ * @param {Array<{x: number, y: number}>} targetPolygon - Target polygon vertices to fill
539
+ * @param {Object} patternData - Parsed pattern data from parsePatternElement()
540
+ * @param {Object} targetBBox - Target element bounding box
541
+ * @param {number} targetBBox.x - Bounding box x coordinate
542
+ * @param {number} targetBBox.y - Bounding box y coordinate
543
+ * @param {number} targetBBox.width - Bounding box width
544
+ * @param {number} targetBBox.height - Bounding box height
545
+ * @param {Object} [options={}] - Resolution options (passed to resolvePattern)
546
+ * @param {number} [options.samples=20] - Curve sampling resolution
547
+ * @param {number} [options.maxTiles=1000] - Maximum tiles to generate
548
+ * @returns {Array<Object>} Array of clipped polygons, each containing:
549
+ * - polygon {Array<{x, y}>}: Clipped polygon vertices (intersection with target)
550
+ * - fill {string}: Fill color
551
+ * - opacity {number}: Opacity value
552
+ *
553
+ * @example
554
+ * // Apply striped pattern to a circle
555
+ * const circlePolygon = [...]; // Circle approximated as polygon
556
+ * const patternData = parsePatternElement(stripesPatternEl);
557
+ * const bbox = { x: 0, y: 0, width: 100, height: 100 };
558
+ * const clippedParts = applyPattern(circlePolygon, patternData, bbox);
559
+ * // Returns only the stripe portions visible within the circle
560
+ */
561
+ export function applyPattern(targetPolygon, patternData, targetBBox, options = {}) {
562
+ const patternPolygons = resolvePattern(patternData, targetBBox, options);
563
+ const result = [];
564
+
565
+ for (const { polygon, fill, opacity } of patternPolygons) {
566
+ if (opacity <= 0) continue;
567
+
568
+ const intersection = PolygonClip.polygonIntersection(targetPolygon, polygon);
569
+
570
+ for (const clippedPoly of intersection) {
571
+ if (clippedPoly.length >= 3) {
572
+ result.push({
573
+ polygon: clippedPoly,
574
+ fill,
575
+ opacity
576
+ });
577
+ }
578
+ }
579
+ }
580
+
581
+ return result;
582
+ }
583
+
584
+ /**
585
+ * Flatten pattern to single combined polygon (union of all shapes)
586
+ *
587
+ * Combines all pattern content into a single unified polygon by computing
588
+ * the union of all shapes. Useful for creating clip paths from patterns or
589
+ * for simplified pattern representations.
590
+ *
591
+ * @param {Object} patternData - Parsed pattern data from parsePatternElement()
592
+ * @param {Object} targetBBox - Target element bounding box
593
+ * @param {number} targetBBox.x - Bounding box x coordinate
594
+ * @param {number} targetBBox.y - Bounding box y coordinate
595
+ * @param {number} targetBBox.width - Bounding box width
596
+ * @param {number} targetBBox.height - Bounding box height
597
+ * @param {Object} [options={}] - Resolution options (passed to resolvePattern)
598
+ * @param {number} [options.samples=20] - Curve sampling resolution
599
+ * @param {number} [options.maxTiles=1000] - Maximum tiles to generate
600
+ * @returns {Array<{x: number, y: number}>} Combined polygon vertices representing union of all pattern shapes
601
+ *
602
+ * @example
603
+ * // Create a clip path from a dot pattern
604
+ * const dotPatternData = parsePatternElement(dotPatternEl);
605
+ * const bbox = { x: 0, y: 0, width: 200, height: 200 };
606
+ * const clipPolygon = patternToClipPath(dotPatternData, bbox);
607
+ * // Returns single polygon that is the union of all dots in the pattern
608
+ */
609
+ export function patternToClipPath(patternData, targetBBox, options = {}) {
610
+ const patternPolygons = resolvePattern(patternData, targetBBox, options);
611
+
612
+ // Union all polygons
613
+ let result = [];
614
+
615
+ for (const { polygon, opacity } of patternPolygons) {
616
+ if (opacity > 0 && polygon.length >= 3) {
617
+ if (result.length === 0) {
618
+ result = polygon;
619
+ } else {
620
+ const unionResult = PolygonClip.polygonUnion(result, polygon);
621
+ if (unionResult.length > 0 && unionResult[0].length >= 3) {
622
+ result = unionResult[0];
623
+ }
624
+ }
625
+ }
626
+ }
627
+
628
+ return result;
629
+ }
630
+
631
+ /**
632
+ * Generate SVG path data for pattern content
633
+ *
634
+ * Converts the flattened pattern polygon into SVG path data string that can
635
+ * be used in a `<path>` element's `d` attribute.
636
+ *
637
+ * @param {Object} patternData - Parsed pattern data from parsePatternElement()
638
+ * @param {Object} targetBBox - Target element bounding box
639
+ * @param {number} targetBBox.x - Bounding box x coordinate
640
+ * @param {number} targetBBox.y - Bounding box y coordinate
641
+ * @param {number} targetBBox.width - Bounding box width
642
+ * @param {number} targetBBox.height - Bounding box height
643
+ * @param {Object} [options={}] - Resolution options (passed to patternToClipPath)
644
+ * @param {number} [options.samples=20] - Curve sampling resolution
645
+ * @param {number} [options.maxTiles=1000] - Maximum tiles to generate
646
+ * @returns {string} SVG path data string (e.g., "M 0 0 L 10 0 L 10 10 Z")
647
+ *
648
+ * @example
649
+ * const patternData = parsePatternElement(patternEl);
650
+ * const bbox = { x: 0, y: 0, width: 100, height: 100 };
651
+ * const pathData = patternToPathData(patternData, bbox);
652
+ * // Returns: "M 0.000000 0.000000 L 10.000000 0.000000 L 10.000000 10.000000 Z"
653
+ * // Can be used in: <path d={pathData} />
654
+ */
655
+ export function patternToPathData(patternData, targetBBox, options = {}) {
656
+ const polygon = patternToClipPath(patternData, targetBBox, options);
657
+
658
+ if (polygon.length < 3) return '';
659
+
660
+ let d = '';
661
+ for (let i = 0; i < polygon.length; i++) {
662
+ const p = polygon[i];
663
+ const x = Number(p.x).toFixed(6);
664
+ const y = Number(p.y).toFixed(6);
665
+ d += i === 0 ? `M ${x} ${y}` : ` L ${x} ${y}`;
666
+ }
667
+ d += ' Z';
668
+
669
+ return d;
670
+ }
671
+
672
+ /**
673
+ * Calculate pattern tile count for a given area
674
+ *
675
+ * Computes how many tiles (columns and rows) are needed to cover the target
676
+ * bounding box. Useful for estimating pattern complexity and performance.
677
+ *
678
+ * @param {Object} patternData - Parsed pattern data from parsePatternElement()
679
+ * @param {Object} targetBBox - Target element bounding box to cover
680
+ * @param {number} targetBBox.width - Bounding box width
681
+ * @param {number} targetBBox.height - Bounding box height
682
+ * @returns {Object} Tile count information:
683
+ * - columns {number}: Number of tile columns needed
684
+ * - rows {number}: Number of tile rows needed
685
+ * - total {number}: Total number of tiles (columns * rows)
686
+ *
687
+ * @example
688
+ * const patternData = { width: 0.25, height: 0.25, patternUnits: 'objectBoundingBox' };
689
+ * const bbox = { x: 0, y: 0, width: 400, height: 300 };
690
+ * const tileCount = getPatternTileCount(patternData, bbox);
691
+ * // With 0.25 fraction: tile is 100x75, needs 4 columns and 4 rows
692
+ * // Result: { columns: 4, rows: 4, total: 16 }
693
+ */
694
+ export function getPatternTileCount(patternData, targetBBox) {
695
+ const tile = getPatternTile(patternData, targetBBox);
696
+
697
+ const tileW = Number(tile.width);
698
+ const tileH = Number(tile.height);
699
+
700
+ if (tileW <= 0 || tileH <= 0) {
701
+ return { columns: 0, rows: 0, total: 0 };
702
+ }
703
+
704
+ const columns = Math.ceil(targetBBox.width / tileW);
705
+ const rows = Math.ceil(targetBBox.height / tileH);
706
+
707
+ return {
708
+ columns,
709
+ rows,
710
+ total: columns * rows
711
+ };
712
+ }
713
+
714
+ /**
715
+ * Get bounding box of pattern content
716
+ *
717
+ * Calculates the bounding box that encloses all child shapes within the pattern
718
+ * definition. Analyzes only the intrinsic geometry of children, not their
719
+ * positions after tile/transform application.
720
+ *
721
+ * Supported shape types: rect, circle, ellipse, line
722
+ * (Other shapes like path, polygon return no bbox contribution)
723
+ *
724
+ * @param {Object} patternData - Parsed pattern data from parsePatternElement()
725
+ * @param {Array} patternData.children - Array of child element data
726
+ * @returns {Object} Bounding box of pattern content:
727
+ * - x {number}: Left edge
728
+ * - y {number}: Top edge
729
+ * - width {number}: Width
730
+ * - height {number}: Height
731
+ * Returns {x: 0, y: 0, width: 0, height: 0} if no children have computable bbox
732
+ *
733
+ * @example
734
+ * const patternData = {
735
+ * children: [
736
+ * { type: 'rect', x: 10, y: 20, width: 50, height: 30 },
737
+ * { type: 'circle', cx: 80, cy: 50, r: 10 }
738
+ * ]
739
+ * };
740
+ * const bbox = getPatternContentBBox(patternData);
741
+ * // Result: { x: 10, y: 20, width: 80, height: 40 }
742
+ * // (from x:10 to x:90, y:20 to y:60)
743
+ */
744
+ export function getPatternContentBBox(patternData) {
745
+ let minX = Infinity;
746
+ let minY = Infinity;
747
+ let maxX = -Infinity;
748
+ let maxY = -Infinity;
749
+
750
+ for (const child of patternData.children) {
751
+ let childBBox = null;
752
+
753
+ switch (child.type) {
754
+ case 'rect':
755
+ childBBox = {
756
+ x: child.x,
757
+ y: child.y,
758
+ width: child.width,
759
+ height: child.height
760
+ };
761
+ break;
762
+ case 'circle':
763
+ childBBox = {
764
+ x: child.cx - child.r,
765
+ y: child.cy - child.r,
766
+ width: child.r * 2,
767
+ height: child.r * 2
768
+ };
769
+ break;
770
+ case 'ellipse':
771
+ childBBox = {
772
+ x: child.cx - child.rx,
773
+ y: child.cy - child.ry,
774
+ width: child.rx * 2,
775
+ height: child.ry * 2
776
+ };
777
+ break;
778
+ case 'line':
779
+ childBBox = {
780
+ x: Math.min(child.x1, child.x2),
781
+ y: Math.min(child.y1, child.y2),
782
+ width: Math.abs(child.x2 - child.x1),
783
+ height: Math.abs(child.y2 - child.y1)
784
+ };
785
+ break;
786
+ }
787
+
788
+ if (childBBox) {
789
+ minX = Math.min(minX, childBBox.x);
790
+ minY = Math.min(minY, childBBox.y);
791
+ maxX = Math.max(maxX, childBBox.x + childBBox.width);
792
+ maxY = Math.max(maxY, childBBox.y + childBBox.height);
793
+ }
794
+ }
795
+
796
+ if (minX === Infinity) {
797
+ return { x: 0, y: 0, width: 0, height: 0 };
798
+ }
799
+
800
+ return {
801
+ x: minX,
802
+ y: minY,
803
+ width: maxX - minX,
804
+ height: maxY - minY
805
+ };
806
+ }
807
+
808
+ /**
809
+ * Parse patternTransform attribute
810
+ *
811
+ * Converts an SVG transform string (e.g., "rotate(45) translate(10, 20)")
812
+ * into a 3x3 transformation matrix. The patternTransform attribute allows
813
+ * additional transformations to be applied to the entire pattern coordinate system.
814
+ *
815
+ * PatternTransform Attribute:
816
+ * - Applied to the pattern coordinate system before patternUnits scaling
817
+ * - Can contain any SVG transform functions: translate, rotate, scale, skewX, skewY, matrix
818
+ * - Useful for rotating, scaling, or skewing entire patterns
819
+ * - Composed right-to-left (last transform applied first in coordinate chain)
820
+ *
821
+ * @param {string|null} transformStr - SVG transform attribute string
822
+ * Examples: "rotate(45)", "translate(10, 20) scale(2)", "matrix(1,0,0,1,0,0)"
823
+ * @returns {Matrix} 3x3 transformation matrix (identity matrix if transformStr is null/empty)
824
+ *
825
+ * @example
826
+ * const M = parsePatternTransform("rotate(45)");
827
+ * // Returns 3x3 matrix representing 45-degree rotation
828
+ *
829
+ * @example
830
+ * const M = parsePatternTransform("translate(100, 50) scale(2)");
831
+ * // Returns matrix that first scales by 2, then translates by (100, 50)
832
+ *
833
+ * @example
834
+ * const M = parsePatternTransform(null);
835
+ * // Returns identity matrix (no transformation)
836
+ */
837
+ export function parsePatternTransform(transformStr) {
838
+ if (!transformStr) {
839
+ return Matrix.identity(3);
840
+ }
841
+
842
+ // Use ClipPathResolver's transform parser
843
+ return ClipPathResolver.parseTransform(transformStr);
844
+ }