Unity Editor Tools: The Place Objects Tool part 2

Place Object Editor Tool

In this post, we’ll complete the Place Objects Editor Tool from the previous post. If you haven’t read that post, you can find it here. In the last post, we covered the main tool functionality. Now we need to build the UI and capture the editor events that make it work. To wrap it up, we’ll add a custom context menu.

UI Toolkit

UI Toolkit is the new preferred UI system for building Editor Tools. If you’re familiar with web technologies like the DOM and CSS, it’ll feel familiar to you. That said, I’m not a UI Toolkit expert, so this explanation will be quick and dirty. I’m only going to demonstrate how I built a simple UI with the help of a decompiler, IntelliSense and a healthy dose of trial and error.

UI Toolkit UIs are a tree of visual elements. Generally, the visual elements and their style are separate, including size, padding, colour, alignment, and many more properties. You can apply a style to individual elements, classes of elements, hierarchies of elements, etc. Like I mentioned, if you’re familiar with HTML and CSS, this will sound familiar. In UI Toolkit, you can build your interface in an HTML-inspired UXML document. Then you can write a separate USS, or Unity Style Sheet, to describe the style properties. Then in your code, you load your UXML file and apply your USS to it. You could also do the whole thing in C#. I’m lazy and not interested in messing around in three different files, so I chose the latter option. That said, I’ve heard that if you modify your USS file while the corresponding Editor Window is open, it updates immediately. In C#, your code recompiles to apply your changes, so it’s worth learning the other approach if you’re spending a lot of time working on a complex tool.

Making the UI

Every window in the Unity editor has a root visual element. Many windows still rely on IMGUI, so they circumvent that by attaching an IMGUI container to their root visual element. Why is this important? Because it means that we can create a UI Toolkit GUI and attach it to the Scene View’s root visual element. As a result, we can draw whatever we want on top of the Scene View.

So we’ll create a root element of our own, add a title label and an object field where the user can specify a game object or prefab to place. Then we’ll add that to the Scene View. We’ll create the UI in OnActivated(), which is a method that’s called when our tool activates. Additionally, we’ll remove it in OnWillBeDeactivated() to ensure we don’t keep stacking UIs every time we select the tool.

public override void OnActivated()
{
    //Create the UI
    _toolRootElement = new VisualElement();
    _toolRootElement.style.width = 200;
    var backgroundColor = EditorGUIUtility.isProSkin
        ? new Color(0.21f, 0.21f, 0.21f, 0.8f)
        : new Color(0.8f, 0.8f, 0.8f, 0.8f);
    _toolRootElement.style.backgroundColor = backgroundColor;
    _toolRootElement.style.marginLeft = 10f;
    _toolRootElement.style.marginBottom = 10f;
    _toolRootElement.style.paddingTop = 5f;
    _toolRootElement.style.paddingRight = 5f;
    _toolRootElement.style.paddingLeft = 5f;
    _toolRootElement.style.paddingBottom = 5f;
    var titleLabel = new Label("Place Objects Tool");
    titleLabel.style.unityTextAlign = TextAnchor.UpperCenter;

    _prefabObjectField = new ObjectField { allowSceneObjects = true, objectType = typeof(GameObject) };

    _toolRootElement.Add(titleLabel);
    _toolRootElement.Add(_prefabObjectField);

    var sv = SceneView.lastActiveSceneView;
    sv.rootVisualElement.Add(_toolRootElement);
    sv.rootVisualElement.style.flexDirection = FlexDirection.ColumnReverse;
}

public override void OnWillBeDeactivated()
{
    _toolRootElement?.RemoveFromHierarchy();
}

Most of the styling makes our little window easier to look at; however, there’s one trick hidden there: the FlexDirection. By default, when you add a visual element to the tree, it appends to the bottom. The reason is that the FlexDirection defaults to Column, which means grow from the top downwards. To layout objects from left to right, you set the FlexDirection to Row, which is particularly useful to place buttons side-by-side. In our case, I want my tool window to sit at the bottom-left of the Scene View. There are many ways to accomplish that, but I found an easy trick is setting the FlexDirection to ColumnReverse, which means UI Toolkit will add visual elements from the bottom upwards.

