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)); + } }