Created
May 10, 2025 11:30
-
-
Save akaashhakim/bdff81123d2b9787e5fc3623fd826ec4 to your computer and use it in GitHub Desktop.
π Quick Replace Tool for Unity A powerful editor extension to batch-replace GameObjects while preserving transforms, with snapping and randomization options!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using UnityEngine; | |
using UnityEditor; | |
using System.Collections.Generic; | |
public class QuickReplaceTool : EditorWindow | |
{ | |
#region INSTRUCTIONS | |
/* | |
* ===== QUICK REPLACE TOOL ===== | |
* HOW TO USE: | |
* 1. SELECT objects in scene | |
* 2. Click [Add Selected to Queue] | |
* 3. Set replacement prefab | |
* 4. Configure options: | |
* - Transform preservation (position/rotation/scale) | |
* - Ground snapping (layers/tags) | |
* - Random prefabs (optional) | |
* 5. Click [Replace Objects] | |
* | |
* TIPS: | |
* - Hold ALT while clicking for quick access | |
* - Use CTRL+Z to undo replacements | |
* - Enable debug logs for troubleshooting | |
*/ | |
#endregion | |
#region CORE_VARIABLES | |
private List<GameObject> replacementQueue = new List<GameObject>(); | |
private GameObject replacementPrefab; | |
private Vector2 scrollPosition; | |
#endregion | |
#region TRANSFORM_VARIABLES | |
private Vector3 positionOffset = Vector3.zero; | |
private Vector3 rotationOffset = Vector3.zero; | |
private Vector3 scaleOffset = Vector3.one; | |
private bool preserveScale = true; | |
private bool preserveRotation = true; | |
private bool preservePosition = true; | |
#endregion | |
#region SNAPPING_VARIABLES | |
private bool snapToGround = false; | |
private bool alignToNormal = false; | |
[SerializeField] private LayerMask groundLayerMask = ~0; | |
private string groundTagFilter = ""; | |
private float snapRaycastDistance = 1000f; | |
#endregion | |
#region RANDOMIZATION_VARIABLES | |
private bool useRandomPrefab = false; | |
[SerializeField] private List<GameObject> randomPrefabList = new List<GameObject>(); | |
#endregion | |
#region UI_VARIABLES | |
private bool showAdvancedSnapping = false; | |
private bool showTransformOptions = true; | |
private bool showReplacementSettings = true; | |
private bool showSnappingOptions = true; | |
private bool logReport = true; | |
#endregion | |
[MenuItem("Tools/Akaash/Quick Replace Tool &r")] | |
public static void ShowWindow() => GetWindow<QuickReplaceTool>("Quick Replace"); | |
private void OnGUI() | |
{ | |
scrollPosition = EditorGUILayout.BeginScrollView(scrollPosition); | |
DrawHeader(); | |
DrawMainContent(); | |
EditorGUILayout.EndScrollView(); | |
} | |
private void DrawHeader() | |
{ | |
EditorGUILayout.Space(); | |
EditorGUILayout.LabelField("Quick Replace Tool", EditorStyles.boldLabel); | |
EditorGUILayout.HelpBox( | |
"1. Select objects in scene\n" + | |
"2. Add to queue\n" + | |
"3. Set replacement prefab\n" + | |
"4. Configure options\n" + | |
"5. Click Replace!\n\n" + | |
"Shortcut: Alt+R | Undo: Ctrl+Z", | |
MessageType.Info); | |
EditorGUILayout.Space(); | |
} | |
private void DrawMainContent() | |
{ | |
DrawQueueSection(); | |
DrawReplacementSettings(); | |
DrawTransformOptions(); | |
DrawSnappingOptions(); | |
DrawActionButtons(); | |
} | |
#region UI_SECTIONS | |
private void DrawQueueSection() | |
{ | |
EditorGUILayout.BeginVertical(EditorStyles.helpBox); | |
EditorGUILayout.LabelField("Replacement Queue", EditorStyles.boldLabel); | |
EditorGUILayout.BeginHorizontal(); | |
if (GUILayout.Button("Add Selected", GUILayout.Height(25))) | |
{ | |
AddSelectedToQueue(); | |
} | |
if (GUILayout.Button("Clear Queue", GUILayout.Height(25))) | |
{ | |
replacementQueue.Clear(); | |
} | |
EditorGUILayout.EndHorizontal(); | |
EditorGUILayout.HelpBox($"Objects in queue: {replacementQueue.Count}", MessageType.None); | |
// Display queue items | |
if (replacementQueue.Count > 0) | |
{ | |
EditorGUILayout.LabelField("Objects to replace:", EditorStyles.miniBoldLabel); | |
EditorGUI.indentLevel++; | |
foreach (var obj in replacementQueue) | |
{ | |
EditorGUILayout.ObjectField(obj, typeof(GameObject), true); | |
} | |
EditorGUI.indentLevel--; | |
} | |
EditorGUILayout.EndVertical(); | |
EditorGUILayout.Space(); | |
} | |
private void DrawReplacementSettings() | |
{ | |
showReplacementSettings = EditorGUILayout.Foldout(showReplacementSettings, "Replacement Settings", true); | |
if (!showReplacementSettings) return; | |
EditorGUILayout.BeginVertical(EditorStyles.helpBox); | |
replacementPrefab = (GameObject)EditorGUILayout.ObjectField("Replacement Prefab", replacementPrefab, typeof(GameObject), false); | |
EditorGUILayout.Space(); | |
useRandomPrefab = EditorGUILayout.Toggle("Use Random Prefabs", useRandomPrefab); | |
if (useRandomPrefab) | |
{ | |
EditorGUI.indentLevel++; | |
SerializedObject so = new SerializedObject(this); | |
EditorGUILayout.PropertyField(so.FindProperty("randomPrefabList"), new GUIContent("Prefab List"), true); | |
so.ApplyModifiedProperties(); | |
EditorGUI.indentLevel--; | |
if (randomPrefabList.Count == 0) | |
{ | |
EditorGUILayout.HelpBox("Add prefabs to the list for randomization", MessageType.Warning); | |
} | |
} | |
EditorGUILayout.EndVertical(); | |
EditorGUILayout.Space(); | |
} | |
private void DrawTransformOptions() | |
{ | |
showTransformOptions = EditorGUILayout.Foldout(showTransformOptions, "Transform Options", true); | |
if (!showTransformOptions) return; | |
EditorGUILayout.BeginVertical(EditorStyles.helpBox); | |
preservePosition = EditorGUILayout.Toggle("Preserve Position", preservePosition); | |
preserveRotation = EditorGUILayout.Toggle("Preserve Rotation", preserveRotation); | |
preserveScale = EditorGUILayout.Toggle("Preserve Scale", preserveScale); | |
EditorGUILayout.Space(); | |
if (!preservePosition) | |
{ | |
EditorGUI.indentLevel++; | |
positionOffset = EditorGUILayout.Vector3Field("Position Offset", positionOffset); | |
EditorGUI.indentLevel--; | |
} | |
if (!preserveRotation) | |
{ | |
EditorGUI.indentLevel++; | |
rotationOffset = EditorGUILayout.Vector3Field("Rotation Offset", rotationOffset); | |
EditorGUI.indentLevel--; | |
} | |
if (!preserveScale) | |
{ | |
EditorGUI.indentLevel++; | |
scaleOffset = EditorGUILayout.Vector3Field("Scale Offset", scaleOffset); | |
EditorGUI.indentLevel--; | |
} | |
EditorGUILayout.EndVertical(); | |
EditorGUILayout.Space(); | |
} | |
private void DrawSnappingOptions() | |
{ | |
showSnappingOptions = EditorGUILayout.Foldout(showSnappingOptions, "Snapping Options", true); | |
if (!showSnappingOptions) return; | |
EditorGUILayout.BeginVertical(EditorStyles.helpBox); | |
snapToGround = EditorGUILayout.Toggle("Snap to Ground", snapToGround); | |
if (snapToGround) | |
{ | |
EditorGUI.indentLevel++; | |
snapRaycastDistance = EditorGUILayout.FloatField("Raycast Distance", snapRaycastDistance); | |
alignToNormal = EditorGUILayout.Toggle("Align to Surface Normal", alignToNormal); | |
EditorGUILayout.Space(); | |
showAdvancedSnapping = EditorGUILayout.Foldout(showAdvancedSnapping, "Advanced Filters"); | |
if (showAdvancedSnapping) | |
{ | |
EditorGUI.indentLevel++; | |
groundLayerMask = EditorGUILayout.MaskField("Ground Layers", | |
groundLayerMask, | |
UnityEditorInternal.InternalEditorUtility.layers); | |
groundTagFilter = EditorGUILayout.TagField("Ground Tag Filter", groundTagFilter); | |
EditorGUI.indentLevel--; | |
} | |
EditorGUI.indentLevel--; | |
} | |
EditorGUILayout.EndVertical(); | |
EditorGUILayout.Space(); | |
} | |
private void DrawActionButtons() | |
{ | |
EditorGUILayout.BeginHorizontal(); | |
GUILayout.FlexibleSpace(); | |
GUI.enabled = replacementQueue.Count > 0 && (replacementPrefab != null || (useRandomPrefab && randomPrefabList.Count > 0)); | |
if (GUILayout.Button("Replace Objects", GUILayout.Height(30), GUILayout.Width(150))) | |
{ | |
ReplaceObjects(); | |
} | |
GUI.enabled = true; | |
GUILayout.FlexibleSpace(); | |
EditorGUILayout.EndHorizontal(); | |
// Add GitHub link and thank you message | |
DrawFooter(); | |
} | |
#endregion | |
#region CORE_FUNCTIONALITY | |
private void AddSelectedToQueue() | |
{ | |
if (Selection.gameObjects.Length == 0) | |
{ | |
EditorUtility.DisplayDialog("No Selection", "Please select objects in the scene first", "OK"); | |
return; | |
} | |
foreach (var obj in Selection.gameObjects) | |
{ | |
if (!replacementQueue.Contains(obj)) | |
replacementQueue.Add(obj); | |
} | |
} | |
private void ReplaceObjects() | |
{ | |
if (replacementQueue.Count == 0) | |
{ | |
Debug.LogWarning("Queue is empty!"); | |
return; | |
} | |
if (!replacementPrefab && !useRandomPrefab) | |
{ | |
Debug.LogError("No replacement prefab assigned!"); | |
return; | |
} | |
if (useRandomPrefab && randomPrefabList.Count == 0) | |
{ | |
Debug.LogError("Random prefab list is empty!"); | |
return; | |
} | |
Undo.SetCurrentGroupName("Quick Replace"); | |
int replacedCount = 0; | |
foreach (var oldObj in replacementQueue) | |
{ | |
if (!oldObj) continue; | |
GameObject newPrefab = useRandomPrefab | |
? randomPrefabList[Random.Range(0, randomPrefabList.Count)] | |
: replacementPrefab; | |
GameObject newObj = (GameObject)PrefabUtility.InstantiatePrefab(newPrefab); | |
// Apply transforms | |
newObj.transform.position = preservePosition ? oldObj.transform.position : oldObj.transform.position + positionOffset; | |
newObj.transform.rotation = preserveRotation ? oldObj.transform.rotation : oldObj.transform.rotation * Quaternion.Euler(rotationOffset); | |
newObj.transform.localScale = preserveScale ? oldObj.transform.localScale : Vector3.Scale(oldObj.transform.localScale, scaleOffset); | |
// Advanced snapping | |
if (snapToGround) | |
SnapToGround(newObj); | |
// Undo/Redo tracking | |
Undo.RegisterCreatedObjectUndo(newObj, "Replace Object"); | |
Undo.DestroyObjectImmediate(oldObj); | |
replacedCount++; | |
} | |
if (logReport) | |
{ | |
Debug.Log($"Replaced {replacedCount} objects. " + | |
(useRandomPrefab ? $"Used {randomPrefabList.Count} random prefabs" : $"Used prefab: {replacementPrefab.name}")); | |
} | |
replacementQueue.Clear(); | |
} | |
private void SnapToGround(GameObject obj) | |
{ | |
// Create a list of all colliders from objects in the replacement queue | |
List<Collider> collidersToIgnore = new List<Collider>(); | |
foreach (var go in replacementQueue) | |
{ | |
if (go != null) | |
{ | |
var collider = go.GetComponent<Collider>(); | |
if (collider != null) | |
{ | |
collidersToIgnore.Add(collider); | |
} | |
} | |
} | |
Vector3 rayStart = obj.transform.position + (Vector3.up * 100); | |
RaycastHit hit; | |
// Perform the raycast and ignore the colliders in our exclusion list | |
if (RaycastIgnore(rayStart, Vector3.down, out hit, snapRaycastDistance, groundLayerMask, collidersToIgnore)) | |
{ | |
// Check tag filter if specified | |
if (!string.IsNullOrEmpty(groundTagFilter) && !hit.collider.CompareTag(groundTagFilter)) | |
return; | |
obj.transform.position = hit.point; | |
if (alignToNormal) | |
{ | |
obj.transform.rotation = Quaternion.FromToRotation(Vector3.up, hit.normal); | |
} | |
} | |
} | |
// Custom raycast function that ignores specific colliders | |
private bool RaycastIgnore(Vector3 origin, Vector3 direction, out RaycastHit hit, float distance, LayerMask layerMask, List<Collider> collidersToIgnore) | |
{ | |
// Get all hits along the ray | |
RaycastHit[] allHits = Physics.RaycastAll(origin, direction, distance, layerMask); | |
// Sort hits by distance | |
System.Array.Sort(allHits, (x, y) => x.distance.CompareTo(y.distance)); | |
// Find the first hit that isn't in our ignore list | |
foreach (var potentialHit in allHits) | |
{ | |
if (!collidersToIgnore.Contains(potentialHit.collider)) | |
{ | |
hit = potentialHit; | |
return true; | |
} | |
} | |
hit = new RaycastHit(); | |
return false; | |
} | |
#endregion | |
private void DrawFooter() | |
{ | |
EditorGUILayout.Space(20); | |
EditorGUILayout.BeginVertical(EditorStyles.helpBox); | |
// Create style for yellow bold text | |
GUIStyle yellowBoldStyle = new GUIStyle(EditorStyles.label); | |
yellowBoldStyle.normal.textColor = Color.yellow; | |
yellowBoldStyle.fontStyle = FontStyle.Bold; | |
yellowBoldStyle.alignment = TextAnchor.MiddleCenter; | |
yellowBoldStyle.wordWrap = true; | |
// Create regular centered style for other text | |
GUIStyle centeredStyle = new GUIStyle(EditorStyles.label); | |
centeredStyle.alignment = TextAnchor.MiddleCenter; | |
centeredStyle.wordWrap = true; | |
EditorGUILayout.LabelField("Thank you for using Quick Replace Tool!", yellowBoldStyle); | |
EditorGUILayout.Space(5); | |
if (GUILayout.Button("β Visit GitHub Repository", EditorStyles.miniButton)) | |
{ | |
Application.OpenURL("https://github.com/akaashhakim"); | |
} | |
EditorGUILayout.Space(5); | |
EditorGUILayout.LabelField("Consider starring the repo if you find it useful!", centeredStyle); | |
EditorGUILayout.EndVertical(); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment