Skip to content

Commit e19909c

Browse files
Add RoundedRectanglePolygon and PathBuilder helpers. Fix #9
1 parent 7f09902 commit e19909c

7 files changed

Lines changed: 352 additions & 2 deletions

File tree

src/ImageSharp.Drawing/PathBuilder.cs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,75 @@ public PathBuilder AddRectangle(float x, float y, float width, float height)
477477
new PointF(x + width, y + height),
478478
new PointF(x, y + height));
479479

480+
/// <summary>
481+
/// Adds a rounded rectangle to the current path as a closed figure.
482+
/// </summary>
483+
/// <param name="rectangle">The rectangle bounds.</param>
484+
/// <param name="radius">The x and y radius of each corner.</param>
485+
/// <returns>The <see cref="PathBuilder"/>.</returns>
486+
public PathBuilder AddRoundedRectangle(RectangleF rectangle, float radius)
487+
=> this.AddRoundedRectangle(rectangle, new SizeF(radius, radius));
488+
489+
/// <summary>
490+
/// Adds a rounded rectangle to the current path as a closed figure.
491+
/// </summary>
492+
/// <param name="rectangle">The rectangle bounds.</param>
493+
/// <param name="radius">The x and y radii of each corner.</param>
494+
/// <returns>The <see cref="PathBuilder"/>.</returns>
495+
public PathBuilder AddRoundedRectangle(RectangleF rectangle, SizeF radius)
496+
{
497+
_ = this.StartFigure();
498+
499+
foreach (ILineSegment segment in new RoundedRectanglePolygon(rectangle, radius).LineSegments)
500+
{
501+
_ = this.AddSegment(segment);
502+
}
503+
504+
return this.CloseFigure();
505+
}
506+
507+
/// <summary>
508+
/// Adds a rounded rectangle to the current path as a closed figure.
509+
/// </summary>
510+
/// <param name="rectangle">The rectangle bounds.</param>
511+
/// <param name="radius">The x and y radius of each corner.</param>
512+
/// <returns>The <see cref="PathBuilder"/>.</returns>
513+
public PathBuilder AddRoundedRectangle(Rectangle rectangle, float radius)
514+
=> this.AddRoundedRectangle((RectangleF)rectangle, radius);
515+
516+
/// <summary>
517+
/// Adds a rounded rectangle to the current path as a closed figure.
518+
/// </summary>
519+
/// <param name="rectangle">The rectangle bounds.</param>
520+
/// <param name="radius">The x and y radii of each corner.</param>
521+
/// <returns>The <see cref="PathBuilder"/>.</returns>
522+
public PathBuilder AddRoundedRectangle(Rectangle rectangle, SizeF radius)
523+
=> this.AddRoundedRectangle((RectangleF)rectangle, radius);
524+
525+
/// <summary>
526+
/// Adds a rounded rectangle to the current path as a closed figure.
527+
/// </summary>
528+
/// <param name="x">The x-coordinate of the rectangle.</param>
529+
/// <param name="y">The y-coordinate of the rectangle.</param>
530+
/// <param name="width">The rectangle width.</param>
531+
/// <param name="height">The rectangle height.</param>
532+
/// <param name="radius">The x and y radius of each corner.</param>
533+
/// <returns>The <see cref="PathBuilder"/>.</returns>
534+
public PathBuilder AddRoundedRectangle(float x, float y, float width, float height, float radius)
535+
=> this.AddRoundedRectangle(new RectangleF(x, y, width, height), radius);
536+
537+
/// <summary>
538+
/// Adds a rounded rectangle to the current path as a closed figure.
539+
/// </summary>
540+
/// <param name="x">The x-coordinate of the rectangle.</param>
541+
/// <param name="y">The y-coordinate of the rectangle.</param>
542+
/// <param name="width">The rectangle width.</param>
543+
/// <param name="height">The rectangle height.</param>
544+
/// <param name="radius">The x and y radii of each corner.</param>
545+
/// <returns>The <see cref="PathBuilder"/>.</returns>
546+
public PathBuilder AddRoundedRectangle(float x, float y, float width, float height, SizeF radius)
547+
=> this.AddRoundedRectangle(new RectangleF(x, y, width, height), radius);
548+
480549
/// <summary>
481550
/// Adds a polygon to the current path as a closed figure.
482551
/// </summary>

src/ImageSharp.Drawing/RectangularPolygon.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,8 @@
66
namespace SixLabors.ImageSharp.Drawing;
77

