GraphView Lasso Selector

Hey everyone! I put together a drop-in Lasso Selector tool for Unity’s GraphView, and I wanted to share my solution. If you end up using it in something you’re working on, just toss me some credit – I’d appreciate it!

I’m building a tool called Jungle Sequencer that uses the GraphView system to turning developers code into visual nodes. It’s all about making your life easier, so I’ve added some cool productivity features that make things smoother and more intuitive.

One of those features is the Lasso Selector. It lets you draw a freeform line around whatever elements you want to grab, making selection a breeze.

Here’s a quick demo of it in action with Jungle. Ignore the fact that it doesn’t look like the standard GraphView – it’s still the GraphView, just with heavily customized styling.

Lasso Selector Demo

Setting this up is super simple. Here’s how:

  • Create a script named LassoSelector.cs
  • Copy & paste the code below into that script
// --------------------------------
// Author:  Jack Randolph
// Contact: jr@jackedupsoftware.com
// --------------------------------

using System;
using System.Collections.Generic;
using UnityEditor.Experimental.GraphView;
using UnityEngine;
using UnityEngine.UIElements;

public class LassoSelector : MouseManipulator
{
    private const int MAX_POINTS = 9999;
    
    private static Material _lineMaterial;
    private static readonly int SrcBlend = Shader.PropertyToID("_SrcBlend");
    private static readonly int DstBlend = Shader.PropertyToID("_DstBlend");
    private static readonly int Cull = Shader.PropertyToID("_Cull");
    private static readonly int ZWrite = Shader.PropertyToID("_ZWrite");
    
    private readonly LassoSelect _lasso = new();
    private readonly List<Vector2> _points = new();
    private bool _active = false;
    
    public LassoSelector()
    {
        activators.Add(new ManipulatorActivationFilter
        {
            button = MouseButton.LeftMouse
        });
        activators.Add(new ManipulatorActivationFilter
        {
            button = MouseButton.LeftMouse,
            modifiers = EventModifiers.Shift
        });
        activators.Add(new ManipulatorActivationFilter
        {
            button = MouseButton.LeftMouse,
            modifiers = EventModifiers.Control
        });
        activators.Add(new ManipulatorActivationFilter
        {
            button = MouseButton.LeftMouse,
            modifiers = EventModifiers.Command
        });
    }
    
