From f924f4f0d702a43c199f405ba92a73b88310b9ee Mon Sep 17 00:00:00 2001 From: HungDK <> Date: Thu, 6 Nov 2025 16:53:36 +0700 Subject: [PATCH] Add extruded mesh collider tool --- Assets/Editor/MeshColliderGeneratorWindow.cs | 301 ++++++++++++++++++ .../MeshColliderGeneratorWindow.cs.meta | 2 + 2 files changed, 303 insertions(+) create mode 100644 Assets/Editor/MeshColliderGeneratorWindow.cs create mode 100644 Assets/Editor/MeshColliderGeneratorWindow.cs.meta diff --git a/Assets/Editor/MeshColliderGeneratorWindow.cs b/Assets/Editor/MeshColliderGeneratorWindow.cs new file mode 100644 index 0000000000..78294e6622 --- /dev/null +++ b/Assets/Editor/MeshColliderGeneratorWindow.cs @@ -0,0 +1,301 @@ +using System.Collections.Generic; +using System.IO; +using UnityEditor; +using UnityEngine; + +public class MeshColliderGeneratorWindow : EditorWindow +{ + private const string GeneratedMeshFolder = "Assets/GeneratedColliders"; + [SerializeField] + private List targetRoots = new List(); + + [SerializeField] + private bool includeInactive = true; + + [SerializeField] + private float xScale = 1f; + + [SerializeField] + private bool skipIfExists = true; + + [SerializeField] + private bool setConvex = false; + + [SerializeField] + private bool extrudeAlongNormals = false; + + [SerializeField] + private float extrusionDistance = 0f; + + private SerializedObject serializedObjectRef; + private SerializedProperty targetsProp; + + [MenuItem("Tools/Mesh Collider Generator")] + public static void ShowWindow() + { + var window = GetWindow(true, "Mesh Collider Generator"); + window.minSize = new Vector2(420, 320); + window.Show(); + } + + private void OnEnable() + { + serializedObjectRef = new SerializedObject(this); + targetsProp = serializedObjectRef.FindProperty("targetRoots"); + } + + private void OnGUI() + { + serializedObjectRef.Update(); + + EditorGUILayout.LabelField("Target GameObjects", EditorStyles.boldLabel); + EditorGUILayout.HelpBox("Assign one or more root GameObjects. All children will be scanned.", MessageType.Info); + EditorGUILayout.PropertyField(targetsProp, true); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Options", EditorStyles.boldLabel); + includeInactive = EditorGUILayout.Toggle(new GUIContent("Include Inactive", "Scan inactive children too"), includeInactive); + xScale = EditorGUILayout.FloatField(new GUIContent("X Scale", "Local X scale for the generated collider GameObject"), Mathf.Max(0f, xScale)); + skipIfExists = EditorGUILayout.Toggle(new GUIContent("Skip If MeshCollider Exists", "Skip if the target object already has a MeshCollider"), skipIfExists); + setConvex = EditorGUILayout.Toggle(new GUIContent("Set Convex", "Set MeshCollider.convex on generated colliders"), setConvex); + + EditorGUILayout.Space(); + EditorGUILayout.LabelField("Extrusion", EditorStyles.boldLabel); + extrudeAlongNormals = EditorGUILayout.Toggle(new GUIContent("Extrude Along Normals", "If enabled, duplicates the mesh and offsets vertices along their normals for the collider"), extrudeAlongNormals); + using (new EditorGUI.DisabledScope(!extrudeAlongNormals)) + { + extrusionDistance = EditorGUILayout.FloatField(new GUIContent("Extrusion Distance", "Distance to offset vertices along normals (units). Positive = expand, negative = shrink."), extrusionDistance); + } + + EditorGUILayout.Space(); + using (new EditorGUI.DisabledScope(targetsProp.arraySize == 0)) + { + if (GUILayout.Button("Scan And Generate")) + { + GenerateForTargets(); + } + } + + serializedObjectRef.ApplyModifiedProperties(); + } + + private void GenerateForTargets() + { + var totalCreated = 0; + try + { + Undo.IncrementCurrentGroup(); + var undoGroup = Undo.GetCurrentGroup(); + + for (int i = 0; i < targetRoots.Count; i++) + { + var root = targetRoots[i]; + if (root == null) + { + continue; + } + + var meshFilters = root.GetComponentsInChildren(includeInactive); + foreach (var meshFilter in meshFilters) + { + if (meshFilter == null) + { + continue; + } + + var meshRenderer = meshFilter.GetComponent(); + if (meshRenderer == null) + { + continue; + } + + var sourceMesh = meshFilter.sharedMesh; + if (sourceMesh == null) + { + continue; + } + + var parent = meshFilter.transform; + + var targetGo = parent.gameObject; + if (skipIfExists && targetGo.GetComponent() != null) + { + continue; + } + + var meshCollider = Undo.AddComponent(targetGo); + + var workingMesh = sourceMesh; + if (extrudeAlongNormals && !Mathf.Approximately(extrusionDistance, 0f)) + { + var extruded = CreateExtrudedMesh(workingMesh, extrusionDistance); + if (extruded != null) + { + workingMesh = extruded; + } + } + + var uniformScale = Mathf.Approximately(xScale, 0f) ? 0.0001f : xScale; + if (!Mathf.Approximately(uniformScale, 1f)) + { + var scaled = CreateScaledMesh(workingMesh, uniformScale); + if (scaled != null) + { + workingMesh = scaled; + } + } + + meshCollider.sharedMesh = workingMesh; + meshCollider.convex = setConvex; + + totalCreated++; + } + } + + Undo.CollapseUndoOperations(Undo.GetCurrentGroup()); + EditorUtility.DisplayDialog("Mesh Collider Generator", $"Created {totalCreated} collider object(s).", "OK"); + } + catch (System.SystemException e) + { + Debug.LogError($"Mesh Collider generation failed: {e.Message}\n{e}"); + } + } + + private static Mesh CreateExtrudedMesh(Mesh source, float distance) + { + if (source == null) + { + return null; + } + + if (!source.isReadable) + { + Debug.LogWarning($"Source mesh '{source.name}' is not readable. Using original mesh for collider."); + return null; + } + + var vertices = source.vertices; + var normals = source.normals; + var triangles = source.triangles; + + if (normals == null || normals.Length != vertices.Length) + { + var temp = new Mesh(); + temp.vertices = vertices; + temp.triangles = triangles; + temp.RecalculateNormals(); + normals = temp.normals; + } + + var newVertices = new Vector3[vertices.Length]; + for (int i = 0; i < vertices.Length; i++) + { + newVertices[i] = vertices[i] + normals[i] * distance; + } + + var extruded = new Mesh(); + extruded.name = string.IsNullOrEmpty(source.name) ? "ExtrudedMesh" : source.name + "_Extruded"; + extruded.vertices = newVertices; + extruded.triangles = triangles; + extruded.RecalculateBounds(); + + SaveMeshAsset(extruded, source); + + return extruded; + } + + private static Mesh CreateScaledMesh(Mesh source, float uniformScale) + { + if (source == null) + { + return null; + } + + if (Mathf.Approximately(uniformScale, 1f)) + { + return null; + } + + if (!source.isReadable) + { + Debug.LogWarning($"Source mesh '{source.name}' is not readable. Using original mesh for collider."); + return null; + } + + var vertices = source.vertices; + var triangles = source.triangles; + + var newVertices = new Vector3[vertices.Length]; + for (int i = 0; i < vertices.Length; i++) + { + newVertices[i] = vertices[i] * uniformScale; + } + + var scaled = new Mesh(); + scaled.name = string.IsNullOrEmpty(source.name) ? "ScaledMesh" : source.name + "_Scaled_Collider"; + scaled.vertices = newVertices; + scaled.triangles = triangles; + scaled.RecalculateBounds(); + SaveMeshAsset(scaled, source); + return scaled; + } + + private static void SaveMeshAsset(Mesh mesh, Mesh sourceForPath) + { + if (mesh == null) + { + return; + } + + string baseFolder = GeneratedMeshFolder; + if (sourceForPath != null) + { + var srcPath = AssetDatabase.GetAssetPath(sourceForPath); + if (!string.IsNullOrEmpty(srcPath)) + { + var dir = Path.GetDirectoryName(srcPath); + if (!string.IsNullOrEmpty(dir) && AssetDatabase.IsValidFolder(dir.Replace('\\','/'))) + { + baseFolder = dir.Replace('\\','/'); + } + } + } + + if (!AssetDatabase.IsValidFolder(baseFolder)) + { + // Fallback: ensure default folder exists + if (!AssetDatabase.IsValidFolder(GeneratedMeshFolder)) + { + var parts = GeneratedMeshFolder.Split('/'); + for (int i = 1; i < parts.Length; i++) + { + var parentPath = string.Join("/", parts, 0, i); + var thisFolder = string.Join("/", parts, 0, i + 1); + if (!AssetDatabase.IsValidFolder(thisFolder)) + { + AssetDatabase.CreateFolder(parentPath, parts[i]); + } + } + } + baseFolder = GeneratedMeshFolder; + } + + var sanitizedName = SanitizeFileName(string.IsNullOrEmpty(mesh.name) ? "GeneratedMesh" : mesh.name); + var combined = baseFolder.EndsWith("/") ? baseFolder + sanitizedName + ".asset" : baseFolder + "/" + sanitizedName + ".asset"; + var path = AssetDatabase.GenerateUniqueAssetPath(combined.Replace('\\','/')); + AssetDatabase.CreateAsset(mesh, path); + AssetDatabase.SaveAssets(); + } + + private static string SanitizeFileName(string name) + { + foreach (var c in Path.GetInvalidFileNameChars()) + { + name = name.Replace(c, '_'); + } + return name; + } +} + + + diff --git a/Assets/Editor/MeshColliderGeneratorWindow.cs.meta b/Assets/Editor/MeshColliderGeneratorWindow.cs.meta new file mode 100644 index 0000000000..09cf4da629 --- /dev/null +++ b/Assets/Editor/MeshColliderGeneratorWindow.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cc28fa1b89fc42f4a96da9be095c6012 \ No newline at end of file