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 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); + } + } +} 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 { } + } +} diff --git a/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs b/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs new file mode 100644 index 0000000..b3f4b15 --- /dev/null +++ b/Assets/Scripts/Editor/Tools/FindWhoReferencesThisGameObject.cs @@ -0,0 +1,167 @@ +// find what scripts reference selected 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 = Object.FindObjectsByType(findObjectsInactive: FindObjectsInactive.Include, sortMode: FindObjectsSortMode.None); + + 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 + }); + } + } + } + + continue; + } + + if (!typeof(UnityEngine.Object).IsAssignableFrom(field.FieldType)) + continue; + + var value = field.GetValue(mono) as UnityEngine.Object; + if (ReferencesTarget(value, target)) + { + results.Add(new ReferenceResult + { + 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) + { + results.Add(new ReferenceResult + { + message = "No references found.", + owner = null + }); + } + } + + 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; + } + } +} diff --git a/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs new file mode 100644 index 0000000..06acb2e --- /dev/null +++ b/Assets/Scripts/Editor/Tools/GameViewGridOverlay.cs @@ -0,0 +1,57 @@ +using UnityEngine; + +namespace UnityLibrary.EditorTools +{ + [ExecuteAlways] + public class GameViewGridOverlay : MonoBehaviour + { +#if UNITY_EDITOR + public bool drawGrid = true; + + [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; + + 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; + + int cellStrideX = gridSizeX + spacingX; + int cellStrideY = gridSizeY + spacingY; + + // 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 < Screen.width; x += cellStrideX) + { + // Draw full box even if it goes beyond screen edges + + // Left + GUI.DrawTexture(new Rect(x, y, 1, gridSizeY), Texture2D.whiteTexture); + // Right + GUI.DrawTexture(new Rect(x + gridSizeX - 1, y, 1, gridSizeY), Texture2D.whiteTexture); + // Top + GUI.DrawTexture(new Rect(x, y, gridSizeX, 1), Texture2D.whiteTexture); + // Bottom + GUI.DrawTexture(new Rect(x, y + gridSizeY - 1, gridSizeX, 1), Texture2D.whiteTexture); + } + } + + GUI.color = oldColor; + } +#endif + } +} 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); + } + } + +} 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; + } + } +} 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)); + } + } +} 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; + } + } + } + } +} diff --git a/Assets/Scripts/Editor/Tools/README.md b/Assets/Scripts/Editor/Tools/README.md new file mode 100644 index 0000000..fcdb9c8 --- /dev/null +++ b/Assets/Scripts/Editor/Tools/README.md @@ -0,0 +1,5 @@ +### GameViewGridOverlay.cs +![Image](https://github.com/user-attachments/assets/48fbced4-48e0-49fe-9acc-666f5449a958) + +### SceneTextSearchWindow.cs +image 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; + } + } +} 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; + } + } + } +} 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) + "..."; + } + } +} 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 + } + } +} 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" +} 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" +}