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

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