From 45e5d25ccfe4c99bc4968963a576e1d805348e34 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 30 Aug 2023 14:25:48 +0200 Subject: [PATCH 1/6] Implemented Geometry.GetWidenedGeometry. --- src/Avalonia.Base/Media/Geometry.cs | 24 ++++++++- src/Avalonia.Base/Media/ImmutableGeometry.cs | 19 +++++++ src/Avalonia.Base/Platform/IGeometryImpl.cs | 8 +++ .../HeadlessPlatformRenderInterface.cs | 2 + src/Skia/Avalonia.Skia/DrawingContextImpl.cs | 22 ++------ src/Skia/Avalonia.Skia/GeometryImpl.cs | 21 ++++++++ .../Helpers/DrawingContextHelper.cs | 28 +++++++++- .../Avalonia.Skia/Helpers/SKPathHelper.cs | 32 +++++++++++ .../Avalonia.Direct2D1/Media/GeometryImpl.cs | 17 ++++++ .../Avalonia.RenderTests/Shapes/PathTests.cs | 50 ++++++++++++++++++ .../GetWidenedPathGeometry_Line.expected.png | Bin 0 -> 1440 bytes ...WidenedPathGeometry_Line_Dash.expected.png | Bin 0 -> 2443 bytes .../GetWidenedPathGeometry_Line.expected.png | Bin 0 -> 1370 bytes ...WidenedPathGeometry_Line_Dash.expected.png | Bin 0 -> 2443 bytes 14 files changed, 201 insertions(+), 22 deletions(-) create mode 100644 src/Avalonia.Base/Media/ImmutableGeometry.cs create mode 100644 src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs create mode 100644 tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line.expected.png create mode 100644 tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line_Dash.expected.png create mode 100644 tests/TestFiles/Skia/Shapes/Path/GetWidenedPathGeometry_Line.expected.png create mode 100644 tests/TestFiles/Skia/Shapes/Path/GetWidenedPathGeometry_Line_Dash.expected.png diff --git a/src/Avalonia.Base/Media/Geometry.cs b/src/Avalonia.Base/Media/Geometry.cs index a66cd616a34..d8fa3bb07fa 100644 --- a/src/Avalonia.Base/Media/Geometry.cs +++ b/src/Avalonia.Base/Media/Geometry.cs @@ -21,6 +21,7 @@ public abstract class Geometry : AvaloniaObject AvaloniaProperty.Register(nameof(Transform)); private bool _isDirty = true; + private bool _canInvaldate = true; private IGeometryImpl? _platformImpl; static Geometry() @@ -30,9 +31,14 @@ static Geometry() internal Geometry() { - } - + + private protected Geometry(IGeometryImpl? platformImpl) + { + _platformImpl = platformImpl; + _isDirty = _canInvaldate = false; + } + /// /// Raised when the geometry changes. /// @@ -118,6 +124,17 @@ public bool StrokeContains(IPen pen, Point point) 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 @@ protected static void AffectsGeometry(params AvaloniaProperty[] properties) /// 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 00000000000..ba16329abb6 --- /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 d1964bf07e0..0d1e7b972cc 100644 --- a/src/Avalonia.Base/Platform/IGeometryImpl.cs +++ b/src/Avalonia.Base/Platform/IGeometryImpl.cs @@ -28,6 +28,14 @@ public interface IGeometryImpl /// 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 7293874671f..a7a2b486b4b 100644 --- a/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs +++ b/src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs @@ -182,6 +182,8 @@ public Rect GetRenderBounds(IPen? pen) 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 76d236e18ab..9260e102ee9 100644 --- a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs +++ b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs @@ -6,6 +6,7 @@ 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 @@ internal PaintWrapper CreatePaint(SKPaint paint, IBrush brush, Size targetSize) 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 c1ce4a661f2..0cee36204de 100644 --- a/src/Skia/Avalonia.Skia/GeometryImpl.cs +++ b/src/Skia/Avalonia.Skia/GeometryImpl.cs @@ -2,6 +2,7 @@ using System.Diagnostics.CodeAnalysis; using Avalonia.Media; using Avalonia.Platform; +using Avalonia.Skia.Helpers; using SkiaSharp; namespace Avalonia.Skia @@ -75,6 +76,22 @@ public Rect GetRenderBounds(IPen? pen) 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 @@ public void UpdateIfNeeded(SKPath? strokePath, IPen? pen) 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 6a726dc9dc9..44caa8ae5a0 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 @@ public static IDrawingContextImpl WrapSkiaCanvas(SKCanvas canvas, Vector dpi) 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 00000000000..3bbb80e3054 --- /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 e1c08e0814d..fec9b37aac5 100644 --- a/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs +++ b/src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs @@ -47,6 +47,23 @@ public Rect GetRenderBounds(Avalonia.Media.IPen pen) } } + 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 bf375121de9..4f1412990b7 100644 --- a/tests/Avalonia.RenderTests/Shapes/PathTests.cs +++ b/tests/Avalonia.RenderTests/Shapes/PathTests.cs @@ -434,5 +434,55 @@ public async Task BeginFigure_IsFilled_Is_Respected() 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 0000000000000000000000000000000000000000..8399b9e790d6031461f36fded22720beeba4933e GIT binary patch literal 1440 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 Date: Wed, 30 Aug 2023 17:57:52 +0200 Subject: [PATCH 2/6] Dispose of the PathCache. --- src/Skia/Avalonia.Skia/GeometryImpl.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Skia/Avalonia.Skia/GeometryImpl.cs b/src/Skia/Avalonia.Skia/GeometryImpl.cs index 0cee36204de..4f2e8f30d80 100644 --- a/src/Skia/Avalonia.Skia/GeometryImpl.cs +++ b/src/Skia/Avalonia.Skia/GeometryImpl.cs @@ -78,7 +78,7 @@ public Rect GetRenderBounds(IPen? pen) public IGeometryImpl GetWidenedGeometry(IPen pen) { - var cache = new PathCache(); + using var cache = new PathCache(); cache.UpdateIfNeeded(StrokePath, pen); if (cache.ExpandedPath is { } path) @@ -160,7 +160,7 @@ protected void InvalidateCaches() _pathCache = default; } - private struct PathCache + private struct PathCache : IDisposable { private double _width, _miterLimit; private PenLineCap _cap; From 47edb343fb29fc094504f227f6699f32d42ad811 Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Wed, 30 Aug 2023 18:29:31 +0200 Subject: [PATCH 3/6] Only include dash style when widening bounds. Otherwise we'd be changing the behavior of an existing API (`StrokeContains`) which should be discussed first. --- src/Skia/Avalonia.Skia/GeometryImpl.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Skia/Avalonia.Skia/GeometryImpl.cs b/src/Skia/Avalonia.Skia/GeometryImpl.cs index 4f2e8f30d80..cd6fd76b3c5 100644 --- a/src/Skia/Avalonia.Skia/GeometryImpl.cs +++ b/src/Skia/Avalonia.Skia/GeometryImpl.cs @@ -79,7 +79,7 @@ public Rect GetRenderBounds(IPen? pen) public IGeometryImpl GetWidenedGeometry(IPen pen) { using var cache = new PathCache(); - cache.UpdateIfNeeded(StrokePath, pen); + cache.UpdateIfNeeded(StrokePath, pen, includeDashStyle: true); if (cache.ExpandedPath is { } path) { @@ -173,7 +173,7 @@ private struct PathCache : IDisposable public Rect RenderBounds => _renderBounds ??= (_path ?? _cachedFor ?? s_emptyPath).Bounds.ToAvaloniaRect(); public SKPath ExpandedPath => _path ?? s_emptyPath; - public void UpdateIfNeeded(SKPath? strokePath, IPen? pen) + public void UpdateIfNeeded(SKPath? strokePath, IPen? pen, bool includeDashStyle = false) { var strokeWidth = pen?.Thickness ?? 0; var miterLimit = pen?.MiterLimit ?? 0; @@ -209,7 +209,7 @@ public void UpdateIfNeeded(SKPath? strokePath, IPen? pen) paint.StrokeJoin = join.ToSKStrokeJoin(); paint.StrokeMiter = (float)miterLimit; - if (DrawingContextHelper.TryCreateDashEffect(pen, out var dashEffect)) + if (includeDashStyle && DrawingContextHelper.TryCreateDashEffect(pen, out var dashEffect)) paint.PathEffect = dashEffect; _path = new SKPath(); From 4e011d4fedb75304f4b29eea6f45bf4af553164a Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 31 Aug 2023 11:56:53 +0200 Subject: [PATCH 4/6] Refactor creating a stroked path. - Move the logic into `SKPathHelper.CreateStrokedPath`; called from `PathHelper` and `GetWidenedGeometry` - Use a pen hash code to check if we're up-to-date in `PathHelper` and include the dash style in that --- src/Skia/Avalonia.Skia/GeometryImpl.cs | 59 +++++-------------- src/Skia/Avalonia.Skia/Helpers/PenHelper.cs | 38 ++++++++++++ .../Avalonia.Skia/Helpers/SKPathHelper.cs | 37 +++++++++++- 3 files changed, 89 insertions(+), 45 deletions(-) create mode 100644 src/Skia/Avalonia.Skia/Helpers/PenHelper.cs diff --git a/src/Skia/Avalonia.Skia/GeometryImpl.cs b/src/Skia/Avalonia.Skia/GeometryImpl.cs index cd6fd76b3c5..a5797d7fd57 100644 --- a/src/Skia/Avalonia.Skia/GeometryImpl.cs +++ b/src/Skia/Avalonia.Skia/GeometryImpl.cs @@ -3,6 +3,7 @@ using Avalonia.Media; using Avalonia.Platform; using Avalonia.Skia.Helpers; +using Avalonia.Utilities; using SkiaSharp; namespace Avalonia.Skia @@ -78,10 +79,7 @@ public Rect GetRenderBounds(IPen? pen) public IGeometryImpl GetWidenedGeometry(IPen pen) { - using var cache = new PathCache(); - cache.UpdateIfNeeded(StrokePath, pen, includeDashStyle: true); - - if (cache.ExpandedPath is { } path) + if (StrokePath is not null && SKPathHelper.CreateStrokedPath(StrokePath, pen) is { } path) { // The path returned to us by skia here does not have closed figures. // Fix that by calling CreateClosedPath. @@ -162,60 +160,34 @@ protected void InvalidateCaches() private struct PathCache : IDisposable { - private double _width, _miterLimit; - private PenLineCap _cap; - private PenLineJoin _join; + private int _penHash; private SKPath? _path, _cachedFor; private Rect? _renderBounds; private static readonly SKPath s_emptyPath = new(); - public Rect RenderBounds => _renderBounds ??= (_path ?? _cachedFor ?? s_emptyPath).Bounds.ToAvaloniaRect(); public SKPath ExpandedPath => _path ?? s_emptyPath; - public void UpdateIfNeeded(SKPath? strokePath, IPen? pen, bool includeDashStyle = false) + public void UpdateIfNeeded(SKPath? strokePath, IPen? pen) { - var strokeWidth = pen?.Thickness ?? 0; - var miterLimit = pen?.MiterLimit ?? 0; - var cap = pen?.LineCap ?? default; - var join = pen?.LineJoin ?? default; - - if (_cachedFor == strokePath - && _path != null - && cap == _cap - && join == _join - && Math.Abs(_width - strokeWidth) < float.Epsilon - && (join != PenLineJoin.Miter || Math.Abs(_miterLimit - miterLimit) > float.Epsilon)) + if (PenHelper.GetHashCode(pen, includeBrush: false) is { } penHash && + penHash == _penHash && + strokePath == _cachedFor) + { // We are up to date return; + } _renderBounds = null; _cachedFor = strokePath; - _width = strokeWidth; - _cap = cap; - _join = join; - _miterLimit = miterLimit; - - if (strokePath == null || Math.Abs(strokeWidth) < float.Epsilon) - { - _path = null; - return; - } - - var paint = SKPaintCache.Shared.Get(); - paint.IsStroke = true; - paint.StrokeWidth = (float)_width; - paint.StrokeCap = cap.ToSKStrokeCap(); - paint.StrokeJoin = join.ToSKStrokeJoin(); - paint.StrokeMiter = (float)miterLimit; - - if (includeDashStyle && DrawingContextHelper.TryCreateDashEffect(pen, out var dashEffect)) - paint.PathEffect = dashEffect; + _penHash = penHash; + _path?.Dispose(); - _path = new SKPath(); - paint.GetFillPath(strokePath, _path); + if (strokePath is not null && pen is not null) + _path = SKPathHelper.CreateStrokedPath(strokePath, pen); + else + _path = null; - SKPaintCache.Shared.ReturnReset(paint); } public void Dispose() @@ -223,7 +195,6 @@ public void Dispose() _path?.Dispose(); _path = null; } - } } } diff --git a/src/Skia/Avalonia.Skia/Helpers/PenHelper.cs b/src/Skia/Avalonia.Skia/Helpers/PenHelper.cs new file mode 100644 index 00000000000..2f7b6d771d3 --- /dev/null +++ b/src/Skia/Avalonia.Skia/Helpers/PenHelper.cs @@ -0,0 +1,38 @@ +using System; +using Avalonia.Media; + +namespace Avalonia.Skia.Helpers; + +internal static class PenHelper +{ + /// + /// Gets a hash code for a pen, optionally including the brush. + /// + /// The pen. + /// Whether to include the brush in the hash code. + /// The hash code. + public static int GetHashCode(IPen? pen, bool includeBrush) + { + if (pen is null) + return 0; + + var hash = new HashCode(); + hash.Add(pen.LineCap); + hash.Add(pen.LineJoin); + hash.Add(pen.MiterLimit); + hash.Add(pen.Thickness); + + if (pen.DashStyle is { } dashStyle) + { + hash.Add(dashStyle.Offset); + + for (var i = 0; i < dashStyle.Dashes?.Count; i++) + hash.Add(dashStyle.Dashes[i]); + } + + if (includeBrush) + hash.Add(pen.Brush); + + return hash.ToHashCode(); + } +} diff --git a/src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs b/src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs index 3bbb80e3054..228d68a4d83 100644 --- a/src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs +++ b/src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs @@ -1,9 +1,17 @@ -using SkiaSharp; +using System; +using Avalonia.Media; +using Avalonia.Utilities; +using SkiaSharp; namespace Avalonia.Skia.Helpers; internal static class SKPathHelper { + /// + /// Creates a new path that is a closed version of the source path. + /// + /// The source path. + /// A closed path. public static SKPath CreateClosedPath(SKPath path) { using var iter = path.CreateIterator(true); @@ -29,4 +37,31 @@ public static SKPath CreateClosedPath(SKPath path) return rv; } + + /// + /// Creates a path that is the result of a pen being applied to the stroke of the given path. + /// + /// The path to stroke. + /// The pen to use to stroke the path. + /// The resulting path, or null if the pen has 0 thickness. + public static SKPath? CreateStrokedPath(SKPath path, IPen pen) + { + if (MathUtilities.IsZero(pen.Thickness)) + return null; + + var paint = SKPaintCache.Shared.Get(); + paint.IsStroke = true; + paint.StrokeWidth = (float)pen.Thickness; + paint.StrokeCap = pen.LineCap.ToSKStrokeCap(); + paint.StrokeJoin = pen.LineJoin.ToSKStrokeJoin(); + paint.StrokeMiter = (float)pen.MiterLimit; + + if (DrawingContextHelper.TryCreateDashEffect(pen, out var dashEffect)) + paint.PathEffect = dashEffect; + + var result = new SKPath(); + paint.GetFillPath(path, result); + SKPaintCache.Shared.ReturnReset(paint); + return result; + } } From 568613d21d271010572d1c9fee7b10dc4fc0708f Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 31 Aug 2023 12:25:11 +0200 Subject: [PATCH 5/6] Added polyfil of System.HashCode. --- src/Avalonia.Base/Utilities/HashCode.cs | 149 ++++++++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 src/Avalonia.Base/Utilities/HashCode.cs diff --git a/src/Avalonia.Base/Utilities/HashCode.cs b/src/Avalonia.Base/Utilities/HashCode.cs new file mode 100644 index 00000000000..c68c42a9c76 --- /dev/null +++ b/src/Avalonia.Base/Utilities/HashCode.cs @@ -0,0 +1,149 @@ +// Taken from: +// https://github.com/mono/SkiaSharp/blob/main/binding/Binding.Shared/HashCode.cs +// Partial code copied from: +// https://github.com/dotnet/runtime/blob/6072e4d3a7a2a1493f514cdf4be75a3d56580e84/src/libraries/System.Private.CoreLib/src/System/HashCode.cs + +#if NETSTANDARD2_0 +#nullable disable + +using System.Runtime.CompilerServices; + +namespace System; + +internal unsafe struct HashCode +{ + private static readonly uint s_seed = GenerateGlobalSeed(); + + private const uint Prime1 = 2654435761U; + private const uint Prime2 = 2246822519U; + private const uint Prime3 = 3266489917U; + private const uint Prime4 = 668265263U; + private const uint Prime5 = 374761393U; + + private uint _v1, _v2, _v3, _v4; + private uint _queue1, _queue2, _queue3; + private uint _length; + + private static unsafe uint GenerateGlobalSeed() + { + var rnd = new Random(); + var result = rnd.Next(); + return unchecked((uint)result); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static void Initialize(out uint v1, out uint v2, out uint v3, out uint v4) + { + v1 = s_seed + Prime1 + Prime2; + v2 = s_seed + Prime2; + v3 = s_seed; + v4 = s_seed - Prime1; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint Round(uint hash, uint input) => + RotateLeft(hash + input * Prime2, 13) * Prime1; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint QueueRound(uint hash, uint queuedValue) => + RotateLeft(hash + queuedValue * Prime3, 17) * Prime4; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint MixState(uint v1, uint v2, uint v3, uint v4) => + RotateLeft(v1, 1) + RotateLeft(v2, 7) + RotateLeft(v3, 12) + RotateLeft(v4, 18); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint RotateLeft(uint value, int offset) => + (value << offset) | (value >> (32 - offset)); + + private static uint MixEmptyState() => + s_seed + Prime5; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static uint MixFinal(uint hash) + { + hash ^= hash >> 15; + hash *= Prime2; + hash ^= hash >> 13; + hash *= Prime3; + hash ^= hash >> 16; + return hash; + } + + public void Add(void* value) => + Add(value == null ? 0 : ((IntPtr)value).GetHashCode()); + + public void Add(T value) => + Add(value?.GetHashCode() ?? 0); + + private void Add(int value) + { + uint val = (uint)value; + + // Storing the value of _length locally shaves of quite a few bytes + // in the resulting machine code. + uint previousLength = _length++; + uint position = previousLength % 4; + + // Switch can't be inlined. + + if (position == 0) + _queue1 = val; + else if (position == 1) + _queue2 = val; + else if (position == 2) + _queue3 = val; + else // position == 3 + { + if (previousLength == 3) + Initialize(out _v1, out _v2, out _v3, out _v4); + + _v1 = Round(_v1, _queue1); + _v2 = Round(_v2, _queue2); + _v3 = Round(_v3, _queue3); + _v4 = Round(_v4, val); + } + } + + public int ToHashCode() + { + // Storing the value of _length locally shaves of quite a few bytes + // in the resulting machine code. + uint length = _length; + + // position refers to the *next* queue position in this method, so + // position == 1 means that _queue1 is populated; _queue2 would have + // been populated on the next call to Add. + uint position = length % 4; + + // If the length is less than 4, _v1 to _v4 don't contain anything + // yet. xxHash32 treats this differently. + + uint hash = length < 4 ? MixEmptyState() : MixState(_v1, _v2, _v3, _v4); + + // _length is incremented once per Add(Int32) and is therefore 4 + // times too small (xxHash length is in bytes, not ints). + + hash += length * 4; + + // Mix what remains in the queue + + // Switch can't be inlined right now, so use as few branches as + // possible by manually excluding impossible scenarios (position > 1 + // is always false if position is not > 0). + if (position > 0) + { + hash = QueueRound(hash, _queue1); + if (position > 1) + { + hash = QueueRound(hash, _queue2); + if (position > 2) + hash = QueueRound(hash, _queue3); + } + } + + hash = MixFinal(hash); + return (int)hash; + } +} +#endif From 23a676dbdd1025d7aa470021a6f851acb6961bbe Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Thu, 31 Aug 2023 13:20:31 +0200 Subject: [PATCH 6/6] Dispose path effect. --- src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs b/src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs index 228d68a4d83..e4584cc858d 100644 --- a/src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs +++ b/src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs @@ -61,6 +61,7 @@ public static SKPath CreateClosedPath(SKPath path) var result = new SKPath(); paint.GetFillPath(path, result); + paint.PathEffect?.Dispose(); SKPaintCache.Shared.ReturnReset(paint); return result; }