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.0enablelatest
- 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.0enablelatest
- 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.Editoren-USnet10.0
- 3.2.0-optiq07
+ 3.2.0-optiq08latest
+ #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.0PackageReference
- 3.2.0-optiq07
+ 3.2.0-optiq08gentledpp,zeprOpti-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
///