Browse Source

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>
pull/21974/head
Emmanuel Hansen 1 month ago
committed by GitHub
parent
commit
fa122d7735
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 24
      api/Avalonia.nupkg.xml
  2. 3
      samples/RenderDemo/MainWindow.xaml
  3. 77
      samples/RenderDemo/Pages/GeometryHitTestingPage.axaml
  4. 109
      samples/RenderDemo/Pages/GeometryHitTestingPage.axaml.cs
  5. 93
      samples/RenderDemo/Pages/HitTestingPage.cs
  6. 62
      src/Avalonia.Base/Input/InputExtensions.cs
  7. 12
      src/Avalonia.Base/Media/Geometry.cs
  8. 9
      src/Avalonia.Base/Media/GeometryHitTestResult.cs
  9. 33
      src/Avalonia.Base/Media/IntersectionResult.cs
  10. 7
      src/Avalonia.Base/Platform/IGeometryImpl.cs
  11. 2
      src/Avalonia.Base/Platform/LtrbRect.cs
  12. 41
      src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs
  13. 14
      src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs
  14. 109
      src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs
  15. 20
      src/Avalonia.Base/Rendering/Composition/ContainerVisual.cs
  16. 3
      src/Avalonia.Base/Rendering/Composition/Drawing/CompositionRenderData.cs
  17. 215
      src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.HitTest.cs
  18. 33
      src/Avalonia.Base/Rendering/Composition/HitTesting/CompositionHitTestAabbTree.cs
  19. 33
      src/Avalonia.Base/Rendering/Composition/HitTesting/GeometryCompositionHitTester.cs
  20. 17
      src/Avalonia.Base/Rendering/Composition/HitTesting/ICompositionHitTester.cs
  21. 22
      src/Avalonia.Base/Rendering/Composition/HitTesting/PointCompositionHitTester.cs
  22. 4
      src/Avalonia.Base/Rendering/Composition/Visual.cs
  23. 18
      src/Avalonia.Base/Rendering/ICustomHitTest.cs
  24. 24
      src/Avalonia.Base/Rendering/IRenderer.cs
  25. 16
      src/Avalonia.Base/Rendering/SceneGraph/CustomDrawOperation.cs
  26. 81
      src/Avalonia.Base/VisualTree/VisualExtensions.cs
  27. 171
      src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs
  28. 23
      src/Skia/Avalonia.Skia/GeometryImpl.cs
  29. 933
      tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs
  30. 6
      tests/Avalonia.Base.UnitTests/Rendering/CustomHitTestBorder.cs
  31. 2
      tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/DrawOperationTests.cs
  32. 38
      tests/Avalonia.Base.UnitTests/VisualTree/VisualExtensions_GetVisualsAt.cs
  33. 8
      tests/Avalonia.RenderTests/TestRenderRoot.cs
  34. 15
      tests/Avalonia.UnitTests/CompositorTestServices.cs
  35. 7
      tests/Avalonia.UnitTests/TestRoot.cs

24
api/Avalonia.nupkg.xml

@ -151,6 +151,18 @@
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Avalonia.Platform.IGeometryImpl.FillContains(Avalonia.Platform.IGeometryImpl)</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Avalonia.Platform.IGeometryImpl.GetFillIntersectionResult(Avalonia.Platform.IGeometryImpl)</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Avalonia.Platform.Storage.IStorageProvider.OpenFilePickerWithResultAsync(Avalonia.Platform.Storage.FilePickerOpenOptions)</Target>
@ -187,6 +199,18 @@
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Avalonia.Platform.IGeometryImpl.FillContains(Avalonia.Platform.IGeometryImpl)</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Avalonia.Platform.IGeometryImpl.GetFillIntersectionResult(Avalonia.Platform.IGeometryImpl)</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0006</DiagnosticId>
<Target>M:Avalonia.Platform.Storage.IStorageProvider.OpenFilePickerWithResultAsync(Avalonia.Platform.Storage.FilePickerOpenOptions)</Target>

3
samples/RenderDemo/MainWindow.xaml

@ -72,6 +72,9 @@
<TabItem Header="Hit Testing">
<pages:HitTestingPage />
</TabItem>
<TabItem Header="Geometry Hit Testing">
<pages:GeometryHitTestingPage />
</TabItem>
<TabItem Header="SkCanvas">
<pages:CustomSkiaPage />
</TabItem>

77
samples/RenderDemo/Pages/GeometryHitTestingPage.axaml

@ -0,0 +1,77 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="RenderDemo.Pages.GeometryHitTestingPage">
<DockPanel>
<StackPanel DockPanel.Dock="Top"
Margin="16"
Spacing="4">
<TextBlock x:Name="Status" />
<StackPanel Orientation="Horizontal"
Spacing="8">
<TextBlock Text="Stroke thickness:"
VerticalAlignment="Center" />
<Slider x:Name="StrokeThickness"
Width="240"
Minimum="1"
Maximum="40"
Value="12"
TickFrequency="1"
IsSnapToTickEnabled="True" />
<TextBlock Text="{Binding #StrokeThickness.Value}"
VerticalAlignment="Center" />
</StackPanel>
</StackPanel>
<Grid x:Name="Scene"
Background="Transparent"
Cursor="None">
<Ellipse x:Name="Ellipse"
Margin="80,50,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Width="180"
Height="120"
Fill="#3B82F6"
Stroke="#1E3A8A"
StrokeThickness="{Binding #StrokeThickness.Value}" />
<Rectangle x:Name="Rectangle"
Margin="0,60,50,0"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Width="180"
Height="120"
RadiusX="12"
RadiusY="12"
Fill="#22C55E"
Stroke="#14532D"
StrokeThickness="{Binding #StrokeThickness.Value}" />
<Path x:Name="Star"
Margin="0,0,50,50"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Width="180"
Height="180"
Stretch="Uniform"
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"
Fill="#F43F5E"
Stroke="#7F1D1D"
StrokeThickness="{Binding #StrokeThickness.Value}" />
<Path x:Name="Probe"
Stretch="None"
IsVisible="False"
IsHitTestVisible="False"
Fill="#66FACC15"
Stroke="#CA8A04"
StrokeThickness="3" />
</Grid>
</DockPanel>
</UserControl>

109
samples/RenderDemo/Pages/GeometryHitTestingPage.axaml.cs