Editor Events

Editor events are an essential concept to understand when building editor tools. Contrary to a game that updates at a real-time frame rate (usually 30-60fps), the editor is idle and only updates when it reacts to an event. Most of the events are input, but others include `Repaint` or `Layout`. You can see all the event types here.

We’ll use Events to read mouse input from the user. However, first, we need to understand the notion of using an event. When the editor receives an event, it passes it from window to window until one calls Use(). Once this happens, the event becomes Used, an EventType to signify that one of the windows processed it. Why is this important? Because when our tool is active, we must capture the MouseDown event first, or we’ll lose it. Doing so is a bit tricky because, typically, the SceneView will process it first. Luckily, the SceneView has a callback called beforeSceneGui. So we’ll hook into that callback and capture the MouseDown event first.

public override void OnActivated()
{
    //Create the UI
    _toolRootElement = new VisualElement();
    _toolRootElement.style.width = 200;
    var backgroundColor = EditorGUIUtility.isProSkin
        ? new Color(0.21f, 0.21f, 0.21f, 0.8f)
        : new Color(0.8f, 0.8f, 0.8f, 0.8f);
    _toolRootElement.style.backgroundColor = backgroundColor;
    _toolRootElement.style.marginLeft = 10f;
    _toolRootElement.style.marginBottom = 10f;
    _toolRootElement.style.paddingTop = 5f;
    _toolRootElement.style.paddingRight = 5f;
    _toolRootElement.style.paddingLeft = 5f;
    _toolRootElement.style.paddingBottom = 5f;

    var titleLabel = new Label("Place Objects Tool");
    titleLabel.style.unityTextAlign = TextAnchor.UpperCenter;

    _prefabObjectField = new ObjectField { allowSceneObjects = true, objectType = typeof(GameObject) };

    _toolRootElement.Add(titleLabel);
    _toolRootElement.Add(_prefabObjectField);

    var sv = SceneView.lastActiveSceneView;
    sv.rootVisualElement.Add(_toolRootElement);
    sv.rootVisualElement.style.flexDirection = FlexDirection.ColumnReverse;
    
    SceneView.beforeSceneGui += BeforeSceneGUI;
}

public override void OnWillBeDeactivated()
{
    _toolRootElement?.RemoveFromHierarchy();
    SceneView.beforeSceneGui -= BeforeSceneGUI;
}

So at the end of OnActivated we’ll register our method to the beforeSceneGui callback, and unregister it when deactivated. This will throw an error because we haven’t written it yet, so let’s do that.

void BeforeSceneGUI(SceneView sceneView)
{
    if (!ToolManager.IsActiveTool(this))
        return;

    if (!HasPlaceableObject)
    {
        _receivedClickDownEvent = false;
        _receivedClickUpEvent = false;
    }
    else
    {
        if (Event.current.type == EventType.MouseDown && Event.current.button == 0)
        {
            _receivedClickDownEvent = true;
            Event.current.Use();
        }

        if (_receivedClickDownEvent && Event.current.type == EventType.MouseUp && Event.current.button == 0)
        {
            _receivedClickDownEvent = false;
            _receivedClickUpEvent = true;
            Event.current.Use();
        }
    }
}

Our strategy is to set flags to determine if we’ve received the events we’re anticipating. Then, in OnToolGUI, we’ll react to those flags rather than the events directly. First, if we’re not the active tool, we return. Then, if we don’t have a placeable object, we can’t act, so reset both flags to false. By the way, the property HasPlaceableObject checks if the _prefabObjectField from earlier has an object set or not.

bool HasPlaceableObject => _prefabObjectField?.value != null;

