""" Influence mapping process creates a "source->destination" list for two influence lists, describing how based on influence metadata (name, position) and mapping requirements (mirror mode, left/right side name prefixes, etc) a mapping is created, where "source->destination" in, say, weight transfer/mirror situation, describes that weights currently associated with source influence, should be transfered to destination influence. for mirror mode, same influence can map to itself, which would generally mean "copy influence weights on one side to be the same on the other side". mapping is returned as "logical index"->"logical index" map. For usage details, see unit test examples. """ from __future__ import division import itertools import json import re from ngSkinTools2.api.log import getLogger from ngSkinTools2.api.python_compatibility import Object, is_string log = getLogger("influenceMapping") def regexpMatchAny(fragments): return '(' + '|'.join(fragments) + ')' illegalCharactersRegexp = re.compile(r"[^\w_\*]") def validate_glob(glob): match = illegalCharactersRegexp.search(glob) if match is not None: raise Exception("invalid pattern '{}': character {} not allowed".format(glob, match.group(0))) def convertGlobToRegexp(glob): """ :type glob: str """ glob = illegalCharactersRegexp.sub("", glob) if "*" not in glob: # if no stars added, just add them on both sides, e.g. "_L_" is the same as "*_L_*" glob = "*" + glob + "*" return "^" + glob.replace("*", "(.*)") + "$" class InfluenceInfo(Object): """ Metadata about an influence in a skin cluster """ SIDE_LEFT = "left" SIDE_RIGHT = "right" SIDE_CENTER = "center" SIDE_MAP = { 0: SIDE_CENTER, 1: SIDE_LEFT, 2: SIDE_RIGHT, } oppositeSides = {SIDE_LEFT: SIDE_RIGHT, SIDE_RIGHT: SIDE_LEFT} def __init__(self, pivot=None, path=None, name=None, logicalIndex=None, labelSide=None, labelText=None): self.pivot = pivot #: influence pivot in world-space coordinates self.path = path #: influence node path self.name = name #: influence node name (if influence is not a DAG node, like ) self.logicalIndex = logicalIndex #: influence logical index in the skin cluster. self.labelSide = labelSide #: joint label "side" attribute self.labelText = labelText #: joint label text def path_name(self): """ returns path if it's not None, otherwise returns name :return: """ if self.path is not None: return self.path if self.name is None: raise Exception("both path and name is empty for InfluenceInfo") return self.name def __repr__(self): return "[InflInfo %r %r %r]" % (self.logicalIndex, self.path, self.pivot) def as_json(self): return { "pivot": self.pivot, "path": self.path, "name": self.name, "logicalIndex": self.logicalIndex, "labelSide": self.labelSide, "labelText": self.labelText, } def from_json(self, json): self.pivot = json["pivot"] self.path = json.get("path", "") self.name = json.get("name", "") self.logicalIndex = json["logicalIndex"] self.labelSide = json["labelSide"] self.labelText = json["labelText"] return self def calcShortestUniqueName(influences): """ calculates "uniquePath" for each influence - in a similar manner as Maya does, only in the context of single skincluster instead of the whole scene. :type influences: list[InfluenceInfo] """ def commonOffset(original, sibling): i = 0 for o, s in zip(original, sibling): if o != s: break i += 1 # extend to full name while i < len(original) and original[i] != '|': i += 1 return i for infl in influences: if infl.path is None: infl.shortestPath = infl.name reversedPaths = [{"path": infl.path[::-1], "infl": infl} for infl in influences if infl.path] # sort by line ending reversedPaths = sorted(reversedPaths, key=lambda item: item["path"]) # compare path to siblings, find a shortest subpath that is different from nearest similar names for prev, curr, next in zip([None] + reversedPaths[:-1], reversedPaths, reversedPaths[1:] + [None]): minLength = curr['path'].find("|") if minLength < 0: minLength = len(curr['path']) prevOffset = minLength if prev is None else commonOffset(curr['path'], prev['path']) nextOffset = minLength if next is None else commonOffset(curr['path'], next['path']) curr['infl'].shortestPath = curr['path'][: max(prevOffset, nextOffset)][::-1] def nameMatches(globs, influences, destination_influences=None, mirror_mode=False): """ for each name pair, calculates a match score, and keeps matches that have highest score. score calculation rules: * each name is broken down into sections, e.g. |root|L_shoulder|L_elbow -> root, L_shoulder, L_elbow * for each section, find glob match, e.g. L_elbow becomes : {withoutGlob: elbow, matchedRule=L_*, oppositeRule=R_*} * two names are matched from the end, section by section: * it is assumed that a section matches if "withoutGlob" part is identical, and section1.matchedRule==section2.oppositeRule matching of the name happens by finding highest score returns map of source->destination matches :type globs: list[(string, string)] :type influences: list[InfluenceInfo] """ if destination_influences is None: destination_influences = influences # 1 each path element is calculated as glob value globRegexps = [[re.compile(convertGlobToRegexp(i)) for i in g] for g in globs] # join with reversed logic globRegexps = globRegexps + [tuple(reversed(ge)) for ge in globRegexps] class GlobInfo(Object): def __init__(self): self.withoutGlob = "" self.matchedRule = None self.oppositeRule = None def convertPathElementToGlobInfo(pathElement): result = GlobInfo() result.withoutGlob = pathElement for expr, opposite in globRegexps: match = expr.match(pathElement) if match is not None: result.matchedRule = expr result.oppositeRule = opposite result.withoutGlob = "".join(match.groups()) break return result def calcMatchScore(info1, info2): """ :type info1: list[GlobInfo] :type info2: list[GlobInfo] """ # optimization - if there's no chance these two paths match, # cut away loop logic if info1[0].withoutGlob != info2[0].withoutGlob: return 0 score = 0 rules_matched = False for e1, e2 in zip(info1, info2): if e1.withoutGlob != e2.withoutGlob or e1.matchedRule != e2.oppositeRule: break if e1.matchedRule is not None: score += 10 rules_matched = True score += 1 # in mirror mode, it's important that at least rule is matched (e.g. L->R or similar) if mirror_mode and not rules_matched: score = 0 return score class MatchData(Object): path_split = re.compile("[\\|\\:]") def __init__(self, infl): """ :type infl: InfluenceInfo """ reversedPath = list(reversed(self.path_split.split(infl.path))) if infl.path else [infl.name] self.infl = infl self.score = 0 self.match = None self.globInfo = [convertPathElementToGlobInfo(e) for e in reversedPath] destination_matches = [MatchData(infl) for infl in destination_influences] if destination_influences == influences: source_data = destination_matches else: source_data = [MatchData(infl) for infl in influences] # encapsulating for profiler def findBestMatches(): for source in source_data: for destination in destination_matches: if source == destination: continue score = calcMatchScore(source.globInfo, destination.globInfo) if (not mirror_mode or score > source.score) and score > destination.score: destination.match = source destination.score = score if mirror_mode: source.match = destination source.score = score findBestMatches() return {md.match.infl: md.infl for md in destination_matches if md.match is not None} def exactNameMatches(influences, destination_influences=None): """ match influences by exact name :type influences: list[InfluenceInfo] """ pass def labelMatches(source_influences, destination_influences, mirror_mode=False): """ :type source_influences: list[InfluenceInfo] """ def infl_key(i): if i.labelText is None: return ("", "") return (i.labelText, i.labelSide) def group_by_label_and_side(infl_list): return {k: list(v) for k, v in itertools.groupby(list(sorted(infl_list, key=infl_key)), key=infl_key)} def as_unique_entries(l): return {k: v[0] for k, v in l.items() if len(v) == 1} result = {} # group by labelText+labelSide keys # it is essential that only unique keys are used; skip repeats of text+side on either list, as they are ambiguous grouped_sources = group_by_label_and_side(source_influences) unique_sources = as_unique_entries(grouped_sources) unique_destinations = unique_sources if mirror_mode else as_unique_entries(group_by_label_and_side(destination_influences)) # "center" treatment in mirror mode: sometimes users might not set left/right sides, and "center" is actually just an untouched default; # in that case, just favour other influences if there are multiple "center" with the same label if mirror_mode: for (label, side), src in grouped_sources.items(): if label == "" or side != InfluenceInfo.SIDE_CENTER: continue # only the cases of len==1 and len==2 are supported if len(src) == 1: result[src[0]] = src[0] else: result[src[0]] = src[1] result[src[1]] = src[0] # find matching label+side pairs for all destinations; flip side for mirror mode for (label, side), src in unique_sources.items(): if label == "": continue if mirror_mode: if side == InfluenceInfo.SIDE_CENTER: continue side = InfluenceInfo.oppositeSides[side] dest = unique_destinations.get((label, side), None) if dest is not None: result[src] = dest return result def distanceMatches(source_influences, destination_influences, threshold, mirror_axis): """ :type source_influences: list[InfluenceInfo] :type destination_influences: list[InfluenceInfo] :type threshold: float :type mirror_axis: union(int, None) """ def distance_squared(p1, p2): return (p1[0] - p2[0]) ** 2 + (p1[1] - p2[1]) ** 2 + (p1[2] - p2[2]) ** 2 threshold_squared = threshold * threshold mirror_mode = mirror_axis is not None result = {} for source in source_influences: # if we're in mirror mode and near mirror axis, match self instead of other influence if mirror_mode and abs(source.pivot[mirror_axis]) < (threshold / 2.0): result[source] = source continue source_pivot = list(source.pivot[:]) if mirror_mode: source_pivot[mirror_axis] = -source_pivot[mirror_axis] best_distance = None for destination in destination_influences: d = distance_squared(source_pivot, destination.pivot) if threshold_squared < d: continue if best_distance is None or d < best_distance: best_distance = d result[source] = destination if mirror_mode: result[destination] = source return result def dg_matches(source_influences, destination_influences, link_resolver): """ :type source_influences: list[InfluenceInfo] :type destination_influences: list[InfluenceInfo] :type link_resolver: """ result = {} dest_by_path = {i.path: i for i in destination_influences} for i in source_influences: dest = link_resolver(i.path) dest = None if dest is None else dest_by_path.get(dest, None) if dest is not None: result[i] = dest return result class InfluenceMappingConfig(Object): """ This class represents a configuration for how influences are matched for weights mirroring or transfering between meshes. """ globs = [ ("L_*", "R_*"), ("l_*", "r_*"), ("lf_*", "rt_*"), ("*_lf", "*_rt"), ] #: For mirrored influences matching, this specifies the globs that will be used for name substitution use_dg_link_matching = True #: turn on to use dependency graph links use_name_matching = True #: should matching by name be used? use_label_matching = True #: should matching by label be used? use_distance_matching = True #: should matching by influence X,Y,Z coordinates be used? distance_threshold = 0.001 #: When matching by distance, if distance between two positions is greater than this threshold, that pair of influences is not considered as potential match. __mirror_axis = None dg_destination_attribute = "oppositeInfluence" #: default attribute name @property def mirror_axis(self): """ int: Mirror axis (0 - X, 1 - Y, 2 - Z) When mirror axis is not None, matching is done in "mirror" mode: * left/right side .globs are used; * matching by position uses mirrorAxis to invert positions first; """ return self.__mirror_axis @mirror_axis.setter def mirror_axis(self, axis): if is_string(axis): self.__mirror_axis = ['x', 'y', 'z'].index(axis) return if axis is not None and not isinstance(axis, int): raise Exception("invalid axis type, need int") self.__mirror_axis = axis @classmethod def transfer_defaults(cls): """ Builds a mapping configuration that is suitable as default for transferring between meshes (or importing) Returns: InfluenceMappingConfig: default transfer configuration """ result = InfluenceMappingConfig() result.mirror_axis = None result.globs = [] return result def as_json(self): """ serializes config as JSON string """ return json.dumps(self.__dict__) def load_json(self, json_string): """ loads configuration from previously saved `as_json` output """ try: self.__dict__ = json.loads(json_string) except: pass def default_dg_resolver(dg_attribute): from maya import cmds def resolver(input_path): try: sources = cmds.listConnections(input_path + "." + dg_attribute, source=True) if sources: return cmds.ls(sources[0], long=True)[0] except: pass return None return resolver class InfluenceMapping(Object): """ this class serves as a hub to calculate influences mapping, given a mapping config and source/destination influences """ def __init__(self): self.config = InfluenceMappingConfig() # type:InfluenceMappingConfig "assigned config" self.influences = [] # type: list[InfluenceInfo] "Source influences list. Can be assigned to result of :py:meth:`Layers.list_influences`" self.destinationInfluences = None self.calculatedMapping = None self.dg_resolver = lambda: default_dg_resolver(self.config.dg_destination_attribute) def calculate(self): mirror_mode = self.config.mirror_axis is not None log.info("calculate influence mapping, mirror mode: %s", mirror_mode) if self.destinationInfluences is None: self.destinationInfluences = self.influences results = [] if mirror_mode: results.append(({infl: infl for infl in self.destinationInfluences}, "fallback to self")) if self.config.use_distance_matching: matches = distanceMatches( self.influences, self.destinationInfluences, self.config.distance_threshold, mirror_axis=self.config.mirror_axis ) results.append((matches, "distance")) if self.config.use_name_matching: results.append((nameMatches(self.config.globs, self.influences, self.destinationInfluences, mirror_mode=mirror_mode), "name")) if self.config.use_label_matching: results.append((labelMatches(self.influences, self.destinationInfluences, mirror_mode=mirror_mode), "label")) if self.config.use_dg_link_matching: matches = dg_matches(self.influences, self.destinationInfluences, self.dg_resolver()) results.append((matches, "DG link")) result = {} for mapping, matchedRule in results: for k, v in mapping.items(): result[k] = { "matchedRule": matchedRule, "infl": v, } self.calculatedMapping = result return result @staticmethod def asIntIntMapping(mapping): """ :meta private: """ return {k.logicalIndex: v['infl'].logicalIndex for k, v in mapping.items()}