using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
namespace PotaToon.Editor
{
///
/// Utility to calculate bounds for each submesh of all meshes under a root object.
///
public static class PotaToonMeshBoundsUtils
{
[Serializable]
public struct SubmeshBoundsInfo
{
public Renderer renderer;
public Bounds localBounds;
public Bounds worldBounds;
}
///
/// Computes per-submesh bounds for all meshes under .
///
/// Root object whose children will be scanned.
/// Include inactive children.
/// Bake skinned meshes to capture deformed vertices.
/// List of SubmeshBoundsInfo entries.
public static List ComputeMeshBounds(GameObject root, bool includeInactive = true, bool bakeSkinnedMeshes = false)
{
var results = new List();
if (root == null)
return results;
// Initialize rotation
var originalRotation = root.transform.rotation;
root.transform.rotation = Quaternion.identity;
// MeshFilter + MeshRenderer pairs
var meshFilters = root.GetComponentsInChildren(includeInactive);
foreach (var mf in meshFilters)
{
var mr = mf.GetComponent();
if (mr == null)
continue;
var mesh = mf.sharedMesh;
if (mesh == null)
continue;
ComputeForMesh(results, mr, mesh, mr.transform.localToWorldMatrix);
}
// Skinned meshes
var skinned = root.GetComponentsInChildren(includeInactive);
foreach (var smr in skinned)
{
var mesh = smr.sharedMesh;
if (mesh == null)
continue;
Mesh source = mesh;
Mesh baked = null;
try
{
if (bakeSkinnedMeshes)
{
baked = new Mesh();
smr.BakeMesh(baked, true);
source = baked;
}
ComputeForMesh(results, smr, source, smr.transform.localToWorldMatrix);
}
finally
{
if (baked != null)
{
UnityEngine.Object.DestroyImmediate(baked);
}
}
}
// Restore rotation
root.transform.rotation = originalRotation;
return results;
}
[Serializable]
public struct RendererBoundsComparison
{
public Renderer renderer; // Target renderer
public Bounds rendererWorldBounds; // Current renderer.bounds (world space)
public Bounds computedWorldBounds; // Combined computed bounds from all submeshes (world space)
}
///
/// Compares current Renderer.bounds (world) against calculated bounds (combined across submeshes, world) for each renderer.
///
public static List CompareRendererAndComputedBounds(GameObject root, bool includeInactive = true, bool bakeSkinnedMeshes = false)
{
var submeshInfos = ComputeMeshBounds(root, includeInactive, bakeSkinnedMeshes);
var results = new List();
if (submeshInfos.Count == 0)
return results;
// Group by renderer
var grouped = new Dictionary>();
foreach (var info in submeshInfos)
{
if (!grouped.TryGetValue(info.renderer, out var list))
{
list = new List();
grouped.Add(info.renderer, list);
}
list.Add(info);
}
foreach (var kvp in grouped)
{
var renderer = kvp.Key;
var list = kvp.Value;
if (list.Count == 0)
continue;
// Combine all computed submesh bounds
Bounds combinedWorld = list[0].worldBounds;
for (int i = 1; i < list.Count; i++)
{
combinedWorld.Encapsulate(list[i].worldBounds);
}
results.Add(new RendererBoundsComparison
{
renderer = renderer,
rendererWorldBounds = renderer.bounds,
computedWorldBounds = combinedWorld,
});
}
return results;
}
///
/// Returns a list of renderers whose computed WORLD bounds differ from current Renderer.bounds by
/// at least (20% by default) when comparing overall size magnitude.
/// Uses the magnitude of Bounds.size (diagonal length) for comparison.
///
/// Root GameObject to analyze.
/// Relative difference threshold (e.g., 0.2 for 20%).
/// Include inactive children.
/// Bake skinned meshes to account for current deformation.
/// Subset of with differences >= threshold.
public static List FindBoundsMismatches(GameObject root, float thresholdRatio = 0.2f, bool includeInactive = true, bool bakeSkinnedMeshes = false)
{
var all = CompareRendererAndComputedBounds(root, includeInactive, bakeSkinnedMeshes);
var flagged = new List();
foreach (var item in all)
{
if (ExceedsThreshold(item.rendererWorldBounds, item.computedWorldBounds, thresholdRatio))
{
flagged.Add(item);
}
}
return flagged;
}
private static bool ExceedsThreshold(in Bounds currentWorld, in Bounds computedWorld, float threshold)
{
const float eps = 1e-6f;
var a = currentWorld.size.magnitude; // diagonal length
var b = computedWorld.size.magnitude;
float rel = Mathf.Abs(b - a) / Mathf.Max(Mathf.Abs(a), eps);
return rel >= threshold;
}
///
/// Applies computed bounds to meshes under .
/// Combines per-submesh world bounds. (same as CompareRendererAndComputedBounds)
///
public static void ApplyComputedBoundsToMeshes(GameObject root, bool includeInactive, bool bakeSkinnedMeshes)
{
var submeshInfos = ComputeMeshBounds(root, includeInactive, bakeSkinnedMeshes);
if (submeshInfos.Count == 0)
return;
// Group by renderer
var grouped = new Dictionary>();
foreach (var info in submeshInfos)
{
if (!grouped.TryGetValue(info.renderer, out var list))
{
list = new List();
grouped.Add(info.renderer, list);
}
list.Add(info);
}
// Initialize rotation
var originalRotation = root.transform.rotation;
root.transform.rotation = Quaternion.identity;
foreach (var kvp in grouped)
{
var renderer = kvp.Key;
var list = kvp.Value;
if (list.Count == 0)
continue;
// Combine WORLD-space bounds across submeshes (match CompareRendererAndComputedBounds)
Bounds combinedLocal = list[0].localBounds;
Bounds combinedWorld = list[0].worldBounds;
for (int i = 1; i < list.Count; i++)
{
combinedLocal.Encapsulate(list[i].localBounds);
combinedWorld.Encapsulate(list[i].worldBounds);
}
// Apply to appropriate target
if (renderer is SkinnedMeshRenderer smr)
{
Undo.RecordObject(smr, "Apply Computed Bounds (Skinned)");
// 1. Set local bounds first to get a position diff since we don't know the position of rigged bone.
smr.localBounds = combinedLocal;
// 2. Apply offset & Set the extents with world bounds
combinedLocal.center += (combinedWorld.center - smr.bounds.center);
combinedLocal.extents = combinedWorld.extents;
smr.localBounds = combinedLocal;
EditorUtility.SetDirty(smr);
}
else if (renderer is MeshRenderer mr)
{
var mf = mr.GetComponent();
if (mf != null && mf.sharedMesh != null)
{
var mesh = mf.sharedMesh;
Undo.RecordObject(mesh, "Apply Computed Bounds (Mesh)");
mesh.bounds = combinedWorld;
EditorUtility.SetDirty(mesh);
}
}
}
// Restore rotation
root.transform.rotation = originalRotation;
}
private static void ComputeForMesh(List sink, Renderer renderer, Mesh mesh, Matrix4x4 localToWorld)
{
var vertices = mesh.vertices;
int subMeshCount = mesh.subMeshCount;
if (vertices == null || vertices.Length == 0 || subMeshCount == 0)
return;
for (int si = 0; si < subMeshCount; si++)
{
// Use indices for the submesh to gather only referenced vertices
var indices = mesh.GetIndices(si);
if (indices == null || indices.Length == 0)
continue;
Vector3 minLocal = new Vector3(float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity);
Vector3 maxLocal = new Vector3(float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity);
Vector3 minWorld = new Vector3(float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity);
Vector3 maxWorld = new Vector3(float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity);
for (int i = 0; i < indices.Length; i++)
{
int vi = indices[i];
if ((uint)vi >= (uint)vertices.Length) // safety against malformed index buffers
continue;
Vector3 vLocal = vertices[vi];
Vector3 vWorld = localToWorld.MultiplyPoint3x4(vLocal);
// expand local bounds
if (vLocal.x < minLocal.x) minLocal.x = vLocal.x;
if (vLocal.y < minLocal.y) minLocal.y = vLocal.y;
if (vLocal.z < minLocal.z) minLocal.z = vLocal.z;
if (vLocal.x > maxLocal.x) maxLocal.x = vLocal.x;
if (vLocal.y > maxLocal.y) maxLocal.y = vLocal.y;
if (vLocal.z > maxLocal.z) maxLocal.z = vLocal.z;
// expand world bounds
if (vWorld.x < minWorld.x) minWorld.x = vWorld.x;
if (vWorld.y < minWorld.y) minWorld.y = vWorld.y;
if (vWorld.z < minWorld.z) minWorld.z = vWorld.z;
if (vWorld.x > maxWorld.x) maxWorld.x = vWorld.x;
if (vWorld.y > maxWorld.y) maxWorld.y = vWorld.y;
if (vWorld.z > maxWorld.z) maxWorld.z = vWorld.z;
}
// If no valid indices, skip
if (!IsFinite(minLocal) || !IsFinite(maxLocal))
continue;
var localCenter = (minLocal + maxLocal) * 0.5f;
var localSize = (maxLocal - minLocal);
var worldCenter = (minWorld + maxWorld) * 0.5f;
var worldSize = (maxWorld - minWorld);
sink.Add(new SubmeshBoundsInfo
{
renderer = renderer,
localBounds = new Bounds(localCenter, localSize),
worldBounds = new Bounds(worldCenter, worldSize),
});
}
}
private static bool IsFinite(in Vector3 v)
{
return float.IsFinite(v.x) && float.IsFinite(v.y) && float.IsFinite(v.z);
}
}
}