856 lines
34 KiB
Python
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()
|