@ -0,0 +1,109 @@
using System.Collections.Generic;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
namespace RenderDemo.Pages;
public class GeometryHitTestingPage : UserControl
{
private readonly IBrush _hitStroke = Brushes.Yellow;
private readonly TranslateTransform _probePosition = new();
private readonly Dictionary<Shape, IBrush?> _strokes = new();
private readonly List<(Shape Shape, IntersectionResult Intersection)> _hits = new();
private readonly Geometry _probeGeometry;
private readonly Panel _scene;
private readonly Path _probe;
private readonly TextBlock _status;
public GeometryHitTestingPage()
{
InitializeComponent();
_scene = this.Get<Panel>("Scene");
_probe = this.Get<Path>("Probe");
_status = this.Get<TextBlock>("Status");
_probeGeometry = CreateProbeGeometry();
_probeGeometry.Transform = _probePosition;
_probe.Data = _probeGeometry;
foreach (var shape in _scene.Children.OfType<Shape>())
{
if (shape != _probe)
_strokes.Add(shape, shape.Stroke);
}
_scene.PointerEntered += SceneOnPointerMoved;
_scene.PointerMoved += SceneOnPointerMoved;
_scene.PointerExited += SceneOnPointerExited;
UpdateStatus();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private Geometry CreateProbeGeometry()
=> new PolylineGeometry([new(0, -42), new(38, 26), new(-38, 26)], true);
private void SceneOnPointerMoved(object? sender, PointerEventArgs e)
{
_probe.IsVisible = true;
MoveProbe(e.GetPosition(_scene));
}
private void MoveProbe(Point position)
{
_probePosition.X = position.X;
_probePosition.Y = position.Y;
ClearHits();
foreach (var result in _scene.GetInputElementsAt(_probeGeometry))
{
if (result?.VisualHit is Shape shape && _strokes.ContainsKey(shape))
{
shape.Stroke = _hitStroke;
_hits.Add((shape, result.IntersectionResult));
}
}
UpdateStatus();
}
private void SceneOnPointerExited(object? sender, PointerEventArgs e)
{
_probe.IsVisible = false;
ClearHits();
UpdateStatus();
}
private void ClearHits()
{
foreach (var hit in _hits)
{
hit.Shape.Stroke = _strokes[hit.Shape];
}
_hits.Clear();
}
private void UpdateStatus()
{
if (_hits.Count == 0)
{
_status.Text = "No intersection";
return;
}
_status.Text = "Intersecting, " + string.Join(", ", _hits.Select(hit => $"{hit.Shape.Name} ({hit.Intersection})"));
}
}

93
samples/RenderDemo/Pages/HitTestingPage.cs

@ -1,10 +1,13 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Numerics;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Rendering.Composition;
@ -15,6 +18,7 @@ namespace RenderDemo.Pages
{
public class HitTestingPage : UserControl
{
private const int RegionSize = 80;
private const int GroupColumns = 8;
private const int GroupRows = 5;
private const int CellsPerGroupSide = 10;
@ -44,6 +48,9 @@ namespace RenderDemo.Pages
private bool _animationsStarted;
private Cell? _lastHitCell;
private Cell? _lastClickedCell;
private bool _regionSelection;
private Ellipse _region;
private List<Cell> _regionHits = new List<Cell>();
public HitTestingPage()
{
@ -54,6 +61,9 @@ namespace RenderDemo.Pages
Background = Brushes.Transparent
};
_scene.PointerMoved += Scene_PointerMoved;
_scene.PointerPressed += Scene_PointerPressed;
_stats = new TextBlock
{
HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
@ -65,6 +75,31 @@ namespace RenderDemo.Pages
IsHitTestVisible = false
};
var regionSelection = new CheckBox()
{
Content = "Region Selection"
};
_region = new Ellipse()
{
Width = RegionSize * 2,
Height = RegionSize,
Fill = Brushes.Transparent,
StrokeThickness = 2,
Stroke = Brushes.Green,
IsVisible = false,
IsHitTestVisible = false
};
regionSelection.IsCheckedChanged += (s, e) =>
{
_regionSelection = !_regionSelection;
_region.IsVisible = _regionSelection;
ClearRegionHighlights();
};
var numberOfHitTests = new NumericUpDown
{
Minimum = 0,
@ -92,7 +127,8 @@ namespace RenderDemo.Pages
Children =
{
new TextBlock { Text = "Hit tests per update:" },
numberOfHitTests
numberOfHitTests,
regionSelection
}
};
@ -119,6 +155,57 @@ namespace RenderDemo.Pages
ResetState();
}
private void ClearRegionHighlights()
{
_lastClickedCell?.ClearHighlight();
_lastClickedCell = null;
foreach (var cell in _regionHits)
{
cell.ClearHighlight();
}
_regionHits.Clear();
}
private void Scene_PointerPressed(object? sender, PointerPressedEventArgs e)
{
if(_regionSelection)
{
var bounds = _region.Bounds;
ClearRegionHighlights();
var geometry = new EllipseGeometry(bounds);
var cells = _scene.GetVisualsAt(geometry);
if (cells == null)
return;
foreach (var cell in cells)
{
if(cell?.VisualHit is Cell c)
{
c.IsLatestClick = true;
c.UpdateHighlight();
_regionHits.Add(c);
}
}
}
}
private void Scene_PointerMoved(object? sender, PointerEventArgs e)
{
if(_regionSelection)
{
var point = e.GetPosition(_scene);
var rect = new Rect(point, new Size()).Inflate(new Thickness(_region.Bounds.Width / 2, _region.Bounds.Height / 2));
Canvas.SetLeft(_region, rect.X);
Canvas.SetTop(_region, rect.Y);
}
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
@ -177,6 +264,8 @@ namespace RenderDemo.Pages
}
}
}
_scene.Children.Add(_region);
}
private void OnCompositionUpdate()
@ -337,7 +426,7 @@ namespace RenderDemo.Pages
private void OnCellPointerPressed(object? sender, PointerPressedEventArgs e)
{
if (sender is not Cell cell)
if (sender is not Cell cell || _regionSelection)
return;
SetLastClickedCell(cell);

62
src/Avalonia.Base/Input/InputExtensions.cs

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Media;
using Avalonia.VisualTree;
#nullable enable
@ -31,10 +32,29 @@ namespace Avalonia.Input
return (element as Visual)?.GetVisualsAt(p, enabledElementsOnly ? s_hitTestEnabledOnlyDelegate : s_hitTestDelegate).Cast<IInputElement>() ??
Enumerable.Empty<IInputElement>();
}
/// <inheritdoc cref="GetInputElementsAt(IInputElement, Point, bool)"/>
public static IEnumerable<IInputElement> GetInputElementsAt(this IInputElement element, Point p) => GetInputElementsAt(element, p, true);
/// <summary>
/// Returns the active input elements intersecting a geometry on an <see cref="IInputElement"/>.
/// </summary>
/// <param name="element">The element to test.</param>
/// <param name="geometry">The geometry on <paramref name="element"/>.</param>
/// <param name="enabledElementsOnly">Whether to only return elements for which <see cref="IInputElement.IsEffectivelyEnabled"/> is true.</param>
/// <returns>
/// The active input elements found intersecting the geometry, with intersection details, ordered topmost first.
/// </returns>
public static IEnumerable<GeometryHitTestResult> GetInputElementsAt(this IInputElement element, Geometry geometry, bool enabledElementsOnly = true)
{
element = element ?? throw new ArgumentNullException(nameof(element));
return (element as Visual)?.GetVisualsAt(geometry, enabledElementsOnly ? s_hitTestEnabledOnlyDelegate : s_hitTestDelegate)
.Where(x => x.VisualHit is IInputElement)
.Select(x => new GeometryHitTestResult(x.VisualHit, x.IntersectionResult)) ??
Enumerable.Empty<GeometryHitTestResult>();
}
/// <summary>
/// Returns the topmost active input element at a point on an <see cref="IInputElement"/>.
/// </summary>
@ -52,6 +72,22 @@ namespace Avalonia.Input
/// <inheritdoc cref="InputHitTest(IInputElement, Point, bool)"/>
public static IInputElement? InputHitTest(this IInputElement element, Point p) => InputHitTest(element, p, true);
/// <summary>
/// Returns the topmost active input element intersecting a geometry on an <see cref="IInputElement"/>.
/// </summary>
/// <param name="element">The element to test.</param>
/// <param name="geometry">The geometry on <paramref name="element"/>.</param>
/// <param name="enabledElementsOnly">Whether to only return elements for which <see cref="IInputElement.IsEffectivelyEnabled"/> is true.</param>
/// <returns>A <see cref="GeometryHitTestResult"/> containing the topmost <see cref="IInputElement"/> intersecting the specified geometry and intersection details, or null if no element intersects.</returns>
public static GeometryHitTestResult? InputHitTest(this IInputElement element, Geometry geometry, bool enabledElementsOnly = true)
{
element = element ?? throw new ArgumentNullException(nameof(element));
var hitTest = (element as Visual)?.GetVisualAt(geometry, enabledElementsOnly ? s_hitTestEnabledOnlyDelegate : s_hitTestDelegate);
return hitTest?.VisualHit is IInputElement inputElement ? hitTest : null;
}
/// <summary>
/// Returns the topmost active input element at a point on an <see cref="IInputElement"/>.
/// </summary>
@ -79,6 +115,30 @@ namespace Avalonia.Input
/// <inheritdoc cref="InputHitTest(IInputElement, Point, Func{Visual, bool}, bool)"/>
public static IInputElement? InputHitTest(this IInputElement element, Point p, Func<Visual, bool> filter) => InputHitTest(element, p, filter, true);
/// <summary>
/// Returns the topmost active input element intersecting a geometry on an <see cref="IInputElement"/>.
/// </summary>
/// <param name="element">The element to test.</param>
/// <param name="geometry">The geometry on <paramref name="element"/>.</param>
/// <param name="filter">
/// A filter predicate. If the predicate returns false then the visual and all its
/// children will be excluded from the results.
/// </param>
/// <param name="enabledElementsOnly">Whether to only return elements for which <see cref="IInputElement.IsEffectivelyEnabled"/> is true.</param>
/// <returns>A <see cref="GeometryHitTestResult"/> containing the topmost <see cref="IInputElement"/> intersecting the specified geometry and intersection details, or null if no element intersects.</returns>
public static IInputElement? InputHitTest(
this IInputElement element,
Geometry geometry,
Func<Visual, bool> filter,
bool enabledElementsOnly = true)
{
element = element ?? throw new ArgumentNullException(nameof(element));
filter = filter ?? throw new ArgumentNullException(nameof(filter));
var hitTestDelegate = enabledElementsOnly ? s_hitTestEnabledOnlyDelegate : s_hitTestDelegate;
return (element as Visual)?.GetVisualAt(geometry, x => hitTestDelegate(x) && filter(x)) as IInputElement;
}
private static bool IsHitTestVisible(Visual visual) => visual is { IsVisible: true, IsAttachedToVisualTree: true } and IInputElement { IsHitTestVisible: true };
private static bool IsHitTestVisible_EnabledOnly(Visual visual) => IsHitTestVisible(visual) && visual is IInputElement { IsEffectivelyEnabled: true };

12
src/Avalonia.Base/Media/Geometry.cs

@ -1,8 +1,8 @@
using System;
using Avalonia.Platform;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Avalonia.Platform;
using Avalonia.Reactive;
using Avalonia.Rendering.Composition;
using Avalonia.Rendering.Composition.Drawing;
@ -120,6 +120,16 @@ namespace Avalonia.Media
return PlatformImpl?.FillContains(point) == true;
}
/// <summary>
/// Returns a value that describes the intersection between the current geometry and the specified geometry
/// </summary>
/// <param name="geometry">The geometry to test for containment.</param>
/// <returns>The <see cref="IntersectionResult"/> describing the intersection between the geometries</returns>
public IntersectionResult? GetFillIntersectionResult(Geometry geometry)
{
return geometry.PlatformImpl == null ? IntersectionResult.Empty : PlatformImpl?.GetFillIntersectionResult(geometry.PlatformImpl);
}
/// <summary>
/// Indicates whether the geometry's stroke contains the specified point.
/// </summary>

9
src/Avalonia.Base/Media/GeometryHitTestResult.cs

@ -0,0 +1,9 @@
namespace Avalonia.Media
{
/// <summary>
/// Returns the results of a hit test that uses a <see cref="Geometry"/> as a hit test parameter.
/// </summary>
/// <param name="VisualHit">Gets the <see cref="Visual"/> that is returned from a hit test result.</param>
/// <param name="IntersectionResult">Gets the <see cref="IntersectionResult"/> value of the hit test.</param>
public record class GeometryHitTestResult(Visual VisualHit, IntersectionResult IntersectionResult);
}

33
src/Avalonia.Base/Media/IntersectionResult.cs