    protected override void RegisterCallbacksOnTarget()
    {
        if (target is not GraphView graphView)
        {
            throw new InvalidOperationException("The LassoSelector manipulator can only be added to a GraphView");
        }
            
        graphView.RegisterCallback<MouseDownEvent>(OnMouseDown);
        graphView.RegisterCallback<MouseUpEvent>(OnMouseUp);
        graphView.RegisterCallback<MouseMoveEvent>(OnMouseMove);
        graphView.RegisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOutEvent);
    }
    
    protected override void UnregisterCallbacksFromTarget()
    {
        target.UnregisterCallback<MouseDownEvent>(OnMouseDown);
        target.UnregisterCallback<MouseUpEvent>(OnMouseUp);
        target.UnregisterCallback<MouseMoveEvent>(OnMouseMove);
        target.UnregisterCallback<MouseCaptureOutEvent>(OnMouseCaptureOutEvent);
    }
    
    private void OnMouseCaptureOutEvent(MouseCaptureOutEvent _)
    {
        if (!_active) return;
            
        _lasso.RemoveFromHierarchy();
        _active = false;
    }
    
    private void OnMouseDown(MouseDownEvent e)
    {
        if (!_active)
        {
            if (target is not GraphView graphView || !CanStartManipulation(e))
                return;

            if (!e.actionKey && !e.shiftKey)
            {
                graphView.ClearSelection();
            }
                
            _points.Clear();
            _points.Add(e.localMousePosition);
            _lasso.SetPoints(_points);
                
            graphView.Add(_lasso);
            _active = true;
                
            target.CaptureMouse();
        }
            
        e.StopImmediatePropagation();
    }
    
    private void OnMouseUp(MouseUpEvent e)
    {
        if (!_active) return;

        if (target is not GraphView graphView || !CanStopManipulation(e))
            return;
            
        graphView.Remove(_lasso);
        _active = false;
            
        foreach (GraphElement graphElement in graphView.graphElements)
        {
            if (!IsWithinFreeformShape(graphElement, _points))
                continue;
                
            if (!e.actionKey)
            {
                graphView.AddToSelection(graphElement);
            }
            else
            {
                graphView.RemoveFromSelection(graphElement);
            }
        }
            
        _points.Clear();
        _lasso.SetPoints(_points);
            
        target.ReleaseMouse();
        e.StopPropagation();
    }
    
    private void OnMouseMove(MouseMoveEvent e)
    {
        if (!_active) return;
        
        if (_points.Count + 1 > MAX_POINTS)
        {
            Debug.LogFormat
            (
                LogType.Warning, LogOption.NoStacktrace, null,
                $"Selection reset: exceeded maximum point limit of {MAX_POINTS}."
            );
            _points.Clear();
        }
        
        _points.Add(e.localMousePosition);
        _lasso.SetPoints(_points);
        
        e.StopPropagation();
    }
    
    private bool IsWithinFreeformShape(GraphElement element, List<Vector2> shape)
    {
        if (target is not GraphView graphView)
            return false;
        
        Rect transformedBounds = ComputeAxisAlignedBound(element.localBound, graphView.viewTransform.matrix);
        Vector2[] corners = new[]
        {
            new Vector2(transformedBounds.xMin, transformedBounds.yMin),
            new Vector2(transformedBounds.xMax, transformedBounds.yMin),
            new Vector2(transformedBounds.xMax, transformedBounds.yMax),
            new Vector2(transformedBounds.xMin, transformedBounds.yMax)
        };
            
        foreach (Vector2 corner in corners)
        {
            if (IsPointInsidePolygon(corner, shape))
                return true;
        }

        return false;
    }
    
    private Rect ComputeAxisAlignedBound(Rect position, Matrix4x4 transform)
    {
        Vector3 a = transform.MultiplyPoint3x4(position.min);
        Vector3 b = transform.MultiplyPoint3x4(position.max);
        return Rect.MinMaxRect
        (
            Math.Min(a.x, b.x),
            Math.Min(a.y, b.y),
            Math.Max(a.x, b.x),
            Math.Max(a.y, b.y)
        );
    }
    
    private bool IsPointInsidePolygon(Vector2 point, List<Vector2> polygon)
    {
        bool isInside = false;
        for (int i = 0, j = polygon.Count - 1; i < polygon.Count; j = i++)
        {
            float piY = polygon[i].y;
            float pjY = polygon[j].y;
            float piX = polygon[i].x;
            float pjX = polygon[j].x;
                
            bool isYAbovePoint = piY > point.y;
            bool isYBelowPoint = pjY > point.y;
            bool isYInRange = isYAbovePoint != isYBelowPoint;

            float slope = (pjX - piX) / (pjY - piY);
            float intersectionX = piX + slope * (point.y - piY);

            bool isXLessThanIntersection = point.x < intersectionX;

            if (isYInRange && isXLessThanIntersection)
            {
                isInside = !isInside;
            }
        }
        return isInside;
    }
    
    private class LassoSelect : ImmediateModeElement
    {
        private const float LINE_WIDTH = 2.5f;
        private readonly Color LineColor = new Color(0f, 0.5f, 1f);
        
        public IReadOnlyList<Vector2> Points => _points;
        private List<Vector2> _points = new();
        
        public void SetPoints(List<Vector2> newPoints)
        {
            _points = newPoints;
            MarkDirtyRepaint();
        }
        
        protected override void ImmediateRepaint()
        {
            if (Points == null || Points.Count < 2)
                return;
                
            CreateLineMaterialIfNeeded();
            _lineMaterial.SetPass(0);
                
            GL.PushMatrix();
            GL.Begin(GL.QUADS);
            GL.Color(LineColor);
                
            for (int i = 0; i < Points.Count - 1; i++)
            {
                DrawThickLine(Points[i], Points[i + 1]);
            }
                
            DrawThickLine(Points[Points.Count - 1], Points[0]);

            GL.End();
            GL.PopMatrix();
        }
        
        private void DrawThickLine(Vector2 pointA, Vector2 pointB)
        {
            Vector2 direction = (pointB - pointA).normalized;
            Vector2 perpendicular = new Vector2(-direction.y, direction.x) * (LINE_WIDTH / 2f);
                
            GL.Color(LineColor);
            GL.Vertex(pointA + perpendicular);
            GL.Vertex(pointA - perpendicular);
            GL.Vertex(pointB - perpendicular);
            GL.Vertex(pointB + perpendicular);
        }
        
        private static void CreateLineMaterialIfNeeded()
        {
            if (_lineMaterial != null)
                return;
                
            Shader shader = Shader.Find("Hidden/Internal-Colored");
            _lineMaterial = new Material(shader)
            {
                hideFlags = HideFlags.HideAndDontSave
            };

            _lineMaterial.SetInt(SrcBlend, (int)UnityEngine.Rendering.BlendMode.SrcAlpha);
            _lineMaterial.SetInt(DstBlend, (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha);
            _lineMaterial.SetInt(Cull, (int)UnityEngine.Rendering.CullMode.Off);
            _lineMaterial.SetInt(ZWrite, 0);
        } 
    }
}

Now, just drop this LassoSelector.cs manipulator into your GraphView like so:

YOUR_GRAPH_VIEW.AddManipulator(new LassoSelector());

And BOOM! You’ve got a fully functional lasso selector in your GraphView implementation!