Now, if we receive a left mouse button down event, set the _receivedClickDownEvent flag to true and Use() the event so that the SceneView ignores it. Then later, once we receive the matching left mouse button up event, do the same for the _receivedClickUpEvent. At this point, we’ve received a pair of mouse events, so we know it’s time to place an object.

Hopefully, this adds some necessary context to the OnToolGUI method from the previous post. The last step is to add a custom context menu on right-click.

Custom Scene View Context Menu

For my last trick, let’s make a simple context menu. The goal is to right-click on an object and have the option to select that as our placeable object. It’s a neat idea because it demonstrates how to make a dynamic context menu that adapts to what we click. As it turns out, this is straightforward to do. Let’s jump right to the code.

void ShowMenu()
{
    var picked = HandleUtility.PickGameObject(Event.current.mousePosition, true);
    if (!picked) return;

    var menu = new GenericMenu();
    menu.AddItem(new GUIContent($"Pick {picked.name}"), false, () => { _prefabObjectField.value = picked; });
    menu.ShowAsContext();
}

Using the HandleUtility API, we can pick a game object underneath the current mouse position. By the way, the PickGameObject method is the same one the scene view uses when you left-click on things in the scene. If we successfully picked an object, we can now create a GenericMenu. With a GenericMenu, we can add menu items, giving them a name and a callback method. We can also use the middle argument to draw a checkmark next to the menu item to represent an on/off state visually. Then, call ShowAsContext() and it’s done.

Finally, where do we call the ShowMenu() method? Since the SceneView uses right-click to enter Fly mode, we have to catch the button press before the SceneView does. So we’ll add it to BeforeSceneGUI with our other Event code. By the way, if Event.current.button equals one, it’s the right mouse button.

