├── README.md ├── __init__.py ├── addon_updater.py ├── addon_updater_ops.py ├── bake_gui.py ├── bake_operators.py ├── classicweight.py ├── common ├── bake.py └── mat.py ├── goldsrc └── importmat.py ├── material_gui.py ├── material_operators.py ├── utils.py └── vertexbone.py /README.md: -------------------------------------------------------------------------------- 1 | Target blender version: 4.0 2 | 3 | # Features 4 | ## Materials 5 | ### Goldsrc 6 | * Import images & setup nodes for materials (chromes are automatically set up too) 7 | ### Common 8 | * Turn material between diffuse and matcap (with optional additive or transparent mode) 9 | * EZ one stop shop for texture baking 10 | * classic vertex weighting (hit shift+q in weight paint mode) 11 | * keybind to toggle custom weight gradient (hit ctrl+shift+q) 12 | ### Edit mode 13 | * In vertex menu: New option "Vertex bones: make from selected" which will take the selected vertices, and make a proxy object & armature with said vertices rigged into bones controlled by the original mesh's vertices. Effectively allowing one to turn vertex animation into bone animation for goldsrc and others. 14 | * In object menu: New option: "Vertex bones: make from selected" which will do the above, but on object level. 15 | 16 | # Installing & Updating 17 | ## Installing 18 | Happens as any blender addon would, grab the master (not the release) as a .zip and slap it in blender 19 | ## Updating 20 | The addon now includes automatic update checking. No need to hassle with that stuff anymore! 21 | 22 | ## Vertex bones: 23 | a placeholder until the303 provides an article: 24 | 25 | **note that a goldsrc skeleton cannot exceed 128 total bones** 26 | 27 | for the edit mode vertex option: 28 | 1. Animate your model and save your project file 29 | 2. In edit mode select your vertices, and go `vertex` -> `Vertex bones: make from selected` 30 | 3. Put the playhead back at the beginning and select the newly created proxy armature, and set it to "rest mode" 31 | 4. Set up your BST paths, then with only proxy mesh selected export the reference SMD 32 | If this is on an existing armature, like in cases of facial animation, parent the vertexbones to the proxy headbone 33 | 5. Set the "rest mode" in the proxy armature back to "pose mode" and in pose mode go to `Pose` -> `Animation` -> `Bake action` and set the time range. 34 | **Note that you may need to enable visual keying in some situations** 35 | 6. Select only the animation in BST exportables list and export 36 | 7. Compile model -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from . import addon_updater_ops 3 | from . import material_gui 4 | from . import bake_gui 5 | from .classicweight import * 6 | from .vertexbone import * 7 | 8 | bl_info = { 9 | "name" : "Zode's blender utils", 10 | "author" : "Zode", 11 | "version" : (1, 4, 4), 12 | "blender" : (4, 4, 0), 13 | "description" : "Adds various utility function(s) to blender", 14 | "warning": "", 15 | "wiki_url": "https://github.com/Zode/blenderutils", 16 | "tracker_url": "https://github.com/Zode/blenderutils/issues", 17 | "category" : "User Interface" 18 | } 19 | 20 | @addon_updater_ops.make_annotations 21 | class ZODEUTILS_PREFERENCES(bpy.types.AddonPreferences): 22 | bl_idname = __package__ 23 | 24 | auto_check_update = bpy.props.BoolProperty( 25 | name="Auto-check for Update", 26 | description="If enabled, auto-check for updates using an interval", 27 | default=False, 28 | ) 29 | updater_interval_months = bpy.props.IntProperty( 30 | name='Months', 31 | description="Number of months between checking for updates", 32 | default=0, 33 | min=0 34 | ) 35 | updater_interval_days = bpy.props.IntProperty( 36 | name='Days', 37 | description="Number of days between checking for updates", 38 | default=0, 39 | min=0, 40 | max=31 41 | ) 42 | updater_interval_hours = bpy.props.IntProperty( 43 | name='Hours', 44 | description="Number of hours between checking for updates", 45 | default=6, 46 | min=0, 47 | max=23 48 | ) 49 | updater_interval_minutes = bpy.props.IntProperty( 50 | name='Minutes', 51 | description="Number of minutes between checking for updates", 52 | default=0, 53 | min=0, 54 | max=59 55 | ) 56 | 57 | def draw(self, context): 58 | addon_updater_ops.update_settings_ui(self, context) 59 | 60 | class ZODEUTILS_WeightViewToggle(bpy.types.Operator): 61 | """Vertex weight gradient toggler""" 62 | bl_idname = "zodeutils.magicview" 63 | bl_label = "vertex weight gradient toggler" 64 | 65 | def execute(self, context): 66 | bpy.context.preferences.view.use_weight_color_range = not bpy.context.preferences.view.use_weight_color_range 67 | 68 | return {"FINISHED"} 69 | 70 | global_addon_keymaps = [] 71 | 72 | def edit_vertex_menu_callback(self, context): 73 | self.layout.operator("zodeutils_vertexbone.make") 74 | 75 | def object_menu_callback(self, context): 76 | self.layout.operator("zodeutils_vertexbone.makeobject") 77 | 78 | 79 | def register(): 80 | addon_updater_ops.register(bl_info) 81 | bpy.utils.register_class(ZODEUTILS_PREFERENCES) 82 | material_gui.register() 83 | bake_gui.register() 84 | bpy.utils.register_class(ZODEUTILS_WeightViewToggle) 85 | bpy.utils.register_class(ZODEUTILS_CVWEIGHT_OT_magic) 86 | bpy.utils.register_class(ZODEUTILS_CVWEIGHT_OT_AssignAll) 87 | bpy.utils.register_class(ZODEUTILS_CVWEIGHT_OT_AssignSelected) 88 | bpy.utils.register_class(ZODEUTILS_CVWEIGHT_OT_CancelAssign) 89 | bpy.utils.register_class(VIEW3D_MT_PIE_ClassicVertexWeight) 90 | bpy.utils.register_class(ZODEUTILS_CVWEIGHT_OT_Info) 91 | bpy.utils.register_class(ZODEUTILS_VERTEXBONE_OT_MakeVertexBone) 92 | bpy.utils.register_class(ZODEUTILS_VERTEXBONE_OT_MakeVertexBoneObject) 93 | bpy.types.VIEW3D_MT_edit_mesh_vertices.append(edit_vertex_menu_callback) 94 | bpy.types.VIEW3D_MT_object.append(object_menu_callback) 95 | 96 | window_manager = bpy.context.window_manager 97 | if window_manager.keyconfigs.addon: 98 | toglgegradient = window_manager.keyconfigs.addon.keymaps.new(name="3D View", space_type="VIEW_3D") 99 | toglgegradient_item = toglgegradient.keymap_items.new(ZODEUTILS_WeightViewToggle.bl_idname, "Q", "PRESS", shift=True, ctrl=True) 100 | global_addon_keymaps.append((toglgegradient, toglgegradient_item)) 101 | 102 | cv = window_manager.keyconfigs.addon.keymaps.new(name="3D View", space_type="VIEW_3D") 103 | cv_item = cv.keymap_items.new(ZODEUTILS_CVWEIGHT_OT_magic.bl_idname, "Q", "PRESS", shift=True) 104 | global_addon_keymaps.append((cv, cv_item)) 105 | 106 | if zodeutils_load_handler in bpy.app.handlers.load_post: 107 | return 108 | 109 | bpy.app.handlers.load_post.append(zodeutils_load_handler) 110 | 111 | def unregister(): 112 | addon_updater_ops.unregister() 113 | bpy.utils.unregister_class(ZODEUTILS_PREFERENCES) 114 | material_gui.unregister() 115 | bake_gui.unregister() 116 | bpy.utils.unregister_class(ZODEUTILS_WeightViewToggle) 117 | bpy.utils.unregister_class(ZODEUTILS_CVWEIGHT_OT_magic) 118 | bpy.utils.unregister_class(ZODEUTILS_CVWEIGHT_OT_AssignAll) 119 | bpy.utils.unregister_class(ZODEUTILS_CVWEIGHT_OT_AssignSelected) 120 | bpy.utils.unregister_class(ZODEUTILS_CVWEIGHT_OT_CancelAssign) 121 | bpy.utils.unregister_class(VIEW3D_MT_PIE_ClassicVertexWeight) 122 | bpy.utils.unregister_class(ZODEUTILS_CVWEIGHT_OT_Info) 123 | bpy.types.VIEW3D_MT_edit_mesh_vertices.remove(edit_vertex_menu_callback) 124 | bpy.types.VIEW3D_MT_object.remove(object_menu_callback) 125 | bpy.utils.unregister_class(ZODEUTILS_VERTEXBONE_OT_MakeVertexBone) 126 | bpy.utils.unregister_class(ZODEUTILS_VERTEXBONE_OT_MakeVertexBoneObject) 127 | 128 | window_manager = bpy.context.window_manager 129 | if window_manager and window_manager.keyconfigs and window_manager.keyconfigs.addon: 130 | for keymap, keymap_item in global_addon_keymaps: 131 | keymap.keymap_items.remove(keymap_item) 132 | 133 | global_addon_keymaps.clear() 134 | 135 | if __name__ == "__main__": 136 | register() -------------------------------------------------------------------------------- /addon_updater.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 2 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 | 20 | """ 21 | See documentation for usage 22 | https://github.com/CGCookie/blender-addon-updater 23 | """ 24 | 25 | __version__ = "1.1.1" 26 | 27 | import errno 28 | import traceback 29 | import platform 30 | import ssl 31 | import urllib.request 32 | import urllib 33 | import os 34 | import json 35 | import zipfile 36 | import shutil 37 | import threading 38 | import fnmatch 39 | from datetime import datetime, timedelta 40 | 41 | # Blender imports, used in limited cases. 42 | import bpy 43 | import addon_utils 44 | 45 | # ----------------------------------------------------------------------------- 46 | # The main class 47 | # ----------------------------------------------------------------------------- 48 | 49 | 50 | class SingletonUpdater: 51 | """Addon updater service class. 52 | 53 | This is the singleton class to instance once and then reference where 54 | needed throughout the addon. It implements all the interfaces for running 55 | updates. 56 | """ 57 | def __init__(self): 58 | 59 | self._engine = GithubEngine() 60 | self._user = None 61 | self._repo = None 62 | self._website = None 63 | self._current_version = None 64 | self._subfolder_path = None 65 | self._tags = list() 66 | self._tag_latest = None 67 | self._tag_names = list() 68 | self._latest_release = None 69 | self._use_releases = False 70 | self._include_branches = False 71 | self._include_branch_list = ['master'] 72 | self._include_branch_auto_check = False 73 | self._manual_only = False 74 | self._version_min_update = None 75 | self._version_max_update = None 76 | 77 | # By default, backup current addon on update/target install. 78 | self._backup_current = True 79 | self._backup_ignore_patterns = None 80 | 81 | # Set patterns the files to overwrite during an update. 82 | self._overwrite_patterns = ["*.py", "*.pyc"] 83 | self._remove_pre_update_patterns = list() 84 | 85 | # By default, don't auto disable+re-enable the addon after an update, 86 | # as this is less stable/often won't fully reload all modules anyways. 87 | self._auto_reload_post_update = False 88 | 89 | # Settings for the frequency of automated background checks. 90 | self._check_interval_enabled = False 91 | self._check_interval_months = 0 92 | self._check_interval_days = 7 93 | self._check_interval_hours = 0 94 | self._check_interval_minutes = 0 95 | 96 | # runtime variables, initial conditions 97 | self._verbose = False 98 | self._use_print_traces = True 99 | self._fake_install = False 100 | self._async_checking = False # only true when async daemon started 101 | self._update_ready = None 102 | self._update_link = None 103 | self._update_version = None 104 | self._source_zip = None 105 | self._check_thread = None 106 | self._select_link = None 107 | self.skip_tag = None 108 | 109 | # Get data from the running blender module (addon). 110 | self._addon = __package__.lower() 111 | self._addon_package = __package__ # Must not change. 112 | self._updater_path = os.path.join( 113 | os.path.dirname(__file__), self._addon + "_updater") 114 | self._addon_root = os.path.dirname(__file__) 115 | self._json = dict() 116 | self._error = None 117 | self._error_msg = None 118 | self._prefiltered_tag_count = 0 119 | 120 | # UI properties, not used within this module but still useful to have. 121 | 122 | # to verify a valid import, in place of placeholder import 123 | self.show_popups = True # UI uses to show popups or not. 124 | self.invalid_updater = False 125 | 126 | # pre-assign basic select-link function 127 | def select_link_function(self, tag): 128 | return tag["zipball_url"] 129 | 130 | self._select_link = select_link_function 131 | 132 | def print_trace(self): 133 | """Print handled exception details when use_print_traces is set""" 134 | if self._use_print_traces: 135 | traceback.print_exc() 136 | 137 | def print_verbose(self, msg): 138 | """Print out a verbose logging message if verbose is true.""" 139 | if not self._verbose: 140 | return 141 | print("{} addon: ".format(self.addon) + msg) 142 | 143 | # ------------------------------------------------------------------------- 144 | # Getters and setters 145 | # ------------------------------------------------------------------------- 146 | @property 147 | def addon(self): 148 | return self._addon 149 | 150 | @addon.setter 151 | def addon(self, value): 152 | self._addon = str(value) 153 | 154 | @property 155 | def api_url(self): 156 | return self._engine.api_url 157 | 158 | @api_url.setter 159 | def api_url(self, value): 160 | if not self.check_is_url(value): 161 | raise ValueError("Not a valid URL: " + value) 162 | self._engine.api_url = value 163 | 164 | @property 165 | def async_checking(self): 166 | return self._async_checking 167 | 168 | @property 169 | def auto_reload_post_update(self): 170 | return self._auto_reload_post_update 171 | 172 | @auto_reload_post_update.setter 173 | def auto_reload_post_update(self, value): 174 | try: 175 | self._auto_reload_post_update = bool(value) 176 | except: 177 | raise ValueError("auto_reload_post_update must be a boolean value") 178 | 179 | @property 180 | def backup_current(self): 181 | return self._backup_current 182 | 183 | @backup_current.setter 184 | def backup_current(self, value): 185 | if value is None: 186 | self._backup_current = False 187 | else: 188 | self._backup_current = value 189 | 190 | @property 191 | def backup_ignore_patterns(self): 192 | return self._backup_ignore_patterns 193 | 194 | @backup_ignore_patterns.setter 195 | def backup_ignore_patterns(self, value): 196 | if value is None: 197 | self._backup_ignore_patterns = None 198 | elif not isinstance(value, list): 199 | raise ValueError("Backup pattern must be in list format") 200 | else: 201 | self._backup_ignore_patterns = value 202 | 203 | @property 204 | def check_interval(self): 205 | return (self._check_interval_enabled, 206 | self._check_interval_months, 207 | self._check_interval_days, 208 | self._check_interval_hours, 209 | self._check_interval_minutes) 210 | 211 | @property 212 | def current_version(self): 213 | return self._current_version 214 | 215 | @current_version.setter 216 | def current_version(self, tuple_values): 217 | if tuple_values is None: 218 | self._current_version = None 219 | return 220 | elif type(tuple_values) is not tuple: 221 | try: 222 | tuple(tuple_values) 223 | except: 224 | raise ValueError( 225 | "current_version must be a tuple of integers") 226 | for i in tuple_values: 227 | if type(i) is not int: 228 | raise ValueError( 229 | "current_version must be a tuple of integers") 230 | self._current_version = tuple(tuple_values) 231 | 232 | @property 233 | def engine(self): 234 | return self._engine.name 235 | 236 | @engine.setter 237 | def engine(self, value): 238 | engine = value.lower() 239 | if engine == "github": 240 | self._engine = GithubEngine() 241 | elif engine == "gitlab": 242 | self._engine = GitlabEngine() 243 | elif engine == "bitbucket": 244 | self._engine = BitbucketEngine() 245 | else: 246 | raise ValueError("Invalid engine selection") 247 | 248 | @property 249 | def error(self): 250 | return self._error 251 | 252 | @property 253 | def error_msg(self): 254 | return self._error_msg 255 | 256 | @property 257 | def fake_install(self): 258 | return self._fake_install 259 | 260 | @fake_install.setter 261 | def fake_install(self, value): 262 | if not isinstance(value, bool): 263 | raise ValueError("fake_install must be a boolean value") 264 | self._fake_install = bool(value) 265 | 266 | # not currently used 267 | @property 268 | def include_branch_auto_check(self): 269 | return self._include_branch_auto_check 270 | 271 | @include_branch_auto_check.setter 272 | def include_branch_auto_check(self, value): 273 | try: 274 | self._include_branch_auto_check = bool(value) 275 | except: 276 | raise ValueError("include_branch_autocheck must be a boolean") 277 | 278 | @property 279 | def include_branch_list(self): 280 | return self._include_branch_list 281 | 282 | @include_branch_list.setter 283 | def include_branch_list(self, value): 284 | try: 285 | if value is None: 286 | self._include_branch_list = ['master'] 287 | elif not isinstance(value, list) or len(value) == 0: 288 | raise ValueError( 289 | "include_branch_list should be a list of valid branches") 290 | else: 291 | self._include_branch_list = value 292 | except: 293 | raise ValueError( 294 | "include_branch_list should be a list of valid branches") 295 | 296 | @property 297 | def include_branches(self): 298 | return self._include_branches 299 | 300 | @include_branches.setter 301 | def include_branches(self, value): 302 | try: 303 | self._include_branches = bool(value) 304 | except: 305 | raise ValueError("include_branches must be a boolean value") 306 | 307 | @property 308 | def json(self): 309 | if len(self._json) == 0: 310 | self.set_updater_json() 311 | return self._json 312 | 313 | @property 314 | def latest_release(self): 315 | if self._latest_release is None: 316 | return None 317 | return self._latest_release 318 | 319 | @property 320 | def manual_only(self): 321 | return self._manual_only 322 | 323 | @manual_only.setter 324 | def manual_only(self, value): 325 | try: 326 | self._manual_only = bool(value) 327 | except: 328 | raise ValueError("manual_only must be a boolean value") 329 | 330 | @property 331 | def overwrite_patterns(self): 332 | return self._overwrite_patterns 333 | 334 | @overwrite_patterns.setter 335 | def overwrite_patterns(self, value): 336 | if value is None: 337 | self._overwrite_patterns = ["*.py", "*.pyc"] 338 | elif not isinstance(value, list): 339 | raise ValueError("overwrite_patterns needs to be in a list format") 340 | else: 341 | self._overwrite_patterns = value 342 | 343 | @property 344 | def private_token(self): 345 | return self._engine.token 346 | 347 | @private_token.setter 348 | def private_token(self, value): 349 | if value is None: 350 | self._engine.token = None 351 | else: 352 | self._engine.token = str(value) 353 | 354 | @property 355 | def remove_pre_update_patterns(self): 356 | return self._remove_pre_update_patterns 357 | 358 | @remove_pre_update_patterns.setter 359 | def remove_pre_update_patterns(self, value): 360 | if value is None: 361 | self._remove_pre_update_patterns = list() 362 | elif not isinstance(value, list): 363 | raise ValueError( 364 | "remove_pre_update_patterns needs to be in a list format") 365 | else: 366 | self._remove_pre_update_patterns = value 367 | 368 | @property 369 | def repo(self): 370 | return self._repo 371 | 372 | @repo.setter 373 | def repo(self, value): 374 | try: 375 | self._repo = str(value) 376 | except: 377 | raise ValueError("repo must be a string value") 378 | 379 | @property 380 | def select_link(self): 381 | return self._select_link 382 | 383 | @select_link.setter 384 | def select_link(self, value): 385 | # ensure it is a function assignment, with signature: 386 | # input self, tag; returns link name 387 | if not hasattr(value, "__call__"): 388 | raise ValueError("select_link must be a function") 389 | self._select_link = value 390 | 391 | @property 392 | def stage_path(self): 393 | return self._updater_path 394 | 395 | @stage_path.setter 396 | def stage_path(self, value): 397 | if value is None: 398 | self.print_verbose("Aborting assigning stage_path, it's null") 399 | return 400 | elif value is not None and not os.path.exists(value): 401 | try: 402 | os.makedirs(value) 403 | except: 404 | self.print_verbose("Error trying to staging path") 405 | self.print_trace() 406 | return 407 | self._updater_path = value 408 | 409 | @property 410 | def subfolder_path(self): 411 | return self._subfolder_path 412 | 413 | @subfolder_path.setter 414 | def subfolder_path(self, value): 415 | self._subfolder_path = value 416 | 417 | @property 418 | def tags(self): 419 | if len(self._tags) == 0: 420 | return list() 421 | tag_names = list() 422 | for tag in self._tags: 423 | tag_names.append(tag["name"]) 424 | return tag_names 425 | 426 | @property 427 | def tag_latest(self): 428 | if self._tag_latest is None: 429 | return None 430 | return self._tag_latest["name"] 431 | 432 | @property 433 | def update_link(self): 434 | return self._update_link 435 | 436 | @property 437 | def update_ready(self): 438 | return self._update_ready 439 | 440 | @property 441 | def update_version(self): 442 | return self._update_version 443 | 444 | @property 445 | def use_releases(self): 446 | return self._use_releases 447 | 448 | @use_releases.setter 449 | def use_releases(self, value): 450 | try: 451 | self._use_releases = bool(value) 452 | except: 453 | raise ValueError("use_releases must be a boolean value") 454 | 455 | @property 456 | def user(self): 457 | return self._user 458 | 459 | @user.setter 460 | def user(self, value): 461 | try: 462 | self._user = str(value) 463 | except: 464 | raise ValueError("User must be a string value") 465 | 466 | @property 467 | def verbose(self): 468 | return self._verbose 469 | 470 | @verbose.setter 471 | def verbose(self, value): 472 | try: 473 | self._verbose = bool(value) 474 | self.print_verbose("Verbose is enabled") 475 | except: 476 | raise ValueError("Verbose must be a boolean value") 477 | 478 | @property 479 | def use_print_traces(self): 480 | return self._use_print_traces 481 | 482 | @use_print_traces.setter 483 | def use_print_traces(self, value): 484 | try: 485 | self._use_print_traces = bool(value) 486 | except: 487 | raise ValueError("use_print_traces must be a boolean value") 488 | 489 | @property 490 | def version_max_update(self): 491 | return self._version_max_update 492 | 493 | @version_max_update.setter 494 | def version_max_update(self, value): 495 | if value is None: 496 | self._version_max_update = None 497 | return 498 | if not isinstance(value, tuple): 499 | raise ValueError("Version maximum must be a tuple") 500 | for subvalue in value: 501 | if type(subvalue) is not int: 502 | raise ValueError("Version elements must be integers") 503 | self._version_max_update = value 504 | 505 | @property 506 | def version_min_update(self): 507 | return self._version_min_update 508 | 509 | @version_min_update.setter 510 | def version_min_update(self, value): 511 | if value is None: 512 | self._version_min_update = None 513 | return 514 | if not isinstance(value, tuple): 515 | raise ValueError("Version minimum must be a tuple") 516 | for subvalue in value: 517 | if type(subvalue) != int: 518 | raise ValueError("Version elements must be integers") 519 | self._version_min_update = value 520 | 521 | @property 522 | def website(self): 523 | return self._website 524 | 525 | @website.setter 526 | def website(self, value): 527 | if not self.check_is_url(value): 528 | raise ValueError("Not a valid URL: " + value) 529 | self._website = value 530 | 531 | # ------------------------------------------------------------------------- 532 | # Parameter validation related functions 533 | # ------------------------------------------------------------------------- 534 | @staticmethod 535 | def check_is_url(url): 536 | if not ("http://" in url or "https://" in url): 537 | return False 538 | if "." not in url: 539 | return False 540 | return True 541 | 542 | def _get_tag_names(self): 543 | tag_names = list() 544 | self.get_tags() 545 | for tag in self._tags: 546 | tag_names.append(tag["name"]) 547 | return tag_names 548 | 549 | def set_check_interval(self, enabled=False, 550 | months=0, days=14, hours=0, minutes=0): 551 | """Set the time interval between automated checks, and if enabled. 552 | 553 | Has enabled = False as default to not check against frequency, 554 | if enabled, default is 2 weeks. 555 | """ 556 | 557 | if type(enabled) is not bool: 558 | raise ValueError("Enable must be a boolean value") 559 | if type(months) is not int: 560 | raise ValueError("Months must be an integer value") 561 | if type(days) is not int: 562 | raise ValueError("Days must be an integer value") 563 | if type(hours) is not int: 564 | raise ValueError("Hours must be an integer value") 565 | if type(minutes) is not int: 566 | raise ValueError("Minutes must be an integer value") 567 | 568 | if not enabled: 569 | self._check_interval_enabled = False 570 | else: 571 | self._check_interval_enabled = True 572 | 573 | self._check_interval_months = months 574 | self._check_interval_days = days 575 | self._check_interval_hours = hours 576 | self._check_interval_minutes = minutes 577 | 578 | def __repr__(self): 579 | return "".format(a=__file__) 580 | 581 | def __str__(self): 582 | return "Updater, with user: {a}, repository: {b}, url: {c}".format( 583 | a=self._user, b=self._repo, c=self.form_repo_url()) 584 | 585 | # ------------------------------------------------------------------------- 586 | # API-related functions 587 | # ------------------------------------------------------------------------- 588 | def form_repo_url(self): 589 | return self._engine.form_repo_url(self) 590 | 591 | def form_tags_url(self): 592 | return self._engine.form_tags_url(self) 593 | 594 | def form_branch_url(self, branch): 595 | return self._engine.form_branch_url(branch, self) 596 | 597 | def get_tags(self): 598 | request = self.form_tags_url() 599 | self.print_verbose("Getting tags from server") 600 | 601 | # get all tags, internet call 602 | all_tags = self._engine.parse_tags(self.get_api(request), self) 603 | if all_tags is not None: 604 | self._prefiltered_tag_count = len(all_tags) 605 | else: 606 | self._prefiltered_tag_count = 0 607 | all_tags = list() 608 | 609 | # pre-process to skip tags 610 | if self.skip_tag is not None: 611 | self._tags = [tg for tg in all_tags if not self.skip_tag(self, tg)] 612 | else: 613 | self._tags = all_tags 614 | 615 | # get additional branches too, if needed, and place in front 616 | # Does NO checking here whether branch is valid 617 | if self._include_branches: 618 | temp_branches = self._include_branch_list.copy() 619 | temp_branches.reverse() 620 | for branch in temp_branches: 621 | request = self.form_branch_url(branch) 622 | include = { 623 | "name": branch.title(), 624 | "zipball_url": request 625 | } 626 | self._tags = [include] + self._tags # append to front 627 | 628 | if self._tags is None: 629 | # some error occurred 630 | self._tag_latest = None 631 | self._tags = list() 632 | 633 | elif self._prefiltered_tag_count == 0 and not self._include_branches: 634 | self._tag_latest = None 635 | if self._error is None: # if not None, could have had no internet 636 | self._error = "No releases found" 637 | self._error_msg = "No releases or tags found in repository" 638 | self.print_verbose("No releases or tags found in repository") 639 | 640 | elif self._prefiltered_tag_count == 0 and self._include_branches: 641 | if not self._error: 642 | self._tag_latest = self._tags[0] 643 | branch = self._include_branch_list[0] 644 | self.print_verbose("{} branch found, no releases: {}".format( 645 | branch, self._tags[0])) 646 | 647 | elif ((len(self._tags) - len(self._include_branch_list) == 0 648 | and self._include_branches) 649 | or (len(self._tags) == 0 and not self._include_branches) 650 | and self._prefiltered_tag_count > 0): 651 | self._tag_latest = None 652 | self._error = "No releases available" 653 | self._error_msg = "No versions found within compatible version range" 654 | self.print_verbose(self._error_msg) 655 | 656 | else: 657 | if not self._include_branches: 658 | self._tag_latest = self._tags[0] 659 | self.print_verbose( 660 | "Most recent tag found:" + str(self._tags[0]['name'])) 661 | else: 662 | # Don't return branch if in list. 663 | n = len(self._include_branch_list) 664 | self._tag_latest = self._tags[n] # guaranteed at least len()=n+1 665 | self.print_verbose( 666 | "Most recent tag found:" + str(self._tags[n]['name'])) 667 | 668 | def get_raw(self, url): 669 | """All API calls to base url.""" 670 | request = urllib.request.Request(url) 671 | try: 672 | context = ssl._create_unverified_context() 673 | except: 674 | # Some blender packaged python versions don't have this, largely 675 | # useful for local network setups otherwise minimal impact. 676 | context = None 677 | 678 | # Setup private request headers if appropriate. 679 | if self._engine.token is not None: 680 | if self._engine.name == "gitlab": 681 | request.add_header('PRIVATE-TOKEN', self._engine.token) 682 | else: 683 | self.print_verbose("Tokens not setup for engine yet") 684 | 685 | # Always set user agent. 686 | request.add_header( 687 | 'User-Agent', "Python/" + str(platform.python_version())) 688 | 689 | # Run the request. 690 | try: 691 | if context: 692 | result = urllib.request.urlopen(request, context=context) 693 | else: 694 | result = urllib.request.urlopen(request) 695 | except urllib.error.HTTPError as e: 696 | if str(e.code) == "403": 697 | self._error = "HTTP error (access denied)" 698 | self._error_msg = str(e.code) + " - server error response" 699 | print(self._error, self._error_msg) 700 | else: 701 | self._error = "HTTP error" 702 | self._error_msg = str(e.code) 703 | print(self._error, self._error_msg) 704 | self.print_trace() 705 | self._update_ready = None 706 | except urllib.error.URLError as e: 707 | reason = str(e.reason) 708 | if "TLSV1_ALERT" in reason or "SSL" in reason.upper(): 709 | self._error = "Connection rejected, download manually" 710 | self._error_msg = reason 711 | print(self._error, self._error_msg) 712 | else: 713 | self._error = "URL error, check internet connection" 714 | self._error_msg = reason 715 | print(self._error, self._error_msg) 716 | self.print_trace() 717 | self._update_ready = None 718 | return None 719 | else: 720 | result_string = result.read() 721 | result.close() 722 | return result_string.decode() 723 | 724 | def get_api(self, url): 725 | """Result of all api calls, decoded into json format.""" 726 | get = None 727 | get = self.get_raw(url) 728 | if get is not None: 729 | try: 730 | return json.JSONDecoder().decode(get) 731 | except Exception as e: 732 | self._error = "API response has invalid JSON format" 733 | self._error_msg = str(e.reason) 734 | self._update_ready = None 735 | print(self._error, self._error_msg) 736 | self.print_trace() 737 | return None 738 | else: 739 | return None 740 | 741 | def stage_repository(self, url): 742 | """Create a working directory and download the new files""" 743 | 744 | local = os.path.join(self._updater_path, "update_staging") 745 | error = None 746 | 747 | # Make/clear the staging folder, to ensure the folder is always clean. 748 | self.print_verbose( 749 | "Preparing staging folder for download:\n" + str(local)) 750 | if os.path.isdir(local): 751 | try: 752 | shutil.rmtree(local) 753 | os.makedirs(local) 754 | except: 755 | error = "failed to remove existing staging directory" 756 | self.print_trace() 757 | else: 758 | try: 759 | os.makedirs(local) 760 | except: 761 | error = "failed to create staging directory" 762 | self.print_trace() 763 | 764 | if error is not None: 765 | self.print_verbose("Error: Aborting update, " + error) 766 | self._error = "Update aborted, staging path error" 767 | self._error_msg = "Error: {}".format(error) 768 | return False 769 | 770 | if self._backup_current: 771 | self.create_backup() 772 | 773 | self.print_verbose("Now retrieving the new source zip") 774 | self._source_zip = os.path.join(local, "source.zip") 775 | self.print_verbose("Starting download update zip") 776 | try: 777 | request = urllib.request.Request(url) 778 | context = ssl._create_unverified_context() 779 | 780 | # Setup private token if appropriate. 781 | if self._engine.token is not None: 782 | if self._engine.name == "gitlab": 783 | request.add_header('PRIVATE-TOKEN', self._engine.token) 784 | else: 785 | self.print_verbose( 786 | "Tokens not setup for selected engine yet") 787 | 788 | # Always set user agent 789 | request.add_header( 790 | 'User-Agent', "Python/" + str(platform.python_version())) 791 | 792 | self.url_retrieve(urllib.request.urlopen(request, context=context), 793 | self._source_zip) 794 | # Add additional checks on file size being non-zero. 795 | self.print_verbose("Successfully downloaded update zip") 796 | return True 797 | except Exception as e: 798 | self._error = "Error retrieving download, bad link?" 799 | self._error_msg = "Error: {}".format(e) 800 | print("Error retrieving download, bad link?") 801 | print("Error: {}".format(e)) 802 | self.print_trace() 803 | return False 804 | 805 | def create_backup(self): 806 | """Save a backup of the current installed addon prior to an update.""" 807 | self.print_verbose("Backing up current addon folder") 808 | local = os.path.join(self._updater_path, "backup") 809 | tempdest = os.path.join( 810 | self._addon_root, os.pardir, self._addon + "_updater_backup_temp") 811 | 812 | self.print_verbose("Backup destination path: " + str(local)) 813 | 814 | if os.path.isdir(local): 815 | try: 816 | shutil.rmtree(local) 817 | except: 818 | self.print_verbose( 819 | "Failed to removed previous backup folder, continuing") 820 | self.print_trace() 821 | 822 | # Remove the temp folder. 823 | # Shouldn't exist but could if previously interrupted. 824 | if os.path.isdir(tempdest): 825 | try: 826 | shutil.rmtree(tempdest) 827 | except: 828 | self.print_verbose( 829 | "Failed to remove existing temp folder, continuing") 830 | self.print_trace() 831 | 832 | # Make a full addon copy, temporarily placed outside the addon folder. 833 | if self._backup_ignore_patterns is not None: 834 | try: 835 | shutil.copytree(self._addon_root, tempdest, 836 | ignore=shutil.ignore_patterns( 837 | *self._backup_ignore_patterns)) 838 | except: 839 | print("Failed to create backup, still attempting update.") 840 | self.print_trace() 841 | return 842 | else: 843 | try: 844 | shutil.copytree(self._addon_root, tempdest) 845 | except: 846 | print("Failed to create backup, still attempting update.") 847 | self.print_trace() 848 | return 849 | shutil.move(tempdest, local) 850 | 851 | # Save the date for future reference. 852 | now = datetime.now() 853 | self._json["backup_date"] = "{m}-{d}-{yr}".format( 854 | m=now.strftime("%B"), d=now.day, yr=now.year) 855 | self.save_updater_json() 856 | 857 | def restore_backup(self): 858 | """Restore the last backed up addon version, user initiated only""" 859 | self.print_verbose("Restoring backup, backing up current addon folder") 860 | backuploc = os.path.join(self._updater_path, "backup") 861 | tempdest = os.path.join( 862 | self._addon_root, os.pardir, self._addon + "_updater_backup_temp") 863 | tempdest = os.path.abspath(tempdest) 864 | 865 | # Move instead contents back in place, instead of copy. 866 | shutil.move(backuploc, tempdest) 867 | shutil.rmtree(self._addon_root) 868 | os.rename(tempdest, self._addon_root) 869 | 870 | self._json["backup_date"] = "" 871 | self._json["just_restored"] = True 872 | self._json["just_updated"] = True 873 | self.save_updater_json() 874 | 875 | self.reload_addon() 876 | 877 | def unpack_staged_zip(self, clean=False): 878 | """Unzip the downloaded file, and validate contents""" 879 | if not os.path.isfile(self._source_zip): 880 | self.print_verbose("Error, update zip not found") 881 | self._error = "Install failed" 882 | self._error_msg = "Downloaded zip not found" 883 | return -1 884 | 885 | # Clear the existing source folder in case previous files remain. 886 | outdir = os.path.join(self._updater_path, "source") 887 | try: 888 | shutil.rmtree(outdir) 889 | self.print_verbose("Source folder cleared") 890 | except: 891 | self.print_trace() 892 | 893 | # Create parent directories if needed, would not be relevant unless 894 | # installing addon into another location or via an addon manager. 895 | try: 896 | os.mkdir(outdir) 897 | except Exception as err: 898 | print("Error occurred while making extract dir:") 899 | print(str(err)) 900 | self.print_trace() 901 | self._error = "Install failed" 902 | self._error_msg = "Failed to make extract directory" 903 | return -1 904 | 905 | if not os.path.isdir(outdir): 906 | print("Failed to create source directory") 907 | self._error = "Install failed" 908 | self._error_msg = "Failed to create extract directory" 909 | return -1 910 | 911 | self.print_verbose( 912 | "Begin extracting source from zip:" + str(self._source_zip)) 913 | with zipfile.ZipFile(self._source_zip, "r") as zfile: 914 | 915 | if not zfile: 916 | self._error = "Install failed" 917 | self._error_msg = "Resulting file is not a zip, cannot extract" 918 | self.print_verbose(self._error_msg) 919 | return -1 920 | 921 | # Now extract directly from the first subfolder (not root) 922 | # this avoids adding the first subfolder to the path length, 923 | # which can be too long if the download has the SHA in the name. 924 | zsep = '/' # Not using os.sep, always the / value even on windows. 925 | for name in zfile.namelist(): 926 | if zsep not in name: 927 | continue 928 | top_folder = name[:name.index(zsep) + 1] 929 | if name == top_folder + zsep: 930 | continue # skip top level folder 931 | sub_path = name[name.index(zsep) + 1:] 932 | if name.endswith(zsep): 933 | try: 934 | os.mkdir(os.path.join(outdir, sub_path)) 935 | self.print_verbose( 936 | "Extract - mkdir: " + os.path.join(outdir, sub_path)) 937 | except OSError as exc: 938 | if exc.errno != errno.EEXIST: 939 | self._error = "Install failed" 940 | self._error_msg = "Could not create folder from zip" 941 | self.print_trace() 942 | return -1 943 | else: 944 | with open(os.path.join(outdir, sub_path), "wb") as outfile: 945 | data = zfile.read(name) 946 | outfile.write(data) 947 | self.print_verbose( 948 | "Extract - create: " + os.path.join(outdir, sub_path)) 949 | 950 | self.print_verbose("Extracted source") 951 | 952 | unpath = os.path.join(self._updater_path, "source") 953 | if not os.path.isdir(unpath): 954 | self._error = "Install failed" 955 | self._error_msg = "Extracted path does not exist" 956 | print("Extracted path does not exist: ", unpath) 957 | return -1 958 | 959 | if self._subfolder_path: 960 | self._subfolder_path.replace('/', os.path.sep) 961 | self._subfolder_path.replace('\\', os.path.sep) 962 | 963 | # Either directly in root of zip/one subfolder, or use specified path. 964 | if not os.path.isfile(os.path.join(unpath, "__init__.py")): 965 | dirlist = os.listdir(unpath) 966 | if len(dirlist) > 0: 967 | if self._subfolder_path == "" or self._subfolder_path is None: 968 | unpath = os.path.join(unpath, dirlist[0]) 969 | else: 970 | unpath = os.path.join(unpath, self._subfolder_path) 971 | 972 | # Smarter check for additional sub folders for a single folder 973 | # containing the __init__.py file. 974 | if not os.path.isfile(os.path.join(unpath, "__init__.py")): 975 | print("Not a valid addon found") 976 | print("Paths:") 977 | print(dirlist) 978 | self._error = "Install failed" 979 | self._error_msg = "No __init__ file found in new source" 980 | return -1 981 | 982 | # Merge code with the addon directory, using blender default behavior, 983 | # plus any modifiers indicated by user (e.g. force remove/keep). 984 | self.deep_merge_directory(self._addon_root, unpath, clean) 985 | 986 | # Now save the json state. 987 | # Change to True to trigger the handler on other side if allowing 988 | # reloading within same blender session. 989 | self._json["just_updated"] = True 990 | self.save_updater_json() 991 | self.reload_addon() 992 | self._update_ready = False 993 | return 0 994 | 995 | def deep_merge_directory(self, base, merger, clean=False): 996 | """Merge folder 'merger' into 'base' without deleting existing""" 997 | if not os.path.exists(base): 998 | self.print_verbose("Base path does not exist:" + str(base)) 999 | return -1 1000 | elif not os.path.exists(merger): 1001 | self.print_verbose("Merger path does not exist") 1002 | return -1 1003 | 1004 | # Path to be aware of and not overwrite/remove/etc. 1005 | staging_path = os.path.join(self._updater_path, "update_staging") 1006 | 1007 | # If clean install is enabled, clear existing files ahead of time 1008 | # note: will not delete the update.json, update folder, staging, or 1009 | # staging but will delete all other folders/files in addon directory. 1010 | error = None 1011 | if clean: 1012 | try: 1013 | # Implement clearing of all folders/files, except the updater 1014 | # folder and updater json. 1015 | # Careful, this deletes entire subdirectories recursively... 1016 | # Make sure that base is not a high level shared folder, but 1017 | # is dedicated just to the addon itself. 1018 | self.print_verbose( 1019 | "clean=True, clearing addon folder to fresh install state") 1020 | 1021 | # Remove root files and folders (except update folder). 1022 | files = [f for f in os.listdir(base) 1023 | if os.path.isfile(os.path.join(base, f))] 1024 | folders = [f for f in os.listdir(base) 1025 | if os.path.isdir(os.path.join(base, f))] 1026 | 1027 | for f in files: 1028 | os.remove(os.path.join(base, f)) 1029 | self.print_verbose( 1030 | "Clean removing file {}".format(os.path.join(base, f))) 1031 | for f in folders: 1032 | if os.path.join(base, f) is self._updater_path: 1033 | continue 1034 | shutil.rmtree(os.path.join(base, f)) 1035 | self.print_verbose( 1036 | "Clean removing folder and contents {}".format( 1037 | os.path.join(base, f))) 1038 | 1039 | except Exception as err: 1040 | error = "failed to create clean existing addon folder" 1041 | print(error, str(err)) 1042 | self.print_trace() 1043 | 1044 | # Walk through the base addon folder for rules on pre-removing 1045 | # but avoid removing/altering backup and updater file. 1046 | for path, dirs, files in os.walk(base): 1047 | # Prune ie skip updater folder. 1048 | dirs[:] = [d for d in dirs 1049 | if os.path.join(path, d) not in [self._updater_path]] 1050 | for file in files: 1051 | for pattern in self.remove_pre_update_patterns: 1052 | if fnmatch.filter([file], pattern): 1053 | try: 1054 | fl = os.path.join(path, file) 1055 | os.remove(fl) 1056 | self.print_verbose("Pre-removed file " + file) 1057 | except OSError: 1058 | print("Failed to pre-remove " + file) 1059 | self.print_trace() 1060 | 1061 | # Walk through the temp addon sub folder for replacements 1062 | # this implements the overwrite rules, which apply after 1063 | # the above pre-removal rules. This also performs the 1064 | # actual file copying/replacements. 1065 | for path, dirs, files in os.walk(merger): 1066 | # Verify structure works to prune updater sub folder overwriting. 1067 | dirs[:] = [d for d in dirs 1068 | if os.path.join(path, d) not in [self._updater_path]] 1069 | rel_path = os.path.relpath(path, merger) 1070 | dest_path = os.path.join(base, rel_path) 1071 | if not os.path.exists(dest_path): 1072 | os.makedirs(dest_path) 1073 | for file in files: 1074 | # Bring in additional logic around copying/replacing. 1075 | # Blender default: overwrite .py's, don't overwrite the rest. 1076 | dest_file = os.path.join(dest_path, file) 1077 | srcFile = os.path.join(path, file) 1078 | 1079 | # Decide to replace if file already exists, and copy new over. 1080 | if os.path.isfile(dest_file): 1081 | # Otherwise, check each file for overwrite pattern match. 1082 | replaced = False 1083 | for pattern in self._overwrite_patterns: 1084 | if fnmatch.filter([file], pattern): 1085 | replaced = True 1086 | break 1087 | if replaced: 1088 | os.remove(dest_file) 1089 | os.rename(srcFile, dest_file) 1090 | self.print_verbose( 1091 | "Overwrote file " + os.path.basename(dest_file)) 1092 | else: 1093 | self.print_verbose( 1094 | "Pattern not matched to {}, not overwritten".format( 1095 | os.path.basename(dest_file))) 1096 | else: 1097 | # File did not previously exist, simply move it over. 1098 | os.rename(srcFile, dest_file) 1099 | self.print_verbose( 1100 | "New file " + os.path.basename(dest_file)) 1101 | 1102 | # now remove the temp staging folder and downloaded zip 1103 | try: 1104 | shutil.rmtree(staging_path) 1105 | except: 1106 | error = ("Error: Failed to remove existing staging directory, " 1107 | "consider manually removing ") + staging_path 1108 | self.print_verbose(error) 1109 | self.print_trace() 1110 | 1111 | def reload_addon(self): 1112 | # if post_update false, skip this function 1113 | # else, unload/reload addon & trigger popup 1114 | if not self._auto_reload_post_update: 1115 | print("Restart blender to reload addon and complete update") 1116 | return 1117 | 1118 | self.print_verbose("Reloading addon...") 1119 | addon_utils.modules(refresh=True) 1120 | bpy.utils.refresh_script_paths() 1121 | 1122 | # not allowed in restricted context, such as register module 1123 | # toggle to refresh 1124 | if "addon_disable" in dir(bpy.ops.wm): # 2.7 1125 | bpy.ops.wm.addon_disable(module=self._addon_package) 1126 | bpy.ops.wm.addon_refresh() 1127 | bpy.ops.wm.addon_enable(module=self._addon_package) 1128 | print("2.7 reload complete") 1129 | else: # 2.8 1130 | bpy.ops.preferences.addon_disable(module=self._addon_package) 1131 | bpy.ops.preferences.addon_refresh() 1132 | bpy.ops.preferences.addon_enable(module=self._addon_package) 1133 | print("2.8 reload complete") 1134 | 1135 | # ------------------------------------------------------------------------- 1136 | # Other non-api functions and setups 1137 | # ------------------------------------------------------------------------- 1138 | def clear_state(self): 1139 | self._update_ready = None 1140 | self._update_link = None 1141 | self._update_version = None 1142 | self._source_zip = None 1143 | self._error = None 1144 | self._error_msg = None 1145 | 1146 | def url_retrieve(self, url_file, filepath): 1147 | """Custom urlretrieve implementation""" 1148 | chunk = 1024 * 8 1149 | f = open(filepath, "wb") 1150 | while 1: 1151 | data = url_file.read(chunk) 1152 | if not data: 1153 | # print("done.") 1154 | break 1155 | f.write(data) 1156 | # print("Read %s bytes" % len(data)) 1157 | f.close() 1158 | 1159 | def version_tuple_from_text(self, text): 1160 | """Convert text into a tuple of numbers (int). 1161 | 1162 | Should go through string and remove all non-integers, and for any 1163 | given break split into a different section. 1164 | """ 1165 | if text is None: 1166 | return () 1167 | 1168 | segments = list() 1169 | tmp = '' 1170 | for char in str(text): 1171 | if not char.isdigit(): 1172 | if len(tmp) > 0: 1173 | segments.append(int(tmp)) 1174 | tmp = '' 1175 | else: 1176 | tmp += char 1177 | if len(tmp) > 0: 1178 | segments.append(int(tmp)) 1179 | 1180 | if len(segments) == 0: 1181 | self.print_verbose("No version strings found text: " + str(text)) 1182 | if not self._include_branches: 1183 | return () 1184 | else: 1185 | return (text) 1186 | return tuple(segments) 1187 | 1188 | def check_for_update_async(self, callback=None): 1189 | """Called for running check in a background thread""" 1190 | is_ready = ( 1191 | self._json is not None 1192 | and "update_ready" in self._json 1193 | and self._json["version_text"] != dict() 1194 | and self._json["update_ready"]) 1195 | 1196 | if is_ready: 1197 | self._update_ready = True 1198 | self._update_link = self._json["version_text"]["link"] 1199 | self._update_version = str(self._json["version_text"]["version"]) 1200 | # Cached update. 1201 | callback(True) 1202 | return 1203 | 1204 | # do the check 1205 | if not self._check_interval_enabled: 1206 | return 1207 | elif self._async_checking: 1208 | self.print_verbose("Skipping async check, already started") 1209 | # already running the bg thread 1210 | elif self._update_ready is None: 1211 | print("{} updater: Running background check for update".format( 1212 | self.addon)) 1213 | self.start_async_check_update(False, callback) 1214 | 1215 | def check_for_update_now(self, callback=None): 1216 | self._error = None 1217 | self._error_msg = None 1218 | self.print_verbose( 1219 | "Check update pressed, first getting current status") 1220 | if self._async_checking: 1221 | self.print_verbose("Skipping async check, already started") 1222 | return # already running the bg thread 1223 | elif self._update_ready is None: 1224 | self.start_async_check_update(True, callback) 1225 | else: 1226 | self._update_ready = None 1227 | self.start_async_check_update(True, callback) 1228 | 1229 | def check_for_update(self, now=False): 1230 | """Check for update not in a syncrhonous manner. 1231 | 1232 | This function is not async, will always return in sequential fashion 1233 | but should have a parent which calls it in another thread. 1234 | """ 1235 | self.print_verbose("Checking for update function") 1236 | 1237 | # clear the errors if any 1238 | self._error = None 1239 | self._error_msg = None 1240 | 1241 | # avoid running again in, just return past result if found 1242 | # but if force now check, then still do it 1243 | if self._update_ready is not None and not now: 1244 | return (self._update_ready, 1245 | self._update_version, 1246 | self._update_link) 1247 | 1248 | if self._current_version is None: 1249 | raise ValueError("current_version not yet defined") 1250 | 1251 | if self._repo is None: 1252 | raise ValueError("repo not yet defined") 1253 | 1254 | if self._user is None: 1255 | raise ValueError("username not yet defined") 1256 | 1257 | self.set_updater_json() # self._json 1258 | 1259 | if not now and not self.past_interval_timestamp(): 1260 | self.print_verbose( 1261 | "Aborting check for updated, check interval not reached") 1262 | return (False, None, None) 1263 | 1264 | # check if using tags or releases 1265 | # note that if called the first time, this will pull tags from online 1266 | if self._fake_install: 1267 | self.print_verbose( 1268 | "fake_install = True, setting fake version as ready") 1269 | self._update_ready = True 1270 | self._update_version = "(999,999,999)" 1271 | self._update_link = "http://127.0.0.1" 1272 | 1273 | return (self._update_ready, 1274 | self._update_version, 1275 | self._update_link) 1276 | 1277 | # Primary internet call, sets self._tags and self._tag_latest. 1278 | self.get_tags() 1279 | 1280 | self._json["last_check"] = str(datetime.now()) 1281 | self.save_updater_json() 1282 | 1283 | # Can be () or ('master') in addition to branches, and version tag. 1284 | new_version = self.version_tuple_from_text(self.tag_latest) 1285 | 1286 | if len(self._tags) == 0: 1287 | self._update_ready = False 1288 | self._update_version = None 1289 | self._update_link = None 1290 | return (False, None, None) 1291 | 1292 | if not self._include_branches: 1293 | link = self.select_link(self, self._tags[0]) 1294 | else: 1295 | n = len(self._include_branch_list) 1296 | if len(self._tags) == n: 1297 | # effectively means no tags found on repo 1298 | # so provide the first one as default 1299 | link = self.select_link(self, self._tags[0]) 1300 | else: 1301 | link = self.select_link(self, self._tags[n]) 1302 | 1303 | if new_version == (): 1304 | self._update_ready = False 1305 | self._update_version = None 1306 | self._update_link = None 1307 | return (False, None, None) 1308 | elif str(new_version).lower() in self._include_branch_list: 1309 | # Handle situation where master/whichever branch is included 1310 | # however, this code effectively is not triggered now 1311 | # as new_version will only be tag names, not branch names. 1312 | if not self._include_branch_auto_check: 1313 | # Don't offer update as ready, but set the link for the 1314 | # default branch for installing. 1315 | self._update_ready = False 1316 | self._update_version = new_version 1317 | self._update_link = link 1318 | self.save_updater_json() 1319 | return (True, new_version, link) 1320 | else: 1321 | # Bypass releases and look at timestamp of last update from a 1322 | # branch compared to now, see if commit values match or not. 1323 | raise ValueError("include_branch_autocheck: NOT YET DEVELOPED") 1324 | 1325 | else: 1326 | # Situation where branches not included. 1327 | if new_version > self._current_version: 1328 | 1329 | self._update_ready = True 1330 | self._update_version = new_version 1331 | self._update_link = link 1332 | self.save_updater_json() 1333 | return (True, new_version, link) 1334 | 1335 | # If no update, set ready to False from None to show it was checked. 1336 | self._update_ready = False 1337 | self._update_version = None 1338 | self._update_link = None 1339 | return (False, None, None) 1340 | 1341 | def set_tag(self, name): 1342 | """Assign the tag name and url to update to""" 1343 | tg = None 1344 | for tag in self._tags: 1345 | if name == tag["name"]: 1346 | tg = tag 1347 | break 1348 | if tg: 1349 | new_version = self.version_tuple_from_text(self.tag_latest) 1350 | self._update_version = new_version 1351 | self._update_link = self.select_link(self, tg) 1352 | elif self._include_branches and name in self._include_branch_list: 1353 | # scenario if reverting to a specific branch name instead of tag 1354 | tg = name 1355 | link = self.form_branch_url(tg) 1356 | self._update_version = name # this will break things 1357 | self._update_link = link 1358 | if not tg: 1359 | raise ValueError("Version tag not found: " + name) 1360 | 1361 | def run_update(self, force=False, revert_tag=None, clean=False, callback=None): 1362 | """Runs an install, update, or reversion of an addon from online source 1363 | 1364 | Arguments: 1365 | force: Install assigned link, even if self.update_ready is False 1366 | revert_tag: Version to install, if none uses detected update link 1367 | clean: not used, but in future could use to totally refresh addon 1368 | callback: used to run function on update completion 1369 | """ 1370 | self._json["update_ready"] = False 1371 | self._json["ignore"] = False # clear ignore flag 1372 | self._json["version_text"] = dict() 1373 | 1374 | if revert_tag is not None: 1375 | self.set_tag(revert_tag) 1376 | self._update_ready = True 1377 | 1378 | # clear the errors if any 1379 | self._error = None 1380 | self._error_msg = None 1381 | 1382 | self.print_verbose("Running update") 1383 | 1384 | if self._fake_install: 1385 | # Change to True, to trigger the reload/"update installed" handler. 1386 | self.print_verbose("fake_install=True") 1387 | self.print_verbose( 1388 | "Just reloading and running any handler triggers") 1389 | self._json["just_updated"] = True 1390 | self.save_updater_json() 1391 | if self._backup_current is True: 1392 | self.create_backup() 1393 | self.reload_addon() 1394 | self._update_ready = False 1395 | res = True # fake "success" zip download flag 1396 | 1397 | elif not force: 1398 | if not self._update_ready: 1399 | self.print_verbose("Update stopped, new version not ready") 1400 | if callback: 1401 | callback( 1402 | self._addon_package, 1403 | "Update stopped, new version not ready") 1404 | return "Update stopped, new version not ready" 1405 | elif self._update_link is None: 1406 | # this shouldn't happen if update is ready 1407 | self.print_verbose("Update stopped, update link unavailable") 1408 | if callback: 1409 | callback(self._addon_package, 1410 | "Update stopped, update link unavailable") 1411 | return "Update stopped, update link unavailable" 1412 | 1413 | if revert_tag is None: 1414 | self.print_verbose("Staging update") 1415 | else: 1416 | self.print_verbose("Staging install") 1417 | 1418 | res = self.stage_repository(self._update_link) 1419 | if not res: 1420 | print("Error in staging repository: " + str(res)) 1421 | if callback is not None: 1422 | callback(self._addon_package, self._error_msg) 1423 | return self._error_msg 1424 | res = self.unpack_staged_zip(clean) 1425 | if res < 0: 1426 | if callback: 1427 | callback(self._addon_package, self._error_msg) 1428 | return res 1429 | 1430 | else: 1431 | if self._update_link is None: 1432 | self.print_verbose("Update stopped, could not get link") 1433 | return "Update stopped, could not get link" 1434 | self.print_verbose("Forcing update") 1435 | 1436 | res = self.stage_repository(self._update_link) 1437 | if not res: 1438 | print("Error in staging repository: " + str(res)) 1439 | if callback: 1440 | callback(self._addon_package, self._error_msg) 1441 | return self._error_msg 1442 | res = self.unpack_staged_zip(clean) 1443 | if res < 0: 1444 | return res 1445 | # would need to compare against other versions held in tags 1446 | 1447 | # run the front-end's callback if provided 1448 | if callback: 1449 | callback(self._addon_package) 1450 | 1451 | # return something meaningful, 0 means it worked 1452 | return 0 1453 | 1454 | def past_interval_timestamp(self): 1455 | if not self._check_interval_enabled: 1456 | return True # ie this exact feature is disabled 1457 | 1458 | if "last_check" not in self._json or self._json["last_check"] == "": 1459 | return True 1460 | 1461 | now = datetime.now() 1462 | last_check = datetime.strptime( 1463 | self._json["last_check"], "%Y-%m-%d %H:%M:%S.%f") 1464 | offset = timedelta( 1465 | days=self._check_interval_days + 30 * self._check_interval_months, 1466 | hours=self._check_interval_hours, 1467 | minutes=self._check_interval_minutes) 1468 | 1469 | delta = (now - offset) - last_check 1470 | if delta.total_seconds() > 0: 1471 | self.print_verbose("Time to check for updates!") 1472 | return True 1473 | 1474 | self.print_verbose("Determined it's not yet time to check for updates") 1475 | return False 1476 | 1477 | def get_json_path(self): 1478 | """Returns the full path to the JSON state file used by this updater. 1479 | 1480 | Will also rename old file paths to addon-specific path if found. 1481 | """ 1482 | json_path = os.path.join( 1483 | self._updater_path, 1484 | "{}_updater_status.json".format(self._addon_package)) 1485 | old_json_path = os.path.join(self._updater_path, "updater_status.json") 1486 | 1487 | # Rename old file if it exists. 1488 | try: 1489 | os.rename(old_json_path, json_path) 1490 | except FileNotFoundError: 1491 | pass 1492 | except Exception as err: 1493 | print("Other OS error occurred while trying to rename old JSON") 1494 | print(err) 1495 | self.print_trace() 1496 | return json_path 1497 | 1498 | def set_updater_json(self): 1499 | """Load or initialize JSON dictionary data for updater state""" 1500 | if self._updater_path is None: 1501 | raise ValueError("updater_path is not defined") 1502 | elif not os.path.isdir(self._updater_path): 1503 | os.makedirs(self._updater_path) 1504 | 1505 | jpath = self.get_json_path() 1506 | if os.path.isfile(jpath): 1507 | with open(jpath) as data_file: 1508 | self._json = json.load(data_file) 1509 | self.print_verbose("Read in JSON settings from file") 1510 | else: 1511 | self._json = { 1512 | "last_check": "", 1513 | "backup_date": "", 1514 | "update_ready": False, 1515 | "ignore": False, 1516 | "just_restored": False, 1517 | "just_updated": False, 1518 | "version_text": dict() 1519 | } 1520 | self.save_updater_json() 1521 | 1522 | def save_updater_json(self): 1523 | """Trigger save of current json structure into file within addon""" 1524 | if self._update_ready: 1525 | if isinstance(self._update_version, tuple): 1526 | self._json["update_ready"] = True 1527 | self._json["version_text"]["link"] = self._update_link 1528 | self._json["version_text"]["version"] = self._update_version 1529 | else: 1530 | self._json["update_ready"] = False 1531 | self._json["version_text"] = dict() 1532 | else: 1533 | self._json["update_ready"] = False 1534 | self._json["version_text"] = dict() 1535 | 1536 | jpath = self.get_json_path() 1537 | if not os.path.isdir(os.path.dirname(jpath)): 1538 | print("State error: Directory does not exist, cannot save json: ", 1539 | os.path.basename(jpath)) 1540 | return 1541 | try: 1542 | with open(jpath, 'w') as outf: 1543 | data_out = json.dumps(self._json, indent=4) 1544 | outf.write(data_out) 1545 | except: 1546 | print("Failed to open/save data to json: ", jpath) 1547 | self.print_trace() 1548 | self.print_verbose("Wrote out updater JSON settings with content:") 1549 | self.print_verbose(str(self._json)) 1550 | 1551 | def json_reset_postupdate(self): 1552 | self._json["just_updated"] = False 1553 | self._json["update_ready"] = False 1554 | self._json["version_text"] = dict() 1555 | self.save_updater_json() 1556 | 1557 | def json_reset_restore(self): 1558 | self._json["just_restored"] = False 1559 | self._json["update_ready"] = False 1560 | self._json["version_text"] = dict() 1561 | self.save_updater_json() 1562 | self._update_ready = None # Reset so you could check update again. 1563 | 1564 | def ignore_update(self): 1565 | self._json["ignore"] = True 1566 | self.save_updater_json() 1567 | 1568 | # ------------------------------------------------------------------------- 1569 | # ASYNC related methods 1570 | # ------------------------------------------------------------------------- 1571 | def start_async_check_update(self, now=False, callback=None): 1572 | """Start a background thread which will check for updates""" 1573 | if self._async_checking: 1574 | return 1575 | self.print_verbose("Starting background checking thread") 1576 | check_thread = threading.Thread(target=self.async_check_update, 1577 | args=(now, callback,)) 1578 | check_thread.daemon = True 1579 | self._check_thread = check_thread 1580 | check_thread.start() 1581 | 1582 | def async_check_update(self, now, callback=None): 1583 | """Perform update check, run as target of background thread""" 1584 | self._async_checking = True 1585 | self.print_verbose("Checking for update now in background") 1586 | 1587 | try: 1588 | self.check_for_update(now=now) 1589 | except Exception as exception: 1590 | print("Checking for update error:") 1591 | print(exception) 1592 | self.print_trace() 1593 | if not self._error: 1594 | self._update_ready = False 1595 | self._update_version = None 1596 | self._update_link = None 1597 | self._error = "Error occurred" 1598 | self._error_msg = "Encountered an error while checking for updates" 1599 | 1600 | self._async_checking = False 1601 | self._check_thread = None 1602 | 1603 | if callback: 1604 | self.print_verbose("Finished check update, doing callback") 1605 | callback(self._update_ready) 1606 | self.print_verbose("BG thread: Finished check update, no callback") 1607 | 1608 | def stop_async_check_update(self): 1609 | """Method to give impression of stopping check for update. 1610 | 1611 | Currently does nothing but allows user to retry/stop blocking UI from 1612 | hitting a refresh button. This does not actually stop the thread, as it 1613 | will complete after the connection timeout regardless. If the thread 1614 | does complete with a successful response, this will be still displayed 1615 | on next UI refresh (ie no update, or update available). 1616 | """ 1617 | if self._check_thread is not None: 1618 | self.print_verbose("Thread will end in normal course.") 1619 | # however, "There is no direct kill method on a thread object." 1620 | # better to let it run its course 1621 | # self._check_thread.stop() 1622 | self._async_checking = False 1623 | self._error = None 1624 | self._error_msg = None 1625 | 1626 | 1627 | # ----------------------------------------------------------------------------- 1628 | # Updater Engines 1629 | # ----------------------------------------------------------------------------- 1630 | 1631 | 1632 | class BitbucketEngine: 1633 | """Integration to Bitbucket API for git-formatted repositories""" 1634 | 1635 | def __init__(self): 1636 | self.api_url = 'https://api.bitbucket.org' 1637 | self.token = None 1638 | self.name = "bitbucket" 1639 | 1640 | def form_repo_url(self, updater): 1641 | return "{}/2.0/repositories/{}/{}".format( 1642 | self.api_url, updater.user, updater.repo) 1643 | 1644 | def form_tags_url(self, updater): 1645 | return self.form_repo_url(updater) + "/refs/tags?sort=-name" 1646 | 1647 | def form_branch_url(self, branch, updater): 1648 | return self.get_zip_url(branch, updater) 1649 | 1650 | def get_zip_url(self, name, updater): 1651 | return "https://bitbucket.org/{user}/{repo}/get/{name}.zip".format( 1652 | user=updater.user, 1653 | repo=updater.repo, 1654 | name=name) 1655 | 1656 | def parse_tags(self, response, updater): 1657 | if response is None: 1658 | return list() 1659 | return [ 1660 | { 1661 | "name": tag["name"], 1662 | "zipball_url": self.get_zip_url(tag["name"], updater) 1663 | } for tag in response["values"]] 1664 | 1665 | 1666 | class GithubEngine: 1667 | """Integration to Github API""" 1668 | 1669 | def __init__(self): 1670 | self.api_url = 'https://api.github.com' 1671 | self.token = None 1672 | self.name = "github" 1673 | 1674 | def form_repo_url(self, updater): 1675 | return "{}/repos/{}/{}".format( 1676 | self.api_url, updater.user, updater.repo) 1677 | 1678 | def form_tags_url(self, updater): 1679 | if updater.use_releases: 1680 | return "{}/releases".format(self.form_repo_url(updater)) 1681 | else: 1682 | return "{}/tags".format(self.form_repo_url(updater)) 1683 | 1684 | def form_branch_list_url(self, updater): 1685 | return "{}/branches".format(self.form_repo_url(updater)) 1686 | 1687 | def form_branch_url(self, branch, updater): 1688 | return "{}/zipball/{}".format(self.form_repo_url(updater), branch) 1689 | 1690 | def parse_tags(self, response, updater): 1691 | if response is None: 1692 | return list() 1693 | return response 1694 | 1695 | 1696 | class GitlabEngine: 1697 | """Integration to GitLab API""" 1698 | 1699 | def __init__(self): 1700 | self.api_url = 'https://gitlab.com' 1701 | self.token = None 1702 | self.name = "gitlab" 1703 | 1704 | def form_repo_url(self, updater): 1705 | return "{}/api/v4/projects/{}".format(self.api_url, updater.repo) 1706 | 1707 | def form_tags_url(self, updater): 1708 | return "{}/repository/tags".format(self.form_repo_url(updater)) 1709 | 1710 | def form_branch_list_url(self, updater): 1711 | # does not validate branch name. 1712 | return "{}/repository/branches".format( 1713 | self.form_repo_url(updater)) 1714 | 1715 | def form_branch_url(self, branch, updater): 1716 | # Could clash with tag names and if it does, it will download TAG zip 1717 | # instead of branch zip to get direct path, would need. 1718 | return "{}/repository/archive.zip?sha={}".format( 1719 | self.form_repo_url(updater), branch) 1720 | 1721 | def get_zip_url(self, sha, updater): 1722 | return "{base}/repository/archive.zip?sha={sha}".format( 1723 | base=self.form_repo_url(updater), 1724 | sha=sha) 1725 | 1726 | # def get_commit_zip(self, id, updater): 1727 | # return self.form_repo_url(updater)+"/repository/archive.zip?sha:"+id 1728 | 1729 | def parse_tags(self, response, updater): 1730 | if response is None: 1731 | return list() 1732 | return [ 1733 | { 1734 | "name": tag["name"], 1735 | "zipball_url": self.get_zip_url(tag["commit"]["id"], updater) 1736 | } for tag in response] 1737 | 1738 | 1739 | # ----------------------------------------------------------------------------- 1740 | # The module-shared class instance, 1741 | # should be what's imported to other files 1742 | # ----------------------------------------------------------------------------- 1743 | 1744 | Updater = SingletonUpdater() 1745 | -------------------------------------------------------------------------------- /addon_updater_ops.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 2 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 | """Blender UI integrations for the addon updater. 20 | 21 | Implements draw calls, popups, and operators that use the addon_updater. 22 | """ 23 | 24 | import os 25 | import traceback 26 | 27 | import bpy 28 | from bpy.app.handlers import persistent 29 | 30 | # Safely import the updater. 31 | # Prevents popups for users with invalid python installs e.g. missing libraries 32 | # and will replace with a fake class instead if it fails (so UI draws work). 33 | try: 34 | from .addon_updater import Updater as updater 35 | except Exception as e: 36 | print("ERROR INITIALIZING UPDATER") 37 | print(str(e)) 38 | traceback.print_exc() 39 | 40 | class SingletonUpdaterNone(object): 41 | """Fake, bare minimum fields and functions for the updater object.""" 42 | 43 | def __init__(self): 44 | self.invalid_updater = True # Used to distinguish bad install. 45 | 46 | self.addon = None 47 | self.verbose = False 48 | self.use_print_traces = True 49 | self.error = None 50 | self.error_msg = None 51 | self.async_checking = None 52 | 53 | def clear_state(self): 54 | self.addon = None 55 | self.verbose = False 56 | self.invalid_updater = True 57 | self.error = None 58 | self.error_msg = None 59 | self.async_checking = None 60 | 61 | def run_update(self, force, callback, clean): 62 | pass 63 | 64 | def check_for_update(self, now): 65 | pass 66 | 67 | updater = SingletonUpdaterNone() 68 | updater.error = "Error initializing updater module" 69 | updater.error_msg = str(e) 70 | 71 | # Must declare this before classes are loaded, otherwise the bl_idname's will 72 | # not match and have errors. Must be all lowercase and no spaces! Should also 73 | # be unique among any other addons that could exist (using this updater code), 74 | # to avoid clashes in operator registration. 75 | updater.addon = "zode_blender_utils" 76 | 77 | 78 | # ----------------------------------------------------------------------------- 79 | # Blender version utils 80 | # ----------------------------------------------------------------------------- 81 | def make_annotations(cls): 82 | """Add annotation attribute to fields to avoid Blender 2.8+ warnings""" 83 | if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80): 84 | return cls 85 | if bpy.app.version < (2, 93, 0): 86 | bl_props = {k: v for k, v in cls.__dict__.items() 87 | if isinstance(v, tuple)} 88 | else: 89 | bl_props = {k: v for k, v in cls.__dict__.items() 90 | if isinstance(v, bpy.props._PropertyDeferred)} 91 | if bl_props: 92 | if '__annotations__' not in cls.__dict__: 93 | setattr(cls, '__annotations__', {}) 94 | annotations = cls.__dict__['__annotations__'] 95 | for k, v in bl_props.items(): 96 | annotations[k] = v 97 | delattr(cls, k) 98 | return cls 99 | 100 | 101 | def layout_split(layout, factor=0.0, align=False): 102 | """Intermediate method for pre and post blender 2.8 split UI function""" 103 | if not hasattr(bpy.app, "version") or bpy.app.version < (2, 80): 104 | return layout.split(percentage=factor, align=align) 105 | return layout.split(factor=factor, align=align) 106 | 107 | 108 | def get_user_preferences(context=None): 109 | """Intermediate method for pre and post blender 2.8 grabbing preferences""" 110 | if not context: 111 | context = bpy.context 112 | prefs = None 113 | if hasattr(context, "user_preferences"): 114 | prefs = context.user_preferences.addons.get(__package__, None) 115 | elif hasattr(context, "preferences"): 116 | prefs = context.preferences.addons.get(__package__, None) 117 | if prefs: 118 | return prefs.preferences 119 | # To make the addon stable and non-exception prone, return None 120 | # raise Exception("Could not fetch user preferences") 121 | return None 122 | 123 | 124 | # ----------------------------------------------------------------------------- 125 | # Updater operators 126 | # ----------------------------------------------------------------------------- 127 | 128 | 129 | # Simple popup to prompt use to check for update & offer install if available. 130 | class AddonUpdaterInstallPopup(bpy.types.Operator): 131 | """Check and install update if available""" 132 | bl_label = "Update {x} addon".format(x=updater.addon) 133 | bl_idname = updater.addon + ".updater_install_popup" 134 | bl_description = "Popup to check and display current updates available" 135 | bl_options = {'REGISTER', 'INTERNAL'} 136 | 137 | # if true, run clean install - ie remove all files before adding new 138 | # equivalent to deleting the addon and reinstalling, except the 139 | # updater folder/backup folder remains 140 | clean_install = bpy.props.BoolProperty( 141 | name="Clean install", 142 | description=("If enabled, completely clear the addon's folder before " 143 | "installing new update, creating a fresh install"), 144 | default=False, 145 | options={'HIDDEN'} 146 | ) 147 | 148 | ignore_enum = bpy.props.EnumProperty( 149 | name="Process update", 150 | description="Decide to install, ignore, or defer new addon update", 151 | items=[ 152 | ("install", "Update Now", "Install update now"), 153 | ("ignore", "Ignore", "Ignore this update to prevent future popups"), 154 | ("defer", "Defer", "Defer choice till next blender session") 155 | ], 156 | options={'HIDDEN'} 157 | ) 158 | 159 | def check(self, context): 160 | return True 161 | 162 | def invoke(self, context, event): 163 | return context.window_manager.invoke_props_dialog(self) 164 | 165 | def draw(self, context): 166 | layout = self.layout 167 | if updater.invalid_updater: 168 | layout.label(text="Updater module error") 169 | return 170 | elif updater.update_ready: 171 | col = layout.column() 172 | col.scale_y = 0.7 173 | col.label(text="Update {} ready!".format(updater.update_version), 174 | icon="LOOP_FORWARDS") 175 | col.label(text="Choose 'Update Now' & press OK to install, ", 176 | icon="BLANK1") 177 | col.label(text="or click outside window to defer", icon="BLANK1") 178 | row = col.row() 179 | row.prop(self, "ignore_enum", expand=True) 180 | col.split() 181 | elif not updater.update_ready: 182 | col = layout.column() 183 | col.scale_y = 0.7 184 | col.label(text="No updates available") 185 | col.label(text="Press okay to dismiss dialog") 186 | # add option to force install 187 | else: 188 | # Case: updater.update_ready = None 189 | # we have not yet checked for the update. 190 | layout.label(text="Check for update now?") 191 | 192 | # Potentially in future, UI to 'check to select/revert to old version'. 193 | 194 | def execute(self, context): 195 | # In case of error importing updater. 196 | if updater.invalid_updater: 197 | return {'CANCELLED'} 198 | 199 | if updater.manual_only: 200 | bpy.ops.wm.url_open(url=updater.website) 201 | elif updater.update_ready: 202 | 203 | # Action based on enum selection. 204 | if self.ignore_enum == 'defer': 205 | return {'FINISHED'} 206 | elif self.ignore_enum == 'ignore': 207 | updater.ignore_update() 208 | return {'FINISHED'} 209 | 210 | res = updater.run_update(force=False, 211 | callback=post_update_callback, 212 | clean=self.clean_install) 213 | 214 | # Should return 0, if not something happened. 215 | if updater.verbose: 216 | if res == 0: 217 | print("Updater returned successful") 218 | else: 219 | print("Updater returned {}, error occurred".format(res)) 220 | elif updater.update_ready is None: 221 | _ = updater.check_for_update(now=True) 222 | 223 | # Re-launch this dialog. 224 | atr = AddonUpdaterInstallPopup.bl_idname.split(".") 225 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') 226 | else: 227 | updater.print_verbose("Doing nothing, not ready for update") 228 | return {'FINISHED'} 229 | 230 | 231 | # User preference check-now operator 232 | class AddonUpdaterCheckNow(bpy.types.Operator): 233 | bl_label = "Check now for " + updater.addon + " update" 234 | bl_idname = updater.addon + ".updater_check_now" 235 | bl_description = "Check now for an update to the {} addon".format( 236 | updater.addon) 237 | bl_options = {'REGISTER', 'INTERNAL'} 238 | 239 | def execute(self, context): 240 | if updater.invalid_updater: 241 | return {'CANCELLED'} 242 | 243 | if updater.async_checking and updater.error is None: 244 | # Check already happened. 245 | # Used here to just avoid constant applying settings below. 246 | # Ignoring if error, to prevent being stuck on the error screen. 247 | return {'CANCELLED'} 248 | 249 | # apply the UI settings 250 | settings = get_user_preferences(context) 251 | if not settings: 252 | updater.print_verbose( 253 | "Could not get {} preferences, update check skipped".format( 254 | __package__)) 255 | return {'CANCELLED'} 256 | 257 | updater.set_check_interval( 258 | enabled=settings.auto_check_update, 259 | months=settings.updater_interval_months, 260 | days=settings.updater_interval_days, 261 | hours=settings.updater_interval_hours, 262 | minutes=settings.updater_interval_minutes) 263 | 264 | # Input is an optional callback function. This function should take a 265 | # bool input. If true: update ready, if false: no update ready. 266 | updater.check_for_update_now(ui_refresh) 267 | 268 | return {'FINISHED'} 269 | 270 | 271 | class AddonUpdaterUpdateNow(bpy.types.Operator): 272 | bl_label = "Update " + updater.addon + " addon now" 273 | bl_idname = updater.addon + ".updater_update_now" 274 | bl_description = "Update to the latest version of the {x} addon".format( 275 | x=updater.addon) 276 | bl_options = {'REGISTER', 'INTERNAL'} 277 | 278 | # If true, run clean install - ie remove all files before adding new 279 | # equivalent to deleting the addon and reinstalling, except the updater 280 | # folder/backup folder remains. 281 | clean_install = bpy.props.BoolProperty( 282 | name="Clean install", 283 | description=("If enabled, completely clear the addon's folder before " 284 | "installing new update, creating a fresh install"), 285 | default=False, 286 | options={'HIDDEN'} 287 | ) 288 | 289 | def execute(self, context): 290 | 291 | # in case of error importing updater 292 | if updater.invalid_updater: 293 | return {'CANCELLED'} 294 | 295 | if updater.manual_only: 296 | bpy.ops.wm.url_open(url=updater.website) 297 | if updater.update_ready: 298 | # if it fails, offer to open the website instead 299 | try: 300 | res = updater.run_update(force=False, 301 | callback=post_update_callback, 302 | clean=self.clean_install) 303 | 304 | # Should return 0, if not something happened. 305 | if updater.verbose: 306 | if res == 0: 307 | print("Updater returned successful") 308 | else: 309 | print("Updater error response: {}".format(res)) 310 | except Exception as expt: 311 | updater._error = "Error trying to run update" 312 | updater._error_msg = str(expt) 313 | updater.print_trace() 314 | atr = AddonUpdaterInstallManually.bl_idname.split(".") 315 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') 316 | elif updater.update_ready is None: 317 | (update_ready, version, link) = updater.check_for_update(now=True) 318 | # Re-launch this dialog. 319 | atr = AddonUpdaterInstallPopup.bl_idname.split(".") 320 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') 321 | 322 | elif not updater.update_ready: 323 | self.report({'INFO'}, "Nothing to update") 324 | return {'CANCELLED'} 325 | else: 326 | self.report( 327 | {'ERROR'}, "Encountered a problem while trying to update") 328 | return {'CANCELLED'} 329 | 330 | return {'FINISHED'} 331 | 332 | 333 | class AddonUpdaterUpdateTarget(bpy.types.Operator): 334 | bl_label = updater.addon + " version target" 335 | bl_idname = updater.addon + ".updater_update_target" 336 | bl_description = "Install a targeted version of the {x} addon".format( 337 | x=updater.addon) 338 | bl_options = {'REGISTER', 'INTERNAL'} 339 | 340 | def target_version(self, context): 341 | # In case of error importing updater. 342 | if updater.invalid_updater: 343 | ret = [] 344 | 345 | ret = [] 346 | i = 0 347 | for tag in updater.tags: 348 | ret.append((tag, tag, "Select to install " + tag)) 349 | i += 1 350 | return ret 351 | 352 | target = bpy.props.EnumProperty( 353 | name="Target version to install", 354 | description="Select the version to install", 355 | items=target_version 356 | ) 357 | 358 | # If true, run clean install - ie remove all files before adding new 359 | # equivalent to deleting the addon and reinstalling, except the 360 | # updater folder/backup folder remains. 361 | clean_install = bpy.props.BoolProperty( 362 | name="Clean install", 363 | description=("If enabled, completely clear the addon's folder before " 364 | "installing new update, creating a fresh install"), 365 | default=False, 366 | options={'HIDDEN'} 367 | ) 368 | 369 | @classmethod 370 | def poll(cls, context): 371 | if updater.invalid_updater: 372 | return False 373 | return updater.update_ready is not None and len(updater.tags) > 0 374 | 375 | def invoke(self, context, event): 376 | return context.window_manager.invoke_props_dialog(self) 377 | 378 | def draw(self, context): 379 | layout = self.layout 380 | if updater.invalid_updater: 381 | layout.label(text="Updater error") 382 | return 383 | split = layout_split(layout, factor=0.5) 384 | sub_col = split.column() 385 | sub_col.label(text="Select install version") 386 | sub_col = split.column() 387 | sub_col.prop(self, "target", text="") 388 | 389 | def execute(self, context): 390 | # In case of error importing updater. 391 | if updater.invalid_updater: 392 | return {'CANCELLED'} 393 | 394 | res = updater.run_update( 395 | force=False, 396 | revert_tag=self.target, 397 | callback=post_update_callback, 398 | clean=self.clean_install) 399 | 400 | # Should return 0, if not something happened. 401 | if res == 0: 402 | updater.print_verbose("Updater returned successful") 403 | else: 404 | updater.print_verbose( 405 | "Updater returned {}, , error occurred".format(res)) 406 | return {'CANCELLED'} 407 | 408 | return {'FINISHED'} 409 | 410 | 411 | class AddonUpdaterInstallManually(bpy.types.Operator): 412 | """As a fallback, direct the user to download the addon manually""" 413 | bl_label = "Install update manually" 414 | bl_idname = updater.addon + ".updater_install_manually" 415 | bl_description = "Proceed to manually install update" 416 | bl_options = {'REGISTER', 'INTERNAL'} 417 | 418 | error = bpy.props.StringProperty( 419 | name="Error Occurred", 420 | default="", 421 | options={'HIDDEN'} 422 | ) 423 | 424 | def invoke(self, context, event): 425 | return context.window_manager.invoke_popup(self) 426 | 427 | def draw(self, context): 428 | layout = self.layout 429 | 430 | if updater.invalid_updater: 431 | layout.label(text="Updater error") 432 | return 433 | 434 | # Display error if a prior autoamted install failed. 435 | if self.error != "": 436 | col = layout.column() 437 | col.scale_y = 0.7 438 | col.label(text="There was an issue trying to auto-install", 439 | icon="ERROR") 440 | col.label(text="Press the download button below and install", 441 | icon="BLANK1") 442 | col.label(text="the zip file like a normal addon.", icon="BLANK1") 443 | else: 444 | col = layout.column() 445 | col.scale_y = 0.7 446 | col.label(text="Install the addon manually") 447 | col.label(text="Press the download button below and install") 448 | col.label(text="the zip file like a normal addon.") 449 | 450 | # If check hasn't happened, i.e. accidentally called this menu, 451 | # allow to check here. 452 | 453 | row = layout.row() 454 | 455 | if updater.update_link is not None: 456 | row.operator( 457 | "wm.url_open", 458 | text="Direct download").url = updater.update_link 459 | else: 460 | row.operator( 461 | "wm.url_open", 462 | text="(failed to retrieve direct download)") 463 | row.enabled = False 464 | 465 | if updater.website is not None: 466 | row = layout.row() 467 | ops = row.operator("wm.url_open", text="Open website") 468 | ops.url = updater.website 469 | else: 470 | row = layout.row() 471 | row.label(text="See source website to download the update") 472 | 473 | def execute(self, context): 474 | return {'FINISHED'} 475 | 476 | 477 | class AddonUpdaterUpdatedSuccessful(bpy.types.Operator): 478 | """Addon in place, popup telling user it completed or what went wrong""" 479 | bl_label = "Installation Report" 480 | bl_idname = updater.addon + ".updater_update_successful" 481 | bl_description = "Update installation response" 482 | bl_options = {'REGISTER', 'INTERNAL', 'UNDO'} 483 | 484 | error = bpy.props.StringProperty( 485 | name="Error Occurred", 486 | default="", 487 | options={'HIDDEN'} 488 | ) 489 | 490 | def invoke(self, context, event): 491 | return context.window_manager.invoke_props_popup(self, event) 492 | 493 | def draw(self, context): 494 | layout = self.layout 495 | 496 | if updater.invalid_updater: 497 | layout.label(text="Updater error") 498 | return 499 | 500 | saved = updater.json 501 | if self.error != "": 502 | col = layout.column() 503 | col.scale_y = 0.7 504 | col.label(text="Error occurred, did not install", icon="ERROR") 505 | if updater.error_msg: 506 | msg = updater.error_msg 507 | else: 508 | msg = self.error 509 | col.label(text=str(msg), icon="BLANK1") 510 | rw = col.row() 511 | rw.scale_y = 2 512 | rw.operator( 513 | "wm.url_open", 514 | text="Click for manual download.", 515 | icon="BLANK1").url = updater.website 516 | elif not updater.auto_reload_post_update: 517 | # Tell user to restart blender after an update/restore! 518 | if "just_restored" in saved and saved["just_restored"]: 519 | col = layout.column() 520 | col.label(text="Addon restored", icon="RECOVER_LAST") 521 | alert_row = col.row() 522 | alert_row.alert = True 523 | alert_row.operator( 524 | "wm.quit_blender", 525 | text="Restart blender to reload", 526 | icon="BLANK1") 527 | updater.json_reset_restore() 528 | else: 529 | col = layout.column() 530 | col.label( 531 | text="Addon successfully installed", icon="FILE_TICK") 532 | alert_row = col.row() 533 | alert_row.alert = True 534 | alert_row.operator( 535 | "wm.quit_blender", 536 | text="Restart blender to reload", 537 | icon="BLANK1") 538 | 539 | else: 540 | # reload addon, but still recommend they restart blender 541 | if "just_restored" in saved and saved["just_restored"]: 542 | col = layout.column() 543 | col.scale_y = 0.7 544 | col.label(text="Addon restored", icon="RECOVER_LAST") 545 | col.label( 546 | text="Consider restarting blender to fully reload.", 547 | icon="BLANK1") 548 | updater.json_reset_restore() 549 | else: 550 | col = layout.column() 551 | col.scale_y = 0.7 552 | col.label( 553 | text="Addon successfully installed", icon="FILE_TICK") 554 | col.label( 555 | text="Consider restarting blender to fully reload.", 556 | icon="BLANK1") 557 | 558 | def execute(self, context): 559 | return {'FINISHED'} 560 | 561 | 562 | class AddonUpdaterRestoreBackup(bpy.types.Operator): 563 | """Restore addon from backup""" 564 | bl_label = "Restore backup" 565 | bl_idname = updater.addon + ".updater_restore_backup" 566 | bl_description = "Restore addon from backup" 567 | bl_options = {'REGISTER', 'INTERNAL'} 568 | 569 | @classmethod 570 | def poll(cls, context): 571 | try: 572 | return os.path.isdir(os.path.join(updater.stage_path, "backup")) 573 | except: 574 | return False 575 | 576 | def execute(self, context): 577 | # in case of error importing updater 578 | if updater.invalid_updater: 579 | return {'CANCELLED'} 580 | updater.restore_backup() 581 | return {'FINISHED'} 582 | 583 | 584 | class AddonUpdaterIgnore(bpy.types.Operator): 585 | """Ignore update to prevent future popups""" 586 | bl_label = "Ignore update" 587 | bl_idname = updater.addon + ".updater_ignore" 588 | bl_description = "Ignore update to prevent future popups" 589 | bl_options = {'REGISTER', 'INTERNAL'} 590 | 591 | @classmethod 592 | def poll(cls, context): 593 | if updater.invalid_updater: 594 | return False 595 | elif updater.update_ready: 596 | return True 597 | else: 598 | return False 599 | 600 | def execute(self, context): 601 | # in case of error importing updater 602 | if updater.invalid_updater: 603 | return {'CANCELLED'} 604 | updater.ignore_update() 605 | self.report({"INFO"}, "Open addon preferences for updater options") 606 | return {'FINISHED'} 607 | 608 | 609 | class AddonUpdaterEndBackground(bpy.types.Operator): 610 | """Stop checking for update in the background""" 611 | bl_label = "End background check" 612 | bl_idname = updater.addon + ".end_background_check" 613 | bl_description = "Stop checking for update in the background" 614 | bl_options = {'REGISTER', 'INTERNAL'} 615 | 616 | def execute(self, context): 617 | # in case of error importing updater 618 | if updater.invalid_updater: 619 | return {'CANCELLED'} 620 | updater.stop_async_check_update() 621 | return {'FINISHED'} 622 | 623 | 624 | # ----------------------------------------------------------------------------- 625 | # Handler related, to create popups 626 | # ----------------------------------------------------------------------------- 627 | 628 | 629 | # global vars used to prevent duplicate popup handlers 630 | ran_auto_check_install_popup = False 631 | ran_update_success_popup = False 632 | 633 | # global var for preventing successive calls 634 | ran_background_check = False 635 | 636 | 637 | @persistent 638 | def updater_run_success_popup_handler(scene): 639 | global ran_update_success_popup 640 | ran_update_success_popup = True 641 | 642 | # in case of error importing updater 643 | if updater.invalid_updater: 644 | return 645 | 646 | try: 647 | if "scene_update_post" in dir(bpy.app.handlers): 648 | bpy.app.handlers.scene_update_post.remove( 649 | updater_run_success_popup_handler) 650 | else: 651 | bpy.app.handlers.depsgraph_update_post.remove( 652 | updater_run_success_popup_handler) 653 | except: 654 | pass 655 | 656 | atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") 657 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') 658 | 659 | 660 | @persistent 661 | def updater_run_install_popup_handler(scene): 662 | global ran_auto_check_install_popup 663 | ran_auto_check_install_popup = True 664 | updater.print_verbose("Running the install popup handler.") 665 | 666 | # in case of error importing updater 667 | if updater.invalid_updater: 668 | return 669 | 670 | try: 671 | if "scene_update_post" in dir(bpy.app.handlers): 672 | bpy.app.handlers.scene_update_post.remove( 673 | updater_run_install_popup_handler) 674 | else: 675 | bpy.app.handlers.depsgraph_update_post.remove( 676 | updater_run_install_popup_handler) 677 | except: 678 | pass 679 | 680 | if "ignore" in updater.json and updater.json["ignore"]: 681 | return # Don't do popup if ignore pressed. 682 | elif "version_text" in updater.json and updater.json["version_text"].get("version"): 683 | version = updater.json["version_text"]["version"] 684 | ver_tuple = updater.version_tuple_from_text(version) 685 | 686 | if ver_tuple < updater.current_version: 687 | # User probably manually installed to get the up to date addon 688 | # in here. Clear out the update flag using this function. 689 | updater.print_verbose( 690 | "{} updater: appears user updated, clearing flag".format( 691 | updater.addon)) 692 | updater.json_reset_restore() 693 | return 694 | atr = AddonUpdaterInstallPopup.bl_idname.split(".") 695 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') 696 | 697 | 698 | def background_update_callback(update_ready): 699 | """Passed into the updater, background thread updater""" 700 | global ran_auto_check_install_popup 701 | updater.print_verbose("Running background update callback") 702 | 703 | # In case of error importing updater. 704 | if updater.invalid_updater: 705 | return 706 | if not updater.show_popups: 707 | return 708 | if not update_ready: 709 | return 710 | 711 | # See if we need add to the update handler to trigger the popup. 712 | handlers = [] 713 | if "scene_update_post" in dir(bpy.app.handlers): # 2.7x 714 | handlers = bpy.app.handlers.scene_update_post 715 | else: # 2.8+ 716 | handlers = bpy.app.handlers.depsgraph_update_post 717 | in_handles = updater_run_install_popup_handler in handlers 718 | 719 | if in_handles or ran_auto_check_install_popup: 720 | return 721 | 722 | if "scene_update_post" in dir(bpy.app.handlers): # 2.7x 723 | bpy.app.handlers.scene_update_post.append( 724 | updater_run_install_popup_handler) 725 | else: # 2.8+ 726 | bpy.app.handlers.depsgraph_update_post.append( 727 | updater_run_install_popup_handler) 728 | ran_auto_check_install_popup = True 729 | updater.print_verbose("Attempted popup prompt") 730 | 731 | 732 | def post_update_callback(module_name, res=None): 733 | """Callback for once the run_update function has completed. 734 | 735 | Only makes sense to use this if "auto_reload_post_update" == False, 736 | i.e. don't auto-restart the addon. 737 | 738 | Arguments: 739 | module_name: returns the module name from updater, but unused here. 740 | res: If an error occurred, this is the detail string. 741 | """ 742 | 743 | # In case of error importing updater. 744 | if updater.invalid_updater: 745 | return 746 | 747 | if res is None: 748 | # This is the same code as in conditional at the end of the register 749 | # function, ie if "auto_reload_post_update" == True, skip code. 750 | updater.print_verbose( 751 | "{} updater: Running post update callback".format(updater.addon)) 752 | 753 | atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") 754 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') 755 | global ran_update_success_popup 756 | ran_update_success_popup = True 757 | else: 758 | # Some kind of error occurred and it was unable to install, offer 759 | # manual download instead. 760 | atr = AddonUpdaterUpdatedSuccessful.bl_idname.split(".") 761 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT', error=res) 762 | return 763 | 764 | 765 | def ui_refresh(update_status): 766 | """Redraw the ui once an async thread has completed""" 767 | for windowManager in bpy.data.window_managers: 768 | for window in windowManager.windows: 769 | for area in window.screen.areas: 770 | area.tag_redraw() 771 | 772 | 773 | def check_for_update_background(): 774 | """Function for asynchronous background check. 775 | 776 | *Could* be called on register, but would be bad practice as the bare 777 | minimum code should run at the moment of registration (addon ticked). 778 | """ 779 | if updater.invalid_updater: 780 | return 781 | global ran_background_check 782 | if ran_background_check: 783 | # Global var ensures check only happens once. 784 | return 785 | elif updater.update_ready is not None or updater.async_checking: 786 | # Check already happened. 787 | # Used here to just avoid constant applying settings below. 788 | return 789 | 790 | # Apply the UI settings. 791 | settings = get_user_preferences(bpy.context) 792 | if not settings: 793 | return 794 | updater.set_check_interval(enabled=settings.auto_check_update, 795 | months=settings.updater_interval_months, 796 | days=settings.updater_interval_days, 797 | hours=settings.updater_interval_hours, 798 | minutes=settings.updater_interval_minutes) 799 | 800 | # Input is an optional callback function. This function should take a bool 801 | # input, if true: update ready, if false: no update ready. 802 | updater.check_for_update_async(background_update_callback) 803 | ran_background_check = True 804 | 805 | 806 | def check_for_update_nonthreaded(self, context): 807 | """Can be placed in front of other operators to launch when pressed""" 808 | if updater.invalid_updater: 809 | return 810 | 811 | # Only check if it's ready, ie after the time interval specified should 812 | # be the async wrapper call here. 813 | settings = get_user_preferences(bpy.context) 814 | if not settings: 815 | if updater.verbose: 816 | print("Could not get {} preferences, update check skipped".format( 817 | __package__)) 818 | return 819 | updater.set_check_interval(enabled=settings.auto_check_update, 820 | months=settings.updater_interval_months, 821 | days=settings.updater_interval_days, 822 | hours=settings.updater_interval_hours, 823 | minutes=settings.updater_interval_minutes) 824 | 825 | (update_ready, version, link) = updater.check_for_update(now=False) 826 | if update_ready: 827 | atr = AddonUpdaterInstallPopup.bl_idname.split(".") 828 | getattr(getattr(bpy.ops, atr[0]), atr[1])('INVOKE_DEFAULT') 829 | else: 830 | updater.print_verbose("No update ready") 831 | self.report({'INFO'}, "No update ready") 832 | 833 | 834 | def show_reload_popup(): 835 | """For use in register only, to show popup after re-enabling the addon. 836 | 837 | Must be enabled by developer. 838 | """ 839 | if updater.invalid_updater: 840 | return 841 | saved_state = updater.json 842 | global ran_update_success_popup 843 | 844 | has_state = saved_state is not None 845 | just_updated = "just_updated" in saved_state 846 | updated_info = saved_state["just_updated"] 847 | 848 | if not (has_state and just_updated and updated_info): 849 | return 850 | 851 | updater.json_reset_postupdate() # So this only runs once. 852 | 853 | # No handlers in this case. 854 | if not updater.auto_reload_post_update: 855 | return 856 | 857 | # See if we need add to the update handler to trigger the popup. 858 | handlers = [] 859 | if "scene_update_post" in dir(bpy.app.handlers): # 2.7x 860 | handlers = bpy.app.handlers.scene_update_post 861 | else: # 2.8+ 862 | handlers = bpy.app.handlers.depsgraph_update_post 863 | in_handles = updater_run_success_popup_handler in handlers 864 | 865 | if in_handles or ran_update_success_popup: 866 | return 867 | 868 | if "scene_update_post" in dir(bpy.app.handlers): # 2.7x 869 | bpy.app.handlers.scene_update_post.append( 870 | updater_run_success_popup_handler) 871 | else: # 2.8+ 872 | bpy.app.handlers.depsgraph_update_post.append( 873 | updater_run_success_popup_handler) 874 | ran_update_success_popup = True 875 | 876 | 877 | # ----------------------------------------------------------------------------- 878 | # Example UI integrations 879 | # ----------------------------------------------------------------------------- 880 | def update_notice_box_ui(self, context): 881 | """Update notice draw, to add to the end or beginning of a panel. 882 | 883 | After a check for update has occurred, this function will draw a box 884 | saying an update is ready, and give a button for: update now, open website, 885 | or ignore popup. Ideal to be placed at the end / beginning of a panel. 886 | """ 887 | 888 | if updater.invalid_updater: 889 | return 890 | 891 | saved_state = updater.json 892 | if not updater.auto_reload_post_update: 893 | if "just_updated" in saved_state and saved_state["just_updated"]: 894 | layout = self.layout 895 | box = layout.box() 896 | col = box.column() 897 | alert_row = col.row() 898 | alert_row.alert = True 899 | alert_row.operator( 900 | "wm.quit_blender", 901 | text="Restart blender", 902 | icon="ERROR") 903 | col.label(text="to complete update") 904 | return 905 | 906 | # If user pressed ignore, don't draw the box. 907 | if "ignore" in updater.json and updater.json["ignore"]: 908 | return 909 | if not updater.update_ready: 910 | return 911 | 912 | layout = self.layout 913 | box = layout.box() 914 | col = box.column(align=True) 915 | col.alert = True 916 | col.label(text="Update ready!", icon="ERROR") 917 | col.alert = False 918 | col.separator() 919 | row = col.row(align=True) 920 | split = row.split(align=True) 921 | colL = split.column(align=True) 922 | colL.scale_y = 1.5 923 | colL.operator(AddonUpdaterIgnore.bl_idname, icon="X", text="Ignore") 924 | colR = split.column(align=True) 925 | colR.scale_y = 1.5 926 | if not updater.manual_only: 927 | colR.operator(AddonUpdaterUpdateNow.bl_idname, 928 | text="Update", icon="LOOP_FORWARDS") 929 | col.operator("wm.url_open", text="Open website").url = updater.website 930 | # ops = col.operator("wm.url_open",text="Direct download") 931 | # ops.url=updater.update_link 932 | col.operator(AddonUpdaterInstallManually.bl_idname, 933 | text="Install manually") 934 | else: 935 | # ops = col.operator("wm.url_open", text="Direct download") 936 | # ops.url=updater.update_link 937 | col.operator("wm.url_open", text="Get it now").url = updater.website 938 | 939 | 940 | def update_settings_ui(self, context, element=None): 941 | """Preferences - for drawing with full width inside user preferences 942 | 943 | A function that can be run inside user preferences panel for prefs UI. 944 | Place inside UI draw using: 945 | addon_updater_ops.update_settings_ui(self, context) 946 | or by: 947 | addon_updater_ops.update_settings_ui(context) 948 | """ 949 | 950 | # Element is a UI element, such as layout, a row, column, or box. 951 | if element is None: 952 | element = self.layout 953 | box = element.box() 954 | 955 | # In case of error importing updater. 956 | if updater.invalid_updater: 957 | box.label(text="Error initializing updater code:") 958 | box.label(text=updater.error_msg) 959 | return 960 | settings = get_user_preferences(context) 961 | if not settings: 962 | box.label(text="Error getting updater preferences", icon='ERROR') 963 | return 964 | 965 | # auto-update settings 966 | box.label(text="Updater Settings") 967 | row = box.row() 968 | 969 | # special case to tell user to restart blender, if set that way 970 | if not updater.auto_reload_post_update: 971 | saved_state = updater.json 972 | if "just_updated" in saved_state and saved_state["just_updated"]: 973 | row.alert = True 974 | row.operator("wm.quit_blender", 975 | text="Restart blender to complete update", 976 | icon="ERROR") 977 | return 978 | 979 | split = layout_split(row, factor=0.4) 980 | sub_col = split.column() 981 | sub_col.prop(settings, "auto_check_update") 982 | sub_col = split.column() 983 | 984 | if not settings.auto_check_update: 985 | sub_col.enabled = False 986 | sub_row = sub_col.row() 987 | sub_row.label(text="Interval between checks") 988 | sub_row = sub_col.row(align=True) 989 | check_col = sub_row.column(align=True) 990 | check_col.prop(settings, "updater_interval_months") 991 | check_col = sub_row.column(align=True) 992 | check_col.prop(settings, "updater_interval_days") 993 | check_col = sub_row.column(align=True) 994 | 995 | # Consider un-commenting for local dev (e.g. to set shorter intervals) 996 | # check_col.prop(settings,"updater_interval_hours") 997 | # check_col = sub_row.column(align=True) 998 | # check_col.prop(settings,"updater_interval_minutes") 999 | 1000 | # Checking / managing updates. 1001 | row = box.row() 1002 | col = row.column() 1003 | if updater.error is not None: 1004 | sub_col = col.row(align=True) 1005 | sub_col.scale_y = 1 1006 | split = sub_col.split(align=True) 1007 | split.scale_y = 2 1008 | if "ssl" in updater.error_msg.lower(): 1009 | split.enabled = True 1010 | split.operator(AddonUpdaterInstallManually.bl_idname, 1011 | text=updater.error) 1012 | else: 1013 | split.enabled = False 1014 | split.operator(AddonUpdaterCheckNow.bl_idname, 1015 | text=updater.error) 1016 | split = sub_col.split(align=True) 1017 | split.scale_y = 2 1018 | split.operator(AddonUpdaterCheckNow.bl_idname, 1019 | text="", icon="FILE_REFRESH") 1020 | 1021 | elif updater.update_ready is None and not updater.async_checking: 1022 | col.scale_y = 2 1023 | col.operator(AddonUpdaterCheckNow.bl_idname) 1024 | elif updater.update_ready is None: # async is running 1025 | sub_col = col.row(align=True) 1026 | sub_col.scale_y = 1 1027 | split = sub_col.split(align=True) 1028 | split.enabled = False 1029 | split.scale_y = 2 1030 | split.operator(AddonUpdaterCheckNow.bl_idname, text="Checking...") 1031 | split = sub_col.split(align=True) 1032 | split.scale_y = 2 1033 | split.operator(AddonUpdaterEndBackground.bl_idname, text="", icon="X") 1034 | 1035 | elif updater.include_branches and \ 1036 | len(updater.tags) == len(updater.include_branch_list) and not \ 1037 | updater.manual_only: 1038 | # No releases found, but still show the appropriate branch. 1039 | sub_col = col.row(align=True) 1040 | sub_col.scale_y = 1 1041 | split = sub_col.split(align=True) 1042 | split.scale_y = 2 1043 | update_now_txt = "Update directly to {}".format( 1044 | updater.include_branch_list[0]) 1045 | split.operator(AddonUpdaterUpdateNow.bl_idname, text=update_now_txt) 1046 | split = sub_col.split(align=True) 1047 | split.scale_y = 2 1048 | split.operator(AddonUpdaterCheckNow.bl_idname, 1049 | text="", icon="FILE_REFRESH") 1050 | 1051 | elif updater.update_ready and not updater.manual_only: 1052 | sub_col = col.row(align=True) 1053 | sub_col.scale_y = 1 1054 | split = sub_col.split(align=True) 1055 | split.scale_y = 2 1056 | split.operator(AddonUpdaterUpdateNow.bl_idname, 1057 | text="Update now to " + str(updater.update_version)) 1058 | split = sub_col.split(align=True) 1059 | split.scale_y = 2 1060 | split.operator(AddonUpdaterCheckNow.bl_idname, 1061 | text="", icon="FILE_REFRESH") 1062 | 1063 | elif updater.update_ready and updater.manual_only: 1064 | col.scale_y = 2 1065 | dl_now_txt = "Download " + str(updater.update_version) 1066 | col.operator("wm.url_open", 1067 | text=dl_now_txt).url = updater.website 1068 | else: # i.e. that updater.update_ready == False. 1069 | sub_col = col.row(align=True) 1070 | sub_col.scale_y = 1 1071 | split = sub_col.split(align=True) 1072 | split.enabled = False 1073 | split.scale_y = 2 1074 | split.operator(AddonUpdaterCheckNow.bl_idname, 1075 | text="Addon is up to date") 1076 | split = sub_col.split(align=True) 1077 | split.scale_y = 2 1078 | split.operator(AddonUpdaterCheckNow.bl_idname, 1079 | text="", icon="FILE_REFRESH") 1080 | 1081 | if not updater.manual_only: 1082 | col = row.column(align=True) 1083 | if updater.include_branches and len(updater.include_branch_list) > 0: 1084 | branch = updater.include_branch_list[0] 1085 | col.operator(AddonUpdaterUpdateTarget.bl_idname, 1086 | text="Install {} / old version".format(branch)) 1087 | else: 1088 | col.operator(AddonUpdaterUpdateTarget.bl_idname, 1089 | text="(Re)install addon version") 1090 | last_date = "none found" 1091 | backup_path = os.path.join(updater.stage_path, "backup") 1092 | if "backup_date" in updater.json and os.path.isdir(backup_path): 1093 | if updater.json["backup_date"] == "": 1094 | last_date = "Date not found" 1095 | else: 1096 | last_date = updater.json["backup_date"] 1097 | backup_text = "Restore addon backup ({})".format(last_date) 1098 | col.operator(AddonUpdaterRestoreBackup.bl_idname, text=backup_text) 1099 | 1100 | row = box.row() 1101 | row.scale_y = 0.7 1102 | last_check = updater.json["last_check"] 1103 | if updater.error is not None and updater.error_msg is not None: 1104 | row.label(text=updater.error_msg) 1105 | elif last_check: 1106 | last_check = last_check[0: last_check.index(".")] 1107 | row.label(text="Last update check: " + last_check) 1108 | else: 1109 | row.label(text="Last update check: Never") 1110 | 1111 | 1112 | def update_settings_ui_condensed(self, context, element=None): 1113 | """Preferences - Condensed drawing within preferences. 1114 | 1115 | Alternate draw for user preferences or other places, does not draw a box. 1116 | """ 1117 | 1118 | # Element is a UI element, such as layout, a row, column, or box. 1119 | if element is None: 1120 | element = self.layout 1121 | row = element.row() 1122 | 1123 | # In case of error importing updater. 1124 | if updater.invalid_updater: 1125 | row.label(text="Error initializing updater code:") 1126 | row.label(text=updater.error_msg) 1127 | return 1128 | settings = get_user_preferences(context) 1129 | if not settings: 1130 | row.label(text="Error getting updater preferences", icon='ERROR') 1131 | return 1132 | 1133 | # Special case to tell user to restart blender, if set that way. 1134 | if not updater.auto_reload_post_update: 1135 | saved_state = updater.json 1136 | if "just_updated" in saved_state and saved_state["just_updated"]: 1137 | row.alert = True # mark red 1138 | row.operator( 1139 | "wm.quit_blender", 1140 | text="Restart blender to complete update", 1141 | icon="ERROR") 1142 | return 1143 | 1144 | col = row.column() 1145 | if updater.error is not None: 1146 | sub_col = col.row(align=True) 1147 | sub_col.scale_y = 1 1148 | split = sub_col.split(align=True) 1149 | split.scale_y = 2 1150 | if "ssl" in updater.error_msg.lower(): 1151 | split.enabled = True 1152 | split.operator(AddonUpdaterInstallManually.bl_idname, 1153 | text=updater.error) 1154 | else: 1155 | split.enabled = False 1156 | split.operator(AddonUpdaterCheckNow.bl_idname, 1157 | text=updater.error) 1158 | split = sub_col.split(align=True) 1159 | split.scale_y = 2 1160 | split.operator(AddonUpdaterCheckNow.bl_idname, 1161 | text="", icon="FILE_REFRESH") 1162 | 1163 | elif updater.update_ready is None and not updater.async_checking: 1164 | col.scale_y = 2 1165 | col.operator(AddonUpdaterCheckNow.bl_idname) 1166 | elif updater.update_ready is None: # Async is running. 1167 | sub_col = col.row(align=True) 1168 | sub_col.scale_y = 1 1169 | split = sub_col.split(align=True) 1170 | split.enabled = False 1171 | split.scale_y = 2 1172 | split.operator(AddonUpdaterCheckNow.bl_idname, text="Checking...") 1173 | split = sub_col.split(align=True) 1174 | split.scale_y = 2 1175 | split.operator(AddonUpdaterEndBackground.bl_idname, text="", icon="X") 1176 | 1177 | elif updater.include_branches and \ 1178 | len(updater.tags) == len(updater.include_branch_list) and not \ 1179 | updater.manual_only: 1180 | # No releases found, but still show the appropriate branch. 1181 | sub_col = col.row(align=True) 1182 | sub_col.scale_y = 1 1183 | split = sub_col.split(align=True) 1184 | split.scale_y = 2 1185 | now_txt = "Update directly to " + str(updater.include_branch_list[0]) 1186 | split.operator(AddonUpdaterUpdateNow.bl_idname, text=now_txt) 1187 | split = sub_col.split(align=True) 1188 | split.scale_y = 2 1189 | split.operator(AddonUpdaterCheckNow.bl_idname, 1190 | text="", icon="FILE_REFRESH") 1191 | 1192 | elif updater.update_ready and not updater.manual_only: 1193 | sub_col = col.row(align=True) 1194 | sub_col.scale_y = 1 1195 | split = sub_col.split(align=True) 1196 | split.scale_y = 2 1197 | split.operator(AddonUpdaterUpdateNow.bl_idname, 1198 | text="Update now to " + str(updater.update_version)) 1199 | split = sub_col.split(align=True) 1200 | split.scale_y = 2 1201 | split.operator(AddonUpdaterCheckNow.bl_idname, 1202 | text="", icon="FILE_REFRESH") 1203 | 1204 | elif updater.update_ready and updater.manual_only: 1205 | col.scale_y = 2 1206 | dl_txt = "Download " + str(updater.update_version) 1207 | col.operator("wm.url_open", text=dl_txt).url = updater.website 1208 | else: # i.e. that updater.update_ready == False. 1209 | sub_col = col.row(align=True) 1210 | sub_col.scale_y = 1 1211 | split = sub_col.split(align=True) 1212 | split.enabled = False 1213 | split.scale_y = 2 1214 | split.operator(AddonUpdaterCheckNow.bl_idname, 1215 | text="Addon is up to date") 1216 | split = sub_col.split(align=True) 1217 | split.scale_y = 2 1218 | split.operator(AddonUpdaterCheckNow.bl_idname, 1219 | text="", icon="FILE_REFRESH") 1220 | 1221 | row = element.row() 1222 | row.prop(settings, "auto_check_update") 1223 | 1224 | row = element.row() 1225 | row.scale_y = 0.7 1226 | last_check = updater.json["last_check"] 1227 | if updater.error is not None and updater.error_msg is not None: 1228 | row.label(text=updater.error_msg) 1229 | elif last_check != "" and last_check is not None: 1230 | last_check = last_check[0: last_check.index(".")] 1231 | row.label(text="Last check: " + last_check) 1232 | else: 1233 | row.label(text="Last check: Never") 1234 | 1235 | 1236 | def skip_tag_function(self, tag): 1237 | """A global function for tag skipping. 1238 | 1239 | A way to filter which tags are displayed, e.g. to limit downgrading too 1240 | long ago. 1241 | 1242 | Args: 1243 | self: The instance of the singleton addon update. 1244 | tag: the text content of a tag from the repo, e.g. "v1.2.3". 1245 | 1246 | Returns: 1247 | bool: True to skip this tag name (ie don't allow for downloading this 1248 | version), or False if the tag is allowed. 1249 | """ 1250 | 1251 | # In case of error importing updater. 1252 | if self.invalid_updater: 1253 | return False 1254 | 1255 | # ---- write any custom code here, return true to disallow version ---- # 1256 | # 1257 | # # Filter out e.g. if 'beta' is in name of release 1258 | # if 'beta' in tag.lower(): 1259 | # return True 1260 | # ---- write any custom code above, return true to disallow version --- # 1261 | 1262 | if self.include_branches: 1263 | for branch in self.include_branch_list: 1264 | if tag["name"].lower() == branch: 1265 | return False 1266 | 1267 | # Function converting string to tuple, ignoring e.g. leading 'v'. 1268 | # Be aware that this strips out other text that you might otherwise 1269 | # want to be kept and accounted for when checking tags (e.g. v1.1a vs 1.1b) 1270 | tupled = self.version_tuple_from_text(tag["name"]) 1271 | if not isinstance(tupled, tuple): 1272 | return True 1273 | 1274 | # Select the min tag version - change tuple accordingly. 1275 | if self.version_min_update is not None: 1276 | if tupled < self.version_min_update: 1277 | return True # Skip if current version below this. 1278 | 1279 | # Select the max tag version. 1280 | if self.version_max_update is not None: 1281 | if tupled >= self.version_max_update: 1282 | return True # Skip if current version at or above this. 1283 | 1284 | # In all other cases, allow showing the tag for updating/reverting. 1285 | # To simply and always show all tags, this return False could be moved 1286 | # to the start of the function definition so all tags are allowed. 1287 | return False 1288 | 1289 | 1290 | def select_link_function(self, tag): 1291 | """Only customize if trying to leverage "attachments" in *GitHub* releases. 1292 | 1293 | A way to select from one or multiple attached downloadable files from the 1294 | server, instead of downloading the default release/tag source code. 1295 | """ 1296 | 1297 | # -- Default, universal case (and is the only option for GitLab/Bitbucket) 1298 | link = tag["zipball_url"] 1299 | 1300 | # -- Example: select the first (or only) asset instead source code -- 1301 | # if "assets" in tag and "browser_download_url" in tag["assets"][0]: 1302 | # link = tag["assets"][0]["browser_download_url"] 1303 | 1304 | # -- Example: select asset based on OS, where multiple builds exist -- 1305 | # # not tested/no error checking, modify to fit your own needs! 1306 | # # assume each release has three attached builds: 1307 | # # release_windows.zip, release_OSX.zip, release_linux.zip 1308 | # # This also would logically not be used with "branches" enabled 1309 | # if platform.system() == "Darwin": # ie OSX 1310 | # link = [asset for asset in tag["assets"] if 'OSX' in asset][0] 1311 | # elif platform.system() == "Windows": 1312 | # link = [asset for asset in tag["assets"] if 'windows' in asset][0] 1313 | # elif platform.system() == "Linux": 1314 | # link = [asset for asset in tag["assets"] if 'linux' in asset][0] 1315 | 1316 | return link 1317 | 1318 | 1319 | # ----------------------------------------------------------------------------- 1320 | # Register, should be run in the register module itself 1321 | # ----------------------------------------------------------------------------- 1322 | classes = ( 1323 | AddonUpdaterInstallPopup, 1324 | AddonUpdaterCheckNow, 1325 | AddonUpdaterUpdateNow, 1326 | AddonUpdaterUpdateTarget, 1327 | AddonUpdaterInstallManually, 1328 | AddonUpdaterUpdatedSuccessful, 1329 | AddonUpdaterRestoreBackup, 1330 | AddonUpdaterIgnore, 1331 | AddonUpdaterEndBackground 1332 | ) 1333 | 1334 | 1335 | def register(bl_info): 1336 | """Registering the operators in this module""" 1337 | # Safer failure in case of issue loading module. 1338 | if updater.error: 1339 | print("Exiting updater registration, " + updater.error) 1340 | return 1341 | updater.clear_state() # Clear internal vars, avoids reloading oddities. 1342 | 1343 | # Confirm your updater "engine" (Github is default if not specified). 1344 | updater.engine = "Github" 1345 | # updater.engine = "GitLab" 1346 | # updater.engine = "Bitbucket" 1347 | 1348 | # If using private repository, indicate the token here. 1349 | # Must be set after assigning the engine. 1350 | # **WARNING** Depending on the engine, this token can act like a password!! 1351 | # Only provide a token if the project is *non-public*, see readme for 1352 | # other considerations and suggestions from a security standpoint. 1353 | updater.private_token = None # "tokenstring" 1354 | 1355 | # Choose your own username, must match website (not needed for GitLab). 1356 | updater.user = "Zode" 1357 | 1358 | # Choose your own repository, must match git name for GitHUb and Bitbucket, 1359 | # for GitLab use project ID (numbers only). 1360 | updater.repo = "blenderutils" 1361 | 1362 | # updater.addon = # define at top of module, MUST be done first 1363 | 1364 | # Website for manual addon download, optional but recommended to set. 1365 | updater.website = "https://github.com/Zode/blenderutils/" 1366 | 1367 | # Addon subfolder path. 1368 | # "sample/path/to/addon" 1369 | # default is "" or None, meaning root 1370 | updater.subfolder_path = "" 1371 | 1372 | # Used to check/compare versions. 1373 | updater.current_version = bl_info["version"] 1374 | 1375 | # Optional, to hard-set update frequency, use this here - however, this 1376 | # demo has this set via UI properties. 1377 | # updater.set_check_interval(enabled=False, months=0, days=0, hours=0, minutes=2) 1378 | 1379 | # Optional, consider turning off for production or allow as an option 1380 | # This will print out additional debugging info to the console 1381 | updater.verbose = True # make False for production default 1382 | 1383 | # Optional, customize where the addon updater processing subfolder is, 1384 | # essentially a staging folder used by the updater on its own 1385 | # Needs to be within the same folder as the addon itself 1386 | # Need to supply a full, absolute path to folder 1387 | # updater.updater_path = # set path of updater folder, by default: 1388 | # /addons/{__package__}/{__package__}_updater 1389 | 1390 | # Auto create a backup of the addon when installing other versions. 1391 | updater.backup_current = True # True by default 1392 | 1393 | # Sample ignore patterns for when creating backup of current during update. 1394 | updater.backup_ignore_patterns = ["__pycache__"] 1395 | # Alternate example patterns: 1396 | # updater.backup_ignore_patterns = [".git", "__pycache__", "*.bat", ".gitignore", "*.exe"] 1397 | 1398 | # Patterns for files to actively overwrite if found in new update file and 1399 | # are also found in the currently installed addon. Note that by default 1400 | # (ie if set to []), updates are installed in the same way as blender: 1401 | # .py files are replaced, but other file types (e.g. json, txt, blend) 1402 | # will NOT be overwritten if already present in current install. Thus 1403 | # if you want to automatically update resources/non py files, add them as a 1404 | # part of the pattern list below so they will always be overwritten by an 1405 | # update. If a pattern file is not found in new update, no action is taken 1406 | # NOTE: This does NOT delete anything proactively, rather only defines what 1407 | # is allowed to be overwritten during an update execution. 1408 | updater.overwrite_patterns = ["*.png", "*.jpg", "README.md", "LICENSE.txt"] 1409 | # updater.overwrite_patterns = [] 1410 | # other examples: 1411 | # ["*"] means ALL files/folders will be overwritten by update, was the 1412 | # behavior pre updater v1.0.4. 1413 | # [] or ["*.py","*.pyc"] matches default blender behavior, ie same effect 1414 | # if user installs update manually without deleting the existing addon 1415 | # first e.g. if existing install and update both have a resource.blend 1416 | # file, the existing installed one will remain. 1417 | # ["some.py"] means if some.py is found in addon update, it will overwrite 1418 | # any existing some.py in current addon install, if any. 1419 | # ["*.json"] means all json files found in addon update will overwrite 1420 | # those of same name in current install. 1421 | # ["*.png","README.md","LICENSE.txt"] means the readme, license, and all 1422 | # pngs will be overwritten by update. 1423 | 1424 | # Patterns for files to actively remove prior to running update. 1425 | # Useful if wanting to remove old code due to changes in filenames 1426 | # that otherwise would accumulate. Note: this runs after taking 1427 | # a backup (if enabled) but before placing in new update. If the same 1428 | # file name removed exists in the update, then it acts as if pattern 1429 | # is placed in the overwrite_patterns property. Note this is effectively 1430 | # ignored if clean=True in the run_update method. 1431 | updater.remove_pre_update_patterns = ["*.py", "*.pyc"] 1432 | # Note setting ["*"] here is equivalent to always running updates with 1433 | # clean = True in the run_update method, ie the equivalent of a fresh, 1434 | # new install. This would also delete any resources or user-made/modified 1435 | # files setting ["__pycache__"] ensures the pycache folder always removed. 1436 | # The configuration of ["*.py", "*.pyc"] is a safe option as this 1437 | # will ensure no old python files/caches remain in event different addon 1438 | # versions have different filenames or structures. 1439 | 1440 | # Allow branches like 'master' as an option to update to, regardless 1441 | # of release or version. 1442 | # Default behavior: releases will still be used for auto check (popup), 1443 | # but the user has the option from user preferences to directly 1444 | # update to the master branch or any other branches specified using 1445 | # the "install {branch}/older version" operator. 1446 | updater.include_branches = True 1447 | 1448 | # (GitHub only) This options allows using "releases" instead of "tags", 1449 | # which enables pulling down release logs/notes, as well as installs update 1450 | # from release-attached zips (instead of the auto-packaged code generated 1451 | # with a release/tag). Setting has no impact on BitBucket or GitLab repos. 1452 | updater.use_releases = False 1453 | # Note: Releases always have a tag, but a tag may not always be a release. 1454 | # Therefore, setting True above will filter out any non-annotated tags. 1455 | # Note 2: Using this option will also display (and filter by) the release 1456 | # name instead of the tag name, bear this in mind given the 1457 | # skip_tag_function filtering above. 1458 | 1459 | # Populate if using "include_branches" option above. 1460 | # Note: updater.include_branch_list defaults to ['master'] branch if set to 1461 | # none. Example targeting another multiple branches allowed to pull from: 1462 | # updater.include_branch_list = ['master', 'dev'] 1463 | updater.include_branch_list = None # None is the equivalent = ['master'] 1464 | 1465 | # Only allow manual install, thus prompting the user to open 1466 | # the addon's web page to download, specifically: updater.website 1467 | # Useful if only wanting to get notification of updates but not 1468 | # directly install. 1469 | updater.manual_only = False 1470 | 1471 | # Used for development only, "pretend" to install an update to test 1472 | # reloading conditions. 1473 | updater.fake_install = False # Set to true to test callback/reloading. 1474 | 1475 | # Show popups, ie if auto-check for update is enabled or a previous 1476 | # check for update in user preferences found a new version, show a popup 1477 | # (at most once per blender session, and it provides an option to ignore 1478 | # for future sessions); default behavior is set to True. 1479 | updater.show_popups = True 1480 | # note: if set to false, there will still be an "update ready" box drawn 1481 | # using the `update_notice_box_ui` panel function. 1482 | 1483 | # Override with a custom function on what tags 1484 | # to skip showing for updater; see code for function above. 1485 | # Set the min and max versions allowed to install. 1486 | # Optional, default None 1487 | # min install (>=) will install this and higher 1488 | updater.version_min_update = (0, 0, 0) 1489 | # updater.version_min_update = None # None or default for no minimum. 1490 | 1491 | # Max install (<) will install strictly anything lower than this version 1492 | # number, useful to limit the max version a given user can install (e.g. 1493 | # if support for a future version of blender is going away, and you don't 1494 | # want users to be prompted to install a non-functioning addon) 1495 | # updater.version_max_update = (9,9,9) 1496 | updater.version_max_update = None # None or default for no max. 1497 | 1498 | # Function defined above, customize as appropriate per repository 1499 | updater.skip_tag = skip_tag_function # min and max used in this function 1500 | 1501 | # Function defined above, optionally customize as needed per repository. 1502 | updater.select_link = select_link_function 1503 | 1504 | # Recommended false to encourage blender restarts on update completion 1505 | # Setting this option to True is NOT as stable as false (could cause 1506 | # blender crashes). 1507 | updater.auto_reload_post_update = False 1508 | 1509 | # The register line items for all operators/panels. 1510 | # If using bpy.utils.register_module(__name__) to register elsewhere 1511 | # in the addon, delete these lines (also from unregister). 1512 | for cls in classes: 1513 | # Apply annotations to remove Blender 2.8+ warnings, no effect on 2.7 1514 | make_annotations(cls) 1515 | # Comment out this line if using bpy.utils.register_module(__name__) 1516 | bpy.utils.register_class(cls) 1517 | 1518 | # Special situation: we just updated the addon, show a popup to tell the 1519 | # user it worked. Could enclosed in try/catch in case other issues arise. 1520 | show_reload_popup() 1521 | 1522 | 1523 | def unregister(): 1524 | for cls in reversed(classes): 1525 | # Comment out this line if using bpy.utils.unregister_module(__name__). 1526 | bpy.utils.unregister_class(cls) 1527 | 1528 | # Clear global vars since they may persist if not restarting blender. 1529 | updater.clear_state() # Clear internal vars, avoids reloading oddities. 1530 | 1531 | global ran_auto_check_install_popup 1532 | ran_auto_check_install_popup = False 1533 | 1534 | global ran_update_success_popup 1535 | ran_update_success_popup = False 1536 | 1537 | global ran_background_check 1538 | ran_background_check = False 1539 | -------------------------------------------------------------------------------- /bake_gui.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from . import bake_operators 3 | from . import addon_updater_ops 4 | 5 | class BakeSettings(bpy.types.PropertyGroup): 6 | SimpleMode : bpy.props.BoolProperty( 7 | name="Simple mode", 8 | description="Simple mode", 9 | default=True 10 | ) 11 | 12 | SaveToFile : bpy.props.BoolProperty( 13 | name="Save to file", 14 | description="Save bake(s) to png files, relative to .blend", 15 | default=False 16 | ) 17 | 18 | UseCage : bpy.props.BoolProperty( 19 | name="Use cage", 20 | description="Cast rays to active object from cage", 21 | default=False 22 | ) 23 | 24 | CageObject : bpy.props.PointerProperty( 25 | name="Cage object", 26 | description="Cage object to use instead of calculating the cage from the active object with cage extrusion", 27 | type=bpy.types.Object 28 | ) 29 | 30 | LowpolyObject : bpy.props.PointerProperty( 31 | name="Lowpoly object", 32 | description="Target object to bake to", 33 | type=bpy.types.Object 34 | ) 35 | 36 | def TargetMaterialItems(self, context): 37 | items = [] 38 | for index, mat in enumerate(context.scene.zodeutils_bake.LowpolyObject.material_slots): 39 | items.append((mat.name, mat.name, "")) 40 | 41 | return items 42 | 43 | TargetMaterial : bpy.props.EnumProperty( 44 | name="Target material", 45 | description="Target material in lowpoly object to bake to", 46 | items=TargetMaterialItems 47 | ) 48 | 49 | HighpolyObject : bpy.props.PointerProperty( 50 | name="Highpoly object", 51 | description="Target object to bake from", 52 | type=bpy.types.Object 53 | ) 54 | 55 | TargetWidth : bpy.props.IntProperty( 56 | name="Width", 57 | description="Bake texture width", 58 | default=1024, 59 | min=1 60 | ) 61 | 62 | TargetHeight : bpy.props.IntProperty( 63 | name="Height", 64 | description="Bake texture Height", 65 | default=1024, 66 | min=1 67 | ) 68 | 69 | RayDistance : bpy.props.FloatProperty( 70 | name="Ray distance", 71 | description="Distance to use for the inward ray cast", 72 | default=0.1, 73 | min=0.0, 74 | max=1.0 75 | ) 76 | 77 | BakeAO : bpy.props.BoolProperty( 78 | name="Bake AO", 79 | description="Enable or Disable ambient occlusion baking", 80 | default=False 81 | ) 82 | 83 | BakeShadow : bpy.props.BoolProperty( 84 | name="Bake shadow", 85 | description="Enable or Disable shadow baking", 86 | default=False 87 | ) 88 | 89 | BakeNormal : bpy.props.BoolProperty( 90 | name="Bake normals", 91 | description="Enable or Disable normal baking", 92 | default=True 93 | ) 94 | 95 | NormalSpace : bpy.props.EnumProperty( 96 | name="Space", 97 | description="Space to bake in", 98 | items=( 99 | ("OBJECT", "Object", "Object space"), 100 | ("TANGENT", "Tangent", "Tangent space"), 101 | ), 102 | default="TANGENT" 103 | ) 104 | 105 | NormalR : bpy.props.EnumProperty( 106 | name="R", 107 | description="Axis to bake red channel in", 108 | items=( 109 | ("POS_X", "+X", ""), 110 | ("POS_Y", "+Y", ""), 111 | ("POS_Z", "+Z", ""), 112 | ("NEG_X", "-X", ""), 113 | ("NEG_Y", "-Y", ""), 114 | ("NEG_Z", "-Z", ""), 115 | ), 116 | default="POS_X" 117 | ) 118 | 119 | NormalG : bpy.props.EnumProperty( 120 | name="G", 121 | description="Axis to bake green channel in", 122 | items=( 123 | ("POS_X", "+X", ""), 124 | ("POS_Y", "+Y", ""), 125 | ("POS_Z", "+Z", ""), 126 | ("NEG_X", "-X", ""), 127 | ("NEG_Y", "-Y", ""), 128 | ("NEG_Z", "-Z", ""), 129 | ), 130 | default="POS_Y" 131 | ) 132 | 133 | NormalB : bpy.props.EnumProperty( 134 | name="B", 135 | description="Axis to bake blue channel in", 136 | items=( 137 | ("POS_X", "+X", ""), 138 | ("POS_Y", "+Y", ""), 139 | ("POS_Z", "+Z", ""), 140 | ("NEG_X", "-X", ""), 141 | ("NEG_Y", "-Y", ""), 142 | ("NEG_Z", "-Z", ""), 143 | ), 144 | default="POS_Z" 145 | ) 146 | 147 | BakeUV : bpy.props.BoolProperty( 148 | name="Bake UV", 149 | description="Enable or Disable UV baking", 150 | default=False 151 | ) 152 | 153 | BakeRoughness : bpy.props.BoolProperty( 154 | name="Bake roughness", 155 | description="Enable or Disable roughness baking", 156 | default=False 157 | ) 158 | 159 | BakeEmit : bpy.props.BoolProperty( 160 | name="Bake emit", 161 | description="Enable or Disable emit baking", 162 | default=False 163 | ) 164 | 165 | BakeEnvironment : bpy.props.BoolProperty( 166 | name="Bake environment", 167 | description="Enable or Disable environment baking", 168 | default=False 169 | ) 170 | 171 | BakeDiffuse : bpy.props.BoolProperty( 172 | name="Bake diffuse", 173 | description="Enable or Disable diffuse baking", 174 | default=False 175 | ) 176 | 177 | DiffuseFlags : bpy.props.EnumProperty( 178 | name="Influence", 179 | description="Set influences", 180 | items=( 181 | ("DIRECT", "Direct", "Add direct lighting contribution"), 182 | ("INDIRECT", "Inirect", "Add indirect lighting contribution"), 183 | ("COLOR", "Color", "Color the pass") 184 | ), 185 | options = {"ENUM_FLAG"}, 186 | default={"DIRECT", "INDIRECT", "COLOR"} 187 | ) 188 | 189 | BakeGlossy : bpy.props.BoolProperty( 190 | name="Bake glossy", 191 | description="Enable or Disable glossy baking", 192 | default=False 193 | ) 194 | 195 | GlossyFlags : bpy.props.EnumProperty( 196 | name="Influence", 197 | description="Set influences", 198 | items=( 199 | ("DIRECT", "Direct", "Add direct lighting contribution"), 200 | ("INDIRECT", "Inirect", "Add indirect lighting contribution"), 201 | ("COLOR", "Color", "Color the pass") 202 | ), 203 | options = {"ENUM_FLAG"}, 204 | default={"DIRECT", "INDIRECT", "COLOR"} 205 | ) 206 | 207 | BakeTransmission : bpy.props.BoolProperty( 208 | name="Bake transmission", 209 | description="Enable or Disable transmission baking", 210 | default=False 211 | ) 212 | 213 | TransmissionFlags : bpy.props.EnumProperty( 214 | name="Influence", 215 | description="Set influences", 216 | items=( 217 | ("DIRECT", "Direct", "Add direct lighting contribution"), 218 | ("INDIRECT", "Inirect", "Add indirect lighting contribution"), 219 | ("COLOR", "Color", "Color the pass") 220 | ), 221 | options = {"ENUM_FLAG"}, 222 | default={"DIRECT", "INDIRECT", "COLOR"} 223 | ) 224 | 225 | BakeSubsurface : bpy.props.BoolProperty( 226 | name="Bake subsurface", 227 | description="Enable or Disable subsurface baking", 228 | default=False 229 | ) 230 | 231 | SubsurfaceFlags : bpy.props.EnumProperty( 232 | name="Influence", 233 | description="Set influences", 234 | items=( 235 | ("DIRECT", "Direct", "Add direct lighting contribution"), 236 | ("INDIRECT", "Inirect", "Add indirect lighting contribution"), 237 | ("COLOR", "Color", "Color the pass") 238 | ), 239 | options = {"ENUM_FLAG"}, 240 | default={"DIRECT", "INDIRECT", "COLOR"} 241 | ) 242 | 243 | BakeCombined : bpy.props.BoolProperty( 244 | name="Bake combined", 245 | description="Enable or Disable combined baking", 246 | default=False 247 | ) 248 | 249 | CombinedFlags : bpy.props.EnumProperty( 250 | name="Influence", 251 | description="Set influences", 252 | items=( 253 | ("DIRECT", "Direct", "Add direct lighting contribution"), 254 | ("INDIRECT", "Inirect", "Add indirect lighting contribution") 255 | ), 256 | options = {"ENUM_FLAG"}, 257 | default={"DIRECT", "INDIRECT"} 258 | ) 259 | 260 | CombinedFilter : bpy.props.EnumProperty( 261 | name="Influence", 262 | description="Set influences", 263 | items=( 264 | ("DIFFUSE", "Diffuse", "Add diffuse contribution"), 265 | ("GLOSSY", "Glossy", "Add glossy contribution"), 266 | ("TRANSMISSION", "Transmission", "Add transmission contribution"), 267 | ("SUBSURFACE", "Subsurface", "Add subsurface contribution"), 268 | ("AO", "Ambient Occlusion", "Add ambient occlusion contribution"), 269 | ("EMIT", "Emit", "Add emission contribution"), 270 | ), 271 | options = {"ENUM_FLAG"}, 272 | default={"DIFFUSE", "GLOSSY", "TRANSMISSION", "SUBSURFACE", "AO", "EMIT"} 273 | ) 274 | 275 | class ZODEUTILS_BAKE(bpy.types.Panel): 276 | bl_label="Zode's bakery" 277 | bl_idname="OBJECT_PT_ZODEUTILS_BAKE" 278 | bl_category="Bake" 279 | bl_space_type = "VIEW_3D" 280 | bl_region_type = "UI" 281 | 282 | def draw(self, context): 283 | self.layout.prop(context.scene.zodeutils_bake, "SimpleMode") 284 | box = self.layout.box() 285 | box.label(text="Bake settings:") 286 | box.prop(context.scene.zodeutils_bake, "HighpolyObject") 287 | box.prop(context.scene.zodeutils_bake, "LowpolyObject") 288 | box.prop(context.scene.zodeutils_bake, "TargetMaterial") 289 | row = box.row() 290 | row.prop(context.scene.zodeutils_bake, "TargetWidth") 291 | row.prop(context.scene.zodeutils_bake, "TargetHeight") 292 | row = box.row() 293 | row.prop(context.scene.zodeutils_bake, "RayDistance") 294 | row.prop(context.scene.zodeutils_bake, "SaveToFile") 295 | 296 | box.prop(context.scene.zodeutils_bake, "UseCage") 297 | if context.scene.zodeutils_bake.UseCage: 298 | box.prop(context.scene.zodeutils_bake, "CageObject") 299 | 300 | row = self.layout.row() 301 | row.prop(context.scene.zodeutils_bake, "BakeAO", icon=bake_operators.GetIconForBake("AO")) 302 | row.prop(context.scene.zodeutils_bake, "BakeShadow", icon=bake_operators.GetIconForBake("SHADOW")) 303 | 304 | row = self.layout.row() 305 | row.prop(context.scene.zodeutils_bake, "BakeNormal", icon=bake_operators.GetIconForBake("NORMAL")) 306 | row.prop(context.scene.zodeutils_bake, "BakeUV", icon=bake_operators.GetIconForBake("UV")) 307 | if context.scene.zodeutils_bake.BakeNormal and not context.scene.zodeutils_bake.SimpleMode: 308 | box = self.layout.box() 309 | box.label(text="Normal bake settings:") 310 | box.prop(context.scene.zodeutils_bake, "NormalSpace") 311 | row = box.row() 312 | row.prop(context.scene.zodeutils_bake, "NormalR") 313 | row.prop(context.scene.zodeutils_bake, "NormalG") 314 | row.prop(context.scene.zodeutils_bake, "NormalB") 315 | 316 | row = self.layout.row() 317 | row.prop(context.scene.zodeutils_bake, "BakeRoughness", icon=bake_operators.GetIconForBake("ROUGHNESS")) 318 | row.prop(context.scene.zodeutils_bake, "BakeEmit", icon=bake_operators.GetIconForBake("EMIT")) 319 | 320 | row = self.layout.row() 321 | row.prop(context.scene.zodeutils_bake, "BakeEnvironment", icon=bake_operators.GetIconForBake("ENVIRONMENT")) 322 | row.prop(context.scene.zodeutils_bake, "BakeDiffuse", icon=bake_operators.GetIconForBake("DIFFUSE")) 323 | if context.scene.zodeutils_bake.BakeDiffuse and not context.scene.zodeutils_bake.SimpleMode: 324 | box = self.layout.box() 325 | box.label(text="Diffuse bake settings:") 326 | box.row().prop(context.scene.zodeutils_bake, "DiffuseFlags", expand=True) 327 | 328 | row = self.layout.row() 329 | row.prop(context.scene.zodeutils_bake, "BakeGlossy", icon=bake_operators.GetIconForBake("GLOSSY")) 330 | row.prop(context.scene.zodeutils_bake, "BakeTransmission", icon=bake_operators.GetIconForBake("TRANSMISSION")) 331 | if context.scene.zodeutils_bake.BakeGlossy and not context.scene.zodeutils_bake.SimpleMode: 332 | box = self.layout.box() 333 | box.label(text="Glossy bake settings:") 334 | box.row().prop(context.scene.zodeutils_bake, "GlossyFlags", expand=True) 335 | 336 | if context.scene.zodeutils_bake.BakeTransmission and not context.scene.zodeutils_bake.SimpleMode: 337 | box = self.layout.box() 338 | box.label(text="Transmission bake settings:") 339 | box.row().prop(context.scene.zodeutils_bake, "TransmissionFlags", expand=True) 340 | 341 | row = self.layout.row() 342 | row.prop(context.scene.zodeutils_bake, "BakeSubsurface", icon=bake_operators.GetIconForBake("SUBSURFACE")) 343 | row.prop(context.scene.zodeutils_bake, "BakeCombined", icon=bake_operators.GetIconForBake("COMBINED")) 344 | if context.scene.zodeutils_bake.BakeSubsurface and not context.scene.zodeutils_bake.SimpleMode: 345 | box = self.layout.box() 346 | box.label(text="Subsurface bake settings:") 347 | box.row().prop(context.scene.zodeutils_bake, "SubsurfaceFlags", expand=True) 348 | 349 | if context.scene.zodeutils_bake.BakeCombined and not context.scene.zodeutils_bake.SimpleMode: 350 | box = self.layout.box() 351 | box.label(text="Combined bake settings:") 352 | box.row().prop(context.scene.zodeutils_bake, "CombinedFlags", expand=True) 353 | box.prop(context.scene.zodeutils_bake, "CombinedFilter", expand=True) 354 | 355 | self.layout.separator() 356 | if context.scene.render.engine != "CYCLES": 357 | self.layout.label(text="Render engine is not set to cycles!", icon="ERROR") 358 | self.layout.operator("zodeutils.bake_fix_engine", icon="MODIFIER") 359 | self.layout.separator() 360 | 361 | self.layout.operator("zodeutils.bake", icon="TEXTURE") 362 | 363 | addon_updater_ops.check_for_update_background() 364 | if addon_updater_ops.updater.update_ready: 365 | addon_updater_ops.update_notice_box_ui(self, context) 366 | 367 | 368 | def register(): 369 | bpy.utils.register_class(BakeSettings) 370 | bpy.types.Scene.zodeutils_bake = bpy.props.PointerProperty(type=BakeSettings) 371 | bpy.utils.register_class(ZODEUTILS_BAKE) 372 | bake_operators.register() 373 | 374 | def unregister(): 375 | del bpy.types.Scene.zodeutils_bake 376 | bpy.utils.unregister_class(BakeSettings) 377 | bpy.utils.unregister_class(ZODEUTILS_BAKE) 378 | bake_operators.unregister() -------------------------------------------------------------------------------- /bake_operators.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from .common.bake import bake 3 | from .utils import MergeClean 4 | 5 | #aah! globals! 6 | bakequeue = [] 7 | bakesdone = [] 8 | currentbake = None 9 | 10 | def ClearBakeQueue(): 11 | global currentbake 12 | global bakesdone 13 | global bakequeue 14 | currentbake = None 15 | bakesdone.clear() 16 | bakesdone = [] 17 | bakequeue.clear() 18 | bakequeue = [] 19 | 20 | def GetIconForBake(type): 21 | if currentbake == type: 22 | return "PLAY" 23 | elif type in bakesdone: 24 | return "SHADING_SOLID" 25 | else: 26 | return "BLANK1" 27 | 28 | def QueueBake(mode, flags): 29 | global bakequeue 30 | print(f"Queued bake: {mode} with flags {flags}") 31 | bakequeue.append({"mode":mode, "flags":flags}) 32 | 33 | class ZODEUTILS_BAKE(bpy.types.Operator): 34 | bl_idname="zodeutils.bake" 35 | bl_label="Bake" 36 | bl_description = "Automatically bake selected operations" 37 | 38 | timer = None 39 | 40 | def modal(self, context, event): 41 | global currentbake 42 | global bakesdone 43 | global bakequeue 44 | 45 | if len(bakequeue) <= 0: 46 | ClearBakeQueue() 47 | self.cancel(context) 48 | return {"FINISHED"} 49 | 50 | if currentbake is None: 51 | print(f"Bake queue: starting bake: {bakequeue[0]}") 52 | currentbake = bakequeue[0]["mode"] 53 | bpy.ops.wm.redraw_timer(type='DRAW_WIN_SWAP', iterations=1) 54 | return {"PASS_THROUGH"} 55 | 56 | bake(bakequeue[0]["mode"], bakequeue[0]["flags"]) 57 | if len(bakequeue) <= 0: 58 | #early exit cuz of erroring in bake 59 | self.cancel(context) 60 | return {"FINISHED"} 61 | 62 | bakesdone.append(bakequeue[0]["mode"]) 63 | del bakequeue[0] 64 | currentbake = None 65 | 66 | return {"PASS_THROUGH"} 67 | 68 | @classmethod 69 | def poll(cls, context): 70 | if len(bakequeue) > 0: 71 | return False 72 | if bpy.context.scene.render.engine != "CYCLES": 73 | return False 74 | bakesettings = bpy.context.scene.zodeutils_bake 75 | if bakesettings.BakeAO: 76 | return True 77 | elif bakesettings.BakeShadow: 78 | return True 79 | elif bakesettings.BakeNormal: 80 | return True 81 | elif bakesettings.BakeUV: 82 | return True 83 | elif bakesettings.BakeRoughness: 84 | return True 85 | elif bakesettings.BakeEmit: 86 | return True 87 | elif bakesettings.BakeEnvironment: 88 | return True 89 | elif bakesettings.BakeDiffuse: 90 | return True 91 | elif bakesettings.BakeGlossy: 92 | return True 93 | elif bakesettings.BakeTransmission: 94 | return True 95 | elif bakesettings.BakeSubsurface: 96 | return True 97 | elif bakesettings.BakeCombined: 98 | return True 99 | else: 100 | return False 101 | 102 | def execute(self, context): 103 | ClearBakeQueue() 104 | bakesettings = bpy.context.scene.zodeutils_bake 105 | if bakesettings.BakeAO: 106 | QueueBake("AO", {"NONE"}) 107 | if bakesettings.BakeShadow: 108 | QueueBake("SHADOW", {"NONE"}) 109 | if bakesettings.BakeNormal: 110 | QueueBake("NORMAL", {"NONE"}) 111 | if bakesettings.BakeUV: 112 | QueueBake("UV", {"NONE"}) 113 | if bakesettings.BakeRoughness: 114 | QueueBake("ROUGHNESS", {"NONE"}) 115 | if bakesettings.BakeEmit: 116 | QueueBake("EMIT", {"NONE"}) 117 | if bakesettings.BakeEnvironment: 118 | QueueBake("ENVIRONMENT", {"NONE"}) 119 | if bakesettings.BakeDiffuse: 120 | QueueBake("DIFFUSE", bakesettings.DiffuseFlags if len(bakesettings.DiffuseFlags) else {"NONE"}) 121 | if bakesettings.BakeGlossy: 122 | QueueBake("GLOSSY", bakesettings.GlossyFlags if len(bakesettings.GlossyFlags) else {"NONE"}) 123 | if bakesettings.BakeTransmission: 124 | QueueBake("TRANSMISSION", bakesettings.TransmissionFlags if len(bakesettings.TransmissionFlags) else {"NONE"}) 125 | if bakesettings.BakeSubsurface: 126 | QueueBake("SUBSURFACE", bakesettings.SubsurfaceFlags if len(bakesettings.SubsurfaceFlags) else {"NONE"}) 127 | if bakesettings.BakeCombined: 128 | combinedflags = bakesettings.CombinedFlags if len(bakesettings.CombinedFlags) > 0 else {"NONE"} 129 | combinedfilter = bakesettings.CombinedFilter if len(bakesettings.CombinedFilter) > 0 else {"NONE"} 130 | if len(bakesettings.CombinedFlags) > 0 and len(bakesettings.CombinedFilter) > 0: 131 | combined = MergeClean(combinedflags, combinedfilter) 132 | elif len(bakesettings.CombinedFlags) <= 0 and len(bakesettings.CombinedFilter) > 0: 133 | combined = combinedfilter 134 | elif len(bakesettings.CombinedFlags) > 0 and len(bakesettings.CombinedFilter) <= 0: 135 | combined = combinedflags 136 | else: 137 | combined = {"NONE"} 138 | QueueBake("COMBINED", combined) 139 | 140 | self.timer = context.window_manager.event_timer_add(0.1, window=context.window) 141 | context.window_manager.modal_handler_add(self) 142 | self.report({"INFO"}, "Bake(s) queued") 143 | return {"RUNNING_MODAL"} 144 | 145 | def cancel(self, context): 146 | context.window_manager.event_timer_remove(self.timer) 147 | 148 | class ZODEUTILS_BAKE_FIX_ENGINE(bpy.types.Operator): 149 | bl_idname="zodeutils.bake_fix_engine" 150 | bl_label="Change to cycles" 151 | bl_description = "Change render engine to cycles" 152 | 153 | def execute(self, context): 154 | context.scene.render.engine = "CYCLES" 155 | return {"FINISHED"} 156 | 157 | def register(): 158 | bpy.utils.register_class(ZODEUTILS_BAKE) 159 | bpy.utils.register_class(ZODEUTILS_BAKE_FIX_ENGINE) 160 | 161 | def unregister(): 162 | bpy.utils.unregister_class(ZODEUTILS_BAKE) 163 | bpy.utils.unregister_class(ZODEUTILS_BAKE_FIX_ENGINE) 164 | -------------------------------------------------------------------------------- /classicweight.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from bpy.types import Menu 3 | import blf 4 | import gpu 5 | from gpu_extras.batch import batch_for_shader 6 | from bpy.app.handlers import persistent 7 | 8 | zodeutils_cvw_iseditingweights = False 9 | 10 | def zodeutils_cvweight_magic(): 11 | global zodeutils_cvw_iseditingweights 12 | obj = bpy.context.object 13 | if obj.vertex_groups.active.lock_weight: 14 | return 15 | 16 | match obj.mode: 17 | case "EDIT": 18 | if zodeutils_cvw_iseditingweights: 19 | bpy.ops.wm.call_menu_pie(name="VIEW3D_MT_PIE_ClassicVertexWeight") 20 | case "WEIGHT_PAINT": 21 | zodeutils_cvw_iseditingweights = True 22 | bpy.ops.object.mode_set(mode="EDIT") 23 | bpy.ops.mesh.select_mode(use_extend=False, use_expand=False, type="VERT") 24 | bpy.ops.mesh.select_all(action="DESELECT") 25 | bpy.ops.object.vertex_group_select() 26 | bpy.ops.zodeutils_cvweight.info("INVOKE_DEFAULT") 27 | 28 | class ZODEUTILS_CVWEIGHT_OT_magic(bpy.types.Operator): 29 | """Run the magic operator""" 30 | bl_idname = "zodeutils_cvweight.magic" 31 | bl_label = "classic vertex weight magic" 32 | 33 | def execute(self, context): 34 | if context.object.vertex_groups and context.object.type == "MESH": 35 | zodeutils_cvweight_magic() 36 | 37 | return {"FINISHED"} 38 | 39 | class ZODEUTILS_CVWEIGHT_OT_AssignSelected(bpy.types.Operator): 40 | """CVWeight: Assign vertices (selected group)""" 41 | bl_idname = "zodeutils_cvweight.assignselected" 42 | bl_label = "classic vertex weight: assign selected (selected group)" 43 | bl_options = {"UNDO"} 44 | 45 | def execute(self, context): 46 | global zodeutils_cvw_iseditingweights 47 | if not zodeutils_cvw_iseditingweights: 48 | return {"FINISHED"} 49 | 50 | if bpy.context.object.vertex_groups.active.lock_weight: 51 | return {"FINISHED"} 52 | 53 | bpy.ops.object.vertex_group_assign() 54 | bpy.ops.mesh.select_all(action="INVERT") 55 | bpy.ops.object.vertex_group_remove_from() 56 | bpy.ops.mesh.select_all(action="DESELECT") 57 | 58 | zodeutils_cvw_iseditingweights = False 59 | bpy.ops.object.mode_set(mode="WEIGHT_PAINT") 60 | return {"FINISHED"} 61 | 62 | class ZODEUTILS_CVWEIGHT_OT_AssignAll(bpy.types.Operator): 63 | """CVWeight: Assign vertices (all groups)""" 64 | bl_idname = "zodeutils_cvweight.assignall" 65 | bl_label = "classic vertex weight: assign selected (all groups)" 66 | bl_options = {"UNDO"} 67 | 68 | def execute(self, context): 69 | global zodeutils_cvw_iseditingweights 70 | if not zodeutils_cvw_iseditingweights: 71 | return {"FINISHED"} 72 | 73 | obj = bpy.context.object 74 | 75 | orig_index = obj.vertex_groups.active_index 76 | for active_index, vertex_group in enumerate(obj.vertex_groups): 77 | obj.vertex_groups.active_index = active_index 78 | if vertex_group.lock_weight: 79 | continue 80 | 81 | if active_index == orig_index: 82 | bpy.ops.object.vertex_group_assign() 83 | bpy.ops.mesh.select_all(action="INVERT") 84 | bpy.ops.object.vertex_group_remove_from() 85 | bpy.ops.mesh.select_all(action="INVERT") 86 | else: 87 | bpy.ops.object.vertex_group_remove_from() 88 | 89 | obj.vertex_groups.active_index = orig_index 90 | 91 | bpy.ops.mesh.select_all(action="DESELECT") 92 | zodeutils_cvw_iseditingweights = False 93 | bpy.ops.object.mode_set(mode="WEIGHT_PAINT") 94 | return {"FINISHED"} 95 | 96 | class ZODEUTILS_CVWEIGHT_OT_CancelAssign(bpy.types.Operator): 97 | """CVWeight: Cancel assignment""" 98 | bl_idname = "zodeutils_cvweight.cancelassign" 99 | bl_label = "classic vertex weight: cancel assignment" 100 | 101 | def execute(self, context): 102 | global zodeutils_cvw_iseditingweights 103 | if not zodeutils_cvw_iseditingweights: 104 | return {"FINISHED"} 105 | 106 | bpy.ops.mesh.select_all(action="DESELECT") 107 | zodeutils_cvw_iseditingweights = False 108 | bpy.ops.object.mode_set(mode="WEIGHT_PAINT") 109 | return {"FINISHED"} 110 | 111 | class VIEW3D_MT_PIE_ClassicVertexWeight(Menu): 112 | bl_label = "Select assignment mode" 113 | 114 | def draw(self, context): 115 | layout = self.layout 116 | 117 | pie = layout.menu_pie() 118 | pie.operator("zodeutils_cvweight.assignselected", text="affect selected group", icon="GROUP_VERTEX") 119 | pie.operator("zodeutils_cvweight.assignall", text="affect all groups", icon="GROUP_VERTEX") 120 | pie.operator("zodeutils_cvweight.cancelassign", text="cancel assignment", icon="CANCEL") 121 | 122 | class ZODEUTILS_CVWEIGHT_OT_Info(bpy.types.Operator): 123 | bl_idname = "zodeutils_cvweight.info" 124 | bl_label = "zodeutils_cvweight.info" 125 | 126 | @classmethod 127 | def poll(cls, context): 128 | return True 129 | 130 | def invoke(self, context, event): 131 | args = (self, context) 132 | self._handle = bpy.types.SpaceView3D.draw_handler_add(self.draw_callback_px, args, "WINDOW", "POST_PIXEL") 133 | context.window_manager.modal_handler_add(self) 134 | return {"RUNNING_MODAL"} 135 | 136 | def modal(self, context, event): 137 | global zodeutils_cvw_iseditingweights 138 | context.area.tag_redraw() 139 | 140 | if not zodeutils_cvw_iseditingweights: 141 | return self.finish() 142 | 143 | if not bpy.context.object.mode == "EDIT": 144 | zodeutils_cvw_iseditingweights = False 145 | return self.finish() 146 | 147 | return {"PASS_THROUGH"} 148 | 149 | def finish(self): 150 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW") 151 | return {"FINISHED"} 152 | 153 | def cancel(self, context): 154 | bpy.types.SpaceView3D.draw_handler_remove(self._handle, "WINDOW") 155 | return {"CANCELLED"} 156 | 157 | def draw_callback_px(tmp, self, context): 158 | region = context.region 159 | font_id = 0 160 | font_size = 24 161 | 162 | xpos = int(region.width / 2.0) 163 | ypos = 64 164 | 165 | locked = bpy.context.object.vertex_groups.active.lock_weight 166 | text = "NOW EDITING VERTEX GROUP WEIGHTS" 167 | if locked: 168 | text2 = "CAN'T EDIT: VERTEX GROUP IS LOCKED" 169 | else: 170 | text2 = "Select vertices and press keybind again" 171 | blf.size(font_id, font_size) 172 | text_dim = blf.dimensions(font_id, text) 173 | text2_dim = blf.dimensions(font_id, text2) 174 | 175 | 176 | border = font_size/4 177 | padding = font_size/2 178 | 179 | vertices = ( 180 | (0, ypos+text_dim[1]+border), 181 | (region.width, ypos+text_dim[1]+border), 182 | (0, ypos-padding-text2_dim[1]-border), 183 | (region.width, ypos-padding-text2_dim[1]-border), 184 | ) 185 | 186 | indices = ( 187 | (0, 1, 2), 188 | (2, 1, 3), 189 | ) 190 | 191 | shader = gpu.shader.from_builtin("UNIFORM_COLOR") 192 | gpu.state.blend_set("ALPHA") 193 | batch = batch_for_shader(shader, "TRIS", {"pos":vertices}, indices=indices) 194 | shader.uniform_float("color", (0.0, 0.0, 0.0, 0.66)) 195 | batch.draw(shader) 196 | 197 | gpu.state.blend_set("NONE") 198 | 199 | blf.position(font_id, xpos - text_dim[0] / 2, ypos, 0) 200 | blf.color(font_id, 1.0, 1.0, 1.0, 1.0) 201 | blf.draw(font_id, text) 202 | blf.position(font_id, xpos - text2_dim[0] / 2, ypos - text_dim[1] - padding, 0) 203 | if locked: 204 | blf.color(font_id, 1.0, 0.0, 0.0, 1.0) 205 | else: 206 | blf.color(font_id, 1.0, 1.0, 1.0, 1.0) 207 | blf.draw(font_id, text2) 208 | 209 | @persistent 210 | def zodeutils_load_handler(dummy): 211 | global zodeutils_cvw_iseditingweights 212 | zodeutils_cvw_iseditingweights = False -------------------------------------------------------------------------------- /common/bake.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import random 3 | from ..utils import Popup, FindOrMakeNodeByLabel, FindOrMakeImage, SceneUnselectAll, NodesUnselectAll, GetMaterialId 4 | from .. import bake_operators 5 | 6 | def bake(mode, flags): 7 | if bpy.context.scene.render.engine != "CYCLES": 8 | Popup(message="Can't bake: Renderer must be cycles!", title="Error", icon="ERROR") 9 | bake_operators.ClearBakeQueue() 10 | return 11 | 12 | bakesettings = bpy.context.scene.zodeutils_bake 13 | if bakesettings.HighpolyObject is None or bakesettings.LowpolyObject is None: 14 | Popup(message="Can't bake: No objects selected!", title="Error", icon="ERROR") 15 | bake_operators.ClearBakeQueue() 16 | return 17 | 18 | width = bakesettings.TargetWidth 19 | height = bakesettings.TargetHeight 20 | print(f"[Zode's bakery] Baking:\nmode: {mode.lower()}\nsize: {width} x {height}\nflags: {flags}") 21 | 22 | SceneUnselectAll() 23 | bakesettings.HighpolyObject.hide_set(False) 24 | bakesettings.LowpolyObject.hide_set(False) 25 | 26 | bakesettings.HighpolyObject.select_set(True) 27 | bakesettings.LowpolyObject.select_set(True) 28 | bpy.context.view_layer.objects.active = bakesettings.LowpolyObject 29 | 30 | print(f"high {bakesettings.HighpolyObject}") 31 | print(f"low {bakesettings.LowpolyObject}") 32 | 33 | if len(bpy.context.selected_objects) < 2: 34 | Popup(message="Can't bake: Select error!", title="Error", icon="ERROR") 35 | bake_operators.ClearBakeQueue() 36 | return 37 | 38 | source = bakesettings.HighpolyObject 39 | if len(source.material_slots) <= 0: 40 | Popup(message="Can't bake: Highpoly mesh has no material!", title="Error", icon="ERROR") 41 | bake_operators.ClearBakeQueue() 42 | return 43 | 44 | target = bakesettings.LowpolyObject 45 | if len(target.material_slots) <= 0: 46 | Popup(message="Can't bake: Lowpoly mesh has no material!", title="Error", icon="ERROR") 47 | bake_operators.ClearBakeQueue() 48 | return 49 | 50 | if bakesettings.TargetMaterial not in target.material_slots: 51 | Popup(message="Can't bake: Failed to get target material!", title="Error", icon="ERROR") 52 | bake_operators.ClearBakeQueue() 53 | return 54 | 55 | #todo: multiple material handling 56 | target.active_material_index = GetMaterialId(bakesettings.TargetMaterial, target.material_slots) 57 | 58 | texture = FindOrMakeImage(f"{target.name}_{mode.lower()}", width=width, height=height) 59 | 60 | #nodes 61 | nodetree = target.material_slots[bakesettings.TargetMaterial].material.node_tree 62 | NodesUnselectAll(nodetree.nodes) 63 | texturenode = FindOrMakeNodeByLabel(nodetree.nodes, "ShaderNodeTexImage", mode.lower(), (0.0, 0.0)) 64 | texturenode.select = True 65 | nodetree.nodes.active = texturenode 66 | texturenode.image = texture 67 | 68 | if bakesettings.UseCage: 69 | bpy.ops.object.bake(type=mode, 70 | normal_space=bakesettings.NormalSpace, 71 | normal_r=bakesettings.NormalR, 72 | normal_g=bakesettings.NormalG, 73 | normal_b=bakesettings.NormalB, 74 | cage_extrusion=bakesettings.RayDistance, 75 | pass_filter=flags, 76 | save_mode="INTERNAL", 77 | use_selected_to_active=True, 78 | use_cage=True, 79 | cage_object=bakesettings.CageObject.name 80 | ) 81 | else: 82 | bpy.ops.object.bake(type=mode, 83 | normal_space=bakesettings.NormalSpace, 84 | normal_r=bakesettings.NormalR, 85 | normal_g=bakesettings.NormalG, 86 | normal_b=bakesettings.NormalB, 87 | cage_extrusion=bakesettings.RayDistance, 88 | pass_filter=flags, 89 | save_mode="INTERNAL", 90 | use_selected_to_active=True 91 | ) 92 | 93 | if mode is "NORMAL": 94 | texturenode.location = (-530.0, -188.0) 95 | normalnode = FindOrMakeNodeByLabel(nodetree.nodes, "ShaderNodeNormalMap", "Normal", (-219, -188.0)) 96 | normalnode.space = bakesettings.NormalSpace 97 | nodetree.links.new(texturenode.outputs["Color"], normalnode.inputs["Color"]) 98 | nodetree.links.new(normalnode.outputs["Normal"], nodetree.nodes.get("Principled BSDF").inputs["Normal"]) 99 | else: #shrug throw at random location cuz i'm not experienced enough to know how these could be utilized in nodes 100 | texturenode.location = (random.uniform(305.0, 900.0), random.uniform(-200.0, 200.0)) 101 | 102 | if bakesettings.SaveToFile: 103 | texture.filepath_raw = f"//{target.name}_{mode.lower()}.png" 104 | texture.file_format = "PNG" 105 | texture.save() 106 | 107 | SceneUnselectAll() 108 | bakesettings.HighpolyObject.hide_set(True) 109 | bakesettings.LowpolyObject.hide_set(False) 110 | bakesettings.LowpolyObject.select_set(True) 111 | bpy.context.view_layer.objects.active = bakesettings.LowpolyObject -------------------------------------------------------------------------------- /common/mat.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import os 3 | 4 | from ..utils import Popup, FindNode, FindOrMakeNodeByLabel, CleanMaterial 5 | 6 | def MakeMaterialDiffuse(): 7 | mat = bpy.context.object.active_material 8 | 9 | if not mat.use_nodes: 10 | mat.use_nodes = True 11 | 12 | if not CleanMaterial(mat): 13 | fixbsdf = mat.node_tree.nodes.get("Principled BSDF") 14 | fixbsdf.label = "Principled BSDF" 15 | 16 | bsdfnode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeBsdfPrincipled", "Principled BSDF", (10.0, 300.0)) 17 | texturenode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeTexImage", "Albedo", (-300.0, 218.0)) 18 | 19 | texturenode.location = (-300.0, 218.0) 20 | 21 | if bpy.context.scene.zodeutils_material.NoSpec: 22 | bsdfnode.inputs["Specular IOR Level"].default_value = 0 23 | bsdfnode.inputs["Metallic"].default_value = 0 24 | 25 | mat.node_tree.links.new(texturenode.outputs["Color"], bsdfnode.inputs["Base Color"]) 26 | 27 | outputnode = FindNode(mat.node_tree.nodes, "ShaderNodeOutputMaterial") 28 | mat.node_tree.links.new(bsdfnode.outputs["BSDF"], outputnode.inputs["Surface"]) 29 | 30 | mat.blend_method = "OPAQUE" 31 | mat["zodeutils_type"] = ["DIFFUSE"] 32 | 33 | if bpy.context.scene.zodeutils_material.Additive: 34 | MaterialAddAdditive(mat) 35 | 36 | if bpy.context.scene.zodeutils_material.Transparent: 37 | MaterialAddTransparency(mat) 38 | 39 | def MakeMaterialMatcap(): 40 | mat = bpy.context.object.active_material 41 | 42 | if not mat.use_nodes: 43 | mat.use_nodes = True 44 | 45 | if not CleanMaterial(mat): 46 | fixbsdf = mat.node_tree.nodes.get("Principled BSDF") 47 | fixbsdf.label = "Principled BSDF" 48 | 49 | bsdfnode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeBsdfPrincipled", "Principled BSDF", (10.0, 300.0)) 50 | texturenode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeTexImage", "Albedo", (-300.0, 218.0)) 51 | geometrynode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeNewGeometry", "Chrome", (-942.0, 218.0)) 52 | vectornode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeVectorTransform", "Chrome", (-729.0, 218.0)) 53 | mappingnode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeMapping", "Chrome", (-516.0, 218.0)) 54 | 55 | texturenode.location = (-300.0, 218.0) 56 | geometrynode.location = (-942.0, 218.0) 57 | vectornode.location = (-729.0, 218.0) 58 | mappingnode.location = (-516.0, 218.0) 59 | 60 | vectornode.vector_type = "VECTOR" 61 | vectornode.convert_from = "OBJECT" 62 | vectornode.convert_to = "CAMERA" 63 | mappingnode.vector_type = "POINT" 64 | mappingnode.inputs["Location"].default_value[0] = 0.5 65 | mappingnode.inputs["Location"].default_value[1] = 0.5 66 | mappingnode.inputs["Scale"].default_value[0] = 0.5 67 | mappingnode.inputs["Scale"].default_value[1] = 0.5 68 | if bpy.context.scene.zodeutils_material.NoSpec: 69 | bsdfnode.inputs["Specular IOR Level"].default_value = 0 70 | bsdfnode.inputs["Metallic"].default_value = 0 71 | 72 | mat.node_tree.links.new(texturenode.outputs["Color"], bsdfnode.inputs["Base Color"]) 73 | mat.node_tree.links.new(geometrynode.outputs["Normal"], vectornode.inputs["Vector"]) 74 | mat.node_tree.links.new(vectornode.outputs["Vector"], mappingnode.inputs["Vector"]) 75 | mat.node_tree.links.new(mappingnode.outputs["Vector"], texturenode.inputs["Vector"]) 76 | 77 | outputnode = FindNode(mat.node_tree.nodes, "ShaderNodeOutputMaterial") 78 | mat.node_tree.links.new(bsdfnode.outputs["BSDF"], outputnode.inputs["Surface"]) 79 | 80 | mat.blend_method = "OPAQUE" 81 | mat["zodeutils_type"] = ["MATCAP"] 82 | 83 | if bpy.context.scene.zodeutils_material.Additive: 84 | MaterialAddAdditive(mat) 85 | 86 | if bpy.context.scene.zodeutils_material.Transparent: 87 | MaterialAddTransparency(mat) 88 | 89 | def MaterialAddAdditive(mat): 90 | bsdfnode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeBsdfPrincipled", "Principled BSDF", (10.0, 300.0)) 91 | mat.node_tree.nodes.remove(bsdfnode) 92 | 93 | addnode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeAddShader", "Additive", (97.0, 298.0)) 94 | bsdfnode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeBsdfTransparent", "Additive", (-111.0, 93.0)) 95 | texturenode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeTexImage", "Albedo", (-300.0, 218.0)) 96 | 97 | texturenode.location = (-206.0, 379.0) 98 | 99 | mat.node_tree.links.new(texturenode.outputs["Color"], addnode.inputs[0]) 100 | mat.node_tree.links.new(bsdfnode.outputs["BSDF"], addnode.inputs[1]) 101 | 102 | outputnode = FindNode(mat.node_tree.nodes, "ShaderNodeOutputMaterial") 103 | mat.node_tree.links.new(addnode.outputs["Shader"], outputnode.inputs["Surface"]) 104 | 105 | mat.blend_method = "BLEND" 106 | temp = mat["zodeutils_type"] 107 | temp.append("ADDITIVE") 108 | mat["zodeutils_type"] = temp 109 | 110 | def MaterialAddTransparency(mat): 111 | #:weary: 112 | texturenode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeTexImage", "Albedo", (-300.0, 218.0)) 113 | texturepath = os.path.normpath(bpy.path.abspath(texturenode.image.filepath, library=texturenode.image.library)) 114 | keyColor = (0, 0, 0, 1) 115 | with open(texturepath, "rb") as file: 116 | if file.read(1).decode("utf-8") != "B" and \ 117 | file.read(1).decode("utf-8") != "M": 118 | print(f"Not a valid .bmp file ({texturepath})!?") 119 | 120 | file.seek(46) #skip to palette count 121 | palcount = int.from_bytes(file.read(1), "little") 122 | #assume 8 bit file 123 | if palcount == 0: 124 | palcount = 256 125 | 126 | file.seek(54+((palcount-1)*4)) # seek to last index in palette 127 | blue = int.from_bytes(file.read(1), "little") 128 | green = int.from_bytes(file.read(1), "little") 129 | red = int.from_bytes(file.read(1), "little") 130 | keyColor = (red/255.0, green/255.0, blue/255.0, 1) 131 | 132 | rgb = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeRGB", "Key", (-984.0, 500.0)) 133 | sub = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeMix", "KeySub", (-759.0, 500.0)) 134 | len = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeVectorMath", "KeyLen", (-585.0, 500.0)) 135 | comp = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeMath", "KeyComp", (-401.0, 500.0)) 136 | inv = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeInvert", "KeyInv", (-180.0, 500.0)) 137 | 138 | rgb.outputs[0].default_value = keyColor 139 | 140 | sub.data_type = "RGBA" 141 | sub.inputs["Factor"].default_value = 1 142 | sub.blend_type = "SUBTRACT" 143 | mat.node_tree.links.new(rgb.outputs["Color"], sub.inputs["A"]) 144 | mat.node_tree.links.new(texturenode.outputs["Color"], sub.inputs["B"]) 145 | 146 | len.operation = "LENGTH" 147 | mat.node_tree.links.new(sub.outputs["Result"], len.inputs["Vector"]) 148 | 149 | comp.operation = "COMPARE" 150 | comp.inputs[1].default_value = 0 151 | mat.node_tree.links.new(len.outputs["Value"], comp.inputs[0]) 152 | 153 | inv.inputs["Fac"].default_value = 1 154 | mat.node_tree.links.new(comp.outputs["Value"], inv.inputs["Color"]) 155 | 156 | if "ADDITIVE" in mat["zodeutils_type"]: 157 | transparent = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeBsdfTransparent", "Transparent BSDF", (26.0, 500.0)) 158 | mix = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeMixShader", "Mix", (382.0, 336.0)) 159 | addnode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeAddShader", "Additive", (97.0, 298.0)) 160 | outputnode = FindNode(mat.node_tree.nodes, "ShaderNodeOutputMaterial") 161 | 162 | transparent.inputs[0].default_value = (1, 1, 1, 0) 163 | mat.node_tree.links.new(inv.outputs["Color"], mix.inputs["Fac"]) 164 | mat.node_tree.links.new(transparent.outputs["BSDF"], mix.inputs[1]) 165 | mat.node_tree.links.new(addnode.outputs["Shader"], mix.inputs[2]) 166 | mat.node_tree.links.new(mix.outputs["Shader"], outputnode.inputs["Surface"]) 167 | mat.blend_method = "BLEND" 168 | else: 169 | bsdfnode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeBsdfPrincipled", "Principled BSDF", (10.0, 300.0)) 170 | mat.node_tree.links.new(inv.outputs["Color"], bsdfnode.inputs["Alpha"]) 171 | mat.blend_method = "CLIP" 172 | 173 | temp = mat["zodeutils_type"] 174 | temp.append("TRANSPARENT") 175 | mat["zodeutils_type"] = temp -------------------------------------------------------------------------------- /goldsrc/importmat.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | from ..utils import Popup, FindOrMakeNodeByLabel 4 | 5 | #TODO: make both functions call the mat.py ones for node setups 6 | 7 | def FixImportMaterials(): 8 | if not bpy.data.is_saved: 9 | Popup(message="File must be saved for relative paths!", title="Error", icon="ERROR") 10 | return False 11 | 12 | for materialslot in bpy.context.object.material_slots: 13 | mat = materialslot.material 14 | 15 | if not mat.use_nodes: 16 | mat.use_nodes = True 17 | 18 | texturenode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeTexImage", "Albedo", (-300.0, 218.0)) 19 | 20 | texturepath = f"//{mat.name}" 21 | try: 22 | texture = bpy.data.images.load(texturepath, check_existing=True) 23 | except Exception as e: 24 | Popup(message=f"Can't load image: {texturepath}!", title="Error", icon="ERROR") 25 | print(f"Can't load image: {texturepath}!\n{e}") 26 | return False 27 | 28 | texturenode.image = texture 29 | texturenode.interpolation = "Closest" 30 | 31 | #skip if already has previous setups 32 | if "zodeutils_type" in mat: 33 | return True 34 | 35 | #not using a fancy find function here since we just assume Principled BSDF always exists :P 36 | bsdf = mat.node_tree.nodes.get("Principled BSDF") 37 | bsdf.label = "Principled BSDF" 38 | mat.node_tree.links.new(texturenode.outputs["Color"], bsdf.inputs["Base Color"]) 39 | 40 | #goldsrc/sven doesn't have fancy shaders... yet 41 | bsdf.inputs["Specular IOR Level"].default_value = 0 42 | bsdf.inputs["Metallic"].default_value = 0 43 | 44 | mat.blend_method = "OPAQUE" 45 | mat["zodeutils_type"] = ["DIFFUSE"] 46 | 47 | if "chrome" in texturepath.lower(): 48 | geometrynode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeNewGeometry", "Chrome", (-942.0, 218.0)) 49 | vectornode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeVectorTransform", "Chrome", (-729.0, 218.0)) 50 | mappingnode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeMapping", "Chrome", (-516.0, 218.0)) 51 | mat.node_tree.links.new(geometrynode.outputs["Normal"], vectornode.inputs["Vector"]) 52 | mat.node_tree.links.new(vectornode.outputs["Vector"], mappingnode.inputs["Vector"]) 53 | mat.node_tree.links.new(mappingnode.outputs["Vector"], texturenode.inputs["Vector"]) 54 | 55 | vectornode.vector_type = "VECTOR" 56 | vectornode.convert_from = "OBJECT" 57 | vectornode.convert_to = "CAMERA" 58 | mappingnode.vector_type = "POINT" 59 | mappingnode.inputs["Location"].default_value[0] = 0.5 60 | mappingnode.inputs["Location"].default_value[1] = 0.5 61 | mappingnode.inputs["Scale"].default_value[0] = 0.5 62 | mappingnode.inputs["Scale"].default_value[1] = 0.5 63 | 64 | mat["zodeutils_type"] = ["MATCAP"] 65 | 66 | return True 67 | 68 | def FixImportAllMaterials(): 69 | if not bpy.data.is_saved: 70 | Popup(message="File must be saved for relative paths!", title="Error", icon="ERROR") 71 | return False 72 | 73 | for collection in bpy.data.collections: 74 | for object in collection.all_objects: 75 | for materialslot in object.material_slots: 76 | mat = materialslot.material 77 | if not ".bmp" in mat.name: 78 | continue 79 | 80 | if not mat.use_nodes: 81 | mat.use_nodes = True 82 | 83 | texturenode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeTexImage", "Albedo", (-300.0, 218.0)) 84 | 85 | texturepath = f"//{mat.name}" 86 | try: 87 | texture = bpy.data.images.load(texturepath, check_existing=True) 88 | except Exception as e: 89 | Popup(message=f"Can't load image: {texturepath}!", title="Error", icon="ERROR") 90 | print(f"Can't load image: {texturepath}!\n{e}") 91 | return False 92 | 93 | texturenode.image = texture 94 | texturenode.interpolation = "Closest" 95 | 96 | #skip if already has previous setups 97 | if "zodeutils_type" in mat: 98 | continue 99 | 100 | #not using a fancy find function here since we just assume Principled BSDF always exists :P 101 | bsdf = mat.node_tree.nodes.get("Principled BSDF") 102 | bsdf.label = "Principled BSDF" 103 | mat.node_tree.links.new(texturenode.outputs["Color"], bsdf.inputs["Base Color"]) 104 | 105 | #goldsrc/sven doesn't have fancy shaders... yet 106 | bsdf.inputs["Specular IOR Level"].default_value = 0 107 | bsdf.inputs["Metallic"].default_value = 0 108 | 109 | mat.blend_method = "OPAQUE" 110 | mat["zodeutils_type"] = ["DIFFUSE"] 111 | 112 | if "chrome" in texturepath.lower(): 113 | geometrynode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeNewGeometry", "Chrome", (-942.0, 218.0)) 114 | vectornode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeVectorTransform", "Chrome", (-729.0, 218.0)) 115 | mappingnode = FindOrMakeNodeByLabel(mat.node_tree.nodes, "ShaderNodeMapping", "Chrome", (-516.0, 218.0)) 116 | mat.node_tree.links.new(geometrynode.outputs["Normal"], vectornode.inputs["Vector"]) 117 | mat.node_tree.links.new(vectornode.outputs["Vector"], mappingnode.inputs["Vector"]) 118 | mat.node_tree.links.new(mappingnode.outputs["Vector"], texturenode.inputs["Vector"]) 119 | 120 | vectornode.vector_type = "VECTOR" 121 | vectornode.convert_from = "OBJECT" 122 | vectornode.convert_to = "CAMERA" 123 | mappingnode.vector_type = "POINT" 124 | mappingnode.inputs["Location"].default_value[0] = 0.5 125 | mappingnode.inputs["Location"].default_value[1] = 0.5 126 | mappingnode.inputs["Scale"].default_value[0] = 0.5 127 | mappingnode.inputs["Scale"].default_value[1] = 0.5 128 | 129 | mat["zodeutils_type"] = ["MATCAP"] 130 | 131 | return True -------------------------------------------------------------------------------- /material_gui.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from . import material_operators 3 | from . import addon_updater_ops 4 | 5 | class MaterialSettings(bpy.types.PropertyGroup): 6 | NoSpec : bpy.props.BoolProperty( 7 | name="No specular & metallic", 8 | description="Zero out specular & metallic", 9 | default=True 10 | ) 11 | 12 | Additive : bpy.props.BoolProperty( 13 | name="Additive", 14 | description="Make it additive also!", 15 | default=False 16 | ) 17 | 18 | Transparent : bpy.props.BoolProperty( 19 | name="Transparent", 20 | description="Make it Transparent also!", 21 | default=False 22 | ) 23 | 24 | class ZODEUTILS_MATERIALS(bpy.types.Panel): 25 | bl_label="Zode's utils" 26 | bl_idname = "OBJECT_PT_ZODEUTILS_MATERIALS" 27 | bl_space_type = "PROPERTIES" 28 | bl_region_type = "WINDOW" 29 | bl_context = "material" 30 | 31 | def draw(self, context): 32 | self.layout.label(text="Goldsrc specific:") 33 | box = self.layout.box() 34 | box.label(text="Import materials:") 35 | row = box.row() 36 | row.operator("zodeutils.goldsrc_material_import", icon="SHADING_TEXTURE") 37 | row.operator("zodeutils.goldsrc_material_import_all", icon="SHADING_TEXTURE") 38 | 39 | self.layout.label(text="Common:") 40 | box = self.layout.box() 41 | box.label(text="Swap selected material to:") 42 | box.prop(context.scene.zodeutils_material, "NoSpec") 43 | box.prop(context.scene.zodeutils_material, "Additive") 44 | box.prop(context.scene.zodeutils_material, "Transparent") 45 | row = box.row() 46 | row.operator("zodeutils.material_to_diffuse", icon="SHADING_SOLID") 47 | row.operator("zodeutils.material_to_matcap", icon="SHADING_RENDERED") 48 | 49 | addon_updater_ops.check_for_update_background() 50 | if addon_updater_ops.updater.update_ready: 51 | addon_updater_ops.update_notice_box_ui(self, context) 52 | 53 | 54 | def register(): 55 | bpy.utils.register_class(MaterialSettings) 56 | bpy.types.Scene.zodeutils_material = bpy.props.PointerProperty(type=MaterialSettings) 57 | bpy.utils.register_class(ZODEUTILS_MATERIALS) 58 | material_operators.register() 59 | 60 | def unregister(): 61 | del bpy.types.Scene.zodeutils_material 62 | bpy.utils.unregister_class(MaterialSettings) 63 | bpy.utils.unregister_class(ZODEUTILS_MATERIALS) 64 | material_operators.unregister() -------------------------------------------------------------------------------- /material_operators.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | from .goldsrc.importmat import FixImportMaterials, FixImportAllMaterials 3 | from .common.mat import * 4 | 5 | class ZODEUTILS_GoldSrcMaterialImport(bpy.types.Operator): 6 | bl_idname="zodeutils.goldsrc_material_import" 7 | bl_label="Selected object" 8 | bl_description = "Automatically sets up nodes & loads .bmp files from the .blend's folder\nAutomatically tries to detect chrome setups" 9 | 10 | @classmethod 11 | def poll(cls, context): 12 | return len(bpy.context.object.material_slots) > 0 13 | 14 | def execute(self, context): 15 | if FixImportMaterials(): 16 | self.report({"INFO"}, "Imported materials") 17 | else: 18 | self.report({"INFO"}, "Didn't import materials") 19 | 20 | return {"FINISHED"} 21 | 22 | class ZODEUTILS_GoldSrcMaterialImportAll(bpy.types.Operator): 23 | bl_idname="zodeutils.goldsrc_material_import_all" 24 | bl_label="All objects" 25 | bl_description = "Automatically sets up nodes & loads .bmp files from the .blend's folder\nAutomatically tries to detect chrome setups for all objects in scene" 26 | 27 | @classmethod 28 | def poll(cls, context): 29 | return len(bpy.data.objects) > 0 30 | 31 | def execute(self, context): 32 | if FixImportAllMaterials(): 33 | self.report({"INFO"}, "Imported materials") 34 | else: 35 | self.report({"INFO"}, "Didn't import materials") 36 | 37 | return {"FINISHED"} 38 | 39 | class ZODEUTILS_MaterialToDiffuse(bpy.types.Operator): 40 | bl_idname="zodeutils.material_to_diffuse" 41 | bl_label="Diffuse" 42 | bl_description = "Swap node setup to standard diffuse setup" 43 | 44 | @classmethod 45 | def poll(cls, context): 46 | return bpy.context.object.active_material is not None 47 | 48 | def execute(self, context): 49 | MakeMaterialDiffuse() 50 | self.report({"INFO"}, "Material turned to diffuse") 51 | return {"FINISHED"} 52 | 53 | class ZODEUTILS_MaterialToMatcap(bpy.types.Operator): 54 | bl_idname="zodeutils.material_to_matcap" 55 | bl_label="Matcap" 56 | bl_description = "Swap node setup to matcap setup, also known as \"chrome\" in goldsrc" 57 | 58 | @classmethod 59 | def poll(cls, context): 60 | return bpy.context.object.active_material is not None 61 | 62 | def execute(self, context): 63 | MakeMaterialMatcap() 64 | self.report({"INFO"}, "Material turned to matcap") 65 | return {"FINISHED"} 66 | 67 | def register(): 68 | bpy.utils.register_class(ZODEUTILS_GoldSrcMaterialImport) 69 | bpy.utils.register_class(ZODEUTILS_GoldSrcMaterialImportAll) 70 | bpy.utils.register_class(ZODEUTILS_MaterialToMatcap) 71 | bpy.utils.register_class(ZODEUTILS_MaterialToDiffuse) 72 | 73 | def unregister(): 74 | bpy.utils.unregister_class(ZODEUTILS_GoldSrcMaterialImport) 75 | bpy.utils.unregister_class(ZODEUTILS_GoldSrcMaterialImportAll) 76 | bpy.utils.unregister_class(ZODEUTILS_MaterialToMatcap) 77 | bpy.utils.unregister_class(ZODEUTILS_MaterialToDiffuse) -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | 3 | #quick&dirty, should use operator.report instead 4 | def Popup(message="", title="", icon="INFO"): 5 | def draw(self, context): 6 | self.layout.label(text=message) 7 | 8 | bpy.context.window_manager.popup_menu(draw, title=title, icon=icon) 9 | 10 | def FindOrMakeNode(nodes, nodename, location): 11 | for node in nodes: 12 | if node.bl_idname == nodename: 13 | return node 14 | 15 | node = nodes.new(nodename) 16 | node.location = location 17 | return node 18 | 19 | def FindNode(nodes, nodename): 20 | for node in nodes: 21 | if node.bl_idname == nodename: 22 | return node 23 | 24 | return None 25 | 26 | def FindOrMakeNodeByLabel(nodes, nodename, label, location): 27 | for node in nodes: 28 | if node.bl_idname == nodename and node.label == label: 29 | return node 30 | 31 | node = nodes.new(nodename) 32 | node.location = location 33 | node.label = label 34 | return node 35 | 36 | def FindNodeByLabel(nodes, nodename, label): 37 | for node in nodes: 38 | if node.bl_idname == nodename and node.label == label: 39 | return node 40 | 41 | return None 42 | 43 | def FindOrMakeImage(name, width, height): 44 | for image in bpy.data.images: 45 | if image.name == name: 46 | return image 47 | 48 | image = bpy.data.images.new(name, width=width, height=height) 49 | return image 50 | 51 | def SceneUnselectAll(): 52 | for object in bpy.data.objects: 53 | object.select_set(False) 54 | 55 | def NodesUnselectAll(nodes): 56 | for node in nodes: 57 | node.select = False 58 | 59 | def MergeClean(a, b): 60 | r = a.copy() 61 | r.update(b) 62 | return r 63 | 64 | def CleanMaterial(mat): 65 | """Cleans up material for swapping""" 66 | if not "zodeutils_type" in mat: 67 | return False 68 | 69 | if type(mat["zodeutils_type"]) is not list: 70 | mat["zodeutils_type"] = [mat["zodeutils_type"]] 71 | 72 | # fix earlier imports not having the label set 73 | bsdf = FindNode(mat.node_tree.nodes, "ShaderNodeBsdfPrincipled") 74 | if bsdf is not None: 75 | bsdf.label = "Principled BSDF" 76 | 77 | types = mat["zodeutils_type"] 78 | retclean = False 79 | for t in types: 80 | #remove non-additive bsdf if applicable 81 | if t == "DIFFUSE" and "ADDITIVE" not in types: 82 | mat.node_tree.nodes.remove(FindNodeByLabel(mat.node_tree.nodes, "ShaderNodeBsdfPrincipled", "Principled BSDF")) 83 | retclean = True 84 | #remove matcap stuff + non-additive bsd if applicable 85 | elif t == "MATCAP": 86 | if "ADDITIVE" not in types: 87 | mat.node_tree.nodes.remove(FindNodeByLabel(mat.node_tree.nodes, "ShaderNodeBsdfPrincipled", "Principled BSDF")) 88 | 89 | mat.node_tree.nodes.remove(FindNodeByLabel(mat.node_tree.nodes, "ShaderNodeNewGeometry", "Chrome")) 90 | mat.node_tree.nodes.remove(FindNodeByLabel(mat.node_tree.nodes, "ShaderNodeVectorTransform", "Chrome")) 91 | mat.node_tree.nodes.remove(FindNodeByLabel(mat.node_tree.nodes, "ShaderNodeMapping", "Chrome")) 92 | retclean = True 93 | #remove additive stuff 94 | elif t == "ADDITIVE": 95 | mat.node_tree.nodes.remove(FindNodeByLabel(mat.node_tree.nodes, "ShaderNodeBsdfTransparent", "Additive")) 96 | mat.node_tree.nodes.remove(FindNodeByLabel(mat.node_tree.nodes, "ShaderNodeAddShader", "Additive")) 97 | retclean = True 98 | #remove transparent stuff 99 | elif t == "TRANSPARENT": 100 | if "ADDITIVE" in types: 101 | mat.node_tree.nodes.remove(FindNodeByLabel(mat.node_tree.nodes, "ShaderNodeBsdfTransparent", "Transparent BSDF")) 102 | mat.node_tree.nodes.remove(FindNodeByLabel(mat.node_tree.nodes, "ShaderNodeMixShader", "Mix")) 103 | 104 | mat.node_tree.nodes.remove(FindNodeByLabel(mat.node_tree.nodes, "ShaderNodeRGB", "Key")) 105 | mat.node_tree.nodes.remove(FindNodeByLabel(mat.node_tree.nodes, "ShaderNodeMix", "KeySub")) 106 | mat.node_tree.nodes.remove(FindNodeByLabel(mat.node_tree.nodes, "ShaderNodeVectorMath", "KeyLen")) 107 | mat.node_tree.nodes.remove(FindNodeByLabel(mat.node_tree.nodes, "ShaderNodeMath", "KeyComp")) 108 | mat.node_tree.nodes.remove(FindNodeByLabel(mat.node_tree.nodes, "ShaderNodeInvert", "KeyInv")) 109 | retclean = True 110 | 111 | return retclean 112 | 113 | def GetMaterialId(material, materialslots): 114 | for index, mat in enumerate(materialslots): 115 | if mat.name == material: 116 | return index 117 | 118 | return 0 119 | 120 | def GetChildren(obj): 121 | children = [] 122 | for ob in bpy.data.objects: 123 | if ob.parent == obj: 124 | children.append(ob) 125 | return children 126 | 127 | def ExistsInChildren(children, name): 128 | for child in children: 129 | if child.name == name: 130 | return True 131 | return False 132 | 133 | def GetFromChildren(children, name): 134 | for child in children: 135 | if child.name == name: 136 | return child 137 | return None 138 | 139 | def FindModifier(object, modifiertype): 140 | for mod in object.modifiers: 141 | if type(mod) == modifiertype: 142 | return mod 143 | 144 | return None 145 | 146 | def RemoveModifierOfType(object, modifiertype): 147 | for mod in object.modifiers: 148 | if type(mod) == modifiertype: 149 | object.modifiers.remove(mod) 150 | return 151 | 152 | def FindCollectionByName(name): 153 | for collection in bpy.data.collections: 154 | if collection.name == name: 155 | return collection 156 | 157 | return None 158 | 159 | def GetFromCollection(collection, name): 160 | for obj in collection.all_objects: 161 | if obj.name == name: 162 | return obj 163 | 164 | return None 165 | 166 | def GetFromEditBones(arm, name): 167 | for bone in arm.edit_bones: 168 | if bone.name == name: 169 | return bone 170 | 171 | return None 172 | 173 | def GetFromVertexGroups(obj, name): 174 | for group in obj.vertex_groups: 175 | if group.name == name: 176 | return group 177 | 178 | return None -------------------------------------------------------------------------------- /vertexbone.py: -------------------------------------------------------------------------------- 1 | import bpy 2 | import bmesh 3 | from mathutils import Vector, Matrix 4 | from .utils import GetChildren, GetFromChildren, FindModifier, FindCollectionByName, GetFromCollection, GetFromVertexGroups, GetFromEditBones 5 | 6 | class ZODEUTILS_VERTEXBONE_OT_MakeVertexBone(bpy.types.Operator): 7 | """VertexBone: Make a vertexbone proxy setup from selected vertices""" 8 | bl_idname = "zodeutils_vertexbone.make" 9 | bl_label = "VertexBones: make from selected" 10 | bl_options = {"UNDO"} 11 | 12 | def execute(self, context): 13 | if not bpy.context.object.mode == "EDIT": 14 | return {"FINISHED"} 15 | 16 | #dirty check to see if any vert is selected lol 17 | vertList = [] 18 | 19 | bm = bmesh.from_edit_mesh(bpy.context.object.data) 20 | for vert in bm.verts: 21 | if not vert.select: 22 | continue 23 | 24 | vertList.append(vert.index) 25 | 26 | if len(vertList) == 0: 27 | return {"FINISHED"} 28 | 29 | originalObject = bpy.context.active_object 30 | bpy.ops.object.mode_set(mode="OBJECT") 31 | bpy.ops.object.select_all(action="DESELECT") 32 | 33 | #setup collection and object 34 | vertexCollection = FindCollectionByName("VertexBoned") 35 | if vertexCollection is None: 36 | vertexCollection = bpy.data.collections.new("VertexBoned") 37 | bpy.context.scene.collection.children.link(vertexCollection) 38 | 39 | newObject = GetFromCollection(vertexCollection, f"{originalObject.name}_vbproxy") 40 | if newObject is None: 41 | newObject = originalObject.copy() 42 | newObject.name = f"{originalObject.name}_vbproxy" 43 | newObject.data = originalObject.data.copy() 44 | newObject.animation_data_clear() 45 | vertexCollection.objects.link(newObject) 46 | 47 | if newObject.data.shape_keys is not None: 48 | newObject.active_shape_key_index = 0 49 | newObject.shape_key_clear() 50 | 51 | armature = None 52 | armatureObject = GetFromCollection(vertexCollection, f"{originalObject.name}_vbarm") 53 | if armatureObject is None: 54 | armature = bpy.data.armatures.new(name=f"{originalObject.name}_vbarm") 55 | armatureObject = bpy.data.objects.new(name=f"{originalObject.name}_vbarm", object_data=armature) 56 | vertexCollection.objects.link(armatureObject) 57 | else: 58 | armature = armatureObject.data 59 | 60 | #setup proxies 61 | bpy.context.view_layer.objects.active = armatureObject 62 | childs = GetChildren(originalObject) 63 | for vert in newObject.data.vertices: 64 | bpy.ops.object.mode_set(mode="EDIT") 65 | if vert.index not in vertList: 66 | continue 67 | 68 | isNewBone = False 69 | bone = GetFromEditBones(armature, "VertexBone_"+str(vert.index)) 70 | if bone is None: 71 | bone = armature.edit_bones.new(name="VertexBone_"+str(vert.index)) 72 | bone.head = newObject.matrix_world @ vert.co 73 | bone.tail = bone.head + Vector((0,0.15,0)) 74 | isNewBone = True 75 | 76 | vertexGroup = GetFromVertexGroups(newObject, "VertexBone_"+str(vert.index)) 77 | if vertexGroup is None: 78 | vertexGroup = newObject.vertex_groups.new(name="VertexBone_"+str(vert.index)) 79 | vertexGroup.add([vert.index], 1.0, "REPLACE") 80 | 81 | vertexProxy = GetFromChildren(childs, f"{originalObject.name}_VertexProxy_{str(vert.index)}") 82 | if vertexProxy is None: 83 | vertexProxy = bpy.data.objects.new(f"{originalObject.name}_VertexProxy_{str(vert.index)}", None) 84 | vertexProxy.empty_display_size = 0.15 85 | vertexProxy.empty_display_type = "PLAIN_AXES" 86 | 87 | vertexProxy.parent = originalObject 88 | vertexProxy.parent_type = "VERTEX" 89 | vertexProxy.parent_vertices = [vert.index] * 3 90 | 91 | vertexCollection.objects.link(vertexProxy) 92 | 93 | #this is dumb why do i have to do it this way blender? 94 | if isNewBone: 95 | bpy.ops.object.mode_set(mode="POSE") 96 | boneObject = armatureObject.pose.bones["VertexBone_"+str(vert.index)] 97 | copyloc = boneObject.constraints.new("COPY_LOCATION") 98 | copyloc.target = vertexProxy 99 | 100 | bpy.ops.object.mode_set(mode="OBJECT") 101 | #and naturally yeet all existing modifiers 102 | bpy.context.view_layer.objects.active = newObject 103 | newObject.modifiers.clear() 104 | if bpy.context.object.rigid_body is not None: 105 | bpy.ops.rigidbody.object_remove() 106 | 107 | #newObject.parent = armatureObject 108 | #newObject.parent_type = "ARMATURE" 109 | armModifier = newObject.modifiers.new(name="VertexBoned", type="ARMATURE") 110 | armModifier.object = armatureObject 111 | 112 | 113 | #original has armature? copy that and join with the newly created armature 114 | originalArmMod = FindModifier(originalObject, bpy.types.ArmatureModifier) 115 | if originalArmMod is not None: 116 | proxyArmature = originalArmMod.object.copy() 117 | proxyArmature.data = originalArmMod.object.data.copy() 118 | vertexCollection.objects.link(proxyArmature) 119 | 120 | bpy.context.view_layer.objects.active = proxyArmature 121 | bpy.ops.object.mode_set(mode="POSE") 122 | originalRootName = proxyArmature.pose.bones[0].name 123 | for bone in proxyArmature.pose.bones: 124 | bone.matrix_basis = Matrix() #reset to identity otherwise the armature will have whatever pose was in the current frame 125 | originalBone = originalArmMod.object.pose.bones.get(bone.name) 126 | 127 | copybone = bone.constraints.new("COPY_TRANSFORMS") 128 | copybone.target = originalArmMod.object 129 | copybone.subtarget = originalBone.name 130 | copybone.owner_space = "LOCAL" 131 | copybone.target_space = "LOCAL" 132 | 133 | #and now for the magic trick that allows it to work with goldsrc exporters: 134 | armModifier.object = proxyArmature 135 | proxyArmature.select_set(True) 136 | armatureObject.select_set(True) 137 | bpy.ops.object.join() 138 | bpy.ops.object.select_all(action="DESELECT") 139 | 140 | #fix weights 141 | bpy.ops.object.mode_set(mode="OBJECT") 142 | bpy.context.view_layer.objects.active = newObject 143 | for vgroup in newObject.vertex_groups: 144 | if vgroup.name[:11] == "VertexBone_": 145 | continue 146 | 147 | vgroup.remove(vertList) 148 | 149 | bpy.context.view_layer.objects.active = originalObject 150 | bpy.ops.object.mode_set(mode="EDIT") 151 | return {"FINISHED"} 152 | 153 | class ZODEUTILS_VERTEXBONE_OT_MakeVertexBoneObject(bpy.types.Operator): 154 | """VertexBone: Make a vertexbone proxy setup from selected object""" 155 | bl_idname = "zodeutils_vertexbone.makeobject" 156 | bl_label = "VertexBones: make from selected" 157 | bl_options = {"UNDO"} 158 | 159 | def execute(self, context): 160 | if not bpy.context.object.mode == "OBJECT": 161 | return {"FINISHED"} 162 | 163 | if len(bpy.context.selected_objects) < 1: 164 | return {"FINISHED"} 165 | 166 | selectList = bpy.context.selected_objects 167 | 168 | for originalObject in selectList: 169 | bpy.ops.object.mode_set(mode="OBJECT") 170 | bpy.ops.object.select_all(action="DESELECT") 171 | 172 | #setup collection and object 173 | vertexCollection = FindCollectionByName("VertexBoned") 174 | if vertexCollection is None: 175 | vertexCollection = bpy.data.collections.new("VertexBoned") 176 | bpy.context.scene.collection.children.link(vertexCollection) 177 | 178 | newObject = GetFromCollection(vertexCollection, f"{originalObject.name}_vbproxy") 179 | if newObject is None: 180 | newObject = originalObject.copy() 181 | newObject.name = f"{originalObject.name}_vbproxy" 182 | newObject.data = originalObject.data.copy() 183 | newObject.animation_data_clear() 184 | vertexCollection.objects.link(newObject) 185 | 186 | if newObject.data.shape_keys is not None: 187 | newObject.active_shape_key_index = 0 188 | newObject.shape_key_clear() 189 | 190 | armature = None 191 | armatureObject = GetFromCollection(vertexCollection, f"{originalObject.name}_vbarm") 192 | if armatureObject is None: 193 | armature = bpy.data.armatures.new(name=f"{originalObject.name}_vbarm") 194 | armatureObject = bpy.data.objects.new(name=f"{originalObject.name}_vbarm", object_data=armature) 195 | vertexCollection.objects.link(armatureObject) 196 | else: 197 | armature = armatureObject.data 198 | 199 | #setup proxies 200 | bpy.context.view_layer.objects.active = armatureObject 201 | bpy.ops.object.mode_set(mode="EDIT") 202 | bone = GetFromEditBones(armature, f"{originalObject.name}_VertexBone_obj") 203 | isNewBone = False 204 | if bone is None: 205 | bone = armature.edit_bones.new(name=f"{originalObject.name}_VertexBone_obj") 206 | bone.head = newObject.matrix_world.to_translation() 207 | bone.tail = bone.head + Vector((0,0.15,0)) 208 | isNewBone = True 209 | 210 | vertList = [] 211 | for vert in newObject.data.vertices: 212 | vertList.append(vert.index) 213 | 214 | vertexGroup = GetFromVertexGroups(newObject, f"{originalObject.name}_VertexBone_obj") 215 | if vertexGroup is None: 216 | vertexGroup = newObject.vertex_groups.new(name=f"{originalObject.name}_VertexBone_obj") 217 | vertexGroup.add(vertList, 1.0, "REPLACE") 218 | 219 | if isNewBone: 220 | bpy.ops.object.mode_set(mode="POSE") 221 | boneObject = armatureObject.pose.bones[f"{originalObject.name}_VertexBone_obj"] 222 | copytrans = boneObject.constraints.new("COPY_TRANSFORMS") 223 | copytrans.target = originalObject 224 | 225 | bpy.ops.object.mode_set(mode="OBJECT") 226 | #and naturally yeet all existing modifiers 227 | bpy.context.view_layer.objects.active = newObject 228 | newObject.modifiers.clear() 229 | if bpy.context.object.rigid_body is not None: 230 | bpy.ops.rigidbody.object_remove() 231 | 232 | armModifier = newObject.modifiers.new(name="VertexBoned", type="ARMATURE") 233 | armModifier.object = armatureObject 234 | 235 | bpy.context.view_layer.objects.active = originalObject 236 | 237 | return {"FINISHED"} --------------------------------------------------------------------------------