@ -0,0 +1,33 @@
namespace Avalonia.Media
{
/// <summary>
/// Provides information about the intersection between a hit geometry and a target geometry or visual.
/// </summary>
public enum IntersectionResult
{
/// <summary>
/// The IntersectionDetail value is not calculated.
/// </summary>
NotCalculated = 0,
/// <summary>
/// The <see cref="Geometry"/> hit test parameter and the target visual, or geometry, do not intersect.
/// </summary>
Empty = 1,
/// <summary>
/// The target geometry or visual is fully inside the hit test <see cref="Geometry"/>.
/// </summary>
FullyInside = 2,
/// <summary>
/// The <see cref="Geometry"/> hit test parameter is fully contained within the boundary of the target visual or geometry.
/// </summary>
FullyContains = 3,
/// <summary>
/// The <see cref="Geometry"/> hit test parameter and the target visual, or geometry, intersect. This means that the two elements overlap, but neither element contains the other.
/// </summary>
Intersects = 4
}
}

7
src/Avalonia.Base/Platform/IGeometryImpl.cs

@ -46,6 +46,13 @@ namespace Avalonia.Platform
/// <returns><c>true</c> if the geometry contains the point; otherwise, <c>false</c>.</returns>
bool FillContains(Point point);
/// <summary>
/// Returns a value that describes the intersection between the current geometry and the specified geometry
/// </summary>
/// <param name="geometry">The geometry to test for containment.</param>
/// <returns>The <see cref="IntersectionResult"/> describing the intersection between the geometries</returns>
IntersectionResult GetFillIntersectionResult(IGeometryImpl geometry);
/// <summary>
/// Intersects the geometry with another geometry.
/// </summary>

2
src/Avalonia.Base/Platform/LtrbRect.cs

@ -301,4 +301,4 @@ public struct LtrbPixelRect
internal static LtrbPixelRect FromRectUnscaled(LtrbRect rect) =>
new((int)rect.Left, (int)rect.Top, (int)Math.Ceiling(rect.Right),
(int)Math.Ceiling(rect.Bottom));
}
}

41
src/Avalonia.Base/Rendering/Composition/CompositingRenderer.cs

@ -4,9 +4,10 @@ using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Diagnostics;
using Avalonia.Platform.Surfaces;
using Avalonia.Media;
using Avalonia.Platform.Surfaces;
using Avalonia.Rendering.Composition.Drawing;
using Avalonia.Rendering.Composition.HitTesting;
using Avalonia.Threading;
namespace Avalonia.Rendering.Composition;
@ -92,6 +93,14 @@ internal class CompositingRenderer : IRendererWithCompositor, IHitTester
/// <inheritdoc/>
public IEnumerable<Visual> HitTest(Point p, Visual? root, Func<Visual, bool>? filter)
=> HitTest<PointCompositionHitTester, Point, Visual>(p, root, filter, (visual, _) => visual);
public IEnumerable<GeometryHitTestResult> HitTest(Geometry geometry, Visual? root, Func<Visual, bool>? filter)
=> HitTest<GeometryCompositionHitTester, Geometry, GeometryHitTestResult>(geometry, root, filter, (visual, intersection) => new GeometryHitTestResult(visual, intersection));
private IEnumerable<TResult> HitTest<THitTester, T, TResult>(T input, Visual? root, Func<Visual, bool>? filter, Func<Visual, IntersectionResult, TResult> resultSelector)
where THitTester : struct, ICompositionHitTester<T>
where TResult : class
{
using var _ = Diagnostic.PerformingHitTest();
@ -102,7 +111,7 @@ internal class CompositingRenderer : IRendererWithCompositor, IHitTester
yield break;
rootVisual = root.CompositionVisual;
}
Func<CompositionVisual, bool>? f = null;
if (filter != null)
f = v =>
@ -112,22 +121,37 @@ internal class CompositingRenderer : IRendererWithCompositor, IHitTester
return true;
};
using var res = CompositionTarget.TryHitTest(p, rootVisual, f);
if(res == null)
using var res = CompositionTarget.TryHitTest<THitTester, T>(input, rootVisual, f);
if (res == null)
yield break;
foreach(var v in res)
foreach (var v in res)
{
if (v is CompositionDrawListVisual dv)
if (v.Item2 is CompositionDrawListVisual dv)
{
if (filter == null || filter(dv.Visual))
yield return dv.Visual;
yield return resultSelector(dv.Visual, v.Item1);
}
}
}
/// <inheritdoc/>
public Visual? HitTestFirst(Point p, Visual root, Func<Visual, bool>? filter)
=> HitTestFirst<PointCompositionHitTester, Point>(p, root, filter, out _);
/// <inheritdoc/>
public GeometryHitTestResult? HitTestFirst(Geometry geometry, Visual root, Func<Visual, bool>? filter)
{
var visual = HitTestFirst<GeometryCompositionHitTester, Geometry>(geometry, root, filter, out var intersectionResult);
return visual != null ? new GeometryHitTestResult(visual, intersectionResult) : null;
}
private Visual? HitTestFirst<THitTester, T>(T input, Visual root, Func<Visual, bool>? filter, out IntersectionResult intersectionResult)
where THitTester : struct, ICompositionHitTester<T>
{
intersectionResult = IntersectionResult.NotCalculated;
using var _ = Diagnostic.PerformingHitTest();
if (root.CompositionVisual == null)
@ -137,7 +161,8 @@ internal class CompositingRenderer : IRendererWithCompositor, IHitTester
? null :
v => v is not CompositionDrawListVisual dlv || filter(dlv.Visual);
return CompositionTarget.TryHitTestFirst(p, root.CompositionVisual, f, static v => v is CompositionDrawListVisual) is CompositionDrawListVisual dv ? dv.Visual : null;
return CompositionTarget.TryHitTestFirst<THitTester, T>(input, root.CompositionVisual, f, static v => v is CompositionDrawListVisual, out intersectionResult)
is CompositionDrawListVisual dv ? dv.Visual : null;
}
/// <inheritdoc/>

14
src/Avalonia.Base/Rendering/Composition/CompositionDrawListVisual.cs

@ -1,3 +1,4 @@
using Avalonia.Media;
using Avalonia.Rendering.Composition.Drawing;
using Avalonia.Rendering.Composition.Server;
using Avalonia.Rendering.Composition.Transport;
@ -65,4 +66,17 @@ internal class CompositionDrawListVisual : CompositionContainerVisual
return DrawList?.HitTest(pt) ?? false;
}
internal override IntersectionResult HitTest(Geometry geometry)
{
var custom = Visual as ICustomHitTest;
if (DrawList == null && custom == null)
return IntersectionResult.Empty;
if (custom != null)
{
return custom.HitTest(geometry);
}
return DrawList?.HitTest(geometry) ?? IntersectionResult.Empty;
}
}

109
src/Avalonia.Base/Rendering/Composition/CompositionTarget.cs

