From 936e19d819f2b47e8af96bf48f0ec9424c625618 Mon Sep 17 00:00:00 2001 From: mika Date: Thu, 29 Jan 2026 11:46:41 +0200 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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 07/12] 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 08/12] 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 09/12] 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 10/12] 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 11/12] 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 12/12] 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));