Rigify: add utility and operator classes for the upcoming face rigs.

- LazyRef utility class that provides a hashable field reference.
- NodeMerger plugin for grouping abstract points by distance.
- pose.rigify_copy_single_parameter operator for copying a single
  property to all selected rigs that inherit from a specific class
  (intended to be used via property panel buttons).
This commit is contained in:
Alexander Gavrilov 2021-07-06 21:50:41 +03:00
parent 735cdeec78
commit 046114b96a
7 changed files with 550 additions and 2 deletions

View File

@ -64,6 +64,7 @@ initial_load_order = [
'rig_ui_template',
'generate',
'rot_mode',
'operators',
'ui',
]
@ -449,6 +450,7 @@ def register():
ui.register()
feature_set_list.register()
metarig_menu.register()
operators.register()
# Classes.
for cls in classes:
@ -597,6 +599,7 @@ def unregister():
clear_rigify_parameters()
# Sub-modules.
operators.unregister()
metarig_menu.unregister()
ui.unregister()
feature_set_list.unregister()

View File

@ -0,0 +1,47 @@
# ====================== BEGIN GPL LICENSE BLOCK ======================
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ======================= END GPL LICENSE BLOCK ========================
# <pep8 compliant>
import importlib
# Submodules to load during register
submodules = (
'copy_mirror_parameters',
)
loaded_submodules = []
def register():
# Lazily load modules to make reloading easier. Loading this way
# hides the sub-modules and their dependencies from initial_load_order.
loaded_submodules[:] = [
importlib.import_module(__name__ + '.' + name) for name in submodules
]
for mod in loaded_submodules:
mod.register()
def unregister():
for mod in reversed(loaded_submodules):
mod.unregister()
loaded_submodules.clear()

View File

@ -0,0 +1,129 @@
# ====================== BEGIN GPL LICENSE BLOCK ======================
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ======================= END GPL LICENSE BLOCK ========================
# <pep8 compliant>
import bpy
import importlib
from ..utils.naming import Side, get_name_base_and_sides, mirror_name
from ..utils.rig import get_rigify_type
from ..rig_lists import get_rig_class
# =============================================
# Single parameter copy button
class POSE_OT_rigify_copy_single_parameter(bpy.types.Operator):
bl_idname = "pose.rigify_copy_single_parameter"
bl_label = "Copy Option To Selected Rigs"
bl_description = "Copy this property value to all selected rigs of the appropriate type"
bl_options = {'UNDO', 'INTERNAL'}
property_name: bpy.props.StringProperty(name='Property Name')
mirror_bone: bpy.props.BoolProperty(name='Mirror As Bone Name')
module_name: bpy.props.StringProperty(name='Module Name')
class_name: bpy.props.StringProperty(name='Class Name')
@classmethod
def poll(cls, context):
return (
context.active_object and context.active_object.type == 'ARMATURE'
and context.active_pose_bone
and context.active_object.data.get('rig_id') is None
and get_rigify_type(context.active_pose_bone)
)
def invoke(self, context, event):
return context.window_manager.invoke_confirm(self, event)
def execute(self, context):
try:
module = importlib.import_module(self.module_name)
filter_rig_class = getattr(module, self.class_name)
except (KeyError, AttributeError, ImportError):
self.report(
{'ERROR'}, f"Cannot find class {self.class_name} in {self.module_name}")
return {'CANCELLED'}
active_pbone = context.active_pose_bone
active_split = get_name_base_and_sides(active_pbone.name)
value = getattr(active_pbone.rigify_parameters, self.property_name)
num_copied = 0
# Copy to different bones of appropriate rig types
for sel_pbone in context.selected_pose_bones:
rig_type = get_rigify_type(sel_pbone)
if rig_type and sel_pbone != active_pbone:
rig_class = get_rig_class(rig_type)
if rig_class and issubclass(rig_class, filter_rig_class):
new_value = value
# If mirror requested and copying to a different side bone, mirror the value
if self.mirror_bone and active_split.side != Side.MIDDLE and value:
sel_split = get_name_base_and_sides(sel_pbone.name)
if sel_split.side == -active_split.side:
new_value = mirror_name(value)
# Assign the final value
setattr(sel_pbone.rigify_parameters,
self.property_name, new_value)
num_copied += 1
if num_copied:
self.report({'INFO'}, f"Copied the value to {num_copied} bones.")
return {'FINISHED'}
else:
self.report({'WARNING'}, "No suitable selected bones to copy to.")
return {'CANCELLED'}
def make_copy_parameter_button(layout, property_name, *, base_class, mirror_bone=False):
"""Displays a button that copies the property to selected rig of the specified base type."""
props = layout.operator(
POSE_OT_rigify_copy_single_parameter.bl_idname, icon='DUPLICATE', text='')
props.property_name = property_name
props.mirror_bone = mirror_bone
props.module_name = base_class.__module__
props.class_name = base_class.__name__
# =============================================
# Registration
classes = (
POSE_OT_rigify_copy_single_parameter,
)
def register():
from bpy.utils import register_class
for cls in classes:
register_class(cls)
def unregister():
from bpy.utils import unregister_class
for cls in classes:
unregister_class(cls)