@ -1,7 +1,9 @@
using System;
using System.Numerics;
using System.Diagnostics.CodeAnalysis;
using Avalonia.Collections.Pooled;
using Avalonia.VisualTree;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering.Composition.HitTesting;
namespace Avalonia.Rendering.Composition
{
@ -24,12 +26,16 @@ namespace Avalonia.Rendering.Composition
if (Root != null)
Root.Root = null;
}
/// <summary>
/// Attempts to perform a hit-tst
/// Attempts to perform a hit-test.
/// </summary>
/// <returns></returns>
public PooledList<CompositionVisual>? TryHitTest(Point point, CompositionVisual? root, Func<CompositionVisual, bool>? filter)
/// <returns>A list of visuals hit during the test.</returns>
public PooledList<(IntersectionResult, CompositionVisual)>? TryHitTest<THitTester, T>(
T input,
CompositionVisual? root,
Func<CompositionVisual, bool>? filter)
where THitTester : struct, ICompositionHitTester<T>
{
Server.Compositor.Readback.NextRead();
@ -43,10 +49,11 @@ namespace Avalonia.Rendering.Composition
var readback = root.TryGetValidReadback();
if (readback == null)
return null;
point = point.Transform(readback.Matrix);
var res = new PooledList<CompositionVisual>();
HitTestCore(root, point, res, filter);
var parentInput = THitTester.Transform(input, in readback.Matrix);
var res = new PooledList<(IntersectionResult, CompositionVisual)>();
HitTestCore<THitTester, T>(root, parentInput, res, filter);
return res;
}
@ -77,31 +84,37 @@ namespace Avalonia.Rendering.Composition
}
}
private void HitTestCore(CompositionVisual visual, Point parentPoint, PooledList<CompositionVisual> result, Func<CompositionVisual, bool>? filter)
private void HitTestCore<THitTester, T>(
CompositionVisual visual,
T parentInput,
PooledList<(IntersectionResult, CompositionVisual)> result,
Func<CompositionVisual, bool>? filter)
where THitTester : struct, ICompositionHitTester<T>
{
if (!HitTestVisual(visual, parentPoint, filter, out var point))
if (!HitTestVisual<THitTester, T>(visual, parentInput, filter, out var input))
return;
// Inspect children
if (visual is CompositionContainerVisual cv)
HitTestChildren(cv, point, result, filter);
HitTestChildren<THitTester, T>(cv, input, result, filter);
// Hit-test the current node
if (visual.HitTest(point))
result.Add(visual);
if (THitTester.HitTest(visual, input) is IntersectionResult intersectionResult && intersectionResult > IntersectionResult.Empty)
result.Add((intersectionResult, visual));
}
private void HitTestChildren(CompositionContainerVisual visual, Point point, PooledList<CompositionVisual> result, Func<CompositionVisual, bool>? filter)
private void HitTestChildren<THitTester, T>(CompositionContainerVisual visual, T input, PooledList<(IntersectionResult, CompositionVisual)> result, Func<CompositionVisual, bool>? filter)
where THitTester : struct, ICompositionHitTester<T>
{
if (visual.Children.Count >= CompositionContainerVisual.HitTestAabbTreeThreshold)
{
var candidates = RentHitTestChildCandidates(out var releaseToField);
try
{
if (visual.TryQueryHitTestChildren(point, candidates))
if (visual.TryQueryHitTestChildren<THitTester, T>(input, candidates))
{
foreach (var child in candidates)
HitTestCore(child, point, result, filter);
HitTestCore<THitTester, T>(child, input, result, filter);
return;
}
}
@ -112,47 +125,58 @@ namespace Avalonia.Rendering.Composition
}
for (var c = visual.Children.Count - 1; c >= 0; c--)
HitTestCore(visual.Children[c], point, result, filter);
HitTestCore<THitTester, T>(visual.Children[c], input, result, filter);
}
private static bool HitTestVisual(CompositionVisual visual, Point parentPoint, Func<CompositionVisual, bool>? filter, out Point point)
private static bool HitTestVisual<THitTester, T>(
CompositionVisual visual,
T parentInput,
Func<CompositionVisual, bool>? filter,
[MaybeNullWhen(false)] out T input)
where THitTester : struct, ICompositionHitTester<T>
{
point = default;
input = default;
if (visual.Visible == false)
if (!visual.Visible)
return false;
if (filter != null && !filter(visual))
return false;
var readback = visual.TryGetValidReadback();
if(readback == null)
if (readback == null)
return false;
if (!visual.DisableSubTreeBoundsHitTestOptimization &&
(readback.TransformedSubtreeBounds == null ||
!readback.TransformedSubtreeBounds.Value.Contains(parentPoint)))
(readback.TransformedSubtreeBounds is not { } transformedSubtreeBounds ||
!THitTester.TransformedSubTreeBoundsMatch(transformedSubtreeBounds, parentInput)))
return false;
if(!readback.Matrix.TryInvert(out var invMatrix))
if (!readback.Matrix.TryInvert(out var invMatrix))
return false;
point = parentPoint.Transform(invMatrix);
input = THitTester.Transform(parentInput, in invMatrix);
if (visual.ClipToBounds
&& (point.X < 0 || point.Y < 0 || point.X > visual.Size.X || point.Y > visual.Size.Y))
if (visual.ClipToBounds && !THitTester.ClippedBoundsMatch(visual, input))
return false;
if (visual.Clip?.FillContains(point) == false)
if (visual.Clip is { } clip && !THitTester.ClipMatches(clip, input))
return false;
return true;
}
public CompositionVisual? TryHitTestFirst(Point point, CompositionVisual? root, Func<CompositionVisual, bool>? filter, Func<CompositionVisual, bool>? resultFilter)
public CompositionVisual? TryHitTestFirst<THitTester, T>(
T input,
CompositionVisual? root,
Func<CompositionVisual, bool>? filter,
Func<CompositionVisual, bool>? resultFilter, out IntersectionResult intersectionResult)
where THitTester : struct, ICompositionHitTester<T>
{
Server.Compositor.Readback.NextRead();
intersectionResult = IntersectionResult.NotCalculated;
root ??= Root;
if (root == null)
return null;
@ -164,12 +188,21 @@ namespace Avalonia.Rendering.Composition
if (readback == null)
return null;
return HitTestFirstCore(root, point.Transform(readback.Matrix), filter, resultFilter);
var parentInput = THitTester.Transform(input, in readback.Matrix);
return HitTestFirstCore<THitTester, T>(root, parentInput, filter, resultFilter, out intersectionResult);
}
internal CompositionVisual? HitTestFirstCore(CompositionVisual visual, Point parentPoint, Func<CompositionVisual, bool>? filter, Func<CompositionVisual, bool>? resultFilter)
internal CompositionVisual? HitTestFirstCore<THitTester, T>(
CompositionVisual visual,
T parentInput,
Func<CompositionVisual, bool>? filter,
Func<CompositionVisual, bool>? resultFilter, out IntersectionResult intersectionResult)
where THitTester : struct, ICompositionHitTester<T>
{
if (!HitTestVisual(visual, parentPoint, filter, out var point))
intersectionResult = IntersectionResult.NotCalculated;
if (!HitTestVisual<THitTester, T>(visual, parentInput, filter, out var input))
return null;
if (visual is CompositionContainerVisual cv)
@ -177,7 +210,7 @@ namespace Avalonia.Rendering.Composition
var queriedIndexedChildren = false;
if (cv.Children.Count >= CompositionContainerVisual.HitTestAabbTreeThreshold)
{
if (cv.TryQueryFirstHitTestChild(this, point, filter, resultFilter, out var hit))
if (cv.TryQueryFirstHitTestChild<THitTester, T>(this, input, filter, resultFilter, out var hit, out intersectionResult))
{
queriedIndexedChildren = true;
if (hit != null)
@ -189,14 +222,16 @@ namespace Avalonia.Rendering.Composition
{
for (var c = cv.Children.Count - 1; c >= 0; c--)
{
var hit = HitTestFirstCore(cv.Children[c], point, filter, resultFilter);
var hit = HitTestFirstCore<THitTester, T>(cv.Children[c], input, filter, resultFilter, out intersectionResult);
if (hit != null)
return hit;
}
}
}
return visual.HitTest(point) && (resultFilter == null || resultFilter(visual)) ? visual : null;
intersectionResult = THitTester.HitTest(visual, input);
return intersectionResult > IntersectionResult.Empty && (resultFilter == null || resultFilter(visual)) ? visual : null;
}
/// <summary>

20
src/Avalonia.Base/Rendering/Composition/ContainerVisual.cs

@ -1,6 +1,7 @@
using System;
using Avalonia.Collections.Pooled;
using Avalonia.Rendering.Composition.Server;
using Avalonia.Media;
using Avalonia.Rendering.Composition.HitTesting;
namespace Avalonia.Rendering.Composition
{
@ -53,7 +54,8 @@ namespace Avalonia.Rendering.Composition
_hitTestChildren?.Clear();
}
internal bool TryQueryHitTestChildren(Point point, PooledList<CompositionVisual> results)
internal bool TryQueryHitTestChildren<THitTester, T>(T input, PooledList<CompositionVisual> results)
where THitTester : struct, ICompositionHitTester<T>
{
if (Children.Count < HitTestAabbTreeThreshold)
{
@ -63,22 +65,30 @@ namespace Avalonia.Rendering.Composition
_hitTestChildren ??= new CompositionHitTestAabbTree(Children);
_hitTestChildren.Query(point, results, Server.Compositor.Readback.ReadRevision);
_hitTestChildren.Query<THitTester, T>(input, results, Server.Compositor.Readback.ReadRevision);
return true;
}
internal bool TryQueryFirstHitTestChild(CompositionTarget target, Point point, Func<CompositionVisual, bool>? filter, Func<CompositionVisual, bool>? resultFilter, out CompositionVisual? hit)
internal bool TryQueryFirstHitTestChild<THitTester, T>(
CompositionTarget target,
T input,
Func<CompositionVisual, bool>? filter,
Func<CompositionVisual, bool>? resultFilter,
out CompositionVisual? hit,
out IntersectionResult intersectionResult)
where THitTester : struct, ICompositionHitTester<T>
{
if (Children.Count < HitTestAabbTreeThreshold)
{
_hitTestChildren = null;
hit = null;
intersectionResult = IntersectionResult.Empty;
return false;
}
_hitTestChildren ??= new CompositionHitTestAabbTree(Children);
hit = _hitTestChildren.QueryFirst(target, point, filter, resultFilter, Server.Compositor.Readback.ReadRevision);
hit = _hitTestChildren.QueryFirst<THitTester, T>(target, input, filter, resultFilter, Server.Compositor.Readback.ReadRevision, out intersectionResult);
return true;
}

3
src/Avalonia.Base/Rendering/Composition/Drawing/CompositionRenderData.cs

@ -1,4 +1,5 @@
using System;
using Avalonia.Media;
using Avalonia.Rendering.Composition.Server;
using Avalonia.Rendering.Composition.Transport;
using Avalonia.Utilities;
@ -47,4 +48,6 @@ internal class CompositionRenderData : ICompositorSerializable, IDisposable
}
public bool HitTest(Point pt) => _stream.HitTest(pt);
public IntersectionResult HitTest(Geometry geometry) => _stream.HitTest(geometry);
}

215
src/Avalonia.Base/Rendering/Composition/Drawing/RenderDataStream.HitTest.cs

