diff --git a/src/Skia/Avalonia.Skia/CombinedGeometryImpl.cs b/src/Skia/Avalonia.Skia/CombinedGeometryImpl.cs index 170cc9d420..1a4f467f40 100644 --- a/src/Skia/Avalonia.Skia/CombinedGeometryImpl.cs +++ b/src/Skia/Avalonia.Skia/CombinedGeometryImpl.cs @@ -8,11 +8,25 @@ namespace Avalonia.Skia /// internal class CombinedGeometryImpl : GeometryImpl { - public CombinedGeometryImpl(GeometryCombineMode combineMode, Geometry g1, Geometry g2) + public CombinedGeometryImpl(SKPath? stroke, SKPath? fill) { - var path1 = (g1.PlatformImpl as GeometryImpl)?.EffectivePath; - var path2 = (g2.PlatformImpl as GeometryImpl)?.EffectivePath; + StrokePath = stroke; + FillPath = fill; + Bounds = (stroke ?? fill)?.TightBounds.ToAvaloniaRect() ?? default; + } + + public static CombinedGeometryImpl ForceCreate(GeometryCombineMode combineMode, Geometry g1, Geometry g2) + { + if (g1.PlatformImpl is GeometryImpl i1 + && g2.PlatformImpl is GeometryImpl i2 + && TryCreate(combineMode, i1, i2) is { } result) + return result; + + return new(null, null); + } + public static CombinedGeometryImpl? TryCreate(GeometryCombineMode combineMode, GeometryImpl g1, GeometryImpl g2) + { var op = combineMode switch { GeometryCombineMode.Intersect => SKPathOp.Intersect, @@ -21,13 +35,28 @@ namespace Avalonia.Skia _ => SKPathOp.Union }; - var path = path1?.Op(path2, op); + var stroke = + g1.StrokePath != null && g2.StrokePath != null + ? g1.StrokePath.Op(g2.StrokePath, op) + : null; + + SKPath? fill = null; + if (g1.FillPath != null && g2.FillPath != null) + { + // Reuse stroke if fill paths are the same + if (ReferenceEquals(g1.FillPath, g1.StrokePath) && ReferenceEquals(g2.FillPath, g2.StrokePath)) + fill = stroke; + else + fill = g1.FillPath.Op(g2.FillPath, op); + } - EffectivePath = path; - Bounds = path?.Bounds.ToAvaloniaRect() ?? default; + if (stroke == null && fill == null) + return null; + return new CombinedGeometryImpl(stroke, fill); } public override Rect Bounds { get; } - public override SKPath? EffectivePath { get; } + public override SKPath? StrokePath { get; } + public override SKPath? FillPath { get; } } } diff --git a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs index db7b068543..af0231579c 100644 --- a/src/Skia/Avalonia.Skia/DrawingContextImpl.cs +++ b/src/Skia/Avalonia.Skia/DrawingContextImpl.cs @@ -230,20 +230,21 @@ namespace Avalonia.Skia var impl = (GeometryImpl) geometry; var size = geometry.Bounds.Size; - if (brush is not null) + if (brush is not null && impl.FillPath != null) { using (var fill = CreatePaint(_fillPaint, brush, size)) { - Canvas.DrawPath(impl.EffectivePath, fill.Paint); + Canvas.DrawPath(impl.FillPath, fill.Paint); } } if (pen is not null + && impl.StrokePath != null && TryCreatePaint(_strokePaint, pen, size.Inflate(new Thickness(pen.Thickness / 2))) is { } stroke) { using (stroke) { - Canvas.DrawPath(impl.EffectivePath, stroke.Paint); + Canvas.DrawPath(impl.StrokePath, stroke.Paint); } } } @@ -639,7 +640,7 @@ namespace Avalonia.Skia { CheckLease(); Canvas.Save(); - Canvas.ClipPath(((GeometryImpl)clip).EffectivePath, SKClipOperation.Intersect, true); + Canvas.ClipPath(((GeometryImpl)clip).FillPath, SKClipOperation.Intersect, true); } /// diff --git a/src/Skia/Avalonia.Skia/EllipseGeometryImpl.cs b/src/Skia/Avalonia.Skia/EllipseGeometryImpl.cs index ac05691c67..64976d4eea 100644 --- a/src/Skia/Avalonia.Skia/EllipseGeometryImpl.cs +++ b/src/Skia/Avalonia.Skia/EllipseGeometryImpl.cs @@ -8,14 +8,15 @@ namespace Avalonia.Skia internal class EllipseGeometryImpl : GeometryImpl { public override Rect Bounds { get; } - public override SKPath EffectivePath { get; } + public override SKPath StrokePath { get; } + public override SKPath FillPath => StrokePath; public EllipseGeometryImpl(Rect rect) { var path = new SKPath(); path.AddOval(rect.ToSKRect()); - EffectivePath = path; + StrokePath = path; Bounds = rect; } } diff --git a/src/Skia/Avalonia.Skia/GeometryGroupImpl.cs b/src/Skia/Avalonia.Skia/GeometryGroupImpl.cs index 2828f9a9c1..01be42bad0 100644 --- a/src/Skia/Avalonia.Skia/GeometryGroupImpl.cs +++ b/src/Skia/Avalonia.Skia/GeometryGroupImpl.cs @@ -11,26 +11,51 @@ namespace Avalonia.Skia { public GeometryGroupImpl(FillRule fillRule, IReadOnlyList children) { - var path = new SKPath + var fillType = fillRule == FillRule.NonZero ? SKPathFillType.Winding : SKPathFillType.EvenOdd; + var count = children.Count; + + var stroke = new SKPath { - FillType = fillRule == FillRule.NonZero ? SKPathFillType.Winding : SKPathFillType.EvenOdd, + FillType = fillType }; - - var count = children.Count; + bool requiresFillPass = false; for (var i = 0; i < count; ++i) { - if (children[i].PlatformImpl is GeometryImpl { EffectivePath: { } effectivePath }) + if (children[i].PlatformImpl is GeometryImpl geo) { - path.AddPath(effectivePath); + if (geo.StrokePath != null) + stroke.AddPath(geo.StrokePath); + if (!ReferenceEquals(geo.StrokePath, geo.FillPath)) + requiresFillPass = true; } } + + StrokePath = stroke; + + if (requiresFillPass) + { + var fill = new SKPath + { + FillType = fillType + }; + + for (var i = 0; i < count; ++i) + { + if (children[i].PlatformImpl is GeometryImpl { FillPath: { } fillPath }) + fill.AddPath(fillPath); + } + + FillPath = fill; + } + else + FillPath = stroke; - EffectivePath = path; - Bounds = path.Bounds.ToAvaloniaRect(); + Bounds = stroke.TightBounds.ToAvaloniaRect(); } public override Rect Bounds { get; } - public override SKPath EffectivePath { get; } + public override SKPath StrokePath { get; } + public override SKPath FillPath { get; } } } diff --git a/src/Skia/Avalonia.Skia/GeometryImpl.cs b/src/Skia/Avalonia.Skia/GeometryImpl.cs index 34270c2078..aee84d1346 100644 --- a/src/Skia/Avalonia.Skia/GeometryImpl.cs +++ b/src/Skia/Avalonia.Skia/GeometryImpl.cs @@ -14,7 +14,7 @@ namespace Avalonia.Skia private PathCache _pathCache; private SKPathMeasure? _cachedPathMeasure; - private SKPathMeasure CachedPathMeasure => _cachedPathMeasure ??= new SKPathMeasure(EffectivePath!); + private SKPathMeasure CachedPathMeasure => _cachedPathMeasure ??= new SKPathMeasure(StrokePath!); /// public abstract Rect Bounds { get; } @@ -24,19 +24,20 @@ namespace Avalonia.Skia { get { - if (EffectivePath is null) + if (StrokePath is null) return 0; return CachedPathMeasure.Length; } } - public abstract SKPath? EffectivePath { get; } + public abstract SKPath? StrokePath { get; } + public abstract SKPath? FillPath { get; } /// public bool FillContains(Point point) { - return PathContainsCore(EffectivePath, point); + return PathContainsCore(FillPath, point); } /// @@ -74,7 +75,7 @@ namespace Avalonia.Skia var paint = SKPaintCache.Shared.Get(); paint.IsStroke = true; paint.StrokeWidth = strokeWidth; - paint.GetFillPath(EffectivePath, strokePath); + paint.GetFillPath(StrokePath, strokePath); SKPaintCache.Shared.ReturnReset(paint); @@ -96,14 +97,10 @@ namespace Avalonia.Skia /// public IGeometryImpl? Intersect(IGeometryImpl geometry) { - if (EffectivePath is { } path - && (geometry as GeometryImpl)?.EffectivePath is { } otherPath - && path.Op(otherPath, SKPathOp.Intersect) is { } result) - { - return new StreamGeometryImpl(result); - } - - return null; + var other = geometry as GeometryImpl; + if (other == null) + return null; + return CombinedGeometryImpl.TryCreate(GeometryCombineMode.Intersect, this, other); } /// @@ -128,7 +125,7 @@ namespace Avalonia.Skia /// public bool TryGetPointAtDistance(double distance, out Point point) { - if (EffectivePath is null) + if (StrokePath is null) { point = new Point(); return false; @@ -142,7 +139,7 @@ namespace Avalonia.Skia /// public bool TryGetPointAndTangentAtDistance(double distance, out Point point, out Point tangent) { - if (EffectivePath is null) + if (StrokePath is null) { point = new Point(); tangent = new Point(); @@ -158,7 +155,7 @@ namespace Avalonia.Skia public bool TryGetSegment(double startDistance, double stopDistance, bool startOnBeginFigure, [NotNullWhen(true)] out IGeometryImpl? segmentGeometry) { - if (EffectivePath is null) + if (StrokePath is null) { segmentGeometry = null; return false; @@ -172,7 +169,7 @@ namespace Avalonia.Skia if (res) { - segmentGeometry = new StreamGeometryImpl(_skPathSegment); + segmentGeometry = new StreamGeometryImpl(_skPathSegment, null); } return res; diff --git a/src/Skia/Avalonia.Skia/LineGeometryImpl.cs b/src/Skia/Avalonia.Skia/LineGeometryImpl.cs index b102a4c119..2ac0a2e18c 100644 --- a/src/Skia/Avalonia.Skia/LineGeometryImpl.cs +++ b/src/Skia/Avalonia.Skia/LineGeometryImpl.cs @@ -9,7 +9,8 @@ namespace Avalonia.Skia internal class LineGeometryImpl : GeometryImpl { public override Rect Bounds { get; } - public override SKPath EffectivePath { get; } + public override SKPath StrokePath { get; } + public override SKPath? FillPath => null; public LineGeometryImpl(Point p1, Point p2) { @@ -17,7 +18,7 @@ namespace Avalonia.Skia path.MoveTo(p1.ToSKPoint()); path.LineTo(p2.ToSKPoint()); - EffectivePath = path; + StrokePath = path; Bounds = new Rect( new Point(Math.Min(p1.X, p2.X), Math.Min(p1.Y, p2.Y)), new Point(Math.Max(p1.X, p2.X), Math.Max(p1.Y, p2.Y))); diff --git a/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs b/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs index ab1c6b8816..9c4b326f14 100644 --- a/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs +++ b/src/Skia/Avalonia.Skia/PlatformRenderInterface.cs @@ -65,7 +65,7 @@ namespace Avalonia.Skia public IGeometryImpl CreateCombinedGeometry(GeometryCombineMode combineMode, Geometry g1, Geometry g2) { - return new CombinedGeometryImpl(combineMode, g1, g2); + return CombinedGeometryImpl.ForceCreate(combineMode, g1, g2); } public IGeometryImpl BuildGlyphRunGeometry(GlyphRun glyphRun) @@ -104,7 +104,7 @@ namespace Avalonia.Skia SKFontCache.Shared.Return(skFont); - return new StreamGeometryImpl(path); + return new StreamGeometryImpl(path, path); } /// diff --git a/src/Skia/Avalonia.Skia/RectangleGeometryImpl.cs b/src/Skia/Avalonia.Skia/RectangleGeometryImpl.cs index 93d453e8f0..2d127e07c9 100644 --- a/src/Skia/Avalonia.Skia/RectangleGeometryImpl.cs +++ b/src/Skia/Avalonia.Skia/RectangleGeometryImpl.cs @@ -8,14 +8,15 @@ namespace Avalonia.Skia internal class RectangleGeometryImpl : GeometryImpl { public override Rect Bounds { get; } - public override SKPath EffectivePath { get; } + public override SKPath StrokePath { get; } + public override SKPath? FillPath => StrokePath; public RectangleGeometryImpl(Rect rect) { var path = new SKPath(); path.AddRect(rect.ToSKRect()); - EffectivePath = path; + StrokePath = path; Bounds = rect; } } diff --git a/src/Skia/Avalonia.Skia/StreamGeometryImpl.cs b/src/Skia/Avalonia.Skia/StreamGeometryImpl.cs index 0c3289767e..eb081c4f50 100644 --- a/src/Skia/Avalonia.Skia/StreamGeometryImpl.cs +++ b/src/Skia/Avalonia.Skia/StreamGeometryImpl.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Avalonia.Media; using Avalonia.Platform; using SkiaSharp; @@ -10,36 +11,39 @@ namespace Avalonia.Skia internal class StreamGeometryImpl : GeometryImpl, IStreamGeometryImpl { private Rect _bounds; - private readonly SKPath _effectivePath; + private readonly SKPath _strokePath; + private SKPath? _fillPath; /// /// Initializes a new instance of the class. /// - /// An existing Skia . + /// An existing Skia for the stroke. + /// An existing Skia for the fill, can also be null or the same as the stroke /// Precomputed path bounds. - public StreamGeometryImpl(SKPath path, Rect bounds) + public StreamGeometryImpl(SKPath stroke, SKPath? fill, Rect? bounds = null) { - _effectivePath = path; - _bounds = bounds; + _strokePath = stroke; + _fillPath = fill; + _bounds = bounds ?? stroke.TightBounds.ToAvaloniaRect(); } - /// - /// Initializes a new instance of the class. - /// - /// An existing Skia . - public StreamGeometryImpl(SKPath path) : this(path, path.TightBounds.ToAvaloniaRect()) + private StreamGeometryImpl(SKPath path) : this(path, path, default(Rect)) { + } /// /// Initializes a new instance of the class. /// - public StreamGeometryImpl() : this(CreateEmptyPath(), default) + public StreamGeometryImpl() : this(CreateEmptyPath()) { } - + /// - public override SKPath EffectivePath => _effectivePath; + public override SKPath? StrokePath => _strokePath; + + /// + public override SKPath? FillPath => _fillPath; /// public override Rect Bounds => _bounds; @@ -47,7 +51,9 @@ namespace Avalonia.Skia /// public IStreamGeometryImpl Clone() { - return new StreamGeometryImpl(_effectivePath.Clone(), Bounds); + var stroke = _strokePath.Clone(); + var fill = _fillPath == _strokePath ? stroke : _fillPath.Clone(); + return new StreamGeometryImpl(stroke, fill, Bounds); } /// @@ -74,7 +80,10 @@ namespace Avalonia.Skia private class StreamContext : IStreamGeometryContextImpl { private readonly StreamGeometryImpl _geometryImpl; - private readonly SKPath _path; + private SKPath Stroke => _geometryImpl._strokePath; + private SKPath Fill => _geometryImpl._fillPath ??= new(); + private bool _isFilled; + private bool Duplicate => _isFilled && !ReferenceEquals(_geometryImpl._fillPath, Stroke); /// /// Initializes a new instance of the class. @@ -83,52 +92,79 @@ namespace Avalonia.Skia public StreamContext(StreamGeometryImpl geometryImpl) { _geometryImpl = geometryImpl; - _path = _geometryImpl._effectivePath; } /// /// Will update bounds of passed geometry. public void Dispose() { - _geometryImpl._bounds = _path.TightBounds.ToAvaloniaRect(); + _geometryImpl._bounds = Stroke.TightBounds.ToAvaloniaRect(); _geometryImpl.InvalidateCaches(); } /// public void ArcTo(Point point, Size size, double rotationAngle, bool isLargeArc, SweepDirection sweepDirection) { - _path.ArcTo( + var arc = isLargeArc ? SKPathArcSize.Large : SKPathArcSize.Small; + var sweep = sweepDirection == SweepDirection.Clockwise + ? SKPathDirection.Clockwise + : SKPathDirection.CounterClockwise; + Stroke.ArcTo( (float)size.Width, (float)size.Height, (float)rotationAngle, - isLargeArc ? SKPathArcSize.Large : SKPathArcSize.Small, - sweepDirection == SweepDirection.Clockwise ? SKPathDirection.Clockwise : SKPathDirection.CounterClockwise, + arc, + sweep, (float)point.X, (float)point.Y); + if(Duplicate) + Fill.ArcTo( + (float)size.Width, + (float)size.Height, + (float)rotationAngle, + arc, + sweep, + (float)point.X, + (float)point.Y); } /// public void BeginFigure(Point startPoint, bool isFilled) { - _path.MoveTo((float)startPoint.X, (float)startPoint.Y); + if (!isFilled) + { + if (Stroke == Fill) + _geometryImpl._fillPath = Stroke.Clone(); + } + + _isFilled = isFilled; + Stroke.MoveTo((float)startPoint.X, (float)startPoint.Y); + if(Duplicate) + Fill.MoveTo((float)startPoint.X, (float)startPoint.Y); } /// public void CubicBezierTo(Point point1, Point point2, Point point3) { - _path.CubicTo((float)point1.X, (float)point1.Y, (float)point2.X, (float)point2.Y, (float)point3.X, (float)point3.Y); + Stroke.CubicTo((float)point1.X, (float)point1.Y, (float)point2.X, (float)point2.Y, (float)point3.X, (float)point3.Y); + if(Duplicate) + Fill.CubicTo((float)point1.X, (float)point1.Y, (float)point2.X, (float)point2.Y, (float)point3.X, (float)point3.Y); } /// public void QuadraticBezierTo(Point point1, Point point2) { - _path.QuadTo((float)point1.X, (float)point1.Y, (float)point2.X, (float)point2.Y); + Stroke.QuadTo((float)point1.X, (float)point1.Y, (float)point2.X, (float)point2.Y); + if(Duplicate) + Fill.QuadTo((float)point1.X, (float)point1.Y, (float)point2.X, (float)point2.Y); } /// public void LineTo(Point point) { - _path.LineTo((float)point.X, (float)point.Y); + Stroke.LineTo((float)point.X, (float)point.Y); + if(Duplicate) + Fill.LineTo((float)point.X, (float)point.Y); } /// @@ -136,14 +172,16 @@ namespace Avalonia.Skia { if (isClosed) { - _path.Close(); + Stroke.Close(); + if (Duplicate) + Fill.Close(); } } /// public void SetFillRule(FillRule fillRule) { - _path.FillType = fillRule == FillRule.EvenOdd ? SKPathFillType.EvenOdd : SKPathFillType.Winding; + Fill.FillType = fillRule == FillRule.EvenOdd ? SKPathFillType.EvenOdd : SKPathFillType.Winding; } } } diff --git a/src/Skia/Avalonia.Skia/TransformedGeometryImpl.cs b/src/Skia/Avalonia.Skia/TransformedGeometryImpl.cs index fb3c2e403f..2dee8c318c 100644 --- a/src/Skia/Avalonia.Skia/TransformedGeometryImpl.cs +++ b/src/Skia/Avalonia.Skia/TransformedGeometryImpl.cs @@ -17,16 +17,27 @@ namespace Avalonia.Skia { SourceGeometry = source; Transform = transform; + var matrix = transform.ToSKMatrix(); - var transformedPath = source.EffectivePath.Clone(); - transformedPath?.Transform(transform.ToSKMatrix()); - - EffectivePath = transformedPath; + var transformedPath = StrokePath = source.StrokePath.Clone(); + transformedPath?.Transform(matrix); + Bounds = transformedPath?.TightBounds.ToAvaloniaRect() ?? default; + + if (ReferenceEquals(source.StrokePath, source.FillPath)) + FillPath = transformedPath; + else if (source.FillPath != null) + { + FillPath = transformedPath = source.FillPath.Clone(); + transformedPath.Transform(matrix); + } } /// - public override SKPath? EffectivePath { get; } + public override SKPath? StrokePath { get; } + + /// + public override SKPath? FillPath { get; } /// public IGeometryImpl SourceGeometry { get; } diff --git a/tests/Avalonia.RenderTests/Shapes/PathTests.cs b/tests/Avalonia.RenderTests/Shapes/PathTests.cs index bac16cca88..bf375121de 100644 --- a/tests/Avalonia.RenderTests/Shapes/PathTests.cs +++ b/tests/Avalonia.RenderTests/Shapes/PathTests.cs @@ -376,5 +376,63 @@ namespace Avalonia.Direct2D1.RenderTests.Shapes await RenderToFile(target); CompareImages(); } + + [Fact] + public async Task BeginFigure_IsFilled_Is_Respected() + { + var target = new Border + { + Width = 200, + Height = 200, + Background = Brushes.White, + Child = new Path + { + Fill = Brushes.Black, + Stroke = Brushes.Black, + StrokeThickness = 10, + Data = new PathGeometry() + { + Figures = new() + { + new PathFigure + { + IsFilled = false, IsClosed = false, + StartPoint = new Point(170,170), + Segments = new () + { + new LineSegment + { + Point = new Point(60, 170) + }, + new LineSegment + { + Point = new Point(60, 60) + } + } + }, + new PathFigure + { + IsFilled = true, IsClosed = true, + StartPoint = new Point(60,20), + Segments = new () + { + new LineSegment + { + Point = new Point(20, 60) + }, + new LineSegment + { + Point = new Point(100, 60) + } + } + } + } + } + } + }; + + await RenderToFile(target); + CompareImages(); + } } } diff --git a/tests/TestFiles/Direct2D1/Shapes/Path/BeginFigure_IsFilled_Is_Respected.expected.png b/tests/TestFiles/Direct2D1/Shapes/Path/BeginFigure_IsFilled_Is_Respected.expected.png new file mode 100644 index 0000000000..d67d71c520 Binary files /dev/null and b/tests/TestFiles/Direct2D1/Shapes/Path/BeginFigure_IsFilled_Is_Respected.expected.png differ diff --git a/tests/TestFiles/Skia/Shapes/Path/BeginFigure_IsFilled_Is_Respected.expected.png b/tests/TestFiles/Skia/Shapes/Path/BeginFigure_IsFilled_Is_Respected.expected.png new file mode 100644 index 0000000000..d67d71c520 Binary files /dev/null and b/tests/TestFiles/Skia/Shapes/Path/BeginFigure_IsFilled_Is_Respected.expected.png differ