Skip to content

Implement visual geometry hit testing#21768

Open
emmauss wants to merge 4 commits into
mainfrom
geometry_hit_test
Open

Implement visual geometry hit testing#21768
emmauss wants to merge 4 commits into
mainfrom
geometry_hit_test

Conversation

@emmauss

@emmauss emmauss commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

What does the pull request do?

This PR introduces geometry-based hit testing, extending the existing point-based hit testing infrastructure to support testing against geometric shapes. This enables applications to query visuals intersecting arbitrary geometries (ellipses, custom paths, etc.) rather than just single points.

What is the current behavior?

Currently, hit testing infrastructure only supports point-based queries via IInputElement.GetInputElementsAt(Point), Visual.GetVisualsAt(Point), and the renderer's HitTest(Point) methods. Applications cannot efficiently query for all visuals intersecting a geometric region.

What is the updated/expected behavior with this PR?

The PR adds parallel geometry-based APIs:

  • New Geometry.FillContains(Geometry) method to test intersection between geometries
  • Extended InputExtensions with geometry overloads: GetInputElementsAt(geometry) and InputHitTest(geometry)
  • Extended VisualExtensions with geometry overloads: GetVisualsAt(geometry) and GetVisualAt(geometry)
  • New IHitTester methods: HitTest(Geometry) and HitTestFirst(Geometry)
  • Internal hit-testing infrastructure updated to support geometry queries throughout the rendering pipeline

How was the solution implemented (if it's not obvious)?

The following api has been added

/// <summary>
/// Provides information about the intersection between a hit geometry and a target geometry or visual.
/// </summary>
public enum IntersectionDetail
{
    /// <summary>
    /// The IntersectionDetail value is not calculated.
    /// </summary>
    NotCalculated = 0,

    /// <summary>
    /// There is no intersection between the hit geometry and the target geometry or visual.
    /// </summary>
    Empty = 1,

    /// <summary>
    /// The target geometry or visual is fully inside the hit geometry.
    /// </summary>
    FullyInside = 2,

    /// <summary>
    /// The target geometry or visual fully contains the hit geometry.
    /// </summary>
    FullyContains = 3,

    /// <summary>
    /// The target geometry or visual overlap the hit geometry and is neither one contains the other.
    /// </summary>
    Intersects = 4
}

public abstract class Geometry
{
    /// <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="IntersectionDetail"/> describing the intersection between the geometries</returns>
    public IntersectionDetail? FillContains(Geometry geometry)
}

public interface IGeometryImpl
{
      /// <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="IntersectionDetail"/> describing the intersection between the geometries</returns>
      IntersectionDetail FillContains(IGeometryImpl geometry);
}

public static class InputExtensions
{
      /// <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, ordered topmost first.
      /// </returns>
      public static IEnumerable<IInputElement> GetInputElementsAt(this IInputElement element, Geometry geometry, bool enabledElementsOnly = true)
      
      /// <inheritdoc cref="GetInputElementsAt(IInputElement, Geometry, bool)"/>
      public static IEnumerable<IInputElement> GetInputElementsAt(this IInputElement element, Geometry geometry)
      
      /// <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>The topmost <see cref="IInputElement"/> intersecting the specified geometry.</returns>
      public static IInputElement? InputHitTest(this IInputElement element, Geometry geometry, bool enabledElementsOnly = true)
      
      /// <inheritdoc cref="InputHitTest(IInputElement, Geometry, bool)"/>
      public static IInputElement? InputHitTest(this IInputElement element, Geometry geometry)
      
      /// <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>The topmost <see cref="IInputElement"/> intersecting the specified geometry.</returns>
      public static IInputElement? InputHitTest(this IInputElement element, Geometry geometry, Func<Visual, bool> filter, bool enabledElementsOnly = true)
      
      /// <inheritdoc cref="InputHitTest(IInputElement, Geometry, Func{Visual, bool}, bool)"/>
      public static IInputElement? InputHitTest(this IInputElement element, Geometry geometry, Func<Visual, bool> filter)
}

public static class VisualExtensions
{
      /// <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>The visual intersecting the requested geometry.</returns>
      public static Visual? GetVisualAt(this Visual visual, Geometry geometry)
      
      /// <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>The visual intersecting the requested geometry.</returns>
      public static Visual? GetVisualAt(this Visual visual, Geometry geometry, Func<Visual, bool> 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>
      /// <returns>The visuals intersecting the requested geometry.</returns>
      public static IEnumerable<Visual> GetVisualsAt(this Visual visual, Geometry geometry)
      
      /// <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.</returns>
      public static IEnumerable<Visual> GetVisualsAt(this Visual visual, Geometry geometry, Func<Visual, bool> filter)
}

public interface IRenderer
{
      /// <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<Visual> HitTest(Geometry geometry, Visual root, Func<Visual, bool> filter);
      
      /// <summary>
      /// Hit tests a geometry to find the first visual intersecting a region.
      /// </summary>
      /// <inheritdoc cref="HitTest(Geometry, Visual, Func{Visual, bool})"/>
      /// <returns>The first visual intersecting the specified geometry, topmost first.</returns>
      Visual? HitTestFirst(Geometry geometry, Visual root, Func<Visual, bool>? filter);
}

public interface ICustomHitTest
{
      /// <summary>
      /// Hit test the geometry in this node.
      /// </summary>
      /// <param name="geometry">The geometry in global coordinates.</param>
      /// <returns>The <see cref="IntersectionDetail"/> describing the intersection between the hit geometry and the node's geometry.</returns>
      /// <remarks>
      /// This method does not recurse to children. If you want to hit test children they must be hit tested manually.
      /// </remarks>
      IntersectionDetail HitTest(Geometry geometry)
      {
          return IntersectionDetail.Empty;
      }
}

public interface ICustomDrawOperation
{
      /// <summary>
      /// Hit test the geometry in this node.
      /// </summary>
      /// <param name="geometry">The geometry in global coordinates.</param>
      /// <returns>The <see cref="IntersectionDetail"/> describing the intersection between the hit geometry and the node's geometry.</returns>
      /// <remarks>
      /// This method does not recurse to children. If you want to hit test children they must be hit tested manually.
      /// </remarks>
      IntersectionDetail HitTest(Geometry geometry)
      {
          return IntersectionDetail.Empty;
      }
}

Checklist

Breaking changes

Interfaces were extended using default interface implementations. There shouldn't be any breaking changes.

Obsoletions / Deprecations

Fixed issues

Fixes #16549

@emmauss
emmauss requested a review from kekekeks July 13, 2026 10:19
@emmauss emmauss added enhancement needs-api-review The PR adds new public APIs that should be reviewed. labels Jul 13, 2026
@emmauss
emmauss requested a review from MrJul July 13, 2026 10:20
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0067452-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul MrJul added this to the 12.2 milestone Jul 21, 2026
@MrJul MrJul added feature and removed enhancement labels Jul 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature needs-api-review The PR adds new public APIs that should be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Hit Test Using Geometry as a Parameter

3 participants