From da44dcf1f3731c9d1c3167ac45d2254923a8d8aa Mon Sep 17 00:00:00 2001 From: Xavier Cho Date: Mon, 2 Sep 2019 05:41:17 +0900 Subject: [PATCH] Implement gradient brush similar to PathGradientBrush (#969) (#989) * Implement gradient brush similar to PathGradientBrush (#969) A gradient brush implementation that works similar to System.Drawing.Drawing2D.PathGradientBrush. This fixes #969, but only convex paths are supported for now. * Update submodule to add test fixtures for #989 * Performance optimization Use LengthSquared() instead of Length() when it's possible. Avoid unnecessary ordering of elements. * Avoid using LINQ in a hotspot * Validate arguments for the public constructor --- .../Processing/PathGradientBrush.cs | 286 ++++++++++++++++++ .../Drawing/FillPathGradientBrushTests.cs | 209 +++++++++++++ tests/Images/External | 2 +- 3 files changed, 496 insertions(+), 1 deletion(-) create mode 100644 src/ImageSharp.Drawing/Processing/PathGradientBrush.cs create mode 100644 tests/ImageSharp.Tests/Drawing/FillPathGradientBrushTests.cs diff --git a/src/ImageSharp.Drawing/Processing/PathGradientBrush.cs b/src/ImageSharp.Drawing/Processing/PathGradientBrush.cs new file mode 100644 index 000000000..fc84e4a1f --- /dev/null +++ b/src/ImageSharp.Drawing/Processing/PathGradientBrush.cs @@ -0,0 +1,286 @@ +// Copyright (c) Six Labors and contributors. +// Licensed under the Apache License, Version 2.0. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Numerics; + +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.Primitives; +using SixLabors.Shapes; + +namespace SixLabors.ImageSharp.Processing +{ + /// + /// Provides an implementation of a brush for painting gradients between multiple color positions in 2D coordinates. + /// It works similarly with the class in System.Drawing.Drawing2D of the same name. + /// + public sealed class PathGradientBrush : IBrush + { + private readonly Polygon path; + + private readonly IList edges; + + private readonly Color centerColor; + + /// + /// Initializes a new instance of the class. + /// + /// Line segments of a polygon that represents the gradient area. + /// Array of colors that correspond to each point in the polygon. + /// Color at the center of the gradient area to which the other colors converge. + public PathGradientBrush(ILineSegment[] lines, Color[] colors, Color centerColor) + { + if (lines == null) + { + throw new ArgumentNullException(nameof(lines)); + } + + if (lines.Length < 3) + { + throw new ArgumentOutOfRangeException( + nameof(lines), + "There must be at least 3 lines to construct a path gradient brush."); + } + + if (colors == null) + { + throw new ArgumentNullException(nameof(colors)); + } + + if (!colors.Any()) + { + throw new ArgumentOutOfRangeException( + nameof(colors), + "One or more color is needed to construct a path gradient brush."); + } + + this.path = new Polygon(lines); + this.centerColor = centerColor; + + Color ColorAt(int index) => colors[index % colors.Length]; + + this.edges = this.path.LineSegments.Select(s => new Path(s)) + .Select((path, i) => new Edge(path, ColorAt(i), ColorAt(i + 1))).ToList(); + } + + /// + /// Initializes a new instance of the class. + /// + /// Line segments of a polygon that represents the gradient area. + /// Array of colors that correspond to each point in the polygon. + public PathGradientBrush(ILineSegment[] lines, Color[] colors) + : this(lines, colors, CalculateCenterColor(colors)) + { + } + + /// + public BrushApplicator CreateApplicator( + ImageFrame source, + RectangleF region, + GraphicsOptions options) + where TPixel : struct, IPixel + { + return new PathGradientBrushApplicator(source, this.path, this.edges, this.centerColor, options); + } + + private static Color CalculateCenterColor(Color[] colors) + { + if (colors == null) + { + throw new ArgumentNullException(nameof(colors)); + } + + if (!colors.Any()) + { + throw new ArgumentOutOfRangeException( + nameof(colors), + "One or more color is needed to construct a path gradient brush."); + } + + return new Color(colors.Select(c => c.ToVector4()).Aggregate((p1, p2) => p1 + p2) / colors.Length); + } + + private static float DistanceBetween(PointF p1, PointF p2) => ((Vector2)(p2 - p1)).Length(); + + private struct Intersection + { + public Intersection(PointF point, float distance) + { + this.Point = point; + this.Distance = distance; + } + + public PointF Point { get; } + + public float Distance { get; } + } + + /// + /// An edge of the polygon that represents the gradient area. + /// + private class Edge + { + private readonly Path path; + + private readonly float length; + + private readonly PointF[] buffer; + + public Edge(Path path, Color startColor, Color endColor) + { + this.path = path; + + Vector2[] points = path.LineSegments.SelectMany(s => s.Flatten()).Select(p => (Vector2)p).ToArray(); + + this.Start = points.First(); + this.StartColor = startColor.ToVector4(); + + this.End = points.Last(); + this.EndColor = endColor.ToVector4(); + + this.length = DistanceBetween(this.End, this.Start); + this.buffer = new PointF[this.path.MaxIntersections]; + } + + public PointF Start { get; } + + public Vector4 StartColor { get; } + + public PointF End { get; } + + public Vector4 EndColor { get; } + + public Intersection? FindIntersection(PointF start, PointF end) + { + int intersections = this.path.FindIntersections(start, end, this.buffer); + + if (intersections == 0) + { + return null; + } + + return this.buffer.Take(intersections) + .Select(p => new Intersection(point: p, distance: ((Vector2)(p - start)).LengthSquared())) + .Aggregate((min, current) => min.Distance > current.Distance ? current : min); + } + + public Vector4 ColorAt(float distance) + { + float ratio = this.length > 0 ? distance / this.length : 0; + + return Vector4.Lerp(this.StartColor, this.EndColor, ratio); + } + + public Vector4 ColorAt(PointF point) => this.ColorAt(DistanceBetween(point, this.Start)); + } + + /// + /// The path gradient brush applicator. + /// + private class PathGradientBrushApplicator : BrushApplicator + where TPixel : struct, IPixel + { + private readonly Path path; + + private readonly PointF center; + + private readonly Vector4 centerColor; + + private readonly float maxDistance; + + private readonly IList edges; + + /// + /// Initializes a new instance of the class. + /// + /// The source image. + /// A polygon that represents the gradient area. + /// Edges of the polygon. + /// Color at the center of the gradient area to which the other colors converge. + /// The options. + public PathGradientBrushApplicator( + ImageFrame source, + Path path, + IList edges, + Color centerColor, + GraphicsOptions options) + : base(source, options) + { + this.path = path; + this.edges = edges; + + PointF[] points = path.LineSegments.Select(s => s.EndPoint).ToArray(); + + this.center = points.Aggregate((p1, p2) => p1 + p2) / points.Length; + this.centerColor = centerColor.ToVector4(); + + this.maxDistance = points.Select(p => (Vector2)(p - this.center)).Select(d => d.Length()).Max(); + } + + /// + internal override TPixel this[int x, int y] + { + get + { + var point = new PointF(x, y); + + if (point == this.center) + { + return new Color(this.centerColor).ToPixel(); + } + + if (!this.path.Contains(point)) + { + return Color.Transparent.ToPixel(); + } + + Vector2 direction = Vector2.Normalize(point - this.center); + + PointF end = point + (PointF)(direction * this.maxDistance); + + (Edge edge, Intersection? info) = this.FindIntersection(point, end); + + PointF intersection = info.Value.Point; + + Vector4 edgeColor = edge.ColorAt(intersection); + + float length = DistanceBetween(intersection, this.center); + float ratio = length > 0 ? DistanceBetween(intersection, point) / length : 0; + + Vector4 color = Vector4.Lerp(edgeColor, this.centerColor, ratio); + + return new Color(color).ToPixel(); + } + } + + private (Edge edge, Intersection? info) FindIntersection(PointF start, PointF end) + { + (Edge edge, Intersection? info) closest = default; + + foreach (Edge edge in this.edges) + { + Intersection? intersection = edge.FindIntersection(start, end); + + if (!intersection.HasValue) + { + continue; + } + + if (closest.info == null || closest.info.Value.Distance > intersection.Value.Distance) + { + closest = (edge, intersection); + } + } + + return closest; + } + + /// + public override void Dispose() + { + } + } + } +} diff --git a/tests/ImageSharp.Tests/Drawing/FillPathGradientBrushTests.cs b/tests/ImageSharp.Tests/Drawing/FillPathGradientBrushTests.cs new file mode 100644 index 000000000..d76893108 --- /dev/null +++ b/tests/ImageSharp.Tests/Drawing/FillPathGradientBrushTests.cs @@ -0,0 +1,209 @@ +// Copyright (c) Six Labors and contributors. +// Licensed under the Apache License, Version 2.0. + +using System; + +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Processing; +using SixLabors.ImageSharp.Tests.TestUtilities.ImageComparison; +using SixLabors.Primitives; +using SixLabors.Shapes; + +using Xunit; + +namespace SixLabors.ImageSharp.Tests.Drawing +{ + [GroupOutput("Drawing/GradientBrushes")] + public class FillPathGradientBrushTests + { + public static ImageComparer TolerantComparer = ImageComparer.TolerantPercentage(0.01f); + + [Theory] + [WithBlankImages(10, 10, PixelTypes.Rgba32)] + public void FillRectangleWithDifferentColors(TestImageProvider provider) + where TPixel : struct, IPixel + { + provider.VerifyOperation( + TolerantComparer, + image => + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(0, 0), new PointF(10, 0)), + new LinearLineSegment(new PointF(10, 0), new PointF(10, 10)), + new LinearLineSegment(new PointF(10, 10), new PointF(0, 10)), + new LinearLineSegment(new PointF(0, 10), new PointF(0, 0)) + }; + + Color[] colors = { Color.Black, Color.Red, Color.Yellow, Color.Green }; + + var brush = new PathGradientBrush(path, colors); + + image.Mutate(x => x.Fill(brush)); + image.DebugSave(provider, appendPixelTypeToFileName: false, appendSourceFileOrDescription: false); + }); + } + + [Theory] + [WithBlankImages(10, 10, PixelTypes.Rgba32)] + public void FillTriangleWithDifferentColors(TestImageProvider provider) + where TPixel : struct, IPixel + { + provider.VerifyOperation( + TolerantComparer, + image => + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(5, 0), new PointF(10, 10)), + new LinearLineSegment(new PointF(10, 10), new PointF(0, 10)), + new LinearLineSegment(new PointF(0, 10), new PointF(5, 0)) + }; + + Color[] colors = { Color.Red, Color.Green, Color.Blue }; + + var brush = new PathGradientBrush(path, colors); + + image.Mutate(x => x.Fill(brush)); + image.DebugSave(provider, appendPixelTypeToFileName: false, appendSourceFileOrDescription: false); + }); + } + + [Theory] + [WithBlankImages(10, 10, PixelTypes.Rgba32)] + public void FillRectangleWithSingleColor(TestImageProvider provider) + where TPixel : struct, IPixel + { + using (Image image = provider.GetImage()) + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(0, 0), new PointF(10, 0)), + new LinearLineSegment(new PointF(10, 0), new PointF(10, 10)), + new LinearLineSegment(new PointF(10, 10), new PointF(0, 10)), + new LinearLineSegment(new PointF(0, 10), new PointF(0, 0)) + }; + + Color[] colors = { Color.Red }; + + var brush = new PathGradientBrush(path, colors); + + image.Mutate(x => x.Fill(brush)); + + image.ComparePixelBufferTo(Color.Red); + } + } + + [Theory] + [WithBlankImages(10, 10, PixelTypes.Rgba32)] + public void ShouldRotateTheColorsWhenThereAreMorePoints(TestImageProvider provider) + where TPixel : struct, IPixel + { + provider.VerifyOperation( + TolerantComparer, + image => + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(0, 0), new PointF(10, 0)), + new LinearLineSegment(new PointF(10, 0), new PointF(10, 10)), + new LinearLineSegment(new PointF(10, 10), new PointF(0, 10)), + new LinearLineSegment(new PointF(0, 10), new PointF(0, 0)) + }; + + Color[] colors = { Color.Red, Color.Yellow }; + + var brush = new PathGradientBrush(path, colors); + + image.Mutate(x => x.Fill(brush)); + image.DebugSave(provider, appendPixelTypeToFileName: false, appendSourceFileOrDescription: false); + }); + } + + [Theory] + [WithBlankImages(10, 10, PixelTypes.Rgba32)] + public void FillWithCustomCenterColor(TestImageProvider provider) + where TPixel : struct, IPixel + { + provider.VerifyOperation( + TolerantComparer, + image => + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(0, 0), new PointF(10, 0)), + new LinearLineSegment(new PointF(10, 0), new PointF(10, 10)), + new LinearLineSegment(new PointF(10, 10), new PointF(0, 10)), + new LinearLineSegment(new PointF(0, 10), new PointF(0, 0)) + }; + + Color[] colors = { Color.Black, Color.Red, Color.Yellow, Color.Green }; + + var brush = new PathGradientBrush(path, colors, Color.White); + + image.Mutate(x => x.Fill(brush)); + image.DebugSave(provider, appendPixelTypeToFileName: false, appendSourceFileOrDescription: false); + }); + } + + [Fact] + public void ShouldThrowArgumentNullExceptionWhenLinesAreNull() + { + Color[] colors = { Color.Black, Color.Red, Color.Yellow, Color.Green }; + + PathGradientBrush Create() => new PathGradientBrush(null, colors, Color.White); + + Assert.Throws(Create); + } + + [Fact] + public void ShouldThrowArgumentOutOfRangeExceptionWhenLessThan3LinesAreGiven() + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(0, 0), new PointF(10, 0)), + new LinearLineSegment(new PointF(10, 0), new PointF(10, 10)) + }; + + Color[] colors = { Color.Black, Color.Red, Color.Yellow, Color.Green }; + + PathGradientBrush Create() => new PathGradientBrush(path, colors, Color.White); + + Assert.Throws(Create); + } + + [Fact] + public void ShouldThrowArgumentNullExceptionWhenColorsAreNull() + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(0, 0), new PointF(10, 0)), + new LinearLineSegment(new PointF(10, 0), new PointF(10, 10)), + new LinearLineSegment(new PointF(10, 10), new PointF(0, 10)), + new LinearLineSegment(new PointF(0, 10), new PointF(0, 0)) + }; + + PathGradientBrush Create() => new PathGradientBrush(path, null, Color.White); + + Assert.Throws(Create); + } + + [Fact] + public void ShouldThrowArgumentOutOfRangeExceptionWhenEmptyColorArrayIsGiven() + { + ILineSegment[] path = + { + new LinearLineSegment(new PointF(0, 0), new PointF(10, 0)), + new LinearLineSegment(new PointF(10, 0), new PointF(10, 10)), + new LinearLineSegment(new PointF(10, 10), new PointF(0, 10)), + new LinearLineSegment(new PointF(0, 10), new PointF(0, 0)) + }; + + var colors = new Color[0]; + + PathGradientBrush Create() => new PathGradientBrush(path, colors, Color.White); + + Assert.Throws(Create); + } + } +} diff --git a/tests/Images/External b/tests/Images/External index 36f39bc62..99a2bc523 160000 --- a/tests/Images/External +++ b/tests/Images/External @@ -1 +1 @@ -Subproject commit 36f39bc624f8a49caf512077bf70cab30c2e5fb4 +Subproject commit 99a2bc523cd4eb00e37af20d1b2088fa11564c57