void BeforeSceneGUI(SceneView sceneView)
{
    if (!ToolManager.IsActiveTool(this))
        return;

    if (Event.current.type == EventType.MouseDown && Event.current.button == 1)
    {
        ShowMenu();
        Event.current.Use();
    }
...

Here’s the entire tool class for context, but I’ll share the Github repository at the end as well.

using UnityEditor;
using UnityEditor.EditorTools;
using UnityEditor.UIElements;
using UnityEngine;
using UnityEngine.UIElements;

[EditorTool("Place Objects Tool")]
public class PlaceObjectsTool : EditorTool
{
    static Texture2D _toolIcon;

    readonly GUIContent _iconContent = new GUIContent
    {
        image = _toolIcon,
        text = "Place Objects Tool",
        tooltip = "Place Objects Tool"
    };

    VisualElement _toolRootElement;
    ObjectField _prefabObjectField;

    bool _receivedClickDownEvent;
    bool _receivedClickUpEvent;

    bool HasPlaceableObject => _prefabObjectField?.value != null;

    public override GUIContent toolbarIcon => _iconContent;

    public override void OnActivated()
    {
        //Create the UI
        _toolRootElement = new VisualElement();
        _toolRootElement.style.width = 200;
        var backgroundColor = EditorGUIUtility.isProSkin
            ? new Color(0.21f, 0.21f, 0.21f, 0.8f)
            : new Color(0.8f, 0.8f, 0.8f, 0.8f);
        _toolRootElement.style.backgroundColor = backgroundColor;
        _toolRootElement.style.marginLeft = 10f;
        _toolRootElement.style.marginBottom = 10f;
        _toolRootElement.style.paddingTop = 5f;
        _toolRootElement.style.paddingRight = 5f;
        _toolRootElement.style.paddingLeft = 5f;
        _toolRootElement.style.paddingBottom = 5f;
        var titleLabel = new Label("Place Objects Tool");
        titleLabel.style.unityTextAlign = TextAnchor.UpperCenter;

        _prefabObjectField = new ObjectField { allowSceneObjects = true, objectType = typeof(GameObject) };

        _toolRootElement.Add(titleLabel);
        _toolRootElement.Add(_prefabObjectField);

        var sv = SceneView.lastActiveSceneView;
        sv.rootVisualElement.Add(_toolRootElement);
        sv.rootVisualElement.style.flexDirection = FlexDirection.ColumnReverse;
        
        SceneView.beforeSceneGui += BeforeSceneGUI;
    }

    public override void OnWillBeDeactivated()
    {
        _toolRootElement?.RemoveFromHierarchy();
        SceneView.beforeSceneGui -= BeforeSceneGUI;
    }

    void BeforeSceneGUI(SceneView sceneView)
    {
        if (!ToolManager.IsActiveTool(this))
            return;

        if (Event.current.type == EventType.MouseDown && Event.current.button == 1)
        {
            ShowMenu();
            Event.current.Use();
        }

        if (!HasPlaceableObject)
        {
            _receivedClickDownEvent = false;
            _receivedClickUpEvent = false;
        }
        else
        {
            if (Event.current.type == EventType.MouseDown && Event.current.button == 0)
            {
                _receivedClickDownEvent = true;
                Event.current.Use();
            }

            if (_receivedClickDownEvent && Event.current.type == EventType.MouseUp && Event.current.button == 0)
            {
                _receivedClickDownEvent = false;
                _receivedClickUpEvent = true;
                Event.current.Use();
            }
        }
    }

    public override void OnToolGUI(EditorWindow window)
    {
        //If we're not in the scene view, we're not the active tool, we don't have a placeable object, exit.
        if (!(window is SceneView))
            return;

        if (!ToolManager.IsActiveTool(this))
            return;

        if (!HasPlaceableObject)
            return;

        //Draw a positional Handle.
        Handles.DrawWireDisc(GetCurrentMousePositionInScene(), Vector3.up, 0.5f);

        //If the user clicked, clone the selected object, place it at the current mouse position.
        if (_receivedClickUpEvent)
        {
            var newObject = _prefabObjectField.value;

            GameObject newObjectInstance;
            if (PrefabUtility.IsPartOfAnyPrefab(newObject))
            {
                var prefabPath = PrefabUtility.GetPrefabAssetPathOfNearestInstanceRoot(newObject);
                var prefab = AssetDatabase.LoadAssetAtPath<GameObject>(prefabPath);
                newObjectInstance = (GameObject)PrefabUtility.InstantiatePrefab(prefab);
            }
            else
            {
                newObjectInstance = Instantiate((GameObject)newObject);
            }

            newObjectInstance.transform.position = GetCurrentMousePositionInScene();

            Undo.RegisterCreatedObjectUndo(newObjectInstance, "Place new object");

            _receivedClickUpEvent = false;
        }

        //Force the window to repaint.
        window.Repaint();
    }

    Vector3 GetCurrentMousePositionInScene()
    {
        Vector3 mousePosition = Event.current.mousePosition;
        var placeObject = HandleUtility.PlaceObject(mousePosition, out var newPosition, out var normal);
        return placeObject ? newPosition : HandleUtility.GUIPointToWorldRay(mousePosition).GetPoint(10);
    }

    void ShowMenu()
    {
        var picked = HandleUtility.PickGameObject(Event.current.mousePosition, true);
        if (!picked) return;

        var menu = new GenericMenu();
        menu.AddItem(new GUIContent($"Pick {picked.name}"), false, () => { _prefabObjectField.value = picked; });
        menu.ShowAsContext();
    }
}

Wrap-up

That concludes my opening tour of editor scripting. The editor is quite extensive and complex. We covered many different concepts in these two posts, but there’s plenty more to learn. To recap, we covered creating a custom EditorTool, creating a UI with UI Toolkit, working with Editor Events, creating a context menu, and several helpful classes like Handles, HandleUtility, PrefabUtility, etc. These basics will serve as a good base for whatever you build.

See the entire project here on Github. To be notified when I write a new post, join my mailing list.

1 Comment

  1. gblekkenhorst

    This was a great tutorial! Can’t wait to try it!

Leave A Comment