Custom VisualElement .ContainsPoint() not being called?

Hi there, I have a custom VisualElement with its own OnGenerateVisualContent() method using Painter2D to draw a series of lines. I want to be able to click on the lines, however, by default, Painter2D lines don’t appear to be clickable. To solve this, I overrode ContainsPoint() to test whether my lines contain a given point, but the method appears to never be called? Neither the does debug output appear, nor does a breakpoint within that method trigger.

Do I need to do something else to ensure that ContainsPoint() is actually called?

Here’s the code for the VisualElement:


using UnityEngine;
using UnityEngine.UIElements;

    public class EdgeView : VisualElement
    {
        private const int STROKE_WIDTH = 2;

        // Need a state for tracking mouse movement when connecting via drag
        // and one of source/target is null

        public PortView sourcePortView;
        public PortView targetPortView;
        private Edge edgeData;

        public bool selected;

        public EdgeView()
        {
            name = "node-connection-edge";
            generateVisualContent += OnGenerateVisualContent;
            this.AddManipulator(new Clickable(OnClick));
        }

        void OnClick()
        {
            selected = !selected;
            MarkDirtyRepaint();
        }

        public void OnGeometryChanged(GeometryChangedEvent evt)
        {
            MarkDirtyRepaint();
        }

        public void Bind(Edge boundEdge)
        {
            edgeData = boundEdge;
            var sourcePort = edgeData.source;
            var targetPort = edgeData.target;
            sourcePortView = sourcePort.view;
            targetPortView = targetPort.view;
            sourcePort.parentNode.view.RegisterCallback<GeometryChangedEvent>(OnGeometryChanged);
            targetPort.parentNode.view.RegisterCallback<GeometryChangedEvent>(OnGeometryChanged);
        }

        public Vector2[] EdgePathPoints()
        {
            Vector2[] points = new Vector2[4];
            var source = NodeCanvasView.instance.PanelToCanvas(sourcePortView.worldBound.center);
            var target = NodeCanvasView.instance.PanelToCanvas(targetPortView.worldBound.center);
            var sourceEdgeExitOffset = sourcePortView.portData.side == PortSide.Bottom ?
                new Vector2(0, 30) : // Offset bottom outputs down
                new Vector2(30, 0);  // Offset right outputs right
            var targetEdgeExitOffset = targetPortView.portData.side == PortSide.Top ?
                new Vector2(0, -30) : // Offset top inputs up
                new Vector2(-30, 0);  // Offset left outputs left

            points[0] = source;
            points[1] = source + sourceEdgeExitOffset;
            points[2] = target + targetEdgeExitOffset;
            points[3] = target;
            return points;
        }

        private bool IsPointInRectangle(Vector2 start, Vector2 end, float width, Vector2 point)
        {
            // Vector AB = B - A
            Vector2 line = end - start;
            float length = line.magnitude;

            if (Mathf.Approximately(length, 0f))
            {
                // Degenerate case: A and B are the same point. 
                return false;
            }

            // Vector AP = P - A
            Vector2 AP = point - start;

            // Dot product (AP · AB) 
            float dot = Vector2.Dot(AP, line);

            // The "along-axis" distance t from start to the projection of P on line
            float t = dot / length;  // measured in the same scale as AB’s magnitude

            // Check if the projection is between start and end:  0 <= t <= L
            if (t < 0f || t > length)
            {
                // Outside rectangle along the spine
                return false;
            }

            // Compute the perpendicular distance from P to the line A->B
            // Vector2.Project(AP, AB) projects AP onto AB.
            Vector2 proj = Vector3.Project(AP, line);
            Vector2 perp = AP - proj;    // This is the perpendicular vector from the line to P
            float d_perp = perp.magnitude;

            // Check if within half-width
            return d_perp <= (width * 0.5f);
        }

        public override bool ContainsPoint(Vector2 localPoint)
        {
            Debug.Log("Testing edge hit");
            var points = EdgePathPoints();
            for (int i = 1; i < points.Length; i++)
            {
                if (IsPointInRectangle(points[i-1], points[i], STROKE_WIDTH, localPoint))
                {
                    return true;
                }
            }
            return false;
        }

        void OnGenerateVisualContent(MeshGenerationContext meshGenerationContext)
        {
            var painter = meshGenerationContext.painter2D;
            painter.lineWidth = STROKE_WIDTH;
            painter.lineCap = LineCap.Round;
            painter.strokeGradient = new Gradient()
            {
                colorKeys = !selected ? new GradientColorKey[]
                {
                    new GradientColorKey(Color.red, 0),
                    new GradientColorKey(Color.blue, 1)
                } : new GradientColorKey[]
                {
                    new GradientColorKey(Color.cyan, 0),
                    new GradientColorKey(Color.cyan, 1)
                },
            };
            var points = EdgePathPoints();
            painter.BeginPath();
            painter.lineJoin = LineJoin.Round;
            painter.MoveTo(points[0]);
            for (int i = 1; i < points.Length; i++)
            {
                painter.LineTo(points[i]);
            }
            painter.Stroke();
        }
    }

Update: I figured out the issue, which is that the VisualElement was considered to have 0/0 width/height. Adding a child with width/height allowed the ContainsPoint() code to run, and seems to work correctly.

2 Likes

Yes, to find the element under the pointer we use the axis-aligned bounding box of the element first, as the ContainsPoint method could be quite expensive. We also ignore elements that have pickingMode=ignore.

All the mesh generated for the element should be generated inside the bounding box of the element theoretically speaking. There is no checks for guaranteeing that because it was never a major problem and doing the extra check per vertex would be costly.

That makes sense, but it would be nice if the documentation made mention of this somewhere.