Welcome to mirror list, hosted at ThFree Co, Russian Federation.

git.blender.org/blender-addons.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
path: root/rigify
diff options
context:
space:
mode:
authorAlexander Gavrilov <angavrilov@gmail.com>2021-07-06 21:50:41 +0300
committerAlexander Gavrilov <angavrilov@gmail.com>2021-07-06 21:57:11 +0300
commit046114b96a1c369886a55de0bf958bf7ac2ae5a1 (patch)
tree9e59df176aa3bb57658fbb88fe1679292b6f08dd /rigify
parent735cdeec78f654e20e63841c25e077c931242cc1 (diff)
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).
Diffstat (limited to 'rigify')
-rw-r--r--rigify/__init__.py3
-rw-r--r--rigify/operators/__init__.py47
-rw-r--r--rigify/operators/copy_mirror_parameters.py129
-rw-r--r--rigify/rig_lists.py6
-rw-r--r--rigify/utils/misc.py45
-rw-r--r--rigify/utils/naming.py5
-rw-r--r--rigify/utils/node_merger.py317
7 files changed, 550 insertions, 2 deletions
diff --git a/rigify/__init__.py b/rigify/__init__.py
index e91fca51..1bb633f6 100644
--- a/rigify/__init__.py
+++ b/rigify/__init__.py
@@ -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()
diff --git a/rigify/operators/__init__.py b/rigify/operators/__init__.py
new file mode 100644
index 00000000..4263c8dd
--- /dev/null
+++ b/rigify/operators/__init__.py
@@ -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()
diff --git a/rigify/operators/copy_mirror_parameters.py b/rigify/operators/copy_mirror_parameters.py
new file mode 100644
index 00000000..95818ba8
--- /dev/null
+++ b/rigify/operators/copy_mirror_parameters.py
@@ -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)
diff --git a/rigify/rig_lists.py b/rigify/rig_lists.py
index 49cc9545..ae7d9d00 100644
--- a/rigify/rig_lists.py
+++ b/rigify/rig_lists.py
@@ -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
diff --git a/rigify/utils/misc.py b/rigify/utils/misc.py
index 20fd6a08..e4ba55f2 100644
--- a/rigify/utils/misc.py
+++ b/rigify/utils/misc.py
@@ -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:
diff --git a/rigify/utils/naming.py b/rigify/utils/naming.py
index 6c54b988..a713e407 100644
--- a/rigify/utils/naming.py
+++ b/rigify/utils/naming.py
@@ -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):
diff --git a/rigify/utils/node_merger.py b/rigify/utils/node_merger.py
new file mode 100644
index 00000000..49ebaf27
--- /dev/null
+++ b/rigify/utils/node_merger.py
@@ -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)