From 57e49912e2b47359a5e4b928b7a5f095cb1b0ba7 Mon Sep 17 00:00:00 2001 From: James Jackson-South Date: Wed, 16 Sep 2026 10:40:58 +1000 Subject: [PATCH] Add path conversion, containment and intersection to Region Region(IPath, IntersectionRule) scan-converts a path into the integer pixels whose centres lie inside it. Contains(Region) and Intersects(Region) compare two regions band by band. The integer clip rounds the path bounds outwards, the same rounding the library applies to clip bounds, so no pixel whose centre lies inside the path is lost. --- src/ImageSharp.Drawing/Region.cs | 587 +++++++++++++++++- tests/ImageSharp.Drawing.Tests/RegionTests.cs | 209 +++++++ 2 files changed, 792 insertions(+), 4 deletions(-) diff --git a/src/ImageSharp.Drawing/Region.cs b/src/ImageSharp.Drawing/Region.cs index 72a1e920..b618f6cb 100644 --- a/src/ImageSharp.Drawing/Region.cs +++ b/src/ImageSharp.Drawing/Region.cs @@ -3,6 +3,7 @@ using System.Collections.ObjectModel; using System.Numerics; +using System.Runtime.CompilerServices; using SixLabors.ImageSharp.Drawing.Helpers; namespace SixLabors.ImageSharp.Drawing; @@ -17,8 +18,8 @@ namespace SixLabors.ImageSharp.Drawing; /// public sealed class Region { - // The canonical model is the same shape used by SkRegion: a sorted set of horizontal - // Y bands, where each band owns sorted, non-overlapping X intervals. This preserves + // The canonical model is a sorted set of horizontal Y bands, where each band owns + // sorted, non-overlapping X intervals. This preserves // disjoint islands, L shapes, holes, and stair-step edges without collapsing anything // to the bounding rectangle. private readonly List bands = []; @@ -45,6 +46,331 @@ public Region() public Region(Rectangle rectangle) : this() => this.Add(rectangle); + /// + /// Initializes a new instance of the class containing the integer coverage of the specified path. + /// + /// The path whose filled area is added to the region. + /// The rule used to determine the filled area of the path. + /// The resulting region contains non-antialiased integer coverage. + public Region(IPath path, IntersectionRule intersectionRule) + : this() + { + RectangleF pathBounds = path.Bounds; + + // The integer clip rounds the bounds outwards so no pixel whose centre lies inside the + // path is lost. This is the same rounding the library applies to clip bounds. + int clipLeft = (int)MathF.Floor(pathBounds.Left); + int clipTop = (int)MathF.Floor(pathBounds.Top); + int clipRight = (int)MathF.Ceiling(pathBounds.Right); + int clipBottom = (int)MathF.Ceiling(pathBounds.Bottom); + + if (clipLeft >= clipRight || clipTop >= clipBottom) + { + return; + } + + LinearGeometry geometry = path.ToLinearGeometry(Vector2.One); + ReadOnlySpan contours = geometry.GetContours(); + int maximumEdgeCount = geometry.Info.PointCount; + + if (maximumEdgeCount == 0) + { + return; + } + + const int stackEdgeBufferSizeInBytes = 512; + int stackEdgeCapacity = stackEdgeBufferSizeInBytes / Unsafe.SizeOf(); + + // Each stored point can contribute at most one edge after the contour is implicitly closed. + // The fixed byte budget bounds per-call stack use while keeping small paths allocation-free; + // larger paths receive one exact, constructor-local array that dies with the conversion. + Span edges = maximumEdgeCount <= stackEdgeCapacity + ? stackalloc RegionEdge[maximumEdgeCount] + : new RegionEdge[maximumEdgeCount]; + + int edgeCount = 0; + + for (int i = 0; i < contours.Length; i++) + { + LinearContour contour = contours[i]; + + if (contour.PointCount < 2) + { + continue; + } + + ReadOnlySpan points = geometry.GetContourPoints(contour); + + // Filled contours are implicitly closed. Starting with the final point emits the + // closing edge without copying the contour or appending a duplicate endpoint. + PointF previous = points[^1]; + + for (int p = 0; p < points.Length; p++) + { + PointF current = points[p]; + + // Edge endpoints use signed 26.6 coordinates. The current X crossing and its + // per-row delta use signed 16.16 coordinates so every scanline advances by one add. + long x0 = (long)(previous.X * 64F); + long y0 = (long)(previous.Y * 64F); + long x1 = (long)(current.X * 64F); + long y1 = (long)(current.Y * 64F); + previous = current; + + int winding = 1; + + if (y0 > y1) + { + long temporary = x0; + x0 = x1; + x1 = temporary; + + temporary = y0; + y0 = y1; + y1 = temporary; + winding = -1; + } + + // Adding one half selects rows by their pixel centres. The bottom row is + // exclusive so adjoining edges contribute their shared vertex exactly once. + int top = (int)((y0 + 32) >> 6); + int bottom = (int)((y1 + 32) >> 6); + + if (top == bottom || top >= clipBottom || bottom <= clipTop) + { + continue; + } + + long slope = ((x1 - x0) << 16) / (y1 - y0); + long distanceToFirstCentre = ((long)top << 6) + 32 - y0; + long x = (x0 + ((slope * distanceToFirstCentre) >> 16)) << 10; + int firstY = Math.Max(top, clipTop); + int lastY = Math.Min(bottom - 1, clipBottom - 1); + + // Advance from the edge's natural first row to the clipped first row in 16.16 + // units. Converting before subtraction prevents the row distance from wrapping. + x += slope * ((long)firstY - top); + + edges[edgeCount++] = new RegionEdge + { + FirstY = firstY, + LastY = lastY, + X = x, + DxDy = slope, + Winding = winding, + Previous = -1, + Next = -1 + }; + } + } + + if (edgeCount == 0) + { + return; + } + + edges[..edgeCount].Sort(); + + // The sorted list contains active edges followed by edges for future rows. Relinking + // crossings in place avoids a second edge-sized order buffer. + for (int i = 0; i < edgeCount; i++) + { + edges[i].Previous = i - 1; + edges[i].Next = i + 1 < edgeCount ? i + 1 : -1; + } + + int activeHead = 0; + int firstFutureEdge = 0; + int y = edges[0].FirstY; + int windingMask = intersectionRule == IntersectionRule.EvenOdd ? 1 : -1; + bool hasBounds = false; + int regionLeft = 0; + int regionTop = 0; + int regionRight = 0; + int regionBottom = 0; + + while (activeHead >= 0 && y < clipBottom) + { + // When no edge spans the vertical gap, skip directly to the next populated row. + if (activeHead == firstFutureEdge && y < edges[firstFutureEdge].FirstY) + { + y = edges[firstFutureEdge].FirstY; + } + + // Future edges are ordered by their first row and initial X. Move only the edges + // beginning on this row into active X order; existing active edges were restored + // to that order while walking the preceding row. + int newEdge = firstFutureEdge; + + while (newEdge >= 0 && edges[newEdge].FirstY <= y) + { + int nextNewEdge = edges[newEdge].Next; + MoveEdgeBackward(edges, newEdge, ref activeHead); + newEdge = nextNewEdge; + } + + RegionBand? previousBand = this.bands.Count > 0 && this.bands[^1].Bottom == y + ? this.bands[^1] + : null; + + RegionBand? rowBand = null; + int matchedIntervalCount = 0; + int winding = 0; + long intervalLeft = 0; + int activeEdge = activeHead; + + while (activeEdge >= 0 && edges[activeEdge].FirstY <= y) + { + // All crossings that round to one integer boundary are one transition. Grouping + // them makes equal-X edge order irrelevant and removes zero-width intermediate spans. + long crossingX = (edges[activeEdge].X + 32768) >> 16; + int windingDelta = 0; + + do + { + int currentEdge = activeEdge; + int nextActiveEdge = edges[currentEdge].Next; + windingDelta += edges[currentEdge].Winding; + + if (edges[currentEdge].LastY == y) + { + int previousActiveEdge = edges[currentEdge].Previous; + + if (previousActiveEdge >= 0) + { + edges[previousActiveEdge].Next = nextActiveEdge; + } + else + { + activeHead = nextActiveEdge; + } + + if (nextActiveEdge >= 0) + { + edges[nextActiveEdge].Previous = previousActiveEdge; + } + } + else + { + edges[currentEdge].X += edges[currentEdge].DxDy; + MoveEdgeBackward(edges, currentEdge, ref activeHead); + } + + activeEdge = nextActiveEdge; + } + while (activeEdge >= 0 && + edges[activeEdge].FirstY <= y && + ((edges[activeEdge].X + 32768) >> 16) == crossingX); + + bool wasInside = (winding & windingMask) != 0; + winding += windingDelta; + bool isInside = (winding & windingMask) != 0; + + if (!wasInside && isInside) + { + intervalLeft = crossingX; + continue; + } + + if (!wasInside || isInside) + { + continue; + } + + // Clamp in the wide type and reject a wholly clipped span before narrowing. + // A surviving endpoint is therefore guaranteed to fit the integer clip. + long clippedLeft = Math.Max(intervalLeft, clipLeft); + long clippedRight = Math.Min(crossingX, clipRight); + + if (clippedLeft >= clippedRight) + { + continue; + } + + int left = (int)clippedLeft; + int right = (int)clippedRight; + + if (!hasBounds) + { + regionLeft = left; + regionTop = y; + regionRight = right; + hasBounds = true; + } + else + { + regionLeft = Math.Min(regionLeft, left); + regionRight = Math.Max(regionRight, right); + } + + regionBottom = y + 1; + + // Delay allocating a row band while its intervals still match the preceding + // band. On the first difference, copy only the already-matched prefix that + // must become part of the new canonical band. + if (rowBand is null && + previousBand is not null && + matchedIntervalCount < previousBand.Intervals.Count && + previousBand.Intervals[matchedIntervalCount].Left == left && + previousBand.Intervals[matchedIntervalCount].Right == right) + { + matchedIntervalCount++; + continue; + } + + if (rowBand is null) + { + rowBand = new RegionBand(y, y + 1); + + if (previousBand is not null && matchedIntervalCount > 0) + { + rowBand.Intervals.EnsureCapacity(previousBand.Intervals.Count); + + for (int i = 0; i < matchedIntervalCount; i++) + { + rowBand.Intervals.Add(previousBand.Intervals[i]); + } + } + } + + rowBand.Intervals.Add(new Interval(left, right)); + } + + firstFutureEdge = activeEdge; + + if (rowBand is not null) + { + this.bands.Add(rowBand); + } + else if (previousBand is not null && matchedIntervalCount == previousBand.Intervals.Count) + { + // Identical consecutive rows share the existing interval storage. + previousBand.Bottom = y + 1; + } + else if (previousBand is not null && matchedIntervalCount > 0) + { + // A shorter row can differ only after its matching prefix has ended. + RegionBand shorterBand = new(y, y + 1); + shorterBand.Intervals.EnsureCapacity(matchedIntervalCount); + + for (int i = 0; i < matchedIntervalCount; i++) + { + shorterBand.Intervals.Add(previousBand.Intervals[i]); + } + + this.bands.Add(shorterBand); + } + + y++; + } + + if (hasBounds) + { + this.bounds = Rectangle.FromLTRB(regionLeft, regionTop, regionRight, regionBottom); + this.rectanglesValid = false; + } + } + /// /// Initializes a new instance of the class containing the same area as the specified region. /// @@ -228,6 +554,80 @@ public bool Contains(int x, int y) return false; } + /// + /// Returns a value indicating whether this region contains the specified region. + /// + /// The region to test. + /// if this region contains all of ; otherwise, . + public bool Contains(Region region) + { + // Empty regions contain no area, while the bounds test rejects containment before scanning the canonical bands. + if (this.IsEmpty || region.IsEmpty || !this.bounds.Contains(region.bounds)) + { + return false; + } + + // Bands are ordered and non-overlapping, so the candidate containing band never needs to move backwards. + int firstContainingBandIndex = 0; + for (int i = 0; i < region.bands.Count; i++) + { + RegionBand requiredBand = region.bands[i]; + while (firstContainingBandIndex < this.bands.Count && this.bands[firstContainingBandIndex].Bottom <= requiredBand.Top) + { + firstContainingBandIndex++; + } + + int containingBandIndex = firstContainingBandIndex; + int coveredTop = requiredBand.Top; + + // Every vertical portion of the required band must be covered without a gap. + while (coveredTop < requiredBand.Bottom) + { + if (containingBandIndex >= this.bands.Count) + { + return false; + } + + RegionBand containingBand = this.bands[containingBandIndex]; + if (containingBand.Top > coveredTop) + { + return false; + } + + // Intervals are also ordered and non-overlapping, allowing a monotonic scan within the overlapping bands. + int containingIntervalIndex = 0; + for (int j = 0; j < requiredBand.Intervals.Count; j++) + { + Interval required = requiredBand.Intervals[j]; + while (containingIntervalIndex < containingBand.Intervals.Count + && containingBand.Intervals[containingIntervalIndex].Right <= required.Left) + { + containingIntervalIndex++; + } + + if (containingIntervalIndex >= containingBand.Intervals.Count) + { + return false; + } + + Interval containing = containingBand.Intervals[containingIntervalIndex]; + + // A required interval is contained only when one interval covers its complete horizontal extent. + if (containing.Left > required.Left || containing.Right < required.Right) + { + return false; + } + } + + // Continue at the first uncovered scanline when the required band spans multiple containing bands. + coveredTop = Math.Min(containingBand.Bottom, requiredBand.Bottom); + containingBandIndex++; + } + } + + return true; + } + /// /// Returns a value indicating whether the region intersects the specified rectangle. /// @@ -279,6 +679,80 @@ public bool Intersects(Rectangle rectangle) return false; } + /// + /// Returns a value indicating whether this region intersects the specified region. + /// + /// The region to test. + /// if the regions have area in common; otherwise, . + public bool Intersects(Region region) + { + // Touching bounds have no shared area, so they can be rejected before scanning the canonical bands. + if (this.IsEmpty || region.IsEmpty + || this.bounds.Right <= region.bounds.Left + || region.bounds.Right <= this.bounds.Left + || this.bounds.Bottom <= region.bounds.Top + || region.bounds.Bottom <= this.bounds.Top) + { + return false; + } + + // Both band lists are ordered and non-overlapping, enabling a linear two-pointer vertical sweep. + int firstBandIndex = 0; + int secondBandIndex = 0; + while (firstBandIndex < this.bands.Count && secondBandIndex < region.bands.Count) + { + RegionBand firstBand = this.bands[firstBandIndex]; + RegionBand secondBand = region.bands[secondBandIndex]; + if (firstBand.Bottom <= secondBand.Top) + { + firstBandIndex++; + continue; + } + + if (secondBand.Bottom <= firstBand.Top) + { + secondBandIndex++; + continue; + } + + // The bands overlap vertically, so scan their ordered intervals for a horizontal overlap. + int firstIntervalIndex = 0; + int secondIntervalIndex = 0; + while (firstIntervalIndex < firstBand.Intervals.Count && secondIntervalIndex < secondBand.Intervals.Count) + { + Interval first = firstBand.Intervals[firstIntervalIndex]; + Interval second = secondBand.Intervals[secondIntervalIndex]; + if (first.Right <= second.Left) + { + firstIntervalIndex++; + continue; + } + + if (second.Right <= first.Left) + { + secondIntervalIndex++; + continue; + } + + return true; + } + + // Advance every band ending at this boundary so the sweep continues beyond the tested vertical overlap. + int overlappingBottom = Math.Min(firstBand.Bottom, secondBand.Bottom); + if (firstBand.Bottom == overlappingBottom) + { + firstBandIndex++; + } + + if (secondBand.Bottom == overlappingBottom) + { + secondBandIndex++; + } + } + + return false; + } + /// /// Intersects this region with the specified rectangle. /// @@ -437,8 +911,8 @@ public IPath ToPath() /// The path describing the region boundary. private IPath BuildBoundaryPath() { - // Match SkRegion's boundary export shape: rectangles are first represented as - // opposing vertical edges, then linked into closed contours around the region + // Rectangles are first represented as opposing vertical edges, then linked into + // closed contours around the region // boundary. Shared internal edges cancel because the rectangle list is already // normalized into non-overlapping bands/intervals. List edges = new(this.rectangles.Count * 2); @@ -813,6 +1287,59 @@ private static bool IntervalsEqual(List first, List second) return true; } + /// + /// Moves an edge backwards through the linked crossing order when its X position precedes its current predecessor. + /// + /// The edge storage containing the linked order. + /// The edge whose X position may have moved backwards. + /// The first edge in crossing order. +#pragma warning disable CA1517 // The method writes edge links through the span indexer. + private static void MoveEdgeBackward(Span edges, int edgeIndex, ref int head) + { + int previousIndex = edges[edgeIndex].Previous; + + if (previousIndex < 0 || edges[previousIndex].X <= edges[edgeIndex].X) + { + return; + } + + // Unlink the edge before searching backwards through the already-sorted prefix. + int nextIndex = edges[edgeIndex].Next; + edges[previousIndex].Next = nextIndex; + + if (nextIndex >= 0) + { + edges[nextIndex].Previous = previousIndex; + } + + int insertionPredecessor = previousIndex; + + while (insertionPredecessor >= 0 && edges[insertionPredecessor].X > edges[edgeIndex].X) + { + insertionPredecessor = edges[insertionPredecessor].Previous; + } + + if (insertionPredecessor < 0) + { + edges[edgeIndex].Previous = -1; + edges[edgeIndex].Next = head; + edges[head].Previous = edgeIndex; + head = edgeIndex; + return; + } + + int insertionSuccessor = edges[insertionPredecessor].Next; + edges[edgeIndex].Previous = insertionPredecessor; + edges[edgeIndex].Next = insertionSuccessor; + edges[insertionPredecessor].Next = edgeIndex; + + if (insertionSuccessor >= 0) + { + edges[insertionSuccessor].Previous = edgeIndex; + } + } +#pragma warning restore CA1517 + /// /// Converts one rectangle to its boundary path. /// @@ -848,6 +1375,58 @@ public Interval(int left, int right) public int Right { get; } } + /// + /// Represents one non-horizontal path edge during integer scan conversion. + /// + private struct RegionEdge : IComparable + { + /// + /// Gets or sets the first scanline crossed by the edge. + /// + public int FirstY { get; set; } + + /// + /// Gets or sets the last scanline crossed by the edge. + /// + public int LastY { get; set; } + + /// + /// Gets or sets the current crossing position in signed 16.16 fixed-point units. + /// + public long X { get; set; } + + /// + /// Gets or sets the signed 16.16 X advance for one scanline. + /// + public long DxDy { get; set; } + + /// + /// Gets or sets the winding contribution made when the edge is crossed. + /// + public int Winding { get; set; } + + /// + /// Gets or sets the preceding edge index in active crossing order. + /// + public int Previous { get; set; } + + /// + /// Gets or sets the following edge index in active crossing order. + /// + public int Next { get; set; } + + /// + /// Compares edges by their first scanline and initial crossing position. + /// + /// The edge to compare with this edge. + /// A value indicating the relative scan order of the edges. + public readonly int CompareTo(RegionEdge other) + { + int y = this.FirstY.CompareTo(other.FirstY); + return y != 0 ? y : this.X.CompareTo(other.X); + } + } + /// /// Represents one Y band with common X interval coverage. /// diff --git a/tests/ImageSharp.Drawing.Tests/RegionTests.cs b/tests/ImageSharp.Drawing.Tests/RegionTests.cs index 2492e514..013e1d60 100644 --- a/tests/ImageSharp.Drawing.Tests/RegionTests.cs +++ b/tests/ImageSharp.Drawing.Tests/RegionTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Six Labors. // Licensed under the Six Labors Split License. +using System.Numerics; + namespace SixLabors.ImageSharp.Drawing.Tests; public class RegionTests @@ -262,4 +264,211 @@ public void ToPath_DisjointIslands_ProducesFigurePerIsland() Assert.Equal(Rectangle.FromLTRB(0, 0, 110, 10), (Rectangle)path.Bounds); } + + [Fact] + public void PathConstructor_ZeroAreaPath_CreatesEmptyRegion() + { + Polygon line = new([new PointF(0, 5), new PointF(10, 5), new PointF(20, 5)]); + Region region = new(line, IntersectionRule.NonZero); + + Assert.True(region.IsEmpty); + Assert.Equal(Rectangle.Empty, region.Bounds); + Assert.Empty(region.Rectangles); + } + + [Fact] + public void PathConstructor_IntegerRectangle_MatchesRectangle() + { + Region region = new(new RectanglePolygon(10, 20, 30, 40), IntersectionRule.NonZero); + + Rectangle single = Assert.Single(region.Rectangles); + Assert.Equal(new Rectangle(10, 20, 30, 40), single); + Assert.Equal(new Rectangle(10, 20, 30, 40), region.Bounds); + } + + [Fact] + public void PathConstructor_FractionalRectangle_SelectsPixelsByCentre() + { + // Columns 10 to 39 have centres inside [10.25, 40.25). Rows 21 to 60 have centres inside [20.75, 60.75). + Region region = new(new RectanglePolygon(10.25F, 20.75F, 30, 40), IntersectionRule.NonZero); + + Rectangle single = Assert.Single(region.Rectangles); + Assert.Equal(Rectangle.FromLTRB(10, 21, 40, 61), single); + Assert.Equal(Rectangle.FromLTRB(10, 21, 40, 61), region.Bounds); + } + + [Theory] + [InlineData(IntersectionRule.NonZero)] + [InlineData(IntersectionRule.EvenOdd)] + public void PathConstructor_Triangle_MatchesPathAtPixelCentres(IntersectionRule intersectionRule) + { + Polygon triangle = new([new PointF(0.25F, 0.25F), new PointF(20.25F, 0.25F), new PointF(0.25F, 40.25F)]); + + AssertMatchesPathAtPixelCentres(triangle, intersectionRule); + } + + [Theory] + [InlineData(0F, 0F, IntersectionRule.NonZero)] + [InlineData(0F, 0F, IntersectionRule.EvenOdd)] + [InlineData(-7.3F, -13.3F, IntersectionRule.NonZero)] + public void PathConstructor_ConcavePolygon_MatchesPathAtPixelCentres(float offsetX, float offsetY, IntersectionRule intersectionRule) + => AssertMatchesPathAtPixelCentres(CreateConcavePolygon(offsetX, offsetY), intersectionRule); + + [Theory] + [InlineData(IntersectionRule.NonZero, true)] + [InlineData(IntersectionRule.EvenOdd, false)] + public void PathConstructor_NestedRectanglesSameWinding_FollowIntersectionRule(IntersectionRule intersectionRule, bool centreFilled) + { + // Both parts wind the same way, so the inner rectangle has winding number two. + ComplexPolygon nested = new(new RectanglePolygon(0.25F, 0.25F, 40, 40), new RectanglePolygon(10.25F, 10.25F, 20, 20)); + + AssertMatchesPathAtPixelCentres(nested, intersectionRule); + Assert.Equal(centreFilled, new Region(nested, intersectionRule).Contains(20, 20)); + } + + [Fact] + public void ContainsRegion_LShape_RequiresFullCoverage() + { + Region shape = new(new Rectangle(0, 0, 10, 20)); + shape.Add(new Rectangle(10, 10, 10, 10)); + + Assert.True(shape.Contains(new Region(shape))); + Assert.True(shape.Contains(new Region(new Rectangle(0, 0, 10, 20)))); + Assert.True(shape.Contains(new Region(new Rectangle(2, 12, 16, 6)))); + Assert.False(shape.Contains(new Region(new Rectangle(12, 2, 5, 5)))); + Assert.False(shape.Contains(new Region(new Rectangle(5, 5, 10, 10)))); + Assert.False(shape.Contains(new Region(new Rectangle(0, 0, 10, 21)))); + } + + [Fact] + public void ContainsRegion_EmptyRegions_ReturnFalse() + { + Region shape = new(new Rectangle(0, 0, 10, 10)); + + Assert.False(shape.Contains(new Region())); + Assert.False(new Region().Contains(shape)); + Assert.False(new Region().Contains(new Region())); + } + + [Fact] + public void IntersectsRegion_RequiresSharedArea() + { + Region shape = new(new Rectangle(0, 0, 10, 20)); + shape.Add(new Rectangle(10, 10, 10, 10)); + + Assert.True(shape.Intersects(new Region(new Rectangle(9, 9, 2, 2)))); + Assert.False(shape.Intersects(new Region(new Rectangle(12, 2, 5, 5)))); + Assert.False(shape.Intersects(new Region(new Rectangle(10, 0, 10, 10)))); + Assert.False(shape.Intersects(new Region(new Rectangle(20, 10, 5, 5)))); + Assert.False(shape.Intersects(new Region(new Rectangle(0, 20, 5, 5)))); + Assert.False(shape.Intersects(new Region())); + Assert.False(new Region().Intersects(shape)); + } + + [Fact] + public void IntersectsRegion_InterleavedIslands_DoNotIntersect() + { + Region first = new(new Rectangle(0, 0, 5, 10)); + first.Add(new Rectangle(10, 0, 5, 10)); + Region second = new(new Rectangle(5, 0, 5, 10)); + second.Add(new Rectangle(15, 0, 5, 10)); + + Assert.False(first.Intersects(second)); + Assert.False(second.Intersects(first)); + + second.Add(new Rectangle(4, 0, 1, 10)); + + Assert.True(first.Intersects(second)); + Assert.True(second.Intersects(first)); + } + + [Fact] + public void ContainsAndIntersectsRegion_MatchPixelMembership() + { + Region shape = new(CreateConcavePolygon(0, 0), IntersectionRule.NonZero); + Region shifted = new(CreateConcavePolygon(3.3F, 5.75F), IntersectionRule.NonZero); + Region insideLowerHalf = new(new RectanglePolygon(2.25F, 30.25F, 5, 5), IntersectionRule.NonZero); + Region insideNotch = new(new RectanglePolygon(8.25F, 2.25F, 4, 4), IntersectionRule.NonZero); + + AssertRegionRelationsMatchPixelMembership(shape, shifted); + AssertRegionRelationsMatchPixelMembership(shape, insideLowerHalf); + AssertRegionRelationsMatchPixelMembership(insideLowerHalf, shape); + AssertRegionRelationsMatchPixelMembership(shape, insideNotch); + } + + /// + /// Creates a polygon with a V-shaped notch in its top edge. Its slanted edges have slopes of one half, + /// so no scanline centre crossing lands on a pixel centre. + /// + /// The horizontal offset applied to every vertex. + /// The vertical offset applied to every vertex. + private static Polygon CreateConcavePolygon(float offsetX, float offsetY) + => new( + [ + new PointF(0.25F + offsetX, 0.25F + offsetY), + new PointF(10.25F + offsetX, 20.25F + offsetY), + new PointF(20.25F + offsetX, 0.25F + offsetY), + new PointF(20.25F + offsetX, 40.25F + offsetY), + new PointF(0.25F + offsetX, 40.25F + offsetY) + ]); + + /// + /// Asserts that a region built from a path holds exactly the pixels whose centres the path contains, + /// and that its bounds are the union of its rectangles. + /// + /// The path to convert. + /// The fill rule. + private static void AssertMatchesPathAtPixelCentres(IPath path, IntersectionRule intersectionRule) + { + Region region = new(path, intersectionRule); + RectangleF pathBounds = path.Bounds; + int left = (int)MathF.Floor(pathBounds.Left) - 1; + int top = (int)MathF.Floor(pathBounds.Top) - 1; + int right = (int)MathF.Ceiling(pathBounds.Right) + 1; + int bottom = (int)MathF.Ceiling(pathBounds.Bottom) + 1; + + for (int y = top; y < bottom; y++) + { + for (int x = left; x < right; x++) + { + bool expected = path.Contains(new PointF(x + 0.5F, y + 0.5F), intersectionRule, Vector2.One); + Assert.True(expected == region.Contains(x, y), $"Pixel ({x}, {y}) expected {expected}."); + } + } + + Rectangle expectedBounds = Rectangle.Empty; + foreach (Rectangle rectangle in region.Rectangles) + { + expectedBounds = expectedBounds.IsEmpty ? rectangle : Rectangle.Union(expectedBounds, rectangle); + } + + Assert.Equal(expectedBounds, region.Bounds); + } + + /// + /// Asserts that region containment and intersection agree with per-pixel membership. + /// + /// The region whose Contains and Intersects are tested. + /// The region passed as the argument. + private static void AssertRegionRelationsMatchPixelMembership(Region first, Region second) + { + Rectangle bounds = Rectangle.Union(first.Bounds, second.Bounds); + bool anyShared = false; + bool secondCovered = !second.IsEmpty; + + for (int y = bounds.Top; y < bounds.Bottom; y++) + { + for (int x = bounds.Left; x < bounds.Right; x++) + { + bool inFirst = first.Contains(x, y); + bool inSecond = second.Contains(x, y); + anyShared |= inFirst && inSecond; + secondCovered &= !inSecond || inFirst; + } + } + + Assert.Equal(anyShared, first.Intersects(second)); + Assert.Equal(anyShared, second.Intersects(first)); + Assert.Equal(secondCovered, first.Contains(second)); + } }