diff --git a/src/Avalonia.Base/Media/Geometry.cs b/src/Avalonia.Base/Media/Geometry.cs index a66cd616a3..d8fa3bb07f 100644 --- a/src/Avalonia.Base/Media/Geometry.cs +++ b/src/Avalonia.Base/Media/Geometry.cs @@ -21,6 +21,7 @@ namespace Avalonia.Media AvaloniaProperty.Register(nameof(Transform)); private bool _isDirty = true; + private bool _canInvaldate = true; private IGeometryImpl? _platformImpl; static Geometry() @@ -30,9 +31,14 @@ namespace Avalonia.Media internal Geometry() { - } - + + private protected Geometry(IGeometryImpl? platformImpl) + { + _platformImpl = platformImpl; + _isDirty = _canInvaldate = false; + } + /// /// Raised when the geometry changes. /// @@ -118,6 +124,17 @@ namespace Avalonia.Media return PlatformImpl?.StrokeContains(pen, point) == true; } + /// + /// Gets a that is the shape defined by the stroke on the Geometry + /// produced by the specified Pen. + /// + /// The pen to use. + /// The outlined geometry. + public Geometry GetWidenedGeometry(IPen pen) + { + return new ImmutableGeometry(PlatformImpl?.GetWidenedGeometry(pen)); + } + /// /// Marks a property as affecting the geometry's . /// @@ -146,6 +163,9 @@ namespace Avalonia.Media /// protected void InvalidateGeometry() { + if (!_canInvaldate) + return; + _isDirty = true; _platformImpl = null; Changed?.Invoke(this, EventArgs.Empty); diff --git a/src/Avalonia.Base/Media/ImmutableGeometry.cs b/src/Avalonia.Base/Media/ImmutableGeometry.cs new file mode 100644 index 0000000000..ba16329abb --- /dev/null +++ b/src/Avalonia.Base/Media/ImmutableGeometry.cs @@ -0,0 +1,19 @@ +using System; +using Avalonia.Platform; + +namespace Avalonia.Media; + +internal class ImmutableGeometry : Geometry +{ + public ImmutableGeometry(IGeometryImpl? platformImpl) + : base(platformImpl) + { + } + + public override Geometry Clone() => new ImmutableGeometry(PlatformImpl); + + private protected override IGeometryImpl? CreateDefiningGeometry() + { + return PlatformImpl; + } +} diff --git a/src/Avalonia.Base/Platform/IGeometryImpl.cs b/src/Avalonia.Base/Platform/IGeometryImpl.cs index d1964bf07e..0d1e7b972c 100644 --- a/src/Avalonia.Base/Platform/IGeometryImpl.cs +++ b/src/Avalonia.Base/Platform/IGeometryImpl.cs @@ -28,6 +28,14 @@ namespace Avalonia.Platform /// The bounding rectangle. Rect GetRenderBounds(IPen? pen); + /// + /// Gets a geometry that is the shape defined by the stroke on the geometry + /// produced by the specified Pen. + /// + /// The pen to use. + /// The outlined geometry. + IGeometryImpl GetWidenedGeometry(IPen pen); + /// /// Indicates whether the geometry's fill contains the specified point. /// diff --git a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs index 7293874671..a7a2b486b4 100644 --- a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs +++ b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs @@ -182,6 +182,8 @@ namespace Avalonia.Headless return Bounds.Inflate(pen.Thickness / 2); } + public IGeometryImpl GetWidenedGeometry(IPen pen) => this; + public bool StrokeContains(IPen? pen, Point point) { return false; diff --git a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs index 76d236e18a..9260e102ee 100644 --- a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs +++ b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs @@ -6,6 +6,7 @@ using System.Threading; using Avalonia.Media; using Avalonia.Platform; using Avalonia.Rendering.Utilities; +using Avalonia.Skia.Helpers; using Avalonia.Utilities; using SkiaSharp; using ISceneBrush = Avalonia.Media.ISceneBrush; @@ -1252,25 +1253,10 @@ namespace Avalonia.Skia paint.StrokeMiter = (float) pen.MiterLimit; - if (pen.DashStyle?.Dashes != null && pen.DashStyle.Dashes.Count > 0) + if (DrawingContextHelper.TryCreateDashEffect(pen, out var dashEffect)) { - var srcDashes = pen.DashStyle.Dashes; - - var count = srcDashes.Count % 2 == 0 ? srcDashes.Count : srcDashes.Count * 2; - - var dashesArray = new float[count]; - - for (var i = 0; i < count; ++i) - { - dashesArray[i] = (float) srcDashes[i % srcDashes.Count] * paint.StrokeWidth; - } - - var offset = (float)(pen.DashStyle.Offset * pen.Thickness); - - var pe = SKPathEffect.CreateDash(dashesArray, offset); - - paint.PathEffect = pe; - rv.AddDisposable(pe); + paint.PathEffect = dashEffect; + rv.AddDisposable(dashEffect); } return rv; diff --git a/src/Skia/Avalonia.Skia/GeometryImpl.cs b/src/Skia/Avalonia.Skia/GeometryImpl.cs index c1ce4a661f..0cee36204d 100644 --- a/src/Skia/Avalonia.Skia/GeometryImpl.cs +++ b/src/Skia/Avalonia.Skia/GeometryImpl.cs @@ -2,6 +2,7 @@ using System; using System.Diagnostics.CodeAnalysis; using Avalonia.Media; using Avalonia.Platform; +using Avalonia.Skia.Helpers; using SkiaSharp; namespace Avalonia.Skia @@ -75,6 +76,22 @@ namespace Avalonia.Skia return _pathCache.RenderBounds; } + public IGeometryImpl GetWidenedGeometry(IPen pen) + { + var cache = new PathCache(); + cache.UpdateIfNeeded(StrokePath, pen); + + if (cache.ExpandedPath is { } path) + { + // The path returned to us by skia here does not have closed figures. + // Fix that by calling CreateClosedPath. + var closed = SKPathHelper.CreateClosedPath(path); + return new StreamGeometryImpl(closed, closed); + } + + return new StreamGeometryImpl(new SKPath(), null); + } + /// public ITransformedGeometryImpl WithTransform(Matrix transform) { @@ -191,6 +208,10 @@ namespace Avalonia.Skia paint.StrokeCap = cap.ToSKStrokeCap(); paint.StrokeJoin = join.ToSKStrokeJoin(); paint.StrokeMiter = (float)miterLimit; + + if (DrawingContextHelper.TryCreateDashEffect(pen, out var dashEffect)) + paint.PathEffect = dashEffect; + _path = new SKPath(); paint.GetFillPath(strokePath, _path); diff --git a/src/Skia/Avalonia.Skia/Helpers/DrawingContextHelper.cs b/src/Skia/Avalonia.Skia/Helpers/DrawingContextHelper.cs index 6a726dc9dc..44caa8ae5a 100644 --- a/src/Skia/Avalonia.Skia/Helpers/DrawingContextHelper.cs +++ b/src/Skia/Avalonia.Skia/Helpers/DrawingContextHelper.cs @@ -1,5 +1,6 @@ -using Avalonia.Platform; -using Avalonia.Rendering; +using System.Diagnostics.CodeAnalysis; +using Avalonia.Media; +using Avalonia.Platform; using SkiaSharp; namespace Avalonia.Skia.Helpers @@ -26,5 +27,28 @@ namespace Avalonia.Skia.Helpers return new DrawingContextImpl(createInfo); } + public static bool TryCreateDashEffect(IPen? pen, [NotNullWhen(true)] out SKPathEffect? effect) + { + if (pen?.DashStyle?.Dashes != null && pen.DashStyle.Dashes.Count > 0) + { + var srcDashes = pen.DashStyle.Dashes; + + var count = srcDashes.Count % 2 == 0 ? srcDashes.Count : srcDashes.Count * 2; + + var dashesArray = new float[count]; + + for (var i = 0; i < count; ++i) + { + dashesArray[i] = (float)srcDashes[i % srcDashes.Count] * (float)pen.Thickness; + } + + var offset = (float)(pen.DashStyle.Offset * pen.Thickness); + effect = SKPathEffect.CreateDash(dashesArray, offset); + return true; + } + + effect = null; + return false; + } } } diff --git a/src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs b/src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs new file mode 100644 index 0000000000..3bbb80e305 --- /dev/null +++ b/src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs @@ -0,0 +1,32 @@ +using SkiaSharp; + +namespace Avalonia.Skia.Helpers; + +internal static class SKPathHelper +{ + public static SKPath CreateClosedPath(SKPath path) + { + using var iter = path.CreateIterator(true); + SKPathVerb verb; + var points = new SKPoint[4]; + var rv = new SKPath(); + while ((verb = iter.Next(points)) != SKPathVerb.Done) + { + if (verb == SKPathVerb.Move) + rv.MoveTo(points[0]); + else if (verb == SKPathVerb.Line) + rv.LineTo(points[1]); + else if (verb == SKPathVerb.Close) + rv.Close(); + else if (verb == SKPathVerb.Quad) + rv.QuadTo(points[1], points[2]); + else if (verb == SKPathVerb.Cubic) + rv.CubicTo(points[1], points[2], points[3]); + else if (verb == SKPathVerb.Conic) + rv.ConicTo(points[1], points[2], iter.ConicWeight()); + + } + + return rv; + } +} diff --git a/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs b/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs index e1c08e0814..fec9b37aac 100644 --- a/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs +++ b/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs @@ -47,6 +47,23 @@ namespace Avalonia.Direct2D1.Media } } + public IGeometryImpl GetWidenedGeometry(IPen pen) + { + var result = new PathGeometry(Direct2D1Platform.Direct2D1Factory); + + using (var sink = result.Open()) + { + Geometry.Widen( + (float)pen.Thickness, + pen.ToDirect2DStrokeStyle(Direct2D1Platform.Direct2D1Factory), + 0.25f, + sink); + sink.Close(); + } + + return new StreamGeometryImpl(result); + } + /// public bool FillContains(Point point) { diff --git a/tests/Avalonia.RenderTests/Shapes/PathTests.cs b/tests/Avalonia.RenderTests/Shapes/PathTests.cs index bf375121de..4f1412990b 100644 --- a/tests/Avalonia.RenderTests/Shapes/PathTests.cs +++ b/tests/Avalonia.RenderTests/Shapes/PathTests.cs @@ -434,5 +434,55 @@ namespace Avalonia.Direct2D1.RenderTests.Shapes await RenderToFile(target); CompareImages(); } + + [Fact] + public async Task GetWidenedPathGeometry_Line() + { + var pen = new Pen(Brushes.Black, 10); + var geometry = StreamGeometry.Parse("M 0,0 L 180,180").GetWidenedGeometry(pen); + + Decorator target = new Decorator + { + Width = 200, + Height = 200, + Child = new Path + { + Stroke = Brushes.Red, + StrokeThickness = 1, + Fill = Brushes.Green, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Data = geometry, + } + }; + + await RenderToFile(target); + CompareImages(); + } + + [Fact] + public async Task GetWidenedPathGeometry_Line_Dash() + { + var pen = new Pen(Brushes.Black, 10, DashStyle.Dash); + var geometry = StreamGeometry.Parse("M 0,0 L 180,180").GetWidenedGeometry(pen); + + Decorator target = new Decorator + { + Width = 200, + Height = 200, + Child = new Path + { + Stroke = Brushes.Red, + StrokeThickness = 1, + Fill = Brushes.Green, + HorizontalAlignment = HorizontalAlignment.Center, + VerticalAlignment = VerticalAlignment.Center, + Data = geometry, + } + }; + + await RenderToFile(target); + CompareImages(); + } } } diff --git a/tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line.expected.png b/tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line.expected.png new file mode 100644 index 0000000000..8399b9e790 Binary files /dev/null and b/tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line.expected.png differ diff --git a/tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line_Dash.expected.png b/tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line_Dash.expected.png new file mode 100644 index 0000000000..a09d404883 Binary files /dev/null and b/tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line_Dash.expected.png differ diff --git a/tests/TestFiles/Skia/Shapes/Path/GetWidenedPathGeometry_Line.expected.png b/tests/TestFiles/Skia/Shapes/Path/GetWidenedPathGeometry_Line.expected.png new file mode 100644 index 0000000000..d7e050f689 Binary files /dev/null and b/tests/TestFiles/Skia/Shapes/Path/GetWidenedPathGeometry_Line.expected.png differ diff --git a/tests/TestFiles/Skia/Shapes/Path/GetWidenedPathGeometry_Line_Dash.expected.png b/tests/TestFiles/Skia/Shapes/Path/GetWidenedPathGeometry_Line_Dash.expected.png new file mode 100644 index 0000000000..a09d404883 Binary files /dev/null and b/tests/TestFiles/Skia/Shapes/Path/GetWidenedPathGeometry_Line_Dash.expected.png differ