diff --git a/retopo_tools/.gitignore b/retopo_tools/.gitignore new file mode 100644 index 0000000..c4c4ffc --- /dev/null +++ b/retopo_tools/.gitignore @@ -0,0 +1 @@ +*.zip diff --git a/retopo_tools/Makefile b/retopo_tools/Makefile new file mode 100644 index 0000000..6b5bf43 --- /dev/null +++ b/retopo_tools/Makefile @@ -0,0 +1,8 @@ +all: retopo_tools_addon.zip + +%.zip: *.py + zip -r $@ $^ + +.PHONY: clean +clean: + ${RM} *.zip diff --git a/retopo_tools/__init__.py b/retopo_tools/__init__.py new file mode 100644 index 0000000..5dfde5f --- /dev/null +++ b/retopo_tools/__init__.py @@ -0,0 +1,81 @@ +# 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 3 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 +# MERCHANTIBILITY 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, see . + + +import bpy +from bpy.types import Context + + +bl_info = { + "name" : "RetopoTools", + "author" : "Name", + "description" : "Set up a standard retopology workflow for a selected object, if 2 objects are selected, then the non-active object is used as the target of mirror and shrinkwrap", + "blender" : (2, 80, 0), + "version" : (0, 0, 1), + "location" : "", + "warning" : "", + "category" : "Generic" +} + +from .tool import RetopoProjectModeOperator, RetopoVertexModeOperator + + +def RetopoProjectModeOperator_menu_func(self, context: Context): + return self.layout.operator(RetopoProjectModeOperator.bl_idname) + +def RetopoVertexModeOperator_menu_func(self, context: Context): + return self.layout.operator(RetopoVertexModeOperator.bl_idname) + +from . import auto_load + +auto_load.init() + + +addon_keymaps = [] + +def register_keymaps(): + addon_kc = bpy.context.window_manager.keyconfigs.addon + if addon_kc is None: + return + for mode in ("Window",): + km = addon_kc.keymaps.new(name=mode) + kmis = tuple( + km.keymap_items.new( + Operator.bl_idname, + type="NONE", + value="ANY", + ) + for Operator in (RetopoProjectModeOperator, RetopoVertexModeOperator) + ) + addon_keymaps.append((km, kmis)) + + +def unregister_keymaps(): + # Don't know if this is really neccessary but this is what they do in the docs tutorial... + for km, kmis in addon_keymaps: + for kmi in kmis: + km.keymap_items.remove(kmi) + bpy.context.window_manager.keyconfigs.addon.keymaps.remove(km) + addon_keymaps.clear() + +def register(): + auto_load.register() + bpy.types.VIEW3D_MT_object.append(RetopoProjectModeOperator_menu_func) + bpy.types.VIEW3D_MT_object.append(RetopoVertexModeOperator_menu_func) + register_keymaps() + +def unregister(): + auto_load.unregister() + bpy.types.VIEW3D_MT_object.remove(RetopoProjectModeOperator_menu_func) + bpy.types.VIEW3D_MT_object.remove(RetopoVertexModeOperator_menu_func) + unregister_keymaps() diff --git a/retopo_tools/auto_load.py b/retopo_tools/auto_load.py new file mode 100644 index 0000000..0f51f59 --- /dev/null +++ b/retopo_tools/auto_load.py @@ -0,0 +1,162 @@ +""" +File is autogenerated by the vscode blender extension. +Handles the tedium of registering blender classes by searching for them and automagically registering them +""" + +import os +import bpy +import sys +import typing +import inspect +import pkgutil +import importlib +from pathlib import Path + +__all__ = ( + "init", + "register", + "unregister", +) + +blender_version = bpy.app.version + +modules = None +ordered_classes = None + +def init(): + global modules + global ordered_classes + + modules = get_all_submodules(Path(__file__).parent) + ordered_classes = get_ordered_classes_to_register(modules) + +def register(): + for cls in ordered_classes: + bpy.utils.register_class(cls) + + for module in modules: + if module.__name__ == __name__: + continue + if hasattr(module, "register"): + module.register() + +def unregister(): + for cls in reversed(ordered_classes): + bpy.utils.unregister_class(cls) + + for module in modules: + if module.__name__ == __name__: + continue + if hasattr(module, "unregister"): + module.unregister() + + +# Import modules +################################################# + +def get_all_submodules(directory): + return list(iter_submodules(directory, directory.name)) + +def iter_submodules(path, package_name): + for name in sorted(iter_submodule_names(path)): + yield importlib.import_module("." + name, package_name) + +def iter_submodule_names(path, root=""): + for _, module_name, is_package in pkgutil.iter_modules([str(path)]): + if is_package: + sub_path = path / module_name + sub_root = root + module_name + "." + yield from iter_submodule_names(sub_path, sub_root) + else: + yield root + module_name + + +# Find classes to register +################################################# + +def get_ordered_classes_to_register(modules): + return toposort(get_register_deps_dict(modules)) + +def get_register_deps_dict(modules): + my_classes = set(iter_my_classes(modules)) + my_classes_by_idname = {cls.bl_idname : cls for cls in my_classes if hasattr(cls, "bl_idname")} + + deps_dict = {} + for cls in my_classes: + deps_dict[cls] = set(iter_my_register_deps(cls, my_classes, my_classes_by_idname)) + return deps_dict + +def iter_my_register_deps(cls, my_classes, my_classes_by_idname): + yield from iter_my_deps_from_annotations(cls, my_classes) + yield from iter_my_deps_from_parent_id(cls, my_classes_by_idname) + +def iter_my_deps_from_annotations(cls, my_classes): + for value in typing.get_type_hints(cls, {}, {}).values(): + dependency = get_dependency_from_annotation(value) + if dependency is not None: + if dependency in my_classes: + yield dependency + +def get_dependency_from_annotation(value): + if blender_version >= (2, 93): + if isinstance(value, bpy.props._PropertyDeferred): + return value.keywords.get("type") + else: + if isinstance(value, tuple) and len(value) == 2: + if value[0] in (bpy.props.PointerProperty, bpy.props.CollectionProperty): + return value[1]["type"] + return None + +def iter_my_deps_from_parent_id(cls, my_classes_by_idname): + if bpy.types.Panel in cls.__bases__: + parent_idname = getattr(cls, "bl_parent_id", None) + if parent_idname is not None: + parent_cls = my_classes_by_idname.get(parent_idname) + if parent_cls is not None: + yield parent_cls + +def iter_my_classes(modules): + base_types = get_register_base_types() + for cls in get_classes_in_modules(modules): + if any(base in base_types for base in cls.__bases__): + if not getattr(cls, "is_registered", False): + yield cls + +def get_classes_in_modules(modules): + classes = set() + for module in modules: + for cls in iter_classes_in_module(module): + classes.add(cls) + return classes + +def iter_classes_in_module(module): + for value in module.__dict__.values(): + if inspect.isclass(value): + yield value + +def get_register_base_types(): + return set(getattr(bpy.types, name) for name in [ + "Panel", "Operator", "PropertyGroup", + "AddonPreferences", "Header", "Menu", + "Node", "NodeSocket", "NodeTree", + "UIList", "RenderEngine", + "Gizmo", "GizmoGroup", + ]) + + +# Find order to register to solve dependencies +################################################# + +def toposort(deps_dict): + sorted_list = [] + sorted_values = set() + while len(deps_dict) > 0: + unsorted = [] + for value, deps in deps_dict.items(): + if len(deps) == 0: + sorted_list.append(value) + sorted_values.add(value) + else: + unsorted.append(value) + deps_dict = {value : deps_dict[value] - sorted_values for value in unsorted} + return sorted_list diff --git a/retopo_tools/tool.py b/retopo_tools/tool.py new file mode 100644 index 0000000..b9437f5 --- /dev/null +++ b/retopo_tools/tool.py @@ -0,0 +1,108 @@ +import bpy +from bpy.types import Context, Object, Mesh, Scene + +def assert_mirror_modifier(obj: Object, target: Object = None): + modifier = next((modifier for modifier in obj.modifiers if modifier.type == "MIRROR"), None) + if modifier is None: + modifier = obj.modifiers.new("Mirror", "MIRROR") + modifier.use_clip = True + + if target is not None and modifier.mirror_object is None: + modifier.mirror_object = target + + +def assert_shrinkwrap_modifier(obj: Object, target: Object = None): + modifier = next((modifier for modifier in obj.modifiers if modifier.type == "SHRINKWRAP"), None) + if modifier is None: + modifier = obj.modifiers.new("Shrinkwrap", "SHRINKWRAP") + + modifier.show_viewport = False + modifier.wrap_method = "PROJECT" + modifier.wrap_mode = "ABOVE_SURFACE" + modifier.project_limit = 0.0 + modifier.use_negative_direction = True + modifier.use_positive_direction = True + + + vgroup = next((group for group in obj.vertex_groups if "no_shrinkwrap" in group.name), None) + if vgroup is None: + vgroup = obj.vertex_groups.new(name="no_shrinkwrap") + + + modifier.vertex_group = vgroup.name + modifier.invert_vertex_group = True + + if target is not None and modifier.target is None: + modifier.target = target + + + +def assert_retopo_modifiers(ctx: Context, target: Object = None): + active = ctx.active_object + assert_mirror_modifier(active, target) + assert_shrinkwrap_modifier(active, target) + +def setup_project_snap_settings(scene: Scene): + scene.tool_settings.use_snap = True + scene.tool_settings.snap_elements = {"FACE"} + scene.tool_settings.use_snap_self = True + scene.tool_settings.use_snap_edit = True + scene.tool_settings.use_snap_nonedit = True + scene.tool_settings.use_snap_selectable = True + scene.tool_settings.use_snap_align_rotation = False + scene.tool_settings.use_snap_backface_culling = True + scene.tool_settings.use_snap_project = True + scene.tool_settings.use_snap_translate = True + scene.tool_settings.use_snap_rotate = True + scene.tool_settings.use_snap_scale = True + +def setup_vertex_snap_settings(scene: Scene): + scene.tool_settings.use_snap = True + scene.tool_settings.snap_elements = {"VERTEX"} + scene.tool_settings.use_snap_self = True + scene.tool_settings.use_snap_edit = True + scene.tool_settings.use_snap_nonedit = False + scene.tool_settings.use_snap_selectable = True + scene.tool_settings.use_snap_align_rotation = False + scene.tool_settings.use_snap_backface_culling = True + scene.tool_settings.use_snap_translate = True + scene.tool_settings.use_snap_rotate = True + scene.tool_settings.use_snap_scale = True + +def get_context_target(ctx: Context): + if len(ctx.selected_objects) != 2: + return None + return next(obj for obj in ctx.selected_objects if obj is not ctx.active_object) + +class RetopoProjectModeOperator(bpy.types.Operator): + bl_idname = "object.retopoprojectmode" + bl_label = "RetopoProjectMode" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(Cls, context: Context): + return context.active_object is not None and type(context.active_object.data) is Mesh + + def execute(self, context: Context): + bpy.ops.object.mode_set(mode="EDIT") + target = get_context_target(context) + assert_retopo_modifiers(context, target) + setup_project_snap_settings(context.scene) + self.report({"INFO"}, "Project Mode") + return {'FINISHED'} + + +class RetopoVertexModeOperator(bpy.types.Operator): + bl_idname = "object.retopovertexmode" + bl_label = "RetopoVertexMode" + + def execute(self, context): + bpy.ops.object.mode_set(mode="EDIT") + target = get_context_target(context) + assert_retopo_modifiers(context, target) + setup_vertex_snap_settings(context.scene) + self.report({"INFO"}, "Vertex Mode") + return {'FINISHED'} + + +