View File

@ -80,6 +80,12 @@ def get_rigs(base_dir, base_path, *, path=[], feature_set=feature_set_list.DEFAU
rigs = {}
implementation_rigs = {}
def get_rig_class(name):
try:
return rigs[name]["module"].Rig
except (KeyError, AttributeError):
return None
def get_internal_rigs():
global rigs, implementation_rigs

View File

@ -153,17 +153,60 @@ def map_apply(func, *inputs):
#=============================================
# Misc
# Lazy references
#=============================================
def force_lazy(value):
"""If the argument is callable, invokes it without arguments. Otherwise returns the argument as is."""
if callable(value):
return value()
else:
return value
class LazyRef:
"""Hashable lazy reference. When called, evaluates (foo, 'a', 'b'...) as foo('a','b')
if foo is callable. Otherwise the remaining arguments are used as attribute names or
keys, like foo.a.b or foo.a[b] etc."""
def __init__(self, first, *args):
self.first = first
self.args = tuple(args)
self.first_hashable = first.__hash__ is not None
def __repr__(self):
return 'LazyRef{}'.format(tuple(self.first, *self.args))
def __eq__(self, other):
return (
isinstance(other, LazyRef) and
(self.first == other.first if self.first_hashable else self.first is other.first) and
self.args == other.args
)
def __hash__(self):
return (hash(self.first) if self.first_hashable else hash(id(self.first))) ^ hash(self.args)
def __call__(self):
first = self.first
if callable(first):
return first(*self.args)
for item in self.args:
if isinstance(first, (dict, list)):
first = first[item]
else:
first = getattr(first, item)
return first
#=============================================
# Misc
#=============================================
def copy_attributes(a, b):
keys = dir(a)
for key in keys:

View File

@ -162,6 +162,9 @@ class SideZ(enum.IntEnum):
return combine_name(parts, side_z=new_side)
NameSides = collections.namedtuple('NameSides', ['base', 'side', 'side_z'])
def get_name_side(name):
return Side.from_parts(split_name(name))
@ -173,7 +176,7 @@ def get_name_side_z(name):
def get_name_base_and_sides(name):
parts = split_name(name)
base = combine_name(parts, side='', side_z='')
return base, Side.from_parts(parts), SideZ.from_parts(parts)
return NameSides(base, Side.from_parts(parts), SideZ.from_parts(parts))
def change_name_side(name, side=None, *, side_z=None):

317
rigify/utils/node_merger.py Normal file
View File

@ -0,0 +1,317 @@
# ====================== BEGIN GPL LICENSE BLOCK ======================
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
# ======================= END GPL LICENSE BLOCK ========================
# <pep8 compliant>
import bpy
import collections
import heapq
import operator
from mathutils import Vector
from mathutils.kdtree import KDTree
from .errors import MetarigError
from ..base_rig import stage, GenerateCallbackHost
from ..base_generate import GeneratorPlugin
class NodeMerger(GeneratorPlugin):
"""
Utility that allows rigs to interact based on common points in space.
Rigs can register node objects representing locations during the
initialize stage, and at the end the plugin sorts them into buckets
based on proximity. For each such bucket a group object is created
and allowed to further process the nodes.
Nodes chosen by the groups as being 'final' become sub-objects of
the plugin and receive stage callbacks.
The domain parameter allows potentially having multiple completely
separate layers of nodes with different purpose.
"""
epsilon = 1e-5
def __init__(self, generator, domain):
super().__init__(generator)
assert domain is not None
assert generator.stage == 'initialize'
self.domain = domain
self.nodes = []
self.final_nodes = []
self.groups = []
self.frozen = False
def register_node(self, node):
assert not self.frozen
node.generator_plugin = self
self.nodes.append(node)
def initialize(self):
self.frozen = True
nodes = self.nodes
tree = KDTree(len(nodes))
for i, node in enumerate(nodes):
tree.insert(node.point, i)
tree.balance()
processed = set()
final_nodes = []
groups = []
for i in range(len(nodes)):
if i in processed:
continue
# Find points to merge
pending = [i]
merge_set = set(pending)
while pending:
added = set()
for j in pending:
for co, idx, dist in tree.find_range(nodes[j].point, self.epsilon):
added.add(idx)
pending = added.difference(merge_set)
merge_set.update(added)
assert merge_set.isdisjoint(processed)
processed.update(merge_set)
# Group the points
merge_list = [nodes[i] for i in merge_set]
merge_list.sort(key=lambda x: x.name)
group_class = merge_list[0].group_class
for item in merge_list[1:]:
cls = item.group_class
if issubclass(cls, group_class):
group_class = cls
elif not issubclass(group_class, cls):
raise MetarigError(
'Group class conflict: {} and {} from {} of {}'.format(
group_class, cls, item.name, item.rig.base_bone,
)
)
group = group_class(merge_list)
group.build(final_nodes)
groups.append(group)
self.final_nodes = self.rigify_sub_objects = final_nodes
self.groups = groups
class MergeGroup(object):
"""
Standard node group, merges nodes based on certain rules and priorities.
1. Nodes are classified into main and query nodes; query nodes are not merged.
2. Nodes owned by the same rig cannot merge with each other.
3. Node can only merge into target if node.can_merge_into(target) is true.
4. Among multiple candidates in one rig, node.get_merge_priority(target) is used.
5. The largest clusters of nodes that can merge are picked until none are left.
The master nodes of the chosen clusters, plus query nodes, become 'final'.
"""
def __init__(self, nodes):
self.nodes = nodes
for node in nodes:
node.group = self
def is_main(node):
return isinstance(node, MainMergeNode)
self.main_nodes = [n for n in nodes if is_main(n)]
self.query_nodes = [n for n in nodes if not is_main(n)]
def build(self, final_nodes):
main_nodes = self.main_nodes
# Sort nodes into rig buckets - can't merge within the same rig
rig_table = collections.defaultdict(list)
for node in main_nodes:
rig_table[node.rig].append(node)
# Build a 'can merge' table
merge_table = {n: set() for n in main_nodes}
for node in main_nodes:
for rig, tgt_nodes in rig_table.items():
if rig is not node.rig:
nodes = [n for n in tgt_nodes if node.can_merge_into(n)]
if nodes:
best_node = max(nodes, key=node.get_merge_priority)
merge_table[best_node].add(node)
# Output groups starting with largest
self.final_nodes = []
pending = set(main_nodes)
while pending:
# Find largest group
nodes = [n for n in main_nodes if n in pending]
max_len = max(len(merge_table[n]) for n in nodes)
nodes = [n for n in nodes if len(merge_table[n]) == max_len]
# If a tie, try to resolve using comparison
if len(nodes) > 1:
weighted_nodes = [
(n, sum(
1 if (n.is_better_cluster(n2)
and not n2.is_better_cluster(n)) else 0
for n2 in nodes
))
for n in nodes
]
max_weight = max(wn[1] for wn in weighted_nodes)
nodes = [wn[0] for wn in weighted_nodes if wn[1] == max_weight]
# Final tie breaker is the name
best = min(nodes, key=lambda n: n.name)
child_set = merge_table[best]
# Link children
best.point = sum((c.point for c in child_set),
best.point) / (len(child_set) + 1)
for child in [n for n in main_nodes if n in child_set]:
child.point = best.point
best.merge_from(child)
child.merge_into(best)
final_nodes.append(best)
self.final_nodes.append(best)
best.merge_done()
# Remove merged nodes from the table
pending.remove(best)
pending -= child_set
for children in merge_table.values():
children &= pending
for node in self.query_nodes:
node.merge_done()
final_nodes += self.query_nodes
class BaseMergeNode(GenerateCallbackHost):
"""Base class of mergeable nodes."""
merge_domain = None
merger = NodeMerger
group_class = MergeGroup
def __init__(self, rig, name, point, *, domain=None):
self.rig = rig
self.obj = rig.obj
self.name = name
self.point = Vector(point)
merger = self.merger(rig.generator, domain or self.merge_domain)
merger.register_node(self)
def register_new_bone(self, new_name, old_name=None):
self.generator_plugin.register_new_bone(new_name, old_name)
def can_merge_into(self, other):
raise NotImplementedError()
def get_merge_priority(self, other):
"Rank candidates to merge into."
return 0
class MainMergeNode(BaseMergeNode):
"""
Base class of standard mergeable nodes. Each node can either be
a master of its cluster or a merged child node. Children become
sub-objects of their master to receive callbacks in defined order.
"""
def __init__(self, rig, name, point, *, domain=None):
super().__init__(rig, name, point, domain=domain)
self.merged_into = None
self.merged = []
def get_merged_siblings(self):
master = self.merged_master
return [master, *master.merged]
def is_better_cluster(self, other):
"Compare with the other node to choose between cluster masters."
return False
def can_merge_from(self, other):
return True
def can_merge_into(self, other):
return other.can_merge_from(self)
def merge_into(self, other):
self.merged_into = other
def merge_from(self, other):
self.merged.append(other)
@property
def is_master_node(self):
return not self.merged_into
def merge_done(self):
self.merged_master = self.merged_into or self
self.rigify_sub_objects = list(self.merged)
for child in self.merged:
child.merge_done()
class QueryMergeNode(BaseMergeNode):
"""Base class for special nodes used only to query which nodes are at a certain location."""
is_master_node = False
require_match = True
def merge_done(self):
self.matched_nodes = [
n for n in self.group.final_nodes if self.can_merge_into(n)
]
self.matched_nodes.sort(key=self.get_merge_priority, reverse=True)
if self.require_match and not self.matched_nodes:
self.rig.raise_error(
'Could not match control node for {}', self.name)