diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bf130bd..645a5b30 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 7bd34314..99af730f 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 a17a4a2f..b9e764d6 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. diff --git a/Tests/Editor/ToolAssetSaveTests.cs b/Tests/Editor/ToolAssetSaveTests.cs index 59813473..ce0bac2d 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;