diff --git a/Svg.Editor.Avalonia.Forms/Svg.Editor.Avalon.Forms.csproj b/Svg.Editor.Avalonia.Forms/Svg.Editor.Avalon.Forms.csproj index cfc38e291..8bf8987a2 100644 --- a/Svg.Editor.Avalonia.Forms/Svg.Editor.Avalon.Forms.csproj +++ b/Svg.Editor.Avalonia.Forms/Svg.Editor.Avalon.Forms.csproj @@ -3,8 +3,10 @@ net10.0 enable latest - 3.2.0-optiq07 + 3.2.0-optiq08 + #3.2.0-optiq08 + circle/ellipse should only select on their outline, not their bounding box #3.2.0-optiq07 Tapping a point now only selects the top-most #3.2.0-optiq06 diff --git a/Svg.Editor.Avalonia.Views/Svg.Editor.Avalon.Views.csproj b/Svg.Editor.Avalonia.Views/Svg.Editor.Avalon.Views.csproj index 0412c4fc3..5c44b3a2a 100644 --- a/Svg.Editor.Avalonia.Views/Svg.Editor.Avalon.Views.csproj +++ b/Svg.Editor.Avalonia.Views/Svg.Editor.Avalon.Views.csproj @@ -3,8 +3,10 @@ net10.0 enable latest - 3.2.0-optiq07 + 3.2.0-optiq08 + #3.2.0-optiq08 + circle/ellipse should only select on their outline, not their bounding box #3.2.0-optiq07 Tapping a point now only selects the top-most #3.2.0-optiq06 diff --git a/Svg.Editor.Core.Tests/SelectionToolTests.cs b/Svg.Editor.Core.Tests/SelectionToolTests.cs index 4595e3d34..574762479 100644 --- a/Svg.Editor.Core.Tests/SelectionToolTests.cs +++ b/Svg.Editor.Core.Tests/SelectionToolTests.cs @@ -283,6 +283,79 @@ public async Task ElementsAreSelected_AndDeleteCommandExecuted_ElementsAreRemove Assert.AreEqual(0, Canvas.SelectedElements.Count); } + [Test] + public async Task EllipseWithoutFillIsTappedInCenter_ShouldNotSelect() + { + // Arrange + await Canvas.EnsureInitialized(); + var tool = Canvas.Tools.OfType().Single(); + Canvas.ActiveTool = tool; + Canvas.ScreenWidth = 800; + Canvas.ScreenHeight = 500; + + // this mirrors EllipseTool.CreateShape exactly + var ellipse = new SvgEllipse + { + Stroke = new SvgColourServer(Color.Create(0, 0, 0)), + Fill = SvgPaintServer.None, + StrokeWidth = new SvgUnit(SvgUnitType.Pixel, 5), + CenterX = new SvgUnit(SvgUnitType.Pixel, 400), + CenterY = new SvgUnit(SvgUnitType.Pixel, 250), + RadiusX = new SvgUnit(SvgUnitType.Pixel, 100), + RadiusY = new SvgUnit(SvgUnitType.Pixel, 60) + }; + Canvas.Document.Children.Add(ellipse); + + // Preassert + Assert.True(Canvas.Document.Children.Any(x => x == ellipse)); + + // Act + var pt1 = PointF.Create(400, 250); + await Canvas.OnEvent(new PointerEvent(EventType.PointerDown, pt1, pt1, pt1, 1)); + await Canvas.OnEvent(new PointerEvent(EventType.PointerUp, pt1, pt1, pt1, 1)); + ((TestScheduler)SchedulerProvider.BackgroundScheduler).AdvanceBy(TimeSpan.FromSeconds(1).Ticks); + + // Assert + Assert.AreEqual(0, Canvas.SelectedElements.Count); + } + + [Test] + public async Task EllipseWithoutFillIsTappedOnBorder_ShouldSelect() + { + // Arrange + await Canvas.EnsureInitialized(); + var tool = Canvas.Tools.OfType().Single(); + Canvas.ActiveTool = tool; + Canvas.ScreenWidth = 800; + Canvas.ScreenHeight = 500; + + // this mirrors EllipseTool.CreateShape exactly + var ellipse = new SvgEllipse + { + Stroke = new SvgColourServer(Color.Create(0, 0, 0)), + Fill = SvgPaintServer.None, + StrokeWidth = new SvgUnit(SvgUnitType.Pixel, 5), + CenterX = new SvgUnit(SvgUnitType.Pixel, 400), + CenterY = new SvgUnit(SvgUnitType.Pixel, 250), + RadiusX = new SvgUnit(SvgUnitType.Pixel, 100), + RadiusY = new SvgUnit(SvgUnitType.Pixel, 60) + }; + Canvas.Document.Children.Add(ellipse); + + // Preassert + Assert.True(Canvas.Document.Children.Any(x => x == ellipse)); + + // Act - tap on the top border of the ellipse (cy - ry) + var pt1 = PointF.Create(400, 190); + await Canvas.OnEvent(new PointerEvent(EventType.PointerDown, pt1, pt1, pt1, 1)); + await Canvas.OnEvent(new PointerEvent(EventType.PointerUp, pt1, pt1, pt1, 1)); + ((TestScheduler)SchedulerProvider.BackgroundScheduler).AdvanceBy(TimeSpan.FromSeconds(1).Ticks); + + // Assert + Assert.AreEqual(1, Canvas.SelectedElements.Count); + Assert.AreSame(ellipse, Canvas.SelectedElements.Single()); + } + [Test] public async Task PolygonIsSelectedInside_ShouldNotSelect() { diff --git a/Svg.Editor.Core/Svg.Editor.Core.csproj b/Svg.Editor.Core/Svg.Editor.Core.csproj index 9427c3daa..33ab8df02 100644 --- a/Svg.Editor.Core/Svg.Editor.Core.csproj +++ b/Svg.Editor.Core/Svg.Editor.Core.csproj @@ -4,9 +4,11 @@ Svg.Editor en-US net10.0 - 3.2.0-optiq07 + 3.2.0-optiq08 latest + #3.2.0-optiq08 + circle/ellipse should only select on their outline, not their bounding box #3.2.0-optiq07 Tapping a point now only selects the top-most #3.2.0-optiq06 diff --git a/Svg.Tests.Win/SvgHitTests.cs b/Svg.Tests.Win/SvgHitTests.cs index bfacd411a..fec01f209 100644 --- a/Svg.Tests.Win/SvgHitTests.cs +++ b/Svg.Tests.Win/SvgHitTests.cs @@ -391,6 +391,383 @@ public void Contains_Group_HasTransformations_IsHit(float x, float y, float wh, } } + [TestCase("outside tap", 75f, 75f, 10, false)] + [TestCase("bounding box corner tap (outside circle outline) w/o fill", 100f, 100f, 10, false)] + [TestCase("top border tap w/o fill", 150f, 100f, 10, true)] + [TestCase("right border tap w/o fill", 200f, 150f, 10, true)] + [TestCase("bottom border tap w/o fill", 150f, 200f, 10, true)] + [TestCase("left border tap w/o fill", 100f, 150f, 10, true)] + [TestCase("center tap w/o fill", 150f, 150f, 10, false)] + public void Intersect_Circle_WithoutFill_DoesNotHitCenter(string ___, float x, float y, float wh, bool expectsHitSuccessful) + { + // Arrange + var rawSvg = @" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + var rect = RectangleF.Create(x, y, wh, wh); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + if (!expectsHitSuccessful) + result.ShouldBeEmpty(); + else + result.Count().ShouldBe(1); + } + + [TestCase("outside tap", 75f, 75f, 10, false)] + [TestCase("bounding box corner tap (outside ellipse outline) w/o fill", 100f, 120f, 10, false)] + [TestCase("top border tap w/o fill", 150f, 120f, 10, true)] + [TestCase("right border tap w/o fill", 200f, 150f, 10, true)] + [TestCase("bottom border tap w/o fill", 150f, 180f, 10, true)] + [TestCase("left border tap w/o fill", 100f, 150f, 10, true)] + [TestCase("center tap w/o fill", 150f, 150f, 10, false)] + public void Intersect_Ellipse_WithoutFill_DoesNotHitCenter(string ___, float x, float y, float wh, bool expectsHitSuccessful) + { + // Arrange + // this is the shape actually produced by EllipseTool.CreateShape (Fill = SvgPaintServer.None) + var rawSvg = @" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + var rect = RectangleF.Create(x, y, wh, wh); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + if (!expectsHitSuccessful) + result.ShouldBeEmpty(); + else + result.Count().ShouldBe(1); + } + + [TestCase("thin stroke - tap is too far from the exact border to count", 1f, false)] + [TestCase("thick stroke - fat finger tolerance grows with the visible border", 30f, true)] + public void Intersect_Rectangle_WithoutFill_WiderStrokeWidensBorderHitTolerance(string ___, float strokeWidth, bool expectsHitSuccessful) + { + // Arrange + var rawSvg = $@" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + // tap 10 units left of the rectangle's left edge (x=100): too far for a 1px stroke, within + // tolerance for a 30px stroke's rendered band + var rect = RectangleF.Create(85f, 145f, 10, 10); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + if (!expectsHitSuccessful) + result.ShouldBeEmpty(); + else + result.Count().ShouldBe(1); + } + + [Test] + public void Intersect_Rectangle_WithoutFill_PercentageStrokeWidthUsesDocumentRelativeTolerance() + { + // Arrange + // svg is 400x300, so per the SVG spec's diagonal formula, a 6% stroke-width resolves to + // sqrt(400^2+300^2)/sqrt(2) * 6/100 =~ 21.2 device units of tolerance (~10.6 half-width). + // treating "6" as if it were already pixels (ignoring the % unit) would instead give a + // bogus ~3 unit half-width tolerance, and this tap would then incorrectly be a miss. + var rawSvg = @" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + // tap 10 units left of the rectangle's left edge (x=100) + var rect = RectangleF.Create(85f, 145f, 10, 10); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + result.Count().ShouldBe(1); + } + + [Test] + public void Intersect_Rectangle_WithoutFillAndWithoutStroke_DoesNotGetExtraTolerance() + { + // Arrange + // no stroke color set at all - HasStroke() must be false, so the (irrelevant) default + // StrokeWidth must not silently widen the hit area + var rawSvg = @" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + var rect = RectangleF.Create(85f, 145f, 10, 10); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + result.ShouldBeEmpty(); + } + + [TestCase("thin stroke - tap is too far from the exact border to count", 1f, false)] + [TestCase("thick stroke - fat finger tolerance grows with the visible border", 30f, true)] + public void Intersect_Line_WiderStrokeWidensBorderHitTolerance(string ___, float strokeWidth, bool expectsHitSuccessful) + { + // Arrange + var rawSvg = $@" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + // tap 10 units above the horizontal line (y=100): too far for a 1px stroke, within + // tolerance for a 30px stroke's rendered band + var rect = RectangleF.Create(145f, 85f, 10, 10); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + if (!expectsHitSuccessful) + result.ShouldBeEmpty(); + else + result.Count().ShouldBe(1); + } + + [TestCase("thin stroke - tap is too far from the exact border to count", 1f, false)] + [TestCase("thick stroke - fat finger tolerance grows with the visible border", 30f, true)] + public void Intersect_Polygon_WithoutFill_WiderStrokeWidensBorderHitTolerance(string ___, float strokeWidth, bool expectsHitSuccessful) + { + // Arrange + // a square drawn as a polygon, same bounds as the rectangle test above + var rawSvg = $@" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + // tap 10 units left of the left edge (x=100): too far for a 1px stroke, within + // tolerance for a 30px stroke's rendered band + var rect = RectangleF.Create(85f, 145f, 10, 10); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + if (!expectsHitSuccessful) + result.ShouldBeEmpty(); + else + result.Count().ShouldBe(1); + } + + [TestCase("thin stroke - tap is too far from the exact border to count", 1f, false)] + [TestCase("thick stroke - fat finger tolerance grows with the visible border", 30f, true)] + public void Intersect_Polyline_WithoutFill_WiderStrokeWidensBorderHitTolerance(string ___, float strokeWidth, bool expectsHitSuccessful) + { + // Arrange + // an "L" shape: top edge (100,100)-(200,100), then down to (200,200) + var rawSvg = $@" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + // tap 10 units above the top edge (y=100): too far for a 1px stroke, within + // tolerance for a 30px stroke's rendered band + var rect = RectangleF.Create(145f, 85f, 10, 10); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + if (!expectsHitSuccessful) + result.ShouldBeEmpty(); + else + result.Count().ShouldBe(1); + } + + [TestCase("thin stroke - tap is too far from the exact border to count", 1f, false)] + [TestCase("thick stroke - fat finger tolerance grows with the visible border", 30f, true)] + public void Intersect_Path_WithoutFill_WiderStrokeWidensBorderHitTolerance(string ___, float strokeWidth, bool expectsHitSuccessful) + { + // Arrange + // same "L" shape as the polyline test above, drawn as a path + var rawSvg = $@" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + // tap 10 units above the top edge (y=100): too far for a 1px stroke, within + // tolerance for a 30px stroke's rendered band + var rect = RectangleF.Create(145f, 85f, 10, 10); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + if (!expectsHitSuccessful) + result.ShouldBeEmpty(); + else + result.Count().ShouldBe(1); + } + + // ---- precise fill-aware interior hit testing (PR #76 review) --------------------------------- + + [TestCase("center tap w fill hits interior", 145f, 145f, 10, true)] + [TestCase("bounding box corner tap w fill (inside bbox, outside circle) is a miss", 105f, 105f, 10, false)] + public void Intersect_Circle_WithFill_HitsInteriorNotBoundingBoxCorner(string ___, float x, float y, float wh, bool expectsHitSuccessful) + { + // Arrange - filled circle: interior selectable, but only inside the actual outline (not the bbox corners) + var rawSvg = @" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + var rect = RectangleF.Create(x, y, wh, wh); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + if (!expectsHitSuccessful) + result.ShouldBeEmpty(); + else + result.Count().ShouldBe(1); + } + + [TestCase("center tap w fill hits interior", 145f, 145f, 10, true)] + [TestCase("bounding box corner tap w fill (inside bbox, outside ellipse) is a miss", 102f, 122f, 10, false)] + public void Intersect_Ellipse_WithFill_HitsInteriorNotBoundingBoxCorner(string ___, float x, float y, float wh, bool expectsHitSuccessful) + { + // Arrange - filled ellipse: interior selectable, but only inside the actual outline + var rawSvg = @" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + var rect = RectangleF.Create(x, y, wh, wh); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + if (!expectsHitSuccessful) + result.ShouldBeEmpty(); + else + result.Count().ShouldBe(1); + } + + [TestCase("tap inside the filled body hits", 110f, 145f, 10, true)] + [TestCase("tap in the concave notch (inside bbox, outside the shape) is a miss", 175f, 115f, 10, false)] + public void Intersect_ConcavePolygon_WithFill_HitsBodyNotNotch(string ___, float x, float y, float wh, bool expectsHitSuccessful) + { + // Arrange - an "L" shaped (concave) polygon. Its bounding box is 100,100-200,200 but the top-right + // quadrant is a notch that is NOT part of the shape. A filled shape must only be hit inside its + // real outline, not anywhere in the bounding box. + var rawSvg = @" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + var rect = RectangleF.Create(x, y, wh, wh); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + if (!expectsHitSuccessful) + result.ShouldBeEmpty(); + else + result.Count().ShouldBe(1); + } + + [TestCase("opaque fill - interior is a hit", "1", true)] + [TestCase("fully transparent fill", "0", true)] + public void Intersect_Rectangle_FillOpacityDoesNotAffectInteriorHit(string ___, string fillOpacity, bool expectsHitSuccessful) + { + // Arrange - no stroke, so the only way to hit the interior is via the fill. fill-opacity only + // affects how the fill renders (visibly transparent or not) - it says nothing about whether the + // shape HAS a fill at all, which is what determines whether the interior is a hit target. + var rawSvg = $@" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + // tap dead center of the rectangle + var rect = RectangleF.Create(145f, 145f, 10, 10); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + if (!expectsHitSuccessful) + result.ShouldBeEmpty(); + else + result.Count().ShouldBe(1); + } + + [Test] + public void Intersect_Rectangle_WithNoFillAtAll_InteriorIsNotAHitTarget() + { + // Arrange - the OTHER half of the fill-opacity rule above: only a shape with genuinely no fill + // colour (fill:none) has no interior hit target - not a transparent one, an absent one. + var rawSvg = @" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + // tap dead center of the rectangle + var rect = RectangleF.Create(145f, 145f, 10, 10); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + result.ShouldBeEmpty(); + } + + [TestCase("non-zero fill rule - the star center is filled", "nonzero", true)] + [TestCase("even-odd fill rule - the star center is a hole", "evenodd", false)] + public void Intersect_SelfIntersectingPolygon_FillRuleDeterminesInterior(string ___, string fillRule, bool expectsHitSuccessful) + { + // Arrange - a pentagram (self-intersecting). Its central pentagon is filled under the non-zero rule + // but is a hole under the even-odd rule, so the fill-rule alone decides whether a tap in the center + // is a hit. Vertices are the 5 points of a star, centered on (150,150). + var rawSvg = $@" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + // tap the center of the star (well away from any edge, so only the fill decides the outcome) + var rect = RectangleF.Create(145f, 145f, 10, 10); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + if (!expectsHitSuccessful) + result.ShouldBeEmpty(); + else + result.Count().ShouldBe(1); + } + + [TestCase("tap in the hole (inner square) - happens to be excluded, but for the wrong reason", 145f, 145f, false)] + [TestCase("tap in the donut body (between outer and inner squares) - WRONGLY excluded - this is the real bug", 110f, 145f, false)] + public void Intersect_MultiContourDonutPath_ObservesActualGetLinesBehavior(string ___, float x, float y, bool expectsHitSuccessful) + { + // Arrange - a "donut": an outer square with an inner square subpath cut out via fill-rule evenodd. + // Correct SVG semantics: the hole (130,130-170,170) is unfilled, the ring between the two squares + // IS filled - so a tap in the donut body (110,145) should be a HIT. + // IntersectsWith's own NOTE flags the known limitation: PathData.GetLines() flattens both subpaths + // into one vertex ring (no subpath break), so this does not evaluate the two contours independently + // - the merged ring ends up self-intersecting instead of forming a real donut. + // Measured directly (do not assume): the hole tap happens to still read as a miss, but the donut-body + // tap - which should be a hit - is ALSO wrongly read as a miss. So the current implementation doesn't + // just fail to carve out the hole; for this shape it fails to detect the filled body at all. This + // test pins that actual (broken) behavior so it's a visible, tracked limitation rather than a + // rediscovered surprise - fixing GetLines() to preserve subpath breaks is the real fix, out of scope here. + var rawSvg = @" + +"; + var svg = SvgDocument.FromSvg(rawSvg); + var rect = RectangleF.Create(x, y, 10, 10); + + // Act + var result = svg.HitTest(rect, SelectionType.Intersect, HitTestResultMode.ReturnAllMatchingDescendants); + + // Assert + if (!expectsHitSuccessful) + result.ShouldBeEmpty(); + else + result.Count().ShouldBe(1); + } + [Test] public void Intersect_TwoLayeredRectangles_OnlySelectsTopLayerRectangle() { diff --git a/Svg/Basic Shapes/SvgCircle.cs b/Svg/Basic Shapes/SvgCircle.cs index 5b13dcb49..0937b475c 100644 --- a/Svg/Basic Shapes/SvgCircle.cs +++ b/Svg/Basic Shapes/SvgCircle.cs @@ -130,6 +130,11 @@ public SvgCircle() CenterY = new SvgUnit(0.0f); } + protected internal override bool IntersectsWith(RectangleF rectangle, Matrix transform, int maxRecursion) + { + var r = this.Radius.Value; + return this.IntersectsWithEllipticalOutline(rectangle, transform, this.CenterX.Value, this.CenterY.Value, r, r); + } public override SvgElement DeepCopy() { diff --git a/Svg/Basic Shapes/SvgEllipse.cs b/Svg/Basic Shapes/SvgEllipse.cs index 3bcffc35c..2651cff01 100644 --- a/Svg/Basic Shapes/SvgEllipse.cs +++ b/Svg/Basic Shapes/SvgEllipse.cs @@ -138,7 +138,10 @@ public SvgEllipse() { } - + protected internal override bool IntersectsWith(RectangleF rectangle, Matrix transform, int maxRecursion) + { + return this.IntersectsWithEllipticalOutline(rectangle, transform, this.CenterX.Value, this.CenterY.Value, this.RadiusX.Value, this.RadiusY.Value); + } public override SvgElement DeepCopy() { diff --git a/Svg/Basic Shapes/SvgLine.cs b/Svg/Basic Shapes/SvgLine.cs index b7b2f1ca2..2dba4558d 100644 --- a/Svg/Basic Shapes/SvgLine.cs +++ b/Svg/Basic Shapes/SvgLine.cs @@ -176,8 +176,8 @@ protected internal override bool IntersectsWith(RectangleF rectangle, Matrix tra { var start = PointF.Create(this.StartX, this.StartY); var end = PointF.Create(this.EndX, this.EndY); - - return (start,end).IsIntersectingWithLine(transform, rectangle); + + return (start,end).IsIntersectingWithLine(transform, rectangle, this.GetStrokeHitTestTolerance()); } public override SvgElement DeepCopy() diff --git a/Svg/Basic Shapes/SvgPolygon.cs b/Svg/Basic Shapes/SvgPolygon.cs index abdf3d788..a1783c2e6 100644 --- a/Svg/Basic Shapes/SvgPolygon.cs +++ b/Svg/Basic Shapes/SvgPolygon.cs @@ -162,9 +162,6 @@ public override SvgElement DeepCopy() protected internal override bool IntersectsWith(RectangleF rectangle, Matrix transform, int maxRecursion) { - if (this.HasFill()) - return true; - var units = Points.ToList(); var lineSegments = new List<(PointF from, PointF to)>(); @@ -181,7 +178,8 @@ protected internal override bool IntersectsWith(RectangleF rectangle, Matrix tra PointF.Create(units[0].Value, units[1].Value)) ); - return lineSegments.IsIntersectingWithLine(transform, rectangle); + return lineSegments.IsIntersectingOrContainedWithinShape(transform, rectangle, + this.HasFill(), this.FillRule, this.GetStrokeHitTestTolerance()); } } } \ No newline at end of file diff --git a/Svg/Basic Shapes/SvgPolyline.cs b/Svg/Basic Shapes/SvgPolyline.cs index 957b0a06f..adb31f3d3 100644 --- a/Svg/Basic Shapes/SvgPolyline.cs +++ b/Svg/Basic Shapes/SvgPolyline.cs @@ -81,9 +81,6 @@ protected override bool RenderStroke(ISvgRenderer renderer, RenderCacheEntry cac protected internal override bool IntersectsWith(RectangleF rectangle, Matrix transform, int maxRecursion) { - if (this.HasFill()) - return true; - var units = Points.ToList(); var lineSegments = new List<(PointF from, PointF to)>(); @@ -96,8 +93,12 @@ protected internal override bool IntersectsWith(RectangleF rectangle, Matrix tra } // does not add last line which connects last point to first point as this would be a polygon // see: https://www.w3schools.com/graphics/svg_polyline.asp + // NOTE: the closing edge is intentionally not stroked, but SVG fills a polyline as if it were + // implicitly closed - IsIntersectingOrContainedWithinShape closes the vertex ring for the + // interior test, so a filled polyline is hit inside that implied closed area. - return lineSegments.IsIntersectingWithLine(transform, rectangle); + return lineSegments.IsIntersectingOrContainedWithinShape(transform, rectangle, + this.HasFill(), this.FillRule, this.GetStrokeHitTestTolerance()); } } } \ No newline at end of file diff --git a/Svg/Basic Shapes/SvgRectangle.cs b/Svg/Basic Shapes/SvgRectangle.cs index 536561a23..8914e6d2e 100644 --- a/Svg/Basic Shapes/SvgRectangle.cs +++ b/Svg/Basic Shapes/SvgRectangle.cs @@ -279,9 +279,6 @@ protected override void Render(ISvgRenderer renderer) protected internal override bool IntersectsWith(RectangleF rectangle, Matrix transform, int maxRecursion) { - if (this.HasFill()) - return true; - var leftTop = PointF.Create(this.X, this.Y); var rightTop = PointF.Create(this.X + this.Width, this.Y); var rightBottom = PointF.Create(this.X + this.Width, this.Y + this.Height); @@ -293,7 +290,8 @@ protected internal override bool IntersectsWith(RectangleF rectangle, Matrix tra lineSegments.Add((rightBottom.Clone(),leftBottom.Clone())); lineSegments.Add((leftBottom.Clone(), leftTop.Clone())); - return lineSegments.IsIntersectingWithLine(transform, rectangle); + return lineSegments.IsIntersectingOrContainedWithinShape(transform, rectangle, + this.HasFill(), this.FillRule, this.GetStrokeHitTestTolerance()); } public override SvgElement DeepCopy() diff --git a/Svg/Paths/SvgPath.cs b/Svg/Paths/SvgPath.cs index 3cf38cc14..d1a112b60 100644 --- a/Svg/Paths/SvgPath.cs +++ b/Svg/Paths/SvgPath.cs @@ -162,12 +162,21 @@ protected override bool RenderStroke(ISvgRenderer renderer, RenderCacheEntry cac protected internal override bool IntersectsWith(RectangleF rectangle, Matrix transform, int maxRecursion) { - if (this.HasFill()) - return true; - var lineSegments = PathData.GetLines(); - - return lineSegments.IsIntersectingWithLine(transform, rectangle); + + // NOTE: GetLines() flattens all subpaths into one segment list with no subpath breaks, so a + // multi-contour path (e.g. a "donut": an outer contour with an inner one cut out via fill-rule) + // is evaluated as a single self-intersecting ring instead of as independent contours. Measured + // directly (Svg.Tests.Win/SvgHitTests.cs, Intersect_MultiContourDonutPath_ObservesActualGetLinesBehavior): + // this is not merely "the hole isn't carved out" - for a simple two-square donut, the actual filled + // body between the two contours can also be wrongly read as unfilled. So this can under-select a + // compound path's real fill area, not just over-select its holes. Still strictly more accurate than + // the previous "any fill => whole bounding box counts" behaviour for single-contour paths (the + // overwhelming majority of real paths), but multi-contour paths need GetLines() to preserve subpath + // breaks (and the interior test to evaluate each contour independently) for a real fix - out of + // scope for this change. + return lineSegments.IsIntersectingOrContainedWithinShape(transform, rectangle, + this.HasFill(), this.FillRule, this.GetStrokeHitTestTolerance()); } diff --git a/Svg/Svg.csproj b/Svg/Svg.csproj index 6cd3c3d8d..44b5c5f60 100644 --- a/Svg/Svg.csproj +++ b/Svg/Svg.csproj @@ -3,10 +3,12 @@ netstandard2.0;net48;net10.0 PackageReference - 3.2.0-optiq07 + 3.2.0-optiq08 gentledpp,zepr Opti-Q GmbH + #3.2.0-optiq08 + circle/ellipse should only select on their outline, not their bounding box #3.2.0-optiq07 Tapping a point now only selects the top-most #3.2.0-optiq06 diff --git a/Svg/SvgHitTestingExtensions.cs b/Svg/SvgHitTestingExtensions.cs index fb6dccedb..1a8292ffe 100644 --- a/Svg/SvgHitTestingExtensions.cs +++ b/Svg/SvgHitTestingExtensions.cs @@ -196,16 +196,21 @@ internal static RectangleF GetTransformedChildBounds(this SvgVisualElement e, Ma /// the line segments to check /// the total transformation matrix for the current lines /// the area we want to intersect with the lines + /// + /// additional tolerance, in the same (untransformed) local units as , to widen the hit area by. + /// Used to account for a shape's own rendered stroke width ("fat finger" tolerance for thin/curved borders), so callers + /// don't need to manually scale it - it is converted to screen space using 's scale. + /// /// /// - public static bool IsIntersectingWithLine(this IList<(PointF from, PointF to)> lines, Matrix transform, RectangleF hitTestArea) + public static bool IsIntersectingWithLine(this IList<(PointF from, PointF to)> lines, Matrix transform, RectangleF hitTestArea, double extraToleranceInLocalUnits = 0) { if (lines == null) throw new ArgumentNullException(nameof(lines)); if (transform == null) throw new ArgumentNullException(nameof(transform)); if (hitTestArea == null) throw new ArgumentNullException(nameof(hitTestArea)); PointF tap = hitTestArea.GetCenterPoint(); - double selectionWidthHeight = hitTestArea.Width / 2; + double selectionWidthHeight = hitTestArea.Width / 2 + transform.ScaleTolerance(extraToleranceInLocalUnits); foreach (var lineSegment in lines) { @@ -223,25 +228,225 @@ public static bool IsIntersectingWithLine(this IList<(PointF from, PointF to)> l /// the line segments to check /// the total transformation matrix for the current lines /// the area we want to intersect with the lines + /// + /// additional tolerance, in the same (untransformed) local units as , to widen the hit area by. + /// Used to account for a shape's own rendered stroke width ("fat finger" tolerance for thin/curved borders), so callers + /// don't need to manually scale it - it is converted to screen space using 's scale. + /// /// /// - public static bool IsIntersectingWithLine(this (PointF from, PointF to) lineSegment, Matrix transform, RectangleF hitTestArea) + public static bool IsIntersectingWithLine(this (PointF from, PointF to) lineSegment, Matrix transform, RectangleF hitTestArea, double extraToleranceInLocalUnits = 0) { if (transform == null) throw new ArgumentNullException(nameof(transform)); if (hitTestArea == null) throw new ArgumentNullException(nameof(hitTestArea)); PointF tap = hitTestArea.GetCenterPoint(); - double selectionWidthHeight = hitTestArea.Width / 2; + double selectionWidthHeight = hitTestArea.Width / 2 + transform.ScaleTolerance(extraToleranceInLocalUnits); transform.TransformPoints(new[] { lineSegment.from, lineSegment.to }); if (IsLineHit(lineSegment.from, lineSegment.to, tap, selectionWidthHeight)) return true; - + return false; } + /// + /// Converts a tolerance expressed in local (untransformed) units into screen-space units, using the + /// average of the matrix's X/Y scale factors. Used to bring a shape's own StrokeWidth (a local-space + /// value) into the same space as the (already screen-space) hit test tolerance. + /// + /// ASSUMPTION - KNOWN INACCURATE UNDER ROTATION: transform.ScaleX/ScaleY are the raw m00/m11 matrix + /// components, not a decomposed scale magnitude - they only equal the true axis scale for a + /// non-rotated (and, for this average, roughly uniform) transform. Under rotation they mix with + /// SkewX/SkewY (e.g. both collapse toward 0 at a 90° rotation), and under strongly non-uniform scale + /// the average over/under-estimates one axis, so the resulting tolerance band is wrong in either case. + /// This is NOT just a theoretical edge case: the `transform` passed in here is not only the canvas's + /// zoom+pan matrix - HitTestInternal clones the incoming transform and mutates it with each element's + /// own SvgTransform (and any ancestor group's, accumulated during recursion) before calling + /// IntersectsWith. So a shape with its own transform="rotate(...)" attribute (e.g. via RotationTool) + /// hits this exact code path today, and its "fat finger" tolerance silently shrinks toward 0 near a + /// 90/270-degree rotation. Fix: use a rotation-invariant scale, e.g. sqrt(|ScaleX*ScaleY - SkewX*SkewY|) + /// (the determinant-based uniform scale, which reduces to this average for a pure uniform/non-rotated + /// scale but stays correct under rotation). + /// + private static double ScaleTolerance(this Matrix transform, double toleranceInLocalUnits) + { + if (toleranceInLocalUnits <= 0) + return 0; + + var scale = (Math.Abs(transform.ScaleX) + Math.Abs(transform.ScaleY)) / 2.0; + return toleranceInLocalUnits * scale; + } + + /// + /// Half of the element's own rendered stroke width, in local (untransformed) units, or 0 if it has no + /// stroke. StrokeWidth is resolved via ToDeviceValue (same conversion RenderStroke uses) rather than + /// its raw .Value, so non-pixel units (%, em, cm, ...) produce a meaningful tolerance instead of just + /// the bare number - no renderer is available during hit testing, so font-relative units (em/ex) fall + /// back to ToDeviceValue's built-in default-font-size guess. + /// Meant to be passed as extra tolerance to + /// so that tapping anywhere within a thick border's visible band counts as a hit, not just the exact + /// geometric outline ("fat finger" tolerance for border-only hit testing on unfilled shapes). + /// + internal static float GetStrokeHitTestTolerance(this SvgVisualElement element) + { + return element.HasStroke() ? element.StrokeWidth.ToDeviceValue(null, UnitRenderingType.Other, element) / 2 : 0; + } + + private const int EllipticalOutlineSegmentCount = 36; + + /// + /// Shared hit test for SvgCircle and SvgEllipse (a circle is just an ellipse with rx == ry == r). + /// Approximates the outline as a polygon, since there is no closed-form line-intersection test for a + /// curve like there is for straight-edged shapes (rectangle/polygon/path). The same polygon is reused + /// as the filled-interior region, so a filled ellipse is only hit inside its actual outline, not + /// anywhere in its bounding box. + /// + /// KNOWN LIMITATION (applies to both the outline AND the interior test): the fixed 36-segment count + /// means the chord/sagitta gap between the approximating polygon and the true curve grows with radius + /// and zoom (roughly r * 0.0038 in screen units at this segment count). This was previously only + /// documented as an outline-hit-test caveat; since the interior test now reuses these same polygon + /// vertices, a tap just inside the true curve but just outside the polygon (or vice versa) near the + /// border can misclassify a fill hit too, not only a border hit. + /// + internal static bool IntersectsWithEllipticalOutline(this SvgVisualElement element, RectangleF rectangle, Matrix transform, float cx, float cy, float rx, float ry) + { + var lineSegments = new List<(PointF from, PointF to)>(); + PointF previous = null; + for (var i = 0; i <= EllipticalOutlineSegmentCount; i++) + { + var angle = 2 * Math.PI * i / EllipticalOutlineSegmentCount; + var current = PointF.Create( + (float)(cx + rx * Math.Cos(angle)), + (float)(cy + ry * Math.Sin(angle))); + + if (previous != null) + lineSegments.Add((previous.Clone(), current.Clone())); + + previous = current; + } + + return lineSegments.IsIntersectingOrContainedWithinShape(transform, rectangle, + element.HasFill(), element.FillRule, element.GetStrokeHitTestTolerance()); + } + + /// + /// Border-or-interior hit test for a closed shape whose outline is described by . + /// A tap counts as a hit when it is either + /// + /// within the stroke band of any edge (the "fat finger" outline test - always applies, filled or not), or + /// inside the filled area, but only when is true. + /// + /// The interior test is a real point-in-polygon test honouring , so a filled + /// shape is only hit inside its actual outline - not anywhere in its (larger) bounding box, and not in + /// the empty regions of a self-intersecting or concave outline. + /// + /// Note: a fill's opacity (fill-opacity) does NOT affect - a fully transparent + /// but explicitly-set fill is still a real hit target (matching e.g. WPF's Background="Transparent" vs + /// Background="{x:Null}" distinction). Only a shape with no fill color at all (fill:none / unset) should + /// pass false here. + /// + /// the shape's outline, in local (untransformed) units + /// the total transformation matrix for the current shape + /// the area we want to intersect with the shape + /// whether the shape has a fill colour set at all (see ) - independent of fill-opacity + /// the shape's fill-rule, used to resolve the interior of self-intersecting outlines + /// extra outline tolerance in local units (e.g. half the stroke width) + /// + internal static bool IsIntersectingOrContainedWithinShape(this IList<(PointF from, PointF to)> lineSegments, + Matrix transform, RectangleF hitTestArea, bool hasFill, SvgFillRule fillRule, double extraToleranceInLocalUnits = 0) + { + if (lineSegments == null) throw new ArgumentNullException(nameof(lineSegments)); + if (transform == null) throw new ArgumentNullException(nameof(transform)); + if (hitTestArea == null) throw new ArgumentNullException(nameof(hitTestArea)); + + PointF tap = hitTestArea.GetCenterPoint(); + double selectionWidthHeight = hitTestArea.Width / 2 + transform.ScaleTolerance(extraToleranceInLocalUnits); + + // Transform a copy of every endpoint into screen space once, keeping the ordered vertex ring for the + // interior test. (We clone rather than transform in place so the ring survives for point-in-polygon.) + var vertices = new List(lineSegments.Count + 1); + for (var i = 0; i < lineSegments.Count; i++) + { + var from = lineSegments[i].from.Clone(); + var to = lineSegments[i].to.Clone(); + transform.TransformPoints(new[] { from, to }); + + // outline ("fat finger" stroke band) hit - always applies, filled or not + if (IsLineHit(from, to, tap, selectionWidthHeight)) + return true; + + vertices.Add(from); + if (i == lineSegments.Count - 1) + vertices.Add(to); + } + + // interior hit - only when the shape actually has a fill color set (opacity is irrelevant here) + return hasFill && IsPointInPolygon(vertices, tap, fillRule); + } + + /// + /// Point-in-polygon test honouring the SVG fill-rule: even-odd uses the crossing-number (ray-cast + /// parity) rule, everything else (non-zero, inherit) uses the winding-number rule. The two agree for a + /// simple (non-self-intersecting) ring and only differ for self-intersecting/overlapping outlines, + /// which is exactly where fill-rule becomes observable. + /// + private static bool IsPointInPolygon(IList polygon, PointF point, SvgFillRule fillRule) + { + if (polygon == null || polygon.Count < 3) + return false; + + return fillRule == SvgFillRule.EvenOdd + ? IsInsideEvenOdd(polygon, point) + : IsInsideNonZero(polygon, point); + } + + // crossing number / ray casting - "inside" toggles on each edge the horizontal ray crosses + private static bool IsInsideEvenOdd(IList poly, PointF p) + { + bool inside = false; + for (int i = 0, j = poly.Count - 1; i < poly.Count; j = i++) + { + var pi = poly[i]; + var pj = poly[j]; + if (((pi.Y > p.Y) != (pj.Y > p.Y)) && + (p.X < (pj.X - pi.X) * (p.Y - pi.Y) / (pj.Y - pi.Y) + pi.X)) + inside = !inside; + } + return inside; + } + + // winding number - counts signed crossings; non-zero total means inside + private static bool IsInsideNonZero(IList poly, PointF p) + { + int wn = 0; + for (int i = 0, j = poly.Count - 1; i < poly.Count; j = i++) + { + // edge from poly[j] to poly[i] + var a = poly[j]; + var b = poly[i]; + if (a.Y <= p.Y) + { + if (b.Y > p.Y && IsLeft(a, b, p) > 0) + wn++; + } + else + { + if (b.Y <= p.Y && IsLeft(a, b, p) < 0) + wn--; + } + } + return wn != 0; + } + + // >0 if p is left of the directed line a->b, <0 if right, 0 if collinear + private static double IsLeft(PointF a, PointF b, PointF p) + { + return (b.X - a.X) * (p.Y - a.Y) - (p.X - a.X) * (b.Y - a.Y); + } + /// /// Google paste https://stackoverflow.com/a/13741803/333571 ///