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

BIN
2023/icons/ngSkinTools2.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -0,0 +1,51 @@
# ngSkinTools2 模块
ngSkinTools2 是一个强大的 Maya 蒙皮权重编辑工具。
## 📁 文件夹结构
```
ngskintools2/ # ngSkinTools2 核心模块
├── __init__.py # 模块初始化(原始)
├── launcher.py # 启动器脚本(新增)
├── api/ # API 接口
├── ui/ # 用户界面
├── operations/ # 操作功能
└── README.md # 本文档
```
## 🚀 使用方法
### 从工具架启动
1. 打开 Maya
2. 切换到 **Nexus_Rigging** 工具架
3. 点击 **ngSkin** 按钮
### 从 Python 启动
```python
from rigging_tools.ngskintools2 import launcher
launcher.LaunchNgSkinTools()
```
### 直接使用 ngSkinTools2 API
```python
from rigging_tools.ngskintools2 import open_ui
open_ui()
```
## ✨ 主要功能
- 高级权重绘制和编辑
- 权重镜像和传递
- 多层权重管理
- 权重导入/导出
- 影响对象管理
## 📝 注意事项
- 确保 Maya 版本兼容
- 需要加载对应的插件
- 建议在绑定工作流程中使用

View File

@@ -0,0 +1,33 @@
import os
DEBUG_MODE = os.getenv("NGSKINTOOLS_DEBUG", 'false') == 'true'
try:
from maya import cmds
BATCH_MODE = cmds.about(batch=True) == 1
except:
BATCH_MODE = True
def open_ui():
"""
opens ngSkinTools2 main UI window. if the window is already open, brings that workspace
window to front.
"""
from ngSkinTools2.ui import mainwindow
mainwindow.open()
def workspace_control_main_window():
"""
this function is used permanently by Maya's "workspace control", and acts as an alternative top-level entry point to open UI
"""
from ngSkinTools2.ui import mainwindow
from ngSkinTools2.ui.paintContextCallbacks import definePaintContextCallbacks
definePaintContextCallbacks()
mainwindow.resume_in_workspace_control()

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)

View File

@@ -0,0 +1,23 @@
"""
The purpose of the module is mostly for testing: close everything and prepare for source reload
"""
from __future__ import print_function
from ngSkinTools2.api.log import getLogger
handlers = []
log = getLogger("cleanup")
def registerCleanupHandler(handler):
handlers.append(handler)
def cleanup():
while len(handlers) > 0:
handler = handlers.pop()
try:
handler()
except Exception as err:
log.error(err)

View File

@@ -0,0 +1,78 @@
from functools import wraps
from maya import cmds
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.python_compatibility import Object
log = getLogger("decorators")
def preserve_selection(function):
"""
decorator for function;
saves selection prior to execution and restores it
after function finishes
"""
@wraps(function)
def undoable_wrapper(*args, **kargs):
selection = cmds.ls(sl=True, fl=True)
highlight = cmds.ls(hl=True, fl=True)
try:
return function(*args, **kargs)
finally:
if selection:
cmds.select(selection)
else:
cmds.select(clear=True)
if highlight:
cmds.hilite(highlight)
return undoable_wrapper
def undoable(function):
"""
groups function contents into one undo block
"""
@wraps(function)
def result(*args, **kargs):
with Undo(name=function.__name__):
return function(*args, **kargs)
return result
class Undo(Object):
"""
an undo context for use "with Undo():"
"""
def __init__(self, name=None):
self.name = name
def __enter__(self):
log.debug("UNDO chunk %r: start", self.name)
cmds.undoInfo(openChunk=True, chunkName=self.name)
return self
def __exit__(self, _type, value, traceback):
log.debug("UNDO chunk %r: end", self.name)
cmds.undoInfo(closeChunk=True)
def trace_exception(function):
@wraps(function)
def result(*args, **kwargs):
try:
return function(*args, **kwargs)
except Exception:
import sys
import traceback
traceback.print_exc(file=sys.__stderr__)
raise
return result

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View File

@@ -0,0 +1,353 @@
<!DOCTYPE html>
<!-- saved from url=(0106)https://apps.autodesk.com/RVT/en/Detail/HelpDoc?appId=2231912794620250494&appLang=en&os=Win64&mode=preview -->
<html lang="en" class=" js canvas canvastext no-touch hashchange history draganddrop rgba multiplebgs borderimage boxshadow textshadow cssgradients csstransitions sessionstorage" data-lt-installed="true"><head><meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
<title>Autodesk App Store - help file</title><link rel="stylesheet" href="./Resources/appstore-combined.min.css"></head>
<body>
<div id="main" class="detail-page helpdoc-page clearfix" style="min-height: 619px;">
<div id="content">
<div id="helpdoc-head">
<img id="helpdoc-head-icon" src="./Resources/resized_5ca803cc-8cd1-4cbe-8825-98efaeef156e_.png" alt="ngSkinTools 2">
<div id="helpdoc-head-description">
<h1 id="helpdoc-product-title">ngSkinTools 2</h1>
<div class="clear"></div>
<hr>
<div class="seller">Viktoras Makauskas</div>
<div class="description">ngSkinTools is a skinning plugin for Autodesk® Maya®, introducing new concepts to character skinning such as layers, any-pose-mirroring, enhanced paint brushes, true smoothing, and more.</div>
</div>
<div class="clear"></div>
</div>
<div class="clear"></div>
<div id="helpdoc-tag"><a href="#description" class="helpdoc-breadcrumb">Description</a>
<a href="#generalinfo" class="helpdoc-breadcrumb">General Usage Instructions</a>
<a href="#screensinfo" class="helpdoc-breadcrumb">Screenshots</a>
<a href="#inunininfo" class="helpdoc-breadcrumb">Installation/Uninstallation</a>
<a href="#knownissueinfo" class="helpdoc-breadcrumb">Known Issues</a>
<a href="#contactinfo" class="helpdoc-breadcrumb">Contact</a>
<a href="#versionhistoryinfo" class="">Version History</a>
<div class="clear"></div>
</div><div class="helpdoc-element description">
<div class="clear"></div>
<h1 id="description">Description</h1>
<div class="helpdoc-text">
<h3 id="layers">Layers</h3>
<p>Skinning
layers are a central feature of ngSkinTools. With them, you break your rig down
into easier manageable parts and edit them separately, then blend everything
together through layer transparency.</p>
<p>Theyre
not just a simple way to make your work more organized - they also physically
isolate groups of influences from the rest of the rig, so paint and edit
operations wont mix-in influences you were not expecting. This also allows you
to do things that were impossible before: per-layer mirroring, adjusting
influence weight up/down through layer transparency, blend transferred weights
with previous weights, to name a few.</p>
<h3 id="viewport-tools">Viewport
tools</h3>
<p>Just
like in the previous version, ngSkinTools brings its own weight painting tools.
Improving viewport experience is the main focus of V2, and it's complete revamp
over the previous implementation.</p>
<ul>
<li>Selecting
influences on screen, a #1 requested feature from users, is nowhere. Just hold
“S” and drag over the surface to select dominant influence from that part of
the mesh, or hover over a joint pivot to select precisely the joints you
want;</li>
<li>In
addition to the usual surface projection mode for the brush, the new “screen”
brush projection mode is useful when you want to quickly set weights for both
sides of the mesh;</li>
<li>Custom
shortcuts while in paint mode allow for quick access to intensity
presets;</li>
<li>Color
feedback is now provided through VP2 APIs, greatly improving the performance
of displayed meshes.</li></ul><br>
<h3 id="smoothing">Smoothing</h3>
<p>Keeping
weights in harmony with each other is not easy. ngSkinTools help you smooth
weights with the control you need, allowing you to control the intensity, number
of iterations and effective radius. For very dense meshes, added “iterations”
argument now allows for the quicker spread of smoothness over larger areas of
the mesh.</p>
<p>The
“relax” tool from V1 is gone. With major performance rework, youll notice that
simple flood-smoothing is now much faster and should be a near-instant operation
even with large meshes.</p>
<p>The
opposite “brother” of smooth brush, “sharpen”, is also there - for cases where
you want to just bring out the dominant influences</p>
<h3 id="mirroring">Mirroring</h3>
<p>Mirroring
is one of the most frequent automated tasks you might want from your skinning
tool. With ngSkinTools, youll be able to:</p>
<ul>
<li>Mirror
rigs in any pose; no need to switch to T-pose;</li>
<li>Have
granular control over left/right/center influences mapping, matching
left/right joints by naming convention, joint labels, etc;</li>
<li>Easily
mirror parts of your rig by leveraging layers;</li>
<li>Automatic
mirroring of weights to the opposite side as you paint so that you dont need
to get distracted from painting while working on symmetrical layers.</li></ul><br>
<h3 id="layer-effects">Layer
effects</h3>
<p>With
the “mirror as a layer effect” feature, ngSkinTools introduce a new concept to
ngSkinTools - layer effects. This differs from automatic mirroring of weights as
its not directly modifying your layer weights; instead, its a post-effect that
happens in the background buffer. This has multiple benefits, like a much
cleaner seamline of left/right sides, the ability to tweak mirroring settings
AFTER weights are painted, etc.</p>
<h3 id="compatibility">Compatibility</h3>
<p>As
it's predecessor, ngSkinTools2 operates on standard Maya skinCluster (also known
as “smooth skin”), so no custom nodes will be required to use your rig. The
plugin has a couple of custom nodes, but theyre only required while you work on
setting up your skin weights and can be deleted after, so your work should stay
compatible with most pipelines out there.</p>
<h3 id="performance">Performance</h3>
<p>A
lot of speed improvements have been made since V2, like improving the
utilization of modern multi-core processors, or eliminating bottlenecks through
much heavier use of performance profiling. Having a responsive, snappy tool is
always a pleasure to work with.</p></div></div>
<div class="helpdoc-element ">
<div class="clear"></div>
<h1 id="generalinfo">General Usage Instructions</h1>
<div class="helpdoc-text"><p>The installer from Autodesk App Store loads the application under the Custom shelf.</p>
<p><a href="http://www.ngskintools.com/docs"target="_blank">http://www.ngskintools.com/docs/</a></p></div>
</div>
<div id="helpdoc-element-screenshot" class="helpdoc-element ">
<div class="clear"></div>
<h1 id="screensinfo">Screenshots</h1>
<div>
<div class="helpdoc-screenshot">
<a href="./Resources/original_89910fa8-2c30-4376-a905-12c8a003d16b_.png" title="" data-mime="Image" rel="gallery">
<div class="helpdoc-img-container">
<span class="helper"></span>
<img class="helpdoc-screenshot-img" src="./Resources/original_89910fa8-2c30-4376-a905-12c8a003d16b_.png">
<span class="helper"></span>
</div>
</a>
</div>
<div class="clear"></div>
</div>
</div>
<div class="helpdoc-command helpdoc-element helpdoc-element-hidden">
<div class="clear"></div>
<h1 id="commandinfo">Commands</h1>
<div>
</div>
</div>
<div class="helpdoc-element ">
<div class="clear"></div>
<h1 id="inunininfo">Installation/Uninstallation</h1>
<div class="helpdoc-text"><div id="pills-tabContent">
<div id="pills-windows">
<p>The installer that ran when you downloaded this plug-in from Autodesk App Store has already installed the plug-in. Windows only: To uninstall this plug-in, simply rerun the installer downloaded, and select the 'Uninstall' button, or you can uninstall it from 'Control Panel\Programs\Programs and Features', just as you would uninstall any other application from your system. The panel on the Plug-ins tab will not be removed until Maya is restarted.</p>
</div>
</div>
<p>Linux and OSX: To uninstall this plug-in, simply delete the module directory from your system. The panel on the Plug-ins tab will not be removed until Maya is restarted.</p>
<div id="pills-tabContent">
<div id="pills-windows">
<p>Download .msi and run it on your computer. The installation will place files in&nbsp;<code>C:\ProgramData\Autodesk\ApplicationPlugins\ngskintools2</code>&nbsp;(unless your&nbsp;<code>%ProgramData%</code>&nbsp;the environment variable is different).</p>
</div>
</div>
<p>Using an autoloader system, nothing needs to be configured additionally. Maya scans autoloader locations for plugins at startup and configures each discovered plugin automatically. The autoloader will create a “ngSkinTools2” shelf with a button to open UI.</p>
<p>Now, restart Maya and a new tab&nbsp;ngSkinTools2&nbsp;should appear on your shelf.</p></div>
</div>
<h3>Available on</h3>
<table>
<tr><td><img height=48 src="./Resources/win64.png" /></td><td><img height=48 src="./Resources/macos64.png" /></td><td><img height=48 src="./Resources/linux64.png" /></td></tr>
<tr><td align="center">Windows</td><td align="center">Mac OSX</td><td align="center">Linux</td></tr>
</table>
<div class="helpdoc-element helpdoc-element-hidden">
<div class="clear"></div>
<h1 id="addinfo">Additional Information</h1>
<div class="helpdoc-text"></div>
</div>
<div class="helpdoc-element ">
<div class="clear"></div>
<h1 id="knownissueinfo">Known Issues</h1>
<div class="helpdoc-text"><p>For upcoming features and bugfixes, visit <a href="http://ngskintools.com/v2/roadmap"target="_blank">ngSkinTools v2 public roadmap</a>.</p></div>
</div>
<div class="helpdoc-element ">
<div class="clear"></div>
<h1 id="contactinfo">Contact</h1>
<div>
<div>
<div class="">Company Name: Viktoras Makauskas</div>
<div class="">Company URL: <a href="http://www.ngskintools.com/" target="_blank">http://www.ngskintools.com</a></div>
<div class="">Support Contact: <a href="mailto:support@ngskintools.com">support@ngskintools.com</a></div>
</div>
<div class="helpdoc-block ">
<div class="clear"></div>
<h1 id="authorinfo">Author/Company Information</h1>
<div class="helpdoc-text">Viktoras Makauskas</div>
</div>
<div class="helpdoc-block ">
<div class="clear"></div>
<h1 id="supportinfo">Support Information</h1>
<div class="helpdoc-text"><p>If you have a problem, a question, a feature request, let me know. Your feedback is what drives the project forward!<strong>&nbsp;</strong></p>
<p><strong>Documentation</strong></p>
<p>Features, tutorials &amp; FAQ available at <a href="http://ngskintools.com/v2/" target="_blank">ngSkinTools official website</a>.&nbsp;</p>
<p><strong>Contact in private</strong></p>
<p>Use the <a href="http://ngskintools.com/contact/" target="_blank">online contact form</a> or email to <a href="mailto:support@ngskintools.com">support@ngskintools.com</a>.</p></div>
</div>
</div>
</div>
<div class="helpdoc-version helpdoc-element">
<div class="clear"></div>
<h1 id="versionhistoryinfo">Version History</h1>
<div>
<table class="helpdoc-table" id="helpdoc-table-version">
<colgroup>
<col style="width:150px">
<col style="width:680px">
</colgroup>
<tbody><tr>
<th>Version Number</th>
<th>Version Description</th>
</tr>
<tr>
<td>
<p>
2.0.24
</p>
</td>
<td style="white-space: pre-wrap;">2.0.23 (2021-Mar-12)
* Fixed: stylus pressure is not updated during the stroke;
* Fixed: Maya crashes when smoothing an empty layer with "adjust existing influences only";
* Fixed: (regression) UI is not opening on macOS;
</td>
</tr>
<tr>
<td>
<p>
2.0.23
</p>
</td>
<td style="white-space: pre-wrap;">2.0.23 (2021-Mar-10)
* Added: adjustable brush size: don't reset to zero when changing brush size in the viewport.
* Added: randomize influence colors in paint/display settings;
* Added: layers on/off "eye" button in layers tree UI;
* Fixed: ngSkinTools will not modify skinCluster's `normalizeWeights` value anymore; for performance boost, you still
can disable skinCluster's normalization by setting `normalizeWeights=None`. In "normalizeWeights:interactive"
skinCluster mode, Maya will no longer complain that "The weight total would have exceeded 1.0". ngSkinTools will try
extra hard to normalize each vertex to a perfect 1.0;
* Fixed: UI is not displayed correctly on high DPI displays when UI scaling is enabled in Maya;
</td>
</tr>
<tr>
<td>
<p>
2.0.22
</p>
</td>
<td style="white-space: pre-wrap;">2.0.22 (2021-Feb-06)
Added: convenience tool for adding influences to existing skin clusters. Select influences, target mesh and select “Tools | Add Influence”;
Fixed: weights will now display properly when viewport option “use default material” is turned on;
Added: (v1 feature) Limit max influences per vertex before writing to skin cluster;
Added: (v1 feature) Prune small weights before writing to skin cluster.
2.0.21 (2021-Jan-11)
Added: copy/paste vertex weights between different selections (tab “tools” - “copy component weights/paste average component weights”);
Added: tool “fill transparency” - for all empty vertices in a layer, assign weights from closest non-empty vertex;
Added: “duplicate layer” operation;
Added: “Merge layers” operation: combine selected layers into one.
2.0.20 (2020-Dec-02)
Fixed: Linux: crashing on startup
2.0.19 (2020-Nov-28)
Added: influences mapping in mirror screen will now allow matching joints by DG connections; if symmetrical joints are linked between themselves with message connections, ngSkinTools will be able to leverage that information when mirroring weights;
Added: symmetry mesh: an option to provide an alternative mesh for calculating vertex mapping for mirroring;
Fixed: deleting visibility node can crash Maya sometimes, e.g. switching to component mode (F8) while paint tool is active
2.0.18 (2020-Nov-20)
Added: “use all joints” option for “weights from closest joint” tool; few internal optimizations to speedup operation;
Added: “weights from closest joint” option: create a new layer
Fixed: “weights from closest joint”: the tool is only using joints as spots, but not as segments;
Fixed: after “weights from closest joint” operation influences list is not refreshed;
Fixed: “weights from closest joint”: “assign” button sometimes disabled;
2.0.17 (2020-Nov-19)
Fixed: “resume in workspace” error while opening UI
2.0.16 (2020-Nov-15)
Added: skin data will be compressed for ngSkinTools data nodes, which should substantially reduce file size for scenes with lots of skinning layers;
Added: paint mode intensity sliders are now exponential: “smooth”, “add” and “sharpen” sliders will now be more precise for lower values, and “scale” mode will allow for more precision when setting high values.
2.0.15 (2020-Nov-10)
Added: new “Set Weights” tab contains tools to apply weights to vertex/edge/polygon selection instead of painting.
Added: a new option for smooth tool - “only adjust existing vertex influences”; when this is turned on, the smooth tool will prevent influences weights spreading across the surface
Fixed: layer mirror effects correctly saved/loaded in files;
Fixed: mask mirror effect was not correctly used by layer blending engine
2.0.14 (2020-Oct-04)
Fixed: occasional crashes when using mirror effect on layers;
Additional stability fixes.
2.0.13 (2020-Oct-04)
Fixed: minor bug in 2.0.12 blocks UI from opening;
2.0.12 (2020-Oct-03)
Added: option to view used influences in influences list;
Added: hide “DQ weights” channel in influences list if skin cluster skinning method is not set to “Weight Blended”;
2.0.11 (2020-Oct-01)
Fixed: broken Linux builds
2.0.10 (2020-Sep-26)
Added: pressing “f” while painting focuses viewport camera to current paint target; for joints and other influences, the current joint pivot is used as camera interest point; when current paint target is a mask, viewport centers around painted values;
Fixed: influence mapping UI error if some influences are no joints;
2.0.9 (2020-Sep-11)
Fixed: undo paint crashing Maya;
Fixed: incorrect brush behavior with multiple viewports open;
Fixed: incorrect mesh display / VP2 transparency setting sensitive;
Fixed: clearing selection while painting does not update the display of current mesh;</td>
</tr>
</tbody></table>
</div>
</div>
</div>
</div></body></html>

View File

