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 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; + } } } 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/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/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 + } + } +}