From ace9f47f3dbffe7c611816fb0f85eea13643eb36 Mon Sep 17 00:00:00 2001 From: mika Date: Thu, 30 Jan 2025 11:14:11 +0200 Subject: [PATCH 01/34] Update BoxColliderFitChildren.cs fixed collider for rotated or scaled children --- .../ContextMenu/BoxColliderFitChildren.cs | 45 +++++++++++-------- 1 file changed, 26 insertions(+), 19 deletions(-) diff --git a/Assets/Scripts/Editor/ContextMenu/BoxColliderFitChildren.cs b/Assets/Scripts/Editor/ContextMenu/BoxColliderFitChildren.cs index 58f7b0d..3287208 100644 --- a/Assets/Scripts/Editor/ContextMenu/BoxColliderFitChildren.cs +++ b/Assets/Scripts/Editor/ContextMenu/BoxColliderFitChildren.cs @@ -14,33 +14,40 @@ static void FixSize(MenuCommand command) { BoxCollider col = (BoxCollider)command.context; - // record undo + // Record undo for undo functionality Undo.RecordObject(col.transform, "Fit Box Collider To Children"); - // get child mesh bounds - var b = GetRecursiveMeshBounds(col.gameObject); + // Get transformed bounds relative to the collider object + Bounds localBounds = GetLocalBounds(col.transform); - // set collider local center and size - col.center = col.transform.root.InverseTransformVector(b.center) - col.transform.position; - col.size = b.size; + // Set collider local center and size + col.center = localBounds.center; + col.size = localBounds.size; } - public static Bounds GetRecursiveMeshBounds(GameObject go) + public static Bounds GetLocalBounds(Transform parent) { - var r = go.GetComponentsInChildren(); - if (r.Length > 0) - { - var b = r[0].bounds; - for (int i = 1; i < r.Length; i++) - { - b.Encapsulate(r[i].bounds); - } - return b; - } - else // TODO no renderers + var renderers = parent.GetComponentsInChildren(); + if (renderers.Length == 0) + return new Bounds(Vector3.zero, Vector3.zero); // No renderers + + // Initialize bounds in local space + Bounds bounds = new Bounds(parent.InverseTransformPoint(renderers[0].bounds.center), + parent.InverseTransformVector(renderers[0].bounds.size)); + + // Encapsulate all child renderers + for (int i = 1; i < renderers.Length; i++) { - return new Bounds(Vector3.one, Vector3.one); + var worldBounds = renderers[i].bounds; + + // Convert world bounds to local space + Vector3 localCenter = parent.InverseTransformPoint(worldBounds.center); + Vector3 localSize = parent.InverseTransformVector(worldBounds.size); + + bounds.Encapsulate(new Bounds(localCenter, localSize)); } + + return bounds; } } } From 3039e539f96efcb645ad7d64a787db92edd60d89 Mon Sep 17 00:00:00 2001 From: mika Date: Thu, 30 Jan 2025 14:47:29 +0200 Subject: [PATCH 02/34] Update BoxColliderFitChildren.cs fix rotated objects, fix negative scale --- .../ContextMenu/BoxColliderFitChildren.cs | 61 +++++++++++-------- 1 file changed, 34 insertions(+), 27 deletions(-) diff --git a/Assets/Scripts/Editor/ContextMenu/BoxColliderFitChildren.cs b/Assets/Scripts/Editor/ContextMenu/BoxColliderFitChildren.cs index 3287208..2690929 100644 --- a/Assets/Scripts/Editor/ContextMenu/BoxColliderFitChildren.cs +++ b/Assets/Scripts/Editor/ContextMenu/BoxColliderFitChildren.cs @@ -1,6 +1,5 @@ // Adjust Box Collider to fit child meshes inside // Usage: You have empty parent transform, with child meshes inside, add box collider to parent then use this -// NOTE: Doesnt work if root transform is rotated using UnityEngine; using UnityEditor; @@ -10,44 +9,52 @@ namespace UnityLibrary public class BoxColliderFitChildren : MonoBehaviour { [MenuItem("CONTEXT/BoxCollider/Fit to Children")] - static void FixSize(MenuCommand command) + static void FitColliderToChildren(MenuCommand command) { BoxCollider col = (BoxCollider)command.context; - // Record undo for undo functionality + // record undo Undo.RecordObject(col.transform, "Fit Box Collider To Children"); - // Get transformed bounds relative to the collider object - Bounds localBounds = GetLocalBounds(col.transform); + // first reset transform rotation + var origRot = col.transform.rotation; + col.transform.rotation = Quaternion.identity; - // Set collider local center and size - col.center = localBounds.center; - col.size = localBounds.size; - } + // get child mesh bounds + var b = GetRecursiveMeshBounds(col.gameObject); - public static Bounds GetLocalBounds(Transform parent) - { - var renderers = parent.GetComponentsInChildren(); - if (renderers.Length == 0) - return new Bounds(Vector3.zero, Vector3.zero); // No renderers + // set collider local center and size + col.center = col.transform.root.InverseTransformVector(b.center) - col.transform.position; - // Initialize bounds in local space - Bounds bounds = new Bounds(parent.InverseTransformPoint(renderers[0].bounds.center), - parent.InverseTransformVector(renderers[0].bounds.size)); + // keep size positive + var size = b.size; + size.x = Mathf.Abs(size.x); + size.y = Mathf.Abs(size.y); + size.z = Mathf.Abs(size.z); - // Encapsulate all child renderers - for (int i = 1; i < renderers.Length; i++) - { - var worldBounds = renderers[i].bounds; + col.size = b.size; - // Convert world bounds to local space - Vector3 localCenter = parent.InverseTransformPoint(worldBounds.center); - Vector3 localSize = parent.InverseTransformVector(worldBounds.size); + // restore rotation + col.transform.rotation = origRot; + } - bounds.Encapsulate(new Bounds(localCenter, localSize)); + public static Bounds GetRecursiveMeshBounds(GameObject go) + { + var r = go.GetComponentsInChildren(); + if (r.Length > 0) + { + var b = r[0].bounds; + for (int i = 1; i < r.Length; i++) + { + b.Encapsulate(r[i].bounds); + } + return b; + } + else // TODO no renderers? + { + //return new Bounds(Vector3.one, Vector3.one); + return new Bounds(); } - - return bounds; } } } From 812e45e185d1885710d3b8f12211949cabfd5464 Mon Sep 17 00:00:00 2001 From: mika Date: Wed, 5 Feb 2025 10:13:04 +0200 Subject: [PATCH 03/34] Create AnimationClipListImporter.cs --- .../Importers/AnimationClipListImporter.cs | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 Assets/Scripts/Editor/Importers/AnimationClipListImporter.cs diff --git a/Assets/Scripts/Editor/Importers/AnimationClipListImporter.cs b/Assets/Scripts/Editor/Importers/AnimationClipListImporter.cs new file mode 100644 index 0000000..733ac03 --- /dev/null +++ b/Assets/Scripts/Editor/Importers/AnimationClipListImporter.cs @@ -0,0 +1,65 @@ +// Checks for a .txt file with the same name as an imported .fbx file (in Assets/Models/ folder), containing a list of animation clips to add to the ModelImporter. +// .txt file should be tab-delimited with the following columns: "title", "start frame", "end frame" (and optional description, not used). +// example: +// Take0 10 40 asdf +// Take1 50 80 wasdf.. + +using System; +using System.IO; +using System.Collections.Generic; +using UnityEngine; +using UnityEditor; + +namespace UnityLibrary.Importers +{ + public class AnimationClipListImporter : AssetPostprocessor + { + void OnPreprocessModel() + { + ModelImporter modelImporter = assetImporter as ModelImporter; + if (modelImporter == null) return; + + string assetPath = assetImporter.assetPath; + if (!assetPath.StartsWith("Assets/Models") || !assetPath.EndsWith(".fbx", StringComparison.OrdinalIgnoreCase)) return; + + string txtPath = Path.ChangeExtension(assetPath, ".txt"); + if (!File.Exists(txtPath)) return; + + try + { + List clips = new List(); + string[] lines = File.ReadAllLines(txtPath); + + foreach (string line in lines) + { + string[] parts = line.Split('\t'); + if (parts.Length < 3) continue; // Ensure we have at least "title, start, end" + + string title = parts[0].Trim(); + if (!int.TryParse(parts[1], out int startFrame) || !int.TryParse(parts[2], out int endFrame)) + continue; + + ModelImporterClipAnimation clip = new ModelImporterClipAnimation + { + name = title, + firstFrame = startFrame, + lastFrame = endFrame, + loopTime = false + }; + + clips.Add(clip); + } + + if (clips.Count > 0) + { + modelImporter.clipAnimations = clips.ToArray(); + Debug.Log($"Added {clips.Count} animation clips to {assetPath}"); + } + } + catch (Exception ex) + { + Debug.LogError($"Failed to process animation data for {assetPath}: {ex.Message}"); + } + } + } +} From 43c11eeb4fa933d4adbe2159f0ef1074fd3c6f7c Mon Sep 17 00:00:00 2001 From: mika Date: Wed, 12 Feb 2025 15:02:15 +0200 Subject: [PATCH 04/34] Create CustomRectTransformInspector.cs --- .../CustomRectTransformInspector.cs | 294 ++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 Assets/Scripts/Editor/CustomInspector/CustomRectTransformInspector.cs diff --git a/Assets/Scripts/Editor/CustomInspector/CustomRectTransformInspector.cs b/Assets/Scripts/Editor/CustomInspector/CustomRectTransformInspector.cs new file mode 100644 index 0000000..02c51af --- /dev/null +++ b/Assets/Scripts/Editor/CustomInspector/CustomRectTransformInspector.cs @@ -0,0 +1,294 @@ +// source https://gist.github.com/GieziJo/f80bcb24c4caa68ebfb204148ccd4b18 +// =============================== +// AUTHOR : J. Giezendanner +// CREATE DATE : 12.03.2020 +// MODIFIED DATE : +// PURPOSE : Adds helper functions to the RectTransform to align the rect to the anchors and vise-versa +// SPECIAL NOTES : Sources for certain informations: +// Display anchors gizmos: +// https://forum.unity.com/threads/recttransform-custom-editor-ontop-of-unity-recttransform-custom-editor.455925/ +// Draw default inspector: +// https://forum.unity.com/threads/extending-instead-of-replacing-built-in-inspectors.407612/ +// =============================== +// Change History: +//================================== + +#if UNITY_EDITOR +using System; +using System.Reflection; +using UnityEditor.SceneManagement; +using UnityEngine; + + +namespace UnityEditor +{ + [CustomEditor(typeof(RectTransform), true)] + [CanEditMultipleObjects] + public class CustomRectTransformInspector : Editor + { + //Unity's built-in editor + Editor defaultEditor = null; + RectTransform rectTransform; + + bool rect2Anchors_foldout = false; + bool anchors2Rect_foldout = false; + bool rect2Anchors__previousState = false; + bool anchors2Rect_previousState = false; + + private bool playerPrefsChecked = false; + + void OnEnable() + { + //When this inspector is created, also create the built-in inspector + defaultEditor = Editor.CreateEditor(targets, Type.GetType("UnityEditor.RectTransformEditor, UnityEditor")); + rectTransform = target as RectTransform; + } + + void OnDisable() + { + //When OnDisable is called, the default editor we created should be destroyed to avoid memory leakage. + //Also, make sure to call any required methods like OnDisable + + if (defaultEditor != null) + { + MethodInfo disableMethod = defaultEditor.GetType().GetMethod("OnDisable", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (disableMethod != null) + disableMethod.Invoke(defaultEditor, null); + DestroyImmediate(defaultEditor); + } + } + + void checkPlayerPrefs() + { + rect2Anchors_foldout = PlayerPrefs.GetInt("giezi_tools_rect2Anchors_foldout_bool", 0) != 0; + anchors2Rect_foldout = PlayerPrefs.GetInt("giezi_tools_anchors2Rect_foldout_bool", 0) != 0; + + rect2Anchors__previousState = rect2Anchors_foldout; + anchors2Rect_previousState = anchors2Rect_foldout; + } + + + public override void OnInspectorGUI() + { + if (!playerPrefsChecked) + { + checkPlayerPrefs(); + playerPrefsChecked = true; + } + + defaultEditor.OnInspectorGUI(); + + + if (rectTransform.parent != null) + { + var centerButtonStyle = new GUIStyle(GUI.skin.button); + centerButtonStyle.fontStyle = FontStyle.Bold; + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Helper Functions", EditorStyles.boldLabel); + + rect2Anchors_foldout = EditorGUILayout.Foldout(rect2Anchors_foldout, "Set Rect to Anchors"); + + if (rect2Anchors_foldout) + { + GUILayout.BeginHorizontal(); + GUILayout.BeginVertical(); + if (GUILayout.Button("Top Left")) + setRectValue("topLeft"); + if (GUILayout.Button("Left")) + setRectValue("left"); + if (GUILayout.Button("Bottom Left")) + setRectValue("bottomLeft"); + GUILayout.EndVertical(); + GUILayout.BeginVertical(); + if (GUILayout.Button("Top")) + setRectValue("top"); + if (GUILayout.Button("All", centerButtonStyle)) + setRectValue("all"); + if (GUILayout.Button("Bottom")) + setRectValue("bottom"); + GUILayout.EndVertical(); + GUILayout.BeginVertical(); + if (GUILayout.Button("Top Right")) + setRectValue("topRight"); + if (GUILayout.Button("Right")) + setRectValue("right"); + if (GUILayout.Button("Bottom Right")) + setRectValue("bottomRight"); + GUILayout.EndVertical(); + GUILayout.EndHorizontal(); + } + + anchors2Rect_foldout = EditorGUILayout.Foldout(anchors2Rect_foldout, "Set Anchors to Rect"); + + if (anchors2Rect_foldout) + { + GUILayout.BeginHorizontal(); + GUILayout.BeginVertical(); + if (GUILayout.Button("Top Left")) + setAnchorsToRect("topLeft"); + if (GUILayout.Button("Left")) + setAnchorsToRect("left"); + if (GUILayout.Button("Bottom Left")) + setAnchorsToRect("bottomLeft"); + GUILayout.EndVertical(); + GUILayout.BeginVertical(); + if (GUILayout.Button("Top")) + setAnchorsToRect("top"); + if (GUILayout.Button("All", centerButtonStyle)) + setAnchorsToRect("all"); + if (GUILayout.Button("Bottom")) + setAnchorsToRect("bottom"); + GUILayout.EndVertical(); + GUILayout.BeginVertical(); + if (GUILayout.Button("Top Right")) + setAnchorsToRect("topRight"); + if (GUILayout.Button("Right")) + setAnchorsToRect("right"); + if (GUILayout.Button("Bottom Right")) + setAnchorsToRect("bottomRight"); + GUILayout.EndVertical(); + GUILayout.EndHorizontal(); + } + + + if (rect2Anchors_foldout != rect2Anchors__previousState) + { + rect2Anchors__previousState = rect2Anchors_foldout; + PlayerPrefs.SetInt("giezi_tools_rect2Anchors_foldout_bool", rect2Anchors_foldout ? 1 : 0); + } + + if (anchors2Rect_foldout != anchors2Rect_previousState) + { + anchors2Rect_previousState = anchors2Rect_foldout; + PlayerPrefs.SetInt("giezi_tools_anchors2Rect_foldout_bool", anchors2Rect_foldout ? 1 : 0); + } + } + } + + + private void OnSceneGUI() + { + MethodInfo onSceneGUI_Method = defaultEditor.GetType() + .GetMethod("OnSceneGUI", BindingFlags.NonPublic | BindingFlags.Instance); + onSceneGUI_Method.Invoke(defaultEditor, null); + } + + + private void setAnchorsToRect(string field) + { + Vector2 anchorMax = new Vector2(); + Vector2 anchorMin = new Vector2(); + var parent = rectTransform.parent; + anchorMin.x = rectTransform.offsetMin.x / parent.GetComponent().rect.size.x; + anchorMin.y = rectTransform.offsetMin.y / parent.GetComponent().rect.size.y; + anchorMax.x = rectTransform.offsetMax.x / parent.GetComponent().rect.size.x; + anchorMax.y = rectTransform.offsetMax.y / parent.GetComponent().rect.size.y; + + + switch (field) + { + case "topLeft": + anchorMax.x = 0; + rectTransform.anchorMax += anchorMax; + rectTransform.offsetMax = new Vector2(rectTransform.offsetMax.x, 0); + + anchorMin.y = 0; + rectTransform.anchorMin += anchorMin; + rectTransform.offsetMin = new Vector2(0, rectTransform.offsetMin.y); + break; + case "top": + anchorMax.x = 0; + rectTransform.anchorMax += anchorMax; + rectTransform.offsetMax = new Vector2(rectTransform.offsetMax.x, 0); + break; + case "topRight": + rectTransform.anchorMax += anchorMax; + rectTransform.offsetMax = Vector2.zero; + break; + case "bottomLeft": + rectTransform.anchorMin += anchorMin; + rectTransform.offsetMin = Vector2.zero; + break; + case "bottom": + anchorMin.x = 0; + rectTransform.anchorMin += anchorMin; + rectTransform.offsetMin = new Vector2(rectTransform.offsetMin.x, 0); + break; + case "bottomRight": + anchorMin.x = 0; + rectTransform.anchorMin += anchorMin; + rectTransform.offsetMin = new Vector2(rectTransform.offsetMin.x, 0); + anchorMax.y = 0; + rectTransform.anchorMax += anchorMax; + rectTransform.offsetMax = new Vector2(0, rectTransform.offsetMax.y); + break; + case "left": + anchorMin.y = 0; + rectTransform.anchorMin += anchorMin; + rectTransform.offsetMin = new Vector2(0, rectTransform.offsetMin.y); + break; + case "right": + anchorMax.y = 0; + rectTransform.anchorMax += anchorMax; + rectTransform.offsetMax = new Vector2(0, rectTransform.offsetMax.y); + break; + case "all": + rectTransform.anchorMax += anchorMax; + rectTransform.anchorMin += anchorMin; + rectTransform.offsetMin = Vector2.zero; + rectTransform.offsetMax = Vector2.zero; + break; + } + + handleChange(); + } + + + private void setRectValue(string field) + { + switch (field) + { + case "topLeft": + rectTransform.offsetMax = new Vector2(rectTransform.offsetMax.x, 0); + rectTransform.offsetMin = new Vector2(0, rectTransform.offsetMin.y); + break; + case "top": + rectTransform.offsetMax = new Vector2(rectTransform.offsetMax.x, 0); + break; + case "topRight": + rectTransform.offsetMax = Vector2.zero; + break; + case "bottomLeft": + rectTransform.offsetMin = Vector2.zero; + break; + case "bottom": + rectTransform.offsetMin = new Vector2(rectTransform.offsetMin.x, 0); + break; + case "bottomRight": + rectTransform.offsetMin = new Vector2(rectTransform.offsetMin.x, 0); + rectTransform.offsetMax = new Vector2(0, rectTransform.offsetMax.y); + break; + case "left": + rectTransform.offsetMin = new Vector2(0, rectTransform.offsetMin.y); + break; + case "right": + rectTransform.offsetMax = new Vector2(0, rectTransform.offsetMax.y); + break; + case "all": + rectTransform.offsetMin = new Vector2(0, 0); + rectTransform.offsetMax = new Vector2(0, 0); + break; + } + + handleChange(); + } + + private void handleChange() + { + EditorSceneManager.MarkSceneDirty(EditorSceneManager.GetActiveScene()); + } + } +} +#endif From 9e2158100a30b276a333993f54676ce5d00d200a Mon Sep 17 00:00:00 2001 From: mika Date: Wed, 12 Feb 2025 15:02:46 +0200 Subject: [PATCH 05/34] Create TransformEditor.cs --- .../Editor/CustomInspector/TransformEditor.cs | 436 ++++++++++++++++++ 1 file changed, 436 insertions(+) create mode 100644 Assets/Scripts/Editor/CustomInspector/TransformEditor.cs diff --git a/Assets/Scripts/Editor/CustomInspector/TransformEditor.cs b/Assets/Scripts/Editor/CustomInspector/TransformEditor.cs new file mode 100644 index 0000000..152989e --- /dev/null +++ b/Assets/Scripts/Editor/CustomInspector/TransformEditor.cs @@ -0,0 +1,436 @@ +// source https://gist.github.com/unitycoder/e5e6384f087639c0d9edc93aa3820468 + +using UnityEngine; +using UnityEditor; + +namespace OddTales.Framework.Core.EditorExtension +{ + /// + /// Custom inspector for Transform component. Using only DrawDefaultInspector would give different display. + /// Script based on Unity wiki implementation : https://wiki.unity3d.com/index.php/TransformInspector + /// Buttons to reset, copy, paste Transform values. + /// Context menu to round/truncate values, hide/show tools. + /// + [CanEditMultipleObjects, CustomEditor(typeof(Transform))] + public class TransformEditor : Editor + { + private const float FIELD_WIDTH = 212.0f; + private const bool WIDE_MODE = true; + + private const float POSITION_MAX = 100000.0f; + + private static GUIContent positionGUIContent = new GUIContent(LocalString("Position")); + private static GUIContent rotationGUIContent = new GUIContent(LocalString("Rotation")); + private static GUIContent scaleGUIContent = new GUIContent(LocalString("Scale")); + + private static string positionWarningText = LocalString("Due to floating-point precision limitations, it is recommended to bring the world coordinates of the GameObject within a smaller range."); + + private SerializedProperty positionProperty, rotationProperty, scaleProperty; + + private static Vector3? positionClipboard = null; + private static Quaternion? rotationClipboard = null; + private static Vector3? scaleClipboard = null; + + private const string SHOW_TOOLS_KEY = "TransformEditor_ShowTools"; + private const string SHOW_RESET_TOOLS_KEY = "TransformEditor_ShowResetTools"; + private const string SHOW_PASTE_TOOLS_KEY = "TransformEditor_ShowPasteTools"; + private const string SHOW_ADVANCED_PASTE_TOOLS_KEY = "TransformEditor_ShowAdvancedPasteTools"; + private const string SHOW_CLIPBOARD_INFORMATIONS_KEY = "TransformEditor_ShowClipboardInformations"; + private const string SHOW_SHORTCUTS_KEY = "TransformEditor_ShowHelpbox"; + + +#if UNITY_2017_3_OR_NEWER + private static System.Reflection.MethodInfo getLocalizedStringMethod; +#endif + + + /// Get translated Transform label + private static string LocalString(string text) + { +#if UNITY_2017_3_OR_NEWER + // Since Unity 2017.3, static class LocalizationDatabase is no longer public. Need to use reflection to access it. + if (getLocalizedStringMethod == null) + { + System.Reflection.Assembly assembly = typeof(UnityEditor.EditorWindow).Assembly; + System.Type localizationDatabaseType = assembly.GetType("UnityEditor.LocalizationDatabase"); + + getLocalizedStringMethod = localizationDatabaseType.GetMethod("GetLocalizedString"); + } + + return (string)getLocalizedStringMethod.Invoke(null, new object[] { text }); +#else + return LocalizationDatabase.GetLocalizedString(text); +#endif + } + + public void OnEnable() + { + positionProperty = serializedObject.FindProperty("m_LocalPosition"); + rotationProperty = serializedObject.FindProperty("m_LocalRotation"); + scaleProperty = serializedObject.FindProperty("m_LocalScale"); + + // Init options + if (!EditorPrefs.HasKey(SHOW_TOOLS_KEY)) EditorPrefs.SetBool(SHOW_TOOLS_KEY, true); + if (!EditorPrefs.HasKey(SHOW_RESET_TOOLS_KEY)) EditorPrefs.SetBool(SHOW_RESET_TOOLS_KEY, true); + if (!EditorPrefs.HasKey(SHOW_PASTE_TOOLS_KEY)) EditorPrefs.SetBool(SHOW_PASTE_TOOLS_KEY, true); + if (!EditorPrefs.HasKey(SHOW_ADVANCED_PASTE_TOOLS_KEY)) EditorPrefs.SetBool(SHOW_ADVANCED_PASTE_TOOLS_KEY, true); + if (!EditorPrefs.HasKey(SHOW_CLIPBOARD_INFORMATIONS_KEY)) EditorPrefs.SetBool(SHOW_CLIPBOARD_INFORMATIONS_KEY, true); + if (!EditorPrefs.HasKey(SHOW_SHORTCUTS_KEY)) EditorPrefs.SetBool(SHOW_SHORTCUTS_KEY, true); + } + + + public override void OnInspectorGUI() + { + Rect beginRect = GUILayoutUtility.GetRect(0, 0); + + EditorGUIUtility.wideMode = TransformEditor.WIDE_MODE; + EditorGUIUtility.labelWidth = EditorGUIUtility.currentViewWidth - TransformEditor.FIELD_WIDTH; // align field to right of inspector + + serializedObject.Update(); + + EditorGUIUtility.labelWidth = 60; // To allow float fields to expand when inspector width is increased + + // Position GUI + EditorGUILayout.BeginHorizontal(); + PositionPropertyField(positionProperty, positionGUIContent); // Note : Can't add generic menu if we use EditorGUILayout.PropertyField instead + if (EditorPrefs.GetBool(SHOW_TOOLS_KEY) && EditorPrefs.GetBool(SHOW_RESET_TOOLS_KEY)) + { + if (GUILayout.Button("Reset", GUILayout.Width(50))) + { + Undo.RecordObjects(targets, "Reset Positions"); + for (int i = 0; i < targets.Length; i++) + { + ((Transform)targets[i]).localPosition = Vector3.zero; + } + GUI.FocusControl(null); + } + } + EditorGUILayout.EndHorizontal(); + + // Rotation GUI + EditorGUILayout.BeginHorizontal(); + RotationPropertyField(rotationProperty, rotationGUIContent); // Note : Can't add generic menu if we use EditorGUILayout.PropertyField instead + if (EditorPrefs.GetBool(SHOW_TOOLS_KEY) && EditorPrefs.GetBool(SHOW_RESET_TOOLS_KEY)) + { + if (GUILayout.Button("Reset", GUILayout.Width(50))) + { + Undo.RecordObjects(targets, "Reset Rotations"); + for (int i = 0; i < targets.Length; i++) + { + TransformUtils.SetInspectorRotation(((Transform)targets[i]), Vector3.zero); + } + GUI.FocusControl(null); + } + + } + EditorGUILayout.EndHorizontal(); + + // Scale GUI + EditorGUILayout.BeginHorizontal(); + ScalePropertyField(scaleProperty, scaleGUIContent); // Note : Can't add generic menu if we use EditorGUILayout.PropertyField instead + if (EditorPrefs.GetBool(SHOW_TOOLS_KEY) && EditorPrefs.GetBool(SHOW_RESET_TOOLS_KEY)) + { + if (GUILayout.Button("Reset", GUILayout.Width(50))) + { + Undo.RecordObjects(targets, "Reset Scales"); + for (int i = 0; i < targets.Length; i++) + { + ((Transform)targets[i]).localScale = Vector3.one; + } + GUI.FocusControl(null); + } + } + EditorGUILayout.EndHorizontal(); + + + if (!ValidatePosition(((Transform)target).position)) EditorGUILayout.HelpBox(positionWarningText, MessageType.Warning); // Display floating-point warning message if values are too high + + if (EditorPrefs.GetBool(SHOW_TOOLS_KEY)) + { + // Paste Tools GUI + if (EditorPrefs.GetBool(SHOW_PASTE_TOOLS_KEY)) + { + GUILayout.BeginHorizontal(); + if (GUILayout.Button("Copy")) + { + positionClipboard = ((Transform)target).localPosition; + rotationClipboard = ((Transform)target).localRotation; + scaleClipboard = ((Transform)target).localScale; + } + + if (!positionClipboard.HasValue) EditorGUI.BeginDisabledGroup(true); + if (GUILayout.Button("Paste")) + { + Undo.RecordObjects(targets, "Paste Clipboard Values"); + for (int i = 0; i < targets.Length; i++) + { + ((Transform)targets[i]).localPosition = positionClipboard.Value; + ((Transform)targets[i]).localRotation = rotationClipboard.Value; + ((Transform)targets[i]).localScale = scaleClipboard.Value; + } + GUI.FocusControl(null); + } + if (!positionClipboard.HasValue) EditorGUI.EndDisabledGroup(); + GUILayout.EndHorizontal(); + } + + // Advanced Paste Tools GUI + if (EditorPrefs.GetBool(SHOW_ADVANCED_PASTE_TOOLS_KEY)) + { + GUILayout.BeginHorizontal(); + + if (!positionClipboard.HasValue) EditorGUI.BeginDisabledGroup(true); + if (GUILayout.Button("Paste position")) + { + Undo.RecordObjects(targets, "Paste Position Clipboard Value"); + for (int i = 0; i < targets.Length; i++) + { + ((Transform)targets[i]).localPosition = positionClipboard.Value; + } + GUI.FocusControl(null); + } + + if (GUILayout.Button("Paste rotation")) + { + Undo.RecordObjects(targets, "Paste Rotation Clipboard Value"); + for (int i = 0; i < targets.Length; i++) + { + ((Transform)targets[i]).rotation = rotationClipboard.Value; + } + GUI.FocusControl(null); + } + + if (GUILayout.Button("Paste scale")) + { + Undo.RecordObjects(targets, "Paste Scale Clipboard Value"); + for (int i = 0; i < targets.Length; i++) + { + ((Transform)targets[i]).localScale = scaleClipboard.Value; + } + GUI.FocusControl(null); + } + if (!positionClipboard.HasValue) EditorGUI.EndDisabledGroup(); + + GUILayout.EndHorizontal(); + } + + // Clipboard GUI + if (EditorPrefs.GetBool(SHOW_CLIPBOARD_INFORMATIONS_KEY)) + { + if (positionClipboard.HasValue && rotationClipboard.HasValue && scaleClipboard.HasValue) + { + + GUIStyle helpboxStyle = new GUIStyle(EditorStyles.helpBox); + helpboxStyle.richText = true; + + EditorGUILayout.TextArea("Clipboard values :\n" + + "Position : " + positionClipboard.Value.ToString("f2") + "\n" + + "Rotation : " + rotationClipboard.Value.ToString("f2") + "\n" + + "Scale : " + scaleClipboard.Value.ToString("f2"), helpboxStyle); + } + } + + + // Shortcuts GUI - Related to InspectorShortcuts.cs https://github.com/VoxelBoy/Useful-Unity-Scripts/blob/master/InspectorShortcuts.cs + if (EditorPrefs.GetBool(SHOW_SHORTCUTS_KEY)) + { + EditorGUILayout.HelpBox("Inspector shortcuts :\n" + + "Toggle inspector lock : Ctrl + Shift + L\n" + + "Toggle inspector mode : Ctrl + Shift + D", MessageType.None); + } + } + Rect endRect = GUILayoutUtility.GetLastRect(); + endRect.y += endRect.height; + + + #region Context Menu + Rect componentRect = new Rect(beginRect.x, beginRect.y, beginRect.width, endRect.y - beginRect.y); + //EditorGUI.DrawRect(componentRect, Color.green); // Debug : display GenericMenu zone + + Event currentEvent = Event.current; + + if (currentEvent.type == EventType.ContextClick) + { + if (componentRect.Contains(currentEvent.mousePosition)) + { + GUI.FocusControl(null); + + GenericMenu menu = new GenericMenu(); + + menu.AddItem(new GUIContent("Display/Tools"), EditorPrefs.GetBool(SHOW_TOOLS_KEY), ToggleOption, SHOW_TOOLS_KEY); + menu.AddSeparator("Display/"); + menu.AddItem(new GUIContent("Display/Reset Tools"), EditorPrefs.GetBool(SHOW_RESET_TOOLS_KEY), ToggleOption, SHOW_RESET_TOOLS_KEY); + menu.AddItem(new GUIContent("Display/Paste Tools"), EditorPrefs.GetBool(SHOW_PASTE_TOOLS_KEY), ToggleOption, SHOW_PASTE_TOOLS_KEY); + menu.AddItem(new GUIContent("Display/Advanced Paste Tools"), EditorPrefs.GetBool(SHOW_ADVANCED_PASTE_TOOLS_KEY), ToggleOption, SHOW_ADVANCED_PASTE_TOOLS_KEY); + menu.AddItem(new GUIContent("Display/Clipboard informations"), EditorPrefs.GetBool(SHOW_CLIPBOARD_INFORMATIONS_KEY), ToggleOption, SHOW_CLIPBOARD_INFORMATIONS_KEY); + menu.AddItem(new GUIContent("Display/Shortcuts informations"), EditorPrefs.GetBool(SHOW_SHORTCUTS_KEY), ToggleOption, SHOW_SHORTCUTS_KEY); + + // Round menu + menu.AddItem(new GUIContent("Round/Three Decimals"), false, Round, 3); + menu.AddItem(new GUIContent("Round/Two Decimals"), false, Round, 2); + menu.AddItem(new GUIContent("Round/One Decimal"), false, Round, 1); + menu.AddItem(new GUIContent("Round/Integer"), false, Round, 0); + + // Truncate menu + menu.AddItem(new GUIContent("Truncate/Three Decimals"), false, Truncate, 3); + menu.AddItem(new GUIContent("Truncate/Two Decimals"), false, Truncate, 2); + menu.AddItem(new GUIContent("Truncate/One Decimal"), false, Truncate, 1); + menu.AddItem(new GUIContent("Truncate/Integer"), false, Truncate, 0); + + menu.ShowAsContext(); + currentEvent.Use(); + } + } + #endregion + + serializedObject.ApplyModifiedProperties(); + } + + + private bool ValidatePosition(Vector3 position) + { + if (Mathf.Abs(position.x) > POSITION_MAX) return false; + if (Mathf.Abs(position.y) > POSITION_MAX) return false; + if (Mathf.Abs(position.z) > POSITION_MAX) return false; + return true; + } + + private void PositionPropertyField(SerializedProperty positionProperty, GUIContent content) + { + Transform transform = (Transform)targets[0]; + Vector3 localPosition = transform.localPosition; + for (int i = 0; i < targets.Length; i++) + { + if (!localPosition.Equals(((Transform)targets[i]).localPosition)) + { + EditorGUI.showMixedValue = true; + break; + } + } + + EditorGUI.BeginChangeCheck(); + Vector3 newLocalPosition = EditorGUILayout.Vector3Field(content, localPosition); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObjects(targets, "Position Changed"); + for (int i = 0; i < targets.Length; i++) + { + ((Transform)targets[i]).localPosition = newLocalPosition; + } + positionProperty.serializedObject.SetIsDifferentCacheDirty(); + } + EditorGUI.showMixedValue = false; + } + + private void RotationPropertyField(SerializedProperty rotationProperty, GUIContent content) + { + Transform transform = (Transform)targets[0]; + Vector3 localRotation = TransformUtils.GetInspectorRotation(transform); + + + for (int i = 0; i < targets.Length; i++) + { + if (!localRotation.Equals(TransformUtils.GetInspectorRotation((Transform)targets[i]))) + { + EditorGUI.showMixedValue = true; + break; + } + } + + EditorGUI.BeginChangeCheck(); + Vector3 eulerAngles = EditorGUILayout.Vector3Field(content, localRotation); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObjects(targets, "Rotation Changed"); + for (int i = 0; i < targets.Length; i++) + { + //((Transform)targets[i]).localEulerAngles = eulerAngles; + TransformUtils.SetInspectorRotation(((Transform)targets[i]), eulerAngles); + } + rotationProperty.serializedObject.SetIsDifferentCacheDirty(); + } + EditorGUI.showMixedValue = false; + } + + private void ScalePropertyField(SerializedProperty scaleProperty, GUIContent content) + { + Transform transform = (Transform)targets[0]; + Vector3 localScale = transform.localScale; + for (int i = 0; i < targets.Length; i++) + { + if (!localScale.Equals(((Transform)targets[i]).localScale)) + { + EditorGUI.showMixedValue = true; + break; + } + } + + EditorGUI.BeginChangeCheck(); + Vector3 newLocalScale = EditorGUILayout.Vector3Field(content, localScale); + if (EditorGUI.EndChangeCheck()) + { + Undo.RecordObjects(targets, "Scale Changed"); + for (int i = 0; i < targets.Length; i++) + { + ((Transform)targets[i]).localScale = newLocalScale; + } + scaleProperty.serializedObject.SetIsDifferentCacheDirty(); + } + EditorGUI.showMixedValue = false; + } + + + #region Generic Menu Callbacks + private void ToggleOption(object obj) + { + EditorPrefs.SetBool(obj.ToString(), !EditorPrefs.GetBool(obj.ToString())); + } + + /// Round all values of the Transform to a given number of decimals + private void Round(object objNumberOfDecimals) + { + int numberOfDecimals = (int)objNumberOfDecimals; + + Undo.RecordObjects(targets, "Round to " + numberOfDecimals + " decimals"); + for (int i = 0; i < targets.Length; i++) + { + ((Transform)targets[i]).localPosition = RoundVector(((Transform)targets[i]).localPosition, numberOfDecimals); + ((Transform)targets[i]).localEulerAngles = RoundVector(((Transform)targets[i]).localEulerAngles, numberOfDecimals); + ((Transform)targets[i]).localScale = RoundVector(((Transform)targets[i]).localScale, numberOfDecimals); + } + } + + /// Round all components of a Vector3 + private Vector3 RoundVector(Vector3 vector, int numberOfDecimals) + { + vector.x = Mathf.Round(vector.x * Mathf.Pow(10.0f, (float)numberOfDecimals)) / Mathf.Pow(10.0f, (float)numberOfDecimals); + vector.y = Mathf.Round(vector.y * Mathf.Pow(10.0f, (float)numberOfDecimals)) / Mathf.Pow(10.0f, (float)numberOfDecimals); + vector.z = Mathf.Round(vector.z * Mathf.Pow(10.0f, (float)numberOfDecimals)) / Mathf.Pow(10.0f, (float)numberOfDecimals); + return vector; + } + + /// Truncate all values of the Transform to a given number of decimals + private void Truncate(object objNumberOfDecimals) + { + int numberOfDecimals = (int)objNumberOfDecimals; + + Undo.RecordObjects(targets, "Truncate to " + numberOfDecimals + " decimals"); + for (int i = 0; i < targets.Length; i++) + { + ((Transform)targets[i]).localPosition = TruncateVector(((Transform)targets[i]).localPosition, numberOfDecimals); + ((Transform)targets[i]).localEulerAngles = TruncateVector(((Transform)targets[i]).localEulerAngles, numberOfDecimals); + ((Transform)targets[i]).localScale = TruncateVector(((Transform)targets[i]).localScale, numberOfDecimals); + } + } + + /// Truncate all components of a Vector3 + private Vector3 TruncateVector(Vector3 vector, int numberOfDecimals) + { + vector.x = Mathf.Floor(vector.x * Mathf.Pow(10.0f, (float)numberOfDecimals)) / Mathf.Pow(10.0f, (float)numberOfDecimals); + vector.y = Mathf.Floor(vector.y * Mathf.Pow(10.0f, (float)numberOfDecimals)) / Mathf.Pow(10.0f, (float)numberOfDecimals); + vector.z = Mathf.Floor(vector.z * Mathf.Pow(10.0f, (float)numberOfDecimals)) / Mathf.Pow(10.0f, (float)numberOfDecimals); + return vector; + } + #endregion + } +} From 84cf73c2e725da11b92be389d5401d4f70dbb82a Mon Sep 17 00:00:00 2001 From: mika Date: Wed, 12 Feb 2025 15:10:51 +0200 Subject: [PATCH 06/34] Create CustomRectTransformCopyInspector.cs --- .../CustomRectTransformCopyInspector.cs | 114 ++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 Assets/Scripts/Editor/CustomInspector/CustomRectTransformCopyInspector.cs diff --git a/Assets/Scripts/Editor/CustomInspector/CustomRectTransformCopyInspector.cs b/Assets/Scripts/Editor/CustomInspector/CustomRectTransformCopyInspector.cs new file mode 100644 index 0000000..0a5de2b --- /dev/null +++ b/Assets/Scripts/Editor/CustomInspector/CustomRectTransformCopyInspector.cs @@ -0,0 +1,114 @@ +#if UNITY_EDITOR +using System; +using System.Reflection; +using UnityEngine; + +namespace UnityEditor +{ + [CustomEditor(typeof(RectTransform), true)] + [CanEditMultipleObjects] + public class CustomRectTransformCopyInspector : Editor + { + // Unity's built-in editor + Editor defaultEditor = null; + RectTransform rectTransform; + + private static RectTransformData copiedData; + + void OnEnable() + { + // Use reflection to get the default Unity RectTransform editor + defaultEditor = Editor.CreateEditor(targets, Type.GetType("UnityEditor.RectTransformEditor, UnityEditor")); + rectTransform = target as RectTransform; + } + + void OnDisable() + { + // Destroy the default editor to avoid memory leaks + if (defaultEditor != null) + { + MethodInfo disableMethod = defaultEditor.GetType().GetMethod("OnDisable", + BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + if (disableMethod != null) + disableMethod.Invoke(defaultEditor, null); + + DestroyImmediate(defaultEditor); + } + } + + public override void OnInspectorGUI() + { + // Draw Unity's default RectTransform Inspector + defaultEditor.OnInspectorGUI(); + + // Add Copy and Paste buttons + EditorGUILayout.Space(); + GUILayout.BeginHorizontal(); + + if (GUILayout.Button("C", GUILayout.Width(30))) // Copy + { + CopyRectTransform(rectTransform); + } + + if (GUILayout.Button("P", GUILayout.Width(30))) // Paste + { + PasteRectTransform(rectTransform); + } + + GUILayout.EndHorizontal(); + } + + private void CopyRectTransform(RectTransform rectTransform) + { + copiedData = new RectTransformData(rectTransform); + Debug.Log("RectTransform copied!"); + } + + private void PasteRectTransform(RectTransform rectTransform) + { + if (copiedData == null) + { + Debug.LogWarning("No RectTransform data to paste!"); + return; + } + + Undo.RecordObject(rectTransform, "Paste RectTransform"); + + copiedData.ApplyTo(rectTransform); + Debug.Log("RectTransform pasted!"); + + EditorUtility.SetDirty(rectTransform); + } + + private class RectTransformData + { + public Vector2 anchorMin; + public Vector2 anchorMax; + public Vector2 anchoredPosition; + public Vector2 sizeDelta; + public Vector2 pivot; + public Quaternion rotation; + + public RectTransformData(RectTransform rectTransform) + { + anchorMin = rectTransform.anchorMin; + anchorMax = rectTransform.anchorMax; + anchoredPosition = rectTransform.anchoredPosition; + sizeDelta = rectTransform.sizeDelta; + pivot = rectTransform.pivot; + rotation = rectTransform.rotation; + } + + public void ApplyTo(RectTransform rectTransform) + { + rectTransform.anchorMin = anchorMin; + rectTransform.anchorMax = anchorMax; + rectTransform.anchoredPosition = anchoredPosition; + rectTransform.sizeDelta = sizeDelta; + rectTransform.pivot = pivot; + rectTransform.rotation = rotation; + } + } + } +} +#endif From 2d15f04fed4a6d7b3f2903dcae6b0daba706582a Mon Sep 17 00:00:00 2001 From: mika Date: Mon, 3 Mar 2025 16:05:17 +0200 Subject: [PATCH 07/34] Update BoxColliderFitChildren.cs fix fitting if some children are wonky --- .../ContextMenu/BoxColliderFitChildren.cs | 75 +++++++++++-------- 1 file changed, 43 insertions(+), 32 deletions(-) diff --git a/Assets/Scripts/Editor/ContextMenu/BoxColliderFitChildren.cs b/Assets/Scripts/Editor/ContextMenu/BoxColliderFitChildren.cs index 2690929..ce3f298 100644 --- a/Assets/Scripts/Editor/ContextMenu/BoxColliderFitChildren.cs +++ b/Assets/Scripts/Editor/ContextMenu/BoxColliderFitChildren.cs @@ -1,6 +1,3 @@ -// Adjust Box Collider to fit child meshes inside -// Usage: You have empty parent transform, with child meshes inside, add box collider to parent then use this - using UnityEngine; using UnityEditor; @@ -13,48 +10,62 @@ static void FitColliderToChildren(MenuCommand command) { BoxCollider col = (BoxCollider)command.context; - // record undo + // Record undo Undo.RecordObject(col.transform, "Fit Box Collider To Children"); - // first reset transform rotation - var origRot = col.transform.rotation; - col.transform.rotation = Quaternion.identity; + // Get world-space bounds of all child meshes + var worldBounds = GetRecursiveMeshBounds(col.gameObject); - // get child mesh bounds - var b = GetRecursiveMeshBounds(col.gameObject); + if (worldBounds.size == Vector3.zero) + { + Debug.LogWarning("No valid meshes found to fit the BoxCollider."); + return; + } - // set collider local center and size - col.center = col.transform.root.InverseTransformVector(b.center) - col.transform.position; + // Convert world-space center to local space + Vector3 localCenter = col.transform.InverseTransformPoint(worldBounds.center); - // keep size positive - var size = b.size; - size.x = Mathf.Abs(size.x); - size.y = Mathf.Abs(size.y); - size.z = Mathf.Abs(size.z); + // Convert world-space size to local space + Vector3 localSize = col.transform.InverseTransformVector(worldBounds.size); - col.size = b.size; + // Ensure size is positive + localSize = new Vector3(Mathf.Abs(localSize.x), Mathf.Abs(localSize.y), Mathf.Abs(localSize.z)); + + // Fix potential center flipping + if (Vector3.Dot(col.transform.right, Vector3.right) < 0) + { + localCenter.x = -localCenter.x; + } + if (Vector3.Dot(col.transform.up, Vector3.up) < 0) + { + localCenter.y = -localCenter.y; + } + if (Vector3.Dot(col.transform.forward, Vector3.forward) < 0) + { + localCenter.z = -localCenter.z; + } - // restore rotation - col.transform.rotation = origRot; + // Apply to collider + col.center = localCenter; + col.size = localSize; } public static Bounds GetRecursiveMeshBounds(GameObject go) { - var r = go.GetComponentsInChildren(); - if (r.Length > 0) - { - var b = r[0].bounds; - for (int i = 1; i < r.Length; i++) - { - b.Encapsulate(r[i].bounds); - } - return b; - } - else // TODO no renderers? - { - //return new Bounds(Vector3.one, Vector3.one); + Renderer[] renderers = go.GetComponentsInChildren(); + + if (renderers.Length == 0) return new Bounds(); + + // Start with the first renderer’s bounds in world space + Bounds worldBounds = renderers[0].bounds; + + for (int i = 1; i < renderers.Length; i++) + { + worldBounds.Encapsulate(renderers[i].bounds); } + + return worldBounds; } } } From 5ed776a118bd48c141f01095fddf333303f1f7d2 Mon Sep 17 00:00:00 2001 From: mika Date: Wed, 5 Mar 2025 10:19:22 +0200 Subject: [PATCH 08/34] Create Standard Stipple Transparency.shader --- .../Standard Stipple Transparency.shader | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 Assets/Shaders/3D/Effects/Standard Stipple Transparency.shader diff --git a/Assets/Shaders/3D/Effects/Standard Stipple Transparency.shader b/Assets/Shaders/3D/Effects/Standard Stipple Transparency.shader new file mode 100644 index 0000000..2e312da --- /dev/null +++ b/Assets/Shaders/3D/Effects/Standard Stipple Transparency.shader @@ -0,0 +1,58 @@ +// Standard shader with stipple transparency +// by Alex Ocias - https://ocias.com +// source: https://ocias.com/blog/unity-stipple-transparency-shader/ +// based on an article by Digital Rune: https://www.digitalrune.com/Blog/Post/1743/Screen-Door-Transparency + +Shader "Ocias/Standard (Stipple Transparency)" { + Properties { + _Color ("Color", Color) = (1,1,1,1) + _MainTex ("Albedo (RGB)", 2D) = "white" {} + _Glossiness ("Smoothness", Range(0,1)) = 0.5 + _Metallic ("Metallic", Range(0,1)) = 0.0 + } + SubShader { + Tags { "RenderType"="Opaque" } + LOD 100 + + CGPROGRAM + // Physically based Standard lighting model, and enable shadows on all light types + #pragma surface surf Standard fullforwardshadows + + // Use shader model 3.0 target, to get nicer looking lighting + #pragma target 3.0 + + sampler2D _MainTex; + + struct Input { + float2 uv_MainTex; + float4 screenPos; + }; + + half _Glossiness; + half _Metallic; + fixed4 _Color; + + void surf (Input IN, inout SurfaceOutputStandard o) { + // Albedo comes from a texture tinted by color + fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; + o.Albedo = c.rgb; + // Metallic and smoothness come from slider variables + o.Metallic = _Metallic; + o.Smoothness = _Glossiness; + + // Screen-door transparency: Discard pixel if below threshold. + float4x4 thresholdMatrix = + { 1.0 / 17.0, 9.0 / 17.0, 3.0 / 17.0, 11.0 / 17.0, + 13.0 / 17.0, 5.0 / 17.0, 15.0 / 17.0, 7.0 / 17.0, + 4.0 / 17.0, 12.0 / 17.0, 2.0 / 17.0, 10.0 / 17.0, + 16.0 / 17.0, 8.0 / 17.0, 14.0 / 17.0, 6.0 / 17.0 + }; + float4x4 _RowAccess = { 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 }; + float2 pos = IN.screenPos.xy / IN.screenPos.w; + pos *= _ScreenParams.xy; // pixel position + clip(c.a - thresholdMatrix[fmod(pos.x, 4)] * _RowAccess[fmod(pos.y, 4)]); + } + ENDCG + } + FallBack "Diffuse" +} From 4005a5acf5ad463d1c8ca6f014e15cc4b9cd87d9 Mon Sep 17 00:00:00 2001 From: mika Date: Wed, 5 Mar 2025 10:19:59 +0200 Subject: [PATCH 09/34] Create Diffuse Stipple Transparency.shader --- .../Diffuse Stipple Transparency.shader | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 Assets/Shaders/3D/Effects/Diffuse Stipple Transparency.shader diff --git a/Assets/Shaders/3D/Effects/Diffuse Stipple Transparency.shader b/Assets/Shaders/3D/Effects/Diffuse Stipple Transparency.shader new file mode 100644 index 0000000..853c4cf --- /dev/null +++ b/Assets/Shaders/3D/Effects/Diffuse Stipple Transparency.shader @@ -0,0 +1,52 @@ +// Diffuse shader with stipple transparency +// by Alex Ocias - https://ocias.com +// source: https://ocias.com/blog/unity-stipple-transparency-shader/ +// based on an article by Digital Rune: https://www.digitalrune.com/Blog/Post/1743/Screen-Door-Transparency + +// Simplified Diffuse shader. Differences from regular Diffuse one: +// - no Main Color +// - fully supports only 1 directional light. Other lights can affect it, but it will be per-vertex/SH. + +Shader "Ocias/Diffuse (Stipple Transparency)" { +Properties { + _MainTex ("Base (RGB)", 2D) = "white" {} + _Transparency ("Transparency", Range(0,1)) = 1.0 +} +SubShader { + Tags { "RenderType"="Opaque" } + LOD 150 + +CGPROGRAM +#pragma surface surf Lambert noforwardadd + +sampler2D _MainTex; + +struct Input { + float2 uv_MainTex; + float4 screenPos; +}; + +half _Transparency; + +void surf (Input IN, inout SurfaceOutput o) { + fixed4 c = tex2D(_MainTex, IN.uv_MainTex); + o.Albedo = c.rgb; + o.Alpha = c.a; + + // Screen-door transparency: Discard pixel if below threshold. + float4x4 thresholdMatrix = + { 1.0 / 17.0, 9.0 / 17.0, 3.0 / 17.0, 11.0 / 17.0, + 13.0 / 17.0, 5.0 / 17.0, 15.0 / 17.0, 7.0 / 17.0, + 4.0 / 17.0, 12.0 / 17.0, 2.0 / 17.0, 10.0 / 17.0, + 16.0 / 17.0, 8.0 / 17.0, 14.0 / 17.0, 6.0 / 17.0 + }; + float4x4 _RowAccess = { 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 }; + float2 pos = IN.screenPos.xy / IN.screenPos.w; + pos *= _ScreenParams.xy; // pixel position + clip(_Transparency - thresholdMatrix[fmod(pos.x, 4)] * _RowAccess[fmod(pos.y, 4)]); +} +ENDCG +} + +Fallback "Mobile/VertexLit" +} From 26927e8a1e5175fb35c7983ec469e3c8d3a42f8f Mon Sep 17 00:00:00 2001 From: mika Date: Wed, 9 Apr 2025 09:29:58 +0300 Subject: [PATCH 10/34] Create ReferenceImageViewer2.cs --- .../Editor/Tools/ReferenceImageViewer2.cs | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/ReferenceImageViewer2.cs diff --git a/Assets/Scripts/Editor/Tools/ReferenceImageViewer2.cs b/Assets/Scripts/Editor/Tools/ReferenceImageViewer2.cs new file mode 100644 index 0000000..e8371c5 --- /dev/null +++ b/Assets/Scripts/Editor/Tools/ReferenceImageViewer2.cs @@ -0,0 +1,110 @@ +// reference image viewer from external folder + +using UnityEngine; +using UnityEditor; +using System.IO; + +namespace UnityLibrary.Tools +{ + public class ReferenceImageViewer2 : EditorWindow + { + private const string EditorPrefsKey = "ReferenceImageViewer_ImagePath"; + + [SerializeField] + private string imagePath = ""; + [SerializeField] + private Texture2D loadedImage; + + [MenuItem("Window/Reference Image Viewer")] + public static void ShowWindow() + { + GetWindow("Reference Image Viewer"); + } + + private void OnEnable() + { + // Delay the reload to ensure Unity's layout system is ready + EditorApplication.delayCall += TryLoadImage; + } + + private void OnDisable() + { + if (!string.IsNullOrEmpty(imagePath)) + EditorPrefs.SetString(EditorPrefsKey, imagePath); + else + EditorPrefs.DeleteKey(EditorPrefsKey); + } + + private void TryLoadImage() + { + imagePath = EditorPrefs.GetString(EditorPrefsKey, ""); + if (!string.IsNullOrEmpty(imagePath) && File.Exists(imagePath)) + { + LoadImageFromPath(imagePath); + Repaint(); // Ensure it's drawn + } + } + + void OnGUI() + { + GUILayout.Label("Reference Image Viewer", EditorStyles.boldLabel); + + if (GUILayout.Button("Browse for Image")) + { + string path = EditorUtility.OpenFilePanel("Select Image", "", "png,jpg,jpeg"); + if (!string.IsNullOrEmpty(path)) + { + imagePath = path; + EditorPrefs.SetString(EditorPrefsKey, imagePath); + LoadImageFromPath(imagePath); + } + } + + // Fallback in case delayCall missed + if (loadedImage == null && !string.IsNullOrEmpty(imagePath) && File.Exists(imagePath)) + { + LoadImageFromPath(imagePath); + Repaint(); + } + + if (loadedImage != null) + { + GUILayout.Space(10); + + float windowWidth = position.width - 20; + float imageAspect = (float)loadedImage.width / loadedImage.height; + + float displayWidth = windowWidth; + float displayHeight = windowWidth / imageAspect; + + if (displayHeight > position.height - 100) + { + displayHeight = position.height - 100; + displayWidth = displayHeight * imageAspect; + } + + Rect imageRect = GUILayoutUtility.GetRect(displayWidth, displayHeight, GUILayout.ExpandWidth(false), GUILayout.ExpandHeight(false)); + EditorGUI.DrawPreviewTexture(imageRect, loadedImage, null, ScaleMode.ScaleToFit); + } + } + + private void LoadImageFromPath(string path) + { + try + { + byte[] fileData = File.ReadAllBytes(path); + loadedImage = new Texture2D(2, 2, TextureFormat.RGBA32, false); + if (!loadedImage.LoadImage(fileData)) + { + Debug.LogError("Failed to load image."); + loadedImage = null; + } + } + catch (System.Exception e) + { + Debug.LogError($"Could not load image from path: {path}\n{e.Message}"); + loadedImage = null; + } + } + } +} From 13b1f1630bf03876994fd545c6a7a43d069e4f26 Mon Sep 17 00:00:00 2001 From: mika Date: Mon, 14 Apr 2025 16:07:26 +0300 Subject: [PATCH 11/34] Create PreciseOffsetEditor.cs --- .../Editor/Tools/PreciseOffsetEditor.cs | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/PreciseOffsetEditor.cs diff --git a/Assets/Scripts/Editor/Tools/PreciseOffsetEditor.cs b/Assets/Scripts/Editor/Tools/PreciseOffsetEditor.cs new file mode 100644 index 0000000..f3c5a44 --- /dev/null +++ b/Assets/Scripts/Editor/Tools/PreciseOffsetEditor.cs @@ -0,0 +1,118 @@ +// gameobject position fiddling tool (when dragging x,y,z axist in transform panel is too fast) + +using UnityEngine; +using UnityEditor; + +namespace UnityLibrary.SceneTools +{ + public class PreciseOffsetEditor : EditorWindow + { + private const string SpeedMultiplierKey = "UnityLibrary_PreciseOffset_SpeedMultiplier"; + + private GameObject selectedObject; + private Vector3 offsetSliderValues = Vector3.zero; + private float speedMultiplier = 0.01f; + + private Vector3 originalPosition; + private GameObject lastSelectedObject; + + [MenuItem("Tools/UnityLibrary/Precise Model Offset")] + public static void ShowWindow() + { + var win = GetWindow("Precise Model Offset"); + win.minSize = new Vector2(300, 220); + win.maxSize = new Vector2(300, 220); + } + + private void OnEnable() + { + speedMultiplier = EditorPrefs.GetFloat(SpeedMultiplierKey, 0.01f); + } + + private void OnDisable() + { + EditorPrefs.SetFloat(SpeedMultiplierKey, speedMultiplier); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("Precise Model Offset", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + int selectedCount = Selection.gameObjects.Length; + selectedObject = Selection.activeGameObject; + + if (selectedCount == 0) + { + EditorGUILayout.HelpBox("No GameObject selected.", MessageType.Warning); + return; + } + + if (selectedCount == 1) + { + EditorGUILayout.LabelField("Selected: " + selectedObject.name); + } + else + { + EditorGUILayout.LabelField("Selected: (multiple)"); + } + + if (selectedObject != lastSelectedObject) + { + originalPosition = selectedObject.transform.position; + offsetSliderValues = Vector3.zero; + lastSelectedObject = selectedObject; + } + + EditorGUILayout.Space(); + EditorGUI.BeginChangeCheck(); + speedMultiplier = EditorGUILayout.Slider("Speed Multiplier", speedMultiplier, 0.001f, 1f); + if (EditorGUI.EndChangeCheck()) + { + EditorPrefs.SetFloat(SpeedMultiplierKey, speedMultiplier); + } + + EditorGUILayout.Space(); + EditorGUI.BeginChangeCheck(); + + // Store old values to detect changes + Vector3 oldOffset = offsetSliderValues; + + offsetSliderValues.x = EditorGUILayout.Slider("X Offset", offsetSliderValues.x, -100f, 100f); + offsetSliderValues.y = EditorGUILayout.Slider("Y Offset", offsetSliderValues.y, -100f, 100f); + offsetSliderValues.z = EditorGUILayout.Slider("Z Offset", offsetSliderValues.z, -100f, 100f); + + Vector3 delta = offsetSliderValues - oldOffset; + + if (delta != Vector3.zero) + { + Undo.RecordObject(selectedObject.transform, "Precise Offset"); + + Vector3 newPosition = selectedObject.transform.position; + + if (!Mathf.Approximately(delta.x, 0f)) + newPosition.x = originalPosition.x + offsetSliderValues.x * speedMultiplier; + + if (!Mathf.Approximately(delta.y, 0f)) + newPosition.y = originalPosition.y + offsetSliderValues.y * speedMultiplier; + + if (!Mathf.Approximately(delta.z, 0f)) + newPosition.z = originalPosition.z + offsetSliderValues.z * speedMultiplier; + + selectedObject.transform.position = newPosition; + + EditorUtility.SetDirty(selectedObject); + } + + EditorGUILayout.Space(); + if (GUILayout.Button("Reset Offset")) + { + offsetSliderValues = Vector3.zero; + if (selectedObject != null) + { + selectedObject.transform.position = originalPosition; + } + } + } + } +} From 48de7352cfc6db616b2a0e66a267c763dede2f7d Mon Sep 17 00:00:00 2001 From: mika Date: Thu, 17 Apr 2025 14:31:36 +0300 Subject: [PATCH 12/34] Create PasteScript.cs --- Assets/Scripts/Editor/Tools/PasteScript.cs | 85 ++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/PasteScript.cs diff --git a/Assets/Scripts/Editor/Tools/PasteScript.cs b/Assets/Scripts/Editor/Tools/PasteScript.cs new file mode 100644 index 0000000..84a1561 --- /dev/null +++ b/Assets/Scripts/Editor/Tools/PasteScript.cs @@ -0,0 +1,85 @@ +// new version of the https://github.com/UnityCommunity/UnityLibrary/blob/master/Assets/Scripts/Editor/Tools/CopyPasteHelper.cs +// creates c# script from clipboard when click button + +using UnityEngine; +using UnityEditor; +using System.IO; +using System.Text.RegularExpressions; + +namespace UnityLibrary.Tools +{ + public class PasteScript : EditorWindow + { + private string statusMessage = ""; + private string lastCreatedScriptPath = ""; + + [MenuItem("Tools/UnityLibrary/Clipboard To C# Script")] + public static void ShowWindow() + { + GetWindow("Clipboard to Script"); + } + + void OnGUI() + { + if (GUILayout.Button("Create Script from Clipboard")) + { + TryCreateScriptFromClipboard(); + } + + GUILayout.Space(10); + + // Draw clickable HelpBox + EditorGUILayout.HelpBox(statusMessage, MessageType.Info); + + Rect helpBoxRect = GUILayoutUtility.GetLastRect(); + if (!string.IsNullOrEmpty(lastCreatedScriptPath) && Event.current.type == EventType.MouseDown && helpBoxRect.Contains(Event.current.mousePosition)) + { + var asset = AssetDatabase.LoadAssetAtPath(lastCreatedScriptPath); + if (asset != null) + { + EditorGUIUtility.PingObject(asset); + } + Event.current.Use(); // Consume the click + } + } + + void TryCreateScriptFromClipboard() + { + string clipboard = EditorGUIUtility.systemCopyBuffer; + + if (IsProbablyCSharp(clipboard)) + { + string folderPath = "Assets/Scripts/Generated"; + Directory.CreateDirectory(folderPath); + + string className = GetClassName(clipboard) ?? "GeneratedScript"; + string path = AssetDatabase.GenerateUniqueAssetPath($"{folderPath}/{className}.cs"); + + File.WriteAllText(path, clipboard); + AssetDatabase.Refresh(); + + statusMessage = $"Script created: {path}"; + lastCreatedScriptPath = path; + } + else + { + statusMessage = "Clipboard does not contain valid C# code."; + lastCreatedScriptPath = ""; + } + } + + bool IsProbablyCSharp(string text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + + // Basic heuristic checks + return Regex.IsMatch(text, @"\b(class|struct|interface|using|namespace)\b"); + } + + string GetClassName(string text) + { + Match match = Regex.Match(text, @"\bclass\s+(\w+)"); + return match.Success ? match.Groups[1].Value : null; + } + } +} From 8b590f2c74ee4af3424e08c1cc8dc466021a9711 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 25 Apr 2025 11:43:12 +0300 Subject: [PATCH 13/34] Create GameViewGridOverlay.cs --- .../Editor/Tools/GameViewGridOverlay.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs diff --git a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs new file mode 100644 index 0000000..a7f87ae --- /dev/null +++ b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs @@ -0,0 +1,45 @@ +// draws grid lines in the game view (useful for seeing the resolution of ui elements in the game view) +// usage: attach to a game object in the scene, set gameobject tag to "EditorOnly" to remove from builds + +using UnityEngine; + +namespace UnityLibrary.EditorTools +{ + [ExecuteAlways] + public class GameViewGridOverlay : MonoBehaviour + { +#if UNITY_EDITOR + public bool drawGrid = true; + + public int gridSpacingX = 64; + public int gridSpacingY = 64; + + public int startOffsetX = 0; + public int startOffsetY = 0; + + public Color gridColor = new Color(1f, 1f, 1f, 0.5f); + + private void OnGUI() + { + if (!drawGrid || Application.isPlaying) return; + + Color oldColor = GUI.color; + GUI.color = gridColor; + + // Horizontal lines + for (int y = startOffsetX; y < Screen.height; y += gridSpacingY) + { + GUI.DrawTexture(new Rect(0, y, Screen.width, 1), Texture2D.whiteTexture); + } + + // Vertical lines + for (int x = startOffsetY; x < Screen.width; x += gridSpacingX) + { + GUI.DrawTexture(new Rect(x, 0, 1, Screen.height), Texture2D.whiteTexture); + } + + GUI.color = oldColor; + } +#endif + } +} From 437db6375f25c0d47d08b8cf543dc3a7e5c754f0 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 25 Apr 2025 11:48:26 +0300 Subject: [PATCH 14/34] Create README.md --- Assets/Scripts/Editor/Tools/README.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/README.md diff --git a/Assets/Scripts/Editor/Tools/README.md b/Assets/Scripts/Editor/Tools/README.md new file mode 100644 index 0000000..20d6e2a --- /dev/null +++ b/Assets/Scripts/Editor/Tools/README.md @@ -0,0 +1,2 @@ +### GameViewGridOverlay.cs +![Image](https://github.com/user-attachments/assets/48fbced4-48e0-49fe-9acc-666f5449a958) From acb71e7a1e95430c371cdf3e054986f200a858c5 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 25 Apr 2025 12:31:50 +0300 Subject: [PATCH 15/34] Update GameViewGridOverlay.cs --- Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs index a7f87ae..143bedc 100644 --- a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs +++ b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs @@ -26,14 +26,14 @@ private void OnGUI() Color oldColor = GUI.color; GUI.color = gridColor; - // Horizontal lines - for (int y = startOffsetX; y < Screen.height; y += gridSpacingY) + // vertical lines + for (int y = startOffsetY; y < Screen.height; y += gridSpacingY) { GUI.DrawTexture(new Rect(0, y, Screen.width, 1), Texture2D.whiteTexture); } - // Vertical lines - for (int x = startOffsetY; x < Screen.width; x += gridSpacingX) + // horizontal lines + for (int x = startOffsetX; x < Screen.width; x += gridSpacingX) { GUI.DrawTexture(new Rect(x, 0, 1, Screen.height), Texture2D.whiteTexture); } From 69fe35d66df49842a2152e6bb6f7c472cdc2f939 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 25 Apr 2025 12:51:50 +0300 Subject: [PATCH 16/34] Update GameViewGridOverlay.cs - adding spacing support --- .../Editor/Tools/GameViewGridOverlay.cs | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs index 143bedc..380bf2a 100644 --- a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs +++ b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs @@ -1,6 +1,3 @@ -// draws grid lines in the game view (useful for seeing the resolution of ui elements in the game view) -// usage: attach to a game object in the scene, set gameobject tag to "EditorOnly" to remove from builds - using UnityEngine; namespace UnityLibrary.EditorTools @@ -11,9 +8,15 @@ public class GameViewGridOverlay : MonoBehaviour #if UNITY_EDITOR public bool drawGrid = true; - public int gridSpacingX = 64; - public int gridSpacingY = 64; + [Header("Grid Cell Size (visible area)")] + public int gridSizeX = 64; + public int gridSizeY = 64; + + [Header("Spacing Between Cells (invisible gap)")] + public int spacingX = 16; + public int spacingY = 16; + [Header("Start Offsets")] public int startOffsetX = 0; public int startOffsetY = 0; @@ -26,16 +29,22 @@ private void OnGUI() Color oldColor = GUI.color; GUI.color = gridColor; - // vertical lines - for (int y = startOffsetY; y < Screen.height; y += gridSpacingY) - { - GUI.DrawTexture(new Rect(0, y, Screen.width, 1), Texture2D.whiteTexture); - } + int cellStrideX = gridSizeX + spacingX; + int cellStrideY = gridSizeY + spacingY; - // horizontal lines - for (int x = startOffsetX; x < Screen.width; x += gridSpacingX) + for (int y = startOffsetY; y + gridSizeY <= Screen.height; y += cellStrideY) { - GUI.DrawTexture(new Rect(x, 0, 1, Screen.height), Texture2D.whiteTexture); + for (int x = startOffsetX; x + gridSizeX <= Screen.width; x += cellStrideX) + { + // Left line + GUI.DrawTexture(new Rect(x, y, 1, gridSizeY), Texture2D.whiteTexture); + // Right line + GUI.DrawTexture(new Rect(x + gridSizeX - 1, y, 1, gridSizeY), Texture2D.whiteTexture); + // Top line + GUI.DrawTexture(new Rect(x, y, gridSizeX, 1), Texture2D.whiteTexture); + // Bottom line + GUI.DrawTexture(new Rect(x, y + gridSizeY - 1, gridSizeX, 1), Texture2D.whiteTexture); + } } GUI.color = oldColor; From 61f149cbada8d0668cf65d95ede339975156a439 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 25 Apr 2025 12:54:11 +0300 Subject: [PATCH 17/34] Update GameViewGridOverlay.cs fix: need to draw past screensize to see last edges --- .../Scripts/Editor/Tools/GameViewGridOverlay.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs index 380bf2a..06acb2e 100644 --- a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs +++ b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs @@ -32,17 +32,20 @@ private void OnGUI() int cellStrideX = gridSizeX + spacingX; int cellStrideY = gridSizeY + spacingY; - for (int y = startOffsetY; y + gridSizeY <= Screen.height; y += cellStrideY) + // Loop until start of the cell is beyond screen, not end of cell + for (int y = startOffsetY; y < Screen.height; y += cellStrideY) { - for (int x = startOffsetX; x + gridSizeX <= Screen.width; x += cellStrideX) + for (int x = startOffsetX; x < Screen.width; x += cellStrideX) { - // Left line + // Draw full box even if it goes beyond screen edges + + // Left GUI.DrawTexture(new Rect(x, y, 1, gridSizeY), Texture2D.whiteTexture); - // Right line + // Right GUI.DrawTexture(new Rect(x + gridSizeX - 1, y, 1, gridSizeY), Texture2D.whiteTexture); - // Top line + // Top GUI.DrawTexture(new Rect(x, y, gridSizeX, 1), Texture2D.whiteTexture); - // Bottom line + // Bottom GUI.DrawTexture(new Rect(x, y + gridSizeY - 1, gridSizeX, 1), Texture2D.whiteTexture); } } From 5ede7f09e9f63080223111ed2e09b4473e33dd92 Mon Sep 17 00:00:00 2001 From: mika Date: Mon, 28 Apr 2025 14:59:35 +0300 Subject: [PATCH 18/34] Create RectTransformCloner.cs --- .../Editor/Tools/RectTransformCloner.cs | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/RectTransformCloner.cs diff --git a/Assets/Scripts/Editor/Tools/RectTransformCloner.cs b/Assets/Scripts/Editor/Tools/RectTransformCloner.cs new file mode 100644 index 0000000..fd55097 --- /dev/null +++ b/Assets/Scripts/Editor/Tools/RectTransformCloner.cs @@ -0,0 +1,151 @@ +// clones UI RectTransform values from one GameObject to another in Unity Editor (only for identical hierarchy) + +using UnityEngine; +using UnityEditor; +using TMPro; +using UnityEngine.UI; + +namespace UnityLibrary.Tools +{ + public class RectTransformCloner : EditorWindow + { + private GameObject source; + private GameObject target; + private bool requireIdenticalNames = true; + private bool cloneTMPAlignment = false; + + [MenuItem("Tools/RectTransform Cloner")] + private static void ShowWindow() + { + var window = GetWindow(); + window.titleContent = new GUIContent("RectTransform Cloner"); + window.Show(); + } + + private void OnGUI() + { + GUILayout.Label("Clone RectTransform", EditorStyles.boldLabel); + + source = (GameObject)EditorGUILayout.ObjectField("Source", source, typeof(GameObject), true); + target = (GameObject)EditorGUILayout.ObjectField("Target", target, typeof(GameObject), true); + requireIdenticalNames = EditorGUILayout.Toggle("Require Identical Names", requireIdenticalNames); + cloneTMPAlignment = EditorGUILayout.Toggle("Clone TMP Alignment", cloneTMPAlignment); + + if (GUILayout.Button("Clone RectTransforms")) + { + if (source == null || target == null) + { + Debug.LogError("Source and Target must be assigned."); + return; + } + + string errorMessage; + if (!CompareHierarchies(source.transform, target.transform, out errorMessage)) + { + Debug.LogError("Source and Target hierarchies do not match!\n" + errorMessage, target); + return; + } + + Undo.RegisterFullObjectHierarchyUndo(target, "Clone RectTransform Values"); + CopyRectTransforms(source.transform, target.transform); + + Debug.Log("RectTransform values cloned successfully.", target); + + if (target.transform.parent != null) + { + RectTransform parentRect = target.transform.parent as RectTransform; + if (parentRect != null) + { + LayoutRebuilder.ForceRebuildLayoutImmediate(parentRect); + } + else + { + Debug.LogWarning("Target's parent is not a RectTransform, cannot force layout rebuild.", target); + } + } + else + { + Debug.LogWarning("Target has no parent, cannot force layout rebuild.", target); + } + EditorUtility.SetDirty(target); + SceneView.RepaintAll(); + } + } + + private bool CompareHierarchies(Transform source, Transform target, out string errorMessage) + { + errorMessage = ""; + + if (source.childCount != target.childCount) + { + errorMessage = $"Child count mismatch at {GetTransformPath(source)}: Source has {source.childCount}, Target has {target.childCount}"; + return false; + } + + for (int i = 0; i < source.childCount; i++) + { + var sourceChild = source.GetChild(i); + var targetChild = target.GetChild(i); + + if (requireIdenticalNames && sourceChild.name != targetChild.name) + { + errorMessage = $"Child name mismatch at {GetTransformPath(sourceChild)}: Source has '{sourceChild.name}', Target has '{targetChild.name}'"; + return false; + } + + if (!CompareHierarchies(sourceChild, targetChild, out errorMessage)) + { + return false; + } + } + + return true; + } + + private void CopyRectTransforms(Transform source, Transform target) + { + var sourceRect = source as RectTransform; + var targetRect = target as RectTransform; + + if (sourceRect != null && targetRect != null) + { + CopyRectTransformValues(sourceRect, targetRect); + + if (cloneTMPAlignment) + { + var sourceTMP = source.GetComponent(); + var targetTMP = target.GetComponent(); + if (sourceTMP != null && targetTMP != null) + { + Undo.RecordObject(targetTMP, "Clone TMP Alignment"); + targetTMP.alignment = sourceTMP.alignment; + } + } + } + + for (int i = 0; i < source.childCount; i++) + { + CopyRectTransforms(source.GetChild(i), target.GetChild(i)); + } + } + + private void CopyRectTransformValues(RectTransform source, RectTransform target) + { + target.anchoredPosition = source.anchoredPosition; + target.sizeDelta = source.sizeDelta; + target.anchorMin = source.anchorMin; + target.anchorMax = source.anchorMax; + target.pivot = source.pivot; + target.localRotation = source.localRotation; + target.localScale = source.localScale; + target.localPosition = source.localPosition; + } + + private string GetTransformPath(Transform t) + { + if (t.parent == null) + return t.name; + return GetTransformPath(t.parent) + "/" + t.name; + } + } +} From 4df3c80b60fc36d07a85ad241f2ca16c9aa277aa Mon Sep 17 00:00:00 2001 From: mika Date: Thu, 15 May 2025 21:34:22 +0300 Subject: [PATCH 19/34] Create FindWhoReferencesThisGameObject.cs --- .../Tools/FindWhoReferencesThisGameObject.cs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs diff --git a/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs b/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs new file mode 100644 index 0000000..b2def9e --- /dev/null +++ b/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs @@ -0,0 +1,123 @@ +// finds what scripts reference a given GameObject in the scene (in events, public fields..) + +using System.Collections.Generic; +using System.Reflection; +using UnityEditor; +using UnityEngine; +using UnityEngine.Events; + +namespace UnityLibrary.Editor +{ + public class FindWhoReferencesThisGameObject : EditorWindow + { + private GameObject target; + private Vector2 scroll; + + private class ReferenceResult + { + public string message; + public GameObject owner; + } + + private List results = new List(); + + [MenuItem("Tools/UnityLibrary/Find References To GameObject")] + public static void ShowWindow() + { + var win = GetWindow("Find References"); + win.minSize = new Vector2(500, 300); + } + + private void OnGUI() + { + GUILayout.Label("Find scripts that reference this GameObject", EditorStyles.boldLabel); + target = EditorGUILayout.ObjectField("Target GameObject", target, typeof(GameObject), true) as GameObject; + + if (GUILayout.Button("Find References")) + { + results.Clear(); + if (target != null) + { + FindReferences(target); + } + else + { + Debug.LogWarning("Please assign a GameObject."); + } + } + + if (results.Count > 0) + { + GUILayout.Label("Results:", EditorStyles.boldLabel); + scroll = GUILayout.BeginScrollView(scroll, GUILayout.Height(400)); + foreach (var res in results) + { + if (GUILayout.Button(res.message, GUILayout.ExpandWidth(true))) + { + EditorGUIUtility.PingObject(res.owner); + Selection.activeGameObject = res.owner; + } + } + GUILayout.EndScrollView(); + } + } + + private void FindReferences(GameObject target) + { + var allObjects = UnityEngine.Object.FindObjectsOfType(true); + + foreach (var mono in allObjects) + { + if (mono == null || mono.gameObject == target) continue; + + var type = mono.GetType(); + var fields = type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + foreach (var field in fields) + { + if (typeof(UnityEventBase).IsAssignableFrom(field.FieldType)) + { + var unityEvent = field.GetValue(mono) as UnityEventBase; + if (unityEvent != null) + { + int count = unityEvent.GetPersistentEventCount(); + for (int i = 0; i < count; i++) + { + var listener = unityEvent.GetPersistentTarget(i); + if (listener == target) + { + results.Add(new ReferenceResult + { + message = $"{mono.name} ({type.Name}) -> UnityEvent '{field.Name}'", + owner = mono.gameObject + }); + } + } + } + } + else if (typeof(UnityEngine.Object).IsAssignableFrom(field.FieldType)) + { + var value = field.GetValue(mono) as UnityEngine.Object; + if (value == target) + { + results.Add(new ReferenceResult + { + message = $"{mono.name} ({type.Name}) -> Field '{field.Name}'", + owner = mono.gameObject + }); + } + } + } + } + + if (results.Count == 0) + { + results.Add(new ReferenceResult + { + message = "No references found.", + owner = null + }); + } + } + } +} From 540d629be475e7e984b893013a5634c725c3ad74 Mon Sep 17 00:00:00 2001 From: mika Date: Thu, 15 May 2025 21:35:50 +0300 Subject: [PATCH 20/34] Update FindWhoReferencesThisGameObject.cs --- Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs b/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs index b2def9e..dd5b2c5 100644 --- a/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs +++ b/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs @@ -1,4 +1,4 @@ -// finds what scripts reference a given GameObject in the scene (in events, public fields..) +// find what scripts reference selected GameObject in the scene (in events, public fields..) using System.Collections.Generic; using System.Reflection; From 15f58004d059406de92b0c1e6668ba08c9e8a168 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 5 Dec 2025 14:35:07 +0200 Subject: [PATCH 21/34] Add SceneTextSearchWindow for scene text searching --- .../Editor/Tools/SceneTextSearchWindow.cs | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/SceneTextSearchWindow.cs diff --git a/Assets/Scripts/Editor/Tools/SceneTextSearchWindow.cs b/Assets/Scripts/Editor/Tools/SceneTextSearchWindow.cs new file mode 100644 index 0000000..8467f3e --- /dev/null +++ b/Assets/Scripts/Editor/Tools/SceneTextSearchWindow.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using UnityEditor; +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +namespace UnityLibrary.EditorTools +{ + public class SceneTextSearchWindow : EditorWindow + { + [Serializable] + private class SearchResult + { + public Component component; + public string text; + } + + private string searchTerm = string.Empty; + private string previousSearchTerm = string.Empty; + private bool caseSensitive = false; + private bool automaticSearch = true; + + private readonly List results = new List(); + private readonly HashSet seenComponents = new HashSet(); + private Vector2 scrollPos; + + [MenuItem("Tools/UnityLibrary/Scene Text Search")] + public static void Open() + { + var window = GetWindow("Scene Text Search"); + window.minSize = new Vector2(600, 300); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("Search text in loaded scenes", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + EditorGUILayout.BeginHorizontal(); + + EditorGUILayout.LabelField("Search term", GUILayout.Width(80)); + + string newSearchTerm = EditorGUILayout.TextField(searchTerm); + + if (GUILayout.Button("Search", GUILayout.Width(80))) + { + DoSearch(); + } + + EditorGUILayout.EndHorizontal(); + + if (newSearchTerm != searchTerm) + { + searchTerm = newSearchTerm; + + if (automaticSearch) + { + if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Length > 1) + { + DoSearch(); + } + else + { + results.Clear(); + seenComponents.Clear(); + } + } + + previousSearchTerm = searchTerm; + } + + EditorGUILayout.BeginHorizontal(); + caseSensitive = EditorGUILayout.Toggle("Case sensitive", caseSensitive); + automaticSearch = EditorGUILayout.Toggle("Automatic search", automaticSearch); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField($"Results: {results.Count}", EditorStyles.boldLabel); + + scrollPos = EditorGUILayout.BeginScrollView(scrollPos); + foreach (var r in results) + { + if (r.component == null) + continue; + + EditorGUILayout.BeginHorizontal(); + + EditorGUILayout.ObjectField(r.component, typeof(Component), true, GUILayout.Width(220)); + + using (new EditorGUI.DisabledScope(true)) + { + EditorGUILayout.TextField(Truncate(r.text, 200)); + } + + EditorGUILayout.EndHorizontal(); + } + EditorGUILayout.EndScrollView(); + } + + private void DoSearch() + { + results.Clear(); + seenComponents.Clear(); + + if (string.IsNullOrEmpty(searchTerm)) + return; + + string term = caseSensitive ? searchTerm : searchTerm.ToLowerInvariant(); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + // UnityEngine.UI.Text + SearchComponents(t => t.text, term); + + // TMP UGUI + SearchComponents(t => t.text, term); + + // TMP 3D + SearchComponents(t => t.text, term); + + // Legacy TextMesh + SearchComponents(t => t.text, term); + + // Generic "other text components" with a string 'text' property/field + SearchGenericTextComponents(term); + + stopwatch.Stop(); + Debug.Log($"SceneTextSearchWindow: Found {results.Count} results in {stopwatch.ElapsedMilliseconds} ms"); + } + + private void SearchComponents(Func getText, string term) where T : Component + { + var objects = Resources.FindObjectsOfTypeAll(); + foreach (var comp in objects) + { + if (!IsSceneObject(comp)) + continue; + + string value = getText(comp); + if (StringMatches(value, term)) + { + AddResult(comp, value); + } + } + } + + private void SearchGenericTextComponents(string term) + { + var monos = Resources.FindObjectsOfTypeAll(); + foreach (var mb in monos) + { + if (!IsSceneObject(mb)) + continue; + + if (seenComponents.Contains(mb)) + continue; + + Type type = mb.GetType(); + + try + { + var prop = type.GetProperty("text", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (prop != null && prop.PropertyType == typeof(string) && prop.CanRead) + { + string value = prop.GetValue(mb, null) as string; + if (StringMatches(value, term)) + { + AddResult(mb, value); + continue; + } + } + } + catch + { + } + + try + { + var field = type.GetField("text", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (field != null && field.FieldType == typeof(string)) + { + string value = field.GetValue(mb) as string; + if (StringMatches(value, term)) + { + AddResult(mb, value); + } + } + } + catch + { + } + } + } + + private bool StringMatches(string value, string term) + { + if (string.IsNullOrEmpty(value)) + return false; + + if (!caseSensitive) + value = value.ToLowerInvariant(); + + return value.Contains(term); + } + + private void AddResult(Component component, string text) + { + if (component == null) + return; + + if (seenComponents.Add(component)) + { + results.Add(new SearchResult + { + component = component, + text = text + }); + } + } + + private static bool IsSceneObject(Component comp) + { + if (comp == null) + return false; + + var go = comp.gameObject; + if (go == null) + return false; + + if (EditorUtility.IsPersistent(go)) + return false; + + if (!go.scene.IsValid() || !go.scene.isLoaded) + return false; + + return true; + } + + private static string Truncate(string input, int maxLength) + { + if (string.IsNullOrEmpty(input)) + return input; + if (input.Length <= maxLength) + return input; + return input.Substring(0, maxLength) + "..."; + } + } +} From e08b7a9587c735d5befb476adbcb2e102fc2dc88 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 5 Dec 2025 14:35:52 +0200 Subject: [PATCH 22/34] Add images for GameViewGridOverlay and SceneTextSearchWindow --- Assets/Scripts/Editor/Tools/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Assets/Scripts/Editor/Tools/README.md b/Assets/Scripts/Editor/Tools/README.md index 20d6e2a..fcdb9c8 100644 --- a/Assets/Scripts/Editor/Tools/README.md +++ b/Assets/Scripts/Editor/Tools/README.md @@ -1,2 +1,5 @@ ### GameViewGridOverlay.cs ![Image](https://github.com/user-attachments/assets/48fbced4-48e0-49fe-9acc-666f5449a958) + +### SceneTextSearchWindow.cs +image From 936e19d819f2b47e8af96bf48f0ec9424c625618 Mon Sep 17 00:00:00 2001 From: mika Date: Thu, 29 Jan 2026 11:46:41 +0200 Subject: [PATCH 23/34] add linerenderer context menu convert linerenderer points between local to worldspace --- .../ContextMenu/LineRendererToLocalSpace.cs | 60 +++++++++++++++++++ .../ContextMenu/LineRendererToWorldSpace.cs | 54 +++++++++++++++++ 2 files changed, 114 insertions(+) create mode 100644 Assets/Scripts/Editor/ContextMenu/LineRendererToLocalSpace.cs create mode 100644 Assets/Scripts/Editor/ContextMenu/LineRendererToWorldSpace.cs diff --git a/Assets/Scripts/Editor/ContextMenu/LineRendererToLocalSpace.cs b/Assets/Scripts/Editor/ContextMenu/LineRendererToLocalSpace.cs new file mode 100644 index 0000000..6cc6cc7 --- /dev/null +++ b/Assets/Scripts/Editor/ContextMenu/LineRendererToLocalSpace.cs @@ -0,0 +1,60 @@ +// converts line renderer points from world space to local space + +using UnityEngine; +using UnityEditor; +using UnityEditor.SceneManagement; + +namespace UnityLibrary.ContextMenu +{ + public static class LineRendererToLocalSpace + { + private const string MenuPath = "CONTEXT/LineRenderer/Convert Points To Local Space"; + + [MenuItem(MenuPath, true)] + private static bool Validate(MenuCommand command) + { + return command != null && command.context is LineRenderer; + } + + [MenuItem(MenuPath)] + private static void Convert(MenuCommand command) + { + var lr = (LineRenderer)command.context; + if (lr == null) return; + + int count = lr.positionCount; + if (count == 0) return; + + Transform t = lr.transform; + + Undo.RecordObject(lr, "Convert LineRenderer To Local Space"); + + // Get current positions in world space no matter what mode it's in. + Vector3[] world = new Vector3[count]; + if (lr.useWorldSpace) + { + lr.GetPositions(world); + } + else + { + Vector3[] local = new Vector3[count]; + lr.GetPositions(local); + for (int i = 0; i < count; i++) + world[i] = t.TransformPoint(local[i]); + } + + // Convert world -> local, switch mode, write back. + Vector3[] newLocal = new Vector3[count]; + for (int i = 0; i < count; i++) + newLocal[i] = t.InverseTransformPoint(world[i]); + + lr.useWorldSpace = false; + lr.SetPositions(newLocal); + + EditorUtility.SetDirty(lr); + + if (!Application.isPlaying) + EditorSceneManager.MarkSceneDirty(lr.gameObject.scene); + } + } +} diff --git a/Assets/Scripts/Editor/ContextMenu/LineRendererToWorldSpace.cs b/Assets/Scripts/Editor/ContextMenu/LineRendererToWorldSpace.cs new file mode 100644 index 0000000..90cc2d2 --- /dev/null +++ b/Assets/Scripts/Editor/ContextMenu/LineRendererToWorldSpace.cs @@ -0,0 +1,54 @@ +// converts LineRenderer points from local space to world space via context menu in Unity Editor + +using UnityEngine; +using UnityEditor; +using UnityEditor.SceneManagement; + +namespace UnityLibrary.ContextMenu +{ + public static class LineRendererToWorldSpace + { + private const string MenuPath = "CONTEXT/LineRenderer/Convert Points To World Space"; + + [MenuItem(MenuPath, true)] + private static bool Validate(MenuCommand command) + { + return command != null && command.context is LineRenderer; + } + + [MenuItem(MenuPath)] + private static void Convert(MenuCommand command) + { + var lr = (LineRenderer)command.context; + if (lr == null) return; + + if (lr.useWorldSpace) + { + Debug.Log("LineRenderer is already using World Space."); + return; + } + + int count = lr.positionCount; + if (count == 0) return; + + Transform t = lr.transform; + + Undo.RecordObject(lr, "Convert LineRenderer To World Space"); + + Vector3[] local = new Vector3[count]; + lr.GetPositions(local); + + Vector3[] world = new Vector3[count]; + for (int i = 0; i < count; i++) + world[i] = t.TransformPoint(local[i]); + + lr.useWorldSpace = true; + lr.SetPositions(world); + + EditorUtility.SetDirty(lr); + + if (!Application.isPlaying) + EditorSceneManager.MarkSceneDirty(lr.gameObject.scene); + } + } +} From b33ae3e7d8e111e1603cb0911618f2568c3889a8 Mon Sep 17 00:00:00 2001 From: John Day Date: Fri, 6 Feb 2026 11:18:01 -0600 Subject: [PATCH 24/34] Graphics.Blit ambiguous method signature --- Assets/Scripts/Docs/Graphics/Graphics_Blit.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/Docs/Graphics/Graphics_Blit.cs b/Assets/Scripts/Docs/Graphics/Graphics_Blit.cs index 6760898..805f4ba 100644 --- a/Assets/Scripts/Docs/Graphics/Graphics_Blit.cs +++ b/Assets/Scripts/Docs/Graphics/Graphics_Blit.cs @@ -26,7 +26,7 @@ void OnPostRender() { // Copies source texture into destination render texture with a shader // Destination RenderTexture is null to blit directly to screen - Graphics.Blit(displayTexture, null, mat); + Graphics.Blit(displayTexture, null as RenderTexture, mat); } } } \ No newline at end of file From 3f2d64bd41c256c8ccc9ba8cec08147441b07a1f Mon Sep 17 00:00:00 2001 From: mika Date: Thu, 12 Feb 2026 22:08:02 +0200 Subject: [PATCH 25/34] Add Android Store Capture Tool for screenshot automation This script provides a tool for capturing Android store screenshots directly from the Unity editor. It allows users to specify output folders and capture various preset resolutions for app icons, feature graphics, and screenshots for phones and tablets. --- .../Editor/Tools/AndroidStoreCaptureTool.cs | 513 ++++++++++++++++++ 1 file changed, 513 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/AndroidStoreCaptureTool.cs diff --git a/Assets/Scripts/Editor/Tools/AndroidStoreCaptureTool.cs b/Assets/Scripts/Editor/Tools/AndroidStoreCaptureTool.cs new file mode 100644 index 0000000..8a73f3a --- /dev/null +++ b/Assets/Scripts/Editor/Tools/AndroidStoreCaptureTool.cs @@ -0,0 +1,513 @@ +// AndroidStoreCaptureTool.cs +// Put this file anywhere under an "Editor" folder. +// Usage: +// 1) Enter Play Mode. +// 2) Open: Tools/Android Store Capture +// 3) Pick output folder and click "Capture All Presets" + +using System; +using System.Collections; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using UnityEditor; +using UnityEngine; + +namespace UnityLibrary.Tools +{ + public class AndroidStoreCaptureTool : EditorWindow + { + [Serializable] + private class Preset + { + public string name; // base file name (without _WxH) + public int width; + public int height; + public CropMode cropMode; + + public Preset(string name, int w, int h, CropMode cropMode) + { + this.name = name; + width = w; + height = h; + this.cropMode = cropMode; + } + } + + private enum CropMode + { + Stretch, // no crop, just scale to target (may distort) + CropToFit // center-crop to target aspect, then scale (no distortion) + } + + private string _outputFolder = "StoreCaptures"; + private int _phoneCount = 2; // Play Console: 2-8 phone screenshots + + // Jobs + private class CaptureJob + { + public Preset preset; + public string filename; + } + + private readonly Queue _queue = new Queue(); + private bool _isRunning; + + // Hidden helper MonoBehaviour that runs coroutines in Play Mode + private CaptureHelper _helper; + + // Presets based on Play Console rules in your message. + // Phone/tablet sizes are common choices within allowed ranges. + private List BuildPresets() + { + var list = new List(); + + // App icon and feature graphic + list.Add(new Preset("appicon", 512, 512, CropMode.CropToFit)); + list.Add(new Preset("featuregraphic", 1024, 500, CropMode.CropToFit)); + + // Phone screenshots (2-8). 9:16 or 16:9. Each side 320..3840. + // We capture portrait by default; toggle to landscape if you want. + for (int i = 1; i <= Mathf.Clamp(_phoneCount, 2, 8); i++) + list.Add(new Preset("phone_" + i.ToString("00"), 1080, 1920, CropMode.CropToFit)); + + // 7-inch tablet screenshots (allowed: 320..3840 each side) + list.Add(new Preset("tablet7_01", 1920, 1200, CropMode.CropToFit)); // landscape 16:10 + list.Add(new Preset("tablet7_02", 1200, 1920, CropMode.CropToFit)); // portrait 10:16 + + // 10-inch tablet screenshots (each side 1080..7680) + list.Add(new Preset("tablet10_01", 2560, 1600, CropMode.CropToFit)); // landscape 16:10 + list.Add(new Preset("tablet10_02", 1600, 2560, CropMode.CropToFit)); // portrait 10:16 + + return list; + } + + [MenuItem("Tools/Android Store Capture")] + public static void Open() + { + var w = GetWindow("Android Store Capture"); + w.minSize = new Vector2(420, 340); + w.Show(); + } + + private void OnDisable() + { + StopRunner(); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("Capture from Game View (Play Mode)", EditorStyles.boldLabel); + + using (new EditorGUILayout.VerticalScope("box")) + { + EditorGUILayout.LabelField("Output", EditorStyles.boldLabel); + + EditorGUILayout.BeginHorizontal(); + _outputFolder = EditorGUILayout.TextField("Folder", _outputFolder); + if (GUILayout.Button("Browse", GUILayout.Width(80))) + { + string picked = EditorUtility.OpenFolderPanel("Pick output folder", Application.dataPath, ""); + if (!string.IsNullOrEmpty(picked)) + { + // Make it project-relative when possible + string proj = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + string full = Path.GetFullPath(picked); + if (full.StartsWith(proj, StringComparison.OrdinalIgnoreCase)) + { + _outputFolder = full.Substring(proj.Length).TrimStart(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + } + else + { + _outputFolder = full; + } + } + } + EditorGUILayout.EndHorizontal(); + + _phoneCount = EditorGUILayout.IntSlider("Phone screenshots (2-8)", _phoneCount, 2, 8); + } + + using (new EditorGUILayout.VerticalScope("box")) + { + EditorGUILayout.LabelField("Actions", EditorStyles.boldLabel); + + if (!EditorApplication.isPlaying) + { + EditorGUILayout.HelpBox("Enter Play Mode first. This tool captures the rendered Game View.", MessageType.Warning); + } + + GUI.enabled = EditorApplication.isPlaying && !_isRunning; + if (GUILayout.Button("Capture All Presets")) + { + EnqueueAll(); + StartRunner(); + } + + if (GUILayout.Button("Capture Only Icon + Feature Graphic")) + { + EnqueueIconAndFeatureOnly(); + StartRunner(); + } + GUI.enabled = true; + + GUI.enabled = _isRunning; + if (GUILayout.Button("Stop")) + { + StopRunner(); + } + GUI.enabled = true; + + if (_isRunning) + { + EditorGUILayout.Space(6); + EditorGUILayout.LabelField("Running...", EditorStyles.boldLabel); + EditorGUILayout.LabelField("Remaining", _queue.Count.ToString()); + } + } + + using (new EditorGUILayout.VerticalScope("box")) + { + EditorGUILayout.LabelField("Notes", EditorStyles.boldLabel); + EditorGUILayout.LabelField("- Files are named like: appicon_512x512.png, featuregraphic_1024x500.png"); + EditorGUILayout.LabelField("- Phone screenshots are named like: phone_01_1080x1920.png"); + EditorGUILayout.LabelField("- Captures center-crop to match target aspect (no stretching)."); + } + } + + private void EnqueueAll() + { + _queue.Clear(); + + string folder = ResolveOutputFolder(); + Directory.CreateDirectory(folder); + + foreach (var p in BuildPresets()) + { + string fn = $"{p.name}_{p.width}x{p.height}.png"; + _queue.Enqueue(new CaptureJob { preset = p, filename = Path.Combine(folder, fn) }); + } + } + + private void EnqueueIconAndFeatureOnly() + { + _queue.Clear(); + + string folder = ResolveOutputFolder(); + Directory.CreateDirectory(folder); + + var icon = new Preset("appicon", 512, 512, CropMode.CropToFit); + var feature = new Preset("featuregraphic", 1024, 500, CropMode.CropToFit); + + _queue.Enqueue(new CaptureJob { preset = icon, filename = Path.Combine(folder, $"appicon_512x512.png") }); + _queue.Enqueue(new CaptureJob { preset = feature, filename = Path.Combine(folder, $"featuregraphic_1024x500.png") }); + } + + private string ResolveOutputFolder() + { + // If user gave absolute path, use it. Otherwise, place under project root. + if (Path.IsPathRooted(_outputFolder)) + return _outputFolder; + + string projectRoot = Path.GetFullPath(Path.Combine(Application.dataPath, "..")); + return Path.Combine(projectRoot, _outputFolder); + } + + private CaptureHelper EnsureHelper() + { + if (_helper != null) return _helper; + + var go = new GameObject("[AndroidStoreCaptureHelper]") + { + hideFlags = HideFlags.HideAndDontSave + }; + _helper = go.AddComponent(); + return _helper; + } + + private void StartRunner() + { + if (_isRunning) return; + if (!EditorApplication.isPlaying) return; + + _isRunning = true; + + GetMainGameView(); + + var helper = EnsureHelper(); + helper.StartCoroutine(RunCaptures()); + } + + private void StopRunner() + { + _isRunning = false; + _queue.Clear(); + + if (_helper != null) + { + _helper.StopAllCoroutines(); + DestroyImmediate(_helper.gameObject); + _helper = null; + } + } + + private IEnumerator RunCaptures() + { + while (_queue.Count > 0) + { + if (!EditorApplication.isPlaying) + { + StopRunner(); + yield break; + } + + var job = _queue.Dequeue(); + + SetGameViewSize(job.preset.width, job.preset.height); + + // Wait for the GameView to resize and re-render + for (int i = 0; i < 6; i++) + yield return null; + + // Wait for end of frame — this is required for ScreenCapture to work + yield return new WaitForEndOfFrame(); + + try + { + ProcessCaptureJob(job); + } + catch (Exception ex) + { + Debug.LogError("Capture failed: " + ex); + } + + Repaint(); + } + + _isRunning = false; + AssetDatabase.Refresh(); + Debug.Log("Android Store Capture: All captures finished."); + Repaint(); + + if (_helper != null) + { + DestroyImmediate(_helper.gameObject); + _helper = null; + } + } + + private void ProcessCaptureJob(CaptureJob job) + { + int targetW = job.preset.width; + int targetH = job.preset.height; + + Texture2D src = ScreenCapture.CaptureScreenshotAsTexture(); + if (src == null) + { + Debug.LogError($"CaptureScreenshotAsTexture returned null for {job.filename}. Skipping."); + return; + } + + Texture2D processed; + if (job.preset.cropMode == CropMode.CropToFit) + processed = CropToAspectThenScale(src, targetW, targetH); + else + processed = ScaleTexture(src, targetW, targetH); + + byte[] png = processed.EncodeToPNG(); + File.WriteAllBytes(job.filename, png); + + DestroyImmediate(src); + if (processed != src) + DestroyImmediate(processed); + + Debug.Log("Saved: " + job.filename); + } + + private static Texture2D CropToAspectThenScale(Texture2D src, int targetW, int targetH) + { + float srcAspect = (float)src.width / src.height; + float dstAspect = (float)targetW / targetH; + + int cropW = src.width; + int cropH = src.height; + + if (srcAspect > dstAspect) + { + // too wide -> crop width + cropW = Mathf.RoundToInt(src.height * dstAspect); + cropH = src.height; + } + else + { + // too tall -> crop height + cropW = src.width; + cropH = Mathf.RoundToInt(src.width / dstAspect); + } + + // Crop from top-left: x starts at 0, y starts from top + int x0 = 0; + int y0 = src.height - cropH; + + Color[] pixels = src.GetPixels(x0, y0, cropW, cropH); + Texture2D cropped = new Texture2D(cropW, cropH, TextureFormat.RGBA32, false); + cropped.SetPixels(pixels); + cropped.Apply(false, false); + + Texture2D scaled = ScaleTexture(cropped, targetW, targetH); + DestroyImmediate(cropped); + return scaled; + } + + private static Texture2D ScaleTexture(Texture2D src, int targetW, int targetH) + { + Texture2D dst = new Texture2D(targetW, targetH, TextureFormat.RGBA32, false); + + for (int y = 0; y < targetH; y++) + { + float v = (targetH == 1) ? 0f : (float)y / (targetH - 1); + for (int x = 0; x < targetW; x++) + { + float u = (targetW == 1) ? 0f : (float)x / (targetW - 1); + Color c = SampleBilinear(src, u, v); + dst.SetPixel(x, y, c); + } + } + + dst.Apply(false, false); + return dst; + } + + private static Color SampleBilinear(Texture2D tex, float u, float v) + { + float x = u * (tex.width - 1); + float y = v * (tex.height - 1); + + int x0 = Mathf.Clamp((int)Mathf.Floor(x), 0, tex.width - 1); + int y0 = Mathf.Clamp((int)Mathf.Floor(y), 0, tex.height - 1); + int x1 = Mathf.Clamp(x0 + 1, 0, tex.width - 1); + int y1 = Mathf.Clamp(y0 + 1, 0, tex.height - 1); + + float tx = x - x0; + float ty = y - y0; + + Color c00 = tex.GetPixel(x0, y0); + Color c10 = tex.GetPixel(x1, y0); + Color c01 = tex.GetPixel(x0, y1); + Color c11 = tex.GetPixel(x1, y1); + + Color a = Color.Lerp(c00, c10, tx); + Color b = Color.Lerp(c01, c11, tx); + return Color.Lerp(a, b, ty); + } + + // --------------------------- + // GameView sizing (internal) + // --------------------------- + + private static EditorWindow GetMainGameView() + { + Type t = Type.GetType("UnityEditor.GameView,UnityEditor"); + if (t == null) return null; + + // Try "GetMainGameView" first (older Unity versions) + MethodInfo getMain = t.GetMethod("GetMainGameView", BindingFlags.NonPublic | BindingFlags.Static); + if (getMain != null) + { + var result = getMain.Invoke(null, null) as EditorWindow; + if (result != null) return result; + } + + // Fallback: try "GetMainGameViewRenderRect" or just find an open GameView window + var gameView = GetWindow(t, false, null, false); + return gameView; + } + + private static void SetGameViewSize(int width, int height) + { + // Creates/uses a fixed resolution entry in the current platform group, then selects it. + // Unity does not expose this publicly; reflection is used. + + Type sizesType = Type.GetType("UnityEditor.GameViewSizes,UnityEditor"); + Type sizeType = Type.GetType("UnityEditor.GameViewSize,UnityEditor"); + Type groupType = Type.GetType("UnityEditor.GameViewSizeGroupType,UnityEditor"); + + if (sizesType == null || sizeType == null || groupType == null) + return; + + var instanceProp = sizesType.GetProperty("instance", BindingFlags.Public | BindingFlags.Static); + if (instanceProp == null) return; + object sizesInstance = instanceProp.GetValue(null, null); + if (sizesInstance == null) return; + + MethodInfo getGroup = sizesType.GetMethod("GetGroup"); + if (getGroup == null) return; + object group = getGroup.Invoke(sizesInstance, new object[] { (int)Enum.Parse(groupType, "Standalone") }); + if (group == null) return; + + // Find existing + MethodInfo getBuiltinCount = group.GetType().GetMethod("GetBuiltinCount"); + MethodInfo getCustomCount = group.GetType().GetMethod("GetCustomCount"); + MethodInfo getGameViewSize = group.GetType().GetMethod("GetGameViewSize"); + + if (getBuiltinCount == null || getCustomCount == null || getGameViewSize == null) return; + + int builtin = (int)getBuiltinCount.Invoke(group, null); + int custom = (int)getCustomCount.Invoke(group, null); + + int total = builtin + custom; + int foundIndex = -1; + + for (int i = 0; i < total; i++) + { + object gvSize = getGameViewSize.Invoke(group, new object[] { i }); + if (gvSize == null) continue; + var widthProp = gvSize.GetType().GetProperty("width"); + var heightProp = gvSize.GetType().GetProperty("height"); + if (widthProp == null || heightProp == null) continue; + + int w = (int)widthProp.GetValue(gvSize, null); + int h = (int)heightProp.GetValue(gvSize, null); + + if (w == width && h == height) + { + foundIndex = i; + break; + } + } + + if (foundIndex < 0) + { + // Add custom size + Type gvSizeType = Type.GetType("UnityEditor.GameViewSizeType,UnityEditor"); + if (gvSizeType == null) return; + object fixedRes = Enum.Parse(gvSizeType, "FixedResolution"); + + ConstructorInfo ctor = sizeType.GetConstructor(new[] { gvSizeType, typeof(int), typeof(int), typeof(string) }); + if (ctor == null) return; + object newSize = ctor.Invoke(new object[] { fixedRes, width, height, width + "x" + height }); + + MethodInfo addCustom = group.GetType().GetMethod("AddCustomSize"); + if (addCustom == null) return; + addCustom.Invoke(group, new object[] { newSize }); + + custom = (int)getCustomCount.Invoke(group, null); + foundIndex = builtin + (custom - 1); + } + + // Select size in GameView + EditorWindow gv = GetMainGameView(); + if (gv == null) return; + + Type gvType = gv.GetType(); + PropertyInfo selectedSizeIndex = gvType.GetProperty("selectedSizeIndex", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (selectedSizeIndex != null) + selectedSizeIndex.SetValue(gv, foundIndex, null); + + gv.Repaint(); + } + + // Hidden MonoBehaviour to run coroutines from the editor tool + private class CaptureHelper : MonoBehaviour { } + } +} From 6a258feb3afab5a28f90aaae8818cabc4216a780 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 20 Feb 2026 11:53:48 +0200 Subject: [PATCH 26/34] Fix FindReferences scripts --- .../Tools/FindWhoReferencesThisGameObject.cs | 64 ++++++++++++++++--- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs b/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs index dd5b2c5..b3f4b15 100644 --- a/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs +++ b/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs @@ -64,7 +64,7 @@ private void OnGUI() private void FindReferences(GameObject target) { - var allObjects = UnityEngine.Object.FindObjectsOfType(true); + var allObjects = Object.FindObjectsByType(findObjectsInactive: FindObjectsInactive.Include, sortMode: FindObjectsSortMode.None); foreach (var mono in allObjects) { @@ -94,20 +94,26 @@ private void FindReferences(GameObject target) } } } + + continue; } - else if (typeof(UnityEngine.Object).IsAssignableFrom(field.FieldType)) + + if (!typeof(UnityEngine.Object).IsAssignableFrom(field.FieldType)) + continue; + + var value = field.GetValue(mono) as UnityEngine.Object; + if (ReferencesTarget(value, target)) { - var value = field.GetValue(mono) as UnityEngine.Object; - if (value == target) + results.Add(new ReferenceResult { - results.Add(new ReferenceResult - { - message = $"{mono.name} ({type.Name}) -> Field '{field.Name}'", - owner = mono.gameObject - }); - } + message = $"{mono.name} ({type.Name}) -> Field '{field.Name}'", + owner = mono.gameObject + }); } } + + // Also scan serialized properties (handles public fields, [SerializeField] private, arrays/lists, etc.) + FindSerializedReferences(mono, target); } if (results.Count == 0) @@ -119,5 +125,43 @@ private void FindReferences(GameObject target) }); } } + + private void FindSerializedReferences(MonoBehaviour mono, GameObject target) + { + var so = new SerializedObject(mono); + var it = so.GetIterator(); + + // enterChildren=true on first call to include all fields + bool enterChildren = true; + while (it.NextVisible(enterChildren)) + { + enterChildren = false; + + if (it.propertyType != SerializedPropertyType.ObjectReference) + continue; + + var obj = it.objectReferenceValue; + if (!ReferencesTarget(obj, target)) + continue; + + results.Add(new ReferenceResult + { + message = $"{mono.name} ({mono.GetType().Name}) -> Serialized '{it.propertyPath}'", + owner = mono.gameObject + }); + } + } + + private static bool ReferencesTarget(UnityEngine.Object value, GameObject target) + { + if (value == null || target == null) return false; + + if (value == target) return true; + + // most common case: field is Transform/Component referencing the target GO + if (value is Component c && c.gameObject == target) return true; + + return false; + } } } From 933c6e63a4d87c6a0391d841098b5d346b4a7f85 Mon Sep 17 00:00:00 2001 From: mika Date: Sun, 22 Mar 2026 20:54:38 +0200 Subject: [PATCH 27/34] Add InspectorFilter for filtering component fields This script provides a filtering mechanism for Unity's inspector, allowing users to filter fields of components based on a string input. It includes UI elements for inputting filters and highlights matched fields. --- .../Scripts/Editor/Tools/InspectorFilter.cs | 289 ++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/InspectorFilter.cs diff --git a/Assets/Scripts/Editor/Tools/InspectorFilter.cs b/Assets/Scripts/Editor/Tools/InspectorFilter.cs new file mode 100644 index 0000000..ab37407 --- /dev/null +++ b/Assets/Scripts/Editor/Tools/InspectorFilter.cs @@ -0,0 +1,289 @@ +// filters the fields of all components of a GameObject based on a user-provided string +// matching against both field names and types, with a UI to input the filter and visual highlights for matched fields. + +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEngine; + +namespace UnityLibrary.EditorTools +{ + [InitializeOnLoad] + public static class InspectorFilter + { + private static readonly Dictionary FiltersByGameObjectId = new Dictionary(); + private static readonly Dictionary> MatchedFieldsByComponentId = new Dictionary>(); + + static InspectorFilter() + { + Editor.finishedDefaultHeaderGUI += OnFinishedDefaultHeaderGUI; + Selection.selectionChanged += ApplyFilterForCurrentSelection; + Undo.undoRedoPerformed += ApplyFilterForCurrentSelection; + EditorApplication.delayCall += ApplyFilterForCurrentSelection; + } + + internal static bool TryGetFilterForGameObject(GameObject go, out string filter) + { + filter = string.Empty; + if (go == null) + { + return false; + } + + if (!FiltersByGameObjectId.TryGetValue(go.GetInstanceID(), out string value) || string.IsNullOrWhiteSpace(value)) + { + return false; + } + + filter = value.Trim(); + return filter.Length > 0; + } + + internal static bool IsPropertyMatch(SerializedProperty property, string filter) + { + if (property == null || string.IsNullOrWhiteSpace(filter)) + { + return false; + } + + if (property.propertyPath == "m_Script") + { + return false; + } + + return property.name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0 + || property.displayName.IndexOf(filter, StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static void OnFinishedDefaultHeaderGUI(Editor editor) + { + if (editor.target is GameObject go) + { + DrawGameObjectFilterUI(go); + return; + } + + if (!(editor.target is Component component)) + { + return; + } + + int gameObjectId = component.gameObject.GetInstanceID(); + if (!FiltersByGameObjectId.TryGetValue(gameObjectId, out string filter) || string.IsNullOrWhiteSpace(filter)) + { + return; + } + + if (!MatchedFieldsByComponentId.TryGetValue(component.GetInstanceID(), out List matchedFields) || matchedFields.Count == 0) + { + return; + } + + Color old = GUI.color; + GUI.color = new Color(1f, 0.95f, 0.55f, 1f); + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + GUI.color = old; + EditorGUILayout.LabelField("Matched fields: " + string.Join(", ", matchedFields), EditorStyles.miniLabel); + EditorGUILayout.EndVertical(); + } + + private static void DrawGameObjectFilterUI(GameObject go) + { + int id = go.GetInstanceID(); + FiltersByGameObjectId.TryGetValue(id, out string currentFilter); + currentFilter ??= string.Empty; + + EditorGUILayout.BeginVertical(EditorStyles.helpBox); + + const string filterControlName = "InspectorFilter_FilterField"; + EditorGUILayout.BeginHorizontal(); + EditorGUI.BeginChangeCheck(); + GUI.SetNextControlName(filterControlName); + string newFilter = EditorGUILayout.TextField("Filter", currentFilter); + bool clearClicked = GUILayout.Button("x", EditorStyles.miniButton, GUILayout.Width(20f)); + bool changed = EditorGUI.EndChangeCheck(); + EditorGUILayout.EndHorizontal(); + + Event e = Event.current; + bool escapePressed = e.type == EventType.KeyDown + && e.keyCode == KeyCode.Escape + && GUI.GetNameOfFocusedControl() == filterControlName; + + if (clearClicked || escapePressed) + { + newFilter = string.Empty; + changed = true; + GUI.FocusControl(null); + if (escapePressed) + { + e.Use(); + } + } + + if (changed) + { + if (string.IsNullOrWhiteSpace(newFilter)) + { + FiltersByGameObjectId.Remove(id); + newFilter = string.Empty; + } + else + { + FiltersByGameObjectId[id] = newFilter; + } + + ApplyFilter(go, newFilter); + ActiveEditorTracker.sharedTracker.ForceRebuild(); + } + else + { + ApplyFilter(go, currentFilter); + } + + EditorGUILayout.EndVertical(); + } + + private static void ApplyFilterForCurrentSelection() + { + if (!(Selection.activeGameObject is GameObject go)) + { + return; + } + + int id = go.GetInstanceID(); + FiltersByGameObjectId.TryGetValue(id, out string filter); + ApplyFilter(go, filter); + ActiveEditorTracker.sharedTracker.ForceRebuild(); + } + + private static void ApplyFilter(GameObject go, string filter) + { + ActiveEditorTracker tracker = ActiveEditorTracker.sharedTracker; + Editor[] editors = tracker.activeEditors; + if (editors == null || editors.Length == 0) + { + return; + } + + bool hasFilter = !string.IsNullOrWhiteSpace(filter); + string normalizedFilter = hasFilter ? filter.Trim() : string.Empty; + + for (int i = 0; i < editors.Length; i++) + { + UnityEngine.Object target = editors[i].target; + if (!(target is Component component) || component.gameObject != go) + { + tracker.SetVisible(i, 1); + continue; + } + + int componentId = component.GetInstanceID(); + + if (!hasFilter) + { + MatchedFieldsByComponentId.Remove(componentId); + tracker.SetVisible(i, 1); + continue; + } + + bool typeMatch = component.GetType().Name.IndexOf(normalizedFilter, StringComparison.OrdinalIgnoreCase) >= 0; + List fieldMatches = GetMatchingSerializedFields(editors[i], normalizedFilter); + bool fieldMatch = fieldMatches.Count > 0; + + if (fieldMatch) + { + MatchedFieldsByComponentId[componentId] = fieldMatches; + } + else + { + MatchedFieldsByComponentId.Remove(componentId); + } + + tracker.SetVisible(i, (typeMatch || fieldMatch) ? 1 : 0); + } + } + + private static List GetMatchingSerializedFields(Editor editor, string filter) + { + List matches = new List(); + + SerializedObject serializedObject = editor.serializedObject; + if (serializedObject == null) + { + return matches; + } + + SerializedProperty iterator = serializedObject.GetIterator(); + bool enterChildren = true; + + while (iterator.NextVisible(enterChildren)) + { + enterChildren = false; + + if (!IsPropertyMatch(iterator, filter)) + { + continue; + } + + string label = iterator.displayName; + if (!matches.Contains(label)) + { + matches.Add(label); + } + } + + return matches; + } + } + + [CustomEditor(typeof(Component), true, isFallback = true)] + [CanEditMultipleObjects] + public class InspectorFilterComponentEditor : Editor + { + private static readonly Color HighlightColor = new Color(0.5058824f, 0.7058824f, 1f, 1f); + + public override void OnInspectorGUI() + { + Component component = target as Component; + if (component == null || !InspectorFilter.TryGetFilterForGameObject(component.gameObject, out string filter)) + { + DrawDefaultInspector(); + return; + } + + serializedObject.Update(); + + SerializedProperty property = serializedObject.GetIterator(); + bool enterChildren = true; + + while (property.NextVisible(enterChildren)) + { + enterChildren = false; + + using (new EditorGUI.DisabledScope(property.propertyPath == "m_Script")) + { + float height = EditorGUI.GetPropertyHeight(property, true); + Rect rect = EditorGUILayout.GetControlRect(true, height); + + if (InspectorFilter.IsPropertyMatch(property, filter)) + { + DrawBorder(rect, HighlightColor, 1f); + } + + EditorGUI.PropertyField(rect, property, true); + } + } + + serializedObject.ApplyModifiedProperties(); + } + + private static void DrawBorder(Rect rect, Color color, float thickness) + { + EditorGUI.DrawRect(new Rect(rect.xMin, rect.yMin, rect.width, thickness), color); + EditorGUI.DrawRect(new Rect(rect.xMin, rect.yMax - thickness, rect.width, thickness), color); + EditorGUI.DrawRect(new Rect(rect.xMin, rect.yMin, thickness, rect.height), color); + EditorGUI.DrawRect(new Rect(rect.xMax - thickness, rect.yMin, thickness, rect.height), color); + } + } + +} From eb10673b60e667a8b2091142b0710d1e9c77b8c6 Mon Sep 17 00:00:00 2001 From: mika Date: Wed, 22 Apr 2026 10:30:58 +0300 Subject: [PATCH 28/34] Add PivotAligner tool for model alignment in Unity This script provides a Unity Editor window for aligning 3D models by selecting a custom pivot point. It allows users to rotate and translate the source model around the chosen pivot. --- Assets/Scripts/Editor/Tools/PivotAligner.cs | 615 ++++++++++++++++++++ 1 file changed, 615 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/PivotAligner.cs diff --git a/Assets/Scripts/Editor/Tools/PivotAligner.cs b/Assets/Scripts/Editor/Tools/PivotAligner.cs new file mode 100644 index 0000000..a03ca8e --- /dev/null +++ b/Assets/Scripts/Editor/Tools/PivotAligner.cs @@ -0,0 +1,615 @@ +/// +/// PivotAligner — Unity Editor Window +/// Align two 3D models by picking a custom pivot point (vertex or face center) +/// on the source model, then rotating/translating it around that point. +/// +/// Place this file inside any Editor/ folder in your project. +/// Open via: Tools/UnityLibrary/Pivot Aligner +/// + +using UnityEngine; +using UnityEditor; + +namespace UnityLibrary.Tools +{ + public class PivotAligner : EditorWindow + { + // ────────────────────────────────────────────────────────────────────────── + // Enums & constants + // ────────────────────────────────────────────────────────────────────────── + + enum PickMode { Vertex, Face } + enum ToolState { Idle, Picking, Rotating } + + const string MENU_PATH = "Tools/UnityLibrary/Pivot Aligner"; + const float GIZMO_RADIUS = 0.06f; + const float GIZMO_CROSS = 0.25f; + + // ────────────────────────────────────────────────────────────────────────── + // Inspector / serialised fields + // ────────────────────────────────────────────────────────────────────────── + + [SerializeField] GameObject sourceObject; + [SerializeField] PickMode pickMode = PickMode.Vertex; + + // ── Rotation ────────────────────────────────────────────────────────────── + // Coarse float fields (full range, typed or dragged) + float rotX, rotY, rotZ; + + // Fine-tune additive deltas (±fineTuneRange degrees, applied on top of coarse) + bool showFinetune = false; + float fineTuneRange = 5f; + float fineX, fineY, fineZ; + + // ── Position offset ─────────────────────────────────────────────────────── + // Shifts the model in world space AND moves the pivot so subsequent + // rotations keep the same relative geometry. + bool showPosOffset = false; + float posOffsetX, posOffsetY, posOffsetZ; + float finePosRange = 0.1f; + + // ────────────────────────────────────────────────────────────────────────── + // Runtime state + // ────────────────────────────────────────────────────────────────────────── + + ToolState state = ToolState.Idle; + Vector3 pivotWorld = Vector3.zero; + bool hasPivot = false; + + // Snapshot taken when pivot is confirmed – rotation is always rebuilt from + // this base so there is no floating-point drift on repeated slider edits. + Vector3 basePosition; + Quaternion baseRotation; + + // Highlight during picking + Vector3 highlightPoint = Vector3.zero; + Vector3 highlightNormal = Vector3.up; + bool hasHighlight = false; + + // Scroll view + Vector2 scroll; + + // Style cache + GUIStyle headerStyle, sectionStyle, stateStyle, subLabelStyle; + bool stylesInit; + + // ────────────────────────────────────────────────────────────────────────── + // Window lifecycle + // ────────────────────────────────────────────────────────────────────────── + + [MenuItem(MENU_PATH)] + public static void ShowWindow() + { + var win = GetWindow("Pivot Aligner"); + win.minSize = new Vector2(440, 520); + } + + void OnEnable() + { + SceneView.duringSceneGui += OnSceneGUI; + titleContent = new GUIContent("Pivot Aligner", + EditorGUIUtility.IconContent("d_ToolHandleLocal").image); + } + + void OnDisable() + { + SceneView.duringSceneGui -= OnSceneGUI; + CancelPicking(); + } + + // ────────────────────────────────────────────────────────────────────────── + // Editor Window GUI + // ────────────────────────────────────────────────────────────────────────── + + void OnGUI() + { + InitStyles(); + scroll = EditorGUILayout.BeginScrollView(scroll); + + // ── Header ──────────────────────────────────────────────────────────── + EditorGUILayout.Space(6); + EditorGUILayout.LabelField("PIVOT ALIGNER", headerStyle); + EditorGUILayout.Space(2); + DrawHR(); + + // ── 1 · Source Model ───────────────────────────────────────────────── + EditorGUILayout.Space(6); + EditorGUILayout.LabelField("1 · Source Model", sectionStyle); + EditorGUI.BeginChangeCheck(); + sourceObject = (GameObject)EditorGUILayout.ObjectField( + "Game Object", sourceObject, typeof(GameObject), true); + if (EditorGUI.EndChangeCheck()) ResetTool(); + + if (sourceObject == null) + { + EditorGUILayout.HelpBox("Assign a GameObject to begin.", MessageType.Info); + EditorGUILayout.EndScrollView(); + return; + } + + // ── 2 · Pick Mode ───────────────────────────────────────────────────── + EditorGUILayout.Space(8); + EditorGUILayout.LabelField("2 · Pick Mode", sectionStyle); + EditorGUILayout.BeginHorizontal(); + if (DrawModeButton(" Vertex", pickMode == PickMode.Vertex)) + { pickMode = PickMode.Vertex; hasHighlight = false; } + if (DrawModeButton(" Face", pickMode == PickMode.Face)) + { pickMode = PickMode.Face; hasHighlight = false; } + EditorGUILayout.EndHorizontal(); + + // ── 3 · Pivot Point ─────────────────────────────────────────────────── + EditorGUILayout.Space(8); + EditorGUILayout.LabelField("3 · Select Pivot Point", sectionStyle); + + using (new EditorGUI.DisabledScope(state == ToolState.Rotating)) + { + if (state != ToolState.Picking) + { + if (GUILayout.Button("⊕ Select Target Point in Scene", GUILayout.Height(30))) + BeginPicking(); + } + else + { + Color prev = GUI.backgroundColor; + GUI.backgroundColor = new Color(1f, 0.55f, 0.15f); + if (GUILayout.Button("✕ Cancel Picking", GUILayout.Height(30))) + CancelPicking(); + GUI.backgroundColor = prev; + EditorGUILayout.HelpBox( + $"Hover model → {(pickMode == PickMode.Vertex ? "vertex" : "face center")} highlights. Click to confirm pivot.", + MessageType.None); + } + } + + if (hasPivot) + { + EditorGUILayout.Space(2); + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.LabelField($"Pivot {pivotWorld:F4}", subLabelStyle); + if (GUILayout.Button("Re-pick", GUILayout.Width(56), GUILayout.Height(18))) + BeginPicking(); + } + } + + // ── 4 · Rotation ────────────────────────────────────────────────────── + EditorGUILayout.Space(8); + DrawHR(); + EditorGUILayout.Space(4); + EditorGUILayout.LabelField("4 · Rotation Around Pivot", sectionStyle); + + using (new EditorGUI.DisabledScope(!hasPivot)) + { + // Coarse float input rows + bool dirty = false; + EditorGUI.BeginChangeCheck(); + DrawRotRow("Pitch X", ref rotX); + DrawRotRow("Yaw Y", ref rotY); + DrawRotRow("Roll Z", ref rotZ); + if (EditorGUI.EndChangeCheck()) dirty = true; + + // Fine-tune + EditorGUILayout.Space(4); + using (new EditorGUILayout.HorizontalScope()) + { + showFinetune = EditorGUILayout.ToggleLeft("Fine-tune ±", showFinetune, GUILayout.Width(102)); + using (new EditorGUI.DisabledScope(!showFinetune)) + fineTuneRange = Mathf.Max(0.001f, EditorGUILayout.FloatField(fineTuneRange, GUILayout.Width(52))); + EditorGUILayout.LabelField("°", subLabelStyle, GUILayout.Width(14)); + } + + if (showFinetune) + { + EditorGUI.BeginChangeCheck(); + fineX = DrawFineRotSlider(" Δ Pitch X", fineX, fineTuneRange); + fineY = DrawFineRotSlider(" Δ Yaw Y", fineY, fineTuneRange); + fineZ = DrawFineRotSlider(" Δ Roll Z", fineZ, fineTuneRange); + if (EditorGUI.EndChangeCheck()) dirty = true; + } + + if (dirty && hasPivot) ApplyAll("Pivot Aligner — Rotate"); + + EditorGUILayout.Space(4); + using (new EditorGUILayout.HorizontalScope()) + { + if (GUILayout.Button("Reset All Rotation", GUILayout.Height(24))) + ResetRotation(); + if (showFinetune && GUILayout.Button("Reset Fine", GUILayout.Width(80), GUILayout.Height(24))) + { fineX = fineY = fineZ = 0f; if (hasPivot) ApplyAll("Pivot Aligner — Rotate"); } + } + } + + // ── 5 · Position Offset ─────────────────────────────────────────────── + EditorGUILayout.Space(8); + DrawHR(); + EditorGUILayout.Space(4); + + using (new EditorGUILayout.HorizontalScope()) + { + showPosOffset = EditorGUILayout.Foldout(showPosOffset, "5 · Position Offset", true, sectionStyle); + EditorGUILayout.LabelField("(moves pivot too)", subLabelStyle); + } + + if (showPosOffset) + { + using (new EditorGUI.DisabledScope(!hasPivot)) + { + // Slider range control + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.LabelField("Slider range ±", subLabelStyle, GUILayout.Width(102)); + finePosRange = Mathf.Max(0.0001f, EditorGUILayout.FloatField(finePosRange, GUILayout.Width(52))); + EditorGUILayout.LabelField("m", subLabelStyle, GUILayout.Width(14)); + } + + EditorGUILayout.Space(2); + + EditorGUI.BeginChangeCheck(); + posOffsetX = DrawOffsetSliderRow("Offset X", posOffsetX, finePosRange); + posOffsetY = DrawOffsetSliderRow("Offset Y", posOffsetY, finePosRange); + posOffsetZ = DrawOffsetSliderRow("Offset Z", posOffsetZ, finePosRange); + if (EditorGUI.EndChangeCheck() && hasPivot) + ApplyAll("Pivot Aligner — Move"); + + EditorGUILayout.Space(3); + if (GUILayout.Button("Reset Position Offset", GUILayout.Height(24))) + ResetPositionOffset(); + } + } + + // ── Apply / Revert ──────────────────────────────────────────────────── + EditorGUILayout.Space(8); + DrawHR(); + EditorGUILayout.Space(4); + + using (new EditorGUI.DisabledScope(!hasPivot)) + { + Color prev = GUI.backgroundColor; + GUI.backgroundColor = new Color(0.22f, 0.80f, 0.40f); + if (GUILayout.Button("✔ Apply & Clear Pivot", GUILayout.Height(36))) + Apply(); + GUI.backgroundColor = prev; + } + + if (hasPivot) + { + if (GUILayout.Button("↺ Cancel & Revert to Original", GUILayout.Height(26))) + RevertAndReset(); + } + + // ── Status bar ──────────────────────────────────────────────────────── + EditorGUILayout.Space(4); + string totalRot = hasPivot + ? $"rot({rotX + fineX:F3}, {rotY + fineY:F3}, {rotZ + fineZ:F3})° " + + $"pos offset({posOffsetX:F4}, {posOffsetY:F4}, {posOffsetZ:F4}) m" + : ""; + string stateLabel = state switch + { + ToolState.Picking => "● PICKING", + ToolState.Rotating => $"● ROTATING {totalRot}", + _ => "○ idle" + }; + EditorGUILayout.LabelField(stateLabel, stateStyle); + EditorGUILayout.Space(4); + EditorGUILayout.EndScrollView(); + + SceneView.RepaintAll(); + } + + // ────────────────────────────────────────────────────────────────────────── + // Row helpers + // ────────────────────────────────────────────────────────────────────────── + + /// Radio-style button: highlighted when active, always clickable, returns true on click. + bool DrawModeButton(string label, bool active) + { + Color prev = GUI.backgroundColor; + if (active) GUI.backgroundColor = new Color(0.3f, 0.65f, 1f); + bool clicked = GUILayout.Button(label, GUILayout.Height(26)); + GUI.backgroundColor = prev; + return clicked; + } + + /// Coarse rotation row: label | float field | quick-snap buttons + void DrawRotRow(string label, ref float value) + { + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.LabelField(label, GUILayout.Width(72)); + value = EditorGUILayout.FloatField(value, GUILayout.Width(74)); + if (GUILayout.Button("-90", GUILayout.Width(36), GUILayout.Height(18))) value -= 90f; + if (GUILayout.Button("-45", GUILayout.Width(36), GUILayout.Height(18))) value -= 45f; + if (GUILayout.Button("0", GUILayout.Width(28), GUILayout.Height(18))) value = 0f; + if (GUILayout.Button("+45", GUILayout.Width(36), GUILayout.Height(18))) value += 45f; + if (GUILayout.Button("+90", GUILayout.Width(36), GUILayout.Height(18))) value += 90f; + } + } + + /// Fine rotation slider: returns new value. + float DrawFineRotSlider(string label, float value, float range) + { + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.LabelField(label, subLabelStyle, GUILayout.Width(76)); + value = GUILayout.HorizontalSlider(value, -range, range); + value = EditorGUILayout.FloatField(value, GUILayout.Width(64)); + EditorGUILayout.LabelField("°", subLabelStyle, GUILayout.Width(14)); + } + return value; + } + + /// Position offset row: slider + float field + zero button. Direct value, no delta accumulation. + float DrawOffsetSliderRow(string label, float value, float range) + { + using (new EditorGUILayout.HorizontalScope()) + { + EditorGUILayout.LabelField(label, GUILayout.Width(72)); + value = GUILayout.HorizontalSlider(value, -range, range); + value = EditorGUILayout.FloatField(value, GUILayout.Width(74)); + EditorGUILayout.LabelField("m", subLabelStyle, GUILayout.Width(14)); + if (GUILayout.Button("0", GUILayout.Width(24), GUILayout.Height(18))) value = 0f; + } + return value; + } + + // ────────────────────────────────────────────────────────────────────────── + // Scene GUI + // ────────────────────────────────────────────────────────────────────────── + + void OnSceneGUI(SceneView sv) + { + if (sourceObject == null) return; + if (hasPivot) DrawPivotGizmo(pivotWorld, Color.cyan); + if (state == ToolState.Picking) HandlePicking(sv); + } + + void HandlePicking(SceneView sv) + { + Event e = Event.current; + if (e.type == EventType.Layout) + HandleUtility.AddDefaultControl(GUIUtility.GetControlID(FocusType.Passive)); + + if (e.type == EventType.MouseMove || e.type == EventType.MouseDown) + { + Ray ray = HandleUtility.GUIPointToWorldRay(e.mousePosition); + TryRaycast(ray, out hasHighlight, out highlightPoint, out highlightNormal); + if (hasHighlight) + DrawPivotGizmo(highlightPoint, new Color(1f, 0.8f, 0.1f, 0.9f)); + if (e.type == EventType.MouseDown && e.button == 0 && hasHighlight) + { ConfirmPivot(highlightPoint); e.Use(); } + sv.Repaint(); + } + if (hasHighlight) + DrawPivotGizmo(highlightPoint, new Color(1f, 0.8f, 0.1f, 0.9f)); + } + + // ────────────────────────────────────────────────────────────────────────── + // Raycasting + // ────────────────────────────────────────────────────────────────────────── + + void TryRaycast(Ray ray, out bool hit, out Vector3 point, out Vector3 normal) + { + hit = false; point = Vector3.zero; normal = Vector3.up; + var filters = sourceObject.GetComponentsInChildren(); + float bestDist = float.MaxValue; + + foreach (var mf in filters) + { + if (mf.sharedMesh == null) continue; + Mesh mesh = mf.sharedMesh; + Transform t = mf.transform; + Ray localRay = new Ray( + t.InverseTransformPoint(ray.origin), + t.InverseTransformDirection(ray.direction).normalized); + + Vector3[] verts = mesh.vertices; + int[] tris = mesh.triangles; + Vector3[] normals = mesh.normals; + + for (int i = 0; i < tris.Length; i += 3) + { + Vector3 v0 = verts[tris[i]], v1 = verts[tris[i + 1]], v2 = verts[tris[i + 2]]; + if (!RayTriangle(localRay, v0, v1, v2, out float dist, out float u, out float v)) continue; + if (dist < 0 || dist >= bestDist) continue; + bestDist = dist; hit = true; + + if (pickMode == PickMode.Vertex) + { + float w = 1f - u - v; + int vi = FindNearestVertex(u, v, w); + point = t.TransformPoint(vi == 0 ? v0 : vi == 1 ? v1 : v2); + } + else point = t.TransformPoint((v0 + v1 + v2) / 3f); + + Vector3 n0 = normals.Length > tris[i] ? normals[tris[i]] : Vector3.up; + Vector3 n1 = normals.Length > tris[i + 1] ? normals[tris[i + 1]] : Vector3.up; + Vector3 n2 = normals.Length > tris[i + 2] ? normals[tris[i + 2]] : Vector3.up; + normal = t.TransformDirection((n0 * (1 - u - v) + n1 * u + n2 * v).normalized); + } + } + } + + static bool RayTriangle(Ray ray, Vector3 v0, Vector3 v1, Vector3 v2, + out float dist, out float u, out float v) + { + dist = u = v = 0; + Vector3 e1 = v1 - v0, e2 = v2 - v0, h = Vector3.Cross(ray.direction, e2); + float det = Vector3.Dot(e1, h); + if (Mathf.Abs(det) < 1e-6f) return false; + float f = 1f / det; + Vector3 s = ray.origin - v0; + u = f * Vector3.Dot(s, h); + if (u < 0 || u > 1) return false; + Vector3 q = Vector3.Cross(s, e1); + v = f * Vector3.Dot(ray.direction, q); + if (v < 0 || u + v > 1) return false; + dist = f * Vector3.Dot(e2, q); + return dist > 1e-5f; + } + + static int FindNearestVertex(float u, float v, float w) + { + if (w >= u && w >= v) return 0; + if (u >= w && u >= v) return 1; + return 2; + } + + // ────────────────────────────────────────────────────────────────────────── + // Gizmo drawing + // ────────────────────────────────────────────────────────────────────────── + + void DrawPivotGizmo(Vector3 pos, Color color) + { + Handles.color = color; + Handles.SphereHandleCap(0, pos, Quaternion.identity, + GIZMO_RADIUS * HandleUtility.GetHandleSize(pos), EventType.Repaint); + float sz = GIZMO_CROSS * HandleUtility.GetHandleSize(pos); + Handles.DrawLine(pos - Vector3.right * sz, pos + Vector3.right * sz); + Handles.DrawLine(pos - Vector3.up * sz, pos + Vector3.up * sz); + Handles.DrawLine(pos - Vector3.forward * sz, pos + Vector3.forward * sz); + GUIStyle s = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = color } }; + Handles.Label(pos + Vector3.up * sz * 1.5f, + (hasPivot && pos == pivotWorld) ? "PIVOT" : "○", s); + } + + // ────────────────────────────────────────────────────────────────────────── + // State transitions + // ────────────────────────────────────────────────────────────────────────── + + void BeginPicking() + { + if (sourceObject == null) return; + state = ToolState.Picking; hasHighlight = false; + SceneView.RepaintAll(); + } + + void CancelPicking() + { + if (state == ToolState.Picking) + state = hasPivot ? ToolState.Rotating : ToolState.Idle; + hasHighlight = false; SceneView.RepaintAll(); + } + + void ConfirmPivot(Vector3 worldPoint) + { + if (hasPivot && state == ToolState.Rotating) RevertTransform(); + pivotWorld = worldPoint; + hasPivot = true; + state = ToolState.Rotating; + hasHighlight = false; + basePosition = sourceObject.transform.position; + baseRotation = sourceObject.transform.rotation; + rotX = rotY = rotZ = 0f; + fineX = fineY = fineZ = 0f; + posOffsetX = posOffsetY = posOffsetZ = 0f; + Repaint(); SceneView.RepaintAll(); + } + + // ────────────────────────────────────────────────────────────────────────── + // Transform computation — single source of truth + // ────────────────────────────────────────────────────────────────────────── + + // Each continuous drag (slider or float field) collapses into a single undo + // step by using the same group name while the control is hot, then + // incrementing undoGroupIndex when the user releases (EndChangeCheck fires + // but the control is no longer hot on the next frame). + int undoGroupIndex = 0; + int lastHotControl = 0; + string lastUndoLabel = ""; + + void ApplyAll(string undoLabel = "Pivot Aligner") + { + if (sourceObject == null || !hasPivot) return; + + // Start a new undo group whenever the active control changes or the + // label changes (e.g. switching from Rotate to Move). + int hot = GUIUtility.hotControl; + if (hot != lastHotControl || undoLabel != lastUndoLabel) + { + undoGroupIndex++; + lastHotControl = hot; + lastUndoLabel = undoLabel; + } + + Undo.RecordObject(sourceObject.transform, undoLabel); + + Quaternion delta = Quaternion.Euler(rotX + fineX, rotY + fineY, rotZ + fineZ); + Vector3 posOff = new Vector3(posOffsetX, posOffsetY, posOffsetZ); + + sourceObject.transform.position = pivotWorld + delta * (basePosition - pivotWorld) + posOff; + sourceObject.transform.rotation = delta * baseRotation; + + // Collapse all RecordObject calls for this drag into one undo step + Undo.CollapseUndoOperations(Undo.GetCurrentGroup() - undoGroupIndex + 1); + } + + void ResetRotation() + { + rotX = rotY = rotZ = fineX = fineY = fineZ = 0f; + ApplyAll("Pivot Aligner — Reset Rotation"); + } + + void ResetPositionOffset() + { + posOffsetX = posOffsetY = posOffsetZ = 0f; + ApplyAll("Pivot Aligner — Reset Offset"); + } + + void Apply() + { + if (sourceObject == null) return; + Undo.SetCurrentGroupName("Pivot Aligner Apply"); + Undo.CollapseUndoOperations(Undo.GetCurrentGroup()); + ResetTool(); + } + + void RevertAndReset() { RevertTransform(); ResetTool(); } + + void RevertTransform() + { + if (sourceObject == null || !hasPivot) return; + Undo.RecordObject(sourceObject.transform, "Pivot Aligner Revert"); + sourceObject.transform.position = basePosition; + sourceObject.transform.rotation = baseRotation; + } + + void ResetTool() + { + state = ToolState.Idle; hasPivot = hasHighlight = false; + rotX = rotY = rotZ = fineX = fineY = fineZ = 0f; + posOffsetX = posOffsetY = posOffsetZ = 0f; + SceneView.RepaintAll(); Repaint(); + } + + // ────────────────────────────────────────────────────────────────────────── + // Styles & layout helpers + // ────────────────────────────────────────────────────────────────────────── + + void InitStyles() + { + if (stylesInit) return; + stylesInit = true; + headerStyle = new GUIStyle(EditorStyles.boldLabel) + { + fontSize = 13, + alignment = TextAnchor.MiddleCenter, + normal = { textColor = new Color(0.65f, 0.88f, 1f) } + }; + sectionStyle = new GUIStyle(EditorStyles.boldLabel) + { normal = { textColor = new Color(0.85f, 0.85f, 0.85f) } }; + stateStyle = new GUIStyle(EditorStyles.miniLabel) + { + alignment = TextAnchor.MiddleRight, + normal = { textColor = new Color(0.40f, 0.75f, 0.50f) } + }; + subLabelStyle = new GUIStyle(EditorStyles.miniLabel) + { normal = { textColor = new Color(0.55f, 0.55f, 0.55f) } }; + } + + void DrawHR() + { + Rect r = EditorGUILayout.GetControlRect(false, 1); + EditorGUI.DrawRect(r, new Color(0.35f, 0.35f, 0.35f, 0.6f)); + } + } +} From 895372d41eeb615278d3c1c2add9b4cd08e2a6a4 Mon Sep 17 00:00:00 2001 From: mika Date: Mon, 11 May 2026 09:26:18 +0300 Subject: [PATCH 29/34] Add UVChannelDebug shader for UV data visualization This shader provides a debug view of UV channels, allowing visualization of UV data across multiple channels with customizable colors and toggles for each channel. --- Assets/Shaders/3D/Debug/UVChannelDebug.shader | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 Assets/Shaders/3D/Debug/UVChannelDebug.shader diff --git a/Assets/Shaders/3D/Debug/UVChannelDebug.shader b/Assets/Shaders/3D/Debug/UVChannelDebug.shader new file mode 100644 index 0000000..b0b2c86 --- /dev/null +++ b/Assets/Shaders/3D/Debug/UVChannelDebug.shader @@ -0,0 +1,150 @@ +// debug view what channels contain UV data + +Shader "UnityLibrary/Debug/UVChannelDebug" +{ + Properties + { + _MainTex ("Texture", 2D) = "white" {} + _ColorUV0 ("UV0 Color", Color) = (1,0,0,1) + _ColorUV1 ("UV1 Color", Color) = (0,1,0,1) + _ColorUV2 ("UV2 Color", Color) = (0,0,1,1) + _ColorUV3 ("UV3 Color", Color) = (1,1,0,1) + _ColorUV4 ("UV4 Color", Color) = (1,0,1,1) + _ColorUV5 ("UV5 Color", Color) = (0,1,1,1) + _ColorUV6 ("UV6 Color", Color) = (1,1,1,1) + _ColorUV7 ("UV7 Color", Color) = (0,0,0,1) + + [Toggle] _EnableUV0 ("Enable UV0", Float) = 1 + [Toggle] _EnableUV1 ("Enable UV1", Float) = 1 + [Toggle] _EnableUV2 ("Enable UV2", Float) = 1 + [Toggle] _EnableUV3 ("Enable UV3", Float) = 1 + [Toggle] _EnableUV4 ("Enable UV4", Float) = 1 + [Toggle] _EnableUV5 ("Enable UV5", Float) = 1 + [Toggle] _EnableUV6 ("Enable UV6", Float) = 1 + [Toggle] _EnableUV7 ("Enable UV7", Float) = 1 + } + SubShader + { + Tags { "RenderType"="Opaque" } + LOD 100 + + Pass + { + CGPROGRAM + #pragma vertex vert + #pragma fragment frag + + #include "UnityCG.cginc" + + struct appdata + { + float4 vertex : POSITION; + float2 uv0 : TEXCOORD0; + float2 uv1 : TEXCOORD1; + float2 uv2 : TEXCOORD2; + float2 uv3 : TEXCOORD3; + float2 uv4 : TEXCOORD4; + float2 uv5 : TEXCOORD5; + float2 uv6 : TEXCOORD6; + float2 uv7 : TEXCOORD7; + }; + + struct v2f + { + float2 uv0 : TEXCOORD0; + float2 uv1 : TEXCOORD1; + float2 uv2 : TEXCOORD2; + float2 uv3 : TEXCOORD3; + float2 uv4 : TEXCOORD4; + float2 uv5 : TEXCOORD5; + float2 uv6 : TEXCOORD6; + float2 uv7 : TEXCOORD7; + float4 vertex : SV_POSITION; + }; + + sampler2D _MainTex; + float4 _MainTex_ST; + float4 _ColorUV0; + float4 _ColorUV1; + float4 _ColorUV2; + float4 _ColorUV3; + float4 _ColorUV4; + float4 _ColorUV5; + float4 _ColorUV6; + float4 _ColorUV7; + + float _EnableUV0; + float _EnableUV1; + float _EnableUV2; + float _EnableUV3; + float _EnableUV4; + float _EnableUV5; + float _EnableUV6; + float _EnableUV7; + + fixed UVChecker(float2 uv) + { + float2 cell = floor(uv * 8.0); + return fmod(cell.x + cell.y, 2.0); + } + + fixed HasUVData(float2 uv) + { + float variation = fwidth(uv.x) + fwidth(uv.y); + float magnitude = abs(uv.x) + abs(uv.y); + return step(1e-5, max(variation, magnitude)); + } + + v2f vert (appdata v) + { + v2f o; + o.vertex = UnityObjectToClipPos(v.vertex); + + o.uv0 = TRANSFORM_TEX(v.uv0, _MainTex); + o.uv1 = TRANSFORM_TEX(v.uv1, _MainTex); + o.uv2 = TRANSFORM_TEX(v.uv2, _MainTex); + o.uv3 = TRANSFORM_TEX(v.uv3, _MainTex); + o.uv4 = TRANSFORM_TEX(v.uv4, _MainTex); + o.uv5 = TRANSFORM_TEX(v.uv5, _MainTex); + o.uv6 = TRANSFORM_TEX(v.uv6, _MainTex); + o.uv7 = TRANSFORM_TEX(v.uv7, _MainTex); + + return o; + } + + fixed4 frag (v2f i) : SV_Target + { + fixed4 col0 = tex2D(_MainTex, i.uv0) * _ColorUV0; + fixed4 col1 = tex2D(_MainTex, i.uv1) * _ColorUV1; + fixed4 col2 = tex2D(_MainTex, i.uv2) * _ColorUV2; + fixed4 col3 = tex2D(_MainTex, i.uv3) * _ColorUV3; + fixed4 col4 = tex2D(_MainTex, i.uv4) * _ColorUV4; + fixed4 col5 = tex2D(_MainTex, i.uv5) * _ColorUV5; + fixed4 col6 = tex2D(_MainTex, i.uv6) * _ColorUV6; + fixed4 col7 = tex2D(_MainTex, i.uv7) * _ColorUV7; + + float e0 = _EnableUV0 * HasUVData(i.uv0); + float e1 = _EnableUV1 * HasUVData(i.uv1); + float e2 = _EnableUV2 * HasUVData(i.uv2); + float e3 = _EnableUV3 * HasUVData(i.uv3); + float e4 = _EnableUV4 * HasUVData(i.uv4); + float e5 = _EnableUV5 * HasUVData(i.uv5); + float e6 = _EnableUV6 * HasUVData(i.uv6); + float e7 = _EnableUV7 * HasUVData(i.uv7); + + fixed4 col = + col0 * e0 + + col1 * e1 + + col2 * e2 + + col3 * e3 + + col4 * e4 + + col5 * e5 + + col6 * e6 + + col7 * e7; + + return saturate(col); + } + ENDCG + } + } +} From 66011d1122c178fbe461e4056700fbf017513f21 Mon Sep 17 00:00:00 2001 From: mika Date: Sat, 23 May 2026 14:15:05 +0300 Subject: [PATCH 30/34] Add custom favorites buttons to Package Manager This script adds custom buttons to the Package Manager's left panel, allowing users to manage their favorite packages directly from the editor. It includes functionality for adding packages and injecting UI elements into the Package Manager window. --- .../PackageManager/PackageManagerFavorites.cs | 326 ++++++++++++++++++ 1 file changed, 326 insertions(+) create mode 100644 Assets/Scripts/Editor/PackageManager/PackageManagerFavorites.cs diff --git a/Assets/Scripts/Editor/PackageManager/PackageManagerFavorites.cs b/Assets/Scripts/Editor/PackageManager/PackageManagerFavorites.cs new file mode 100644 index 0000000..1ccc0f1 --- /dev/null +++ b/Assets/Scripts/Editor/PackageManager/PackageManagerFavorites.cs @@ -0,0 +1,326 @@ +// adds custom buttons into PackageManager left panel (tested in 6.5) +// edit your own favorites in the code + +#if UNITY_EDITOR +using System; +using System.Collections.Generic; +using UnityEditor; +using UnityEditor.Compilation; +using UnityEditor.PackageManager; +using UnityEditor.PackageManager.Requests; +using UnityEngine; +using UnityEngine.UIElements; + +namespace UnityLibrary.Editor.Tools +{ + + [InitializeOnLoad] + public static class PackageManagerFavorites + { + private const string InjectedElementName = "kelobyte-package-manager-dummy-panel"; + private static double nextScanTime; + private static AddRequest addRequest; + private static string pendingPackageName; + + static PackageManagerFavorites() + { + EditorApplication.update += OnEditorUpdate; + } + + [MenuItem("Window/Package Management/Open Package Manager With Dummy Button")] + private static void OpenPackageManagerAndInject() + { + Type windowType = GetPackageManagerWindowType(); + + if (windowType == null) + { + Debug.LogWarning("Package Manager window type was not found."); + return; + } + + EditorWindow window = EditorWindow.GetWindow(windowType); + window.Show(); + + EditorApplication.delayCall += TryInject; + } + + private static void OnEditorUpdate() + { + if (EditorApplication.timeSinceStartup < nextScanTime) + return; + + nextScanTime = EditorApplication.timeSinceStartup + 0.5; + TryInject(); + } + + private static void TryInject() + { + Type windowType = GetPackageManagerWindowType(); + + if (windowType == null) + return; + + UnityEngine.Object[] windows = Resources.FindObjectsOfTypeAll(windowType); + + foreach (UnityEngine.Object obj in windows) + { + EditorWindow window = obj as EditorWindow; + + if (window == null) + continue; + + InjectIntoWindow(window); + } + } + + private static Type GetPackageManagerWindowType() + { + Type type = Type.GetType("UnityEditor.PackageManager.UI.PackageManagerWindow, UnityEditor.PackageManagerUIModule"); + + if (type != null) + return type; + + foreach (System.Reflection.Assembly assembly in AppDomain.CurrentDomain.GetAssemblies()) + { + type = assembly.GetType("UnityEditor.PackageManager.UI.PackageManagerWindow"); + + if (type != null) + return type; + } + + return null; + } + + private static void InjectIntoWindow(EditorWindow window) + { + VisualElement root = window.rootVisualElement; + + if (root == null) + return; + + if (root.Q(InjectedElementName) != null) + return; + + VisualElement insertParent = FindInsertParent(root); + int insertIndex = FindInsertIndex(insertParent); + + if (insertParent == null) + return; + + VisualElement panel = CreateDummyPanel(); + + if (insertIndex >= 0 && insertIndex <= insertParent.childCount) + insertParent.Insert(insertIndex, panel); + else + insertParent.Add(panel); + } + + private static VisualElement CreateDummyPanel() + { + VisualElement wrapper = new VisualElement(); + wrapper.name = InjectedElementName; + + wrapper.style.marginTop = 8; + wrapper.style.marginBottom = 8; + wrapper.style.paddingLeft = 6; + wrapper.style.paddingRight = 6; + + Foldout foldout = new Foldout(); + foldout.text = "Favorites"; + foldout.value = true; + + Button newtonsoftButton = CreatePackageButton( + "Newtonsoft Json", + "com.unity.nuget.newtonsoft-json" + ); + + Button gltfastButton = CreatePackageButton( + "Unity glTFast", + "com.unity.cloud.gltfast" + ); + + Button unityGltfButton = CreatePackageButton( + "Khronos UnityGLTF", + "https://github.com/KhronosGroup/UnityGLTF.git" + ); + + Button myEssentials = CreatePackageButton( + "Essentials", + " https://github.com/unitycoder/UnityEditorEssentials.git" + ); + + foldout.Add(newtonsoftButton); + foldout.Add(gltfastButton); + foldout.Add(unityGltfButton); + foldout.Add(myEssentials); + + wrapper.Add(foldout); + + return wrapper; + } + + private static Button CreatePackageButton(string label, string packageNameOrUrl) + { + Button button = new Button(() => + { + AddPackage(packageNameOrUrl); + }); + + button.text = label; + button.style.marginTop = 4; + button.style.height = 24; + + return button; + } + + private static void AddPackage(string packageNameOrUrl) + { + string packageToAdd = packageNameOrUrl.Trim(); + + if (addRequest != null && !addRequest.IsCompleted) + { + Debug.LogWarning("A package add request is already in progress."); + return; + } + + Debug.Log($"Adding package: {packageToAdd}"); + + pendingPackageName = packageToAdd; + addRequest = Client.Add(packageToAdd); + + EditorApplication.update -= MonitorAddPackageRequest; + EditorApplication.update += MonitorAddPackageRequest; + } + + private static void MonitorAddPackageRequest() + { + if (addRequest == null || !addRequest.IsCompleted) + return; + + EditorApplication.update -= MonitorAddPackageRequest; + + if (addRequest.Status == StatusCode.Success) + { + Debug.Log($"Package added: {pendingPackageName}"); + Client.Resolve(); + AssetDatabase.Refresh(); + CompilationPipeline.RequestScriptCompilation(); + } + else + { + string errorMessage = addRequest.Error != null ? addRequest.Error.message : "Unknown error."; + Debug.LogError($"Failed to add package '{pendingPackageName}': {errorMessage}"); + } + + addRequest = null; + pendingPackageName = null; + } + + private static VisualElement FindInsertParent(VisualElement root) + { + Label servicesLabel = FindLabel(root, "Services"); + + if (servicesLabel != null && servicesLabel.parent != null && servicesLabel.parent.parent != null) + return servicesLabel.parent.parent; + + Label cloudLabel = FindLabel(root, "Cloud"); + + if (cloudLabel != null && cloudLabel.parent != null && cloudLabel.parent.parent != null) + return cloudLabel.parent.parent; + + Label sourcesLabel = FindLabel(root, "Sources"); + + if (sourcesLabel != null && sourcesLabel.parent != null && sourcesLabel.parent.parent != null) + return sourcesLabel.parent.parent; + + return FindLikelyLeftPanel(root); + } + + private static int FindInsertIndex(VisualElement parent) + { + if (parent == null) + return -1; + + for (int i = 0; i < parent.childCount; i++) + { + VisualElement child = parent[i]; + + if (ContainsLabel(child, "Services")) + return i + 1; + } + + for (int i = 0; i < parent.childCount; i++) + { + VisualElement child = parent[i]; + + if (ContainsLabel(child, "Cloud")) + return i + 1; + } + + return -1; + } + + private static VisualElement FindLikelyLeftPanel(VisualElement root) + { + List all = new List(); + Collect(root, all); + + foreach (VisualElement element in all) + { + Rect rect = element.worldBound; + + if (rect.width > 120 && rect.width < 260 && rect.height > 250 && rect.x < 250) + return element; + } + + return null; + } + + private static Label FindLabel(VisualElement root, string text) + { + List all = new List(); + Collect(root, all); + + foreach (VisualElement element in all) + { + Label label = element as Label; + + if (label != null && label.text == text) + return label; + } + + return null; + } + + private static bool ContainsLabel(VisualElement root, string text) + { + if (root == null) + return false; + + Label label = root as Label; + + if (label != null && label.text == text) + return true; + + for (int i = 0; i < root.childCount; i++) + { + if (ContainsLabel(root[i], text)) + return true; + } + + return false; + } + + private static void Collect(VisualElement root, List elements) + { + if (root == null) + return; + + elements.Add(root); + + for (int i = 0; i < root.childCount; i++) + Collect(root[i], elements); + } + } +} +#endif From b4dacb90500646cda882ef9afae00d33a5788843 Mon Sep 17 00:00:00 2001 From: mika Date: Tue, 26 May 2026 10:18:41 +0300 Subject: [PATCH 31/34] Add ToggleEvents script for toggle state events --- Assets/Scripts/UI/ToggleEvents.cs | 44 +++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 Assets/Scripts/UI/ToggleEvents.cs diff --git a/Assets/Scripts/UI/ToggleEvents.cs b/Assets/Scripts/UI/ToggleEvents.cs new file mode 100644 index 0000000..b4f0952 --- /dev/null +++ b/Assets/Scripts/UI/ToggleEvents.cs @@ -0,0 +1,44 @@ +// easy way to get Toggle state as events (compared to the default OnChanged, which triggers on both..) + +using UnityEngine; +using UnityEngine.Events; +using UnityEngine.UI; + +namespace UnityLibrary.UI +{ + [RequireComponent(typeof(Toggle))] + public class ToggleEvents : MonoBehaviour + { + [Header("Events")] + public UnityEvent OnChecked; + public UnityEvent OnUnchecked; + + private Toggle toggle; + + private void Awake() + { + toggle = GetComponent(); + toggle.onValueChanged.AddListener(HandleToggleChanged); + } + + private void OnDestroy() + { + if (toggle != null) + { + toggle.onValueChanged.RemoveListener(HandleToggleChanged); + } + } + + private void HandleToggleChanged(bool isOn) + { + if (isOn) + { + OnChecked?.Invoke(); + } + else + { + OnUnchecked?.Invoke(); + } + } + } +} From 0f4758a2e4678c4ab64eb43768da84a42e46e486 Mon Sep 17 00:00:00 2001 From: mika Date: Sat, 30 May 2026 23:24:30 +0300 Subject: [PATCH 32/34] Create MeshThumbnailGrabberWindow.cs --- .../Tools/MeshThumbnailGrabberWindow.cs | 788 ++++++++++++++++++ 1 file changed, 788 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/MeshThumbnailGrabberWindow.cs diff --git a/Assets/Scripts/Editor/Tools/MeshThumbnailGrabberWindow.cs b/Assets/Scripts/Editor/Tools/MeshThumbnailGrabberWindow.cs new file mode 100644 index 0000000..f12e6bc --- /dev/null +++ b/Assets/Scripts/Editor/Tools/MeshThumbnailGrabberWindow.cs @@ -0,0 +1,788 @@ +// editortool to capture thumbnails of meshes, prefabs, or gameobjects with custom settings and save as PNG + +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEngine; + +namespace UnityLibrary.Editor.Tools +{ + public class MeshThumbnailGrabberWindow : EditorWindow + { + private Object sourceObject; + + private PreviewRenderUtility previewUtility; + + private GameObject previewRoot; + private GameObject previewInstance; + + private Texture2D exportPreviewTexture; + + private readonly List drawables = new List(); + + private Vector2 orbit = new Vector2(135f, -20f); + + private float zoom = 1f; + private float fitDistance = 3f; + private float cameraDistanceMultiplier = 1f; + private float lightIntensity = 1.25f; + + private int outputWidth = 512; + private int outputHeight = 512; + + private Color backgroundColor = new Color(0f, 0f, 0f, 0f); + private Color ambientColor = new Color(0.35f, 0.35f, 0.35f, 1f); + private Color lightColor = Color.white; + + private bool drawGround = false; + + private struct PreviewDrawable + { + public Mesh Mesh; + public Matrix4x4 Matrix; + public Material[] Materials; + } + + [MenuItem("Tools/Thumbnail Grabber/Mesh Thumbnail Grabber")] + public static void Open() + { + var win = GetWindow("Mesh Thumbnail Grabber"); + win.minSize = new Vector2(500, 680); + } + + private void OnEnable() + { + CreatePreviewUtility(); + } + + private void OnDisable() + { + Cleanup(); + } + + private void CreatePreviewUtility() + { + if (previewUtility != null) + { + previewUtility.Cleanup(); + } + + previewUtility = new PreviewRenderUtility(true); + previewUtility.cameraFieldOfView = 30f; + + ApplyLightSettings(); + } + + private void Cleanup() + { + DestroyPreviewObjects(); + + if (previewUtility != null) + { + previewUtility.Cleanup(); + previewUtility = null; + } + + ClearExportPreview(); + } + + private void DestroyPreviewObjects() + { + drawables.Clear(); + + if (previewRoot != null) + { + DestroyImmediate(previewRoot); + previewRoot = null; + previewInstance = null; + } + } + + private void ClearExportPreview() + { + if (exportPreviewTexture != null) + { + DestroyImmediate(exportPreviewTexture); + exportPreviewTexture = null; + } + } + + private void OnGUI() + { + EditorGUILayout.Space(); + + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + EditorGUI.BeginChangeCheck(); + + sourceObject = EditorGUILayout.ObjectField( + "Source", + sourceObject, + typeof(Object), + false + ); + + if (EditorGUI.EndChangeCheck()) + { + ClearExportPreview(); + CreatePreviewInstance(); + FitToView(); + Repaint(); + } + } + + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + EditorGUI.BeginChangeCheck(); + + outputWidth = Mathf.Clamp(EditorGUILayout.IntField("Output Width", outputWidth), 16, 8192); + outputHeight = Mathf.Clamp(EditorGUILayout.IntField("Output Height", outputHeight), 16, 8192); + + if (EditorGUI.EndChangeCheck()) + { + ClearExportPreview(); + } + + zoom = EditorGUILayout.Slider("Zoom", zoom, 0.05f, 100f); + cameraDistanceMultiplier = EditorGUILayout.Slider("Distance Multiplier", cameraDistanceMultiplier, 0.1f, 5f); + + EditorGUI.BeginChangeCheck(); + + lightIntensity = EditorGUILayout.Slider("Light Intensity", lightIntensity, 0f, 5f); + ambientColor = EditorGUILayout.ColorField("Ambient Color", ambientColor); + lightColor = EditorGUILayout.ColorField("Light Color", lightColor); + drawGround = EditorGUILayout.Toggle("Draw Ground", drawGround); + + if (EditorGUI.EndChangeCheck()) + { + ApplyLightSettings(); + ClearExportPreview(); + + CenterObjectToOrigin(); + CacheDrawables(); + Repaint(); + } + } + + Rect previewRect = GUILayoutUtility.GetRect( + 10, + 10000, + 10, + 10000, + GUILayout.ExpandWidth(true), + GUILayout.ExpandHeight(true) + ); + + DrawInteractivePreview(previewRect); + + using (new EditorGUILayout.HorizontalScope()) + { + GUI.enabled = sourceObject != null; + + if (GUILayout.Button("Fit To View", GUILayout.Height(28))) + { + FitToView(); + ClearExportPreview(); + Repaint(); + } + + if (GUILayout.Button("Reset Rotation", GUILayout.Height(28))) + { + orbit = new Vector2(135f, -20f); + ClearExportPreview(); + Repaint(); + } + + if (GUILayout.Button("Preview Export Size", GUILayout.Height(28))) + { + UpdateExportPreview(); + } + + if (GUILayout.Button("Save PNG", GUILayout.Height(28))) + { + SavePng(); + } + + GUI.enabled = true; + } + + DrawExportPreviewPanel(); + } + + private void ApplyLightSettings() + { + if (previewUtility == null) + { + return; + } + + previewUtility.lights[0].intensity = lightIntensity; + previewUtility.lights[0].color = lightColor; + previewUtility.lights[0].transform.rotation = Quaternion.Euler(40f, 40f, 0f); + + previewUtility.lights[1].intensity = lightIntensity * 0.35f; + previewUtility.lights[1].color = lightColor; + previewUtility.lights[1].transform.rotation = Quaternion.Euler(340f, 218f, 177f); + } + + private void DrawInteractivePreview(Rect rect) + { + if (previewUtility == null) + { + CreatePreviewUtility(); + } + + if (sourceObject == null) + { + EditorGUI.DrawRect(rect, new Color(0.15f, 0.15f, 0.15f, 1f)); + GUI.Label(rect, "Assign a prefab, model, GameObject, or Mesh", EditorStyles.centeredGreyMiniLabel); + return; + } + + if (previewInstance == null) + { + CreatePreviewInstance(); + FitToView(); + } + + HandlePreviewInput(rect); + + Texture texture = RenderPreview(rect.width, rect.height); + + if (texture != null) + { + GUI.DrawTexture(rect, texture, ScaleMode.StretchToFill, true); + } + } + + private void HandlePreviewInput(Rect rect) + { + Event e = Event.current; + + if (!rect.Contains(e.mousePosition)) + { + return; + } + + if (e.type == EventType.MouseDrag && e.button == 0) + { + orbit += new Vector2(e.delta.x, -e.delta.y); + orbit.y = Mathf.Clamp(orbit.y, -89f, 89f); + + ClearExportPreview(); + + e.Use(); + Repaint(); + } + + if (e.type == EventType.ScrollWheel) + { + zoom *= 1f - e.delta.y * 0.08f; + zoom = Mathf.Clamp(zoom, 0.05f, 100f); + + ClearExportPreview(); + + e.Use(); + Repaint(); + } + } + + private void CreatePreviewInstance() + { + DestroyPreviewObjects(); + + if (sourceObject == null) + { + return; + } + + previewRoot = new GameObject("Thumbnail Preview Root"); + previewRoot.hideFlags = HideFlags.HideAndDontSave; + previewRoot.transform.position = Vector3.zero; + previewRoot.transform.rotation = Quaternion.identity; + previewRoot.transform.localScale = Vector3.one; + + GameObject sourceGameObject = sourceObject as GameObject; + Mesh sourceMesh = sourceObject as Mesh; + + if (sourceGameObject != null) + { + previewInstance = Instantiate(sourceGameObject, previewRoot.transform); + previewInstance.hideFlags = HideFlags.HideAndDontSave; + } + else if (sourceMesh != null) + { + previewInstance = new GameObject("Mesh Preview Instance"); + previewInstance.hideFlags = HideFlags.HideAndDontSave; + previewInstance.transform.SetParent(previewRoot.transform, false); + + MeshFilter meshFilter = previewInstance.AddComponent(); + meshFilter.sharedMesh = sourceMesh; + + MeshRenderer meshRenderer = previewInstance.AddComponent(); + meshRenderer.sharedMaterial = GetDefaultMaterial(); + } + else + { + string path = AssetDatabase.GetAssetPath(sourceObject); + GameObject loaded = AssetDatabase.LoadAssetAtPath(path); + + if (loaded != null) + { + previewInstance = Instantiate(loaded, previewRoot.transform); + previewInstance.hideFlags = HideFlags.HideAndDontSave; + } + } + + if (previewInstance == null) + { + DestroyPreviewObjects(); + return; + } + + DisableSceneOnlyComponents(previewInstance); + + previewInstance.transform.localPosition = Vector3.zero; + previewInstance.transform.localRotation = Quaternion.identity; + previewInstance.transform.localScale = Vector3.one; + + CenterObjectToOrigin(); + CacheDrawables(); + } + + private void CenterObjectToOrigin() + { + if (previewRoot == null || previewInstance == null) + { + return; + } + + previewRoot.transform.position = Vector3.zero; + previewRoot.transform.rotation = Quaternion.identity; + previewRoot.transform.localScale = Vector3.one; + + previewInstance.transform.localPosition = Vector3.zero; + + Bounds bounds = GetWorldBounds(previewRoot); + Vector3 centerOffset = bounds.center; + + previewInstance.transform.position -= centerOffset; + + if (drawGround) + { + Bounds centeredBounds = GetWorldBounds(previewRoot); + previewInstance.transform.position -= new Vector3(0f, centeredBounds.min.y, 0f); + } + } + + private void FitToView() + { + if (previewInstance == null || previewUtility == null) + { + return; + } + + CenterObjectToOrigin(); + CacheDrawables(); + + Bounds bounds = GetWorldBounds(previewRoot); + + float radius = Mathf.Max(bounds.extents.magnitude, 0.001f); + float fov = previewUtility.cameraFieldOfView; + float fovRad = fov * Mathf.Deg2Rad; + + fitDistance = radius / Mathf.Sin(fovRad * 0.5f); + fitDistance *= 1.15f; + + zoom = 1f; + } + + private void CacheDrawables() + { + drawables.Clear(); + + if (previewRoot == null) + { + return; + } + + Renderer[] renderers = previewRoot.GetComponentsInChildren(true); + + foreach (Renderer renderer in renderers) + { + if (renderer == null || !renderer.enabled) + { + continue; + } + + MeshFilter meshFilter = renderer.GetComponent(); + + if (meshFilter != null && meshFilter.sharedMesh != null) + { + drawables.Add(new PreviewDrawable + { + Mesh = meshFilter.sharedMesh, + Matrix = renderer.localToWorldMatrix, + Materials = renderer.sharedMaterials + }); + + continue; + } + + SkinnedMeshRenderer skinned = renderer as SkinnedMeshRenderer; + + if (skinned != null && skinned.sharedMesh != null) + { + drawables.Add(new PreviewDrawable + { + Mesh = skinned.sharedMesh, + Matrix = skinned.localToWorldMatrix, + Materials = skinned.sharedMaterials + }); + } + } + } + + private Texture RenderPreview(float width, float height) + { + if (previewUtility == null || previewInstance == null) + { + return null; + } + + Rect rect = new Rect(0f, 0f, Mathf.Max(1f, width), Mathf.Max(1f, height)); + + previewUtility.BeginPreview(rect, GUIStyle.none); + RenderCurrentView(); + return previewUtility.EndPreview(); + } + + private void RenderCurrentView() + { + Camera camera = previewUtility.camera; + + camera.clearFlags = CameraClearFlags.Color; + camera.backgroundColor = backgroundColor; + camera.nearClipPlane = 0.01f; + camera.farClipPlane = 10000f; + camera.fieldOfView = previewUtility.cameraFieldOfView; + camera.allowHDR = false; + camera.allowMSAA = true; + + RenderSettings.ambientLight = ambientColor; + + Quaternion rotation = Quaternion.Euler(-orbit.y, orbit.x, 0f); + + float distance = fitDistance * cameraDistanceMultiplier / Mathf.Max(zoom, 0.001f); + distance = Mathf.Max(distance, 0.01f); + + Vector3 cameraDirection = rotation * Vector3.forward; + + camera.transform.position = -cameraDirection * distance; + camera.transform.rotation = Quaternion.LookRotation(cameraDirection, Vector3.up); + + ApplyLightSettings(); + + DrawCachedRenderers(); + + if (drawGround) + { + DrawGround(); + } + + camera.Render(); + } + + private void DrawCachedRenderers() + { + for (int i = 0; i < drawables.Count; i++) + { + PreviewDrawable drawable = drawables[i]; + + if (drawable.Mesh == null) + { + continue; + } + + DrawMeshWithMaterials(drawable.Mesh, drawable.Matrix, drawable.Materials); + } + } + + private void DrawMeshWithMaterials(Mesh mesh, Matrix4x4 matrix, Material[] materials) + { + if (mesh == null) + { + return; + } + + int subMeshCount = Mathf.Max(1, mesh.subMeshCount); + + for (int i = 0; i < subMeshCount; i++) + { + Material material = GetDefaultMaterial(); + + if (materials != null && i < materials.Length && materials[i] != null) + { + material = materials[i]; + } + + if (material == null) + { + continue; + } + + previewUtility.DrawMesh(mesh, matrix, material, i); + } + } + + private void DrawGround() + { + Mesh planeMesh = Resources.GetBuiltinResource("Plane.fbx"); + + if (planeMesh == null) + { + return; + } + + Bounds bounds = GetWorldBounds(previewRoot); + + float size = Mathf.Max(bounds.size.x, bounds.size.z, 1f) * 1.5f; + + Matrix4x4 matrix = Matrix4x4.TRS( + new Vector3(0f, bounds.min.y, 0f), + Quaternion.identity, + new Vector3(size * 0.1f, 1f, size * 0.1f) + ); + + previewUtility.DrawMesh(planeMesh, matrix, GetDefaultMaterial(), 0); + } + + private Bounds GetWorldBounds(GameObject root) + { + if (root == null) + { + return new Bounds(Vector3.zero, Vector3.one); + } + + Renderer[] renderers = root.GetComponentsInChildren(true); + + bool hasBounds = false; + Bounds bounds = new Bounds(Vector3.zero, Vector3.one); + + foreach (Renderer renderer in renderers) + { + if (renderer == null) + { + continue; + } + + if (!hasBounds) + { + bounds = renderer.bounds; + hasBounds = true; + } + else + { + bounds.Encapsulate(renderer.bounds); + } + } + + if (!hasBounds) + { + return new Bounds(Vector3.zero, Vector3.one); + } + + return bounds; + } + + private static Material GetDefaultMaterial() + { + Material material = AssetDatabase.GetBuiltinExtraResource("Default-Material.mat"); + + if (material != null) + { + return material; + } + + Shader shader = Shader.Find("Standard"); + + if (shader != null) + { + return new Material(shader); + } + + return null; + } + + private static void DisableSceneOnlyComponents(GameObject root) + { + MonoBehaviour[] behaviours = root.GetComponentsInChildren(true); + + foreach (MonoBehaviour behaviour in behaviours) + { + if (behaviour != null) + { + behaviour.enabled = false; + } + } + + Camera[] cameras = root.GetComponentsInChildren(true); + + foreach (Camera camera in cameras) + { + camera.enabled = false; + } + + Light[] lights = root.GetComponentsInChildren(true); + + foreach (Light light in lights) + { + light.enabled = false; + } + } + + private void SavePng() + { + if (sourceObject == null || previewInstance == null) + { + return; + } + + string defaultName = sourceObject.name + "_thumbnail_" + outputWidth + "x" + outputHeight + ".png"; + + string path = EditorUtility.SaveFilePanel( + "Save Thumbnail PNG", + Application.dataPath, + defaultName, + "png" + ); + + if (string.IsNullOrEmpty(path)) + { + return; + } + + Texture2D texture = RenderToTexture2D(outputWidth, outputHeight); + + if (texture == null) + { + Debug.LogError("Failed to render thumbnail."); + return; + } + + byte[] png = texture.EncodeToPNG(); + File.WriteAllBytes(path, png); + + DestroyImmediate(texture); + + AssetDatabase.Refresh(); + + PingSavedAsset(path); + UpdateExportPreview(); + + Debug.Log("Saved thumbnail: " + path); + } + + private Texture2D RenderToTexture2D(int width, int height) + { + if (previewUtility == null || previewInstance == null) + { + return null; + } + + Rect rect = new Rect(0f, 0f, width, height); + + previewUtility.BeginStaticPreview(rect); + RenderCurrentView(); + + Texture2D texture = previewUtility.EndStaticPreview(); + + if (texture == null) + { + return null; + } + + Texture2D copy = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false); + copy.SetPixels(texture.GetPixels()); + copy.Apply(); + + return copy; + } + + private static void PingSavedAsset(string absolutePath) + { + string projectPath = Path.GetFullPath(Application.dataPath + "/..").Replace("\\", "/"); + string fixedPath = Path.GetFullPath(absolutePath).Replace("\\", "/"); + + if (!fixedPath.StartsWith(projectPath)) + { + EditorUtility.RevealInFinder(absolutePath); + return; + } + + string assetPath = fixedPath.Substring(projectPath.Length + 1); + + Object asset = AssetDatabase.LoadAssetAtPath(assetPath); + + if (asset != null) + { + Selection.activeObject = asset; + EditorGUIUtility.PingObject(asset); + } + else + { + EditorUtility.RevealInFinder(absolutePath); + } + } + + private void UpdateExportPreview() + { + if (sourceObject == null || previewInstance == null) + { + return; + } + + ClearExportPreview(); + + exportPreviewTexture = RenderToTexture2D(outputWidth, outputHeight); + Repaint(); + } + + private void DrawExportPreviewPanel() + { + if (exportPreviewTexture == null) + { + return; + } + + EditorGUILayout.Space(); + + using (new EditorGUILayout.VerticalScope(EditorStyles.helpBox)) + { + EditorGUILayout.LabelField( + "Export Preview: " + exportPreviewTexture.width + "x" + exportPreviewTexture.height + " px", + EditorStyles.boldLabel + ); + + Rect rect = GUILayoutUtility.GetRect( + exportPreviewTexture.width, + exportPreviewTexture.height, + GUILayout.Width(exportPreviewTexture.width), + GUILayout.Height(exportPreviewTexture.height) + ); + + EditorGUI.DrawTextureTransparent( + rect, + exportPreviewTexture, + ScaleMode.StretchToFill + ); + + if (GUILayout.Button("Clear Preview")) + { + ClearExportPreview(); + } + } + } + + } // class +} // namespace From 636a217963ad02d12496b7a4390257442ba70953 Mon Sep 17 00:00:00 2001 From: mika Date: Sat, 30 May 2026 23:39:11 +0300 Subject: [PATCH 33/34] Update MeshThumbnailGrabberWindow with new preferences fix sRGB, fix Alpha --- .../Tools/MeshThumbnailGrabberWindow.cs | 124 ++++++++++++++++-- 1 file changed, 111 insertions(+), 13 deletions(-) diff --git a/Assets/Scripts/Editor/Tools/MeshThumbnailGrabberWindow.cs b/Assets/Scripts/Editor/Tools/MeshThumbnailGrabberWindow.cs index f12e6bc..ea28fba 100644 --- a/Assets/Scripts/Editor/Tools/MeshThumbnailGrabberWindow.cs +++ b/Assets/Scripts/Editor/Tools/MeshThumbnailGrabberWindow.cs @@ -43,20 +43,94 @@ private struct PreviewDrawable public Material[] Materials; } - [MenuItem("Tools/Thumbnail Grabber/Mesh Thumbnail Grabber")] + [MenuItem("Tools/UnityLibrary/Mesh Thumbnail Grabber")] public static void Open() { var win = GetWindow("Mesh Thumbnail Grabber"); win.minSize = new Vector2(500, 680); } + private const string PrefOrbitX = "MeshThumbGrabber_OrbitX"; + private const string PrefOrbitY = "MeshThumbGrabber_OrbitY"; + private const string PrefZoom = "MeshThumbGrabber_Zoom"; + private const string PrefCamDistMult = "MeshThumbGrabber_CamDistMult"; + private const string PrefLightIntensity = "MeshThumbGrabber_LightIntensity"; + private const string PrefOutputWidth = "MeshThumbGrabber_OutputWidth"; + private const string PrefOutputHeight = "MeshThumbGrabber_OutputHeight"; + private const string PrefBgColorR = "MeshThumbGrabber_BgR"; + private const string PrefBgColorG = "MeshThumbGrabber_BgG"; + private const string PrefBgColorB = "MeshThumbGrabber_BgB"; + private const string PrefBgColorA = "MeshThumbGrabber_BgA"; + private const string PrefAmbientColorR = "MeshThumbGrabber_AmbR"; + private const string PrefAmbientColorG = "MeshThumbGrabber_AmbG"; + private const string PrefAmbientColorB = "MeshThumbGrabber_AmbB"; + private const string PrefAmbientColorA = "MeshThumbGrabber_AmbA"; + private const string PrefLightColorR = "MeshThumbGrabber_LightR"; + private const string PrefLightColorG = "MeshThumbGrabber_LightG"; + private const string PrefLightColorB = "MeshThumbGrabber_LightB"; + private const string PrefLightColorA = "MeshThumbGrabber_LightA"; + private const string PrefDrawGround = "MeshThumbGrabber_DrawGround"; + + private void LoadPrefs() + { + orbit.x = EditorPrefs.GetFloat(PrefOrbitX, orbit.x); + orbit.y = EditorPrefs.GetFloat(PrefOrbitY, orbit.y); + zoom = EditorPrefs.GetFloat(PrefZoom, zoom); + cameraDistanceMultiplier = EditorPrefs.GetFloat(PrefCamDistMult, cameraDistanceMultiplier); + lightIntensity = EditorPrefs.GetFloat(PrefLightIntensity, lightIntensity); + outputWidth = EditorPrefs.GetInt(PrefOutputWidth, outputWidth); + outputHeight = EditorPrefs.GetInt(PrefOutputHeight, outputHeight); + backgroundColor = new Color( + EditorPrefs.GetFloat(PrefBgColorR, backgroundColor.r), + EditorPrefs.GetFloat(PrefBgColorG, backgroundColor.g), + EditorPrefs.GetFloat(PrefBgColorB, backgroundColor.b), + EditorPrefs.GetFloat(PrefBgColorA, backgroundColor.a)); + ambientColor = new Color( + EditorPrefs.GetFloat(PrefAmbientColorR, ambientColor.r), + EditorPrefs.GetFloat(PrefAmbientColorG, ambientColor.g), + EditorPrefs.GetFloat(PrefAmbientColorB, ambientColor.b), + EditorPrefs.GetFloat(PrefAmbientColorA, ambientColor.a)); + lightColor = new Color( + EditorPrefs.GetFloat(PrefLightColorR, lightColor.r), + EditorPrefs.GetFloat(PrefLightColorG, lightColor.g), + EditorPrefs.GetFloat(PrefLightColorB, lightColor.b), + EditorPrefs.GetFloat(PrefLightColorA, lightColor.a)); + drawGround = EditorPrefs.GetBool(PrefDrawGround, drawGround); + } + + private void SavePrefs() + { + EditorPrefs.SetFloat(PrefOrbitX, orbit.x); + EditorPrefs.SetFloat(PrefOrbitY, orbit.y); + EditorPrefs.SetFloat(PrefZoom, zoom); + EditorPrefs.SetFloat(PrefCamDistMult, cameraDistanceMultiplier); + EditorPrefs.SetFloat(PrefLightIntensity, lightIntensity); + EditorPrefs.SetInt(PrefOutputWidth, outputWidth); + EditorPrefs.SetInt(PrefOutputHeight, outputHeight); + EditorPrefs.SetFloat(PrefBgColorR, backgroundColor.r); + EditorPrefs.SetFloat(PrefBgColorG, backgroundColor.g); + EditorPrefs.SetFloat(PrefBgColorB, backgroundColor.b); + EditorPrefs.SetFloat(PrefBgColorA, backgroundColor.a); + EditorPrefs.SetFloat(PrefAmbientColorR, ambientColor.r); + EditorPrefs.SetFloat(PrefAmbientColorG, ambientColor.g); + EditorPrefs.SetFloat(PrefAmbientColorB, ambientColor.b); + EditorPrefs.SetFloat(PrefAmbientColorA, ambientColor.a); + EditorPrefs.SetFloat(PrefLightColorR, lightColor.r); + EditorPrefs.SetFloat(PrefLightColorG, lightColor.g); + EditorPrefs.SetFloat(PrefLightColorB, lightColor.b); + EditorPrefs.SetFloat(PrefLightColorA, lightColor.a); + EditorPrefs.SetBool(PrefDrawGround, drawGround); + } + private void OnEnable() { + LoadPrefs(); CreatePreviewUtility(); } private void OnDisable() { + SavePrefs(); Cleanup(); } @@ -141,6 +215,7 @@ private void OnGUI() if (EditorGUI.EndChangeCheck()) { ClearExportPreview(); + SavePrefs(); } zoom = EditorGUILayout.Slider("Zoom", zoom, 0.05f, 100f); @@ -160,6 +235,7 @@ private void OnGUI() CenterObjectToOrigin(); CacheDrawables(); + SavePrefs(); Repaint(); } } @@ -183,6 +259,7 @@ private void OnGUI() { FitToView(); ClearExportPreview(); + SavePrefs(); Repaint(); } @@ -190,6 +267,7 @@ private void OnGUI() { orbit = new Vector2(135f, -20f); ClearExportPreview(); + SavePrefs(); Repaint(); } @@ -275,12 +353,18 @@ private void HandlePreviewInput(Rect rect) Repaint(); } + if (e.type == EventType.MouseUp && e.button == 0) + { + SavePrefs(); + } + if (e.type == EventType.ScrollWheel) { zoom *= 1f - e.delta.y * 0.08f; zoom = Mathf.Clamp(zoom, 0.05f, 100f); ClearExportPreview(); + SavePrefs(); e.Use(); Repaint(); @@ -690,23 +774,37 @@ private Texture2D RenderToTexture2D(int width, int height) return null; } - Rect rect = new Rect(0f, 0f, width, height); + RenderTexture rt = RenderTexture.GetTemporary(width, height, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB); + RenderTexture prevActive = RenderTexture.active; - previewUtility.BeginStaticPreview(rect); - RenderCurrentView(); + try + { + // BeginPreview activates the preview scene lights; we then swap its + // internal RGB target for our ARGB32 RT before camera.Render() fires. + Rect previewRect = new Rect(0f, 0f, width, height); + previewUtility.BeginPreview(previewRect, GUIStyle.none); - Texture2D texture = previewUtility.EndStaticPreview(); + previewUtility.camera.targetTexture = rt; - if (texture == null) - { - return null; - } + RenderCurrentView(); - Texture2D copy = new Texture2D(texture.width, texture.height, TextureFormat.RGBA32, false); - copy.SetPixels(texture.GetPixels()); - copy.Apply(); + // EndPreview would blit its internal RT to screen – skip that and + // read directly from our ARGB32 RT instead. + previewUtility.EndPreview(); - return copy; + RenderTexture.active = rt; + + Texture2D copy = new Texture2D(width, height, TextureFormat.RGBA32, false); + copy.ReadPixels(new Rect(0, 0, width, height), 0, 0); + copy.Apply(); + + return copy; + } + finally + { + RenderTexture.active = prevActive; + RenderTexture.ReleaseTemporary(rt); + } } private static void PingSavedAsset(string absolutePath) From ec6e1d7d008826831f991fff585bc6bfb5d88c38 Mon Sep 17 00:00:00 2001 From: mika Date: Wed, 3 Jun 2026 16:32:09 +0300 Subject: [PATCH 34/34] PivotAligner add Scale support --- Assets/Scripts/Editor/Tools/PivotAligner.cs | 782 +++++++++++++------- 1 file changed, 523 insertions(+), 259 deletions(-) diff --git a/Assets/Scripts/Editor/Tools/PivotAligner.cs b/Assets/Scripts/Editor/Tools/PivotAligner.cs index a03ca8e..e24d762 100644 --- a/Assets/Scripts/Editor/Tools/PivotAligner.cs +++ b/Assets/Scripts/Editor/Tools/PivotAligner.cs @@ -6,7 +6,6 @@ /// Place this file inside any Editor/ folder in your project. /// Open via: Tools/UnityLibrary/Pivot Aligner /// - using UnityEngine; using UnityEditor; @@ -14,111 +13,109 @@ namespace UnityLibrary.Tools { public class PivotAligner : EditorWindow { - // ────────────────────────────────────────────────────────────────────────── - // Enums & constants - // ────────────────────────────────────────────────────────────────────────── - enum PickMode { Vertex, Face } - enum ToolState { Idle, Picking, Rotating } + enum ToolState { Idle, Picking, Transforming } - const string MENU_PATH = "Tools/UnityLibrary/Pivot Aligner"; + const string MENU_PATH = "Tools/Pivot Aligner"; const float GIZMO_RADIUS = 0.06f; const float GIZMO_CROSS = 0.25f; - // ────────────────────────────────────────────────────────────────────────── - // Inspector / serialised fields - // ────────────────────────────────────────────────────────────────────────── - - [SerializeField] GameObject sourceObject; - [SerializeField] PickMode pickMode = PickMode.Vertex; - - // ── Rotation ────────────────────────────────────────────────────────────── - // Coarse float fields (full range, typed or dragged) - float rotX, rotY, rotZ; + [SerializeField] private GameObject sourceObject; + [SerializeField] private PickMode pickMode = PickMode.Vertex; + + // Rotation + private float rotX; + private float rotY; + private float rotZ; + + private bool showFinetune = false; + private float fineTuneRange = 5f; + private float fineX; + private float fineY; + private float fineZ; + + // Scale + private bool showScale = true; + private bool uniformScale = true; + private float uniformScaleValue = 1f; + private float scaleX = 1f; + private float scaleY = 1f; + private float scaleZ = 1f; + + // Position offset + private bool showPosOffset = false; + private float posOffsetX; + private float posOffsetY; + private float posOffsetZ; + private float finePosRange = 0.1f; - // Fine-tune additive deltas (±fineTuneRange degrees, applied on top of coarse) - bool showFinetune = false; - float fineTuneRange = 5f; - float fineX, fineY, fineZ; - - // ── Position offset ─────────────────────────────────────────────────────── - // Shifts the model in world space AND moves the pivot so subsequent - // rotations keep the same relative geometry. - bool showPosOffset = false; - float posOffsetX, posOffsetY, posOffsetZ; - float finePosRange = 0.1f; - - // ────────────────────────────────────────────────────────────────────────── // Runtime state - // ────────────────────────────────────────────────────────────────────────── - - ToolState state = ToolState.Idle; - Vector3 pivotWorld = Vector3.zero; - bool hasPivot = false; + private ToolState state = ToolState.Idle; + private Vector3 pivotWorld = Vector3.zero; + private bool hasPivot = false; - // Snapshot taken when pivot is confirmed – rotation is always rebuilt from - // this base so there is no floating-point drift on repeated slider edits. - Vector3 basePosition; - Quaternion baseRotation; + private Vector3 basePosition; + private Quaternion baseRotation; + private Vector3 baseScale; - // Highlight during picking - Vector3 highlightPoint = Vector3.zero; - Vector3 highlightNormal = Vector3.up; - bool hasHighlight = false; + private Vector3 highlightPoint = Vector3.zero; + private Vector3 highlightNormal = Vector3.up; + private bool hasHighlight = false; - // Scroll view - Vector2 scroll; + private Vector2 scroll; - // Style cache - GUIStyle headerStyle, sectionStyle, stateStyle, subLabelStyle; - bool stylesInit; + private GUIStyle headerStyle; + private GUIStyle sectionStyle; + private GUIStyle stateStyle; + private GUIStyle subLabelStyle; + private bool stylesInit; - // ────────────────────────────────────────────────────────────────────────── - // Window lifecycle - // ────────────────────────────────────────────────────────────────────────── + private int undoGroupIndex = 0; + private int lastHotControl = 0; + private string lastUndoLabel = ""; [MenuItem(MENU_PATH)] public static void ShowWindow() { - var win = GetWindow("Pivot Aligner"); - win.minSize = new Vector2(440, 520); + PivotAligner win = GetWindow("Pivot Aligner"); + win.minSize = new Vector2(440, 560); } - void OnEnable() + private void OnEnable() { SceneView.duringSceneGui += OnSceneGUI; - titleContent = new GUIContent("Pivot Aligner", - EditorGUIUtility.IconContent("d_ToolHandleLocal").image); + titleContent = new GUIContent("Pivot Aligner"); } - void OnDisable() + private void OnDisable() { SceneView.duringSceneGui -= OnSceneGUI; CancelPicking(); } - // ────────────────────────────────────────────────────────────────────────── - // Editor Window GUI - // ────────────────────────────────────────────────────────────────────────── - - void OnGUI() + private void OnGUI() { InitStyles(); + scroll = EditorGUILayout.BeginScrollView(scroll); - // ── Header ──────────────────────────────────────────────────────────── EditorGUILayout.Space(6); EditorGUILayout.LabelField("PIVOT ALIGNER", headerStyle); EditorGUILayout.Space(2); DrawHR(); - // ── 1 · Source Model ───────────────────────────────────────────────── EditorGUILayout.Space(6); - EditorGUILayout.LabelField("1 · Source Model", sectionStyle); + EditorGUILayout.LabelField("1. Source Model", sectionStyle); + EditorGUI.BeginChangeCheck(); sourceObject = (GameObject)EditorGUILayout.ObjectField( - "Game Object", sourceObject, typeof(GameObject), true); - if (EditorGUI.EndChangeCheck()) ResetTool(); + "Game Object", + sourceObject, + typeof(GameObject), + true); + + if (EditorGUI.EndChangeCheck()) + ResetTool(); if (sourceObject == null) { @@ -127,36 +124,48 @@ void OnGUI() return; } - // ── 2 · Pick Mode ───────────────────────────────────────────────────── EditorGUILayout.Space(8); - EditorGUILayout.LabelField("2 · Pick Mode", sectionStyle); + EditorGUILayout.LabelField("2. Pick Mode", sectionStyle); + EditorGUILayout.BeginHorizontal(); - if (DrawModeButton(" Vertex", pickMode == PickMode.Vertex)) - { pickMode = PickMode.Vertex; hasHighlight = false; } - if (DrawModeButton(" Face", pickMode == PickMode.Face)) - { pickMode = PickMode.Face; hasHighlight = false; } + + if (DrawModeButton("Vertex", pickMode == PickMode.Vertex)) + { + pickMode = PickMode.Vertex; + hasHighlight = false; + } + + if (DrawModeButton("Face", pickMode == PickMode.Face)) + { + pickMode = PickMode.Face; + hasHighlight = false; + } + EditorGUILayout.EndHorizontal(); - // ── 3 · Pivot Point ─────────────────────────────────────────────────── EditorGUILayout.Space(8); - EditorGUILayout.LabelField("3 · Select Pivot Point", sectionStyle); + EditorGUILayout.LabelField("3. Select Pivot Point", sectionStyle); - using (new EditorGUI.DisabledScope(state == ToolState.Rotating)) + using (new EditorGUI.DisabledScope(state == ToolState.Transforming)) { if (state != ToolState.Picking) { - if (GUILayout.Button("⊕ Select Target Point in Scene", GUILayout.Height(30))) + if (GUILayout.Button("Select Target Point in Scene", GUILayout.Height(30))) BeginPicking(); } else { Color prev = GUI.backgroundColor; GUI.backgroundColor = new Color(1f, 0.55f, 0.15f); - if (GUILayout.Button("✕ Cancel Picking", GUILayout.Height(30))) + + if (GUILayout.Button("Cancel Picking", GUILayout.Height(30))) CancelPicking(); + GUI.backgroundColor = prev; + + string modeText = pickMode == PickMode.Vertex ? "vertex" : "face center"; EditorGUILayout.HelpBox( - $"Hover model → {(pickMode == PickMode.Vertex ? "vertex" : "face center")} highlights. Click to confirm pivot.", + "Hover over the model. The nearest " + modeText + " will highlight. Click to confirm pivot.", MessageType.None); } } @@ -164,69 +173,133 @@ void OnGUI() if (hasPivot) { EditorGUILayout.Space(2); + using (new EditorGUILayout.HorizontalScope()) { - EditorGUILayout.LabelField($"Pivot {pivotWorld:F4}", subLabelStyle); + EditorGUILayout.LabelField("Pivot " + pivotWorld.ToString("F4"), subLabelStyle); + if (GUILayout.Button("Re-pick", GUILayout.Width(56), GUILayout.Height(18))) BeginPicking(); } } - // ── 4 · Rotation ────────────────────────────────────────────────────── EditorGUILayout.Space(8); DrawHR(); EditorGUILayout.Space(4); - EditorGUILayout.LabelField("4 · Rotation Around Pivot", sectionStyle); + EditorGUILayout.LabelField("4. Rotation Around Pivot", sectionStyle); using (new EditorGUI.DisabledScope(!hasPivot)) { - // Coarse float input rows bool dirty = false; + EditorGUI.BeginChangeCheck(); - DrawRotRow("Pitch X", ref rotX); - DrawRotRow("Yaw Y", ref rotY); - DrawRotRow("Roll Z", ref rotZ); - if (EditorGUI.EndChangeCheck()) dirty = true; - // Fine-tune + DrawRotRow("Pitch X", ref rotX); + DrawRotRow("Yaw Y", ref rotY); + DrawRotRow("Roll Z", ref rotZ); + + if (EditorGUI.EndChangeCheck()) + dirty = true; + EditorGUILayout.Space(4); + using (new EditorGUILayout.HorizontalScope()) { - showFinetune = EditorGUILayout.ToggleLeft("Fine-tune ±", showFinetune, GUILayout.Width(102)); + showFinetune = EditorGUILayout.ToggleLeft("Fine-tune +/-", showFinetune, GUILayout.Width(102)); + using (new EditorGUI.DisabledScope(!showFinetune)) fineTuneRange = Mathf.Max(0.001f, EditorGUILayout.FloatField(fineTuneRange, GUILayout.Width(52))); - EditorGUILayout.LabelField("°", subLabelStyle, GUILayout.Width(14)); + + EditorGUILayout.LabelField("deg", subLabelStyle, GUILayout.Width(28)); } if (showFinetune) { EditorGUI.BeginChangeCheck(); - fineX = DrawFineRotSlider(" Δ Pitch X", fineX, fineTuneRange); - fineY = DrawFineRotSlider(" Δ Yaw Y", fineY, fineTuneRange); - fineZ = DrawFineRotSlider(" Δ Roll Z", fineZ, fineTuneRange); - if (EditorGUI.EndChangeCheck()) dirty = true; + + fineX = DrawFineRotSlider("Delta X", fineX, fineTuneRange); + fineY = DrawFineRotSlider("Delta Y", fineY, fineTuneRange); + fineZ = DrawFineRotSlider("Delta Z", fineZ, fineTuneRange); + + if (EditorGUI.EndChangeCheck()) + dirty = true; } - if (dirty && hasPivot) ApplyAll("Pivot Aligner — Rotate"); + if (dirty && hasPivot) + ApplyAll("Pivot Aligner - Rotate"); EditorGUILayout.Space(4); + using (new EditorGUILayout.HorizontalScope()) { if (GUILayout.Button("Reset All Rotation", GUILayout.Height(24))) ResetRotation(); + if (showFinetune && GUILayout.Button("Reset Fine", GUILayout.Width(80), GUILayout.Height(24))) - { fineX = fineY = fineZ = 0f; if (hasPivot) ApplyAll("Pivot Aligner — Rotate"); } + { + fineX = 0f; + fineY = 0f; + fineZ = 0f; + + if (hasPivot) + ApplyAll("Pivot Aligner - Rotate"); + } } } - // ── 5 · Position Offset ─────────────────────────────────────────────── EditorGUILayout.Space(8); DrawHR(); EditorGUILayout.Space(4); using (new EditorGUILayout.HorizontalScope()) { - showPosOffset = EditorGUILayout.Foldout(showPosOffset, "5 · Position Offset", true, sectionStyle); + showScale = EditorGUILayout.Foldout(showScale, "5. Scale Around Pivot", true, sectionStyle); + EditorGUILayout.LabelField("(uses selected point)", subLabelStyle); + } + + if (showScale) + { + using (new EditorGUI.DisabledScope(!hasPivot)) + { + EditorGUI.BeginChangeCheck(); + + uniformScale = EditorGUILayout.ToggleLeft("Uniform Scale", uniformScale); + + if (uniformScale) + { + uniformScaleValue = EditorGUILayout.FloatField("Scale", uniformScaleValue); + uniformScaleValue = Mathf.Max(0.0001f, uniformScaleValue); + + scaleX = uniformScaleValue; + scaleY = uniformScaleValue; + scaleZ = uniformScaleValue; + } + else + { + scaleX = Mathf.Max(0.0001f, EditorGUILayout.FloatField("Scale X", scaleX)); + scaleY = Mathf.Max(0.0001f, EditorGUILayout.FloatField("Scale Y", scaleY)); + scaleZ = Mathf.Max(0.0001f, EditorGUILayout.FloatField("Scale Z", scaleZ)); + + uniformScaleValue = scaleX; + } + + if (EditorGUI.EndChangeCheck() && hasPivot) + ApplyAll("Pivot Aligner - Scale"); + + EditorGUILayout.Space(3); + + if (GUILayout.Button("Reset Scale", GUILayout.Height(24))) + ResetScale(); + } + } + + EditorGUILayout.Space(8); + DrawHR(); + EditorGUILayout.Space(4); + + using (new EditorGUILayout.HorizontalScope()) + { + showPosOffset = EditorGUILayout.Foldout(showPosOffset, "6. Position Offset", true, sectionStyle); EditorGUILayout.LabelField("(moves pivot too)", subLabelStyle); } @@ -234,10 +307,9 @@ void OnGUI() { using (new EditorGUI.DisabledScope(!hasPivot)) { - // Slider range control using (new EditorGUILayout.HorizontalScope()) { - EditorGUILayout.LabelField("Slider range ±", subLabelStyle, GUILayout.Width(102)); + EditorGUILayout.LabelField("Slider range +/-", subLabelStyle, GUILayout.Width(102)); finePosRange = Mathf.Max(0.0001f, EditorGUILayout.FloatField(finePosRange, GUILayout.Width(52))); EditorGUILayout.LabelField("m", subLabelStyle, GUILayout.Width(14)); } @@ -245,19 +317,21 @@ void OnGUI() EditorGUILayout.Space(2); EditorGUI.BeginChangeCheck(); - posOffsetX = DrawOffsetSliderRow("Offset X", posOffsetX, finePosRange); - posOffsetY = DrawOffsetSliderRow("Offset Y", posOffsetY, finePosRange); - posOffsetZ = DrawOffsetSliderRow("Offset Z", posOffsetZ, finePosRange); + + posOffsetX = DrawOffsetSliderRow("Offset X", posOffsetX, finePosRange); + posOffsetY = DrawOffsetSliderRow("Offset Y", posOffsetY, finePosRange); + posOffsetZ = DrawOffsetSliderRow("Offset Z", posOffsetZ, finePosRange); + if (EditorGUI.EndChangeCheck() && hasPivot) - ApplyAll("Pivot Aligner — Move"); + ApplyAll("Pivot Aligner - Move"); EditorGUILayout.Space(3); + if (GUILayout.Button("Reset Position Offset", GUILayout.Height(24))) ResetPositionOffset(); } } - // ── Apply / Revert ──────────────────────────────────────────────────── EditorGUILayout.Space(8); DrawHR(); EditorGUILayout.Space(4); @@ -266,138 +340,193 @@ void OnGUI() { Color prev = GUI.backgroundColor; GUI.backgroundColor = new Color(0.22f, 0.80f, 0.40f); - if (GUILayout.Button("✔ Apply & Clear Pivot", GUILayout.Height(36))) + + if (GUILayout.Button("Apply and Clear Pivot", GUILayout.Height(36))) Apply(); + GUI.backgroundColor = prev; } if (hasPivot) { - if (GUILayout.Button("↺ Cancel & Revert to Original", GUILayout.Height(26))) + if (GUILayout.Button("Cancel and Revert to Original", GUILayout.Height(26))) RevertAndReset(); } - // ── Status bar ──────────────────────────────────────────────────────── EditorGUILayout.Space(4); - string totalRot = hasPivot - ? $"rot({rotX + fineX:F3}, {rotY + fineY:F3}, {rotZ + fineZ:F3})° " + - $"pos offset({posOffsetX:F4}, {posOffsetY:F4}, {posOffsetZ:F4}) m" - : ""; - string stateLabel = state switch + + string totalInfo = ""; + + if (hasPivot) + { + totalInfo = + "rot(" + + (rotX + fineX).ToString("F3") + ", " + + (rotY + fineY).ToString("F3") + ", " + + (rotZ + fineZ).ToString("F3") + ") deg scale(" + + scaleX.ToString("F3") + ", " + + scaleY.ToString("F3") + ", " + + scaleZ.ToString("F3") + ") pos offset(" + + posOffsetX.ToString("F4") + ", " + + posOffsetY.ToString("F4") + ", " + + posOffsetZ.ToString("F4") + ") m"; + } + + string stateLabel; + + switch (state) { - ToolState.Picking => "● PICKING", - ToolState.Rotating => $"● ROTATING {totalRot}", - _ => "○ idle" - }; + case ToolState.Picking: + stateLabel = "PICKING"; + break; + + case ToolState.Transforming: + stateLabel = "TRANSFORMING " + totalInfo; + break; + + default: + stateLabel = "idle"; + break; + } + EditorGUILayout.LabelField(stateLabel, stateStyle); EditorGUILayout.Space(4); + EditorGUILayout.EndScrollView(); SceneView.RepaintAll(); } - // ────────────────────────────────────────────────────────────────────────── - // Row helpers - // ────────────────────────────────────────────────────────────────────────── - - /// Radio-style button: highlighted when active, always clickable, returns true on click. - bool DrawModeButton(string label, bool active) + private bool DrawModeButton(string label, bool active) { Color prev = GUI.backgroundColor; - if (active) GUI.backgroundColor = new Color(0.3f, 0.65f, 1f); + + if (active) + GUI.backgroundColor = new Color(0.3f, 0.65f, 1f); + bool clicked = GUILayout.Button(label, GUILayout.Height(26)); + GUI.backgroundColor = prev; + return clicked; } - /// Coarse rotation row: label | float field | quick-snap buttons - void DrawRotRow(string label, ref float value) + private void DrawRotRow(string label, ref float value) { using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField(label, GUILayout.Width(72)); + value = EditorGUILayout.FloatField(value, GUILayout.Width(74)); - if (GUILayout.Button("-90", GUILayout.Width(36), GUILayout.Height(18))) value -= 90f; - if (GUILayout.Button("-45", GUILayout.Width(36), GUILayout.Height(18))) value -= 45f; - if (GUILayout.Button("0", GUILayout.Width(28), GUILayout.Height(18))) value = 0f; - if (GUILayout.Button("+45", GUILayout.Width(36), GUILayout.Height(18))) value += 45f; - if (GUILayout.Button("+90", GUILayout.Width(36), GUILayout.Height(18))) value += 90f; + + if (GUILayout.Button("-90", GUILayout.Width(36), GUILayout.Height(18))) + value -= 90f; + + if (GUILayout.Button("-45", GUILayout.Width(36), GUILayout.Height(18))) + value -= 45f; + + if (GUILayout.Button("0", GUILayout.Width(28), GUILayout.Height(18))) + value = 0f; + + if (GUILayout.Button("+45", GUILayout.Width(36), GUILayout.Height(18))) + value += 45f; + + if (GUILayout.Button("+90", GUILayout.Width(36), GUILayout.Height(18))) + value += 90f; } } - /// Fine rotation slider: returns new value. - float DrawFineRotSlider(string label, float value, float range) + private float DrawFineRotSlider(string label, float value, float range) { using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField(label, subLabelStyle, GUILayout.Width(76)); + value = GUILayout.HorizontalSlider(value, -range, range); value = EditorGUILayout.FloatField(value, GUILayout.Width(64)); - EditorGUILayout.LabelField("°", subLabelStyle, GUILayout.Width(14)); + + EditorGUILayout.LabelField("deg", subLabelStyle, GUILayout.Width(28)); } + return value; } - /// Position offset row: slider + float field + zero button. Direct value, no delta accumulation. - float DrawOffsetSliderRow(string label, float value, float range) + private float DrawOffsetSliderRow(string label, float value, float range) { using (new EditorGUILayout.HorizontalScope()) { EditorGUILayout.LabelField(label, GUILayout.Width(72)); + value = GUILayout.HorizontalSlider(value, -range, range); value = EditorGUILayout.FloatField(value, GUILayout.Width(74)); + EditorGUILayout.LabelField("m", subLabelStyle, GUILayout.Width(14)); - if (GUILayout.Button("0", GUILayout.Width(24), GUILayout.Height(18))) value = 0f; + + if (GUILayout.Button("0", GUILayout.Width(24), GUILayout.Height(18))) + value = 0f; } + return value; } - // ────────────────────────────────────────────────────────────────────────── - // Scene GUI - // ────────────────────────────────────────────────────────────────────────── - - void OnSceneGUI(SceneView sv) + private void OnSceneGUI(SceneView sv) { - if (sourceObject == null) return; - if (hasPivot) DrawPivotGizmo(pivotWorld, Color.cyan); - if (state == ToolState.Picking) HandlePicking(sv); + if (sourceObject == null) + return; + + if (hasPivot) + DrawPivotGizmo(pivotWorld, Color.cyan); + + if (state == ToolState.Picking) + HandlePicking(sv); } - void HandlePicking(SceneView sv) + private void HandlePicking(SceneView sv) { Event e = Event.current; + if (e.type == EventType.Layout) HandleUtility.AddDefaultControl(GUIUtility.GetControlID(FocusType.Passive)); if (e.type == EventType.MouseMove || e.type == EventType.MouseDown) { Ray ray = HandleUtility.GUIPointToWorldRay(e.mousePosition); + TryRaycast(ray, out hasHighlight, out highlightPoint, out highlightNormal); + if (hasHighlight) DrawPivotGizmo(highlightPoint, new Color(1f, 0.8f, 0.1f, 0.9f)); + if (e.type == EventType.MouseDown && e.button == 0 && hasHighlight) - { ConfirmPivot(highlightPoint); e.Use(); } + { + ConfirmPivot(highlightPoint); + e.Use(); + } + sv.Repaint(); } + if (hasHighlight) DrawPivotGizmo(highlightPoint, new Color(1f, 0.8f, 0.1f, 0.9f)); } - // ────────────────────────────────────────────────────────────────────────── - // Raycasting - // ────────────────────────────────────────────────────────────────────────── - - void TryRaycast(Ray ray, out bool hit, out Vector3 point, out Vector3 normal) + private void TryRaycast(Ray ray, out bool hit, out Vector3 point, out Vector3 normal) { - hit = false; point = Vector3.zero; normal = Vector3.up; - var filters = sourceObject.GetComponentsInChildren(); + hit = false; + point = Vector3.zero; + normal = Vector3.up; + + MeshFilter[] filters = sourceObject.GetComponentsInChildren(); float bestDist = float.MaxValue; - foreach (var mf in filters) + foreach (MeshFilter mf in filters) { - if (mf.sharedMesh == null) continue; + if (mf.sharedMesh == null) + continue; + Mesh mesh = mf.sharedMesh; Transform t = mf.transform; + Ray localRay = new Ray( t.InverseTransformPoint(ray.origin), t.InverseTransformDirection(ray.direction).normalized); @@ -408,122 +537,192 @@ void TryRaycast(Ray ray, out bool hit, out Vector3 point, out Vector3 normal) for (int i = 0; i < tris.Length; i += 3) { - Vector3 v0 = verts[tris[i]], v1 = verts[tris[i + 1]], v2 = verts[tris[i + 2]]; - if (!RayTriangle(localRay, v0, v1, v2, out float dist, out float u, out float v)) continue; - if (dist < 0 || dist >= bestDist) continue; - bestDist = dist; hit = true; + Vector3 v0 = verts[tris[i]]; + Vector3 v1 = verts[tris[i + 1]]; + Vector3 v2 = verts[tris[i + 2]]; + + float dist; + float u; + float v; + + if (!RayTriangle(localRay, v0, v1, v2, out dist, out u, out v)) + continue; + + if (dist < 0f || dist >= bestDist) + continue; + + bestDist = dist; + hit = true; if (pickMode == PickMode.Vertex) { float w = 1f - u - v; int vi = FindNearestVertex(u, v, w); - point = t.TransformPoint(vi == 0 ? v0 : vi == 1 ? v1 : v2); + + if (vi == 0) + point = t.TransformPoint(v0); + else if (vi == 1) + point = t.TransformPoint(v1); + else + point = t.TransformPoint(v2); + } + else + { + point = t.TransformPoint((v0 + v1 + v2) / 3f); } - else point = t.TransformPoint((v0 + v1 + v2) / 3f); Vector3 n0 = normals.Length > tris[i] ? normals[tris[i]] : Vector3.up; Vector3 n1 = normals.Length > tris[i + 1] ? normals[tris[i + 1]] : Vector3.up; Vector3 n2 = normals.Length > tris[i + 2] ? normals[tris[i + 2]] : Vector3.up; - normal = t.TransformDirection((n0 * (1 - u - v) + n1 * u + n2 * v).normalized); + + normal = t.TransformDirection((n0 * (1f - u - v) + n1 * u + n2 * v).normalized); } } } - static bool RayTriangle(Ray ray, Vector3 v0, Vector3 v1, Vector3 v2, - out float dist, out float u, out float v) + private static bool RayTriangle( + Ray ray, + Vector3 v0, + Vector3 v1, + Vector3 v2, + out float dist, + out float u, + out float v) { - dist = u = v = 0; - Vector3 e1 = v1 - v0, e2 = v2 - v0, h = Vector3.Cross(ray.direction, e2); + dist = 0f; + u = 0f; + v = 0f; + + Vector3 e1 = v1 - v0; + Vector3 e2 = v2 - v0; + Vector3 h = Vector3.Cross(ray.direction, e2); + float det = Vector3.Dot(e1, h); - if (Mathf.Abs(det) < 1e-6f) return false; + + if (Mathf.Abs(det) < 1e-6f) + return false; + float f = 1f / det; Vector3 s = ray.origin - v0; + u = f * Vector3.Dot(s, h); - if (u < 0 || u > 1) return false; + + if (u < 0f || u > 1f) + return false; + Vector3 q = Vector3.Cross(s, e1); + v = f * Vector3.Dot(ray.direction, q); - if (v < 0 || u + v > 1) return false; + + if (v < 0f || u + v > 1f) + return false; + dist = f * Vector3.Dot(e2, q); + return dist > 1e-5f; } - static int FindNearestVertex(float u, float v, float w) + private static int FindNearestVertex(float u, float v, float w) { - if (w >= u && w >= v) return 0; - if (u >= w && u >= v) return 1; + if (w >= u && w >= v) + return 0; + + if (u >= w && u >= v) + return 1; + return 2; } - // ────────────────────────────────────────────────────────────────────────── - // Gizmo drawing - // ────────────────────────────────────────────────────────────────────────── - - void DrawPivotGizmo(Vector3 pos, Color color) + private void DrawPivotGizmo(Vector3 pos, Color color) { Handles.color = color; - Handles.SphereHandleCap(0, pos, Quaternion.identity, - GIZMO_RADIUS * HandleUtility.GetHandleSize(pos), EventType.Repaint); - float sz = GIZMO_CROSS * HandleUtility.GetHandleSize(pos); + + float handleSize = HandleUtility.GetHandleSize(pos); + + Handles.SphereHandleCap( + 0, + pos, + Quaternion.identity, + GIZMO_RADIUS * handleSize, + EventType.Repaint); + + float sz = GIZMO_CROSS * handleSize; + Handles.DrawLine(pos - Vector3.right * sz, pos + Vector3.right * sz); Handles.DrawLine(pos - Vector3.up * sz, pos + Vector3.up * sz); Handles.DrawLine(pos - Vector3.forward * sz, pos + Vector3.forward * sz); - GUIStyle s = new GUIStyle(EditorStyles.miniLabel) { normal = { textColor = color } }; - Handles.Label(pos + Vector3.up * sz * 1.5f, - (hasPivot && pos == pivotWorld) ? "PIVOT" : "○", s); - } - // ────────────────────────────────────────────────────────────────────────── - // State transitions - // ────────────────────────────────────────────────────────────────────────── + GUIStyle s = new GUIStyle(EditorStyles.miniLabel); + s.normal.textColor = color; + + string label = hasPivot && Vector3.Distance(pos, pivotWorld) < 0.0001f ? "PIVOT" : "point"; + + Handles.Label(pos + Vector3.up * sz * 1.5f, label, s); + } - void BeginPicking() + private void BeginPicking() { - if (sourceObject == null) return; - state = ToolState.Picking; hasHighlight = false; + if (sourceObject == null) + return; + + state = ToolState.Picking; + hasHighlight = false; + SceneView.RepaintAll(); } - void CancelPicking() + private void CancelPicking() { if (state == ToolState.Picking) - state = hasPivot ? ToolState.Rotating : ToolState.Idle; - hasHighlight = false; SceneView.RepaintAll(); + state = hasPivot ? ToolState.Transforming : ToolState.Idle; + + hasHighlight = false; + + SceneView.RepaintAll(); } - void ConfirmPivot(Vector3 worldPoint) + private void ConfirmPivot(Vector3 worldPoint) { - if (hasPivot && state == ToolState.Rotating) RevertTransform(); + if (hasPivot && state == ToolState.Transforming) + RevertTransform(); + pivotWorld = worldPoint; hasPivot = true; - state = ToolState.Rotating; + state = ToolState.Transforming; hasHighlight = false; + basePosition = sourceObject.transform.position; baseRotation = sourceObject.transform.rotation; - rotX = rotY = rotZ = 0f; - fineX = fineY = fineZ = 0f; - posOffsetX = posOffsetY = posOffsetZ = 0f; - Repaint(); SceneView.RepaintAll(); - } + baseScale = sourceObject.transform.localScale; + + rotX = 0f; + rotY = 0f; + rotZ = 0f; - // ────────────────────────────────────────────────────────────────────────── - // Transform computation — single source of truth - // ────────────────────────────────────────────────────────────────────────── + fineX = 0f; + fineY = 0f; + fineZ = 0f; - // Each continuous drag (slider or float field) collapses into a single undo - // step by using the same group name while the control is hot, then - // incrementing undoGroupIndex when the user releases (EndChangeCheck fires - // but the control is no longer hot on the next frame). - int undoGroupIndex = 0; - int lastHotControl = 0; - string lastUndoLabel = ""; + scaleX = 1f; + scaleY = 1f; + scaleZ = 1f; + uniformScaleValue = 1f; - void ApplyAll(string undoLabel = "Pivot Aligner") + posOffsetX = 0f; + posOffsetY = 0f; + posOffsetZ = 0f; + + Repaint(); + SceneView.RepaintAll(); + } + + private void ApplyAll(string undoLabel = "Pivot Aligner") { - if (sourceObject == null || !hasPivot) return; + if (sourceObject == null || !hasPivot) + return; - // Start a new undo group whenever the active control changes or the - // label changes (e.g. switching from Rotate to Move). int hot = GUIUtility.hotControl; + if (hot != lastHotControl || undoLabel != lastUndoLabel) { undoGroupIndex++; @@ -533,80 +732,145 @@ void ApplyAll(string undoLabel = "Pivot Aligner") Undo.RecordObject(sourceObject.transform, undoLabel); - Quaternion delta = Quaternion.Euler(rotX + fineX, rotY + fineY, rotZ + fineZ); - Vector3 posOff = new Vector3(posOffsetX, posOffsetY, posOffsetZ); + Quaternion deltaRotation = Quaternion.Euler( + rotX + fineX, + rotY + fineY, + rotZ + fineZ); - sourceObject.transform.position = pivotWorld + delta * (basePosition - pivotWorld) + posOff; - sourceObject.transform.rotation = delta * baseRotation; + Vector3 scaleMultiplier = new Vector3(scaleX, scaleY, scaleZ); + Vector3 positionOffset = new Vector3(posOffsetX, posOffsetY, posOffsetZ); + + Vector3 baseOffset = basePosition - pivotWorld; + + Vector3 scaledOffset = new Vector3( + baseOffset.x * scaleMultiplier.x, + baseOffset.y * scaleMultiplier.y, + baseOffset.z * scaleMultiplier.z); + + sourceObject.transform.position = + pivotWorld + + deltaRotation * scaledOffset + + positionOffset; + + sourceObject.transform.rotation = deltaRotation * baseRotation; + sourceObject.transform.localScale = Vector3.Scale(baseScale, scaleMultiplier); - // Collapse all RecordObject calls for this drag into one undo step Undo.CollapseUndoOperations(Undo.GetCurrentGroup() - undoGroupIndex + 1); } - void ResetRotation() + private void ResetRotation() { - rotX = rotY = rotZ = fineX = fineY = fineZ = 0f; - ApplyAll("Pivot Aligner — Reset Rotation"); + rotX = 0f; + rotY = 0f; + rotZ = 0f; + + fineX = 0f; + fineY = 0f; + fineZ = 0f; + + ApplyAll("Pivot Aligner - Reset Rotation"); } - void ResetPositionOffset() + private void ResetScale() { - posOffsetX = posOffsetY = posOffsetZ = 0f; - ApplyAll("Pivot Aligner — Reset Offset"); + scaleX = 1f; + scaleY = 1f; + scaleZ = 1f; + uniformScaleValue = 1f; + + ApplyAll("Pivot Aligner - Reset Scale"); } - void Apply() + private void ResetPositionOffset() { - if (sourceObject == null) return; + posOffsetX = 0f; + posOffsetY = 0f; + posOffsetZ = 0f; + + ApplyAll("Pivot Aligner - Reset Offset"); + } + + private void Apply() + { + if (sourceObject == null) + return; + Undo.SetCurrentGroupName("Pivot Aligner Apply"); Undo.CollapseUndoOperations(Undo.GetCurrentGroup()); + ResetTool(); } - void RevertAndReset() { RevertTransform(); ResetTool(); } + private void RevertAndReset() + { + RevertTransform(); + ResetTool(); + } - void RevertTransform() + private void RevertTransform() { - if (sourceObject == null || !hasPivot) return; + if (sourceObject == null || !hasPivot) + return; + Undo.RecordObject(sourceObject.transform, "Pivot Aligner Revert"); + sourceObject.transform.position = basePosition; sourceObject.transform.rotation = baseRotation; + sourceObject.transform.localScale = baseScale; } - void ResetTool() + private void ResetTool() { - state = ToolState.Idle; hasPivot = hasHighlight = false; - rotX = rotY = rotZ = fineX = fineY = fineZ = 0f; - posOffsetX = posOffsetY = posOffsetZ = 0f; - SceneView.RepaintAll(); Repaint(); - } + state = ToolState.Idle; + + hasPivot = false; + hasHighlight = false; + + rotX = 0f; + rotY = 0f; + rotZ = 0f; + + fineX = 0f; + fineY = 0f; + fineZ = 0f; - // ────────────────────────────────────────────────────────────────────────── - // Styles & layout helpers - // ────────────────────────────────────────────────────────────────────────── + scaleX = 1f; + scaleY = 1f; + scaleZ = 1f; + uniformScaleValue = 1f; - void InitStyles() + posOffsetX = 0f; + posOffsetY = 0f; + posOffsetZ = 0f; + + SceneView.RepaintAll(); + Repaint(); + } + + private void InitStyles() { - if (stylesInit) return; + if (stylesInit) + return; + stylesInit = true; - headerStyle = new GUIStyle(EditorStyles.boldLabel) - { - fontSize = 13, - alignment = TextAnchor.MiddleCenter, - normal = { textColor = new Color(0.65f, 0.88f, 1f) } - }; - sectionStyle = new GUIStyle(EditorStyles.boldLabel) - { normal = { textColor = new Color(0.85f, 0.85f, 0.85f) } }; - stateStyle = new GUIStyle(EditorStyles.miniLabel) - { - alignment = TextAnchor.MiddleRight, - normal = { textColor = new Color(0.40f, 0.75f, 0.50f) } - }; - subLabelStyle = new GUIStyle(EditorStyles.miniLabel) - { normal = { textColor = new Color(0.55f, 0.55f, 0.55f) } }; + + headerStyle = new GUIStyle(EditorStyles.boldLabel); + headerStyle.fontSize = 13; + headerStyle.alignment = TextAnchor.MiddleCenter; + headerStyle.normal.textColor = new Color(0.65f, 0.88f, 1f); + + sectionStyle = new GUIStyle(EditorStyles.boldLabel); + sectionStyle.normal.textColor = new Color(0.85f, 0.85f, 0.85f); + + stateStyle = new GUIStyle(EditorStyles.miniLabel); + stateStyle.alignment = TextAnchor.MiddleRight; + stateStyle.normal.textColor = new Color(0.40f, 0.75f, 0.50f); + + subLabelStyle = new GUIStyle(EditorStyles.miniLabel); + subLabelStyle.normal.textColor = new Color(0.55f, 0.55f, 0.55f); } - void DrawHR() + private void DrawHR() { Rect r = EditorGUILayout.GetControlRect(false, 1); EditorGUI.DrawRect(r, new Color(0.35f, 0.35f, 0.35f, 0.6f));