diff --git a/src/ShimSkiaSharp/SKPath.cs b/src/ShimSkiaSharp/SKPath.cs index 7145609017..e650430866 100644 --- a/src/ShimSkiaSharp/SKPath.cs +++ b/src/ShimSkiaSharp/SKPath.cs @@ -44,13 +44,6 @@ public SKPath() Commands = new List(); } - private void ComputePointBounds(float x, float y, ref SKRect bounds) - { - bounds.Left = Math.Min(x, bounds.Left); - bounds.Right = Math.Max(x, bounds.Right); - bounds.Top = Math.Min(y, bounds.Top); - bounds.Bottom = Math.Max(y, bounds.Bottom); - } private SKRect GetBounds() { @@ -61,6 +54,9 @@ private SKRect GetBounds() var bounds = new SKRect(float.MaxValue, float.MaxValue, float.MinValue, float.MinValue); + var last = new SKPoint(); + var haveLast = false; + foreach (var pathCommand in Commands) { switch (pathCommand) @@ -69,44 +65,76 @@ private SKRect GetBounds() { var x = moveToPathCommand.X; var y = moveToPathCommand.Y; - ComputePointBounds(x, y, ref bounds); + SKPathBoundsHelper.ComputePointBounds(x, y, ref bounds); + last = new SKPoint(x, y); + haveLast = true; } break; case LineToPathCommand lineToPathCommand: { var x = lineToPathCommand.X; var y = lineToPathCommand.Y; - ComputePointBounds(x, y, ref bounds); + if (haveLast) + { + SKPathBoundsHelper.AddLineBounds(last.X, last.Y, x, y, ref bounds); + } + else + { + SKPathBoundsHelper.ComputePointBounds(x, y, ref bounds); + } + last = new SKPoint(x, y); + haveLast = true; } break; case ArcToPathCommand arcToPathCommand: { - var x = arcToPathCommand.X; - var y = arcToPathCommand.Y; - ComputePointBounds(x, y, ref bounds); + var end = new SKPoint(arcToPathCommand.X, arcToPathCommand.Y); + if (haveLast) + { + SKPathBoundsHelper.AddArcBounds(last, end, arcToPathCommand.Rx, arcToPathCommand.Ry, arcToPathCommand.XAxisRotate, arcToPathCommand.LargeArc, arcToPathCommand.Sweep, ref bounds); + } + else + { + SKPathBoundsHelper.ComputePointBounds(end.X, end.Y, ref bounds); + } + last = end; + haveLast = true; } break; case QuadToPathCommand quadToPathCommand: { - var x0 = quadToPathCommand.X0; - var y0 = quadToPathCommand.Y0; - var x1 = quadToPathCommand.X1; - var y1 = quadToPathCommand.Y1; - ComputePointBounds(x0, y0, ref bounds); - ComputePointBounds(x1, y1, ref bounds); + var p1 = new SKPoint(quadToPathCommand.X0, quadToPathCommand.Y0); + var p2 = new SKPoint(quadToPathCommand.X1, quadToPathCommand.Y1); + if (haveLast) + { + SKPathBoundsHelper.AddQuadBounds(last, p1, p2, ref bounds); + } + else + { + SKPathBoundsHelper.ComputePointBounds(p1.X, p1.Y, ref bounds); + SKPathBoundsHelper.ComputePointBounds(p2.X, p2.Y, ref bounds); + } + last = p2; + haveLast = true; } break; case CubicToPathCommand cubicToPathCommand: { - var x0 = cubicToPathCommand.X0; - var y0 = cubicToPathCommand.Y0; - var x1 = cubicToPathCommand.X1; - var y1 = cubicToPathCommand.Y1; - var x2 = cubicToPathCommand.X2; - var y2 = cubicToPathCommand.Y2; - ComputePointBounds(x0, y0, ref bounds); - ComputePointBounds(x1, y1, ref bounds); - ComputePointBounds(x2, y2, ref bounds); + var p1 = new SKPoint(cubicToPathCommand.X0, cubicToPathCommand.Y0); + var p2 = new SKPoint(cubicToPathCommand.X1, cubicToPathCommand.Y1); + var p3 = new SKPoint(cubicToPathCommand.X2, cubicToPathCommand.Y2); + if (haveLast) + { + SKPathBoundsHelper.AddCubicBounds(last, p1, p2, p3, ref bounds); + } + else + { + SKPathBoundsHelper.ComputePointBounds(p1.X, p1.Y, ref bounds); + SKPathBoundsHelper.ComputePointBounds(p2.X, p2.Y, ref bounds); + SKPathBoundsHelper.ComputePointBounds(p3.X, p3.Y, ref bounds); + } + last = p3; + haveLast = true; } break; case ClosePathCommand _: @@ -114,22 +142,28 @@ private SKRect GetBounds() case AddRectPathCommand addRectPathCommand: { var rect = addRectPathCommand.Rect; - ComputePointBounds(rect.Left, rect.Top, ref bounds); - ComputePointBounds(rect.Right, rect.Bottom, ref bounds); + SKPathBoundsHelper.ComputePointBounds(rect.Left, rect.Top, ref bounds); + SKPathBoundsHelper.ComputePointBounds(rect.Right, rect.Bottom, ref bounds); + last = rect.BottomRight; + haveLast = true; } break; case AddRoundRectPathCommand addRoundRectPathCommand: { var rect = addRoundRectPathCommand.Rect; - ComputePointBounds(rect.Left, rect.Top, ref bounds); - ComputePointBounds(rect.Right, rect.Bottom, ref bounds); + SKPathBoundsHelper.ComputePointBounds(rect.Left, rect.Top, ref bounds); + SKPathBoundsHelper.ComputePointBounds(rect.Right, rect.Bottom, ref bounds); + last = rect.BottomRight; + haveLast = true; } break; case AddOvalPathCommand addOvalPathCommand: { var rect = addOvalPathCommand.Rect; - ComputePointBounds(rect.Left, rect.Top, ref bounds); - ComputePointBounds(rect.Right, rect.Bottom, ref bounds); + SKPathBoundsHelper.ComputePointBounds(rect.Left, rect.Top, ref bounds); + SKPathBoundsHelper.ComputePointBounds(rect.Right, rect.Bottom, ref bounds); + last = rect.BottomRight; + haveLast = true; } break; case AddCirclePathCommand addCirclePathCommand: @@ -137,8 +171,10 @@ private SKRect GetBounds() var x = addCirclePathCommand.X; var y = addCirclePathCommand.Y; var radius = addCirclePathCommand.Radius; - ComputePointBounds(x - radius, y - radius, ref bounds); - ComputePointBounds(x + radius, y + radius, ref bounds); + SKPathBoundsHelper.ComputePointBounds(x - radius, y - radius, ref bounds); + SKPathBoundsHelper.ComputePointBounds(x + radius, y + radius, ref bounds); + last = new SKPoint(x + radius, y + radius); + haveLast = true; } break; case AddPolyPathCommand addPolyPathCommand: @@ -148,7 +184,12 @@ private SKRect GetBounds() var points = addPolyPathCommand.Points; foreach (var point in points) { - ComputePointBounds(point.X, point.Y, ref bounds); + SKPathBoundsHelper.ComputePointBounds(point.X, point.Y, ref bounds); + } + if (points.Count > 0) + { + last = points[points.Count - 1]; + haveLast = true; } } } diff --git a/src/ShimSkiaSharp/SKPathBoundsHelper.cs b/src/ShimSkiaSharp/SKPathBoundsHelper.cs new file mode 100644 index 0000000000..203e268ae1 --- /dev/null +++ b/src/ShimSkiaSharp/SKPathBoundsHelper.cs @@ -0,0 +1,206 @@ +// Copyright (c) Wiesław Šoltés. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for details. +using System; +using System.Collections.Generic; + +namespace ShimSkiaSharp; + +internal static class SKPathBoundsHelper +{ + public static void ComputePointBounds(float x, float y, ref SKRect bounds) + { + bounds.Left = Math.Min(x, bounds.Left); + bounds.Right = Math.Max(x, bounds.Right); + bounds.Top = Math.Min(y, bounds.Top); + bounds.Bottom = Math.Max(y, bounds.Bottom); + } + + public static void AddLineBounds(float x0, float y0, float x1, float y1, ref SKRect bounds) + { + if (x0 < x1) + { + bounds.Left = Math.Min(x0, bounds.Left); + bounds.Right = Math.Max(x1, bounds.Right); + } + else + { + bounds.Left = Math.Min(x1, bounds.Left); + bounds.Right = Math.Max(x0, bounds.Right); + } + + if (y0 < y1) + { + bounds.Top = Math.Min(y0, bounds.Top); + bounds.Bottom = Math.Max(y1, bounds.Bottom); + } + else + { + bounds.Top = Math.Min(y1, bounds.Top); + bounds.Bottom = Math.Max(y0, bounds.Bottom); + } + } + + private static float Quad(float a, float b, float c, float t) + { + var mt = 1f - t; + return mt * mt * a + 2f * mt * t * b + t * t * c; + } + + public static void AddQuadBounds(SKPoint p0, SKPoint p1, SKPoint p2, ref SKRect bounds) + { + ComputePointBounds(p0.X, p0.Y, ref bounds); + ComputePointBounds(p2.X, p2.Y, ref bounds); + + var denomX = p0.X - 2f * p1.X + p2.X; + if (Math.Abs(denomX) > float.Epsilon) + { + var t = (p0.X - p1.X) / denomX; + if (t > 0f && t < 1f) + { + var x = Quad(p0.X, p1.X, p2.X, t); + var y = Quad(p0.Y, p1.Y, p2.Y, t); + ComputePointBounds(x, y, ref bounds); + } + } + + var denomY = p0.Y - 2f * p1.Y + p2.Y; + if (Math.Abs(denomY) > float.Epsilon) + { + var t = (p0.Y - p1.Y) / denomY; + if (t > 0f && t < 1f) + { + var x = Quad(p0.X, p1.X, p2.X, t); + var y = Quad(p0.Y, p1.Y, p2.Y, t); + ComputePointBounds(x, y, ref bounds); + } + } + } + + private static float Cubic(float a, float b, float c, float d, float t) + { + var mt = 1f - t; + return mt * mt * mt * a + 3f * mt * mt * t * b + 3f * mt * t * t * c + t * t * t * d; + } + + private static IEnumerable SolveCubicDerivative(float a, float b, float c, float d) + { + var A = -a + 3f * b - 3f * c + d; + var B = 2f * (a - 2f * b + c); + var C = b - a; + + if (Math.Abs(A) < float.Epsilon) + { + if (Math.Abs(B) < float.Epsilon) + yield break; + + var t = -C / B; + if (t > 0f && t < 1f) + yield return t; + yield break; + } + + var discriminant = B * B - 4f * A * C; + if (discriminant < 0f) + yield break; + + var sqrt = (float)Math.Sqrt(discriminant); + var q = -B / (2f * A); + var r = sqrt / (2f * A); + + var t1 = q + r; + if (t1 > 0f && t1 < 1f) + yield return t1; + + var t2 = q - r; + if (t2 > 0f && t2 < 1f) + yield return t2; + } + + public static void AddCubicBounds(SKPoint p0, SKPoint p1, SKPoint p2, SKPoint p3, ref SKRect bounds) + { + ComputePointBounds(p0.X, p0.Y, ref bounds); + ComputePointBounds(p3.X, p3.Y, ref bounds); + + foreach (var t in SolveCubicDerivative(p0.X, p1.X, p2.X, p3.X)) + { + var x = Cubic(p0.X, p1.X, p2.X, p3.X, t); + var y = Cubic(p0.Y, p1.Y, p2.Y, p3.Y, t); + ComputePointBounds(x, y, ref bounds); + } + + foreach (var t in SolveCubicDerivative(p0.Y, p1.Y, p2.Y, p3.Y)) + { + var x = Cubic(p0.X, p1.X, p2.X, p3.X, t); + var y = Cubic(p0.Y, p1.Y, p2.Y, p3.Y, t); + ComputePointBounds(x, y, ref bounds); + } + } + + public static void AddArcBounds(SKPoint p0, SKPoint p1, float rx, float ry, float angle, SKPathArcSize largeArc, SKPathDirection sweep, ref SKRect bounds) + { + if (rx <= 0f || ry <= 0f) + { + ComputePointBounds(p0.X, p0.Y, ref bounds); + ComputePointBounds(p1.X, p1.Y, ref bounds); + return; + } + + var phi = angle * (float)Math.PI / 180f; + var cosPhi = (float)Math.Cos(phi); + var sinPhi = (float)Math.Sin(phi); + + var dx2 = (p0.X - p1.X) / 2f; + var dy2 = (p0.Y - p1.Y) / 2f; + + var x1p = cosPhi * dx2 + sinPhi * dy2; + var y1p = -sinPhi * dx2 + cosPhi * dy2; + + rx = Math.Abs(rx); + ry = Math.Abs(ry); + + var rxsq = rx * rx; + var rysq = ry * ry; + var x1psq = x1p * x1p; + var y1psq = y1p * y1p; + + var lambda = x1psq / rxsq + y1psq / rysq; + if (lambda > 1f) + { + var factor = (float)Math.Sqrt(lambda); + rx *= factor; + ry *= factor; + rxsq = rx * rx; + rysq = ry * ry; + } + + var sign = (largeArc == SKPathArcSize.Large) == (sweep == SKPathDirection.Clockwise) ? -1f : 1f; + var sq = (rxsq * rysq - rxsq * y1psq - rysq * x1psq) / (rxsq * y1psq + rysq * x1psq); + sq = Math.Max(sq, 0f); + var coef = sign * (float)Math.Sqrt(sq); + var cxp = coef * (rx * y1p / ry); + var cyp = coef * (-ry * x1p / rx); + + var cx = cosPhi * cxp - sinPhi * cyp + (p0.X + p1.X) / 2f; + var cy = sinPhi * cxp + cosPhi * cyp + (p0.Y + p1.Y) / 2f; + + var startAngle = (float)Math.Atan2((y1p - cyp) / ry, (x1p - cxp) / rx); + var endAngle = (float)Math.Atan2((-y1p - cyp) / ry, (-x1p - cxp) / rx); + var sweepFlag = sweep == SKPathDirection.Clockwise; + var deltaAngle = endAngle - startAngle; + if (!sweepFlag && deltaAngle > 0) + deltaAngle -= 2f * (float)Math.PI; + else if (sweepFlag && deltaAngle < 0) + deltaAngle += 2f * (float)Math.PI; + + const int segments = 20; + for (var i = 0; i <= segments; i++) + { + var theta = startAngle + deltaAngle * i / segments; + var cosTheta = (float)Math.Cos(theta); + var sinTheta = (float)Math.Sin(theta); + var x = cosPhi * rx * cosTheta - sinPhi * ry * sinTheta + cx; + var y = sinPhi * rx * cosTheta + cosPhi * ry * sinTheta + cy; + ComputePointBounds(x, y, ref bounds); + } + } +} diff --git a/src/Svg.Model/Drawables/Elements/LineDrawable.cs b/src/Svg.Model/Drawables/Elements/LineDrawable.cs index 792d24eb75..c8140522a7 100644 --- a/src/Svg.Model/Drawables/Elements/LineDrawable.cs +++ b/src/Svg.Model/Drawables/Elements/LineDrawable.cs @@ -84,4 +84,45 @@ private void Initialize(SKRect skViewport,HashSet? references) MarkerService.CreateMarkers(svgLine, Path, skViewport, this, AssetLoader, references); } + + private static float DistanceToSegment(SKPoint p, SKPoint a, SKPoint b) + { + var vx = b.X - a.X; + var vy = b.Y - a.Y; + var ux = p.X - a.X; + var uy = p.Y - a.Y; + var lenSq = vx * vx + vy * vy; + if (lenSq <= float.Epsilon) + { + return (float)Math.Sqrt(ux * ux + uy * uy); + } + + var t = (ux * vx + uy * vy) / lenSq; + if (t < 0f) t = 0f; + else if (t > 1f) t = 1f; + + var px = a.X + t * vx; + var py = a.Y + t * vy; + var dx = p.X - px; + var dy = p.Y - py; + return (float)Math.Sqrt(dx * dx + dy * dy); + } + + public override bool HitTest(SKPoint point) + { + if (Path?.Commands is { Count: >= 2 } commands && + commands[0] is MoveToPathCommand move && + commands[1] is LineToPathCommand line) + { + var start = TotalTransform.MapPoint(new SKPoint(move.X, move.Y)); + var end = TotalTransform.MapPoint(new SKPoint(line.X, line.Y)); + var distance = DistanceToSegment(point, start, end); + var tolerance = Stroke?.StrokeWidth / 2f ?? 1f; + if (tolerance <= 0f) + tolerance = 1f; + return distance <= tolerance; + } + + return base.HitTest(point); + } } diff --git a/tests/ShimSkiaSharp.UnitTests/SKPathTests.cs b/tests/ShimSkiaSharp.UnitTests/SKPathTests.cs index 7a8c3d2a96..fddd86592a 100644 --- a/tests/ShimSkiaSharp.UnitTests/SKPathTests.cs +++ b/tests/ShimSkiaSharp.UnitTests/SKPathTests.cs @@ -36,4 +36,17 @@ public void MoveTo_LineTo_UpdatesBounds() path.LineTo(3, 4); Assert.Equal(new SKRect(1,2,3,4), path.Bounds); } + + [Fact] + public void QuadTo_UpdatesBoundsPrecisely() + { + var path = new SKPath(); + path.MoveTo(0, 0); + path.QuadTo(0.5f, 1f, 1f, 0f); + var expected = new SKRect(0f, 0f, 1f, 0.5f); + Assert.Equal(expected.Left, path.Bounds.Left, 3); + Assert.Equal(expected.Top, path.Bounds.Top, 3); + Assert.Equal(expected.Right, path.Bounds.Right, 3); + Assert.Equal(expected.Bottom, path.Bounds.Bottom, 3); + } } diff --git a/tests/Svg.Skia.UnitTests/HitTestTests.cs b/tests/Svg.Skia.UnitTests/HitTestTests.cs index 88589d82f0..0be591e9e5 100644 --- a/tests/Svg.Skia.UnitTests/HitTestTests.cs +++ b/tests/Svg.Skia.UnitTests/HitTestTests.cs @@ -52,4 +52,44 @@ public void HitTest_Text_Point() var results = svg.HitTestElements(new SKPoint(12, 20)).Select(e => e.ID).ToList(); Assert.Contains("hello", results); } + + [Fact] + public void HitTest_Line_Point() + { + var svg = new SKSvg(); + using var _ = svg.Load(GetSvgPath("HitTestLine.svg")); + + var results = svg.HitTestElements(new SKPoint(50, 12)).Select(e => e.ID).ToList(); + Assert.Contains("line", results); + } + + [Fact] + public void HitTest_PathQuad_Point() + { + var svg = new SKSvg(); + using var _ = svg.Load(GetSvgPath("HitTestQuad.svg")); + + var results = svg.HitTestElements(new SKPoint(50, 50)).Select(e => e.ID).ToList(); + Assert.Contains("quad", results); + } + + [Fact] + public void HitTest_PathCubic_Point() + { + var svg = new SKSvg(); + using var _ = svg.Load(GetSvgPath("HitTestCubic.svg")); + + var results = svg.HitTestElements(new SKPoint(50, 50)).Select(e => e.ID).ToList(); + Assert.Contains("cubic", results); + } + + [Fact] + public void HitTest_PathArc_Point() + { + var svg = new SKSvg(); + using var _ = svg.Load(GetSvgPath("HitTestArc.svg")); + + var results = svg.HitTestElements(new SKPoint(50, 70)).Select(e => e.ID).ToList(); + Assert.Contains("arc", results); + } } diff --git a/tests/Tests/HitTestArc.svg b/tests/Tests/HitTestArc.svg new file mode 100644 index 0000000000..22b01a8211 --- /dev/null +++ b/tests/Tests/HitTestArc.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/Tests/HitTestCubic.svg b/tests/Tests/HitTestCubic.svg new file mode 100644 index 0000000000..af89f81928 --- /dev/null +++ b/tests/Tests/HitTestCubic.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/Tests/HitTestLine.svg b/tests/Tests/HitTestLine.svg new file mode 100644 index 0000000000..3192c0f75e --- /dev/null +++ b/tests/Tests/HitTestLine.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/Tests/HitTestQuad.svg b/tests/Tests/HitTestQuad.svg new file mode 100644 index 0000000000..c5f3bf3a74 --- /dev/null +++ b/tests/Tests/HitTestQuad.svg @@ -0,0 +1,3 @@ + + +