@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,1407 @@
1
+ /**
2
+ * Mask Resolver Module - Flatten SVG mask elements
3
+ *
4
+ * Resolves SVG mask elements by converting them to clipping operations
5
+ * or alpha channel modifications with Decimal.js precision.
6
+ *
7
+ * SVG Mask Concepts:
8
+ *
9
+ * 1. Mask Types (mask-type CSS property):
10
+ * - 'luminance' (default): Uses the luminance of the mask content to determine opacity.
11
+ * The luminance is calculated using the sRGB formula: 0.2126*R + 0.7152*G + 0.0722*B.
12
+ * White (luminance=1) means fully opaque, black (luminance=0) means fully transparent.
13
+ *
14
+ * - 'alpha': Uses only the alpha channel of the mask content to determine opacity.
15
+ * The RGB color values are ignored, only the alpha/opacity values matter.
16
+ *
17
+ * 2. Coordinate Systems (maskUnits and maskContentUnits):
18
+ * - 'objectBoundingBox': Coordinates are relative to the bounding box of the element
19
+ * being masked. Values are typically 0-1 representing fractions of the bbox dimensions.
20
+ * Default for maskUnits.
21
+ *
22
+ * - 'userSpaceOnUse': Coordinates are in the current user coordinate system (absolute).
23
+ * Default for maskContentUnits.
24
+ *
25
+ * 3. Default Mask Region:
26
+ * Per SVG spec, the default mask region extends from -10% to 120% in both dimensions
27
+ * relative to the object bounding box, giving a 20% margin around the masked element.
28
+ *
29
+ * 4. Mesh Gradient Integration:
30
+ * Mesh gradients can be used as mask fills. When a mask child references a mesh gradient,
31
+ * the gradient's color values are sampled and converted to opacity values based on the
32
+ * mask type (luminance or alpha). The mesh's Coons patch boundaries can also be used
33
+ * for purely geometric clipping operations.
34
+ *
35
+ * Supports:
36
+ * - Luminance masks (default)
37
+ * - Alpha masks (mask-type="alpha")
38
+ * - maskUnits (userSpaceOnUse, objectBoundingBox)
39
+ * - maskContentUnits (userSpaceOnUse, objectBoundingBox)
40
+ * - Mesh gradient fills in mask content
41
+ * - Shape-based clipping using mesh gradient boundaries
42
+ *
43
+ * @module mask-resolver
44
+ */
45
+
46
+ import Decimal from 'decimal.js';
47
+ import { Matrix } from './matrix.js';
48
+ import * as Transforms2D from './transforms2d.js';
49
+ import * as PolygonClip from './polygon-clip.js';
50
+ import * as ClipPathResolver from './clip-path-resolver.js';
51
+ import * as MeshGradient from './mesh-gradient.js';
52
+
53
+ Decimal.set({ precision: 80 });
54
+
55
+ const D = x => (x instanceof Decimal ? x : new Decimal(x));
56
+
57
+ // Default mask bounds (SVG spec: -10% to 120% in each dimension)
58
+ const DEFAULT_MASK_X = -0.1;
59
+ const DEFAULT_MASK_Y = -0.1;
60
+ const DEFAULT_MASK_WIDTH = 1.2;
61
+ const DEFAULT_MASK_HEIGHT = 1.2;
62
+
63
+ /**
64
+ * Mask type enumeration
65
+ *
66
+ * Defines the two standard SVG mask types as specified by the mask-type CSS property.
67
+ *
68
+ * @enum {string}
69
+ * @property {string} LUMINANCE - Uses the luminance of mask content (default). Formula: 0.2126*R + 0.7152*G + 0.0722*B
70
+ * @property {string} ALPHA - Uses only the alpha channel of mask content, ignoring color values
71
+ *
72
+ * @example
73
+ * // Create a luminance-based mask
74
+ * const maskData = {
75
+ * maskType: MaskType.LUMINANCE,
76
+ * // ... other properties
77
+ * };
78
+ *
79
+ * @example
80
+ * // Create an alpha-based mask
81
+ * const maskData = {
82
+ * maskType: MaskType.ALPHA,
83
+ * // ... other properties
84
+ * };
85
+ */
86
+ export const MaskType = {
87
+ LUMINANCE: 'luminance',
88
+ ALPHA: 'alpha'
89
+ };
90
+
91
+ /**
92
+ * Parse SVG mask element to structured data
93
+ *
94
+ * Extracts all relevant attributes from an SVG <mask> element and its children,
95
+ * converting them to a standardized data structure for further processing.
96
+ *
97
+ * Handles maskUnits and maskContentUnits coordinate systems, applies default values
98
+ * per SVG specification, and recursively parses child elements (shapes, groups, etc.).
99
+ *
100
+ * @param {Element} maskElement - SVG mask DOM element to parse
101
+ * @returns {Object} Parsed mask data structure containing:
102
+ * - {string} id - Mask element ID
103
+ * - {string} maskUnits - Coordinate system for mask region ('objectBoundingBox' | 'userSpaceOnUse')
104
+ * - {string} maskContentUnits - Coordinate system for mask content ('objectBoundingBox' | 'userSpaceOnUse')
105
+ * - {string} maskType - Mask compositing mode ('luminance' | 'alpha')
106
+ * - {number|null} x - Mask region x coordinate (null for userSpaceOnUse without explicit value)
107
+ * - {number|null} y - Mask region y coordinate
108
+ * - {number|null} width - Mask region width
109
+ * - {number|null} height - Mask region height
110
+ * - {string|null} transform - Transform attribute value
111
+ * - {Array<Object>} children - Parsed child elements with shape-specific attributes
112
+ *
113
+ * @example
114
+ * // Parse a basic mask element
115
+ * const maskEl = document.querySelector('#myMask');
116
+ * const maskData = parseMaskElement(maskEl);
117
+ * // Result: {
118
+ * // id: 'myMask',
119
+ * // maskUnits: 'objectBoundingBox',
120
+ * // maskContentUnits: 'userSpaceOnUse',
121
+ * // maskType: 'luminance',
122
+ * // x: -0.1, y: -0.1, width: 1.2, height: 1.2,
123
+ * // transform: null,
124
+ * // children: [...]
125
+ * // }
126
+ *
127
+ * @example
128
+ * // Parse a mask with custom bounds
129
+ * const maskData = parseMaskElement(maskElement);
130
+ * console.log(`Mask type: ${maskData.maskType}`); // 'luminance' or 'alpha'
131
+ * console.log(`Number of shapes: ${maskData.children.length}`);
132
+ */
133
+ export function parseMaskElement(maskElement) {
134
+ const data = {
135
+ id: maskElement.getAttribute('id') || '',
136
+ maskUnits: maskElement.getAttribute('maskUnits') || 'objectBoundingBox',
137
+ maskContentUnits: maskElement.getAttribute('maskContentUnits') || 'userSpaceOnUse',
138
+ maskType: maskElement.getAttribute('mask-type') ||
139
+ maskElement.style?.maskType || 'luminance',
140
+ x: maskElement.getAttribute('x'),
141
+ y: maskElement.getAttribute('y'),
142
+ width: maskElement.getAttribute('width'),
143
+ height: maskElement.getAttribute('height'),
144
+ transform: maskElement.getAttribute('transform') || null,
145
+ children: []
146
+ };
147
+
148
+ // Set defaults based on maskUnits
149
+ if (data.maskUnits === 'objectBoundingBox') {
150
+ data.x = data.x !== null ? parseFloat(data.x) : DEFAULT_MASK_X;
151
+ data.y = data.y !== null ? parseFloat(data.y) : DEFAULT_MASK_Y;
152
+ data.width = data.width !== null ? parseFloat(data.width) : DEFAULT_MASK_WIDTH;
153
+ data.height = data.height !== null ? parseFloat(data.height) : DEFAULT_MASK_HEIGHT;
154
+ } else {
155
+ data.x = data.x !== null ? parseFloat(data.x) : null;
156
+ data.y = data.y !== null ? parseFloat(data.y) : null;
157
+ data.width = data.width !== null ? parseFloat(data.width) : null;
158
+ data.height = data.height !== null ? parseFloat(data.height) : null;
159
+ }
160
+
161
+ // Parse child elements
162
+ for (const child of maskElement.children) {
163
+ const tagName = child.tagName.toLowerCase();
164
+ const childData = {
165
+ type: tagName,
166
+ fill: child.getAttribute('fill') || child.style?.fill || 'black',
167
+ fillOpacity: parseFloat(child.getAttribute('fill-opacity') ||
168
+ child.style?.fillOpacity || '1'),
169
+ opacity: parseFloat(child.getAttribute('opacity') ||
170
+ child.style?.opacity || '1'),
171
+ transform: child.getAttribute('transform') || null
172
+ };
173
+
174
+ // Parse shape-specific attributes
175
+ switch (tagName) {
176
+ case 'rect':
177
+ childData.x = parseFloat(child.getAttribute('x') || '0');
178
+ childData.y = parseFloat(child.getAttribute('y') || '0');
179
+ childData.width = parseFloat(child.getAttribute('width') || '0');
180
+ childData.height = parseFloat(child.getAttribute('height') || '0');
181
+ childData.rx = parseFloat(child.getAttribute('rx') || '0');
182
+ childData.ry = parseFloat(child.getAttribute('ry') || '0');
183
+ break;
184
+ case 'circle':
185
+ childData.cx = parseFloat(child.getAttribute('cx') || '0');
186
+ childData.cy = parseFloat(child.getAttribute('cy') || '0');
187
+ childData.r = parseFloat(child.getAttribute('r') || '0');
188
+ break;
189
+ case 'ellipse':
190
+ childData.cx = parseFloat(child.getAttribute('cx') || '0');
191
+ childData.cy = parseFloat(child.getAttribute('cy') || '0');
192
+ childData.rx = parseFloat(child.getAttribute('rx') || '0');
193
+ childData.ry = parseFloat(child.getAttribute('ry') || '0');
194
+ break;
195
+ case 'path':
196
+ childData.d = child.getAttribute('d') || '';
197
+ break;
198
+ case 'polygon':
199
+ childData.points = child.getAttribute('points') || '';
200
+ break;
201
+ case 'polyline':
202
+ childData.points = child.getAttribute('points') || '';
203
+ break;
204
+ case 'g':
205
+ // Recursively parse group children
206
+ childData.children = [];
207
+ for (const gc of child.children) {
208
+ // Simplified - just store the tag and basic info
209
+ childData.children.push({
210
+ type: gc.tagName.toLowerCase(),
211
+ fill: gc.getAttribute('fill') || 'inherit'
212
+ });
213
+ }
214
+ break;
215
+ }
216
+
217
+ data.children.push(childData);
218
+ }
219
+
220
+ return data;
221
+ }
222
+
223
+ /**
224
+ * Get the effective mask region in user coordinate space
225
+ *
226
+ * Computes the actual mask region by applying the appropriate coordinate system
227
+ * transformation based on maskUnits. If maskUnits is 'objectBoundingBox', the mask
228
+ * coordinates are treated as fractions of the target element's bounding box.
229
+ *
230
+ * SVG Spec: Default mask region is -10% to 120% in each dimension when using
231
+ * objectBoundingBox units, providing a 20% margin around the masked element.
232
+ *
233
+ * @param {Object} maskData - Parsed mask data from parseMaskElement()
234
+ * @param {Object} targetBBox - Target element bounding box with properties:
235
+ * - {number} x - Bounding box x coordinate
236
+ * - {number} y - Bounding box y coordinate
237
+ * - {number} width - Bounding box width
238
+ * - {number} height - Bounding box height
239
+ * @returns {Object} Computed mask region in user coordinates with Decimal precision:
240
+ * - {Decimal} x - Mask region x coordinate
241
+ * - {Decimal} y - Mask region y coordinate
242
+ * - {Decimal} width - Mask region width
243
+ * - {Decimal} height - Mask region height
244
+ *
245
+ * @example
246
+ * // Get mask region for objectBoundingBox units
247
+ * const maskData = {
248
+ * maskUnits: 'objectBoundingBox',
249
+ * x: -0.1, y: -0.1, width: 1.2, height: 1.2
250
+ * };
251
+ * const targetBBox = { x: 100, y: 50, width: 200, height: 150 };
252
+ * const region = getMaskRegion(maskData, targetBBox);
253
+ * // region.x = 100 + (-0.1 * 200) = 80
254
+ * // region.y = 50 + (-0.1 * 150) = 35
255
+ * // region.width = 1.2 * 200 = 240
256
+ * // region.height = 1.2 * 150 = 180
257
+ *
258
+ * @example
259
+ * // Get mask region for userSpaceOnUse units
260
+ * const maskData = {
261
+ * maskUnits: 'userSpaceOnUse',
262
+ * x: 50, y: 50, width: 300, height: 200
263
+ * };
264
+ * const region = getMaskRegion(maskData, targetBBox);
265
+ * // region.x = 50 (absolute)
266
+ * // region.y = 50 (absolute)
267
+ */
268
+ export function getMaskRegion(maskData, targetBBox) {
269
+ if (maskData.maskUnits === 'objectBoundingBox') {
270
+ // Coordinates are relative to target bounding box
271
+ return {
272
+ x: D(targetBBox.x).plus(D(maskData.x).mul(targetBBox.width)),
273
+ y: D(targetBBox.y).plus(D(maskData.y).mul(targetBBox.height)),
274
+ width: D(maskData.width).mul(targetBBox.width),
275
+ height: D(maskData.height).mul(targetBBox.height)
276
+ };
277
+ }
278
+
279
+ // userSpaceOnUse - use values directly (or defaults if null)
280
+ return {
281
+ x: maskData.x !== null ? D(maskData.x) : D(targetBBox.x).minus(D(targetBBox.width).mul(0.1)),
282
+ y: maskData.y !== null ? D(maskData.y) : D(targetBBox.y).minus(D(targetBBox.height).mul(0.1)),
283
+ width: maskData.width !== null ? D(maskData.width) : D(targetBBox.width).mul(1.2),
284
+ height: maskData.height !== null ? D(maskData.height) : D(targetBBox.height).mul(1.2)
285
+ };
286
+ }
287
+
288
+ /**
289
+ * Convert a mask child element to a polygon representation
290
+ *
291
+ * Transforms mask content (rectangles, circles, paths, etc.) into polygon vertices
292
+ * using the clip-path-resolver's shape conversion. Handles coordinate system
293
+ * transformations based on maskContentUnits.
294
+ *
295
+ * When maskContentUnits is 'objectBoundingBox', the polygon coordinates are scaled
296
+ * and translated relative to the target element's bounding box. When 'userSpaceOnUse',
297
+ * coordinates remain in absolute user space.
298
+ *
299
+ * @param {Object} child - Mask child element data (from parseMaskElement)
300
+ * @param {Object} targetBBox - Target element bounding box {x, y, width, height}
301
+ * @param {string} contentUnits - maskContentUnits value ('objectBoundingBox' | 'userSpaceOnUse')
302
+ * @param {number} [samples=20] - Number of samples for curve approximation in paths/circles
303
+ * @returns {Array<{x: Decimal, y: Decimal}>} Polygon vertices with Decimal precision
304
+ *
305
+ * @example
306
+ * // Convert a rectangle in objectBoundingBox units
307
+ * const child = { type: 'rect', x: 0, y: 0, width: 1, height: 1 };
308
+ * const bbox = { x: 100, y: 50, width: 200, height: 150 };
309
+ * const polygon = maskChildToPolygon(child, bbox, 'objectBoundingBox');
310
+ * // Result: vertices at (100,50), (300,50), (300,200), (100,200)
311
+ *
312
+ * @example
313
+ * // Convert a circle in userSpaceOnUse
314
+ * const child = { type: 'circle', cx: 150, cy: 100, r: 50 };
315
+ * const polygon = maskChildToPolygon(child, bbox, 'userSpaceOnUse', 32);
316
+ * // Result: 32-sided polygon approximating the circle
317
+ */
318
+ export function maskChildToPolygon(child, targetBBox, contentUnits, samples = 20) {
319
+ // Create element-like object for ClipPathResolver
320
+ const element = {
321
+ type: child.type,
322
+ ...child,
323
+ transform: child.transform
324
+ };
325
+
326
+ // Get polygon using ClipPathResolver
327
+ let polygon = ClipPathResolver.shapeToPolygon(element, null, samples);
328
+
329
+ // Apply objectBoundingBox scaling if needed
330
+ if (contentUnits === 'objectBoundingBox' && polygon.length > 0) {
331
+ polygon = polygon.map(p => ({
332
+ x: D(targetBBox.x).plus(p.x.mul(targetBBox.width)),
333
+ y: D(targetBBox.y).plus(p.y.mul(targetBBox.height))
334
+ }));
335
+ }
336
+
337
+ return polygon;
338
+ }
339
+
340
+ /**
341
+ * Calculate luminance value for a CSS color string
342
+ *
343
+ * Converts a color to its perceived brightness using the standard sRGB luminance formula:
344
+ * Luminance = 0.2126*R + 0.7152*G + 0.0722*B
345
+ *
346
+ * These coefficients reflect the human eye's different sensitivity to red, green, and blue.
347
+ * Green contributes most to perceived brightness (71.52%), red less (21.26%), and blue
348
+ * least (7.22%).
349
+ *
350
+ * This formula is used in luminance-based SVG masks to determine opacity: white (luminance=1)
351
+ * produces full opacity, black (luminance=0) produces full transparency.
352
+ *
353
+ * @param {string} colorStr - CSS color string in formats:
354
+ * - Hex: '#rgb', '#rrggbb'
355
+ * - RGB: 'rgb(r, g, b)'
356
+ * - Named: 'white', 'black', 'red', 'green', 'blue', 'yellow', 'cyan', 'magenta', 'gray'/'grey'
357
+ * - Special: 'none', 'transparent'
358
+ * @returns {number} Luminance value in range 0-1 (0=black, 1=white)
359
+ *
360
+ * @example
361
+ * // Pure colors
362
+ * colorToLuminance('white'); // Returns 1.0
363
+ * colorToLuminance('black'); // Returns 0.0
364
+ * colorToLuminance('red'); // Returns 0.2126
365
+ * colorToLuminance('green'); // Returns 0.7152
366
+ * colorToLuminance('blue'); // Returns 0.0722
367
+ *
368
+ * @example
369
+ * // Hex colors
370
+ * colorToLuminance('#ffffff'); // Returns 1.0 (white)
371
+ * colorToLuminance('#000000'); // Returns 0.0 (black)
372
+ * colorToLuminance('#808080'); // Returns ~0.5 (gray)
373
+ * colorToLuminance('#f00'); // Returns 0.2126 (red, short form)
374
+ *
375
+ * @example
376
+ * // RGB colors
377
+ * colorToLuminance('rgb(255, 0, 0)'); // Returns 0.2126 (red)
378
+ * colorToLuminance('rgb(128, 128, 128)'); // Returns ~0.5 (gray)
379
+ *
380
+ * @example
381
+ * // Use in luminance mask calculation
382
+ * const maskChild = { fill: '#808080', opacity: 0.8 };
383
+ * const luminance = colorToLuminance(maskChild.fill);
384
+ * const effectiveOpacity = luminance * maskChild.opacity; // 0.5 * 0.8 = 0.4
385
+ */
386
+ export function colorToLuminance(colorStr) {
387
+ if (!colorStr || colorStr === 'none' || colorStr === 'transparent') {
388
+ return 0;
389
+ }
390
+
391
+ // Parse RGB values
392
+ const rgbMatch = colorStr.match(/rgb\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/i);
393
+ if (rgbMatch) {
394
+ const r = parseInt(rgbMatch[1]) / 255;
395
+ const g = parseInt(rgbMatch[2]) / 255;
396
+ const b = parseInt(rgbMatch[3]) / 255;
397
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
398
+ }
399
+
400
+ // Parse hex colors
401
+ const hexMatch = colorStr.match(/^#([0-9a-f]{3,6})$/i);
402
+ if (hexMatch) {
403
+ let hex = hexMatch[1];
404
+ if (hex.length === 3) {
405
+ hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
406
+ }
407
+ const r = parseInt(hex.slice(0, 2), 16) / 255;
408
+ const g = parseInt(hex.slice(2, 4), 16) / 255;
409
+ const b = parseInt(hex.slice(4, 6), 16) / 255;
410
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
411
+ }
412
+
413
+ // Named colors (common ones)
414
+ const namedColors = {
415
+ white: 1,
416
+ black: 0,
417
+ red: 0.2126,
418
+ green: 0.7152,
419
+ blue: 0.0722,
420
+ yellow: 0.9278,
421
+ cyan: 0.7874,
422
+ magenta: 0.2848,
423
+ gray: 0.5,
424
+ grey: 0.5
425
+ };
426
+
427
+ const lower = colorStr.toLowerCase();
428
+ if (namedColors[lower] !== undefined) {
429
+ return namedColors[lower];
430
+ }
431
+
432
+ // Default to 1 (white/opaque) if unknown
433
+ return 1;
434
+ }
435
+
436
+ /**
437
+ * Calculate effective opacity for a mask child element
438
+ *
439
+ * Combines fill-opacity and opacity attributes, and applies luminance calculation
440
+ * for luminance-based masks. The result determines how much the mask child
441
+ * contributes to masking the target element.
442
+ *
443
+ * For luminance masks: opacity = fill-opacity × opacity × luminance(fill-color)
444
+ * For alpha masks: opacity = fill-opacity × opacity (color is ignored)
445
+ *
446
+ * @param {Object} child - Mask child data with properties:
447
+ * - {string} fill - Fill color
448
+ * - {number} fillOpacity - Fill opacity (0-1)
449
+ * - {number} opacity - Element opacity (0-1)
450
+ * @param {string} maskType - Mask type: 'luminance' or 'alpha'
451
+ * @returns {number} Effective opacity value in range 0-1
452
+ *
453
+ * @example
454
+ * // Alpha mask - color is ignored
455
+ * const child = { fill: 'red', fillOpacity: 0.8, opacity: 0.5 };
456
+ * const opacity = getMaskChildOpacity(child, MaskType.ALPHA);
457
+ * // Returns: 0.8 × 0.5 = 0.4
458
+ *
459
+ * @example
460
+ * // Luminance mask - white fill
461
+ * const child = { fill: 'white', fillOpacity: 0.8, opacity: 0.5 };
462
+ * const opacity = getMaskChildOpacity(child, MaskType.LUMINANCE);
463
+ * // Returns: 0.8 × 0.5 × 1.0 = 0.4
464
+ *
465
+ * @example
466
+ * // Luminance mask - gray fill
467
+ * const child = { fill: '#808080', fillOpacity: 1.0, opacity: 1.0 };
468
+ * const opacity = getMaskChildOpacity(child, MaskType.LUMINANCE);
469
+ * // Returns: 1.0 × 1.0 × 0.5 = 0.5 (gray has ~50% luminance)
470
+ *
471
+ * @example
472
+ * // Luminance mask - black fill
473
+ * const child = { fill: 'black', fillOpacity: 1.0, opacity: 1.0 };
474
+ * const opacity = getMaskChildOpacity(child, MaskType.LUMINANCE);
475
+ * // Returns: 1.0 × 1.0 × 0.0 = 0.0 (black = fully transparent in luminance mask)
476
+ */
477
+ export function getMaskChildOpacity(child, maskType) {
478
+ const fillOpacity = child.fillOpacity || 1;
479
+ const opacity = child.opacity || 1;
480
+ const baseOpacity = fillOpacity * opacity;
481
+
482
+ if (maskType === MaskType.ALPHA) {
483
+ return baseOpacity;
484
+ }
485
+
486
+ // Luminance mask: multiply by luminance of fill color
487
+ const luminance = colorToLuminance(child.fill);
488
+ return baseOpacity * luminance;
489
+ }
490
+
491
+ /**
492
+ * Resolve mask to a set of weighted clipping polygons
493
+ *
494
+ * Converts all mask child elements into polygon representations with associated
495
+ * opacity values. Each child element becomes one or more polygons that define
496
+ * regions with specific opacity levels for masking.
497
+ *
498
+ * This is the core mask resolution function that processes mask content into
499
+ * a format suitable for clipping operations or alpha compositing.
500
+ *
501
+ * @param {Object} maskData - Parsed mask data from parseMaskElement()
502
+ * @param {Object} targetBBox - Target element bounding box {x, y, width, height}
503
+ * @param {Object} [options={}] - Resolution options:
504
+ * - {number} samples - Number of curve samples for path approximation (default: 20)
505
+ * @returns {Array<{polygon: Array, opacity: number}>} Array of mask regions, each containing:
506
+ * - polygon: Array of {x: Decimal, y: Decimal} vertices
507
+ * - opacity: Effective opacity value (0-1) for this region
508
+ *
509
+ * @example
510
+ * // Resolve a simple mask with a white rectangle
511
+ * const maskData = {
512
+ * maskType: 'luminance',
513
+ * maskContentUnits: 'userSpaceOnUse',
514
+ * children: [{
515
+ * type: 'rect',
516
+ * x: 0, y: 0, width: 100, height: 100,
517
+ * fill: 'white',
518
+ * opacity: 1.0
519
+ * }]
520
+ * };
521
+ * const bbox = { x: 0, y: 0, width: 200, height: 200 };
522
+ * const regions = resolveMask(maskData, bbox);
523
+ * // Returns: [{ polygon: [...4 vertices...], opacity: 1.0 }]
524
+ *
525
+ * @example
526
+ * // Resolve mask with multiple children
527
+ * const regions = resolveMask(maskData, bbox, { samples: 32 });
528
+ * regions.forEach(({ polygon, opacity }) => {
529
+ * console.log(`Region with ${polygon.length} vertices, opacity: ${opacity}`);
530
+ * });
531
+ */
532
+ export function resolveMask(maskData, targetBBox, options = {}) {
533
+ const { samples = 20 } = options;
534
+ const maskType = maskData.maskType || MaskType.LUMINANCE;
535
+ const result = [];
536
+
537
+ for (const child of maskData.children) {
538
+ const polygon = maskChildToPolygon(
539
+ child,
540
+ targetBBox,
541
+ maskData.maskContentUnits,
542
+ samples
543
+ );
544
+
545
+ if (polygon.length >= 3) {
546
+ const opacity = getMaskChildOpacity(child, maskType);
547
+ result.push({ polygon, opacity });
548
+ }
549
+ }
550
+
551
+ return result;
552
+ }
553
+
554
+ /**
555
+ * Apply mask to a target polygon
556
+ *
557
+ * Computes the intersection of a target polygon with mask regions, producing
558
+ * a set of clipped polygons each with associated opacity values. This is used
559
+ * to apply SVG masks to geometric shapes.
560
+ *
561
+ * The function:
562
+ * 1. Resolves the mask into opacity-weighted regions
563
+ * 2. Computes the geometric intersection of each mask region with the target
564
+ * 3. Returns only non-zero opacity intersections
565
+ *
566
+ * The result can be used for rendering semi-transparent regions or for further
567
+ * compositing operations.
568
+ *
569
+ * @param {Array<{x: Decimal, y: Decimal}>} targetPolygon - Target polygon vertices to be masked
570
+ * @param {Object} maskData - Parsed mask data from parseMaskElement()
571
+ * @param {Object} targetBBox - Target element bounding box {x, y, width, height}
572
+ * @param {Object} [options={}] - Options passed to resolveMask():
573
+ * - {number} samples - Curve sampling resolution (default: 20)
574
+ * @returns {Array<{polygon: Array, opacity: number}>} Array of masked regions:
575
+ * - polygon: Clipped polygon vertices
576
+ * - opacity: Mask opacity for this region (0-1)
577
+ *
578
+ * @example
579
+ * // Apply a circular mask to a rectangle
580
+ * const targetPolygon = [
581
+ * { x: D(0), y: D(0) },
582
+ * { x: D(100), y: D(0) },
583
+ * { x: D(100), y: D(100) },
584
+ * { x: D(0), y: D(100) }
585
+ * ];
586
+ * const maskData = {
587
+ * maskType: 'luminance',
588
+ * maskContentUnits: 'userSpaceOnUse',
589
+ * children: [{
590
+ * type: 'circle',
591
+ * cx: 50, cy: 50, r: 40,
592
+ * fill: 'white',
593
+ * opacity: 0.8
594
+ * }]
595
+ * };
596
+ * const bbox = { x: 0, y: 0, width: 100, height: 100 };
597
+ * const result = applyMask(targetPolygon, maskData, bbox);
598
+ * // Returns: [{ polygon: [...circle-rect intersection...], opacity: 0.8 }]
599
+ *
600
+ * @example
601
+ * // Handle multiple mask regions
602
+ * const result = applyMask(targetPolygon, maskData, bbox);
603
+ * result.forEach(({ polygon, opacity }) => {
604
+ * // Render polygon with opacity
605
+ * console.log(`Render ${polygon.length}-sided polygon at ${opacity * 100}% opacity`);
606
+ * });
607
+ */
608
+ export function applyMask(targetPolygon, maskData, targetBBox, options = {}) {
609
+ const maskRegions = resolveMask(maskData, targetBBox, options);
610
+ const result = [];
611
+
612
+ for (const { polygon: maskPoly, opacity } of maskRegions) {
613
+ if (opacity <= 0) continue;
614
+
615
+ const intersection = PolygonClip.polygonIntersection(targetPolygon, maskPoly);
616
+
617
+ for (const clippedPoly of intersection) {
618
+ if (clippedPoly.length >= 3) {
619
+ result.push({ polygon: clippedPoly, opacity });
620
+ }
621
+ }
622
+ }
623
+
624
+ return result;
625
+ }
626
+
627
+ /**
628
+ * Flatten mask to simple clipPath (binary opacity)
629
+ *
630
+ * Converts a mask with potentially multiple opacity levels into a single binary
631
+ * clipping polygon. All mask regions with opacity above the threshold are combined
632
+ * (unioned) into one shape; regions below threshold are discarded.
633
+ *
634
+ * This is useful when you need simple geometric clipping without opacity gradations,
635
+ * such as when the rendering system doesn't support partial transparency or when
636
+ * simplification is desired for performance.
637
+ *
638
+ * @param {Object} maskData - Parsed mask data from parseMaskElement()
639
+ * @param {Object} targetBBox - Target element bounding box {x, y, width, height}
640
+ * @param {number} [opacityThreshold=0.5] - Minimum opacity to include (0-1).
641
+ * Regions with opacity >= threshold become part of the clip path;
642
+ * regions below threshold are excluded.
643
+ * @param {Object} [options={}] - Options passed to resolveMask():
644
+ * - {number} samples - Curve sampling resolution (default: 20)
645
+ * @returns {Array<{x: Decimal, y: Decimal}>} Combined clipping polygon vertices,
646
+ * or empty array if no regions meet the threshold
647
+ *
648
+ * @example
649
+ * // Convert a multi-region mask to binary clipPath
650
+ * const maskData = {
651
+ * maskType: 'luminance',
652
+ * children: [
653
+ * { type: 'circle', cx: 30, cy: 30, r: 20, fill: 'white', opacity: 0.8 },
654
+ * { type: 'circle', cx: 70, cy: 70, r: 20, fill: '#808080', opacity: 1.0 }
655
+ * ]
656
+ * };
657
+ * const bbox = { x: 0, y: 0, width: 100, height: 100 };
658
+ * const clipPath = maskToClipPath(maskData, bbox, 0.5);
659
+ * // First circle: opacity 0.8 >= 0.5 ✓ (included)
660
+ * // Second circle: opacity ~0.5 >= 0.5 ✓ (included, gray has 50% luminance)
661
+ * // Result: union of both circles
662
+ *
663
+ * @example
664
+ * // Use strict threshold to exclude semi-transparent regions
665
+ * const clipPath = maskToClipPath(maskData, bbox, 0.9);
666
+ * // Only regions with opacity >= 0.9 are included
667
+ *
668
+ * @example
669
+ * // Convert to SVG path
670
+ * const polygon = maskToClipPath(maskData, bbox);
671
+ * const pathData = polygon.map((p, i) =>
672
+ * `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`
673
+ * ).join(' ') + ' Z';
674
+ */
675
+ export function maskToClipPath(maskData, targetBBox, opacityThreshold = 0.5, options = {}) {
676
+ const maskRegions = resolveMask(maskData, targetBBox, options);
677
+
678
+ // Union all regions above threshold
679
+ let result = [];
680
+
681
+ for (const { polygon, opacity } of maskRegions) {
682
+ if (opacity >= opacityThreshold && polygon.length >= 3) {
683
+ if (result.length === 0) {
684
+ result = polygon;
685
+ } else {
686
+ // Union with existing result
687
+ const unionResult = PolygonClip.polygonUnion(result, polygon);
688
+ if (unionResult.length > 0 && unionResult[0].length >= 3) {
689
+ result = unionResult[0];
690
+ }
691
+ }
692
+ }
693
+ }
694
+
695
+ return result;
696
+ }
697
+
698
+ /**
699
+ * Generate SVG path data string for mask as clipPath
700
+ *
701
+ * Converts a mask to a binary clipping polygon and formats it as an SVG path
702
+ * data string (d attribute). This is useful for creating <clipPath> elements
703
+ * from mask definitions.
704
+ *
705
+ * Uses default opacity threshold of 0.5 to determine which mask regions to include.
706
+ *
707
+ * @param {Object} maskData - Parsed mask data from parseMaskElement()
708
+ * @param {Object} targetBBox - Target element bounding box {x, y, width, height}
709
+ * @param {Object} [options={}] - Options passed to maskToClipPath():
710
+ * - {number} samples - Curve sampling resolution (default: 20)
711
+ * @returns {string} SVG path data string (e.g., "M 10 20 L 30 40 L 50 20 Z"),
712
+ * or empty string if no valid mask regions
713
+ *
714
+ * @example
715
+ * // Generate clipPath from mask
716
+ * const maskData = {
717
+ * maskType: 'luminance',
718
+ * children: [{
719
+ * type: 'rect',
720
+ * x: 10, y: 10, width: 80, height: 80,
721
+ * fill: 'white'
722
+ * }]
723
+ * };
724
+ * const bbox = { x: 0, y: 0, width: 100, height: 100 };
725
+ * const pathData = maskToPathData(maskData, bbox);
726
+ * // Returns: "M 10.000000 10.000000 L 90.000000 10.000000 L 90.000000 90.000000 L 10.000000 90.000000 Z"
727
+ *
728
+ * @example
729
+ * // Use in SVG clipPath element
730
+ * const pathData = maskToPathData(maskData, bbox, { samples: 32 });
731
+ * const clipPathSVG = `
732
+ * <clipPath id="converted-mask">
733
+ * <path d="${pathData}" />
734
+ * </clipPath>
735
+ * `;
736
+ */
737
+ export function maskToPathData(maskData, targetBBox, options = {}) {
738
+ const polygon = maskToClipPath(maskData, targetBBox, 0.5, options);
739
+
740
+ if (polygon.length < 3) return '';
741
+
742
+ let d = '';
743
+ for (let i = 0; i < polygon.length; i++) {
744
+ const p = polygon[i];
745
+ const x = Number(p.x).toFixed(6);
746
+ const y = Number(p.y).toFixed(6);
747
+ d += i === 0 ? `M ${x} ${y}` : ` L ${x} ${y}`;
748
+ }
749
+ d += ' Z';
750
+
751
+ return d;
752
+ }
753
+
754
+ // ============================================================================
755
+ // Mesh Gradient Mask Support
756
+ // ============================================================================
757
+
758
+ /**
759
+ * Check if fill attribute references a gradient
760
+ *
761
+ * Parses CSS fill values to detect gradient references in the url(#id) format.
762
+ * This is used to identify when mask content uses gradients (including mesh
763
+ * gradients) as fills rather than solid colors.
764
+ *
765
+ * @param {string} fill - Fill attribute value (e.g., "url(#gradient1)", "red", "#fff")
766
+ * @returns {string|null} Gradient ID if reference found, null otherwise
767
+ *
768
+ * @example
769
+ * // Detect gradient reference
770
+ * parseGradientReference('url(#myGradient)'); // Returns: 'myGradient'
771
+ * parseGradientReference('url( #mesh-1 )'); // Returns: 'mesh-1' (whitespace ignored)
772
+ *
773
+ * @example
774
+ * // Solid colors return null
775
+ * parseGradientReference('red'); // Returns: null
776
+ * parseGradientReference('#ff0000'); // Returns: null
777
+ * parseGradientReference('none'); // Returns: null
778
+ *
779
+ * @example
780
+ * // Use in mask processing
781
+ * const child = { fill: 'url(#meshGradient1)' };
782
+ * const gradientId = parseGradientReference(child.fill);
783
+ * if (gradientId) {
784
+ * const gradientData = gradientDefs[gradientId];
785
+ * // Process gradient-based mask
786
+ * }
787
+ */
788
+ export function parseGradientReference(fill) {
789
+ if (!fill || typeof fill !== 'string') return null;
790
+
791
+ const match = fill.match(/url\s*\(\s*#([^)\s]+)\s*\)/i);
792
+ return match ? match[1] : null;
793
+ }
794
+
795
+ /**
796
+ * Calculate luminance from RGB color object
797
+ *
798
+ * Converts an RGB color with 0-255 channel values to luminance using the
799
+ * standard sRGB formula. This is a variant of colorToLuminance() that accepts
800
+ * color objects instead of CSS strings.
801
+ *
802
+ * Used primarily for mesh gradient color conversion where colors are stored
803
+ * as numeric RGB components.
804
+ *
805
+ * @param {Object} color - RGB color object with properties:
806
+ * - {number} r - Red channel (0-255)
807
+ * - {number} g - Green channel (0-255)
808
+ * - {number} b - Blue channel (0-255)
809
+ * @returns {number} Luminance value in range 0-1 using formula: 0.2126*R + 0.7152*G + 0.0722*B
810
+ *
811
+ * @example
812
+ * // Pure colors
813
+ * rgbToLuminance({ r: 255, g: 255, b: 255 }); // Returns: 1.0 (white)
814
+ * rgbToLuminance({ r: 0, g: 0, b: 0 }); // Returns: 0.0 (black)
815
+ * rgbToLuminance({ r: 255, g: 0, b: 0 }); // Returns: 0.2126 (red)
816
+ * rgbToLuminance({ r: 0, g: 255, b: 0 }); // Returns: 0.7152 (green)
817
+ * rgbToLuminance({ r: 0, g: 0, b: 255 }); // Returns: 0.0722 (blue)
818
+ *
819
+ * @example
820
+ * // Gray scale
821
+ * rgbToLuminance({ r: 128, g: 128, b: 128 }); // Returns: ~0.5 (50% gray)
822
+ *
823
+ * @example
824
+ * // Use with mesh gradient colors
825
+ * const meshColor = { r: 200, g: 150, b: 100 };
826
+ * const luminance = rgbToLuminance(meshColor);
827
+ * const maskOpacity = luminance; // For luminance-based masks
828
+ */
829
+ export function rgbToLuminance(color) {
830
+ if (!color) return 0;
831
+ const r = (color.r || 0) / 255;
832
+ const g = (color.g || 0) / 255;
833
+ const b = (color.b || 0) / 255;
834
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
835
+ }
836
+
837
+ /**
838
+ * Sample mesh gradient to create luminance-based mask regions
839
+ *
840
+ * Subdivides Coons patch mesh gradient into small polygonal regions, each with
841
+ * an opacity value derived from the gradient's color at that location. This
842
+ * converts a continuous color gradient into discrete opacity regions suitable
843
+ * for masking operations.
844
+ *
845
+ * The mesh gradient's Coons patches are tessellated into triangles or quads,
846
+ * and each patch's color is converted to an opacity value based on the mask type:
847
+ * - Luminance masks: Use sRGB luminance formula on RGB values
848
+ * - Alpha masks: Use the alpha channel directly
849
+ *
850
+ * This enables using mesh gradients as sophisticated variable-opacity masks.
851
+ *
852
+ * @param {Object} meshData - Parsed mesh gradient data with properties:
853
+ * - {Array} patches - Array of Coons patch definitions
854
+ * @param {Object} shapeBBox - Bounding box of the shape using the gradient {x, y, width, height}
855
+ * @param {string} [maskType='luminance'] - Mask type: 'luminance' or 'alpha'
856
+ * @param {Object} [options={}] - Sampling options:
857
+ * - {number} subdivisions - Number of subdivisions per patch (default: 4)
858
+ * @returns {Array<{polygon: Array, opacity: number}>} Array of mask regions:
859
+ * - polygon: Tessellated patch polygon vertices
860
+ * - opacity: Opacity derived from gradient color (0-1)
861
+ *
862
+ * @example
863
+ * // Sample mesh gradient for luminance mask
864
+ * const meshData = {
865
+ * patches: [
866
+ * // Coons patch with white-to-black gradient
867
+ * { corners: [...], colors: [{r:255,g:255,b:255}, {r:0,g:0,b:0}, ...] }
868
+ * ]
869
+ * };
870
+ * const bbox = { x: 0, y: 0, width: 100, height: 100 };
871
+ * const regions = sampleMeshGradientForMask(meshData, bbox, 'luminance', { subdivisions: 8 });
872
+ * // Returns: Array of small polygons with opacity values from 0.0 (black) to 1.0 (white)
873
+ *
874
+ * @example
875
+ * // Sample mesh gradient for alpha mask
876
+ * const regions = sampleMeshGradientForMask(meshData, bbox, 'alpha', { subdivisions: 4 });
877
+ * // Opacity derived from alpha channel, RGB colors ignored
878
+ *
879
+ * @example
880
+ * // Use in complex mask
881
+ * const regions = sampleMeshGradientForMask(meshData, bbox, 'luminance');
882
+ * regions.forEach(({ polygon, opacity }) => {
883
+ * console.log(`Region with ${polygon.length} vertices, opacity: ${opacity.toFixed(2)}`);
884
+ * });
885
+ */
886
+ export function sampleMeshGradientForMask(meshData, shapeBBox, maskType = 'luminance', options = {}) {
887
+ const { subdivisions = 4 } = options;
888
+ const result = [];
889
+
890
+ // Get mesh gradient polygons with colors
891
+ const meshPolygons = MeshGradient.meshGradientToPolygons(meshData, { subdivisions });
892
+
893
+ for (const { polygon, color } of meshPolygons) {
894
+ if (polygon.length < 3) continue;
895
+
896
+ let opacity;
897
+ if (maskType === MaskType.ALPHA) {
898
+ // Alpha mask: use alpha channel
899
+ opacity = (color.a || 255) / 255;
900
+ } else {
901
+ // Luminance mask: calculate from RGB
902
+ opacity = rgbToLuminance(color);
903
+ }
904
+
905
+ if (opacity > 0) {
906
+ result.push({ polygon, opacity });
907
+ }
908
+ }
909
+
910
+ return result;
911
+ }
912
+
913
+ /**
914
+ * Apply mesh gradient as a mask to a target polygon
915
+ *
916
+ * Combines a mesh gradient with a target polygon to produce variable-opacity
917
+ * masking. The target polygon is clipped against each tessellated region of
918
+ * the mesh gradient, inheriting the opacity from the gradient's color values.
919
+ *
920
+ * This creates sophisticated effects where different parts of the target shape
921
+ * have different opacity levels based on the mesh gradient's color variation.
922
+ *
923
+ * @param {Array<{x: Decimal, y: Decimal}>} targetPolygon - Target polygon vertices to be masked
924
+ * @param {Object} meshData - Parsed mesh gradient data with Coons patches
925
+ * @param {Object} targetBBox - Target element bounding box {x, y, width, height}
926
+ * @param {string} [maskType='luminance'] - Mask type: 'luminance' or 'alpha'
927
+ * @param {Object} [options={}] - Options:
928
+ * - {number} subdivisions - Mesh patch subdivisions (default: 4)
929
+ * @returns {Array<{polygon: Array, opacity: number}>} Array of clipped regions:
930
+ * - polygon: Intersection of target with mesh region
931
+ * - opacity: Opacity from mesh gradient color (0-1)
932
+ *
933
+ * @example
934
+ * // Apply radial-like mesh gradient mask to rectangle
935
+ * const targetPolygon = [
936
+ * { x: D(0), y: D(0) },
937
+ * { x: D(100), y: D(0) },
938
+ * { x: D(100), y: D(100) },
939
+ * { x: D(0), y: D(100) }
940
+ * ];
941
+ * const meshData = {
942
+ * patches: [] // white center fading to black edges
943
+ * };
944
+ * const bbox = { x: 0, y: 0, width: 100, height: 100 };
945
+ * const result = applyMeshGradientMask(targetPolygon, meshData, bbox, 'luminance');
946
+ * // Returns: Multiple polygon fragments with varying opacity (center bright, edges dark)
947
+ *
948
+ * @example
949
+ * // Render masked result
950
+ * const maskedRegions = applyMeshGradientMask(targetPolygon, meshData, bbox, 'luminance', {
951
+ * subdivisions: 8
952
+ * });
953
+ * maskedRegions.forEach(({ polygon, opacity }) => {
954
+ * // Render each polygon fragment with its specific opacity
955
+ * renderPolygon(polygon, { fillOpacity: opacity });
956
+ * });
957
+ */
958
+ export function applyMeshGradientMask(targetPolygon, meshData, targetBBox, maskType = 'luminance', options = {}) {
959
+ const meshMaskRegions = sampleMeshGradientForMask(meshData, targetBBox, maskType, options);
960
+ const result = [];
961
+
962
+ for (const { polygon: maskPoly, opacity } of meshMaskRegions) {
963
+ if (opacity <= 0) continue;
964
+
965
+ const intersection = PolygonClip.polygonIntersection(targetPolygon, maskPoly);
966
+
967
+ for (const clippedPoly of intersection) {
968
+ if (clippedPoly.length >= 3) {
969
+ result.push({ polygon: clippedPoly, opacity });
970
+ }
971
+ }
972
+ }
973
+
974
+ return result;
975
+ }
976
+
977
+ /**
978
+ * Enhanced mask resolution with gradient fill support
979
+ *
980
+ * Extends the basic resolveMask() function to handle mask children that use
981
+ * gradient fills (especially mesh gradients) instead of solid colors. When a
982
+ * mask child's fill references a gradient, the gradient is sampled and converted
983
+ * to opacity regions based on the mask type.
984
+ *
985
+ * This enables complex masking effects where:
986
+ * - Mesh gradients define variable opacity across the mask shape
987
+ * - The mask shape (rect, circle, path) clips the gradient sampling
988
+ * - Multiple gradient-filled shapes combine to form the complete mask
989
+ *
990
+ * @param {Object} maskData - Parsed mask data from parseMaskElement()
991
+ * @param {Object} targetBBox - Target element bounding box {x, y, width, height}
992
+ * @param {Object} [gradientDefs={}] - Map of gradient ID to parsed gradient data:
993
+ * - Key: gradient ID string (from url(#id))
994
+ * - Value: gradient data object (must have .type property)
995
+ * @param {Object} [options={}] - Resolution options:
996
+ * - {number} samples - Path curve sampling (default: 20)
997
+ * - {number} subdivisions - Mesh gradient subdivisions (default: 4)
998
+ * @returns {Array<{polygon: Array, opacity: number}>} Array of mask regions with gradients applied
999
+ *
1000
+ * @example
1001
+ * // Mask with mesh gradient fill
1002
+ * const maskData = {
1003
+ * maskType: 'luminance',
1004
+ * children: [{
1005
+ * type: 'rect',
1006
+ * x: 0, y: 0, width: 100, height: 100,
1007
+ * fill: 'url(#meshGrad1)',
1008
+ * opacity: 1.0
1009
+ * }]
1010
+ * };
1011
+ * const gradientDefs = {
1012
+ * meshGrad1: {
1013
+ * type: 'meshgradient',
1014
+ * patches: [] // Coons patches
1015
+ * }
1016
+ * };
1017
+ * const bbox = { x: 0, y: 0, width: 100, height: 100 };
1018
+ * const regions = resolveMaskWithGradients(maskData, bbox, gradientDefs);
1019
+ * // Returns: Array of small polygons (from mesh subdivision) with luminance-based opacity
1020
+ *
1021
+ * @example
1022
+ * // Mix of solid and gradient fills
1023
+ * const maskData = {
1024
+ * maskType: 'alpha',
1025
+ * children: [
1026
+ * { type: 'circle', fill: 'white', opacity: 1.0, cx: 25, cy: 25, r: 20 },
1027
+ * { type: 'rect', fill: 'url(#meshGrad1)', opacity: 0.8, x: 50, y: 50, width: 50, height: 50 }
1028
+ * ]
1029
+ * };
1030
+ * const regions = resolveMaskWithGradients(maskData, bbox, gradientDefs, { subdivisions: 8 });
1031
+ * // Returns: Solid circle region + mesh-sampled rectangle regions
1032
+ *
1033
+ * @example
1034
+ * // Gradient clipped by mask shape
1035
+ * // The mesh gradient is sampled only within the circle's boundary
1036
+ * const regions = resolveMaskWithGradients(maskData, bbox, gradientDefs);
1037
+ * regions.forEach(({ polygon, opacity }) => {
1038
+ * // Each polygon is a piece of the gradient, clipped by the mask child shape
1039
+ * });
1040
+ */
1041
+ export function resolveMaskWithGradients(maskData, targetBBox, gradientDefs = {}, options = {}) {
1042
+ const { samples = 20, subdivisions = 4 } = options;
1043
+ const maskType = maskData.maskType || MaskType.LUMINANCE;
1044
+ const result = [];
1045
+
1046
+ for (const child of maskData.children) {
1047
+ // Check if fill references a gradient
1048
+ const gradientId = parseGradientReference(child.fill);
1049
+
1050
+ if (gradientId && gradientDefs[gradientId]) {
1051
+ const gradientData = gradientDefs[gradientId];
1052
+
1053
+ // Get the shape polygon first
1054
+ const shapePolygon = maskChildToPolygon(
1055
+ child,
1056
+ targetBBox,
1057
+ maskData.maskContentUnits,
1058
+ samples
1059
+ );
1060
+
1061
+ if (shapePolygon.length < 3) continue;
1062
+
1063
+ // Check if it's a mesh gradient
1064
+ if (gradientData.type === 'meshgradient' || gradientData.patches) {
1065
+ // Sample mesh gradient within the shape
1066
+ const meshMaskRegions = sampleMeshGradientForMask(
1067
+ gradientData,
1068
+ targetBBox,
1069
+ maskType,
1070
+ { subdivisions }
1071
+ );
1072
+
1073
+ // Clip mesh regions to the shape
1074
+ for (const { polygon: meshPoly, opacity: meshOpacity } of meshMaskRegions) {
1075
+ const clipped = PolygonClip.polygonIntersection(shapePolygon, meshPoly);
1076
+
1077
+ for (const clippedPoly of clipped) {
1078
+ if (clippedPoly.length >= 3) {
1079
+ // Combine mesh opacity with child opacity
1080
+ const combinedOpacity = meshOpacity * (child.opacity || 1) * (child.fillOpacity || 1);
1081
+ if (combinedOpacity > 0) {
1082
+ result.push({ polygon: clippedPoly, opacity: combinedOpacity });
1083
+ }
1084
+ }
1085
+ }
1086
+ }
1087
+ }
1088
+ // Could add support for linearGradient/radialGradient here
1089
+ } else {
1090
+ // Solid fill - use standard resolution
1091
+ const polygon = maskChildToPolygon(
1092
+ child,
1093
+ targetBBox,
1094
+ maskData.maskContentUnits,
1095
+ samples
1096
+ );
1097
+
1098
+ if (polygon.length >= 3) {
1099
+ const opacity = getMaskChildOpacity(child, maskType);
1100
+ if (opacity > 0) {
1101
+ result.push({ polygon, opacity });
1102
+ }
1103
+ }
1104
+ }
1105
+ }
1106
+
1107
+ return result;
1108
+ }
1109
+
1110
+ /**
1111
+ * Create a mesh gradient mask from scratch
1112
+ *
1113
+ * Generates a complete mask data structure where a mesh gradient directly
1114
+ * defines the opacity distribution across a region. Unlike using a mesh gradient
1115
+ * as a fill within a mask child, this creates a standalone mask entirely from
1116
+ * the gradient.
1117
+ *
1118
+ * The result is a mask object that can be used in rendering systems that
1119
+ * support variable-opacity regions, such as rasterizers or vector renderers
1120
+ * with gradient mesh support.
1121
+ *
1122
+ * @param {Object} meshData - Parsed mesh gradient data with Coons patches
1123
+ * @param {Object} bounds - Mask region bounds:
1124
+ * - {number} x - Mask region x coordinate
1125
+ * - {number} y - Mask region y coordinate
1126
+ * - {number} width - Mask region width
1127
+ * - {number} height - Mask region height
1128
+ * @param {string} [maskType='luminance'] - Mask type: 'luminance' or 'alpha'
1129
+ * @param {Object} [options={}] - Options:
1130
+ * - {number} subdivisions - Mesh patch subdivisions (default: 4)
1131
+ * @returns {Object} Mask data structure:
1132
+ * - {string} type - Always 'meshGradientMask'
1133
+ * - {Object} bounds - The provided bounds
1134
+ * - {string} maskType - The mask type
1135
+ * - {Array} regions - Array of {polygon, opacity} from mesh sampling
1136
+ *
1137
+ * @example
1138
+ * // Create a standalone mesh gradient mask
1139
+ * const meshData = {
1140
+ * patches: [
1141
+ * // Coons patch creating radial-like fade
1142
+ * {} // patch definition
1143
+ * ]
1144
+ * };
1145
+ * const bounds = { x: 0, y: 0, width: 200, height: 200 };
1146
+ * const mask = createMeshGradientMask(meshData, bounds, 'luminance', { subdivisions: 8 });
1147
+ * // Result: {
1148
+ * // type: 'meshGradientMask',
1149
+ * // bounds: { x: 0, y: 0, width: 200, height: 200 },
1150
+ * // maskType: 'luminance',
1151
+ * // regions: [{ polygon: [...], opacity: 0.8 }, ...]
1152
+ * // }
1153
+ *
1154
+ * @example
1155
+ * // Apply to target
1156
+ * const mask = createMeshGradientMask(meshData, bounds, 'alpha');
1157
+ * mask.regions.forEach(({ polygon, opacity }) => {
1158
+ * // Apply region to rendering pipeline
1159
+ * });
1160
+ */
1161
+ export function createMeshGradientMask(meshData, bounds, maskType = 'luminance', options = {}) {
1162
+ const regions = sampleMeshGradientForMask(meshData, bounds, maskType, options);
1163
+
1164
+ return {
1165
+ type: 'meshGradientMask',
1166
+ bounds,
1167
+ maskType,
1168
+ regions
1169
+ };
1170
+ }
1171
+
1172
+ /**
1173
+ * Get the outer boundary of a mesh gradient as a clipping polygon
1174
+ *
1175
+ * Extracts the geometric shape defined by the mesh gradient's Coons patch
1176
+ * boundaries, completely ignoring color information. This treats the mesh
1177
+ * gradient purely as a shape defined by its patch edges.
1178
+ *
1179
+ * The function samples all edges of all patches and collects them into a
1180
+ * boundary polygon. For simple meshes, this gives the outline; for complex
1181
+ * meshes, it returns all sampled edge points which can be used for convex
1182
+ * hull computation or other boundary extraction.
1183
+ *
1184
+ * Useful when you want to use a mesh gradient's geometric shape for clipping
1185
+ * without considering its color/opacity values.
1186
+ *
1187
+ * @param {Object} meshData - Parsed mesh gradient data with properties:
1188
+ * - {Array} patches - Array of Coons patch definitions with edge curves
1189
+ * @param {Object} [options={}] - Options:
1190
+ * - {number} samples - Number of samples per edge curve (default: 20)
1191
+ * @returns {Array<{x: Decimal, y: Decimal}>} Boundary polygon vertices from
1192
+ * sampled patch edges, or empty array if no patches
1193
+ *
1194
+ * @example
1195
+ * // Get boundary of single-patch mesh
1196
+ * const meshData = {
1197
+ * patches: [{
1198
+ * top: [] // bezier curve points,
1199
+ * right: [] // bezier curve points,
1200
+ * bottom: [] // bezier curve points,
1201
+ * left: [] // bezier curve points
1202
+ * }]
1203
+ * };
1204
+ * const boundary = getMeshGradientBoundary(meshData, { samples: 32 });
1205
+ * // Returns: ~128 points (4 edges × 32 samples) forming the patch outline
1206
+ *
1207
+ * @example
1208
+ * // Use as clip path shape
1209
+ * const boundary = getMeshGradientBoundary(meshData);
1210
+ * const clipped = PolygonClip.polygonIntersection(targetPolygon, boundary);
1211
+ * // Clips target using only the mesh's geometric shape, ignoring colors
1212
+ *
1213
+ * @example
1214
+ * // High-resolution boundary sampling
1215
+ * const boundary = getMeshGradientBoundary(meshData, { samples: 64 });
1216
+ * console.log(`Boundary has ${boundary.length} vertices`);
1217
+ */
1218
+ export function getMeshGradientBoundary(meshData, options = {}) {
1219
+ const { samples = 20 } = options;
1220
+
1221
+ if (!meshData.patches || meshData.patches.length === 0) {
1222
+ return [];
1223
+ }
1224
+
1225
+ // Collect all boundary curves from the mesh
1226
+ // The outer boundary is formed by the external edges of the patch grid
1227
+ const allPoints = [];
1228
+
1229
+ for (const patch of meshData.patches) {
1230
+ // Sample each edge of the patch
1231
+ if (patch.top) {
1232
+ const topPoints = MeshGradient.sampleBezierCurve(patch.top, samples);
1233
+ allPoints.push(...topPoints);
1234
+ }
1235
+ if (patch.right) {
1236
+ const rightPoints = MeshGradient.sampleBezierCurve(patch.right, samples);
1237
+ allPoints.push(...rightPoints);
1238
+ }
1239
+ if (patch.bottom) {
1240
+ const bottomPoints = MeshGradient.sampleBezierCurve(patch.bottom, samples);
1241
+ allPoints.push(...bottomPoints);
1242
+ }
1243
+ if (patch.left) {
1244
+ const leftPoints = MeshGradient.sampleBezierCurve(patch.left, samples);
1245
+ allPoints.push(...leftPoints);
1246
+ }
1247
+ }
1248
+
1249
+ if (allPoints.length < 3) {
1250
+ return [];
1251
+ }
1252
+
1253
+ // Compute convex hull or use all points as boundary
1254
+ // For a simple mesh, we return the outline
1255
+ return allPoints;
1256
+ }
1257
+
1258
+ /**
1259
+ * Clip a target polygon using a mesh gradient's shape (geometry-only)
1260
+ *
1261
+ * Uses the mesh gradient purely as a geometric clipping boundary, completely
1262
+ * ignoring all color and opacity information. The Coons patch curves define
1263
+ * the shape, which is tessellated and used for polygon intersection.
1264
+ *
1265
+ * This is useful when you want the organic, curved shapes that mesh gradients
1266
+ * can define, but don't need the color variation for masking. The result is
1267
+ * binary clipping (inside/outside) rather than variable opacity.
1268
+ *
1269
+ * The function:
1270
+ * 1. Tessellates all Coons patches into polygons
1271
+ * 2. Unions them into a single shape
1272
+ * 3. Clips the target polygon against this shape
1273
+ *
1274
+ * @param {Array<{x: Decimal, y: Decimal}>} targetPolygon - Target polygon vertices to clip
1275
+ * @param {Object} meshData - Parsed mesh gradient data with Coons patches
1276
+ * @param {Object} [options={}] - Options:
1277
+ * - {number} subdivisions - Patch tessellation subdivisions (default: 4)
1278
+ * @returns {Array<Array>} Array of clipped polygon(s), each as array of vertices.
1279
+ * Returns empty array if no intersection.
1280
+ *
1281
+ * @example
1282
+ * // Clip rectangle using mesh gradient shape
1283
+ * const targetPolygon = [
1284
+ * { x: D(0), y: D(0) },
1285
+ * { x: D(200), y: D(0) },
1286
+ * { x: D(200), y: D(200) },
1287
+ * { x: D(0), y: D(200) }
1288
+ * ];
1289
+ * const meshData = {
1290
+ * patches: [
1291
+ * // Organic curved shape defined by Coons patch
1292
+ * {} // patch with curved edges
1293
+ * ]
1294
+ * };
1295
+ * const clipped = clipWithMeshGradientShape(targetPolygon, meshData, { subdivisions: 8 });
1296
+ * // Returns: Rectangle clipped to the curved mesh shape
1297
+ *
1298
+ * @example
1299
+ * // Use mesh gradient as complex clipPath
1300
+ * const clipped = clipWithMeshGradientShape(targetPolygon, meshData);
1301
+ * if (clipped.length > 0) {
1302
+ * // Render each clipped polygon fragment
1303
+ * clipped.forEach(poly => renderPolygon(poly));
1304
+ * }
1305
+ *
1306
+ * @example
1307
+ * // Multiple patches create complex shape
1308
+ * const meshData = {
1309
+ * patches: [] // 3x3 grid of patches forming rounded shape
1310
+ * };
1311
+ * const clipped = clipWithMeshGradientShape(targetPolygon, meshData, { subdivisions: 6 });
1312
+ * // All patches unioned into one shape before clipping
1313
+ */
1314
+ export function clipWithMeshGradientShape(targetPolygon, meshData, options = {}) {
1315
+ const { subdivisions = 4 } = options;
1316
+
1317
+ // Get all patch polygons (ignoring colors)
1318
+ const meshPolygons = MeshGradient.meshGradientToPolygons(meshData, { subdivisions });
1319
+
1320
+ if (meshPolygons.length === 0) {
1321
+ return [];
1322
+ }
1323
+
1324
+ // Union all patch polygons to get the complete mesh shape
1325
+ let meshShape = meshPolygons[0].polygon;
1326
+
1327
+ for (let i = 1; i < meshPolygons.length; i++) {
1328
+ const unionResult = PolygonClip.polygonUnion(meshShape, meshPolygons[i].polygon);
1329
+ if (unionResult.length > 0 && unionResult[0].length >= 3) {
1330
+ meshShape = unionResult[0];
1331
+ }
1332
+ }
1333
+
1334
+ // Clip target with the mesh shape
1335
+ return PolygonClip.polygonIntersection(targetPolygon, meshShape);
1336
+ }
1337
+
1338
+ /**
1339
+ * Convert mesh gradient to a simple clip path polygon
1340
+ *
1341
+ * Tessellates all Coons patches and unions them into a single polygon shape
1342
+ * that represents the mesh gradient's geometric boundary. This is the geometry-only
1343
+ * equivalent of the gradient, suitable for use in <clipPath> elements or
1344
+ * binary clipping operations.
1345
+ *
1346
+ * All color information is discarded; only the shape defined by the patch
1347
+ * boundaries is preserved.
1348
+ *
1349
+ * @param {Object} meshData - Parsed mesh gradient data with Coons patches
1350
+ * @param {Object} [options={}] - Options:
1351
+ * - {number} subdivisions - Patch tessellation subdivisions (default: 4)
1352
+ * @returns {Array<{x: Decimal, y: Decimal}>} Unified clip path polygon vertices,
1353
+ * or empty array if no patches
1354
+ *
1355
+ * @example
1356
+ * // Convert single-patch mesh to clipPath
1357
+ * const meshData = {
1358
+ * patches: [{
1359
+ * top: [p1, p2, p3, p4], // Bezier curve points
1360
+ * right: [p1, p2, p3, p4],
1361
+ * bottom: [p1, p2, p3, p4],
1362
+ * left: [p1, p2, p3, p4]
1363
+ * }]
1364
+ * };
1365
+ * const clipPath = meshGradientToClipPath(meshData, { subdivisions: 8 });
1366
+ * // Returns: Polygon approximating the patch's curved boundary
1367
+ *
1368
+ * @example
1369
+ * // Multi-patch mesh becomes single shape
1370
+ * const meshData = {
1371
+ * patches: [patch1, patch2, patch3]
1372
+ * };
1373
+ * const clipPath = meshGradientToClipPath(meshData);
1374
+ * // Returns: Union of all three patches as one polygon
1375
+ *
1376
+ * @example
1377
+ * // Use in SVG clipPath element
1378
+ * const polygon = meshGradientToClipPath(meshData, { subdivisions: 6 });
1379
+ * const pathData = polygon.map((p, i) =>
1380
+ * `${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`
1381
+ * ).join(' ') + ' Z';
1382
+ * const svgClipPath = `<clipPath id="mesh-shape"><path d="${pathData}"/></clipPath>`;
1383
+ */
1384
+ export function meshGradientToClipPath(meshData, options = {}) {
1385
+ const { subdivisions = 4 } = options;
1386
+
1387
+ const meshPolygons = MeshGradient.meshGradientToPolygons(meshData, { subdivisions });
1388
+
1389
+ if (meshPolygons.length === 0) {
1390
+ return [];
1391
+ }
1392
+
1393
+ // Union all patches into one shape
1394
+ let result = meshPolygons[0].polygon;
1395
+
1396
+ for (let i = 1; i < meshPolygons.length; i++) {
1397
+ const poly = meshPolygons[i].polygon;
1398
+ if (poly.length >= 3) {
1399
+ const unionResult = PolygonClip.polygonUnion(result, poly);
1400
+ if (unionResult.length > 0 && unionResult[0].length >= 3) {
1401
+ result = unionResult[0];
1402
+ }
1403
+ }
1404
+ }
1405
+
1406
+ return result;
1407
+ }