This commit is contained in:
2025-11-24 08:27:50 +08:00
parent 6940f17517
commit e76152945e
104 changed files with 10003 additions and 0 deletions

View File

@@ -0,0 +1,40 @@
from . import import_v1
from .copy_paste_weights import PasteOperation, copy_weights, cut_weights, paste_weights
from .import_export import FileFormat, export_json, import_json
from .influenceMapping import InfluenceInfo, InfluenceMapping, InfluenceMappingConfig
from .layers import (
Layer,
LayerEffects,
Layers,
NamedPaintTarget,
get_layers_enabled,
init_layers,
)
from .mirror import Mirror, MirrorOptions
from .paint import (
BrushProjectionMode,
BrushShape,
PaintMode,
PaintModeSettings,
PaintTool,
TabletMode,
WeightsDisplayMode,
)
from .suspend_updates import suspend_updates
from .target_info import (
add_influences,
get_related_skin_cluster,
is_slow_mode_skin_cluster,
list_influences,
)
from .tools import (
assign_from_closest_joint,
copy_component_weights,
duplicate_layer,
fill_transparency,
flood_weights,
merge_layers,
paste_average_component_weights,
unify_weights,
)
from .transfer import VertexTransferMode, transfer_layers

View File

@@ -0,0 +1,19 @@
from maya import cmds
def as_comma_separated_list(l):
return ",".join((str(i) for i in l))
def get_source(plug, **kwargs):
for i in cmds.listConnections(plug, source=True, **kwargs) or []:
return i
return None
def get_source_node(plug):
return get_source(plug, plugs=False)
def get_source_plug(plug):
return get_source(plug, plugs=True)

View File

@@ -0,0 +1,47 @@
import json
from maya import cmds
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.python_compatibility import Object
log = getLogger("api/layers")
def __define_property__(name, conversion, doc, refresh_on_write=True):
return property(
lambda self: conversion(self.__load__(name)), lambda self, val: self.__save__(name, conversion(val), refresh=refresh_on_write), doc=doc
)
# noinspection PyBroadException
class Config(Object):
"""
per-skin-cluster configuration
"""
influence_colors = __define_property__(
"influence_colors",
lambda v: {int(k): tuple(float(v[i]) for i in range(3)) for k, v in v.items()},
doc="Influence color map [logical index]->(r,g,b)",
refresh_on_write=True,
)
def __init__(self, data_node):
self.data_node = data_node
def __load__(self, attr):
try:
return json.loads(cmds.getAttr(self.data_node + ".config_" + attr))
except:
return None
def __save__(self, attr, value, refresh=False):
if not cmds.attributeQuery("config_" + attr, node=self.data_node, exists=True):
cmds.addAttr(self.data_node, dt="string", longName="config_" + attr)
cmds.setAttr(self.data_node + ".config_" + attr, json.dumps(value), type='string')
if refresh:
from ngSkinTools2.api.tools import refresh_screen
refresh_screen(self.data_node)

View File

@@ -0,0 +1,37 @@
from ngSkinTools2.api import plugin
def __clipboard_operation__(layer, influences, operation):
influences = "" if influences is None else ','.join([str(i) for i in influences])
plugin.ngst2Layers(layer.mesh, e=True, id=layer.id, clipboard=operation, paintTarget=influences)
def copy_weights(layer, influences):
"""
:type layer: ngSkinTools2.api.layers.Layer
:type influences: list
"""
__clipboard_operation__(layer, influences, 'copy')
def cut_weights(layer, influences):
"""
:type layer: ngSkinTools2.api.layers.Layer
:type influences: list
"""
__clipboard_operation__(layer, influences, 'cut')
class PasteOperation:
replace = 'pasteReplace'
add = 'pasteAdd'
subtract = 'pasteSubtract'
def paste_weights(layer, operation=PasteOperation.replace, influences=None):
"""
:type layer: ngSkinTools2.api.layers.Layer
:param operation: one of paste_* constants
:param influences: list of target influences
"""
__clipboard_operation__(layer, influences, operation)

View File

@@ -0,0 +1,190 @@
"""
## Event handling brainstorm
# Usecase: when selection changes, handlers need to update to this.
Handlers are interested in same data (what's the selected mesh, are layers available, etc). When even is received
by handler, all data to handle the even is there. Data is mostly pre-fetched (assuming that someone will eventually
need it anyway), but for some events lazy-loading might be needed.
# Usecase: event handlers need to respond only when data actually changes (state goes from "layers available"
to "layers unavailable")
Even handlers store that information on heir side. Signal has no way of knowing prevous state of the handler.
# Usecase: event can be fired as a source of multiple other events (layer availability changed: could come from
data transformation or undo/redo event)
Events have their own hierarchy, "layers availability changed" signal stores information about it's previous state
and emits if state changes.
## Events hierarchy complexity
Whenever possible, keep event tree localized in single place for easier refactoring.
"""
from maya import cmds
from ngSkinTools2 import api, cleanup, signal
from ngSkinTools2.api import target_info
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.python_compatibility import Object
from ngSkinTools2.signal import Signal
log = getLogger("events")
class ConditionalEmit(Object):
def __init__(self, name, check):
self.signal = Signal(name)
self.check = check
def emitIfChanged(self):
if self.check():
self.signal.emit()
def addHandler(self, handler, **kwargs):
self.signal.addHandler(handler, **kwargs)
def removeHandler(self, handler):
self.signal.removeHandler(handler)
def script_job(*args, **kwargs):
"""
a proxy on top of cmds.scriptJob for scriptJob creation;
will register an automatic cleanup procedure to kill the job
"""
job = cmds.scriptJob(*args, **kwargs)
def kill():
# noinspection PyBroadException
try:
cmds.scriptJob(kill=job)
except:
# should be no issue if we cannot kill the job anymore (e.g., killing from the
# import traceback; traceback.print_exc()
pass
cleanup.registerCleanupHandler(kill)
return job
class Events(Object):
"""
root tree of events signaling each other
"""
def __init__(self, state):
"""
:type state: ngSkinTools2.api.session.State
"""
def script_job_signal(name):
result = Signal(name + "_scriptJob")
script_job(e=[name, result.emit])
return result
self.mayaDeleteAll = script_job_signal('deleteAll')
self.nodeSelectionChanged = script_job_signal('SelectionChanged')
self.undoExecuted = script_job_signal('Undo')
self.redoExecuted = script_job_signal('Redo')
self.undoRedoExecuted = Signal('undoRedoExecuted')
self.undoExecuted.addHandler(self.undoRedoExecuted.emit)
self.redoExecuted.addHandler(self.undoRedoExecuted.emit)
self.toolChanged = script_job_signal('ToolChanged')
self.quitApplication = script_job_signal('quitApplication')
def check_target_changed():
"""
verify that currently selected mesh is changed, and this means a change in LayersManager.
"""
selection = cmds.ls(selection=True, objectsOnly=True) or []
selected_skin_cluster = None if not selection else target_info.get_related_skin_cluster(selection[-1])
if selected_skin_cluster is not None:
layers_available = api.get_layers_enabled(selected_skin_cluster)
else:
layers_available = False
if state.selectedSkinCluster == selected_skin_cluster and state.layersAvailable == layers_available:
return False
state.selection = selection
state.set_skin_cluster(selected_skin_cluster)
state.skin_cluster_dq_channel_used = (
False if selected_skin_cluster is None else cmds.getAttr(selected_skin_cluster + ".skinningMethod") == 2
)
state.layersAvailable = layers_available
state.all_layers = [] # reset when target has actually changed
log.info("target changed, layers available: %s", state.layersAvailable)
return True
self.targetChanged = event = ConditionalEmit("targetChanged", check_target_changed)
for source in [self.mayaDeleteAll, self.undoRedoExecuted, self.nodeSelectionChanged]:
source.addHandler(event.emitIfChanged)
def check_layers_list_changed():
state.all_layers = [] if not state.layersAvailable else api.Layers(state.selectedSkinCluster).list()
return True
self.layerListChanged = ConditionalEmit("layerListChanged", check_layers_list_changed)
signal.on(self.targetChanged, self.undoRedoExecuted)(self.layerListChanged.emitIfChanged)
def check_current_layer_changed():
# current layer changed if current mesh changed,
# or id within the mesh changed
current_layer = None
if state.selectedSkinCluster is not None and state.layersAvailable:
current_layer = api.Layers(state.selectedSkinCluster).current_layer()
if state.selectedSkinCluster == state.currentLayer.selectedSkinCluster and state.currentLayer.layer == current_layer:
return False
state.currentLayer.selectedSkinCluster = state.selectedSkinCluster
state.currentLayer.layer = current_layer
return True
self.currentLayerChanged = event = ConditionalEmit("currentLayerChanged", check_current_layer_changed)
self.targetChanged.addHandler(event.emitIfChanged)
self.undoRedoExecuted.addHandler(event.emitIfChanged)
def check_current_paint_target_changed():
skin_cluster = state.selectedSkinCluster
new_layer = state.currentLayer.layer
new_targets = None
if new_layer is not None:
new_targets = new_layer.paint_targets
log.info("[%s] checking current influence changed to %s %r", skin_cluster, new_layer, new_targets)
if (
skin_cluster == state.currentInfluence.skinCluster
and new_layer == state.currentInfluence.layer
and new_targets == state.currentInfluence.targets
):
return False
log.info("[%s] current influence changed to %s %r", skin_cluster, new_layer, new_targets)
state.currentInfluence.skinCluster = skin_cluster
state.currentInfluence.layer = new_layer
state.currentInfluence.targets = new_targets
return True
self.currentInfluenceChanged = event = ConditionalEmit("currentInfluenceChanged", check_current_paint_target_changed)
self.currentLayerChanged.addHandler(event.emitIfChanged)
self.influencesListUpdated = Signal("influencesListUpdated")
# now get initial state
self.targetChanged.emitIfChanged()

