#!/usr/bin/python3 # copyright (c) 2018- polygoniq xyz s.r.o. # ##### BEGIN GPL LICENSE BLOCK ##### # # This program is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License # as published by the Free Software Foundation; either version 2 # of the License, or (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # # ##### END GPL LICENSE BLOCK ##### import os import sys import blf import bpy_extras.view3d_utils import collections import mathutils import typing import bpy import tempfile import logging import logging.handlers import importlib root_logger = logging.getLogger("polygoniq") logger = logging.getLogger(f"polygoniq.{__name__}") if not getattr(root_logger, "polygoniq_initialized", False): root_logger_formatter = logging.Formatter( "P%(process)d:%(asctime)s:%(name)s:%(levelname)s: [%(filename)s:%(lineno)d] %(message)s", "%H:%M:%S", ) try: root_logger.setLevel(int(os.environ.get("POLYGONIQ_LOG_LEVEL", "20"))) except (ValueError, TypeError): root_logger.setLevel(20) root_logger.propagate = False root_logger_stream_handler = logging.StreamHandler() root_logger_stream_handler.setFormatter(root_logger_formatter) root_logger.addHandler(root_logger_stream_handler) try: log_path = os.path.join(tempfile.gettempdir(), "polygoniq_logs") os.makedirs(log_path, exist_ok=True) root_logger_handler = logging.handlers.TimedRotatingFileHandler( os.path.join(log_path, f"blender_addons.txt"), when="h", interval=1, backupCount=2, utc=True, ) root_logger_handler.setFormatter(root_logger_formatter) root_logger.addHandler(root_logger_handler) except: logger.exception( f"Can't create rotating log handler for polygoniq root logger " f"in module \"{__name__}\", file \"{__file__}\"" ) setattr(root_logger, "polygoniq_initialized", True) logger.info( f"polygoniq root logger initialized in module \"{__name__}\", file \"{__file__}\" -----" ) # To comply with extension encapsulation, after the addon initialization: # - sys.path needs to stay the same as before the initialization # - global namespace can not contain any additional modules outside of __package__ # Dependencies for all 'production' addons are shipped in folder `./python_deps` # So we do the following: # - Add `./python_deps` to sys.path # - Import all dependencies to global namespace # - Manually remap the dependencies from global namespace in sys.modules to a subpackage of __package__ # - Clear global namespace of remapped dependencies # - Remove `./python_deps` from sys.path # - For developer experience, import "real" dependencies again, only if TYPE_CHECKING is True # See https://docs.blender.org/manual/en/4.2/extensions/addons.html#extensions-and-namespace # for more details ADDITIONAL_DEPS_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "python_deps")) try: if os.path.isdir(ADDITIONAL_DEPS_DIR) and ADDITIONAL_DEPS_DIR not in sys.path: sys.path.insert(0, ADDITIONAL_DEPS_DIR) dependencies = { "polib", "hatchery", # hatchery is used for bounding box calculations } for dependency in dependencies: logger.debug(f"Importing additional dependency {dependency}") dependency_module = importlib.import_module(dependency) local_module_name = f"{__package__}.{dependency}" sys.modules[local_module_name] = dependency_module for module_name in list(sys.modules.keys()): if module_name.startswith(tuple(dependencies)): del sys.modules[module_name] from . import polib from . import hatchery from . import preferences from . import utils from . import image_sizer from . import object_render_estimator from . import memory_usage from . import mesh_decimation from . import derivative_generator if typing.TYPE_CHECKING: import polib import hatchery finally: if ADDITIONAL_DEPS_DIR in sys.path: sys.path.remove(ADDITIONAL_DEPS_DIR) bl_info = { "name": "memsaver_personal", "author": "polygoniq xyz s.r.o.", "version": (1, 2, 2), # bump doc_url as well! "blender": (3, 3, 0), "location": "memsaver panel in the polygoniq tab in the sidebar of the 3D View window", "description": "", "category": "System", "doc_url": "https://docs.polygoniq.com/memsaver/1.2.2/", "tracker_url": "https://polygoniq.com/discord/", } telemetry = polib.get_telemetry("memsaver") telemetry.report_addon(bl_info, __file__) ADDON_CLASSES: typing.List[typing.Type] = [] class ImageSizerOperatorBase(bpy.types.Operator): @staticmethod def get_target_images(context: bpy.types.Context) -> typing.Set[bpy.types.Image]: operator_target = preferences.get_preferences(context).operator_target if operator_target == 'SELECTED_OBJECTS': return { image for obj in context.selected_objects for image in utils.get_images_used_in_object(obj) } elif operator_target == 'SCENE_OBJECTS': return { image for obj in context.scene.objects for image in utils.get_images_used_in_object(obj) } elif operator_target == 'ALL_OBJECTS': return { image for obj in bpy.data.objects for image in utils.get_images_used_in_object(obj) } elif operator_target == 'ALL_IMAGES_EXCEPT_HDR_EXR': return { img for img in bpy.data.images if not img.filepath.lower().endswith((".hdr", ".exr")) } elif operator_target == 'ALL_HDR_EXR_IMAGES': return { img for img in bpy.data.images if img.filepath.lower().endswith((".hdr", ".exr")) } elif operator_target == 'ALL_IMAGES': return set(bpy.data.images) else: raise ValueError(f"Unknown selection target '{operator_target}'") def invoke(self, context: bpy.types.Context, event: bpy.types.Event): return context.window_manager.invoke_props_dialog(self) def draw(self, context: bpy.types.Context) -> None: prefs = preferences.get_preferences(context) self.layout.prop(prefs, "operator_target", text="") @polib.log_helpers_bpy.logged_operator class ChangeImageSize(ImageSizerOperatorBase): bl_idname = "memsaver.change_image_size" bl_label = "Change Image Size" bl_description = ( "Change images of given objects, generate lower resolution images on demand " "if necessary" ) bl_options = {'REGISTER'} def draw(self, context: bpy.types.Context): super().draw(context) prefs = preferences.get_preferences(context) self.layout.prop(prefs, "change_size_desired_size") if prefs.change_size_desired_size == 'CUSTOM': self.layout.prop(prefs, "change_size_custom_size") @polib.utils_bpy.blender_cursor('WAIT') def execute(self, context: bpy.types.Context): prefs = preferences.get_preferences(context) cache_path = prefs.get_cache_path() images = ChangeImageSize.get_target_images(context) logger.info(f"Working with target images: {images}") logger.info(f"desired_size={prefs.change_size_desired_size}") logger.info(f"custom_size={prefs.change_size_custom_size}") context.window_manager.progress_begin(0, len(images)) progress = 0 desired_size = ( prefs.change_size_custom_size if prefs.change_size_desired_size == 'CUSTOM' else int(prefs.change_size_desired_size) ) for image in images: try: image_sizer.change_image_size(cache_path, image, desired_size) except: logger.exception(f"Uncaught exception while changing size of image {image.name}") self.report( {'WARNING'}, f"Errors encountered when changing size of image {image.name}, skipping...", ) progress += 1 context.window_manager.progress_update(progress) context.window_manager.progress_end() return {'FINISHED'} ADDON_CLASSES.append(ChangeImageSize) @polib.log_helpers_bpy.logged_operator class AdaptiveOptimize(ImageSizerOperatorBase): bl_idname = "memsaver.adaptive_optimize" bl_label = "Adaptive Optimize" bl_description = ( "Optionally change image size of given objects based on how large the " "objects appear in the render based on active camera, generate lower resolution images " "if necessary. Optionally decimate far away meshes based on the distance from camera" ) bl_options = {'REGISTER'} def draw(self, context: bpy.types.Context): super().draw(context) prefs = preferences.get_preferences(context) self.layout.prop(prefs, "adaptive_image_enabled") if prefs.adaptive_image_enabled: box = self.layout.box() box.prop(prefs, "adaptive_image_quality_factor") box.prop(prefs, "adaptive_image_minimum_size") box.prop(prefs, "adaptive_image_maximum_size") self.layout.separator() self.layout.prop(prefs, "adaptive_mesh_enabled") if prefs.adaptive_mesh_enabled: box = self.layout.box() box.prop(prefs, "adaptive_mesh_full_quality_distance") box.prop(prefs, "adaptive_mesh_lowest_quality_distance") box.prop(prefs, "adaptive_mesh_lowest_quality_decimation_ratio", slider=True) box.prop(prefs, "adaptive_mesh_lowest_face_count") self.layout.separator() self.layout.prop(prefs, "adaptive_animation_mode") @staticmethod def get_target_objects(context: bpy.types.Context) -> typing.Iterable[bpy.types.Object]: operator_target = preferences.get_preferences(context).operator_target if operator_target == 'SELECTED_OBJECTS': return context.selected_objects elif operator_target == 'SCENE_OBJECTS': return context.scene.objects elif operator_target == 'ALL_OBJECTS': return bpy.data.objects elif operator_target == 'ALL_IMAGES_EXCEPT_HDR_EXR': # ALL_IMAGES_EXCEPT_HDR_EXR doesn't make sense for this operator, we do the same thing # as ALL_OBJECTS return bpy.data.objects elif operator_target == 'ALL_HDR_EXR_IMAGES': # ALL_HDR_EXR_IMAGES doesn't make sense for this operator, we do the same thing # as ALL_OBJECTS return bpy.data.objects elif operator_target == 'ALL_IMAGES': # ALL_IMAGES doesn't make sense for this operator, we do the same thing as ALL_OBJECTS return bpy.data.objects else: raise ValueError(f"Unknown selection target '{operator_target}'") @classmethod def poll(cls, context: bpy.types.Context): return context.scene is not None and context.scene.camera is not None @polib.utils_bpy.blender_cursor('WAIT') def execute(self, context: bpy.types.Context): prefs = preferences.get_preferences(context) objects = list(AdaptiveOptimize.get_target_objects(context)) logger.info(f"Working with target objects: {objects}") logger.info(f"image_enabled={prefs.adaptive_image_enabled}") if prefs.adaptive_image_enabled: adaptive_image_minimum_size = int(prefs.adaptive_image_minimum_size) adaptive_image_maximum_size = int(prefs.adaptive_image_maximum_size) if adaptive_image_minimum_size > adaptive_image_maximum_size: error_msg = ( f"Selected Minimal image size '{adaptive_image_minimum_size}' is " f"bigger than Maximal size '{adaptive_image_maximum_size}'!" ) logger.error(f"{error_msg} Aborting..") self.report({'ERROR'}, f"{error_msg} Please select valid values.") return {'CANCELLED'} logger.info(f"image_quality_factor={prefs.adaptive_image_quality_factor}") logger.info(f"image_minimum_size={adaptive_image_minimum_size}") logger.info(f"image_maximum_size={adaptive_image_maximum_size}") logger.info(f"mesh_enabled={prefs.adaptive_mesh_enabled}") if prefs.adaptive_mesh_enabled: if ( prefs.adaptive_mesh_full_quality_distance > prefs.adaptive_mesh_lowest_quality_distance ): error_msg = ( f"Selected Full quality distance " f"'{prefs.adaptive_mesh_full_quality_distance}' is bigger than Lowest " f"quality distance '{prefs.adaptive_mesh_lowest_quality_distance}'!" ) logger.error(f"{error_msg} Aborting..") self.report({'ERROR'}, f"{error_msg} Please select valid values.") return {'CANCELLED'} logger.info(f"mesh_minimum_distance={prefs.adaptive_mesh_full_quality_distance}") logger.info( f"mesh_lowest_quality_distance={prefs.adaptive_mesh_lowest_quality_distance}" ) logger.info( f"mesh_lowest_quality_decimation_ratio=" f"{prefs.adaptive_mesh_lowest_quality_decimation_ratio}" ) logger.info(f"animation_mode={prefs.adaptive_animation_mode}") if prefs.adaptive_image_enabled: context.window_manager.progress_begin(0, 1 + len(objects)) size_map_generator = object_render_estimator.get_size_map_for_objects_current_frame if prefs.adaptive_animation_mode: size_map_generator = object_render_estimator.get_size_map_for_objects_animation_mode image_size_map = size_map_generator( context.scene, context.scene.camera, objects, prefs.adaptive_image_quality_factor, adaptive_image_minimum_size, adaptive_image_maximum_size, True, ) progress = 1 context.window_manager.progress_update(progress) cache_path = prefs.get_cache_path() for image, change_size_desired_size in image_size_map.items(): try: image_sizer.change_image_size(cache_path, image, change_size_desired_size) except: logger.exception( f"Uncaught exception while changing size of image {image.name}" ) self.report( {'WARNING'}, f"Errors encountered when changing size of image {image.name}, skipping...", ) progress += 1 context.window_manager.progress_update(progress) context.window_manager.progress_end() if prefs.adaptive_mesh_enabled: objects_decimation_ratio_map_generator = ( mesh_decimation.get_objects_decimation_ratio_map_current_frame ) if prefs.adaptive_animation_mode: objects_decimation_ratio_map_generator = ( mesh_decimation.get_objects_decimation_ratio_map_animation_mode ) objects_decimation_ratio_map = objects_decimation_ratio_map_generator( context.scene, context.scene.camera, objects, prefs.adaptive_mesh_full_quality_distance, prefs.adaptive_mesh_lowest_quality_distance, prefs.adaptive_mesh_lowest_quality_decimation_ratio, prefs.adaptive_mesh_lowest_face_count, ) for obj, decimation_ratio in objects_decimation_ratio_map.items(): logger.info(f"Setting decimation_ratio {decimation_ratio} for object {obj.name}") mesh_decimation.set_decimation_ratio(obj, decimation_ratio) return {'FINISHED'} ADDON_CLASSES.append(AdaptiveOptimize) @polib.log_helpers_bpy.logged_operator class RevertImagesToOriginals(ImageSizerOperatorBase): bl_idname = "memsaver.revert_images_to_originals" bl_label = "Revert Images to Originals" bl_description = ( "Change given images back to their originals. This does not delete lower " "resolution images that may have been generated previously" ) bl_options = {'REGISTER'} @polib.utils_bpy.blender_cursor('WAIT') def execute(self, context: bpy.types.Context): images = RevertImagesToOriginals.get_target_images(context) logger.info(f"Working with target images: {images}") for image in images: try: image_sizer.revert_to_original(image) except Exception as e: logger.exception( f"Uncaught exception while reverting image {image.name} to original" ) self.report( {'WARNING'}, f"Errors encountered when reverting image {image.name} to original, skipping...", ) return {'FINISHED'} ADDON_CLASSES.append(RevertImagesToOriginals) @polib.log_helpers_bpy.logged_operator class RevertMeshesToOriginals(ImageSizerOperatorBase): bl_idname = "memsaver.revert_meshes_to_originals" bl_label = "Revert Meshes to Originals" bl_description = "Removes any memsaver decimate modifiers if present" bl_options = {'REGISTER'} @polib.utils_bpy.blender_cursor('WAIT') def execute(self, context: bpy.types.Context): objects = AdaptiveOptimize.get_target_objects(context) logger.info(f"Working with target objects: {objects}") for obj in objects: try: mesh_decimation.revert_to_original(obj) except Exception as e: logger.exception( f"Uncaught exception while reverting object {obj.name} to original" ) self.report( {'WARNING'}, f"Errors encountered when reverting object {obj.name} to original, skipping...", ) return {'FINISHED'} ADDON_CLASSES.append(RevertMeshesToOriginals) @polib.log_helpers_bpy.logged_operator class CheckDerivatives(ImageSizerOperatorBase): bl_idname = "memsaver.check_derivatives" bl_label = "Check & Regenerate Images" bl_description = ( "Check that given images all have valid paths, if the path is invalid and " "it is a lower resolution derivative we re-generate it" ) bl_options = {'REGISTER'} @polib.utils_bpy.blender_cursor('WAIT') def execute(self, context: bpy.types.Context): prefs = preferences.get_preferences(context) cache_path = prefs.get_cache_path() logger.debug(f"Cache path: {cache_path}") images = CheckDerivatives.get_target_images(context) logger.info(f"Working with target images: {images}") for image in images: try: # Unlike in the post_load handler, here we insist on the currently set cache_path image_sizer.check_derivative(cache_path, image) except Exception as e: logger.exception(f"Uncaught exception while checking image {image.name}") self.report( {'WARNING'}, f"Errors encountered when checking image {image.name}, skipping..." ) return {'FINISHED'} ADDON_CLASSES.append(CheckDerivatives) @polib.log_helpers_bpy.logged_operator class PreviewAdaptiveOptimize(bpy.types.Operator): bl_idname = "memsaver.preview_adaptive_optimize" bl_label = "Preview Adaptive Optimize" bl_description = ( "Starts a preview mode to observe what image sizes and which mesh " "decimations will be generated when using Adaptive Optimize operator" ) bl_options = {'REGISTER'} bgl_2d_handler_ref = None is_running: bool = False obj_image_info_map: typing.DefaultDict[ bpy.types.Object, typing.List[typing.Tuple[bpy.types.Image, float, float, float, typing.Optional[str]]], ] = collections.defaultdict(list) obj_decimation_ratio_map: typing.DefaultDict[bpy.types.Object, float] = collections.defaultdict( lambda: 1.0 ) def draw(self, context: bpy.types.Context): self.layout.label(text="Preview sizes will be generated based on those settings") prefs = preferences.get_preferences(bpy.context) self.layout.prop(prefs, "adaptive_image_enabled") if prefs.adaptive_image_enabled: box = self.layout.box() box.prop(prefs, "adaptive_image_quality_factor") box.prop(prefs, "adaptive_image_minimum_size") box.prop(prefs, "adaptive_image_maximum_size") self.layout.separator() self.layout.prop(prefs, "adaptive_mesh_enabled") if prefs.adaptive_mesh_enabled: box = self.layout.box() box.prop(prefs, "adaptive_mesh_full_quality_distance") box.prop(prefs, "adaptive_mesh_lowest_quality_distance") box.prop(prefs, "adaptive_mesh_lowest_quality_decimation_ratio", slider=True) box.prop(prefs, "adaptive_mesh_lowest_face_count") self.layout.separator() self.layout.prop(prefs, "adaptive_animation_mode") def __init__(self): PreviewAdaptiveOptimize.bgl_2d_handler_ref = bpy.types.SpaceView3D.draw_handler_add( self.draw_px, (), 'WINDOW', 'POST_PIXEL' ) def __del__(self): # It is necessary to cancel the operator using __del__, because VSCode reloads # don't end the operator if it is running. self.cancel(bpy.context) def draw_px(self): prefs = preferences.get_preferences(bpy.context) font_size = prefs.overlay_text_size_px * bpy.context.preferences.view.ui_scale region = bpy.context.region rv3d = bpy.context.space_data.region_3d text_style = polib.render_bpy.TextStyle(font_size=font_size, color=prefs.overlay_text_color) for obj in bpy.context.selected_objects: texts = [] if prefs.adaptive_image_enabled: images = PreviewAdaptiveOptimize.obj_image_info_map.get(obj, None) if images is None: continue texts.extend([(self._format_image_size(*i), text_style) for i in images]) if prefs.adaptive_mesh_enabled: decimation_ratio = PreviewAdaptiveOptimize.obj_decimation_ratio_map.get(obj, 1.0) texts.append((self._format_mesh_decimation(decimation_ratio), text_style)) if len(texts) > 0: polib.render_bpy.text_box_3d(obj.location, 100, 10, 10, None, texts, region, rv3d) def modal(self, context: bpy.types.Context, event: bpy.types.Event): for area in context.window.screen.areas: if area.type == 'VIEW_3D': area.tag_redraw() if event.value == 'PRESS' and event.type == 'ESC': self.cancel(context) return {'FINISHED'} return {'PASS_THROUGH'} def cancel(self, context: bpy.types.Context): cls = type(self) if hasattr(cls, "bgl_2d_handler_ref") and cls.bgl_2d_handler_ref is not None: bpy.types.SpaceView3D.draw_handler_remove(cls.bgl_2d_handler_ref, 'WINDOW') cls.bgl_2d_handler_ref = None cls.is_running = False @polib.utils_bpy.blender_cursor('WAIT') def execute(self, context: bpy.types.Context): cls = type(self) cls.obj_image_info_map.clear() cls.obj_decimation_ratio_map.clear() prefs = preferences.get_preferences(context) if prefs.adaptive_image_enabled: adaptive_image_minimum_size = int(prefs.adaptive_image_minimum_size) adaptive_image_maximum_size = int(prefs.adaptive_image_maximum_size) if adaptive_image_minimum_size > adaptive_image_maximum_size: error_msg = ( f"Selected Minimal image size '{adaptive_image_minimum_size}' is " f"bigger than Maximal size '{adaptive_image_maximum_size}'!" ) logger.error(error_msg) self.report({'ERROR'}, f"{error_msg} Please select valid values.") return {'CANCELLED'} # Pre-compute the maps from all objects in the .blend file, then in draw_px display # only information about selected objects size_map_generator = object_render_estimator.get_size_map_for_objects_current_frame if prefs.adaptive_animation_mode: size_map_generator = object_render_estimator.get_size_map_for_objects_animation_mode image_size_map = size_map_generator( context.scene, context.scene.camera, bpy.data.objects, prefs.adaptive_image_quality_factor, adaptive_image_minimum_size, adaptive_image_maximum_size, True, ) for obj in bpy.data.objects: images = utils.get_images_used_in_object(obj) for img in images: orig_size = max(img.size[0], img.size[1]) assert orig_size >= 0, "negative original size!" if orig_size == 0: logger.warning(f"{img.name} has original size equal to 0!") new_size = image_size_map.get(img, 1) if img.packed_file is not None: cls.obj_image_info_map[obj].append( ( img, orig_size, orig_size, 1, "Image is PACKED, resizing is not possible!", ) ) else: cls.obj_image_info_map[obj].append( (img, orig_size, new_size, new_size / max(1, orig_size), None) ) cls.obj_image_info_map[obj].sort(key=lambda x: x[3], reverse=True) if prefs.adaptive_mesh_enabled: if ( prefs.adaptive_mesh_full_quality_distance > prefs.adaptive_mesh_lowest_quality_distance ): error_msg = ( f"Selected Full quality distance " f"'{prefs.adaptive_mesh_full_quality_distance}' is bigger than Lowest " f"quality distance '{prefs.adaptive_mesh_lowest_quality_distance}'!" ) logger.error(f"{error_msg} Aborting..") self.report({'ERROR'}, f"{error_msg} Please select valid values.") return {'CANCELLED'} objects_decimation_ratio_map_generator = ( mesh_decimation.get_objects_decimation_ratio_map_current_frame ) if prefs.adaptive_animation_mode: objects_decimation_ratio_map_generator = ( mesh_decimation.get_objects_decimation_ratio_map_animation_mode ) cls.obj_decimation_ratio_map = objects_decimation_ratio_map_generator( context.scene, context.scene.camera, bpy.data.objects, prefs.adaptive_mesh_full_quality_distance, prefs.adaptive_mesh_lowest_quality_distance, prefs.adaptive_mesh_lowest_quality_decimation_ratio, prefs.adaptive_mesh_lowest_face_count, ) cls.is_running = True context.window_manager.modal_handler_add(self) return {'RUNNING_MODAL'} def invoke(self, context: bpy.types.Context, event: bpy.types.Event): # If the operator is already running, don't do anything if PreviewAdaptiveOptimize.is_running: return {'PASS_THROUGH'} return context.window_manager.invoke_props_dialog(self) def _format_image_size( self, img: bpy.types.Image, orig_size: int, new_size: int, ratio: float, additional_info: typing.Optional[str], ) -> str: formatted_msg = f"{img.name}, {orig_size}px -> {new_size}px, {ratio * 100.0:.0f}%" if additional_info is not None: formatted_msg += f", {additional_info}" return formatted_msg def _format_mesh_decimation(self, decimation_ratio: float) -> str: if decimation_ratio == 1.0: return "Mesh, No decimation" else: return f"Mesh, {(1.0 - decimation_ratio)*100:.0f}% decimation" ADDON_CLASSES.append(PreviewAdaptiveOptimize) @polib.log_helpers_bpy.logged_panel class MemSaverPanel(bpy.types.Panel): bl_idname = "VIEW3D_PT_memsaver" bl_label = str(bl_info.get("name", "memsaver")).replace("_", " ") bl_space_type = 'VIEW_3D' bl_region_type = 'UI' bl_category = "polygoniq" bl_order = 30 bl_options = {'DEFAULT_CLOSED'} def draw_header(self, context: bpy.types.Context): try: self.layout.label( text="", icon_value=polib.ui_bpy.icon_manager.get_polygoniq_addon_icon_id("memsaver"), ) except KeyError: pass def draw_header_preset(self, context: bpy.types.Context) -> None: self.layout.operator(preferences.OpenCacheFolder.bl_idname, icon='FILEBROWSER', text="") self.layout.operator("preferences.addon_show", icon='SETTINGS').module = __package__ polib.ui_bpy.draw_doc_button(self.layout, __package__) def draw_preview_mode(self, context: bpy.types.Context): layout = self.layout col = layout.column(align=True) col.label(text="Select object to preview", icon='RESTRICT_SELECT_OFF') prefs = preferences.get_preferences(context) col.prop(prefs, "overlay_text_size_px") col.prop(prefs, "overlay_text_color", text="") col.separator() row = col.row() row.alert = True row.label(text="Press ESC to exit", icon='PANEL_CLOSE') def draw(self, context: bpy.types.Context): layout = self.layout if PreviewAdaptiveOptimize.is_running: self.draw_preview_mode(context) return any_generator_available = derivative_generator.is_generator_available() col = layout.column(align=True) if not any_generator_available: col.alert = True col.label(text="Failed to install Python image") col.label(text="processing library! Try to close") col.label(text="Blender and run it once as") col.label(text="administrator in order for") col.label(text="memsaver to install the missing") col.label(text="library.") col.label(text="You can also use Blender 3.5") col.label(text="or newer since it contains") col.label(text="the library by default.") row = layout.row(align=True) row.scale_x = row.scale_y = 1.4 row.operator(AdaptiveOptimize.bl_idname, text="Adaptive Optimize", icon='VIEW_PERSPECTIVE') row.operator(PreviewAdaptiveOptimize.bl_idname, text="", icon='VIEWZOOM') row.enabled = any_generator_available row = layout.row(align=True) row.operator(ChangeImageSize.bl_idname, text="Resize Images", icon='ARROW_LEFTRIGHT') row.enabled = any_generator_available col = layout.column(align=True) col.operator(RevertImagesToOriginals.bl_idname, icon='LOOP_BACK') col.operator(RevertMeshesToOriginals.bl_idname, icon='LOOP_BACK') row = col.row(align=True) row.operator(CheckDerivatives.bl_idname) row.enabled = any_generator_available col.separator() col.operator(memory_usage.EstimateMemoryUsage.bl_idname, icon='MEMORY') ADDON_CLASSES.append(MemSaverPanel) @bpy.app.handlers.persistent def memsaver_load_post(_) -> None: """Go through all bpy.data.images and check derivatives, regen if necessary Whenever a .blend is loaded we have to go through all images and check that their derivatives are present where they should be, if they are not we will regenerate. This can also be achieved manually with the "Check & Regenerate" button/operator from the panel. """ logger.info( f"Checking all bpy.data.images' derivatives as part of a load_post handler " f"(bpy.data.filepath=\"{bpy.data.filepath}\")..." ) for image in bpy.data.images: # We purposefully infer cache_path from the image.filepath to avoid regenerating everything # when preferences get corrupted or changed try: image_sizer.check_derivative(None, image) except: logger.exception(f"Uncaught exception while checking image {image.name}") def register(): preferences.register() memory_usage.register() for cls in ADDON_CLASSES: bpy.utils.register_class(cls) bpy.app.handlers.load_post.append(memsaver_load_post) def unregister(): bpy.app.handlers.load_post.remove(memsaver_load_post) for cls in reversed(ADDON_CLASSES): bpy.utils.unregister_class(cls) memory_usage.unregister() preferences.unregister() # Remove all nested modules from module cache, more reliable than importlib.reload(..) # Idea by BD3D / Jacques Lucke for module_name in list(sys.modules.keys()): if module_name.startswith(__package__): del sys.modules[module_name] # We clear the master 'polib' icon manager to prevent ResourceWarning and leaks. # If other addon uses the icon_manager, the previews will be reloaded on demand. polib.ui_bpy.icon_manager.clear()