Files
Nexus/2023/scripts/rigging_tools/ngskintools2/api/influenceMapping.py
2025-11-24 08:27:50 +08:00

546 lines
18 KiB
Python

"""
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()}