diff --git a/CHANGELOG.md b/CHANGELOG.md index 645a5b30..4379f69b 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 +- Prepare reads a collision node's own material names before removing its renderer, then falls back to its matching render part. A `_COL` node without a surface in its name or a render sibling now gets the same surface in Preview and Apply. Existing surface references and collider placement rules are unchanged. - 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. diff --git a/Documentation~/PREPARE_SURFACE_BINDING.md b/Documentation~/PREPARE_SURFACE_BINDING.md new file mode 100644 index 00000000..53ef51c3 --- /dev/null +++ b/Documentation~/PREPARE_SURFACE_BINDING.md @@ -0,0 +1,83 @@ +# Prepare: component surface binding + +The Colliders step can attach a game's component and assign one existing surface +asset to its serialized object-reference field. The package has no reference to a +game assembly. It does not create surface assets, set their physics/damage values, +or assign `Collider.sharedMaterial`. + +## Project configuration + +Open **Project Settings > Mesh Lab > Prepare > Collider Surfaces**. + +| Setting | Value to obtain from the game | +| --- | --- | +| Component | Fully qualified, concrete runtime component type | +| Field | Serialized field name, including any leading underscore; not the C# property or setter | +| Asset Type | Fully qualified asset type assignable to that field | +| Surface words | Keyword to exact asset object name, without `.asset` | + +The component's field may be public or private with `[SerializeField]`. The +binding writes it through `SerializedObject`; it does not invoke a custom setter. +Check whether the game's setter has necessary side effects before using this +binding. Prefer full type names: short names can collide across assemblies. + +Enable **Colliders** and **Surfaces** in the matching recipe. For FBX hierarchy +changes also enable **Prefab**, which writes a variant. The first matching recipe +wins; the default only matches `SM_*`. **Run on Import** is opt-in and handles +first imports without an existing `.meta`, not ordinary reimports. + +## Recognition and asset selection + +1. First surface word in the part's name. +2. First recognized material name: the node's own materials, followed by matching + render siblings for a collision node. Siblings share `MeshNaming.GroupKey` + after LOD/collision suffix removal. +3. First surface word in the target model's name. + +Surface words are case-insensitive prefixes of individual name tokens. Tokens +split at separators, case boundaries and digits. `wood` matches `Wooden`, but a +rule containing `reinforced_concrete` cannot match two tokens. Use a single +unambiguous token for a special surface and map it to the actual asset name. + +The defaults are wood, concrete, metal, glass, plastic, tile and cardboard. A +game may need different names and additional rules. The binder does not inspect +a surface enum inside the asset. It searches assets of the configured type and +requires an exact object-name match ignoring case. Duplicate names select the +longest shared folder prefix with the target, then the ordinal path. A scene +target uses its scene path, so duplicate names can resolve differently from a +model target. Keep semantic asset names unique where possible. + +## Boundaries to check before a batch + +| Case | Current behavior / consequence | +| --- | --- | +| One node with Wood and Metal render materials | First recognized material wins for the entire collider; no submesh/triangle assignment, conflict warning or automatic split | +| Existing non-null reference on the destination object | Preserved even if naming suggests another surface; no asset lookup | +| Missing asset or unrecognized surface | Warning; no surface component is added; collider preparation can still succeed | +| Valid repeated Apply | Existing binding is kept and no duplicate component is added | +| Root already has any MeshCollider | Whole Colliders step is skipped, including all surface bindings below it | +| Asset becomes available after a previous failed binding | A non-root collider with recognizable naming can retry; a root MeshCollider prevents the retry | +| Largest collision node moves to root | Only the destination object's existing binding is checked; a custom binding on the source node is not transferred | +| Several colliders on one GameObject | One component/reference is shared by that object; not a per-collider mapping | + +For mixed surfaces, author separate explicitly named collision parts, e.g. +`Seat_Wood_COL` and `Frame_Metal_COL`. Check any largest-part relocation when +manual surface components already exist. Do not remove an intentional root +collider merely to force Prepare to run. Existing root-collider models need a +separate migration/fill step, which this API does not currently provide. + +A collision node whose material name is its only surface evidence loses that +evidence when its renderer is removed. A successful binding survives, but a +missing asset should be fixed before Apply; explicit surface names also allow a +later retry on non-root colliders. + +## Verification + +`PrepareCollidersTests` covers own-material Preview/Apply parity, explicit name +priority, matching render fallback, preservation of a different manual reference, +missing-asset retry on a non-root collider, repeated Apply, and incompatible types. +`PreparePrefabTests.ComponentBinding_UsesNearestExactAssetAndPersistsAcrossPrefabReload` +uses real temporary assets and reloads a prefab variant to check persistence. + +Run these in Unity's EditMode Test Runner. A license-free compile check verifies +C# types but does not execute Unity serialization, Undo or AssetDatabase behavior. diff --git a/Editor/Prepare/ColliderStep.cs b/Editor/Prepare/ColliderStep.cs index e5955a6f..a2082a2b 100644 --- a/Editor/Prepare/ColliderStep.cs +++ b/Editor/Prepare/ColliderStep.cs @@ -110,12 +110,16 @@ void Prepare(PrepareContext context, PrepareReport report, bool apply, Source so string offset = main ? LodHierarchy.OffsetFromRoot(root, source.node) : null; var host = main && offset == null ? root : source.node; string subject = Subject(root, source.node); + // Read the collision node's materials before Apply removes its renderer. Preview + // and Apply must choose the same surface, including a node with no render sibling. + string surface = null, origin = null; + if (binding != null && binding.Unavailable == null) + (surface, origin) = SurfaceBinding.Resolve(source.node.name, MaterialNames(root, source), context.Target.Name, vocabulary); GiveCollider(context, report, apply, source, host, subject, Placement(main, offset, host == root)); if (host != source.node) DropNodeCollider(context, report, apply, source, subject); else if (host != root) Activate(context, report, apply, source, subject); if (source.collisionNode) RemoveRenderers(context, report, apply, source.node, subject); if (binding == null || binding.Unavailable != null) return; - var (surface, origin) = SurfaceBinding.Resolve(source.node.name, MaterialNames(root, source), context.Target.Name, vocabulary); binding.Bind(context, host.gameObject, surface, origin, Subject(root, host), report, apply); } @@ -229,8 +233,8 @@ static List LodSources(GameObject root, int lod, out string level) return chosen.sources; } - // The part's materials: a render node's own; a collision node's, those of the render - // nodes of its part (the same name once LOD and collision suffixes are off). + // The part's materials: its own first, then for a collision node the render nodes of + // its part (the same name once LOD and collision suffixes are off). static IEnumerable MaterialNames(Transform root, Source source) { IEnumerable renderers; @@ -238,8 +242,9 @@ static IEnumerable MaterialNames(Transform root, Source source) else { string key = MeshNaming.GroupKey(source.node.name); - renderers = root.GetComponentsInChildren(true).Where(r => !MeshNaming.IsCollision(r.name) - && string.Equals(MeshNaming.GroupKey(r.name), key, StringComparison.OrdinalIgnoreCase)); + renderers = source.node.GetComponents().Concat( + root.GetComponentsInChildren(true).Where(r => !MeshNaming.IsCollision(r.name) + && string.Equals(MeshNaming.GroupKey(r.name), key, StringComparison.OrdinalIgnoreCase))); } return renderers.SelectMany(r => r.sharedMaterials).Where(m => m != null).Select(m => m.name).Distinct(); } diff --git a/README.md b/README.md index 0a8c09f4..460e8cf7 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,8 @@ Unity -batchmode -quit -projectPath -executeMethod SashaRX.UnityMeshLa The exit code is 1 when a model was not applied (with `-preparePreview`: would be refused), else 0. +For game surface components, see [binding configuration and limitations](Documentation~/PREPARE_SURFACE_BINDING.md), including mixed materials, existing assignments and root-collider models. + ## Cleanup Scan and fix the hygiene issues the other operations leave behind: **Mesh** (empty channels, attribute set, duplicate material slots, Read/Write handling without importer flips), **Colliders** (stray attributes on collider meshes), **Split / Merge** (same-material merge, per-material split), import settings. Every fix records Undo. diff --git a/Tests/Editor/PrepareCollidersTests.cs b/Tests/Editor/PrepareCollidersTests.cs index 8bd9098b..1bf8a1e9 100644 --- a/Tests/Editor/PrepareCollidersTests.cs +++ b/Tests/Editor/PrepareCollidersTests.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; using NUnit.Framework; +using UnityEditor; using UnityEngine; using UnityEngine.TestTools; @@ -229,6 +230,9 @@ public void Resolve_ReadsThePartThenItsMaterialsThenTheModel() Assert.AreEqual("Wood", SurfaceBinding.Resolve("Seat_COL", new[] { "M_Building_Atlas_01", "M_Wood_Oak" }, "SM_Glass", words).surface); Assert.AreEqual("Glass", SurfaceBinding.Resolve("Seat_COL", new[] { "M_Atlas" }, "SM_Glass_Door", words).surface); Assert.IsNull(SurfaceBinding.Resolve("Seat_COL", null, "Bench", words).surface); + Assert.AreEqual("Wood", SurfaceBinding.Resolve("Seat_COL", new[] { "M_Wood", "M_Metal" }, "Bench", words).surface, + "a multi-material node gets one surface: the first recognized material, not one per submesh"); + Assert.AreEqual("Metal", SurfaceBinding.Resolve("Seat_COL", new[] { "M_Metal", "M_Wood" }, "Bench", words).surface); } [Test] @@ -309,5 +313,125 @@ public void ComponentBinding_SaysWhyItCannotBind() }; StringAssert.Contains("no serialized field", new ComponentSurfaceBinding(wrongField).Unavailable); } + + PrepareTestSurfaceData Surface(string name) + { + var asset = ScriptableObject.CreateInstance(); + asset.name = name; + owned.Add(asset); + return asset; + } + + Material RenderMaterial(string name) + { + var material = new Material(Shader.Find("Standard")) { name = name }; + owned.Add(material); + return material; + } + + static SurfaceBindingSettings BindingSettings() => new SurfaceBindingSettings + { + componentType = typeof(PrepareTestSurface).FullName, + field = "_material", + dataType = typeof(PrepareTestSurfaceData).FullName, + }; + + [TestCase(false)] + [TestCase(true)] + public void ComponentBinding_ReadsCollisionNodeMaterialBeforeRemovingItsRenderer(bool onRoot) + { + var wood = Surface("Wood"); + var root = Root("Bench"); + var node = Node(root, "Seat_COL", Box("Seat_COL", 1), onRoot ? Vector3.zero : Vector3.up, + RenderMaterial("M_Wood_Oak")); + var host = onRoot ? root : node; + var binding = new ComponentSurfaceBinding(BindingSettings(), (type, name, near) => name == "Wood" ? wood : null); + var step = new ColliderStep(binding); + + var preview = PrepareRunner.Preview(PrepareTarget.ForSceneObject(root), new[] { step }); + + Assert.IsNull(host.GetComponent(), "Preview must not add the component"); + Assert.IsNotNull(node.GetComponent()); + var planned = preview.Steps.Single().Report.Lines.Single(l => l.check == ComponentSurfaceBinding.Check); + Assert.AreEqual(PrepareLevel.Changed, planned.level, preview.ToString()); + + var applied = Apply(root, step); + + Assert.AreSame(wood, host.GetComponent()?.Material, applied.ToString()); + Assert.IsNull(node.GetComponent()); + Assert.AreEqual(planned.message, applied.Steps.Single().Report.Lines.Single(l => l.check == ComponentSurfaceBinding.Check).message); + Assert.IsFalse(Apply(root, step).Changes, "a bound collider is stable on a second Apply"); + Assert.AreEqual(1, host.GetComponents().Length); + } + + [TestCase("Seat_COL", "M_Wood_Oak", "Wood")] + [TestCase("Seat_COL", "M_Atlas", "Metal")] + [TestCase("Seat_Glass_COL", "M_Wood_Oak", "Glass")] + public void ComponentBinding_OwnMaterialsThenMatchedRenderPartWithExplicitNameFirst(string part, string ownMaterial, string expected) + { + var asset = Surface(expected); + var root = Root("Bench"); + var node = Node(root, part, Box(part, 1), Vector3.up, RenderMaterial(ownMaterial)); + Node(root, MeshNaming.GroupKey(part) + "_LOD0", Box("Render", 1), Vector3.up, RenderMaterial("M_Metal")); + var binding = new ComponentSurfaceBinding(BindingSettings(), (type, name, near) => name == expected ? asset : null); + + var result = Apply(root, new ColliderStep(binding)); + + Assert.AreSame(asset, node.GetComponent()?.Material, result.ToString()); + } + + [Test] + public void ComponentBinding_PreservesAnExistingDifferentAssignmentWithoutLookingUpAssets() + { + var original = Surface("CustomSurface"); + var root = Root("Bench"); + var node = Node(root, "Seat_Wood_COL", Box("Seat_Wood_COL", 1), Vector3.up); + var component = node.AddComponent(); + using (var serialized = new SerializedObject(component)) + { + serialized.FindProperty("_material").objectReferenceValue = original; + serialized.ApplyModifiedPropertiesWithoutUndo(); + } + int lookups = 0; + var binding = new ComponentSurfaceBinding(BindingSettings(), (type, name, near) => { lookups++; return null; }); + + Apply(root, new ColliderStep(binding)); + var again = Apply(root, new ColliderStep(binding)); + + Assert.AreSame(original, component.Material); + Assert.AreEqual(0, lookups, "a manual assignment must survive even when the inferred asset is missing"); + Assert.AreEqual(1, node.GetComponents().Length); + Assert.IsFalse(again.Changes, again.ToString()); + } + + [Test] + public void ComponentBinding_MissingAssetAddsNoComponentAndCanRetryOnANonRootCollider() + { + var root = Root("Bench"); + var node = Node(root, "Seat_Wood_COL", Box("Seat_Wood_COL", 1), Vector3.up); + PrepareTestSurfaceData available = null; + var binding = new ComponentSurfaceBinding(BindingSettings(), (type, name, near) => available); + + var first = Apply(root, new ColliderStep(binding)); + + Assert.IsNull(node.GetComponent()); + Assert.IsNotNull(node.GetComponent()); + Assert.IsTrue(first.Steps.Single().Report.Lines.Any(l => l.check == ComponentSurfaceBinding.Check && l.level == PrepareLevel.Warning)); + available = Surface("Wood"); + Apply(root, new ColliderStep(binding)); + + Assert.AreSame(available, node.GetComponent()?.Material); + Assert.AreEqual(1, node.GetComponents().Length); + Assert.IsFalse(Apply(root, new ColliderStep(binding)).Changes); + } + + [Test] + public void ComponentBinding_RejectsIncompatibleAssetType() + { + var settings = BindingSettings(); + settings.dataType = typeof(Material).FullName; + + StringAssert.Contains("does not take", new ComponentSurfaceBinding(settings).Unavailable); + } } } diff --git a/Tests/Editor/PreparePrefabTests.cs b/Tests/Editor/PreparePrefabTests.cs index 6c4fc178..381d6a38 100644 --- a/Tests/Editor/PreparePrefabTests.cs +++ b/Tests/Editor/PreparePrefabTests.cs @@ -72,6 +72,48 @@ static PrepareReport Run(IPrepareStep step, PrefabOutput output, string model, b return report; } + [Test] + public void ComponentBinding_UsesNearestExactAssetAndPersistsAcrossPrefabReload() + { + string model = Model("SM_Bench"); + string variant = $"{folder}/Bench.prefab"; + string assetName = "Surface_" + Guid.NewGuid().ToString("N"); + var near = ScriptableObject.CreateInstance(); + var far = ScriptableObject.CreateInstance(); + var partial = ScriptableObject.CreateInstance(); + AssetDatabase.CreateAsset(near, $"{folder}/{assetName}.asset"); + AssetDatabase.CreateFolder(folder, "Other"); + AssetDatabase.CreateAsset(far, $"{folder}/Other/{assetName}.asset"); + AssetDatabase.CreateAsset(partial, $"{folder}/{assetName}_Extra.asset"); + // Only the near asset shares the model's extra folder; filenames on their own + // must not outrank proximity, and the partial-name hit must not be assigned. + AssetDatabase.CreateFolder(folder, "Model"); + Assert.IsEmpty(AssetDatabase.MoveAsset(model, $"{folder}/Model/SM_Bench.prefab")); + model = $"{folder}/Model/SM_Bench.prefab"; + Assert.IsEmpty(AssetDatabase.MoveAsset(AssetDatabase.GetAssetPath(near), $"{folder}/Model/{assetName}.asset")); + var settings = new SurfaceBindingSettings + { + componentType = typeof(PrepareTestSurface).FullName, + field = "_material", + dataType = typeof(PrepareTestSurfaceData).FullName, + rules = new System.Collections.Generic.List { new SurfaceRule("wood", assetName) }, + }; + var step = new ColliderStep(new ComponentSurfaceBinding(settings), SurfaceBinding.Vocabulary(settings)); + + using (var output = PrefabOutput.Open(model, variant)) + { + var report = Run(step, output, model, true); + Assert.AreSame(near, output.Root.transform.Find("Seat_Wood_COL").GetComponent()?.Material, report.ToString()); + Assert.IsTrue(output.Save(out string error), error); + } + + using (var output = PrefabOutput.Open(model, variant)) + { + Assert.AreSame(near, output.Root.transform.Find("Seat_Wood_COL").GetComponent()?.Material); + Assert.AreEqual(0, Run(step, output, model, true).Count(PrepareLevel.Changed)); + } + } + [Test] public void Output_SavesTheChangesAsAVariantOfTheModelAndAReopenedOneNeedsNothing() {