@emasoft/svg-matrix 1.0.19 → 1.0.20

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,898 @@
1
+ /**
2
+ * SVG Boolean Operations with Full Fill-Rule and Stroke Support
3
+ *
4
+ * Comprehensive boolean operations that account for:
5
+ * - Fill rules (nonzero vs evenodd)
6
+ * - Stroke properties (width, linecap, linejoin, dash)
7
+ * - All SVG shape elements (rect, circle, ellipse, polygon, etc.)
8
+ *
9
+ * The key insight: boolean operations work on RENDERED AREAS, not paths.
10
+ * A path with evenodd fill-rule and self-intersections has holes.
11
+ * A stroked path adds area beyond the geometric path.
12
+ *
13
+ * @module svg-boolean-ops
14
+ */
15
+
16
+ import Decimal from 'decimal.js';
17
+ import { PolygonClip } from './index.js';
18
+
19
+ Decimal.set({ precision: 80 });
20
+
21
+ const D = x => (x instanceof Decimal ? x : new Decimal(x));
22
+ const EPSILON = new Decimal('1e-40');
23
+
24
+ const {
25
+ point,
26
+ pointsEqual,
27
+ cross,
28
+ polygonArea,
29
+ polygonIntersection,
30
+ polygonUnion,
31
+ polygonDifference,
32
+ isCounterClockwise,
33
+ ensureCCW,
34
+ segmentIntersection
35
+ } = PolygonClip;
36
+
37
+ // ============================================================================
38
+ // Fill Rule Support
39
+ // ============================================================================
40
+
41
+ /**
42
+ * Fill rule enumeration matching SVG spec.
43
+ */
44
+ export const FillRule = {
45
+ NONZERO: 'nonzero',
46
+ EVENODD: 'evenodd'
47
+ };
48
+
49
+ /**
50
+ * Test if a point is inside a polygon with specified fill rule.
51
+ *
52
+ * @param {Object} pt - Point to test {x, y}
53
+ * @param {Array} polygon - Polygon vertices
54
+ * @param {string} fillRule - 'nonzero' or 'evenodd'
55
+ * @returns {number} 1 inside, 0 on boundary, -1 outside
56
+ */
57
+ export function pointInPolygonWithRule(pt, polygon, fillRule = FillRule.NONZERO) {
58
+ const n = polygon.length;
59
+ if (n < 3) return -1;
60
+
61
+ let winding = 0;
62
+
63
+ for (let i = 0; i < n; i++) {
64
+ const p1 = polygon[i];
65
+ const p2 = polygon[(i + 1) % n];
66
+
67
+ // Check if point is on the edge
68
+ if (pointOnSegment(pt, p1, p2)) {
69
+ return 0; // On boundary
70
+ }
71
+
72
+ // Ray casting from pt going right (+x direction)
73
+ if (p1.y.lte(pt.y)) {
74
+ if (p2.y.gt(pt.y)) {
75
+ // Upward crossing
76
+ if (cross(p1, p2, pt).gt(0)) {
77
+ winding++;
78
+ }
79
+ }
80
+ } else {
81
+ if (p2.y.lte(pt.y)) {
82
+ // Downward crossing
83
+ if (cross(p1, p2, pt).lt(0)) {
84
+ winding--;
85
+ }
86
+ }
87
+ }
88
+ }
89
+
90
+ // Apply fill rule
91
+ if (fillRule === FillRule.EVENODD) {
92
+ // evenodd: inside if odd number of crossings
93
+ return Math.abs(winding) % 2 === 1 ? 1 : -1;
94
+ } else {
95
+ // nonzero: inside if winding number is not zero
96
+ return winding !== 0 ? 1 : -1;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Check if a point lies on a line segment.
102
+ */
103
+ function pointOnSegment(pt, a, b) {
104
+ const crossVal = cross(a, b, pt);
105
+ if (crossVal.abs().gt(EPSILON)) {
106
+ return false;
107
+ }
108
+
109
+ const minX = Decimal.min(a.x, b.x);
110
+ const maxX = Decimal.max(a.x, b.x);
111
+ const minY = Decimal.min(a.y, b.y);
112
+ const maxY = Decimal.max(a.y, b.y);
113
+
114
+ return pt.x.gte(minX.minus(EPSILON)) && pt.x.lte(maxX.plus(EPSILON)) &&
115
+ pt.y.gte(minY.minus(EPSILON)) && pt.y.lte(maxY.plus(EPSILON));
116
+ }
117
+
118
+ // ============================================================================
119
+ // SVG Element to Path Conversion
120
+ // ============================================================================
121
+
122
+ /**
123
+ * Convert SVG rect to polygon vertices.
124
+ *
125
+ * @param {Object} rect - {x, y, width, height, rx?, ry?}
126
+ * @returns {Array} Polygon vertices (or path with curves for rounded corners)
127
+ */
128
+ export function rectToPolygon(rect) {
129
+ const x = D(rect.x || 0);
130
+ const y = D(rect.y || 0);
131
+ const w = D(rect.width);
132
+ const h = D(rect.height);
133
+ const rx = D(rect.rx || 0);
134
+ const ry = D(rect.ry || rx); // ry defaults to rx if not specified
135
+
136
+ // Simple rectangle (no rounded corners)
137
+ if (rx.eq(0) && ry.eq(0)) {
138
+ return [
139
+ point(x, y),
140
+ point(x.plus(w), y),
141
+ point(x.plus(w), y.plus(h)),
142
+ point(x, y.plus(h))
143
+ ];
144
+ }
145
+
146
+ // Rounded rectangle - approximate corners with line segments
147
+ // For true curves, would need bezier handling
148
+ const actualRx = Decimal.min(rx, w.div(2));
149
+ const actualRy = Decimal.min(ry, h.div(2));
150
+ const segments = 8; // segments per corner
151
+
152
+ const vertices = [];
153
+
154
+ // Top-right corner
155
+ for (let i = 0; i <= segments; i++) {
156
+ const angle = Math.PI * 1.5 + (Math.PI / 2) * (i / segments);
157
+ vertices.push(point(
158
+ x.plus(w).minus(actualRx).plus(actualRx.times(Math.cos(angle))),
159
+ y.plus(actualRy).plus(actualRy.times(Math.sin(angle)))
160
+ ));
161
+ }
162
+
163
+ // Bottom-right corner
164
+ for (let i = 0; i <= segments; i++) {
165
+ const angle = 0 + (Math.PI / 2) * (i / segments);
166
+ vertices.push(point(
167
+ x.plus(w).minus(actualRx).plus(actualRx.times(Math.cos(angle))),
168
+ y.plus(h).minus(actualRy).plus(actualRy.times(Math.sin(angle)))
169
+ ));
170
+ }
171
+
172
+ // Bottom-left corner
173
+ for (let i = 0; i <= segments; i++) {
174
+ const angle = Math.PI / 2 + (Math.PI / 2) * (i / segments);
175
+ vertices.push(point(
176
+ x.plus(actualRx).plus(actualRx.times(Math.cos(angle))),
177
+ y.plus(h).minus(actualRy).plus(actualRy.times(Math.sin(angle)))
178
+ ));
179
+ }
180
+
181
+ // Top-left corner
182
+ for (let i = 0; i <= segments; i++) {
183
+ const angle = Math.PI + (Math.PI / 2) * (i / segments);
184
+ vertices.push(point(
185
+ x.plus(actualRx).plus(actualRx.times(Math.cos(angle))),
186
+ y.plus(actualRy).plus(actualRy.times(Math.sin(angle)))
187
+ ));
188
+ }
189
+
190
+ return vertices;
191
+ }
192
+
193
+ /**
194
+ * Convert SVG circle to polygon (approximation).
195
+ *
196
+ * @param {Object} circle - {cx, cy, r}
197
+ * @param {number} segments - Number of segments (default 32)
198
+ * @returns {Array} Polygon vertices
199
+ */
200
+ export function circleToPolygon(circle, segments = 32) {
201
+ const cx = D(circle.cx || 0);
202
+ const cy = D(circle.cy || 0);
203
+ const r = D(circle.r);
204
+
205
+ const vertices = [];
206
+ for (let i = 0; i < segments; i++) {
207
+ const angle = (2 * Math.PI * i) / segments;
208
+ vertices.push(point(
209
+ cx.plus(r.times(Math.cos(angle))),
210
+ cy.plus(r.times(Math.sin(angle)))
211
+ ));
212
+ }
213
+
214
+ return vertices;
215
+ }
216
+
217
+ /**
218
+ * Convert SVG ellipse to polygon (approximation).
219
+ *
220
+ * @param {Object} ellipse - {cx, cy, rx, ry}
221
+ * @param {number} segments - Number of segments (default 32)
222
+ * @returns {Array} Polygon vertices
223
+ */
224
+ export function ellipseToPolygon(ellipse, segments = 32) {
225
+ const cx = D(ellipse.cx || 0);
226
+ const cy = D(ellipse.cy || 0);
227
+ const rx = D(ellipse.rx);
228
+ const ry = D(ellipse.ry);
229
+
230
+ const vertices = [];
231
+ for (let i = 0; i < segments; i++) {
232
+ const angle = (2 * Math.PI * i) / segments;
233
+ vertices.push(point(
234
+ cx.plus(rx.times(Math.cos(angle))),
235
+ cy.plus(ry.times(Math.sin(angle)))
236
+ ));
237
+ }
238
+
239
+ return vertices;
240
+ }
241
+
242
+ /**
243
+ * Convert SVG line to polygon (requires stroke width for area).
244
+ *
245
+ * @param {Object} line - {x1, y1, x2, y2}
246
+ * @param {Object} stroke - {width, linecap}
247
+ * @returns {Array} Polygon vertices representing stroked line
248
+ */
249
+ export function lineToPolygon(line, stroke = { width: 1, linecap: 'butt' }) {
250
+ const x1 = D(line.x1);
251
+ const y1 = D(line.y1);
252
+ const x2 = D(line.x2);
253
+ const y2 = D(line.y2);
254
+ const halfWidth = D(stroke.width).div(2);
255
+
256
+ // Direction vector
257
+ const dx = x2.minus(x1);
258
+ const dy = y2.minus(y1);
259
+ const len = dx.pow(2).plus(dy.pow(2)).sqrt();
260
+
261
+ if (len.lt(EPSILON)) {
262
+ // Degenerate line - return empty or point
263
+ return [];
264
+ }
265
+
266
+ // Normal vector (perpendicular)
267
+ const nx = dy.neg().div(len).times(halfWidth);
268
+ const ny = dx.div(len).times(halfWidth);
269
+
270
+ const vertices = [];
271
+
272
+ if (stroke.linecap === 'square') {
273
+ // Extend endpoints by half width
274
+ const ex = dx.div(len).times(halfWidth);
275
+ const ey = dy.div(len).times(halfWidth);
276
+ // Vertices in CCW order: right-start -> right-end -> left-end -> left-start
277
+ vertices.push(
278
+ point(x1.minus(ex).minus(nx), y1.minus(ey).minus(ny)),
279
+ point(x2.plus(ex).minus(nx), y2.plus(ey).minus(ny)),
280
+ point(x2.plus(ex).plus(nx), y2.plus(ey).plus(ny)),
281
+ point(x1.minus(ex).plus(nx), y1.minus(ey).plus(ny))
282
+ );
283
+ } else if (stroke.linecap === 'round') {
284
+ // Add semicircles at endpoints in CCW order
285
+ // Start from right side of start point, go around start cap, along left side,
286
+ // around end cap, and back along right side
287
+ const segments = 8;
288
+ const startAngle = Math.atan2(ny.toNumber(), nx.toNumber());
289
+
290
+ // Start cap (semicircle) - going CCW from right side (-normal) to left side (+normal)
291
+ for (let i = 0; i <= segments; i++) {
292
+ const angle = startAngle - Math.PI / 2 - Math.PI * (i / segments);
293
+ vertices.push(point(
294
+ x1.plus(halfWidth.times(Math.cos(angle))),
295
+ y1.plus(halfWidth.times(Math.sin(angle)))
296
+ ));
297
+ }
298
+
299
+ // End cap (semicircle) - continuing CCW from left side to right side
300
+ for (let i = 0; i <= segments; i++) {
301
+ const angle = startAngle + Math.PI / 2 - Math.PI * (i / segments);
302
+ vertices.push(point(
303
+ x2.plus(halfWidth.times(Math.cos(angle))),
304
+ y2.plus(halfWidth.times(Math.sin(angle)))
305
+ ));
306
+ }
307
+ } else {
308
+ // butt (default) - simple rectangle
309
+ // Vertices in CCW order: right-start -> right-end -> left-end -> left-start
310
+ vertices.push(
311
+ point(x1.minus(nx), y1.minus(ny)),
312
+ point(x2.minus(nx), y2.minus(ny)),
313
+ point(x2.plus(nx), y2.plus(ny)),
314
+ point(x1.plus(nx), y1.plus(ny))
315
+ );
316
+ }
317
+
318
+ return vertices;
319
+ }
320
+
321
+ /**
322
+ * Convert SVG polygon points string to polygon array.
323
+ *
324
+ * @param {string|Array} points - "x1,y1 x2,y2 ..." or [{x,y}...]
325
+ * @returns {Array} Polygon vertices
326
+ */
327
+ export function svgPolygonToPolygon(points) {
328
+ if (Array.isArray(points)) {
329
+ return points.map(p => point(p.x, p.y));
330
+ }
331
+
332
+ // Parse SVG points string
333
+ const coords = points.trim().split(/[\s,]+/).map(Number);
334
+ const vertices = [];
335
+ for (let i = 0; i < coords.length; i += 2) {
336
+ vertices.push(point(coords[i], coords[i + 1]));
337
+ }
338
+ return vertices;
339
+ }
340
+
341
+ // ============================================================================
342
+ // Stroke to Path Conversion (Path Offsetting)
343
+ // ============================================================================
344
+
345
+ /**
346
+ * Offset a polygon by a given distance (for stroke width).
347
+ *
348
+ * This creates the "stroke outline" - the area covered by the stroke.
349
+ * For a closed polygon, this returns both inner and outer offset paths.
350
+ *
351
+ * @param {Array} polygon - Input polygon vertices
352
+ * @param {number} distance - Offset distance (stroke-width / 2)
353
+ * @param {Object} options - {linejoin: 'miter'|'round'|'bevel', miterLimit: 4}
354
+ * @returns {Object} {outer: Array, inner: Array} offset polygons
355
+ */
356
+ export function offsetPolygon(polygon, distance, options = {}) {
357
+ const dist = D(distance);
358
+ const linejoin = options.linejoin || 'miter';
359
+ const miterLimit = D(options.miterLimit || 4);
360
+
361
+ if (polygon.length < 3) {
362
+ return { outer: [], inner: [] };
363
+ }
364
+
365
+ const n = polygon.length;
366
+ const outerVertices = [];
367
+ const innerVertices = [];
368
+
369
+ for (let i = 0; i < n; i++) {
370
+ const prev = polygon[(i - 1 + n) % n];
371
+ const curr = polygon[i];
372
+ const next = polygon[(i + 1) % n];
373
+
374
+ // Edge vectors
375
+ const dx1 = curr.x.minus(prev.x);
376
+ const dy1 = curr.y.minus(prev.y);
377
+ const dx2 = next.x.minus(curr.x);
378
+ const dy2 = next.y.minus(curr.y);
379
+
380
+ // Normalize
381
+ const len1 = dx1.pow(2).plus(dy1.pow(2)).sqrt();
382
+ const len2 = dx2.pow(2).plus(dy2.pow(2)).sqrt();
383
+
384
+ if (len1.lt(EPSILON) || len2.lt(EPSILON)) {
385
+ continue; // Skip degenerate edges
386
+ }
387
+
388
+ // Unit normals (perpendicular, pointing outward for CCW polygon)
389
+ // For CCW polygon, outward normal is (dy, -dx) / len
390
+ // This points to the LEFT of the edge direction, which is outward for CCW
391
+ const nx1 = dy1.div(len1);
392
+ const ny1 = dx1.neg().div(len1);
393
+ const nx2 = dy2.div(len2);
394
+ const ny2 = dx2.neg().div(len2);
395
+
396
+ // Average normal for the corner
397
+ let nx = nx1.plus(nx2).div(2);
398
+ let ny = ny1.plus(ny2).div(2);
399
+ const nlen = nx.pow(2).plus(ny.pow(2)).sqrt();
400
+
401
+ if (nlen.lt(EPSILON)) {
402
+ // Parallel edges - use either normal
403
+ nx = nx1;
404
+ ny = ny1;
405
+ } else {
406
+ nx = nx.div(nlen);
407
+ ny = ny.div(nlen);
408
+ }
409
+
410
+ // Compute the actual offset distance at this corner
411
+ // For miter join, the offset point is further out at sharp corners
412
+ const dot = nx1.times(nx2).plus(ny1.times(ny2));
413
+ const sinHalfAngle = D(1).minus(dot).div(2).sqrt();
414
+
415
+ let actualDist = dist;
416
+ if (sinHalfAngle.gt(EPSILON)) {
417
+ const miterDist = dist.div(sinHalfAngle);
418
+
419
+ if (linejoin === 'miter' && miterDist.lte(dist.times(miterLimit))) {
420
+ actualDist = miterDist;
421
+ } else if (linejoin === 'bevel' || miterDist.gt(dist.times(miterLimit))) {
422
+ // Bevel: add two points instead of one
423
+ const outerPt1 = point(curr.x.plus(nx1.times(dist)), curr.y.plus(ny1.times(dist)));
424
+ const outerPt2 = point(curr.x.plus(nx2.times(dist)), curr.y.plus(ny2.times(dist)));
425
+ outerVertices.push(outerPt1, outerPt2);
426
+
427
+ const innerPt1 = point(curr.x.minus(nx1.times(dist)), curr.y.minus(ny1.times(dist)));
428
+ const innerPt2 = point(curr.x.minus(nx2.times(dist)), curr.y.minus(ny2.times(dist)));
429
+ innerVertices.push(innerPt1, innerPt2);
430
+ continue;
431
+ } else if (linejoin === 'round') {
432
+ // Round: add arc segments
433
+ const startAngle = Math.atan2(ny1.toNumber(), nx1.toNumber());
434
+ const endAngle = Math.atan2(ny2.toNumber(), nx2.toNumber());
435
+ let angleDiff = endAngle - startAngle;
436
+ if (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
437
+ if (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
438
+
439
+ const segments = Math.max(2, Math.ceil(Math.abs(angleDiff) / (Math.PI / 8)));
440
+ for (let j = 0; j <= segments; j++) {
441
+ const angle = startAngle + angleDiff * (j / segments);
442
+ outerVertices.push(point(
443
+ curr.x.plus(dist.times(Math.cos(angle))),
444
+ curr.y.plus(dist.times(Math.sin(angle)))
445
+ ));
446
+ innerVertices.push(point(
447
+ curr.x.minus(dist.times(Math.cos(angle))),
448
+ curr.y.minus(dist.times(Math.sin(angle)))
449
+ ));
450
+ }
451
+ continue;
452
+ }
453
+ }
454
+
455
+ // Single offset point
456
+ outerVertices.push(point(curr.x.plus(nx.times(actualDist)), curr.y.plus(ny.times(actualDist))));
457
+ innerVertices.push(point(curr.x.minus(nx.times(actualDist)), curr.y.minus(ny.times(actualDist))));
458
+ }
459
+
460
+ return {
461
+ outer: outerVertices,
462
+ inner: innerVertices.reverse() // Reverse for consistent winding
463
+ };
464
+ }
465
+
466
+ /**
467
+ * Convert a stroked polygon to a filled area polygon.
468
+ *
469
+ * The stroke area is the region between the outer and inner offset paths.
470
+ *
471
+ * @param {Array} polygon - Original polygon (closed path)
472
+ * @param {Object} strokeProps - {width, linejoin, miterLimit}
473
+ * @returns {Array} Polygon representing the stroke area
474
+ */
475
+ export function strokeToFilledPolygon(polygon, strokeProps) {
476
+ const halfWidth = D(strokeProps.width || 1).div(2);
477
+ const offset = offsetPolygon(polygon, halfWidth, strokeProps);
478
+
479
+ // The stroke area is the outer path with the inner path as a hole
480
+ // For simple boolean operations, we return the outer path
481
+ // For complex cases with holes, would need to handle subpaths
482
+ return offset.outer;
483
+ }
484
+
485
+ // ============================================================================
486
+ // Dash Array Support
487
+ // ============================================================================
488
+
489
+ /**
490
+ * Apply dash array to a polygon, returning multiple sub-polygons.
491
+ *
492
+ * @param {Array} polygon - Input polygon
493
+ * @param {Array} dashArray - [dash, gap, dash, gap, ...]
494
+ * @param {number} dashOffset - Starting offset
495
+ * @returns {Array<Array>} Array of polygon segments
496
+ */
497
+ export function applyDashArray(polygon, dashArray, dashOffset = 0) {
498
+ if (!dashArray || dashArray.length === 0) {
499
+ return [polygon];
500
+ }
501
+
502
+ // Normalize dash array (must have even length)
503
+ const dashes = dashArray.length % 2 === 0
504
+ ? dashArray.map(d => D(d))
505
+ : [...dashArray, ...dashArray].map(d => D(d));
506
+
507
+ const segments = [];
508
+ let currentSegment = [];
509
+ let dashIndex = 0;
510
+ let remainingInDash = dashes[0];
511
+ let drawing = true; // Start with dash (not gap)
512
+
513
+ // Apply offset
514
+ let offset = D(dashOffset);
515
+ while (offset.gt(0)) {
516
+ if (offset.gte(remainingInDash)) {
517
+ offset = offset.minus(remainingInDash);
518
+ dashIndex = (dashIndex + 1) % dashes.length;
519
+ remainingInDash = dashes[dashIndex];
520
+ drawing = !drawing;
521
+ } else {
522
+ remainingInDash = remainingInDash.minus(offset);
523
+ offset = D(0);
524
+ }
525
+ }
526
+
527
+ // Process polygon edges
528
+ const n = polygon.length;
529
+ for (let i = 0; i < n; i++) {
530
+ const p1 = polygon[i];
531
+ const p2 = polygon[(i + 1) % n];
532
+
533
+ const dx = p2.x.minus(p1.x);
534
+ const dy = p2.y.minus(p1.y);
535
+ const edgeLen = dx.pow(2).plus(dy.pow(2)).sqrt();
536
+
537
+ if (edgeLen.lt(EPSILON)) continue;
538
+
539
+ let t = D(0);
540
+
541
+ while (t.lt(1)) {
542
+ const remaining = edgeLen.times(D(1).minus(t));
543
+
544
+ if (remaining.lte(remainingInDash)) {
545
+ // Rest of edge fits in current dash/gap
546
+ if (drawing) {
547
+ currentSegment.push(point(
548
+ p1.x.plus(dx.times(t)),
549
+ p1.y.plus(dy.times(t))
550
+ ));
551
+ currentSegment.push(point(p2.x, p2.y));
552
+ }
553
+ remainingInDash = remainingInDash.minus(remaining);
554
+ t = D(1);
555
+
556
+ if (remainingInDash.lt(EPSILON)) {
557
+ if (drawing && currentSegment.length >= 2) {
558
+ segments.push(currentSegment);
559
+ currentSegment = [];
560
+ }
561
+ dashIndex = (dashIndex + 1) % dashes.length;
562
+ remainingInDash = dashes[dashIndex];
563
+ drawing = !drawing;
564
+ }
565
+ } else {
566
+ // Edge extends beyond current dash/gap
567
+ const tEnd = t.plus(remainingInDash.div(edgeLen));
568
+
569
+ if (drawing) {
570
+ currentSegment.push(point(
571
+ p1.x.plus(dx.times(t)),
572
+ p1.y.plus(dy.times(t))
573
+ ));
574
+ currentSegment.push(point(
575
+ p1.x.plus(dx.times(tEnd)),
576
+ p1.y.plus(dy.times(tEnd))
577
+ ));
578
+ segments.push(currentSegment);
579
+ currentSegment = [];
580
+ }
581
+
582
+ t = tEnd;
583
+ dashIndex = (dashIndex + 1) % dashes.length;
584
+ remainingInDash = dashes[dashIndex];
585
+ drawing = !drawing;
586
+ }
587
+ }
588
+ }
589
+
590
+ // Don't forget the last segment
591
+ if (currentSegment.length >= 2) {
592
+ segments.push(currentSegment);
593
+ }
594
+
595
+ return segments;
596
+ }
597
+
598
+ // ============================================================================
599
+ // SVG Region - Unified Representation
600
+ // ============================================================================
601
+
602
+ /**
603
+ * Represents an SVG element's filled/stroked region for boolean operations.
604
+ *
605
+ * Handles:
606
+ * - Fill area with fill-rule
607
+ * - Stroke area (offset path)
608
+ * - Dash arrays
609
+ * - Combined fill+stroke
610
+ */
611
+ export class SVGRegion {
612
+ constructor(options = {}) {
613
+ this.fillPolygons = options.fillPolygons || []; // Array of polygons
614
+ this.fillRule = options.fillRule || FillRule.NONZERO;
615
+ this.strokePolygons = options.strokePolygons || []; // Array of stroked regions
616
+ }
617
+
618
+ /**
619
+ * Create region from SVG element.
620
+ *
621
+ * @param {string} type - 'rect', 'circle', 'ellipse', 'polygon', 'path'
622
+ * @param {Object} props - Element properties
623
+ * @param {Object} style - {fill, fillRule, stroke, strokeWidth, ...}
624
+ * @returns {SVGRegion}
625
+ */
626
+ static fromElement(type, props, style = {}) {
627
+ let polygon;
628
+
629
+ switch (type) {
630
+ case 'rect':
631
+ polygon = rectToPolygon(props);
632
+ break;
633
+ case 'circle':
634
+ polygon = circleToPolygon(props);
635
+ break;
636
+ case 'ellipse':
637
+ polygon = ellipseToPolygon(props);
638
+ break;
639
+ case 'polygon':
640
+ polygon = svgPolygonToPolygon(props.points);
641
+ break;
642
+ case 'line':
643
+ // Lines have no fill, only stroke
644
+ polygon = null;
645
+ break;
646
+ default:
647
+ throw new Error('Unsupported element type: ' + type);
648
+ }
649
+
650
+ const region = new SVGRegion({
651
+ fillRule: style.fillRule || FillRule.NONZERO
652
+ });
653
+
654
+ // Add fill region if element has fill
655
+ if (polygon && style.fill !== 'none') {
656
+ region.fillPolygons = [polygon];
657
+ }
658
+
659
+ // Add stroke region if element has stroke
660
+ if (style.stroke !== 'none' && style.strokeWidth > 0) {
661
+ const sourcePolygon = type === 'line'
662
+ ? lineToPolygon(props, { width: style.strokeWidth, linecap: style.strokeLinecap })
663
+ : polygon;
664
+
665
+ if (sourcePolygon) {
666
+ let strokePolygons;
667
+
668
+ // Apply dash array if present
669
+ if (style.strokeDasharray && style.strokeDasharray.length > 0) {
670
+ const dashedSegments = applyDashArray(
671
+ sourcePolygon,
672
+ style.strokeDasharray,
673
+ style.strokeDashoffset || 0
674
+ );
675
+ strokePolygons = dashedSegments.map(seg =>
676
+ strokeToFilledPolygon(seg, {
677
+ width: style.strokeWidth,
678
+ linejoin: style.strokeLinejoin,
679
+ miterLimit: style.strokeMiterlimit
680
+ })
681
+ );
682
+ } else {
683
+ strokePolygons = [strokeToFilledPolygon(sourcePolygon, {
684
+ width: style.strokeWidth,
685
+ linejoin: style.strokeLinejoin,
686
+ miterLimit: style.strokeMiterlimit
687
+ })];
688
+ }
689
+
690
+ region.strokePolygons = strokePolygons.filter(p => p.length >= 3);
691
+ }
692
+ }
693
+
694
+ return region;
695
+ }
696
+
697
+ /**
698
+ * Get all polygons that make up this region's filled area.
699
+ *
700
+ * @returns {Array<Array>} Array of polygons
701
+ */
702
+ getAllPolygons() {
703
+ return [...this.fillPolygons, ...this.strokePolygons];
704
+ }
705
+
706
+ /**
707
+ * Test if a point is inside this region.
708
+ *
709
+ * @param {Object} pt - Point {x, y}
710
+ * @returns {boolean}
711
+ */
712
+ containsPoint(pt) {
713
+ // Check fill polygons with fill rule
714
+ for (const poly of this.fillPolygons) {
715
+ if (pointInPolygonWithRule(pt, poly, this.fillRule) >= 0) {
716
+ return true;
717
+ }
718
+ }
719
+
720
+ // Check stroke polygons (always nonzero since they're outlines)
721
+ for (const poly of this.strokePolygons) {
722
+ if (pointInPolygonWithRule(pt, poly, FillRule.NONZERO) >= 0) {
723
+ return true;
724
+ }
725
+ }
726
+
727
+ return false;
728
+ }
729
+ }
730
+
731
+ // ============================================================================
732
+ // Boolean Operations on SVG Regions
733
+ // ============================================================================
734
+
735
+ /**
736
+ * Compute intersection of two SVG regions.
737
+ *
738
+ * @param {SVGRegion} regionA
739
+ * @param {SVGRegion} regionB
740
+ * @returns {SVGRegion} Intersection region
741
+ */
742
+ export function regionIntersection(regionA, regionB) {
743
+ const resultPolygons = [];
744
+
745
+ const polygonsA = regionA.getAllPolygons();
746
+ const polygonsB = regionB.getAllPolygons();
747
+
748
+ for (const polyA of polygonsA) {
749
+ for (const polyB of polygonsB) {
750
+ const intersection = polygonIntersection(polyA, polyB);
751
+ for (const poly of intersection) {
752
+ if (poly.length >= 3) {
753
+ resultPolygons.push(poly);
754
+ }
755
+ }
756
+ }
757
+ }
758
+
759
+ return new SVGRegion({
760
+ fillPolygons: resultPolygons,
761
+ fillRule: FillRule.NONZERO // Result is always simple polygons
762
+ });
763
+ }
764
+
765
+ /**
766
+ * Compute union of two SVG regions.
767
+ *
768
+ * @param {SVGRegion} regionA
769
+ * @param {SVGRegion} regionB
770
+ * @returns {SVGRegion} Union region
771
+ */
772
+ export function regionUnion(regionA, regionB) {
773
+ const resultPolygons = [];
774
+
775
+ const polygonsA = regionA.getAllPolygons();
776
+ const polygonsB = regionB.getAllPolygons();
777
+
778
+ // Start with all A polygons
779
+ let combined = [...polygonsA];
780
+
781
+ // Union each B polygon with the combined result
782
+ for (const polyB of polygonsB) {
783
+ const newCombined = [];
784
+ let merged = false;
785
+
786
+ for (const polyA of combined) {
787
+ const union = polygonUnion(polyA, polyB);
788
+
789
+ if (union.length === 1) {
790
+ // Merged into single polygon
791
+ if (!merged) {
792
+ newCombined.push(union[0]);
793
+ merged = true;
794
+ }
795
+ } else {
796
+ // No overlap, keep both
797
+ newCombined.push(polyA);
798
+ if (!merged) {
799
+ newCombined.push(polyB);
800
+ merged = true;
801
+ }
802
+ }
803
+ }
804
+
805
+ if (!merged && combined.length === 0) {
806
+ newCombined.push(polyB);
807
+ }
808
+
809
+ combined = newCombined;
810
+ }
811
+
812
+ return new SVGRegion({
813
+ fillPolygons: combined,
814
+ fillRule: FillRule.NONZERO
815
+ });
816
+ }
817
+
818
+ /**
819
+ * Compute difference of two SVG regions (A - B).
820
+ *
821
+ * @param {SVGRegion} regionA
822
+ * @param {SVGRegion} regionB
823
+ * @returns {SVGRegion} Difference region
824
+ */
825
+ export function regionDifference(regionA, regionB) {
826
+ let resultPolygons = regionA.getAllPolygons().map(p => [...p]);
827
+
828
+ const polygonsB = regionB.getAllPolygons();
829
+
830
+ // Subtract each B polygon from result
831
+ for (const polyB of polygonsB) {
832
+ const newResult = [];
833
+
834
+ for (const polyA of resultPolygons) {
835
+ const diff = polygonDifference(polyA, polyB);
836
+ for (const poly of diff) {
837
+ if (poly.length >= 3) {
838
+ newResult.push(poly);
839
+ }
840
+ }
841
+ }
842
+
843
+ resultPolygons = newResult;
844
+ }
845
+
846
+ return new SVGRegion({
847
+ fillPolygons: resultPolygons,
848
+ fillRule: FillRule.NONZERO
849
+ });
850
+ }
851
+
852
+ /**
853
+ * Compute XOR (symmetric difference) of two SVG regions.
854
+ *
855
+ * @param {SVGRegion} regionA
856
+ * @param {SVGRegion} regionB
857
+ * @returns {SVGRegion} XOR region
858
+ */
859
+ export function regionXOR(regionA, regionB) {
860
+ const diffAB = regionDifference(regionA, regionB);
861
+ const diffBA = regionDifference(regionB, regionA);
862
+
863
+ return new SVGRegion({
864
+ fillPolygons: [...diffAB.fillPolygons, ...diffBA.fillPolygons],
865
+ fillRule: FillRule.NONZERO
866
+ });
867
+ }
868
+
869
+ // ============================================================================
870
+ // Exports
871
+ // ============================================================================
872
+
873
+ export default {
874
+ // Fill rules
875
+ FillRule,
876
+ pointInPolygonWithRule,
877
+
878
+ // Element converters
879
+ rectToPolygon,
880
+ circleToPolygon,
881
+ ellipseToPolygon,
882
+ lineToPolygon,
883
+ svgPolygonToPolygon,
884
+
885
+ // Stroke handling
886
+ offsetPolygon,
887
+ strokeToFilledPolygon,
888
+ applyDashArray,
889
+
890
+ // SVG Region
891
+ SVGRegion,
892
+
893
+ // Boolean operations on regions
894
+ regionIntersection,
895
+ regionUnion,
896
+ regionDifference,
897
+ regionXOR
898
+ };