View File

@@ -0,0 +1,3 @@
from ngSkinTools2.signal import Event
tool_settings_changed = Event('tool_settings_changed')

View File

@@ -0,0 +1,11 @@
def display_error(message):
from ngSkinTools2 import BATCH_MODE
if BATCH_MODE:
import sys
sys.stdout.write(message + "\n")
else:
from ngSkinTools2.ui import dialogs
dialogs.displayError(message)

View File

@@ -0,0 +1,46 @@
import json
import threading
import maya.utils
from .python_compatibility import PY2
# HTTP library might not be available in batch mode
available = True
try:
# different ways to import urlopen
if PY2:
from urllib import urlencode
# noinspection PyUnresolvedReferences
from urllib2 import HTTPError, Request, urlopen
else:
from urllib.error import HTTPError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
_ = urlencode
_ = urlopen
_ = Request
_ = HTTPError
except:
available = False
def encode_url(base_url, args):
return base_url + "?" + urlencode(args)
def get_async(url, success_callback, failure_callback):
def runnerFunc():
defer_func = maya.utils.executeDeferred
try:
result = urlopen(url).read()
defer_func(success_callback, json.loads(result))
except Exception as err:
defer_func(failure_callback, str(err))
t = threading.Thread(target=runnerFunc)
t.start()
return t

View File

@@ -0,0 +1,110 @@
from os import unlink
from ngSkinTools2.api import plugin
from . import transfer
from .influenceMapping import InfluenceMappingConfig
# noinspection PyClassHasNoInit
class FileFormat:
JSON = "json"
CompressedJSON = "compressed json"
# noinspection PyShadowingBuiltins
def import_json(
target,
file,
vertex_transfer_mode=transfer.VertexTransferMode.closestPoint,
influences_mapping_config=InfluenceMappingConfig.transfer_defaults(),
format=FileFormat.JSON,
):
"""
Transfer layers from file into provided target mesh. Existing layers, if any, will be preserved
:param str target: destination mesh or skin cluster node name
:param str file: file path to load json from
:param vertex_transfer_mode: vertex mapping mode when matching imported file's vertices to the target mesh
:param InfluenceMappingConfig influences_mapping_config:
:param str format: expected file format, one of `FileFormat` values
"""
importer = transfer.LayersTransfer()
importer.vertex_transfer_mode = vertex_transfer_mode
importer.influences_mapping.config = influences_mapping_config
importer.load_source_from_file(file, format=format)
importer.target = target
importer.execute()
# noinspection PyShadowingBuiltins
def export_json(target, file, format=FileFormat.JSON):
"""
Save skinning layers to file in json format, to be later used in `import_json`
:param str target: source mesh or skin cluster node name
:param str file: file path to save json to
:param str format: exported file format, one of `FileFormat` values
"""
with FileFormatWrapper(file, format=format, read_mode=False) as f:
plugin.ngst2tools(
tool="exportJsonFile",
target=target,
file=f.plain_file,
)
def compress_gzip(source, dest):
import gzip
import shutil
with open(source, 'rb') as f_in, gzip.open(dest, 'wb') as f_out:
shutil.copyfileobj(f_in, f_out)
def decompress_gzip(source, dest):
import gzip
import shutil
with gzip.open(source, 'rb') as f_in, open(dest, 'wb') as f_out:
shutil.copyfileobj(f_in, f_out)
class FileFormatWrapper:
def __init__(self, target_file, format, read_mode=False):
self.target_file = target_file
self.format = format
self.plain_file = target_file
if self.using_temp_file():
self.plain_file = target_file + "_temp"
self.read_mode = read_mode
def using_temp_file(self):
return self.format != FileFormat.JSON
def __compress__(self):
if self.format == FileFormat.CompressedJSON:
compress_gzip(self.plain_file, self.target_file)
def __decompress__(self):
if self.format == FileFormat.CompressedJSON:
decompress_gzip(self.target_file, self.plain_file)
def __enter__(self):
if not self.using_temp_file():
return self
if self.read_mode:
self.__decompress__()
return self
def __exit__(self, _, value, traceback):
if not self.using_temp_file():
return self
try:
if not self.read_mode:
self.__compress__()
finally:
unlink(self.plain_file)

View File

@@ -0,0 +1,103 @@
# for same mesh, convert from v1 layers to v2
from maya import cmds, mel
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.decorators import undoable
logger = getLogger("import v1")
__has_v1 = None
def has_v1():
global __has_v1
if __has_v1 is not None:
return __has_v1
__has_v1 = False
try:
cmds.loadPlugin('ngSkinTools')
__has_v1 = True
except:
pass
def can_import(target):
if not has_v1():
return False
try:
result = cmds.ngSkinLayer(target, q=True, lda=True)
return result == 1
except Exception as err:
logger.error(err)
return False
def cleanup(selection):
"""
Delete V1 data from provided list of nodes. Must be a v1 compatible target
:type selection: list[string]
"""
for s in selection:
hist = cmds.listHistory(s) or []
skinClusters = [i for i in hist if cmds.nodeType(i) in ('skinCluster')]
cmds.delete(
[
i
for skinCluster in skinClusters
for i in cmds.listHistory(skinCluster, future=True, levels=1)
if cmds.nodeType(i) in ('ngSkinLayerDisplay', 'ngSkinLayerData')
]
)
@undoable
def import_layers(target):
if not has_v1():
return False
import ngSkinTools2.api as ngst_api
layers = ngst_api.init_layers(target)
layerList = cmds.ngSkinLayer(target, q=True, listLayers=True)
layerList = [layerList[i : i + 3] for i in range(0, len(layerList), 3)]
layerIdMap = {0: None}
def get_layer_influences(layerId):
influences = mel.eval("ngSkinLayer -id {0} -q -listLayerInfluences -activeInfluences {1}".format(layerId, target)) or []
return zip(influences[0::2], map(int, influences[1::2]))
def get_layer_weights(layerId, infl):
return mel.eval("ngSkinLayer -id {0:d} -paintTarget {1} -q -w {2:s}".format(layerId, infl, target))
def copy_layer(oldLayerId, newLayer):
"""
:type oldLayerId: int
:type newLayer: ngSkinTools2.api.layers.Layer
"""
newLayer.opacity = mel.eval("ngSkinLayer -id {0:d} -q -opacity {1:s}".format(oldLayerId, target))
newLayer.enabled = mel.eval("ngSkinLayer -id {0:d} -q -enabled {1:s}".format(oldLayerId, target))
newLayer.set_weights(ngst_api.NamedPaintTarget.MASK, get_layer_weights(oldLayerId, "mask"))
newLayer.set_weights(ngst_api.NamedPaintTarget.DUAL_QUATERNION, get_layer_weights(oldLayerId, "dq"))
for inflPath, inflId in get_layer_influences(oldLayerId):
logger.info("importing influence %s for layer %s", inflPath, oldLayerId)
weights = get_layer_weights(oldLayerId, inflId)
newLayer.set_weights(inflId, weights)
with ngst_api.suspend_updates(target):
for layerId, layerName, layerParent in layerList:
layerId = int(layerId)
layerParent = int(layerParent)
newLayer = layers.add(name=layerName, force_empty=True, parent=layerIdMap[layerParent])
newLayer.index = 0
layerIdMap[layerId] = newLayer.id
copy_layer(layerId, newLayer)

View File

