From 5ed776a118bd48c141f01095fddf333303f1f7d2 Mon Sep 17 00:00:00 2001 From: mika Date: Wed, 5 Mar 2025 10:19:22 +0200 Subject: [PATCH 01/22] Create Standard Stipple Transparency.shader --- .../Standard Stipple Transparency.shader | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 Assets/Shaders/3D/Effects/Standard Stipple Transparency.shader diff --git a/Assets/Shaders/3D/Effects/Standard Stipple Transparency.shader b/Assets/Shaders/3D/Effects/Standard Stipple Transparency.shader new file mode 100644 index 0000000..2e312da --- /dev/null +++ b/Assets/Shaders/3D/Effects/Standard Stipple Transparency.shader @@ -0,0 +1,58 @@ +// Standard shader with stipple transparency +// by Alex Ocias - https://ocias.com +// source: https://ocias.com/blog/unity-stipple-transparency-shader/ +// based on an article by Digital Rune: https://www.digitalrune.com/Blog/Post/1743/Screen-Door-Transparency + +Shader "Ocias/Standard (Stipple Transparency)" { + Properties { + _Color ("Color", Color) = (1,1,1,1) + _MainTex ("Albedo (RGB)", 2D) = "white" {} + _Glossiness ("Smoothness", Range(0,1)) = 0.5 + _Metallic ("Metallic", Range(0,1)) = 0.0 + } + SubShader { + Tags { "RenderType"="Opaque" } + LOD 100 + + CGPROGRAM + // Physically based Standard lighting model, and enable shadows on all light types + #pragma surface surf Standard fullforwardshadows + + // Use shader model 3.0 target, to get nicer looking lighting + #pragma target 3.0 + + sampler2D _MainTex; + + struct Input { + float2 uv_MainTex; + float4 screenPos; + }; + + half _Glossiness; + half _Metallic; + fixed4 _Color; + + void surf (Input IN, inout SurfaceOutputStandard o) { + // Albedo comes from a texture tinted by color + fixed4 c = tex2D (_MainTex, IN.uv_MainTex) * _Color; + o.Albedo = c.rgb; + // Metallic and smoothness come from slider variables + o.Metallic = _Metallic; + o.Smoothness = _Glossiness; + + // Screen-door transparency: Discard pixel if below threshold. + float4x4 thresholdMatrix = + { 1.0 / 17.0, 9.0 / 17.0, 3.0 / 17.0, 11.0 / 17.0, + 13.0 / 17.0, 5.0 / 17.0, 15.0 / 17.0, 7.0 / 17.0, + 4.0 / 17.0, 12.0 / 17.0, 2.0 / 17.0, 10.0 / 17.0, + 16.0 / 17.0, 8.0 / 17.0, 14.0 / 17.0, 6.0 / 17.0 + }; + float4x4 _RowAccess = { 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 }; + float2 pos = IN.screenPos.xy / IN.screenPos.w; + pos *= _ScreenParams.xy; // pixel position + clip(c.a - thresholdMatrix[fmod(pos.x, 4)] * _RowAccess[fmod(pos.y, 4)]); + } + ENDCG + } + FallBack "Diffuse" +} From 4005a5acf5ad463d1c8ca6f014e15cc4b9cd87d9 Mon Sep 17 00:00:00 2001 From: mika Date: Wed, 5 Mar 2025 10:19:59 +0200 Subject: [PATCH 02/22] Create Diffuse Stipple Transparency.shader --- .../Diffuse Stipple Transparency.shader | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 Assets/Shaders/3D/Effects/Diffuse Stipple Transparency.shader diff --git a/Assets/Shaders/3D/Effects/Diffuse Stipple Transparency.shader b/Assets/Shaders/3D/Effects/Diffuse Stipple Transparency.shader new file mode 100644 index 0000000..853c4cf --- /dev/null +++ b/Assets/Shaders/3D/Effects/Diffuse Stipple Transparency.shader @@ -0,0 +1,52 @@ +// Diffuse shader with stipple transparency +// by Alex Ocias - https://ocias.com +// source: https://ocias.com/blog/unity-stipple-transparency-shader/ +// based on an article by Digital Rune: https://www.digitalrune.com/Blog/Post/1743/Screen-Door-Transparency + +// Simplified Diffuse shader. Differences from regular Diffuse one: +// - no Main Color +// - fully supports only 1 directional light. Other lights can affect it, but it will be per-vertex/SH. + +Shader "Ocias/Diffuse (Stipple Transparency)" { +Properties { + _MainTex ("Base (RGB)", 2D) = "white" {} + _Transparency ("Transparency", Range(0,1)) = 1.0 +} +SubShader { + Tags { "RenderType"="Opaque" } + LOD 150 + +CGPROGRAM +#pragma surface surf Lambert noforwardadd + +sampler2D _MainTex; + +struct Input { + float2 uv_MainTex; + float4 screenPos; +}; + +half _Transparency; + +void surf (Input IN, inout SurfaceOutput o) { + fixed4 c = tex2D(_MainTex, IN.uv_MainTex); + o.Albedo = c.rgb; + o.Alpha = c.a; + + // Screen-door transparency: Discard pixel if below threshold. + float4x4 thresholdMatrix = + { 1.0 / 17.0, 9.0 / 17.0, 3.0 / 17.0, 11.0 / 17.0, + 13.0 / 17.0, 5.0 / 17.0, 15.0 / 17.0, 7.0 / 17.0, + 4.0 / 17.0, 12.0 / 17.0, 2.0 / 17.0, 10.0 / 17.0, + 16.0 / 17.0, 8.0 / 17.0, 14.0 / 17.0, 6.0 / 17.0 + }; + float4x4 _RowAccess = { 1,0,0,0, 0,1,0,0, 0,0,1,0, 0,0,0,1 }; + float2 pos = IN.screenPos.xy / IN.screenPos.w; + pos *= _ScreenParams.xy; // pixel position + clip(_Transparency - thresholdMatrix[fmod(pos.x, 4)] * _RowAccess[fmod(pos.y, 4)]); +} +ENDCG +} + +Fallback "Mobile/VertexLit" +} From 26927e8a1e5175fb35c7983ec469e3c8d3a42f8f Mon Sep 17 00:00:00 2001 From: mika Date: Wed, 9 Apr 2025 09:29:58 +0300 Subject: [PATCH 03/22] Create ReferenceImageViewer2.cs --- .../Editor/Tools/ReferenceImageViewer2.cs | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/ReferenceImageViewer2.cs diff --git a/Assets/Scripts/Editor/Tools/ReferenceImageViewer2.cs b/Assets/Scripts/Editor/Tools/ReferenceImageViewer2.cs new file mode 100644 index 0000000..e8371c5 --- /dev/null +++ b/Assets/Scripts/Editor/Tools/ReferenceImageViewer2.cs @@ -0,0 +1,110 @@ +// reference image viewer from external folder + +using UnityEngine; +using UnityEditor; +using System.IO; + +namespace UnityLibrary.Tools +{ + public class ReferenceImageViewer2 : EditorWindow + { + private const string EditorPrefsKey = "ReferenceImageViewer_ImagePath"; + + [SerializeField] + private string imagePath = ""; + [SerializeField] + private Texture2D loadedImage; + + [MenuItem("Window/Reference Image Viewer")] + public static void ShowWindow() + { + GetWindow("Reference Image Viewer"); + } + + private void OnEnable() + { + // Delay the reload to ensure Unity's layout system is ready + EditorApplication.delayCall += TryLoadImage; + } + + private void OnDisable() + { + if (!string.IsNullOrEmpty(imagePath)) + EditorPrefs.SetString(EditorPrefsKey, imagePath); + else + EditorPrefs.DeleteKey(EditorPrefsKey); + } + + private void TryLoadImage() + { + imagePath = EditorPrefs.GetString(EditorPrefsKey, ""); + if (!string.IsNullOrEmpty(imagePath) && File.Exists(imagePath)) + { + LoadImageFromPath(imagePath); + Repaint(); // Ensure it's drawn + } + } + + void OnGUI() + { + GUILayout.Label("Reference Image Viewer", EditorStyles.boldLabel); + + if (GUILayout.Button("Browse for Image")) + { + string path = EditorUtility.OpenFilePanel("Select Image", "", "png,jpg,jpeg"); + if (!string.IsNullOrEmpty(path)) + { + imagePath = path; + EditorPrefs.SetString(EditorPrefsKey, imagePath); + LoadImageFromPath(imagePath); + } + } + + // Fallback in case delayCall missed + if (loadedImage == null && !string.IsNullOrEmpty(imagePath) && File.Exists(imagePath)) + { + LoadImageFromPath(imagePath); + Repaint(); + } + + if (loadedImage != null) + { + GUILayout.Space(10); + + float windowWidth = position.width - 20; + float imageAspect = (float)loadedImage.width / loadedImage.height; + + float displayWidth = windowWidth; + float displayHeight = windowWidth / imageAspect; + + if (displayHeight > position.height - 100) + { + displayHeight = position.height - 100; + displayWidth = displayHeight * imageAspect; + } + + Rect imageRect = GUILayoutUtility.GetRect(displayWidth, displayHeight, GUILayout.ExpandWidth(false), GUILayout.ExpandHeight(false)); + EditorGUI.DrawPreviewTexture(imageRect, loadedImage, null, ScaleMode.ScaleToFit); + } + } + + private void LoadImageFromPath(string path) + { + try + { + byte[] fileData = File.ReadAllBytes(path); + loadedImage = new Texture2D(2, 2, TextureFormat.RGBA32, false); + if (!loadedImage.LoadImage(fileData)) + { + Debug.LogError("Failed to load image."); + loadedImage = null; + } + } + catch (System.Exception e) + { + Debug.LogError($"Could not load image from path: {path}\n{e.Message}"); + loadedImage = null; + } + } + } +} From 13b1f1630bf03876994fd545c6a7a43d069e4f26 Mon Sep 17 00:00:00 2001 From: mika Date: Mon, 14 Apr 2025 16:07:26 +0300 Subject: [PATCH 04/22] Create PreciseOffsetEditor.cs --- .../Editor/Tools/PreciseOffsetEditor.cs | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/PreciseOffsetEditor.cs diff --git a/Assets/Scripts/Editor/Tools/PreciseOffsetEditor.cs b/Assets/Scripts/Editor/Tools/PreciseOffsetEditor.cs new file mode 100644 index 0000000..f3c5a44 --- /dev/null +++ b/Assets/Scripts/Editor/Tools/PreciseOffsetEditor.cs @@ -0,0 +1,118 @@ +// gameobject position fiddling tool (when dragging x,y,z axist in transform panel is too fast) + +using UnityEngine; +using UnityEditor; + +namespace UnityLibrary.SceneTools +{ + public class PreciseOffsetEditor : EditorWindow + { + private const string SpeedMultiplierKey = "UnityLibrary_PreciseOffset_SpeedMultiplier"; + + private GameObject selectedObject; + private Vector3 offsetSliderValues = Vector3.zero; + private float speedMultiplier = 0.01f; + + private Vector3 originalPosition; + private GameObject lastSelectedObject; + + [MenuItem("Tools/UnityLibrary/Precise Model Offset")] + public static void ShowWindow() + { + var win = GetWindow("Precise Model Offset"); + win.minSize = new Vector2(300, 220); + win.maxSize = new Vector2(300, 220); + } + + private void OnEnable() + { + speedMultiplier = EditorPrefs.GetFloat(SpeedMultiplierKey, 0.01f); + } + + private void OnDisable() + { + EditorPrefs.SetFloat(SpeedMultiplierKey, speedMultiplier); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("Precise Model Offset", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + int selectedCount = Selection.gameObjects.Length; + selectedObject = Selection.activeGameObject; + + if (selectedCount == 0) + { + EditorGUILayout.HelpBox("No GameObject selected.", MessageType.Warning); + return; + } + + if (selectedCount == 1) + { + EditorGUILayout.LabelField("Selected: " + selectedObject.name); + } + else + { + EditorGUILayout.LabelField("Selected: (multiple)"); + } + + if (selectedObject != lastSelectedObject) + { + originalPosition = selectedObject.transform.position; + offsetSliderValues = Vector3.zero; + lastSelectedObject = selectedObject; + } + + EditorGUILayout.Space(); + EditorGUI.BeginChangeCheck(); + speedMultiplier = EditorGUILayout.Slider("Speed Multiplier", speedMultiplier, 0.001f, 1f); + if (EditorGUI.EndChangeCheck()) + { + EditorPrefs.SetFloat(SpeedMultiplierKey, speedMultiplier); + } + + EditorGUILayout.Space(); + EditorGUI.BeginChangeCheck(); + + // Store old values to detect changes + Vector3 oldOffset = offsetSliderValues; + + offsetSliderValues.x = EditorGUILayout.Slider("X Offset", offsetSliderValues.x, -100f, 100f); + offsetSliderValues.y = EditorGUILayout.Slider("Y Offset", offsetSliderValues.y, -100f, 100f); + offsetSliderValues.z = EditorGUILayout.Slider("Z Offset", offsetSliderValues.z, -100f, 100f); + + Vector3 delta = offsetSliderValues - oldOffset; + + if (delta != Vector3.zero) + { + Undo.RecordObject(selectedObject.transform, "Precise Offset"); + + Vector3 newPosition = selectedObject.transform.position; + + if (!Mathf.Approximately(delta.x, 0f)) + newPosition.x = originalPosition.x + offsetSliderValues.x * speedMultiplier; + + if (!Mathf.Approximately(delta.y, 0f)) + newPosition.y = originalPosition.y + offsetSliderValues.y * speedMultiplier; + + if (!Mathf.Approximately(delta.z, 0f)) + newPosition.z = originalPosition.z + offsetSliderValues.z * speedMultiplier; + + selectedObject.transform.position = newPosition; + + EditorUtility.SetDirty(selectedObject); + } + + EditorGUILayout.Space(); + if (GUILayout.Button("Reset Offset")) + { + offsetSliderValues = Vector3.zero; + if (selectedObject != null) + { + selectedObject.transform.position = originalPosition; + } + } + } + } +} From 48de7352cfc6db616b2a0e66a267c763dede2f7d Mon Sep 17 00:00:00 2001 From: mika Date: Thu, 17 Apr 2025 14:31:36 +0300 Subject: [PATCH 05/22] Create PasteScript.cs --- Assets/Scripts/Editor/Tools/PasteScript.cs | 85 ++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/PasteScript.cs diff --git a/Assets/Scripts/Editor/Tools/PasteScript.cs b/Assets/Scripts/Editor/Tools/PasteScript.cs new file mode 100644 index 0000000..84a1561 --- /dev/null +++ b/Assets/Scripts/Editor/Tools/PasteScript.cs @@ -0,0 +1,85 @@ +// new version of the https://github.com/UnityCommunity/UnityLibrary/blob/master/Assets/Scripts/Editor/Tools/CopyPasteHelper.cs +// creates c# script from clipboard when click button + +using UnityEngine; +using UnityEditor; +using System.IO; +using System.Text.RegularExpressions; + +namespace UnityLibrary.Tools +{ + public class PasteScript : EditorWindow + { + private string statusMessage = ""; + private string lastCreatedScriptPath = ""; + + [MenuItem("Tools/UnityLibrary/Clipboard To C# Script")] + public static void ShowWindow() + { + GetWindow("Clipboard to Script"); + } + + void OnGUI() + { + if (GUILayout.Button("Create Script from Clipboard")) + { + TryCreateScriptFromClipboard(); + } + + GUILayout.Space(10); + + // Draw clickable HelpBox + EditorGUILayout.HelpBox(statusMessage, MessageType.Info); + + Rect helpBoxRect = GUILayoutUtility.GetLastRect(); + if (!string.IsNullOrEmpty(lastCreatedScriptPath) && Event.current.type == EventType.MouseDown && helpBoxRect.Contains(Event.current.mousePosition)) + { + var asset = AssetDatabase.LoadAssetAtPath(lastCreatedScriptPath); + if (asset != null) + { + EditorGUIUtility.PingObject(asset); + } + Event.current.Use(); // Consume the click + } + } + + void TryCreateScriptFromClipboard() + { + string clipboard = EditorGUIUtility.systemCopyBuffer; + + if (IsProbablyCSharp(clipboard)) + { + string folderPath = "Assets/Scripts/Generated"; + Directory.CreateDirectory(folderPath); + + string className = GetClassName(clipboard) ?? "GeneratedScript"; + string path = AssetDatabase.GenerateUniqueAssetPath($"{folderPath}/{className}.cs"); + + File.WriteAllText(path, clipboard); + AssetDatabase.Refresh(); + + statusMessage = $"Script created: {path}"; + lastCreatedScriptPath = path; + } + else + { + statusMessage = "Clipboard does not contain valid C# code."; + lastCreatedScriptPath = ""; + } + } + + bool IsProbablyCSharp(string text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + + // Basic heuristic checks + return Regex.IsMatch(text, @"\b(class|struct|interface|using|namespace)\b"); + } + + string GetClassName(string text) + { + Match match = Regex.Match(text, @"\bclass\s+(\w+)"); + return match.Success ? match.Groups[1].Value : null; + } + } +} From 8b590f2c74ee4af3424e08c1cc8dc466021a9711 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 25 Apr 2025 11:43:12 +0300 Subject: [PATCH 06/22] Create GameViewGridOverlay.cs --- .../Editor/Tools/GameViewGridOverlay.cs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs diff --git a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs new file mode 100644 index 0000000..a7f87ae --- /dev/null +++ b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs @@ -0,0 +1,45 @@ +// draws grid lines in the game view (useful for seeing the resolution of ui elements in the game view) +// usage: attach to a game object in the scene, set gameobject tag to "EditorOnly" to remove from builds + +using UnityEngine; + +namespace UnityLibrary.EditorTools +{ + [ExecuteAlways] + public class GameViewGridOverlay : MonoBehaviour + { +#if UNITY_EDITOR + public bool drawGrid = true; + + public int gridSpacingX = 64; + public int gridSpacingY = 64; + + public int startOffsetX = 0; + public int startOffsetY = 0; + + public Color gridColor = new Color(1f, 1f, 1f, 0.5f); + + private void OnGUI() + { + if (!drawGrid || Application.isPlaying) return; + + Color oldColor = GUI.color; + GUI.color = gridColor; + + // Horizontal lines + for (int y = startOffsetX; y < Screen.height; y += gridSpacingY) + { + GUI.DrawTexture(new Rect(0, y, Screen.width, 1), Texture2D.whiteTexture); + } + + // Vertical lines + for (int x = startOffsetY; x < Screen.width; x += gridSpacingX) + { + GUI.DrawTexture(new Rect(x, 0, 1, Screen.height), Texture2D.whiteTexture); + } + + GUI.color = oldColor; + } +#endif + } +} From 437db6375f25c0d47d08b8cf543dc3a7e5c754f0 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 25 Apr 2025 11:48:26 +0300 Subject: [PATCH 07/22] Create README.md --- Assets/Scripts/Editor/Tools/README.md | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/README.md diff --git a/Assets/Scripts/Editor/Tools/README.md b/Assets/Scripts/Editor/Tools/README.md new file mode 100644 index 0000000..20d6e2a --- /dev/null +++ b/Assets/Scripts/Editor/Tools/README.md @@ -0,0 +1,2 @@ +### GameViewGridOverlay.cs +![Image](https://github.com/user-attachments/assets/48fbced4-48e0-49fe-9acc-666f5449a958) From acb71e7a1e95430c371cdf3e054986f200a858c5 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 25 Apr 2025 12:31:50 +0300 Subject: [PATCH 08/22] Update GameViewGridOverlay.cs --- Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs index a7f87ae..143bedc 100644 --- a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs +++ b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs @@ -26,14 +26,14 @@ private void OnGUI() Color oldColor = GUI.color; GUI.color = gridColor; - // Horizontal lines - for (int y = startOffsetX; y < Screen.height; y += gridSpacingY) + // vertical lines + for (int y = startOffsetY; y < Screen.height; y += gridSpacingY) { GUI.DrawTexture(new Rect(0, y, Screen.width, 1), Texture2D.whiteTexture); } - // Vertical lines - for (int x = startOffsetY; x < Screen.width; x += gridSpacingX) + // horizontal lines + for (int x = startOffsetX; x < Screen.width; x += gridSpacingX) { GUI.DrawTexture(new Rect(x, 0, 1, Screen.height), Texture2D.whiteTexture); } From 69fe35d66df49842a2152e6bb6f7c472cdc2f939 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 25 Apr 2025 12:51:50 +0300 Subject: [PATCH 09/22] Update GameViewGridOverlay.cs - adding spacing support --- .../Editor/Tools/GameViewGridOverlay.cs | 35 ++++++++++++------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs index 143bedc..380bf2a 100644 --- a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs +++ b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs @@ -1,6 +1,3 @@ -// draws grid lines in the game view (useful for seeing the resolution of ui elements in the game view) -// usage: attach to a game object in the scene, set gameobject tag to "EditorOnly" to remove from builds - using UnityEngine; namespace UnityLibrary.EditorTools @@ -11,9 +8,15 @@ public class GameViewGridOverlay : MonoBehaviour #if UNITY_EDITOR public bool drawGrid = true; - public int gridSpacingX = 64; - public int gridSpacingY = 64; + [Header("Grid Cell Size (visible area)")] + public int gridSizeX = 64; + public int gridSizeY = 64; + + [Header("Spacing Between Cells (invisible gap)")] + public int spacingX = 16; + public int spacingY = 16; + [Header("Start Offsets")] public int startOffsetX = 0; public int startOffsetY = 0; @@ -26,16 +29,22 @@ private void OnGUI() Color oldColor = GUI.color; GUI.color = gridColor; - // vertical lines - for (int y = startOffsetY; y < Screen.height; y += gridSpacingY) - { - GUI.DrawTexture(new Rect(0, y, Screen.width, 1), Texture2D.whiteTexture); - } + int cellStrideX = gridSizeX + spacingX; + int cellStrideY = gridSizeY + spacingY; - // horizontal lines - for (int x = startOffsetX; x < Screen.width; x += gridSpacingX) + for (int y = startOffsetY; y + gridSizeY <= Screen.height; y += cellStrideY) { - GUI.DrawTexture(new Rect(x, 0, 1, Screen.height), Texture2D.whiteTexture); + for (int x = startOffsetX; x + gridSizeX <= Screen.width; x += cellStrideX) + { + // Left line + GUI.DrawTexture(new Rect(x, y, 1, gridSizeY), Texture2D.whiteTexture); + // Right line + GUI.DrawTexture(new Rect(x + gridSizeX - 1, y, 1, gridSizeY), Texture2D.whiteTexture); + // Top line + GUI.DrawTexture(new Rect(x, y, gridSizeX, 1), Texture2D.whiteTexture); + // Bottom line + GUI.DrawTexture(new Rect(x, y + gridSizeY - 1, gridSizeX, 1), Texture2D.whiteTexture); + } } GUI.color = oldColor; From 61f149cbada8d0668cf65d95ede339975156a439 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 25 Apr 2025 12:54:11 +0300 Subject: [PATCH 10/22] Update GameViewGridOverlay.cs fix: need to draw past screensize to see last edges --- .../Scripts/Editor/Tools/GameViewGridOverlay.cs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs index 380bf2a..06acb2e 100644 --- a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs +++ b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs @@ -32,17 +32,20 @@ private void OnGUI() int cellStrideX = gridSizeX + spacingX; int cellStrideY = gridSizeY + spacingY; - for (int y = startOffsetY; y + gridSizeY <= Screen.height; y += cellStrideY) + // Loop until start of the cell is beyond screen, not end of cell + for (int y = startOffsetY; y < Screen.height; y += cellStrideY) { - for (int x = startOffsetX; x + gridSizeX <= Screen.width; x += cellStrideX) + for (int x = startOffsetX; x < Screen.width; x += cellStrideX) { - // Left line + // Draw full box even if it goes beyond screen edges + + // Left GUI.DrawTexture(new Rect(x, y, 1, gridSizeY), Texture2D.whiteTexture); - // Right line + // Right GUI.DrawTexture(new Rect(x + gridSizeX - 1, y, 1, gridSizeY), Texture2D.whiteTexture); - // Top line + // Top GUI.DrawTexture(new Rect(x, y, gridSizeX, 1), Texture2D.whiteTexture); - // Bottom line + // Bottom GUI.DrawTexture(new Rect(x, y + gridSizeY - 1, gridSizeX, 1), Texture2D.whiteTexture); } } From 5ede7f09e9f63080223111ed2e09b4473e33dd92 Mon Sep 17 00:00:00 2001 From: mika Date: Mon, 28 Apr 2025 14:59:35 +0300 Subject: [PATCH 11/22] Create RectTransformCloner.cs --- .../Editor/Tools/RectTransformCloner.cs | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/RectTransformCloner.cs diff --git a/Assets/Scripts/Editor/Tools/RectTransformCloner.cs b/Assets/Scripts/Editor/Tools/RectTransformCloner.cs new file mode 100644 index 0000000..fd55097 --- /dev/null +++ b/Assets/Scripts/Editor/Tools/RectTransformCloner.cs @@ -0,0 +1,151 @@ +// clones UI RectTransform values from one GameObject to another in Unity Editor (only for identical hierarchy) + +using UnityEngine; +using UnityEditor; +using TMPro; +using UnityEngine.UI; + +namespace UnityLibrary.Tools +{ + public class RectTransformCloner : EditorWindow + { + private GameObject source; + private GameObject target; + private bool requireIdenticalNames = true; + private bool cloneTMPAlignment = false; + + [MenuItem("Tools/RectTransform Cloner")] + private static void ShowWindow() + { + var window = GetWindow(); + window.titleContent = new GUIContent("RectTransform Cloner"); + window.Show(); + } + + private void OnGUI() + { + GUILayout.Label("Clone RectTransform", EditorStyles.boldLabel); + + source = (GameObject)EditorGUILayout.ObjectField("Source", source, typeof(GameObject), true); + target = (GameObject)EditorGUILayout.ObjectField("Target", target, typeof(GameObject), true); + requireIdenticalNames = EditorGUILayout.Toggle("Require Identical Names", requireIdenticalNames); + cloneTMPAlignment = EditorGUILayout.Toggle("Clone TMP Alignment", cloneTMPAlignment); + + if (GUILayout.Button("Clone RectTransforms")) + { + if (source == null || target == null) + { + Debug.LogError("Source and Target must be assigned."); + return; + } + + string errorMessage; + if (!CompareHierarchies(source.transform, target.transform, out errorMessage)) + { + Debug.LogError("Source and Target hierarchies do not match!\n" + errorMessage, target); + return; + } + + Undo.RegisterFullObjectHierarchyUndo(target, "Clone RectTransform Values"); + CopyRectTransforms(source.transform, target.transform); + + Debug.Log("RectTransform values cloned successfully.", target); + + if (target.transform.parent != null) + { + RectTransform parentRect = target.transform.parent as RectTransform; + if (parentRect != null) + { + LayoutRebuilder.ForceRebuildLayoutImmediate(parentRect); + } + else + { + Debug.LogWarning("Target's parent is not a RectTransform, cannot force layout rebuild.", target); + } + } + else + { + Debug.LogWarning("Target has no parent, cannot force layout rebuild.", target); + } + EditorUtility.SetDirty(target); + SceneView.RepaintAll(); + } + } + + private bool CompareHierarchies(Transform source, Transform target, out string errorMessage) + { + errorMessage = ""; + + if (source.childCount != target.childCount) + { + errorMessage = $"Child count mismatch at {GetTransformPath(source)}: Source has {source.childCount}, Target has {target.childCount}"; + return false; + } + + for (int i = 0; i < source.childCount; i++) + { + var sourceChild = source.GetChild(i); + var targetChild = target.GetChild(i); + + if (requireIdenticalNames && sourceChild.name != targetChild.name) + { + errorMessage = $"Child name mismatch at {GetTransformPath(sourceChild)}: Source has '{sourceChild.name}', Target has '{targetChild.name}'"; + return false; + } + + if (!CompareHierarchies(sourceChild, targetChild, out errorMessage)) + { + return false; + } + } + + return true; + } + + private void CopyRectTransforms(Transform source, Transform target) + { + var sourceRect = source as RectTransform; + var targetRect = target as RectTransform; + + if (sourceRect != null && targetRect != null) + { + CopyRectTransformValues(sourceRect, targetRect); + + if (cloneTMPAlignment) + { + var sourceTMP = source.GetComponent(); + var targetTMP = target.GetComponent(); + if (sourceTMP != null && targetTMP != null) + { + Undo.RecordObject(targetTMP, "Clone TMP Alignment"); + targetTMP.alignment = sourceTMP.alignment; + } + } + } + + for (int i = 0; i < source.childCount; i++) + { + CopyRectTransforms(source.GetChild(i), target.GetChild(i)); + } + } + + private void CopyRectTransformValues(RectTransform source, RectTransform target) + { + target.anchoredPosition = source.anchoredPosition; + target.sizeDelta = source.sizeDelta; + target.anchorMin = source.anchorMin; + target.anchorMax = source.anchorMax; + target.pivot = source.pivot; + target.localRotation = source.localRotation; + target.localScale = source.localScale; + target.localPosition = source.localPosition; + } + + private string GetTransformPath(Transform t) + { + if (t.parent == null) + return t.name; + return GetTransformPath(t.parent) + "/" + t.name; + } + } +} From 4df3c80b60fc36d07a85ad241f2ca16c9aa277aa Mon Sep 17 00:00:00 2001 From: mika Date: Thu, 15 May 2025 21:34:22 +0300 Subject: [PATCH 12/22] Create FindWhoReferencesThisGameObject.cs --- .../Tools/FindWhoReferencesThisGameObject.cs | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs diff --git a/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs b/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs new file mode 100644 index 0000000..b2def9e --- /dev/null +++ b/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs @@ -0,0 +1,123 @@ +// finds what scripts reference a given GameObject in the scene (in events, public fields..) + +using System.Collections.Generic; +using System.Reflection; +using UnityEditor; +using UnityEngine; +using UnityEngine.Events; + +namespace UnityLibrary.Editor +{ + public class FindWhoReferencesThisGameObject : EditorWindow + { + private GameObject target; + private Vector2 scroll; + + private class ReferenceResult + { + public string message; + public GameObject owner; + } + + private List results = new List(); + + [MenuItem("Tools/UnityLibrary/Find References To GameObject")] + public static void ShowWindow() + { + var win = GetWindow("Find References"); + win.minSize = new Vector2(500, 300); + } + + private void OnGUI() + { + GUILayout.Label("Find scripts that reference this GameObject", EditorStyles.boldLabel); + target = EditorGUILayout.ObjectField("Target GameObject", target, typeof(GameObject), true) as GameObject; + + if (GUILayout.Button("Find References")) + { + results.Clear(); + if (target != null) + { + FindReferences(target); + } + else + { + Debug.LogWarning("Please assign a GameObject."); + } + } + + if (results.Count > 0) + { + GUILayout.Label("Results:", EditorStyles.boldLabel); + scroll = GUILayout.BeginScrollView(scroll, GUILayout.Height(400)); + foreach (var res in results) + { + if (GUILayout.Button(res.message, GUILayout.ExpandWidth(true))) + { + EditorGUIUtility.PingObject(res.owner); + Selection.activeGameObject = res.owner; + } + } + GUILayout.EndScrollView(); + } + } + + private void FindReferences(GameObject target) + { + var allObjects = UnityEngine.Object.FindObjectsOfType(true); + + foreach (var mono in allObjects) + { + if (mono == null || mono.gameObject == target) continue; + + var type = mono.GetType(); + var fields = type.GetFields(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance); + + foreach (var field in fields) + { + if (typeof(UnityEventBase).IsAssignableFrom(field.FieldType)) + { + var unityEvent = field.GetValue(mono) as UnityEventBase; + if (unityEvent != null) + { + int count = unityEvent.GetPersistentEventCount(); + for (int i = 0; i < count; i++) + { + var listener = unityEvent.GetPersistentTarget(i); + if (listener == target) + { + results.Add(new ReferenceResult + { + message = $"{mono.name} ({type.Name}) -> UnityEvent '{field.Name}'", + owner = mono.gameObject + }); + } + } + } + } + else if (typeof(UnityEngine.Object).IsAssignableFrom(field.FieldType)) + { + var value = field.GetValue(mono) as UnityEngine.Object; + if (value == target) + { + results.Add(new ReferenceResult + { + message = $"{mono.name} ({type.Name}) -> Field '{field.Name}'", + owner = mono.gameObject + }); + } + } + } + } + + if (results.Count == 0) + { + results.Add(new ReferenceResult + { + message = "No references found.", + owner = null + }); + } + } + } +} From 540d629be475e7e984b893013a5634c725c3ad74 Mon Sep 17 00:00:00 2001 From: mika Date: Thu, 15 May 2025 21:35:50 +0300 Subject: [PATCH 13/22] Update FindWhoReferencesThisGameObject.cs --- Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs b/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs index b2def9e..dd5b2c5 100644 --- a/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs +++ b/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs @@ -1,4 +1,4 @@ -// finds what scripts reference a given GameObject in the scene (in events, public fields..) +// find what scripts reference selected GameObject in the scene (in events, public fields..) using System.Collections.Generic; using System.Reflection; From 15f58004d059406de92b0c1e6668ba08c9e8a168 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 5 Dec 2025 14:35:07 +0200 Subject: [PATCH 14/22] Add SceneTextSearchWindow for scene text searching --- .../Editor/Tools/SceneTextSearchWindow.cs | 250 ++++++++++++++++++ 1 file changed, 250 insertions(+) create mode 100644 Assets/Scripts/Editor/Tools/SceneTextSearchWindow.cs diff --git a/Assets/Scripts/Editor/Tools/SceneTextSearchWindow.cs b/Assets/Scripts/Editor/Tools/SceneTextSearchWindow.cs new file mode 100644 index 0000000..8467f3e --- /dev/null +++ b/Assets/Scripts/Editor/Tools/SceneTextSearchWindow.cs @@ -0,0 +1,250 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using UnityEditor; +using UnityEngine; +using UnityEngine.UI; +using TMPro; + +namespace UnityLibrary.EditorTools +{ + public class SceneTextSearchWindow : EditorWindow + { + [Serializable] + private class SearchResult + { + public Component component; + public string text; + } + + private string searchTerm = string.Empty; + private string previousSearchTerm = string.Empty; + private bool caseSensitive = false; + private bool automaticSearch = true; + + private readonly List results = new List(); + private readonly HashSet seenComponents = new HashSet(); + private Vector2 scrollPos; + + [MenuItem("Tools/UnityLibrary/Scene Text Search")] + public static void Open() + { + var window = GetWindow("Scene Text Search"); + window.minSize = new Vector2(600, 300); + } + + private void OnGUI() + { + EditorGUILayout.LabelField("Search text in loaded scenes", EditorStyles.boldLabel); + EditorGUILayout.Space(); + + EditorGUILayout.BeginHorizontal(); + + EditorGUILayout.LabelField("Search term", GUILayout.Width(80)); + + string newSearchTerm = EditorGUILayout.TextField(searchTerm); + + if (GUILayout.Button("Search", GUILayout.Width(80))) + { + DoSearch(); + } + + EditorGUILayout.EndHorizontal(); + + if (newSearchTerm != searchTerm) + { + searchTerm = newSearchTerm; + + if (automaticSearch) + { + if (!string.IsNullOrEmpty(searchTerm) && searchTerm.Length > 1) + { + DoSearch(); + } + else + { + results.Clear(); + seenComponents.Clear(); + } + } + + previousSearchTerm = searchTerm; + } + + EditorGUILayout.BeginHorizontal(); + caseSensitive = EditorGUILayout.Toggle("Case sensitive", caseSensitive); + automaticSearch = EditorGUILayout.Toggle("Automatic search", automaticSearch); + EditorGUILayout.EndHorizontal(); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField($"Results: {results.Count}", EditorStyles.boldLabel); + + scrollPos = EditorGUILayout.BeginScrollView(scrollPos); + foreach (var r in results) + { + if (r.component == null) + continue; + + EditorGUILayout.BeginHorizontal(); + + EditorGUILayout.ObjectField(r.component, typeof(Component), true, GUILayout.Width(220)); + + using (new EditorGUI.DisabledScope(true)) + { + EditorGUILayout.TextField(Truncate(r.text, 200)); + } + + EditorGUILayout.EndHorizontal(); + } + EditorGUILayout.EndScrollView(); + } + + private void DoSearch() + { + results.Clear(); + seenComponents.Clear(); + + if (string.IsNullOrEmpty(searchTerm)) + return; + + string term = caseSensitive ? searchTerm : searchTerm.ToLowerInvariant(); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + + // UnityEngine.UI.Text + SearchComponents(t => t.text, term); + + // TMP UGUI + SearchComponents(t => t.text, term); + + // TMP 3D + SearchComponents(t => t.text, term); + + // Legacy TextMesh + SearchComponents(t => t.text, term); + + // Generic "other text components" with a string 'text' property/field + SearchGenericTextComponents(term); + + stopwatch.Stop(); + Debug.Log($"SceneTextSearchWindow: Found {results.Count} results in {stopwatch.ElapsedMilliseconds} ms"); + } + + private void SearchComponents(Func getText, string term) where T : Component + { + var objects = Resources.FindObjectsOfTypeAll(); + foreach (var comp in objects) + { + if (!IsSceneObject(comp)) + continue; + + string value = getText(comp); + if (StringMatches(value, term)) + { + AddResult(comp, value); + } + } + } + + private void SearchGenericTextComponents(string term) + { + var monos = Resources.FindObjectsOfTypeAll(); + foreach (var mb in monos) + { + if (!IsSceneObject(mb)) + continue; + + if (seenComponents.Contains(mb)) + continue; + + Type type = mb.GetType(); + + try + { + var prop = type.GetProperty("text", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (prop != null && prop.PropertyType == typeof(string) && prop.CanRead) + { + string value = prop.GetValue(mb, null) as string; + if (StringMatches(value, term)) + { + AddResult(mb, value); + continue; + } + } + } + catch + { + } + + try + { + var field = type.GetField("text", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); + if (field != null && field.FieldType == typeof(string)) + { + string value = field.GetValue(mb) as string; + if (StringMatches(value, term)) + { + AddResult(mb, value); + } + } + } + catch + { + } + } + } + + private bool StringMatches(string value, string term) + { + if (string.IsNullOrEmpty(value)) + return false; + + if (!caseSensitive) + value = value.ToLowerInvariant(); + + return value.Contains(term); + } + + private void AddResult(Component component, string text) + { + if (component == null) + return; + + if (seenComponents.Add(component)) + { + results.Add(new SearchResult + { + component = component, + text = text + }); + } + } + + private static bool IsSceneObject(Component comp) + { + if (comp == null) + return false; + + var go = comp.gameObject; + if (go == null) + return false; + + if (EditorUtility.IsPersistent(go)) + return false; + + if (!go.scene.IsValid() || !go.scene.isLoaded) + return false; + + return true; + } + + private static string Truncate(string input, int maxLength) + { + if (string.IsNullOrEmpty(input)) + return input; + if (input.Length <= maxLength) + return input; + return input.Substring(0, maxLength) + "..."; + } + } +} From e08b7a9587c735d5befb476adbcb2e102fc2dc88 Mon Sep 17 00:00:00 2001 From: mika Date: Fri, 5 Dec 2025 14:35:52 +0200 Subject: [PATCH 15/22] Add images for GameViewGridOverlay and SceneTextSearchWindow --- Assets/Scripts/Editor/Tools/README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Assets/Scripts/Editor/Tools/README.md b/Assets/Scripts/Editor/Tools/README.md index 20d6e2a..fcdb9c8 100644 --- a/Assets/Scripts/Editor/Tools/README.md +++ b/Assets/Scripts/Editor/Tools/README.md @@ -1,2 +1,5 @@ ### GameViewGridOverlay.cs ![Image](https://github.com/user-attachments/assets/48fbced4-48e0-49fe-9acc-666f5449a958) + +### SceneTextSearchWindow.cs +image From 936e19d819f2b47e8af96bf48f0ec9424c625618 Mon Sep 17 00:00:00 2001 From: mika Date: Thu, 29 Jan 2026 11:46:41 +0200 Subject: [PATCH 16/22] 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 17/22] 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 18/22] 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 19/22] 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 20/22] 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 21/22] 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 22/22] 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 + } + } +}