Skip to content

Commit fa122d7

Browse files
emmaussMrJul
andauthored
Implement visual geometry hit testing (#21768)
* Implement visual geometry hit testing * fix xml comment * fix xml comment * update api diff * Introduced ICompositionHitTester to avoid duplicated hit test code between point and geometry * Add geometry hit testing page to RenderDemo * fix geometry hit testing not including stroke * fix test * update api and tests * update api diff * Update GeometryHitTestingPage with new API * addressed reviews. * remove nullability in GeometryHitTestResult Visual hit --------- Co-authored-by: Julien Lebosquain <julien@lebosquain.net>
1 parent 77c9c25 commit fa122d7

35 files changed

Lines changed: 2116 additions & 168 deletions

api/Avalonia.nupkg.xml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,18 @@
151151
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
152152
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
153153
</Suppression>
154+
<Suppression>
155+
<DiagnosticId>CP0006</DiagnosticId>
156+
<Target>M:Avalonia.Platform.IGeometryImpl.FillContains(Avalonia.Platform.IGeometryImpl)</Target>
157+
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
158+
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
159+
</Suppression>
160+
<Suppression>
161+
<DiagnosticId>CP0006</DiagnosticId>
162+
<Target>M:Avalonia.Platform.IGeometryImpl.GetFillIntersectionResult(Avalonia.Platform.IGeometryImpl)</Target>
163+
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
164+
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
165+
</Suppression>
154166
<Suppression>
155167
<DiagnosticId>CP0006</DiagnosticId>
156168
<Target>M:Avalonia.Platform.Storage.IStorageProvider.OpenFilePickerWithResultAsync(Avalonia.Platform.Storage.FilePickerOpenOptions)</Target>
@@ -187,6 +199,18 @@
187199
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
188200
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
189201
</Suppression>
202+
<Suppression>
203+
<DiagnosticId>CP0006</DiagnosticId>
204+
<Target>M:Avalonia.Platform.IGeometryImpl.FillContains(Avalonia.Platform.IGeometryImpl)</Target>
205+
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
206+
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
207+
</Suppression>
208+
<Suppression>
209+
<DiagnosticId>CP0006</DiagnosticId>
210+
<Target>M:Avalonia.Platform.IGeometryImpl.GetFillIntersectionResult(Avalonia.Platform.IGeometryImpl)</Target>
211+
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
212+
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
213+
</Suppression>
190214
<Suppression>
191215
<DiagnosticId>CP0006</DiagnosticId>
192216
<Target>M:Avalonia.Platform.Storage.IStorageProvider.OpenFilePickerWithResultAsync(Avalonia.Platform.Storage.FilePickerOpenOptions)</Target>

samples/RenderDemo/MainWindow.xaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@
7272
<TabItem Header="Hit Testing">
7373
<pages:HitTestingPage />
7474
</TabItem>
75+
<TabItem Header="Geometry Hit Testing">
76+
<pages:GeometryHitTestingPage />
77+
</TabItem>
7578
<TabItem Header="SkCanvas">
7679
<pages:CustomSkiaPage />
7780
</TabItem>
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<UserControl xmlns="https://github.com/avaloniaui"
2+
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
3+
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
4+
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
5+
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
6+
x:Class="RenderDemo.Pages.GeometryHitTestingPage">
7+
<DockPanel>
8+
9+
<StackPanel DockPanel.Dock="Top"
10+
Margin="16"
11+
Spacing="4">
12+
<TextBlock x:Name="Status" />
13+
<StackPanel Orientation="Horizontal"
14+
Spacing="8">
15+
<TextBlock Text="Stroke thickness:"
16+
VerticalAlignment="Center" />
17+
<Slider x:Name="StrokeThickness"
18+
Width="240"
19+
Minimum="1"
20+
Maximum="40"
21+
Value="12"
22+
TickFrequency="1"
23+
IsSnapToTickEnabled="True" />
24+
<TextBlock Text="{Binding #StrokeThickness.Value}"
25+
VerticalAlignment="Center" />
26+
</StackPanel>
27+
</StackPanel>
28+
29+
<Grid x:Name="Scene"
30+
Background="Transparent"
31+
Cursor="None">
32+
<Ellipse x:Name="Ellipse"
33+
Margin="80,50,0,0"
34+
HorizontalAlignment="Left"
35+
VerticalAlignment="Top"
36+
Width="180"
37+
Height="120"
38+
Fill="#3B82F6"
39+
Stroke="#1E3A8A"
40+
StrokeThickness="{Binding #StrokeThickness.Value}" />
41+
42+
<Rectangle x:Name="Rectangle"
43+
Margin="0,60,50,0"
44+
HorizontalAlignment="Right"
45+
VerticalAlignment="Top"
46+
Width="180"
47+
Height="120"
48+
RadiusX="12"
49+
RadiusY="12"
50+
Fill="#22C55E"
51+
Stroke="#14532D"
52+
StrokeThickness="{Binding #StrokeThickness.Value}" />
53+
54+
<Path x:Name="Star"
55+
Margin="0,0,50,50"
56+
HorizontalAlignment="Right"
57+
VerticalAlignment="Bottom"
58+
Width="180"
59+
Height="180"
60+
Stretch="Uniform"
61+
Data="M 50,0 L 61,35 L 98,35 L 68,57 L 79,91 L 50,70 L 21,91 L 32,57 L 2,35 L 39,35 Z"
62+
Fill="#F43F5E"
63+
Stroke="#7F1D1D"
64+
StrokeThickness="{Binding #StrokeThickness.Value}" />
65+
66+
<Path x:Name="Probe"
67+
Stretch="None"
68+
IsVisible="False"
69+
IsHitTestVisible="False"
70+
Fill="#66FACC15"
71+
Stroke="#CA8A04"
72+
StrokeThickness="3" />
73+
</Grid>
74+
</DockPanel>
75+
76+
</UserControl>
77+
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
using System.Collections.Generic;
2+
using System.Linq;
3+
using Avalonia;
4+
using Avalonia.Controls;
5+
using Avalonia.Controls.Shapes;
6+
using Avalonia.Input;
7+
using Avalonia.Markup.Xaml;
8+
using Avalonia.Media;
9+
10+
namespace RenderDemo.Pages;
11+
12+
public class GeometryHitTestingPage : UserControl
13+
{
14+
private readonly IBrush _hitStroke = Brushes.Yellow;
15+
private readonly TranslateTransform _probePosition = new();
16+
private readonly Dictionary<Shape, IBrush?> _strokes = new();
17+
private readonly List<(Shape Shape, IntersectionResult Intersection)> _hits = new();
18+
private readonly Geometry _probeGeometry;
19+
private readonly Panel _scene;
20+
private readonly Path _probe;
21+
private readonly TextBlock _status;
22+
23+
public GeometryHitTestingPage()
24+
{
25+
InitializeComponent();
26+
27+
_scene = this.Get<Panel>("Scene");
28+
_probe = this.Get<Path>("Probe");
29+
_status = this.Get<TextBlock>("Status");
30+
31+
_probeGeometry = CreateProbeGeometry();
32+
_probeGeometry.Transform = _probePosition;
33+
_probe.Data = _probeGeometry;
34+
35+
foreach (var shape in _scene.Children.OfType<Shape>())
36+
{
37+
if (shape != _probe)
38+
_strokes.Add(shape, shape.Stroke);
39+
}
40+
41+
_scene.PointerEntered += SceneOnPointerMoved;
42+
_scene.PointerMoved += SceneOnPointerMoved;
43+
_scene.PointerExited += SceneOnPointerExited;
44+
45+
UpdateStatus();
46+
}
47+
48+
private void InitializeComponent()
49+
{
50+
AvaloniaXamlLoader.Load(this);
51+
}
52+
53+
private Geometry CreateProbeGeometry()
54+
=> new PolylineGeometry([new(0, -42), new(38, 26), new(-38, 26)], true);
55+
56+
private void SceneOnPointerMoved(object? sender, PointerEventArgs e)
57+
{
58+
_probe.IsVisible = true;
59+
MoveProbe(e.GetPosition(_scene));
60+
}
61+
62+
private void MoveProbe(Point position)
63+
{
64+
_probePosition.X = position.X;
65+
_probePosition.Y = position.Y;
66+
67+
ClearHits();
68+
69+
foreach (var result in _scene.GetInputElementsAt(_probeGeometry))
70+
{
71+
if (result?.VisualHit is Shape shape && _strokes.ContainsKey(shape))
72+
{
73+
shape.Stroke = _hitStroke;
74+
_hits.Add((shape, result.IntersectionResult));
75+
}
76+
}
77+
78+
UpdateStatus();
79+
}
80+
81+
private void SceneOnPointerExited(object? sender, PointerEventArgs e)
82+
{
83+
_probe.IsVisible = false;
84+
ClearHits();
85+
UpdateStatus();
86+
}
87+
88+
private void ClearHits()
89+
{
90+
foreach (var hit in _hits)
91+
{
92+
hit.Shape.Stroke = _strokes[hit.Shape];
93+
}
94+
95+
_hits.Clear();
96+
}
97+
98+
private void UpdateStatus()
99+
{
100+
if (_hits.Count == 0)
101+
{
102+
_status.Text = "No intersection";
103+
return;
104+
}
105+
106+
_status.Text = "Intersecting, " + string.Join(", ", _hits.Select(hit => $"{hit.Shape.Name} ({hit.Intersection})"));
107+
}
108+
}
109+

samples/RenderDemo/Pages/HitTestingPage.cs

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
using System;
2+
using System.Collections.Generic;
23
using System.Diagnostics;
4+
using System.Linq;
35
using System.Numerics;
46
using Avalonia;
57
using Avalonia.Animation;
68
using Avalonia.Animation.Easings;
79
using Avalonia.Controls;
10+
using Avalonia.Controls.Shapes;
811
using Avalonia.Input;
912
using Avalonia.Media;
1013
using Avalonia.Rendering.Composition;
@@ -15,6 +18,7 @@ namespace RenderDemo.Pages
1518
{
1619
public class HitTestingPage : UserControl
1720
{
21+
private const int RegionSize = 80;
1822
private const int GroupColumns = 8;
1923
private const int GroupRows = 5;
2024
private const int CellsPerGroupSide = 10;
@@ -44,6 +48,9 @@ public class HitTestingPage : UserControl
4448
private bool _animationsStarted;
4549
private Cell? _lastHitCell;
4650
private Cell? _lastClickedCell;
51+
private bool _regionSelection;
52+
private Ellipse _region;
53+
private List<Cell> _regionHits = new List<Cell>();
4754

4855
public HitTestingPage()
4956
{
@@ -54,6 +61,9 @@ public HitTestingPage()
5461
Background = Brushes.Transparent
5562
};
5663

64+
_scene.PointerMoved += Scene_PointerMoved;
65+
_scene.PointerPressed += Scene_PointerPressed;
66+
5767
_stats = new TextBlock
5868
{
5969
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
@@ -65,6 +75,31 @@ public HitTestingPage()
6575
IsHitTestVisible = false
6676
};
6777

78+
var regionSelection = new CheckBox()
79+
{
80+
Content = "Region Selection"
81+
};
82+
83+
_region = new Ellipse()
84+
{
85+
Width = RegionSize * 2,
86+
Height = RegionSize,
87+
Fill = Brushes.Transparent,
88+
StrokeThickness = 2,
89+
Stroke = Brushes.Green,
90+
IsVisible = false,
91+
IsHitTestVisible = false
92+
};
93+
94+
regionSelection.IsCheckedChanged += (s, e) =>
95+
{
96+
_regionSelection = !_regionSelection;
97+
98+
_region.IsVisible = _regionSelection;
99+
100+
ClearRegionHighlights();
101+
};
102+
68103
var numberOfHitTests = new NumericUpDown
69104
{
70105
Minimum = 0,
@@ -92,7 +127,8 @@ public HitTestingPage()
92127
Children =
93128
{
94129
new TextBlock { Text = "Hit tests per update:" },
95-
numberOfHitTests
130+
numberOfHitTests,
131+
regionSelection
96132
}
97133
};
98134

@@ -119,6 +155,57 @@ public HitTestingPage()
119155
ResetState();
120156
}
121157

158+
private void ClearRegionHighlights()
159+
{
160+
_lastClickedCell?.ClearHighlight();
161+
_lastClickedCell = null;
162+
foreach (var cell in _regionHits)
163+
{
164+
cell.ClearHighlight();
165+
}
166+
_regionHits.Clear();
167+
}
168+
169+
private void Scene_PointerPressed(object? sender, PointerPressedEventArgs e)
170+
{
171+
if(_regionSelection)
172+
{
173+
var bounds = _region.Bounds;
174+
175+
ClearRegionHighlights();
176+
177+
var geometry = new EllipseGeometry(bounds);
178+
179+
var cells = _scene.GetVisualsAt(geometry);
180+
181+
if (cells == null)
182+
return;
183+
184+
foreach (var cell in cells)
185+
{
186+
if(cell?.VisualHit is Cell c)
187+
{
188+
c.IsLatestClick = true;
189+
c.UpdateHighlight();
190+
_regionHits.Add(c);
191+
}
192+
}
193+
}
194+
}
195+
196+
private void Scene_PointerMoved(object? sender, PointerEventArgs e)
197+
{
198+
if(_regionSelection)
199+
{
200+
var point = e.GetPosition(_scene);
201+
202+
var rect = new Rect(point, new Size()).Inflate(new Thickness(_region.Bounds.Width / 2, _region.Bounds.Height / 2));
203+
204+
Canvas.SetLeft(_region, rect.X);
205+
Canvas.SetTop(_region, rect.Y);
206+
}
207+
}
208+
122209
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
123210
{
124211
base.OnAttachedToVisualTree(e);
@@ -177,6 +264,8 @@ private void BuildScene()
177264
}
178265
}
179266
}
267+
268+
_scene.Children.Add(_region);
180269
}
181270

182271
private void OnCompositionUpdate()
@@ -337,7 +426,7 @@ private void UpdateStats()
337426

338427
private void OnCellPointerPressed(object? sender, PointerPressedEventArgs e)
339428
{
340-
if (sender is not Cell cell)
429+
if (sender is not Cell cell || _regionSelection)
341430
return;
342431

343432
SetLastClickedCell(cell);

0 commit comments

Comments
 (0)