@@ -0,0 +1,545 @@
"""
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()}

View File

@@ -0,0 +1,117 @@
import re
from ngSkinTools2.api.python_compatibility import Object
from ngSkinTools2.signal import Signal
class InfluenceNameFilter(Object):
"""
simple helper object to match against filter strings;
accepts filter as a string, breaks it down into lowercase tokens, and
matches values in non-case sensitive way
e.g. filter "leg arm spines" matches "leg", "left_leg",
"R_arm", but does not match "spine"
in a special case of empty filter, returns true for isMatch
"""
def __init__(self):
self.matchers = []
self.changed = Signal("filter changed")
self.currentFilterString = ""
def set_filter_string(self, filter_string):
if self.currentFilterString == filter_string:
# avoid emitting change events if there's no change
return
self.currentFilterString = filter_string
def create_pattern(expression):
expression = "".join([char for char in expression if char.lower() in "abcdefghijklmnopqrstuvwxyz0123456789_*"])
expression = expression.replace("*", ".*")
return re.compile(expression, re.I)
self.matchers = [create_pattern(i.strip()) for i in filter_string.split() if i.strip() != '']
self.changed.emit()
return self
def short_name(self, name):
try:
return name[name.rindex("|") + 1 :]
except Exception as err:
return name
def is_match(self, value):
if len(self.matchers) == 0:
return True
value = self.short_name(str(value).lower())
for pattern in self.matchers:
if pattern.search(value) is not None:
return True
return False
def short_name(name, min_len=0):
"""
:param str name:
:param int min_len:
"""
idx = name.rfind("|", None, len(name) - min_len - 1)
if idx < 0:
return name
return name[idx + 1 :]
class IndexedName(object):
def __init__(self, path):
self.path = path
self.name = short_name(path)
def extend_short_name(self):
self.name = short_name(self.path, len(self.name))
def extend_unique_names(items, from_item):
"""
:param int from_item:
:param list[IndexedName] items:
"""
if from_item >= len(items) - 1:
return
curr = items[from_item]
needs_more_iterations = True
while needs_more_iterations:
needs_more_iterations = False
curr_name = curr.name
for item in items[from_item + 1 :]:
if item.name == curr_name:
if not needs_more_iterations:
curr.extend_short_name()
item.extend_short_name()
needs_more_iterations = True
def unique_names(name_list):
"""
returns a list of shortened names without duplicates. e.g ["|a|b", "|a|b|c", "b|b"] will become ["a|b", "c", "b|b"]
:param name_list:
:return:
"""
# assign index to each name to later restore the original order
indexed_names = [IndexedName(i) for i in name_list]
sorted_by_reveresed_name = sorted(indexed_names, key=lambda x: x.path[::-1])
for i, _ in enumerate(sorted_by_reveresed_name):
extend_unique_names(sorted_by_reveresed_name, i)
return [i.name for i in indexed_names]

View File

@@ -0,0 +1,17 @@
def make_editable_property(propertyName):
return property(lambda self: self.__query__(**{propertyName: True}), lambda self, val: self.__edit__(**{propertyName: val}))
def influences_map_to_list(influencesMapping):
return ','.join(str(k) + "," + str(v) for (k, v) in list(influencesMapping.items()))
def float_list_as_string(floatList):
"""
returns empty string for None and []
otherwise, returns a list of floats, comma delimited
"""
if not floatList:
return ""
return ",".join([str(i) for i in floatList])

View File

@@ -0,0 +1,416 @@
import json
from maya import mel
from ngSkinTools2.api import internals, plugin, target_info
from ngSkinTools2.api.config import Config
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.python_compatibility import Object, is_string
from ngSkinTools2.api.suspend_updates import suspend_updates
from ngSkinTools2.decorators import undoable
logger = getLogger("api/layers")
class NamedPaintTarget(Object):
MASK = "mask"
DUAL_QUATERNION = "dq"
class LayerEffects(Object):
def __init__(self, layer, state=None):
self.__layer = layer # type: Layer
if state is not None:
self.__set_state__(state)
def __set_state__(self, state):
from ngSkinTools2.api import MirrorOptions
self.mirror_mask = state.get("mirrorMask", False)
self.mirror_weights = state.get("mirrorWeights", False)
self.mirror_dq = state.get("mirrorDq", False)
self.mirror_direction = state.get("mirrorDirection", MirrorOptions.directionPositiveToNegative)
def configure_mirror(self, everything=None, mirror_mask=None, mirror_weights=None, mirror_dq=None, mirror_direction=None):
"""
Enable/disable components for mirror effect:
>>> layer.effects.configure_mirror(mirror_mask=True)
>>> layer.effects.configure_mirror(mirror_dq=False)
>>> # equivalent of setting all flags to False
>>> layer.effects.configure_mirror(everything=False)
Mirroring direction must be set explicitly.
>>> from ngSkinTools2.api import MirrorOptions
>>> layer.effects.configure_mirror(mirror_mask=True,mirror_direction=MirrorOptions.directionPositiveToNegative)
:arg bool mirror_mask: should mask be mirrored with this effect?
:arg bool mirror_weights: should influence weights be mirrored with this effect?
:arg bool mirror_dq: should dq weights be mirrored with this effect?
:arg int mirror_direction: mirroring direction. Use `MirrorOptions.directionPositiveToNegative`, `MirrorOptions.directionNegativeToPositive`
or `MirrorOptions.directionFlip`
"""
if everything is not None:
mirror_mask = mirror_dq = mirror_weights = everything
logger.info(
"configure mirror: layer %s mask %r weights %r dq %r direction %r",
self.__layer.name,
mirror_mask,
mirror_weights,
mirror_dq,
mirror_direction,
)
args = {'mirrorLayerDq': mirror_dq, 'mirrorLayerMask': mirror_mask, 'mirrorLayerWeights': mirror_weights, "mirrorDirection": mirror_direction}
self.__layer.__edit__(configureMirrorEffect=True, **{k: v for k, v in list(args.items()) if v is not None})
def _build_layer_property(name, doc, edit_name=None, default_value=None):
if edit_name is None:
edit_name = name
def getter(self):
return self.__get_state__(name, default_value=default_value)
def setter(self, val):
if isinstance(val, (list, tuple)):
val = ",".join([str(i) for i in val])
self.__edit__(**{edit_name: val})
return property(getter, setter, doc=doc)
class Layer(Object):
""" """
name = _build_layer_property('name', "str: Layer name") # type: str
enabled = _build_layer_property('enabled', "bool: is layer enabled or disabled") # type: bool
opacity = _build_layer_property('opacity', "float: value between 1.0 and 0") # type: float
paint_target = _build_layer_property(
'paintTarget', "str or int: currently active paint target for this layer (either an influence or one of named targets)"
) # type: Union(str, int)
index = _build_layer_property('index', edit_name='layerIndex', doc="int: layer index in parent's child list; set to reorder") # type: int
locked_influences = _build_layer_property(
'lockedInfluences',
doc="list[int]: list of locked influence indexes",
default_value=[],
) # type: list[int]
@classmethod
def load(cls, mesh, layer_id):
if layer_id < 0:
raise Exception("invalid layer ID: %s" % layer_id)
result = Layer(mesh, layer_id)
result.reload()
return result
def __init__(self, mesh, id, state=None):
self.mesh = mesh
self.id = id
self.effects = LayerEffects(self) # type: LayerEffects
"configure effects for this layer"
self.__state = None
if state is not None:
self.__set_state(state)
def __get_state__(self, k, default_value=None):
return self.__state.get(k, default_value)
def __query__(self, arg, **kwargs):
keys = " ".join(["-{k} {v}".format(k=k, v=v) for k, v in list(kwargs.items())])
return mel.eval("ngst2Layers -id {id} {keys} -q -{arg} {mesh}".format(id=self.id, mesh=self.mesh, keys=keys, arg=arg))
def __edit__(self, **kwargs):
self.__set_state(plugin.ngst2Layers(self.mesh, e=True, id=as_layer_id(self), **kwargs))
def __set_state(self, state):
if state is None:
# some plugin functions still return empty result after edits - nevermind those
return
if is_string(state):
try:
state = json.loads(state)
except Exception as err:
raise Exception(str(err) + "; input body was: " + repr(state))
self.__state = state
# logger.info("setting layer state %r: %r", self.id, state)
self.parent_id = state['parentId']
self.__parent = None
self.children_ids = state['children']
self.__children = []
self.effects.__set_state__(state['effects'])
def reload(self):
"""
Refresh layer data from plugin.
"""
self.__set_state(self.__query__('layerAttributesJson'))
def __eq__(self, other):
if not isinstance(other, Layer):
return False
return self.mesh == other.mesh and self.id == other.id and self.__state == other.__state
def __repr__(self):
return "[Layer #{id} '#{name}']".format(id=self.id, name=self.name)
@property
def paint_targets(self):
"""
list[str or int]: list of paint targets to be set as current for this layer
"""
return self.__get_state__("paintTargets")
@paint_targets.setter
def paint_targets(self, targets):
self.__edit__(**{"paintTarget": ",".join([str(target) for target in targets])})
@property
def parent(self):
"""
Layer: layer parent, or None, if layer is at root level.
"""
if self.__parent is None:
if self.parent_id is not None:
self.__parent = Layer.load(self.mesh, self.parent_id)
return self.__parent
@parent.setter
def parent(self, parent):
if parent is None:
parent = 0
self.__edit__(parent=as_layer_id(parent))
@property
def num_children(self):
"""
int: a bit more lightweight method to count number of child layers than len(children()), as it does not
prefetch children data.
"""
return len(self.children_ids)
@property
def children(self):
"""
list[Layer]: lazily load children if needed, and return as Layer objects
"""
if len(self.children_ids) != 0:
if len(self.__children) == 0:
self.__children = [Layer.load(self.mesh, i) for i in self.children_ids]
return self.__children
def set_current(self):
"""
Set as "default" layer for other operations.
.. warning::
Scheduled for removal. API calls should specify target layer explicitly
"""
plugin.ngst2Layers(self.mesh, currentLayer=self.id)
def set_weights(self, influence, weights_list, undo_enabled=True):
"""
Modify weights in the layer.
:arg int/str influence: either index of an influence, or named paint target (one of :py:class:`NamedPaintTarget` values)
:arg list[int] weights_list: weights for each vertex (must match number of vertices in skin cluster)
:arg bool undo_enabled: set to False if you don't need undo, for slight performance boost
"""
self.__edit__(paintTarget=influence, vertexWeights=internals.float_list_as_string(weights_list), undoEnabled=undo_enabled)
def get_weights(self, influence):
"""
get influence (or named paint target) weights for all vertices
"""
result = self.__query__('vertexWeights', paintTarget=influence)
if result is None:
return []
return [float(i) for i in result]
def get_used_influences(self):
"""
:rtype: list[int]
"""
result = self.__query__('usedInfluences')
return result or []
def as_layer_id(layer):
"""
converts given input to layer ID. If input is a Layer object, returns it's ID, otherwise assumes that input is already a layer ID
Returns:
int: layer ID
"""
if isinstance(layer, Layer):
return layer.id
return int(layer)
def as_layer_id_list(layers):
"""
maps a given layer list with `as_layer_id`
Args:
layers (list[Any]): objects representing a list of layers
:rtype: list[int]
"""
return (as_layer_id(i) for i in layers)
def generate_layer_name(existing_layers, base_name):
"""
A little utility to generate a unique layer name. For example, if base_name="test", it will try to use values in sequence "test", "test (1)",
"test (2)" and will return first value that is not a name for any layer in the given layers list.
:arg existing_layers Layer: whatever
"""
name = base_name
currentLayerNames = [i.name for i in existing_layers]
index = 1
while name in currentLayerNames:
index += 1
name = base_name + " ({0})".format(index)
return name
class Layers(Object):
"""
Layers manages skinning layers on provided target (skinCluster or a mesh)
"""
prune_weights_filter_threshold = internals.make_editable_property('pruneWeightsFilterThreshold')
influence_limit_per_vertex = internals.make_editable_property('influenceLimitPerVertex')
def __init__(self, target):
"""
:param str target: name of skin cluster node or skinned mesh.
"""
if not target:
raise Exception("target must be specified")
self.__target = target
self.__cached_data_node = None
def add(self, name, force_empty=False, parent=None):
"""
creates new layer with given name and returns its ID; when force_empty flag is set to true,
layer weights will not be populated from skin cluster.
"""
layer_id = plugin.ngst2Layers(self.mesh, name=name, add=True, forceEmpty=force_empty)
result = Layer.load(self.mesh, layer_id)
result.parent = parent
return result
def delete(self, layer):
plugin.ngst2Layers(self.mesh, removeLayer=True, id=as_layer_id(layer))
def list(self):
"""
returns all layers as Layer objects.
:rtype list[Layer]
"""
data = json.loads(plugin.ngst2Layers(self.mesh, q=True, listLayers=True))
return [Layer(self.mesh, id=l['id'], state=l) for l in data]
@undoable
def clear(self):
"""
delete all layers
"""
with suspend_updates(self.data_node):
for i in self.list():
if i.parent_id is None:
self.delete(i)
def list_influences(self):
"""
Wraps :py:meth:`target_info.list_influences`
"""
return target_info.list_influences(self.mesh)
def current_layer(self):
"""
get current layer that was previously marked as current with :py:meth:`Layer.set_current`.
.. warning::
Scheduled for removal. API calls should specify target layer explicitly
"""
layer_id = plugin.ngst2Layers(self.mesh, q=True, currentLayer=True)
if layer_id < 0:
return None
return Layer.load(self.mesh, layer_id)
def __edit__(self, **kwargs):
plugin.ngst2Layers(self.mesh, e=True, **kwargs)
def __query__(self, **kwargs):
return plugin.ngst2Layers(self.mesh, q=True, **kwargs)
def set_influences_mirror_mapping(self, influencesMapping):
plugin.ngst2Layers(self.mesh, configureMirrorMapping=True, influencesMapping=internals.influences_map_to_list(influencesMapping))
@property
def mesh(self):
return self.__target
def is_enabled(self):
"""
returns true if skinning layers are enabled for the given mesh
:return:
"""
return get_layers_enabled(self.mesh)
@property
def data_node(self):
if not self.__cached_data_node:
self.__cached_data_node = target_info.get_related_data_node(self.mesh)
return self.__cached_data_node
@property
def config(self):
return Config(self.data_node)
def init_layers(target):
"""Attach ngSkinTools data node to given target. Does nothing if layers are already attached.
:arg str target: skin cluster or mesh node to attach layers to
:rtype: Layers
"""
plugin.ngst2Layers(target, layerDataAttach=True)
return Layers(target)
def get_layers_enabled(selection):
"""
return true if layers are enabled on this selection
"""
return plugin.ngst2Layers(selection, q=True, lda=True)

View File

@@ -0,0 +1,76 @@
import logging
import sys
from ngSkinTools2.api.python_compatibility import Object
class DummyLogger(Object):
def __getattr__(self, name):
return self.doNothing
def doNothing(self, *args, **kwargs):
pass
def isEnabledFor(self, *args):
return False
def getLogger(self, name):
return self
class LogLineCountFilter(logging.Filter):
def __init__(self):
self.count = 1
def filter(self, record):
record.count = self.count
self.count = (self.count + 1) % 1000
return True
class SimpleLoggerFactory(Object):
ROOT_LOGGER_NAME = "ngSkinTools2"
def __init__(self, level=logging.DEBUG):
self.level = level
self.log = self.configureRootLogger()
def configureRootLogger(self):
logger = logging.getLogger(self.ROOT_LOGGER_NAME)
logger.setLevel(self.level)
logger.addFilter(LogLineCountFilter())
# logger.handlers = []
formatter = logging.Formatter("%(count)3d: [UI %(levelname)s %(filename)s:%(lineno)d] %(message)s")
formatter.datefmt = '%H:%M:%S'
for i in logger.handlers[:]:
logger.removeHandler(i)
ch = logging.StreamHandler(sys.__stdout__)
ch.setLevel(self.level)
ch.setFormatter(formatter)
logger.addHandler(ch)
logger.propagate = False
return logger
def getLogger(self, name):
return self.log
self.log.debug("creating logger '%s'" % name)
result = logging.getLogger(self.ROOT_LOGGER_NAME + "." + name)
result.setLevel(self.level)
result.info("alive check")
return result
import ngSkinTools2
currentLoggerFactory = DummyLogger() if not ngSkinTools2.DEBUG_MODE else SimpleLoggerFactory(level=logging.DEBUG)
def getLogger(name):
return currentLoggerFactory.getLogger(name)

View File

@@ -0,0 +1,184 @@
import itertools
from maya import cmds
from ngSkinTools2.api import influenceMapping, internals, plugin, target_info
from ngSkinTools2.api.cmd_wrappers import get_source_node
from ngSkinTools2.api.layers import Layers
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.python_compatibility import Object
log = getLogger("mirror")
class Mirror(Object):
"""
query and configure mirror options for provided target
"""
axis = internals.make_editable_property('mirrorAxis')
seam_width = internals.make_editable_property('mirrorWidth')
vertex_transfer_mode = internals.make_editable_property('vertexTransferMode')
def __init__(self, target):
"""
:type target: skin target (skinCluster or mesh)
"""
self.target = target
self.__skin_cluster__ = None
self.__data_node__ = None
# noinspection PyMethodMayBeStatic
def __query__(self, **kwargs):
return plugin.ngst2Layers(self.target, q=True, **kwargs)
# noinspection PyMethodMayBeStatic
def __edit__(self, **kwargs):
plugin.ngst2Layers(self.target, configureMirrorMapping=True, **kwargs)
self.recalculate_influences_mapping()
def __mapper_config_attr(self):
return self.__get_data_node__() + ".influenceMappingOptions"
def build_influences_mapper(self, defaults=None):
mapper = influenceMapping.InfluenceMapping()
layers = Layers(self.target)
mapper.influences = layers.list_influences()
mapper.config.load_json(cmds.getAttr(self.__mapper_config_attr()))
mapper.config.mirror_axis = self.axis
return mapper
def save_influences_mapper(self, mapper):
"""
:type mapper: influenceMapping.InfluenceMapping
"""
self.set_mirror_config(mapper.config.as_json())
def set_mirror_config(self, config_as_json):
cmds.setAttr(self.__mapper_config_attr(), config_as_json, type='string')
def set_influences_mapping(self, mapping):
"""
:type mapping: map[int] -> int
"""
log.info("mapping updated: %r", mapping)
mapping_as_string = ','.join(str(k) + "," + str(v) for (k, v) in list(mapping.items()))
plugin.ngst2Layers(self.target, configureMirrorMapping=True, influencesMapping=mapping_as_string)
def recalculate_influences_mapping(self):
"""
loads current influence mapping settings, and update influences mapping with these values
"""
m = self.build_influences_mapper().calculate()
self.set_influences_mapping(influenceMapping.InfluenceMapping.asIntIntMapping(m))
def mirror(self, options):
"""
:type options: MirrorOptions
"""
plugin.ngst2Layers(
self.target,
mirrorLayerWeights=options.mirrorWeights,
mirrorLayerMask=options.mirrorMask,
mirrorLayerDq=options.mirrorDq,
mirrorDirection=options.direction,
)
def set_reference_mesh(self, mesh_shape):
dest = self.__get_data_node__() + ".mirrorMesh"
if mesh_shape:
cmds.connectAttr(mesh_shape + ".outMesh", dest)
else:
for i in cmds.listConnections(dest, source=True, plugs=True):
cmds.disconnectAttr(i, dest)
def get_reference_mesh(self):
return get_source_node(self.__get_data_node__() + '.mirrorMesh')
def __get_skin_cluster__(self):
if self.__skin_cluster__ is None:
self.__skin_cluster__ = target_info.get_related_skin_cluster(self.target)
return self.__skin_cluster__
def __get_data_node__(self):
if self.__data_node__ is None:
self.__data_node__ = target_info.get_related_data_node(self.target)
return self.__data_node__
# noinspection PyStatementEffect
def build_reference_mesh(self):
sc = self.__get_skin_cluster__()
dn = self.__get_data_node__()
if sc is None or dn is None:
return
existing_ref_mesh = self.get_reference_mesh()
if existing_ref_mesh:
cmds.select(existing_ref_mesh)
raise Exception("symmetry mesh already configured for %s: %s" % (str(sc), existing_ref_mesh))
def get_shape(node):
return cmds.listRelatives(node, shapes=True)[0]
result, _ = cmds.polyCube()
g = cmds.group(empty=True, name="ngskintools_mirror_mesh_setup")
cmds.parent(result, g)
result = cmds.rename(g + "|" + result, "mirror_reference_mesh")
cmds.delete(result, ch=True)
cmds.connectAttr(sc + ".input[0].inputGeometry", get_shape(result) + ".inMesh")
cmds.delete(result, ch=True)
(mirrored,) = cmds.duplicate(result)
mirrored = cmds.rename(g + "|" + mirrored, 'flipped_preview')
mirrored_shape = get_shape(mirrored)
cmds.setAttr(mirrored + ".sx", -1)
cmds.setAttr(mirrored + ".overrideEnabled", 1)
cmds.setAttr(mirrored + ".overrideDisplayType", 2)
cmds.setAttr(mirrored + ".overrideShading", 0)
cmds.setAttr(mirrored + ".overrideTexturing", 1)
(blend,) = cmds.blendShape(result, mirrored_shape)
cmds.setAttr(blend + ".weight[0]", 1.0)
# lock accidental transformations
for c, t, m in itertools.product('xyz', 'trs', (result, mirrored)):
cmds.setAttr(m + "." + t + c, lock=True)
# shift setup to the right by slightly more than bounding box width
bb = cmds.exactWorldBoundingBox(g)
cmds.move((bb[3] - bb[0]) * 1.2, 0, 0, g, r=True)
self.set_reference_mesh(str(result))
cmds.select(result)
return result
class MirrorOptions(Object):
directionNegativeToPositive = 0
directionPositiveToNegative = 1
directionGuess = 2
directionFlip = 3
def __init__(self):
self.mirrorWeights = True
self.mirrorMask = True
self.mirrorDq = True
self.direction = MirrorOptions.directionPositiveToNegative
def set_reference_mesh_from_selection():
selection = cmds.ls(sl=True, long=True)
if len(selection) != 2:
log.debug("wrong selection size")
return
m = Mirror(selection[1])
m.set_reference_mesh(selection[0])

View File

@@ -0,0 +1,466 @@
import copy
import json
from maya import cmds
from ngSkinTools2.api import internals, plugin
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.pyside import QtCore
from ngSkinTools2.api.python_compatibility import Object
log = getLogger("api/paint")
# noinspection PyClassHasNoInit
class BrushProjectionMode(Object):
surface = 0
screen = 1
# noinspection PyClassHasNoInit
class PaintMode(Object):
"""
Constants for paint mode
"""
replace = 1
add = 2
scale = 3
smooth = 4
sharpen = 5
@classmethod
def all(cls):
return cls.replace, cls.smooth, cls.add, cls.scale, cls.sharpen
# noinspection PyClassHasNoInit
class TabletMode(Object):
unused = 0
multiplyIntensity = 1
multiplyOpacity = 2
multiplyRadius = 3
# noinspection PyClassHasNoInit
class WeightsDisplayMode(Object):
allInfluences = 0
currentInfluence = 1
currentInfluenceColored = 2
# noinspection PyClassHasNoInit
class MaskDisplayMode(Object):
default_ = 0
color_ramp = 1
# noinspection PyClassHasNoInit
class BrushShape(Object):
solid = 0 # 1.0 for whole brush size
smooth = 1 # feathered edges
gaus = 2 # very smooth from center
# noinspection PyClassHasNoInit
class PaintModeSettings(Object):
"""
Brush/Flood settings
"""
__property_map = {
"brush_projection_mode": 'brushProjectionMode',
"mode": 'paintMode',
"brush_radius": 'brushRadius',
"brush_shape": 'brushShape',
"intensity": 'brushIntensity',
"iterations": 'brushIterations',
"tablet_mode": 'tabletMode',
"mirror": 'interactiveMirror',
"influences_limit": 'influencesLimit',
"fixed_influences_per_vertex": 'fixedInfluencesPerVertex',
"limit_to_component_selection": 'limitToComponentSelection',
"use_volume_neighbours": 'useVolumeNeighbours',
"distribute_to_other_influences": 'redistributeRemovedWeight',
"sample_joint_on_stroke_start": 'sampleJointOnStrokeStart',
}
mode = PaintMode.replace #: Tool mode. One of the :py:class:`PaintMode` values.
# varies by mode
intensity = 1.0 #: tool intensity;
iterations = 1
"""
iterations; repeats the same smooth operation given number of times -
using this parameter instead of calling `flood_weights` multiple times.
"""
brush_shape = BrushShape.solid
# varies by screen mode
# can be modified inside the plugin
brush_radius = 10
mirror = False #: is automatic mirroring on or off
distribute_to_other_influences = False
influences_limit = 0 #: influences limit per vertex to ensure while smoothing
brush_projection_mode = BrushProjectionMode.surface #: brush projection mode, one of :py:class:`BrushProjectionMode` values.
sample_joint_on_stroke_start = False
tablet_mode = TabletMode.unused
use_volume_neighbours = False
limit_to_component_selection = False
fixed_influences_per_vertex = False
"""
only applicable for smooth mode; when set to True, smoothing will not add additional influences to a vertex.
"""
def apply_primary_brush(self):
self.__apply(1)
def apply_alternative_brush(self):
self.__apply(2)
def apply_inverted_brush(self):
self.__apply(3)
def __apply(self, settings_type):
"""
apply settings to C++ plugin side.
:param settings_type:
"""
kwargs = {v: getattr(self, k) for k, v in self.__property_map.items()}
kwargs['paintType'] = settings_type
plugin.ngst2PaintSettingsCmd(**kwargs)
def from_dict(self, values):
# type: (dict) -> PaintModeSettings
for k in self.__property_map.keys():
if k in values:
setattr(self, k, values[k])
return self
def to_dict(self):
# type: () -> dict
return {k: getattr(self, k) for k in self.__property_map.keys()}
def __make_common_property__(property_name):
def setval(self, val):
setattr(self.primary_settings, property_name, val)
self.apply_settings()
return property(lambda self: getattr(self.primary_settings, property_name), setval)
def __make_mode_property__(property_name):
return __make_dimensional_property__(lambda self: self.mode, lambda self: self.mode_settings[self.mode], property_name)
def __make_projection_property__(property_name):
return __make_dimensional_property__(
lambda self: self.brush_projection_mode, lambda self: self.projection_settings[self.brush_projection_mode], property_name
)
def __make_dimensional_property__(name, get_dimension_func, property_name):
def setval(self, val):
log.debug("setting dimensional property %s/%s: %r", name(self), property_name, val)
dimension = get_dimension_func(self)
dimension[property_name] = val
setattr(self.primary_settings, property_name, val)
self.apply_settings()
def getval(self):
return get_dimension_func(self).get(property_name, getattr(self.primary_settings, property_name))
return property(getval, setval)
class PaintSettingsModel(Object):
"""
Paint settings model manages paint settings persistence and storage on CPP side;
in CPP side three states need to be maintained:
* primary settings
* alternative settings (used when shift is pressed)
* inverse settings (used when ctrl is pressed)
"""
projection_settings = None # type: dict
mode_settings = None # type: dict
primary_settings = None # type: PaintModeSettings
paint_mode = __make_common_property__("mode")
mode = __make_common_property__("mode")
intensity = __make_mode_property__("intensity")
iterations = __make_mode_property__("iterations")
brush_shape = __make_mode_property__("brush_shape")
brush_radius = __make_projection_property__("brush_radius")
mirror = __make_common_property__("mirror")
distribute_to_other_influences = __make_common_property__("distribute_to_other_influences")
influences_limit = __make_common_property__("influences_limit")
brush_projection_mode = __make_common_property__("brush_projection_mode")
sample_joint_on_stroke_start = __make_common_property__("sample_joint_on_stroke_start")
tablet_mode = __make_common_property__("tablet_mode")
use_volume_neighbours = __make_common_property__("use_volume_neighbours")
limit_to_component_selection = __make_common_property__("limit_to_component_selection")
fixed_influences_per_vertex = __make_common_property__("fixed_influences_per_vertex")
def __init__(self):
self.projection_settings = None
self.mode_settings = None
self.primary_settings = None
self.storage_func_save = lambda data: None
self.storage_func_load = lambda: ""
self.apply_settings_func = self.apply_plugin_settings
self.setup_maya_option_var_persistence()
def __save_settings(self):
data = {
"common": self.primary_settings.to_dict(),
"mode_settings": self.mode_settings,
"projection_settings": self.projection_settings,
}
serialized_data = json.dumps(data)
log.info("saving brush settings: %s", serialized_data)
self.storage_func_save(serialized_data)
def load_settings(self):
def to_int_keys(d):
return {int(k): v for k, v in d.items()}
try:
saved_data = self.storage_func_load()
log.info("loading brush settings from %s", saved_data)
if saved_data is None:
self.initialize_defaults()
return
data = json.loads(saved_data)
self.primary_settings = PaintModeSettings().from_dict(data['common'])
self.mode_settings = to_int_keys(data['mode_settings'])
self.projection_settings = to_int_keys(data['projection_settings'])
self.apply_settings()
except Exception as err:
log.info(err)
def setup_maya_option_var_persistence(self):
from ngSkinTools2.ui import options
val = options.PersistentValue(options.VAR_OPTION_PREFIX + "_brush_settings")
self.storage_func_load = val.get
self.storage_func_save = val.set
self.load_settings()
def __bake_settings(self, mode):
result = copy.copy(self.primary_settings)
result.mode = mode
for k, v in self.mode_settings[mode].items():
setattr(result, k, v)
for k, v in self.projection_settings[self.brush_projection_mode].items():
setattr(result, k, v)
return result
def apply_settings(self):
# TODO: very inelegant here; we should not have to re-bake and re-set all settings at once
# if we're switching any of dimensional settings, we need to reflect this
self.primary_settings = self.__bake_settings(self.mode)
primary = self.primary_settings
inverted_modes = {
PaintMode.replace: PaintMode.replace,
PaintMode.add: PaintMode.scale,
PaintMode.scale: PaintMode.add,
PaintMode.smooth: PaintMode.sharpen,
PaintMode.sharpen: PaintMode.smooth,
}
alternative = self.__bake_settings(PaintMode.smooth)
inverted = self.__bake_settings(inverted_modes.get(self.mode, PaintMode.replace))
if self.mode == PaintMode.replace:
inverted.intensity = 0
log.debug("normal mode intensity: %r", primary.intensity)
self.apply_settings_func(primary, alternative, inverted)
self.__save_settings()
def initialize_defaults(self):
self.primary_settings = PaintModeSettings()
self.primary_settings.mode = PaintMode.replace
self.primary_settings.brush_radius = 2
self.primary_settings.intensity = 1.0
self.primary_settings.tablet_mode = TabletMode.unused
self.primary_settings.brush_shape = BrushShape.solid
self.primary_settings.distribute_to_other_influences = False
self.mode_settings = {
PaintMode.replace: {
"intensity": 1.0,
"brush_shape": BrushShape.solid,
},
PaintMode.add: {
"intensity": 0.1,
"brush_shape": BrushShape.solid,
},
PaintMode.scale: {
"intensity": 0.95,
"brush_shape": BrushShape.solid,
},
PaintMode.smooth: {
"intensity": 0.2,
"iterations": 5,
"brush_shape": BrushShape.smooth,
},
PaintMode.sharpen: {
"intensity": 0.2,
"iterations": 5,
"brush_shape": BrushShape.smooth,
},
}
self.projection_settings = {
BrushProjectionMode.surface: {
"brush_radius": 2,
},
BrushProjectionMode.screen: {
"brush_radius": 100,
},
}
self.apply_settings()
# noinspection PyMethodMayBeStatic
def apply_plugin_settings(self, primary, alternative, inverted):
# type: (PaintModeSettings, PaintModeSettings, PaintModeSettings) -> None
primary.apply_primary_brush()
alternative.apply_alternative_brush()
inverted.apply_inverted_brush()
class DisplaySettings(Object):
def __init__(self):
from ngSkinTools2.ui import options
self.persistence = options.PersistentDict("paint_display_settings")
weights_display_mode = internals.make_editable_property('weightsDisplayMode')
mask_display_mode = internals.make_editable_property('maskDisplayMode')
layer_effects_display = internals.make_editable_property('layerEffectsDisplay')
display_masked = internals.make_editable_property('displayMasked')
show_selected_verts_only = internals.make_editable_property('showSelectedVertsOnly')
wireframe_color = internals.make_editable_property('wireframeColor')
wireframe_color_single_influence = internals.make_editable_property('wireframeColorSingleInfluence')
# noinspection PyMethodMayBeStatic
def __edit__(self, **kwargs):
plugin.ngst2PaintSettingsCmd(**kwargs)
for k, v in kwargs.items():
self.persistence[k] = v
@property
def display_node_visible(self):
"""
gets/sets visibility of temporary node that displays weight colors. when set to false, displays original mesh instead.
"""
return plugin.ngst2PaintSettingsCmd(q=True, displayNodeVisible=True)
@display_node_visible.setter
def display_node_visible(self, value):
plugin.ngst2PaintSettingsCmd(displayNodeVisible=value)
# noinspection PyMethodMayBeStatic
def __query__(self, **kwargs):
for k in kwargs:
persisted = self.persistence[k]
if persisted is not None:
return persisted
return plugin.ngst2PaintSettingsCmd(q=True, **kwargs)
class PaintTool(PaintSettingsModel):
__paint_context = None
def __init__(self):
PaintSettingsModel.__init__(self)
self.display_settings = DisplaySettings()
def update_plugin_brush_radius(self):
new_value = plugin.ngst2PaintSettingsCmd(q=True, brushRadius=True)
if self.brush_radius != new_value:
self.brush_radius = new_value
def update_plugin_brush_intensity(self):
new_value = plugin.ngst2PaintSettingsCmd(q=True, brushIntensity=True)
if self.intensity != new_value:
self.intensity = new_value
@classmethod
def start(cls):
if cls.__paint_context is None:
cls.__paint_context = plugin.ngst2PaintContext()
cmds.setToolTo(cls.__paint_context)
def flood(self, layer, influence=None, influences=None):
from ngSkinTools2.api import tools
tools.flood_weights(target=layer, influence=influence, influences=influences, settings=self.primary_settings)
@classmethod
def is_painting(cls):
return cmds.contextInfo(cmds.currentCtx(), c=True) == 'ngst2PaintContext'
class Popups(Object):
def __init__(self):
self.windows = []
def add(self, w):
self.windows.append(w)
w.destroyed.connect(lambda *args: self.remove(w))
def remove(self, w):
self.windows = [i for i in self.windows if i != w]
def close_all(self):
for i in self.windows:
i.close()
self.windows = []
popups = Popups()
class TabletEventFilter(QtCore.QObject):
def __init__(self):
QtCore.QObject.__init__(self)
self.pressure = 1.0
def eventFilter(self, obj, event):
if event.type() in [QtCore.QEvent.TabletPress, QtCore.QEvent.TabletMove]:
self.pressure = event.pressure()
# log.info("tablet pressure: %r", self.pressure)
return QtCore.QObject.eventFilter(self, obj, event)
def install(self):
from ngSkinTools2.ui import qt
log.info("installing event filter...")
qt.mainWindow.installEventFilter(self)
log.info("...done")
def uninstall(self):
from ngSkinTools2.ui import qt
qt.mainWindow.removeEventFilter(self)
log.info("event filter uninstalled")
tabletEventFilter = TabletEventFilter()

View File

@@ -0,0 +1,70 @@
import json
from maya import cmds, mel
from ngSkinTools2.api import feedback
from ngSkinTools2.api.log import getLogger
log = getLogger("plugin")
def ngst2Layers(*args, **kwargs):
log.debug("ngst2layers [%r] [%r]", args, kwargs)
return cmds.ngst2Layers(*args, **kwargs)
def ngst2LayersMel(cmd):
cmd = "ngst2Layers " + cmd
log.debug(cmd)
return mel.eval(cmd)
def ngst2tools(**kwargs):
log.debug("ngst2tools [%r]", kwargs)
result = cmds.ngst2Tools(json.dumps(kwargs))
if result is not None:
result = json.loads(result)
return result
def ngst2PaintContext():
log.debug("ngst2PaintContext()")
return cmds.ngst2PaintContext()
def ngst2PaintSettingsCmd(**kwargs):
log.debug("ngst2PaintSettingsCmd [%r]", kwargs)
return cmds.ngst2PaintSettingsCmd(**kwargs)
def ngst2_hotkey(**kwargs):
log.debug("ngst2Hotkey [%r]", kwargs)
return cmds.ngst2Hotkey(**kwargs)
pluginBinary = 'ngSkinTools2'
def is_plugin_loaded():
return cmds.pluginInfo(pluginBinary, q=True, loaded=True)
def load_plugin():
from maya import cmds
if not is_plugin_loaded():
cmds.loadPlugin(pluginBinary, quiet=True)
if not is_plugin_loaded():
feedback.display_error("Failed to load the plugin. This is often a case-by-case issue - contact support.")
return
from ngSkinTools2 import version
expected_version = version.pluginVersion()
actual_version = cmds.pluginInfo(pluginBinary, q=True, version=True)
if actual_version != expected_version:
feedback.display_error(
"Invalid plugin version detected: required '{expectedVersion}', "
"but was '{actualVersion}'. Clean reinstall recommended.".format(expectedVersion=expected_version, actualVersion=actual_version)
)

View File

@@ -0,0 +1,29 @@
pyside6 = False
try:
from PySide6 import QtCore, QtGui, QtSvg, QtWidgets
from PySide6.QtCore import Qt
from PySide6.QtGui import QAction, QActionGroup
from PySide6.QtSvgWidgets import QSvgWidget
from PySide6.QtWidgets import QWidget
pyside6 = True
except:
from PySide2 import QtCore, QtGui, QtSvg, QtWidgets
from PySide2.QtCore import Qt
from PySide2.QtSvg import QSvgWidget
from PySide2.QtWidgets import QAction, QActionGroup, QWidget
def get_main_window():
from maya import OpenMayaUI as omui
return wrap_instance(omui.MQtUtil.mainWindow(), QWidget)
def wrap_instance(ptr, widget):
if pyside6:
from shiboken6 import wrapInstance
else:
from shiboken2 import wrapInstance
return wrapInstance(int(ptr), widget)

View File

@@ -0,0 +1,24 @@
import sys
PY2 = sys.version_info[0] == 2
PY3 = not PY2
def is_string(obj):
if PY2:
# noinspection PyUnresolvedReferences
return isinstance(obj, basestring)
return isinstance(obj, str)
# need to use a new-style class in case of python2, or "normal" class otherwise
if PY2:
class Object(object):
pass
else:
class Object:
pass

View File

@@ -0,0 +1,162 @@
"""
UI session is running as long as any of the ngSkinTools UI windows are open.
"""
import functools
from ngSkinTools2 import cleanup, signal
from ngSkinTools2.api import Layers, PaintTool, events, mirror, plugin
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.python_compatibility import Object
from ngSkinTools2.observableValue import ObservableValue
from ngSkinTools2.signal import SignalHub
log = getLogger("events")
class CurrentLayerState(Object):
def __init__(self):
self.selectedSkinCluster = None
# will be None, when no current layer is available
self.layer = None # type: ngSkinTools2.api.Layer
class CurrentPaintTargetState(Object):
def __init__(self):
self.skinCluster = None
self.layerId = None
self.targets = None
class State(Object):
def __init__(self):
self.layersAvailable = False
self.selection = []
self.selectedSkinCluster = None
self.skin_cluster_dq_channel_used = False
self.all_layers = [] # type: List[ngSkinTools2.api.Layer]
self.currentLayer = CurrentLayerState()
self.currentInfluence = CurrentPaintTargetState()
def set_skin_cluster(self, cluster):
self.selectedSkinCluster = cluster
self.layers = None if cluster is None else Layers(cluster)
def mirror(self):
# type: () -> mirror.Mirror
return mirror.Mirror(self.selectedSkinCluster)
class Context(Object):
def __init__(self):
self.selected_layers = ObservableValue() # [] layerId
class Session(Object):
def __init__(self):
# reference objects that are keeping the session
self.references = set()
self.state = None # type: State
self.events = None # type: events.Events
self.signal_hub = None # type: SignalHub
self.context = None # type: Context
self.referenceId = 0
def active(self):
return len(self.references) > 0
def start(self):
log.info("STARTING SESSION")
plugin.load_plugin()
self.paint_tool = PaintTool()
self.state = State()
self.events = events.Events(self.state)
self.signal_hub = SignalHub()
self.signal_hub.activate()
cleanup.registerCleanupHandler(self.signal_hub.deactivate)
self.context = Context()
@signal.on(self.events.targetChanged)
def on_target_change():
log.info("target changed: clearing target context")
self.context.selected_layers.set([])
self.events.nodeSelectionChanged.emit()
def end(self):
log.info("ENDING SESSION")
cleanup.cleanup()
self.state = None
self.events = None
self.context = None
self.signal_hub = None
pass
def addReference(self):
"""
returns unique ID for this added reference; this value needs to be passed into removeReference();
this ensures that reference holder does not remove other references rather than his own.
:return:
"""
self.referenceId += 1
if not self.active():
self.start()
self.references.add(self.referenceId)
return self.referenceId
def removeReference(self, referenceId):
try:
self.references.remove(referenceId)
except KeyError:
pass
if not self.active():
self.end()
def addQtWidgetReference(self, widget):
ref = self.addReference()
widget.destroyed.connect(lambda: self.removeReference(ref))
def reference(self):
class Context(Object):
def __init__(self, session):
"""
:type session: Session
:type refObj: object
"""
self.session = session
def __enter__(self):
self.ref = self.session.addReference()
def __exit__(self, exc_type, exc_val, exc_tb):
self.session.removeReference(self.ref)
return Context(self)
session = Session()
def withSession(func):
"""
decorator makes sure that single session is running throughout function's lifetime
"""
@functools.wraps(func)
def result(*args, **kwargs):
ref = session.addReference()
try:
return func(*args, **kwargs)
finally:
session.removeReference(ref)
return result

View File

@@ -0,0 +1,17 @@
from ngSkinTools2.api import plugin
from ngSkinTools2.api.python_compatibility import Object
def suspend_updates(target):
return SuspendUpdatesContext(target)
class SuspendUpdatesContext(Object):
def __init__(self, target):
self.target = target
def __enter__(self):
plugin.ngst2Layers(self.target, suspendUpdates=True)
def __exit__(self, _type, value, traceback):
plugin.ngst2Layers(self.target, suspendUpdates=False)

View File

@@ -0,0 +1,86 @@
import json
from maya import cmds
from ngSkinTools2.api import plugin
from .influenceMapping import InfluenceInfo
def get_related_skin_cluster(target):
"""
Returns skinCluster if provided node name represents skinned mesh. Returns true for shapes that have
skinCLuster in their deformation stack, or it's a skinCluster node itself.
For invalid targets, returns None
"""
return plugin.ngst2Layers(target, q=True, layerDataAttachTarget=True)
def get_related_data_node(target):
"""
:returns: ngSkinTools data node name for this target
"""
return plugin.ngst2Layers(target, q=True, layerDataNode=True)
def unserialize_influences_from_json_data(info):
def as_influence_info(data):
influence = InfluenceInfo()
influence.pivot = data['pivot']
influence.path = data.get('path', None)
influence.name = data.get('name', None)
influence.labelText = data['labelText']
influence.labelSide = InfluenceInfo.SIDE_MAP[data['labelSide']]
influence.logicalIndex = data['index']
return influence
if not info:
return []
return [as_influence_info(i) for i in info]
def list_influences(target):
"""
List influences in the given skin cluster as InfluenceInfo objects
:param str target: target mesh or skin cluster
:rtype: list[InfluenceInfo]
"""
info = json.loads(plugin.ngst2Layers(target, q=True, influenceInfo=True))
return unserialize_influences_from_json_data(info)
def add_influences(influences, target):
"""
A shortcut for adding additional influences to a skincluster, without impacting existing weights
:param list[str] influences: list of influence paths
:param str target: target mesh or skin cluster
"""
skin_cluster = get_related_skin_cluster(target)
def long_names(names):
result = set(cmds.ls(names, long=True))
if len(result) != len(names):
raise Exception("could not convert to a list of influences names: " + str(names))
return result
existing = long_names([i.name if not i.path else i.path for i in list_influences(skin_cluster)])
influences = long_names(influences)
for i in influences - existing:
cmds.skinCluster(skin_cluster, edit=True, addInfluence=i, weight=0)
def is_slow_mode_skin_cluster(target):
"""
returns true, if ngSkinTools chose to use slow ngSkinTools api for this target. Right now this only happens when skinCluster has non-transform
nodes as influences (e.g. inverseMatrix node).
:param str target: target mesh or skin cluster
"""
return plugin.ngst2Layers(target, q=True, skinClusterWriteMode=True) == "plug"

View File

@@ -0,0 +1,189 @@
from ngSkinTools2.api import Layer, Layers
from ngSkinTools2.api import layers as api_layers
from ngSkinTools2.api import plugin
from ngSkinTools2.api.layers import generate_layer_name
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.paint import PaintModeSettings
from ngSkinTools2.api.target_info import list_influences
from ngSkinTools2.decorators import undoable
log = getLogger("tools")
def assign_from_closest_joint(target, layer, influences=None):
# type: (str, Layer, List[int]) -> None
"""
For each selected vertex, picks a nearest joint and assigns 1.0 weight to that joint.
Operates on the currently active component selection, or whole mesh, depending on selection.
:param str target: skinned mesh or skin cluster node name;
:param Layer layer: int or :py:class:`Layer` object to apply weights to;
:param List[int] influences: selects only from provided subset of skinCluster influences.
"""
if influences is None:
influences = [i.logicalIndex for i in list_influences(target)]
if len(influences) == 0:
# nothing to do?
return
plugin.ngst2tools(
tool="closestJoint",
target=target,
layer=api_layers.as_layer_id(layer),
influences=[int(i) for i in influences],
)
def unify_weights(target, layer, overall_effect, single_cluster_mode):
"""
For all selected vertices, calculates average weights and assigns that value to each vertice. The effect is that all vertices end up having same weights.
Operates on the currently active component selection, or whole mesh, depending on selection.
:param str target: skinned mesh or skin cluster node name;
:param Layer layer: int or :py:class:`Layer` object to apply weights to;
:param float overall_effect: value between `0.0` and `1.0`, intensity of the operation. When applying newly calculated weights to the skin cluster,
the formula is `weights = lerp(originalWeights, newWeights, overallEffect)`.
:param bool single_cluster_mode: if `true`, all weights will receive the same average. If `false`, each connected mesh shell will be computed independently.
"""
plugin.ngst2tools(
tool="unifyWeights",
target=target,
layer=api_layers.as_layer_id(layer),
overallEffect=overall_effect,
singleClusterMode=single_cluster_mode,
)
def flood_weights(target, influence=None, influences=None, settings=None):
"""
Apply paint tool in the layer with the given settings.
:param target: layer or mesh to set the weights in.
:param influence: target influence: either an int for the logical index of the influence, or one of :py:class:`NamedPaintTarget` constants. Can be skipped if tool mode is Smooth or Sharpen.
:param influences: if specified, overrides "influence" and allows passing multiple influences instead. Only supported by flood and sharpen at the moment.
:type settings: PaintModeSettings
"""
if settings is None:
settings = PaintModeSettings() # just use default settings
args = {
'tool': "floodWeights",
'influences': influences if influences is not None else [influence],
'mode': settings.mode,
'intensity': settings.intensity,
'iterations': int(settings.iterations),
'influencesLimit': int(settings.influences_limit),
'mirror': bool(settings.mirror),
'distributeRemovedWeight': settings.distribute_to_other_influences,
'limitToComponentSelection': settings.limit_to_component_selection,
'useVolumeNeighbours': settings.use_volume_neighbours,
'fixedInfluencesPerVertex': bool(settings.fixed_influences_per_vertex),
}
layer = None if not isinstance(target, Layer) else target # type: Layer
if layer:
args['layer'] = api_layers.as_layer_id(layer)
args['target'] = target if layer is None else layer.mesh
plugin.ngst2tools(**args)
@undoable
def merge_layers(layers):
"""
:type layers: list[Layer]
:rtype: Layer
"""
if len(layers) > 1:
# verify that all layers are from the same parent
for i, j in zip(layers[:-1], layers[1:]):
if i.mesh != j.mesh:
raise Exception("layers are not from the same mesh")
result = plugin.ngst2tools(
tool="mergeLayers",
target=layers[0].mesh,
layers=[api_layers.as_layer_id(i) for i in layers],
)
target_layer = Layer.load(layers[0].mesh, result['layerId'])
target_layer.set_current()
return target_layer
@undoable
def duplicate_layer(layer):
"""
:type layer: Layer
:rtype: Layer
"""
result = plugin.ngst2tools(
tool="duplicateLayer",
target=layer.mesh,
sourceLayer=layer.id,
)
target_layer = Layer.load(layer.mesh, result['layerId'])
import re
base_name = re.sub(r"( \(copy\))?( \(\d+\))*", "", layer.name)
other_layers = [l for l in Layers(target_layer.mesh).list() if l.id != target_layer.id]
target_layer.name = generate_layer_name(other_layers, base_name + " (copy)")
target_layer.set_current()
return target_layer
@undoable
def fill_transparency(layer):
"""
:type layer: Layer
"""
plugin.ngst2tools(
tool="fillLayerTransparency",
target=layer.mesh,
layer=layer.id,
)
def copy_component_weights(layer):
"""
:type layer: Layer
"""
plugin.ngst2tools(
tool="copyComponentWeights",
target=layer.mesh,
layer=layer.id,
)
def paste_average_component_weights(layer):
"""
:type layer: Layer
"""
plugin.ngst2tools(
tool="pasteAverageComponentWeights",
target=layer.mesh,
layer=layer.id,
)
def refresh_screen(target):
plugin.ngst2tools(
tool="refreshScreen",
target=target,
)

View File

@@ -0,0 +1,110 @@
import itertools
from ngSkinTools2.api.python_compatibility import Object
from ngSkinTools2.decorators import undoable
from . import plugin
from .influenceMapping import InfluenceMapping, InfluenceMappingConfig
from .layers import init_layers, target_info
from .mirror import Mirror
from .suspend_updates import suspend_updates
class VertexTransferMode(Object):
"""
Constants for vertex_transfer_mode argument
"""
closestPoint = 'closestPoint' #: When vertices from two surface are matched, each destination mesh vertex finds a closest point on source mesh, and weights are calculated based on the triangle weights of that closest point.
uvSpace = 'uvSpace' #: Similar to closestPoint strategy, but matching is done in UV space instead of XYZ space.
vertexId = 'vertexId' #: Vertices are matched by ID. Not usable for mirroring; this is used for transfer/import cases where meshes are known to be identical
class LayersTransfer(Object):
def __init__(self):
self.source = None
self.target = None
self.source_file = None
self.vertex_transfer_mode = VertexTransferMode.closestPoint
self.influences_mapping = InfluenceMapping()
self.influences_mapping.config = InfluenceMappingConfig.transfer_defaults()
self.keep_existing_layers = True
self.customize_callback = None
def load_source_from_file(self, file, format):
from .import_export import FileFormatWrapper
with FileFormatWrapper(file, format=format, read_mode=True) as f:
data = plugin.ngst2tools(
tool="importJsonFile",
file=f.plain_file,
)
self.source = "-reference-mesh-"
self.source_file = file
influences = target_info.unserialize_influences_from_json_data(data['influences'])
self.influences_mapping.influences = influences
def calc_influences_mapping_as_flat_list(self):
mapping_pairs = list(self.influences_mapping.asIntIntMapping(self.influences_mapping.calculate()).items())
if len(mapping_pairs) == 0:
raise Exception("no mapping between source and destination influences")
# convert dict to flat array
return list(itertools.chain.from_iterable(mapping_pairs))
def execute(self):
# sanity check: destination must be skinnable target
if target_info.get_related_skin_cluster(self.target) is None:
return False
if not self.influences_mapping.influences:
if target_info.get_related_skin_cluster(self.source) is None:
return False
self.influences_mapping.influences = target_info.list_influences(self.source)
self.influences_mapping.destinationInfluences = target_info.list_influences(self.target)
if self.customize_callback is None:
self.complete_execution()
else:
self.customize_callback(self)
@undoable
def complete_execution(self):
l = init_layers(self.target)
Mirror(self.target).recalculate_influences_mapping()
with suspend_updates(self.target):
if not self.keep_existing_layers:
l.clear()
plugin.ngst2tools(
tool="transfer",
source=self.source,
target=self.target,
vertexTransferMode=self.vertex_transfer_mode,
influencesMapping=self.calc_influences_mapping_as_flat_list(),
)
def transfer_layers(
source, destination, vertex_transfer_mode=VertexTransferMode.closestPoint, influences_mapping_config=InfluenceMappingConfig.transfer_defaults()
):
"""
Transfer skinning layers from source to destination mesh.
:param str source: source mesh or skin cluster node name
:param str destination: destination mesh or skin cluster node name
:param str vertex_transfer_mode: describes how source mesh vertices are mapped to destination vertices. Defaults to `closestPoint`
:param InfluenceMappingConfig influences_mapping_config: configuration for InfluenceMapping; supply this, or `influences_mapping` instance; default settings for transfer are used if this is not supplied.
:param InfluenceMapping influences_mapping: mapper instance to use for matching influences; if this is provided, `influences_mapping_config` is ignored.
:return:
"""
t = LayersTransfer()
t.source = source
t.target = destination
t.vertex_transfer_mode = vertex_transfer_mode
t.influences_mapping.config = influences_mapping_config
t.execute()

View File

@@ -0,0 +1,51 @@
import datetime
from ngSkinTools2 import version
from ngSkinTools2.api import http_client
from ngSkinTools2.api.python_compatibility import Object
class UpdateInfo(Object):
def __init__(self):
self.update_available = False
self.update_date = ''
self.latest_version = ''
self.download_url = ''
def download_update_info(success_callback, failure_callback):
"""
executes version info download in separate thread,
then runs provided callbacks in main thread when download completes or fails
returns thread object that gets started.
"""
import platform
from maya import cmds
os = platform.system()
maya_version = str(int(cmds.about(api=True)))
url = http_client.encode_url(
"https://versiondb.ngskintools.com/releases/ngSkinTools-v2-" + os + "-maya" + maya_version,
{
'currentVersion': version.pluginVersion(),
'uniqueClientId': version.uniqueClientId(),
},
)
def on_success(response):
try:
info = UpdateInfo()
info.update_date = datetime.datetime.strptime(response['dateReleased'], "%Y-%m-%d")
info.latest_version = response['latestVersion']
info.download_url = response['downloadUrl']
info.update_available = version.compare_semver(version.pluginVersion(), info.latest_version) > 0
success_callback(info)
except Exception as err:
failure_callback(str(err))
return http_client.get_async(url, success_callback=on_success, failure_callback=failure_callback)