├── .flake8 ├── .gitignore ├── .gitmodules ├── .mypy.ini ├── .pep8 ├── .pylintrc ├── README.md ├── TODO ├── __init__.py ├── addon_updater.py ├── addon_updater_ops.py ├── agpl-3.0.txt ├── assets.py ├── cmedit ├── __init__.py ├── assets.py ├── file_io.py ├── rigging.py ├── symmetry.py └── vg_calc.py ├── common.py ├── file_io.py ├── finalize.py ├── gpl-3.0.txt ├── hair.py ├── lib ├── __init__.py ├── charlib.py ├── drivers.py ├── fit_calc.py ├── fitting.py ├── hair.py ├── materials.py ├── morpher.py ├── morpher_cores.py ├── morphs.py ├── rigging.py ├── sliding_joints.py ├── utils.py └── yaml │ ├── LICENSE │ ├── __init__.py │ ├── composer.py │ ├── constructor.py │ ├── cyaml.py │ ├── dumper.py │ ├── emitter.py │ ├── error.py │ ├── events.py │ ├── loader.py │ ├── nodes.py │ ├── parser.py │ ├── reader.py │ ├── representer.py │ ├── resolver.py │ ├── scanner.py │ ├── serializer.py │ └── tokens.py ├── library.py ├── license.txt ├── morphing.py ├── pose.py ├── prefs.py ├── pyrightconfig.json ├── randomize.py ├── rig.py └── rigify.py /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | ignore= 4 | F722, # flake8 doesn't understand Blender's property annotations 5 | W503, # WTF W503 and W504 contradict each other 6 | F821, # Very strange 7 | E401 8 | E741 9 | 10 | exclude=lib/yaml, tmp, dev, demo, backups 11 | 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .idea 3 | .vscode 4 | .mypy_cache 5 | backups 6 | demo 7 | work 8 | dev 9 | rig 10 | tmp 11 | ___test.py 12 | charmorph_updater/CharMorph_updater_status.json 13 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "data"] 2 | path = data 3 | url = https://github.com/Upliner/CharMorph-db 4 | -------------------------------------------------------------------------------- /.mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | exclude = (?x)^(backups/|tmp/|dev/|demo/|lib/yaml) 3 | ignore_missing_imports = True 4 | disable_error_code = valid-type 5 | no_strict_optional = True 6 | 7 | -------------------------------------------------------------------------------- /.pep8: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | max_line_length = 120 3 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MESSAGES CONTROL] 2 | disable=c-extension-no-member, # Suppress errors when using bpy imports 3 | invalid-name, # Blender has its own naming conventions that doesn't match with pylint ones 4 | too-few-public-methods, # Subclasses of Blender classes sometimes require too few public methods 5 | too-many-ancestors, # It's not my fault that yaml uses so many ancestors 6 | no-self-use, # self is often not used in methods that are meant to be overriden. And trying to convert them to static result in arguments-differ error 7 | eval-used, # I believe I use eval() in very safe way 8 | multiple-imports, 9 | missing-module-docstring, 10 | missing-class-docstring, 11 | missing-function-docstring 12 | 13 | [MASTER] 14 | ignore=lib/yaml 15 | 16 | [DESIGN] 17 | max-attributes=10 # I'll try to use less attributes in future, but let's raise a little for now 18 | max-locals=20 # Such complex project with some performance-critical parts really needs max-locals to be raised a little bit 19 | max-line-length=120 # Let's raise line length limitations a little in our era of wide screens 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CharMorph 2 | 3 | CharMorph is a character creation tool for Blender. 4 | It uses base meshes and morphs from ManuelbastioniLAB/MB-Lab while being designed for easy creation of new models and modification of existing ones. 5 | 6 | This addon reimplements most of MB-Lab's features, but it currently does not contain any MB-Lab code. 7 | It uses a different database format and other internal differences, as well as less hard coded features. 8 | 9 | It is planned that CharMorph won't be limited to humanoids. Animals and other creatures are welcome at CharMorph too. 10 | 11 | ## Options: 12 | 13 | * **Use local materials:** 14 | 15 | Make a copy of local materials instead of importing them every time. 16 | 17 | It is safe to use this if you're creating a scene from scratch, but it is recommended to disable this option if you already have MB-Lab/older Charmorph characters in your scene. 18 | 19 | ## Differences from MB-Lab: 20 | 21 | * Noticeably improved performance 22 | * Direct setting of skin and eyes color 23 | * Material displacement instead of displacement modifier. 24 | This means there will be no real displacement in EEVEE, but a nice live preview with bumps is available. 25 | In Cycles, the skin material is set to "Displacement and bump" by default. 26 | * Hairstyles 27 | * Realtime asset fitting with combined masks 28 | * Rigify support with full face rig 29 | * Alternative topology feature for applying morphs to models with different topology 30 | * Characters are created at the 3D cursor's location, not always at world origin 31 | 32 | ## Downsides 33 | 34 | * Rig is added only at finalization, because it takes quite a long time for Rigify to generate a rig and I have no idea if it's possible to morph such rig in real time. 35 | * Still lacking some features (Automodelling, measures) just because I don't use them in my projects. Maybe they'll be implemented later. 36 | 37 | ## Development notes 38 | 39 | This project uses git submodules, so you need to use `git clone --recursive` when cloning this repository. If you forgot to do so, you can execute the following commands individually after cloning: 40 | ``` 41 | cd CharMorph 42 | git submodule init 43 | git submodule update 44 | cd data 45 | git submodule init 46 | git submodule update 47 | ``` 48 | 49 | ## Installation manual 50 | 51 | * Download the latest `charmorph.zip` package from the [releases page](https://github.com/Upliner/CharMorph/releases/latest) (not the source code file but the release package). 52 | * In Blender go to Edit->Preferences->Addons, click "Install..." and select the downloaded zip package. 53 | 54 | **NOTE:** If the zip file is smaller than 10MB, it means the file contains the addon only, without the character library. If that's the case, you can download it from [here](http://github.com/Upliner/CharMorph-db/) and extract it to the CharMorph data directory, which should be located in `%appdata%\Blender Foundation\Blender\\scripts\addons\CharMorph\data` on Windows 55 | 56 | ## Links 57 | 58 | * Features showcase on this [BlenderArtists forum thread](https://blenderartists.org/t/charmorph-character-creation-tool-mb-lab-based/1252543) 59 | * Discord server: https://discord.gg/bMsvxN3jPY 60 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | General: 2 | - Proper context handling (use bpy.context only if really necessary) 3 | 4 | Character management: 5 | - Unzipper for character/asset packages 6 | - Dependency analyser so you can delete characters without affecting other ones 7 | (may be tricky for textures, use special texture_deps config?) 8 | 9 | Performance: 10 | - Fast YAML library 11 | 12 | Art: 13 | - Compare bumping with MB-Lab? 14 | - More hairstyles 15 | 16 | Char creation: 17 | - Asset list in creation panel? 18 | - Alternative topologies from library 19 | 20 | Morphing: 21 | - Manual sculpting mode 22 | - Shape rig morphing 23 | - Allow to mix L1 shapes? 24 | - Replace preset mix with mix factor 25 | 26 | Finalize: 27 | - Choice between materials displacement and displacement modifier 28 | 29 | Materials: 30 | - Skin coloring for Antonia 31 | - Shared textures for assets 32 | - Lip color control? 33 | - Ability to disable skin features (blush, freckles) 34 | and completely remove them from node tree removing unneccessary texture images too 35 | - Texture layers? 36 | 37 | Reom: 38 | - More symmetry fixes 39 | 40 | Randomize: 41 | - Randomize eye and skin colors 42 | 43 | Hair: 44 | - Import scalp vertex groups only when necessary so VG cleanup on finalize won't be necessary anymore 45 | - Rework hair shaders engine 46 | - Lichtso hair engine 47 | - Move hair.blend to char.blend? 48 | - Hair refitting after edit 49 | 50 | Fitting: 51 | - Hybrid mode: store asset morphs in shapekeys even in numpy mode (probably not activating them), 52 | get asset morph data from morpher-code, not charlib! 53 | - BMesh instead of creating from_mix shapekey? 54 | - MakeHuman asset support ? 55 | - Bounding box transform? 56 | - More fitting settings? (Thresholds and so on) 57 | - Asset baking? 58 | - Ability to select MB-Lab fitting algorithm 59 | - Use avg instead of max for thresh? I think no, we need to use no less than first n 60 | 61 | Assets: 62 | - Use default underwear assets instead of censors -- done for Antonia 63 | - Image browser for clothes and hair 64 | 65 | Rigging: 66 | - Always place rig to the same collection with the character 67 | - Combining rig from subrigs 68 | - Fix Reom's eyelashes in ARP 69 | - Force bone bending angle 70 | - Generate rig from metarig 71 | - Use VertexWeightProximity or geonodes for folding knees, elbows and belly 72 | - Add chest and abdomen bones for breathing -- done for Antonia 73 | - Pupil dilation bones -- done for Antonia 74 | - Size and limb length morphs for Antonia 75 | - Relative rigger instead of current absolute one? 76 | - Head pivot shift 77 | - Deformation cage support? (it seems that better fitting algorighm is needed for it) 78 | - Fork and optimize Rigify for really realtime morphing? 79 | - Blenrig? 80 | 81 | Editing: 82 | - Automatic material converter (It took quite a while to convert Reom's materials) 83 | - Refine for XL recalc 84 | - MB-Lab import/export tools 85 | - Export L2__Body_Size_min back to MB-Lab 86 | 87 | Pose: 88 | - Fix glitchy capture poses 89 | - Different mix modes 90 | - Native pose support (not just MB-Lab) 91 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 3 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | # 19 | # Copyright (C) 2020 Michael Vigovsky 20 | 21 | import logging 22 | import bpy # pylint: disable=import-error 23 | 24 | from . import addon_updater_ops 25 | from . import common, library, assets, morphing, randomize, file_io, hair, finalize, rig, rigify, pose, prefs, cmedit 26 | from .lib import charlib 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | bl_info = { 31 | "name": "CharMorph", 32 | "author": "Michael Vigovsky", 33 | "version": (0, 3, 5), 34 | "blender": (3, 3, 0), 35 | "location": "View3D > Tools > CharMorph", 36 | "description": "Character creation and morphing, cloth fitting and rigging tools", 37 | 'wiki_url': "", 38 | 'tracker_url': 'https://github.com/Upliner/CharMorph/issues', 39 | "category": "Characters" 40 | } 41 | VERSION_ANNEX = "" 42 | 43 | owner = object() 44 | 45 | 46 | class VIEW3D_PT_CharMorph(bpy.types.Panel): 47 | bl_idname = "VIEW3D_PT_CharMorph" 48 | bl_label = "".join(("CharMorph ", ".".join(str(item) for item in bl_info["version"]), VERSION_ANNEX)) 49 | bl_space_type = 'VIEW_3D' 50 | bl_region_type = 'UI' 51 | bl_category = "CharMorph" 52 | bl_order = 1 53 | 54 | def draw(self, _): 55 | pass 56 | 57 | 58 | def on_select(): 59 | common.manager.on_select() 60 | 61 | 62 | @bpy.app.handlers.persistent 63 | def undoredo_post(_context, _scene): 64 | common.manager.on_select(undoredo=True) 65 | 66 | 67 | def subscribe_select_obj(): 68 | bpy.msgbus.clear_by_owner(owner) 69 | bpy.msgbus.subscribe_rna( 70 | owner=owner, 71 | key=(bpy.types.LayerObjects, "active"), 72 | args=(), 73 | options={"PERSISTENT"}, 74 | notify=on_select) 75 | 76 | 77 | @bpy.app.handlers.persistent 78 | def load_handler(_): 79 | subscribe_select_obj() 80 | common.manager.del_charmorphs() 81 | on_select() 82 | 83 | 84 | @bpy.app.handlers.persistent 85 | def select_handler(_): 86 | on_select() 87 | 88 | 89 | classes: list[type] = [None, prefs.CharMorphPrefs, VIEW3D_PT_CharMorph] 90 | 91 | uiprops = [bpy.types.PropertyGroup] 92 | 93 | for module in library, morphing, randomize, file_io, assets, hair, rig, rigify, finalize, pose: 94 | classes.extend(module.classes) 95 | if hasattr(module, "UIProps"): 96 | uiprops.append(module.UIProps) 97 | 98 | CharMorphUIProps = type("CharMorphUIProps", tuple(uiprops), {}) 99 | classes[0] = CharMorphUIProps 100 | 101 | class_register, class_unregister = bpy.utils.register_classes_factory(classes) 102 | 103 | 104 | def register(): 105 | # addon updater code and configurations 106 | # in case of broken version, try to register the updater first 107 | # so that users can revert back to a working version 108 | addon_updater_ops.register(bl_info) 109 | logger.debug("Charmorph register") 110 | charlib.library.load() 111 | class_register() 112 | common.register() 113 | bpy.types.WindowManager.charmorph_ui = bpy.props.PointerProperty(type=CharMorphUIProps, options={"SKIP_SAVE"}) 114 | subscribe_select_obj() 115 | 116 | bpy.app.handlers.load_post.append(load_handler) 117 | bpy.app.handlers.undo_post.append(undoredo_post) 118 | bpy.app.handlers.redo_post.append(undoredo_post) 119 | bpy.app.handlers.depsgraph_update_post.append(select_handler) 120 | 121 | cmedit.register() 122 | 123 | 124 | def unregister(): 125 | # addon updater unregister 126 | addon_updater_ops.unregister() 127 | logger.debug("Charmorph unregister") 128 | cmedit.unregister() 129 | 130 | for hlist in bpy.app.handlers: 131 | if not isinstance(hlist, list): 132 | continue 133 | for handler in hlist: 134 | if handler in (load_handler, select_handler): 135 | hlist.remove(handler) 136 | break 137 | 138 | bpy.msgbus.clear_by_owner(owner) 139 | del bpy.types.WindowManager.charmorph_ui 140 | common.manager.del_charmorphs() 141 | 142 | common.unregister() 143 | class_unregister() 144 | 145 | 146 | if __name__ == "__main__": 147 | register() 148 | -------------------------------------------------------------------------------- /assets.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 3 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | # 19 | # Copyright (C) 2020-2022 Michael Vigovsky 20 | 21 | import os, logging 22 | import bpy, bpy_extras # pylint: disable=import-error 23 | 24 | from .lib import fitting, morpher, utils 25 | from .lib.charlib import library, Asset 26 | from .common import manager as mm 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | def get_fitter(obj): 32 | if obj is mm.morpher.core.obj: 33 | return mm.morpher.fitter 34 | return morpher.get(obj).fitter 35 | 36 | 37 | def get_asset_conf(context): 38 | ui = context.window_manager.charmorph_ui 39 | item = ui.fitting_library_asset 40 | if item.startswith("char_"): 41 | obj = ui.fitting_char 42 | char = library.obj_char(obj) 43 | return char.assets.get(item[5:]) 44 | if item.startswith("add_"): 45 | return library.additional_assets.get(item[4:]) 46 | return None 47 | 48 | 49 | def do_refit(_ui, _ctx): 50 | f = mm.morpher.fitter 51 | if f: 52 | f.clear_cache() 53 | f.refit_all() 54 | 55 | 56 | def get_assets(ui, _): 57 | char = library.obj_char(ui.fitting_char) 58 | return [("char_" + k, k, '') for k in sorted(char.assets.keys())]\ 59 | + [("add_" + k, k, '') for k in sorted(library.additional_assets.keys())] 60 | 61 | 62 | class UIProps: 63 | fitting_char: bpy.props.PointerProperty( 64 | name="Char", 65 | description="Character for fitting", 66 | type=bpy.types.Object, 67 | poll=lambda ui, obj: 68 | utils.visible_mesh_poll(ui, obj) 69 | and ("charmorph_fit_id" not in obj.data or 'cm_alt_topo' in obj.data) 70 | ) 71 | fitting_asset: bpy.props.PointerProperty( 72 | name="Local asset", 73 | description="Asset for fitting", 74 | type=bpy.types.Object, 75 | poll=lambda ui, obj: utils.visible_mesh_poll(ui, obj) and ("charmorph_template" not in obj.data)) 76 | fitting_binder: bpy.props.EnumProperty( 77 | name="Algorighm", 78 | default="SOFT", 79 | items=[ 80 | ("SOFT", "Soft", "This algorithm tries to make softer look for clothing but can cause more intersections with character"), 81 | ("HARD", "Hard", "This algorighm is better for tight clothing but can cause more artifacts"), 82 | ], 83 | update=do_refit, 84 | description="Fitting algorighm") 85 | fitting_mask: bpy.props.EnumProperty( 86 | name="Mask", 87 | default="COMB", 88 | items=[ 89 | ("NONE", "No mask", "Don't mask character at all"), 90 | ("SEPR", "Separate", "Use separate mask vertex groups and modifiers for each asset"), 91 | ("COMB", "Combined", "Use combined vertex group and modifier for all character assets"), 92 | ], 93 | description="Mask parts of character that are invisible under clothing") 94 | fitting_transforms: bpy.props.BoolProperty( 95 | name="Apply transforms", 96 | default=True, 97 | description="Apply object transforms before fitting") 98 | fitting_weights: bpy.props.EnumProperty( 99 | name="Weights", 100 | default="ORIG", 101 | items=[ 102 | ("NONE", "None", "Don't transfer weights and armature modifiers to the asset"), 103 | ("ORIG", "Original", "Use original weights from character library"), 104 | ("OBJ", "Object", "Use weights directly from object" 105 | "(use it if you manually weight-painted the character before fitting the asset)"), 106 | ], 107 | description="Select source for armature deform weights") 108 | fitting_weights_ovr: bpy.props.BoolProperty( 109 | name="Weights overwrite", 110 | default=False, 111 | description="Overwrite existing asset weights") 112 | fitting_library_asset: bpy.props.EnumProperty( 113 | name="Library asset", 114 | description="Select asset from library", 115 | items=get_assets) 116 | fitting_library_dir: bpy.props.StringProperty( 117 | name="Library dir", 118 | description="Additional library directory", 119 | update=lambda ui, _: library.update_additional_assets(ui.fitting_library_dir), 120 | subtype='DIR_PATH') 121 | 122 | 123 | class CHARMORPH_PT_Assets(bpy.types.Panel): 124 | bl_label = "Assets" 125 | bl_parent_id = "VIEW3D_PT_CharMorph" 126 | bl_space_type = 'VIEW_3D' 127 | bl_region_type = 'UI' 128 | bl_options = {"DEFAULT_CLOSED"} 129 | bl_order = 7 130 | 131 | @classmethod 132 | def poll(cls, context): 133 | return context.mode == "OBJECT" # is it neccesary? 134 | 135 | def draw(self, context): 136 | ui = context.window_manager.charmorph_ui 137 | l = self.layout 138 | col = l.column(align=True) 139 | col.prop(ui, "fitting_char") 140 | col.prop(ui, "fitting_asset") 141 | l.prop(ui, "fitting_binder") 142 | l.prop(ui, "fitting_mask") 143 | col = l.column(align=True) 144 | col.prop(ui, "fitting_weights") 145 | col.prop(ui, "fitting_weights_ovr") 146 | col.prop(ui, "fitting_transforms") 147 | l.separator() 148 | if ui.fitting_asset and 'charmorph_fit_id' in ui.fitting_asset.data: 149 | l.operator("charmorph.unfit") 150 | else: 151 | l.operator("charmorph.fit_local") 152 | l.separator() 153 | l.operator("charmorph.fit_external") 154 | asset = get_asset_conf(context) or fitting.EmptyAsset 155 | col = l.column(align=True) 156 | col.label(text="Author: " + asset.author) 157 | col.label(text="License: " + asset.license) 158 | l.prop(ui, "fitting_library_asset") 159 | 160 | l.operator("charmorph.fit_library") 161 | l.prop(ui, "fitting_library_dir") 162 | l.separator() 163 | 164 | 165 | def mesh_obj(obj): 166 | if obj and obj.type == "MESH": 167 | return obj 168 | return None 169 | 170 | 171 | def get_char(context): 172 | obj = mesh_obj(context.window_manager.charmorph_ui.fitting_char) 173 | if not obj or ('charmorph_fit_id' in obj.data and 'cm_alt_topo' not in obj.data): 174 | return None 175 | return obj 176 | 177 | 178 | def fitter_from_ctx(context): 179 | return get_fitter(get_char(context)) 180 | 181 | 182 | def get_asset_obj(context): 183 | return mesh_obj(context.window_manager.charmorph_ui.fitting_asset) 184 | 185 | 186 | class OpFitLocal(bpy.types.Operator): 187 | bl_idname = "charmorph.fit_local" 188 | bl_label = "Fit local asset" 189 | bl_description = "Fit selected local asset to the character" 190 | bl_options = {"UNDO"} 191 | 192 | @classmethod 193 | def poll(cls, context): 194 | if context.mode != "OBJECT": 195 | return False 196 | char = get_char(context) 197 | if not char: 198 | return False 199 | asset = get_asset_obj(context) 200 | if not asset or asset == char: 201 | return False 202 | return True 203 | 204 | def execute(self, context): # pylint: disable=no-self-use 205 | char = get_char(context) 206 | asset = get_asset_obj(context) 207 | if mm.morpher.core.obj is asset: 208 | mm.create_charmorphs(char) 209 | if context.window_manager.charmorph_ui.fitting_transforms: 210 | utils.apply_transforms(asset, char) 211 | get_fitter(char).fit_new((asset,)) 212 | return {"FINISHED"} 213 | 214 | 215 | def fitExtPoll(context): 216 | return context.mode == "OBJECT" and get_char(context) 217 | 218 | 219 | class OpFitExternal(bpy.types.Operator, bpy_extras.io_utils.ImportHelper): 220 | bl_idname = "charmorph.fit_external" 221 | bl_label = "Fit from file" 222 | bl_description = "Import and fit an asset from external .blend file" 223 | bl_options = {"UNDO"} 224 | 225 | filename_ext = ".blend" 226 | filter_glob: bpy.props.StringProperty(default="*.blend", options={'HIDDEN'}) 227 | 228 | @classmethod 229 | def poll(cls, context): 230 | return fitExtPoll(context) 231 | 232 | def execute(self, context): 233 | name, _ = os.path.splitext(self.filepath) 234 | if fitter_from_ctx(context).fit_import((Asset(name, self.filepath),)): 235 | return {"FINISHED"} 236 | self.report({'ERROR'}, "Import failed") 237 | return {"CANCELLED"} 238 | 239 | 240 | class OpFitLibrary(bpy.types.Operator): 241 | bl_idname = "charmorph.fit_library" 242 | bl_label = "Fit from library" 243 | bl_description = "Import and fit an asset from library" 244 | bl_options = {"UNDO"} 245 | 246 | @classmethod 247 | def poll(cls, context): 248 | return fitExtPoll(context) 249 | 250 | def execute(self, context): 251 | asset_data = get_asset_conf(context) 252 | if asset_data is None: 253 | self.report({'ERROR'}, "Asset is not found") 254 | return {"CANCELLED"} 255 | if fitter_from_ctx(context).fit_import((asset_data,)): 256 | return {"FINISHED"} 257 | self.report({'ERROR'}, "Import failed") 258 | return {"CANCELLED"} 259 | 260 | 261 | class OpUnfit(bpy.types.Operator): 262 | bl_idname = "charmorph.unfit" 263 | bl_label = "Unfit" 264 | bl_options = {"UNDO"} 265 | 266 | @classmethod 267 | def poll(cls, context): 268 | asset = get_asset_obj(context) 269 | return context.mode == "OBJECT" and asset and 'charmorph_fit_id' in asset.data 270 | 271 | def execute(self, context): # pylint: disable=no-self-use 272 | ui = context.window_manager.charmorph_ui 273 | asset = get_asset_obj(context) 274 | 275 | if asset.parent: 276 | if ui.fitting_transforms: 277 | utils.copy_transforms(asset, asset.parent) 278 | asset.parent = asset.parent.parent 279 | if asset.parent and asset.parent.type == "ARMATURE": 280 | if ui.fitting_transforms: 281 | utils.copy_transforms(asset, asset.parent) #FIXME: Make transforms sum 282 | asset.parent = asset.parent.parent 283 | 284 | mask = fitting.mask_name(asset) 285 | for char in {asset.parent, ui.fitting_char}: # pylint: disable=use-sequence-for-iteration 286 | if not char or char == asset or 'charmorph_fit_id' in char.data: 287 | continue 288 | f = get_fitter(char) 289 | f.remove_cache(asset) 290 | if mask in char.modifiers: 291 | char.modifiers.remove(char.modifiers[mask]) 292 | if mask in char.vertex_groups: 293 | char.vertex_groups.remove(char.vertex_groups[mask]) 294 | f.children = None 295 | if "cm_mask_combined" in char.modifiers: 296 | f.recalc_comb_mask() 297 | if char.data.get("charmorph_asset_morphs"): 298 | name = asset.data.get("charmorph_asset") 299 | if name: 300 | f.mcore.remove_asset_morph(name) 301 | f.morpher.update() 302 | try: 303 | del asset.data['charmorph_fit_id'] 304 | except KeyError: 305 | pass 306 | 307 | if asset.data.shape_keys and "charmorph_fitting" in asset.data.shape_keys.key_blocks: 308 | asset.shape_key_remove(asset.data.shape_keys.key_blocks["charmorph_fitting"]) 309 | 310 | mm.last_object = asset # Prevent swithing morpher to asset object 311 | return {"FINISHED"} 312 | 313 | 314 | classes = [OpFitLocal, OpUnfit, OpFitExternal, OpFitLibrary, CHARMORPH_PT_Assets] 315 | -------------------------------------------------------------------------------- /cmedit/__init__.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 3 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | # 19 | # Copyright (C) 2020-2022 Michael Vigovsky 20 | 21 | import logging 22 | 23 | import bpy # pylint: disable=import-error 24 | 25 | from ..lib import utils 26 | from . import assets, file_io, rigging, vg_calc, symmetry 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | 31 | class VIEW3D_PT_CMEdit(bpy.types.Panel): 32 | bl_idname = "VIEW3D_PT_CMEdit" 33 | bl_label = "CharMorph Editor" 34 | bl_space_type = 'VIEW_3D' 35 | bl_region_type = 'UI' 36 | bl_category = "CharMorph" 37 | bl_options = {"DEFAULT_CLOSED"} 38 | bl_order = 2 39 | 40 | def draw(self, _): # pylint: disable=no-self-use 41 | pass 42 | 43 | 44 | class CMEditUIProps(bpy.types.PropertyGroup, vg_calc.UIProps, assets.UIProps): 45 | char_obj: bpy.props.PointerProperty( 46 | name="Char", 47 | description="Character mesh for rigging and asset fitting", 48 | type=bpy.types.Object, 49 | poll=utils.visible_mesh_poll, 50 | ) 51 | 52 | 53 | classes = [CMEditUIProps, VIEW3D_PT_CMEdit] 54 | 55 | for module in assets, rigging, vg_calc, symmetry, file_io: 56 | classes.extend(module.classes) 57 | 58 | register_classes, unregister_classes = bpy.utils.register_classes_factory(classes) 59 | 60 | 61 | def register(): 62 | register_classes() 63 | bpy.types.WindowManager.cmedit_ui = bpy.props.PointerProperty(type=CMEditUIProps, options={"SKIP_SAVE"}) 64 | 65 | 66 | def unregister(): 67 | del bpy.types.WindowManager.cmedit_ui 68 | unregister_classes() 69 | 70 | 71 | if __name__ == "__main__": 72 | register() 73 | -------------------------------------------------------------------------------- /cmedit/assets.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 3 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | # 19 | # Copyright (C) 2020-2022 Michael Vigovsky 20 | 21 | import numpy 22 | 23 | import bpy, bpy_extras, bmesh # pylint: disable=import-error 24 | 25 | from . import file_io 26 | from ..lib import morpher_cores, fit_calc, utils 27 | 28 | class CMEDIT_PT_Assets(bpy.types.Panel): 29 | bl_label = "Assets" 30 | bl_parent_id = "VIEW3D_PT_CMEdit" 31 | bl_space_type = 'VIEW_3D' 32 | bl_region_type = 'UI' 33 | bl_category = "CharMorph" 34 | bl_options = {"DEFAULT_CLOSED"} 35 | bl_order = 3 36 | 37 | def draw(self, context): 38 | ui = context.window_manager.cmedit_ui 39 | l = self.layout 40 | l.prop(ui, "char_obj") 41 | l.prop(ui, "asset_obj") 42 | l.prop(ui, "retarg_sk_src") 43 | l.prop(ui, "retarg_sk_dst") 44 | l.operator("cmedit.retarget") 45 | l.operator("cmedit.final_to_sk") 46 | l.operator("cmedit.fold_export") 47 | 48 | 49 | def get_shape_keys(obj): 50 | sk = obj.data.shape_keys 51 | if not sk or not sk.key_blocks: 52 | return () 53 | return [("sk_" + sk.name, sk.name, '') for sk in sk.key_blocks] 54 | 55 | 56 | def get_shape_keys_with_morpher(ui, _): 57 | result = [("m_b", "(morpher basis)", ""), ("m_f", "(morpher final)", "")] 58 | if ui.char_obj: 59 | result.extend(get_shape_keys(ui.char_obj)) 60 | return result 61 | 62 | 63 | def get_fold_shape_keys(_, context): 64 | ui = context.window_manager.cmedit_ui 65 | return [("_", "(none)", "")] + get_shape_keys(ui.char_obj) 66 | 67 | 68 | def retarg_get_geom(obj, name): 69 | if name.startswith("m_"): 70 | mcore = morpher_cores.get(obj) 71 | if name == "m_b": 72 | return fit_calc.geom_morpher(mcore) 73 | if name == "m_f": 74 | return fit_calc.geom_morpher_final(mcore) 75 | if name.startswith("sk_"): 76 | return fit_calc.geom_shapekey(obj.data, obj.data.shape_keys.key_blocks[name[3:]]) 77 | raise ValueError("Invalid retarget geom name: " + name) 78 | 79 | 80 | class OpRetarget(bpy.types.Operator): 81 | bl_idname = "cmedit.retarget" 82 | bl_label = "Retarget asset" 83 | bl_description = "Refit asset from selected source shape key to target one" 84 | bl_options = {"UNDO"} 85 | 86 | @classmethod 87 | def poll(cls, context): 88 | ui = context.window_manager.cmedit_ui 89 | return ui.char_obj and ui.asset_obj 90 | 91 | def execute(self, context): 92 | ui = context.window_manager.cmedit_ui 93 | char = ui.char_obj 94 | 95 | geom_src = retarg_get_geom(char, ui.retarg_sk_src) 96 | geom_dst = retarg_get_geom(char, ui.retarg_sk_dst) 97 | if len(geom_src.verts) != len(geom_dst.verts): 98 | self.report({"ERROR"}, f"Vertex count mismatch: {len(geom_src.verts)} != {len(geom_dst.verts)}. " 99 | "Can't retarget alt_topo morpher states with shape keys.") 100 | return {"FINISHED"} 101 | 102 | if not ui.asset_obj.data.shape_keys: 103 | ui.asset_obj.shape_key_add(name="Basis", from_mix=False) 104 | sk = ui.asset_obj.shape_key_add(name="retarget", from_mix=False) 105 | sk.value = 1 106 | 107 | f = fit_calc.FitCalculator(geom_src) 108 | fit = f.get_binding(ui.asset_obj).fit(geom_dst.verts - geom_src.verts) 109 | fit += utils.get_basis_numpy(ui.asset_obj) 110 | sk.data.foreach_set("co", fit.reshape(-1)) 111 | 112 | return {"FINISHED"} 113 | 114 | 115 | class OpFinalToSk(bpy.types.Operator): 116 | bl_idname = "cmedit.final_to_sk" 117 | bl_label = "Final to shape key" 118 | bl_options = {"UNDO"} 119 | bl_description = "Add shape key from final form (shape keys + modifiers)."\ 120 | "Can be useful because of problems with applying of corrective smooth modifier." 121 | 122 | @classmethod 123 | def poll(cls, context): 124 | return context.object 125 | 126 | def execute(self, context): # pylint: disable=no-self-use 127 | if not context.object.data.shape_keys: 128 | context.object.shape_key_add(name="Basis", from_mix=False) 129 | sk = context.object.shape_key_add(name="final", from_mix=False) 130 | bm = bmesh.new() 131 | try: 132 | utils.bmesh_cage_object(bm, context) 133 | a = [v.co[i] for v in bm.verts for i in range(3)] 134 | sk.data.foreach_set("co", a) 135 | finally: 136 | bm.free() 137 | 138 | return {"FINISHED"} 139 | 140 | 141 | def get_sk_verts(ui, sk): 142 | return utils.verts_to_numpy(ui.char_obj.data.shape_keys.key_blocks[sk[3:]].data) 143 | 144 | 145 | class OpExportFold(bpy.types.Operator, bpy_extras.io_utils.ExportHelper): 146 | bl_idname = "cmedit.fold_export" 147 | bl_label = "Export fitting data" 148 | bl_description = "Export data for fitting acceleration and correction. Use char as proxy mesh and asset as full asset" 149 | filename_ext = ".npz" 150 | 151 | precision: file_io.prop_precision 152 | cutoff: file_io.prop_cutoff 153 | filter_glob: bpy.props.StringProperty(default="*.npz", options={'HIDDEN'}) 154 | sk_binding: bpy.props.EnumProperty( 155 | name="Binding shape key", 156 | description="Shape key for binding", 157 | items=get_fold_shape_keys, 158 | ) 159 | sk_weights: bpy.props.EnumProperty( 160 | name="Weights shape key", 161 | description="Shape key for rig weights transfer", 162 | items=get_fold_shape_keys, 163 | ) 164 | 165 | @classmethod 166 | def poll(cls, context): 167 | ui = context.window_manager.cmedit_ui 168 | return ui.char_obj and ui.asset_obj 169 | 170 | def execute(self, context): 171 | ui = context.window_manager.cmedit_ui 172 | for obj in ui.char_obj, ui.asset_obj: 173 | if obj.data.is_editmode: 174 | obj.update_from_editmode() 175 | f = fit_calc.FitCalculator(fit_calc.geom_mesh(ui.char_obj.data)) 176 | binding = f.get_binding(ui.asset_obj)[0] 177 | 178 | if self.sk_binding.startswith("sk_"): 179 | verts = get_sk_verts(ui, self.sk_binding) 180 | else: 181 | verts = f.geom.verts 182 | 183 | faces = numpy.array(f.geom.faces, dtype=numpy.uint32) 184 | faces = faces.astype(file_io.get_bits(faces.reshape(-1)), casting="same_kind") 185 | 186 | data = { 187 | "verts": verts.astype(file_io.float_dtype(self.precision), casting="same_kind"), 188 | "faces": faces, 189 | "pos": binding[0], 190 | "idx": binding[1].astype(file_io.get_bits(binding[1]), casting="same_kind"), 191 | "weights": binding[2].astype(numpy.float32, casting="same_kind"), 192 | } 193 | 194 | if self.sk_weights.startswith("sk_"): 195 | diff = get_sk_verts(ui, self.sk_weights) - verts 196 | idx = file_io.morph_idx_epsilon(diff, self.cutoff) 197 | data["wmorph_idx"] = idx 198 | data["wmorph_delta"] = diff[idx].astype(file_io.float_dtype(self.precision), casting="same_kind") 199 | 200 | numpy.savez(self.filepath, **data) 201 | return {"FINISHED"} 202 | 203 | 204 | class UIProps: 205 | asset_obj: bpy.props.PointerProperty( 206 | name="Asset", 207 | description="Asset mesh for retargetting", 208 | type=bpy.types.Object, 209 | poll=utils.visible_mesh_poll, 210 | ) 211 | retarg_sk_src: bpy.props.EnumProperty( 212 | name="Source shape key", 213 | description="Source shape key for retarget", 214 | items=get_shape_keys_with_morpher, 215 | ) 216 | retarg_sk_dst: bpy.props.EnumProperty( 217 | name="Target shape key", 218 | description="Target shape key for retarget", 219 | items=get_shape_keys_with_morpher, 220 | ) 221 | 222 | 223 | classes = OpRetarget, OpFinalToSk, OpExportFold, CMEDIT_PT_Assets 224 | -------------------------------------------------------------------------------- /cmedit/rigging.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 3 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | # 19 | # Copyright (C) 2021 Michael Vigovsky 20 | 21 | import os, logging, json 22 | import bpy, bpy_extras, mathutils # pylint: disable=import-error 23 | 24 | from ..lib import rigging, drivers, utils 25 | from . import vg_calc 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | def kdtree_from_bones(bones): 31 | kd = mathutils.kdtree.KDTree(len(bones) * 2) 32 | for i, bone in enumerate(bones): 33 | if not bone.use_connect: 34 | kd.insert(bone.head, i * 2) 35 | kd.insert(bone.tail, i * 2 + 1) 36 | kd.balance() 37 | return kd 38 | 39 | 40 | def selected_joints(context): 41 | joints = {} 42 | for bone in context.object.data.edit_bones: 43 | if bone.select_head: 44 | if bone.use_connect: 45 | b = bone.parent 46 | joints[f"joint_{b.name}_tail"] = (b, "tail") 47 | else: 48 | joints[f"joint_{bone.name}_head"] = (bone, "head") 49 | if bone.select_tail: 50 | joints[f"joint_{bone.name}_tail"] = (bone, "tail") 51 | return joints 52 | 53 | 54 | def joint_list_extended(context, xmirror): 55 | result = selected_joints(context) 56 | bones = context.object.data.edit_bones 57 | kd = kdtree_from_bones(bones) 58 | for name, (bone, attr) in list(result.items()): 59 | co = getattr(bone, attr) 60 | checklist = [co] 61 | if xmirror: 62 | checklist.append(mathutils.Vector((-co[0], co[1], co[2]))) 63 | for co2 in checklist: 64 | for _, jid, _ in kd.find_range(co2, 0.00001): 65 | bone2 = bones[jid // 2] 66 | if list(utils.bone_get_collections(bone2)) != list(utils.bone_get_collections(bone)): 67 | continue 68 | attr = "head" if jid & 1 == 0 else "tail" 69 | name = f"joint_{bone2.name}_{attr}" 70 | if name not in result: 71 | result[name] = (bone2, attr) 72 | return result 73 | 74 | 75 | def editable_bones_poll(context): 76 | return context.mode == "EDIT_ARMATURE" and context.window_manager.cmedit_ui.char_obj 77 | 78 | 79 | class OpStoreRoll(bpy.types.Operator): 80 | bl_options = {"UNDO"} 81 | 82 | @classmethod 83 | def poll(cls, context): 84 | return editable_bones_poll(context) 85 | 86 | def execute(self, context): 87 | for bone in context.selected_editable_bones: 88 | for axis in ('x', 'z'): 89 | if axis != self.axis and "charmorph_axis_" + axis in bone: 90 | del bone["charmorph_axis_" + axis] 91 | bone["charmorph_axis_" + self.axis] = list(getattr(bone, self.axis + "_axis")) 92 | return {"FINISHED"} 93 | 94 | 95 | class OpStoreRollX(OpStoreRoll): 96 | bl_idname = "cmedit.store_roll_x" 97 | bl_label = "Save bone roll X axis" 98 | axis = "x" 99 | 100 | 101 | class OpStoreRollZ(OpStoreRoll): 102 | bl_idname = "cmedit.store_roll_z" 103 | bl_label = "Save bone roll Z axis" 104 | axis = "z" 105 | 106 | 107 | class OpJointsToVG(bpy.types.Operator): 108 | bl_idname = "cmedit.joints_to_vg" 109 | bl_label = "Selected joints to VG" 110 | bl_description = "Move selected joints according to their vertex groups" 111 | bl_options = {"UNDO"} 112 | 113 | @classmethod 114 | def poll(cls, context): 115 | return editable_bones_poll(context) 116 | 117 | def execute(self, context): # pylint: disable=no-self-use 118 | ui = context.window_manager.cmedit_ui 119 | r = rigging.Rigger(context) 120 | r.joints_from_char(ui.char_obj) 121 | r.run(joint_list_extended(context, False).values()) 122 | return {"FINISHED"} 123 | 124 | 125 | class OpCalcVg(bpy.types.Operator): 126 | bl_idname = "cmedit.calc_vg" 127 | bl_label = "Recalc vertex groups" 128 | bl_description = "Recalculate joint vertex groups according to selected method" 129 | bl_options = {"UNDO"} 130 | 131 | @classmethod 132 | def poll(cls, context): 133 | return editable_bones_poll(context) 134 | 135 | def execute(self, context): # pylint: disable=no-self-use 136 | ui = context.window_manager.cmedit_ui 137 | joints = joint_list_extended(context, ui.vg_xmirror) 138 | 139 | err = vg_calc.VGCalculator(context.object, ui.char_obj, ui).run(joints) 140 | if isinstance(err, str): 141 | self.report({"ERROR"}, err) 142 | elif ui.vg_auto_snap: 143 | bpy.ops.cmedit.joints_to_vg() 144 | 145 | return {"FINISHED"} 146 | 147 | 148 | class OpRigifyFinalize(bpy.types.Operator): 149 | bl_idname = "cmedit.rigify_finalize" 150 | bl_label = "Finalize Rigify rig" 151 | bl_description = "Fix Rigify rig to make it suitable for char (in combo box)."\ 152 | "It adds deform flag to necessary bones and fixes facial bendy bones" 153 | bl_options = {"UNDO"} 154 | 155 | @classmethod 156 | def poll(cls, context): 157 | return context.object and context.object.type == "ARMATURE" and context.window_manager.cmedit_ui.char_obj 158 | 159 | def execute(self, context): # pylint: disable=no-self-use 160 | rigging.rigify_finalize(context.object, context.window_manager.cmedit_ui.char_obj) 161 | return {"FINISHED"} 162 | 163 | 164 | class OpRigifyTweaks(bpy.types.Operator, bpy_extras.io_utils.ImportHelper): 165 | bl_idname = "cmedit.rigify_tweaks" 166 | bl_label = "Apply rigify tweaks" 167 | bl_description = "Apply rigify tweaks from yaml file" 168 | bl_options = {"UNDO"} 169 | 170 | filter_glob: bpy.props.StringProperty(default="*.yaml", options={'HIDDEN'}) 171 | 172 | @classmethod 173 | def poll(cls, context): 174 | return context.object and context.object.type == "ARMATURE" 175 | 176 | def execute(self, context): # pylint: disable=no-self-use 177 | with open(self.filepath, "r", encoding="utf-8") as f: 178 | tweaks = utils.load_yaml(f) 179 | pre_tweaks, editmode_tweaks, post_tweaks = rigging.unpack_tweaks(os.path.dirname(self.filepath), tweaks) 180 | old_mode = context.mode 181 | if old_mode.startswith("EDIT_"): 182 | old_mode = "EDIT" 183 | if len(pre_tweaks) > 0: 184 | bpy.ops.object.mode_set(mode="OBJECT") 185 | for tweak in pre_tweaks: 186 | rigging.apply_tweak(context.object, tweak) 187 | 188 | if len(editmode_tweaks) > 0: 189 | bpy.ops.object.mode_set(mode="EDIT") 190 | for tweak in editmode_tweaks: 191 | rigging.apply_editmode_tweak(context, tweak) 192 | 193 | if len(post_tweaks) > 0: 194 | bpy.ops.object.mode_set(mode="OBJECT") 195 | for tweak in post_tweaks: 196 | rigging.apply_tweak(context.object, tweak) 197 | bpy.ops.object.mode_set(mode=old_mode) 198 | return {"FINISHED"} 199 | 200 | 201 | class OpCleanupJoints(bpy.types.Operator): 202 | bl_idname = "cmedit.cleanup_joints" 203 | bl_label = "Cleanup joint VGs" 204 | bl_description = "Remove all unused joint_* vertex groups. Metarig must be selected" 205 | bl_options = {"UNDO"} 206 | 207 | @classmethod 208 | def poll(cls, context): 209 | return context.window_manager.cmedit_ui.char_obj and context.object and context.object.type == "ARMATURE" 210 | 211 | def execute(self, context): 212 | char = context.window_manager.cmedit_ui.char_obj 213 | joints = rigging.get_joints(context.object) 214 | joints = { 215 | f"joint_{bone.name}_{attr}" 216 | for bone, attr in joints 217 | if attr != "head" or not utils.is_true(bone.get("charmorph_connected")) 218 | } 219 | if len(joints) == 0: 220 | self.report({'ERROR'}, "No joints found") 221 | return {"CANCELLED"} 222 | 223 | for vg in list(char.vertex_groups): 224 | if vg.name.startswith("joint_") and vg.name not in joints: 225 | logger.debug("removing group %s", vg.name) 226 | char.vertex_groups.remove(vg) 227 | 228 | return {"FINISHED"} 229 | 230 | 231 | class OpBBoneHandles(bpy.types.Operator): 232 | bl_idname = "cmedit.bbone_handles" 233 | bl_label = "B-Bone handles" 234 | bl_description = "Add custom handles same to automatic for selected bbones" 235 | bl_options = {"UNDO"} 236 | 237 | @classmethod 238 | def poll(cls, context): 239 | return context.mode == "EDIT_ARMATURE" 240 | 241 | def execute(self, context): # pylint: disable=no-self-use 242 | for bone in context.selected_editable_bones: 243 | if bone.bbone_segments < 2: 244 | continue 245 | if bone.bbone_handle_type_start == "AUTO": 246 | bone.bbone_handle_type_start = "ABSOLUTE" 247 | if abs(bone.bbone_easein) > 0.01 and bone.parent and bone.use_connect: 248 | bone.bbone_custom_handle_start = bone.parent 249 | if bone.bbone_handle_type_end == "AUTO": 250 | bone.bbone_handle_type_end = "ABSOLUTE" 251 | if abs(bone.bbone_easeout) > 0.01: 252 | children = bone.children 253 | if len(children) == 1: 254 | bone.bbone_custom_handle_end = bone.children[0] 255 | return {"FINISHED"} 256 | 257 | 258 | class OpDrExport(bpy.types.Operator, bpy_extras.io_utils.ExportHelper): 259 | bl_idname = "cmedit.dr_export" 260 | bl_label = "Export drivers" 261 | bl_description = 'Export rig drivers. Have rig selected And character mesh chosen above' 262 | filename_ext = ".json" 263 | 264 | filter_glob: bpy.props.StringProperty(default="*.json", options={'HIDDEN'}) 265 | 266 | @classmethod 267 | def poll(cls, context): 268 | ui = context.window_manager.cmedit_ui 269 | return context.object and context.object.type == "ARMATURE" and ui.char_obj 270 | 271 | def execute(self, context): 272 | ui = context.window_manager.cmedit_ui 273 | with open(self.filepath, "w", encoding="utf-8") as f: 274 | json.dump(drivers.export(char=ui.char_obj, rig=context.object), f, indent=4) 275 | return {"FINISHED"} 276 | 277 | 278 | class OpDrImport(bpy.types.Operator, bpy_extras.io_utils.ImportHelper): 279 | bl_idname = "cmedit.dr_import" 280 | bl_label = "Import drivers" 281 | bl_description = "Import rig drivers. Have rig selected And character mesh chosen above." 282 | bl_options = {"UNDO"} 283 | 284 | filter_glob: bpy.props.StringProperty(default="*.json", options={'HIDDEN'}) 285 | overwrite: bpy.props.BoolProperty( 286 | name="Overwrite existing drivers", 287 | description="Overwrite any existing drivers with imported ones", 288 | default=True, 289 | ) 290 | 291 | @classmethod 292 | def poll(cls, context): 293 | ui = context.window_manager.cmedit_ui 294 | return context.object and context.object.type == "ARMATURE" and ui.char_obj 295 | 296 | def execute(self, context): 297 | ui = context.window_manager.cmedit_ui 298 | drivers.dimport( 299 | utils.parse_file(self.filepath, json.load, {}), self.overwrite, 300 | char=ui.char_obj, rig=context.object) 301 | return {"FINISHED"} 302 | 303 | 304 | class OpDrClean(bpy.types.Operator): 305 | bl_idname = "cmedit.dr_clean" 306 | bl_label = "Clean drivers" 307 | bl_description = "Delete all drivers from selected object" 308 | bl_options = {"UNDO"} 309 | 310 | @classmethod 311 | def poll(cls, context): 312 | return context.object 313 | 314 | def execute(self, context): 315 | drivers.clear_obj(context.object) 316 | return {"FINISHED"} 317 | 318 | 319 | class CMEDIT_PT_Rigging(bpy.types.Panel): 320 | bl_label = "Rigging" 321 | bl_parent_id = "VIEW3D_PT_CMEdit" 322 | bl_space_type = 'VIEW_3D' 323 | bl_region_type = 'UI' 324 | bl_category = "CharMorph" 325 | bl_options = {"DEFAULT_CLOSED"} 326 | bl_order = 1 327 | 328 | def draw(self, context): 329 | ui = context.window_manager.cmedit_ui 330 | l = self.layout 331 | l.prop(ui, "char_obj") 332 | l.operator("cmedit.cleanup_joints") 333 | l.operator("cmedit.store_roll_x") 334 | l.operator("cmedit.store_roll_z") 335 | l.operator("cmedit.bbone_handles") 336 | l.operator("cmedit.rigify_finalize") 337 | l.operator("cmedit.rigify_tweaks") 338 | l.separator() 339 | l.operator("cmedit.dr_export") 340 | l.operator("cmedit.dr_import") 341 | l.operator("cmedit.dr_clean") 342 | l.separator() 343 | l.operator("cmedit.joints_to_vg") 344 | 345 | 346 | classes = (OpJointsToVG, OpCalcVg, OpRigifyFinalize, OpCleanupJoints, OpBBoneHandles, OpRigifyTweaks, 347 | OpDrExport, OpDrImport, OpDrClean, OpStoreRollX, OpStoreRollZ, CMEDIT_PT_Rigging) 348 | -------------------------------------------------------------------------------- /cmedit/symmetry.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 3 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | # 19 | # Copyright (C) 2021 Michael Vigovsky 20 | 21 | import bpy, mathutils # pylint: disable=import-error 22 | 23 | from ..lib import utils 24 | 25 | 26 | def is_deform(group_name): 27 | return group_name.startswith("DEF-") or group_name.startswith("MCH-") or group_name.startswith("ORG-") 28 | 29 | 30 | def swap_l_r(name): 31 | new_name = name.replace(".L", ".R").replace("_L_", "_R_").replace(".l", ".r").replace("_l_", "_r_") 32 | if new_name != name: 33 | return new_name 34 | return name.replace(".R", ".L").replace("_R_", "_L_").replace(".r", ".l").replace("_r_", "_l_") 35 | 36 | 37 | def counterpart_vertex(verts, kd, v): 38 | counterparts = kd.find_range(mathutils.Vector((-v.co[0], v.co[1], v.co[2])), 0.00001) 39 | if len(counterparts) == 0: 40 | print(v.index, v.co, "no counterpart") 41 | return None 42 | if len(counterparts) > 1: 43 | print(v.index, v.co, "multiple counterparts:", counterparts) 44 | return None 45 | return verts[counterparts[0][1]] 46 | 47 | 48 | class OpCheckSymmetry(bpy.types.Operator): 49 | bl_idname = "cmedit.check_symmetry" 50 | bl_label = "Check symmetry" 51 | bl_description = "Check X axis symmetry and print results to system console" 52 | 53 | @classmethod 54 | def poll(cls, context): 55 | return context.object and context.object.type == "MESH" 56 | 57 | def execute(self, context): # pylint: disable=no-self-use 58 | obj = context.object 59 | mesh = obj.data 60 | kd = utils.kdtree_from_verts(mesh.vertices) 61 | 62 | def groups_to_list(group): 63 | return [(obj.vertex_groups[g.group].name, g.weight) for g in group] 64 | for v in mesh.vertices: 65 | if v.co[0] == 0 or v.co[0] == -0: 66 | continue 67 | v2 = counterpart_vertex(mesh.vertices, kd, v) 68 | if v2 is None: 69 | continue 70 | if len(v.groups) != len(v2.groups): 71 | print(v.index, v.co, "vg mismatch:", groups_to_list(v.groups), groups_to_list(v2.groups)) 72 | 73 | gdict = {obj.vertex_groups[g.group].name: (obj.vertex_groups[g.group], g.weight) for g in v2.groups} 74 | 75 | wgt = 0 76 | 77 | for g in v.groups: 78 | g1 = obj.vertex_groups[g.group] 79 | if is_deform(g1.name): 80 | wgt += g.weight 81 | g2_name = swap_l_r(g1.name) 82 | g2, g2_weight = gdict.get(g2_name, (None, None)) 83 | if not g2: 84 | print(v.index, v.co, g1.name, g.weight, "vg counterpart not found") 85 | continue 86 | if abs(g.weight - g2_weight) >= 0.01: 87 | print(v.index, v.co, g1.name, "vg weight mismatch:", g.weight, g2_weight) 88 | continue 89 | 90 | if abs(wgt - 1) >= 0.0001: 91 | print(v.index, v.co, "not normalized:", wgt) 92 | return {"FINISHED"} 93 | 94 | 95 | def get_group_weight(v, idx): 96 | for g in v.groups: 97 | if g.group == idx: 98 | return g.weight 99 | return 0 100 | 101 | 102 | class OpSymmetrizeVG(bpy.types.Operator): 103 | bl_idname = "cmedit.symmetrize_vg" 104 | bl_label = "Symmetrize current VG" 105 | bl_description = "Symmetrize current vertex group using X axis" 106 | bl_options = {"UNDO"} 107 | 108 | @classmethod 109 | def poll(cls, context): 110 | return context.object and context.object.type == "MESH" and context.object.vertex_groups.active 111 | 112 | def execute(self, context): # pylint: disable=no-self-use 113 | obj = context.object 114 | vg = obj.vertex_groups.active 115 | idx = vg.index 116 | mesh = obj.data 117 | kd = utils.kdtree_from_verts(mesh.vertices) 118 | for v in mesh.vertices: 119 | if v.co[0] < 1e-30: 120 | continue 121 | v2 = counterpart_vertex(mesh.vertices, kd, v) 122 | if v2 is None: 123 | continue 124 | w = (get_group_weight(v, idx) + get_group_weight(v2, idx)) / 2 125 | if w >= 1e-5: 126 | vg.add([v.index, v2.index], w, "REPLACE") 127 | else: 128 | vg.remove([v.index, v2.index]) 129 | return {"FINISHED"} 130 | 131 | 132 | class OpSymmetrizeWeights(bpy.types.Operator): 133 | bl_idname = "cmedit.symmetrize_weights" 134 | bl_label = "Normalize+symmetrize weights" 135 | bl_description = "Normalize and symmetrize selected vertices using X axis" 136 | bl_options = {"UNDO"} 137 | 138 | @classmethod 139 | def poll(cls, context): 140 | return context.object and context.object.type == "MESH" 141 | 142 | def execute(self, context): 143 | obj = context.object 144 | mesh = obj.data 145 | if mesh.is_editmode: 146 | obj.update_from_editmode() 147 | kd = utils.kdtree_from_verts(mesh.vertices) 148 | for v in mesh.vertices: 149 | if not v.select: 150 | continue 151 | 152 | def normalize(v): 153 | groups = [] 154 | wgt = 0 155 | for ge in v.groups: 156 | if is_deform(obj.vertex_groups[ge.group].name): 157 | wgt += ge.weight 158 | groups.append(ge) 159 | if abs(wgt - 1) < 0.0001: 160 | return 161 | for ge in groups: 162 | ge.weight /= wgt 163 | if v.co[0] == 0 or v.co[0] == -0: 164 | normalize(v) 165 | continue 166 | v2 = counterpart_vertex(mesh.vertices, kd, v) 167 | if v2 is None: 168 | print("no counterpart", v.index) 169 | continue 170 | gdict = {obj.vertex_groups[g.group].name: g for g in v2.groups} 171 | 172 | wgt2 = 0 173 | # cleanup groups without counterparts before normalizing 174 | for g in v.groups: 175 | if g.group > len(obj.vertex_groups) or g.group < 0: 176 | print("bad vg id", v.index, g.group) 177 | continue 178 | vg = obj.vertex_groups[g.group] 179 | g2e = gdict.get(swap_l_r(vg.name)) 180 | if g2e: 181 | if is_deform(vg.name): 182 | wgt2 += g2e.weight 183 | elif not vg.lock_weight: 184 | if not is_deform(vg.name): 185 | print("removing non-deform vg", v.index, v2.index, v.co, vg.name) 186 | vg.remove([v.index]) 187 | 188 | if wgt2 < 0.0001: 189 | print(v.index, v2.index, "situation is too bad, please check") 190 | continue 191 | if abs(wgt2 - 1) < 0.0001: 192 | wgt2 = 1 193 | 194 | normalize(v) 195 | 196 | for g1e in v.groups: 197 | vg = obj.vertex_groups[g1e.group] 198 | if vg.lock_weight: 199 | continue 200 | g2name = swap_l_r(vg.name) 201 | g2e = gdict[g2name] 202 | g2w = g2e.weight 203 | if is_deform(g2name): 204 | g2w /= wgt2 205 | if g2w > 1: 206 | print(v.index, v2.index, g2name, g2e.group, g2e.weight, g2w, wgt2) 207 | self.report({'ERROR'}, "Bad g2 weight!") 208 | return {"FINISHED"} 209 | 210 | if abs(g1e.weight - g2w) >= 0.00001: 211 | if v2.select: 212 | wgt = (g1e.weight + g2w) / 2 213 | g1e.weight = wgt 214 | g2e.weight = wgt 215 | else: 216 | g1e.weight = g2w 217 | 218 | normalize(v) 219 | return {"FINISHED"} 220 | 221 | 222 | class OpSymmetrizeJoints(bpy.types.Operator): 223 | bl_idname = "cmedit.symmetrize_joints" 224 | bl_label = "Symmetrize joints" 225 | bl_description = "Symmetrize joints: add missing joint vertex groups from other side, report non-symmetrical joints" 226 | bl_options = {"UNDO"} 227 | 228 | @classmethod 229 | def poll(cls, context): 230 | return context.object and context.object.type == "MESH" and context.mode == "OBJECT" 231 | 232 | def execute(self, context): # pylint: disable=no-self-use 233 | obj = context.object 234 | mesh = obj.data 235 | kd = utils.kdtree_from_verts(mesh.vertices) 236 | vg_map = {} 237 | new_vg = set() 238 | for vg in obj.vertex_groups: 239 | if not vg.name.startswith("joint_"): 240 | continue 241 | cname = swap_l_r(vg.name) 242 | if cname in obj.vertex_groups: 243 | cvg = obj.vertex_groups[cname] 244 | else: 245 | cvg = obj.vertex_groups.new(name=cname) 246 | new_vg.add(cvg.index) 247 | vg_map[vg.index] = cvg 248 | 249 | for v in mesh.vertices: 250 | for g in v.groups: 251 | if g.group in new_vg: 252 | continue 253 | cvg = vg_map.get(g.group) 254 | if cvg is None: 255 | continue 256 | v2 = counterpart_vertex(mesh.vertices, kd, v) 257 | if v2 is None: 258 | continue 259 | if cvg.index in new_vg: 260 | cvg.add([v2.index], g.weight, "REPLACE") 261 | else: 262 | try: 263 | w2 = cvg.weight(v2.index) 264 | except RuntimeError: 265 | w2 = 0 266 | if abs(g.weight - w2) >= 1e-5: 267 | print("assymetry:", cvg.name, v.index, g.weight, v2.index, w2) 268 | 269 | return {"FINISHED"} 270 | 271 | 272 | class OpSymmetrizeOffsets(bpy.types.Operator): 273 | bl_idname = "cmedit.symmetrize_offsets" 274 | bl_label = "Symmetrize offsets" 275 | bl_description = "Symmetrize joints: add missing bone offsets from other side, report non-symmetrical offsets" 276 | bl_options = {"UNDO"} 277 | 278 | @classmethod 279 | def poll(cls, context): 280 | return context.object and context.object.type == "ARMATURE" 281 | 282 | def execute(self, context): # pylint: disable=no-self-use 283 | if context.mode == "EDIT_ARMATURE": 284 | bones = context.object.data.edit_bones 285 | else: 286 | bones = context.object.data.bones 287 | for bone in bones: 288 | cname = swap_l_r(bone.name) 289 | if cname == bone.name: 290 | continue 291 | if cname not in bones: 292 | print("No counterpart for", bone.name) 293 | continue 294 | bone2 = bones[cname] 295 | for attr in "head", "tail": 296 | if "charmorph_offs_" + attr in bone and "charmorph_offs_" + attr in bone2: 297 | v1 = mathutils.Vector(bone["charmorph_offs_" + attr]) 298 | v2 = mathutils.Vector(bone2["charmorph_offs_" + attr]) 299 | v2[0] = -v2[0] 300 | if (v1 - v2).length > 1e-6: 301 | v2[0] = -v2[0] 302 | print("assymetry:", bone.name, attr, v1, v2) 303 | if "charmorph_offs_" + attr in bone: 304 | src = bone 305 | dst = bone2 306 | elif "charmorph_offs_" + attr in bone2: 307 | src = bone2 308 | dst = bone 309 | else: 310 | continue 311 | dst["charmorph_offs_" + attr] = src["charmorph_offs_" + attr] 312 | 313 | return {"FINISHED"} 314 | 315 | 316 | class CMEDIT_PT_Symmetry(bpy.types.Panel): 317 | bl_label = "Symmetry" 318 | bl_parent_id = "VIEW3D_PT_CMEdit" 319 | bl_space_type = 'VIEW_3D' 320 | bl_region_type = 'UI' 321 | bl_category = "CharMorph" 322 | bl_options = {"DEFAULT_CLOSED"} 323 | bl_order = 2 324 | 325 | def draw(self, _): 326 | l = self.layout 327 | l.operator("cmedit.check_symmetry") 328 | l.operator("cmedit.symmetrize_weights") 329 | l.operator("cmedit.symmetrize_vg") 330 | l.operator("cmedit.symmetrize_joints") 331 | l.operator("cmedit.symmetrize_offsets") 332 | 333 | 334 | classes = OpCheckSymmetry, OpSymmetrizeVG, OpSymmetrizeWeights, OpSymmetrizeJoints, OpSymmetrizeOffsets, CMEDIT_PT_Symmetry 335 | -------------------------------------------------------------------------------- /common.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 3 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | # 19 | # Copyright (C) 2020-2022 Michael Vigovsky 20 | 21 | import logging, json 22 | import bpy # pylint: disable=import-error 23 | 24 | from . import prefs 25 | from .lib import charlib, morpher, morpher_cores 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | if "undo_push" in dir(bpy.ops.ed): 30 | undo_push = bpy.ops.ed.undo_push 31 | else: 32 | undo_push = None 33 | 34 | 35 | class UndoHandler: 36 | dragging = False 37 | name = None 38 | value = None 39 | 40 | def __call__(self, name, value): 41 | self.value = value 42 | if not self.dragging: 43 | self.name = name 44 | self.dragging = True 45 | bpy.ops.charmorph.on_prop_change("INVOKE_DEFAULT") 46 | 47 | def finish(self): 48 | self.dragging = False 49 | if isinstance(self.value, float): 50 | self.value = f"{self.value:.3}" 51 | if self.name: 52 | result = f"{self.name}: {self.value}" 53 | else: 54 | result = OpMorphCharacter.bl_label 55 | self.name = None 56 | self.value = None 57 | return result 58 | 59 | 60 | undo_handler = UndoHandler() 61 | 62 | 63 | class Manager: 64 | last_object = None 65 | old_morpher = None 66 | 67 | def __init__(self): 68 | self.morpher = morpher.null_morpher 69 | 70 | def get_basis(self, data): 71 | return charlib.get_basis(data, self.morpher, True) 72 | 73 | def update_morpher(self, m: morpher.Morpher): 74 | self.old_morpher = None 75 | self.morpher = m 76 | self.last_object = m.core.obj 77 | 78 | ui = bpy.context.window_manager.charmorph_ui 79 | c = m.core.char 80 | 81 | if ui.rig not in c.armature: 82 | if c.default_armature: 83 | ui.rig = c.default_armature 84 | 85 | if not m.core.L1 and c.default_type: 86 | m.set_L1(c.default_type, False) 87 | 88 | if c.randomize_incl_regex is not None: 89 | ui.randomize_incl = c.randomize_incl_regex 90 | if c.randomize_excl_regex is not None: 91 | ui.randomize_excl = c.randomize_excl_regex 92 | 93 | m.create_charmorphs_L2() 94 | 95 | if not ui.morph_category.startswith("<") and ui.morph_category not in m.categories: 96 | ui.morph_category = "" 97 | 98 | def _get_old_storage(self, obj): 99 | for m in (self.morpher, self.old_morpher): 100 | if m and hasattr(m.core, "storage") and m.core.char is charlib.library.obj_char(obj): 101 | if m.core.storage: 102 | return m.core.storage 103 | return None 104 | 105 | def _get_morpher(self, obj): 106 | return morpher.get(obj, self._get_old_storage(obj), undo_handler) 107 | 108 | def recreate_charmorphs(self): 109 | if not self.morpher: 110 | return 111 | self.morpher = self._get_morpher(self.morpher.core.obj) 112 | self.morpher.create_charmorphs_L2() 113 | 114 | def create_charmorphs(self, obj): 115 | self.last_object = obj 116 | if obj.type != "MESH": 117 | return 118 | if self.morpher.core.obj is obj and not self.morpher.error: 119 | return 120 | 121 | self.update_morpher(self._get_morpher(obj)) 122 | 123 | def del_charmorphs(self): 124 | self.last_object = None 125 | self.morpher = morpher.null_morpher 126 | morpher.del_charmorphs_L2() 127 | 128 | def on_select(self, undoredo=False): 129 | self.old_morpher = None 130 | if self.morpher is not morpher.null_morpher: 131 | force_recreate = False 132 | if undoredo and isinstance(self.morpher.core, morpher_cores.ShapeKeysMorpher): 133 | logger.debug("Resetting morpher because of undo/redo") 134 | force_recreate = True 135 | elif not self.morpher.check_obj(): 136 | logger.warning("Current morphing object is invalid, resetting...") 137 | force_recreate = True 138 | if force_recreate: 139 | self.old_morpher = self.morpher 140 | self.del_charmorphs() 141 | if bpy.context.mode != "OBJECT": 142 | return 143 | obj = bpy.context.object 144 | if obj is None: 145 | return 146 | ui = bpy.context.window_manager.charmorph_ui 147 | 148 | if obj is self.last_object: 149 | return 150 | 151 | if obj.type == "MESH": 152 | asset = None 153 | if (obj.parent and obj.parent.type == "MESH" 154 | and "charmorph_fit_id" in obj.data 155 | and "charmorph_template" not in obj.data): 156 | asset = obj 157 | obj = obj.parent 158 | if asset: 159 | ui.fitting_char = obj 160 | ui.fitting_asset = asset 161 | elif charlib.library.obj_char(obj): 162 | ui.fitting_char = obj 163 | else: 164 | ui.fitting_asset = obj 165 | 166 | if obj is self.last_object: 167 | return 168 | 169 | self.create_charmorphs(obj) 170 | 171 | 172 | manager = Manager() 173 | 174 | 175 | class MorpherCheckOperator(bpy.types.Operator): 176 | bl_options = {"UNDO"} 177 | 178 | @classmethod 179 | def poll(cls, context): 180 | return context.mode == "OBJECT" and manager.morpher 181 | 182 | def exec(self, _ctx, _ui): 183 | return {"CANCELLED"} 184 | 185 | def execute(self, context): 186 | m = manager.morpher 187 | if not m.check_obj(): 188 | self.report({"ERROR"}, "Invalid object selected") 189 | return {"CANCELLED"} 190 | return self.exec(context, context.window_manager.charmorph_ui) 191 | 192 | 193 | def _get_undo_mode(): 194 | lprefs = prefs.get_prefs() 195 | if not lprefs: 196 | return "S" 197 | return lprefs.preferences.undo_mode 198 | 199 | 200 | class OpMorphCharacter(bpy.types.Operator): 201 | bl_idname = "charmorph.on_prop_change" 202 | bl_label = "Morph CharMorph character" 203 | bl_description = "Helper operator to make undo work with CharMorph" 204 | 205 | def modal(self, _, event): 206 | if not undo_handler.dragging: 207 | return {'FINISHED'} 208 | if event.value == 'RELEASE': 209 | msg = undo_handler.finish() 210 | if undo_push and _get_undo_mode() == "A": 211 | undo_push(message=msg) 212 | 213 | return {'PASS_THROUGH'} 214 | 215 | def invoke(self, context, _): 216 | context.window_manager.modal_handler_add(self) 217 | return {'RUNNING_MODAL'} 218 | 219 | 220 | def register(): 221 | if undo_push and _get_undo_mode() == "A": 222 | logger.debug("Advanced undo mode") 223 | OpMorphCharacter.bl_options = set() 224 | else: 225 | logger.debug("Simple undo mode") 226 | OpMorphCharacter.bl_options = {"UNDO"} 227 | bpy.utils.register_class(OpMorphCharacter) 228 | 229 | 230 | def unregister(): 231 | bpy.utils.unregister_class(OpMorphCharacter) 232 | 233 | 234 | def update_undo_mode(): 235 | unregister() 236 | register() 237 | 238 | 239 | prefs.undo_update_hook = update_undo_mode 240 | -------------------------------------------------------------------------------- /file_io.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 3 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | # 19 | # Copyright (C) 2020 Michael Vigovsky 20 | 21 | import json 22 | import bpy, bpy_extras # pylint: disable=import-error 23 | 24 | from .lib import morphs, utils 25 | from .common import manager as mm 26 | 27 | 28 | class UIProps: 29 | export_format: bpy.props.EnumProperty( 30 | name="Format", 31 | description="Export format", 32 | default="yaml", 33 | items=[ 34 | ("yaml", "CharMorph (yaml)", ""), 35 | ("json", "MB-Lab (json)", "") 36 | ]) 37 | 38 | 39 | class CHARMORPH_PT_ImportExport(bpy.types.Panel): 40 | bl_label = "Import/Export" 41 | bl_parent_id = "VIEW3D_PT_CharMorph" 42 | bl_space_type = 'VIEW_3D' 43 | bl_region_type = 'UI' 44 | bl_options = {"DEFAULT_CLOSED"} 45 | bl_order = 5 46 | 47 | @classmethod 48 | def poll(cls, _): 49 | return bool(mm.morpher) 50 | 51 | def draw(self, context): 52 | ui = context.window_manager.charmorph_ui 53 | 54 | self.layout.label(text="Export format:") 55 | self.layout.prop(ui, "export_format", expand=True) 56 | self.layout.separator() 57 | col = self.layout.column(align=True) 58 | if ui.export_format == "json": 59 | col.operator("charmorph.export_json") 60 | elif ui.export_format == "yaml": 61 | col.operator("charmorph.export_yaml") 62 | col.operator("charmorph.import") 63 | 64 | 65 | def morphs_to_data(): 66 | m = mm.morpher 67 | typ = [] 68 | 69 | if m.L1: 70 | typ.append(m.L1) 71 | alt_name = m.char.types.get(m.L1, {}).get("title") 72 | if alt_name: 73 | typ.append(alt_name) 74 | 75 | return { 76 | "type": typ, 77 | "morphs": {morph.name: m.core.prop_get(morph.name) for morph in m.core.morphs_l2 if morph.name}, 78 | "meta": {k: m.meta_get(k) for k in m.core.char.morphs_meta}, 79 | "materials": m.materials.as_dict() 80 | } 81 | 82 | 83 | class OpExportJson(bpy.types.Operator, bpy_extras.io_utils.ExportHelper): 84 | bl_idname = "charmorph.export_json" 85 | bl_label = "Export morphs" 86 | bl_description = "Export current morphs to MB-Lab compatible json file" 87 | filename_ext = ".json" 88 | 89 | filter_glob: bpy.props.StringProperty(default="*.json", options={'HIDDEN'}) 90 | 91 | @classmethod 92 | def poll(cls, _): 93 | return bool(mm.morpher) 94 | 95 | def execute(self, _): 96 | with open(self.filepath, "w", encoding="utf-8") as f: 97 | json.dump(morphs.charmorph_to_mblab(morphs_to_data()), f, indent=4, sort_keys=True) 98 | return {"FINISHED"} 99 | 100 | 101 | class OpExportYaml(bpy.types.Operator, bpy_extras.io_utils.ExportHelper): 102 | bl_idname = "charmorph.export_yaml" 103 | bl_label = "Export morphs" 104 | bl_description = "Export current morphs to yaml file" 105 | filename_ext = ".yaml" 106 | 107 | filter_glob: bpy.props.StringProperty(default="*.yaml", options={'HIDDEN'}) 108 | 109 | @classmethod 110 | def poll(cls, _): 111 | return bool(mm.morpher) 112 | 113 | def execute(self, _): 114 | with open(self.filepath, "w", encoding="utf-8") as f: 115 | utils.dump_yaml(morphs_to_data(), f) 116 | return {"FINISHED"} 117 | 118 | 119 | class OpImport(bpy.types.Operator, bpy_extras.io_utils.ImportHelper): 120 | bl_idname = "charmorph.import" 121 | bl_label = "Import morphs" 122 | bl_description = "Import morphs from yaml or json file" 123 | bl_options = {"UNDO"} 124 | 125 | filter_glob: bpy.props.StringProperty(default="*.yaml;*.json", options={'HIDDEN'}) 126 | 127 | @classmethod 128 | def poll(cls, _): 129 | return bool(mm.morpher) 130 | 131 | def execute(self, _): 132 | data = morphs.load_morph_data(self.filepath) 133 | if data is None: 134 | self.report({'ERROR'}, "Can't recognize format") 135 | return {"CANCELLED"} 136 | 137 | typenames = data.get("type", []) 138 | if isinstance(typenames, str): 139 | typenames = [typenames] 140 | 141 | m = mm.morpher 142 | typemap = {v["title"]: k for k, v in m.core.char.types.items() if "title" in v} 143 | for name in (name for sublist in ([name, typemap.get(name)] for name in typenames) for name in sublist): 144 | if not name: 145 | continue 146 | if m.set_L1(name, False): 147 | break 148 | 149 | m.apply_morph_data(data, False) 150 | return {"FINISHED"} 151 | 152 | 153 | classes = [OpImport, OpExportJson, OpExportYaml, CHARMORPH_PT_ImportExport] 154 | -------------------------------------------------------------------------------- /lib/__init__.py: -------------------------------------------------------------------------------- 1 | # empty init file to workaround pylint bug 2 | -------------------------------------------------------------------------------- /lib/drivers.py: -------------------------------------------------------------------------------- 1 | import bpy # pylint: disable=import-error 2 | 3 | VARS_DRIVER = ("expression", "type", "use_self") 4 | VARS_TARGET = ("context_property", "bone_target", "data_path", "rotation_mode", "transform_space", "transform_type") 5 | 6 | cm_map = {} 7 | 8 | 9 | class DriverException(Exception): 10 | pass 11 | 12 | 13 | def id_to_cm(t): 14 | if t is None: 15 | return "none" 16 | try: 17 | return cm_map[t.name] 18 | except KeyError as e: 19 | raise DriverException(e.args[0]) 20 | 21 | 22 | def target_data(t): 23 | if t.id_type != "OBJECT": 24 | raise DriverException("Invalid target id_type " + t.id_type) 25 | result = {k: getattr(t, k) for k in VARS_TARGET} 26 | result["cm_id"] = id_to_cm(t.id) 27 | return result 28 | 29 | 30 | def variables_data(item): 31 | return {v.name: { 32 | "type": v.type, 33 | "targets": [target_data(t) for t in v.targets], 34 | } for v in item} 35 | 36 | 37 | def driver_data(d): 38 | result = {k: getattr(d, k) for k in VARS_DRIVER} 39 | result["variables"] = variables_data(d.variables) 40 | return result 41 | 42 | 43 | def get_drivers(item): 44 | ad = item.animation_data 45 | if not ad: 46 | return {} 47 | return [{ 48 | "data_path": d.data_path, 49 | "array_index": d.array_index, 50 | "driver": driver_data(d.driver), 51 | } for d in item.animation_data.drivers] 52 | 53 | 54 | def driver_items(name, obj): 55 | d = [m for m in ( 56 | (name, get_drivers(obj)), 57 | (name+".data", get_drivers(obj.data)), 58 | ) if m[1]] 59 | if obj.type == "MESH": 60 | item = get_drivers(obj.data.shape_keys) 61 | if item: 62 | d.append((name+".data.shape_keys", item)) 63 | return d 64 | 65 | 66 | def export(**args): 67 | try: 68 | for k, v in args.items(): 69 | cm_map[v.name] = k 70 | return dict(it for m in (driver_items(k, v) for k, v in args.items()) for it in m) 71 | finally: 72 | cm_map.clear() 73 | 74 | 75 | def clear_item(item): 76 | ad = item.animation_data 77 | if not ad: 78 | return 79 | for d in ad.drivers: 80 | item.driver_remove(d.data_path, d.array_index) 81 | 82 | 83 | def clear_obj(obj): 84 | clear_item(obj) 85 | clear_item(obj.data) 86 | if obj.type == "MESH": 87 | clear_item(obj.data.shape_keys) 88 | 89 | 90 | def cm_to_id(t: str): 91 | if t.lower() == "none": 92 | return None 93 | try: 94 | return cm_map[t] 95 | except KeyError as e: 96 | raise DriverException(e.args[0]) 97 | 98 | 99 | def fill_target(t, d): 100 | try: 101 | t.id_type = "OBJECT" 102 | except AttributeError: 103 | pass 104 | t.id = cm_to_id(d["cm_id"]) 105 | for k in VARS_TARGET: 106 | v = d.get(k) 107 | if v is not None: 108 | setattr(t, k, v) 109 | 110 | 111 | def fill_variable(t, d): 112 | t.type = d["type"] 113 | dt = d["targets"] 114 | if len(t.targets) != len(dt): 115 | raise DriverException("Target count mismatch") 116 | for dst, src in zip(t.targets, dt): 117 | fill_target(dst, src) 118 | 119 | 120 | def fill_driver(t, d): 121 | for k in VARS_DRIVER: 122 | v = d.get(k) 123 | if v is not None: 124 | setattr(t, k, v) 125 | for k, v in d["variables"].items(): 126 | var = t.variables.new() 127 | var.name = k 128 | fill_variable(var, v) 129 | 130 | 131 | def name_to_obj(name: str): 132 | parts = name.split(".") 133 | result = cm_to_id(parts[0]) 134 | for k in parts[1:]: 135 | result = getattr(result, k) 136 | return result 137 | 138 | 139 | def dimport(d: dict, overwrite: bool, **args): 140 | try: 141 | for k, v in args.items(): 142 | cm_map[k] = v 143 | for k, v in d.items(): 144 | t = name_to_obj(k) 145 | if not t: 146 | raise DriverException("Invalid object " + k) 147 | for drv in v: 148 | try: 149 | if overwrite: 150 | fc = t.driver_remove(drv["data_path"], drv["array_index"]) 151 | fc = t.driver_add(drv["data_path"], drv["array_index"]) 152 | except TypeError: 153 | if overwrite: 154 | fc = t.driver_remove(drv["data_path"]) 155 | fc = t.driver_add(drv["data_path"]) 156 | fill_driver(fc.driver, drv["driver"]) 157 | finally: 158 | cm_map.clear() 159 | -------------------------------------------------------------------------------- /lib/hair.py: -------------------------------------------------------------------------------- 1 | 2 | # ##### BEGIN GPL LICENSE BLOCK ##### 3 | # 4 | # This program is free software; you can redistribute it and/or 5 | # modify it under the terms of the GNU General Public License 6 | # as published by the Free Software Foundation; either version 3 7 | # of the License, or (at your option) any later version. 8 | # 9 | # This program is distributed in the hope that it will be useful, 10 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | # GNU General Public License for more details. 13 | # 14 | # You should have received a copy of the GNU General Public License 15 | # along with this program; if not, write to the Free Software Foundation, 16 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 17 | # 18 | # ##### END GPL LICENSE BLOCK ##### 19 | # 20 | # Copyright (C) 2021-2022 Michael Vigovsky 21 | import logging, numpy 22 | 23 | import bpy # pylint: disable=import-error 24 | 25 | from . import fit_calc, utils 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | def np_particles_data(obj, particles, precision=numpy.float32): 30 | cnt = numpy.empty(len(particles), dtype=numpy.uint8) 31 | total = 0 32 | mx = 1 33 | for i, p in enumerate(particles): 34 | c = len(p.hair_keys) - 1 35 | cnt[i] = c 36 | total += c 37 | if c > mx: 38 | mx = c 39 | 40 | data = numpy.empty((total, 3), dtype=precision) 41 | tmp = numpy.empty(mx * 3 + 3, dtype=precision) 42 | i = 0 43 | for p in particles: 44 | t2 = tmp[:len(p.hair_keys) * 3] 45 | p.hair_keys.foreach_get("co_local", t2) 46 | t2 = t2[3:].reshape((-1, 3)) 47 | data[i:i + len(t2)] = t2 48 | i += len(t2) 49 | 50 | utils.np_matrix_transform(data, obj.matrix_world.inverted()) 51 | return {"cnt": cnt, "data": data} 52 | 53 | 54 | def export_hair(obj, psys_idx, filepath, precision): 55 | pss = obj.particle_systems 56 | old_psys_idx = pss.active_index 57 | pss.active_index = psys_idx 58 | 59 | psys = pss[psys_idx] 60 | is_global = psys.is_global_hair 61 | with bpy.context.temp_override(object=obj): 62 | if not is_global: 63 | bpy.ops.particle.disconnect_hair() 64 | numpy.savez_compressed(filepath, **np_particles_data(obj, psys.particles, precision)) 65 | if not is_global: 66 | bpy.ops.particle.connect_hair() 67 | 68 | pss.active_index = old_psys_idx 69 | 70 | 71 | def update_hair(obj, cnts, morphed): 72 | t = utils.Timer() 73 | utils.np_matrix_transform(morphed[1:], obj.matrix_world) 74 | psys = obj.particle_systems.active 75 | have_mismatch = False 76 | t.time("hcalc") 77 | 78 | # I wish I could just get a transformation matrix for every particle and avoid these disconnects/connects! 79 | with bpy.context.temp_override(object=obj): 80 | bpy.ops.particle.disconnect_hair() 81 | t.time("disconnect") 82 | try: 83 | pos = 0 84 | for p, cnt in zip(psys.particles, cnts): 85 | if len(p.hair_keys) != cnt + 1: 86 | if not have_mismatch: 87 | logger.error("Particle mismatch %d %d", len(p.hair_keys), cnt) 88 | have_mismatch = True 89 | continue 90 | marr = morphed[pos:pos + cnt + 1] 91 | marr[0] = p.hair_keys[0].co_local 92 | pos += cnt 93 | p.hair_keys.foreach_set("co_local", marr.reshape(-1)) 94 | finally: 95 | t.time("hair_set") 96 | with bpy.context.temp_override(object=obj): 97 | bpy.ops.particle.connect_hair() 98 | t.time("connect") 99 | return True 100 | 101 | 102 | class HairData: 103 | __slots__ = "cnts", "data", "binding" 104 | cnts: numpy.ndarray 105 | data: numpy.ndarray 106 | binding: fit_calc.FitBinding 107 | 108 | def get_morphed(self, diff: numpy.ndarray): 109 | result = numpy.empty((len(self.data) + 1, 3)) 110 | result[1:] = self.binding.fit(diff) 111 | result[1:] += self.data 112 | return result 113 | 114 | 115 | class HairFitter(fit_calc.MorpherFitCalculator): 116 | def __init__(self, *args): 117 | super().__init__(*args) 118 | self.hair_cache = {} 119 | 120 | def get_diff_arr(self): 121 | return self.mcore.get_diff() 122 | 123 | def get_hair_data(self, psys): 124 | if not psys.is_edited: 125 | return None 126 | fit_id = psys.settings.get("charmorph_fit_id") 127 | if fit_id: 128 | data = self.hair_cache.get(fit_id) 129 | if isinstance(data, HairData): 130 | return data 131 | 132 | z = self.mcore.char.get_np(f"hairstyles/{psys.settings.get('charmorph_hairstyle','')}.npz") 133 | if z is None: 134 | logger.error("Hairstyle npz file is not found") 135 | return None 136 | 137 | hd = HairData() 138 | hd.cnts = z["cnt"] 139 | hd.data = z["data"].astype(dtype=numpy.float64, casting="same_kind") 140 | 141 | if len(hd.cnts) != len(psys.particles): 142 | logger.error("Mismatch between current hairsyle and .npz!") 143 | return None 144 | 145 | hd.binding = self.calc_binding_hair(hd.data) 146 | self.hair_cache[fit_id] = hd 147 | return hd 148 | 149 | # use separate get_diff function to support hair fitting for posed characters 150 | def get_diff_hair(self): 151 | char = self.mcore.obj 152 | if not char.find_armature(): 153 | return self.get_diff_arr() 154 | 155 | restore_modifiers = utils.disable_modifiers(char) 156 | echar = char.evaluated_get(bpy.context.evaluated_depsgraph_get()) 157 | try: 158 | deformed = echar.to_mesh() 159 | basis = self.mcore.get_basis_alt_topo() 160 | if len(deformed.vertices) != len(basis): 161 | logger.error("Can't fit posed hair: vertex count mismatch") 162 | return self.get_diff_arr() 163 | result = numpy.empty(len(basis) * 3) 164 | deformed.vertices.foreach_get("co", result) 165 | result = result.reshape(-1, 3) 166 | result -= basis 167 | return result 168 | finally: 169 | echar.to_mesh_clear() 170 | for m in restore_modifiers: 171 | m.show_viewport = True 172 | 173 | def fit_hair(self, obj, idx): 174 | t = utils.Timer() 175 | psys = obj.particle_systems[idx] 176 | hd = self.get_hair_data(psys) 177 | if not hd: 178 | return False 179 | 180 | obj.particle_systems.active_index = idx 181 | 182 | restore_modifiers = utils.disable_modifiers(obj, lambda m: m.type == "SHRINKWRAP") 183 | try: 184 | update_hair(obj, hd.cnts, hd.get_morphed(self.get_diff_hair())) 185 | finally: 186 | for m in restore_modifiers: 187 | m.show_viewport = True 188 | 189 | t.time("hair_fit") 190 | return True 191 | 192 | def fit_obj_hair(self, obj): 193 | has_fit = False 194 | for i in range(len(obj.particle_systems)): 195 | has_fit |= self.fit_hair(obj, i) 196 | return has_fit 197 | -------------------------------------------------------------------------------- /lib/materials.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 3 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | # 19 | # Copyright (C) 2020 Michael Vigovsky 20 | 21 | import os, re, logging 22 | 23 | import bpy # pylint: disable=import-error 24 | 25 | from .charlib import Character 26 | from . import utils 27 | from .. import prefs 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | colorspaces = { 32 | item.name for item in 33 | bpy.types.ColorManagedInputColorspaceSettings.bl_rna.properties.get("name").enum_items 34 | } 35 | 36 | 37 | def init_materials(obj, char: Character): 38 | load_materials(obj, char) 39 | load_textures(obj, char) 40 | 41 | 42 | def load_materials(obj, char: Character): 43 | mtllist = char.materials 44 | if not mtllist: 45 | return 46 | if len(obj.data.materials) != len(mtllist): 47 | logger.error("Material count mismatch in %s: %d != %d", char, len(obj.data.materials), len(mtllist)) 48 | return 49 | 50 | settings_tree = None 51 | 52 | def copy_material(mtl): 53 | mtl = mtl.copy() 54 | if not mtl.node_tree: 55 | return mtl 56 | 57 | nonlocal settings_tree 58 | for node in mtl.node_tree.nodes: 59 | if node.type == "GROUP" and node.name == "charmorph_settings" and node.node_tree: 60 | if not settings_tree: 61 | settings_tree = node.node_tree.copy() 62 | node.node_tree = settings_tree 63 | return mtl 64 | 65 | ui = bpy.context.window_manager.charmorph_ui 66 | materials_to_load = set() 67 | load_ids = [] 68 | adult_mode = prefs.is_adult_mode() 69 | 70 | for i, mtl_name in enumerate(mtllist): 71 | if not mtl_name: 72 | continue 73 | mtl = None 74 | if ui.material_local or ui.material_mode == "MS": 75 | mtl = bpy.data.materials.get(mtl_name) 76 | if mtl: 77 | if ui.material_mode != "MS": 78 | mtl = copy_material(mtl) 79 | obj.data.materials[i] = mtl 80 | elif "_censor" not in mtl_name or not adult_mode: 81 | materials_to_load.add(mtl_name) 82 | load_ids.append(i) 83 | 84 | if materials_to_load: 85 | materials_to_load = list(materials_to_load) 86 | with bpy.data.libraries.load(char.path(char.material_lib)) as (_, data_to): 87 | data_to.materials = materials_to_load.copy() 88 | material_dict = {} 89 | for i, mtl in enumerate(data_to.materials): 90 | material_dict[materials_to_load[i]] = mtl 91 | for i in load_ids: 92 | obj.data.materials[i] = material_dict.get(mtllist[i]) 93 | 94 | if adult_mode: 95 | for i in range(len(mtllist) - 1, 0, -1): 96 | if "_censor" in mtllist[i]: 97 | obj.data.materials.pop(index=i) 98 | 99 | 100 | def is_udim(s: str): 101 | return "" in s or "" in s 102 | 103 | 104 | def ud_to_regex(s: str): 105 | return re.compile(re.escape(s).replace("", r"\d+").replace("", r"u\d+_v\d+")) 106 | 107 | 108 | # Returns a dictionary { texture_short_name: (filename, texture_settings) 109 | def load_texdir(path, settings: dict) -> tuple[dict[str, tuple[str, str]], dict]: 110 | if not os.path.exists(path): 111 | return {}, settings 112 | settings = settings.copy() 113 | settings.update(utils.parse_file(os.path.join(path, "settings.yaml"), utils.load_yaml, {})) 114 | default_setting = settings.get("*") 115 | ud_map = [(ud_to_regex(s), s) for s in settings.keys() if is_udim(s)] 116 | 117 | result = {} 118 | for item in os.listdir(path): 119 | name, ext = os.path.splitext(item) 120 | full_path = os.path.join(path, item) 121 | if ext == ".yaml" or not os.path.isfile(full_path): 122 | continue 123 | for regex, val in ud_map: 124 | if regex.fullmatch(name): 125 | name = val 126 | full_path = os.path.join(path, val + ext) 127 | break 128 | old = result.get(name) 129 | if old is not None: 130 | if os.path.splitext(old[0])[1] != ext: 131 | logger.error("different extensions for texture %s at %s", name, path) 132 | continue 133 | result[name] = (full_path, settings.get(name, default_setting)) 134 | 135 | return result, settings 136 | 137 | 138 | # Returns a dictionary { texture_short_name: tuple(filename, texture_full_name, texture_settings) } 139 | def load_texmap(char: Character, tex_set) -> dict[str, tuple[str, str, str]]: 140 | result = {} 141 | char_texes, settings = load_texdir(char.path("textures"), {}) 142 | 143 | for k, v in load_texdir(char.lib.path("textures"), {})[0].items(): 144 | if k not in char_texes: 145 | result[k] = (v[0], "charmorph--" + k, v[1]) 146 | 147 | for k, v in char_texes.items(): 148 | result[k] = (v[0], f"charmorph-{char}-{k}", v[1]) 149 | 150 | if tex_set and tex_set != "/": 151 | for k, v in load_texdir(char.path("textures", tex_set), settings)[0].items(): 152 | result[k] = (v[0], f"charmorph-{char}-{tex_set}-{k}", v[1]) 153 | return result 154 | 155 | 156 | def tex_try_names(char, tex_set, names): 157 | for name in names: 158 | if name.startswith("tex_"): 159 | name = name[4:] 160 | if tex_set and tex_set != "/": 161 | yield f"charmorph-{char}-{tex_set}-{name}" 162 | yield f"charmorph-{char}-{name}" 163 | yield "charmorph--" + name 164 | 165 | 166 | # Currently only colorspace settings are supported 167 | def apply_tex_settings(img, settings): 168 | if not settings: 169 | return 170 | if settings in colorspaces: 171 | img.colorspace_settings.name = settings 172 | return 173 | if settings == "Linear": 174 | if "Non-Color" in colorspaces: 175 | img.colorspace_settings.name = "Non-Color" 176 | return 177 | if settings == "Non-Color": 178 | if "Linear" in colorspaces: 179 | img.colorspace_settings.name = "Linear" 180 | img.colorspace_settings.is_data = True 181 | return 182 | logger.error("Color settings %s is not available!", settings) 183 | if settings != "sRGB" and "Linear" in colorspaces: 184 | img.colorspace_settings.name = "Linear" 185 | 186 | 187 | def texture_max_res(ui): 188 | val = ui.tex_downscale 189 | if val == "UL": 190 | return 1024 * 1024 191 | return 1024 * int(val[0]) 192 | 193 | 194 | def load_textures(obj, char): 195 | if not obj.data.materials: 196 | return 197 | 198 | ui = bpy.context.window_manager.charmorph_ui 199 | texmap = None 200 | 201 | groups = set() 202 | 203 | def scan_nodes(nodes): 204 | nonlocal texmap 205 | for node in nodes: 206 | if char.recurse_materials and node.type == "GROUP" and node.node_tree.name not in groups: 207 | groups.add(node.node_tree.name) 208 | scan_nodes(node.node_tree.nodes.values()) 209 | if node.type != "TEX_IMAGE": 210 | continue 211 | img = None 212 | if ui.material_local or ui.material_mode in ["MS", "TS"]: 213 | for name in tex_try_names(char.name, ui.tex_set, [node.name, node.label]): 214 | img = bpy.data.images.get(name) 215 | if img is not None: 216 | break 217 | 218 | if img is None: 219 | if texmap is None: 220 | texmap = load_texmap(char, ui.tex_set) 221 | 222 | img_tuple = None 223 | for name in [node.name, node.label]: 224 | if name.startswith("tex_"): 225 | name = name[4:] 226 | else: 227 | continue 228 | img_tuple = texmap.get(name) 229 | if img_tuple is not None: 230 | break 231 | if img_tuple is not None: 232 | img = bpy.data.images.load(img_tuple[0], check_existing=True) 233 | if is_udim(img_tuple[0]): 234 | img.source = 'TILED' 235 | img.name = img_tuple[1] 236 | apply_tex_settings(img, img_tuple[2]) 237 | if not img.has_data: 238 | img.reload() 239 | max_res = texture_max_res(ui) 240 | width, height = img.size 241 | if width > max_res or height > max_res: 242 | logger.debug("resizing image %s", img_tuple[0]) 243 | img.scale(min(width, max_res), min(height, max_res)) 244 | 245 | if img is not None: 246 | node.image = img 247 | 248 | for mtl in obj.data.materials: 249 | if not mtl or not mtl.node_tree: 250 | continue 251 | scan_nodes(mtl.node_tree.nodes.values()) 252 | 253 | 254 | def get_props(obj): 255 | if not obj.data.materials: 256 | return {} 257 | colors = [] 258 | values = [] 259 | groups = set() 260 | 261 | def scan_nodes(data_type, name, nodes): 262 | for node in nodes: 263 | if node.type == "GROUP" and node.name == "charmorph_settings" and node.node_tree.name not in groups: 264 | groups.add(node.node_tree.name) 265 | scan_nodes(1, node.node_tree.name, node.node_tree.nodes.values()) 266 | if node.label == "": 267 | continue 268 | if node.type == "RGB" and not node.name.startswith("RGB."): 269 | colors.append((node.name, (data_type, name))) 270 | elif node.type == "VALUE": 271 | values.append((node.name, (data_type, name))) 272 | 273 | for mtl in obj.data.materials: 274 | if not mtl or not mtl.node_tree: 275 | continue 276 | scan_nodes(0, mtl.name, mtl.node_tree.nodes.values()) 277 | return dict(colors + values) 278 | 279 | 280 | tree_types = ( 281 | lambda name: bpy.data.materials[name].node_tree, 282 | lambda name: bpy.data.node_groups[name] 283 | ) 284 | 285 | 286 | class Materials: 287 | props: dict = {} 288 | 289 | def __init__(self, obj): 290 | if obj: 291 | self.props = get_props(obj) 292 | 293 | def as_dict(self): 294 | return {k: (list(v.default_value) if v.node.type == "RGB" else v.default_value) for k, v in self.get_node_outputs()} 295 | 296 | def get_node_outputs(self): 297 | return ((k, self.get_node_output(k, v)) for k, v in self.props.items()) 298 | 299 | def get_node_output(self, node_name: str, tree_data: tuple[str, str] = None): 300 | try: 301 | if tree_data is None: 302 | tree_data = self.props[node_name] 303 | return tree_types[tree_data[0]](tree_data[1]).nodes[node_name].outputs[0] 304 | except KeyError: 305 | return None 306 | 307 | def apply(self, data): 308 | if not data: 309 | return 310 | if isinstance(data, dict): 311 | data = data.items() 312 | for k, v in data: 313 | prop = self.get_node_output(k) 314 | if not prop: 315 | continue 316 | if prop.node.type == "RGB": 317 | prop.default_value = utils.parse_color(v) 318 | else: 319 | prop.default_value = v 320 | -------------------------------------------------------------------------------- /lib/sliding_joints.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 3 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | # 19 | # Copyright (C) 2022 Michael Vigovsky 20 | # 21 | # It is my implementation of sliding joints on top of rigify 22 | # Thanks to DanPro for the idea! 23 | # https://www.youtube.com/watch?v=c7csuy-09k8 24 | # 25 | 26 | import re, math, logging 27 | 28 | import bpy # pylint: disable=import-error 29 | from rna_prop_ui import rna_idprop_ui_create # pylint: disable=import-error, no-name-in-module 30 | 31 | from . import charlib, utils 32 | 33 | logger = logging.getLogger(__name__) 34 | eval_unsafe = re.compile(r"__|\(\s*\)|[:;,{'\"\[]") 35 | 36 | 37 | def _parse_joint(item: dict): 38 | side = item.get("side", "") 39 | if isinstance(side, list): 40 | for s in side: 41 | yield item["upper_bone"], item["lower_bone"], s 42 | else: 43 | yield item["upper_bone"], item["lower_bone"], side 44 | 45 | 46 | class SJCalc: 47 | rig_name = "" 48 | influence: dict[str, dict[str, float]] = {} 49 | calc_error = False 50 | 51 | def __init__(self, char: charlib.Character, rig, get_co): 52 | self.rig = rig 53 | self.char = char 54 | self.get_co = get_co 55 | if rig: 56 | self.rig_name = rig.data.get("charmorph_rig_type") 57 | 58 | if self.rig_name: 59 | joints = self._rig_joints(self.rig_name) 60 | if joints: 61 | self.influence = { 62 | self.rig_name: 63 | {k: v for k, v in ((k, self._get_influence(v)) for k, v in joints.items()) if v is not None} 64 | } 65 | else: 66 | self.influence = { 67 | name: {k: self._calc_influence(v) for k, v in rig.sliding_joints.items()} 68 | for name, rig in self.char.armature.items() if rig.sliding_joints 69 | } 70 | 71 | def _rig_joints(self, rig): 72 | return self.char.armature.get(rig, charlib.Armature).sliding_joints 73 | 74 | def _calc_avg_dists(self, vert_pairs): 75 | if not vert_pairs: 76 | return 1 77 | return sum((self.get_co(a) - self.get_co(b)).length for a, b in vert_pairs) / len(vert_pairs) 78 | 79 | def _calc_influence(self, data): 80 | result = data.get("influence") 81 | if result is not None: 82 | return result 83 | calc = data["calc"] 84 | if not calc or not self.get_co: 85 | return data.get("default_influence", 0) 86 | 87 | if isinstance(calc, str): 88 | # Check for eval safety. Attacks like 9**9**9 are still possible, but it's quite useless 89 | if eval_unsafe.search(calc): 90 | logger.error("bad calc: %s", calc) 91 | return 0 92 | calc = compile(calc, "", "eval") 93 | data["calc"] = calc 94 | 95 | vals = {} 96 | for k, v in data.items(): 97 | if k.startswith("verts_"): 98 | vals[k] = self._calc_avg_dists(v) 99 | try: 100 | return eval(calc, {"__builtins__": None}, vals) 101 | except Exception as e: 102 | logger.error("bad calc: %s", e, exc_info=e) 103 | return 0 104 | 105 | def recalc(self): 106 | for rig, influence in self.influence.items(): 107 | for k, v in self._rig_joints(rig).items(): 108 | if k in influence and "calc" in v: 109 | influence[k] = self._calc_influence(v) 110 | 111 | def _get_influence(self, item): 112 | for c in self._get_constraints(item): 113 | return c.influence 114 | return None 115 | 116 | def _get_constraints(self, joint): 117 | for _, lower_bone, side in _parse_joint(joint): 118 | bone = self.rig.pose.bones.get(f"MCH-{lower_bone}{side}") 119 | if not bone: 120 | continue 121 | c = bone.constraints 122 | if not c or c[0].type != "COPY_ROTATION": 123 | continue 124 | yield c[0] 125 | 126 | def _prop(self, rig, influence, name): 127 | joint = self._rig_joints(rig).get(name) 128 | 129 | def setter(_, value): 130 | influence[name] = value 131 | if self.rig: 132 | ok = False 133 | for c in self._get_constraints(joint): 134 | c.influence = value 135 | ok = True 136 | if not ok: 137 | influence[name] = 0 138 | 139 | return bpy.props.FloatProperty( 140 | name="_".join((rig, name)), 141 | min=0, soft_max=0.2, max=1.0, 142 | precision=3, 143 | get=lambda _: influence.get(name, 0), 144 | set=setter 145 | ) 146 | 147 | def rig_joints(self, rig): 148 | if self.rig_name: 149 | return self.rig_name, self.influence.get(self.rig_name, ()) 150 | return rig, self._rig_joints(rig) 151 | 152 | def props(self): 153 | return (self._prop(rig, influence, name) for rig, influence in self.influence.items() for name in influence) 154 | 155 | 156 | def create(context, upper_bone, lower_bone, side): 157 | bones = context.object.data.edit_bones 158 | 159 | mch_name = f"MCH-{lower_bone}{side}" 160 | 161 | if mch_name in bones: 162 | raise Exception("Seems to already have sliding joint") 163 | 164 | tweak_name = f"{lower_bone}_tweak{side}" 165 | 166 | bone = bones[f"MCH-{tweak_name}"] 167 | bone.name = f"MCH-{upper_bone}_tweak{side}.002" 168 | 169 | mch_size = bone.bbone_x 170 | mch_collections = utils.bone_get_collections(bone) 171 | 172 | bone = bones[tweak_name] 173 | bone.name = f"{upper_bone}_tweak{side}.002" 174 | tweak_tail = bone.tail 175 | tweak_collections = utils.bone_get_collections(bone) 176 | tweak_size = bone.bbone_x 177 | 178 | bone = bones.new(mch_name) 179 | bone.parent = bones[f"ORG-{lower_bone}{side}"] 180 | bone.use_connect = True 181 | bone.use_deform = False 182 | bone.tail = bone.parent.head 183 | org_roll = bone.parent.z_axis 184 | bone.align_roll(-org_roll) 185 | utils.bone_set_collections(bone, mch_collections) 186 | bone.bbone_x = bone.parent.bbone_x 187 | bone.bbone_z = bone.parent.bbone_z 188 | mch_bone = bone 189 | 190 | bone = bones.new(f"MCH-{lower_bone}_tweak{side}") 191 | bone.parent = mch_bone 192 | bone.use_connect = True 193 | bone.tail = tweak_tail 194 | utils.bone_set_collections(bone, mch_collections) 195 | bone.bbone_x = mch_size 196 | bone.bbone_z = mch_size 197 | mch_bone = bone 198 | 199 | bone = bones.new(tweak_name) 200 | bone.parent = mch_bone 201 | bone.head = mch_bone.head 202 | bone.use_deform = False 203 | bone.tail = tweak_tail 204 | bone.align_roll(org_roll) 205 | utils.bone_set_collections(bone, tweak_collections) 206 | bone.bbone_x = tweak_size 207 | bone.bbone_z = tweak_size 208 | 209 | lower_bone = bones[f"DEF-{lower_bone}{side}"] 210 | lower_bone.use_connect = False 211 | 212 | bone = bones[f"DEF-{upper_bone}{side}.001"] 213 | bone.bbone_handle_type_end = "TANGENT" 214 | bone.bbone_custom_handle_end = lower_bone 215 | 216 | 217 | def finalize(rig, upper_bone, lower_bone, side, influence): 218 | bones = rig.pose.bones 219 | 220 | mch_name = f"MCH-{lower_bone}{side}" 221 | tweak_name = f"{lower_bone}_tweak{side}" 222 | old_tweak = f"{upper_bone}_tweak{side}.002" 223 | 224 | obone = bones[old_tweak] 225 | bone = bones[tweak_name] 226 | bone.custom_shape = obone.custom_shape 227 | if hasattr(bone, "bone_group"): 228 | bone.bone_group = obone.bone_group 229 | bone.lock_rotation = (True, False, True) 230 | bone.lock_scale = (False, True, False) 231 | 232 | # Make rubber tweak property, but lock it to zero 233 | rna_idprop_ui_create(bone, "rubber_tweak", default=0, min=0, max=0) 234 | 235 | mch_bone = bones[mch_name] 236 | utils.lock_obj(mch_bone, True) 237 | 238 | c = mch_bone.constraints.new("COPY_ROTATION") 239 | c.target = rig 240 | c.subtarget = f"ORG-{lower_bone}{side}" 241 | c.use_y = False 242 | c.use_z = False 243 | c.influence = influence 244 | c.owner_space = "LOCAL" 245 | c.target_space = "LOCAL" 246 | 247 | c = bones[f"MCH-{lower_bone}_tweak{side}"].constraints.new("COPY_SCALE") 248 | c.target = rig 249 | c.subtarget = "root" 250 | c.use_make_uniform = True 251 | 252 | def replace_tweak(bone): 253 | for c in bone.constraints: 254 | if c.type == "COPY_TRANSFORMS" and c.target == rig and c.subtarget == old_tweak: 255 | c.subtarget = tweak_name 256 | 257 | replace_tweak(bones[f"DEF-{lower_bone}{side}"]) 258 | replace_tweak(bones[f"MCH-{tweak_name}.001"]) 259 | 260 | c = mch_bone.constraints.new("LIMIT_ROTATION") 261 | c.owner_space = "LOCAL" 262 | c.use_limit_x = True 263 | c.max_x = math.pi / 2 264 | 265 | 266 | def _parse_dict(data): 267 | return ((k, *result) for k, v in data.items() for result in _parse_joint(v)) 268 | 269 | 270 | def create_from_conf(sj_calc, conf) -> list[tuple[str, str, str, float]]: 271 | result = [] 272 | for name, upper_bone, lower_bone, side in _parse_dict(conf.sliding_joints): 273 | influence = sj_calc.influence.get(conf.name, {}).get(name, 0) 274 | if influence > 0.0001: 275 | create(bpy.context, upper_bone, lower_bone, side) 276 | result.append((upper_bone, lower_bone, side, influence)) 277 | return result 278 | -------------------------------------------------------------------------------- /lib/yaml/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-2020 Ingy döt Net 2 | Copyright (c) 2006-2016 Kirill Simonov 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of 5 | this software and associated documentation files (the "Software"), to deal in 6 | the Software without restriction, including without limitation the rights to 7 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 8 | of the Software, and to permit persons to whom the Software is furnished to do 9 | so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/yaml/composer.py: -------------------------------------------------------------------------------- 1 | 2 | __all__ = ['Composer', 'ComposerError'] 3 | 4 | from .error import MarkedYAMLError 5 | from .events import * 6 | from .nodes import * 7 | 8 | class ComposerError(MarkedYAMLError): 9 | pass 10 | 11 | class Composer: 12 | 13 | def __init__(self): 14 | self.anchors = {} 15 | 16 | def check_node(self): 17 | # Drop the STREAM-START event. 18 | if self.check_event(StreamStartEvent): 19 | self.get_event() 20 | 21 | # If there are more documents available? 22 | return not self.check_event(StreamEndEvent) 23 | 24 | def get_node(self): 25 | # Get the root node of the next document. 26 | if not self.check_event(StreamEndEvent): 27 | return self.compose_document() 28 | 29 | def get_single_node(self): 30 | # Drop the STREAM-START event. 31 | self.get_event() 32 | 33 | # Compose a document if the stream is not empty. 34 | document = None 35 | if not self.check_event(StreamEndEvent): 36 | document = self.compose_document() 37 | 38 | # Ensure that the stream contains no more documents. 39 | if not self.check_event(StreamEndEvent): 40 | event = self.get_event() 41 | raise ComposerError("expected a single document in the stream", 42 | document.start_mark, "but found another document", 43 | event.start_mark) 44 | 45 | # Drop the STREAM-END event. 46 | self.get_event() 47 | 48 | return document 49 | 50 | def compose_document(self): 51 | # Drop the DOCUMENT-START event. 52 | self.get_event() 53 | 54 | # Compose the root node. 55 | node = self.compose_node(None, None) 56 | 57 | # Drop the DOCUMENT-END event. 58 | self.get_event() 59 | 60 | self.anchors = {} 61 | return node 62 | 63 | def compose_node(self, parent, index): 64 | if self.check_event(AliasEvent): 65 | event = self.get_event() 66 | anchor = event.anchor 67 | if anchor not in self.anchors: 68 | raise ComposerError(None, None, "found undefined alias %r" 69 | % anchor, event.start_mark) 70 | return self.anchors[anchor] 71 | event = self.peek_event() 72 | anchor = event.anchor 73 | if anchor is not None: 74 | if anchor in self.anchors: 75 | raise ComposerError("found duplicate anchor %r; first occurrence" 76 | % anchor, self.anchors[anchor].start_mark, 77 | "second occurrence", event.start_mark) 78 | self.descend_resolver(parent, index) 79 | if self.check_event(ScalarEvent): 80 | node = self.compose_scalar_node(anchor) 81 | elif self.check_event(SequenceStartEvent): 82 | node = self.compose_sequence_node(anchor) 83 | elif self.check_event(MappingStartEvent): 84 | node = self.compose_mapping_node(anchor) 85 | self.ascend_resolver() 86 | return node 87 | 88 | def compose_scalar_node(self, anchor): 89 | event = self.get_event() 90 | tag = event.tag 91 | if tag is None or tag == '!': 92 | tag = self.resolve(ScalarNode, event.value, event.implicit) 93 | node = ScalarNode(tag, event.value, 94 | event.start_mark, event.end_mark, style=event.style) 95 | if anchor is not None: 96 | self.anchors[anchor] = node 97 | return node 98 | 99 | def compose_sequence_node(self, anchor): 100 | start_event = self.get_event() 101 | tag = start_event.tag 102 | if tag is None or tag == '!': 103 | tag = self.resolve(SequenceNode, None, start_event.implicit) 104 | node = SequenceNode(tag, [], 105 | start_event.start_mark, None, 106 | flow_style=start_event.flow_style) 107 | if anchor is not None: 108 | self.anchors[anchor] = node 109 | index = 0 110 | while not self.check_event(SequenceEndEvent): 111 | node.value.append(self.compose_node(node, index)) 112 | index += 1 113 | end_event = self.get_event() 114 | node.end_mark = end_event.end_mark 115 | return node 116 | 117 | def compose_mapping_node(self, anchor): 118 | start_event = self.get_event() 119 | tag = start_event.tag 120 | if tag is None or tag == '!': 121 | tag = self.resolve(MappingNode, None, start_event.implicit) 122 | node = MappingNode(tag, [], 123 | start_event.start_mark, None, 124 | flow_style=start_event.flow_style) 125 | if anchor is not None: 126 | self.anchors[anchor] = node 127 | while not self.check_event(MappingEndEvent): 128 | #key_event = self.peek_event() 129 | item_key = self.compose_node(node, None) 130 | #if item_key in node.value: 131 | # raise ComposerError("while composing a mapping", start_event.start_mark, 132 | # "found duplicate key", key_event.start_mark) 133 | item_value = self.compose_node(node, item_key) 134 | #node.value[item_key] = item_value 135 | node.value.append((item_key, item_value)) 136 | end_event = self.get_event() 137 | node.end_mark = end_event.end_mark 138 | return node 139 | 140 | -------------------------------------------------------------------------------- /lib/yaml/cyaml.py: -------------------------------------------------------------------------------- 1 | 2 | __all__ = [ 3 | 'CBaseLoader', 'CSafeLoader', 'CFullLoader', 'CUnsafeLoader', 'CLoader', 4 | 'CBaseDumper', 'CSafeDumper', 'CDumper' 5 | ] 6 | 7 | from _yaml import CParser, CEmitter 8 | 9 | from .constructor import * 10 | 11 | from .serializer import * 12 | from .representer import * 13 | 14 | from .resolver import * 15 | 16 | class CBaseLoader(CParser, BaseConstructor, BaseResolver): 17 | 18 | def __init__(self, stream): 19 | CParser.__init__(self, stream) 20 | BaseConstructor.__init__(self) 21 | BaseResolver.__init__(self) 22 | 23 | class CSafeLoader(CParser, SafeConstructor, Resolver): 24 | 25 | def __init__(self, stream): 26 | CParser.__init__(self, stream) 27 | SafeConstructor.__init__(self) 28 | Resolver.__init__(self) 29 | 30 | class CFullLoader(CParser, FullConstructor, Resolver): 31 | 32 | def __init__(self, stream): 33 | CParser.__init__(self, stream) 34 | FullConstructor.__init__(self) 35 | Resolver.__init__(self) 36 | 37 | class CUnsafeLoader(CParser, UnsafeConstructor, Resolver): 38 | 39 | def __init__(self, stream): 40 | CParser.__init__(self, stream) 41 | UnsafeConstructor.__init__(self) 42 | Resolver.__init__(self) 43 | 44 | class CLoader(CParser, Constructor, Resolver): 45 | 46 | def __init__(self, stream): 47 | CParser.__init__(self, stream) 48 | Constructor.__init__(self) 49 | Resolver.__init__(self) 50 | 51 | class CBaseDumper(CEmitter, BaseRepresenter, BaseResolver): 52 | 53 | def __init__(self, stream, 54 | default_style=None, default_flow_style=False, 55 | canonical=None, indent=None, width=None, 56 | allow_unicode=None, line_break=None, 57 | encoding=None, explicit_start=None, explicit_end=None, 58 | version=None, tags=None, sort_keys=True): 59 | CEmitter.__init__(self, stream, canonical=canonical, 60 | indent=indent, width=width, encoding=encoding, 61 | allow_unicode=allow_unicode, line_break=line_break, 62 | explicit_start=explicit_start, explicit_end=explicit_end, 63 | version=version, tags=tags) 64 | Representer.__init__(self, default_style=default_style, 65 | default_flow_style=default_flow_style, sort_keys=sort_keys) 66 | Resolver.__init__(self) 67 | 68 | class CSafeDumper(CEmitter, SafeRepresenter, Resolver): 69 | 70 | def __init__(self, stream, 71 | default_style=None, default_flow_style=False, 72 | canonical=None, indent=None, width=None, 73 | allow_unicode=None, line_break=None, 74 | encoding=None, explicit_start=None, explicit_end=None, 75 | version=None, tags=None, sort_keys=True): 76 | CEmitter.__init__(self, stream, canonical=canonical, 77 | indent=indent, width=width, encoding=encoding, 78 | allow_unicode=allow_unicode, line_break=line_break, 79 | explicit_start=explicit_start, explicit_end=explicit_end, 80 | version=version, tags=tags) 81 | SafeRepresenter.__init__(self, default_style=default_style, 82 | default_flow_style=default_flow_style, sort_keys=sort_keys) 83 | Resolver.__init__(self) 84 | 85 | class CDumper(CEmitter, Serializer, Representer, Resolver): 86 | 87 | def __init__(self, stream, 88 | default_style=None, default_flow_style=False, 89 | canonical=None, indent=None, width=None, 90 | allow_unicode=None, line_break=None, 91 | encoding=None, explicit_start=None, explicit_end=None, 92 | version=None, tags=None, sort_keys=True): 93 | CEmitter.__init__(self, stream, canonical=canonical, 94 | indent=indent, width=width, encoding=encoding, 95 | allow_unicode=allow_unicode, line_break=line_break, 96 | explicit_start=explicit_start, explicit_end=explicit_end, 97 | version=version, tags=tags) 98 | Representer.__init__(self, default_style=default_style, 99 | default_flow_style=default_flow_style, sort_keys=sort_keys) 100 | Resolver.__init__(self) 101 | 102 | -------------------------------------------------------------------------------- /lib/yaml/dumper.py: -------------------------------------------------------------------------------- 1 | 2 | __all__ = ['BaseDumper', 'SafeDumper', 'Dumper'] 3 | 4 | from .emitter import * 5 | from .serializer import * 6 | from .representer import * 7 | from .resolver import * 8 | 9 | class BaseDumper(Emitter, Serializer, BaseRepresenter, BaseResolver): 10 | 11 | def __init__(self, stream, 12 | default_style=None, default_flow_style=False, 13 | canonical=None, indent=None, width=None, 14 | allow_unicode=None, line_break=None, 15 | encoding=None, explicit_start=None, explicit_end=None, 16 | version=None, tags=None, sort_keys=True): 17 | Emitter.__init__(self, stream, canonical=canonical, 18 | indent=indent, width=width, 19 | allow_unicode=allow_unicode, line_break=line_break) 20 | Serializer.__init__(self, encoding=encoding, 21 | explicit_start=explicit_start, explicit_end=explicit_end, 22 | version=version, tags=tags) 23 | Representer.__init__(self, default_style=default_style, 24 | default_flow_style=default_flow_style, sort_keys=sort_keys) 25 | Resolver.__init__(self) 26 | 27 | class SafeDumper(Emitter, Serializer, SafeRepresenter, Resolver): 28 | 29 | def __init__(self, stream, 30 | default_style=None, default_flow_style=False, 31 | canonical=None, indent=None, width=None, 32 | allow_unicode=None, line_break=None, 33 | encoding=None, explicit_start=None, explicit_end=None, 34 | version=None, tags=None, sort_keys=True): 35 | Emitter.__init__(self, stream, canonical=canonical, 36 | indent=indent, width=width, 37 | allow_unicode=allow_unicode, line_break=line_break) 38 | Serializer.__init__(self, encoding=encoding, 39 | explicit_start=explicit_start, explicit_end=explicit_end, 40 | version=version, tags=tags) 41 | SafeRepresenter.__init__(self, default_style=default_style, 42 | default_flow_style=default_flow_style, sort_keys=sort_keys) 43 | Resolver.__init__(self) 44 | 45 | class Dumper(Emitter, Serializer, Representer, Resolver): 46 | 47 | def __init__(self, stream, 48 | default_style=None, default_flow_style=False, 49 | canonical=None, indent=None, width=None, 50 | allow_unicode=None, line_break=None, 51 | encoding=None, explicit_start=None, explicit_end=None, 52 | version=None, tags=None, sort_keys=True): 53 | Emitter.__init__(self, stream, canonical=canonical, 54 | indent=indent, width=width, 55 | allow_unicode=allow_unicode, line_break=line_break) 56 | Serializer.__init__(self, encoding=encoding, 57 | explicit_start=explicit_start, explicit_end=explicit_end, 58 | version=version, tags=tags) 59 | Representer.__init__(self, default_style=default_style, 60 | default_flow_style=default_flow_style, sort_keys=sort_keys) 61 | Resolver.__init__(self) 62 | 63 | -------------------------------------------------------------------------------- /lib/yaml/error.py: -------------------------------------------------------------------------------- 1 | 2 | __all__ = ['Mark', 'YAMLError', 'MarkedYAMLError'] 3 | 4 | class Mark: 5 | 6 | def __init__(self, name, index, line, column, buffer, pointer): 7 | self.name = name 8 | self.index = index 9 | self.line = line 10 | self.column = column 11 | self.buffer = buffer 12 | self.pointer = pointer 13 | 14 | def get_snippet(self, indent=4, max_length=75): 15 | if self.buffer is None: 16 | return None 17 | head = '' 18 | start = self.pointer 19 | while start > 0 and self.buffer[start-1] not in '\0\r\n\x85\u2028\u2029': 20 | start -= 1 21 | if self.pointer-start > max_length/2-1: 22 | head = ' ... ' 23 | start += 5 24 | break 25 | tail = '' 26 | end = self.pointer 27 | while end < len(self.buffer) and self.buffer[end] not in '\0\r\n\x85\u2028\u2029': 28 | end += 1 29 | if end-self.pointer > max_length/2-1: 30 | tail = ' ... ' 31 | end -= 5 32 | break 33 | snippet = self.buffer[start:end] 34 | return ' '*indent + head + snippet + tail + '\n' \ 35 | + ' '*(indent+self.pointer-start+len(head)) + '^' 36 | 37 | def __str__(self): 38 | snippet = self.get_snippet() 39 | where = " in \"%s\", line %d, column %d" \ 40 | % (self.name, self.line+1, self.column+1) 41 | if snippet is not None: 42 | where += ":\n"+snippet 43 | return where 44 | 45 | class YAMLError(Exception): 46 | pass 47 | 48 | class MarkedYAMLError(YAMLError): 49 | 50 | def __init__(self, context=None, context_mark=None, 51 | problem=None, problem_mark=None, note=None): 52 | self.context = context 53 | self.context_mark = context_mark 54 | self.problem = problem 55 | self.problem_mark = problem_mark 56 | self.note = note 57 | 58 | def __str__(self): 59 | lines = [] 60 | if self.context is not None: 61 | lines.append(self.context) 62 | if self.context_mark is not None \ 63 | and (self.problem is None or self.problem_mark is None 64 | or self.context_mark.name != self.problem_mark.name 65 | or self.context_mark.line != self.problem_mark.line 66 | or self.context_mark.column != self.problem_mark.column): 67 | lines.append(str(self.context_mark)) 68 | if self.problem is not None: 69 | lines.append(self.problem) 70 | if self.problem_mark is not None: 71 | lines.append(str(self.problem_mark)) 72 | if self.note is not None: 73 | lines.append(self.note) 74 | return '\n'.join(lines) 75 | 76 | -------------------------------------------------------------------------------- /lib/yaml/events.py: -------------------------------------------------------------------------------- 1 | 2 | # Abstract classes. 3 | 4 | class Event(object): 5 | def __init__(self, start_mark=None, end_mark=None): 6 | self.start_mark = start_mark 7 | self.end_mark = end_mark 8 | def __repr__(self): 9 | attributes = [key for key in ['anchor', 'tag', 'implicit', 'value'] 10 | if hasattr(self, key)] 11 | arguments = ', '.join(['%s=%r' % (key, getattr(self, key)) 12 | for key in attributes]) 13 | return '%s(%s)' % (self.__class__.__name__, arguments) 14 | 15 | class NodeEvent(Event): 16 | def __init__(self, anchor, start_mark=None, end_mark=None): 17 | self.anchor = anchor 18 | self.start_mark = start_mark 19 | self.end_mark = end_mark 20 | 21 | class CollectionStartEvent(NodeEvent): 22 | def __init__(self, anchor, tag, implicit, start_mark=None, end_mark=None, 23 | flow_style=None): 24 | self.anchor = anchor 25 | self.tag = tag 26 | self.implicit = implicit 27 | self.start_mark = start_mark 28 | self.end_mark = end_mark 29 | self.flow_style = flow_style 30 | 31 | class CollectionEndEvent(Event): 32 | pass 33 | 34 | # Implementations. 35 | 36 | class StreamStartEvent(Event): 37 | def __init__(self, start_mark=None, end_mark=None, encoding=None): 38 | self.start_mark = start_mark 39 | self.end_mark = end_mark 40 | self.encoding = encoding 41 | 42 | class StreamEndEvent(Event): 43 | pass 44 | 45 | class DocumentStartEvent(Event): 46 | def __init__(self, start_mark=None, end_mark=None, 47 | explicit=None, version=None, tags=None): 48 | self.start_mark = start_mark 49 | self.end_mark = end_mark 50 | self.explicit = explicit 51 | self.version = version 52 | self.tags = tags 53 | 54 | class DocumentEndEvent(Event): 55 | def __init__(self, start_mark=None, end_mark=None, 56 | explicit=None): 57 | self.start_mark = start_mark 58 | self.end_mark = end_mark 59 | self.explicit = explicit 60 | 61 | class AliasEvent(NodeEvent): 62 | pass 63 | 64 | class ScalarEvent(NodeEvent): 65 | def __init__(self, anchor, tag, implicit, value, 66 | start_mark=None, end_mark=None, style=None): 67 | self.anchor = anchor 68 | self.tag = tag 69 | self.implicit = implicit 70 | self.value = value 71 | self.start_mark = start_mark 72 | self.end_mark = end_mark 73 | self.style = style 74 | 75 | class SequenceStartEvent(CollectionStartEvent): 76 | pass 77 | 78 | class SequenceEndEvent(CollectionEndEvent): 79 | pass 80 | 81 | class MappingStartEvent(CollectionStartEvent): 82 | pass 83 | 84 | class MappingEndEvent(CollectionEndEvent): 85 | pass 86 | 87 | -------------------------------------------------------------------------------- /lib/yaml/loader.py: -------------------------------------------------------------------------------- 1 | 2 | __all__ = ['BaseLoader', 'FullLoader', 'SafeLoader', 'Loader', 'UnsafeLoader'] 3 | 4 | from .reader import * 5 | from .scanner import * 6 | from .parser import * 7 | from .composer import * 8 | from .constructor import * 9 | from .resolver import * 10 | 11 | class BaseLoader(Reader, Scanner, Parser, Composer, BaseConstructor, BaseResolver): 12 | 13 | def __init__(self, stream): 14 | Reader.__init__(self, stream) 15 | Scanner.__init__(self) 16 | Parser.__init__(self) 17 | Composer.__init__(self) 18 | BaseConstructor.__init__(self) 19 | BaseResolver.__init__(self) 20 | 21 | class FullLoader(Reader, Scanner, Parser, Composer, FullConstructor, Resolver): 22 | 23 | def __init__(self, stream): 24 | Reader.__init__(self, stream) 25 | Scanner.__init__(self) 26 | Parser.__init__(self) 27 | Composer.__init__(self) 28 | FullConstructor.__init__(self) 29 | Resolver.__init__(self) 30 | 31 | class SafeLoader(Reader, Scanner, Parser, Composer, SafeConstructor, Resolver): 32 | 33 | def __init__(self, stream): 34 | Reader.__init__(self, stream) 35 | Scanner.__init__(self) 36 | Parser.__init__(self) 37 | Composer.__init__(self) 38 | SafeConstructor.__init__(self) 39 | Resolver.__init__(self) 40 | 41 | class Loader(Reader, Scanner, Parser, Composer, Constructor, Resolver): 42 | 43 | def __init__(self, stream): 44 | Reader.__init__(self, stream) 45 | Scanner.__init__(self) 46 | Parser.__init__(self) 47 | Composer.__init__(self) 48 | Constructor.__init__(self) 49 | Resolver.__init__(self) 50 | 51 | # UnsafeLoader is the same as Loader (which is and was always unsafe on 52 | # untrusted input). Use of either Loader or UnsafeLoader should be rare, since 53 | # FullLoad should be able to load almost all YAML safely. Loader is left intact 54 | # to ensure backwards compatibility. 55 | class UnsafeLoader(Reader, Scanner, Parser, Composer, Constructor, Resolver): 56 | 57 | def __init__(self, stream): 58 | Reader.__init__(self, stream) 59 | Scanner.__init__(self) 60 | Parser.__init__(self) 61 | Composer.__init__(self) 62 | Constructor.__init__(self) 63 | Resolver.__init__(self) 64 | -------------------------------------------------------------------------------- /lib/yaml/nodes.py: -------------------------------------------------------------------------------- 1 | 2 | class Node(object): 3 | def __init__(self, tag, value, start_mark, end_mark): 4 | self.tag = tag 5 | self.value = value 6 | self.start_mark = start_mark 7 | self.end_mark = end_mark 8 | def __repr__(self): 9 | value = self.value 10 | #if isinstance(value, list): 11 | # if len(value) == 0: 12 | # value = '' 13 | # elif len(value) == 1: 14 | # value = '<1 item>' 15 | # else: 16 | # value = '<%d items>' % len(value) 17 | #else: 18 | # if len(value) > 75: 19 | # value = repr(value[:70]+u' ... ') 20 | # else: 21 | # value = repr(value) 22 | value = repr(value) 23 | return '%s(tag=%r, value=%s)' % (self.__class__.__name__, self.tag, value) 24 | 25 | class ScalarNode(Node): 26 | id = 'scalar' 27 | def __init__(self, tag, value, 28 | start_mark=None, end_mark=None, style=None): 29 | self.tag = tag 30 | self.value = value 31 | self.start_mark = start_mark 32 | self.end_mark = end_mark 33 | self.style = style 34 | 35 | class CollectionNode(Node): 36 | def __init__(self, tag, value, 37 | start_mark=None, end_mark=None, flow_style=None): 38 | self.tag = tag 39 | self.value = value 40 | self.start_mark = start_mark 41 | self.end_mark = end_mark 42 | self.flow_style = flow_style 43 | 44 | class SequenceNode(CollectionNode): 45 | id = 'sequence' 46 | 47 | class MappingNode(CollectionNode): 48 | id = 'mapping' 49 | 50 | -------------------------------------------------------------------------------- /lib/yaml/reader.py: -------------------------------------------------------------------------------- 1 | # This module contains abstractions for the input stream. You don't have to 2 | # looks further, there are no pretty code. 3 | # 4 | # We define two classes here. 5 | # 6 | # Mark(source, line, column) 7 | # It's just a record and its only use is producing nice error messages. 8 | # Parser does not use it for any other purposes. 9 | # 10 | # Reader(source, data) 11 | # Reader determines the encoding of `data` and converts it to unicode. 12 | # Reader provides the following methods and attributes: 13 | # reader.peek(length=1) - return the next `length` characters 14 | # reader.forward(length=1) - move the current position to `length` characters. 15 | # reader.index - the number of the current character. 16 | # reader.line, stream.column - the line and the column of the current character. 17 | 18 | __all__ = ['Reader', 'ReaderError'] 19 | 20 | from .error import YAMLError, Mark 21 | 22 | import codecs, re 23 | 24 | class ReaderError(YAMLError): 25 | 26 | def __init__(self, name, position, character, encoding, reason): 27 | self.name = name 28 | self.character = character 29 | self.position = position 30 | self.encoding = encoding 31 | self.reason = reason 32 | 33 | def __str__(self): 34 | if isinstance(self.character, bytes): 35 | return "'%s' codec can't decode byte #x%02x: %s\n" \ 36 | " in \"%s\", position %d" \ 37 | % (self.encoding, ord(self.character), self.reason, 38 | self.name, self.position) 39 | else: 40 | return "unacceptable character #x%04x: %s\n" \ 41 | " in \"%s\", position %d" \ 42 | % (self.character, self.reason, 43 | self.name, self.position) 44 | 45 | class Reader(object): 46 | # Reader: 47 | # - determines the data encoding and converts it to a unicode string, 48 | # - checks if characters are in allowed range, 49 | # - adds '\0' to the end. 50 | 51 | # Reader accepts 52 | # - a `bytes` object, 53 | # - a `str` object, 54 | # - a file-like object with its `read` method returning `str`, 55 | # - a file-like object with its `read` method returning `unicode`. 56 | 57 | # Yeah, it's ugly and slow. 58 | 59 | def __init__(self, stream): 60 | self.name = None 61 | self.stream = None 62 | self.stream_pointer = 0 63 | self.eof = True 64 | self.buffer = '' 65 | self.pointer = 0 66 | self.raw_buffer = None 67 | self.raw_decode = None 68 | self.encoding = None 69 | self.index = 0 70 | self.line = 0 71 | self.column = 0 72 | if isinstance(stream, str): 73 | self.name = "" 74 | self.check_printable(stream) 75 | self.buffer = stream+'\0' 76 | elif isinstance(stream, bytes): 77 | self.name = "" 78 | self.raw_buffer = stream 79 | self.determine_encoding() 80 | else: 81 | self.stream = stream 82 | self.name = getattr(stream, 'name', "") 83 | self.eof = False 84 | self.raw_buffer = None 85 | self.determine_encoding() 86 | 87 | def peek(self, index=0): 88 | try: 89 | return self.buffer[self.pointer+index] 90 | except IndexError: 91 | self.update(index+1) 92 | return self.buffer[self.pointer+index] 93 | 94 | def prefix(self, length=1): 95 | if self.pointer+length >= len(self.buffer): 96 | self.update(length) 97 | return self.buffer[self.pointer:self.pointer+length] 98 | 99 | def forward(self, length=1): 100 | if self.pointer+length+1 >= len(self.buffer): 101 | self.update(length+1) 102 | while length: 103 | ch = self.buffer[self.pointer] 104 | self.pointer += 1 105 | self.index += 1 106 | if ch in '\n\x85\u2028\u2029' \ 107 | or (ch == '\r' and self.buffer[self.pointer] != '\n'): 108 | self.line += 1 109 | self.column = 0 110 | elif ch != '\uFEFF': 111 | self.column += 1 112 | length -= 1 113 | 114 | def get_mark(self): 115 | if self.stream is None: 116 | return Mark(self.name, self.index, self.line, self.column, 117 | self.buffer, self.pointer) 118 | else: 119 | return Mark(self.name, self.index, self.line, self.column, 120 | None, None) 121 | 122 | def determine_encoding(self): 123 | while not self.eof and (self.raw_buffer is None or len(self.raw_buffer) < 2): 124 | self.update_raw() 125 | if isinstance(self.raw_buffer, bytes): 126 | if self.raw_buffer.startswith(codecs.BOM_UTF16_LE): 127 | self.raw_decode = codecs.utf_16_le_decode 128 | self.encoding = 'utf-16-le' 129 | elif self.raw_buffer.startswith(codecs.BOM_UTF16_BE): 130 | self.raw_decode = codecs.utf_16_be_decode 131 | self.encoding = 'utf-16-be' 132 | else: 133 | self.raw_decode = codecs.utf_8_decode 134 | self.encoding = 'utf-8' 135 | self.update(1) 136 | 137 | NON_PRINTABLE = re.compile('[^\x09\x0A\x0D\x20-\x7E\x85\xA0-\uD7FF\uE000-\uFFFD\U00010000-\U0010ffff]') 138 | def check_printable(self, data): 139 | match = self.NON_PRINTABLE.search(data) 140 | if match: 141 | character = match.group() 142 | position = self.index+(len(self.buffer)-self.pointer)+match.start() 143 | raise ReaderError(self.name, position, ord(character), 144 | 'unicode', "special characters are not allowed") 145 | 146 | def update(self, length): 147 | if self.raw_buffer is None: 148 | return 149 | self.buffer = self.buffer[self.pointer:] 150 | self.pointer = 0 151 | while len(self.buffer) < length: 152 | if not self.eof: 153 | self.update_raw() 154 | if self.raw_decode is not None: 155 | try: 156 | data, converted = self.raw_decode(self.raw_buffer, 157 | 'strict', self.eof) 158 | except UnicodeDecodeError as exc: 159 | character = self.raw_buffer[exc.start] 160 | if self.stream is not None: 161 | position = self.stream_pointer-len(self.raw_buffer)+exc.start 162 | else: 163 | position = exc.start 164 | raise ReaderError(self.name, position, character, 165 | exc.encoding, exc.reason) 166 | else: 167 | data = self.raw_buffer 168 | converted = len(data) 169 | self.check_printable(data) 170 | self.buffer += data 171 | self.raw_buffer = self.raw_buffer[converted:] 172 | if self.eof: 173 | self.buffer += '\0' 174 | self.raw_buffer = None 175 | break 176 | 177 | def update_raw(self, size=4096): 178 | data = self.stream.read(size) 179 | if self.raw_buffer is None: 180 | self.raw_buffer = data 181 | else: 182 | self.raw_buffer += data 183 | self.stream_pointer += len(data) 184 | if not data: 185 | self.eof = True 186 | -------------------------------------------------------------------------------- /lib/yaml/resolver.py: -------------------------------------------------------------------------------- 1 | 2 | __all__ = ['BaseResolver', 'Resolver'] 3 | 4 | from .error import * 5 | from .nodes import * 6 | 7 | import re 8 | 9 | class ResolverError(YAMLError): 10 | pass 11 | 12 | class BaseResolver: 13 | 14 | DEFAULT_SCALAR_TAG = 'tag:yaml.org,2002:str' 15 | DEFAULT_SEQUENCE_TAG = 'tag:yaml.org,2002:seq' 16 | DEFAULT_MAPPING_TAG = 'tag:yaml.org,2002:map' 17 | 18 | yaml_implicit_resolvers = {} 19 | yaml_path_resolvers = {} 20 | 21 | def __init__(self): 22 | self.resolver_exact_paths = [] 23 | self.resolver_prefix_paths = [] 24 | 25 | @classmethod 26 | def add_implicit_resolver(cls, tag, regexp, first): 27 | if not 'yaml_implicit_resolvers' in cls.__dict__: 28 | implicit_resolvers = {} 29 | for key in cls.yaml_implicit_resolvers: 30 | implicit_resolvers[key] = cls.yaml_implicit_resolvers[key][:] 31 | cls.yaml_implicit_resolvers = implicit_resolvers 32 | if first is None: 33 | first = [None] 34 | for ch in first: 35 | cls.yaml_implicit_resolvers.setdefault(ch, []).append((tag, regexp)) 36 | 37 | @classmethod 38 | def add_path_resolver(cls, tag, path, kind=None): 39 | # Note: `add_path_resolver` is experimental. The API could be changed. 40 | # `new_path` is a pattern that is matched against the path from the 41 | # root to the node that is being considered. `node_path` elements are 42 | # tuples `(node_check, index_check)`. `node_check` is a node class: 43 | # `ScalarNode`, `SequenceNode`, `MappingNode` or `None`. `None` 44 | # matches any kind of a node. `index_check` could be `None`, a boolean 45 | # value, a string value, or a number. `None` and `False` match against 46 | # any _value_ of sequence and mapping nodes. `True` matches against 47 | # any _key_ of a mapping node. A string `index_check` matches against 48 | # a mapping value that corresponds to a scalar key which content is 49 | # equal to the `index_check` value. An integer `index_check` matches 50 | # against a sequence value with the index equal to `index_check`. 51 | if not 'yaml_path_resolvers' in cls.__dict__: 52 | cls.yaml_path_resolvers = cls.yaml_path_resolvers.copy() 53 | new_path = [] 54 | for element in path: 55 | if isinstance(element, (list, tuple)): 56 | if len(element) == 2: 57 | node_check, index_check = element 58 | elif len(element) == 1: 59 | node_check = element[0] 60 | index_check = True 61 | else: 62 | raise ResolverError("Invalid path element: %s" % element) 63 | else: 64 | node_check = None 65 | index_check = element 66 | if node_check is str: 67 | node_check = ScalarNode 68 | elif node_check is list: 69 | node_check = SequenceNode 70 | elif node_check is dict: 71 | node_check = MappingNode 72 | elif node_check not in [ScalarNode, SequenceNode, MappingNode] \ 73 | and not isinstance(node_check, str) \ 74 | and node_check is not None: 75 | raise ResolverError("Invalid node checker: %s" % node_check) 76 | if not isinstance(index_check, (str, int)) \ 77 | and index_check is not None: 78 | raise ResolverError("Invalid index checker: %s" % index_check) 79 | new_path.append((node_check, index_check)) 80 | if kind is str: 81 | kind = ScalarNode 82 | elif kind is list: 83 | kind = SequenceNode 84 | elif kind is dict: 85 | kind = MappingNode 86 | elif kind not in [ScalarNode, SequenceNode, MappingNode] \ 87 | and kind is not None: 88 | raise ResolverError("Invalid node kind: %s" % kind) 89 | cls.yaml_path_resolvers[tuple(new_path), kind] = tag 90 | 91 | def descend_resolver(self, current_node, current_index): 92 | if not self.yaml_path_resolvers: 93 | return 94 | exact_paths = {} 95 | prefix_paths = [] 96 | if current_node: 97 | depth = len(self.resolver_prefix_paths) 98 | for path, kind in self.resolver_prefix_paths[-1]: 99 | if self.check_resolver_prefix(depth, path, kind, 100 | current_node, current_index): 101 | if len(path) > depth: 102 | prefix_paths.append((path, kind)) 103 | else: 104 | exact_paths[kind] = self.yaml_path_resolvers[path, kind] 105 | else: 106 | for path, kind in self.yaml_path_resolvers: 107 | if not path: 108 | exact_paths[kind] = self.yaml_path_resolvers[path, kind] 109 | else: 110 | prefix_paths.append((path, kind)) 111 | self.resolver_exact_paths.append(exact_paths) 112 | self.resolver_prefix_paths.append(prefix_paths) 113 | 114 | def ascend_resolver(self): 115 | if not self.yaml_path_resolvers: 116 | return 117 | self.resolver_exact_paths.pop() 118 | self.resolver_prefix_paths.pop() 119 | 120 | def check_resolver_prefix(self, depth, path, kind, 121 | current_node, current_index): 122 | node_check, index_check = path[depth-1] 123 | if isinstance(node_check, str): 124 | if current_node.tag != node_check: 125 | return 126 | elif node_check is not None: 127 | if not isinstance(current_node, node_check): 128 | return 129 | if index_check is True and current_index is not None: 130 | return 131 | if (index_check is False or index_check is None) \ 132 | and current_index is None: 133 | return 134 | if isinstance(index_check, str): 135 | if not (isinstance(current_index, ScalarNode) 136 | and index_check == current_index.value): 137 | return 138 | elif isinstance(index_check, int) and not isinstance(index_check, bool): 139 | if index_check != current_index: 140 | return 141 | return True 142 | 143 | def resolve(self, kind, value, implicit): 144 | if kind is ScalarNode and implicit[0]: 145 | if value == '': 146 | resolvers = self.yaml_implicit_resolvers.get('', []) 147 | else: 148 | resolvers = self.yaml_implicit_resolvers.get(value[0], []) 149 | resolvers += self.yaml_implicit_resolvers.get(None, []) 150 | for tag, regexp in resolvers: 151 | if regexp.match(value): 152 | return tag 153 | implicit = implicit[1] 154 | if self.yaml_path_resolvers: 155 | exact_paths = self.resolver_exact_paths[-1] 156 | if kind in exact_paths: 157 | return exact_paths[kind] 158 | if None in exact_paths: 159 | return exact_paths[None] 160 | if kind is ScalarNode: 161 | return self.DEFAULT_SCALAR_TAG 162 | elif kind is SequenceNode: 163 | return self.DEFAULT_SEQUENCE_TAG 164 | elif kind is MappingNode: 165 | return self.DEFAULT_MAPPING_TAG 166 | 167 | class Resolver(BaseResolver): 168 | pass 169 | 170 | Resolver.add_implicit_resolver( 171 | 'tag:yaml.org,2002:bool', 172 | re.compile(r'''^(?:yes|Yes|YES|no|No|NO 173 | |true|True|TRUE|false|False|FALSE 174 | |on|On|ON|off|Off|OFF)$''', re.X), 175 | list('yYnNtTfFoO')) 176 | 177 | Resolver.add_implicit_resolver( 178 | 'tag:yaml.org,2002:float', 179 | re.compile(r'''^(?:[-+]?(?:[0-9][0-9_]*)\.[0-9_]*(?:[eE][-+][0-9]+)? 180 | |\.[0-9_]+(?:[eE][-+][0-9]+)? 181 | |[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+\.[0-9_]* 182 | |[-+]?\.(?:inf|Inf|INF) 183 | |\.(?:nan|NaN|NAN))$''', re.X), 184 | list('-+0123456789.')) 185 | 186 | Resolver.add_implicit_resolver( 187 | 'tag:yaml.org,2002:int', 188 | re.compile(r'''^(?:[-+]?0b[0-1_]+ 189 | |[-+]?0[0-7_]+ 190 | |[-+]?(?:0|[1-9][0-9_]*) 191 | |[-+]?0x[0-9a-fA-F_]+ 192 | |[-+]?[1-9][0-9_]*(?::[0-5]?[0-9])+)$''', re.X), 193 | list('-+0123456789')) 194 | 195 | Resolver.add_implicit_resolver( 196 | 'tag:yaml.org,2002:merge', 197 | re.compile(r'^(?:<<)$'), 198 | ['<']) 199 | 200 | Resolver.add_implicit_resolver( 201 | 'tag:yaml.org,2002:null', 202 | re.compile(r'''^(?: ~ 203 | |null|Null|NULL 204 | | )$''', re.X), 205 | ['~', 'n', 'N', '']) 206 | 207 | Resolver.add_implicit_resolver( 208 | 'tag:yaml.org,2002:timestamp', 209 | re.compile(r'''^(?:[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9] 210 | |[0-9][0-9][0-9][0-9] -[0-9][0-9]? -[0-9][0-9]? 211 | (?:[Tt]|[ \t]+)[0-9][0-9]? 212 | :[0-9][0-9] :[0-9][0-9] (?:\.[0-9]*)? 213 | (?:[ \t]*(?:Z|[-+][0-9][0-9]?(?::[0-9][0-9])?))?)$''', re.X), 214 | list('0123456789')) 215 | 216 | Resolver.add_implicit_resolver( 217 | 'tag:yaml.org,2002:value', 218 | re.compile(r'^(?:=)$'), 219 | ['=']) 220 | 221 | # The following resolver is only for documentation purposes. It cannot work 222 | # because plain scalars cannot start with '!', '&', or '*'. 223 | Resolver.add_implicit_resolver( 224 | 'tag:yaml.org,2002:yaml', 225 | re.compile(r'^(?:!|&|\*)$'), 226 | list('!&*')) 227 | 228 | -------------------------------------------------------------------------------- /lib/yaml/serializer.py: -------------------------------------------------------------------------------- 1 | 2 | __all__ = ['Serializer', 'SerializerError'] 3 | 4 | from .error import YAMLError 5 | from .events import * 6 | from .nodes import * 7 | 8 | class SerializerError(YAMLError): 9 | pass 10 | 11 | class Serializer: 12 | 13 | ANCHOR_TEMPLATE = 'id%03d' 14 | 15 | def __init__(self, encoding=None, 16 | explicit_start=None, explicit_end=None, version=None, tags=None): 17 | self.use_encoding = encoding 18 | self.use_explicit_start = explicit_start 19 | self.use_explicit_end = explicit_end 20 | self.use_version = version 21 | self.use_tags = tags 22 | self.serialized_nodes = {} 23 | self.anchors = {} 24 | self.last_anchor_id = 0 25 | self.closed = None 26 | 27 | def open(self): 28 | if self.closed is None: 29 | self.emit(StreamStartEvent(encoding=self.use_encoding)) 30 | self.closed = False 31 | elif self.closed: 32 | raise SerializerError("serializer is closed") 33 | else: 34 | raise SerializerError("serializer is already opened") 35 | 36 | def close(self): 37 | if self.closed is None: 38 | raise SerializerError("serializer is not opened") 39 | elif not self.closed: 40 | self.emit(StreamEndEvent()) 41 | self.closed = True 42 | 43 | #def __del__(self): 44 | # self.close() 45 | 46 | def serialize(self, node): 47 | if self.closed is None: 48 | raise SerializerError("serializer is not opened") 49 | elif self.closed: 50 | raise SerializerError("serializer is closed") 51 | self.emit(DocumentStartEvent(explicit=self.use_explicit_start, 52 | version=self.use_version, tags=self.use_tags)) 53 | self.anchor_node(node) 54 | self.serialize_node(node, None, None) 55 | self.emit(DocumentEndEvent(explicit=self.use_explicit_end)) 56 | self.serialized_nodes = {} 57 | self.anchors = {} 58 | self.last_anchor_id = 0 59 | 60 | def anchor_node(self, node): 61 | if node in self.anchors: 62 | if self.anchors[node] is None: 63 | self.anchors[node] = self.generate_anchor(node) 64 | else: 65 | self.anchors[node] = None 66 | if isinstance(node, SequenceNode): 67 | for item in node.value: 68 | self.anchor_node(item) 69 | elif isinstance(node, MappingNode): 70 | for key, value in node.value: 71 | self.anchor_node(key) 72 | self.anchor_node(value) 73 | 74 | def generate_anchor(self, node): 75 | self.last_anchor_id += 1 76 | return self.ANCHOR_TEMPLATE % self.last_anchor_id 77 | 78 | def serialize_node(self, node, parent, index): 79 | alias = self.anchors[node] 80 | if node in self.serialized_nodes: 81 | self.emit(AliasEvent(alias)) 82 | else: 83 | self.serialized_nodes[node] = True 84 | self.descend_resolver(parent, index) 85 | if isinstance(node, ScalarNode): 86 | detected_tag = self.resolve(ScalarNode, node.value, (True, False)) 87 | default_tag = self.resolve(ScalarNode, node.value, (False, True)) 88 | implicit = (node.tag == detected_tag), (node.tag == default_tag) 89 | self.emit(ScalarEvent(alias, node.tag, implicit, node.value, 90 | style=node.style)) 91 | elif isinstance(node, SequenceNode): 92 | implicit = (node.tag 93 | == self.resolve(SequenceNode, node.value, True)) 94 | self.emit(SequenceStartEvent(alias, node.tag, implicit, 95 | flow_style=node.flow_style)) 96 | index = 0 97 | for item in node.value: 98 | self.serialize_node(item, node, index) 99 | index += 1 100 | self.emit(SequenceEndEvent()) 101 | elif isinstance(node, MappingNode): 102 | implicit = (node.tag 103 | == self.resolve(MappingNode, node.value, True)) 104 | self.emit(MappingStartEvent(alias, node.tag, implicit, 105 | flow_style=node.flow_style)) 106 | for key, value in node.value: 107 | self.serialize_node(key, node, None) 108 | self.serialize_node(value, node, key) 109 | self.emit(MappingEndEvent()) 110 | self.ascend_resolver() 111 | 112 | -------------------------------------------------------------------------------- /lib/yaml/tokens.py: -------------------------------------------------------------------------------- 1 | 2 | class Token(object): 3 | def __init__(self, start_mark, end_mark): 4 | self.start_mark = start_mark 5 | self.end_mark = end_mark 6 | def __repr__(self): 7 | attributes = [key for key in self.__dict__ 8 | if not key.endswith('_mark')] 9 | attributes.sort() 10 | arguments = ', '.join(['%s=%r' % (key, getattr(self, key)) 11 | for key in attributes]) 12 | return '%s(%s)' % (self.__class__.__name__, arguments) 13 | 14 | #class BOMToken(Token): 15 | # id = '' 16 | 17 | class DirectiveToken(Token): 18 | id = '' 19 | def __init__(self, name, value, start_mark, end_mark): 20 | self.name = name 21 | self.value = value 22 | self.start_mark = start_mark 23 | self.end_mark = end_mark 24 | 25 | class DocumentStartToken(Token): 26 | id = '' 27 | 28 | class DocumentEndToken(Token): 29 | id = '' 30 | 31 | class StreamStartToken(Token): 32 | id = '' 33 | def __init__(self, start_mark=None, end_mark=None, 34 | encoding=None): 35 | self.start_mark = start_mark 36 | self.end_mark = end_mark 37 | self.encoding = encoding 38 | 39 | class StreamEndToken(Token): 40 | id = '' 41 | 42 | class BlockSequenceStartToken(Token): 43 | id = '' 44 | 45 | class BlockMappingStartToken(Token): 46 | id = '' 47 | 48 | class BlockEndToken(Token): 49 | id = '' 50 | 51 | class FlowSequenceStartToken(Token): 52 | id = '[' 53 | 54 | class FlowMappingStartToken(Token): 55 | id = '{' 56 | 57 | class FlowSequenceEndToken(Token): 58 | id = ']' 59 | 60 | class FlowMappingEndToken(Token): 61 | id = '}' 62 | 63 | class KeyToken(Token): 64 | id = '?' 65 | 66 | class ValueToken(Token): 67 | id = ':' 68 | 69 | class BlockEntryToken(Token): 70 | id = '-' 71 | 72 | class FlowEntryToken(Token): 73 | id = ',' 74 | 75 | class AliasToken(Token): 76 | id = '' 77 | def __init__(self, value, start_mark, end_mark): 78 | self.value = value 79 | self.start_mark = start_mark 80 | self.end_mark = end_mark 81 | 82 | class AnchorToken(Token): 83 | id = '' 84 | def __init__(self, value, start_mark, end_mark): 85 | self.value = value 86 | self.start_mark = start_mark 87 | self.end_mark = end_mark 88 | 89 | class TagToken(Token): 90 | id = '' 91 | def __init__(self, value, start_mark, end_mark): 92 | self.value = value 93 | self.start_mark = start_mark 94 | self.end_mark = end_mark 95 | 96 | class ScalarToken(Token): 97 | id = '' 98 | def __init__(self, value, plain, start_mark, end_mark, style=None): 99 | self.value = value 100 | self.plain = plain 101 | self.start_mark = start_mark 102 | self.end_mark = end_mark 103 | self.style = style 104 | 105 | -------------------------------------------------------------------------------- /library.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 3 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | # 19 | # Copyright (C) 2020-2022 Michael Vigovsky 20 | 21 | import os, logging 22 | import bpy # pylint: disable=import-error 23 | from bpy_extras.wm_utils.progress_report import ProgressReport # pylint: disable=import-error, no-name-in-module 24 | 25 | from . import common, prefs 26 | from .lib import morpher, materials, morphs, utils 27 | from .lib.charlib import library, empty_char 28 | 29 | logger = logging.getLogger(__name__) 30 | 31 | 32 | class OpReloadLib(bpy.types.Operator): 33 | bl_idname = "charmorph.reload_library" 34 | bl_label = "Reload library" 35 | bl_description = "Reload character library" 36 | 37 | def execute(self, _): # pylint: disable=no-self-use 38 | library.load() 39 | common.manager.recreate_charmorphs() 40 | return {"FINISHED"} 41 | 42 | 43 | def _import_moprhs(wm, obj, char): 44 | ui = wm.charmorph_ui 45 | if not ui.use_sk: 46 | ui.import_morphs = False 47 | ui.import_expressions = False 48 | 49 | steps = int(ui.import_morphs) + int(ui.import_expressions) 50 | if not steps: 51 | return None 52 | 53 | storage = morphs.MorphStorage(char) 54 | importer = morphs.MorphImporter(storage, obj) 55 | 56 | with ProgressReport(wm) as progress: 57 | progress.enter_substeps(steps, "Importing shape keys...") 58 | if ui.import_morphs: 59 | importer.import_morphs(progress) 60 | # ??? without extra empty step, progress resets to 0% after loadging morphs. TODO: investigate 61 | progress.step() 62 | progress.step("Morphs imported") 63 | if ui.import_expressions: 64 | importer.import_expressions(progress) 65 | progress.step("Expressions imported") 66 | progress.leave_substeps("Shape keys done") 67 | 68 | return storage 69 | 70 | 71 | class OpImport(bpy.types.Operator): 72 | bl_idname = "charmorph.import_char" 73 | bl_label = "Import character" 74 | bl_description = "Import character" 75 | bl_options = {"UNDO"} 76 | 77 | @classmethod 78 | def poll(cls, context): 79 | return context.mode == "OBJECT" 80 | 81 | def execute(self, context): 82 | ui = context.window_manager.charmorph_ui 83 | if not ui.base_model: 84 | self.report({'ERROR'}, "Please select base model") 85 | return {"CANCELLED"} 86 | 87 | char = library.chars[ui.base_model] 88 | 89 | if ui.alt_topo != "" and char.faces is None: 90 | ui.alt_topo = "" 91 | return {"CANCELLED"} 92 | 93 | if ui.alt_topo == "": 94 | if not ui.alt_topo_obj or ui.alt_topo_obj.type != "MESH": 95 | self.report({'ERROR'}, "Please select correct custom alternative topology object") 96 | return {"CANCELLED"} 97 | 98 | orig_mesh = ui.alt_topo_obj.data 99 | mesh = orig_mesh.copy() 100 | mesh.name = char.name 101 | # TODO: cleanup shape keys 102 | mesh["cm_alt_topo"] = orig_mesh 103 | 104 | obj = bpy.data.objects.new(char.name, mesh) 105 | context.collection.objects.link(obj) 106 | storage = None 107 | else: 108 | obj = utils.import_obj(char.blend_file(), char.char_obj) 109 | if obj is None: 110 | self.report({'ERROR'}, "Import failed") 111 | return {"CANCELLED"} 112 | 113 | storage = _import_moprhs(context.window_manager, obj, char) 114 | 115 | if not ui.import_morphs and os.path.isdir(char.path("morphs")): 116 | obj.data["cm_morpher"] = "ext" 117 | 118 | materials.init_materials(obj, char) 119 | 120 | obj.location = context.scene.cursor.location 121 | if ui.import_cursor_z: 122 | obj.rotation_mode = "XYZ" 123 | obj.rotation_euler = (0, 0, context.scene.cursor.rotation_euler[2]) 124 | 125 | obj.data["charmorph_template"] = ui.base_model 126 | 127 | if (ui.use_sk or char.np_basis is None) and (not obj.data.shape_keys or not obj.data.shape_keys.key_blocks): 128 | obj.shape_key_add(name="Basis", from_mix=False) 129 | 130 | m = morpher.get(obj, storage, common.undo_handler) 131 | common.manager.update_morpher(m) 132 | 133 | context.view_layer.objects.active = obj 134 | ui.fitting_char = obj 135 | 136 | asset_list = [] 137 | 138 | def add_assets(lst): 139 | asset_list.extend((char.assets[name] for name in lst)) 140 | add_assets(char.default_assets) 141 | if not prefs.is_adult_mode(): 142 | add_assets(char.underwear) 143 | 144 | m.fitter.fit_import(asset_list) 145 | m.update() 146 | 147 | return {"FINISHED"} 148 | 149 | 150 | def char_default_tex_set(char): 151 | if not char: 152 | return "/" 153 | if not char.default_tex_set: 154 | return char.texture_sets[0] 155 | return char.default_tex_set 156 | 157 | 158 | def update_base_model(ui, _): 159 | ui.tex_set = char_default_tex_set(library.chars.get(ui.base_model)) 160 | 161 | 162 | class UIProps: 163 | base_model: bpy.props.EnumProperty( 164 | name="Base", 165 | items=lambda _ui, _: [(name, char.title, char.description) for name, char in library.chars.items()], 166 | update=update_base_model, 167 | description="Choose a base model") 168 | material_mode: bpy.props.EnumProperty( 169 | name="Materials", 170 | default="TS", 171 | description="Share materials between different Charmorph characters or not", 172 | items=[ 173 | ("NS", "Non-Shared", "Use unique material for each character"), 174 | ("TS", "Shared textures only", "Use same texture for all characters"), 175 | ("MS", "Shared", "Use same materials for all characters")] 176 | ) 177 | # TODO: copy materials from custom object 178 | material_local: bpy.props.BoolProperty( 179 | name="Use local materials", default=True, 180 | description="Use local copies of materials for faster loading") 181 | tex_set: bpy.props.EnumProperty( 182 | name="Texture set", 183 | description="Select texture set for the character", 184 | items=lambda ui, _: [ 185 | (name, "" if name == "/" else name, "") 186 | for name in library.chars.get(ui.base_model, empty_char).texture_sets 187 | ], 188 | ) 189 | tex_downscale: bpy.props.EnumProperty( 190 | name="Downscale textures", 191 | description="Downscale large textures to avoid memory overflows", 192 | default="UL", 193 | items=[("1K", "1K", ""), ("2K", "2K", ""), ("4K", "4K", ""), ("UL", "No limit", "")] 194 | ) 195 | import_cursor_z: bpy.props.BoolProperty( 196 | name="Use Z cursor rotation", default=True, 197 | description="Take 3D cursor Z rotation into account when creating the character") 198 | use_sk: bpy.props.BoolProperty( 199 | name="Use shape keys for morphing", default=False, 200 | description="Use shape keys during morphing" 201 | "(should be on if you plan to resume morphing later, maybe with other versions of CharMorph)") 202 | import_morphs: bpy.props.BoolProperty( 203 | name="Import morphing shape keys", default=False, 204 | description="Import and morph character using shape keys") 205 | import_expressions: bpy.props.BoolProperty( 206 | name="Import expression shape keys", default=False, 207 | description="Import and morph character using shape keys") 208 | alt_topo: bpy.props.EnumProperty( 209 | name="Alt topo", 210 | default="", 211 | description="Select alternative topology to use", 212 | items=[ 213 | ("", "", "Use base character topology"), 214 | ("", "", "Use custom local object as alt topo")] 215 | ) 216 | alt_topo_obj: bpy.props.PointerProperty( 217 | name="Custom alt topo", 218 | type=bpy.types.Object, 219 | description="Select custom object to use as alternative topology", 220 | poll=utils.visible_mesh_poll) 221 | 222 | 223 | class CHARMORPH_PT_Library(bpy.types.Panel): 224 | bl_label = "Character library" 225 | bl_parent_id = "VIEW3D_PT_CharMorph" 226 | bl_space_type = 'VIEW_3D' 227 | bl_region_type = 'UI' 228 | bl_order = 1 229 | 230 | @classmethod 231 | def poll(cls, context): 232 | return context.mode == "OBJECT" 233 | 234 | def draw(self, context): 235 | l = self.layout 236 | ui = context.window_manager.charmorph_ui 237 | l.operator('charmorph.reload_library') 238 | l.separator() 239 | if library.dirpath == "": 240 | l.label(text="Data dir is not found. Importing is not available.") 241 | return 242 | if not library.chars: 243 | l.label(text=f"No characters found at {library.dirpath}. Nothing to import.") 244 | return 245 | l.prop(ui, "base_model") 246 | char = library.chars.get(ui.base_model) 247 | if char: 248 | r = l.row() 249 | c = r.column() 250 | c.alignment = "RIGHT" 251 | c.ui_units_x = 2.5 252 | c.label(text="Author:") 253 | c.label(text="License:") 254 | c = r.column() 255 | c.label(text=char.author) 256 | c.label(text=char.license) 257 | 258 | l.prop(ui, "material_mode") 259 | l.prop(ui, "material_local") 260 | l.prop(ui, "tex_set") 261 | l.prop(ui, "tex_downscale") 262 | l.prop(ui, "import_cursor_z") 263 | c = l.column() 264 | c.prop(ui, "use_sk") 265 | c = c.column() 266 | c.enabled = ui.use_sk and ui.alt_topo == "" 267 | c.prop(ui, "import_morphs") 268 | c.prop(ui, "import_expressions") 269 | c = l.column() 270 | c.enabled = bool(char and char.basis and char.has_faces) 271 | c.prop(ui, "alt_topo") 272 | if ui.alt_topo == "": 273 | c.prop(ui, "alt_topo_obj") 274 | 275 | l.operator('charmorph.import_char', icon='ARMATURE_DATA') 276 | 277 | l.alignment = "CENTER" 278 | c = l.column(align=True) 279 | if prefs.is_adult_mode(): 280 | labels = ["Adult mode is on", "The character will be naked"] 281 | else: 282 | labels = ["Adult mode is off", "Default underwear or censors will be added"] 283 | for text in labels: 284 | r = c.row() 285 | r.alignment = "CENTER" 286 | r.label(text=text) 287 | 288 | 289 | classes = [OpReloadLib, OpImport, CHARMORPH_PT_Library] 290 | -------------------------------------------------------------------------------- /license.txt: -------------------------------------------------------------------------------- 1 | LICENSE OF THE CODE 2 | ======================= 3 | 4 | All python files released in the CharMorph package, 5 | are released under GNU General Public License 3. 6 | 7 | CharMorph - Copyright (C) 2020-2021 Michael Vigovsky 8 | This program is free software: you can redistribute it and/or modify it 9 | under the terms of the GNU General Public License as published by 10 | the Free Software Foundation, either version 3 of the License, or 11 | (at your option) any later version. 12 | 13 | This program is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 16 | See the GNU General Public License for more details: 17 | https://www.gnu.org/licenses/gpl.html 18 | 19 | 20 | LICENSE OF THE DATABASE 21 | ======================= 22 | 23 | Each character has its own license stated in select list. 24 | CharMorph contains MB-Lab characters that are based on 25 | ManuelbastioniLAB package, all the meshes and data contained 26 | in these directories including .blend files, 27 | all the images and all the json, yaml files are released 28 | under GNU Affero General Public License 3. 29 | 30 | ManuelbastioniLAB - Copyright (C) 2015-2018 Manuel Bastioni 31 | This program is free software: you can redistribute it and/or modify it 32 | under the terms of the GNU Affero General Public License as published by 33 | the Free Software Foundation, either version 3 of the License, or 34 | (at your option) any later version. 35 | 36 | This program is distributed in the hope that it will be useful, 37 | but WITHOUT ANY WARRANTY; without even the implied warranty of 38 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. 39 | See the GNU Affero General Public License for more details: 40 | https://www.gnu.org/licenses/agpl.html. 41 | 42 | 43 | LICENSE OF MODELS GENERATED BY THE SOFTWARE. 44 | ============================================ 45 | 46 | The AGPL can be an obstacle in case someone wants to create a closed 47 | source game or closed source 3D models using 3D characters made with 48 | the lab because the AGPL will propagate from the base models and from 49 | the database to the output models. Please use CC-BY models if you want 50 | to avoid it. 51 | 52 | - License of MB-Lab characters is AGPL 3: https://www.gnu.org/licenses/agpl.html. 53 | As derived product of the AGPL'd database, the models must be 54 | distribuited under AGPL 3, with the same copyright of the database. 55 | 56 | 57 | LICENSE OF RENDERS GENERATED BY THE SOFTWARE. 58 | ============================================ 59 | 60 | Rendered two-dimensional images or two-dimensional videos of a scene that includes 3D models generated 61 | with this addon are not considered a derived product of the licensed 3D database and 3D base models. 62 | Assuming that the rendering depicts a non-reverse-engineering scene[*], due to many factors 63 | (i.e. the transformation from 3D space to 2D space, the position and the type of camera used, 64 | the other 3D elements included in the scene, the position and type of lights, the post production, 65 | the composition of multiple characters, the path of the camera, ecc.) the original 3D data is 66 | fundamentally modified and transformed sufficiently that it constitutes an original work. 67 | 68 | For this reason, the author of the 2D rendering is the sole copyright owner of 2D image/video 69 | created by him and the sole responsible for the use of his 2D image/video. 70 | 71 | Also the author of the 2D rendering can use any license for the two-dimensional image/video 72 | created by him and of course he can use his two-dimensional image/video for commercial projects. 73 | 74 | [*] For example, an orthographic rendering of a shadeless plane with lab textures on it, 75 | will be considered as reverse engineering scene for textures extraction. 76 | -------------------------------------------------------------------------------- /morphing.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 3 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | # 19 | # Copyright (C) 2020-2022 Michael Vigovsky 20 | 21 | import logging 22 | import bpy # pylint: disable=import-error 23 | 24 | from .lib import morpher, fit_calc, utils 25 | from .common import manager 26 | 27 | logger = logging.getLogger(__name__) 28 | 29 | 30 | class OpResetChar(bpy.types.Operator): 31 | bl_idname = "charmorph.reset_char" 32 | bl_label = "Reset character" 33 | bl_description = "Reset all unavailable character morphs" 34 | bl_options = {"UNDO"} 35 | 36 | @classmethod 37 | def poll(cls, context): 38 | return context.mode == "OBJECT" and manager.morpher.core.char 39 | 40 | def execute(self, _): 41 | mcore = manager.morpher.core 42 | mcore.cleanup_asset_morphs() 43 | mcore.obj.data["cm_morpher"] = "ext" 44 | 45 | new_morpher = morpher.get(mcore.obj) 46 | if (new_morpher.error and not new_morpher.alt_topo_buildable) or not new_morpher.core.has_morphs(): 47 | if new_morpher.error: 48 | self.report({'ERROR'}, new_morpher.error) 49 | else: 50 | self.report({'ERROR'}, "Error - no morphs found") 51 | del mcore.obj.data["cm_morpher"] 52 | return {"CANCELLED"} 53 | manager.update_morpher(new_morpher) 54 | return {"FINISHED"} 55 | 56 | 57 | class OpProceedSlowMorphing(bpy.types.Operator): 58 | bl_idname = "charmorph.proceed_slow" 59 | bl_label = "Proceed" 60 | bl_description = "Proceed to slow morphing" 61 | bl_options = {"UNDO"} 62 | 63 | @classmethod 64 | def poll(cls, context): 65 | return context.mode == "OBJECT" and manager.morpher and manager.morpher.is_slow 66 | 67 | def execute(self, _): 68 | manager.morpher.is_slow = False 69 | return {"FINISHED"} 70 | 71 | 72 | class OpBuildAltTopo(bpy.types.Operator): 73 | bl_idname = "charmorph.build_alt_topo" 74 | bl_label = "Build alt topo" 75 | bl_description = "Build alt topo from modified character mesh" 76 | bl_options = {"UNDO"} 77 | 78 | @classmethod 79 | def poll(cls, _): 80 | return manager.morpher and manager.morpher.core.alt_topo_buildable and manager.morpher.core.has_morphs() 81 | 82 | def execute(self, context): # pylint: disable=no-self-use 83 | ui = context.window_manager.charmorph_ui 84 | mcore = manager.morpher.core 85 | obj = mcore.obj 86 | btype = ui.alt_topo_build_type 87 | sk = obj.data.shape_keys 88 | has_sk = bool(sk and sk.key_blocks) 89 | if btype == "K" and has_sk: 90 | obj.data["cm_alt_topo"] = "sk" 91 | manager.update_morpher(morpher.get(obj)) 92 | return {"FINISHED"} 93 | result = fit_calc.FitCalculator(fit_calc.geom_morpher_final(mcore))\ 94 | .get_binding(obj).fit(mcore.full_basis - mcore.get_final()) 95 | result += utils.get_morphed_numpy(obj) 96 | result = result.reshape(-1) 97 | if btype == "K": 98 | basis = obj.shape_key_add(name="Basis", from_mix=False) 99 | final = obj.shape_key_add(name="charmorph_final", from_mix=False) 100 | basis.data.foreach_set("co", result) 101 | obj.data["cm_alt_topo"] = "sk" 102 | final.value = 1 103 | else: 104 | mesh = obj.data.copy() 105 | obj.data["cm_alt_topo"] = mesh 106 | if has_sk: 107 | old_mesh = obj.data 108 | obj.data = mesh 109 | while mesh.shape_keys and mesh.shape_keys.key_blocks: 110 | obj.shape_key_remove(mesh.shape_keys.key_blocks[0]) 111 | obj.data = old_mesh 112 | mesh.vertices.foreach_set("co", result) 113 | 114 | manager.update_morpher(morpher.get(obj)) 115 | return {"FINISHED"} 116 | 117 | 118 | class UIProps: 119 | relative_meta: bpy.props.BoolProperty( 120 | name="Relative meta props", 121 | description="Adjust meta props relatively", 122 | default=True) 123 | meta_materials: bpy.props.EnumProperty( 124 | name="Materials", 125 | description="How changing meta properties will affect materials", 126 | default="A", 127 | items=[ 128 | ("N", "None", "Don't change materials"), 129 | ("A", "Absolute", "Change materials according to absolute value of meta property"), 130 | ("R", "Relative", "Change materials according to relative value of meta property")]) 131 | morph_filter: bpy.props.StringProperty( 132 | name="Filter", 133 | description="Show only morphs matching this name", 134 | options={"TEXTEDIT_UPDATE"}, 135 | ) 136 | morph_clamp: bpy.props.BoolProperty( 137 | name="Clamp props", 138 | description="Clamp properties to (-1..1) so they remain in realistic range", 139 | get=lambda _: manager.morpher.core.clamp, 140 | set=lambda _, value: manager.morpher.set_clamp(value), 141 | update=lambda _ui, _: manager.morpher.update()) 142 | morph_l1: bpy.props.EnumProperty( 143 | name="Type", 144 | description="Choose character type", 145 | items=lambda _ui, _: manager.morpher.L1_list, 146 | get=lambda _: manager.morpher.L1_idx, 147 | set=lambda _, value: manager.morpher.set_L1_by_idx(value), 148 | options={"SKIP_SAVE"}) 149 | morph_category: bpy.props.EnumProperty( 150 | name="Category", 151 | items=lambda _ui, _: 152 | [("", "", "Hide all morphs"), ("", "", "Show all morphs")] 153 | + [(name, name, "") for name in manager.morpher.categories], 154 | description="Select morphing categories to show") 155 | morph_preset: bpy.props.EnumProperty( 156 | name="Presets", 157 | items=lambda _ui, _: manager.morpher.presets_list, 158 | description="Choose morphing preset", 159 | update=lambda ui, _: manager.morpher.apply_morph_data( 160 | manager.morpher.presets.get(ui.morph_preset), ui.morph_preset_mix)) 161 | morph_preset_mix: bpy.props.BoolProperty( 162 | name="Mix with current", 163 | description="Mix selected preset with current morphs", 164 | default=False) 165 | alt_topo_build_type: bpy.props.EnumProperty( 166 | name="Alt topo type", 167 | description="Type of alt topo to build", 168 | default="P", 169 | items=[ 170 | ("K", "Shapekey", "Store alt topo basis in shapekey"), 171 | ("P", "Separate mesh", "Store alt topo basis in separate mesh")]) 172 | 173 | 174 | class CHARMORPH_PT_Morphing(bpy.types.Panel): 175 | bl_label = "Morphing" 176 | bl_parent_id = "VIEW3D_PT_CharMorph" 177 | bl_space_type = 'VIEW_3D' 178 | bl_region_type = 'UI' 179 | bl_order = 2 180 | 181 | @classmethod 182 | def poll(cls, context): 183 | if context.mode != "OBJECT": 184 | if manager.morpher and not manager.morpher.error: 185 | manager.last_object = None 186 | manager.morpher.error = "Please re-select character" 187 | return False 188 | return manager.morpher 189 | 190 | def draw(self, context): 191 | mm = manager.morpher 192 | m = mm.core 193 | ui = context.window_manager.charmorph_ui 194 | 195 | if mm.is_slow: 196 | col = self.layout.column() 197 | col.label(text="Warning:") 198 | col.label(text="Morphing a rigged character") 199 | col.label(text="with this rig type") 200 | col.label(text="can be very slow") 201 | col.label(text="Proceed with caution") 202 | self.layout.operator("charmorph.proceed_slow") 203 | return 204 | 205 | if mm.error: 206 | self.layout.label(text="Morphing error:") 207 | col = self.layout.column() 208 | for line in mm.error.split("\n"): 209 | col.label(text=line) 210 | if m.alt_topo_buildable: 211 | col = self.layout.column() 212 | col.label(text="It seems there have been changes to object's topology") 213 | col.label(text="You can try to build alt topo") 214 | col.label(text="to continue morphing") 215 | self.layout.operator("charmorph.build_alt_topo") 216 | self.layout.prop(ui, "alt_topo_build_type") 217 | 218 | return 219 | 220 | if not hasattr(context.window_manager, "charmorphs") or not m.has_morphs(): 221 | if m.char: 222 | col = self.layout.column(align=True) 223 | col.label(text="Object is detected as") 224 | col.label(text="valid CharMorph character,") 225 | col.label(text="but the morphing data was removed") 226 | if m.obj.data.get("cm_morpher") == "ext": 227 | return 228 | col.separator() 229 | col.label(text="You can reset the character") 230 | col.label(text="to resume morphing") 231 | col.separator() 232 | col.operator('charmorph.reset_char') 233 | else: 234 | self.layout.label(text="No morphing data found") 235 | return 236 | 237 | self.layout.label(text="Character type") 238 | col = self.layout.column(align=True) 239 | if m.morphs_l1: 240 | col.prop(ui, "morph_l1") 241 | 242 | col = self.layout.column(align=True) 243 | col.prop(ui, "morph_preset") 244 | col.prop(ui, "morph_preset_mix") 245 | 246 | col.separator() 247 | 248 | morphs = context.window_manager.charmorphs 249 | meta_morphs = m.char.morphs_meta.keys() 250 | if meta_morphs: 251 | self.layout.label(text="Meta morphs") 252 | col = self.layout.column(align=True) 253 | col.prop(ui, "meta_materials") 254 | col.prop(ui, "relative_meta") 255 | 256 | for prop in meta_morphs: 257 | col.prop(morphs, "meta_" + prop, slider=True) 258 | 259 | self.layout.prop(ui, "morph_clamp") 260 | 261 | self.layout.separator() 262 | 263 | if mm.categories: 264 | self.layout.label(text="Sub Morphs:") 265 | self.layout.prop(ui, "morph_category") 266 | if ui.morph_category == "": 267 | return 268 | 269 | self.layout.prop(ui, "morph_filter") 270 | col = self.layout.column(align=True) 271 | for morph in m.morphs_l2: 272 | prop = morph.name 273 | if not prop: 274 | col.separator() 275 | elif ui.morph_category == "" or prop.startswith(ui.morph_category): 276 | if ui.morph_filter.lower() in prop.lower(): 277 | col.prop(morphs, "prop_" + prop, slider=True) 278 | 279 | 280 | class CHARMORPH_PT_Materials(bpy.types.Panel): 281 | bl_label = "Materials" 282 | bl_parent_id = "VIEW3D_PT_CharMorph" 283 | bl_space_type = 'VIEW_3D' 284 | bl_region_type = 'UI' 285 | bl_options = {"DEFAULT_CLOSED"} 286 | bl_order = 6 287 | 288 | @classmethod 289 | def poll(cls, _): 290 | return manager.morpher and manager.morpher.materials and manager.morpher.materials.props 291 | 292 | def draw(self, _): 293 | for _, prop in manager.morpher.materials.get_node_outputs(): 294 | self.layout.prop(prop, "default_value", text=prop.node.label) 295 | 296 | 297 | classes = [OpResetChar, OpBuildAltTopo, OpProceedSlowMorphing, CHARMORPH_PT_Morphing, CHARMORPH_PT_Materials] 298 | -------------------------------------------------------------------------------- /pose.py: -------------------------------------------------------------------------------- 1 | # ##### BEGIN GPL LICENSE BLOCK ##### 2 | # 3 | # This program is free software; you can redistribute it and/or 4 | # modify it under the terms of the GNU General Public License 5 | # as published by the Free Software Foundation; either version 3 6 | # of the License, or (at your option) any later version. 7 | # 8 | # This program is distributed in the hope that it will be useful, 9 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 10 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 11 | # GNU General Public License for more details. 12 | # 13 | # You should have received a copy of the GNU General Public License 14 | # along with this program; if not, write to the Free Software Foundation, 15 | # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 16 | # 17 | # ##### END GPL LICENSE BLOCK ##### 18 | # 19 | # Copyright (C) 2020 Michael Vigovsky 20 | 21 | import logging, re 22 | import bpy # pylint: disable=import-error 23 | 24 | from mathutils import Matrix, Vector # pylint: disable=import-error 25 | 26 | from .lib.charlib import library 27 | 28 | logger = logging.getLogger(__name__) 29 | 30 | m1 = Matrix.Identity(4) 31 | m2 = m1.copy() 32 | m2[1][1] = -1 33 | m2[3][3] = -1 34 | flip_x_z = { 35 | "L": Matrix(((1, 0, 0, 0), (0, 0, 0, 1), (0, 0,-1, 0), (0,-1, 0, 0))), 36 | "R": Matrix(((1, 0, 0, 0), (0, 0, 0,-1), (0, 0, 1, 0), (0, 1, 0, 0))), 37 | } 38 | 39 | 40 | def qrotation(mat): 41 | def rot(v): 42 | return (v[3], v[0], v[1], v[2]) 43 | return Matrix((rot(mat[3]), rot(mat[0]), rot(mat[1]), rot(mat[2]))) 44 | 45 | 46 | shoulder_angle = 1.3960005939006805 47 | shoulder_rot = { 48 | "L": qrotation(Matrix.Rotation( shoulder_angle, 4, (0, 1, 0))), 49 | "R": qrotation(Matrix.Rotation(-shoulder_angle, 4, (0, 1, 0))), 50 | } 51 | 52 | bone_map = { 53 | "root": ("root", m2), 54 | "pelvis": ("torso", qrotation(Matrix.Rotation(1.4466689567595232, 4, (1, 0, 0)))), 55 | "spine01": ("spine_fk.001", m1), 56 | "spine02": ("spine_fk.002", m1), 57 | "spine03": ("spine_fk.003", m1), 58 | "neck": ("neck", m1), 59 | "head": ("head", m1), 60 | } 61 | 62 | for side in ["L", "R"]: 63 | bone_map["thigh_" + side] = ("thigh_fk." + side, m2) 64 | bone_map["calf_" + side] = ("shin_fk." + side, m2) 65 | bone_map["foot_" + side] = ("foot_fk." + side, m2) 66 | bone_map["toes_" + side] = ("toe." + side, m1) 67 | bone_map["breast_" + side] = ("breast." + side, m1) 68 | bone_map["clavicle_" + side] = ("shoulder." + side, shoulder_rot[side]) 69 | bone_map["upperarm_" + side] = ("upper_arm_fk." + side, m1) 70 | bone_map["lowerarm_" + side] = ("forearm_fk." + side, m1) 71 | bone_map["hand_" + side] = ("hand_fk." + side, flip_x_z[side]) 72 | for i in range(1, 4): 73 | is_master = "_master" if i == 1 else "" 74 | bone_map[f"thumb0{i}_{side}"] = (f"thumb.0{i}{is_master}.{side}", m2) 75 | for finger in ["index", "middle", "ring", "pinky"]: 76 | bone_map[f"{finger}0{i}_{side}"] = (f"f_{finger}.0{i}{is_master}.{side}", m2) 77 | del side 78 | 79 | # Different rigify versions use different parameters for IK2FK so we need to scan its modules 80 | 81 | ik2fk_map = {} 82 | 83 | re_rigid = re.compile(r'^rig_id = "([0-9a-z]*)"$', re.MULTILINE) 84 | 85 | 86 | def scan_rigify_modules(): 87 | for t in bpy.data.texts: 88 | s = t.as_string() 89 | m = re_rigid.search(s) 90 | if not m: 91 | continue 92 | rig_id = m.group(1) 93 | limbs = [] 94 | s = s[m.end(0) + 1:] 95 | re_operator = re.compile(rf"^( *)props = [0-9a-z_]*\.operator\('pose.rigify_limb_ik2fk_{rig_id}'", re.MULTILINE) 96 | 97 | while True: 98 | m = re_operator.search(s) 99 | if not m: 100 | break 101 | indent = m.group(1) 102 | re_prop = re.compile(rf"{indent}props.([0-9a-z_]*) = '([^']*)'$") 103 | props = {} 104 | while True: 105 | s = s[m.end(0):] 106 | s = s[s.find("\n") + 1:] 107 | line = s[:s.find("\n")] 108 | m = re_prop.match(line) 109 | if not m: 110 | break 111 | props[m.group(1)] = m.group(2) 112 | if len(props) > 0: 113 | limbs.append(props) 114 | if len(limbs) > 0: 115 | ik2fk_map[rig_id] = limbs 116 | 117 | 118 | def apply_pose(ui, context): 119 | if not ui.pose or ui.pose == " ": 120 | return 121 | rig = context.active_object 122 | pose = library.obj_char(rig).poses.get(ui.pose) 123 | if not pose: 124 | logger.error("pose not found %s %s", ui.pose, rig) 125 | return 126 | rig_id = rig.data["rig_id"] 127 | 128 | # Some settings 129 | ik_fk = {} 130 | rig.pose.bones["torso"]["neck_follow"] = 1.0 131 | rig.pose.bones["torso"]["head_follow"] = 1.0 132 | for side in ["L", "R"]: 133 | for limb in ["upper_arm", "thigh"]: 134 | bone = rig.pose.bones[f"{limb}_parent.{side}"] 135 | bone["fk_limb_follow"] = 0.0 136 | ik_fk[bone.name] = bone.get("IK_FK", 1.0) 137 | bone["IK_FK"] = 1.0 138 | 139 | # TODO: different mix modes 140 | old_mode = context.mode 141 | try: 142 | bpy.ops.object.mode_set(mode="POSE") 143 | bpy.ops.pose.select_all(action="SELECT") 144 | bpy.ops.pose.loc_clear() 145 | bpy.ops.pose.rot_clear() 146 | bpy.ops.pose.scale_clear() 147 | finally: 148 | bpy.ops.object.mode_set(mode=old_mode) 149 | 150 | for k, v in pose.items(): 151 | name, matrix = bone_map.get(k, ("", None)) 152 | target_bone = rig.pose.bones.get(name) 153 | if not target_bone: 154 | logger.debug("no target for %s", k) 155 | continue 156 | target_bone.rotation_mode = "QUATERNION" 157 | target_bone.rotation_quaternion = matrix @ Vector(v) 158 | 159 | spine_fk = rig.pose.bones.get("spine_fk") 160 | spine_fk1 = rig.pose.bones.get("spine_fk.001") 161 | spine_fk2 = rig.pose.bones.get("spine_fk.002") 162 | 163 | if spine_fk and spine_fk1 and spine_fk2: 164 | q = spine_fk1.rotation_quaternion 165 | spine_fk.rotation_quaternion = [-q[0], q[1], q[2], q[3]] 166 | spine_fk2.rotation_quaternion @= q 167 | 168 | if hasattr(context, "evaluated_depsgraph_get"): 169 | # Calculate lowest point for sitting and similiar poses 170 | erig = rig.evaluated_get(context.evaluated_depsgraph_get()) 171 | torso = rig.pose.bones.get("torso") 172 | min_z = torso.head[2] 173 | for bone in erig.pose.bones: 174 | if not bone.name.startswith("ORG-"): 175 | continue 176 | for attr in ["head", "tail"]: 177 | val = getattr(bone, attr) 178 | if val[2] < min_z: 179 | min_z = val[2] 180 | min_z = max(min_z, 0) 181 | if torso: 182 | torso.location = (0, 0, -min_z) 183 | 184 | ik2fk_operator = None 185 | ik2fk_limbs = None 186 | 187 | if ui.pose_ik2fk: 188 | op_id = "rigify_limb_ik2fk_" + rig_id 189 | if hasattr(bpy.ops.pose, op_id): 190 | op = getattr(bpy.ops.pose, op_id) 191 | if op.poll(): 192 | ik2fk_operator = op 193 | if rig_id not in ik2fk_map: 194 | scan_rigify_modules() 195 | ik2fk_limbs = ik2fk_map.get(rig_id) 196 | if not ik2fk_limbs: 197 | logger.error("CharMorph doesn't support IK2FK for your Rigify version") 198 | else: 199 | logger.error("Rigify UI doesn't seem to be available. IK2FK is disabled") 200 | 201 | if ik2fk_operator and ik2fk_limbs: 202 | fail = False 203 | for limb in ik2fk_limbs: 204 | result = ik2fk_operator(**limb) 205 | if "FINISHED" not in result: 206 | fail = True 207 | 208 | if fail: 209 | logger.error("IK2FK failed") 210 | else: 211 | for k, v in ik_fk.items(): 212 | rig.pose.bones[k]["IK_FK"] = v 213 | 214 | 215 | def poll(context): 216 | if not (context.mode in ["OBJECT", "POSE"] and context.active_object 217 | and context.active_object.type == "ARMATURE" 218 | and context.active_object.data.get("rig_id")): 219 | return False 220 | char = library.obj_char(context.active_object) 221 | return len(char.poses) > 0 222 | 223 | 224 | class OpApplyPose(bpy.types.Operator): 225 | bl_idname = "charmorph.apply_pose" 226 | bl_label = "Apply pose" 227 | bl_description = "Apply selected pose" 228 | bl_options = {"UNDO"} 229 | 230 | @classmethod 231 | def poll(cls, context): 232 | return poll(context) 233 | 234 | def execute(self, context): # pylint: disable=no-self-use 235 | apply_pose(context.window_manager.charmorph_ui, context) 236 | return {"FINISHED"} 237 | 238 | 239 | def get_poses(_, context): 240 | return [(" ", "