88
/// <summary>
9-
/// A polygon tha allows the optimized drawing of rectangles.
9+
/// A closed rectangular path defined by four straight edges.
1010
/// </summary>
11-
/// <seealso cref="IPath" />
1211
public sealed class RectangularPolygon : IPath, ISimplePath, IPathInternals
1312
{
1413
private readonly Vector2 topLeft;
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// Copyright (c) Six Labors.
2+
// Licensed under the Six Labors Split License.
3+
4+
using System.Numerics;
5+
6+
namespace SixLabors.ImageSharp.Drawing;
7+
8+
/// <summary>
9+
/// A rounded rectangle shape defined by rectangle bounds and corner radii.
10+
/// </summary>
11+
public sealed class RoundedRectanglePolygon : Polygon
12+
{
13+
/// <summary>
14+
/// Initializes a new instance of the <see cref="RoundedRectanglePolygon"/> class.
15+
/// </summary>
16+
/// <param name="rectangle">The rectangle bounds.</param>
17+
/// <param name="radius">The x and y radius of each corner.</param>
18+
public RoundedRectanglePolygon(RectangleF rectangle, float radius)
19+
: this(rectangle, new SizeF(radius, radius))
20+
{
21+
}
22+
23+
/// <summary>
24+
/// Initializes a new instance of the <see cref="RoundedRectanglePolygon"/> class.
25+
/// </summary>
26+
/// <param name="rectangle">The rectangle bounds.</param>
27+
/// <param name="radius">The x and y radii of each corner.</param>
28+
public RoundedRectanglePolygon(RectangleF rectangle, SizeF radius)
29+
: base(CreateSegments(rectangle, radius))
30+
{
31+
}
32+
33+
/// <summary>
34+
/// Initializes a new instance of the <see cref="RoundedRectanglePolygon"/> class.
35+
/// </summary>
36+
/// <param name="x">The x-coordinate of the rectangle.</param>
37+
/// <param name="y">The y-coordinate of the rectangle.</param>
38+
/// <param name="width">The rectangle width.</param>
39+
/// <param name="height">The rectangle height.</param>
40+
/// <param name="radius">The x and y radius of each corner.</param>
41+
public RoundedRectanglePolygon(float x, float y, float width, float height, float radius)
42+
: this(new RectangleF(x, y, width, height), radius)
43+
{
44+
}
45+
46+
/// <summary>
47+
/// Initializes a new instance of the <see cref="RoundedRectanglePolygon"/> class.
48+
/// </summary>
49+
/// <param name="x">The x-coordinate of the rectangle.</param>
50+
/// <param name="y">The y-coordinate of the rectangle.</param>
51+
/// <param name="width">The rectangle width.</param>
52+
/// <param name="height">The rectangle height.</param>
53+
/// <param name="radius">The x and y radii of each corner.</param>
54+
public RoundedRectanglePolygon(float x, float y, float width, float height, SizeF radius)
55+
: this(new RectangleF(x, y, width, height), radius)
56+
{
57+
}
58+
59+
private RoundedRectanglePolygon(ILineSegment[] segments)
60+
: base(segments, true)
61+
{
62+
}
63+
64+
/// <inheritdoc />
65+
public override IPath Transform(Matrix4x4 matrix)
66+
{
67+
if (matrix.IsIdentity)
68+
{
69+
return this;
70+
}
71+
72+
ILineSegment[] segments = new ILineSegment[this.LineSegments.Count];
73+
74+
for (int i = 0; i < segments.Length; i++)
75+
{
76+
segments[i] = this.LineSegments[i].Transform(matrix);
77+
}
78+
79+
return new RoundedRectanglePolygon(segments);
80+
}
81+
82+
private static ILineSegment[] CreateSegments(RectangleF rectangle, SizeF radius)
83+
{
84+
float left = MathF.Min(rectangle.Left, rectangle.Right);
85+
float top = MathF.Min(rectangle.Top, rectangle.Bottom);
86+
float right = MathF.Max(rectangle.Left, rectangle.Right);
87+
float bottom = MathF.Max(rectangle.Top, rectangle.Bottom);
88+
float width = right - left;
89+
float height = bottom - top;
90+
91+
if (width <= 0 || height <= 0)
92+
{
93+
return [];
94+
}
95+
96+
float radiusX = radius.Width;
97+
float radiusY = radius.Height;
98+
99+
if (radiusX <= 0 || radiusY <= 0)
100+
{
101+
return
102+
[
103+
new LinearLineSegment(
104+
new PointF(left, top),
105+
new PointF(right, top),
106+
new PointF(right, bottom),
107+
new PointF(left, bottom))
108+
];
109+
}
110+
111+
float radiusScale = MathF.Min(width / (radiusX + radiusX), height / (radiusY + radiusY));
112+
if (radiusScale < 1F)
113+
{
114+
// Preserve the supplied corner shape while shrinking it enough that opposing corners do not overlap.
115+
radiusX *= radiusScale;
116+
radiusY *= radiusScale;
117+
}
118+
119+
SizeF cornerRadius = new(radiusX, radiusY);
120+
PointF topLeft = new(left + radiusX, top);
121+
PointF topRight = new(right - radiusX, top);
122+
PointF rightTop = new(right, top + radiusY);
123+
PointF rightBottom = new(right, bottom - radiusY);
124+
PointF bottomRight = new(right - radiusX, bottom);
125+
PointF bottomLeft = new(left + radiusX, bottom);
126+
PointF leftBottom = new(left, bottom - radiusY);
127+
PointF leftTop = new(left, top + radiusY);
128+
129+
return
130+
[
131+
new LinearLineSegment(topLeft, topRight),
132+
new ArcLineSegment(new PointF(right - radiusX, top + radiusY), cornerRadius, 0F, -90F, 90F),
133+
new LinearLineSegment(rightTop, rightBottom),
134+
new ArcLineSegment(new PointF(right - radiusX, bottom - radiusY), cornerRadius, 0F, 0F, 90F),
135+
new LinearLineSegment(bottomRight, bottomLeft),
136+
new ArcLineSegment(new PointF(left + radiusX, bottom - radiusY), cornerRadius, 0F, 90F, 90F),
137+
new LinearLineSegment(leftBottom, leftTop),
138+
new ArcLineSegment(new PointF(left + radiusX, top + radiusY), cornerRadius, 0F, 180F, 90F)
139+
];
140+
}
141+
}

tests/ImageSharp.Drawing.Tests/Processing/DrawingCanvasTests.Primitives.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,43 @@ public void FillPrimitiveHelpers_MatchesReference<TPixel>(TestImageProvider<TPix
192192
target.CompareToReferenceOutput(provider, appendSourceFileOrDescription: false);
193193
}
194194

195+
[Theory]
196+
[WithBlankImage(360, 220, PixelTypes.Rgba32)]
197+
public void RoundedRectanglePolygon_MatchesReference<TPixel>(TestImageProvider<TPixel> provider)
198+
where TPixel : unmanaged, IPixel<TPixel>
199+
{
200+
using Image<TPixel> target = provider.GetImage();
201+
using (DrawingCanvas<TPixel> canvas = CreateCanvas(provider, target, new DrawingOptions()))
202+
{
203+
canvas.Clear(Brushes.Solid(Color.White));
204+
205+
// Filled shape proves the rounded rectangle contributes a closed fill region.
206+
canvas.Fill(
207+
Brushes.Solid(Color.CornflowerBlue.WithAlpha(0.75F)),
208+
new RoundedRectanglePolygon(18, 18, 138, 76, 18));
209+
210+
// Dashed stroke covers the original issue request: rounded corners must remain a normal strokable path.
211+
canvas.Draw(
212+
Pens.Dash(Color.DarkSlateBlue, 5F),
213+
new RoundedRectanglePolygon(190, 18, 132, 76, 24));
214+
215+
// Elliptical radii exercise independent x/y corner curvature.
216+
canvas.Fill(
217+
Brushes.Solid(Color.MediumSeaGreen.WithAlpha(0.78F)),
218+
new RoundedRectanglePolygon(26, 124, 118, 58, new SizeF(28, 12)));
219+
220+
// Oversized radii should scale down to a capsule-like shape within the supplied bounds.
221+
canvas.Draw(
222+
Pens.Solid(Color.OrangeRed, 6F),
223+
new RoundedRectanglePolygon(188, 122, 138, 62, 90));
224+
225+
canvas.Draw(Pens.Solid(Color.Black.WithAlpha(0.5F), 2F), new Rectangle(8, 8, 344, 204));
226+
}
227+
228+
target.DebugSave(provider, appendSourceFileOrDescription: false);
229+
target.CompareToReferenceOutput(provider, appendSourceFileOrDescription: false);
230+
}
231+
195232
[Theory]
196233
[WithBlankImage(240, 160, PixelTypes.Rgba32)]
197234
public void DrawPieHelpers_MatchesReference<TPixel>(TestImageProvider<TPixel> provider)

tests/ImageSharp.Drawing.Tests/Shapes/PathBuilderTests.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,30 @@ public void AddRectangleMatchesRectangularPolygon()
8585
AssertEquivalentPaths(actual, expected);
8686
}
8787

