// Copyright © Sascha Graeff/13Pixels. namespace ThirteenPixels.Placr { using UnityEngine; using UnityEditor; using UnityEditor.EditorTools; using System.Collections.Generic; using System.Linq; /// /// Placr increases your level design speed by letting you place objects quickly. /// // Enable the Placr Tool and select a Prefab or 3D model in your project view. // Options are available in the scene view. // Controls: // * Left click to place the object. // * Right click to de-select the object. // * Hold Ctrl to rotate the object with your mouse. // * Hold Shift to change the vertical offset. Right click while holding Shift to reset. [EditorTool(nameof(Placr))] internal class Placr : EditorTool { #region Operations private abstract class PlacrOperationBase { public abstract bool isActive { get; } public abstract void Update(Vector3 position, Quaternion rotation); public abstract void Apply(PlacrModifierList modifiers); public abstract void Abort(); } private class PlaceNewPrefabInstancesOperation : PlacrOperationBase { private GameObject[] prefabs; private GameObject selectedPrefab; private Transform prototype; public override bool isActive => prototype != null; public PlaceNewPrefabInstancesOperation(GameObject[] prefabs) { this.prefabs = prefabs; SelectRandomPrefab(); CreatePrototype(); } private void SelectRandomPrefab() { selectedPrefab = prefabs[Random.Range(0, prefabs.Length)]; } private void CreatePrototype() { var prototypeGameObject = Instantiate(selectedPrefab); prototypeGameObject.hideFlags = HideFlags.HideAndDontSave; var colliders = prototypeGameObject.GetComponentsInChildren(); foreach (var collider in colliders) { DestroyImmediate(collider); } var scripts = prototypeGameObject.GetComponentsInChildren(); foreach (var script in scripts) { DestroyImmediate(script); } prototype = prototypeGameObject.transform; } public override void Update(Vector3 position, Quaternion rotation) { prototype.position = position; prototype.rotation = rotation; } public override void Apply(PlacrModifierList modifiers) { var newInstance = (GameObject)PrefabUtility.InstantiatePrefab(selectedPrefab); var newTransform = newInstance.transform; if (Settings.Parent.parent) { newTransform.SetParent(Settings.Parent.parent); } newTransform.position = prototype.position; newTransform.rotation = prototype.rotation; modifiers.ApplyTo(newInstance); Undo.RegisterCreatedObjectUndo(newInstance, "Placr Place Object"); if (prefabs.Length > 1) { var previousSelected = selectedPrefab; SelectRandomPrefab(); if (selectedPrefab != previousSelected) { DestroyImmediate(prototype.gameObject); CreatePrototype(); } } } public override void Abort() { selectedPrefab = null; prefabs = null; if (prototype) { DestroyImmediate(prototype.gameObject); } } } private class MoveExistingGameObjectOperation : PlacrOperationBase { private Transform target; private Vector3 originalPosition; private Quaternion originalRotation; private readonly List temporarilyDisabledColliders = new List(); public override bool isActive => target != null; public MoveExistingGameObjectOperation(GameObject gameObject) { target = gameObject.transform; originalPosition = target.position; originalRotation = target.rotation; DisableTargetColliders(gameObject); } private void DisableTargetColliders(GameObject gameObject) { var colliders = gameObject.GetComponentsInChildren(); foreach (var collider in colliders) { if (collider.enabled) { collider.enabled = false; temporarilyDisabledColliders.Add(collider); } } } public override void Update(Vector3 position, Quaternion rotation) { target.position = position; target.rotation = rotation; } public override void Apply(PlacrModifierList modifiers) { foreach (var collider in temporarilyDisabledColliders) { var serializedObject = new SerializedObject(collider); PrefabUtility.RevertPropertyOverride(serializedObject.FindProperty("m_Enabled"), InteractionMode.AutomatedAction); serializedObject.ApplyModifiedPropertiesWithoutUndo(); if (!collider.enabled) { collider.enabled = true; } } temporarilyDisabledColliders.Clear(); var newPosition = target.position; var newRotation = target.rotation; Update(originalPosition, originalRotation); Undo.RecordObject(target, "Placr Move Object"); Update(newPosition, newRotation); target = null; } public override void Abort() { Update(originalPosition, originalRotation); target = null; } } #endregion private static class Settings { public static class Grid { public static bool isActive { get; private set; } private static Vector3 offset; private static Vector3 size = new Vector3(1, 0, 1); public static Vector3 Align(Vector3 v) { v.x = RoundToNext(v.x, offset.x, size.x); v.y = RoundToNext(v.y, offset.y, size.y); v.z = RoundToNext(v.z, offset.z, size.z); return v; } public static void DisplayOptionsGUI() { isActive = GUILayout.Toggle(isActive, new GUIContent("Grid")); GUI.enabled = isActive; GUILayout.BeginVertical(GUILayout.Width(150)); GUILayout.BeginHorizontal(); GUILayout.Label("Offset", GUILayout.Width(50)); offset = EditorGUILayout.Vector3Field(GUIContent.none, offset); GUILayout.EndHorizontal(); GUILayout.BeginHorizontal(); GUILayout.Label("Size", GUILayout.Width(50)); size = EditorGUILayout.Vector3Field(GUIContent.none, size); GUILayout.EndHorizontal(); GUILayout.EndVertical(); GUI.enabled = true; } } public static class AngleSnap { private static float snapValue = 0f; public static void DisplayOptionsGUI() { GUILayout.BeginHorizontal(GUILayout.Width(260)); GUILayout.Label("Angle Snap", GUILayout.Width(75)); snapValue = EditorGUILayout.FloatField(snapValue); snapValue = Mathf.Clamp(snapValue, 0, 180); void DisplayAngleSnapButton(float angle) { if (GUILayout.Button(angle + "", EditorStyles.miniButton)) { snapValue = angle; } } DisplayAngleSnapButton(0); DisplayAngleSnapButton(15); DisplayAngleSnapButton(45); DisplayAngleSnapButton(90); GUILayout.EndHorizontal(); } public static float CalculateSnappedAngle(float unsnappedAngle) { if (snapValue > 0) { return Mathf.Round(unsnappedAngle / snapValue) * snapValue; } return unsnappedAngle; } } // This setting was obsolete if Unity would let us use the default parent feature... public static class Parent { public static Transform parent { get; private set; } public static void DisplayOptionsGUI() { parent = (Transform)EditorGUILayout.ObjectField(new GUIContent("New Instance Parent"), parent, typeof(Transform), true); if (parent && IsPrefab(parent.gameObject)) { parent = null; } } } } private static class LastHit { public static Vector3 position { get; private set; } public static Vector3 normal { get; private set; } public static float distance { get; private set; } public static void Update(Vector3 position, Vector3 normal, float distance) { LastHit.position = position; LastHit.normal = normal; LastHit.distance = distance; } } private static class RaycastModes { private static readonly string[] titles = { "Ground Raycast", "Surface Raycast", "Fixed Ground Plane" }; public const int SURFACE_RAYCAST = 1; public const int FIXED_GROUND_PLANE = 2; public static int activeMode { get; private set; } = 0; public static void DisplayToolbar() { activeMode = GUILayout.Toolbar(activeMode, titles); } } private const float windowWidth = 380f; private PlacrOperationBase currentOperation; private bool isRunningOperation => currentOperation != null; private static float verticalOffset = 0f; private static float unsnappedYaw = 0f; private static float yaw = 0f; // Variables for proper right-click-to-exit detection private bool exiting; private Vector2 exitClickPosition; [SerializeField, HideInInspector] private PlacrModifierList modifierList = default; private bool displayModifiers = false; [SerializeField] private Texture2D toolIcon; public GUIContent _toolbarIconContent; public override GUIContent toolbarIcon => _toolbarIconContent; internal static Placr instance { get; private set; } public static bool isActive => instance != null; private void OnEnable() { _toolbarIconContent = new GUIContent() { image = toolIcon, text = " " + nameof(Placr), tooltip = nameof(Placr) }; #if !UNITY_2020_2_OR_NEWER instance = this; OnToolActivated(); #endif } #if UNITY_2020_2_OR_NEWER public override void OnActivated() { OnToolActivated(); } public override void OnWillBeDeactivated() { OnToolDeactivated(); } #else private static bool placrIsActive = false; [InitializeOnLoadMethod] private static void Intialize() { EditorTools.activeToolChanged += OnActiveToolChanged; } private static void OnActiveToolChanged() { if (EditorTools.IsActiveTool(instance)) { if (!placrIsActive) { instance.OnToolActivated(); placrIsActive = true; } } else { if (placrIsActive) { instance.OnToolDeactivated(); placrIsActive = false; } } } #endif private void OnToolActivated() { Selection.selectionChanged += OnSelectionChange; EditorApplication.update += RepaintSceneViews; ResetVerticalOffset(); ResetAngle(); modifierList = CreateInstance(); OnSelectionChange(); instance = this; } private void OnToolDeactivated() { Selection.selectionChanged -= OnSelectionChange; EditorApplication.update -= RepaintSceneViews; AbortCurrentOperation(); DestroyImmediate(modifierList); instance = null; } private void RepaintSceneViews() { if (isRunningOperation) { SceneView.RepaintAll(); } } public override void OnToolGUI(EditorWindow window) { #if !UNITY_2021_2_OR_NEWER Handles.BeginGUI(); GUILayout.Window(0, new Rect(10, 30, windowWidth, 0), id => OnGUI(), toolbarIcon); if (displayModifiers) { GUILayout.Window(1, new Rect(10, 200, windowWidth, 0), id => modifierList.Display(), new GUIContent("Modifiers")); } Handles.EndGUI(); #endif if (isRunningOperation) { PerformPlacrLogic(SceneView.currentDrawingSceneView); } } #if UNITY_2021_2_OR_NEWER public void DrawOverlayGUI() { GUILayout.BeginVertical(GUILayout.Width(windowWidth)); GUILayout.Space(8); DrawWindow(0, id => OnGUI(), toolbarIcon); if (displayModifiers) { GUILayout.Space(8); DrawWindow(1, id => modifierList.Display(), new GUIContent("Modifiers")); } GUILayout.FlexibleSpace(); GUILayout.EndVertical(); } private static void DrawWindow(int id, GUI.WindowFunction func, GUIContent title) { GUILayout.BeginVertical(title, GUI.skin.GetStyle("Window")); func(id); GUILayout.EndVertical(); } #endif /// /// Renders the in-SceneView GUI. /// private void OnGUI() { RaycastModes.DisplayToolbar(); GUILayout.BeginHorizontal(); GUILayout.BeginVertical(); Settings.Grid.DisplayOptionsGUI(); Settings.AngleSnap.DisplayOptionsGUI(); GUILayout.EndVertical(); GUILayout.BeginVertical(); DisplayPickUpButton(); DisplayModifierButton(); GUILayout.EndVertical(); GUILayout.EndHorizontal(); Settings.Parent.DisplayOptionsGUI(); } private void DisplayPickUpButton() { GUI.enabled = !isRunningOperation && Selection.activeGameObject && !IsPrefab(Selection.activeGameObject); if (GUILayout.Button("Pick up", GUILayout.Height(58))) { StartOperation(new MoveExistingGameObjectOperation(Selection.activeGameObject)); } GUI.enabled = true; } private void DisplayModifierButton() { if (modifierList == null) { modifierList = CreateInstance(); } var activeModifierCount = modifierList.activeModifierCount; var label = $"{modifierList.activeModifierCount} modifier{(modifierList.activeModifierCount == 1 ? "" : "s")}"; if (GUILayout.Button(label)) { displayModifiers = !displayModifiers; } } /// /// Performs the Placr logic as described in the class summary. /// private void PerformPlacrLogic(SceneView sceneView) { HandleUtility.AddDefaultControl(GUIUtility.GetControlID(FocusType.Passive)); var e = Event.current; if (!e.control && !e.shift) { EditorGUIUtility.AddCursorRect(sceneView.position, MouseCursor.MoveArrow); var ray = HandleUtility.GUIPointToWorldRay(e.mousePosition); if (RaycastModes.activeMode == RaycastModes.FIXED_GROUND_PLANE) { var plane = new Plane(Vector3.up, Vector3.zero); if (plane.Raycast(ray, out var distance)) { LastHit.Update(ray.GetPoint(distance), Vector3.up, distance); } } else { RaycastHit hit; if (Physics.Raycast(ray, out hit)) { var normal = RaycastModes.activeMode == RaycastModes.SURFACE_RAYCAST ? hit.normal : Vector3.up; LastHit.Update(hit.point, normal, hit.distance); } } } else if (e.control) { EditorGUIUtility.AddCursorRect(sceneView.position, MouseCursor.RotateArrow); unsnappedYaw -= e.delta.x; unsnappedYaw = Mathf.Repeat(unsnappedYaw, 360f); yaw = Settings.AngleSnap.CalculateSnappedAngle(unsnappedYaw); } else if (e.shift && e.button != 1) { EditorGUIUtility.AddCursorRect(sceneView.position, MouseCursor.ResizeVertical); verticalOffset += e.delta.y * -0.001f * LastHit.distance; } var newPosition = LastHit.position + Vector3.up * verticalOffset; if (Settings.Grid.isActive) { newPosition = Settings.Grid.Align(newPosition); } var newRotation = Quaternion.AngleAxis(yaw, LastHit.normal) * Quaternion.FromToRotation(Vector3.up, LastHit.normal); currentOperation.Update(newPosition, newRotation); CheckMouseClicks(); } private static float RoundToNext(float value, float offset, float scale) { if (scale <= 0) return value; value -= offset; value /= scale; value = Mathf.Round(value); return value * scale + offset; } /// /// Check if the left or right mouse button has been clicked. /// Place object or de-select asset accordingly. /// private void CheckMouseClicks() { var e = Event.current; if (e.type == EventType.MouseDown && !e.alt) { if (e.button == 0) { currentOperation.Apply(modifierList); if (!currentOperation.isActive) { currentOperation = null; } } else if (e.button == 1) { if (e.shift) { ResetVerticalOffset(); } else { exiting = true; exitClickPosition = e.mousePosition; } } } if (exiting) { var dist = Vector2.Distance(e.mousePosition, exitClickPosition); if (dist >= 5) { exiting = false; } else if (e.type == EventType.MouseUp && e.button == 1) { AbortCurrentOperation(); Selection.activeGameObject = null; } } } private void StartOperation(PlacrOperationBase operation) { AbortCurrentOperation(); currentOperation = operation; } private void AbortCurrentOperation() { if (currentOperation != null) { currentOperation.Abort(); currentOperation = null; } } private void OnSelectionChange() { AbortCurrentOperation(); ResetVerticalOffset(); var selection = Selection.gameObjects; if (selection.Length > 0) { var selectionIsAllPrefabs = selection.All(go => IsPrefab(go)); if (selectionIsAllPrefabs) { StartOperation(new PlaceNewPrefabInstancesOperation(selection.ToArray())); } } } private static bool IsPrefab(GameObject gameObject) { return gameObject && gameObject.scene.path == null; } private void ResetVerticalOffset() { verticalOffset = 0f; } private void ResetAngle() { unsnappedYaw = 0f; yaw = 0f; } } }