@emasoft/svg-matrix 1.0.5 → 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +317 -396
- package/package.json +19 -1
- package/src/browser-verify.js +463 -0
- package/src/clip-path-resolver.js +759 -0
- package/src/geometry-to-path.js +348 -0
- package/src/index.js +413 -6
- package/src/marker-resolver.js +1006 -0
- package/src/mask-resolver.js +1407 -0
- package/src/mesh-gradient.js +1215 -0
- package/src/pattern-resolver.js +844 -0
- package/src/polygon-clip.js +1491 -0
- package/src/svg-flatten.js +1264 -105
- package/src/text-to-path.js +820 -0
- package/src/transforms2d.js +493 -37
- package/src/transforms3d.js +418 -47
- package/src/use-symbol-resolver.js +1126 -0
- package/samples/preserveAspectRatio_SVG.svg +0 -63
- package/samples/test.svg +0 -39
|
@@ -0,0 +1,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
|
+
}
|