@emasoft/svg-matrix 1.0.5 → 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1215 @@
1
+ /**
2
+ * Mesh Gradient Module - SVG 2.0 meshGradient support with Decimal.js precision
3
+ *
4
+ * Implements parsing, evaluation, and rasterization of Coons patch mesh gradients.
5
+ * Supports both bilinear and bicubic (Coons) interpolation types.
6
+ *
7
+ * @module mesh-gradient
8
+ */
9
+
10
+ import Decimal from 'decimal.js';
11
+ import { Matrix } from './matrix.js';
12
+ import * as Transforms2D from './transforms2d.js';
13
+ import * as PolygonClip from './polygon-clip.js';
14
+
15
+ Decimal.set({ precision: 80 });
16
+
17
+ const D = x => (x instanceof Decimal ? x : new Decimal(x));
18
+
19
+ // Default samples per patch edge for rasterization
20
+ const DEFAULT_PATCH_SAMPLES = 16;
21
+
22
+ // Subdivision threshold for adaptive rendering
23
+ const SUBDIVISION_THRESHOLD = 2;
24
+
25
+ /**
26
+ * Create a point with Decimal coordinates.
27
+ *
28
+ * Points are the fundamental building blocks for Coons patches and Bezier curves,
29
+ * using arbitrary-precision Decimal values for accurate geometric calculations.
30
+ *
31
+ * @param {number|string|Decimal} x - The x-coordinate (converted to Decimal)
32
+ * @param {number|string|Decimal} y - The y-coordinate (converted to Decimal)
33
+ * @returns {{x: Decimal, y: Decimal}} Point object with Decimal coordinates
34
+ *
35
+ * @example
36
+ * // Create a point at (10.5, 20.75)
37
+ * const p = point(10.5, 20.75);
38
+ * console.log(p.x.toString()); // "10.5"
39
+ *
40
+ * @example
41
+ * // Using high-precision strings
42
+ * const p = point("3.14159265358979323846", "2.71828182845904523536");
43
+ */
44
+ export function point(x, y) {
45
+ return { x: D(x), y: D(y) };
46
+ }
47
+
48
+ /**
49
+ * Create an RGBA color object with values in the range 0-255.
50
+ *
51
+ * All components are rounded to integers. This represents colors in sRGB color space,
52
+ * which is the standard for SVG and web graphics.
53
+ *
54
+ * @param {number} r - Red component (0-255)
55
+ * @param {number} g - Green component (0-255)
56
+ * @param {number} b - Blue component (0-255)
57
+ * @param {number} [a=255] - Alpha component (0-255, default is fully opaque)
58
+ * @returns {{r: number, g: number, b: number, a: number}} Color object with integer RGBA values
59
+ *
60
+ * @example
61
+ * // Create opaque red
62
+ * const red = color(255, 0, 0);
63
+ *
64
+ * @example
65
+ * // Create semi-transparent blue
66
+ * const blue = color(0, 0, 255, 128);
67
+ */
68
+ export function color(r, g, b, a = 255) {
69
+ return { r: Math.round(r), g: Math.round(g), b: Math.round(b), a: Math.round(a) };
70
+ }
71
+
72
+ /**
73
+ * Parse a CSS color string into an RGBA color object.
74
+ *
75
+ * Supports multiple CSS color formats:
76
+ * - rgb(r, g, b) and rgba(r, g, b, a)
77
+ * - Hex colors: #RGB, #RRGGBB, #RRGGBBAA
78
+ * - Named colors: black, white, red, green, blue, yellow, cyan, magenta, transparent
79
+ *
80
+ * The opacity parameter multiplies the alpha channel, useful for applying
81
+ * SVG stop-opacity attributes.
82
+ *
83
+ * @param {string} colorStr - CSS color string (rgb(), rgba(), hex, or named color)
84
+ * @param {number} [opacity=1] - Optional opacity multiplier (0-1)
85
+ * @returns {{r: number, g: number, b: number, a: number}} RGBA color with values 0-255
86
+ *
87
+ * @example
88
+ * // Parse hex color
89
+ * const c1 = parseColor("#ff0000");
90
+ * // {r: 255, g: 0, b: 0, a: 255}
91
+ *
92
+ * @example
93
+ * // Parse rgba with opacity modifier
94
+ * const c2 = parseColor("rgba(128, 128, 128, 0.5)", 0.8);
95
+ * // Alpha = 0.5 * 0.8 * 255 = 102
96
+ *
97
+ * @example
98
+ * // Parse named color
99
+ * const c3 = parseColor("cyan");
100
+ * // {r: 0, g: 255, b: 255, a: 255}
101
+ */
102
+ export function parseColor(colorStr, opacity = 1) {
103
+ if (!colorStr) return color(0, 0, 0, 255);
104
+
105
+ // Handle rgb() and rgba()
106
+ const rgbMatch = colorStr.match(/rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/i);
107
+ if (rgbMatch) {
108
+ const a = rgbMatch[4] !== undefined ? parseFloat(rgbMatch[4]) * 255 : 255;
109
+ return color(
110
+ parseInt(rgbMatch[1]),
111
+ parseInt(rgbMatch[2]),
112
+ parseInt(rgbMatch[3]),
113
+ a * opacity
114
+ );
115
+ }
116
+
117
+ // Handle hex colors
118
+ const hexMatch = colorStr.match(/^#([0-9a-f]{3,8})$/i);
119
+ if (hexMatch) {
120
+ const hex = hexMatch[1];
121
+ if (hex.length === 3) {
122
+ return color(
123
+ parseInt(hex[0] + hex[0], 16),
124
+ parseInt(hex[1] + hex[1], 16),
125
+ parseInt(hex[2] + hex[2], 16),
126
+ 255 * opacity
127
+ );
128
+ } else if (hex.length === 6) {
129
+ return color(
130
+ parseInt(hex.slice(0, 2), 16),
131
+ parseInt(hex.slice(2, 4), 16),
132
+ parseInt(hex.slice(4, 6), 16),
133
+ 255 * opacity
134
+ );
135
+ } else if (hex.length === 8) {
136
+ return color(
137
+ parseInt(hex.slice(0, 2), 16),
138
+ parseInt(hex.slice(2, 4), 16),
139
+ parseInt(hex.slice(4, 6), 16),
140
+ parseInt(hex.slice(6, 8), 16) * opacity
141
+ );
142
+ }
143
+ }
144
+
145
+ // Named colors (subset for common ones)
146
+ const namedColors = {
147
+ black: [0, 0, 0], white: [255, 255, 255], red: [255, 0, 0],
148
+ green: [0, 128, 0], blue: [0, 0, 255], yellow: [255, 255, 0],
149
+ cyan: [0, 255, 255], magenta: [255, 0, 255], transparent: [0, 0, 0, 0]
150
+ };
151
+ const named = namedColors[colorStr.toLowerCase()];
152
+ if (named) {
153
+ return color(named[0], named[1], named[2], (named[3] ?? 255) * opacity);
154
+ }
155
+
156
+ return color(0, 0, 0, 255 * opacity);
157
+ }
158
+
159
+ /**
160
+ * Linearly interpolate between two colors.
161
+ *
162
+ * Performs component-wise linear interpolation in sRGB color space:
163
+ * result = c1 * (1 - t) + c2 * t
164
+ *
165
+ * For physically accurate color interpolation, colors should ideally be
166
+ * converted to linear RGB space first, but this implementation uses
167
+ * sRGB directly for simplicity and compatibility with SVG standards.
168
+ *
169
+ * @param {{r: number, g: number, b: number, a: number}} c1 - First color (at t=0)
170
+ * @param {{r: number, g: number, b: number, a: number}} c2 - Second color (at t=1)
171
+ * @param {number|Decimal} t - Interpolation factor (0 = c1, 1 = c2)
172
+ * @returns {{r: number, g: number, b: number, a: number}} Interpolated color
173
+ *
174
+ * @example
175
+ * // Interpolate from red to blue
176
+ * const red = color(255, 0, 0);
177
+ * const blue = color(0, 0, 255);
178
+ * const purple = lerpColor(red, blue, 0.5);
179
+ * // {r: 128, g: 0, b: 128, a: 255}
180
+ *
181
+ * @example
182
+ * // Fade from opaque to transparent
183
+ * const opaque = color(100, 150, 200, 255);
184
+ * const trans = color(100, 150, 200, 0);
185
+ * const semi = lerpColor(opaque, trans, 0.75);
186
+ * // {r: 100, g: 150, b: 200, a: 64}
187
+ */
188
+ export function lerpColor(c1, c2, t) {
189
+ const tNum = Number(t);
190
+ const mt = 1 - tNum;
191
+ return color(
192
+ c1.r * mt + c2.r * tNum,
193
+ c1.g * mt + c2.g * tNum,
194
+ c1.b * mt + c2.b * tNum,
195
+ c1.a * mt + c2.a * tNum
196
+ );
197
+ }
198
+
199
+ /**
200
+ * Bilinear color interpolation for a quadrilateral.
201
+ *
202
+ * Interpolates color across a quad defined by four corner colors using
203
+ * bilinear interpolation in parameter space (u, v):
204
+ *
205
+ * color(u,v) = (1-u)(1-v)·c00 + u(1-v)·c10 + (1-u)v·c01 + uv·c11
206
+ *
207
+ * This ensures smooth color transitions across the quad, with the color
208
+ * at each corner matching the specified corner color exactly.
209
+ *
210
+ * @param {{r: number, g: number, b: number, a: number}} c00 - Color at corner (u=0, v=0)
211
+ * @param {{r: number, g: number, b: number, a: number}} c10 - Color at corner (u=1, v=0)
212
+ * @param {{r: number, g: number, b: number, a: number}} c01 - Color at corner (u=0, v=1)
213
+ * @param {{r: number, g: number, b: number, a: number}} c11 - Color at corner (u=1, v=1)
214
+ * @param {number|Decimal} u - U parameter (0 to 1)
215
+ * @param {number|Decimal} v - V parameter (0 to 1)
216
+ * @returns {{r: number, g: number, b: number, a: number}} Interpolated color
217
+ *
218
+ * @example
219
+ * // Interpolate in center of quad
220
+ * const red = color(255, 0, 0);
221
+ * const green = color(0, 255, 0);
222
+ * const blue = color(0, 0, 255);
223
+ * const yellow = color(255, 255, 0);
224
+ * const center = bilinearColor(red, green, blue, yellow, 0.5, 0.5);
225
+ * // Average of all four corners
226
+ */
227
+ export function bilinearColor(c00, c10, c01, c11, u, v) {
228
+ const uNum = Number(u);
229
+ const vNum = Number(v);
230
+ const mu = 1 - uNum;
231
+ const mv = 1 - vNum;
232
+
233
+ return color(
234
+ mu * mv * c00.r + uNum * mv * c10.r + mu * vNum * c01.r + uNum * vNum * c11.r,
235
+ mu * mv * c00.g + uNum * mv * c10.g + mu * vNum * c01.g + uNum * vNum * c11.g,
236
+ mu * mv * c00.b + uNum * mv * c10.b + mu * vNum * c01.b + uNum * vNum * c11.b,
237
+ mu * mv * c00.a + uNum * mv * c10.a + mu * vNum * c01.a + uNum * vNum * c11.a
238
+ );
239
+ }
240
+
241
+ // ============================================================================
242
+ // Bezier Curve Evaluation
243
+ // ============================================================================
244
+
245
+ /**
246
+ * Evaluate a cubic Bezier curve at parameter t using the Bernstein polynomial.
247
+ *
248
+ * The cubic Bezier curve is defined by the parametric equation:
249
+ *
250
+ * B(t) = (1-t)³·p0 + 3(1-t)²t·p1 + 3(1-t)t²·p2 + t³·p3
251
+ *
252
+ * where t ∈ [0,1]. This is the standard cubic Bezier formulation used in
253
+ * SVG path data and meshGradient definitions.
254
+ *
255
+ * @param {{x: Decimal, y: Decimal}} p0 - Start point (at t=0)
256
+ * @param {{x: Decimal, y: Decimal}} p1 - First control point
257
+ * @param {{x: Decimal, y: Decimal}} p2 - Second control point
258
+ * @param {{x: Decimal, y: Decimal}} p3 - End point (at t=1)
259
+ * @param {Decimal} t - Parameter value (0 to 1)
260
+ * @returns {{x: Decimal, y: Decimal}} Point on the curve at parameter t
261
+ *
262
+ * @example
263
+ * // Evaluate curve at midpoint
264
+ * const p0 = point(0, 0);
265
+ * const p1 = point(50, 100);
266
+ * const p2 = point(150, 100);
267
+ * const p3 = point(200, 0);
268
+ * const mid = evalCubicBezier(p0, p1, p2, p3, D(0.5));
269
+ * // Returns point on smooth S-curve
270
+ *
271
+ * @example
272
+ * // Sample curve at multiple points
273
+ * for (let i = 0; i <= 10; i++) {
274
+ * const t = D(i).div(10);
275
+ * const pt = evalCubicBezier(p0, p1, p2, p3, t);
276
+ * console.log(`t=${i/10}: (${pt.x}, ${pt.y})`);
277
+ * }
278
+ */
279
+ export function evalCubicBezier(p0, p1, p2, p3, t) {
280
+ const mt = D(1).minus(t);
281
+ const mt2 = mt.mul(mt);
282
+ const mt3 = mt2.mul(mt);
283
+ const t2 = t.mul(t);
284
+ const t3 = t2.mul(t);
285
+
286
+ return {
287
+ x: mt3.mul(p0.x).plus(D(3).mul(mt2).mul(t).mul(p1.x))
288
+ .plus(D(3).mul(mt).mul(t2).mul(p2.x)).plus(t3.mul(p3.x)),
289
+ y: mt3.mul(p0.y).plus(D(3).mul(mt2).mul(t).mul(p1.y))
290
+ .plus(D(3).mul(mt).mul(t2).mul(p2.y)).plus(t3.mul(p3.y))
291
+ };
292
+ }
293
+
294
+ /**
295
+ * Split a cubic Bezier curve at t=0.5 using De Casteljau's algorithm.
296
+ *
297
+ * De Casteljau's algorithm recursively subdivides the curve by computing
298
+ * midpoints at each level:
299
+ *
300
+ * Level 0: p0, p1, p2, p3 (original control points)
301
+ * Level 1: q0 = mid(p0,p1), q1 = mid(p1,p2), q2 = mid(p2,p3)
302
+ * Level 2: r0 = mid(q0,q1), r1 = mid(q1,q2)
303
+ * Level 3: s = mid(r0,r1) (point on curve at t=0.5)
304
+ *
305
+ * The two resulting curves share the point s and maintain C² continuity.
306
+ *
307
+ * @param {Array<{x: Decimal, y: Decimal}>} curve - Array of 4 control points [p0, p1, p2, p3]
308
+ * @returns {Array<Array<{x: Decimal, y: Decimal}>>} Two curves: [[q0,q1,q2,q3], [r0,r1,r2,r3]]
309
+ *
310
+ * @example
311
+ * // Split a curve for adaptive subdivision
312
+ * const curve = [
313
+ * point(0, 0),
314
+ * point(100, 200),
315
+ * point(200, 200),
316
+ * point(300, 0)
317
+ * ];
318
+ * const [left, right] = splitBezier(curve);
319
+ * // left and right are two Bezier curves that together equal the original
320
+ *
321
+ * @example
322
+ * // Recursive subdivision for adaptive sampling
323
+ * function subdivideCurve(curve, maxDepth) {
324
+ * if (maxDepth === 0) return [curve];
325
+ * const [left, right] = splitBezier(curve);
326
+ * return [...subdivideCurve(left, maxDepth-1), ...subdivideCurve(right, maxDepth-1)];
327
+ * }
328
+ */
329
+ export function splitBezier(curve) {
330
+ const [p0, p1, p2, p3] = curve;
331
+
332
+ // De Casteljau subdivision at t=0.5
333
+ const mid = (a, b) => ({
334
+ x: a.x.plus(b.x).div(2),
335
+ y: a.y.plus(b.y).div(2)
336
+ });
337
+
338
+ const q0 = p0;
339
+ const q1 = mid(p0, p1);
340
+ const q2 = mid(mid(p0, p1), mid(p1, p2));
341
+ const q3 = mid(mid(mid(p0, p1), mid(p1, p2)), mid(mid(p1, p2), mid(p2, p3)));
342
+
343
+ const r0 = q3;
344
+ const r1 = mid(mid(p1, p2), mid(p2, p3));
345
+ const r2 = mid(p2, p3);
346
+ const r3 = p3;
347
+
348
+ return [[q0, q1, q2, q3], [r0, r1, r2, r3]];
349
+ }
350
+
351
+ // ============================================================================
352
+ // Coons Patch Evaluation
353
+ // ============================================================================
354
+
355
+ /**
356
+ * A Coons patch representing a bicubic surface defined by four boundary curves.
357
+ *
358
+ * Coons patches are used in SVG 2.0 meshGradient elements to create smooth
359
+ * color gradients across curved surfaces. Each patch is bounded by four
360
+ * cubic Bezier curves and interpolates both geometry and color.
361
+ *
362
+ * The Coons patch formula creates a bicubic surface S(u,v) that interpolates
363
+ * the four boundary curves using bilinearly blended interpolation:
364
+ *
365
+ * S(u,v) = Lc(u,v) + Ld(u,v) - B(u,v)
366
+ *
367
+ * where:
368
+ * - Lc(u,v) = (1-v)·C(u) + v·D(u) (ruled surface in u direction)
369
+ * - Ld(u,v) = (1-u)·A(v) + u·B(v) (ruled surface in v direction)
370
+ * - B(u,v) = bilinear interpolation of corner points
371
+ * - C(u), D(u), A(v), B(v) are the four boundary curves
372
+ *
373
+ * @class
374
+ */
375
+ export class CoonsPatch {
376
+ /**
377
+ * Create a Coons patch from four boundary curves and corner colors.
378
+ *
379
+ * The boundary curves must form a closed loop:
380
+ * - top[3] should equal right[0] (top-right corner)
381
+ * - right[3] should equal bottom[3] (bottom-right corner)
382
+ * - bottom[0] should equal left[3] (bottom-left corner)
383
+ * - left[0] should equal top[0] (top-left corner)
384
+ *
385
+ * @param {Array<{x: Decimal, y: Decimal}>} top - Top boundary curve [p0, p1, p2, p3], left to right
386
+ * @param {Array<{x: Decimal, y: Decimal}>} right - Right boundary curve [p0, p1, p2, p3], top to bottom
387
+ * @param {Array<{x: Decimal, y: Decimal}>} bottom - Bottom boundary curve [p0, p1, p2, p3], left to right
388
+ * @param {Array<{x: Decimal, y: Decimal}>} left - Left boundary curve [p0, p1, p2, p3], top to bottom
389
+ * @param {Array<Array<{r: number, g: number, b: number, a: number}>>} colors - 2x2 array of corner colors [[c00, c10], [c01, c11]]
390
+ *
391
+ * @example
392
+ * // Create a simple rectangular patch
393
+ * const patch = new CoonsPatch(
394
+ * [point(0,0), point(33,0), point(67,0), point(100,0)], // top
395
+ * [point(100,0), point(100,33), point(100,67), point(100,100)], // right
396
+ * [point(0,100), point(33,100), point(67,100), point(100,100)], // bottom
397
+ * [point(0,0), point(0,33), point(0,67), point(0,100)], // left
398
+ * [[color(255,0,0), color(0,255,0)], // top-left red, top-right green
399
+ * [color(0,0,255), color(255,255,0)]] // bottom-left blue, bottom-right yellow
400
+ * );
401
+ */
402
+ constructor(top, right, bottom, left, colors) {
403
+ this.top = top;
404
+ this.right = right;
405
+ this.bottom = bottom;
406
+ this.left = left;
407
+ this.colors = colors;
408
+ }
409
+
410
+ /**
411
+ * Evaluate the Coons patch at parametric coordinates (u, v).
412
+ *
413
+ * Computes both the geometric position and interpolated color at the
414
+ * given parameter values using the bilinearly blended Coons formula:
415
+ *
416
+ * Position:
417
+ * S(u,v) = Sc(u,v) + Sd(u,v) - B(u,v)
418
+ *
419
+ * where:
420
+ * - Sc(u,v) = (1-v)·top(u) + v·bottom(u) (interpolate between top/bottom edges)
421
+ * - Sd(u,v) = (1-u)·left(v) + u·right(v) (interpolate between left/right edges)
422
+ * - B(u,v) = (1-u)(1-v)·P00 + u(1-v)·P10 + (1-u)v·P01 + uv·P11 (bilinear blend of corners)
423
+ *
424
+ * The Coons formula ensures that S(u,v) exactly matches the boundary
425
+ * curves when u or v equals 0 or 1.
426
+ *
427
+ * Color is computed using bilinear interpolation of the four corner colors.
428
+ *
429
+ * @param {Decimal} u - U parameter (0 = left edge, 1 = right edge)
430
+ * @param {Decimal} v - V parameter (0 = top edge, 1 = bottom edge)
431
+ * @returns {{point: {x: Decimal, y: Decimal}, color: {r: number, g: number, b: number, a: number}}} Evaluated point and color
432
+ *
433
+ * @example
434
+ * // Evaluate at center of patch
435
+ * const result = patch.evaluate(D(0.5), D(0.5));
436
+ * console.log(`Center point: (${result.point.x}, ${result.point.y})`);
437
+ * console.log(`Center color: rgb(${result.color.r}, ${result.color.g}, ${result.color.b})`);
438
+ *
439
+ * @example
440
+ * // Sample a grid of points
441
+ * for (let i = 0; i <= 10; i++) {
442
+ * for (let j = 0; j <= 10; j++) {
443
+ * const u = D(i).div(10);
444
+ * const v = D(j).div(10);
445
+ * const {point, color} = patch.evaluate(u, v);
446
+ * // Use point and color for rendering
447
+ * }
448
+ * }
449
+ */
450
+ evaluate(u, v) {
451
+ // Boundary curves
452
+ const Lc = evalCubicBezier(...this.top, u); // L_c(u,0)
453
+ const Ld = evalCubicBezier(...this.bottom, u); // L_d(u,1)
454
+ const La = evalCubicBezier(...this.left, v); // L_a(0,v)
455
+ const Lb = evalCubicBezier(...this.right, v); // L_b(1,v)
456
+
457
+ // Corner points
458
+ const P00 = this.top[0];
459
+ const P10 = this.top[3];
460
+ const P01 = this.bottom[0];
461
+ const P11 = this.bottom[3];
462
+
463
+ // Coons patch formula: S(u,v) = Lc(u) + Ld(u) - B(u,v)
464
+ // where B is bilinear interpolation of corners
465
+ const mu = D(1).minus(u);
466
+ const mv = D(1).minus(v);
467
+
468
+ // Ruled surface in u direction
469
+ const Sc_x = mv.mul(Lc.x).plus(v.mul(Ld.x));
470
+ const Sc_y = mv.mul(Lc.y).plus(v.mul(Ld.y));
471
+
472
+ // Ruled surface in v direction
473
+ const Sd_x = mu.mul(La.x).plus(u.mul(Lb.x));
474
+ const Sd_y = mu.mul(La.y).plus(u.mul(Lb.y));
475
+
476
+ // Bilinear interpolation of corners
477
+ const B_x = mu.mul(mv).mul(P00.x)
478
+ .plus(u.mul(mv).mul(P10.x))
479
+ .plus(mu.mul(v).mul(P01.x))
480
+ .plus(u.mul(v).mul(P11.x));
481
+ const B_y = mu.mul(mv).mul(P00.y)
482
+ .plus(u.mul(mv).mul(P10.y))
483
+ .plus(mu.mul(v).mul(P01.y))
484
+ .plus(u.mul(v).mul(P11.y));
485
+
486
+ // Coons formula: Sc + Sd - B
487
+ const pt = {
488
+ x: Sc_x.plus(Sd_x).minus(B_x),
489
+ y: Sc_y.plus(Sd_y).minus(B_y)
490
+ };
491
+
492
+ // Color interpolation (bilinear)
493
+ const col = bilinearColor(
494
+ this.colors[0][0], this.colors[0][1],
495
+ this.colors[1][0], this.colors[1][1],
496
+ u, v
497
+ );
498
+
499
+ return { point: pt, color: col };
500
+ }
501
+
502
+ /**
503
+ * Subdivide this patch into four sub-patches for adaptive rendering.
504
+ *
505
+ * Splits the patch at u=0.5 and v=0.5, creating four smaller patches that
506
+ * together exactly reproduce the original patch. This is used for adaptive
507
+ * subdivision when rendering curved patches with high accuracy.
508
+ *
509
+ * Each boundary curve is split using De Casteljau's algorithm. The center
510
+ * point and mid-edge points are computed by evaluating the patch at the
511
+ * subdivision parameters. Interior curves connecting these points are
512
+ * approximated as linear (degenerate Bezier curves) for simplicity.
513
+ *
514
+ * The subdivision maintains color continuity by evaluating colors at the
515
+ * subdivision points.
516
+ *
517
+ * @returns {Array<CoonsPatch>} Array of 4 sub-patches: [top-left, top-right, bottom-left, bottom-right]
518
+ *
519
+ * @example
520
+ * // Adaptive subdivision until patches are flat enough
521
+ * function subdividePatch(patch, maxDepth = 5) {
522
+ * if (patch.isFlat() || maxDepth === 0) {
523
+ * return [patch];
524
+ * }
525
+ * const subPatches = patch.subdivide();
526
+ * return subPatches.flatMap(sp => subdividePatch(sp, maxDepth - 1));
527
+ * }
528
+ *
529
+ * @example
530
+ * // Subdivide once for finer rendering
531
+ * const patch = new CoonsPatch(top, right, bottom, left, colors);
532
+ * const [tl, tr, bl, br] = patch.subdivide();
533
+ * // Render each sub-patch separately
534
+ */
535
+ subdivide() {
536
+ // Split each boundary curve
537
+ const [topL, topR] = splitBezier(this.top);
538
+ const [rightT, rightB] = splitBezier(this.right);
539
+ const [bottomL, bottomR] = splitBezier(this.bottom);
540
+ const [leftT, leftB] = splitBezier(this.left);
541
+
542
+ // Compute center point and mid-edge points
543
+ const center = this.evaluate(D(0.5), D(0.5));
544
+ const midTop = this.evaluate(D(0.5), D(0));
545
+ const midBottom = this.evaluate(D(0.5), D(1));
546
+ const midLeft = this.evaluate(D(0), D(0.5));
547
+ const midRight = this.evaluate(D(1), D(0.5));
548
+
549
+ // Interior curves (linear for simplicity - could be improved)
550
+ const midH = [midLeft.point, midLeft.point, center.point, center.point];
551
+ const midH2 = [center.point, center.point, midRight.point, midRight.point];
552
+ const midV = [midTop.point, midTop.point, center.point, center.point];
553
+ const midV2 = [center.point, center.point, midBottom.point, midBottom.point];
554
+
555
+ // Colors at subdivided corners
556
+ const c00 = this.colors[0][0];
557
+ const c10 = this.colors[0][1];
558
+ const c01 = this.colors[1][0];
559
+ const c11 = this.colors[1][1];
560
+ const cMid = center.color;
561
+ const cTop = midTop.color;
562
+ const cBottom = midBottom.color;
563
+ const cLeft = midLeft.color;
564
+ const cRight = midRight.color;
565
+
566
+ return [
567
+ // Top-left
568
+ new CoonsPatch(topL, midV, midH, leftT, [[c00, cTop], [cLeft, cMid]]),
569
+ // Top-right
570
+ new CoonsPatch(topR, rightT, midH2, midV, [[cTop, c10], [cMid, cRight]]),
571
+ // Bottom-left
572
+ new CoonsPatch(midH, midV2, bottomL, leftB, [[cLeft, cMid], [c01, cBottom]]),
573
+ // Bottom-right
574
+ new CoonsPatch(midH2, rightB, bottomR, midV2, [[cMid, cRight], [cBottom, c11]])
575
+ ];
576
+ }
577
+
578
+ /**
579
+ * Check if this patch is flat enough for direct rendering without subdivision.
580
+ *
581
+ * A patch is considered flat when all four boundary curves are approximately
582
+ * linear, meaning the control points lie close to the straight line between
583
+ * the curve endpoints.
584
+ *
585
+ * Flatness is tested by computing the perpendicular distance from each
586
+ * control point to the line connecting the curve's start and end points.
587
+ * If these distances are below SUBDIVISION_THRESHOLD (typically 2 pixels),
588
+ * the curve is considered flat.
589
+ *
590
+ * This test is used to determine when adaptive subdivision can stop,
591
+ * allowing flat patches to be rendered directly as simple quads.
592
+ *
593
+ * @returns {boolean} true if all boundary curves are nearly linear
594
+ *
595
+ * @example
596
+ * // Adaptive rendering with flatness test
597
+ * function renderPatch(patch) {
598
+ * if (patch.isFlat()) {
599
+ * // Render as simple quad
600
+ * renderQuad(patch);
601
+ * } else {
602
+ * // Subdivide further
603
+ * patch.subdivide().forEach(renderPatch);
604
+ * }
605
+ * }
606
+ */
607
+ isFlat() {
608
+ // Check if all boundary curves are nearly linear
609
+ const curveFlat = (curve) => {
610
+ const [p0, p1, p2, p3] = curve;
611
+ // Distance from control points to line p0-p3
612
+ const dx = p3.x.minus(p0.x);
613
+ const dy = p3.y.minus(p0.y);
614
+ const len2 = dx.mul(dx).plus(dy.mul(dy));
615
+ if (len2.lt(1e-10)) return true;
616
+
617
+ const dist1 = dx.mul(p1.y.minus(p0.y)).minus(dy.mul(p1.x.minus(p0.x))).abs();
618
+ const dist2 = dx.mul(p2.y.minus(p0.y)).minus(dy.mul(p2.x.minus(p0.x))).abs();
619
+
620
+ return dist1.div(len2.sqrt()).lt(SUBDIVISION_THRESHOLD) &&
621
+ dist2.div(len2.sqrt()).lt(SUBDIVISION_THRESHOLD);
622
+ };
623
+
624
+ return curveFlat(this.top) && curveFlat(this.right) &&
625
+ curveFlat(this.bottom) && curveFlat(this.left);
626
+ }
627
+
628
+ /**
629
+ * Get the axis-aligned bounding box of this patch.
630
+ *
631
+ * Computes the bounding box by examining all control points of the four
632
+ * boundary curves. Note that this may be a conservative estimate since
633
+ * the actual curve may not reach the control points. For tighter bounds,
634
+ * the curves' extrema would need to be computed.
635
+ *
636
+ * @returns {{minX: Decimal, minY: Decimal, maxX: Decimal, maxY: Decimal}} Bounding box with Decimal coordinates
637
+ *
638
+ * @example
639
+ * // Get bounding box for clipping
640
+ * const bbox = patch.getBBox();
641
+ * console.log(`Patch bounds: (${bbox.minX}, ${bbox.minY}) to (${bbox.maxX}, ${bbox.maxY})`);
642
+ *
643
+ * @example
644
+ * // Check if patch intersects viewport
645
+ * const bbox = patch.getBBox();
646
+ * const visible = bbox.maxX >= 0 && bbox.minX <= viewportWidth &&
647
+ * bbox.maxY >= 0 && bbox.minY <= viewportHeight;
648
+ */
649
+ getBBox() {
650
+ const allPoints = [...this.top, ...this.right, ...this.bottom, ...this.left];
651
+ let minX = allPoints[0].x, maxX = allPoints[0].x;
652
+ let minY = allPoints[0].y, maxY = allPoints[0].y;
653
+
654
+ for (const p of allPoints) {
655
+ if (p.x.lt(minX)) minX = p.x;
656
+ if (p.x.gt(maxX)) maxX = p.x;
657
+ if (p.y.lt(minY)) minY = p.y;
658
+ if (p.y.gt(maxY)) maxY = p.y;
659
+ }
660
+
661
+ return { minX, minY, maxX, maxY };
662
+ }
663
+ }
664
+
665
+ // ============================================================================
666
+ // Mesh Gradient Parsing
667
+ // ============================================================================
668
+
669
+ /**
670
+ * Parse a mesh gradient definition into an array of Coons patches.
671
+ *
672
+ * Converts an SVG 2.0 meshGradient structure (with meshrows, meshpatches, and stops)
673
+ * into a computational representation using CoonsPatch objects. Each meshpatch in
674
+ * the SVG becomes one CoonsPatch with four boundary curves and corner colors.
675
+ *
676
+ * The SVG meshGradient structure:
677
+ * ```xml
678
+ * <meshgradient x="0" y="0" type="bilinear">
679
+ * <meshrow>
680
+ * <meshpatch>
681
+ * <stop path="c 100,0 100,0 100,0" stop-color="red"/>
682
+ * <stop path="c 0,100 0,100 0,100" stop-color="green"/>
683
+ * <stop path="c -100,0 -100,0 -100,0" stop-color="blue"/>
684
+ * <stop path="c 0,-100 0,-100 0,-100" stop-color="yellow"/>
685
+ * </meshpatch>
686
+ * </meshrow>
687
+ * </meshgradient>
688
+ * ```
689
+ *
690
+ * @param {Object} meshGradientDef - Mesh gradient definition object with meshrows, type, etc.
691
+ * @param {number} [meshGradientDef.x=0] - X coordinate of gradient origin
692
+ * @param {number} [meshGradientDef.y=0] - Y coordinate of gradient origin
693
+ * @param {string} [meshGradientDef.type='bilinear'] - Interpolation type ('bilinear' or 'bicubic')
694
+ * @param {string} [meshGradientDef.gradientUnits='userSpaceOnUse'] - Coordinate system
695
+ * @param {string} [meshGradientDef.gradientTransform] - Transform matrix
696
+ * @param {Array} meshGradientDef.meshrows - Array of mesh rows
697
+ * @returns {{patches: Array<CoonsPatch>, type: string, gradientUnits: string, gradientTransform: string|null, x: number, y: number}} Parsed mesh data
698
+ *
699
+ * @example
700
+ * // Parse a simple mesh gradient
701
+ * const meshDef = {
702
+ * x: 0, y: 0,
703
+ * type: 'bilinear',
704
+ * meshrows: [
705
+ * {
706
+ * meshpatches: [
707
+ * {
708
+ * stops: [
709
+ * { path: 'c 100,0 100,0 100,0', color: 'red' },
710
+ * { path: 'c 0,100 0,100 0,100', color: 'green' },
711
+ * { path: 'c -100,0 -100,0 -100,0', color: 'blue' },
712
+ * { path: 'c 0,-100 0,-100 0,-100', color: 'yellow' }
713
+ * ]
714
+ * }
715
+ * ]
716
+ * }
717
+ * ]
718
+ * };
719
+ * const meshData = parseMeshGradient(meshDef);
720
+ * console.log(`Parsed ${meshData.patches.length} patches`);
721
+ */
722
+ export function parseMeshGradient(meshGradientDef) {
723
+ const x = D(meshGradientDef.x || 0);
724
+ const y = D(meshGradientDef.y || 0);
725
+ const type = meshGradientDef.type || 'bilinear';
726
+ const gradientUnits = meshGradientDef.gradientUnits || 'userSpaceOnUse';
727
+ const gradientTransform = meshGradientDef.gradientTransform || null;
728
+
729
+ const patches = [];
730
+ const meshRows = meshGradientDef.meshrows || [];
731
+
732
+ // Build the mesh grid
733
+ // nodes[row][col] = point, colors[row][col] = color
734
+ const nodes = [[point(x, y)]];
735
+ const colors = [[]];
736
+
737
+ let currentX = x;
738
+ let currentY = y;
739
+
740
+ for (let rowIdx = 0; rowIdx < meshRows.length; rowIdx++) {
741
+ const row = meshRows[rowIdx];
742
+ const meshPatches = row.meshpatches || [];
743
+
744
+ if (rowIdx > 0) {
745
+ nodes.push([]);
746
+ colors.push([]);
747
+ }
748
+
749
+ for (let colIdx = 0; colIdx < meshPatches.length; colIdx++) {
750
+ const patch = meshPatches[colIdx];
751
+ const stops = patch.stops || [];
752
+
753
+ // Each patch has up to 4 stops defining edges
754
+ for (let stopIdx = 0; stopIdx < stops.length; stopIdx++) {
755
+ const stop = stops[stopIdx];
756
+ const pathData = stop.path || '';
757
+ const stopColor = stop.color ? parseColor(stop.color, stop.opacity || 1) : null;
758
+
759
+ // Parse path command (c/C/l/L for bezier/line)
760
+ const pathMatch = pathData.match(/^\s*([cClL])\s*(.*)/);
761
+ if (pathMatch) {
762
+ const cmd = pathMatch[1];
763
+ const coords = pathMatch[2].trim().split(/[\s,]+/).map(Number);
764
+
765
+ if (cmd === 'c' || cmd === 'C') {
766
+ // Cubic bezier: c x1,y1 x2,y2 x3,y3 (relative)
767
+ // or C x1,y1 x2,y2 x3,y3 (absolute)
768
+ const isRelative = cmd === 'c';
769
+ // Store bezier control points for patch construction
770
+ } else if (cmd === 'l' || cmd === 'L') {
771
+ // Line: l dx,dy (relative) or L x,y (absolute)
772
+ const isRelative = cmd === 'l';
773
+ }
774
+ }
775
+
776
+ // Store color at corner
777
+ if (stopColor && (stopIdx === 0 || stopIdx === 3)) {
778
+ // Corner colors
779
+ }
780
+ }
781
+ }
782
+ }
783
+
784
+ return {
785
+ patches,
786
+ type,
787
+ gradientUnits,
788
+ gradientTransform,
789
+ x: Number(x),
790
+ y: Number(y)
791
+ };
792
+ }
793
+
794
+ /**
795
+ * Parse an SVG meshGradient DOM element into a mesh gradient definition object.
796
+ *
797
+ * Extracts the meshGradient structure from a DOM element, reading all attributes
798
+ * and child elements (meshrow, meshpatch, stop) to create a data structure
799
+ * suitable for parseMeshGradient().
800
+ *
801
+ * Handles:
802
+ * - Gradient attributes (x, y, type, gradientUnits, gradientTransform)
803
+ * - Nested meshrow/meshpatch/stop hierarchy
804
+ * - Stop colors from style attributes (stop-color, stop-opacity)
805
+ * - Path data for Bezier curves
806
+ *
807
+ * @param {Element} element - SVG <meshgradient> DOM element
808
+ * @returns {Object} Mesh gradient definition suitable for parseMeshGradient()
809
+ *
810
+ * @example
811
+ * // Parse from DOM
812
+ * const meshElement = document.querySelector('#myMeshGradient');
813
+ * const meshDef = parseMeshGradientElement(meshElement);
814
+ * const meshData = parseMeshGradient(meshDef);
815
+ *
816
+ * @example
817
+ * // Parse and render
818
+ * const meshElement = document.getElementById('gradient1');
819
+ * const meshDef = parseMeshGradientElement(meshElement);
820
+ * const meshData = parseMeshGradient(meshDef);
821
+ * const imageData = rasterizeMeshGradient(meshData, 800, 600);
822
+ */
823
+ export function parseMeshGradientElement(element) {
824
+ const data = {
825
+ x: element.getAttribute('x') || '0',
826
+ y: element.getAttribute('y') || '0',
827
+ type: element.getAttribute('type') || 'bilinear',
828
+ gradientUnits: element.getAttribute('gradientUnits') || 'userSpaceOnUse',
829
+ gradientTransform: element.getAttribute('gradientTransform'),
830
+ meshrows: []
831
+ };
832
+
833
+ const meshRows = element.querySelectorAll('meshrow');
834
+ meshRows.forEach(row => {
835
+ const rowData = { meshpatches: [] };
836
+ const meshPatches = row.querySelectorAll('meshpatch');
837
+
838
+ meshPatches.forEach(patch => {
839
+ const patchData = { stops: [] };
840
+ const stops = patch.querySelectorAll('stop');
841
+
842
+ stops.forEach(stop => {
843
+ const style = stop.getAttribute('style') || '';
844
+ const colorMatch = style.match(/stop-color:\s*([^;]+)/);
845
+ const opacityMatch = style.match(/stop-opacity:\s*([^;]+)/);
846
+
847
+ patchData.stops.push({
848
+ path: stop.getAttribute('path') || '',
849
+ color: colorMatch ? colorMatch[1].trim() : null,
850
+ opacity: opacityMatch ? parseFloat(opacityMatch[1]) : 1
851
+ });
852
+ });
853
+
854
+ rowData.meshpatches.push(patchData);
855
+ });
856
+
857
+ data.meshrows.push(rowData);
858
+ });
859
+
860
+ return data;
861
+ }
862
+
863
+ // ============================================================================
864
+ // Mesh Gradient Rasterization
865
+ // ============================================================================
866
+
867
+ /**
868
+ * Rasterize a mesh gradient to a pixel buffer using adaptive subdivision.
869
+ *
870
+ * Converts a mesh gradient (array of CoonsPatch objects) into a raster image
871
+ * by adaptively subdividing each patch until it's flat enough to render
872
+ * directly. Uses scanline rendering with bilinear color interpolation and
873
+ * alpha blending.
874
+ *
875
+ * The rasterization process:
876
+ * 1. For each patch, check if it's flat using isFlat()
877
+ * 2. If flat, render directly as a colored quad
878
+ * 3. If curved, subdivide into 4 sub-patches and recurse
879
+ * 4. Apply alpha blending for overlapping patches
880
+ *
881
+ * @param {Object} meshData - Parsed mesh gradient data from parseMeshGradient()
882
+ * @param {Array<CoonsPatch>} meshData.patches - Array of Coons patches to render
883
+ * @param {number} width - Output image width in pixels
884
+ * @param {number} height - Output image height in pixels
885
+ * @param {Object} [options={}] - Rasterization options
886
+ * @param {number} [options.samples=16] - Initial samples per patch edge (for non-adaptive methods)
887
+ * @returns {{data: Uint8ClampedArray, width: number, height: number}} Image data compatible with Canvas ImageData
888
+ *
889
+ * @example
890
+ * // Rasterize a mesh gradient to 800x600
891
+ * const meshData = parseMeshGradient(meshDef);
892
+ * const imageData = rasterizeMeshGradient(meshData, 800, 600);
893
+ * // Use with Canvas
894
+ * const canvas = document.createElement('canvas');
895
+ * canvas.width = 800;
896
+ * canvas.height = 600;
897
+ * const ctx = canvas.getContext('2d');
898
+ * ctx.putImageData(new ImageData(imageData.data, 800, 600), 0, 0);
899
+ *
900
+ * @example
901
+ * // High-quality rendering with more samples
902
+ * const imageData = rasterizeMeshGradient(meshData, 1920, 1080, { samples: 32 });
903
+ */
904
+ export function rasterizeMeshGradient(meshData, width, height, options = {}) {
905
+ const { samples = DEFAULT_PATCH_SAMPLES } = options;
906
+
907
+ // Create image data buffer
908
+ const imageData = new Uint8ClampedArray(width * height * 4);
909
+
910
+ // For each patch, rasterize to the buffer
911
+ for (const patch of meshData.patches || []) {
912
+ rasterizePatch(patch, imageData, width, height, samples);
913
+ }
914
+
915
+ return { data: imageData, width, height };
916
+ }
917
+
918
+ /**
919
+ * Rasterize a single Coons patch using adaptive subdivision.
920
+ *
921
+ * Recursively subdivides the patch until each sub-patch is flat enough
922
+ * (determined by isFlat()), then renders it as a simple quad. This adaptive
923
+ * approach ensures smooth rendering of curved patches while avoiding
924
+ * unnecessary subdivision of flat regions.
925
+ *
926
+ * @private
927
+ * @param {CoonsPatch} patch - The patch to rasterize
928
+ * @param {Uint8ClampedArray} imageData - Output pixel buffer (RGBA, 4 bytes per pixel)
929
+ * @param {number} width - Image width in pixels
930
+ * @param {number} height - Image height in pixels
931
+ * @param {number} samples - Subdivision control parameter (unused in adaptive mode)
932
+ */
933
+ function rasterizePatch(patch, imageData, width, height, samples) {
934
+ // Adaptive subdivision approach
935
+ const stack = [patch];
936
+
937
+ while (stack.length > 0) {
938
+ const currentPatch = stack.pop();
939
+
940
+ if (currentPatch.isFlat() || samples <= 2) {
941
+ // Render as quad
942
+ renderPatchQuad(currentPatch, imageData, width, height);
943
+ } else {
944
+ // Subdivide
945
+ stack.push(...currentPatch.subdivide());
946
+ }
947
+ }
948
+ }
949
+
950
+ /**
951
+ * Render a flat patch as a colored quadrilateral using scanline rasterization.
952
+ *
953
+ * Renders the patch by scanning all pixels in its bounding box and evaluating
954
+ * the patch at each pixel's (u,v) coordinates. Uses bilinear color interpolation
955
+ * from the corner colors.
956
+ *
957
+ * Alpha blending is performed using the Porter-Duff "over" operator:
958
+ * out_α = src_α + dst_α × (1 - src_α)
959
+ * out_rgb = (src_rgb × src_α + dst_rgb × dst_α × (1 - src_α)) / out_α
960
+ *
961
+ * @private
962
+ * @param {CoonsPatch} patch - The flat patch to render
963
+ * @param {Uint8ClampedArray} imageData - Output pixel buffer (RGBA format)
964
+ * @param {number} width - Image width in pixels
965
+ * @param {number} height - Image height in pixels
966
+ */
967
+ function renderPatchQuad(patch, imageData, width, height) {
968
+ const bbox = patch.getBBox();
969
+ const minX = Math.max(0, Math.floor(Number(bbox.minX)));
970
+ const maxX = Math.min(width - 1, Math.ceil(Number(bbox.maxX)));
971
+ const minY = Math.max(0, Math.floor(Number(bbox.minY)));
972
+ const maxY = Math.min(height - 1, Math.ceil(Number(bbox.maxY)));
973
+
974
+ // Simple scan-line fill with bilinear color interpolation
975
+ for (let y = minY; y <= maxY; y++) {
976
+ for (let x = minX; x <= maxX; x++) {
977
+ // Convert pixel to patch (u,v) coordinates
978
+ const u = D(x).minus(bbox.minX).div(bbox.maxX.minus(bbox.minX) || D(1));
979
+ const v = D(y).minus(bbox.minY).div(bbox.maxY.minus(bbox.minY) || D(1));
980
+
981
+ if (u.gte(0) && u.lte(1) && v.gte(0) && v.lte(1)) {
982
+ const { color } = patch.evaluate(u, v);
983
+ const idx = (y * width + x) * 4;
984
+
985
+ // Alpha blending
986
+ const srcA = color.a / 255;
987
+ const dstA = imageData[idx + 3] / 255;
988
+ const outA = srcA + dstA * (1 - srcA);
989
+
990
+ if (outA > 0) {
991
+ imageData[idx] = (color.r * srcA + imageData[idx] * dstA * (1 - srcA)) / outA;
992
+ imageData[idx + 1] = (color.g * srcA + imageData[idx + 1] * dstA * (1 - srcA)) / outA;
993
+ imageData[idx + 2] = (color.b * srcA + imageData[idx + 2] * dstA * (1 - srcA)) / outA;
994
+ imageData[idx + 3] = outA * 255;
995
+ }
996
+ }
997
+ }
998
+ }
999
+ }
1000
+
1001
+ // ============================================================================
1002
+ // Mesh Gradient to Path Approximation
1003
+ // ============================================================================
1004
+
1005
+ /**
1006
+ * Approximate a mesh gradient as a collection of filled polygons.
1007
+ *
1008
+ * Converts each Coons patch into a grid of colored quads by sampling the
1009
+ * patch at regular intervals. This is useful for exporting to vector formats
1010
+ * that don't support mesh gradients (PDF, PostScript, etc.) or for
1011
+ * compatibility with older renderers.
1012
+ *
1013
+ * Each patch is subdivided into subdivisions × subdivisions quads, with each
1014
+ * quad assigned an average color from its four corners. The finer the
1015
+ * subdivision, the smoother the gradient approximation, but the more polygons
1016
+ * are generated.
1017
+ *
1018
+ * @param {Object} meshData - Parsed mesh gradient data from parseMeshGradient()
1019
+ * @param {Array<CoonsPatch>} meshData.patches - Array of Coons patches
1020
+ * @param {Object} [options={}] - Approximation options
1021
+ * @param {number} [options.subdivisions=8] - Number of subdivisions per patch edge
1022
+ * @returns {Array<{polygon: Array<{x: Decimal, y: Decimal}>, color: {r: number, g: number, b: number, a: number}}>} Array of colored polygons
1023
+ *
1024
+ * @example
1025
+ * // Convert mesh to polygons for PDF export
1026
+ * const meshData = parseMeshGradient(meshDef);
1027
+ * const polygons = meshGradientToPolygons(meshData, { subdivisions: 16 });
1028
+ * // Export each polygon to PDF
1029
+ * polygons.forEach(({polygon, color}) => {
1030
+ * pdf.fillColor(color.r, color.g, color.b, color.a / 255);
1031
+ * pdf.polygon(polygon.map(p => [p.x, p.y]));
1032
+ * pdf.fill();
1033
+ * });
1034
+ *
1035
+ * @example
1036
+ * // Low-resolution approximation
1037
+ * const polygons = meshGradientToPolygons(meshData, { subdivisions: 4 });
1038
+ * console.log(`Generated ${polygons.length} polygons`);
1039
+ */
1040
+ export function meshGradientToPolygons(meshData, options = {}) {
1041
+ const { subdivisions = 8 } = options;
1042
+ const result = [];
1043
+
1044
+ for (const patch of meshData.patches || []) {
1045
+ const polys = patchToPolygons(patch, subdivisions);
1046
+ result.push(...polys);
1047
+ }
1048
+
1049
+ return result;
1050
+ }
1051
+
1052
+ /**
1053
+ * Convert a single Coons patch into a grid of filled quadrilaterals.
1054
+ *
1055
+ * Samples the patch at regular intervals to create a grid of quads. Each quad
1056
+ * is assigned a color that's the average of its four corner colors. This
1057
+ * provides a piecewise-constant color approximation of the smooth gradient.
1058
+ *
1059
+ * @private
1060
+ * @param {CoonsPatch} patch - The patch to convert
1061
+ * @param {number} subdivisions - Number of subdivisions per edge (creates subdivisions² quads)
1062
+ * @returns {Array<{polygon: Array, color: Object}>} Array of colored quads
1063
+ */
1064
+ function patchToPolygons(patch, subdivisions) {
1065
+ const result = [];
1066
+ const step = D(1).div(subdivisions);
1067
+
1068
+ for (let i = 0; i < subdivisions; i++) {
1069
+ for (let j = 0; j < subdivisions; j++) {
1070
+ const u0 = step.mul(i);
1071
+ const u1 = step.mul(i + 1);
1072
+ const v0 = step.mul(j);
1073
+ const v1 = step.mul(j + 1);
1074
+
1075
+ const p00 = patch.evaluate(u0, v0);
1076
+ const p10 = patch.evaluate(u1, v0);
1077
+ const p01 = patch.evaluate(u0, v1);
1078
+ const p11 = patch.evaluate(u1, v1);
1079
+
1080
+ // Average color for this quad
1081
+ const avgColor = {
1082
+ r: (p00.color.r + p10.color.r + p01.color.r + p11.color.r) / 4,
1083
+ g: (p00.color.g + p10.color.g + p01.color.g + p11.color.g) / 4,
1084
+ b: (p00.color.b + p10.color.b + p01.color.b + p11.color.b) / 4,
1085
+ a: (p00.color.a + p10.color.a + p01.color.a + p11.color.a) / 4
1086
+ };
1087
+
1088
+ result.push({
1089
+ polygon: [
1090
+ PolygonClip.point(p00.point.x, p00.point.y),
1091
+ PolygonClip.point(p10.point.x, p10.point.y),
1092
+ PolygonClip.point(p11.point.x, p11.point.y),
1093
+ PolygonClip.point(p01.point.x, p01.point.y)
1094
+ ],
1095
+ color: avgColor
1096
+ });
1097
+ }
1098
+ }
1099
+
1100
+ return result;
1101
+ }
1102
+
1103
+ // ============================================================================
1104
+ // Mesh Gradient Clipping
1105
+ // ============================================================================
1106
+
1107
+ /**
1108
+ * Apply a clipping path to a mesh gradient.
1109
+ *
1110
+ * Clips a mesh gradient against an arbitrary polygon using the Sutherland-Hodgman
1111
+ * polygon clipping algorithm (via polygon-clip module). The mesh is first
1112
+ * approximated as polygons, then each polygon is clipped against the clip path.
1113
+ *
1114
+ * This is useful for implementing SVG clipPath with mesh gradients or for
1115
+ * rendering only the visible portion of a gradient.
1116
+ *
1117
+ * @param {Object} meshData - Parsed mesh gradient data from parseMeshGradient()
1118
+ * @param {Array<CoonsPatch>} meshData.patches - Array of patches to clip
1119
+ * @param {Array<{x: Decimal, y: Decimal}>} clipPolygon - Clipping polygon vertices (must be closed)
1120
+ * @param {Object} [options={}] - Clipping options
1121
+ * @param {number} [options.subdivisions=16] - Mesh subdivision before clipping (higher = more accurate)
1122
+ * @returns {Array<{polygon: Array<{x: Decimal, y: Decimal}>, color: {r: number, g: number, b: number, a: number}}>} Clipped polygons with preserved colors
1123
+ *
1124
+ * @example
1125
+ * // Clip mesh to circular region
1126
+ * const meshData = parseMeshGradient(meshDef);
1127
+ * const circlePolygon = [];
1128
+ * for (let i = 0; i < 32; i++) {
1129
+ * const angle = (i / 32) * 2 * Math.PI;
1130
+ * circlePolygon.push(point(
1131
+ * 100 + 50 * Math.cos(angle),
1132
+ * 100 + 50 * Math.sin(angle)
1133
+ * ));
1134
+ * }
1135
+ * const clipped = clipMeshGradient(meshData, circlePolygon);
1136
+ *
1137
+ * @example
1138
+ * // Clip to viewport rectangle
1139
+ * const viewport = [
1140
+ * point(0, 0), point(800, 0),
1141
+ * point(800, 600), point(0, 600)
1142
+ * ];
1143
+ * const clipped = clipMeshGradient(meshData, viewport, { subdivisions: 32 });
1144
+ */
1145
+ export function clipMeshGradient(meshData, clipPolygon, options = {}) {
1146
+ const { subdivisions = 16 } = options;
1147
+
1148
+ // First convert mesh to polygons
1149
+ const meshPolygons = meshGradientToPolygons(meshData, { subdivisions });
1150
+
1151
+ // Clip each polygon
1152
+ const clippedPolygons = [];
1153
+
1154
+ for (const { polygon, color } of meshPolygons) {
1155
+ const clipped = PolygonClip.polygonIntersection(polygon, clipPolygon);
1156
+
1157
+ for (const clippedPoly of clipped) {
1158
+ if (clippedPoly.length >= 3) {
1159
+ clippedPolygons.push({ polygon: clippedPoly, color });
1160
+ }
1161
+ }
1162
+ }
1163
+
1164
+ return clippedPolygons;
1165
+ }
1166
+
1167
+ /**
1168
+ * Generate SVG path elements from clipped mesh gradient polygons.
1169
+ *
1170
+ * Converts the polygon representation back to SVG path data with fill colors,
1171
+ * suitable for embedding in SVG documents. Each polygon becomes a closed path
1172
+ * with an rgba() fill color.
1173
+ *
1174
+ * This is the final step in rendering mesh gradients to SVG when the target
1175
+ * doesn't support native meshGradient elements.
1176
+ *
1177
+ * @param {Array<{polygon: Array<{x: Decimal, y: Decimal}>, color: {r: number, g: number, b: number, a: number}}>} clippedPolygons - Result from clipMeshGradient() or meshGradientToPolygons()
1178
+ * @returns {Array<{pathData: string, fill: string}>} Array of SVG path objects with path data and fill color
1179
+ *
1180
+ * @example
1181
+ * // Convert clipped mesh to SVG paths
1182
+ * const clipped = clipMeshGradient(meshData, clipPolygon);
1183
+ * const svgPaths = clippedMeshToSVG(clipped);
1184
+ * svgPaths.forEach(({pathData, fill}) => {
1185
+ * const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
1186
+ * path.setAttribute('d', pathData);
1187
+ * path.setAttribute('fill', fill);
1188
+ * svg.appendChild(path);
1189
+ * });
1190
+ *
1191
+ * @example
1192
+ * // Generate SVG string
1193
+ * const svgPaths = clippedMeshToSVG(clipped);
1194
+ * const svgContent = svgPaths.map(({pathData, fill}) =>
1195
+ * `<path d="${pathData}" fill="${fill}"/>`
1196
+ * ).join('\n');
1197
+ */
1198
+ export function clippedMeshToSVG(clippedPolygons) {
1199
+ return clippedPolygons.map(({ polygon, color }) => {
1200
+ let pathData = '';
1201
+ for (let i = 0; i < polygon.length; i++) {
1202
+ const p = polygon[i];
1203
+ if (i === 0) {
1204
+ pathData += `M ${Number(p.x).toFixed(6)} ${Number(p.y).toFixed(6)}`;
1205
+ } else {
1206
+ pathData += ` L ${Number(p.x).toFixed(6)} ${Number(p.y).toFixed(6)}`;
1207
+ }
1208
+ }
1209
+ pathData += ' Z';
1210
+
1211
+ const fill = `rgba(${Math.round(color.r)},${Math.round(color.g)},${Math.round(color.b)},${(color.a / 255).toFixed(3)})`;
1212
+
1213
+ return { pathData, fill };
1214
+ });
1215
+ }