Files
blender-portable-repo/extensions/user_default/memsaver_personal/__init__.py
T
2026-03-17 14:30:01 -06:00

856 lines
34 KiB
Python

#!/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()