diff --git a/src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs b/src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs index abc07d0e71..63cbfb2dbe 100644 --- a/src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs +++ b/src/Avalonia.Headless/HeadlessPlatformRenderInterface.cs @@ -48,6 +48,7 @@ namespace Avalonia.Headless public IStreamGeometryImpl CreateStreamGeometry() => new HeadlessStreamingGeometryStub(); public IGeometryImpl CreateGeometryGroup(FillRule fillRule, IReadOnlyList children) => throw new NotImplementedException(); + public IGeometryImpl CreateCombinedGeometry(GeometryCombineMode combineMode, Geometry g1, Geometry g2) => throw new NotImplementedException(); public IRenderTarget CreateRenderTarget(IEnumerable surfaces) => new HeadlessRenderTarget(); diff --git a/src/Avalonia.Visuals/Media/CombinedGeometry.cs b/src/Avalonia.Visuals/Media/CombinedGeometry.cs new file mode 100644 index 0000000000..2202030b7a --- /dev/null +++ b/src/Avalonia.Visuals/Media/CombinedGeometry.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Avalonia.Platform; + +#nullable enable + +namespace Avalonia.Media +{ + public enum GeometryCombineMode + { + /// + /// The two regions are combined by taking the union of both. The resulting geometry is + /// geometry A + geometry B. + /// + Union, + + /// + /// The two regions are combined by taking their intersection. The new area consists of the + /// overlapping region between the two geometries. + /// + Intersect, + + /// + /// The two regions are combined by taking the area that exists in the first region but not + /// the second and the area that exists in the second region but not the first. The new + /// region consists of (A-B) + (B-A), where A and B are geometries. + /// + Xor, + + /// + /// The second region is excluded from the first. Given two geometries, A and B, the area of + /// geometry B is removed from the area of geometry A, producing a region that is A-B. + /// + Exclude, + } + + /// + /// Represents a 2-D geometric shape defined by the combination of two Geometry objects. + /// + public class CombinedGeometry : Geometry + { + /// + /// Defines the property. + /// + public static readonly StyledProperty Geometry1Property = + AvaloniaProperty.Register(nameof(Geometry1)); + + /// + /// Defines the property. + /// + public static readonly StyledProperty Geometry2Property = + AvaloniaProperty.Register(nameof(Geometry2)); + /// + /// Defines the property. + /// + public static readonly StyledProperty GeometryCombineModeProperty = + AvaloniaProperty.Register(nameof(GeometryCombineMode)); + + /// + /// Initializes a new instance of the class. + /// + public CombinedGeometry() + { + } + + /// + /// Initializes a new instance of the class with the + /// specified objects. + /// + /// The first geometry to combine. + /// The second geometry to combine. + public CombinedGeometry(Geometry geometry1, Geometry geometry2) + { + Geometry1 = geometry1; + Geometry2 = geometry2; + } + + /// + /// Initializes a new instance of the class with the + /// specified objects and . + /// + /// The method by which geometry1 and geometry2 are combined. + /// The first geometry to combine. + /// The second geometry to combine. + public CombinedGeometry(GeometryCombineMode combineMode, Geometry? geometry1, Geometry? geometry2) + { + Geometry1 = geometry1; + Geometry2 = geometry2; + GeometryCombineMode = combineMode; + } + + /// + /// Initializes a new instance of the class with the + /// specified objects, and + /// . + /// + /// The method by which geometry1 and geometry2 are combined. + /// The first geometry to combine. + /// The second geometry to combine. + /// The transform applied to the geometry. + public CombinedGeometry( + GeometryCombineMode combineMode, + Geometry? geometry1, + Geometry? geometry2, + Transform? transform) + { + Geometry1 = geometry1; + Geometry2 = geometry2; + GeometryCombineMode = combineMode; + Transform = transform; + } + + /// + /// Gets or sets the first object of this + /// object. + /// + public Geometry? Geometry1 + { + get => GetValue(Geometry1Property); + set => SetValue(Geometry1Property, value); + } + + /// + /// Gets or sets the second object of this + /// object. + /// + public Geometry? Geometry2 + { + get => GetValue(Geometry2Property); + set => SetValue(Geometry2Property, value); + } + + /// + /// Gets or sets the method by which the two geometries (specified by the + /// and properties) are combined. The + /// default value is . + /// + public GeometryCombineMode GeometryCombineMode + { + get => GetValue(GeometryCombineModeProperty); + set => SetValue(GeometryCombineModeProperty, value); + } + + public override Geometry Clone() + { + return new CombinedGeometry(GeometryCombineMode, Geometry1, Geometry2, Transform); + } + + protected override IGeometryImpl? CreateDefiningGeometry() + { + var g1 = Geometry1; + var g2 = Geometry2; + + if (g1 is object && g2 is object) + { + var factory = AvaloniaLocator.Current.GetService(); + return factory.CreateCombinedGeometry(GeometryCombineMode, g1, g2); + } + else if (GeometryCombineMode == GeometryCombineMode.Intersect) + return null; + else if (g1 is object) + return g1.PlatformImpl; + else if (g2 is object) + return g2.PlatformImpl; + else + return null; + } + } +} diff --git a/src/Avalonia.Visuals/Media/GeometryGroup.cs b/src/Avalonia.Visuals/Media/GeometryGroup.cs index a3ce6a9dcd..edbe63d4bb 100644 --- a/src/Avalonia.Visuals/Media/GeometryGroup.cs +++ b/src/Avalonia.Visuals/Media/GeometryGroup.cs @@ -50,7 +50,7 @@ namespace Avalonia.Media public override Geometry Clone() { - var result = new GeometryGroup { FillRule = FillRule }; + var result = new GeometryGroup { FillRule = FillRule, Transform = Transform }; if (_children?.Count > 0) result.Children = new GeometryCollection(_children); return result; diff --git a/src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs b/src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs index 2cc44681d5..772f1ac9f3 100644 --- a/src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs +++ b/src/Avalonia.Visuals/Platform/IPlatformRenderInterface.cs @@ -67,6 +67,15 @@ namespace Avalonia.Platform /// A combined geometry. IGeometryImpl CreateGeometryGroup(FillRule fillRule, IReadOnlyList children); + /// + /// Creates a geometry group implementation. + /// + /// The combine mode + /// The first geometry. + /// The second geometry. + /// A combined geometry. + IGeometryImpl CreateCombinedGeometry(GeometryCombineMode combineMode, Geometry g1, Geometry g2); + /// /// Creates a renderer. /// diff --git a/src/Skia/Avalonia.Skia/CombinedGeometryImpl.cs b/src/Skia/Avalonia.Skia/CombinedGeometryImpl.cs new file mode 100644 index 0000000000..40d7e10ae3 --- /dev/null +++ b/src/Skia/Avalonia.Skia/CombinedGeometryImpl.cs @@ -0,0 +1,35 @@ +using System.Collections.Generic; +using Avalonia.Media; +using SkiaSharp; + +#nullable enable + +namespace Avalonia.Skia +{ + /// + /// A Skia implementation of a . + /// + internal class CombinedGeometryImpl : GeometryImpl + { + public CombinedGeometryImpl(GeometryCombineMode combineMode, Geometry g1, Geometry g2) + { + var path1 = ((GeometryImpl)g1.PlatformImpl).EffectivePath; + var path2 = ((GeometryImpl)g2.PlatformImpl).EffectivePath; + var op = combineMode switch + { + GeometryCombineMode.Intersect => SKPathOp.Intersect, + GeometryCombineMode.Xor => SKPathOp.Xor, + GeometryCombineMode.Exclude => SKPathOp.Difference, + _ => SKPathOp.Union, + }; + + var path = path1.Op(path2, op); + + EffectivePath = path; + Bounds = path.Bounds.ToAvaloniaRect(); + } + + public override Rect Bounds { get; } + public override SKPath EffectivePath { get; } + } +} diff --git a/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs b/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs index 447d683a2c..e2175f1145 100644 --- a/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs +++ b/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs @@ -67,6 +67,11 @@ namespace Avalonia.Skia return new GeometryGroupImpl(fillRule, children); } + public IGeometryImpl CreateCombinedGeometry(GeometryCombineMode combineMode, Geometry g1, Geometry g2) + { + return new CombinedGeometryImpl(combineMode, g1, g2); + } + /// public IBitmapImpl LoadBitmap(string fileName) { diff --git a/src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs b/src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs index 91f81223a0..eef4416101 100644 --- a/src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs +++ b/src/Windows/Avalonia.Direct2D1/Direct2D1Platform.cs @@ -176,6 +176,7 @@ namespace Avalonia.Direct2D1 public IGeometryImpl CreateRectangleGeometry(Rect rect) => new RectangleGeometryImpl(rect); public IStreamGeometryImpl CreateStreamGeometry() => new StreamGeometryImpl(); public IGeometryImpl CreateGeometryGroup(FillRule fillRule, IReadOnlyList children) => new GeometryGroupImpl(fillRule, children); + public IGeometryImpl CreateCombinedGeometry(GeometryCombineMode combineMode, Geometry g1, Geometry g2) => new CombinedGeometryImpl(combineMode, g1, g2); /// public IBitmapImpl LoadBitmap(string fileName) diff --git a/src/Windows/Avalonia.Direct2D1/Media/CombinedGeometryImpl.cs b/src/Windows/Avalonia.Direct2D1/Media/CombinedGeometryImpl.cs new file mode 100644 index 0000000000..5a13c10bbc --- /dev/null +++ b/src/Windows/Avalonia.Direct2D1/Media/CombinedGeometryImpl.cs @@ -0,0 +1,36 @@ +using SharpDX.Direct2D1; +using AM = Avalonia.Media; + +namespace Avalonia.Direct2D1.Media +{ + /// + /// A Direct2D implementation of a . + /// + internal class CombinedGeometryImpl : GeometryImpl + { + /// + /// Initializes a new instance of the class. + /// + public CombinedGeometryImpl( + AM.GeometryCombineMode combineMode, + AM.Geometry geometry1, + AM.Geometry geometry2) + : base(CreateGeometry(combineMode, geometry1, geometry2)) + { + } + + private static Geometry CreateGeometry( + AM.GeometryCombineMode combineMode, + AM.Geometry geometry1, + AM.Geometry geometry2) + { + var g1 = ((GeometryImpl)geometry1.PlatformImpl).Geometry; + var g2 = ((GeometryImpl)geometry2.PlatformImpl).Geometry; + var dest = new PathGeometry(Direct2D1Platform.Direct2D1Factory); + using var sink = dest.Open(); + g1.Combine(g2, (CombineMode)combineMode, sink); + sink.Close(); + return dest; + } + } +} diff --git a/tests/Avalonia.Benchmarks/NullRenderingPlatform.cs b/tests/Avalonia.Benchmarks/NullRenderingPlatform.cs index fd8dd3ff94..3e11c74e1c 100644 --- a/tests/Avalonia.Benchmarks/NullRenderingPlatform.cs +++ b/tests/Avalonia.Benchmarks/NullRenderingPlatform.cs @@ -41,6 +41,11 @@ namespace Avalonia.Benchmarks throw new NotImplementedException(); } + public IGeometryImpl CreateCombinedGeometry(GeometryCombineMode combineMode, Geometry g1, Geometry g2) + { + throw new NotImplementedException(); + } + public IRenderTarget CreateRenderTarget(IEnumerable surfaces) { throw new NotImplementedException(); diff --git a/tests/Avalonia.RenderTests/Media/CombinedGeometryTests.cs b/tests/Avalonia.RenderTests/Media/CombinedGeometryTests.cs new file mode 100644 index 0000000000..0469dec651 --- /dev/null +++ b/tests/Avalonia.RenderTests/Media/CombinedGeometryTests.cs @@ -0,0 +1,56 @@ +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Controls.Shapes; +using Avalonia.Media; +using Xunit; + +#if AVALONIA_SKIA +namespace Avalonia.Skia.RenderTests +#else +namespace Avalonia.Direct2D1.RenderTests.Media +#endif +{ + public class CombinedGeometryTests : TestBase + { + public CombinedGeometryTests() + : base(@"Media\CombinedGeometry") + { + } + + [Theory] + [InlineData(Avalonia.Media.GeometryCombineMode.Union)] + [InlineData(Avalonia.Media.GeometryCombineMode.Intersect)] + [InlineData(Avalonia.Media.GeometryCombineMode.Xor)] + [InlineData(Avalonia.Media.GeometryCombineMode.Exclude)] + public async Task GeometryCombineMode(GeometryCombineMode mode) + { + var target = new Border + { + Width = 200, + Height = 200, + Background = Brushes.White, + Child = new Path + { + Data = new CombinedGeometry + { + GeometryCombineMode = mode, + Geometry1 = new RectangleGeometry(new Rect(25, 25, 100, 100)), + Geometry2 = new EllipseGeometry + { + Center = new Point(125, 125), + RadiusX = 50, + RadiusY = 50, + } + }, + Fill = Brushes.Blue, + Stroke = Brushes.Red, + StrokeThickness = 1, + } + }; + + var testName = $"GeometryCombineMode_{mode}"; + await RenderToFile(target, testName); + CompareImages(testName); + } + } +} diff --git a/tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs b/tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs index 34697b8616..1f632034be 100644 --- a/tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs +++ b/tests/Avalonia.UnitTests/MockPlatformRenderInterface.cs @@ -57,6 +57,11 @@ namespace Avalonia.UnitTests return Mock.Of(); } + public IGeometryImpl CreateCombinedGeometry(GeometryCombineMode combineMode, Geometry g1, Geometry g2) + { + return Mock.Of(); + } + public IWriteableBitmapImpl CreateWriteableBitmap( PixelSize size, Vector dpi, diff --git a/tests/Avalonia.Visuals.UnitTests/VisualTree/MockRenderInterface.cs b/tests/Avalonia.Visuals.UnitTests/VisualTree/MockRenderInterface.cs index dded3caa88..229bb8aef3 100644 --- a/tests/Avalonia.Visuals.UnitTests/VisualTree/MockRenderInterface.cs +++ b/tests/Avalonia.Visuals.UnitTests/VisualTree/MockRenderInterface.cs @@ -42,6 +42,11 @@ namespace Avalonia.Visuals.UnitTests.VisualTree throw new NotImplementedException(); } + public IGeometryImpl CreateCombinedGeometry(GeometryCombineMode combineMode, Geometry g1, Geometry g2) + { + throw new NotImplementedException(); + } + public IBitmapImpl LoadBitmap(Stream stream) { throw new NotImplementedException(); diff --git a/tests/TestFiles/Direct2D1/Media/CombinedGeometry/GeometryCombineMode_Exclude.expected.png b/tests/TestFiles/Direct2D1/Media/CombinedGeometry/GeometryCombineMode_Exclude.expected.png new file mode 100644 index 0000000000..2c4aa99eeb Binary files /dev/null and b/tests/TestFiles/Direct2D1/Media/CombinedGeometry/GeometryCombineMode_Exclude.expected.png differ diff --git a/tests/TestFiles/Direct2D1/Media/CombinedGeometry/GeometryCombineMode_Intersect.expected.png b/tests/TestFiles/Direct2D1/Media/CombinedGeometry/GeometryCombineMode_Intersect.expected.png new file mode 100644 index 0000000000..abb3cea270 Binary files /dev/null and b/tests/TestFiles/Direct2D1/Media/CombinedGeometry/GeometryCombineMode_Intersect.expected.png differ diff --git a/tests/TestFiles/Direct2D1/Media/CombinedGeometry/GeometryCombineMode_Union.expected.png b/tests/TestFiles/Direct2D1/Media/CombinedGeometry/GeometryCombineMode_Union.expected.png new file mode 100644 index 0000000000..f431ef4403 Binary files /dev/null and b/tests/TestFiles/Direct2D1/Media/CombinedGeometry/GeometryCombineMode_Union.expected.png differ diff --git a/tests/TestFiles/Direct2D1/Media/CombinedGeometry/GeometryCombineMode_Xor.expected.png b/tests/TestFiles/Direct2D1/Media/CombinedGeometry/GeometryCombineMode_Xor.expected.png new file mode 100644 index 0000000000..4dd547c4bf Binary files /dev/null and b/tests/TestFiles/Direct2D1/Media/CombinedGeometry/GeometryCombineMode_Xor.expected.png differ diff --git a/tests/TestFiles/Skia/Media/CombinedGeometry/GeometryCombineMode_Exclude.expected.png b/tests/TestFiles/Skia/Media/CombinedGeometry/GeometryCombineMode_Exclude.expected.png new file mode 100644 index 0000000000..2c4aa99eeb Binary files /dev/null and b/tests/TestFiles/Skia/Media/CombinedGeometry/GeometryCombineMode_Exclude.expected.png differ diff --git a/tests/TestFiles/Skia/Media/CombinedGeometry/GeometryCombineMode_Intersect.expected.png b/tests/TestFiles/Skia/Media/CombinedGeometry/GeometryCombineMode_Intersect.expected.png new file mode 100644 index 0000000000..abb3cea270 Binary files /dev/null and b/tests/TestFiles/Skia/Media/CombinedGeometry/GeometryCombineMode_Intersect.expected.png differ diff --git a/tests/TestFiles/Skia/Media/CombinedGeometry/GeometryCombineMode_Union.expected.png b/tests/TestFiles/Skia/Media/CombinedGeometry/GeometryCombineMode_Union.expected.png new file mode 100644 index 0000000000..f431ef4403 Binary files /dev/null and b/tests/TestFiles/Skia/Media/CombinedGeometry/GeometryCombineMode_Union.expected.png differ diff --git a/tests/TestFiles/Skia/Media/CombinedGeometry/GeometryCombineMode_Xor.expected.png b/tests/TestFiles/Skia/Media/CombinedGeometry/GeometryCombineMode_Xor.expected.png new file mode 100644 index 0000000000..4dd547c4bf Binary files /dev/null and b/tests/TestFiles/Skia/Media/CombinedGeometry/GeometryCombineMode_Xor.expected.png differ