361 lines
12 KiB
Python
361 lines
12 KiB
Python
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
|