using System.Collections.Generic; using System.Linq; using UnityEngine; [AddComponentMenu("Tools/Smart Material Cloner")] [ExecuteAlways] // lets you run it in edit mode public class SmartMaterialCloner : MonoBehaviour { [Header("Copy from this hierarchy (GOOD materials)")] public Transform SourceRoot; [Header("Matching Options")] public bool IncludeInactive = true; public bool MatchByMeshFirst = true; public bool NormalizeNames = true; public bool PreferSameSlotCount = true; public bool BreakTiesByNearest = true; [Header("Output")] public bool VerboseLog = true; // 覧 Context menu actions you can run from the component's header 覧 [ContextMenu("Preview Matches")] public void PreviewMatches() => ApplyInternal(previewOnly: true); [ContextMenu("Apply From Source")] public void ApplyFromSource() => ApplyInternal(previewOnly: false); // 覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧覧 private class SrcEntry { public Renderer renderer; public string nameKey; public string meshKey; public int slotCount; public Vector3 worldPos; public Material[] mats; } private void ApplyInternal(bool previewOnly) { if (SourceRoot == null) { Debug.LogError("[SmartMaterialCloner] Please assign SourceRoot (the good hierarchy)."); return; } var sourceRenderers = SourceRoot.GetComponentsInChildren(IncludeInactive); if (sourceRenderers.Length == 0) { Debug.LogWarning("[SmartMaterialCloner] No renderers found under SourceRoot."); return; } // Build indices var srcList = new List(sourceRenderers.Length); foreach (var r in sourceRenderers) { srcList.Add(new SrcEntry { renderer = r, nameKey = NormalizeNames ? Normalize(r.gameObject.name) : r.gameObject.name, meshKey = GetMeshKey(r), slotCount = GetSlotCount(r), worldPos = r.transform.position, mats = CloneArray(r.sharedMaterials) }); } var byMesh = srcList.Where(e => !string.IsNullOrEmpty(e.meshKey)) .GroupBy(e => e.meshKey) .ToDictionary(g => g.Key, g => g.ToList()); var byName = srcList.GroupBy(e => e.nameKey) .ToDictionary(g => g.Key, g => g.ToList()); var targets = GetComponentsInChildren(IncludeInactive); int applied = 0, missed = 0, previewed = 0; foreach (var t in targets) { var tNameKey = NormalizeNames ? Normalize(t.gameObject.name) : t.gameObject.name; var tMeshKey = GetMeshKey(t); int tSlots = GetSlotCount(t); Vector3 tPos = t.transform.position; var picked = PickBestCandidate(tMeshKey, tNameKey, tSlots, tPos, byMesh, byName); if (picked == null) { missed++; if (VerboseLog) Debug.Log($"[SmartMaterialCloner] Missed: {FullPath(t.transform)} (mesh='{tMeshKey ?? "none"}')"); continue; } if (previewOnly) { previewed++; if (VerboseLog) Debug.Log($"[SmartMaterialCloner][PREVIEW] {FullPath(t.transform)} <= {FullPath(picked.renderer.transform)} ({picked.mats.Length} mats)"); continue; } t.sharedMaterials = picked.mats; // no Editor API; works in edit or play mode applied++; if (VerboseLog) Debug.Log($"[SmartMaterialCloner] Applied to {FullPath(t.transform)} from {FullPath(picked.renderer.transform)}"); } Debug.Log($"[SmartMaterialCloner] {(previewOnly ? $"Previewed {previewed}" : $"Applied {applied}")}, Missed {missed}"); } private SrcEntry PickBestCandidate( string tMeshKey, string tNameKey, int tSlots, Vector3 tPos, Dictionary> byMesh, Dictionary> byName) { List candidates = null; if (MatchByMeshFirst && !string.IsNullOrEmpty(tMeshKey) && byMesh.TryGetValue(tMeshKey, out candidates)) return PickBest(candidates, tSlots, tPos); if (byName.TryGetValue(tNameKey, out candidates)) return PickBest(candidates, tSlots, tPos); return null; } private SrcEntry PickBest(List candidates, int targetSlots, Vector3 targetPos) { if (candidates == null || candidates.Count == 0) return null; IEnumerable filtered = candidates; if (PreferSameSlotCount) { var slotMatches = candidates.Where(c => c.slotCount == targetSlots).ToList(); if (slotMatches.Count > 0) filtered = slotMatches; } if (BreakTiesByNearest) { float bestDist = float.MaxValue; SrcEntry best = null; foreach (var c in filtered) { float d = (c.worldPos - targetPos).sqrMagnitude; if (d < bestDist) { bestDist = d; best = c; } } return best; } return filtered.First(); } private static int GetSlotCount(Renderer r) => r && r.sharedMaterials != null ? r.sharedMaterials.Length : 0; private static string GetMeshKey(Renderer r) { if (!r) return null; Mesh mesh = null; if (r is SkinnedMeshRenderer sk) mesh = sk.sharedMesh; else { var mf = r.GetComponent(); if (mf) mesh = mf.sharedMesh; } if (!mesh) return null; // Runtime-safe key (no AssetDatabase) return $"{mesh.name}::submeshes={mesh.subMeshCount}"; } private static string Normalize(string name) { if (string.IsNullOrEmpty(name)) return name; string n = name; if (n.EndsWith(" (Clone)")) n = n.Substring(0, n.Length - " (Clone)".Length); int idxOpen = n.LastIndexOf(" ("); if (idxOpen >= 0 && n.EndsWith(")")) { string inside = n.Substring(idxOpen + 2, n.Length - idxOpen - 3); if (int.TryParse(inside, out _)) n = n.Substring(0, idxOpen); } return n.Trim(); } private static string FullPath(Transform t) { var stack = new System.Collections.Generic.Stack(); while (t != null) { stack.Push(t.name); t = t.parent; } return string.Join("/", stack); } private static Material[] CloneArray(Material[] src) => src == null ? new Material[0] : (Material[])src.Clone(); }