From 9c4acc2ca8e90423923cc038a507ae7a3195159e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 00:11:41 +0000 Subject: [PATCH 1/2] Collision at the root (sidecar): place nodes whatever Read/Write is Every import refused the listed nodes as "unreadable" on a model whose Read/Write is off (Unity's default), and PlaceInSidecar then dropped them from the sidecar. In OnPostprocessModel a mesh's isReadable follows that setting, yet Unity lets the import change the mesh: BakeMatrix takes an `importing` flag that skips the readability guard, and ApplyOnImport no longer refuses an unreadable mesh. BakeMatrix is split into its normals, tangents and winding parts (same behaviour), bringing its cognitive complexity under Sonar's limit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L9SukSbP376zkY7oEgVTpH --- CHANGELOG.md | 1 + Editor/Mesh/CollisionRootPlacement.cs | 12 +--- Editor/Mesh/MeshTransform.cs | 81 ++++++++++++++++----------- 3 files changed, 53 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf130bd8..645a5b30d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - Reverse UV: the audit report is written only at the Verbose log level (the last 20 are kept) and a failed write no longer fails the transfer; the audit itself still gates every result. The ancestry kept per mesh and saved in the sidecar is a compact record of per-face overlap flags, charts, chain keys and overlap relations instead of the node's whole report (earlier sidecars are read and rewritten on restore), and the viewer's quality check no longer hashes it every frame. ### Fixed +- Place Collision at Root (Sidecar) works on a model whose Read/Write is off, Unity's default: every import refused the listed nodes as "unreadable" and the place step then dropped them from the sidecar, though a model's import may change its meshes whatever that setting. - A model's sidecar no longer counts as holding UV Transfer settings when none were saved in it. Unity gives every sidecar a settings block with the defaults, so selecting a model whose sidecar had none applied those defaults over the current settings, and a sidecar left with nothing else (after a UV2 reset, say) was kept instead of deleted. Settings saved before this change count when they differ from the defaults. - Prepare's Preset step no longer applies the preset's avatar description (the importer's human description: bone mapping and its scale): it is the model's own, as its remaps are. - Prepare's surface binding reports a warning, instead of failing, when Unity does not add the surface component to a collider's object (a script in an editor assembly, say). diff --git a/Editor/Mesh/CollisionRootPlacement.cs b/Editor/Mesh/CollisionRootPlacement.cs index 7bd343140..99af730fa 100644 --- a/Editor/Mesh/CollisionRootPlacement.cs +++ b/Editor/Mesh/CollisionRootPlacement.cs @@ -46,7 +46,7 @@ internal static void ApplyOnImport(GameObject root, IList paths, string { var node = CollisionHierarchyAnalyzer.Find(root.transform, path); string problem = node == null ? "is not in the model" - : Refusal(root.transform, node, users) ?? CurveRefusal(root.transform, node, animated) ?? UnreadableMesh(node); + : Refusal(root.transform, node, users) ?? CurveRefusal(root.transform, node, animated); if (problem != null) { UvtLog.Warn($"[Collision] '{modelPath}': '{CollisionHierarchyAnalyzer.NodeName(path)}' {problem}; the sidecar does not place it at the root."); @@ -60,7 +60,8 @@ static void PlaceAtRoot(Transform root, Transform node) { var filters = node.GetComponentsInChildren(true).Where(f => f.sharedMesh != null).ToList(); var toRoot = filters.Select(f => root.worldToLocalMatrix * f.transform.localToWorldMatrix).ToList(); - for (int i = 0; i < filters.Count; i++) MeshTransform.BakeMatrix(filters[i].sharedMesh, toRoot[i]); + // On import a mesh takes the change whether or not the model's Read/Write is on. + for (int i = 0; i < filters.Count; i++) MeshTransform.BakeMatrix(filters[i].sharedMesh, toRoot[i], importing: true); foreach (var t in node.GetComponentsInChildren(true)) { t.localPosition = Vector3.zero; @@ -88,13 +89,6 @@ static string MeshRefusal(Mesh mesh, Dictionary users) return mesh.blendShapeCount > 0 ? $"has blend shapes on '{mesh.name}'" : null; } - // On import every mesh is readable; one that is not would keep its old positions while its node moves. - static string UnreadableMesh(Transform node) - { - var mesh = node.GetComponentsInChildren(true).Select(f => f.sharedMesh).FirstOrDefault(m => m != null && !m.isReadable); - return mesh != null ? $"has '{mesh.name}' unreadable" : null; - } - // How many renderers (static or skinned) show each mesh of the model. static Dictionary MeshUsers(GameObject root) { diff --git a/Editor/Mesh/MeshTransform.cs b/Editor/Mesh/MeshTransform.cs index a17a4a2fd..b9e764d69 100644 --- a/Editor/Mesh/MeshTransform.cs +++ b/Editor/Mesh/MeshTransform.cs @@ -18,50 +18,67 @@ internal static class MeshTransform /// not), tangents the matrix itself and are re-orthogonalised against the new /// normal; a mirroring matrix (negative determinant) flips every triangle's /// winding and the tangent handedness so the surface still faces out and the - /// normal map still decodes. Bounds are recalculated. Readable mesh required. + /// normal map still decodes. Bounds are recalculated. Readable mesh required, except + /// while its model imports. /// - public static void BakeMatrix(Mesh mesh, Matrix4x4 matrix) + /// + /// Called from a model's OnPostprocessModel, where Unity lets a mesh be changed whatever + /// its Read/Write setting (its isReadable is false there when the setting is off). + /// + public static void BakeMatrix(Mesh mesh, Matrix4x4 matrix, bool importing = false) { - if (mesh == null || !mesh.isReadable) return; + if (mesh == null || (!importing && !mesh.isReadable)) return; if (matrix.isIdentity) return; bool mirrored = matrix.determinant < 0f; - var normalMatrix = matrix.inverse.transpose; var vertices = mesh.vertices; for (int i = 0; i < vertices.Length; ++i) vertices[i] = matrix.MultiplyPoint3x4(vertices[i]); mesh.vertices = vertices; + var normals = BakeNormals(mesh, matrix.inverse.transpose, vertices.Length); + BakeTangents(mesh, matrix, normals, vertices.Length, mirrored); + if (mirrored) + for (int sub = 0; sub < mesh.subMeshCount; ++sub) FlipWinding(mesh, sub); + mesh.RecalculateBounds(); + } + + // The mesh's normals by the inverse transpose, written back; none when it has none per vertex. + static Vector3[] BakeNormals(Mesh mesh, Matrix4x4 normalMatrix, int vertexCount) + { var normals = mesh.normals; - bool hasNormals = normals != null && normals.Length == vertices.Length; - if (hasNormals) + if (normals == null || normals.Length != vertexCount) return System.Array.Empty(); + for (int i = 0; i < normals.Length; ++i) normals[i] = normalMatrix.MultiplyVector(normals[i]).normalized; + mesh.normals = normals; + return normals; + } + + // Tangents by the matrix, re-orthogonalised against the new normals; the handedness flips when mirrored. + static void BakeTangents(Mesh mesh, Matrix4x4 matrix, Vector3[] normals, int vertexCount, bool mirrored) + { + var tangents = mesh.tangents; + if (tangents == null || tangents.Length != vertexCount) return; + bool hasNormals = normals.Length == vertexCount; + for (int i = 0; i < tangents.Length; ++i) { - for (int i = 0; i < normals.Length; ++i) normals[i] = normalMatrix.MultiplyVector(normals[i]).normalized; - mesh.normals = normals; + var t = matrix.MultiplyVector(new Vector3(tangents[i].x, tangents[i].y, tangents[i].z)); + if (hasNormals) t -= normals[i] * Vector3.Dot(t, normals[i]); + t.Normalize(); + tangents[i] = new Vector4(t.x, t.y, t.z, mirrored ? -tangents[i].w : tangents[i].w); } - var tangents = mesh.tangents; - if (tangents != null && tangents.Length == vertices.Length) + mesh.tangents = tangents; + } + + // Each submesh keeps its topology; only faces (triangles, quads) have a winding to flip. + static void FlipWinding(Mesh mesh, int sub) + { + var topology = mesh.GetTopology(sub); + int corners = topology switch { MeshTopology.Triangles => 3, MeshTopology.Quads => 4, _ => 0 }; + if (corners == 0) return; + var indices = mesh.GetIndices(sub); + // The second and the last corner swap: 0 1 2 → 0 2 1, 0 1 2 3 → 0 3 2 1. + for (int i = 0; i + corners - 1 < indices.Length; i += corners) { - for (int i = 0; i < tangents.Length; ++i) - { - var t = matrix.MultiplyVector(new Vector3(tangents[i].x, tangents[i].y, tangents[i].z)); - if (hasNormals) t -= normals[i] * Vector3.Dot(t, normals[i]); - t.Normalize(); - tangents[i] = new Vector4(t.x, t.y, t.z, mirrored ? -tangents[i].w : tangents[i].w); - } - mesh.tangents = tangents; + int tmp = indices[i + 1]; indices[i + 1] = indices[i + corners - 1]; indices[i + corners - 1] = tmp; } - if (mirrored) - for (int sub = 0; sub < mesh.subMeshCount; ++sub) - { - // Each submesh keeps its topology; only faces have a winding to flip. - var topology = mesh.GetTopology(sub); - var indices = mesh.GetIndices(sub); - if (topology == MeshTopology.Triangles) - for (int i = 0; i + 2 < indices.Length; i += 3) { int tmp = indices[i + 1]; indices[i + 1] = indices[i + 2]; indices[i + 2] = tmp; } - else if (topology == MeshTopology.Quads) - for (int i = 0; i + 3 < indices.Length; i += 4) { int tmp = indices[i + 1]; indices[i + 1] = indices[i + 3]; indices[i + 3] = tmp; } - else continue; - mesh.SetIndices(indices, topology, sub, calculateBounds: false); - } - mesh.RecalculateBounds(); + mesh.SetIndices(indices, topology, sub, calculateBounds: false); } /// The union of every renderer's world bounds under root; when it has none. From cf0e79454e133b63aa708dd19d508a7a77e97426 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 00:11:41 +0000 Subject: [PATCH 2/2] Tests: a UV reset test keeps settings saved as SaveSettings saves them ModelReplacementInvalidatesOnlyUvSidecarBeforeFailingFirstImport set a default ToolSettings directly, which since ToolSettings.saved counts as no settings: the UV reset deleted the then empty sidecar and the test's import hook hit a null sidecar. It now marks them saved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01L9SukSbP376zkY7oEgVTpH --- Tests/Editor/ToolAssetSaveTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/Editor/ToolAssetSaveTests.cs b/Tests/Editor/ToolAssetSaveTests.cs index 598134730..ce0bac2d8 100644 --- a/Tests/Editor/ToolAssetSaveTests.cs +++ b/Tests/Editor/ToolAssetSaveTests.cs @@ -129,7 +129,7 @@ public void ModelReplacementInvalidatesOnlyUvSidecarBeforeFailingFirstImport() var data = SidecarStore.LoadOrCreate(source.path); string meshName = source.entry.fbxMesh.name; data.Set(meshName, new[] { Vector2.zero, Vector2.right, Vector2.up }); - data.toolSettings = new ToolSettings(); + data.toolSettings = new ToolSettings { saved = true }; // as SaveSettings leaves them SidecarStore.Save(data); var before = File.ReadAllBytes(source.path); int committed = 0, imports = 0;