@@ -0,0 +1,67 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
ngSkinTools2 启动器
用于从工具架快速启动 ngSkinTools2
"""
import sys
import os
def LaunchNgSkinTools():
"""
启动 ngSkinTools2 主界面
"""
try:
from maya import cmds, mel
# 将当前目录添加到 Python 路径,并使用别名
# 这样 ngSkinTools2 内部的 "from ngSkinTools2.ui" 就能找到模块
current_dir = os.path.dirname(os.path.abspath(__file__))
parent_dir = os.path.dirname(current_dir)
# 添加父目录到路径
if parent_dir not in sys.path:
sys.path.insert(0, parent_dir)
# 添加当前目录到路径,作为 ngSkinTools2 模块
if current_dir not in sys.path:
sys.path.insert(0, current_dir)
# 在 sys.modules 中创建别名,让 ngSkinTools2 指向 ngskintools2
import rigging_tools.ngskintools2 as ngskintools2_module
sys.modules['ngSkinTools2'] = ngskintools2_module
# 查找并添加插件路径
# ngSkinTools2 插件在 ngskintools2/plug-ins/2023/ 下
plugin_dir = os.path.join(current_dir, 'plug-ins', '2023')
if os.path.exists(plugin_dir):
# 添加插件路径
current_plugin_path = os.environ.get('MAYA_PLUG_IN_PATH', '')
if plugin_dir not in current_plugin_path:
os.environ['MAYA_PLUG_IN_PATH'] = plugin_dir + os.pathsep + current_plugin_path
# 加载插件
if not cmds.pluginInfo('ngSkinTools2', query=True, loaded=True):
try:
cmds.loadPlugin(os.path.join(plugin_dir, 'ngSkinTools2.mll'))
print("ngSkinTools2 plugin loaded successfully")
except Exception as plugin_error:
print(f"Warning: Could not load ngSkinTools2 plugin: {plugin_error}")
else:
print(f"Warning: Plugin directory not found: {plugin_dir}")
# 现在可以导入并打开 UI
from rigging_tools.ngskintools2 import open_ui
open_ui()
print("ngSkinTools2 UI opened successfully")
except Exception as e:
print(f"Failed to open ngSkinTools2: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
LaunchNgSkinTools()

View File

@@ -0,0 +1,32 @@
ngSkinTools Software License Agreement.
This is a legal agreement between you and ngSkinTools author (Viktoras Makauskas) covering your use of
ngSkinTools (the "Software").
1) ngSkinTools is provided as freeware.
2) ngSkinTools Software is owned by Viktoras Makauskas and is protected by copyright laws and international
treaty provisions. Therefore, you must treat the Software like any other copyrighted material.
3) You may not distribute, rent, sub-license or otherwise make available to others the Software or
documentation or copies thereof, except as expressly permitted in this License without prior written consent
from ngSkinTools author (Viktoras Makauskas). In the case of an authorized transfer, the transferee must agree
to be bound by the terms and conditions of this License Agreement.
4) You may not remove any proprietary notices, labels, trademarks on the Software or documentation. You may not
modify, de-compile, disassemble or reverse engineer the Software.
5) Limited warranty: ngSkinTools software and documentation are "as is" without any warranty as to their
performance, merchantability or fitness for any particular purpose. The licensee assumes the entire risk as to
the quality and performance of the software. In no event shall ngSkinTools author or anyone else who has been
involved in the creation, development, production, or delivery of this software be liable for any direct,
incidental or consequential damages, such as, but not limited to, loss of anticipated profits, benefits, use,
or data resulting from the use of this software, or arising out of any breach of warranty.
Copyright (C) 2009-2020 Viktoras Makauskas
http://www.ngskintools.com
support@ngskintools.com
All rights reserved.

View File

@@ -0,0 +1,10 @@
MAYA_2018 = 20180000
MAYA_2019 = 20190000
MAYA_2020 = 20200000
MAYA_2021 = 20210000
def at_least(version):
from maya import cmds
return cmds.about(api=True) >= version

View File

@@ -0,0 +1,25 @@
from ngSkinTools2.api.python_compatibility import Object
from ngSkinTools2.signal import Signal
class Undefined(Object):
pass
class ObservableValue(Object):
def __init__(self, default_value=Undefined):
self.value = default_value
self.changed = Signal("observable value")
def set(self, value):
self.value = value
self.changed.emit()
def __call__(self, default=Undefined, *args, **kwargs):
if self.value != Undefined:
return self.value
if default != Undefined:
return default
raise Exception("using observable value before setting it")

View File

@@ -0,0 +1,64 @@
from ngSkinTools2 import api, signal
from ngSkinTools2.api import Layer, PasteOperation
from ngSkinTools2.api.session import Session
def action_copy_cut(session, parent, cut):
"""
:type session: Session
:type parent: PySide2.QtWidgets.QWidget
:type cut: bool
"""
from ngSkinTools2.ui import actions
def cut_copy_callback():
if session.state.selectedSkinCluster is None:
return
if session.state.currentLayer.layer is None:
return
influences = session.state.currentLayer.layer.paint_targets
operation = api.copy_weights # type: Callable[[Layer, list], None]
if cut:
operation = api.cut_weights
operation(session.state.currentLayer.layer, influences)
operation_name = "Cut" if cut else "Copy"
result = actions.define_action(parent, operation_name + " weights to clipboard", callback=cut_copy_callback)
@signal.on(session.events.currentLayerChanged, session.events.currentInfluenceChanged, qtParent=parent)
def on_selection_changed():
layer = session.state.currentLayer.layer
result.setEnabled(layer is not None and len(layer.paint_targets) > 0)
return result
def action_paste(session, parent, operation):
"""
:type session: Session
:type parent: PySide2.QtWidgets.QWidget
:type cut: bool
"""
from ngSkinTools2.ui import actions
def paste_callback():
if session.state.currentLayer.layer is None:
return
influences = session.state.currentLayer.layer.paint_targets
api.paste_weights(session.state.currentLayer.layer, operation, influences=influences)
labels = {
PasteOperation.add: 'Paste weights (add to existing)',
PasteOperation.subtract: 'Paste weight (subtract from existing)',
PasteOperation.replace: 'Paste weights (replace existing)',
}
result = actions.define_action(parent, labels[operation], callback=paste_callback)
result.setToolTip("Paste previously copied weights from clipboard")
@signal.on(session.events.currentLayerChanged)
def on_selection_changed():
result.setEnabled(session.state.currentLayer.layer is not None)
return result

View File

@@ -0,0 +1,90 @@
from ngSkinTools2 import api, signal
from ngSkinTools2.api.pyside import QtWidgets
from ngSkinTools2.ui.options import PersistentValue
filter_normal_json = 'JSON files(*.json)'
filter_compressed = 'Compressed JSON(*.json.gz)'
file_dialog_filters = ";;".join([filter_normal_json, filter_compressed])
format_map = {
filter_normal_json: api.FileFormat.JSON,
filter_compressed: api.FileFormat.CompressedJSON,
}
default_filter = PersistentValue("default_import_filter", default_value=api.FileFormat.JSON)
def buildAction_export(session, parent):
from ngSkinTools2.ui import actions
def export_callback():
file_name, selected_filter = QtWidgets.QFileDialog.getSaveFileName(
parent, "Export to Json", filter=file_dialog_filters, selectedFilter=default_filter.get()
)
if not file_name:
return
default_filter.set(selected_filter)
if session.state.layersAvailable:
api.export_json(session.state.selectedSkinCluster, file_name, format=format_map[selected_filter])
result = actions.define_action(
parent,
"Export Layers to Json...",
callback=export_callback,
tooltip="Save layer info to external file, suitable for importing weights to different scene/mesh",
)
@signal.on(session.events.targetChanged, qtParent=parent)
def update_to_target():
result.setEnabled(session.state.layersAvailable)
update_to_target()
return result
def buildAction_import(session, parent, file_dialog_func=None):
from ngSkinTools2.ui import actions
from ngSkinTools2.ui.transferDialog import LayersTransfer, UiModel, open
def default_file_dialog_func():
file_name, selected_filter = QtWidgets.QFileDialog.getOpenFileName(
parent, "Import from Json", filter=file_dialog_filters, selectedFilter=default_filter.get()
)
if file_name:
default_filter.set(selected_filter)
return file_name, selected_filter
if file_dialog_func is None:
file_dialog_func = default_file_dialog_func
def transfer_dialog(transfer):
model = UiModel()
model.transfer = transfer
open(parent, model)
def import_callback():
if session.state.selectedSkinCluster is None:
return
file_name, selected_format = file_dialog_func()
if not file_name:
return
t = LayersTransfer()
t.load_source_from_file(file_name, format=format_map[selected_format])
t.target = session.state.selectedSkinCluster
t.customize_callback = transfer_dialog
t.execute()
result = actions.define_action(parent, "Import Layers from Json...", callback=import_callback, tooltip="Load previously exported weights")
@signal.on(session.events.targetChanged, qtParent=parent)
def update():
result.setEnabled(session.state.selectedSkinCluster is not None)
update()
return result

View File

@@ -0,0 +1,40 @@
from ngSkinTools2 import api, signal
def can_import(session):
"""
:type session: ngSkinTools2.api.session.Session
"""
if not session.state.selection:
return False
if session.state.layersAvailable:
return False
return api.import_v1.can_import(session.state.selection[-1])
def build_action_import_v1(session, parent):
"""
:type parent: PySide2.QtWidgets.QWidget
:type session: ngSkinTools2.api.session.Session
"""
from ngSkinTools2.ui import actions
def do_convert():
api.import_v1.import_layers(session.state.selection[-1])
api.import_v1.cleanup(session.state.selection[-1:])
update_state()
session.events.targetChanged.emitIfChanged()
result = actions.define_action(parent, "Convert From v1.0 Layers", callback=do_convert)
result.setToolTip("Convert skinning layers from previous version of ngSkinTools; after completing this action, v1 nodes will be deleted.")
@signal.on(session.events.targetChanged)
def update_state():
result.setVisible(can_import(session))
update_state()
return result

View File

@@ -0,0 +1,229 @@
import random
from maya import cmds
import ngSkinTools2.api
from ngSkinTools2 import signal
from ngSkinTools2.api import Mirror
from ngSkinTools2.api.layers import generate_layer_name
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.pyside import QAction, QtCore
from ngSkinTools2.api.session import session, withSession
from ngSkinTools2.decorators import undoable
from ngSkinTools2.ui import dialogs, qt
from ngSkinTools2.ui.action import Action
from ngSkinTools2.ui.options import config
logger = getLogger("layer operations")
@withSession
@undoable
def initializeLayers(createFirstLayer=True):
if session.state.layersAvailable:
return # ignore
target = session.state.selectedSkinCluster
layers = ngSkinTools2.api.init_layers(target)
with ngSkinTools2.api.suspend_updates(target):
if createFirstLayer:
layer = layers.add("Base weights")
layer.set_current()
Mirror(target).set_mirror_config(config.mirrorInfluencesDefaults)
session.events.targetChanged.emitIfChanged()
if ngSkinTools2.api.is_slow_mode_skin_cluster(target):
dialogs.info(
"ngSkinTools switched to slow maya API for setting skin cluster weights for this skinCluster, to workaround a Maya bug when skinCluster uses dg nodes as inputs"
)
@undoable
def addLayer():
layers = ngSkinTools2.api.Layers(session.state.selectedSkinCluster)
def guessParent():
currentLayer = layers.current_layer()
if currentLayer is None:
return None
# current layer is a parent?
if currentLayer.num_children > 0:
return currentLayer
return currentLayer.parent
with ngSkinTools2.api.suspend_updates(layers.mesh):
new_layer = layers.add(generate_layer_name(session.state.all_layers, "New Layer"))
new_layer.parent = guessParent()
session.events.layerListChanged.emitIfChanged()
setCurrentLayer(new_layer)
return new_layer
def build_action_initialize_layers(session, parent):
from ngSkinTools2.ui import actions
from . import import_v1_actions
def do_initialize():
if import_v1_actions.can_import(session):
q = (
"Skinning layers from previous version of ngSkinTools are present on this mesh. This operation will initialize "
"skinning layers from scratch, discarding previous layers information. Do you want to continue?"
)
if not dialogs.yesNo(q):
return
initializeLayers()
result = actions.define_action(parent, "Initialize Skinning Layers", callback=do_initialize)
@signal.on(session.events.nodeSelectionChanged)
def update():
result.setEnabled(session.state.selectedSkinCluster is not None)
update()
return result
def buildAction_createLayer(session, parent):
from ngSkinTools2.ui import actions
result = actions.define_action(parent, "Create Layer", callback=addLayer, icon=":/newLayerEmpty.png", shortcut=QtCore.Qt.Key_Insert)
@signal.on(session.events.targetChanged)
def update_to_target():
result.setEnabled(session.state.layersAvailable)
update_to_target()
return result
def buildAction_deleteLayer(session, parent):
from ngSkinTools2.ui import actions
result = actions.define_action(parent, "Delete Layer", callback=deleteSelectedLayers, shortcut=QtCore.Qt.Key_Delete)
@signal.on(session.context.selected_layers.changed, session.events.targetChanged, qtParent=parent)
def update_to_target():
result.setEnabled(session.state.layersAvailable and bool(session.context.selected_layers(default=[])))
update_to_target()
return result
@undoable
def setCurrentLayer(layer):
"""
:type layer: ngSkinTools2.api.layers.Layer
"""
if not session.active():
logger.info("didn't set current layer: no session")
if not session.state.layersAvailable:
logger.info("didn't set current layer: layers not enabled")
logger.info("setting current layer to %r on %r", layer, session.state.selectedSkinCluster)
layer.set_current()
session.events.currentLayerChanged.emitIfChanged()
def getCurrentLayer():
layers = ngSkinTools2.api.Layers(session.state.selectedSkinCluster)
return layers.current_layer()
@undoable
def renameLayer(layer, newName):
layer.name = newName
cmds.evalDeferred(session.events.layerListChanged.emitIfChanged)
@undoable
def deleteSelectedLayers():
layers = ngSkinTools2.api.Layers(session.state.selectedSkinCluster)
for i in session.context.selected_layers(default=[]):
layers.delete(i)
session.events.layerListChanged.emitIfChanged()
session.events.currentLayerChanged.emitIfChanged()
class ToggleEnabledAction(Action):
name = "Enabled"
checkable = True
def __init__(self, session):
Action.__init__(self, session)
def checked(self):
"""
return true if most of selected layers are enabled
:return:
"""
layers = session.context.selected_layers(default=[])
if not layers:
return True
enabled_disabled_balance = 0
for layer in layers:
try:
# eat up the exception if layer id is invalid
enabled_disabled_balance += 1 if layer.enabled else -1
except:
pass
return enabled_disabled_balance >= 0
def run(self):
enabled = not self.checked()
selected_layers = session.context.selected_layers()
if not selected_layers:
return
for i in selected_layers:
i.enabled = enabled
logger.info("layers toggled: %r", selected_layers)
session.events.layerListChanged.emitIfChanged()
def enabled(self):
return session.state.layersAvailable and bool(session.context.selected_layers(default=[]))
def update_on_signals(self):
return [session.context.selected_layers.changed, session.events.layerListChanged, session.events.targetChanged]
def build_action_randomize_influences_colors(session, parent):
"""
builds a UI action for randomly choosing new colors for influences
:type session: ngSkinTools2.api.session.Session
"""
result = QAction("Randomize colors", parent)
result.setToolTip("Choose random colors for each influence, selecting from Maya's pallete of indexed colors")
def color_filter(c):
brightness = c[0] * c[0] + c[1] * c[1] + c[2] * c[2]
return brightness > 0.001 and brightness < 0.99 # just a fancy way to skip white and black
colors = set([tuple(cmds.colorIndex(i, q=True)) for i in range(1, 30)])
colors = [c for c in colors if color_filter(c)]
@qt.on(result.triggered)
def triggered():
if session.state.selectedSkinCluster is None:
return
layers = ngSkinTools2.api.Layers(session.state.selectedSkinCluster)
layers.config.influence_colors = {i.logicalIndex: random.choice(colors) for i in layers.list_influences()}
return result

View File

@@ -0,0 +1,45 @@
from maya import cmds
from ngSkinTools2.api import PaintTool
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.session import session
from ngSkinTools2.ui.action import Action
log = getLogger("operations/paint")
class FloodAction(Action):
name = "Flood"
tooltip = "Apply current brush to whole selection"
def run(self):
session.paint_tool.flood(self.session.state.currentLayer.layer, influences=self.session.state.currentLayer.layer.paint_targets)
def enabled(self):
return PaintTool.is_painting() and self.session.state.selectedSkinCluster is not None and self.session.state.currentLayer.layer is not None
def update_on_signals(self):
return [
self.session.events.toolChanged,
self.session.events.currentLayerChanged,
self.session.events.currentInfluenceChanged,
self.session.events.targetChanged,
]
class PaintAction(Action):
name = "Paint"
tooltip = "Toggle paint tool"
checkable = True
def run(self):
if self.checked():
cmds.setToolTo("moveSuperContext")
else:
self.session.paint_tool.start()
def update_on_signals(self):
return [self.session.events.toolChanged]
def checked(self):
return PaintTool.is_painting()

View File

@@ -0,0 +1,101 @@
import itertools
from maya import cmds
from ngSkinTools2.api import PaintTool, target_info
from ngSkinTools2.api.session import Session
from ngSkinTools2.decorators import undoable
def as_list(arg):
return [] if arg is None else arg
customNodeTypes = ['ngst2MeshDisplay', 'ngst2SkinLayerData']
def list_custom_nodes():
"""
list all custom nodes in the scene
"""
result = []
for nodeType in customNodeTypes:
result.extend(as_list(cmds.ls(type=nodeType)))
return result
def list_custom_nodes_for_mesh(mesh=None):
"""
list custom nodes only related to provided mesh. None means current selection
"""
skin_cluster = target_info.get_related_skin_cluster(mesh)
if skin_cluster is None:
return []
# delete any ngSkinTools deformers from the history, and find upstream stuff from given skinCluster.
hist = as_list(cmds.listHistory(skin_cluster, future=True, levels=1))
return [i for i in hist if cmds.nodeType(i) in customNodeTypes]
def list_custom_nodes_for_meshes(meshes):
return list(itertools.chain.from_iterable([list_custom_nodes_for_mesh(i) for i in meshes]))
message_scene_noCustomNodes = 'Scene does not contain any custom ngSkinTools nodes.'
message_selection_noCustomNodes = 'Selection does not contain any custom ngSkinTools nodes.'
message_scene_warning = (
'This command deletes all custom ngSkinTools nodes. Skin weights ' 'will be preserved, but all layer data will be lost. Do you want to continue?'
)
message_selection_warning = (
'This command deletes custom ngSkinTools nodes for selection. Skin weights '
'will be preserved, but all layer data will be lost. Do you want to continue?'
)
@undoable
def remove_custom_nodes(interactive=False, session=None, meshes=None):
"""
Removes custom ngSkinTools2 nodes from the scene or selection.
:type meshes: list[str]
:param meshes: list of node names; if empty, operation will be scene-wide.
:type session: Session
:param session: optional; if specified, will fire events for current session about changed status of selected mesh
:type interactive: bool
:param interactive: if True, user warnings will be emited
"""
from ngSkinTools2.ui import dialogs
if meshes is None:
meshes = []
is_selection_mode = len(meshes) > 0
custom_nodes = list_custom_nodes() if not is_selection_mode else list_custom_nodes_for_meshes(meshes)
if not custom_nodes:
if interactive:
dialogs.info(message_selection_noCustomNodes if is_selection_mode else message_scene_noCustomNodes)
return
delete_confirmed = True
if interactive:
delete_confirmed = dialogs.yesNo(message_selection_warning if is_selection_mode else message_scene_warning)
if delete_confirmed:
cmds.delete(custom_nodes)
if PaintTool.is_painting():
# make sure that painting is canceled to restore mesh display etc
cmds.setToolTo("Move")
if session is not None:
session.events.targetChanged.emitIfChanged()
@undoable
def remove_custom_nodes_from_selection(interactive=False, session=None):
selection = cmds.ls(sl=True)
remove_custom_nodes(interactive=interactive, session=session, meshes=as_list(selection))

View File

@@ -0,0 +1,295 @@
from maya import cmds
from ngSkinTools2 import api, signal
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.python_compatibility import Object
from ngSkinTools2.api.session import Session
from ngSkinTools2.decorators import undoable
from ngSkinTools2.observableValue import ObservableValue
from ngSkinTools2.operations import layers
from ngSkinTools2.ui import dialogs
logger = getLogger("operation/tools")
def __create_tool_action__(parent, session, action_name, action_tooltip, exec_handler, enabled_handler=None):
"""
:type session: Session
"""
from ngSkinTools2.ui import actions
def execute():
if not session.active():
return
exec_handler()
result = actions.define_action(parent, action_name, callback=execute, tooltip=action_tooltip)
@signal.on(session.events.targetChanged, session.events.currentLayerChanged)
def update_state():
enabled = session.state.layersAvailable and session.state.currentLayer.layer is not None
if enabled and enabled_handler is not None:
enabled = enabled_handler(session.state.currentLayer.layer)
result.setEnabled(enabled)
update_state()
return result
class ClosestJointOptions(Object):
def __init__(self):
self.create_new_layer = ObservableValue(False)
self.all_influences = ObservableValue(True)
def create_action__from_closest_joint(parent, session):
options = ClosestJointOptions()
def exec_handler():
layer = session.state.currentLayer.layer
influences = None
if not options.all_influences():
influences = layer.paint_targets
if not influences:
dialogs.info("Select one or more influences in Influences list")
return
if options.create_new_layer():
layer = layers.addLayer()
api.assign_from_closest_joint(
session.state.selectedSkinCluster,
layer,
influences=influences,
)
session.events.currentLayerChanged.emitIfChanged()
session.events.influencesListUpdated.emit()
if layer.paint_target is None:
used_influences = layer.get_used_influences()
if used_influences:
layer.paint_target = min(used_influences)
return (
__create_tool_action__(
parent,
session,
action_name=u"Assign From Closest Joint",
action_tooltip="Assign 1.0 weight for closest influence per each vertex in selected layer",
exec_handler=exec_handler,
),
options,
)
class UnifyWeightsOptions(Object):
overall_effect = ObservableValue(1.0)
single_cluster_mode = ObservableValue(False)
def create_action__unify_weights(parent, session):
options = UnifyWeightsOptions()
def exec_handler():
api.unify_weights(
session.state.selectedSkinCluster,
session.state.currentLayer.layer,
overall_effect=options.overall_effect(),
single_cluster_mode=options.single_cluster_mode(),
)
return (
__create_tool_action__(
parent,
session,
action_name=u"Unify Weights",
action_tooltip="For selected vertices, make verts the same for all verts",
exec_handler=exec_handler,
),
options,
)
def create_action__merge_layers(parent, session):
"""
:param parent: UI parent for this action
:type session: Session
"""
def exec_handler():
api.merge_layers(layers=session.context.selected_layers(default=[]))
session.events.layerListChanged.emitIfChanged()
session.events.currentLayerChanged.emitIfChanged()
def enabled_handler(layer):
return layer is not None and layer.index > 0
return __create_tool_action__(
parent,
session,
action_name=u"Merge",
action_tooltip="Merge contents of this layer into underlying layer. Pre-effects weights will be used for this",
exec_handler=exec_handler,
enabled_handler=enabled_handler,
)
def create_action__duplicate_layer(parent, session):
"""
:param parent: UI parent for this action
:type session: Session
"""
@undoable
def exec_handler():
with api.suspend_updates(session.state.selectedSkinCluster):
for source in session.context.selected_layers(default=[]):
api.duplicate_layer(layer=source)
session.events.layerListChanged.emitIfChanged()
session.events.currentLayerChanged.emitIfChanged()
return __create_tool_action__(
parent,
session,
action_name=u"Duplicate",
action_tooltip="Duplicate selected layer(s)",
exec_handler=exec_handler,
)
def create_action__fill_transparency(parent, session):
"""
:param parent: UI parent for this action
:type session: Session
"""
@undoable
def exec_handler():
with api.suspend_updates(session.state.selectedSkinCluster):
for source in session.context.selected_layers(default=[]):
api.fill_transparency(layer=source)
return __create_tool_action__(
parent,
session,
action_name=u"Fill Transparency",
action_tooltip="All transparent vertices in the selected layer(s) receive weights from their closest non-empty neighbour vertex",
exec_handler=exec_handler,
)
def create_action__copy_component_weights(parent, session):
"""
:param parent: UI parent for this action
:type session: Session
"""
def exec_handler():
for source in session.context.selected_layers(default=[]):
api.copy_component_weights(layer=source)
return __create_tool_action__(
parent,
session,
action_name=u"Copy Component Weights",
action_tooltip="Store components weights in memory for further component-based paste actions",
exec_handler=exec_handler,
)
def create_action__paste_average_component_weight(parent, session):
"""
:param parent: UI parent for this action
:type session: Session
"""
def exec_handler():
for l in session.context.selected_layers(default=[]):
api.paste_average_component_weights(layer=l)
return __create_tool_action__(
parent,
session,
action_name=u"Paste Average Component Weight",
action_tooltip="Compute average of copied component weights and set that value to currently selected components",
exec_handler=exec_handler,
)
def create_action__add_influences(parent, session):
"""
:param parent: UI parent for this action
:type session: Session
"""
def exec_handler():
selection = cmds.ls(sl=True, l=True)
if len(selection) < 2:
logger.info("invalid selection: %s", selection)
return
api.add_influences(selection[:-1], selection[-1])
cmds.select(selection[-1])
session.events.influencesListUpdated.emit()
return __create_tool_action__(
parent,
session,
action_name=u"Add Influences",
action_tooltip="Add selected influences to current skin cluster.",
exec_handler=exec_handler,
)
def create_action__select_affected_vertices(parent, session):
"""
:param parent: UI parent for this action
:type session: Session
"""
def exec_handler():
selected_layers = session.context.selected_layers(default=[])
if not selected_layers:
return
if not session.state.currentLayer.layer:
return
influences = session.state.currentLayer.layer.paint_targets
if not influences:
return
non_zero_weights = []
for layer in selected_layers:
for i in influences:
weights = layer.get_weights(i)
if weights:
non_zero_weights.append(weights)
if not non_zero_weights:
return
current_selection = cmds.ls(sl=True, o=True, l=True)
if len(current_selection) != 1:
return
# we're not sure - this won't work if skin cluster is selected directly
selected_mesh_probably = current_selection[0]
combined_weights = [sum(i) for i in zip(*non_zero_weights)]
indexes = [selected_mesh_probably + ".vtx[%d]" % index for index, i in enumerate(combined_weights) if i > 0.00001]
try:
cmds.select(indexes)
except:
pass
return __create_tool_action__(
parent,
session,
action_name=u"Select Affected Vertices",
action_tooltip="Select vertices that have non-zero weight for current influence.",
exec_handler=exec_handler,
)

View File

@@ -0,0 +1,31 @@
from ngSkinTools2.api.python_compatibility import Object
def website_base_url():
import ngSkinTools2
if ngSkinTools2.DEBUG_MODE:
return "http://localhost:1313"
return "https://www.ngskintools.com"
class WebsiteLinksActions(Object):
def __init__(self, parent):
self.api_root = make_documentation_action(parent, "API Documentation", "/v2/api")
self.user_guide = make_documentation_action(parent, "User Guide", "/v2/")
self.changelog = make_documentation_action(parent, "Change Log", "/v2/changelog", icon=None)
self.contact = make_documentation_action(parent, "Contact", "/contact/", icon=None)
def make_documentation_action(parent, title, url, icon=":/help.png"):
from ngSkinTools2.ui import actions
def handler():
import webbrowser
webbrowser.open_new(website_base_url() + url)
result = actions.define_action(parent, title, callback=handler, icon=icon)
result.setToolTip("opens {0} in a browser".format(url))
return result

View File

@@ -0,0 +1,63 @@
"""
these are methods that are called by plugin when corresponding events happen
"""
from ngSkinTools2 import api
from ngSkinTools2.api import eventtypes as et
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.session import session
from ngSkinTools2.ui import hotkeys_setup
log = getLogger("plugin callbacks")
def current_paint_target_changed():
# log.info("current paint target changed")
if session.active():
if session.state.currentLayer.layer is not None:
session.state.currentLayer.layer.reload()
session.events.currentInfluenceChanged.emitIfChanged()
def tool_settings_changed():
et.tool_settings_changed.emit()
def paint_tool_started():
api.paint.tabletEventFilter.install()
hotkeys_setup.toggle_paint_hotkey_set(enabled=True)
def paint_tool_stopped():
api.paint.tabletEventFilter.uninstall()
hotkeys_setup.toggle_paint_hotkey_set(enabled=False)
def get_stylus_intensity():
return api.paint.tabletEventFilter.pressure
def initialize_influences_mirror_mapping(mesh):
"""
gets called by plugin when influences mirror mapping is not set yet
:return:
"""
from ngSkinTools2.api.mirror import Mirror
Mirror(mesh).recalculate_influences_mapping()
def display_node_created(display_node):
"""
gets called when display node is created
:param display_node:
:return:
"""
from maya import cmds
# add node to isolated objects if we're currently in isolated mode
current_view = cmds.paneLayout('viewPanes', q=True, pane1=True)
is_isolated = cmds.isolateSelect(current_view, q=True, state=True)
if is_isolated:
cmds.isolateSelect(current_view, addDagObject=display_node)

View File

@@ -0,0 +1,253 @@
"""
Signal is the previous method of emiting/subscribing to signals.
* Subscribers are dependant on emiter's instances
* There's only one global queue
New system is changing to allow decoupling subscribers and receivers, and allowing the code to work even when there's no need to process signals
* Subscribers need to be able to subsribe prior to instantiation of emitters
"""
from functools import partial
from ngSkinTools2 import cleanup
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.python_compatibility import Object
log = getLogger("signal")
class SignalQueue(Object):
def __init__(self):
self.max_length = 100
self.queue = []
def emit(self, handler):
if len(self.queue) > self.max_length:
log.error("queue max length reached: emitting too many events?")
raise Exception("queue max length reached: emitting too many events?")
should_start = len(self.queue) == 0
self.queue.append(handler)
if should_start:
self.process()
def process(self):
current_handler = 0
queue = self.queue
while current_handler < len(queue):
# noinspection PyBroadException
try:
queue[current_handler]()
except Exception:
import ngSkinTools2
if ngSkinTools2.DEBUG_MODE:
import sys
import traceback
traceback.print_exc(file=sys.__stderr__)
current_handler += 1
if len(self.queue) > 50:
log.info("handler queue finished with %d items", len(self.queue))
self.queue = []
# noinspection PyBroadException
class Signal(Object):
"""
Signal class collects observers, interested in some particular event,and handles
signaling them all when some event occurs. Both handling and signaling happens outside
of signal's own code
Handlers are processed breath first, in a queue based system.
1. root signal fires, adds all it's handlers to the queue;
2. queue starts being processed
3. handlers fire more signals, in turn adding more handlers to the end of the queue.
"""
all = []
queue = SignalQueue()
def __init__(self, name):
if name is None:
raise Exception("need name for debug purposes later")
self.name = name
self.handlers = []
self.executing = False
self.enabled = True
self.reset()
Signal.all.append(self)
cleanup.registerCleanupHandler(self.reset)
def reset(self):
self.handlers = []
self.executing = False
def emit_deferred(self, *args):
import maya.utils as mu
mu.executeDeferred(self.emit, *args)
def emit(self, *args):
"""
emit mostly just adds handlers to the processing queue,
but if nobody is processing handlers at the emit time,
it is started here as well.
"""
if not self.enabled:
return
# log.info("emit: %s", self.name)
if self.executing:
raise Exception('Nested emit on %s detected' % self.name)
for i in self.handlers[:]:
Signal.queue.emit(partial(i, *args))
def addHandler(self, handler, qtParent=None):
if hasattr(handler, 'emit'):
handler = handler.emit
self.handlers.append(handler)
def remove():
return self.removeHandler(handler)
if qtParent is not None:
qtParent.destroyed.connect(remove)
return remove
def removeHandler(self, handler):
try:
self.handlers.remove(handler)
except ValueError:
# not found in list? no biggie.
pass
def on(*signals, **kwargs):
"""
decorator for function: list signals that should fire for this function.
@signal.on(signalReference)
def something():
...
"""
def decorator(fn):
for i in signals:
i.addHandler(fn, **kwargs)
return fn
return decorator
# --------------------------------------
# rework:
# * decoupled emitters and subscribers
# * subscribers are resolved only at the time of event
# * can emit events even when there's no active sessions (acts as no-op)
class Event(Object):
def __init__(self, name):
self.name = name
def __or__(self, other):
return EventList() | self | other
def __iter__(self):
yield self
def emit(self, *args, **kwargs):
for hub in SignalHub.active_hubs:
hub.emit(self, *args, **kwargs)
class EventList(Object):
"""
helper to build and iterate over a list of "or"-ed events
"""
def __init__(self):
self.items = []
def __or__(self, other):
self.items.append(other)
return self
def __iter__(self):
return iter(self.items)
class SignalHub(Object):
active_hubs = set()
def __init__(self):
self.handlers = {}
self.queue = SignalQueue()
def activate(self):
self.active_hubs.add(self)
def deactivate(self):
self.active_hubs.remove(self)
def subscribe(self, event, handler):
"""
:param Event event: event to subscribe to
:param Callable handler: callback which will be called when event is emitted
:return: unsubscribe function: call it to terminate this subscription
"""
self.handlers.setdefault(event, []).append(handler)
def unsubscribe():
try:
self.handlers[event].remove(handler)
except ValueError:
# not found in list? no biggie.
pass
return unsubscribe
def emit(self, event):
if not event in self.handlers:
return
for i in self.handlers[event]:
self.queue.emit(i)
def on(self, events, scope=None):
"""
decorator for function: bind function to signals
@hub.on(event1 | event2, scope=qt_object)
def something():
...
"""
def decorator(fn):
unsubscribe_handlers = []
try:
unsubscribe_handlers.append(scope.destroyed.connect)
except:
pass
for e in events:
unsub = self.subscribe(e, fn)
if unsubscribe_handlers:
for i in unsubscribe_handlers:
i(unsub)
return fn
return decorator

View File

@@ -0,0 +1,91 @@
import os
from xml.sax.saxutils import escape as escape
from ngSkinTools2 import cleanup, version
from ngSkinTools2.api.pyside import Qt, QtWidgets
from ngSkinTools2.api.session import session
from ngSkinTools2.ui import qt
from ngSkinTools2.ui.layout import scale_multiplier
def show(parent):
"""
:type parent: QWidget
"""
def header():
# noinspection PyShadowingNames
def leftSide():
layout = QtWidgets.QVBoxLayout()
layout.addStretch()
layout.addWidget(QtWidgets.QLabel("<h1>ngSkinTools</h1>"))
layout.addWidget(QtWidgets.QLabel("Version {0}".format(version.pluginVersion())))
layout.addWidget(QtWidgets.QLabel(version.COPYRIGHT))
url = QtWidgets.QLabel('<a href="{0}" style="color: #007bff;">{0}</a>'.format(version.PRODUCT_URL))
url.setTextInteractionFlags(Qt.TextBrowserInteraction)
url.setOpenExternalLinks(True)
layout.addWidget(url)
layout.addStretch()
return layout
def logo():
from ngSkinTools2.api.pyside import QSvgWidget
w = QSvgWidget(os.path.join(os.path.dirname(__file__), "images", "logo.svg"))
w.setFixedSize(*((70 * scale_multiplier,) * 2))
layout.addWidget(w)
return w
result = QtWidgets.QWidget()
result.setPalette(qt.alternative_palette_light())
result.setAutoFillBackground(True)
hSplit = QtWidgets.QHBoxLayout()
hSplit.setContentsMargins(30, 30, 30, 30)
result.setLayout(hSplit)
hSplit.addLayout(leftSide())
hSplit.addStretch()
hSplit.addWidget(logo())
return result
# noinspection PyShadowingNames
def body():
result = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout()
result.setLayout(layout)
layout.setContentsMargins(30, 30, 30, 30)
return result
# noinspection PyShadowingNames
def buttonsRow(window):
layout = QtWidgets.QHBoxLayout()
layout.addStretch()
btnClose = QtWidgets.QPushButton("Close")
btnClose.setMinimumWidth(100 * scale_multiplier)
layout.addWidget(btnClose)
layout.setContentsMargins(20 * scale_multiplier, 15 * scale_multiplier, 20 * scale_multiplier, 15 * scale_multiplier)
btnClose.clicked.connect(lambda: window.close())
return layout
window = QtWidgets.QWidget(parent, Qt.Window | Qt.WindowTitleHint | Qt.CustomizeWindowHint)
window.resize(600 * scale_multiplier, 500 * scale_multiplier)
window.setAttribute(Qt.WA_DeleteOnClose)
window.setWindowTitle("About ngSkinTools")
layout = QtWidgets.QVBoxLayout()
window.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(header())
layout.addWidget(body())
layout.addStretch(2)
layout.addLayout(buttonsRow(window))
window.show()
cleanup.registerCleanupHandler(window.close)

View File

@@ -0,0 +1,57 @@
from ngSkinTools2 import signal
from ngSkinTools2.api.python_compatibility import Object
from ngSkinTools2.api.session import Session
class Action(Object):
name = "Action"
tooltip = ""
checkable = False
def __init__(self, session):
self.session = session # type: Session
def run(self):
pass
def enabled(self):
return True
def checked(self):
return False
def run_if_enabled(self):
if self.enabled():
self.run()
def update_on_signals(self):
return []
def as_qt_action(self, parent):
from ngSkinTools2.ui import actions
result = actions.define_action(parent, self.name, callback=self.run_if_enabled, tooltip=self.tooltip)
result.setCheckable(self.checkable)
def update():
result.setEnabled(self.enabled())
if self.checkable:
result.setChecked(self.checked())
signal.on(*self.update_on_signals(), qtParent=parent)(update)
update()
return result
def qt_action(action_class, session, parent):
"""
Wrap provided action_class into a QT action
"""
return action_class(session).as_qt_action(parent)
def do_action_hotkey(action_class):
from ngSkinTools2.api import session
action_class(session.session).run_if_enabled()

View File

@@ -0,0 +1,146 @@
from ngSkinTools2 import signal
from ngSkinTools2.api import PasteOperation
from ngSkinTools2.api.pyside import QAction, QtGui, QtWidgets
from ngSkinTools2.api.python_compatibility import Object
from ngSkinTools2.api.session import Session
from ngSkinTools2.operations import import_export_actions, import_v1_actions
from ngSkinTools2.operations.layers import (
ToggleEnabledAction,
build_action_initialize_layers,
)
from ngSkinTools2.operations.paint import FloodAction, PaintAction
from ngSkinTools2.operations.website_links import WebsiteLinksActions
from ngSkinTools2.ui import action
from ngSkinTools2.ui.updatewindow import build_action_check_for_updates
def define_action(parent, label, callback=None, icon=None, shortcut=None, tooltip=None):
result = QAction(label, parent)
if icon is not None:
result.setIcon(QtGui.QIcon(icon))
if callback is not None:
result.triggered.connect(callback)
if shortcut is not None:
if not isinstance(shortcut, QtGui.QKeySequence):
shortcut = QtGui.QKeySequence(shortcut)
result.setShortcut(shortcut)
if tooltip is not None:
result.setToolTip(tooltip)
result.setStatusTip(tooltip)
return result
def build_action_delete_custom_nodes_for_selection(parent, session):
from ngSkinTools2.operations import removeLayerData
result = define_action(
parent,
"Delete Custom Nodes For Selection",
callback=lambda: removeLayerData.remove_custom_nodes_from_selection(interactive=True, session=session),
)
@signal.on(session.events.nodeSelectionChanged)
def update():
result.setEnabled(bool(session.state.selection))
update()
return result
class Actions(Object):
def separator(self, parent, label=""):
separator = QAction(parent)
separator.setText(label)
separator.setSeparator(True)
return separator
def __init__(self, parent, session):
"""
:type session: Session
"""
qt_action = lambda a: action.qt_action(a, session, parent)
from ngSkinTools2.operations import layers, removeLayerData, tools
from ngSkinTools2.ui.transferDialog import build_transfer_action
self.initialize = build_action_initialize_layers(session, parent)
self.exportFile = import_export_actions.buildAction_export(session, parent)
self.importFile = import_export_actions.buildAction_import(session, parent)
self.import_v1 = import_v1_actions.build_action_import_v1(session, parent)
self.addLayer = layers.buildAction_createLayer(session, parent)
self.deleteLayer = layers.buildAction_deleteLayer(session, parent)
self.toggle_layer_enabled = qt_action(ToggleEnabledAction)
# self.moveLayerUp = defineCallbackAction(u"Move Layer Up", None, icon=":/moveLayerUp.png")
# self.moveLayerDown = defineCallbackAction(u"Move Layer Down", None, icon=":/moveLayerDown.png")
self.paint = qt_action(PaintAction)
self.flood = qt_action(FloodAction)
self.toolsAssignFromClosestJoint, self.toolsAssignFromClosestJointOptions = tools.create_action__from_closest_joint(parent, session)
(
self.toolsAssignFromClosestJointSelectedInfluences,
self.toolsAssignFromClosestJointOptionsSelectedInfluences,
) = tools.create_action__from_closest_joint(parent, session)
self.toolsAssignFromClosestJointOptionsSelectedInfluences.all_influences.set(False)
self.toolsAssignFromClosestJointOptionsSelectedInfluences.create_new_layer.set(False)
self.toolsUnifyWeights, self.toolsUnifyWeightsOptions = tools.create_action__unify_weights(parent, session)
self.toolsDeleteCustomNodes = define_action(
parent, "Delete All Custom Nodes", callback=lambda: removeLayerData.remove_custom_nodes(interactive=True, session=session)
)
self.toolsDeleteCustomNodesOnSelection = build_action_delete_custom_nodes_for_selection(parent, session)
self.transfer = build_transfer_action(session=session, parent=parent)
# self.setLayerMirrored = defineAction(u"Mirrored", icon=":/polyMirrorGeometry.png")
# self.setLayerMirrored.setCheckable(True)
self.documentation = WebsiteLinksActions(parent=parent)
self.check_for_updates = build_action_check_for_updates(parent=parent)
from ngSkinTools2.operations import copy_paste_actions
self.cut_influences = copy_paste_actions.action_copy_cut(session, parent, True)
self.copy_influences = copy_paste_actions.action_copy_cut(session, parent, False)
self.paste_weights = copy_paste_actions.action_paste(session, parent, PasteOperation.replace)
self.paste_weights_add = copy_paste_actions.action_paste(session, parent, PasteOperation.add)
self.paste_weights_sub = copy_paste_actions.action_paste(session, parent, PasteOperation.subtract)
self.copy_components = tools.create_action__copy_component_weights(parent=parent, session=session)
self.paste_component_average = tools.create_action__paste_average_component_weight(parent=parent, session=session)
self.merge_layer = tools.create_action__merge_layers(parent=parent, session=session)
self.duplicate_layer = tools.create_action__duplicate_layer(parent=parent, session=session)
self.fill_layer_transparency = tools.create_action__fill_transparency(parent=parent, session=session)
self.add_influences = tools.create_action__add_influences(parent=parent, session=session)
from ngSkinTools2.ui import influencesview
self.show_used_influences_only = influencesview.build_used_influences_action(parent)
self.set_influences_sorted = influencesview.build_set_influences_sorted_action(parent)
self.randomize_influences_colors = layers.build_action_randomize_influences_colors(parent=parent, session=session)
self.select_affected_vertices = tools.create_action__select_affected_vertices(parent=parent, session=session)
def addLayersActions(self, context):
context.addAction(self.addLayer)
context.addAction(self.deleteLayer)
context.addAction(self.separator(context))
context.addAction(self.merge_layer)
context.addAction(self.duplicate_layer)
context.addAction(self.fill_layer_transparency)
context.addAction(self.separator(context))
context.addAction(self.toggle_layer_enabled)
def addInfluencesActions(self, context):
context.addAction(self.separator(context, "Actions"))
context.addAction(self.toolsAssignFromClosestJointSelectedInfluences)
context.addAction(self.select_affected_vertices)
context.addAction(self.separator(context, "Clipboard"))
context.addAction(self.cut_influences)
context.addAction(self.copy_influences)
context.addAction(self.paste_weights)
context.addAction(self.paste_weights_add)
context.addAction(self.paste_weights_sub)

View File

@@ -0,0 +1,66 @@
from ngSkinTools2.api import PaintTool
from ngSkinTools2.api.paint import popups
from ngSkinTools2.api.pyside import QtCore, QtGui, QtWidgets
from ngSkinTools2.ui import qt, widgets
from ngSkinTools2.ui.layout import scale_multiplier
def brush_settings_popup(paint):
"""
:type paint: PaintTool
"""
window = QtWidgets.QWidget(qt.mainWindow)
window.setWindowFlags(QtCore.Qt.Popup | QtCore.Qt.FramelessWindowHint)
window.setAttribute(QtCore.Qt.WA_DeleteOnClose)
spacing = 5
layout = QtWidgets.QVBoxLayout()
layout.setSpacing(spacing)
intensity_slider = widgets.NumberSliderGroup()
widgets.set_paint_expo(intensity_slider, paint.paint_mode)
intensity_slider.set_value(paint.intensity)
@qt.on(intensity_slider.slider.sliderReleased, intensity_slider.spinner.editingFinished)
def close_with_slider_intensity():
close_with_intensity(intensity_slider.value())
def close_with_intensity(value):
paint.intensity = value
window.close()
def create_intensity_button(intensity):
btn = QtWidgets.QPushButton("{0:.3g}".format(intensity))
btn.clicked.connect(lambda: close_with_intensity(intensity))
btn.setMinimumWidth(60 * scale_multiplier)
btn.setMinimumHeight(30 * scale_multiplier)
return btn
layout.addLayout(intensity_slider.layout())
for values in [(0.0, 1.0), (0.25, 0.5, 0.75), (0.025, 0.05, 0.075, 0.1, 0.125)]:
row = QtWidgets.QHBoxLayout()
row.setSpacing(spacing)
for v in values:
row.addWidget(create_intensity_button(v))
layout.addLayout(row)
group = QtWidgets.QGroupBox("Brush Intensity")
group.setLayout(layout)
layout = QtWidgets.QVBoxLayout()
layout.setContentsMargins(4 * scale_multiplier, 4 * scale_multiplier, 4 * scale_multiplier, 4 * scale_multiplier)
layout.addWidget(group)
window.setLayout(layout)
window.show()
mp = QtGui.QCursor.pos()
window.move(mp.x() - window.size().width() / 2, mp.y() - window.size().height() / 2)
window.activateWindow()
popups.close_all()
popups.add(window)

View File

@@ -0,0 +1,57 @@
from maya import OpenMaya as om
from ngSkinTools2.api.pyside import QtCore, QtWidgets
openDialogs = []
messagesCallbacks = []
# main window will set itself here
promptsParent = None
def __baseMessageBox(message):
msg = QtWidgets.QMessageBox(promptsParent)
msg.setWindowTitle("ngSkinTools2")
msg.setText(message)
for i in messagesCallbacks:
i(message)
openDialogs.append(msg)
return msg
def displayError(message):
"""
displays error in script editor and in a dialog box
"""
message = str(message)
om.MGlobal.displayError('[ngSkinTools2] ' + message)
msg = __baseMessageBox(message)
msg.setIcon(QtWidgets.QMessageBox.Critical)
msg.exec_()
def info(message):
msg = __baseMessageBox(message)
msg.setIcon(QtWidgets.QMessageBox.Information)
msg.exec_()
def yesNo(message):
msg = __baseMessageBox(message)
msg.setIcon(QtWidgets.QMessageBox.Question)
msg.setStandardButtons(QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No)
msg.setDefaultButton(QtWidgets.QMessageBox.Yes)
return msg.exec_() == QtWidgets.QMessageBox.Yes
def closeAllAfterTimeout(timeout, result=0):
def closeAll():
while openDialogs:
msg = openDialogs.pop()
msg.done(result)
QtCore.QTimer.singleShot(timeout, closeAll)

View File

@@ -0,0 +1,86 @@
"""
Global list of hotkey-able functions within the plugin.
These functions will be embedded in end-user's hotkey setup by absolute path (package.function_name)
so the names should not fluctuate.
"""
from ngSkinTools2.api import NamedPaintTarget, PaintTool, WeightsDisplayMode, plugin
from ngSkinTools2.api.paint import MaskDisplayMode
from ngSkinTools2.api.session import session, withSession
from ngSkinTools2.operations.paint import FloodAction, PaintAction
from ngSkinTools2.ui.action import do_action_hotkey
def paint_tool_start():
do_action_hotkey(PaintAction)
def paint_tool_toggle_help():
plugin.ngst2_hotkey(paintContextToggleHelp=True)
def paint_tool_flood():
do_action_hotkey(FloodAction)
def paint_tool_focus_current_influence():
plugin.ngst2_hotkey(paintContextViewFit=True)
def paint_tool_brush_size():
plugin.ngst2_hotkey(paintContextBrushSize=True)
def paint_tool_brush_size_release():
plugin.ngst2_hotkey(paintContextBrushSize=False)
def paint_tool_sample_influence():
plugin.ngst2_hotkey(paintContextSampleInfluence=True)
def paint_tool_sample_influence_release():
plugin.ngst2_hotkey(paintContextSampleInfluence=False)
@withSession
def select_paint_brush_intensity():
from ngSkinTools2.ui.brush_settings_popup import brush_settings_popup
brush_settings_popup(session.paint_tool)
@withSession
def paint_tool_toggle_original_mesh():
paint = session.paint_tool
paint.display_settings.display_node_visible = not paint.display_settings.display_node_visible
session.events.toolChanged.emit()
@withSession
def paint_tool_cycle_weights_display_mode():
"""
cycle current display mode "all influences" -> "current influence" -> "current influence colored"
:return:
"""
paint = session.paint_tool
targets = session.state.currentInfluence.targets
is_mask_mode = targets is not None and len(targets) == 1 and targets[0] == NamedPaintTarget.MASK
settings = paint.display_settings
if is_mask_mode:
settings.mask_display_mode = {
MaskDisplayMode.default_: MaskDisplayMode.color_ramp,
MaskDisplayMode.color_ramp: MaskDisplayMode.default_,
}.get(settings.mask_display_mode, MaskDisplayMode.default_)
else:
settings.weights_display_mode = {
WeightsDisplayMode.allInfluences: WeightsDisplayMode.currentInfluence,
WeightsDisplayMode.currentInfluence: WeightsDisplayMode.currentInfluenceColored,
WeightsDisplayMode.currentInfluenceColored: WeightsDisplayMode.allInfluences,
}.get(settings.weights_display_mode, WeightsDisplayMode.allInfluences)
session.events.toolChanged.emit()

View File

@@ -0,0 +1,200 @@
"""
Maya internals dissection, comments are ours. similar example available in "command|hotkey code", see "Here's an example
of how to create runtimeCommand with a certain hotkey context"
```
// add new hotkey ctx
// t: Specifies the context type. It's used together with the other flags such as "currentClient", "addClient",
// "removeClient" and so on.
// ac: Associates a client to the given hotkey context type. This flag needs to be used with the flag "type" which
// specifies the context type.
hotkeyCtx -t "Tool" -ac "sculptMeshCache";
// create new runtime command, associate with created context
runTimeCommand -default true
-annotation (uiRes("m_defaultRunTimeCommands.kModifySizePressAnnot"))
-category ("Other items.Brush Tools")
-command ("if ( `contextInfo -ex sculptMeshCacheContext`) sculptMeshCacheCtx -e -adjustSize 1 sculptMeshCacheContext;")
-hotkeyCtx ("sculptMeshCache")
SculptMeshActivateBrushSize;
// create named command for the runtime command
nameCommand
-annotation "Start adjust size"
-command ("SculptMeshActivateBrushSize")
SculptMeshActivateBrushSizeNameCommand;
// assign hotkey for name command
hotkey -keyShortcut "b" -name ("SculptMeshActivateBrushSizeNameCommand") -releaseName ("SculptMeshDeactivateBrushSizeNameCommand");
```
"""
from maya import cmds
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.python_compatibility import is_string
from . import hotkeys
hotkeySetName = 'ngSkinTools2'
context = 'ngst2PaintContext'
command_prefix = "ngskintools_2_" # maya breaks command name on capital letters and before numbers, so this will ensure that all command names start with "ngskintools 2 "
log = getLogger("hotkeys setup")
def uninstall_hotkeys():
if cmds.hotkeySet(hotkeySetName, q=True, exists=True):
cmds.hotkeySet(hotkeySetName, e=True, delete=True)
def setup_named_commands():
# "default" mode will force a read-only behavior for runTimCommands
# only turn this on for production mode
import ngSkinTools2
append_only_mode = not ngSkinTools2.DEBUG_MODE
def add_command(name, annotation, command, context=None):
if not is_string(command):
command = function_link(command)
runtime_command_name = command_prefix + name
# delete (if exists) and recreate runtime command
if not append_only_mode and cmds.runTimeCommand(runtime_command_name, q=True, exists=True):
cmds.runTimeCommand(runtime_command_name, e=True, delete=True)
if not cmds.runTimeCommand(runtime_command_name, q=True, exists=True):
additional_args = {}
if context is not None:
additional_args['hotkeyCtx'] = context
cmds.runTimeCommand(
runtime_command_name,
category="Other items.ngSkinTools2",
default=append_only_mode,
annotation=annotation,
command=command,
commandLanguage="python",
**additional_args
)
cmds.nameCommand(
command_prefix + name + "NameCommand",
annotation=annotation + "-",
sourceType="python",
default=append_only_mode,
command=runtime_command_name,
)
def add_toggle(name, annotation, command_on, command_off, context=None):
add_command(name + 'On', annotation=annotation, command=command_on, context=context)
add_command(name + 'Off', annotation=annotation + "(release)", command=command_off, context=context)
add_toggle(
'BrushSize',
annotation='Toggle brush size mode',
command_on=hotkeys.paint_tool_brush_size,
command_off=hotkeys.paint_tool_brush_size_release,
context=context,
)
add_command('ToggleHelp', annotation='toggle help', command=hotkeys.paint_tool_toggle_help, context=context)
add_command('ViewFitInfluence', annotation='fit influence in view', command=hotkeys.paint_tool_focus_current_influence, context=context)
add_toggle(
'SampleInfluence',
annotation='Sample influence',
command_on=hotkeys.paint_tool_sample_influence,
command_off=hotkeys.paint_tool_sample_influence_release,
context=context,
)
add_command("SetBrushIntensity", annotation="set brush intensity", command=hotkeys.select_paint_brush_intensity, context=context)
add_command("PaintFlood", annotation="apply current brush to all vertices", command=hotkeys.paint_tool_flood, context=context)
add_command("Paint", annotation="start paint tool", command=hotkeys.paint_tool_start)
add_command(
"ToggleOriginalMesh",
annotation="toggle between weights display and original mesh while painting",
command=hotkeys.paint_tool_toggle_original_mesh,
)
add_command(
"CycleWeightsDisplayMode",
annotation='Cycle weights display mode "all influences" -> "current influence" -> "current influence colored"',
command=hotkeys.paint_tool_cycle_weights_display_mode,
)
def define_hotkeys():
setup_named_commands()
def nc(name_command_short_name):
return command_prefix + name_command_short_name + "NameCommand"
# cmds.hotkey(k="b", name=nc("BrushSizeOn"), releaseName=nc("BrushSizeOff"))
cmds.hotkey(keyShortcut="b", name=nc("BrushSizeOn"), releaseName=nc("BrushSizeOff"))
cmds.hotkey(keyShortcut="i", name=nc("SetBrushIntensity"))
cmds.hotkey(keyShortcut="f", ctrlModifier=True, name=nc("PaintFlood"))
cmds.hotkey(keyShortcut="f", name=nc("ViewFitInfluence"))
cmds.hotkey(keyShortcut="h", name=nc("ToggleHelp"))
cmds.hotkey(keyShortcut="s", name=nc("SampleInfluenceOn"), releaseName=nc("SampleInfluenceOff"))
cmds.hotkey(keyShortcut="d", name=nc("CycleWeightsDisplayMode"))
cmds.hotkey(keyShortcut="t", name=nc("ToggleOriginalMesh"))
def install_hotkeys():
uninstall_hotkeys()
__hotkey_set_handler.remember()
try:
if cmds.hotkeySet(hotkeySetName, q=True, exists=True):
cmds.hotkeySet(hotkeySetName, e=True, current=True)
else:
cmds.hotkeySet(hotkeySetName, current=True)
cmds.hotkeyCtx(addClient=context, type='Tool')
define_hotkeys()
finally:
__hotkey_set_handler.restore()
def function_link(fun):
return "import {module}; {module}.{fn}()".format(module=fun.__module__, fn=fun.__name__)
class HotkeySetHandler:
def __init__(self):
log.info("initializing new hotkey set handler")
self.prev_hotkey_set = None
def remember(self):
if self.prev_hotkey_set is not None:
return
log.info("remembering current hotkey set")
self.prev_hotkey_set = cmds.hotkeySet(q=True, current=True)
def restore(self):
if self.prev_hotkey_set is None:
return
log.info("restoring previous hotkey set")
cmds.hotkeySet(self.prev_hotkey_set, e=True, current=True)
self.prev_hotkey_set = None
__hotkey_set_handler = HotkeySetHandler()
def toggle_paint_hotkey_set(enabled):
if enabled:
__hotkey_set_handler.remember()
cmds.hotkeySet(hotkeySetName, e=True, current=True)
else:
__hotkey_set_handler.restore()

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="16"
height="16"
fill="currentColor"
class="bi bi-eye-fill"
viewBox="0 0 16 16"
version="1.1"
id="svg1488"
sodipodi:docname="eye-fill.svg"
inkscape:version="1.0.2-2 (e86c870879, 2021-01-15)">
<metadata
id="metadata1494">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs1492" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="3840"
inkscape:window-height="2080"
id="namedview1490"
showgrid="false"
inkscape:zoom="63"
inkscape:cx="8"
inkscape:cy="8"
inkscape:window-x="3829"
inkscape:window-y="-11"
inkscape:window-maximized="1"
inkscape:current-layer="svg1488" />
<path
d="M10.5 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"
id="path1484"
style="fill:#ffffff;fill-opacity:1" />
<path
d="M0 8s3-5.5 8-5.5S16 8 16 8s-3 5.5-8 5.5S0 8 0 8zm8 3.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z"
id="path1486"
style="fill:#ffffff;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="16"
height="16"
fill="currentColor"
class="bi bi-eye-slash-fill"
viewBox="0 0 16 16"
version="1.1"
id="svg2073"
sodipodi:docname="eye-slash-fill.svg"
inkscape:version="1.0.2-2 (e86c870879, 2021-01-15)">
<metadata
id="metadata2079">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs2077" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="3840"
inkscape:window-height="2080"
id="namedview2075"
showgrid="false"
inkscape:zoom="63"
inkscape:cx="8"
inkscape:cy="10.539683"
inkscape:window-x="3829"
inkscape:window-y="-11"
inkscape:window-maximized="1"
inkscape:current-layer="svg2073" />
<path
d="M10.79 12.912l-1.614-1.615a3.5 3.5 0 0 1-4.474-4.474l-2.06-2.06C.938 6.278 0 8 0 8s3 5.5 8 5.5a7.027 7.027 0 0 0 2.79-.588zM5.21 3.088A7.028 7.028 0 0 1 8 2.5c5 0 8 5.5 8 5.5s-.939 1.721-2.641 3.238l-2.062-2.062a3.5 3.5 0 0 0-4.474-4.474L5.21 3.088z"
id="path2069"
style="fill:#bababa;fill-opacity:1" />
<path
d="M5.525 7.646a2.5 2.5 0 0 0 2.829 2.829l-2.83-2.829zm4.95.708l-2.829-2.83a2.5 2.5 0 0 1 2.829 2.829zm3.171 6l-12-12 .708-.708 12 12-.708.707z"
id="path2071"
style="fill:#bababa;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-eye-slash" viewBox="0 0 16 16">
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/>
<path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299l.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/>
<path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884l-12-12 .708-.708 12 12-.708.708z"/>
</svg>

After

Width:  |  Height:  |  Size: 893 B

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="16"
height="16"
fill="currentColor"
class="bi bi-eye"
viewBox="0 0 16 16"
version="1.1"
id="svg875"
sodipodi:docname="eye.svg"
inkscape:version="1.0.2-2 (e86c870879, 2021-01-15)">
<metadata
id="metadata881">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs879" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="2133"
inkscape:window-height="1414"
id="namedview877"
showgrid="false"
inkscape:pagecheckerboard="true"
inkscape:zoom="63"
inkscape:cx="8"
inkscape:cy="9.2698413"
inkscape:window-x="4867"
inkscape:window-y="429"
inkscape:window-maximized="0"
inkscape:current-layer="svg875" />
<path
d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"
id="path871"
style="stroke-width:1.0015748;stroke-miterlimit:4;stroke-dasharray:none;stroke:none;stroke-opacity:1;fill:#ffffff;fill-opacity:1" />
<path
d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"
id="path873"
style="stroke-width:1.0015748;stroke-miterlimit:4;stroke-dasharray:none;fill:#ffffff;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -0,0 +1,131 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="42"
height="42"
viewBox="0 0 11.1125 11.1125"
version="1.1"
id="svg8"
inkscape:version="1.0.2-2 (e86c870879, 2021-01-15)"
sodipodi:docname="logo-colored2.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:zoom="10"
inkscape:cx="57.337866"
inkscape:cy="29.075965"
inkscape:document-units="mm"
inkscape:current-layer="g968"
inkscape:document-rotation="0"
showgrid="false"
inkscape:window-width="3840"
inkscape:window-height="2080"
inkscape:window-x="3829"
inkscape:window-y="-11"
inkscape:window-maximized="1"
inkscape:pagecheckerboard="false"
units="px" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="g968"
inkscape:label="main"
style="display:inline">
<path
id="path1008"
style="display:inline;opacity:1;mix-blend-mode:normal;fill:#e6f7e1;fill-opacity:1;fill-rule:evenodd;stroke:#394241;stroke-width:0.381;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 10.815145,5.5547744 A 5.2636318,5.2687149 0 0 1 5.5515153,10.823488 5.2636318,5.2687149 0 0 1 0.28788462,5.5547744 5.2636318,5.2687149 0 0 1 5.5515153,0.2860595 5.2636318,5.2687149 0 0 1 10.815145,5.5547744 Z"
inkscape:export-xdpi="465.70001"
inkscape:export-ydpi="465.70001" />
<path
style="display:inline;opacity:1;mix-blend-mode:normal;fill:#8ed07d;fill-opacity:1;stroke:#394241;stroke-width:0.132292;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 10.071522,5.5547744 A 4.520009,4.5243745 0 0 1 5.5515153,10.079147 4.520009,4.5243745 0 0 1 1.0315065,5.5547744 4.520009,4.5243745 0 0 1 5.5515153,1.0303994 4.520009,4.5243745 0 0 1 10.071522,5.5547744 Z"
id="path1010"
inkscape:export-xdpi="465.70001"
inkscape:export-ydpi="465.70001" />
<path
id="path1012"
style="display:inline;opacity:1;mix-blend-mode:normal;fill:#e4f6de;fill-opacity:1;fill-rule:evenodd;stroke:#394241;stroke-width:0.264583;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 8.2782313,3.4978104 a 0.99620533,0.99716729 0 0 1 -0.996205,0.997168 0.99620533,0.99716729 0 0 1 -0.996206,-0.997168 0.99620533,0.99716729 0 0 1 0.996206,-0.997167 0.99620533,0.99716729 0 0 1 0.996205,0.997167 z"
inkscape:export-xdpi="465.70001"
inkscape:export-ydpi="465.70001" />
<path
id="path1014"
style="display:inline;opacity:1;mix-blend-mode:normal;fill:#394241;fill-opacity:1;stroke:#394241;stroke-width:0.132292;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 6.1665103,6.0291114 0.362793,-1.729342 m 0,0 -1.678701,0.935426 c 0,0 0.507188,0.0474 0.759757,0.206425 0.25257,0.159026 0.556151,0.587491 0.556151,0.587491"
inkscape:export-xdpi="465.70001"
inkscape:export-ydpi="465.70001" />
<path
id="path1016"
style="display:inline;opacity:1;mix-blend-mode:normal;fill:none;stroke:#394241;stroke-width:0.132;stroke-linecap:round;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 2.8789345,7.9067184 -0.5782041,0.785102 m 5.8312779,-0.953399 0.673759,0.98138 m -3.200692,0.07241 0.02713,1.2964136 M 2.8293132,7.5401614 C 2.0841484,7.0595114 1.5609154,6.3432344 1.0846774,5.1276854 m 5.2510059,3.037117 c 1.565971,-0.02362 3.1253259,-1.1282499 3.7386737,-2.2919899"
sodipodi:nodetypes="cccccccccc"
inkscape:export-xdpi="465.70001"
inkscape:export-ydpi="465.70001" />
<g
id="g1026"
style="fill:#45954d;fill-opacity:1;stroke:none;stroke-opacity:1"
transform="translate(0.00677962,-0.03127322)"
inkscape:export-xdpi="465.70001"
inkscape:export-ydpi="465.70001">
<path
style="display:inline;fill:#cae5c1;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0269407;stroke-linecap:round;stroke-opacity:1"
d="M 8.505902,8.1900327 8.2143526,7.7654947 8.4591298,7.6195127 c 0.5506069,-0.328376 1.047841,-0.767506 1.4030352,-1.239084 l 0.07046,-0.09355 -0.0128,0.07484 c -0.082767,0.48376 -0.30625,1.076837 -0.5745653,1.524772 -0.1008621,0.168383 -0.3570904,0.517576 -0.4764757,0.64935 l -0.071327,0.07873 z"
id="path1018"
inkscape:export-xdpi="465.70001"
inkscape:export-ydpi="465.70001" />
<path
style="display:inline;fill:#cae5c1;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0269407;stroke-linecap:round;stroke-opacity:1"
d="M 5.6947136,9.78319 C 5.6879536,9.6422617 5.6828286,9.3872697 5.6833236,9.2165407 l 8.996e-4,-0.310415 0.1047906,-0.06548 c 0.1368939,-0.08554 0.3882578,-0.323017 0.4904542,-0.463355 l 0.080931,-0.111135 0.2082474,-0.01182 c 0.3803844,-0.02159 0.8510395,-0.141515 1.297223,-0.330527 l 0.2096587,-0.08882 0.3001579,0.43557 c 0.1650868,0.239563 0.3001579,0.443507 0.3001579,0.453207 0,0.02468 -0.1876353,0.200193 -0.3598442,0.336599 C 7.8780736,9.4072457 7.3539626,9.68306 6.8315378,9.841564 6.5909701,9.914554 6.2021559,9.993084 6.000476,10.00942 5.923302,10.01572 5.8256991,10.02498 5.7835811,10.03011 l -0.076578,0.0093 z"
id="path1020"
inkscape:export-xdpi="465.70001"
inkscape:export-ydpi="465.70001" />
<path
style="display:inline;fill:#cae5c1;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0269407;stroke-linecap:round;stroke-opacity:1"
d="M 5.1398679,10.017257 C 4.1884222,9.933694 3.2165381,9.4967897 2.5160663,8.8377427 l -0.1261678,-0.118706 0.21553,-0.289988 c 0.1185414,-0.159492 0.2216215,-0.291499 0.2290666,-0.293348 0.00745,-0.0019 0.059841,0.06838 0.1164354,0.156066 0.1213623,0.188035 0.3700815,0.435839 0.5621639,0.560097 0.5624248,0.36383 1.2599882,0.437436 1.8765803,0.198012 l 0.1430793,-0.05556 v 0.294403 c 0,0.161922 0.0055,0.3967533 0.012229,0.5218473 l 0.012229,0.227444 -0.1198055,-0.0026 c -0.065893,-0.0014 -0.1997861,-0.0096 -0.2975398,-0.01818 z"
id="path1022"
inkscape:export-xdpi="465.70001"
inkscape:export-ydpi="465.70001" />
<path
style="display:inline;fill:#cae5c1;fill-opacity:1;fill-rule:evenodd;stroke:none;stroke-width:0.0269407;stroke-linecap:round;stroke-opacity:1"
d="M 2.1635305,8.4698927 C 1.9097453,8.1663167 1.7204681,7.8762037 1.5570923,7.5403777 1.2671987,6.9444897 1.1262435,6.3732707 1.1069707,5.7162637 l -0.00851,-0.289987 0.041376,0.09354 c 0.097201,0.219754 0.3172379,0.649387 0.4168077,0.813835 0.2559593,0.422741 0.6076541,0.835074 0.9264318,1.086166 0.1353842,0.106639 0.1490128,0.123649 0.1696878,0.211798 0.012364,0.05272 0.0418,0.147838 0.065413,0.211382 l 0.042933,0.115534 -0.2344342,0.319447 c -0.1289388,0.175695 -0.2398767,0.318883 -0.2465287,0.318195 -0.00665,-6.88e-4 -0.059131,-0.05752 -0.1166208,-0.126285 z"
id="path1024"
inkscape:export-xdpi="465.70001"
inkscape:export-ydpi="465.70001" />
</g>
<path
id="path1028"
style="display:inline;opacity:1;mix-blend-mode:normal;fill:#e4f6de;fill-opacity:1;fill-rule:evenodd;stroke:#394241;stroke-width:0.27;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="M 6.5574843,7.1065184 A 1.9072757,1.9091177 0 0 1 4.6502091,9.0156364 1.9072757,1.9091177 0 0 1 2.7429334,7.1065184 1.9072757,1.9091177 0 0 1 4.6502091,5.1974004 1.9072757,1.9091177 0 0 1 6.5574843,7.1065184 Z"
inkscape:export-xdpi="465.70001"
inkscape:export-ydpi="465.70001" />
<path
id="path1030"
style="opacity:1;mix-blend-mode:normal;fill:#000000;fill-opacity:0;stroke:#394241;stroke-width:0.132292;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1"
d="m 4.5859576,9.0166314 c -0.4550684,-0.740982 -0.536541,-2.94365 0.010119,-3.784058 m 1.9302377,1.867597 c -1.222413,0.573856 -2.9362113,0.537553 -3.865322,-0.01532"
inkscape:export-xdpi="465.70001"
inkscape:export-ydpi="465.70001" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.7 KiB

View File

@@ -0,0 +1,360 @@
from ngSkinTools2 import cleanup, signal
from ngSkinTools2.api import influenceMapping, mirror
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.pyside import QtCore, QtGui, QtWidgets
from ngSkinTools2.signal import Signal
from ngSkinTools2.ui import dialogs, qt, widgets
from ngSkinTools2.ui.dialogs import yesNo
from ngSkinTools2.ui.layout import scale_multiplier
from ngSkinTools2.ui.options import config
from ngSkinTools2.ui.widgets import NumberSliderGroup
log = getLogger("influence mapping UI")
def open_ui_for_mesh(ui_parent, mesh):
m = mirror.Mirror(mesh)
mapper = m.build_influences_mapper()
def do_apply(mapping):
m.set_influences_mapping(mapping)
m.save_influences_mapper(mapper)
return open_as_dialog(ui_parent, mapper, do_apply)
def open_as_dialog(parent, matcher, result_callback):
"""
:type matcher: ngSkinTools2.api.influenceMapping.InfluenceMapping
"""
main_layout, reload_ui, recalc_matches = build_ui(parent, matcher)
def button_row(window):
def apply():
result_callback(matcher.asIntIntMapping(matcher.calculatedMapping))
window.close()
def save_defaults():
if not yesNo("Save current settings as default?"):
return
config.mirrorInfluencesDefaults = matcher.config.as_json()
def load_defaults():
matcher.config.load_json(config.mirrorInfluencesDefaults)
reload_ui()
recalc_matches()
return widgets.button_row(
[
("Apply", apply),
("Cancel", window.close),
],
side_menu=[
("Save As Default", save_defaults),
("Load Defaults", load_defaults),
],
)
window = QtWidgets.QDialog(parent)
cleanup.registerCleanupHandler(window.close)
window.setWindowTitle("Influence Mirror Mapping")
window.setAttribute(QtCore.Qt.WA_DeleteOnClose)
window.resize(720 * scale_multiplier, 500 * scale_multiplier)
window.setLayout(QtWidgets.QVBoxLayout())
window.layout().addWidget(main_layout)
window.layout().addLayout(button_row(window))
window.show()
recalc_matches()
return window
def build_ui(parent, matcher):
"""
:param parent: parent qt widget
:type matcher: influenceMapping.InfluenceMapping
"""
influence_data = matcher.influences
influenceMapping.calcShortestUniqueName(matcher.influences)
if matcher.destinationInfluences is not None and matcher.destinationInfluences != matcher.influences:
influenceMapping.calcShortestUniqueName(matcher.destinationInfluences)
update_globs = Signal("need recalc")
reload_ui = Signal("reload_ui")
mirror_mode = matcher.config.mirror_axis is not None
def build_tree_hierarchy(tree_view):
tree_items = {} # mapping of path->treeItem
influence_items = {} # same as above, only includes non-intermediate items
def find_item(path, is_intermediate):
result = tree_items.get(path, None)
if result is not None:
return result
split_path = path.rsplit("|", 1)
parent_path, name = split_path if len(split_path) == 2 else ["", split_path[0]]
item = QtWidgets.QTreeWidgetItem([name, '-', '(not in skin cluster)' if is_intermediate else '?'])
tree_items[path] = item
parent_item = None if parent_path == "" else find_item(parent_path, True)
if parent_item is not None:
parent_item.addChild(item)
else:
tree_view.addTopLevelItem(item)
item.setExpanded(True)
return item
for i in influence_data:
influence_items[i.path_name()] = find_item(i.path_name(), False)
return influence_items
def tolerance():
result = NumberSliderGroup(min_value=0.001, max_value=10)
result.spinner.setDecimals(3)
@signal.on(reload_ui)
def reload():
with qt.signals_blocked(result):
result.set_value(matcher.config.distance_threshold)
@signal.on(result.valueChanged)
def changed():
matcher.config.distance_threshold = result.value()
recalcMatches()
reload()
return result
def pattern():
result = QtWidgets.QTableWidget()
result.setColumnCount(2)
result.setHorizontalHeaderLabels(["Pattern", "Opposite"] if mirror_mode else ["Source", "Destination"])
result.setEditTriggers(QtWidgets.QTableWidget.AllEditTriggers)
result.verticalHeader().setVisible(False)
result.verticalHeader().setDefaultSectionSize(20)
item_font = QtGui.QFont("Courier New", 12)
item_font.setStyleHint(QtGui.QFont.Monospace)
@signal.on(reload_ui)
def reload_patterns():
with qt.signals_blocked(result):
result.setRowCount(len(matcher.config.globs) + 1)
for rowIndex, patterns in enumerate(matcher.config.globs + [('', '')]):
for colIndex, p in enumerate(patterns):
item = QtWidgets.QTableWidgetItem(p)
item.setFont(item_font)
result.setItem(rowIndex, colIndex, item)
reload_patterns()
@signal.on(update_globs)
def update_matcher_globs():
globs = []
def text(r, c):
item = result.item(r, c)
if item is None:
return ""
return item.text().strip()
for row in range(result.rowCount()):
v1 = text(row, 0)
v2 = text(row, 1)
if v1 != "" and v2 != "":
globs.append((v1, v2))
matcher.config.globs = globs
recalcMatches()
@qt.on(result.itemChanged)
def item_changed(item):
log.debug("item changed")
item.setText(item.text().strip())
try:
influenceMapping.validate_glob(item.text().strip())
except Exception as err:
dialogs.displayError(str(err))
item.setText(influenceMapping.illegalCharactersRegexp.sub("", item.text()))
if item.row() != result.rowCount() - 1:
if item.text().strip() == "":
result.removeRow(item.row())
# ensure one empty line at the end
rows = result.rowCount()
last_item = result.item(rows - 1, 0)
if last_item and last_item.text() != "":
result.setRowCount(rows + 1)
update_matcher_globs()
return result
def automaticRules():
form = QtWidgets.QFormLayout()
use_joint_names = QtWidgets.QCheckBox("Match by joint name")
naming_patterns = pattern()
use_position = QtWidgets.QCheckBox("Match by position")
tolerance_scroll = tolerance()
use_joint_labels = QtWidgets.QCheckBox("Match by joint label")
use_dg_links = QtWidgets.QCheckBox("Match by dependency graph links")
def update_enabled_disabled():
def enable_form_row(form_item, e):
form_item.setEnabled(e)
form.labelForField(form_item).setEnabled(e)
checked = use_joint_names.isChecked()
enable_form_row(naming_patterns, checked)
checked = use_position.isChecked()
tolerance_scroll.set_enabled(checked)
form.labelForField(tolerance_scroll.layout()).setEnabled(checked)
enable_form_row(dg_attribute, use_dg_links.isChecked())
@qt.on(use_joint_names.toggled, use_position.toggled, use_joint_labels.toggled, use_dg_links.toggled)
def use_joint_names_toggled():
update_enabled_disabled()
matcher.config.use_name_matching = use_joint_names.isChecked()
matcher.config.use_distance_matching = use_position.isChecked()
matcher.config.use_label_matching = use_joint_labels.isChecked()
matcher.config.use_dg_link_matching = use_dg_links.isChecked()
recalcMatches()
dg_attribute = QtWidgets.QLineEdit()
@qt.on(dg_attribute.editingFinished)
def use_joint_names_toggled():
matcher.config.dg_destination_attribute = str(dg_attribute.text()).strip()
recalcMatches()
@signal.on(reload_ui)
def update_values():
with qt.signals_blocked(dg_attribute):
dg_attribute.setText(matcher.config.dg_destination_attribute)
with qt.signals_blocked(use_joint_names):
use_joint_names.setChecked(matcher.config.use_name_matching)
with qt.signals_blocked(use_position):
use_position.setChecked(matcher.config.use_distance_matching)
with qt.signals_blocked(use_joint_labels):
use_joint_labels.setChecked(matcher.config.use_label_matching)
with qt.signals_blocked(use_dg_links):
use_dg_links.setChecked(matcher.config.use_dg_link_matching)
update_enabled_disabled()
g = QtWidgets.QGroupBox("Rules")
g.setLayout(form)
form.addRow(use_dg_links)
form.addRow("Attribute name:", dg_attribute)
form.addRow(use_joint_labels)
form.addRow(use_joint_names)
form.addRow("Naming scheme:", naming_patterns)
form.addRow(use_position)
form.addRow("Position tolerance:", tolerance_scroll.layout())
update_values()
return g
def scriptedRules():
g = QtWidgets.QGroupBox("Scripted rules")
g.setLayout(QtWidgets.QVBoxLayout())
g.layout().addWidget(QtWidgets.QLabel("TODO"))
return g
def manualRules():
g = QtWidgets.QGroupBox("Manual overrides")
g.setLayout(QtWidgets.QVBoxLayout())
g.layout().addWidget(QtWidgets.QLabel("TODO"))
return g
leftSide = QtWidgets.QScrollArea()
leftSide.setFrameShape(QtWidgets.QFrame.NoFrame)
leftSide.setFocusPolicy(QtCore.Qt.NoFocus)
leftSide.setWidgetResizable(True)
l = QtWidgets.QVBoxLayout()
l.setContentsMargins(0, 0, 0, 0)
l.addWidget(automaticRules())
# l.addWidget(scriptedRules())
# l.addWidget(manualRules())
# l.addStretch()
leftSide.setWidget(qt.wrap_layout_into_widget(l))
def createMappingView():
view = QtWidgets.QTreeWidget()
view.setColumnCount(3)
view.setHeaderLabels(["Source", "Destination", "Matched by rule"])
view.setIndentation(7)
view.setExpandsOnDoubleClick(False)
usedItems = build_tree_hierarchy(view)
linkedItemRole = QtCore.Qt.UserRole + 1
def previewMapping(mapping):
"""
:type mapping: dict[InfluenceInfo, InfluenceInfo]
"""
for treeItem in list(usedItems.values()):
treeItem.setText(1, "(not matched)")
treeItem.setText(2, "")
for k, v in list(mapping.items()):
treeItem = usedItems.get(k.path_name(), None)
if treeItem is None:
continue
treeItem.setText(1, "(self)" if k == v['infl'] else v["infl"].shortestPath)
treeItem.setText(2, v["matchedRule"])
treeItem.setData(1, linkedItemRole, v["infl"].path)
@qt.on(view.itemDoubleClicked)
def itemDoubleClicked(item, column):
item.setExpanded(True)
linkedItemPath = item.data(1, linkedItemRole)
item = usedItems.get(linkedItemPath, None)
if item is not None:
item.setSelected(True)
view.scrollToItem(item)
return view, previewMapping
def recalcMatches():
matches = matcher.calculate()
mappingView_updateMatches(matches)
g = QtWidgets.QGroupBox("Calculated mapping")
g.setLayout(QtWidgets.QVBoxLayout())
mappingView, mappingView_updateMatches = createMappingView()
g.layout().addWidget(mappingView)
mainLayout = QtWidgets.QSplitter(orientation=QtCore.Qt.Horizontal, parent=parent)
mainLayout.addWidget(leftSide)
mainLayout.addWidget(g)
mainLayout.setStretchFactor(0, 10)
mainLayout.setStretchFactor(1, 10)
mainLayout.setCollapsible(0, True)
mainLayout.setSizes([200] * 2)
return mainLayout, reload_ui.emit, recalcMatches

View File

@@ -0,0 +1,315 @@
from ngSkinTools2 import signal
from ngSkinTools2.api import influence_names
from ngSkinTools2.api.influenceMapping import InfluenceInfo
from ngSkinTools2.api.layers import Layer
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.pyside import QtCore, QtGui, QtWidgets
from ngSkinTools2.api.target_info import list_influences
from ngSkinTools2.ui import actions, qt
from ngSkinTools2.ui.layout import scale_multiplier
from ngSkinTools2.ui.options import Config, config
log = getLogger("influencesView")
_ = Layer # only imported for type reference
def build_used_influences_action(parent):
def toggle():
config.influences_show_used_influences_only.set(not config.influences_show_used_influences_only())
result = actions.define_action(
parent,
"Used Influences Only",
callback=toggle,
tooltip="If enabled, influences view will only show influences that have weights on current layer",
)
@signal.on(config.influences_show_used_influences_only.changed, qtParent=parent)
def update():
result.setChecked(config.influences_show_used_influences_only())
result.setCheckable(True)
update()
return result
def build_set_influences_sorted_action(parent):
from ngSkinTools2.ui import actions
def toggle():
new_value = Config.InfluencesSortDescending
if config.influences_sort() == new_value:
new_value = Config.InfluencesSortUnsorted
config.influences_sort.set(new_value)
result = actions.define_action(
parent,
"Show influences sorted",
callback=toggle,
tooltip="Sort influences by name",
)
@signal.on(config.influences_show_used_influences_only.changed, qtParent=parent)
def update():
result.setChecked(config.influences_sort() == Config.InfluencesSortDescending)
result.setCheckable(True)
update()
return result
icon_mask = QtGui.QIcon(":/blendColors.svg")
icon_dq = QtGui.QIcon(":/rotate_M.png")
icon_joint = QtGui.QIcon(":/joint.svg")
icon_joint_disabled = qt.image_icon("joint_disabled.png")
icon_transform = QtGui.QIcon(":/cube.png")
icon_transform_disabled = qt.image_icon("cube_disabled.png")
def build_view(parent, actions, session, filter):
"""
:param parent: ui parent
:type actions: ngSkinTools2.ui.actions.Actions
:type session: ngSkinTools2.ui.session.Session
:type filter: InfluenceNameFilter
"""
icon_locked = QtGui.QIcon(":/Lock_ON.png")
icon_unlocked = QtGui.QIcon(":/Lock_OFF_grey.png")
id_role = QtCore.Qt.UserRole + 1
item_size_hint = QtCore.QSize(25 * scale_multiplier, 25 * scale_multiplier)
def get_item_id(item):
if item is None:
return None
return item.data(0, id_role)
tree_items = {}
def build_items(view, items, layer):
# type: (QtWidgets.QTreeWidget, list[InfluenceInfo], Layer) -> None
is_group_layer = layer is not None and layer.num_children != 0
def rebuild_buttons(item, item_id, buttons):
bar = QtWidgets.QToolBar(parent=parent)
bar.setMovable(False)
bar.setIconSize(QtCore.QSize(13 * scale_multiplier, 13 * scale_multiplier))
def add_or_remove(input_list, items, should_add):
if should_add:
return list(input_list) + list(items)
return [i for i in input_list if i not in items]
def lock_unlock_handler(lock):
def handler():
targets = layer.paint_targets
if item_id not in targets:
targets = (item_id,)
layer.locked_influences = add_or_remove(layer.locked_influences, targets, lock)
log.info("updated locked influences to %r", layer.locked_influences)
session.events.influencesListUpdated.emit()
return handler
if "unlocked" in buttons:
a = bar.addAction(icon_unlocked, "Toggle locked/unlocked")
qt.on(a.triggered)(lock_unlock_handler(True))
if "locked" in buttons:
a = bar.addAction(icon_locked, "Toggle locked/unlocked")
qt.on(a.triggered)(lock_unlock_handler(False))
view.setItemWidget(item, 1, bar)
selected_ids = []
if session.state.currentLayer.layer:
selected_ids = session.state.currentLayer.layer.paint_targets
current_id = None if not selected_ids else selected_ids[0]
with qt.signals_blocked(view):
tree_items.clear()
tree_root = view.invisibleRootItem()
item_index = 0
for item_id, displayName, icon, buttons in wanted_tree_items(
items=items,
include_dq_item=session.state.skin_cluster_dq_channel_used,
is_group_layer=is_group_layer,
layer=layer,
config=config,
filter=filter,
):
if item_index >= tree_root.childCount():
item = QtWidgets.QTreeWidgetItem([displayName])
else:
item = tree_root.child(item_index)
item.setText(0, displayName)
item.setData(0, id_role, item_id)
item.setIcon(0, icon)
item.setSizeHint(0, item_size_hint)
tree_root.addChild(item)
tree_items[item_id] = item
if item_id == current_id:
view.setCurrentItem(item, 0, QtCore.QItemSelectionModel.NoUpdate)
item.setSelected(item_id in selected_ids)
rebuild_buttons(item, item_id, buttons)
item_index += 1
while item_index < tree_root.childCount():
tree_root.removeChild(tree_root.child(item_index))
view = QtWidgets.QTreeWidget(parent)
view.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
view.setUniformRowHeights(True)
view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
view.setContextMenuPolicy(QtCore.Qt.ActionsContextMenu)
actions.addInfluencesActions(view)
view.addAction(actions.separator(parent, "View Options"))
view.addAction(actions.show_used_influences_only)
view.addAction(actions.set_influences_sorted)
view.setIndentation(10 * scale_multiplier)
view.header().setStretchLastSection(False)
view.header().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
view.setHeaderLabels(["Influences", ""])
view.header().setSectionResizeMode(1, QtWidgets.QHeaderView.Fixed)
view.setColumnWidth(1, 25 * scale_multiplier)
# view.setHeaderHidden(True)
def refresh_items():
items = list_influences(session.state.currentLayer.selectedSkinCluster)
def sort_func(a):
"""
:type a: InfluenceInfo
"""
return a.name
# items = sorted(items, key=sort_func)
build_items(view, items, session.state.currentLayer.layer)
@signal.on(
filter.changed,
config.influences_show_used_influences_only.changed,
config.influences_sort.changed,
session.events.influencesListUpdated,
)
def filter_changed():
refresh_items()
@signal.on(session.events.currentLayerChanged, qtParent=view)
def current_layer_changed():
if not session.state.currentLayer.layer:
build_items(view, [], None)
else:
log.info("current layer changed to %s", session.state.currentLayer.layer)
refresh_items()
current_influence_changed()
@signal.on(session.events.currentInfluenceChanged, qtParent=view)
def current_influence_changed():
if session.state.currentLayer.layer is None:
return
log.info("current influence changed - updating item selection")
with qt.signals_blocked(view):
targets = session.state.currentLayer.layer.paint_targets
first = True
for tree_item in tree_items.values():
selected = get_item_id(tree_item) in targets
if selected and first:
view.setCurrentItem(tree_item, 0, QtCore.QItemSelectionModel.NoUpdate)
first = False
tree_item.setSelected(selected)
@qt.on(view.currentItemChanged)
def current_item_changed(curr, prev):
if curr is None:
return
if session.state.selectedSkinCluster is None:
return
if not session.state.currentLayer.layer:
return
log.info("focused item changed: %r", get_item_id(curr))
sync_paint_targets_to_selection()
@qt.on(view.itemSelectionChanged)
def sync_paint_targets_to_selection():
log.info("syncing paint targets")
selected_ids = [get_item_id(item) for item in view.selectedItems()]
selected_ids = [i for i in selected_ids if i is not None]
current_item = view.currentItem()
if current_item and current_item.isSelected():
# move id of current item to front, if it's selected
item_id = get_item_id(current_item)
selected_ids.remove(item_id)
selected_ids = [item_id] + selected_ids
if session.state.currentLayer.layer:
session.state.currentLayer.layer.paint_targets = selected_ids
current_layer_changed()
return view
def get_icon(influence, is_joint):
if influence.used:
return icon_joint if is_joint else icon_transform
return icon_joint_disabled if is_joint else icon_transform_disabled
def wanted_tree_items(
layer,
config,
is_group_layer,
include_dq_item,
filter,
items,
):
"""
:type items: list[InfluenceInfo]
"""
if layer is None:
return
# calculate "used" regardless as we're displaying it visually even if "show used influences only" is toggled off
used = set((layer.get_used_influences() or []))
locked = set((layer.locked_influences or []))
for i in items:
i.used = i.logicalIndex in used
i.locked = i.logicalIndex in locked
if config.influences_show_used_influences_only() and layer is not None:
items = [i for i in items if i.used]
if is_group_layer:
items = []
yield "mask", "[Mask]", icon_mask, []
if not is_group_layer and include_dq_item:
yield "dq", "[DQ Weights]", icon_dq, []
names = influence_names.unique_names([i.path_name() for i in items])
for i, name in zip(items, names):
i.unique_name = name
if config.influences_sort() == Config.InfluencesSortDescending:
items = list(sorted(items, key=lambda i: i.unique_name))
for i in items:
is_joint = i.path is not None
if filter.is_match(i.path_name()):
yield i.logicalIndex, i.unique_name, get_icon(i, is_joint), ["locked" if i.locked else "unlocked"]

View File

@@ -0,0 +1,225 @@
from ngSkinTools2 import api, signal
from ngSkinTools2.api import python_compatibility
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.pyside import QtCore, QtWidgets
from ngSkinTools2.api.session import session
from ngSkinTools2.ui import qt
from ngSkinTools2.ui.layout import scale_multiplier
if python_compatibility.PY3:
from typing import Union
log = getLogger("layersView")
def build_view(parent, actions):
from ngSkinTools2.operations import layers
layer_icon_size = 20
visibility_icon_size = 13
icon_layer = qt.scaled_icon(":/layeredTexture.svg", layer_icon_size, layer_icon_size)
icon_layer_disabled = qt.scaled_icon(":/layerEditor.png", layer_icon_size, layer_icon_size)
icon_visible = qt.scaled_icon("eye-fill.svg", visibility_icon_size, visibility_icon_size)
icon_hidden = qt.scaled_icon("eye-slash-fill.svg", visibility_icon_size, visibility_icon_size)
layer_data_role = QtCore.Qt.UserRole + 1
def item_to_layer(item):
# type: (QtWidgets.QTreeWidgetItem) -> Union[api.Layer, None]
if item is None:
return None
return item.data(0, layer_data_role)
# noinspection PyShadowingNames
def sync_layer_parents_to_widget_items(view):
"""
after drag/drop tree reordering, just brute-force check
that rearranged items match layers parents
:return:
"""
def sync_item(tree_item, parent_layer_id):
for i in range(tree_item.childCount()):
child = tree_item.child(i)
rebuild_buttons(child)
child_layer = item_to_layer(child)
if child_layer.parent_id != parent_layer_id:
log.info("changing layer parent: %r->%r (was %r)", parent_layer_id, child_layer, child_layer.parent_id)
child_layer.parent = parent_layer_id
new_index = tree_item.childCount() - i - 1
if child_layer.index != new_index:
log.info("changing layer index: %r->%r (was %r)", child_layer, new_index, child_layer.index)
child_layer.index = new_index
sync_item(child, child_layer.id)
with qt.signals_blocked(view):
sync_item(view.invisibleRootItem(), None)
# noinspection PyPep8Naming
class LayersWidget(QtWidgets.QTreeWidget):
def dropEvent(self, event):
QtWidgets.QTreeWidget.dropEvent(self, event)
sync_layer_parents_to_widget_items(self)
view = LayersWidget(parent)
view.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows)
view.setUniformRowHeights(True)
view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
# enable drag/drop
view.setDragEnabled(True)
view.viewport().setAcceptDrops(True)
view.setDragDropMode(QtWidgets.QAbstractItemView.InternalMove)
view.setDropIndicatorShown(True)
# add context menu
view.setContextMenuPolicy(QtCore.Qt.ActionsContextMenu)
actions.addLayersActions(view)
view.setHeaderLabels(["Layers", ""])
# view.setHeaderHidden(True)
view.header().setMinimumSectionSize(1)
view.header().setStretchLastSection(False)
view.header().swapSections(0, 1)
view.header().setSectionResizeMode(0, QtWidgets.QHeaderView.Stretch)
view.header().setSectionResizeMode(1, QtWidgets.QHeaderView.Fixed)
view.setColumnWidth(1, 25 * scale_multiplier)
view.setIndentation(15 * scale_multiplier)
view.setIconSize(QtCore.QSize(layer_icon_size * scale_multiplier, layer_icon_size * scale_multiplier))
tree_items = {}
def rebuild_buttons(item):
layer = item_to_layer(item)
bar = QtWidgets.QToolBar(parent=parent)
bar.setMovable(False)
bar.setIconSize(QtCore.QSize(visibility_icon_size * scale_multiplier, visibility_icon_size * scale_multiplier))
a = bar.addAction(icon_visible if layer is None or layer.enabled else icon_hidden, "Toggle enabled/disabled")
@qt.on(a.triggered)
def handler():
layer.enabled = not layer.enabled
session.events.layerListChanged.emitIfChanged()
view.setItemWidget(item, 1, bar)
def build_items(layer_infos):
"""
sync items in view with provided layer values, trying to delete as little items on the view as possible
:type layer_infos: list[api.Layer]
"""
# build map "parent id->list of children "
log.info("syncing items...")
# save selected layers IDs to restore item selection later
selected_layer_ids = {item_to_layer(item).id for item in view.selectedItems()}
log.info("selected layer IDs: %r", selected_layer_ids)
current_item_id = None if view.currentItem() is None else item_to_layer(view.currentItem()).id
hierarchy = {}
for child in layer_infos:
if child.parent_id not in hierarchy:
hierarchy[child.parent_id] = []
hierarchy[child.parent_id].append(child)
def sync(parent_tree_item, children_list):
while parent_tree_item.childCount() > len(children_list):
parent_tree_item.removeChild(parent_tree_item.child(len(children_list)))
for index, child in enumerate(reversed(children_list)):
if index >= parent_tree_item.childCount():
item = QtWidgets.QTreeWidgetItem()
item.setSizeHint(1, QtCore.QSize(1 * scale_multiplier, 25 * scale_multiplier))
item.setFlags(item.flags() | QtCore.Qt.ItemIsEditable)
parent_tree_item.addChild(item)
else:
item = parent_tree_item.child(index)
tree_items[child.id] = item
item.setData(0, layer_data_role, child)
item.setText(0, child.name)
item.setIcon(0, icon_layer if child.enabled else icon_layer_disabled)
rebuild_buttons(item)
sync(item, hierarchy.get(child.id, []))
with qt.signals_blocked(view):
tree_items.clear()
sync(view.invisibleRootItem(), hierarchy.get(None, []))
current_item = tree_items.get(current_item_id, None)
if current_item is not None:
view.setCurrentItem(current_item, 0, QtCore.QItemSelectionModel.NoUpdate)
for i in selected_layer_ids:
item = tree_items.get(i, None)
if item is not None:
item.setSelected(True)
@signal.on(session.events.layerListChanged, qtParent=view)
def refresh_layer_list():
log.info("event handler for layer list changed")
if not session.state.layersAvailable:
build_items([])
else:
build_items(session.state.all_layers)
update_selected_items()
@signal.on(session.events.currentLayerChanged, qtParent=view)
def current_layer_changed():
log.info("event handler for currentLayerChanged")
layer = session.state.currentLayer.layer
current_item = view.currentItem()
if layer is None:
view.setCurrentItem(None)
return
prev_layer = None if current_item is None else item_to_layer(current_item)
if prev_layer is None or prev_layer.id != layer.id:
item = tree_items.get(layer.id, None)
if item is not None:
log.info("setting current item to " + item.text(0))
view.setCurrentItem(item, 0, QtCore.QItemSelectionModel.SelectCurrent | QtCore.QItemSelectionModel.ClearAndSelect)
item.setSelected(True)
@qt.on(view.currentItemChanged)
def current_item_changed(curr, _):
log.info("current item changed")
if curr is None:
return
selected_layer = item_to_layer(curr)
if layers.getCurrentLayer() == selected_layer:
return
layers.setCurrentLayer(selected_layer)
@qt.on(view.itemChanged)
def item_changed(item, column):
log.info("item changed")
layers.renameLayer(item_to_layer(item), item.text(column))
@qt.on(view.itemSelectionChanged)
def update_selected_items():
selection = [item_to_layer(item) for item in view.selectedItems()]
if selection != session.context.selected_layers(default=[]):
log.info("new selected layers: %r", selection)
session.context.selected_layers.set(selection)
refresh_layer_list()
return view

View File

@@ -0,0 +1,50 @@
from maya import cmds
from ngSkinTools2.api.pyside import QtCore, QtWidgets
from ngSkinTools2.api.python_compatibility import Object
from ngSkinTools2.ui import qt
try:
scale_multiplier = cmds.mayaDpiSetting(q=True, realScaleValue=True)
except:
# the command is not available on macos, using 1.0 for fallback
scale_multiplier = 1
def createTitledRow(title, contents, *additional_rows):
row = QtWidgets.QFormLayout()
row.setContentsMargins(0, 0, 0, 0)
label = QtWidgets.QLabel(title)
# label.setAlignment(QtCore.Qt.AlignRight |QtCore.Qt.)
label.setFixedWidth(100 * scale_multiplier)
if contents is None:
row.addRow(label, QtWidgets.QWidget())
return row
row.addRow(label, contents)
for i in additional_rows:
row.addRow(None, i)
return row
class TabSetup(Object):
def __init__(self):
self.innerLayout = innerLayout = QtWidgets.QVBoxLayout()
innerLayout.setContentsMargins(0, 0, 0, 0)
innerLayout.setSpacing(3 * scale_multiplier)
self.scrollArea = scrollArea = QtWidgets.QScrollArea()
scrollArea.setFocusPolicy(QtCore.Qt.NoFocus)
scrollArea.setFrameShape(QtWidgets.QFrame.NoFrame)
scrollArea.setWidget(qt.wrap_layout_into_widget(innerLayout))
scrollArea.setWidgetResizable(True)
self.lowerButtonsRow = lowerButtonsRow = QtWidgets.QHBoxLayout()
self.mainLayout = mainLayout = QtWidgets.QVBoxLayout()
mainLayout.addWidget(scrollArea)
mainLayout.addLayout(lowerButtonsRow)
mainLayout.setContentsMargins(7, 7, 7, 7)
self.tabContents = qt.wrap_layout_into_widget(mainLayout)

View File

@@ -0,0 +1,223 @@
from maya import OpenMayaUI as omui
from maya import cmds
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.pyside import QtCore, QtGui, QtWidgets, QWidget, wrap_instance
from ngSkinTools2.api.session import session
from ngSkinTools2.ui.options import config
from .. import cleanup, signal, version
from ..observableValue import ObservableValue
from . import (
aboutwindow,
dialogs,
hotkeys_setup,
qt,
tabLayerEffects,
tabMirror,
tabPaint,
tabSetWeights,
tabTools,
targetui,
updatewindow,
)
from .layout import scale_multiplier
log = getLogger("main window")
def get_image_path(file_name):
import os
for i in os.getenv("XBMLANGPATH", "").split(os.path.pathsep):
result = os.path.join(i, file_name)
if os.path.isfile(result):
return result
return file_name
def build_menu(parent, actions):
menu = QtWidgets.QMenuBar(parent=parent)
def top_level_menu(label):
sub_item = menu.addMenu(label)
sub_item.setSeparatorsCollapsible(False)
sub_item.setTearOffEnabled(True)
return sub_item
sub = top_level_menu("File")
sub.addSeparator().setText("Import/Export")
sub.addAction(actions.importFile)
sub.addAction(actions.exportFile)
sub = top_level_menu("Layers")
sub.addSeparator().setText("Layer actions")
sub.addAction(actions.initialize)
sub.addAction(actions.import_v1)
actions.addLayersActions(sub)
sub.addSeparator().setText("Copy")
sub.addAction(actions.transfer)
sub = top_level_menu("Tools")
sub.addAction(actions.add_influences)
sub.addAction(actions.toolsAssignFromClosestJoint)
sub.addSeparator()
sub.addAction(actions.transfer)
sub.addSeparator()
sub.addAction(actions.toolsDeleteCustomNodesOnSelection)
sub.addAction(actions.toolsDeleteCustomNodes)
sub = top_level_menu("View")
sub.addAction(actions.show_used_influences_only)
sub = top_level_menu("Help")
sub.addAction(actions.documentation.user_guide)
sub.addAction(actions.documentation.api_root)
sub.addAction(actions.documentation.changelog)
sub.addAction(actions.documentation.contact)
sub.addSeparator()
sub.addAction(actions.check_for_updates)
sub.addAction("About...").triggered.connect(lambda: aboutwindow.show(parent))
return menu
def build_rmb_menu_layers(view, actions):
view.setContextMenuPolicy(QtCore.Qt.ActionsContextMenu)
actions.addLayersActions(view)
class MainWindowOptions:
current_tab = ObservableValue(0)
def build_ui(parent):
"""
:type parent: QWidget
"""
options = MainWindowOptions()
window = QtWidgets.QWidget(parent)
session.addQtWidgetReference(window)
from ngSkinTools2.ui.actions import Actions
actions = Actions(parent=window, session=session)
tabs = QtWidgets.QTabWidget(window)
tabs.addTab(tabPaint.build_ui(tabs, actions), "Paint")
tabs.addTab(tabSetWeights.build_ui(tabs), "Set Weights")
tabs.addTab(tabMirror.build_ui(tabs), "Mirror")
tabs.addTab(tabLayerEffects.build_ui(), "Effects")
tabs.addTab(tabTools.build_ui(actions, session), "Tools")
@signal.on(options.current_tab.changed)
def set_current_tab():
tabs.setCurrentIndex(options.current_tab())
layers_toolbar = QtWidgets.QToolBar()
layers_toolbar.addAction(actions.addLayer)
layers_toolbar.setOrientation(QtCore.Qt.Vertical)
spacing_h = 5
spacing_v = 5
layers_row = targetui.build_target_ui(window, actions, session)
split = QtWidgets.QSplitter(orientation=QtCore.Qt.Vertical, parent=window)
split.addWidget(layers_row)
split.addWidget(tabs)
split.setStretchFactor(0, 2)
split.setStretchFactor(1, 3)
split.setContentsMargins(spacing_h, spacing_v, spacing_h, spacing_v)
def build_icon_label():
w = QWidget()
w.setStyleSheet("background-color: #dcce87;color: #373737;")
l = QtWidgets.QHBoxLayout()
icon = QtWidgets.QLabel()
icon.setPixmap(QtGui.QIcon(":/error.png").pixmap(16 * scale_multiplier, 16 * scale_multiplier))
icon.setFixedSize(16 * scale_multiplier, 16 * scale_multiplier)
text = QtWidgets.QLabel("<placeholder>")
text.setWordWrap(True)
text.setTextInteractionFlags(QtCore.Qt.TextSelectableByMouse)
l.addWidget(icon)
l.addWidget(text)
w.setContentsMargins(0, 0, 0, 0)
w.setLayout(l)
return w, text.setText
layout = QtWidgets.QVBoxLayout(window)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(build_menu(window, actions))
layout.addWidget(split)
window.setLayout(layout)
hotkeys_setup.install_hotkeys()
dialogs.promptsParent = window
if config.checkForUpdatesAtStartup():
updatewindow.silent_check_and_show_if_available(qt.mainWindow)
return window, options
DOCK_NAME = 'ngSkinTools2_mainWindow'
def workspace_control_permanent_script():
from ngSkinTools2 import workspace_control_main_window
return "import {f.__module__}; {f.__module__}.{f.__name__}()".format(f=workspace_control_main_window)
# noinspection PyShadowingBuiltins
def open():
"""
opens main window
"""
if not cmds.workspaceControl(DOCK_NAME, q=True, exists=True):
# build UI script in type-safe manner
cmds.workspaceControl(
DOCK_NAME,
retain=False,
floating=True,
# ttc=["AttributeEditor",-1],
uiScript=workspace_control_permanent_script(),
)
# bring tab to front
cmds.evalDeferred(lambda *args: cmds.workspaceControl(DOCK_NAME, e=True, r=True))
def close():
from maya import cmds
# noinspection PyBroadException
try:
cmds.deleteUI(DOCK_NAME)
except:
pass
pass
cleanup.registerCleanupHandler(close)
def resume_in_workspace_control():
"""
this method is responsible for resuming workspace control when Maya is building/restoring UI as part of it's
workspace management cycle (open UI for the first time, restart maya, change workspace, etc)
"""
cmds.workspaceControl(DOCK_NAME, e=True, label="ngSkinTools " + version.pluginVersion())
widget = wrap_instance(omui.MQtUtil.findControl(DOCK_NAME), QtWidgets.QWidget)
ui, _ = build_ui(widget)
widget.layout().addWidget(ui)

View File

@@ -0,0 +1,22 @@
from ngSkinTools2 import signal
from ngSkinTools2.api.pyside import QtWidgets
from ngSkinTools2.ui import qt, widgets
def bind(ui, model):
if isinstance(ui, QtWidgets.QCheckBox):
ui.setChecked(model())
@qt.on(ui.stateChanged)
def update_model():
model.set(ui.isChecked())
elif isinstance(ui, widgets.NumberSliderGroup):
ui.set_value(model())
@signal.on(ui.valueChanged)
def update_model():
model.set(ui.value())
else:
raise Exception("could not bind control to model")

View File

@@ -0,0 +1,201 @@
import json
from maya import cmds
from ngSkinTools2 import signal
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.python_compatibility import Object, is_string
from ngSkinTools2.observableValue import ObservableValue
log = getLogger("plugin")
class Value(Object):
def __init__(self, value=None):
self.value = value
def get(self):
return self.value
def set(self, value):
self.value = value
def getInt(self):
try:
return int(self.get())
except:
return 0
class PersistentValue(Value):
"""
persistent value can store itself into Maya's "option vars" array
"""
def __init__(self, name, default_value=None, prefix=None):
Value.__init__(self)
if prefix is None:
prefix = VAR_OPTION_PREFIX
self.name = prefix + name
self.default_value = default_value
self.value = load_option(self.name, self.default_value)
def set(self, value):
Value.set(self, value)
save_option(self.name, self.value)
class PersistentDict(Object):
def __init__(self, name, default_values=None):
if default_values is None:
default_values = {}
self.persistence = PersistentValue(name=name, default_value=json.dumps(default_values))
def __get_values(self):
# type: () -> dict
return json.loads(self.persistence.get())
def __getitem__(self, item):
return self.__get_values().get(item, None)
def __setitem__(self, key, value):
v = self.__get_values()
v[key] = value
self.persistence.set(json.dumps(v))
def load_option(var_name, default_value):
"""
loads value from optionVar
"""
from ngSkinTools2 import BATCH_MODE
if BATCH_MODE:
return default_value
if cmds.optionVar(exists=var_name):
return cmds.optionVar(q=var_name)
return default_value
def save_option(varName, value):
"""
saves option via optionVar
"""
from ngSkinTools2 import BATCH_MODE
if BATCH_MODE:
return
# variable does not exist, attempt to save it
key = None
if isinstance(value, float):
key = 'fv'
elif isinstance(value, int):
key = 'iv'
elif is_string(value):
key = 'sv'
else:
raise ValueError("could not save option %s: invalid value %r" % (varName, value))
kvargs = {key: (varName, value)}
log.info("saving optionvar: %r", kvargs)
cmds.optionVar(**kvargs)
VAR_OPTION_PREFIX = 'ngSkinTools2_'
def delete_custom_options():
for varName in cmds.optionVar(list=True):
if varName.startswith(VAR_OPTION_PREFIX):
cmds.optionVar(remove=varName)
cmds.windowPref('MirrorWeightsWindow', ra=True)
def build_config_property(name, default_value, doc=''):
return property(lambda self: self.__get_value__(name, default_value), lambda self, val: self.__set_value__(name, val), doc=doc)
class Config(Object):
"""
Maya-wide settings for ngSkinTools2
"""
mirrorInfluencesDefaults = build_config_property('mirrorInfluencesDefaults', "{}") # type: string
InfluencesSortUnsorted = 'unsorted'
InfluencesSortDescending = 'descending'
def __init__(self):
from ngSkinTools2.api.mirror import MirrorOptions
self.__storage__ = PersistentValue("config", "{}")
self.__state__ = self.load()
self.unique_client_id = PersistentValue('updateCheckUniqueClientId')
self.checkForUpdatesAtStartup = self.build_observable_value('checkForUpdatesAtStartup', True)
self.influences_show_used_influences_only = self.build_observable_value("influencesViewShowUsedInfluencesOnly", False)
# influences sort is not a simple "true/false" flag to allow different sorting methods in the future.
self.influences_sort = self.build_observable_value("influencesSort", Config.InfluencesSortUnsorted)
default_mirror_options = MirrorOptions()
self.mirror_direction = self.build_observable_value("mirrorDirection", default_mirror_options.direction)
self.mirror_dq = self.build_observable_value("mirrorDq", default_mirror_options.mirrorDq)
self.mirror_mask = self.build_observable_value("mirrorMask", default_mirror_options.mirrorMask)
self.mirror_weights = self.build_observable_value("mirrorWeights", default_mirror_options.mirrorWeights)
def __get_value__(self, name, default_value):
result = self.__state__.get(name, default_value)
log.info("config: return %s=%r", name, result)
return result
def __set_value__(self, name, value):
log.info("config: save %s=%r", name, value)
self.__state__[name] = value
self.save()
def build_observable_value(self, name, default_value):
"""
builds ObservableValue that is loaded and persisted into config when changed
:type name: str
:rtype: ngSkinTools2.observableValue.ObservableValue
"""
result = ObservableValue(self.__get_value__(name=name, default_value=default_value))
@signal.on(result.changed)
def save():
self.__set_value__(name, result())
return result
def load(self):
# noinspection PyBroadException
try:
return json.loads(self.__storage__.get())
except:
return {}
def save(self):
self.__storage__.set(json.dumps(self.__state__))
config = Config()
def bind_checkbox(cb, option):
from ngSkinTools2.ui import qt
cb.setChecked(option())
@qt.on(cb.toggled)
def update():
option.set(cb.isChecked())
return cb

View File

@@ -0,0 +1,24 @@
from maya import mel
def definePaintContextCallbacks():
"""
Maya expects some mel procedures to be present for paint context metadata
"""
mel.eval(
"""
global proc ngst2PaintContextProperties() {
setUITemplate -pushTemplate DefaultTemplate;
setUITemplate -popTemplate;
}
global proc ngst2PaintContextValues(string $toolName)
{
string $icon = "ngSkinToolsShelfIcon.png";
string $help = "ngSkinTools2 - paint skin weights";
toolPropertySetCommon $toolName $icon $help;
}
"""
)

View File

@@ -0,0 +1,39 @@
from threading import Thread
from maya import utils
from ngSkinTools2.api.python_compatibility import Object
class ParallelTask(Object):
def __init__(self):
self.__run_handlers = []
self.__done_handlers = []
def add_run_handler(self, handler):
self.__run_handlers.append(handler)
def add_done_handler(self, handler):
self.__done_handlers.append(handler)
def start(self, async_exec=True):
def done():
for i in self.__done_handlers:
i(self)
def thread():
for i in self.__run_handlers:
i(self)
if async_exec:
utils.executeDeferred(done)
else:
done()
self.current_thread = Thread(target=thread)
if async_exec:
self.current_thread.start()
else:
self.current_thread.run()
def wait(self):
self.current_thread.join()

View File

@@ -0,0 +1,128 @@
import os
from ngSkinTools2.api.pyside import QtCore, QtGui, QtWidgets, get_main_window
from ngSkinTools2.api.python_compatibility import Object
def wrap_layout_into_widget(layout):
w = QtWidgets.QWidget()
w.setLayout(layout)
return w
def signals_blocked(widget):
return SignalBlockContext(widget)
class SignalBlockContext(Object):
def __init__(self, widget):
self.widget = widget
def __enter__(self):
self.prevState = self.widget.blockSignals(True)
def __exit__(self, *args):
self.widget.blockSignals(self.prevState)
def on(*signals):
"""
decorator for function: list signals that should fire for this function.
instead of:
def something():
...
btn.clicked.connect(something)
do:
@qt.on(btn.clicked)
def something():
...
"""
def decorator(fn):
for i in signals:
i.connect(fn)
return fn
return decorator
class SingleWindowPolicy(Object):
def __init__(self):
self.lastWindow = None
def setCurrent(self, window):
if self.lastWindow:
self.lastWindow.close()
self.lastWindow = window
on(window.finished)(self.cleanup)
def cleanup(self):
self.lastWindow = None
def alternative_palette_light():
palette = QtGui.QPalette()
palette.setColor(QtGui.QPalette.Window, QtGui.QColor(243, 244, 246))
palette.setColor(QtGui.QPalette.WindowText, QtGui.QColor(33, 37, 41))
return palette
def bind_action_to_button(action, button):
"""
:type button: PySide2.QtWidgets.QPushButton
:type action: PySide2.QtWidgets.QAction
"""
@on(action.changed)
def update_state():
button.setText(action.text())
button.setEnabled(action.isEnabled())
button.setToolTip(action.toolTip())
button.setStatusTip(action.statusTip())
button.setVisible(action.isVisible())
if action.isCheckable():
button.setChecked(action.isChecked())
button.setCheckable(action.isCheckable())
on(button.clicked)(action.trigger)
update_state()
return button
images_path = os.path.join(os.path.dirname(__file__), "images")
def icon_path(path):
if path.startswith(':'):
return path
return os.path.join(images_path, path)
def scaled_icon(path, w, h):
from ngSkinTools2.ui.layout import scale_multiplier
return QtGui.QIcon(
QtGui.QPixmap(icon_path(path)).scaled(w * scale_multiplier, h * scale_multiplier, QtCore.Qt.KeepAspectRatio, QtCore.Qt.SmoothTransformation)
)
def image_icon(file_name):
return QtGui.QIcon(icon_path(file_name))
def select_data(combo, data):
"""
set combo box index to data index
"""
combo.setCurrentIndex(combo.findData(data))
mainWindow = get_main_window()

View File

@@ -0,0 +1,41 @@
from maya import cmds, mel
def install_shelf():
"""
checks if there's ngSkintTools shelf installed, and if not, creates one.
this runs each time Maya starts (via Autoloader's ngSkinTools_load.mel) - avoid duplication, like creating things
that already exist.
"""
# don't do anything if we're in batch mode. UI commands are not available
if cmds.about(batch=True) == 1:
return
maya_shelf = mel.eval("$tempngSkinTools2Var=$gShelfTopLevel")
existing_shelves = cmds.shelfTabLayout(maya_shelf, q=True, tabLabel=True)
parent_shelf = 'ngSkinTools2'
if parent_shelf in existing_shelves:
return
mel.eval('addNewShelfTab ' + parent_shelf)
cmds.shelfButton(
parent=parent_shelf,
enable=1,
visible=1,
preventOverride=0,
label="ngst",
annotation="opens ngSkinTools2 UI",
image="ngSkinTools2ShelfIcon.png",
style="iconOnly",
noBackground=1,
align="center",
marginWidth=1,
marginHeight=1,
command="import ngSkinTools2; ngSkinTools2.open_ui()",
sourceType="python",
commandRepeatable=0,
)

View File

@@ -0,0 +1,200 @@
from ngSkinTools2 import signal
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.mirror import MirrorOptions
from ngSkinTools2.api.pyside import QtCore, QtWidgets
from ngSkinTools2.api.session import session
from ngSkinTools2.ui import qt, widgets
from ngSkinTools2.ui.layout import TabSetup, createTitledRow
log = getLogger("tab layer effects")
def check_state_from_boolean_states(states):
"""
for a list of booleans, return checkbox check state - one of Qt.Checked, Qt.Unchecked and Qt.PartiallyChecked
:type states: list[bool]
"""
current_state = None
for i in states:
if current_state is None:
current_state = i
continue
if i != current_state:
return QtCore.Qt.PartiallyChecked
if current_state:
return QtCore.Qt.Checked
return QtCore.Qt.Unchecked
def build_ui():
def list_layers():
return [] if not session.state.layersAvailable else session.context.selected_layers(default=[])
def build_properties():
layout = QtWidgets.QVBoxLayout()
opacity = widgets.NumberSliderGroup(tooltip="multiply layer mask to control overall transparency of the layer.")
opacity.set_value(1.0)
layout.addLayout(createTitledRow("Opacity:", opacity.layout()))
def default_selection_opacity(layers):
if len(layers) > 0:
return layers[0].opacity
return 1.0
@signal.on(session.context.selected_layers.changed, session.events.currentLayerChanged, qtParent=tab.tabContents)
def update_values():
layers = list_layers()
enabled = len(layers) > 0
opacity.set_enabled(enabled)
opacity.set_value(default_selection_opacity(layers))
@signal.on(opacity.valueChanged)
def opacity_edited():
layers = list_layers()
# avoid changing opacity of all selected layers if we just changed slider value based on changed layer selection
if opacity.value() == default_selection_opacity(layers):
return
val = opacity.value()
for i in list_layers():
if abs(i.opacity - val) > 0.00001:
i.opacity = val
update_values()
group = QtWidgets.QGroupBox("Layer properties")
group.setLayout(layout)
return group
def build_mirror_effect():
def configure_mirror_all_layers(option, value):
for i in list_layers():
i.effects.configure_mirror(**{option: value})
mirror_direction = QtWidgets.QComboBox()
mirror_direction.addItem("Positive to negative", MirrorOptions.directionPositiveToNegative)
mirror_direction.addItem("Negative to positive", MirrorOptions.directionNegativeToPositive)
mirror_direction.addItem("Flip", MirrorOptions.directionFlip)
mirror_direction.setMinimumWidth(1)
@qt.on(mirror_direction.currentIndexChanged)
def value_changed():
configure_mirror_all_layers("mirror_direction", mirror_direction.currentData())
influences = QtWidgets.QCheckBox("Influence weights")
mask = QtWidgets.QCheckBox("Layer mask")
dq = QtWidgets.QCheckBox("Dual quaternion weights")
def configure_checkbox(checkbox, option):
@qt.on(checkbox.stateChanged)
def update_pref():
if checkbox.checkState() == QtCore.Qt.PartiallyChecked:
checkbox.setCheckState(QtCore.Qt.Checked)
enabled = checkbox.checkState() == QtCore.Qt.Checked
configure_mirror_all_layers(option, enabled)
configure_checkbox(influences, 'mirror_weights')
configure_checkbox(mask, 'mirror_mask')
configure_checkbox(dq, 'mirror_dq')
@signal.on(session.context.selected_layers.changed, session.events.currentLayerChanged, qtParent=tab.tabContents)
def update_values():
layers = list_layers()
with qt.signals_blocked(influences):
influences.setCheckState(check_state_from_boolean_states([i.effects.mirror_weights for i in layers]))
with qt.signals_blocked(mask):
mask.setCheckState(check_state_from_boolean_states([i.effects.mirror_mask for i in layers]))
with qt.signals_blocked(dq):
dq.setCheckState(check_state_from_boolean_states([i.effects.mirror_dq for i in layers]))
with qt.signals_blocked(mirror_direction):
qt.select_data(mirror_direction, MirrorOptions.directionPositiveToNegative if not layers else layers[0].effects.mirror_direction)
update_values()
def elements():
result = QtWidgets.QVBoxLayout()
for i in [influences, mask, dq]:
i.setTristate(True)
result.addWidget(i)
return result
layout = QtWidgets.QVBoxLayout()
layout.addLayout(createTitledRow("Mirror effect on:", elements()))
layout.addLayout(createTitledRow("Mirror direction:", mirror_direction))
group = QtWidgets.QGroupBox("Mirror")
group.setLayout(layout)
return group
def build_skin_properties():
use_max_influences = QtWidgets.QCheckBox("Limit max influences per vertex")
max_influences = widgets.NumberSliderGroup(min_value=1, max_value=5, tooltip="", value_type=int)
use_prune_weight = QtWidgets.QCheckBox("Prune small weights before writing to skin cluster")
prune_weight = widgets.NumberSliderGroup(decimals=6, min_value=0.000001, max_value=0.05, tooltip="")
prune_weight.set_value(prune_weight.min_value)
prune_weight.set_expo("start", 3)
@signal.on(session.events.targetChanged)
def update_ui():
group.setEnabled(session.state.layersAvailable)
if session.state.layersAvailable:
with qt.signals_blocked(use_max_influences):
use_max_influences.setChecked(session.state.layers.influence_limit_per_vertex != 0)
with qt.signals_blocked(max_influences):
max_influences.set_value(session.state.layers.influence_limit_per_vertex if use_max_influences.isChecked() else 4)
with qt.signals_blocked(use_prune_weight):
use_prune_weight.setChecked(session.state.layers.prune_weights_filter_threshold != 0)
with qt.signals_blocked(prune_weight):
prune_weight.set_value(session.state.layers.prune_weights_filter_threshold if use_prune_weight.isChecked() else 0.0001)
update_ui_enabled()
def update_ui_enabled():
max_influences.set_enabled(use_max_influences.isChecked())
prune_weight.set_enabled(use_prune_weight.isChecked())
@qt.on(use_max_influences.stateChanged, use_prune_weight.stateChanged)
@signal.on(max_influences.valueChanged, prune_weight.valueChanged)
def update_values():
log.info("updating effects tab")
if session.state.layersAvailable:
session.state.layers.influence_limit_per_vertex = max_influences.value() if use_max_influences.isChecked() else 0
session.state.layers.prune_weights_filter_threshold = 0 if not use_prune_weight.isChecked() else prune_weight.value_trimmed()
update_ui_enabled()
layout = QtWidgets.QVBoxLayout()
layout.addWidget(use_max_influences)
layout.addLayout(createTitledRow("Max influences:", max_influences.layout()))
layout.addWidget(use_prune_weight)
layout.addLayout(createTitledRow("Prune below:", prune_weight.layout()))
group = QtWidgets.QGroupBox("Skin Properties")
group.setLayout(layout)
update_ui()
return group
tab = TabSetup()
tab.innerLayout.addWidget(build_properties())
tab.innerLayout.addWidget(build_mirror_effect())
tab.innerLayout.addWidget(build_skin_properties())
tab.innerLayout.addStretch()
@signal.on(session.events.targetChanged, qtParent=tab.tabContents)
def update_tab_enabled():
tab.tabContents.setEnabled(session.state.layersAvailable)
update_tab_enabled()
return tab.tabContents

View File

@@ -0,0 +1,215 @@
from ngSkinTools2 import signal
from ngSkinTools2.api import Mirror, MirrorOptions, VertexTransferMode
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.mirror import set_reference_mesh_from_selection
from ngSkinTools2.api.pyside import QtWidgets
from ngSkinTools2.api.session import session
from ngSkinTools2.ui import qt
from ngSkinTools2.ui.layout import TabSetup, createTitledRow
from ngSkinTools2.ui.options import bind_checkbox, config
from ngSkinTools2.ui.widgets import NumberSliderGroup
log = getLogger("tab paint")
def build_ui(parent_window):
def build_mirroring_options_group():
def get_mirror_direction():
mirror_direction = QtWidgets.QComboBox()
mirror_direction.addItem("Guess from stroke", MirrorOptions.directionGuess)
mirror_direction.addItem("Positive to negative", MirrorOptions.directionPositiveToNegative)
mirror_direction.addItem("Negative to positive", MirrorOptions.directionNegativeToPositive)
mirror_direction.addItem("Flip", MirrorOptions.directionFlip)
mirror_direction.setMinimumWidth(1)
qt.select_data(mirror_direction, config.mirror_direction())
@qt.on(mirror_direction.currentIndexChanged)
def value_changed():
config.mirror_direction.set(mirror_direction.currentData())
return mirror_direction
def axis():
mirror_axis = QtWidgets.QComboBox()
mirror_axis.addItem("X", 'x')
mirror_axis.addItem("Y", 'y')
mirror_axis.addItem("Z", 'z')
@qt.on(mirror_axis.currentIndexChanged)
def value_changed():
session.state.mirror().axis = mirror_axis.currentData()
@signal.on(session.events.targetChanged)
def target_changed():
if session.state.layersAvailable:
qt.select_data(mirror_axis, session.state.mirror().axis)
target_changed()
return mirror_axis
def mirror_seam_width():
seam_width_ctrl = NumberSliderGroup(max_value=100)
@signal.on(seam_width_ctrl.valueChanged)
def value_changed():
session.state.mirror().seam_width = seam_width_ctrl.value()
@signal.on(session.events.targetChanged)
def update_values():
if session.state.layersAvailable:
seam_width_ctrl.set_value(session.state.mirror().seam_width)
update_values()
return seam_width_ctrl.layout()
def elements():
influences = bind_checkbox(QtWidgets.QCheckBox("Influence weights"), config.mirror_weights)
mask = bind_checkbox(QtWidgets.QCheckBox("Layer mask"), config.mirror_mask)
dq = bind_checkbox(QtWidgets.QCheckBox("Dual quaternion weights"), config.mirror_dq)
return influences, mask, dq
result = QtWidgets.QGroupBox("Mirroring options")
layout = QtWidgets.QVBoxLayout()
result.setLayout(layout)
layout.addLayout(createTitledRow("Axis:", axis()))
layout.addLayout(createTitledRow("Direction:", get_mirror_direction()))
layout.addLayout(createTitledRow("Seam width:", mirror_seam_width()))
layout.addLayout(createTitledRow("Elements to mirror:", *elements()))
return result
def vertex_mapping_group():
# noinspection PyShadowingNames
def mirror_mesh_group():
mesh_name_edit = QtWidgets.QLineEdit("mesh1")
mesh_name_edit.setReadOnly(True)
select_button = QtWidgets.QPushButton("Select")
create_button = QtWidgets.QPushButton("Create")
set_button = QtWidgets.QPushButton("Set")
set_button.setToolTip("Select symmetry mesh and a skinned target first")
layout = QtWidgets.QHBoxLayout()
layout.addWidget(mesh_name_edit)
layout.addWidget(create_button)
layout.addWidget(select_button)
layout.addWidget(set_button)
@signal.on(session.events.targetChanged, qtParent=tab.tabContents)
def update_ui():
if not session.state.layersAvailable:
return
mesh = Mirror(session.state.selectedSkinCluster).get_reference_mesh()
mesh_name_edit.setText(mesh or "")
def select_mesh(m):
if m is None:
return
from maya import cmds
cmds.setToolTo("moveSuperContext")
cmds.selectMode(component=True)
cmds.select(m + ".vtx[*]", r=True)
cmds.hilite(m, replace=True)
cmds.viewFit()
@qt.on(select_button.clicked)
def select_handler():
select_mesh(Mirror(session.state.selectedSkinCluster).get_reference_mesh())
@qt.on(create_button.clicked)
def create():
if not session.state.layersAvailable:
return
m = Mirror(session.state.selectedSkinCluster)
mesh = m.get_reference_mesh()
if mesh is None:
mesh = m.build_reference_mesh()
update_ui()
select_mesh(mesh)
@qt.on(set_button.clicked)
def set_clicked():
set_reference_mesh_from_selection()
update_ui()
update_ui()
return layout
vertex_mapping_mode = QtWidgets.QComboBox()
vertex_mapping_mode.addItem("Closest point on surface", VertexTransferMode.closestPoint)
vertex_mapping_mode.addItem("UV space", VertexTransferMode.uvSpace)
result = QtWidgets.QGroupBox("Vertex Mapping")
layout = QtWidgets.QVBoxLayout()
layout.addLayout(createTitledRow("Mapping mode:", vertex_mapping_mode))
layout.addLayout(createTitledRow("Symmetry mesh:", mirror_mesh_group()))
result.setLayout(layout)
@qt.on(vertex_mapping_mode.currentIndexChanged)
def value_changed():
session.state.mirror().vertex_transfer_mode = vertex_mapping_mode.currentData()
@signal.on(session.events.targetChanged)
def target_changed():
if session.state.layersAvailable:
qt.select_data(vertex_mapping_mode, session.state.mirror().vertex_transfer_mode)
return result
def influence_mapping_group():
def edit_mapping():
mapping = QtWidgets.QPushButton("Preview and edit mapping")
single_window_policy = qt.SingleWindowPolicy()
@qt.on(mapping.clicked)
def edit():
from ngSkinTools2.ui import influenceMappingUI
window = influenceMappingUI.open_ui_for_mesh(parent_window, session.state.selectedSkinCluster)
single_window_policy.setCurrent(window)
return mapping
layout = QtWidgets.QVBoxLayout()
layout.addWidget(edit_mapping())
result = QtWidgets.QGroupBox("Influences mapping")
result.setLayout(layout)
return result
tab = TabSetup()
tab.innerLayout.addWidget(build_mirroring_options_group())
tab.innerLayout.addWidget(vertex_mapping_group())
tab.innerLayout.addWidget(influence_mapping_group())
tab.innerLayout.addStretch()
btn_mirror = QtWidgets.QPushButton("Mirror")
tab.lowerButtonsRow.addWidget(btn_mirror)
@qt.on(btn_mirror.clicked)
def mirror_clicked():
if session.state.currentLayer.layer:
mirror_options = MirrorOptions()
mirror_options.direction = config.mirror_direction()
mirror_options.mirrorDq = config.mirror_dq()
mirror_options.mirrorMask = config.mirror_mask()
mirror_options.mirrorWeights = config.mirror_weights()
session.state.mirror().mirror(mirror_options)
@signal.on(session.events.targetChanged, qtParent=tab.tabContents)
def update_ui():
tab.tabContents.setEnabled(session.state.layersAvailable)
update_ui()
return tab.tabContents

View File

@@ -0,0 +1,428 @@
from ngSkinTools2 import signal
from ngSkinTools2.api import BrushShape, PaintMode, PaintTool, WeightsDisplayMode
from ngSkinTools2.api import eventtypes as et
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.paint import BrushProjectionMode, MaskDisplayMode
from ngSkinTools2.api.pyside import QAction, QActionGroup, QtGui, QtWidgets
from ngSkinTools2.api.session import session
from ngSkinTools2.ui import qt, widgets
from ngSkinTools2.ui.layout import TabSetup, createTitledRow
from ngSkinTools2.ui.qt import bind_action_to_button
log = getLogger("tab paint")
# noinspection PyShadowingNames
def build_ui(parent, global_actions):
"""
:type parent: PySide2.QtWidgets.QWidget
:type global_actions: ngSkinTools2.ui.actions.Actions
"""
paint = session.paint_tool
# TODO: move paint model to session maybe?
on_signal = session.signal_hub.on
def update_ui():
pass # noop until it's defined
def build_brush_settings_group():
def brush_mode_row3():
row = QtWidgets.QVBoxLayout()
group = QActionGroup(parent)
actions = {}
# noinspection PyShadowingNames
def create_brush_mode_button(t, mode, label, tooltip):
a = QAction(label, parent)
a.setToolTip(tooltip)
a.setCheckable(True)
actions[mode] = a
group.addAction(a)
@qt.on(a.toggled)
def toggled(checked):
if checked and paint.paint_mode != mode:
paint.paint_mode = mode
t.addAction(a)
t = QtWidgets.QToolBar()
create_brush_mode_button(t, PaintMode.replace, "Replace", "Whatever")
create_brush_mode_button(t, PaintMode.add, "Add", "")
create_brush_mode_button(t, PaintMode.scale, "Scale", "")
row.addWidget(t)
t = QtWidgets.QToolBar()
create_brush_mode_button(t, PaintMode.smooth, "Smooth", "")
create_brush_mode_button(t, PaintMode.sharpen, "Sharpen", "")
row.addWidget(t)
@on_signal(et.tool_settings_changed, scope=row)
def update_current_brush_mode():
actions[paint.paint_mode].setChecked(True)
update_current_brush_mode()
return row
# noinspection DuplicatedCode
def brush_shape_row():
# noinspection PyShadowingNames
result = QtWidgets.QToolBar()
group = QActionGroup(parent)
def add_brush_shape_action(icon, title, shape, checked=False):
a = QAction(title, parent)
a.setCheckable(True)
a.setIcon(QtGui.QIcon(icon))
a.setChecked(checked)
result.addAction(a)
group.addAction(a)
# noinspection PyShadowingNames
def toggled(checked):
if checked:
paint.brush_shape = shape
update_ui()
# noinspection PyShadowingNames
@on_signal(et.tool_settings_changed, scope=a)
def update_to_tool():
a.setChecked(paint.brush_shape == shape)
update_to_tool()
qt.on(a.toggled)(toggled)
add_brush_shape_action(':/circleSolid.png', 'Solid', BrushShape.solid, checked=True)
add_brush_shape_action(':/circlePoly.png', 'Smooth', BrushShape.smooth)
add_brush_shape_action(':/circleGaus.png', 'Gaus', BrushShape.gaus)
return result
# noinspection DuplicatedCode
def brush_projection_mode_row():
# noinspection PyShadowingNames
result = QtWidgets.QToolBar()
group = QActionGroup(parent)
def add(title, tooltip, mode, use_volume, checked):
a = QAction(title, parent)
a.setCheckable(True)
a.setChecked(checked)
a.setToolTip(tooltip)
a.setStatusTip(tooltip)
result.addAction(a)
group.addAction(a)
# noinspection PyShadowingNames
@qt.on(a.toggled)
def toggled(checked):
if checked:
paint.brush_projection_mode = mode
paint.use_volume_neighbours = use_volume
update_ui()
# noinspection PyShadowingNames
@on_signal(et.tool_settings_changed, scope=a)
def update_to_tool():
a.setChecked(
paint.brush_projection_mode == mode and (mode != BrushProjectionMode.surface or paint.use_volume_neighbours == use_volume)
)
with qt.signals_blocked(a):
update_to_tool()
add(
'Surface',
'Using first surface hit under the mouse, update all nearby vertices that are connected by surface to the hit location. '
+ 'Only current shell will be updated.',
BrushProjectionMode.surface,
use_volume=False,
checked=True,
)
add(
'Volume',
'Using first surface hit under the mouse, update all nearby vertices, including those from other shells.',
BrushProjectionMode.surface,
use_volume=True,
checked=False,
)
add(
'Screen',
'Use screen projection of a brush, updating all vertices on all surfaces that are within the brush radius.',
BrushProjectionMode.screen,
use_volume=False,
checked=False,
)
return result
def stylus_pressure_selection():
# noinspection PyShadowingNames
result = QtWidgets.QComboBox()
result.addItem("Unused")
result.addItem("Multiply intensity")
result.addItem("Multiply opacity")
result.addItem("Multiply radius")
return result
layout = QtWidgets.QVBoxLayout()
layout.addLayout(createTitledRow("Brush projection:", brush_projection_mode_row()))
layout.addLayout(createTitledRow("Brush mode:", brush_mode_row3()))
layout.addLayout(createTitledRow("Brush shape:", brush_shape_row()))
intensity = widgets.NumberSliderGroup()
radius = widgets.NumberSliderGroup(
max_value=100, tooltip="You can also set brush radius by just holding <b>B</b> " "and mouse-dragging in the viewport"
)
iterations = widgets.NumberSliderGroup(value_type=int, min_value=1, max_value=100)
layout.addLayout(createTitledRow("Intensity:", intensity.layout()))
layout.addLayout(createTitledRow("Brush radius:", radius.layout()))
layout.addLayout(createTitledRow("Brush iterations:", iterations.layout()))
influences_limit = widgets.NumberSliderGroup(value_type=int, min_value=0, max_value=10)
layout.addLayout(createTitledRow("Influences limit:", influences_limit.layout()))
@signal.on(influences_limit.valueChanged)
def influences_limit_changed():
paint.influences_limit = influences_limit.value()
update_ui()
fixed_influences = QtWidgets.QCheckBox("Only adjust existing vertex influences")
fixed_influences.setToolTip(
"When this option is enabled, smooth will only adjust existing influences per vertex, "
"and won't include other influences from nearby vertices"
)
layout.addLayout(createTitledRow("Weight bleeding:", fixed_influences))
@qt.on(fixed_influences.stateChanged)
def fixed_influences_changed():
paint.fixed_influences_per_vertex = fixed_influences.isChecked()
limit_to_component_selection = QtWidgets.QCheckBox("Limit to component selection")
limit_to_component_selection.setToolTip("When this option is enabled, smoothing will only happen between selected components")
layout.addLayout(createTitledRow("Isolation:", limit_to_component_selection))
@qt.on(limit_to_component_selection.stateChanged)
def limit_to_component_selection_changed():
paint.limit_to_component_selection = limit_to_component_selection.isChecked()
interactive_mirror = QtWidgets.QCheckBox("Interactive mirror")
layout.addLayout(createTitledRow("", interactive_mirror))
@qt.on(interactive_mirror.stateChanged)
def interactive_mirror_changed():
paint.mirror = interactive_mirror.isChecked()
update_ui()
sample_joint_on_stroke_start = QtWidgets.QCheckBox("Sample current joint on stroke start")
layout.addLayout(createTitledRow("", sample_joint_on_stroke_start))
@qt.on(sample_joint_on_stroke_start.stateChanged)
def interactive_mirror_changed():
paint.sample_joint_on_stroke_start = sample_joint_on_stroke_start.isChecked()
update_ui()
redistribute_removed_weight = QtWidgets.QCheckBox("Distribute to other influences")
layout.addLayout(createTitledRow("Removed weight:", redistribute_removed_weight))
@qt.on(redistribute_removed_weight.stateChanged)
def redistribute_removed_weight_changed():
paint.distribute_to_other_influences = redistribute_removed_weight.isChecked()
update_ui()
stylus = stylus_pressure_selection()
layout.addLayout(createTitledRow("Stylus pressure:", stylus))
@on_signal(et.tool_settings_changed, scope=layout)
def update_ui():
log.info("updating paint settings ui")
log.info("brush mode:%s, brush shape: %s", paint.mode, paint.brush_shape)
paint.update_plugin_brush_radius()
paint.update_plugin_brush_intensity()
with qt.signals_blocked(intensity):
intensity.set_value(paint.intensity)
widgets.set_paint_expo(intensity, paint.paint_mode)
with qt.signals_blocked(radius):
radius.set_range(0, 1000 if paint.brush_projection_mode == BrushProjectionMode.screen else 100, soft_max=True)
radius.set_value(paint.brush_radius)
with qt.signals_blocked(iterations):
iterations.set_value(paint.iterations)
iterations.set_enabled(paint.paint_mode in [PaintMode.smooth, PaintMode.sharpen])
with qt.signals_blocked(stylus):
stylus.setCurrentIndex(paint.tablet_mode)
with qt.signals_blocked(interactive_mirror):
interactive_mirror.setChecked(paint.mirror)
with qt.signals_blocked(redistribute_removed_weight):
redistribute_removed_weight.setChecked(paint.distribute_to_other_influences)
with qt.signals_blocked(influences_limit):
influences_limit.set_value(paint.influences_limit)
with qt.signals_blocked(sample_joint_on_stroke_start):
sample_joint_on_stroke_start.setChecked(paint.sample_joint_on_stroke_start)
with qt.signals_blocked(fixed_influences):
fixed_influences.setChecked(paint.fixed_influences_per_vertex)
fixed_influences.setEnabled(paint.paint_mode == PaintMode.smooth)
with qt.signals_blocked(limit_to_component_selection):
limit_to_component_selection.setChecked(paint.limit_to_component_selection)
limit_to_component_selection.setEnabled(fixed_influences.isEnabled())
@signal.on(radius.valueChanged, qtParent=layout)
def radius_edited():
log.info("updated brush radius")
paint.brush_radius = radius.value()
update_ui()
@signal.on(intensity.valueChanged, qtParent=layout)
def intensity_edited():
paint.intensity = intensity.value()
update_ui()
@signal.on(iterations.valueChanged, qtParent=layout)
def iterations_edited():
paint.iterations = iterations.value()
update_ui()
@qt.on(stylus.currentIndexChanged)
def stylus_edited():
paint.tablet_mode = stylus.currentIndex()
update_ui()
update_ui()
result = QtWidgets.QGroupBox("Brush behavior")
result.setLayout(layout)
return result
def build_display_settings():
result = QtWidgets.QGroupBox("Display settings")
layout = QtWidgets.QVBoxLayout()
influences_display = QtWidgets.QComboBox()
influences_display.addItem("All influences, multiple colors", WeightsDisplayMode.allInfluences)
influences_display.addItem("Current influence, grayscale", WeightsDisplayMode.currentInfluence)
influences_display.addItem("Current influence, colored", WeightsDisplayMode.currentInfluenceColored)
influences_display.setMinimumWidth(1)
influences_display.setCurrentIndex(paint.display_settings.weights_display_mode)
display_toolbar = QtWidgets.QToolBar()
display_toolbar.addAction(global_actions.randomize_influences_colors)
@qt.on(influences_display.currentIndexChanged)
def influences_display_changed():
paint.display_settings.weights_display_mode = influences_display.currentData()
update_ui_to_tool()
display_layout = QtWidgets.QVBoxLayout()
display_layout.addWidget(influences_display)
display_layout.addWidget(display_toolbar)
layout.addLayout(createTitledRow("Influences display:", display_layout))
mask_display = QtWidgets.QComboBox()
mask_display.addItem("Default", MaskDisplayMode.default_)
mask_display.addItem("Color ramp", MaskDisplayMode.color_ramp)
mask_display.setMinimumWidth(1)
mask_display.setCurrentIndex(paint.display_settings.weights_display_mode)
@qt.on(mask_display.currentIndexChanged)
def influences_display_changed():
paint.display_settings.mask_display_mode = mask_display.currentData()
update_ui_to_tool()
layout.addLayout(createTitledRow("Mask display:", mask_display))
show_effects = QtWidgets.QCheckBox("Show layer effects")
layout.addLayout(createTitledRow("", show_effects))
show_masked = QtWidgets.QCheckBox("Show masked weights")
layout.addLayout(createTitledRow("", show_masked))
show_selected_verts_only = QtWidgets.QCheckBox("Hide unselected vertices")
layout.addLayout(createTitledRow("", show_selected_verts_only))
@qt.on(show_effects.stateChanged)
def show_effects_changed():
paint.display_settings.layer_effects_display = show_effects.isChecked()
@qt.on(show_masked.stateChanged)
def show_masked_changed():
paint.display_settings.display_masked = show_masked.isChecked()
@qt.on(show_selected_verts_only.stateChanged)
def show_selected_verts_changed():
paint.display_settings.show_selected_verts_only = show_selected_verts_only.isChecked()
mesh_toolbar = QtWidgets.QToolBar()
toggle_original_mesh = QAction("Show Original Mesh", mesh_toolbar)
toggle_original_mesh.setCheckable(True)
mesh_toolbar.addAction(toggle_original_mesh)
layout.addLayout(createTitledRow("", mesh_toolbar))
@qt.on(toggle_original_mesh.triggered)
def toggle_display_node_visible():
paint.display_settings.display_node_visible = not toggle_original_mesh.isChecked()
update_ui_to_tool()
wireframe_color_button = widgets.ColorButton()
layout.addLayout(createTitledRow("Wireframe color:", wireframe_color_button))
@signal.on(wireframe_color_button.color_changed)
def update_wireframe_color():
if paint.display_settings.weights_display_mode == WeightsDisplayMode.allInfluences:
paint.display_settings.wireframe_color = wireframe_color_button.get_color_3f()
else:
paint.display_settings.wireframe_color_single_influence = wireframe_color_button.get_color_3f()
@signal.on(session.events.toolChanged, qtParent=tab.tabContents)
def update_ui_to_tool():
ds = paint.display_settings
toggle_original_mesh.setChecked(PaintTool.is_painting() and not ds.display_node_visible)
qt.select_data(influences_display, ds.weights_display_mode)
qt.select_data(mask_display, ds.mask_display_mode)
show_effects.setChecked(ds.layer_effects_display)
show_masked.setChecked(ds.display_masked)
show_selected_verts_only.setChecked(ds.show_selected_verts_only)
global_actions.randomize_influences_colors.setEnabled(ds.weights_display_mode == WeightsDisplayMode.allInfluences)
display_toolbar.setVisible(global_actions.randomize_influences_colors.isEnabled())
if ds.weights_display_mode == WeightsDisplayMode.allInfluences:
wireframe_color_button.set_color(ds.wireframe_color)
else:
wireframe_color_button.set_color(ds.wireframe_color_single_influence)
update_ui_to_tool()
result.setLayout(layout)
return result
tab = TabSetup()
tab.innerLayout.addWidget(build_brush_settings_group())
tab.innerLayout.addWidget(build_display_settings())
tab.innerLayout.addStretch()
tab.lowerButtonsRow.addWidget(bind_action_to_button(global_actions.paint, QtWidgets.QPushButton()))
tab.lowerButtonsRow.addWidget(bind_action_to_button(global_actions.flood, QtWidgets.QPushButton()))
@signal.on(session.events.toolChanged, qtParent=tab.tabContents)
def update_to_tool():
tab.scrollArea.setEnabled(PaintTool.is_painting())
@signal.on(session.events.targetChanged, qtParent=tab.tabContents)
def update_tab_enabled():
tab.tabContents.setEnabled(session.state.layersAvailable)
update_to_tool()
update_tab_enabled()
return tab.tabContents

View File

@@ -0,0 +1,228 @@
from ngSkinTools2 import signal
from ngSkinTools2.api import PaintMode, PaintModeSettings, flood_weights
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.pyside import QAction, QActionGroup, QtWidgets
from ngSkinTools2.api.python_compatibility import Object
from ngSkinTools2.api.session import session
from ngSkinTools2.signal import Signal
from ngSkinTools2.ui import qt, widgets
from ngSkinTools2.ui.layout import TabSetup, createTitledRow
from ngSkinTools2.ui.ui_lock import UiLock
log = getLogger("tab set weights")
def make_presets():
presets = {m: PaintModeSettings() for m in PaintMode.all()}
for k, v in presets.items():
v.mode = k
presets[PaintMode.smooth].intensity = 0.3
presets[PaintMode.scale].intensity = 0.3
presets[PaintMode.add].intensity = 0.1
presets[PaintMode.scale].intensity = 0.95
return presets
class Model(Object):
def __init__(self):
self.mode_changed = Signal("mode changed")
self.presets = make_presets()
self.current_settings = None
self.set_mode(PaintMode.replace)
def set_mode(self, mode):
self.current_settings = self.presets[mode]
self.mode_changed.emit()
def apply(self):
flood_weights(session.state.currentLayer.layer, influences=session.state.currentLayer.layer.paint_targets, settings=self.current_settings)
def build_ui(parent):
model = Model()
ui_lock = UiLock()
def build_mode_settings_group():
def mode_row():
row = QtWidgets.QVBoxLayout()
group = QActionGroup(parent)
actions = {}
def create_mode_button(toolbar, mode, label, tooltip):
a = QAction(label, parent)
a.setToolTip(tooltip)
a.setStatusTip(tooltip)
a.setCheckable(True)
actions[mode] = a
group.addAction(a)
@qt.on(a.toggled)
@ui_lock.skip_if_updating
def toggled(checked):
if checked:
model.set_mode(mode)
update_ui()
toolbar.addAction(a)
t = QtWidgets.QToolBar()
create_mode_button(t, PaintMode.replace, "Replace", "")
create_mode_button(t, PaintMode.add, "Add", "")
create_mode_button(t, PaintMode.scale, "Scale", "")
row.addWidget(t)
t = QtWidgets.QToolBar()
create_mode_button(t, PaintMode.smooth, "Smooth", "")
create_mode_button(t, PaintMode.sharpen, "Sharpen", "")
row.addWidget(t)
actions[model.current_settings.mode].setChecked(True)
return row
influences_limit = widgets.NumberSliderGroup(value_type=int, min_value=0, max_value=10)
@signal.on(influences_limit.valueChanged)
@ui_lock.skip_if_updating
def influences_limit_changed():
for _, v in model.presets.items():
v.influences_limit = influences_limit.value()
update_ui()
intensity = widgets.NumberSliderGroup()
@signal.on(intensity.valueChanged, qtParent=parent)
@ui_lock.skip_if_updating
def intensity_edited():
model.current_settings.intensity = intensity.value()
update_ui()
iterations = widgets.NumberSliderGroup(value_type=int, min_value=1, max_value=100)
@signal.on(iterations.valueChanged, qtParent=parent)
@ui_lock.skip_if_updating
def iterations_edited():
model.current_settings.iterations = iterations.value()
update_ui()
fixed_influences = QtWidgets.QCheckBox("Only adjust existing vertex influences")
fixed_influences.setToolTip(
"When this option is enabled, smooth will only adjust existing influences per vertex, "
"and won't include other influences from nearby vertices"
)
volume_neighbours = QtWidgets.QCheckBox("Smooth across gaps and thin surfaces")
volume_neighbours.setToolTip(
"Use all nearby neighbours, regardless if they belong to same surface. "
"This will allow for smoothing to happen across gaps and thin surfaces."
)
limit_to_component_selection = QtWidgets.QCheckBox("Limit to component selection")
limit_to_component_selection.setToolTip("When this option is enabled, smoothing will only happen between selected components")
@qt.on(fixed_influences.stateChanged)
@ui_lock.skip_if_updating
def fixed_influences_changed(*_):
model.current_settings.fixed_influences_per_vertex = fixed_influences.isChecked()
@qt.on(limit_to_component_selection.stateChanged)
@ui_lock.skip_if_updating
def limit_to_component_selection_changed(*_):
model.current_settings.limit_to_component_selection = limit_to_component_selection.isChecked()
def update_ui():
with ui_lock:
widgets.set_paint_expo(intensity, model.current_settings.mode)
intensity.set_value(model.current_settings.intensity)
iterations.set_value(model.current_settings.iterations)
iterations.set_enabled(model.current_settings.mode in [PaintMode.smooth, PaintMode.sharpen])
fixed_influences.setEnabled(model.current_settings.mode in [PaintMode.smooth])
fixed_influences.setChecked(model.current_settings.fixed_influences_per_vertex)
limit_to_component_selection.setChecked(model.current_settings.limit_to_component_selection)
limit_to_component_selection.setEnabled(fixed_influences.isEnabled())
influences_limit.set_value(model.current_settings.influences_limit)
volume_neighbours.setChecked(model.current_settings.use_volume_neighbours)
volume_neighbours.setEnabled(model.current_settings.mode == PaintMode.smooth)
settings_group = QtWidgets.QGroupBox("Mode Settings")
layout = QtWidgets.QVBoxLayout()
layout.addLayout(createTitledRow("Mode:", mode_row()))
layout.addLayout(createTitledRow("Intensity:", intensity.layout()))
layout.addLayout(createTitledRow("Iterations:", iterations.layout()))
layout.addLayout(createTitledRow("Influences limit:", influences_limit.layout()))
layout.addLayout(createTitledRow("Weight bleeding:", fixed_influences))
layout.addLayout(createTitledRow("Volume smoothing:", volume_neighbours))
layout.addLayout(createTitledRow("Isolation:", limit_to_component_selection))
settings_group.setLayout(layout)
update_ui()
return settings_group
def common_settings():
layout = QtWidgets.QVBoxLayout()
mirror = QtWidgets.QCheckBox("Mirror")
layout.addLayout(createTitledRow("", mirror))
@qt.on(mirror.stateChanged)
@ui_lock.skip_if_updating
def mirror_changed(*_):
for _, v in model.presets.items():
v.mirror = mirror.isChecked()
redistribute_removed_weight = QtWidgets.QCheckBox("Distribute to other influences")
layout.addLayout(createTitledRow("Removed weight:", redistribute_removed_weight))
@qt.on(redistribute_removed_weight.stateChanged)
def redistribute_removed_weight_changed():
for _, v in model.presets.items():
v.distribute_to_other_influences = redistribute_removed_weight.isChecked()
@signal.on(model.mode_changed, qtParent=layout)
def update_ui():
mirror.setChecked(model.current_settings.mirror)
redistribute_removed_weight.setChecked(model.current_settings.distribute_to_other_influences)
group = QtWidgets.QGroupBox("Common Settings")
group.setLayout(layout)
update_ui()
return group
def apply_button():
btn = QtWidgets.QPushButton("Apply")
btn.setToolTip("Apply selected operation to vertex")
@qt.on(btn.clicked)
def clicked():
model.apply()
return btn
tab = TabSetup()
tab.innerLayout.addWidget(build_mode_settings_group())
tab.innerLayout.addWidget(common_settings())
tab.innerLayout.addStretch()
tab.lowerButtonsRow.addWidget(apply_button())
@signal.on(session.events.targetChanged, qtParent=tab.tabContents)
def update_tab_enabled():
tab.tabContents.setEnabled(session.state.layersAvailable)
update_tab_enabled()
return tab.tabContents

View File

@@ -0,0 +1,117 @@
from ngSkinTools2 import signal
from ngSkinTools2.api.pyside import QtWidgets
from ngSkinTools2.api.session import Session
from ngSkinTools2.ui import model_binds, qt, widgets
from ngSkinTools2.ui.actions import Actions
from ngSkinTools2.ui.layout import TabSetup, createTitledRow
def build_ui(actions, session):
"""
:type actions: Actions
:type session: Session
"""
def assign_weights_from_closest_joint_group():
options = actions.toolsAssignFromClosestJointOptions
# noinspection PyShadowingNames
def influences_options():
result = QtWidgets.QVBoxLayout()
button_group = QtWidgets.QButtonGroup()
for index, i in enumerate(["Use all available influences", "Use selected influences"]):
radio = QtWidgets.QRadioButton(i)
button_group.addButton(radio, index)
result.addWidget(radio)
@qt.on(radio.toggled)
def update_value():
options.all_influences.set(button_group.buttons()[0].isChecked())
# noinspection PyShadowingNames
@signal.on(options.all_influences.changed, qtParent=result)
def update_ui():
button_group.buttons()[0 if options.all_influences() else 1].setChecked(True)
update_ui()
return result
new_layer = QtWidgets.QCheckBox("Create new layer")
@qt.on(new_layer.toggled)
def update_new_layer():
options.create_new_layer.set(new_layer.isChecked())
@signal.on(options.create_new_layer.changed)
def update_ui():
new_layer.setChecked(options.create_new_layer())
btn = QtWidgets.QPushButton()
qt.bind_action_to_button(actions.toolsAssignFromClosestJoint, btn)
update_ui()
result = QtWidgets.QGroupBox("Assign weights from closest joint")
layout = QtWidgets.QVBoxLayout()
result.setLayout(layout)
layout.addLayout(createTitledRow("Target layer", new_layer))
layout.addLayout(createTitledRow("Influences", influences_options()))
layout.addWidget(btn)
return result
def unify_weights_group():
options = actions.toolsUnifyWeightsOptions
intensity = widgets.NumberSliderGroup()
model_binds.bind(intensity, options.overall_effect)
single_cluster_mode = QtWidgets.QCheckBox(
"Single group mode",
)
single_cluster_mode.setToolTip("average weights across whole selection, ignoring separate shells or selection gaps")
model_binds.bind(single_cluster_mode, options.single_cluster_mode)
btn = QtWidgets.QPushButton()
qt.bind_action_to_button(actions.toolsUnifyWeights, btn)
result = QtWidgets.QGroupBox("Unify weights")
layout = QtWidgets.QVBoxLayout()
result.setLayout(layout)
layout.addLayout(createTitledRow("Intensity:", intensity.layout()))
layout.addLayout(createTitledRow("Clustering:", single_cluster_mode))
layout.addWidget(btn)
return result
def other_tools_group():
result = QtWidgets.QGroupBox("Other")
layout = QtWidgets.QVBoxLayout()
result.setLayout(layout)
layout.addWidget(to_button(actions.fill_layer_transparency))
layout.addWidget(to_button(actions.copy_components))
layout.addWidget(to_button(actions.paste_component_average))
return result
tab = TabSetup()
tab.innerLayout.addWidget(assign_weights_from_closest_joint_group())
tab.innerLayout.addWidget(unify_weights_group())
tab.innerLayout.addWidget(other_tools_group())
tab.innerLayout.addStretch()
@signal.on(session.events.targetChanged, qtParent=tab.tabContents)
def update_tab_enabled():
tab.tabContents.setEnabled(session.state.layersAvailable)
update_tab_enabled()
return tab.tabContents
def to_button(action):
btn = QtWidgets.QPushButton()
qt.bind_action_to_button(action, btn)
return btn

View File

@@ -0,0 +1,142 @@
from ngSkinTools2 import signal
from ngSkinTools2.api.influence_names import InfluenceNameFilter
from ngSkinTools2.api.pyside import QAction, QtCore, QtWidgets
from ngSkinTools2.api.session import Session
from ngSkinTools2.operations import import_v1_actions
from ngSkinTools2.ui import influencesview, layersview, qt
from ngSkinTools2.ui.layout import scale_multiplier
def build_layers_ui(parent, actions, session):
"""
:type session: Session
:type actions: ngSkinTools2.ui.actions.Actions
:type parent: QWidget
"""
influences_filter = InfluenceNameFilter()
def build_infl_filter():
img = qt.image_icon("clear-input-white.png")
result = QtWidgets.QHBoxLayout()
result.setSpacing(5)
filter = QtWidgets.QComboBox()
filter.setMinimumHeight(22 * scale_multiplier)
filter.setEditable(True)
filter.lineEdit().setPlaceholderText("Search...")
result.addWidget(filter)
# noinspection PyShadowingNames
clear = QAction(result)
clear.setIcon(img)
filter.lineEdit().addAction(clear, QtWidgets.QLineEdit.TrailingPosition)
@qt.on(filter.editTextChanged)
def filter_edited():
influences_filter.set_filter_string(filter.currentText())
clear.setVisible(len(filter.currentText()) != 0)
@qt.on(clear.triggered)
def clear_clicked():
filter.clearEditText()
filter_edited()
return result
split = QtWidgets.QSplitter(orientation=QtCore.Qt.Horizontal, parent=parent)
layout = QtWidgets.QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(3)
clear = QtWidgets.QPushButton()
clear.setFixedSize(20, 20)
# layout.addWidget(clear)
layers = layersview.build_view(parent, actions)
layout.addWidget(layers)
split.addWidget(qt.wrap_layout_into_widget(layout))
layout = QtWidgets.QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(3)
influences = influencesview.build_view(parent, actions, session, filter=influences_filter)
layout.addWidget(influences)
layout.addLayout(build_infl_filter())
split.addWidget(qt.wrap_layout_into_widget(layout))
return split
def build_no_layers_ui(parent, actions, session):
"""
:param parent: ui parent
:type actions: ngSkinTools2.ui.actions.Actions
:type session: Session
"""
layout = QtWidgets.QVBoxLayout()
layout.setContentsMargins(30, 30, 30, 30)
selection_display = QtWidgets.QLabel("pPlane1")
selection_display.setStyleSheet("font-weight: bold")
selection_note = QtWidgets.QLabel("Skinning Layers cannot be attached to this object")
selection_note.setWordWrap(True)
layout.addStretch(1)
layout.addWidget(selection_display)
layout.addWidget(selection_note)
layout.addWidget(qt.bind_action_to_button(actions.import_v1, QtWidgets.QPushButton()))
layout.addWidget(qt.bind_action_to_button(actions.initialize, QtWidgets.QPushButton()))
layout.addStretch(3)
layout_widget = qt.wrap_layout_into_widget(layout)
@signal.on(session.events.targetChanged, qtParent=parent)
def handle_target_changed():
if session.state.layersAvailable:
return # no need to update
is_skinned = session.state.selectedSkinCluster is not None
selection_display.setText(session.state.selectedSkinCluster)
selection_display.setVisible(is_skinned)
note = "Select a mesh with a skin cluster attached."
if is_skinned:
note = "Skinning layers are not yet initialized for this mesh."
if import_v1_actions.can_import(session):
note = "Skinning layers from previous ngSkinTools version are initialized on this mesh."
selection_note.setText(note)
if session.active():
handle_target_changed()
return layout_widget
def build_target_ui(parent, actions, session):
"""
:param actions:
:param parent:
:type session: Session
"""
result = QtWidgets.QStackedWidget()
result.addWidget(build_no_layers_ui(parent, actions, session))
result.addWidget(build_layers_ui(parent, actions, session))
result.setMinimumHeight(300 * scale_multiplier)
@signal.on(session.events.targetChanged, qtParent=parent)
def handle_target_changed():
if not session.state.layersAvailable:
result.setCurrentIndex(0)
else:
result.setCurrentIndex(1)
if session.active():
handle_target_changed()
return result

View File

@@ -0,0 +1,207 @@
from ngSkinTools2 import api, cleanup, signal
from ngSkinTools2.api import VertexTransferMode
from ngSkinTools2.api.pyside import QtCore, QtWidgets
from ngSkinTools2.api.session import session
from ngSkinTools2.api.transfer import LayersTransfer
from ngSkinTools2.decorators import undoable
from ngSkinTools2.ui import influenceMappingUI, qt, widgets
from ngSkinTools2.ui.layout import createTitledRow, scale_multiplier
class UiModel:
def __init__(self):
self.transfer = LayersTransfer()
def destination_has_layers(self):
l = api.Layers(self.transfer.target)
return l.is_enabled() and len(l.list()) > 0
@undoable
def do_apply(self):
self.transfer.complete_execution()
from maya import cmds
cmds.select(self.transfer.target)
if session.active():
session.events.targetChanged.emitIfChanged()
single_transfer_dialog_policy = qt.SingleWindowPolicy()
def open(parent, model):
"""
:type model: UiModel
"""
def buttonRow(window):
def apply():
model.do_apply()
session.events.layerListChanged.emitIfChanged()
window.close()
return widgets.button_row(
[
("Transfer", apply),
("Cancel", window.close),
]
)
def view_influences_settings():
tabs.setCurrentIndex(1)
def build_settings():
result = QtWidgets.QVBoxLayout()
vertexMappingMode = QtWidgets.QComboBox()
vertexMappingMode.addItem("Closest point on surface", VertexTransferMode.closestPoint)
vertexMappingMode.addItem("UV space", VertexTransferMode.uvSpace)
vertexMappingMode.addItem("By vertex ID (source and destination vert count must match)", VertexTransferMode.vertexId)
g = QtWidgets.QGroupBox("Selection")
layout = QtWidgets.QVBoxLayout()
g.setLayout(layout)
sourceLabel = QtWidgets.QLabel()
layout.addLayout(createTitledRow("Source:", sourceLabel))
destinationLabel = QtWidgets.QLabel()
layout.addLayout(createTitledRow("Destination:", destinationLabel))
result.addWidget(g)
g = QtWidgets.QGroupBox("Vertex mapping")
layout = QtWidgets.QVBoxLayout()
layout.addLayout(createTitledRow("Mapping mode:", vertexMappingMode))
g.setLayout(layout)
result.addWidget(g)
g = QtWidgets.QGroupBox("Influences mapping")
layout = QtWidgets.QVBoxLayout()
g.setLayout(layout)
edit = QtWidgets.QPushButton("Configure")
qt.on(edit.clicked)(view_influences_settings)
button_row = QtWidgets.QHBoxLayout()
button_row.addWidget(edit)
button_row.addStretch()
layout.addLayout(button_row)
result.addWidget(g)
g = QtWidgets.QGroupBox("Other options")
layout = QtWidgets.QVBoxLayout()
g.setLayout(layout)
keep_layers = QtWidgets.QCheckBox("Keep existing layers on destination")
keep_layers_row = qt.wrap_layout_into_widget(createTitledRow("Destination layers:", keep_layers))
layout.addWidget(keep_layers_row)
@qt.on(keep_layers.stateChanged)
def checked():
model.transfer.keep_existing_layers = keep_layers.isChecked()
result.addWidget(g)
result.addStretch()
def update_settings_to_model():
keep_layers.setChecked(model.transfer.keep_existing_layers)
qt.select_data(vertexMappingMode, model.transfer.vertex_transfer_mode)
source_title = model.transfer.source
if model.transfer.source_file is not None:
source_title = 'file ' + model.transfer.source_file
sourceLabel.setText("<strong>" + source_title + "</strong>")
destinationLabel.setText("<strong>" + model.transfer.target + "</strong>")
keep_layers_row.setEnabled(model.destination_has_layers())
@qt.on(vertexMappingMode.currentIndexChanged)
def vertex_mapping_mode_changed():
model.transfer.vertex_transfer_mode = vertexMappingMode.currentData()
update_settings_to_model()
return result
def build_influenes_tab():
infl_ui, _, recalcMatches = influenceMappingUI.build_ui(parent, model.transfer.influences_mapping)
padding = QtWidgets.QVBoxLayout()
padding.setContentsMargins(0, 20 * scale_multiplier, 0, 0)
padding.addWidget(infl_ui)
recalcMatches()
return padding
tabs = QtWidgets.QTabWidget()
tabs.addTab(qt.wrap_layout_into_widget(build_settings()), "Settings")
tabs.addTab(qt.wrap_layout_into_widget(build_influenes_tab()), "Influences mapping")
window = QtWidgets.QDialog(parent)
cleanup.registerCleanupHandler(window.close)
window.setWindowTitle("Transfer")
window.setAttribute(QtCore.Qt.WA_DeleteOnClose)
window.resize(720 * scale_multiplier, 500 * scale_multiplier)
window.setLayout(QtWidgets.QVBoxLayout())
window.layout().addWidget(tabs)
window.layout().addLayout(buttonRow(window))
if session.active():
session.addQtWidgetReference(window)
single_transfer_dialog_policy.setCurrent(window)
window.show()
def build_transfer_action(session, parent):
from maya import cmds
from .actions import define_action
targets = []
def detect_targets():
targets[:] = []
selection = cmds.ls(sl=True)
if len(selection) != 2:
return False
if not api.Layers(selection[0]).is_enabled():
return False
targets[:] = selection
return True
def transfer_dialog(transfer):
"""
:type transfer: LayersTransfer
"""
model = UiModel()
model.transfer = transfer
open(parent, model)
def handler():
if not targets:
return
t = LayersTransfer()
t.source = targets[0]
t.target = targets[1]
t.customize_callback = transfer_dialog
t.execute()
result = define_action(parent, "Transfer layers...", callback=handler)
@signal.on(session.events.nodeSelectionChanged)
def on_selection_changed():
result.setEnabled(detect_targets())
on_selection_changed()
return result

View File

@@ -0,0 +1,23 @@
import functools
from ngSkinTools2.api.python_compatibility import Object
class UiLock(Object):
def __init__(self):
self.updating = False
def __enter__(self):
self.updating = True
def skip_if_updating(self, fn):
@functools.wraps(fn)
def wrapper(*args, **kwargs):
if self.updating:
return
return fn(*args, **kwargs)
return wrapper
def __exit__(self, _type, value, traceback):
self.updating = False

View File

@@ -0,0 +1,132 @@
import webbrowser
from ngSkinTools2.api import versioncheck
from ngSkinTools2.api.log import getLogger
from ngSkinTools2.api.pyside import Qt, QtWidgets
from ngSkinTools2.ui.options import bind_checkbox, config
from .. import cleanup, signal, version
from . import qt
from .layout import scale_multiplier
log = getLogger("plugin")
def show(parent, silent_mode):
"""
:type parent: QWidget
"""
error_signal = signal.Signal("error")
success_signal = signal.Signal("success")
# noinspection PyShadowingNames
def body():
result = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout()
result.setLayout(layout)
layout.setContentsMargins(20, 30, 20, 20)
header = QtWidgets.QLabel("<strong>Checking for update...</strong>")
result1 = QtWidgets.QLabel("Current version: <strong>2.0.0</strong>")
result2 = QtWidgets.QLabel("Update available: 2.0.1")
download = QtWidgets.QPushButton("Download ngSkinTools v2.0.1")
# layout.addWidget(QtWidgets.QLabel("Checking for updates..."))
layout.addWidget(header)
layout.addWidget(result1)
layout.addWidget(result2)
layout.addWidget(download)
result1.setVisible(False)
result2.setVisible(False)
download.setVisible(False)
@signal.on(error_signal)
def error_handler(error):
header.setText("<strong>Error: {0}</strong>".format(error))
@signal.on(success_signal)
def success_handler(info):
"""
:type info: ngSkinTools2.api.versioncheck.
"""
header.setText("<strong>{0}</strong>".format('Update available!' if info.update_available else 'ngSkinTools is up to date.'))
result1.setVisible(True)
result1.setText("Current version: <strong>{0}</strong>".format(version.pluginVersion()))
if info.update_available:
result2.setVisible(True)
result2.setText(
"Update available: <strong>{0}</strong>, released on {1}".format(info.latest_version, info.update_date.strftime("%d %B, %Y"))
)
download.setVisible(True)
download.setText("Download ngSkinTools v" + info.latest_version)
@qt.on(download.clicked)
def open_link():
webbrowser.open_new(info.download_url)
return result
# noinspection PyShadowingNames
def buttonsRow(window):
btn_close = QtWidgets.QPushButton("Close")
btn_close.setMinimumWidth(100 * scale_multiplier)
check_do_on_startup = bind_checkbox(QtWidgets.QCheckBox("Check for updates at startup"), config.checkForUpdatesAtStartup)
layout = QtWidgets.QHBoxLayout()
layout.addWidget(check_do_on_startup)
layout.addStretch()
layout.addWidget(btn_close)
layout.setContentsMargins(20 * scale_multiplier, 15 * scale_multiplier, 20 * scale_multiplier, 15 * scale_multiplier)
btn_close.clicked.connect(lambda: window.close())
return layout
window = QtWidgets.QWidget(parent, Qt.Window | Qt.WindowTitleHint | Qt.CustomizeWindowHint)
window.resize(400 * scale_multiplier, 200 * scale_multiplier)
window.setAttribute(Qt.WA_DeleteOnClose)
window.setWindowTitle("ngSkinTools2 version update")
layout = QtWidgets.QVBoxLayout()
window.setLayout(layout)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(body())
layout.addStretch(2)
layout.addLayout(buttonsRow(window))
if not silent_mode:
window.show()
@signal.on(success_signal)
def on_success(info):
if silent_mode:
if info.update_available:
window.show()
else:
log.info("not showing the window")
window.close()
cleanup.registerCleanupHandler(window.close)
@qt.on(window.destroyed)
def closed():
log.info("deleting update window")
versioncheck.download_update_info(success_callback=success_signal.emit, failure_callback=error_signal.emit)
def silent_check_and_show_if_available(parent):
show(parent, silent_mode=True)
def show_and_start_update(parent):
show(parent, silent_mode=False)
def build_action_check_for_updates(parent):
from ngSkinTools2.ui import actions
return actions.define_action(parent, "Check for Updates...", callback=lambda: show_and_start_update(parent))

Some files were not shown because too many files have changed in this diff Show More