@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using Avalonia.Rendering.SceneGraph;
using Avalonia.Utilities;
@ -13,88 +13,189 @@ internal partial class RenderDataStream
{
public bool SavedLive;
public bool RestorePoint;
public Point SavedPoint;
public Point? SavedPoint;
}
internal struct HitTestVisitor : IRenderDataVisitor<HitTestScope>
{
public bool StopVisiting { get; private set; }
public Geometry? CurrentGeometry
{
get => _currentGeometry;
set
{
_currentGeometry = value;
_renderedGeometry = GetRenderedGeometry(value, s_defaultStokePen);
}
}
public bool HitFound;
public Point Current;
public IntersectionResult HitResult;
private Geometry? _currentGeometry;
private Geometry? _renderedGeometry;
public Point? CurrentPoint;
public bool Live;
public HitTestVisitor(Point point)
private Stack<Geometry>? _savedGeometries = null;
public HitTestVisitor()
{
StopVisiting = false;
HitFound = false;
Current = point;
Live = true;
}
public HitTestVisitor(Geometry geometry) : this()
{
HitResult = IntersectionResult.NotCalculated;
CurrentGeometry = geometry;
_savedGeometries = new Stack<Geometry>();
}
public HitTestVisitor(Point point) : this()
{
HitResult = IntersectionResult.NotCalculated;
CurrentPoint = point;
}
private void Hit()
{
HitFound = true;
StopVisiting = true;
}
private void Hit(IntersectionResult result)
{
HitResult = result;
StopVisiting = true;
}
public void OnDrawLine(IPen? serverPen, IPen? clientPen, Point p1, Point p2)
{
if (Live && HitTestLine(clientPen, p1, p2, Current))
if (!Live)
return;
if (CurrentPoint is { } point && HitTestLine(clientPen, p1, p2, point))
Hit();
else if (_renderedGeometry is { } geometry && HitTestLine(clientPen, p1, p2, geometry) is { } intersectionDetail)
Hit(intersectionDetail);
}
public void OnDrawRectangle(IBrush? serverBrush, IPen? serverPen, IPen? clientPen, RoundedRect rect,
BoxShadows boxShadows)
{
if (Live && HitTestRectangle(serverBrush, clientPen, rect, Current))
if (!Live)
return;
if (CurrentPoint is { } point && HitTestRectangle(serverBrush, clientPen, rect, point))
Hit();
else if (_renderedGeometry is { } geometry && HitTestRectangle(serverBrush, clientPen, rect, geometry) is { } intersectionDetail)
Hit(intersectionDetail);
}
public void OnDrawEllipse(IBrush? serverBrush, IPen? serverPen, IPen? clientPen, Rect rect)
{
if (Live && HitTestEllipse(serverBrush, clientPen, rect, Current))
if (!Live)
return;
if (CurrentPoint is { } point && HitTestEllipse(serverBrush, clientPen, rect, point))
Hit();
else if (_renderedGeometry is { } geometry && HitTestEllipse(serverBrush, clientPen, rect, geometry) is { } intersectionDetail)
Hit(intersectionDetail);
}
public void OnDrawGeometry(IBrush? serverBrush, IPen? serverPen, IPen? clientPen, IGeometryImpl? geometry)
{
if (Live && geometry != null &&
((serverBrush != null && geometry.FillContains(Current)) ||
(clientPen != null && geometry.StrokeContains(clientPen, Current))))
if (!Live || geometry == null)
return;
if (CurrentPoint is { } point &&
((serverBrush != null && geometry.FillContains(point)) ||
(clientPen != null && geometry.StrokeContains(clientPen, point))))
Hit();
else if (_renderedGeometry is { } currentGeometry &&
serverBrush != null && currentGeometry.PlatformImpl != null &&
GetRenderedGeometry(geometry, clientPen) is { } renderedGeometry &&
renderedGeometry.GetFillIntersectionResult(currentGeometry.PlatformImpl) is { } intersectionDetail)
Hit(intersectionDetail);
}
public void OnDrawGlyphRun(IBrush? serverBrush, IRef<IGlyphRunImpl>? glyphRun)
{
if (Live && glyphRun != null && glyphRun.Item.Bounds.ContainsExclusive(Current))
if (!Live || glyphRun == null)
return;
if (CurrentPoint is { } point && glyphRun.Item.Bounds.ContainsExclusive(point))
Hit();
else if (_renderedGeometry is { } geometry && GetIntersectionDetail(glyphRun.Item.Bounds, geometry.Bounds) is { } intersectionDetail)
Hit(intersectionDetail);
}
public void OnDrawBitmap(IRef<IBitmapImpl>? bitmap, double opacity, Rect sourceRect, Rect destRect)
{
if (Live && destRect.Contains(Current))
if (!Live)
return;
if (CurrentPoint is { } point && destRect.Contains(point))
Hit();
else if (_renderedGeometry is { } geometry && GetIntersectionDetail(destRect, geometry.Bounds) is { } intersectionDetail)
Hit(intersectionDetail);
}
public void OnDrawCustom(ICustomDrawOperation? operation)
{
if (Live && operation != null && operation.HitTest(Current))
if (!Live)
return;
if (CurrentPoint is { } point && operation != null && operation.HitTest(point))
Hit();
else if (_renderedGeometry is { } geometry && operation != null && operation.HitTest(geometry) is { } intersectionDetail)
Hit(intersectionDetail);
}
private IntersectionResult GetIntersectionDetail(Rect firstRect, Rect secondRect)
{
if (firstRect.Contains(secondRect))
return IntersectionResult.FullyContains;
if (secondRect.Contains(secondRect))
return IntersectionResult.FullyInside;
if (firstRect.Intersects(secondRect))
return IntersectionResult.Intersects;
return IntersectionResult.Empty;
}
public HitTestScope OnPushClip(RoundedRect clip)
{
var scope = new HitTestScope { SavedLive = Live };
if (Live && !clip.Rect.Contains(Current))
Live = false;
if (Live)
{
if ((CurrentPoint is { } point && !clip.Rect.Contains(point)) ||
(_renderedGeometry is { } geometry && !clip.Rect.Contains(geometry.Bounds)))
Live = false;
}
return scope;
}
public HitTestScope OnPushGeometryClip(IGeometryImpl? geometry)
{
var scope = new HitTestScope { SavedLive = Live };
if (Live && geometry != null && !geometry.FillContains(Current))
Live = false;
if (Live && geometry != null)
{
if ((CurrentPoint is { } point && !geometry.FillContains(point)) || (_renderedGeometry is { } currentGeometry && currentGeometry.PlatformImpl != null &&
geometry.GetFillIntersectionResult(currentGeometry.PlatformImpl) > IntersectionResult.Empty))
Live = false;
}
return scope;
}
@ -112,8 +213,18 @@ internal partial class RenderDataStream
if (matrix.TryInvert(out var inverted))
{
scope.RestorePoint = true;
scope.SavedPoint = Current;
Current = Current.Transform(inverted);
if (CurrentPoint is { } point)
{
scope.SavedPoint = point;
CurrentPoint = point.Transform(inverted);
}
else if (CurrentGeometry != null)
{
_savedGeometries?.Push(CurrentGeometry);
CurrentGeometry = CurrentGeometry.Clone();
CurrentGeometry.Transform = new MatrixTransform((CurrentGeometry.Transform?.Value ?? Matrix.Identity) * inverted);
_renderedGeometry?.Transform = CurrentGeometry.Transform;
}
}
else
Live = false;
@ -134,7 +245,10 @@ internal partial class RenderDataStream
{
Live = scope.SavedLive;
if (scope.RestorePoint)
Current = scope.SavedPoint;
{
CurrentPoint = scope.SavedPoint;
CurrentGeometry = _savedGeometries?.Pop();
}
}
}
@ -145,6 +259,13 @@ internal partial class RenderDataStream
return visitor.HitFound;
}
public IntersectionResult HitTest(Geometry geometry)
{
var visitor = new HitTestVisitor(geometry);
Visit<HitTestVisitor, HitTestScope>(ref visitor);
return visitor.HitResult;
}
private static bool HitTestLine(IPen? clientPen, Point p1, Point p2, Point p)
{
if (clientPen == null)
@ -176,6 +297,14 @@ internal partial class RenderDataStream
return Math.Abs(distance) <= halfThickness;
}
private static IntersectionResult? HitTestLine(IPen? clientPen, Point p1, Point p2, Geometry geometry)
{
if (clientPen == null)
return IntersectionResult.NotCalculated;
return geometry.GetFillIntersectionResult(new LineGeometry(p1, p2));
}
private static bool HitTestRectangle(IBrush? serverBrush, IPen? clientPen, RoundedRect rect, Point p)
{
var strokeThicknessAdjustment = (clientPen?.Thickness / 2) ?? 0;
@ -208,6 +337,35 @@ internal partial class RenderDataStream
return false;
}
private static IntersectionResult? HitTestRectangle(IBrush? serverBrush, IPen? clientPen, RoundedRect rect, Geometry geometry)
{
var strokeThicknessAdjustment = (clientPen?.Thickness / 2) ?? 0;
if (rect.IsRounded)
{
var outer = rect.Inflate(strokeThicknessAdjustment, strokeThicknessAdjustment);
return new RectangleGeometry(outer.Rect, outer.RadiiTopLeft.X, outer.RadiiTopLeft.Y).GetFillIntersectionResult(geometry);
}
else
{
var outer = rect.Rect.Inflate(strokeThicknessAdjustment);
return new RectangleGeometry(outer).GetFillIntersectionResult(geometry);
}
}
private static readonly IPen s_defaultStokePen = new Pen();
private static Geometry? GetRenderedGeometry(Geometry? geometry, IPen? pen)
{
return geometry == null ? null : new CombinedGeometry(geometry.GetWidenedGeometry(pen ?? s_defaultStokePen), geometry);
}
private static IGeometryImpl? GetRenderedGeometry(IGeometryImpl? geometry, IPen? pen)
{
return geometry == null ? null : new CombinedGeometry(new ImmutableGeometry(geometry.GetWidenedGeometry(pen ?? s_defaultStokePen)),
new ImmutableGeometry(geometry)).PlatformImpl;
}
private static bool HitTestEllipse(IBrush? serverBrush, IPen? clientPen, Rect rect, Point p)
{
var center = rect.Center;
@ -240,6 +398,19 @@ internal partial class RenderDataStream
return false;
}
private static IntersectionResult? HitTestEllipse(IBrush? serverBrush, IPen? clientPen, Rect rect, Geometry geometry)
{
var center = rect.Center;
var strokeThickness = clientPen?.Thickness ?? 0;
var rx = rect.Width / 2 + strokeThickness / 2;
var ry = rect.Height / 2 + strokeThickness / 2;
var ellipse = new EllipseGeometry(rect);
return ellipse.GetFillIntersectionResult(geometry);
}
private static bool EllipseContains(double dx, double dy, double radiusX, double radiusY)
{
var rx2 = radiusX * radiusX;

33
src/Avalonia.Base/Rendering/Composition/CompositionHitTestAabbTree.cs → src/Avalonia.Base/Rendering/Composition/HitTesting/CompositionHitTestAabbTree.cs

@ -4,9 +4,10 @@ using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Avalonia.Collections.Pooled;
using Avalonia.Media;
using Avalonia.Platform;
namespace Avalonia.Rendering.Composition;
namespace Avalonia.Rendering.Composition.HitTesting;
internal sealed class CompositionHitTestAabbTree
{
@ -104,7 +105,8 @@ internal sealed class CompositionHitTestAabbTree
RemoveBucketIfEmpty(oldBucket);
}
public void Query(Point point, PooledList<CompositionVisual> results, ulong readbackRevision)
public void Query<THitTester, T>(T input, PooledList<CompositionVisual> results, ulong readbackRevision)
where THitTester : struct, ICompositionHitTester<T>
{
var candidates = ArrayPool<Candidate>.Shared.Rent(OrderBucketSize);
var stack = ArrayPool<int>.Shared.Rent(16);
@ -122,7 +124,7 @@ internal sealed class CompositionHitTestAabbTree
candidateCount = 0;
var stackCount = 0;
QueryBucket(bucket, point, ref candidates, ref candidateCount, ref stack, ref stackCount);
QueryBucket<THitTester, T>(bucket, input, ref candidates, ref candidateCount, ref stack, ref stackCount);
candidates.AsSpan(0, candidateCount).Sort(s_candidateComparer);
for (var j = 0; j < candidateCount; j++)
@ -139,8 +141,16 @@ internal sealed class CompositionHitTestAabbTree
}
}
public CompositionVisual? QueryFirst(CompositionTarget target, Point point, Func<CompositionVisual, bool>? filter, Func<CompositionVisual, bool>? resultFilter, ulong readbackRevision)
public CompositionVisual? QueryFirst<THitTester, T>(
CompositionTarget target,
T input,
Func<CompositionVisual, bool>? filter,
Func<CompositionVisual, bool>? resultFilter,
ulong readbackRevision,
out IntersectionResult intersectionResult)
where THitTester : struct, ICompositionHitTester<T>
{
intersectionResult = IntersectionResult.NotCalculated;
var candidates = ArrayPool<Candidate>.Shared.Rent(OrderBucketSize);
var stack = ArrayPool<int>.Shared.Rent(16);
var candidateCount = 0;
@ -157,12 +167,12 @@ internal sealed class CompositionHitTestAabbTree
candidateCount = 0;
var stackCount = 0;
QueryBucket(bucket, point, ref candidates, ref candidateCount, ref stack, ref stackCount);
QueryBucket<THitTester, T>(bucket, input, ref candidates, ref candidateCount, ref stack, ref stackCount);
candidates.AsSpan(0, candidateCount).Sort(s_candidateComparer);
for (var j = 0; j < candidateCount; j++)
{
var hit = target.HitTestFirstCore(candidates[j].Visual, point, filter, resultFilter);
var hit = target.HitTestFirstCore<THitTester, T>(candidates[j].Visual, input, filter, resultFilter, out intersectionResult);
if (hit != null)
return hit;
}
@ -241,7 +251,14 @@ internal sealed class CompositionHitTestAabbTree
AddUnbounded(visual, entry.Order, ref entry);
}
private void QueryBucket(Bucket bucket, Point point, ref Candidate[] candidates, ref int candidateCount, ref int[] stack, ref int stackCount)
private void QueryBucket<THitTester, T>(
Bucket bucket,
T input,
ref Candidate[] candidates,
ref int candidateCount,
ref int[] stack,
ref int stackCount)
where THitTester : struct, ICompositionHitTester<T>
{
PushQueryNode(ref stack, ref stackCount, bucket.Root);
@ -250,7 +267,7 @@ internal sealed class CompositionHitTestAabbTree
var nodeIndex = stack[--stackCount];
var node = _nodes[nodeIndex];
if (!node.Bounds.Contains(point))
if (!THitTester.TransformedSubTreeBoundsMatch(node.Bounds, input))
continue;
if (node.IsLeaf)

33
src/Avalonia.Base/Rendering/Composition/HitTesting/GeometryCompositionHitTester.cs

@ -0,0 +1,33 @@
using Avalonia.Media;
using Avalonia.Platform;
namespace Avalonia.Rendering.Composition.HitTesting;
internal struct GeometryCompositionHitTester : ICompositionHitTester<Geometry>
{
public static Geometry Transform(Geometry input, in Matrix matrix)
{
var result = input.Clone();
result.Transform = new MatrixTransform((input.Transform?.Value ?? Matrix.Identity) * matrix);
return result;
}
public static IntersectionResult HitTest(CompositionVisual visual, Geometry input)
=> visual.HitTest(input);
public static bool TransformedSubTreeBoundsMatch(LtrbRect bounds, Geometry input)
{
var geometryRenderBounds = input.Bounds;
return bounds.Intersects(new LtrbRect(geometryRenderBounds));
}
public static bool ClippedBoundsMatch(CompositionVisual visual, Geometry input)
{
var bounds = input.Bounds;
return bounds.Width > 0 && bounds.Height > 0 && bounds.Intersects(new Rect(new Size(visual.Size.X, visual.Size.Y)));
}
public static bool ClipMatches(IGeometryImpl clip, Geometry input)
=> input.PlatformImpl is { } geometryImpl &&
clip.GetFillIntersectionResult(geometryImpl) > IntersectionResult.Empty;
}

17
src/Avalonia.Base/Rendering/Composition/HitTesting/ICompositionHitTester.cs

@ -0,0 +1,17 @@
using Avalonia.Media;
using Avalonia.Platform;
namespace Avalonia.Rendering.Composition.HitTesting;
internal interface ICompositionHitTester<T>
{
static abstract T Transform(T input, in Matrix matrix);
static abstract IntersectionResult HitTest(CompositionVisual visual, T input);
static abstract bool TransformedSubTreeBoundsMatch(LtrbRect bounds, T input);
static abstract bool ClippedBoundsMatch(CompositionVisual visual, T input);
static abstract bool ClipMatches(IGeometryImpl clip, T input);
}

22
src/Avalonia.Base/Rendering/Composition/HitTesting/PointCompositionHitTester.cs

@ -0,0 +1,22 @@
using Avalonia.Media;
using Avalonia.Platform;
namespace Avalonia.Rendering.Composition.HitTesting;
internal struct PointCompositionHitTester : ICompositionHitTester<Point>
{
public static Point Transform(Point input, in Matrix matrix)
=> input.Transform(matrix);
public static IntersectionResult HitTest(CompositionVisual visual, Point input)
=> visual.HitTest(input) ? IntersectionResult.FullyContains : IntersectionResult.Empty;
public static bool TransformedSubTreeBoundsMatch(LtrbRect bounds, Point input)
=> bounds.Contains(input);
public static bool ClippedBoundsMatch(CompositionVisual visual, Point input)
=> input.X >= 0 && input.Y >= 0 && input.X <= visual.Size.X && input.Y <= visual.Size.Y;
public static bool ClipMatches(IGeometryImpl clip, Point input)
=> clip.FillContains(input);
}

4
src/Avalonia.Base/Rendering/Composition/Visual.cs

@ -1,9 +1,6 @@
using System;
using System.Numerics;
using Avalonia.Media;
using Avalonia.Rendering.Composition.Drawing;
using Avalonia.Rendering.Composition.Server;
using Avalonia.VisualTree;
namespace Avalonia.Rendering.Composition
{
@ -102,5 +99,6 @@ namespace Avalonia.Rendering.Composition
internal object? Tag { get; set; }
internal virtual bool HitTest(Point point) => true;
internal virtual IntersectionResult HitTest(Geometry geometry) => IntersectionResult.Intersects;
}
}