88+
[Fact]
89+
public void AddRoundedRectangleMatchesRoundedRectangleShape()
90+
{
91+
PathBuilder builder = new();
92+
builder.AddRoundedRectangle(new RectangleF(10, 20, 30, 40), new SizeF(5, 8));
93+
94+
Polygon actual = Assert.IsType<Polygon>(builder.Build());
95+
RoundedRectanglePolygon expected = new(10, 20, 30, 40, new SizeF(5, 8));
96+
97+
AssertEquivalentPaths(actual, expected);
98+
}
99+
100+
[Fact]
101+
public void AddRoundedRectangleRadiusOverloadMatchesRoundedRectangleShape()
102+
{
103+
PathBuilder builder = new();
104+
builder.AddRoundedRectangle(10, 20, 30, 40, 6);
105+
106+
Polygon actual = Assert.IsType<Polygon>(builder.Build());
107+
RoundedRectanglePolygon expected = new(10, 20, 30, 40, 6);
108+
109+
AssertEquivalentPaths(actual, expected);
110+
}
111+
88112
[Fact]
89113
public void AddPolygonEnumerableMatchesPolygonShape()
90114
{
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
// Copyright (c) Six Labors.
2+
// Licensed under the Six Labors Split License.
3+
4+
using System.Numerics;
5+
using SixLabors.ImageSharp.Drawing.Tests;
6+
7+
namespace SixLabors.ImageSharp.Drawing.Tests.Shapes;
8+
9+
public class RoundedRectangleTests
10+
{
11+
[Fact]
12+
public void BoundsMatchRectangle()
13+
{
14+
RoundedRectanglePolygon shape = new(10, 20, 100, 40, 12);
15+
16+
Assert.Equal(new RectangleF(10, 20, 100, 40), shape.Bounds);
17+
}
18+
19+
[Fact]
20+
public void ZeroRadiusMatchesRectangularPolygon()
21+
{
22+
RoundedRectanglePolygon shape = new(10, 20, 100, 40, 0);
23+
RectangularPolygon rectangle = new(10, 20, 100, 40);
24+
PointF[] actualPoints = shape.Flatten().Single().Points.ToArray();
25+
PointF[] expectedPoints = rectangle.Flatten().Single().Points.ToArray();
26+
27+
Assert.Equal(expectedPoints, actualPoints);
28+
}
29+
30+
[Fact]
31+
public void RoundedRectangleUsesCurvedCorners()
32+
{
33+
RoundedRectanglePolygon shape = new(10, 20, 100, 40, 12);
34+
PointF[] points = shape.Flatten().Single().Points.ToArray();
35+
36+
Assert.True(points.Length > 4);
37+
}
38+
39+
[Fact]
40+
public void OversizedRadiusScalesToFitBounds()
41+
{
42+
RoundedRectanglePolygon shape = new(10, 20, 100, 40, 100);
43+
PointF[] points = shape.Flatten().Single().Points.ToArray();
44+
45+
Assert.Equal(new RectangleF(10, 20, 100, 40), shape.Bounds);
46+
Assert.True(points.Length > 4);
47+
}
48+
49+
[Fact]
50+
public void ShapePaths()
51+
{
52+
RoundedRectanglePolygon shape = new(10, 20, 100, 40, 12);
53+
54+
Assert.Equal(shape, shape.AsClosedPath());
55+
}
56+
57+
[Fact]
58+
public void TransformIdentityReturnsShapeObject()
59+
{
60+
RoundedRectanglePolygon shape = new(10, 20, 100, 40, 12);
61+
IPath transformedShape = shape.Transform(Matrix4x4.Identity);
62+
63+
Assert.Same(shape, transformedShape);
64+
}
65+
66+
[Fact]
67+
public void Transform()
68+
{
69+
RoundedRectanglePolygon shape = new(0, 0, 100, 40, 12);
70+
71+
IPath newShape = shape.Transform(new Matrix4x4(new Matrix3x2(0, 1, 1, 0, 20, 2)));
72+
73+
ApproximateFloatComparer comparer = new(1e-4F);
74+
Assert.Equal(new PointF(20, 2), newShape.Bounds.Location, comparer);
75+
Assert.Equal(new SizeF(40, 100), newShape.Bounds.Size, comparer);
76+
}
77+
}
Lines changed: 3 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)