@emasoft/svg-matrix 1.0.19 → 1.0.21

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.
@@ -46,6 +46,7 @@ import {
46
46
  ellipseToPathDataHP
47
47
  } from './geometry-to-path.js';
48
48
  import { Logger } from './logger.js';
49
+ import { FillRule, pointInPolygonWithRule } from './svg-boolean-ops.js';
49
50
 
50
51
  // Alias for cleaner code
51
52
  const parseTransform = parseTransformAttribute;
@@ -566,6 +567,97 @@ export function resolveClipPath(clipPathDef, targetElement, ctm = null, options
566
567
  return unified;
567
568
  }
568
569
 
570
+ /**
571
+ * Clip a polygon using a clip polygon with clip-rule support.
572
+ *
573
+ * For simple (non-self-intersecting) clip polygons, this is equivalent to
574
+ * polygonIntersection. For self-intersecting clip paths, the clip-rule
575
+ * determines which regions are inside the clip:
576
+ *
577
+ * - 'nonzero': All regions where winding number != 0 are inside
578
+ * - 'evenodd': Only regions with odd crossing count are inside
579
+ *
580
+ * The algorithm:
581
+ * 1. For simple clip polygons, use standard polygon intersection
582
+ * 2. For self-intersecting clip polygons with evenodd rule, we need to
583
+ * compute which parts of the element polygon are in the "filled" regions
584
+ * of the clip polygon according to the evenodd rule
585
+ *
586
+ * @private
587
+ * @param {Array} elementPolygon - The polygon to be clipped
588
+ * @param {Array} clipPolygon - The clipping polygon (may be self-intersecting)
589
+ * @param {string} clipRule - 'nonzero' or 'evenodd'
590
+ * @returns {Array} Clipped polygon(s), or array of polygon arrays for multi-region results
591
+ */
592
+ function clipPolygonWithRule(elementPolygon, clipPolygon, clipRule) {
593
+ // For nonzero rule, standard intersection works correctly
594
+ // because polygonIntersection uses the winding number test internally
595
+ if (clipRule === 'nonzero') {
596
+ return PolygonClip.polygonIntersection(elementPolygon, clipPolygon);
597
+ }
598
+
599
+ // For evenodd rule with self-intersecting clip paths, we need a different approach
600
+ // The idea: filter vertices of the intersection result by the evenodd test
601
+ // First get the standard intersection
602
+ const intersection = PolygonClip.polygonIntersection(elementPolygon, clipPolygon);
603
+ if (intersection.length === 0) return [];
604
+
605
+ // For each resulting polygon, check if its centroid is inside according to evenodd
606
+ // This handles the case where the clip path has holes due to evenodd rule
607
+ const result = [];
608
+ for (const poly of intersection) {
609
+ if (poly.length < 3) continue;
610
+
611
+ // Compute centroid of the polygon
612
+ const centroid = computeCentroid(poly);
613
+
614
+ // Test if centroid is inside the clip polygon according to evenodd rule
615
+ const fillRule = clipRule === 'evenodd' ? FillRule.EVENODD : FillRule.NONZERO;
616
+ const inside = pointInPolygonWithRule(centroid, clipPolygon, fillRule);
617
+
618
+ // If centroid is inside (1) or on boundary (0), keep this polygon
619
+ if (inside >= 0) {
620
+ result.push(poly);
621
+ }
622
+ }
623
+
624
+ return result.length === 1 ? result[0] : result;
625
+ }
626
+
627
+ /**
628
+ * Compute the centroid (center of mass) of a polygon.
629
+ * @private
630
+ */
631
+ function computeCentroid(polygon) {
632
+ let cx = new Decimal(0);
633
+ let cy = new Decimal(0);
634
+ let area = new Decimal(0);
635
+
636
+ for (let i = 0; i < polygon.length; i++) {
637
+ const p1 = polygon[i];
638
+ const p2 = polygon[(i + 1) % polygon.length];
639
+ const cross = p1.x.times(p2.y).minus(p2.x.times(p1.y));
640
+ area = area.plus(cross);
641
+ cx = cx.plus(p1.x.plus(p2.x).times(cross));
642
+ cy = cy.plus(p1.y.plus(p2.y).times(cross));
643
+ }
644
+
645
+ area = area.div(2);
646
+ if (area.abs().lt(1e-10)) {
647
+ // Degenerate polygon - return average of vertices
648
+ let sumX = new Decimal(0);
649
+ let sumY = new Decimal(0);
650
+ for (const p of polygon) {
651
+ sumX = sumX.plus(p.x);
652
+ sumY = sumY.plus(p.y);
653
+ }
654
+ return PolygonClip.point(sumX.div(polygon.length), sumY.div(polygon.length));
655
+ }
656
+
657
+ const factor = new Decimal(1).div(area.times(6));
658
+ return PolygonClip.point(cx.times(factor), cy.times(factor));
659
+ }
660
+
569
661
  /**
570
662
  * Apply a clipPath to an element, returning the clipped geometry.
571
663
  *
@@ -577,11 +669,17 @@ export function resolveClipPath(clipPathDef, targetElement, ctm = null, options
577
669
  * This is the main function for applying clip paths to elements. The result is a
578
670
  * polygon representing the visible portion of the element after clipping.
579
671
  *
672
+ * The clip-rule property determines how the clipping region is calculated for
673
+ * self-intersecting paths:
674
+ * - 'nonzero' (default): Uses winding number rule (SVG default)
675
+ * - 'evenodd': Uses even-odd rule (creates holes in self-intersecting paths)
676
+ *
580
677
  * @param {Object} element - SVG element to be clipped (rect, circle, path, etc.)
581
678
  * @param {Object} clipPathDef - clipPath definition object (see resolveClipPath)
582
679
  * @param {Matrix|null} [ctm=null] - Current transformation matrix (3x3) to apply
583
680
  * @param {Object} [options={}] - Additional options
584
681
  * @param {number} [options.samples=20] - Number of sample points per curve segment
682
+ * @param {string} [options.clipRule='nonzero'] - Clip rule: 'nonzero' or 'evenodd'
585
683
  * @returns {Array<{x: Decimal, y: Decimal}>} Clipped polygon representing the intersection
586
684
  * of the element and clipPath. Empty array if no intersection or invalid input.
587
685
  *
@@ -595,6 +693,15 @@ export function resolveClipPath(clipPathDef, targetElement, ctm = null, options
595
693
  * // Returns polygon approximating the intersection (rounded corners of rect)
596
694
  *
597
695
  * @example
696
+ * // Clip with evenodd rule (creates holes in self-intersecting clip paths)
697
+ * const rect = { type: 'rect', x: 0, y: 0, width: 100, height: 100 };
698
+ * const clipDef = {
699
+ * children: [{ type: 'path', d: 'M 0,0 L 100,100 L 100,0 L 0,100 Z' }] // Self-intersecting
700
+ * };
701
+ * const clipped = applyClipPath(rect, clipDef, null, { clipRule: 'evenodd' });
702
+ * // Center region will NOT be clipped (evenodd creates hole there)
703
+ *
704
+ * @example
598
705
  * // Clip with objectBoundingBox coordinate system
599
706
  * const ellipse = { type: 'ellipse', cx: 100, cy: 100, rx: 80, ry: 60 };
600
707
  * const clipDef = {
@@ -604,14 +711,15 @@ export function resolveClipPath(clipPathDef, targetElement, ctm = null, options
604
711
  * const clipped = applyClipPath(ellipse, clipDef, null, { samples: 50 });
605
712
  */
606
713
  export function applyClipPath(element, clipPathDef, ctm = null, options = {}) {
607
- const { samples = DEFAULT_CURVE_SAMPLES } = options;
714
+ const { samples = DEFAULT_CURVE_SAMPLES, clipRule = 'nonzero' } = options;
608
715
  const clipPolygon = resolveClipPath(clipPathDef, element, ctm, options);
609
716
  if (clipPolygon.length < 3) return [];
610
717
 
611
718
  const elementPolygon = shapeToPolygon(element, ctm, samples);
612
719
  if (elementPolygon.length < 3) return [];
613
720
 
614
- return PolygonClip.polygonIntersection(elementPolygon, clipPolygon);
721
+ // Use clip-rule aware intersection for self-intersecting clip paths
722
+ return clipPolygonWithRule(elementPolygon, clipPolygon, clipRule);
615
723
  }
616
724
 
617
725
  /**