18
src/Avalonia.Base/Rendering/ICustomHitTest.cs

@ -1,6 +1,4 @@
using System.Collections.Generic;
using System.Linq;
using Avalonia.VisualTree;
using Avalonia.Media;
namespace Avalonia.Rendering
{
@ -12,5 +10,19 @@ namespace Avalonia.Rendering
{
/// <param name="point">The point to hit test in global coordinate space.</param>
bool HitTest(Point point);
/// <summary>
/// Hit test the geometry in this node.
/// </summary>
/// <param name="geometry">The geometry in global coordinates.</param>
/// <returns>The <see cref="IntersectionResult"/> describing the intersecting between the hit geometry and the node's geometry.</returns>
/// <remarks>
/// This method does not recurse to childs, if you want
/// to hit test children they must be hit tested manually.
/// </remarks>
IntersectionResult HitTest(Geometry geometry)
{
return IntersectionResult.Empty;
}
}
}

24
src/Avalonia.Base/Rendering/IRenderer.cs

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Avalonia.Media;
using Avalonia.Metadata;
using Avalonia.Rendering.Composition;
@ -93,11 +94,34 @@ namespace Avalonia.Rendering
/// <returns>The visuals at the specified point, topmost first.</returns>
IEnumerable<Visual> HitTest(Point p, Visual root, Func<Visual, bool>? filter);
/// <summary>
/// Hit tests a geometry to find the visuals intersecting a region.
/// </summary>
/// <remarks>
/// <para>⚠️ This method is low-level and <b>DOES NOT respect <see cref="Input.InputElement.IsHitTestVisible"/></b>.</para>
/// <para>Use <see cref="Input.InputExtensions"/> to perform input hit testing, or provide your own <paramref name="filter"/> function.</para>
/// </remarks>
/// <param name="geometry">The geometry, in coordinates relative to<paramref name="root"/></param>
/// <param name="root">The root of the subtree to search.</param>
/// <param name="filter">
/// A filter predicate. If the predicate returns false then the visual and all its
/// children will be excluded from the results.
/// </param>
/// <returns>The visuals intersecting the specified geometry, topmost first.</returns>
IEnumerable<GeometryHitTestResult> HitTest(Geometry geometry, Visual root, Func<Visual, bool> filter);
/// <summary>
/// Hit tests a location to find first visual at the specified point.
/// </summary>
/// <inheritdoc cref="HitTest(Point, Visual, Func{Visual, bool}?)"/>
/// <returns>The visual at the specified point, topmost first.</returns>
Visual? HitTestFirst(Point p, Visual root, Func<Visual, bool>? filter);
/// <summary>
/// Hit tests a geometry to find the visuals intersecting a region.
/// </summary>
/// <inheritdoc cref="HitTest(Geometry, Visual, Func{Visual, bool}?)"/>
/// <returns>The visuals intersecting the specified geometry, topmost first.</returns>
GeometryHitTestResult? HitTestFirst(Geometry geometry, Visual root, Func<Visual, bool>? filter);
}
}

