diff --git a/Editor/HeuristicBoneMapper.cs b/Editor/HeuristicBoneMapper.cs index 9c4b03ee..755e652d 100644 --- a/Editor/HeuristicBoneMapper.cs +++ b/Editor/HeuristicBoneMapper.cs @@ -231,6 +231,9 @@ namespace nadena.dev.modular_avatar.core.editor }; internal static readonly Regex Regex_VRM_Bone = new Regex(@"^([LRC])_(.*)$"); + + internal static ImmutableHashSet AllBoneNames = + boneNamePatterns.SelectMany(x => x).Select(NormalizeName).ToImmutableHashSet(); internal static string NormalizeName(string name) { @@ -247,6 +250,8 @@ namespace nadena.dev.modular_avatar.core.editor private static void InsertboneNamePatternsToRuntime() { ModularAvatarMergeArmature.boneNamePatterns = boneNamePatterns; + ModularAvatarMergeArmature.AllBoneNames = AllBoneNames; + ModularAvatarMergeArmature.NormalizeBoneName = NormalizeName; } static HeuristicBoneMapper() diff --git a/Runtime/ModularAvatarMergeArmature.cs b/Runtime/ModularAvatarMergeArmature.cs index 7f7c3225..98a5a91a 100644 --- a/Runtime/ModularAvatarMergeArmature.cs +++ b/Runtime/ModularAvatarMergeArmature.cs @@ -26,6 +26,8 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; using nadena.dev.modular_avatar.core.armature_lock; using UnityEngine; using UnityEngine.Serialization; @@ -49,6 +51,10 @@ namespace nadena.dev.modular_avatar.core [HelpURL("https://modular-avatar.nadena.dev/docs/reference/merge-armature?lang=auto")] public class ModularAvatarMergeArmature : AvatarTagComponent, IHaveObjReferences { + // Injected by HeuristicBoneMapper + internal static Func NormalizeBoneName; + internal static ImmutableHashSet AllBoneNames; + public AvatarObjectReference mergeTarget = new AvatarObjectReference(); public GameObject mergeTargetObject => mergeTarget.Get(this); @@ -203,6 +209,66 @@ namespace nadena.dev.modular_avatar.core } } + class PSCandidate + { + public string prefix, suffix; + public int matches; + + public PSCandidate CountMatches(ModularAvatarMergeArmature merger) + { + var target = merger.mergeTarget.Get(merger).transform; + var source = merger.transform; + + var oldPrefix = merger.prefix; + var oldSuffix = merger.suffix; + + try + { + merger.prefix = prefix; + merger.suffix = suffix; + + matches = merger.GetBonesForLock().Count; + return this; + } + finally + { + merger.prefix = oldPrefix; + merger.suffix = oldSuffix; + } + } + + /// + /// Counts the number of children which take the form prefix // heuristic bone name // suffix + /// + /// + public PSCandidate CountHeuristicMatches(Transform root) + { + int count = 1; + + Walk(root); + + matches = count; + return this; + + void Walk(Transform t) + { + foreach (Transform child in t) + { + if (child.name.StartsWith(prefix) && child.name.EndsWith(suffix)) + { + var boneName = child.name.Substring(prefix.Length, child.name.Length - prefix.Length - suffix.Length); + boneName = NormalizeBoneName(boneName); + if (AllBoneNames.Contains(boneName)) + { + count++; + Walk(child); + } + } + } + } + } + } + public void InferPrefixSuffix() { // We only infer if targeting the armature (below the Hips bone) @@ -215,33 +281,64 @@ namespace nadena.dev.modular_avatar.core // We also require that the attached object has exactly one child (presumably the hips) if (transform.childCount != 1) return; + List candidates = new(); + + // always consider the current configuration + candidates.Add(new PSCandidate() {prefix = prefix, suffix = suffix}.CountMatches(this)); + // Infer the prefix and suffix by comparing the names of the mergeTargetObject's hips with the child of the // GameObject we're attached to. var baseName = hips.name; - var mergeName = transform.GetChild(0).name; - var isInferred = false; + var mergeHips = transform.GetChild(0); + var mergeName = mergeHips.name; - foreach (var hipNameCandidate in boneNamePatterns[(int)HumanBodyBones.Hips]) + // Classic substring match + { + var prefixLength = mergeName.IndexOf(baseName, StringComparison.InvariantCulture); + if (prefixLength >= 0) + { + var suffixLength = mergeName.Length - prefixLength - baseName.Length; + + candidates.Add(new PSCandidate() + { + prefix = mergeName.Substring(0, prefixLength), + suffix = mergeName.Substring(mergeName.Length - suffixLength) + }.CountMatches(this)); + } + } + + // Heuristic match - try to see if we get a better prefix/suffix pattern if we allow for fuzzy-matching of + // bone names. Since our goal is to minimize unnecessary renaming (and potentially failing matches), we do + // this only if the number of heuristic matches is more than twice the number of matches from the static + // pattern above, as using this will force most bones to be renamed. + foreach (var hipNameCandidate in + boneNamePatterns[(int)HumanBodyBones.Hips].OrderByDescending(p => p.Length)) { var prefixLength = mergeName.IndexOf(hipNameCandidate, StringComparison.InvariantCultureIgnoreCase); if (prefixLength < 0) continue; var suffixLength = mergeName.Length - prefixLength - hipNameCandidate.Length; - prefix = mergeName.Substring(0, prefixLength); - suffix = mergeName.Substring(mergeName.Length - suffixLength); - isInferred = true; + var prefix = mergeName.Substring(0, prefixLength); + var suffix = mergeName.Substring(mergeName.Length - suffixLength); + + var candidate = new PSCandidate + { + prefix = prefix, + suffix = suffix + }.CountHeuristicMatches(mergeHips); + candidate.matches = (candidate.matches + 1) / 2; + + candidates.Add(candidate); break; } - - if (!isInferred) { // Also check with old method as fallback - var prefixLength = mergeName.IndexOf(baseName, StringComparison.InvariantCulture); - if (prefixLength < 0) return; - - var suffixLength = mergeName.Length - prefixLength - baseName.Length; - - prefix = mergeName.Substring(0, prefixLength); - suffix = mergeName.Substring(mergeName.Length - suffixLength); + + // Select which candidate to use + var selected = candidates.OrderByDescending(c => c.matches).FirstOrDefault(); + if (selected != null && selected.matches > 0) + { + prefix = selected.prefix; + suffix = selected.suffix; } if (prefix == "J_Bip_C_") diff --git a/UnitTests~/EasySetupOutfit/InferPrefixSuffixTest.cs b/UnitTests~/EasySetupOutfit/InferPrefixSuffixTest.cs index 24c96a52..50634578 100644 --- a/UnitTests~/EasySetupOutfit/InferPrefixSuffixTest.cs +++ b/UnitTests~/EasySetupOutfit/InferPrefixSuffixTest.cs @@ -17,7 +17,7 @@ public class InferPrefixSuffixTest : TestBase var outfit = CreateChild(root, "Outfit"); var outfit_armature = CreateChild(outfit, "armature"); - var outfit_hips = CreateChild(outfit_armature, "hips"); + var outfit_hips = CreateChild(outfit_armature, "hip"); var outfit_mama = outfit_armature.AddComponent(); outfit_mama.mergeTarget = new AvatarObjectReference(); @@ -42,7 +42,8 @@ public class InferPrefixSuffixTest : TestBase var outfit = CreateChild(root, "Outfit"); var outfit_armature = CreateChild(outfit, "armature"); - var outfit_hips = CreateChild(outfit_armature, "pre_Hips.suf"); + var outfit_hips = CreateChild(outfit_armature, "pre_hips.suf"); + var outfit_mama = outfit_armature.AddComponent(); outfit_mama.mergeTarget = new AvatarObjectReference(); @@ -50,13 +51,24 @@ public class InferPrefixSuffixTest : TestBase outfit_mama.LockMode = ArmatureLockMode.BaseToMerge; outfit_mama.InferPrefixSuffix(); + + // Initially, we determine "hip" to be the match + Assert.AreEqual("pre_", outfit_mama.prefix); + Assert.AreEqual("s.suf", outfit_mama.suffix); + + // Now, add the legs. + var outfit_left_leg = CreateChild(outfit_hips, "pre_upleg.l.suf"); + var outfit_right_leg = CreateChild(outfit_hips, "pre_upleg.r.suf"); + + // Now, we match 3 with ".suf" vs 1 with "s.suf", so the inference should change. + outfit_mama.InferPrefixSuffix(); Assert.AreEqual("pre_", outfit_mama.prefix); Assert.AreEqual(".suf", outfit_mama.suffix); } [Test] - public void TestSameHipsName_Success() + public void TestSameHipsName_Multiple() { var root = CreateCommonPrefab("shapell.fbx"); #if MA_VRCSDK3_AVATARS @@ -68,6 +80,8 @@ public class InferPrefixSuffixTest : TestBase var outfit = CreateChild(root, "Outfit"); var outfit_armature = CreateChild(outfit, "armature"); var outfit_hips = CreateChild(outfit_armature, "pre_TEST_HI2.suf"); // Make it a little bit different name to confirm it matches the current implementation + var outfit_spine = CreateChild(outfit_hips, "pre_Spine2.suf"); + var outfit_chest = CreateChild(outfit_spine, "pre_Bust2.suf"); var outfit_mama = outfit_armature.AddComponent(); outfit_mama.mergeTarget = new AvatarObjectReference(); @@ -81,7 +95,7 @@ public class InferPrefixSuffixTest : TestBase } [Test] - public void TestSameHipsName_Fail() + public void TestSameHipsName_Single() { var root = CreateCommonPrefab("shapell.fbx"); #if MA_VRCSDK3_AVATARS @@ -101,8 +115,38 @@ public class InferPrefixSuffixTest : TestBase outfit_mama.InferPrefixSuffix(); - // Current(v1.10.x) InferPrefixSuffix fail to infer prefix/suffix when avatar has unique prefix/suffix and outfit has their name - Assert.AreNotEqual("pre_", outfit_mama.prefix); - Assert.AreNotEqual(".suf", outfit_mama.suffix); + Assert.AreEqual("pre_", outfit_mama.prefix); + Assert.AreEqual(".suf", outfit_mama.suffix); + } + + [Test] + public void TestSpuriousMatch() + { + var root = CreateCommonPrefab("shapell.fbx"); +#if MA_VRCSDK3_AVATARS + root.AddComponent(); +#endif + var animator = root.GetComponent(); + var root_hips = animator.GetBoneTransform(HumanBodyBones.Hips); + var root_armature = root_hips.parent; + + root_hips.gameObject.name = "bone_pelvis"; + root_armature.gameObject.name = "bone_root"; + animator.GetBoneTransform(HumanBodyBones.Spine).gameObject.name = "bone_Spine"; + + var outfit = CreateChild(root, "Outfit"); + var outfit_armature = CreateChild(outfit, "bone_root"); + var outfit_hips = CreateChild(outfit_armature, "bone_pelvis"); + var outfit_spine = CreateChild(outfit_hips, "bone_Spine"); + + var outfit_mama = outfit_armature.AddComponent(); + outfit_mama.mergeTarget = new AvatarObjectReference(); + outfit_mama.mergeTarget.referencePath = RuntimeUtil.RelativePath(root, root_armature.gameObject); + outfit_mama.LockMode = ArmatureLockMode.BaseToMerge; + + outfit_mama.InferPrefixSuffix(); + + Assert.AreEqual("", outfit_mama.prefix); + Assert.AreEqual("", outfit_mama.suffix); } }