From 046114b96a1c369886a55de0bf958bf7ac2ae5a1 Mon Sep 17 00:00:00 2001 From: Alexander Gavrilov Date: Tue, 6 Jul 2021 21:50:41 +0300 Subject: 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). --- rigify/__init__.py | 3 + rigify/operators/__init__.py | 47 +++++ rigify/operators/copy_mirror_parameters.py | 129 ++++++++++++ rigify/rig_lists.py | 6 + rigify/utils/misc.py | 45 +++- rigify/utils/naming.py | 5 +- rigify/utils/node_merger.py | 317 +++++++++++++++++++++++++++++ 7 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 rigify/operators/__init__.py create mode 100644 rigify/operators/copy_mirror_parameters.py create mode 100644 rigify/utils/node_merger.py (limited to 'rigify') 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 ======================== + +# + +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 ======================== + +# + +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 ======================== + +# + +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) -- cgit v1.2.3