16
src/Avalonia.Base/Rendering/SceneGraph/CustomDrawOperation.cs

@ -1,7 +1,5 @@
using System;
using Avalonia.Logging;
using Avalonia.Media;
using Avalonia.Platform;
namespace Avalonia.Rendering.SceneGraph
{
@ -23,6 +21,20 @@ namespace Avalonia.Rendering.SceneGraph
/// </remarks>
bool HitTest(Point p);
/// <summary>
/// Hit test the geometry in this node.
/// </summary>
/// <param name="geometry">The geometry in global coordinates.</param>
/// <returns>The <see cref="IntersectionResult"/> describing the intersecting between the hit geometry and the node's geometry.</returns>
/// <remarks>
/// This method does not recurse to childs, if you want
/// to hit test children they must be hit tested manually.
/// </remarks>
IntersectionResult HitTest(Geometry geometry)
{
return IntersectionResult.Empty;
}
/// <summary>
/// Renders the node to a drawing context.
/// </summary>

81
src/Avalonia.Base/VisualTree/VisualExtensions.cs

@ -1,9 +1,8 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.CompilerServices;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering;
using Avalonia.Utilities;
@ -319,6 +318,19 @@ namespace Avalonia.VisualTree
return visual.GetVisualAt(p, x => x.IsVisible);
}
/// <summary>
/// Gets the first visual in the visual tree whose bounds intersects a geometry.
/// </summary>
/// <param name="visual">The root visual to test.</param>
/// <param name="geometry">The geometry.</param>
/// <returns>A <see cref="GeometryHitTestResult"/> containing the visual intersecting the requested geometry and intersection details, or null if no intersection.</returns>
public static GeometryHitTestResult? GetVisualAt(this Visual visual, Geometry geometry)
{
ThrowHelper.ThrowIfNull(visual, nameof(visual));
return visual.GetVisualAt(geometry, x => x.IsVisible);
}
/// <summary>
/// Gets the first visual in the visual tree whose bounds contain a point.
/// </summary>
@ -342,6 +354,29 @@ namespace Avalonia.VisualTree
return source.HitTester.HitTestFirst(p, visual, filter);
}
/// <summary>
/// Gets the first visual in the visual tree whose bounds intersects a geometry.
/// </summary>
/// <param name="visual">The root visual to test.</param>
/// <param name="geometry">The geometry.</param>
/// <param name="filter">
/// A filter predicate. If the predicate returns false then the visual and all its
/// children will be excluded from the results.
/// </param>
/// <returns>A <see cref="GeometryHitTestResult"/> containing the visual intersecting the requested geometry and intersection details, or null if no intersection.</returns>
public static GeometryHitTestResult? GetVisualAt(this Visual visual, Geometry geometry, Func<Visual, bool> filter)
{
ThrowHelper.ThrowIfNull(visual, nameof(visual));
var source = visual.GetPresentationSource();
if (source is null)
{
return null;
}
return source.HitTester.HitTestFirst(geometry, visual, filter);
}
/// <summary>
/// Enumerates the visible visuals in the visual tree whose bounds contain a point.
/// </summary>
@ -357,6 +392,21 @@ namespace Avalonia.VisualTree
return visual.GetVisualsAt(p, x => x.IsVisible);
}
/// <summary>
/// Enumerates the visible visuals in the visual tree whose bounds intersects a geometry.
/// </summary>
/// <param name="visual">The root visual to test.</param>
/// <param name="geometry">The geometry.</param>
/// <returns>The visuals intersecting the requested geometry with intersection details.</returns>
public static IEnumerable<GeometryHitTestResult> GetVisualsAt(
this Visual visual,
Geometry geometry)
{
ThrowHelper.ThrowIfNull(visual, nameof(visual));
return visual.GetVisualsAt(geometry, x => x.IsVisible);
}
/// <summary>
/// Enumerates the visuals in the visual tree whose bounds contain a point.
/// </summary>
@ -384,6 +434,33 @@ namespace Avalonia.VisualTree
return source.HitTester.HitTest(p, visual, filter);
}
/// <summary>
/// Enumerates the visible visuals in the visual tree whose bounds intersects a geometry.
/// </summary>
/// <param name="visual">The root visual to test.</param>
/// <param name="geometry">The geometry.</param>
/// <param name="filter">
/// A filter predicate. If the predicate returns false then the visual and all its
/// children will be excluded from the results.
/// </param>
/// <returns>The visuals intersecting the requested geometry with intersection details.</returns>
public static IEnumerable<GeometryHitTestResult> GetVisualsAt(
this Visual visual,
Geometry geometry,
Func<Visual, bool> filter)
{
ThrowHelper.ThrowIfNull(visual, nameof(visual));
var source = visual.GetPresentationSource();
if (source is null)
{
return Array.Empty<GeometryHitTestResult>();
}
return source.HitTester.HitTest(geometry, visual, filter);
}
/// <summary>
/// Enumerates the children of an <see cref="Visual"/> in the visual tree.
/// </summary>

171
src/Headless/Avalonia.Headless/HeadlessPlatformRenderInterface.cs

