Browse Source

Merge pull request #12724 from AvaloniaUI/feature/widened-geometry

Implemented Geometry.GetWidenedGeometry.
pull/12750/head
Steven Kirk 2 years ago
committed by GitHub
parent
commit
c8da11fa0d
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 24
      src/Avalonia.Base/Media/Geometry.cs
  2. 19
      src/Avalonia.Base/Media/ImmutableGeometry.cs
  3. 8
      src/Avalonia.Base/Platform/IGeometryImpl.cs
  4. 149
      src/Avalonia.Base/Utilities/HashCode.cs
  5. 2
      src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs
  6. 22
      src/Skia/Avalonia.Skia/DrawingContextImpl.cs
  7. 64
      src/Skia/Avalonia.Skia/GeometryImpl.cs
  8. 28
      src/Skia/Avalonia.Skia/Helpers/DrawingContextHelper.cs
  9. 38
      src/Skia/Avalonia.Skia/Helpers/PenHelper.cs
  10. 68
      src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs
  11. 17
      src/Windows/Avalonia.Direct2D1/Media/GeometryImpl.cs
  12. 50
      tests/Avalonia.RenderTests/Shapes/PathTests.cs
  13. BIN
      tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line.expected.png
  14. BIN
      tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line_Dash.expected.png
  15. BIN
      tests/TestFiles/Skia/Shapes/Path/GetWidenedPathGeometry_Line.expected.png
  16. BIN
      tests/TestFiles/Skia/Shapes/Path/GetWidenedPathGeometry_Line_Dash.expected.png

24
src/Avalonia.Base/Media/Geometry.cs

@ -21,6 +21,7 @@ namespace Avalonia.Media
AvaloniaProperty.Register<Geometry, Transform?>(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;
}
/// <summary>
/// Raised when the geometry changes.
/// </summary>
@ -118,6 +124,17 @@ namespace Avalonia.Media
return PlatformImpl?.StrokeContains(pen, point) == true;
}
/// <summary>
/// Gets a <see cref="Geometry"/> that is the shape defined by the stroke on the Geometry
/// produced by the specified Pen.
/// </summary>
/// <param name="pen">The pen to use.</param>
/// <returns>The outlined geometry.</returns>
public Geometry GetWidenedGeometry(IPen pen)
{
return new ImmutableGeometry(PlatformImpl?.GetWidenedGeometry(pen));
}
/// <summary>
/// Marks a property as affecting the geometry's <see cref="PlatformImpl"/>.
/// </summary>
@ -146,6 +163,9 @@ namespace Avalonia.Media
/// </summary>
protected void InvalidateGeometry()
{
if (!_canInvaldate)
return;
_isDirty = true;
_platformImpl = null;
Changed?.Invoke(this, EventArgs.Empty);

19
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;
}
}

8
src/Avalonia.Base/Platform/IGeometryImpl.cs

@ -28,6 +28,14 @@ namespace Avalonia.Platform
/// <returns>The bounding rectangle.</returns>
Rect GetRenderBounds(IPen? pen);
/// <summary>
/// Gets a geometry that is the shape defined by the stroke on the geometry
/// produced by the specified Pen.
/// </summary>
/// <param name="pen">The pen to use.</param>
/// <returns>The outlined geometry.</returns>
IGeometryImpl GetWidenedGeometry(IPen pen);
/// <summary>
/// Indicates whether the geometry's fill contains the specified point.
/// </summary>

149
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>(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

2
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;

22
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;
@ -1268,25 +1269,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;

64
src/Skia/Avalonia.Skia/GeometryImpl.cs

@ -2,6 +2,8 @@ using System;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Skia.Helpers;
using Avalonia.Utilities;
using SkiaSharp;
namespace Avalonia.Skia
@ -75,6 +77,19 @@ namespace Avalonia.Skia
return _pathCache.RenderBounds;
}
public IGeometryImpl GetWidenedGeometry(IPen pen)
{
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.
var closed = SKPathHelper.CreateClosedPath(path);
return new StreamGeometryImpl(closed, closed);
}
return new StreamGeometryImpl(new SKPath(), null);
}
/// <inheritdoc />
public ITransformedGeometryImpl WithTransform(Matrix transform)
{
@ -143,58 +158,36 @@ namespace Avalonia.Skia
_pathCache = default;
}
private struct PathCache
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)
{
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;
}
_penHash = penHash;
_path?.Dispose();
var paint = SKPaintCache.Shared.Get();
paint.IsStroke = true;
paint.StrokeWidth = (float)_width;
paint.StrokeCap = cap.ToSKStrokeCap();
paint.StrokeJoin = join.ToSKStrokeJoin();
paint.StrokeMiter = (float)miterLimit;
_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()
@ -202,7 +195,6 @@ namespace Avalonia.Skia
_path?.Dispose();
_path = null;
}
}
}
}

28
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;
}
}
}

38
src/Skia/Avalonia.Skia/Helpers/PenHelper.cs

@ -0,0 +1,38 @@
using System;
using Avalonia.Media;
namespace Avalonia.Skia.Helpers;
internal static class PenHelper
{
/// <summary>
/// Gets a hash code for a pen, optionally including the brush.
/// </summary>
/// <param name="pen">The pen.</param>
/// <param name="includeBrush">Whether to include the brush in the hash code.</param>
/// <returns>The hash code.</returns>
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();
}
}

68
src/Skia/Avalonia.Skia/Helpers/SKPathHelper.cs

@ -0,0 +1,68 @@
using System;
using Avalonia.Media;
using Avalonia.Utilities;
using SkiaSharp;
namespace Avalonia.Skia.Helpers;
internal static class SKPathHelper
{
/// <summary>
/// Creates a new path that is a closed version of the source path.
/// </summary>
/// <param name="path">The source path.</param>
/// <returns>A closed path.</returns>
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;
}
/// <summary>
/// Creates a path that is the result of a pen being applied to the stroke of the given path.
/// </summary>
/// <param name="path">The path to stroke.</param>
/// <param name="pen">The pen to use to stroke the path.</param>
/// <returns>The resulting path, or null if the pen has 0 thickness.</returns>
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);
paint.PathEffect?.Dispose();
SKPaintCache.Shared.ReturnReset(paint);
return result;
}
}

17
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);
}
/// <inheritdoc/>
public bool FillContains(Point point)
{

50
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();
}
}
}

BIN
tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
tests/TestFiles/Direct2D1/Shapes/Path/GetWidenedPathGeometry_Line_Dash.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

BIN
tests/TestFiles/Skia/Shapes/Path/GetWidenedPathGeometry_Line.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

BIN
tests/TestFiles/Skia/Shapes/Path/GetWidenedPathGeometry_Line_Dash.expected.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Loading…
Cancel
Save