@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using Avalonia.Media.TextFormatting;
@ -43,7 +44,7 @@ namespace Avalonia.Headless
public IGeometryImpl CreateRectangleGeometry(Rect rect)
{
return new HeadlessGeometryStub(rect);
return new HeadlessRectangleGeometryContextStub(rect);
}
public IStreamGeometryImpl CreateStreamGeometry() => new HeadlessStreamingGeometryStub();
@ -224,10 +225,32 @@ namespace Avalonia.Headless
segmentGeometry = null;
return false;
}
public virtual IntersectionResult GetFillIntersectionResult(IGeometryImpl geometry)
{
var intersection = Intersect(geometry);
if (intersection?.Bounds.Size != new Size())
{
var bounds = GetRenderBounds(null);
var otherBounds = geometry.GetRenderBounds(null);
if (bounds.Contains(otherBounds))
return IntersectionResult.FullyContains;
if (otherBounds.Contains(bounds))
return IntersectionResult.FullyInside;
return IntersectionResult.Intersects;
}
return IntersectionResult.Empty;
}
}
private class HeadlessTransformedGeometryStub : HeadlessGeometryStub, ITransformedGeometryImpl
private class HeadlessTransformedGeometryStub : HeadlessGeometryStub, ITransformedGeometryImpl, IHeadlessGeometryWithEdges
{
private List<Point>? _points;
public HeadlessTransformedGeometryStub(IGeometryImpl b, Matrix transform) : this(Fix(b, transform))
{
@ -253,12 +276,103 @@ namespace Avalonia.Headless
public IGeometryImpl SourceGeometry { get; }
public Matrix Transform { get; }
public List<Point> Points
{
get
{
if (SourceGeometry is IHeadlessGeometryWithEdges geometryWithEdges)
{
return _points ??= geometryWithEdges.Points.Select(x => x.Transform(Transform)).ToList();
}
return [];
}
}
}
private class HeadlessRectangleGeometryContextStub : HeadlessGeometryStub, IHeadlessGeometryWithEdges
{
private List<Point> _points = new List<Point>();
public HeadlessRectangleGeometryContextStub(Rect bounds) : base(bounds)
{
_points.Add(bounds.TopLeft);
_points.Add(bounds.TopRight);
_points.Add(bounds.BottomLeft);
_points.Add(bounds.BottomRight);
}
public List<Point> Points => _points;
public override IntersectionResult GetFillIntersectionResult(IGeometryImpl geometry)
{
if (geometry is IHeadlessGeometryWithEdges stub)
{
var axes = (this as IHeadlessGeometryWithEdges).GetAxes();
axes.AddRange(stub.GetAxes());
foreach (var axis in axes)
{
var (min, max) = (this as IHeadlessGeometryWithEdges).ProjectionOnAxis(axis);
var projection2 = stub.ProjectionOnAxis(axis);
if (max < projection2.min || projection2.max < min)
return IntersectionResult.Empty;
}
return IntersectionResult.Intersects;
}
return base.GetFillIntersectionResult(geometry);
}
}
internal interface IHeadlessGeometryWithEdges
{
List<Point> Points { get; }
public (double min, double max) ProjectionOnAxis(Vector axis)
{
double min = double.PositiveInfinity, max = double.NegativeInfinity;
foreach (var point in Points)
{
var p = Vector.Dot(axis, new Vector(point.X, point.Y));
if (p < min)
{
min = p;
}
if (p > max)
{
max = p;
}
}
return (min, max);
}
public List<Vector> GetAxes()
{
List<Vector> axes = new List<Vector>();
for (var i = 0; i < Points.Count; i++)
{
var point = Points[i];
var otherPoint = Points[(i + i) % Points.Count];
var edge = new Vector(point.X - otherPoint.X, point.Y - otherPoint.Y);
axes.Add(new Vector(-edge.Y, edge.X));
}
return axes;
}
}
private class HeadlessStreamingGeometryStub : HeadlessGeometryStub, IStreamGeometryImpl
{
private HeadlessStreamingGeometryContextStub _context;
public HeadlessStreamingGeometryStub() : base(default)
{
_context = new HeadlessStreamingGeometryContextStub(this);
@ -279,10 +393,21 @@ namespace Avalonia.Headless
return _context.FillContains(point);
}
private class HeadlessStreamingGeometryContextStub : IStreamGeometryContextImpl
public override IntersectionResult GetFillIntersectionResult(IGeometryImpl geometry)
{
if (geometry is IHeadlessGeometryWithEdges stub)
return _context.FillContains(stub);
return base.GetFillIntersectionResult(geometry);
}
private class HeadlessStreamingGeometryContextStub : IStreamGeometryContextImpl, IHeadlessGeometryWithEdges
{
private readonly HeadlessStreamingGeometryStub _parent;
private List<Point> points = new List<Point>();
private List<Point> _points = new List<Point>();
public List<Point> Points => _points;
public HeadlessStreamingGeometryContextStub(HeadlessStreamingGeometryStub parent)
{
_parent = parent;
@ -290,7 +415,7 @@ namespace Avalonia.Headless
private void Track(Point pt)
{
points.Add(pt);
_points.Add(pt);
}
public Rect CalculateBounds()
@ -300,7 +425,7 @@ namespace Avalonia.Headless
var top = double.MaxValue;
var bottom = double.MinValue;
foreach (var p in points)
foreach (var p in _points)
{
left = Math.Min(p.X, left);
right = Math.Max(p.X, right);
@ -310,7 +435,7 @@ namespace Avalonia.Headless
return new Rect(new Point(left, top), new Point(right, bottom));
}
public void Dispose()
{
_parent.Bounds = CalculateBounds();
@ -345,16 +470,16 @@ namespace Avalonia.Headless
{
}
public bool FillContains(Point point)
{
// Use the algorithm from https://www.blackpawn.com/texts/pointinpoly/default.html
// to determine if the point is in the geometry (since it will always be convex in this situation)
for (int i = 0; i < points.Count; i++)
for (int i = 0; i < _points.Count; i++)
{
var a = points[i];
var b = points[(i + 1) % points.Count];
var c = points[(i + 2) % points.Count];
var a = _points[i];
var b = _points[(i + 1) % _points.Count];
var c = _points[(i + 2) % _points.Count];
Vector v0 = c - a;
Vector v1 = b - a;
@ -370,10 +495,28 @@ namespace Avalonia.Headless
var invDenom = 1 / (dot00 * dot11 - dot01 * dot01);
var u = (dot11 * dot02 - dot01 * dot12) * invDenom;
var v = (dot00 * dot12 - dot01 * dot02) * invDenom;
if ((u >= 0) && (v >= 0) && (u + v < 1)) return true;
if ((u >= 0) && (v >= 0) && (u + v < 1))
return true;
}
return false;
}
public IntersectionResult FillContains(IHeadlessGeometryWithEdges geometry)
{
var axes = (this as IHeadlessGeometryWithEdges).GetAxes();
axes.AddRange(geometry.GetAxes());
foreach (var axis in axes)
{
var (min, max) = (this as IHeadlessGeometryWithEdges).ProjectionOnAxis(axis);
var projection2 = geometry.ProjectionOnAxis(axis);
if (max < projection2.min || projection2.max < min)
return IntersectionResult.Empty;
}
return IntersectionResult.Intersects;
}
}
}

23
src/Skia/Avalonia.Skia/GeometryImpl.cs

@ -3,7 +3,6 @@ using System.Diagnostics.CodeAnalysis;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Skia.Helpers;
using Avalonia.Utilities;
using SkiaSharp;
namespace Avalonia.Skia
@ -171,6 +170,28 @@ namespace Avalonia.Skia
}
}
/// <inheritdoc />
public IntersectionResult GetFillIntersectionResult(IGeometryImpl geometry)
{
var other = geometry as GeometryImpl;
if (other == null || FillPath == null || other.FillPath == null)
return IntersectionResult.Empty;
var region = new SKRegion(FillPath);
var otherRegion = new SKRegion(other.FillPath);
if(region.Contains(otherRegion))
return IntersectionResult.FullyInside;
if (otherRegion.Contains(region))
return IntersectionResult.FullyContains;
if (region.Intersects(otherRegion))
return IntersectionResult.Intersects;
return IntersectionResult.Empty;
}
private struct PathCache : IDisposable
{
private int _penHash;

933
tests/Avalonia.Base.UnitTests/Rendering/CompositorHitTestingTests.cs

File diff suppressed because it is too large

6
tests/Avalonia.Base.UnitTests/Rendering/CustomHitTestBorder.cs

@ -1,4 +1,5 @@
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Rendering;
namespace Avalonia.Base.UnitTests.Rendering
@ -11,5 +12,10 @@ namespace Avalonia.Base.UnitTests.Rendering
return new Rect( -Bounds.Width / 2,0, Bounds.Width, Bounds.Height)
.Contains(point);
}
public IntersectionResult HitTest(Geometry geometry)
{
return new RectangleGeometry(new Rect(-Bounds.Width / 2, 0, Bounds.Width, Bounds.Height)).GetFillIntersectionResult(geometry) ?? IntersectionResult.Empty;
}
}
}

2
tests/Avalonia.Base.UnitTests/Rendering/SceneGraph/DrawOperationTests.cs

@ -126,7 +126,7 @@ namespace Avalonia.Base.UnitTests.Rendering.SceneGraph
ctx.Context.DrawGeometry(Brushes.Black, null, geometry.Object);
}
Assert.False(ctx.Context.GetRenderResults()!.HitTest(default));
Assert.False(ctx.Context.GetRenderResults()!.HitTest(default(Point)));
}
[Fact]

38
tests/Avalonia.Base.UnitTests/VisualTree/VisualExtensions_GetVisualsAt.cs

@ -50,6 +50,44 @@ namespace Avalonia.Base.UnitTests.VisualTree
}
[Fact]
public void Should_Find_Control_With_Geometry()
{
Border target;
using var services = new CompositorTestServices(new Size(200, 200))
{
TopLevel =
{
Content = new StackPanel
{
Background = null,
Children =
{
(target = new Border
{
Width = 100,
Height = 200,
Background = Brushes.Red,
}),
new Border
{
Width = 100,
Height = 200,
Background = Brushes.Green,
}
},
Orientation = Orientation.Horizontal,
}
}
};
services.RunJobs();
var geo = new RectangleGeometry(target.Bounds);
var result = target.GetVisualsAt(geo);
Assert.Equal(new GeometryHitTestResult(target, IntersectionResult.FullyContains), result.Single());
}
[Fact]
public void Should_Not_Find_Sibling_Control()
{

8
tests/Avalonia.RenderTests/TestRenderRoot.cs

@ -1,5 +1,3 @@
using Avalonia.Rendering;
using System.Threading.Tasks;
using System;
using System.Collections.Generic;
using Avalonia.Controls;
@ -7,7 +5,9 @@ using Avalonia.Input;
using Avalonia.Input.TextInput;
using Avalonia.Layout;
using Avalonia.LogicalTree;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering;
using Avalonia.Rendering.Composition;
@ -45,7 +45,11 @@ namespace Avalonia.Skia.RenderTests
{
public IEnumerable<Visual> HitTest(Point p, Visual root, Func<Visual, bool>? filter) => Array.Empty<Visual>();
public IEnumerable<GeometryHitTestResult> HitTest(Geometry geometry, Visual root, Func<Visual, bool> filter) => Array.Empty<GeometryHitTestResult>();
public Visual? HitTestFirst(Point p, Visual root, Func<Visual, bool>? filter) => null;
public GeometryHitTestResult? HitTestFirst(Geometry geometry, Visual root, Func<Visual, bool>? filter) => null;
}
internal void Initialize(CompositingRenderer renderer, Control child)

15
tests/Avalonia.UnitTests/CompositorTestServices.cs

@ -9,6 +9,7 @@ using Avalonia.Controls.Templates;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Input.Raw;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Platform.Surfaces;
using Avalonia.Rendering;
@ -107,6 +108,13 @@ public class CompositorTestServices : IDisposable
Assert.Equal(expected, tested);
}
public void AssertHitTest(Geometry geometry, Func<Visual, bool>? filter, params GeometryHitTestResult[] expected)
{
RunJobs();
var tested = Renderer.HitTest(geometry, TopLevel, filter);
Assert.Equal(expected, tested);
}
public void AssertHitTestFirst(Point pt, Func<Visual, bool>? filter, Visual? expected)
{
RunJobs();
@ -114,6 +122,13 @@ public class CompositorTestServices : IDisposable
Assert.Equal(expected, tested);
}
public void AssertHitTestFirst(Geometry geometry, Func<Visual, bool>? filter, Visual? expected)
{
RunJobs();
var tested = Renderer.HitTestFirst(geometry, TopLevel, filter);
Assert.Equal(expected, tested?.VisualHit);
}
public class DebugEvents : ICompositionTargetDebugEvents
{
public List<Rect> Rects = new();

7
tests/Avalonia.UnitTests/TestRoot.cs

@ -7,6 +7,7 @@ using Avalonia.Input;
using Avalonia.Input.TextInput;
using Avalonia.Layout;
using Avalonia.LogicalTree;
using Avalonia.Media;
using Avalonia.Platform;
using Avalonia.Rendering;
using Avalonia.Styling;
@ -30,11 +31,15 @@ namespace Avalonia.UnitTests
SetPresentationSourceForRootVisual(this);
}
class NullHitTester : IHitTester
class NullHitTester : IHitTester
{
public IEnumerable<Visual> HitTest(Point p, Visual root, Func<Visual, bool>? filter) => Array.Empty<Visual>();
public IEnumerable<GeometryHitTestResult> HitTest(Geometry geometry, Visual root, Func<Visual, bool> filter) => Array.Empty<GeometryHitTestResult>();
public Visual? HitTestFirst(Point p, Visual root, Func<Visual, bool>? filter) => null;
public GeometryHitTestResult? HitTestFirst(Geometry geometry, Visual root, Func<Visual, bool>? filter) => null;
}
public TestRoot(Control? child)

Loading…
Cancel
Save