2025-07-01
This commit is contained in:
@@ -0,0 +1,855 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,20 @@
|
||||
schema_version = "1.0.0"
|
||||
|
||||
id = "memsaver_personal"
|
||||
version = "1.2.2"
|
||||
name = "memsaver_personal"
|
||||
tagline = "Save memory by adjusting sizes of images based on their size in render"
|
||||
maintainer = "polygoniq <ahoy@polygoniq.com>"
|
||||
type = "add-on"
|
||||
|
||||
permissions = ["files", "network"]
|
||||
website = "https://docs.polygoniq.com/memsaver/1.2.2/"
|
||||
tags = ["Material", "Mesh", "Scene", "User Interface"]
|
||||
blender_version_min = "4.2.0"
|
||||
|
||||
license = [
|
||||
"SPDX:GPL-3.0-or-later",
|
||||
]
|
||||
copyright = [
|
||||
"2018- polygoniq xyz s.r.o",
|
||||
]
|
||||
@@ -0,0 +1,207 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import os
|
||||
import sys
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
GENERATORS = []
|
||||
|
||||
|
||||
try:
|
||||
# OpenImageIO is installed by default in Blender 3.5+
|
||||
import OpenImageIO as oiio
|
||||
|
||||
def generate_derivative_OIIO(
|
||||
original_abs_path: str, derivative_path: str, side_size: int
|
||||
) -> bool:
|
||||
_, original_ext = os.path.splitext(original_abs_path)
|
||||
_, derivative_ext = os.path.splitext(derivative_path)
|
||||
assert original_ext == derivative_ext
|
||||
|
||||
# TODO: More extensions?
|
||||
# TODO: Update faq.md in docs when adding new extensions
|
||||
if original_ext.lower() not in {
|
||||
".bmp",
|
||||
".exr",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".jpe",
|
||||
".jif",
|
||||
".jfif",
|
||||
".png",
|
||||
".tga",
|
||||
".hdr",
|
||||
".tiff",
|
||||
".tif",
|
||||
".webp",
|
||||
}:
|
||||
logger.warning(
|
||||
f"Can't generate derivative of {original_abs_path} because its extension "
|
||||
f"{original_ext} is not supported by OpenImageIO."
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
original_image = oiio.ImageInput.open(original_abs_path)
|
||||
if original_image is None:
|
||||
raise RuntimeError(
|
||||
f"OIIO returned None when trying to load {original_abs_path}. "
|
||||
f"OIIO error: {oiio.geterror()}"
|
||||
)
|
||||
original_spec = original_image.spec()
|
||||
except:
|
||||
logger.exception(
|
||||
f"Uncaught exception while loading {original_abs_path} image with OpenImageIO"
|
||||
)
|
||||
return False
|
||||
|
||||
original_width = original_spec.width
|
||||
original_height = original_spec.height
|
||||
if side_size >= original_width and side_size >= original_height:
|
||||
logger.warning(
|
||||
f"Refused to generate derivative of {original_abs_path} of side size {side_size} "
|
||||
f"because the original size {original_width}x{original_height} is larger or equal!"
|
||||
)
|
||||
return False
|
||||
|
||||
# Compute the new dimensions while maintaining aspect ratio
|
||||
if original_width >= original_height:
|
||||
new_width = side_size
|
||||
new_height = int(side_size * original_height / original_width)
|
||||
else:
|
||||
new_height = side_size
|
||||
new_width = int(side_size * original_width / original_height)
|
||||
|
||||
try:
|
||||
derivative_spec = oiio.ImageSpec(
|
||||
new_width, new_height, original_spec.nchannels, original_spec.format
|
||||
)
|
||||
icc_profile = original_spec.get_string_attribute("ICCProfile")
|
||||
if icc_profile != "":
|
||||
derivative_spec.attribute("ICCProfile", oiio.TypeDesc.TypeString, icc_profile)
|
||||
original_buf = oiio.ImageBuf(original_abs_path)
|
||||
derivative_buf = oiio.ImageBuf(derivative_spec)
|
||||
oiio.ImageBufAlgo.resize(derivative_buf, original_buf, filtername="lanczos3")
|
||||
derivative_buf.write(derivative_path)
|
||||
except:
|
||||
logger.exception(
|
||||
f"Uncaught exception while generating derivative for {original_abs_path} "
|
||||
f"with OpenImageIO. OIIO error: {oiio.geterror()}"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
f"Generated derivative of size {new_width}x{new_height} from original "
|
||||
f"{original_abs_path} using OpenImageIO"
|
||||
)
|
||||
return True
|
||||
|
||||
GENERATORS.append(generate_derivative_OIIO)
|
||||
logger.info("OpenImageIO successfully imported and will be used for memsaver.")
|
||||
except ImportError:
|
||||
logger.info("OpenImageIO could not be imported, we can't use it for memsaver.")
|
||||
|
||||
|
||||
if len(GENERATORS) == 0: # We will only try to use PIL/Pillow if OpenImageIO is not present
|
||||
try:
|
||||
# Install modules which are not in Blender python by default
|
||||
# https://conference.blender.org/2022/presentations/1405/
|
||||
# or change to this: https://blender.stackexchange.com/questions/168448/bundling-python-library-with-addon
|
||||
try:
|
||||
import PIL.Image
|
||||
except ModuleNotFoundError as ex:
|
||||
import subprocess
|
||||
|
||||
python_exe = sys.executable
|
||||
args = [python_exe, "-m", "ensurepip", "--upgrade", "--default-pip"]
|
||||
if subprocess.call(args=args) != 0:
|
||||
raise RuntimeError("Couldn't ensure pip in Blender's python!")
|
||||
args = [python_exe, "-m", "pip", "install", "--upgrade", "Pillow"]
|
||||
if subprocess.call(args=args) != 0:
|
||||
raise RuntimeError("Couldn't install Pillow module in Blender's python!")
|
||||
import PIL.Image
|
||||
|
||||
def generate_derivative_PIL(
|
||||
original_abs_path: str, derivative_path: str, side_size: int
|
||||
) -> bool:
|
||||
_, original_ext = os.path.splitext(original_abs_path)
|
||||
_, derivative_ext = os.path.splitext(derivative_path)
|
||||
assert original_ext == derivative_ext
|
||||
|
||||
# TODO: More extensions?
|
||||
# TODO: Update faq.md in docs when adding new extensions
|
||||
if original_ext.lower() not in {
|
||||
".bmp",
|
||||
".jpg",
|
||||
".jpeg",
|
||||
".jpe",
|
||||
".jif",
|
||||
".jfif",
|
||||
".png",
|
||||
".tga",
|
||||
".tiff",
|
||||
".tif",
|
||||
".webp",
|
||||
}:
|
||||
logger.warning(
|
||||
f"Can't generate derivative of {original_abs_path} because its extension "
|
||||
f"{original_ext} is not supported by PIL."
|
||||
)
|
||||
return False
|
||||
|
||||
try:
|
||||
original_image = PIL.Image.open(original_abs_path)
|
||||
except:
|
||||
logger.exception(
|
||||
f"Uncaught exception while loading {original_abs_path} image with PIL"
|
||||
)
|
||||
return False
|
||||
|
||||
original_width, original_height = original_image.size
|
||||
if side_size >= original_width and side_size >= original_height:
|
||||
logger.warning(
|
||||
f"Refused to generate derivative of {original_abs_path} of side size {side_size} "
|
||||
f"because the original size {original_width}x{original_height} is larger or equal!"
|
||||
)
|
||||
return False
|
||||
|
||||
derivative_image = original_image.copy()
|
||||
# thumbnail() creates image no larger than side_size while keeping original aspect ratio
|
||||
derivative_image.thumbnail((side_size, side_size), PIL.Image.Resampling.LANCZOS)
|
||||
derivative_image.save(
|
||||
derivative_path, icc_profile=original_image.info.get("icc_profile", b"")
|
||||
)
|
||||
logger.info(
|
||||
f"Generated derivative of size {side_size} from original {original_abs_path} using PIL"
|
||||
)
|
||||
return True
|
||||
|
||||
GENERATORS.append(generate_derivative_PIL)
|
||||
logger.info("PIL/Pillow successfully imported and will be used for memsaver.")
|
||||
except ImportError:
|
||||
logger.error("PIL/Pillow could not be imported, we can't use it for memsaver.")
|
||||
|
||||
|
||||
if len(GENERATORS) == 0:
|
||||
logger.error("No generators available, memsaver won't be able to create derivative images!")
|
||||
|
||||
|
||||
def generate_derivative(original_abs_path: str, derivative_path: str, side_size: int) -> bool:
|
||||
assert side_size > 0
|
||||
logger.debug(
|
||||
f"Asked to generate derivative of size {side_size} from original {original_abs_path}"
|
||||
)
|
||||
|
||||
for generator in GENERATORS:
|
||||
if generator(original_abs_path, derivative_path, side_size):
|
||||
return True
|
||||
|
||||
# either it's not supported or its side size is already smaller or equal
|
||||
return False
|
||||
|
||||
|
||||
def is_generator_available() -> bool:
|
||||
return len(GENERATORS) > 0
|
||||
@@ -0,0 +1,431 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import typing
|
||||
import bpy
|
||||
import os
|
||||
import glob
|
||||
import hashlib
|
||||
import functools
|
||||
import dataclasses
|
||||
import logging
|
||||
from . import derivative_generator
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
ORIGINAL_PATH_PROPERTY_NAME = "memsaver_original_path"
|
||||
# TODO: change value appropriately, test whether the change causes an issue
|
||||
DERIVATIVE_FILENAME_PROPERTY_NAME = "memsaver_derivative_path"
|
||||
DERIVATIVE_SIZE_PROPERTY_NAME = "memsaver_derivative_size"
|
||||
|
||||
|
||||
def remove_memsaver_properties(image: bpy.types.Image) -> None:
|
||||
image[DERIVATIVE_FILENAME_PROPERTY_NAME] = None
|
||||
image[ORIGINAL_PATH_PROPERTY_NAME] = None
|
||||
image[DERIVATIVE_SIZE_PROPERTY_NAME] = 0
|
||||
|
||||
|
||||
def was_filepath_overwritten_on_derivative(image: bpy.types.Image) -> bool:
|
||||
"""Verifies whether an image derivative is pointing to a different file then memsaver expects.
|
||||
|
||||
If the image derivative's filepath is pointing to a different image then expected,
|
||||
it was changed outside of memsaver. Such image can no longer be considered an image derivative.
|
||||
"""
|
||||
# image is generated or packed, memsaver does not handle the filepath
|
||||
if image.filepath in {None, ""}:
|
||||
return False
|
||||
|
||||
# the image is not an image derivative, memsaver does not handle the filepath
|
||||
if image.get(DERIVATIVE_FILENAME_PROPERTY_NAME, None) is None:
|
||||
return False
|
||||
|
||||
return (
|
||||
image[DERIVATIVE_FILENAME_PROPERTY_NAME].lower()
|
||||
!= bpy.path.basename(image.filepath).lower()
|
||||
)
|
||||
|
||||
|
||||
def get_filepath_hash(abs_path: str) -> str:
|
||||
"""Given an absolute file path, return a hash
|
||||
|
||||
This hash is the base for image derivative paths.
|
||||
"""
|
||||
# TODO: We generate the hash from absolute path, is that OK?
|
||||
assert os.path.isabs(abs_path)
|
||||
return hashlib.sha256(abs_path.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def get_original_path(image: bpy.types.Image) -> typing.Optional[str]:
|
||||
# original path set by memsaver is incorrect if the derivative's filepath was changed
|
||||
# outside of memsaver, so there is no original path to get
|
||||
assert not was_filepath_overwritten_on_derivative(image)
|
||||
|
||||
original_path = image.get(ORIGINAL_PATH_PROPERTY_NAME, None)
|
||||
if original_path is not None:
|
||||
assert isinstance(original_path, str)
|
||||
return typing.cast(str, original_path)
|
||||
return None
|
||||
|
||||
|
||||
def ensure_original_path(image: bpy.types.Image) -> str:
|
||||
if was_filepath_overwritten_on_derivative(image):
|
||||
remove_memsaver_properties(image)
|
||||
original_path = get_original_path(image)
|
||||
if original_path in {None, ""}:
|
||||
# we don't absolutize the path, we will do that only just before we have to
|
||||
image[ORIGINAL_PATH_PROPERTY_NAME] = image.filepath
|
||||
else:
|
||||
return typing.cast(str, original_path)
|
||||
|
||||
original_path = get_original_path(image)
|
||||
# it can be "" in case the image is generated or packed
|
||||
assert original_path is not None
|
||||
return typing.cast(str, original_path)
|
||||
|
||||
|
||||
def revert_to_original(image: bpy.types.Image) -> None:
|
||||
if was_filepath_overwritten_on_derivative(image):
|
||||
remove_memsaver_properties(image)
|
||||
logger.debug(
|
||||
f"Asked to revert image {image.name} to the original but it's filepath was changed "
|
||||
f"outside of memsaver, filepath: {image.filepath}."
|
||||
)
|
||||
return
|
||||
|
||||
original_path = get_original_path(image)
|
||||
if original_path in {None, ""}:
|
||||
remove_memsaver_properties(image)
|
||||
logger.debug(
|
||||
f"Asked to revert image {image.name} to the original but it already is, "
|
||||
f"filepath: {image.filepath}."
|
||||
)
|
||||
return
|
||||
|
||||
image.filepath = original_path
|
||||
remove_memsaver_properties(image)
|
||||
logger.debug(f"Reverted image {image.name} to the original at {original_path} (bpy path).")
|
||||
|
||||
|
||||
def is_derivative_stale(original_abs_path: str, derivative_path: str) -> bool:
|
||||
"""Returns True if derivative image does not exist or the original image has changed"""
|
||||
if not os.path.isfile(derivative_path):
|
||||
logger.debug(f"Derivative at path {derivative_path} is stale because it doesn't exist!")
|
||||
return True # doesn't exist, must be stale
|
||||
|
||||
# derivative has been modified earlier than original, must be stale
|
||||
if os.path.getmtime(derivative_path) <= os.path.getmtime(original_abs_path):
|
||||
logger.debug(
|
||||
f"Derivative at path {derivative_path} is stale because the original "
|
||||
f"{original_abs_path} has a newer modified date!"
|
||||
)
|
||||
return True
|
||||
|
||||
logger.debug(
|
||||
f"Derivative at path {derivative_path} is up to date, original at {original_abs_path}."
|
||||
)
|
||||
return False
|
||||
|
||||
|
||||
def find_sequence_num_indices(filename: str) -> typing.Tuple[int, int]:
|
||||
"""Returns tuple (num_start, num_end) with indices of the first (last) digit of sequence number.
|
||||
|
||||
Returns (-1, -1) if the filename doesn't contain sequence number.
|
||||
"""
|
||||
# Blender is very robust when considering sequence numbers, it iterates through filename
|
||||
# from end to start and the first numeric sequence found is considered to be the sequence number:
|
||||
# https://github.com/blender/blender/blob/36983fb5e4a297cb26855b6777a7ce25c12d7c49/source/blender/blenlib/intern/path_util.c#L70
|
||||
num_start, num_end = -1, -1
|
||||
found_digit = False
|
||||
for i in range(len(filename) - 1, -1, -1):
|
||||
if filename[i].isdigit():
|
||||
if found_digit:
|
||||
num_start = i
|
||||
else:
|
||||
found_digit = True
|
||||
num_start, num_end = i, i
|
||||
elif found_digit:
|
||||
break
|
||||
|
||||
return (num_start, num_end)
|
||||
|
||||
|
||||
def udim_matcher(filename: str, start_index: int) -> typing.Optional[typing.Tuple[str, str]]:
|
||||
"""Tries to match UDIM number in 'filename' starting at 'start_index'.
|
||||
|
||||
UDIM is one format for UDIM/Tiles textures, it has format: 1001 + u-tile + v-tile * 10 (e.g. 1012)
|
||||
Returns matched UDIM number and filename without UDIM or None if UDIM wasn't found.
|
||||
"""
|
||||
candidate = filename[start_index : start_index + 4]
|
||||
if len(candidate) < 4:
|
||||
return None
|
||||
if not candidate.isdigit():
|
||||
return None
|
||||
return (candidate, filename[:start_index] + filename[start_index + 4 :])
|
||||
|
||||
|
||||
def uvtile_matcher(filename: str, start_index: int) -> typing.Optional[typing.Tuple[str, str]]:
|
||||
"""Tries to match UV Tile string in 'filename' starting at 'start_index'.
|
||||
|
||||
UV Tile is one format for UDIM/Tiles textures, it has format: u(u-tile + 1)_v(v-tile + 1) (e.g. u1_v2)
|
||||
Returns matched UV Tile string and filename without UV Tile or None if UV Tile wasn't found.
|
||||
"""
|
||||
candidate = filename[start_index : start_index + 5]
|
||||
if len(candidate) < 5:
|
||||
return None
|
||||
if (
|
||||
candidate[0] == "u"
|
||||
and candidate[1].isdigit()
|
||||
and candidate[2] == "_"
|
||||
and candidate[3] == "v"
|
||||
and candidate[4].isdigit()
|
||||
):
|
||||
return (candidate, filename[:start_index] + filename[start_index + 5 :])
|
||||
return None
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class PathMapping:
|
||||
"""Mapping from an original to a derivate path.
|
||||
|
||||
Simple container with named items, used dataclass instead of NamedTuple to be consistent with
|
||||
OrigToDerivativePaths.
|
||||
"""
|
||||
|
||||
original: str
|
||||
derivative: str
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class OrigToDerivativePaths:
|
||||
"""Dataclass for storing mapping from original to derivative paths for one image datablock.
|
||||
|
||||
'assigned_path' is mapping from path that is originally assigned in image.filepath to the
|
||||
new path that should be assigned to image.filepath
|
||||
|
||||
'resource_paths' are mappings from actual image files on disc to the derivative files on the disc
|
||||
|
||||
These two are different for UDIMs where image.filepath has assigned path with <UDIM> or <UVTILE>
|
||||
token which doesn't correspond to any file on disc. Blender then references all image files
|
||||
matched by those tokens.
|
||||
"""
|
||||
|
||||
assigned_path: PathMapping
|
||||
resource_paths: typing.List[PathMapping]
|
||||
|
||||
|
||||
def get_derivative_paths(
|
||||
image: bpy.types.Image, cache_path: str, original_abs_path: str, side_size: int
|
||||
) -> typing.Optional[OrigToDerivativePaths]:
|
||||
"""Returns mapping from the original to the derivative image paths.
|
||||
|
||||
None is returned if we don't support resizing image of given type or derivative paths cannot be
|
||||
safely inferred.
|
||||
"""
|
||||
dir_path = os.path.dirname(original_abs_path)
|
||||
filename, ext = os.path.splitext(os.path.basename(original_abs_path))
|
||||
|
||||
if image.source == 'FILE':
|
||||
# Simple single image on the disc
|
||||
path_hash = get_filepath_hash(original_abs_path)
|
||||
derivative_path = os.path.join(cache_path, f"{path_hash}_{side_size}{ext}")
|
||||
path_map = PathMapping(original_abs_path, derivative_path)
|
||||
return OrigToDerivativePaths(path_map, [path_map])
|
||||
elif image.source == 'SEQUENCE':
|
||||
# Sequence of images, one of the images is assigned to image.filepath, Blender then finds
|
||||
# the rest of the images based on varying sequence number on the name.
|
||||
# e.g. image_0001_diffuse.png -> image_0002_diffuse.png, image_0003_diffuse.png, ..
|
||||
num_start, num_end = find_sequence_num_indices(filename)
|
||||
if num_start < 0 or num_end < 0:
|
||||
logger.error(
|
||||
f"Image {image.name} with filepath {image.filepath} of type 'SEQUENCE' does not "
|
||||
f"have a sequence number in the filename, skipping derivates generation!"
|
||||
)
|
||||
return None
|
||||
|
||||
# Generate derivative path for the assigned image
|
||||
main_digit = filename[num_start : num_end + 1]
|
||||
orig_filename_wo_digit = filename[:num_start] + filename[num_end + 1 :]
|
||||
path_hash = get_filepath_hash(os.path.join(dir_path, orig_filename_wo_digit + ext))
|
||||
derivative_path = os.path.join(cache_path, f"{path_hash}_{side_size}_{main_digit}{ext}")
|
||||
path_map = PathMapping(original_abs_path, derivative_path)
|
||||
result = OrigToDerivativePaths(path_map, [path_map])
|
||||
|
||||
# Generate derivative paths for all the corresponding sequence images found on discs
|
||||
for potential_seq_filepath in glob.glob(f"{dir_path}/*{ext}"):
|
||||
potential_seq_filename, _ = os.path.splitext(os.path.basename(potential_seq_filepath))
|
||||
digit = potential_seq_filename[num_start : num_end + 1]
|
||||
filename_wo_digit = (
|
||||
potential_seq_filename[:num_start] + potential_seq_filename[num_end + 1 :]
|
||||
)
|
||||
if (
|
||||
not digit.isdigit()
|
||||
or digit == main_digit
|
||||
or filename_wo_digit != orig_filename_wo_digit
|
||||
):
|
||||
continue
|
||||
derivative_path = os.path.join(cache_path, f"{path_hash}_{side_size}_{digit}{ext}")
|
||||
result.resource_paths.append(PathMapping(potential_seq_filepath, derivative_path))
|
||||
return result
|
||||
elif image.source == 'TILED':
|
||||
# Find type of token and it's starting index
|
||||
if (udim_start := filename.find("<UDIM>")) > -1:
|
||||
token = "<UDIM>"
|
||||
orig_filename_wo_token = filename.replace(token, "", 1)
|
||||
matcher = functools.partial(udim_matcher, start_index=udim_start)
|
||||
elif (udim_start := filename.find("<UVTILE>")) > -1:
|
||||
token = "<UVTILE>"
|
||||
orig_filename_wo_token = filename.replace(token, "", 1)
|
||||
matcher = functools.partial(uvtile_matcher, start_index=udim_start)
|
||||
|
||||
if udim_start == -1:
|
||||
logger.error(
|
||||
f"Image {image.name} with filepath {image.filepath} of type 'TILED' does not have "
|
||||
f"<UDIM> nor <UVTILE> tag in the filename, skipping derivates generation!"
|
||||
)
|
||||
return None
|
||||
|
||||
# Generate derivative path for filepath with token
|
||||
path_hash = get_filepath_hash(os.path.join(dir_path, orig_filename_wo_token + ext))
|
||||
derivative_path = os.path.join(cache_path, f"{path_hash}_{side_size}.{token}{ext}")
|
||||
path_map = PathMapping(original_abs_path, derivative_path)
|
||||
result = OrigToDerivativePaths(path_map, [])
|
||||
|
||||
# Search for TILED images on disc and generate derivative path for them
|
||||
for potential_tile_filepath in glob.glob(os.path.join(dir_path, f"*{ext}")):
|
||||
potential_tile_filename, _ = os.path.splitext(os.path.basename(potential_tile_filepath))
|
||||
split = matcher(potential_tile_filename)
|
||||
if split is None:
|
||||
continue
|
||||
tile_num, filename_wo_token = split
|
||||
if orig_filename_wo_token != filename_wo_token:
|
||||
continue
|
||||
derivative_path = os.path.join(cache_path, f"{path_hash}_{side_size}.{tile_num}{ext}")
|
||||
result.resource_paths.append(PathMapping(potential_tile_filepath, derivative_path))
|
||||
return result
|
||||
else: # One of 'MOVIE', 'GENERATED', 'VIEWER'
|
||||
logger.debug(
|
||||
f"Image '{image.name}' is of type '{image.source}' and thus no derivative will be "
|
||||
f"generated. We don't support it yet or it doesn't even make sense to resize!"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def ensure_and_assign_derivative_images(
|
||||
cache_path: str, original_path: str, image: bpy.types.Image, side_size: int
|
||||
) -> None:
|
||||
"""Generate derivatives of the image and assign them to the image object"""
|
||||
original_abs_path = os.path.abspath(bpy.path.abspath(original_path, library=image.library))
|
||||
image_paths = get_derivative_paths(image, cache_path, original_abs_path, side_size)
|
||||
if image_paths is None:
|
||||
return
|
||||
|
||||
error_occured = False
|
||||
for path_map in image_paths.resource_paths:
|
||||
orig_path, derivative_path = path_map.original, path_map.derivative
|
||||
if not is_derivative_stale(orig_path, derivative_path):
|
||||
logger.debug(
|
||||
f"Derivative {derivative_path} of size {side_size} from original {orig_path} is "
|
||||
f"up to date, no need to re-generate."
|
||||
)
|
||||
continue
|
||||
|
||||
if os.path.isfile(derivative_path):
|
||||
os.unlink(derivative_path)
|
||||
error_occured = not derivative_generator.generate_derivative(
|
||||
orig_path, derivative_path, side_size
|
||||
)
|
||||
if error_occured:
|
||||
logger.error(
|
||||
f"Failed to generate derivative {derivative_path} of size {side_size} "
|
||||
f"from original {orig_path}."
|
||||
)
|
||||
# Break here, we'll keep the current image
|
||||
break
|
||||
else:
|
||||
logger.debug(
|
||||
f"Generated derivative {derivative_path} of size {side_size} from original "
|
||||
f"{orig_path} because it was stale."
|
||||
)
|
||||
|
||||
if not error_occured:
|
||||
image.filepath = image_paths.assigned_path.derivative
|
||||
# duplicating information to be able to tell if image was changed outside of memsaver
|
||||
# saving a basename of the derivative should be enough because it contains the hash suffix
|
||||
image[DERIVATIVE_FILENAME_PROPERTY_NAME] = bpy.path.basename(image.filepath)
|
||||
image[DERIVATIVE_SIZE_PROPERTY_NAME] = side_size
|
||||
logger.debug(
|
||||
f"Set image {image.name} to derivative {image_paths.assigned_path.derivative} of "
|
||||
f"size {side_size}, original at {image_paths.assigned_path.original}."
|
||||
)
|
||||
else:
|
||||
if all(os.path.isfile(path_map.original) for path_map in image_paths.resource_paths):
|
||||
# We failed to generate the derivative, which means we could be stuck on outdated or
|
||||
# some weird derivative from the past, e.g. 1x1.
|
||||
# Switch to original as all original images exist.
|
||||
revert_to_original(image)
|
||||
logger.warning(
|
||||
f"Failed to generate derivatives from original {original_abs_path}, "
|
||||
f"reverted image to the original!"
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Failed to generate derivatives from original {original_abs_path} "
|
||||
f"and original doesn't exist, keeping old derivative!"
|
||||
)
|
||||
|
||||
|
||||
def change_image_size(cache_path: str, image: bpy.types.Image, side_size: int) -> None:
|
||||
assert side_size > 0
|
||||
original_path = ensure_original_path(image)
|
||||
if original_path == "":
|
||||
logger.warning(
|
||||
f"Image {image.name} has empty original path, it's most likely generated or packed. "
|
||||
f"Can't change its size."
|
||||
)
|
||||
return
|
||||
|
||||
if image.packed_file is not None:
|
||||
logger.warning(f"Image {image.name} is packed. Can't change its size.")
|
||||
return
|
||||
|
||||
if image.get(DERIVATIVE_SIZE_PROPERTY_NAME, 0) == 0:
|
||||
# if the image has the original path and its size is smaller than what's requested, we can
|
||||
# do an early out optimization here
|
||||
if max(image.size) <= side_size:
|
||||
logger.debug(
|
||||
f"Image {image.name} has original size {list(image.size)} which is smaller or "
|
||||
f"equal to the requested side size {side_size}. Skipping!"
|
||||
)
|
||||
return
|
||||
|
||||
ensure_and_assign_derivative_images(cache_path, original_path, image, side_size)
|
||||
|
||||
|
||||
def check_derivative(cache_path: typing.Optional[str], image: bpy.types.Image) -> None:
|
||||
"""Re-generate derivative image if the original image file was changed on disk
|
||||
|
||||
cache_path may be None, in this case it will be inferred from derivative absolute path
|
||||
"""
|
||||
if was_filepath_overwritten_on_derivative(image):
|
||||
remove_memsaver_properties(image)
|
||||
return
|
||||
|
||||
original_path = get_original_path(image)
|
||||
if original_path is None: # it's original or original path custom property is not present
|
||||
return
|
||||
|
||||
side_size: int = image.get(DERIVATIVE_SIZE_PROPERTY_NAME, 0)
|
||||
if side_size == 0: # it's original or no custom property is present
|
||||
return
|
||||
|
||||
# we can infer cache_path, this is useful especially in the post_load handler
|
||||
if cache_path is None:
|
||||
cache_path = os.path.dirname(image.filepath)
|
||||
if not os.path.isdir(cache_path):
|
||||
logger.warning(
|
||||
f"Inferred cache_path {cache_path} from image.filepath {image.filepath}, "
|
||||
f"however the cache_path is not a valid directory! Skipping!"
|
||||
)
|
||||
return
|
||||
|
||||
ensure_and_assign_derivative_images(cache_path, original_path, image, side_size)
|
||||
@@ -0,0 +1,382 @@
|
||||
#!/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 bpy
|
||||
import typing
|
||||
import collections
|
||||
import tempfile
|
||||
import logging
|
||||
from . import polib
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
MODULE_CLASSES: typing.List[typing.Type] = []
|
||||
|
||||
|
||||
class DatablockMemoryUsage:
|
||||
@staticmethod
|
||||
def get_attribute_datatype_size(datatype: str) -> int:
|
||||
if datatype == 'FLOAT':
|
||||
return 4
|
||||
elif datatype == 'INT':
|
||||
return 4
|
||||
elif datatype == 'FLOAT_VECTOR':
|
||||
return 3 * 4
|
||||
elif datatype == 'FLOAT_COLOR':
|
||||
return 4 * 4 # RGBA
|
||||
elif datatype == 'STRING':
|
||||
# TODO: we just make something up here...
|
||||
return 16
|
||||
elif datatype == 'BOOLEAN':
|
||||
return 1
|
||||
elif datatype == 'FLOAT2':
|
||||
return 2 * 4
|
||||
else:
|
||||
# TODO: warn?
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def get_attribute_domain_multiplier(datablock: bpy.types.Mesh, domain: str) -> int:
|
||||
if domain == 'POINT':
|
||||
return len(datablock.vertices)
|
||||
elif domain == 'EDGE':
|
||||
return len(datablock.edges)
|
||||
elif domain == 'FACE':
|
||||
return len(datablock.polygons)
|
||||
elif domain == 'CORNER':
|
||||
# TODO: no idea
|
||||
return 0
|
||||
elif domain == 'CURVE':
|
||||
# TODO: no idea
|
||||
return 0
|
||||
elif domain == 'INSTANCE':
|
||||
# TODO: no idea
|
||||
return 0
|
||||
else:
|
||||
logger.warning(f"{domain} not supported in memory estimation")
|
||||
return 0
|
||||
|
||||
def __init__(self, datablock: bpy.types.ID):
|
||||
self.bytes = 0
|
||||
if hasattr(datablock, "name_full"):
|
||||
self.title = f"{type(datablock).__name__}:{datablock.name_full}"
|
||||
else:
|
||||
self.title = f"{type(datablock).__name__}:{datablock.name}"
|
||||
|
||||
if isinstance(datablock, bpy.types.Mesh):
|
||||
# vertex position, 3x float32
|
||||
self.bytes += len(datablock.vertices) * 3 * 4
|
||||
|
||||
if datablock.has_custom_normals:
|
||||
# custom normal, 3x float32
|
||||
self.bytes += len(datablock.vertices) * 3 * 4
|
||||
|
||||
# each attribute has to be treated separately
|
||||
for attribute in datablock.attributes:
|
||||
self.bytes += self.get_attribute_datatype_size(
|
||||
attribute.data_type
|
||||
) * self.get_attribute_domain_multiplier(datablock, attribute.domain)
|
||||
|
||||
# each edge indexes into two vertices, each index is int32, plus crease and bevel floats
|
||||
self.bytes += len(datablock.edges) * 4 * 4
|
||||
# edge index, vertex index
|
||||
# TODO: normals? does blender store tangent, bitangent or is this calculated on the fly?
|
||||
self.bytes += len(datablock.loops) * 2 * 4
|
||||
for polygon in datablock.polygons:
|
||||
# loop_start, loop_total indices
|
||||
self.bytes += 2 * 4
|
||||
# vertex indices
|
||||
self.bytes += len(polygon.vertices) * 4
|
||||
|
||||
for uv_layer in datablock.uv_layers:
|
||||
# we assume 2x float32
|
||||
self.bytes += len(datablock.vertices) * 2 * 4
|
||||
|
||||
for vertex_color in datablock.vertex_colors:
|
||||
# we assume 3x int8
|
||||
self.bytes += len(datablock.vertices) * 3 * 1
|
||||
|
||||
# TODO: This model is super simplified
|
||||
|
||||
elif isinstance(datablock, bpy.types.Image):
|
||||
width, height = datablock.size
|
||||
bits_per_pixel = datablock.depth
|
||||
self.bytes += (width * height * bits_per_pixel) // 8
|
||||
|
||||
assert self.bytes >= 0, f"The {datablock.name} datablock cannot use negative memory"
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.bytes} B"
|
||||
|
||||
|
||||
class MemoryUsageStatistics:
|
||||
def __init__(self):
|
||||
self.datablocks_memory_usage: typing.Dict[str, DatablockMemoryUsage] = {}
|
||||
self.target_to_dependencies: typing.DefaultDict[str, typing.Set[str]] = (
|
||||
collections.defaultdict(set)
|
||||
)
|
||||
self.dependency_to_targets: typing.DefaultDict[str, typing.Set[str]] = (
|
||||
collections.defaultdict(set)
|
||||
)
|
||||
|
||||
def debug_print(self):
|
||||
print(self.datablocks_memory_usage)
|
||||
print(self.target_to_dependencies)
|
||||
print(self.dependency_to_targets)
|
||||
|
||||
def write_html_report(self, fp: typing.IO) -> None:
|
||||
assert (
|
||||
len(self.datablocks_memory_usage) > 0
|
||||
), "Memory usage cannot be calculated with no datablocks"
|
||||
|
||||
sorted_by_usage = sorted(
|
||||
self.datablocks_memory_usage.items(), key=lambda x: x[1].bytes, reverse=True
|
||||
)
|
||||
_, max_usage = sorted_by_usage[0]
|
||||
total_usage_bytes = sum(usage.bytes for _, usage in sorted_by_usage)
|
||||
max_usage_bytes = max_usage.bytes
|
||||
# We want to avoid zero division when calculating percentages
|
||||
if total_usage_bytes == 0:
|
||||
total_usage_bytes = 1
|
||||
if max_usage_bytes == 0:
|
||||
max_usage_bytes = 1
|
||||
|
||||
# TODO: This should be improved to look better
|
||||
# TODO: Missing dependencies visualization, why is a particular mesh even loaded, ...
|
||||
print("<html><body>\n", file=fp)
|
||||
print(f"<b>total usage:</b> {total_usage_bytes / 1024 / 1024 :.3f} MiB<br>\n", file=fp)
|
||||
print("<table>\n", file=fp)
|
||||
print("<tr>\n", file=fp)
|
||||
print("\t\t<th>datablock</th>\n", file=fp)
|
||||
print("\t\t<th>approximate usage</th>\n", file=fp)
|
||||
print("\t\t<th>%</th>", file=fp)
|
||||
print("\t\t<th></th>\n", file=fp)
|
||||
print("\t</tr>\n", file=fp)
|
||||
|
||||
for _, datablock_usage in sorted_by_usage:
|
||||
print("\t<tr>\n", file=fp)
|
||||
print(f"\t\t<td>{datablock_usage.title}</td>\n", file=fp)
|
||||
print(f"\t\t<td>{datablock_usage.bytes / 1024.0 / 1024.0 :.3f} MiB</td>\n", file=fp)
|
||||
print(
|
||||
f"\t\t<td>{datablock_usage.bytes * 100 / total_usage_bytes :.2f}%</td>\n", file=fp
|
||||
)
|
||||
red_channel = datablock_usage.bytes * 255 // max_usage_bytes
|
||||
print(
|
||||
f"\t\t<td><div style=\"background: rgb({red_channel}, {255 - red_channel}, 0); height: 12px; width: {datablock_usage.bytes * 200 // max_usage_bytes}px\"></div></td>\n",
|
||||
file=fp,
|
||||
)
|
||||
print("\t</tr>\n", file=fp)
|
||||
print("</table>\n", file=fp)
|
||||
print("</body></html>\n", file=fp)
|
||||
|
||||
def _datablock_key(self, datablock: bpy.types.ID, scope: str = "") -> str:
|
||||
# NodeTrees are owned by materials and between materials NodeTree names can clash, so we
|
||||
# assign the scope as part of the datablock_key to make it unique.
|
||||
if hasattr(datablock, "name_full"):
|
||||
return f"{scope}:{type(datablock).__name__}:{datablock.name_full}"
|
||||
else:
|
||||
return f"{scope}:{type(datablock).__name__}:{datablock.name}"
|
||||
|
||||
def record_dependency(self, target: bpy.types.ID, dependency: bpy.types.ID) -> None:
|
||||
target_key = self._datablock_key(target)
|
||||
dependency_key = self._datablock_key(dependency)
|
||||
|
||||
self.target_to_dependencies[target_key].add(dependency_key)
|
||||
self.dependency_to_targets[dependency_key].add(target_key)
|
||||
|
||||
def record_datablock(self, datablock: bpy.types.ID, scope: str = "") -> None:
|
||||
datablock_key = self._datablock_key(datablock, scope)
|
||||
if datablock_key in self.datablocks_memory_usage:
|
||||
return # already recorded
|
||||
|
||||
self.datablocks_memory_usage[datablock_key] = DatablockMemoryUsage(datablock)
|
||||
|
||||
if isinstance(datablock, bpy.types.Scene):
|
||||
self._record_scene_dependencies(datablock)
|
||||
elif isinstance(datablock, bpy.types.World):
|
||||
self._record_world_dependencies(datablock)
|
||||
elif isinstance(datablock, bpy.types.Collection):
|
||||
self._record_collection_dependencies(datablock)
|
||||
elif isinstance(datablock, bpy.types.Object):
|
||||
self._record_object_dependencies(datablock)
|
||||
elif isinstance(datablock, bpy.types.Camera):
|
||||
pass
|
||||
elif isinstance(datablock, bpy.types.Light):
|
||||
pass
|
||||
elif isinstance(datablock, bpy.types.Mesh):
|
||||
pass # meshes don't depend on anything
|
||||
elif isinstance(datablock, bpy.types.Curve):
|
||||
pass # meshes don't depend on anything
|
||||
elif isinstance(datablock, bpy.types.Material):
|
||||
self._record_material_dependencies(datablock)
|
||||
elif isinstance(datablock, bpy.types.NodeTree): # TODO: or only ShaderNodeTree?
|
||||
self._record_node_tree_dependencies(datablock, scope)
|
||||
elif isinstance(datablock, bpy.types.ShaderNodeTexImage):
|
||||
self._record_shader_node_tex_image_dependencies(datablock)
|
||||
elif isinstance(datablock, bpy.types.ShaderNodeTexEnvironment):
|
||||
self._record_shader_node_tex_environment_dependencies(datablock)
|
||||
elif isinstance(datablock, bpy.types.Image):
|
||||
pass # images don't depend on anything
|
||||
elif isinstance(datablock, bpy.types.Armature):
|
||||
pass # TODO: Support armatures, armatures are ligthweight
|
||||
elif isinstance(datablock, bpy.types.ShaderNodeGroup):
|
||||
self._record_shader_node_group_dependencies(datablock)
|
||||
else:
|
||||
logger.warning(f"{datablock} not taken into account in estimation!")
|
||||
|
||||
def _record_scene_dependencies(self, scene: bpy.types.Scene) -> None:
|
||||
if scene.world is not None:
|
||||
self.record_datablock(scene.world)
|
||||
self.record_dependency(scene, scene.world)
|
||||
|
||||
self.record_datablock(scene.collection)
|
||||
self.record_dependency(scene, scene.collection)
|
||||
|
||||
def _record_world_dependencies(self, world: bpy.types.World) -> None:
|
||||
if world.use_nodes:
|
||||
self.record_datablock(world.node_tree, self._datablock_key(world))
|
||||
self.record_dependency(world, world.node_tree)
|
||||
|
||||
def _record_collection_dependencies(self, collection: bpy.types.Collection) -> None:
|
||||
for obj in collection.objects:
|
||||
if obj.hide_render:
|
||||
continue # skip hidden objects
|
||||
|
||||
self.record_datablock(obj)
|
||||
self.record_dependency(collection, obj)
|
||||
|
||||
for child_collection in collection.children:
|
||||
if child_collection.hide_render:
|
||||
continue # skip hidden collections
|
||||
|
||||
self.record_datablock(child_collection)
|
||||
self.record_dependency(collection, child_collection)
|
||||
|
||||
def _record_object_dependencies(self, obj: bpy.types.Object) -> None:
|
||||
if obj.data is not None:
|
||||
self.record_datablock(obj.data)
|
||||
self.record_dependency(obj, obj.data)
|
||||
if obj.instance_type == 'COLLECTION':
|
||||
if obj.instance_collection is not None:
|
||||
self.record_datablock(obj.instance_collection)
|
||||
self.record_dependency(obj, obj.instance_collection)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Object {obj.name} has instance_type='COLLECTION' but its "
|
||||
f"instance_collection is None! Skipping..."
|
||||
)
|
||||
|
||||
for material_slot in obj.material_slots:
|
||||
if material_slot.material is not None:
|
||||
self.record_datablock(material_slot.material)
|
||||
self.record_dependency(obj, material_slot.material)
|
||||
|
||||
for child_object in obj.children: # TODO: this takes O(len(bpy.data.objects))!
|
||||
self.record_datablock(child_object)
|
||||
self.record_dependency(obj, child_object)
|
||||
self._record_object_dependencies(child_object)
|
||||
|
||||
def _record_material_dependencies(self, material: bpy.types.Object) -> None:
|
||||
if not material.use_nodes:
|
||||
logger.warning(
|
||||
f"Warning: Cannot record dependencies of {material.name}, only node based "
|
||||
f"materials are supported for now."
|
||||
)
|
||||
return
|
||||
|
||||
if material.node_tree is not None:
|
||||
self.record_datablock(material.node_tree, self._datablock_key(material))
|
||||
self.record_dependency(material, material.node_tree)
|
||||
|
||||
def _record_node_tree_dependencies(
|
||||
self, node_tree: bpy.types.NodeTree, scope: str = ""
|
||||
) -> None:
|
||||
for node in node_tree.nodes:
|
||||
if isinstance(node, bpy.types.ShaderNodeTexImage):
|
||||
self.record_datablock(node, scope)
|
||||
self.record_dependency(node_tree, node)
|
||||
self._record_shader_node_tex_image_dependencies(node)
|
||||
elif isinstance(node, bpy.types.ShaderNodeTexEnvironment):
|
||||
self.record_datablock(node, scope)
|
||||
self.record_dependency(node_tree, node)
|
||||
self._record_shader_node_tex_environment_dependencies(node)
|
||||
elif isinstance(node, bpy.types.ShaderNodeGroup):
|
||||
self.record_datablock(node, scope)
|
||||
self.record_dependency(node_tree, node)
|
||||
else:
|
||||
# TODO: Tons of nodes we don't support yet
|
||||
pass
|
||||
|
||||
def _record_shader_node_tex_image_dependencies(
|
||||
self, node: bpy.types.ShaderNodeTexImage
|
||||
) -> None:
|
||||
if node.image is not None:
|
||||
self.record_datablock(node.image)
|
||||
self.record_dependency(node, node.image)
|
||||
|
||||
def _record_shader_node_tex_environment_dependencies(
|
||||
self, node: bpy.types.ShaderNodeTexEnvironment
|
||||
) -> None:
|
||||
if node.image is not None:
|
||||
self.record_datablock(node.image)
|
||||
self.record_dependency(node, node.image)
|
||||
|
||||
def _record_shader_node_group_dependencies(self, node: bpy.types.ShaderNodeGroup) -> None:
|
||||
if node.node_tree is not None:
|
||||
self.record_datablock(node.node_tree, scope="bpy.data.node_groups")
|
||||
self.record_dependency(node, node.node_tree)
|
||||
|
||||
|
||||
@polib.log_helpers_bpy.logged_operator
|
||||
class EstimateMemoryUsage(bpy.types.Operator):
|
||||
bl_idname = "memsaver.estimate_memory_usage"
|
||||
bl_label = "Estimate Memory Usage (Beta)"
|
||||
bl_description = (
|
||||
"Goes through datablocks that would have to be loaded for rendering, "
|
||||
"estimates how much memory is needed for each one"
|
||||
)
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
def execute(self, context: bpy.types.Context):
|
||||
stats = MemoryUsageStatistics()
|
||||
stats.record_datablock(context.scene)
|
||||
|
||||
out_file = tempfile.NamedTemporaryFile(
|
||||
mode="w", prefix="memory_usage_", suffix=".html", delete=False
|
||||
)
|
||||
stats.write_html_report(out_file)
|
||||
logger.info(f"Wrote memory estimation HTML to: {out_file.name}")
|
||||
polib.utils_bpy.xdg_open_file(out_file.name)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
MODULE_CLASSES.append(EstimateMemoryUsage)
|
||||
|
||||
|
||||
def register():
|
||||
for cls in MODULE_CLASSES:
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(MODULE_CLASSES):
|
||||
bpy.utils.unregister_class(cls)
|
||||
@@ -0,0 +1,200 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import typing
|
||||
import collections
|
||||
import bpy
|
||||
import logging
|
||||
from . import object_render_estimator
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
DECIMATE_MODIFIER_NAME = "memsaver_decimate"
|
||||
|
||||
|
||||
def get_meshes_used_in_object(obj: bpy.types.Object) -> typing.Iterable[bpy.types.Mesh]:
|
||||
if obj.type == 'EMPTY':
|
||||
if obj.instance_type == 'COLLECTION':
|
||||
if obj.instance_collection is None:
|
||||
return # warn?
|
||||
|
||||
for instanced_object in obj.instance_collection.objects:
|
||||
yield from get_meshes_used_in_object(instanced_object)
|
||||
|
||||
elif obj.type == 'MESH':
|
||||
if obj.data is None:
|
||||
return # warn?
|
||||
|
||||
yield obj.data
|
||||
|
||||
|
||||
def generate_mesh_objects_map(
|
||||
objects: typing.Iterable[bpy.types.Object],
|
||||
) -> typing.DefaultDict[bpy.types.Mesh, typing.List[bpy.types.Object]]:
|
||||
mesh_objects_map: typing.DefaultDict[bpy.types.Mesh, typing.List[bpy.types.Object]] = (
|
||||
collections.defaultdict(list)
|
||||
)
|
||||
|
||||
for obj in objects:
|
||||
meshes = get_meshes_used_in_object(obj)
|
||||
for mesh in meshes:
|
||||
mesh_objects_map[mesh].append(obj)
|
||||
|
||||
return mesh_objects_map
|
||||
|
||||
|
||||
def update_object_decimation_ratio_map(
|
||||
objects_decimation_ratio_map: typing.DefaultDict[bpy.types.Object, float],
|
||||
mesh_objects_map: typing.DefaultDict[bpy.types.Mesh, typing.List[bpy.types.Object]],
|
||||
scene: bpy.types.Scene,
|
||||
camera: bpy.types.Object,
|
||||
objects: typing.Iterable[bpy.types.Object],
|
||||
full_quality_distance: float,
|
||||
lowest_quality_distance: float,
|
||||
lowest_quality_decimation_ratio: float,
|
||||
lowest_face_count: float,
|
||||
) -> None:
|
||||
decimation_interpolation_denominator = lowest_quality_distance - full_quality_distance
|
||||
if decimation_interpolation_denominator <= 0:
|
||||
decimation_interpolation_denominator = 1
|
||||
|
||||
for mesh, objects in mesh_objects_map.items():
|
||||
if len(mesh.polygons) < lowest_face_count:
|
||||
continue # ignore meshes with lower face count than the one given
|
||||
|
||||
mesh_min_distance = float("inf")
|
||||
|
||||
for obj in objects:
|
||||
if obj.library is not None: # linked, non-editable object
|
||||
# set min distance to 0 to avoid any decimation, we won't be able to add the same
|
||||
# decimation to all objects using this mesh, so let's not decimate at all
|
||||
mesh_min_distance = 0.0
|
||||
break
|
||||
|
||||
_, _, obj_min_distance = object_render_estimator.get_object_2d_bounds(
|
||||
scene, camera, obj
|
||||
)
|
||||
if obj_min_distance is not None:
|
||||
mesh_min_distance = min(mesh_min_distance, obj_min_distance)
|
||||
|
||||
# object_render_estimator will return negative values of the object intersects the camera
|
||||
# we assume non-negative values so let's clamp to 0
|
||||
mesh_min_distance = max(0.0, mesh_min_distance)
|
||||
|
||||
# Everything closer than full_quality_distance has decimation ratio 1.0
|
||||
# Everything further than lowest_quality_distance has decimation ratio set to
|
||||
# lowest_quality_decimation_ratio
|
||||
# Between minimum_distance and lowest_quality_distance interpolate linearly from 1.0 to
|
||||
# lowest_quality_decimation_ratio
|
||||
|
||||
decimation_ratio = 1.0
|
||||
if mesh_min_distance <= full_quality_distance:
|
||||
decimation_ratio = 1.0
|
||||
elif mesh_min_distance <= lowest_quality_distance:
|
||||
nominator = mesh_min_distance - full_quality_distance
|
||||
interpolation_factor = nominator / decimation_interpolation_denominator
|
||||
assert interpolation_factor >= 0.0
|
||||
assert interpolation_factor <= 1.0
|
||||
decimation_ratio = (
|
||||
1.0 - interpolation_factor
|
||||
) * 1.0 + interpolation_factor * lowest_quality_decimation_ratio
|
||||
else:
|
||||
decimation_ratio = lowest_quality_decimation_ratio
|
||||
|
||||
assert decimation_ratio >= 0.0
|
||||
assert decimation_ratio <= 1.0
|
||||
for obj in objects:
|
||||
objects_decimation_ratio_map[obj] = max(
|
||||
objects_decimation_ratio_map[obj], decimation_ratio
|
||||
)
|
||||
|
||||
|
||||
def get_objects_decimation_ratio_map_current_frame(
|
||||
scene: bpy.types.Scene,
|
||||
camera: bpy.types.Object,
|
||||
objects: typing.Iterable[bpy.types.Object],
|
||||
minimum_distance: float,
|
||||
lowest_quality_distance: float,
|
||||
lowest_quality_decimation_ratio: float,
|
||||
lowest_face_count: float,
|
||||
) -> typing.DefaultDict[bpy.types.Object, float]:
|
||||
mesh_objects_map = generate_mesh_objects_map(objects)
|
||||
objects_decimation_ratio_map: typing.DefaultDict[bpy.types.Object, float] = (
|
||||
collections.defaultdict(float)
|
||||
)
|
||||
update_object_decimation_ratio_map(
|
||||
objects_decimation_ratio_map,
|
||||
mesh_objects_map,
|
||||
scene,
|
||||
camera,
|
||||
objects,
|
||||
minimum_distance,
|
||||
lowest_quality_distance,
|
||||
lowest_quality_decimation_ratio,
|
||||
lowest_face_count,
|
||||
)
|
||||
return objects_decimation_ratio_map
|
||||
|
||||
|
||||
def get_objects_decimation_ratio_map_animation_mode(
|
||||
scene: bpy.types.Scene,
|
||||
camera: bpy.types.Object,
|
||||
objects: typing.Iterable[bpy.types.Object],
|
||||
minimum_distance: float,
|
||||
lowest_quality_distance: float,
|
||||
lowest_quality_decimation_ratio: float,
|
||||
lowest_face_count: float,
|
||||
) -> typing.DefaultDict[bpy.types.Object, float]:
|
||||
previous_frame_current = scene.frame_current
|
||||
try:
|
||||
mesh_objects_map = generate_mesh_objects_map(objects)
|
||||
objects_decimation_ratio_map: typing.DefaultDict[bpy.types.Object, float] = (
|
||||
collections.defaultdict(float)
|
||||
)
|
||||
current_frame = scene.frame_start
|
||||
while current_frame <= scene.frame_end:
|
||||
scene.frame_current = current_frame
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
update_object_decimation_ratio_map(
|
||||
objects_decimation_ratio_map,
|
||||
mesh_objects_map,
|
||||
scene,
|
||||
camera,
|
||||
objects,
|
||||
minimum_distance,
|
||||
lowest_quality_distance,
|
||||
lowest_quality_decimation_ratio,
|
||||
lowest_face_count,
|
||||
)
|
||||
current_frame += 1
|
||||
|
||||
return objects_decimation_ratio_map
|
||||
finally:
|
||||
scene.frame_current = previous_frame_current
|
||||
bpy.context.view_layer.update()
|
||||
|
||||
|
||||
def revert_to_original(obj: bpy.types.Object) -> None:
|
||||
if DECIMATE_MODIFIER_NAME in obj.modifiers:
|
||||
obj.modifiers.remove(obj.modifiers[DECIMATE_MODIFIER_NAME])
|
||||
|
||||
|
||||
def set_decimation_ratio(obj: bpy.types.Object, decimation_ratio: float) -> None:
|
||||
if decimation_ratio == 1.0:
|
||||
# no decimation
|
||||
revert_to_original(obj)
|
||||
else:
|
||||
memsaver_decimation = None
|
||||
if DECIMATE_MODIFIER_NAME in obj.modifiers:
|
||||
memsaver_decimation = obj.modifiers[DECIMATE_MODIFIER_NAME]
|
||||
else:
|
||||
memsaver_decimation = obj.modifiers.new(DECIMATE_MODIFIER_NAME, 'DECIMATE')
|
||||
|
||||
if memsaver_decimation is None:
|
||||
logger.error(
|
||||
f"Failed to create memsaver decimation modifier for object {obj.name} "
|
||||
f"of type {obj.type}, skipping!"
|
||||
)
|
||||
return
|
||||
memsaver_decimation.ratio = decimation_ratio
|
||||
@@ -0,0 +1,203 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import typing
|
||||
import collections
|
||||
import bpy
|
||||
import bpy_extras.object_utils
|
||||
import logging
|
||||
from . import hatchery
|
||||
from . import utils
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
def get_object_2d_bounds(
|
||||
scene: bpy.types.Scene, camera: bpy.types.Object, obj: bpy.types.Object
|
||||
) -> typing.Tuple[float, float, typing.Optional[float]]:
|
||||
"""Returns width, height in pixels and depth in world units of bounding box in camera space
|
||||
|
||||
Result can be bigger than render resolution as it's not clipped to camera plane. Returns
|
||||
0, 0 None if object's 3D bounding box is not visible from camera.
|
||||
"""
|
||||
|
||||
logger.debug(
|
||||
f"Asked for 2D bounds of object {obj.name} in scene {scene.name} using camera {camera.name}."
|
||||
)
|
||||
|
||||
bounding_box = hatchery.bounding_box.AlignedBox()
|
||||
bounding_box.extend_by_object(obj)
|
||||
|
||||
min_x = float("inf")
|
||||
max_x = float("-inf")
|
||||
min_y = float("inf")
|
||||
max_y = float("-inf")
|
||||
min_depth = float("inf")
|
||||
max_depth = float("-inf")
|
||||
|
||||
for corner in bounding_box.get_corners():
|
||||
# get the 2D pixel coordinates of the bounding box corner. NDC = normalized device coords
|
||||
try:
|
||||
corner_ndc_x, corner_ndc_y, corner_depth = bpy_extras.object_utils.world_to_camera_view(
|
||||
scene, camera, corner
|
||||
)
|
||||
except:
|
||||
logger.exception(
|
||||
f"Failed to get NDC coordinates for one of bounding box corners for {obj.name}, "
|
||||
f"skipping that corner..."
|
||||
)
|
||||
continue
|
||||
|
||||
# NDC has [0, 0] for the bottom left and [1, 1] for the top right of the camera frame.
|
||||
# But if corner in world_to_camera_view() is behind camera, x and y of NDC is
|
||||
# multiplied by -1 (frame is scaled by -1).
|
||||
# https://github.com/blender/blender/blob/ce96abd33aeaa0ae4e13db09ce6a1cacb18e9ff8/scripts/modules/bpy_extras/object_utils.py#L254
|
||||
if corner_depth < 0.0:
|
||||
corner_ndc_x = -corner_ndc_x
|
||||
corner_ndc_y = -corner_ndc_y
|
||||
|
||||
# convert NDC into pixel values
|
||||
corner_2d_x = corner_ndc_x * scene.render.resolution_x
|
||||
corner_2d_y = (1.0 - corner_ndc_y) * scene.render.resolution_y
|
||||
|
||||
min_x = min(min_x, corner_2d_x)
|
||||
max_x = max(max_x, corner_2d_x)
|
||||
min_y = min(min_y, corner_2d_y)
|
||||
max_y = max(max_y, corner_2d_y)
|
||||
min_depth = min(min_depth, corner_depth)
|
||||
max_depth = max(max_depth, corner_depth)
|
||||
|
||||
if max_depth < 0:
|
||||
# the object is entirely behind the camera
|
||||
logger.debug(
|
||||
f"Object {obj.name} 2D bounds ended up as 0, 0 because it's entirely behind the "
|
||||
f"camera. min_depth: {min_depth}, max_depth: {max_depth}."
|
||||
)
|
||||
return 0, 0, None
|
||||
|
||||
if max_x < 0:
|
||||
# the object is entirely left of the frustum
|
||||
logger.debug(
|
||||
f"Object {obj.name} 2D bounds ended up as 0, 0 because it's entirely left of the "
|
||||
f"frustum of the camera. min_x: {min_x}, max_x: {max_x}."
|
||||
)
|
||||
return 0, 0, None
|
||||
|
||||
if min_x > bpy.context.scene.render.resolution_x:
|
||||
# the object is entirely right of the frustum
|
||||
logger.debug(
|
||||
f"Object {obj.name} 2D bounds ended up as 0, 0 because it's entirely right of the "
|
||||
f"frustum of the camera. min_x: {min_x}, max_x: {max_x}."
|
||||
)
|
||||
return 0, 0, None
|
||||
|
||||
if max_y < 0:
|
||||
# the object is entirely under the frustum
|
||||
logger.debug(
|
||||
f"Object {obj.name} 2D bounds ended up as 0, 0 because it's entirely down under the "
|
||||
f"frustum of the camera. min_y: {min_y}, max_y: {max_y}."
|
||||
)
|
||||
return 0, 0, None
|
||||
|
||||
if min_y > bpy.context.scene.render.resolution_y:
|
||||
# the object is entirely over the frustum
|
||||
logger.debug(
|
||||
f"Object {obj.name} 2D bounds ended up as 0, 0 because it's entirely up over the "
|
||||
f"frustum of the camera. min_y: {min_y}, max_y: {max_y}."
|
||||
)
|
||||
return 0, 0, None
|
||||
|
||||
size_x = max_x - min_x
|
||||
size_y = max_y - min_y
|
||||
assert size_x >= 0
|
||||
assert size_y >= 0
|
||||
|
||||
logger.debug(
|
||||
f"Object {obj.name} 2D bounds ended up as {size_x}, {size_y}. "
|
||||
f"min_x: {min_x}, max_x: {max_x}, min_y: {min_y}, max_y: {max_y}, "
|
||||
f"min_depth: {min_depth}, max_depth: {max_depth}."
|
||||
)
|
||||
return size_x, size_y, min_depth
|
||||
|
||||
|
||||
def update_size_map_for_objects(
|
||||
size_map: typing.DefaultDict[bpy.types.Image, int],
|
||||
scene: bpy.types.Scene,
|
||||
camera: bpy.types.Object,
|
||||
objects: typing.Iterable[bpy.types.Object],
|
||||
size_factor: float,
|
||||
min_size: int,
|
||||
max_size: int,
|
||||
size_pot_only: bool = True,
|
||||
) -> None:
|
||||
assert max_size >= min_size
|
||||
|
||||
for obj in objects:
|
||||
size_x, size_y, _ = get_object_2d_bounds(scene, camera, obj)
|
||||
size_max = max(size_x, size_y)
|
||||
side_size: int = round(size_max * size_factor)
|
||||
|
||||
assert side_size >= 0
|
||||
side_size = max(side_size, min_size)
|
||||
side_size = min(side_size, max_size)
|
||||
if side_size == 0:
|
||||
side_size = 1 # we can't scale images to 0
|
||||
|
||||
if size_pot_only:
|
||||
side_size = 1 << (side_size - 1).bit_length()
|
||||
if side_size < 32 and side_size > 1:
|
||||
# don't generate any sizes between 1 and 32, it's wasted files
|
||||
side_size = 32
|
||||
|
||||
for image in utils.get_images_used_in_object(obj):
|
||||
if size_map[image] < side_size:
|
||||
logger.debug(
|
||||
f"Upgrading image {image.name} from size {size_map[image]} to {side_size} because "
|
||||
f"of its usage in object {obj.name}, 2D bounds of object: {size_x}, {size_y}."
|
||||
)
|
||||
# Only update the new size if it is smaller than the original image size
|
||||
size_map[image] = min(side_size, max(image.size[0], image.size[1]))
|
||||
|
||||
|
||||
def get_size_map_for_objects_current_frame(
|
||||
scene: bpy.types.Scene,
|
||||
camera: bpy.types.Object,
|
||||
objects: typing.Iterable[bpy.types.Object],
|
||||
size_factor: float,
|
||||
min_size: int,
|
||||
max_size: int,
|
||||
size_pot_only: bool = True,
|
||||
) -> typing.DefaultDict[bpy.types.Image, int]:
|
||||
# 0 in the dictionary means we want the original
|
||||
ret: typing.DefaultDict[bpy.types.Image, int] = collections.defaultdict(lambda: 1)
|
||||
update_size_map_for_objects(
|
||||
ret, scene, camera, objects, size_factor, min_size, max_size, size_pot_only
|
||||
)
|
||||
return ret
|
||||
|
||||
|
||||
def get_size_map_for_objects_animation_mode(
|
||||
scene: bpy.types.Scene,
|
||||
camera: bpy.types.Object,
|
||||
objects: typing.Iterable[bpy.types.Object],
|
||||
size_factor: float,
|
||||
min_size: int,
|
||||
max_size: int,
|
||||
size_pot_only: bool = True,
|
||||
) -> typing.DefaultDict[bpy.types.Image, int]:
|
||||
previous_frame_current = scene.frame_current
|
||||
try:
|
||||
# 0 in the dictionary means we want the original
|
||||
ret: typing.DefaultDict[bpy.types.Image, int] = collections.defaultdict(lambda: 1)
|
||||
current_frame = scene.frame_start
|
||||
while current_frame <= scene.frame_end:
|
||||
scene.frame_current = current_frame
|
||||
bpy.context.view_layer.update()
|
||||
update_size_map_for_objects(
|
||||
ret, scene, camera, objects, size_factor, min_size, max_size, size_pot_only
|
||||
)
|
||||
current_frame += 1
|
||||
|
||||
return ret
|
||||
finally:
|
||||
scene.frame_current = previous_frame_current
|
||||
bpy.context.view_layer.update()
|
||||
@@ -0,0 +1,237 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import typing
|
||||
import bpy
|
||||
import os
|
||||
import tempfile
|
||||
import shutil
|
||||
from . import polib
|
||||
|
||||
telemetry = polib.get_telemetry("memsaver")
|
||||
|
||||
|
||||
MODULE_CLASSES: typing.List[typing.Type] = []
|
||||
|
||||
|
||||
IMAGE_SIZES_ENUM = [
|
||||
("32", "32", "32"),
|
||||
("64", "64", "64"),
|
||||
("128", "128", "128"),
|
||||
("256", "256", "256"),
|
||||
("512", "512", "512"),
|
||||
("1024", "1024", "1024"),
|
||||
("2048", "2048", "2048"),
|
||||
("4096", "4096", "4096"),
|
||||
("8192", "8192", "8192"),
|
||||
("16384", "16384", "16384"),
|
||||
]
|
||||
|
||||
|
||||
@polib.log_helpers_bpy.logged_operator
|
||||
class PackLogs(bpy.types.Operator):
|
||||
bl_idname = "memsaver.pack_logs"
|
||||
bl_label = "Pack Logs"
|
||||
bl_description = "Archives polygoniq logs as zip file and opens its location"
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
def execute(self, context):
|
||||
packed_logs_directory_path = polib.log_helpers_bpy.pack_logs(telemetry)
|
||||
polib.utils_bpy.xdg_open_file(packed_logs_directory_path)
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
MODULE_CLASSES.append(PackLogs)
|
||||
|
||||
|
||||
@polib.log_helpers_bpy.logged_operator
|
||||
class OpenCacheFolder(bpy.types.Operator):
|
||||
bl_idname = "memsaver.open_cache_folder"
|
||||
bl_label = "Open Cache Folder"
|
||||
bl_description = "Opens the directory with cached derivative images"
|
||||
bl_options = {'REGISTER'}
|
||||
|
||||
def execute(self, context):
|
||||
prefs = get_preferences(context)
|
||||
polib.utils_bpy.xdg_open_file(prefs.get_cache_path())
|
||||
return {'FINISHED'}
|
||||
|
||||
|
||||
MODULE_CLASSES.append(OpenCacheFolder)
|
||||
|
||||
|
||||
@polib.log_helpers_bpy.logged_preferences
|
||||
class Preferences(bpy.types.AddonPreferences):
|
||||
bl_idname = __package__
|
||||
|
||||
cache_path: bpy.props.StringProperty(
|
||||
name="Cache Path",
|
||||
default=os.path.expanduser("~/memsaver_cache"),
|
||||
description="Where derivatives (of various sizes) of images will be cached.",
|
||||
subtype='DIR_PATH',
|
||||
update=lambda self, context: polib.utils_bpy.absolutize_preferences_path(
|
||||
self, context, "cache_path"
|
||||
),
|
||||
)
|
||||
|
||||
# Used to choose target of image sizer operator
|
||||
operator_target: bpy.props.EnumProperty(
|
||||
name="Target",
|
||||
description="Choose to what objects/images the operator should apply to",
|
||||
items=[
|
||||
('SELECTED_OBJECTS', "Selected Objects", "All selected objects"),
|
||||
('SCENE_OBJECTS', "Scene Objects", "All objects in current scene"),
|
||||
('ALL_OBJECTS', "All Objects", "All objects in the .blend file"),
|
||||
(None),
|
||||
(
|
||||
'ALL_IMAGES_EXCEPT_HDR_EXR',
|
||||
"All Images except HDR and EXR",
|
||||
"All images except HDR and EXR, even those not used in any objects",
|
||||
),
|
||||
(
|
||||
'ALL_HDR_EXR_IMAGES',
|
||||
"All HDR and EXR Images",
|
||||
"All HDR and EXR images, even those not used in any objects",
|
||||
),
|
||||
('ALL_IMAGES', "All Images", "All images, even those not used in any objects"),
|
||||
],
|
||||
default='SCENE_OBJECTS',
|
||||
)
|
||||
|
||||
adaptive_image_enabled: bpy.props.BoolProperty(
|
||||
name="Optimize Images",
|
||||
default=True,
|
||||
)
|
||||
|
||||
adaptive_image_quality_factor: bpy.props.FloatProperty(
|
||||
name="Quality Factor",
|
||||
description="Object 2D bounds are multiplied by this to figure out image side size. \n\n"
|
||||
"Texture sizes are calculated based on how many pixels the objects that use them take "
|
||||
"either horizontally or vertically in the camera view. For example if an object is bigger "
|
||||
"vertically and it takes 10% of a FHD resolution then it takes 108 px and so its textures "
|
||||
"(if not used anywhere else) have to be at least 256px if the quality factor is set to "
|
||||
"2.00. Please note that if a big texture is mapped onto an object in a way that only "
|
||||
"a fraction of it is actually used, the downscaling might have a detrimental effect since "
|
||||
"even though the object is let's say 970 px wide it uses only a small part of the entire "
|
||||
"texture which then gets downscaled based on the bounds and not based on actual mapping "
|
||||
"density",
|
||||
default=1.0,
|
||||
min=0.001,
|
||||
)
|
||||
|
||||
adaptive_image_minimum_size: bpy.props.EnumProperty(
|
||||
name="Minimum Image Size",
|
||||
description="Minimal image size, the algorithm will not choose a smaller size than this",
|
||||
items=IMAGE_SIZES_ENUM,
|
||||
default="256",
|
||||
)
|
||||
|
||||
adaptive_image_maximum_size: bpy.props.EnumProperty(
|
||||
name="Maximum Image Size",
|
||||
description="Maximal image size, the algorithm will not choose a larger size than this",
|
||||
items=IMAGE_SIZES_ENUM,
|
||||
default="4096", # 4k
|
||||
)
|
||||
|
||||
adaptive_mesh_enabled: bpy.props.BoolProperty(
|
||||
name="Decimate Meshes",
|
||||
default=False,
|
||||
)
|
||||
|
||||
adaptive_mesh_full_quality_distance: bpy.props.FloatProperty(
|
||||
name="Full Quality Max Distance",
|
||||
description="We won't apply any decimation on meshes closer to the camera than this value",
|
||||
default=20.0,
|
||||
min=0.0,
|
||||
)
|
||||
|
||||
adaptive_mesh_lowest_quality_distance: bpy.props.FloatProperty(
|
||||
name="Lowest Quality Distance",
|
||||
description="Distance at which we will apply the lowest quality decimation",
|
||||
default=200.0,
|
||||
min=0.0,
|
||||
)
|
||||
|
||||
adaptive_mesh_lowest_quality_decimation_ratio: bpy.props.FloatProperty(
|
||||
name="Lowest Quality Decimation Ratio",
|
||||
description="Which decimation ratio do we apply at the lowest quality distance",
|
||||
default=0.2,
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
)
|
||||
|
||||
adaptive_mesh_lowest_face_count: bpy.props.IntProperty(
|
||||
name="Lowest Face Count",
|
||||
description="Ignore meshes with face count lower than this value",
|
||||
default=5000,
|
||||
min=0,
|
||||
)
|
||||
|
||||
adaptive_animation_mode: bpy.props.BoolProperty(
|
||||
name="Animation Mode",
|
||||
description="Instead of figuring out distances from current frame, consider all frames",
|
||||
default=False,
|
||||
)
|
||||
|
||||
overlay_text_size_px: bpy.props.FloatProperty(
|
||||
name="Overlay Text Size",
|
||||
description="Size of the overlay text in pixels",
|
||||
default=16.0,
|
||||
min=1.0,
|
||||
)
|
||||
|
||||
overlay_text_color: bpy.props.FloatVectorProperty(
|
||||
name="Overlay Text Color",
|
||||
description="Color of the text overlay. "
|
||||
"Useful when the default value would blend with background",
|
||||
min=0.0,
|
||||
max=1.0,
|
||||
default=(1.0, 1.0, 1.0, 1.0),
|
||||
size=4,
|
||||
subtype='COLOR',
|
||||
)
|
||||
|
||||
change_size_desired_size: bpy.props.EnumProperty(
|
||||
name="Desired Size",
|
||||
description="Desired Maximum Side Size of Textures",
|
||||
items=IMAGE_SIZES_ENUM + [("CUSTOM", "CUSTOM", "CUSTOM")],
|
||||
default="2048",
|
||||
)
|
||||
|
||||
change_size_custom_size: bpy.props.IntProperty(
|
||||
name="Custom Size",
|
||||
description="Desired Maximum Side Size of Textures",
|
||||
default=3072,
|
||||
)
|
||||
|
||||
def get_cache_path(self) -> str:
|
||||
if not os.path.isdir(self.cache_path):
|
||||
os.makedirs(self.cache_path)
|
||||
return self.cache_path
|
||||
|
||||
def draw(self, context: bpy.types.Context):
|
||||
row = self.layout.row()
|
||||
row.prop(self, "cache_path")
|
||||
row = self.layout.row()
|
||||
row.operator(OpenCacheFolder.bl_idname, icon='FILE_CACHE')
|
||||
|
||||
row = self.layout.row()
|
||||
row.operator(PackLogs.bl_idname, icon='EXPERIMENTAL')
|
||||
|
||||
polib.ui_bpy.draw_settings_footer(self.layout)
|
||||
|
||||
|
||||
MODULE_CLASSES.append(Preferences)
|
||||
|
||||
|
||||
def get_preferences(context: bpy.types.Context) -> Preferences:
|
||||
return context.preferences.addons[__package__].preferences
|
||||
|
||||
|
||||
def register():
|
||||
for cls in MODULE_CLASSES:
|
||||
bpy.utils.register_class(cls)
|
||||
|
||||
|
||||
def unregister():
|
||||
for cls in reversed(MODULE_CLASSES):
|
||||
bpy.utils.unregister_class(cls)
|
||||
@@ -0,0 +1,50 @@
|
||||
#!/usr/bin/python3
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
# Minimal library that contains code for spawning Blender assets. We try to keep this library
|
||||
# as minimal as possible, because every change in this module triggers huge rebuilds
|
||||
# (mostly rendering previews). We want this library to be only external dependency of
|
||||
# render_previews, so don't ever import polib here!
|
||||
|
||||
|
||||
if "bounding_box" not in locals():
|
||||
from . import bounding_box
|
||||
from . import displacement
|
||||
from . import load
|
||||
from . import spawn
|
||||
from . import textures
|
||||
from . import utils
|
||||
else:
|
||||
import importlib
|
||||
|
||||
bounding_box = importlib.reload(bounding_box)
|
||||
displacement = importlib.reload(displacement)
|
||||
load = importlib.reload(load)
|
||||
spawn = importlib.reload(spawn)
|
||||
textures = importlib.reload(textures)
|
||||
utils = importlib.reload(utils)
|
||||
|
||||
|
||||
# fake bl_info so that this gets picked up by vscode blender integration
|
||||
bl_info = {
|
||||
"name": "hatchery",
|
||||
"description": "",
|
||||
}
|
||||
|
||||
|
||||
def register(): # stub just to avoid an AttributeError when using blender_vscode extension
|
||||
pass
|
||||
|
||||
|
||||
def unregister(): # stub just to avoid an AttributeError when using blender_vscode extension
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"bounding_box",
|
||||
"displacement",
|
||||
"load",
|
||||
"spawn",
|
||||
"textures",
|
||||
"utils",
|
||||
]
|
||||
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/python3
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import bpy
|
||||
import itertools
|
||||
import mathutils
|
||||
import math
|
||||
import typing
|
||||
|
||||
|
||||
class AlignedBox:
|
||||
"""Axis-aligned bounding box"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
min: typing.Optional[mathutils.Vector] = None,
|
||||
max: typing.Optional[mathutils.Vector] = None,
|
||||
):
|
||||
self.min = min if min is not None else mathutils.Vector((math.inf,) * 3)
|
||||
self.max = max if max is not None else mathutils.Vector((-math.inf,) * 3)
|
||||
|
||||
def is_valid(self) -> bool:
|
||||
"""Checks whether this aligned box is valid
|
||||
|
||||
Aligned box is valid if and only if its volume is non-negative,
|
||||
any aligned box becomes valid if it was extended by at least one point or any other object
|
||||
"""
|
||||
for min_field, max_field in zip(self.min, self.max):
|
||||
if min_field > max_field:
|
||||
return False
|
||||
return True
|
||||
|
||||
def extend_by_point(self, point: mathutils.Vector) -> None:
|
||||
"""Extends this aligned box by given infinitesimal point
|
||||
|
||||
This makes sure the resulting aligned box contains everything it contained before, plus
|
||||
the given point.
|
||||
"""
|
||||
self.min.x = min(self.min.x, point.x)
|
||||
self.min.y = min(self.min.y, point.y)
|
||||
self.min.z = min(self.min.z, point.z)
|
||||
|
||||
self.max.x = max(self.max.x, point.x)
|
||||
self.max.y = max(self.max.y, point.y)
|
||||
self.max.z = max(self.max.z, point.z)
|
||||
|
||||
def extend_by_object(
|
||||
self,
|
||||
obj: bpy.types.Object,
|
||||
parent_collection_matrix: mathutils.Matrix = mathutils.Matrix.Identity(4),
|
||||
) -> None:
|
||||
"""Extend the bounding box to cover given object
|
||||
|
||||
If the AlignedBox is extended by object then min_x, max_x,... values are in world space,
|
||||
not object local space. When object moves after initialization of the AlignedBox,
|
||||
coordinate properties are not recomputed to match new object's position - this class does
|
||||
not store any reference to initialization objects.
|
||||
AlignedBox computes boundaries even for instanced collection objects, that's its main
|
||||
difference compared to the bound_box property of bpy.types.Object.
|
||||
|
||||
Note: Other methods of this class are space-neutral but this method only makes sense if
|
||||
the bounding box is considered a world-space bounding box.
|
||||
"""
|
||||
# matrix_world is matrix relative to object's blend.
|
||||
# Thus collection objects have offset inside collection defined by their matrix_world.
|
||||
# We need to multiply parent_collection_matrix by obj.matrix_world in recursion
|
||||
# to get matrix relevant to top-most collection world space.
|
||||
obj_matrix = parent_collection_matrix @ obj.matrix_world
|
||||
# if object is a collection, it has bounding box ((0,0,0), (0,0,0), ...)
|
||||
# we need to manually traverse objects from collections and extend main bounding box
|
||||
# to contain all objects
|
||||
if obj.instance_type == 'COLLECTION':
|
||||
collection = obj.instance_collection
|
||||
if collection is not None: # if this happens we assume no objects
|
||||
for collection_obj in collection.objects:
|
||||
self.extend_by_object(collection_obj, obj_matrix)
|
||||
else:
|
||||
for corner in obj.bound_box:
|
||||
self.extend_by_point(obj_matrix @ mathutils.Vector(corner))
|
||||
|
||||
def get_eccentricity(self) -> mathutils.Vector:
|
||||
"""Returns relative eccentricity in each axis."""
|
||||
return (self.max - self.min) / 2.0
|
||||
|
||||
def get_center(self) -> mathutils.Vector:
|
||||
return (self.min + self.max) / 2.0
|
||||
|
||||
def get_size(self) -> mathutils.Vector:
|
||||
return self.max - self.min
|
||||
|
||||
def get_corners(self) -> typing.Iterable[mathutils.Vector]:
|
||||
for i, j, k in itertools.product([self.min, self.max], repeat=3):
|
||||
yield mathutils.Vector((i.x, j.y, k.z))
|
||||
|
||||
def __str__(self):
|
||||
return (
|
||||
f"Aligned Box\n"
|
||||
f"X = ({self.min.x}, {self.max.x})\n"
|
||||
f"Y = ({self.min.y}, {self.max.y})\n"
|
||||
f"Z = ({self.min.z}, {self.max.z})"
|
||||
)
|
||||
@@ -0,0 +1,88 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
# This module contains the materialiq displacement functionality.
|
||||
|
||||
# Some of the code is repeated and could be reused from 'polib/node_utils_bpy', but that would mean
|
||||
# we have to split the semantically correct module into two modules in 'polib' and in 'hatchery'
|
||||
# as we don't want to import polib here.
|
||||
|
||||
|
||||
import bpy
|
||||
import typing
|
||||
|
||||
|
||||
def _get_top_level_material_outputs(
|
||||
node_tree: bpy.types.NodeTree,
|
||||
) -> typing.Set[bpy.types.ShaderNodeOutputMaterial]:
|
||||
ret = set()
|
||||
for node in node_tree.nodes:
|
||||
if isinstance(node, bpy.types.ShaderNodeOutputMaterial):
|
||||
ret.add(node)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def _get_displacement_nodegroups(
|
||||
node_tree: bpy.types.NodeTree,
|
||||
) -> typing.Set[bpy.types.ShaderNodeGroup]:
|
||||
ret = set()
|
||||
for node in node_tree.nodes:
|
||||
if not hasattr(node, "node_tree"):
|
||||
continue
|
||||
|
||||
if node.node_tree.name.startswith("mq_Displacement"):
|
||||
ret.add(node)
|
||||
else:
|
||||
ret.update(_get_displacement_nodegroups(node.node_tree))
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def unlink_displacement(material: bpy.types.Material) -> None:
|
||||
if material.node_tree is None:
|
||||
# it's not using nodes or the node_tree is invalid
|
||||
return
|
||||
|
||||
material_output_nodes = _get_top_level_material_outputs(material.node_tree)
|
||||
|
||||
for material_output_node in material_output_nodes:
|
||||
# Find links connected to the material output node "Displacement" socket and unlink them
|
||||
for link in material.node_tree.links:
|
||||
if link.to_node != material_output_node:
|
||||
continue
|
||||
if link.to_socket.name != "Displacement":
|
||||
continue
|
||||
|
||||
material.node_tree.links.remove(link)
|
||||
break
|
||||
|
||||
|
||||
def can_link_displacement(material: bpy.types.Material) -> bool:
|
||||
if material.node_tree is None:
|
||||
return False
|
||||
|
||||
displacement_nodegroups = _get_displacement_nodegroups(material.node_tree)
|
||||
|
||||
return len(displacement_nodegroups) == 1
|
||||
|
||||
|
||||
def link_displacement(material: bpy.types.Material) -> None:
|
||||
if material.node_tree is None:
|
||||
# it's not using nodes or the node_tree is invalid
|
||||
return
|
||||
|
||||
displacement_nodegroups = _get_displacement_nodegroups(material.node_tree)
|
||||
if len(displacement_nodegroups) != 1:
|
||||
raise RuntimeError(
|
||||
f"Tried to link materialiq displacement in {material.name} which does not have the "
|
||||
f"mq_Displacement node or there are multiple such nodes."
|
||||
)
|
||||
|
||||
displacement_nodegroup = displacement_nodegroups.pop()
|
||||
|
||||
material_output_nodes = _get_top_level_material_outputs(material.node_tree)
|
||||
|
||||
for material_output_node in material_output_nodes:
|
||||
material.node_tree.links.new(
|
||||
displacement_nodegroup.outputs["Displacement"],
|
||||
material_output_node.inputs["Displacement"],
|
||||
)
|
||||
@@ -0,0 +1,130 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import bpy
|
||||
import os
|
||||
import typing
|
||||
|
||||
|
||||
def try_get_linked_datablock(
|
||||
datablock_collection: bpy.types.bpy_prop_collection, datablock_name: str, blend_path: str
|
||||
) -> typing.Optional[bpy.types.ID]:
|
||||
"""Returns datablock 'datablock_name' linked from 'blend_path' or None if datablock wasn't linked yet.
|
||||
|
||||
Tries to find library corresponding to 'blend_path' and then checks if there's the datablock
|
||||
'datablock_name' linked from this library.
|
||||
"""
|
||||
# Filenames longer than 63 characters are cropped in Blender
|
||||
expected_lib_name = os.path.basename(blend_path)[:63]
|
||||
# This is not 100% reliable, there can be multiple libraries with the same name, so we also
|
||||
# check if library.filepath is the same as the blend_path later.
|
||||
library = bpy.data.libraries.get(expected_lib_name, None)
|
||||
if library is None:
|
||||
return None
|
||||
|
||||
lib_path = bpy.path.abspath(library.filepath)
|
||||
blend_path = os.path.abspath(blend_path)
|
||||
try:
|
||||
if os.path.samefile(lib_path, blend_path):
|
||||
return datablock_collection.get((datablock_name, library.filepath), None)
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def load_master_collection(blend_path: str, link: bool = True) -> bpy.types.Collection:
|
||||
"""Links master collection from 'blend_path' and returns it.
|
||||
|
||||
Master collection is the collection with the same name as basename of the 'blend_path'
|
||||
"""
|
||||
asset_name, _ = os.path.splitext(os.path.basename(blend_path))
|
||||
if link:
|
||||
# Check if collection is already linked. Linking already linked collection doesn't do
|
||||
# anything wrong, Blender recognizes that the same collection is already linked. However it
|
||||
# prints warning: "WARN (blo.readfile): ...\readfile.c:4543 link_named_part: Append: ID 'ASSET_NAME' is already linked"
|
||||
# which looks unprofessional.
|
||||
linked_collection = try_get_linked_datablock(bpy.data.collections, asset_name, blend_path)
|
||||
if linked_collection is not None:
|
||||
return linked_collection
|
||||
|
||||
with bpy.data.libraries.load(blend_path, link=link) as (data_from, data_to):
|
||||
# The root collection of the asset should have the same name as the asset name
|
||||
assert asset_name in data_from.collections
|
||||
data_to.collections = [asset_name]
|
||||
|
||||
return data_to.collections[0]
|
||||
|
||||
|
||||
def load_material(blend_path: str) -> bpy.types.Material:
|
||||
"""Appends material 'blend_path' to current file and returns it.
|
||||
|
||||
This allows loading materials from .blend file that are linked. The assumption here is
|
||||
that the .blend has to contain a mesh with the same name as the material - this loads the
|
||||
mesh and gets access to its material which is then returned.
|
||||
"""
|
||||
asset_name, _ = os.path.splitext(os.path.basename(blend_path))
|
||||
# We use two approaches to load material:
|
||||
# 1. Material is present in the blend_path -> load first one
|
||||
# 2. Material is not available in data_from -> Material can be linked in the source file so it
|
||||
# isn't available through the load API. We take the first mesh in the data and load the
|
||||
# material from there.
|
||||
#
|
||||
# We use those two approaches because the materials can be linked from the library in the
|
||||
# material sources directly if artists want to use the materials in assets too (simplifies
|
||||
# linking and changes a lot).
|
||||
using_transfer_mesh = False
|
||||
with bpy.data.libraries.load(blend_path, link=False) as (data_from, data_to):
|
||||
if len(data_from.materials) > 0:
|
||||
assert len(data_from.materials) > 0
|
||||
data_to.materials = [data_from.materials[0]]
|
||||
else:
|
||||
if asset_name in data_from.meshes:
|
||||
data_to.meshes = [asset_name]
|
||||
using_transfer_mesh = True
|
||||
|
||||
if using_transfer_mesh:
|
||||
transfer_mesh: bpy.types.Mesh = data_to.meshes[0]
|
||||
assert len(transfer_mesh.materials) > 0
|
||||
material = transfer_mesh.materials[0].make_local()
|
||||
bpy.data.meshes.remove(transfer_mesh)
|
||||
else:
|
||||
material = data_to.materials[0]
|
||||
|
||||
return material
|
||||
|
||||
|
||||
def load_particles(blend_path: str) -> typing.List[bpy.types.ParticleSettings]:
|
||||
"""Loads are particle system from 'blend_path' and returns them."""
|
||||
with bpy.data.libraries.load(blend_path, link=False) as (data_from, data_to):
|
||||
data_to.particles = data_from.particles
|
||||
|
||||
assert len(data_to.particles) > 0
|
||||
return data_to.particles
|
||||
|
||||
|
||||
def load_world(blend_path: str) -> bpy.types.World:
|
||||
"""Loads first world from 'blend_path' and returns it."""
|
||||
with bpy.data.libraries.load(blend_path, link=False) as (data_from, data_to):
|
||||
assert len(data_from.worlds) > 0
|
||||
data_to.worlds = [data_from.worlds[0]]
|
||||
|
||||
world = data_to.worlds[0]
|
||||
return world
|
||||
|
||||
|
||||
def load_scene(blend_path: str) -> bpy.types.Scene:
|
||||
"""Loads first scene from 'blend_path' and returns it."""
|
||||
with bpy.data.libraries.load(blend_path, link=False) as (data_from, data_to):
|
||||
assert len(data_from.scenes) > 0
|
||||
data_to.scenes = [data_from.scenes[0]]
|
||||
|
||||
return data_to.scenes[0]
|
||||
|
||||
|
||||
def load_master_object(blend_path: str) -> bpy.types.Object:
|
||||
"""Loads object with the same name as basename of the given .blend path"""
|
||||
asset_name, _ = os.path.splitext(os.path.basename(blend_path))
|
||||
with bpy.data.libraries.load(blend_path, link=False) as (data_from, data_to):
|
||||
assert len(data_from.objects) > 0
|
||||
data_to.objects = [asset_name]
|
||||
|
||||
return data_to.objects[0]
|
||||
@@ -0,0 +1,302 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
# This module contains functions that are used to spawn the asset - get the asset into the scene.
|
||||
#
|
||||
# The function here can adjust context and are more high level. The 'load' module is more low-level
|
||||
# and serves only for loading the datablocks.
|
||||
#
|
||||
# Each asset type has its function and spawn options. Spawn options define properties that can
|
||||
# change the behavior of the spawn function.
|
||||
|
||||
import bpy
|
||||
import abc
|
||||
import dataclasses
|
||||
import mathutils
|
||||
import typing
|
||||
import logging
|
||||
|
||||
from . import utils
|
||||
from . import load
|
||||
from . import textures
|
||||
from . import displacement
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class DatablockSpawnOptions:
|
||||
"""Base for all spawn options
|
||||
|
||||
Currently this is empty, but can store option relevant to all asset types.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class SpawnedData(abc.ABC):
|
||||
def __init__(self, datablocks: typing.Set[bpy.types.ID]):
|
||||
self.datablocks = datablocks
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ModelSpawnOptions(DatablockSpawnOptions):
|
||||
parent_collection: typing.Optional[bpy.types.Collection] = None
|
||||
# If present the spawned model instancer is selected, other objects are deselected
|
||||
select_spawned: bool = False
|
||||
location_override: typing.Optional[mathutils.Vector] = None
|
||||
rotation_euler_override: typing.Optional[mathutils.Euler] = None
|
||||
|
||||
|
||||
class ModelSpawnedData(SpawnedData):
|
||||
def __init__(self, collection: bpy.types.Collection, instancer: bpy.types.Object):
|
||||
self.collection = collection
|
||||
self.instancer = instancer
|
||||
super().__init__({collection, instancer})
|
||||
|
||||
|
||||
def spawn_model(
|
||||
path: str, context: bpy.types.Context, options: ModelSpawnOptions
|
||||
) -> ModelSpawnedData:
|
||||
"""Loads model from given 'path' and instances it on empty on cursor position.
|
||||
|
||||
This assumes the path contains 'master' collection - check load.load_master_collection.
|
||||
Further spawn behavior like parent collection can be tweaked in ModelSpawnOptions.
|
||||
|
||||
Returns the empty that instances the model 'master' collection.
|
||||
"""
|
||||
|
||||
if options.parent_collection is None and options.select_spawned:
|
||||
raise RuntimeError(
|
||||
"Wrong arguments: Cannot select spawned model objects without a parent collection. "
|
||||
"The object wouldn't be present in the View Layer!"
|
||||
)
|
||||
|
||||
root_collection = load.load_master_collection(path)
|
||||
root_empty = bpy.data.objects.new(root_collection.name, None)
|
||||
root_empty.instance_type = 'COLLECTION'
|
||||
root_empty.instance_collection = root_collection
|
||||
root_empty.location = context.scene.cursor.location
|
||||
if options.location_override is not None:
|
||||
root_empty.location = options.location_override
|
||||
|
||||
if options.rotation_euler_override is not None:
|
||||
root_empty.rotation_euler = options.rotation_euler_override
|
||||
|
||||
# Copy all children properties from the instanced objects to the instancer object
|
||||
for obj in root_empty.instance_collection.all_objects:
|
||||
if obj.library is None:
|
||||
continue
|
||||
|
||||
utils.copy_custom_props(obj, root_empty)
|
||||
|
||||
for col in root_empty.users_collection:
|
||||
col.objects.unlink(root_empty)
|
||||
|
||||
if options.parent_collection is not None:
|
||||
options.parent_collection.objects.link(root_empty)
|
||||
|
||||
# Only change selection if we linked the object, so it is present in view layer and if
|
||||
# caller wants to.
|
||||
if options.select_spawned:
|
||||
for selected_obj in context.selected_objects:
|
||||
selected_obj.select_set(False)
|
||||
|
||||
root_empty.select_set(True)
|
||||
|
||||
return ModelSpawnedData(root_collection, root_empty)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class MaterialSpawnOptions(DatablockSpawnOptions):
|
||||
texture_size: int = 2048
|
||||
use_displacement: bool = False
|
||||
target_objects: typing.Set[bpy.types.Object] = dataclasses.field(default_factory=set)
|
||||
|
||||
|
||||
class MaterialSpawnedData(SpawnedData):
|
||||
def __init__(self, material: bpy.types.Material):
|
||||
self.material = material
|
||||
super().__init__({material})
|
||||
|
||||
|
||||
def spawn_material(
|
||||
path: str, context: bpy.types.Context, options: MaterialSpawnOptions
|
||||
) -> MaterialSpawnedData:
|
||||
"""Loads material from 'path' and adds it to all selected objects containing material slots.
|
||||
|
||||
(materialiq materials only)
|
||||
Automatically changes texture sizes and links / unlinks displacement based on spawning options.
|
||||
|
||||
Returns the spawned material.
|
||||
"""
|
||||
material = load.load_material(path)
|
||||
for obj in options.target_objects:
|
||||
if not utils.can_have_materials_assigned(obj):
|
||||
continue
|
||||
if len(obj.material_slots) < 1:
|
||||
obj.data.materials.append(material)
|
||||
else:
|
||||
obj.material_slots[obj.active_material_index].material = material
|
||||
|
||||
textures.change_texture_sizes(options.texture_size, textures.get_used_textures(material))
|
||||
|
||||
if displacement.can_link_displacement(material):
|
||||
if options.use_displacement:
|
||||
displacement.link_displacement(material)
|
||||
else:
|
||||
displacement.unlink_displacement(material)
|
||||
|
||||
return MaterialSpawnedData(material)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class ParticleSystemSpawnOptions(DatablockSpawnOptions):
|
||||
display_type: str = 'TEXTURED'
|
||||
display_percentage: float = 100.0
|
||||
instance_collection_parent: typing.Optional[bpy.types.Collection] = None
|
||||
include_base_material: bool = True
|
||||
max_particle_count: int = 10000
|
||||
# count is used when preserve_density is False
|
||||
count: int = 1000
|
||||
preserve_density: bool = True
|
||||
target_objects: typing.Set[bpy.types.Object] = dataclasses.field(default_factory=set)
|
||||
|
||||
|
||||
class ParticlesSpawnedData(SpawnedData):
|
||||
def __init__(
|
||||
self,
|
||||
particles: typing.Iterable[bpy.types.ParticleSettings],
|
||||
material: typing.Optional[bpy.types.Material] = None,
|
||||
):
|
||||
self.particles = particles
|
||||
self.material = material
|
||||
datablocks = set(particles)
|
||||
if material is not None:
|
||||
datablocks.add(material)
|
||||
super().__init__(datablocks)
|
||||
|
||||
|
||||
def spawn_particles(
|
||||
path: str, context: bpy.types.Context, options: ParticleSystemSpawnOptions
|
||||
) -> ParticlesSpawnedData:
|
||||
"""Loads all particle systems from a given path and puts them on objects based on options.
|
||||
|
||||
Returns list of particle settings that were loaded.
|
||||
"""
|
||||
all_particle_settings = load.load_particles(path)
|
||||
|
||||
# Get lowest z location from target objects and calculate total mesh are of all target objects
|
||||
# so the instanced objects locations and particle counts are adjusted properly.
|
||||
lowest_obj_z = 0.0
|
||||
total_mesh_area = 0.0
|
||||
for target_obj in options.target_objects:
|
||||
lowest_obj_z = min(target_obj.location.z, lowest_obj_z)
|
||||
total_mesh_area += utils.calculate_mesh_area(target_obj)
|
||||
|
||||
for particle_settings in all_particle_settings:
|
||||
particle_settings.display_percentage = options.display_percentage
|
||||
for obj in particle_settings.instance_collection.all_objects:
|
||||
# We spawn all objects 10units below the lowest location of target objects
|
||||
obj.location.z = lowest_obj_z - 10.0
|
||||
obj.display_type = options.display_type
|
||||
|
||||
if options.instance_collection_parent is not None:
|
||||
options.instance_collection_parent.children.link(particle_settings.instance_collection)
|
||||
|
||||
if options.preserve_density:
|
||||
new_count = int(total_mesh_area * particle_settings.pps_density)
|
||||
if new_count > options.max_particle_count:
|
||||
logger.warning(
|
||||
f"Particle count exceeded maximum by: {int(new_count - options.max_particle_count)}"
|
||||
)
|
||||
new_count = options.max_particle_count
|
||||
else:
|
||||
new_count = options.count
|
||||
particle_settings.count = new_count
|
||||
|
||||
for target_obj in options.target_objects:
|
||||
# Create modifiers and adjust particle system settings based on spawn options
|
||||
mod: bpy.types.ParticleSystemModifier = target_obj.modifiers.new(
|
||||
particle_settings.name, type='PARTICLE_SYSTEM'
|
||||
)
|
||||
mod.particle_system.settings = particle_settings
|
||||
utils.ensure_particle_naming_consistency(mod, mod.particle_system)
|
||||
|
||||
spawned_material_data = None
|
||||
if options.include_base_material:
|
||||
spawned_material_data = spawn_material(
|
||||
path, context, MaterialSpawnOptions(target_objects=options.target_objects)
|
||||
)
|
||||
|
||||
return ParticlesSpawnedData(
|
||||
all_particle_settings,
|
||||
spawned_material_data.material if spawned_material_data is not None else None,
|
||||
)
|
||||
|
||||
|
||||
class WorldSpawnedData(SpawnedData):
|
||||
def __init__(self, world: bpy.types.World):
|
||||
self.world = world
|
||||
super().__init__({world})
|
||||
|
||||
|
||||
def spawn_world(
|
||||
path: str, context: bpy.types.Context, options: DatablockSpawnOptions
|
||||
) -> WorldSpawnedData:
|
||||
"""Loads world from 'path' and replaces current scene world with it, returns the loaded world."""
|
||||
world = load.load_world(path)
|
||||
context.scene.world = world
|
||||
return WorldSpawnedData(world)
|
||||
|
||||
|
||||
class SceneSpawnedData(SpawnedData):
|
||||
def __init__(self, scene: bpy.types.Scene):
|
||||
self.scene = scene
|
||||
super().__init__({scene})
|
||||
|
||||
|
||||
def spawn_scene(
|
||||
path: str, context: bpy.types.Context, options: DatablockSpawnOptions
|
||||
) -> SceneSpawnedData:
|
||||
"""Loads scene from 'path' and replaces current scene with it, returns the loaded scene."""
|
||||
scene = load.load_scene(path)
|
||||
context.window.scene = scene
|
||||
return SceneSpawnedData(scene)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class GeometryNodesSpawnOptions(DatablockSpawnOptions):
|
||||
parent_collection: typing.Optional[bpy.types.Collection] = None
|
||||
|
||||
|
||||
class GeometryNodesSpawnedData(SpawnedData):
|
||||
def __init__(
|
||||
self, container_obj: bpy.types.Object, modifiers: typing.Iterable[bpy.types.Modifier]
|
||||
):
|
||||
self.container_obj = container_obj
|
||||
self.modifiers = modifiers
|
||||
super().__init__({container_obj} | {m.node_group for m in modifiers})
|
||||
|
||||
|
||||
def spawn_geometry_nodes(
|
||||
path: str, context: bpy.types.Context, options: GeometryNodesSpawnOptions
|
||||
) -> GeometryNodesSpawnedData:
|
||||
"""Loads object with the same name as basename of 'path' and adds it to the scene collection"""
|
||||
# Currently default behavior is to append the object containing the geometry nodes.
|
||||
# TODO: In future we want to load either node group into node tree, apply onto active
|
||||
# object and choose whether to start draw, or edit mode.
|
||||
obj = load.load_master_object(path)
|
||||
if options.parent_collection is not None:
|
||||
options.parent_collection.objects.link(obj)
|
||||
|
||||
# Due to a bug in Blender while converting boolean inputs we reassign the modifier node
|
||||
# group when spawning. The bug happens when object with modifiers is appended from a blend
|
||||
# file, where the modifier node group is linked from a different file. First append is
|
||||
# correct, but any subsequently appended object with the same modifier triggers the:
|
||||
# 'Property type does not match input socket (NAME)' error and can make some setups not work
|
||||
# Issue link: https://projects.blender.org/blender/blender/issues/110825
|
||||
for mod in obj.modifiers:
|
||||
if mod.type == 'NODES':
|
||||
mod.node_group = mod.node_group
|
||||
|
||||
return GeometryNodesSpawnedData(obj, {m for m in obj.modifiers if m.type == 'NODES'})
|
||||
@@ -0,0 +1,102 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
# This module contains materialiq texture switching related functions.
|
||||
|
||||
import bpy
|
||||
import os
|
||||
import typing
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
TEXTURE_EXTENSIONS = {".png", ".jpg"}
|
||||
|
||||
|
||||
def generate_filepath(texture_path: str, basename: str, max_size: str, ext: str) -> str:
|
||||
if basename.startswith("mq_") and basename.split("_")[-1].isdigit():
|
||||
name_without_resolution = basename.rsplit("_", 1)[0]
|
||||
|
||||
return os.path.join(texture_path, f"{name_without_resolution}_{max_size}{ext}")
|
||||
|
||||
|
||||
def is_materialiq_texture(image: bpy.types.Image) -> bool:
|
||||
basename, _ = os.path.splitext(os.path.basename(image.filepath))
|
||||
if basename.startswith("mq_") and basename.split("_")[-1].isdigit():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def change_texture_size(max_size: int, image: bpy.types.Image):
|
||||
if not is_materialiq_texture(image):
|
||||
return
|
||||
|
||||
basename, ext = os.path.splitext(os.path.basename(image.filepath))
|
||||
if ext not in TEXTURE_EXTENSIONS:
|
||||
return
|
||||
|
||||
logger.debug(f"Changing {image.name} to {max_size}...")
|
||||
|
||||
new_path = None
|
||||
found = False
|
||||
parent_dir = os.path.dirname(image.filepath)
|
||||
for ext in TEXTURE_EXTENSIONS:
|
||||
new_path = generate_filepath(parent_dir, basename, str(max_size), ext)
|
||||
new_abs_path = bpy.path.abspath(new_path)
|
||||
# We getsize() to check that the file is not empty. Because of compress_texture, there could
|
||||
# exist different file formats of the same texture, and all except one of them would be empty.
|
||||
if os.path.exists(new_abs_path) and os.path.getsize(new_abs_path) > 0:
|
||||
found = True
|
||||
break
|
||||
|
||||
if not found:
|
||||
logger.warning(f"Can't find {image.name} in size {max_size}, skipping...")
|
||||
return
|
||||
|
||||
image.filepath = new_path
|
||||
image.name = os.path.basename(new_path)
|
||||
|
||||
|
||||
def change_texture_sizes(
|
||||
max_size: int, only_textures: typing.Optional[typing.Set[bpy.types.Image]] = None
|
||||
):
|
||||
logger.debug(f"mq: changing textures to {max_size}...")
|
||||
|
||||
if only_textures is not None:
|
||||
for image in only_textures:
|
||||
change_texture_size(max_size, image)
|
||||
else:
|
||||
for image in bpy.data.images:
|
||||
change_texture_size(max_size, image)
|
||||
|
||||
|
||||
def get_used_textures_in_node(node: bpy.types.Node) -> typing.Set[bpy.types.Image]:
|
||||
ret = set()
|
||||
|
||||
if hasattr(node, "node_tree"):
|
||||
for child_node in node.node_tree.nodes:
|
||||
ret.update(get_used_textures_in_node(child_node))
|
||||
|
||||
if hasattr(node, "image"):
|
||||
if node.image:
|
||||
ret.add(node.image)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def get_used_textures(material: bpy.types.Material) -> typing.Set[bpy.types.Image]:
|
||||
if material is None:
|
||||
return set()
|
||||
|
||||
if not material.use_nodes:
|
||||
logger.warning(
|
||||
f"Can't get used textures from material '{material.name}' that is not using "
|
||||
f"the node system!"
|
||||
)
|
||||
return set()
|
||||
|
||||
assert material.node_tree is not None, "use_nodes is True, yet node_tree is None"
|
||||
ret = set()
|
||||
for node in material.node_tree.nodes:
|
||||
ret.update(get_used_textures_in_node(node))
|
||||
|
||||
return ret
|
||||
@@ -0,0 +1,150 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import bpy
|
||||
import typing
|
||||
import bmesh
|
||||
import rna_prop_ui
|
||||
|
||||
|
||||
def copy_custom_prop(src: bpy.types.ID, dst: bpy.types.ID, prop_name: str) -> None:
|
||||
"""Copies custom property 'prop_name' from 'src' to 'dst' while preserving its settings"""
|
||||
# In order to copy the property with its configuration (min, max, subtype, etc)
|
||||
# we need to use following code. Code is taken from the "Copy Attributes" addon that's
|
||||
# shipped within Blender.
|
||||
|
||||
# Create the property.
|
||||
dst[prop_name] = src[prop_name]
|
||||
# Copy the settings of the property.
|
||||
try:
|
||||
dst_prop_manager = dst.id_properties_ui(prop_name)
|
||||
except TypeError:
|
||||
# Python values like lists or dictionaries don't have any settings to copy.
|
||||
# They just consist of a value and nothing else.
|
||||
# Note: This also skips copying the properties that cannot be edited by
|
||||
# id_properties_ui
|
||||
return
|
||||
|
||||
src_prop_manager = src.id_properties_ui(prop_name)
|
||||
assert src_prop_manager, f"Property '{prop_name}' not found in {src}"
|
||||
|
||||
dst_prop_manager.update_from(src_prop_manager)
|
||||
|
||||
# Copy the Library Overridable flag, which is stored elsewhere, sometimes it's not possible
|
||||
# to copy the library override
|
||||
try:
|
||||
prop_rna_path = f'["{prop_name}"]'
|
||||
is_lib_overridable = src.is_property_overridable_library(prop_rna_path)
|
||||
dst.property_overridable_library_set(prop_rna_path, is_lib_overridable)
|
||||
except:
|
||||
pass
|
||||
|
||||
|
||||
def copy_custom_props(
|
||||
src: bpy.types.ID, dst: bpy.types.ID, only_existing: bool = False, recursive: bool = False
|
||||
) -> None:
|
||||
"""Copies all custom properties from 'src' to 'dst'
|
||||
|
||||
If 'only_existing' is True, then properties that don't exist on
|
||||
the 'dst' object are not created, only values of existing properties are
|
||||
updated.
|
||||
|
||||
If 'recursive' is provided the property is copied to all children of 'dst' object
|
||||
"""
|
||||
if recursive:
|
||||
for child in dst.children:
|
||||
copy_custom_props(src, child, only_existing, recursive)
|
||||
|
||||
for prop_name in src.keys():
|
||||
if only_existing and prop_name not in dst:
|
||||
continue
|
||||
|
||||
copy_custom_prop(src, dst, prop_name)
|
||||
|
||||
|
||||
def ensure_particle_naming_consistency(
|
||||
modifier: bpy.types.ParticleSystemModifier, particle_system: bpy.types.ParticleSystem
|
||||
) -> None:
|
||||
"""
|
||||
Particle data gets duplicated and has the object duplicate suffix on copy, but modifiers and particle system names do not.
|
||||
This function ensures the same naming on the whole particle system -> modifier, data, particle system, instance_collection
|
||||
|
||||
Using the name from instance collection is currently the best approach. Creating modifier creates particle data automatically,
|
||||
but we don't want to use those, we use the ones loaded from our blends (this gives them .001). Instance collections have the most
|
||||
correct duplicate suffix because we have almost full control over them (at least when we are creating them).
|
||||
"""
|
||||
if modifier is None or particle_system is None:
|
||||
raise RuntimeError(
|
||||
"Cannot ensure naming consistency if modifier or particle_system is None!"
|
||||
)
|
||||
|
||||
ps_settings = particle_system.settings
|
||||
if ps_settings is None or ps_settings.instance_collection is None:
|
||||
raise RuntimeError(
|
||||
f"Cannot ensure naming consistency if particle_system ({particle_system.name}) has no settings or no instance_collection!"
|
||||
)
|
||||
|
||||
modifier.name = particle_system.name = ps_settings.name = ps_settings.instance_collection.name
|
||||
|
||||
|
||||
def get_area_based_particle_count(
|
||||
obj: bpy.types.Object, density: float, max_particle_count: int, include_weights: bool = False
|
||||
) -> typing.Tuple[int, int]:
|
||||
mesh_area = calculate_mesh_area(obj, include_weights)
|
||||
particle_count = int(mesh_area * density)
|
||||
if particle_count > max_particle_count:
|
||||
return max_particle_count, particle_count - max_particle_count
|
||||
return particle_count, 0
|
||||
|
||||
|
||||
def calculate_mesh_area(obj: bpy.types.Object, include_weight: bool = False) -> float:
|
||||
mesh = obj.data
|
||||
try:
|
||||
if obj.mode == 'EDIT':
|
||||
bm = bmesh.from_edit_mesh(mesh)
|
||||
else:
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(mesh)
|
||||
|
||||
bm.transform(obj.matrix_world)
|
||||
if include_weight:
|
||||
vg = obj.vertex_groups.active
|
||||
mesh_area = 0
|
||||
for face in bm.faces:
|
||||
f_area = face.calc_area()
|
||||
weighted_verts = 0
|
||||
weight = 0
|
||||
for v in face.verts:
|
||||
# heavy approach, but we don't know whether i vertex is in the group :(
|
||||
try:
|
||||
weight += vg.weight(v.index)
|
||||
weighted_verts += 1
|
||||
except:
|
||||
pass
|
||||
if weighted_verts > 0:
|
||||
mesh_area += (weight / weighted_verts) * f_area
|
||||
else:
|
||||
mesh_area = sum(f.calc_area() for f in bm.faces)
|
||||
|
||||
finally:
|
||||
bm.free()
|
||||
|
||||
return mesh_area
|
||||
|
||||
|
||||
def can_have_materials_assigned(obj: bpy.types.Object) -> bool:
|
||||
"""Checks whether given object can have materials assigned
|
||||
|
||||
We check for multiple things: type of the object and the availability of material_slots.
|
||||
"""
|
||||
|
||||
# In theory checking the availability of material_slots is not necessary, all these
|
||||
# object types should have it. We check for it to avoid exceptions and errors in our code.
|
||||
return obj.type in {
|
||||
'MESH',
|
||||
'CURVE',
|
||||
'SURFACE',
|
||||
'META',
|
||||
'FONT',
|
||||
'GPENCIL',
|
||||
'VOLUME',
|
||||
} and hasattr(obj, "material_slots")
|
||||
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/python3
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
if "asset_pack_bpy" not in locals():
|
||||
from . import asset_pack
|
||||
from . import bl_info_utils
|
||||
|
||||
# polib is used outside of Blender as well, we have to support
|
||||
# a usecase where bpy is not available and can't be imported
|
||||
try:
|
||||
import bpy
|
||||
from . import asset_pack_bpy
|
||||
from . import color_utils
|
||||
from . import geonodes_mod_utils_bpy
|
||||
from . import installation_utils_bpy
|
||||
from . import linalg_bpy
|
||||
from . import log_helpers_bpy
|
||||
from . import material_utils_bpy
|
||||
from . import module_install_utils_bpy
|
||||
from . import node_utils_bpy
|
||||
from . import preview_manager_bpy
|
||||
from . import remove_duplicates_bpy
|
||||
from . import render_bpy
|
||||
from . import rigs_shared_bpy
|
||||
from . import snap_to_ground_bpy
|
||||
from . import spline_utils_bpy
|
||||
from . import split_file_reader
|
||||
from . import telemetry_module_bpy as telemetry_native_module
|
||||
from . import ui_bpy
|
||||
from . import utils_bpy
|
||||
|
||||
# singleton instance
|
||||
module_provider = module_install_utils_bpy.ModuleProvider()
|
||||
|
||||
def init_polygoniq_global():
|
||||
global telemetry_module_bpy
|
||||
|
||||
if not hasattr(bpy, "polygoniq_global"):
|
||||
bpy.polygoniq_global = {"telemetry": {}, "telemetry_module_bpy": {}} # deprecated!
|
||||
|
||||
if "telemetry_module_bpy" not in bpy.polygoniq_global:
|
||||
bpy.polygoniq_global["telemetry_module_bpy"] = {}
|
||||
|
||||
# another polygoniq addon might have already initialized telemetry!
|
||||
# we want to use just one instance unless it's a different API version
|
||||
if telemetry_native_module.API_VERSION in bpy.polygoniq_global["telemetry_module_bpy"]:
|
||||
telemetry_module_bpy = bpy.polygoniq_global["telemetry_module_bpy"][
|
||||
telemetry_native_module.API_VERSION
|
||||
]
|
||||
else:
|
||||
telemetry_module_bpy = telemetry_native_module
|
||||
bpy.polygoniq_global["telemetry_module_bpy"][
|
||||
telemetry_native_module.API_VERSION
|
||||
] = telemetry_module_bpy
|
||||
telemetry_module_bpy.bootstrap_telemetry()
|
||||
|
||||
init_polygoniq_global()
|
||||
|
||||
def get_telemetry(product: str):
|
||||
return telemetry_module_bpy.get_telemetry(product)
|
||||
|
||||
except ImportError as e:
|
||||
if e.name != "bpy":
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
f"polib has been initialized without bpy, all polib modules that use bpy are imported as dummies only."
|
||||
)
|
||||
|
||||
import types
|
||||
|
||||
asset_pack_bpy = types.ModuleType("asset_pack_bpy")
|
||||
color_utils = types.ModuleType("color_utils")
|
||||
geonodes_mod_utils_bpy = types.ModuleType("geonodes_mod_utils_bpy")
|
||||
installation_utils_bpy = types.ModuleType("installation_utils_bpy")
|
||||
linalg_bpy = types.ModuleType("linalg_bpy")
|
||||
log_helpers_bpy = types.ModuleType("log_helpers_bpy")
|
||||
material_utils_bpy = types.ModuleType("material_utils_bpy")
|
||||
module_install_utils_bpy = types.ModuleType("module_install_utils_bpy")
|
||||
node_utils_bpy = types.ModuleType("node_utils_bpy")
|
||||
preview_manager_bpy = types.ModuleType("preview_manager_bpy")
|
||||
remove_duplicates_bpy = types.ModuleType("remove_duplicates_bpy")
|
||||
render_bpy = types.ModuleType("render_bpy")
|
||||
rigs_shared_bpy = types.ModuleType("rigs_shared_bpy")
|
||||
snap_to_ground_bpy = types.ModuleType("snap_to_ground_bpy")
|
||||
spline_utils_bpy = types.ModuleType("spline_utils_bpy")
|
||||
split_file_reader = types.ModuleType("split_file_reader")
|
||||
telemetry_native_module = types.ModuleType("telemetry_native_module")
|
||||
ui_bpy = types.ModuleType("ui_bpy")
|
||||
utils_bpy = types.ModuleType("utils_bpy")
|
||||
|
||||
|
||||
else:
|
||||
import importlib
|
||||
|
||||
try:
|
||||
asset_pack = importlib.reload(asset_pack)
|
||||
asset_pack_bpy = importlib.reload(asset_pack_bpy)
|
||||
color_utils = importlib.reload(color_utils)
|
||||
bl_info_utils = importlib.reload(bl_info_utils)
|
||||
geonodes_mod_utils_bpy = importlib.reload(geonodes_mod_utils_bpy)
|
||||
installation_utils_bpy = importlib.reload(installation_utils_bpy)
|
||||
linalg_bpy = importlib.reload(linalg_bpy)
|
||||
log_helpers_bpy = importlib.reload(log_helpers_bpy)
|
||||
material_utils_bpy = importlib.reload(material_utils_bpy)
|
||||
module_install_utils_bpy = importlib.reload(module_install_utils_bpy)
|
||||
node_utils_bpy = importlib.reload(node_utils_bpy)
|
||||
remove_duplicates_bpy = importlib.reload(remove_duplicates_bpy)
|
||||
render_bpy = importlib.reload(render_bpy)
|
||||
rigs_shared_bpy = importlib.reload(rigs_shared_bpy)
|
||||
snap_to_ground_bpy = importlib.reload(snap_to_ground_bpy)
|
||||
spline_utils_bpy = importlib.reload(spline_utils_bpy)
|
||||
split_file_reader = importlib.reload(split_file_reader)
|
||||
telemetry_native_module = importlib.reload(telemetry_native_module)
|
||||
ui_bpy = importlib.reload(ui_bpy)
|
||||
utils_bpy = importlib.reload(utils_bpy)
|
||||
except ImportError:
|
||||
# in case these are fake modules created with types.ModuleType (when bpy is not available)
|
||||
pass
|
||||
|
||||
|
||||
# fake bl_info so that this gets picked up by vscode blender integration
|
||||
bl_info = {
|
||||
"name": "polib",
|
||||
"description": "",
|
||||
}
|
||||
|
||||
|
||||
def register(): # stub just to avoid an AttributeError when using blender_vscode extension
|
||||
pass
|
||||
|
||||
|
||||
def unregister(): # stub just to avoid an AttributeError when using blender_vscode extension
|
||||
pass
|
||||
|
||||
|
||||
__all__ = [
|
||||
"asset_pack_bpy",
|
||||
"asset_pack",
|
||||
"color_utils",
|
||||
"bl_info_utils",
|
||||
"geonodes_mod_utils_bpy",
|
||||
"get_telemetry",
|
||||
"installation_utils_bpy",
|
||||
"linalg_bpy",
|
||||
"log_helpers_bpy",
|
||||
"material_utils_bpy",
|
||||
"module_install_utils_bpy",
|
||||
"node_utils_bpy",
|
||||
"preview_manager_bpy",
|
||||
"remove_duplicates_bpy",
|
||||
"render_bpy",
|
||||
"rigs_shared_bpy",
|
||||
"snap_to_ground_bpy",
|
||||
"spline_utils_bpy",
|
||||
"split_file_reader",
|
||||
# telemetry_module_bpy intentionally missing, you should interact with it via get_telemetry
|
||||
"ui_bpy",
|
||||
"utils_bpy",
|
||||
]
|
||||
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/python3
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
|
||||
def is_library_blend(path: str) -> bool:
|
||||
basename = os.path.basename(path)
|
||||
# lowercase letters and numbers for prefix, followed by _Library_
|
||||
# e.g. "mq_Library_NodeGroups.blend, am154_Library_Materials.blend"
|
||||
return re.match(r"^[a-z0-9]+_Library_.+\.blend$", basename) is not None
|
||||
@@ -0,0 +1,803 @@
|
||||
#!/usr/bin/python3
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import bpy
|
||||
import bpy.utils.previews
|
||||
import typing
|
||||
import collections
|
||||
import enum
|
||||
import logging
|
||||
|
||||
try:
|
||||
import hatchery
|
||||
except ImportError:
|
||||
from blender_addons import hatchery
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
if "linalg_bpy" not in locals():
|
||||
from . import linalg_bpy
|
||||
from . import utils_bpy
|
||||
from . import rigs_shared_bpy
|
||||
else:
|
||||
import importlib
|
||||
|
||||
linalg_bpy = importlib.reload(linalg_bpy)
|
||||
utils_bpy = importlib.reload(utils_bpy)
|
||||
rigs_shared_bpy = importlib.reload(rigs_shared_bpy)
|
||||
|
||||
|
||||
CustomAttributeValueType = typing.Union[
|
||||
str,
|
||||
int,
|
||||
float,
|
||||
typing.Tuple[int, ...],
|
||||
typing.Tuple[float, ...],
|
||||
typing.List[int],
|
||||
typing.List[float],
|
||||
]
|
||||
|
||||
|
||||
# Maps asset pack names to blender Collection color_tags
|
||||
ASSET_PACK_COLLECTION_COLOR_MAP = {
|
||||
"botaniq": 'COLOR_04', # green
|
||||
"traffiq": 'COLOR_02', # orange
|
||||
"aquatiq": 'COLOR_05', # blue
|
||||
}
|
||||
|
||||
|
||||
PARTICLE_SYSTEM_TOKEN = "pps"
|
||||
PREVIEW_NOT_FOUND = "No-Asset-Found"
|
||||
|
||||
|
||||
BOTANIQ_SEASONS = {"spring", "summer", "autumn", "winter"}
|
||||
|
||||
|
||||
# order matters, assets often have multiple seasons, color is set according to the first
|
||||
# matched season
|
||||
BOTANIQ_SEASONS_WITH_COLOR_CHANNEL = (
|
||||
("summer", 1.0),
|
||||
("spring", 0.75),
|
||||
("winter", 0.5),
|
||||
("autumn", 0.25),
|
||||
)
|
||||
|
||||
BOTANIQ_ANIMATED_CATEGORIES = {
|
||||
"coniferous",
|
||||
"deciduous",
|
||||
"shrubs",
|
||||
"flowers",
|
||||
"grass",
|
||||
"ivy",
|
||||
"plants",
|
||||
"sapling",
|
||||
"tropical",
|
||||
"vine",
|
||||
"weed",
|
||||
}
|
||||
|
||||
|
||||
class CustomPropertyNames:
|
||||
# traffiq specific custom property names
|
||||
TQ_DIRT = "tq_dirt"
|
||||
TQ_SCRATCHES = "tq_scratches"
|
||||
TQ_BUMPS = "tq_bumps"
|
||||
TQ_PRIMARY_COLOR = "tq_primary_color"
|
||||
TQ_FLAKES_AMOUNT = "tq_flakes_amount"
|
||||
TQ_CLEARCOAT = "tq_clearcoat"
|
||||
TQ_LIGHTS = "tq_main_lights"
|
||||
# botaniq specific custom property names
|
||||
BQ_BRIGHTNESS = "bq_brightness"
|
||||
BQ_RANDOM_PER_BRANCH = "bq_random_per_branch"
|
||||
BQ_RANDOM_PER_LEAF = "bq_random_per_leaf"
|
||||
BQ_SEASON_OFFSET = "bq_season_offset"
|
||||
|
||||
|
||||
def get_all_object_ancestors(obj: bpy.types.Object) -> typing.Iterable[bpy.types.Object]:
|
||||
"""Returns given object's parent, the parent's parent, ..."""
|
||||
|
||||
current = obj.parent
|
||||
while current is not None:
|
||||
yield current
|
||||
current = current.parent
|
||||
|
||||
|
||||
def filter_out_descendants_from_objects(
|
||||
objects: typing.Iterable[bpy.types.Object],
|
||||
) -> typing.Set[bpy.types.Object]:
|
||||
"""Given a list of objects (i.e. selected objects) this function will return only the
|
||||
roots. By roots we mean included objects that have no ancestor that is also contained
|
||||
in object.
|
||||
|
||||
Example of use of this is when figuring out which objects to snap to ground. If you have
|
||||
a complicated selection of cars, their wheels, etc... you onlt want to snap the parent car
|
||||
body, not all objects.
|
||||
"""
|
||||
|
||||
all_objects = set(objects)
|
||||
|
||||
ret = set()
|
||||
for obj in objects:
|
||||
ancestors = get_all_object_ancestors(obj)
|
||||
if len(all_objects.intersection(ancestors)) == 0:
|
||||
# this object has no ancestors that are also contained in objects
|
||||
ret.add(obj)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def is_polygoniq_object(
|
||||
obj: bpy.types.Object,
|
||||
addon_name_filter: typing.Optional[typing.Callable[[str], bool]] = None,
|
||||
include_editable: bool = True,
|
||||
include_linked: bool = True,
|
||||
) -> bool:
|
||||
if include_editable and obj.instance_type == 'NONE' and obj.get("polygoniq_addon", None):
|
||||
# only non-'EMPTY' objects can be considered editable
|
||||
return addon_name_filter is None or addon_name_filter(obj.get("polygoniq_addon", None))
|
||||
|
||||
elif include_linked and obj.instance_collection is not None:
|
||||
# the object is linked and the custom properties are in the linked collection
|
||||
# in most cases there will be exactly one linked object but we want to play it
|
||||
# safe and will check all of them. if any linked object is a polygoniq object
|
||||
# we assume the whole instance collection is
|
||||
for linked_obj in obj.instance_collection.objects:
|
||||
if is_polygoniq_object(linked_obj, addon_name_filter):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def find_polygoniq_root_objects(
|
||||
objects: typing.Iterable[bpy.types.Object], addon_name: typing.Optional[str] = None
|
||||
) -> typing.Set[bpy.types.Object]:
|
||||
"""Finds and returns polygoniq root objects in 'objects'.
|
||||
|
||||
Returned objects are either root or their parent isn't polygoniq object.
|
||||
E. g. for 'objects' selected from hierarchy:
|
||||
Users_Empty -> Audi_R8 -> [Lights, Wheel1..N -> [Brakes]], this returns Audi_R8.
|
||||
"""
|
||||
|
||||
traversed_objects = set()
|
||||
root_objects = set()
|
||||
addon_name_filter = None if addon_name is None else lambda x: x == addon_name
|
||||
|
||||
for obj in objects:
|
||||
if obj in traversed_objects:
|
||||
continue
|
||||
|
||||
current_obj = obj
|
||||
while True:
|
||||
if current_obj in traversed_objects:
|
||||
break
|
||||
|
||||
if current_obj.parent is None:
|
||||
if is_polygoniq_object(current_obj, addon_name_filter):
|
||||
root_objects.add(current_obj)
|
||||
break
|
||||
|
||||
if is_polygoniq_object(current_obj, addon_name_filter) and not is_polygoniq_object(
|
||||
current_obj.parent, addon_name_filter
|
||||
):
|
||||
root_objects.add(current_obj)
|
||||
break
|
||||
|
||||
traversed_objects.add(current_obj)
|
||||
current_obj = current_obj.parent
|
||||
|
||||
return root_objects
|
||||
|
||||
|
||||
def get_polygoniq_objects(
|
||||
objects: typing.Iterable[bpy.types.Object],
|
||||
addon_name: typing.Optional[str] = None,
|
||||
include_editable: bool = True,
|
||||
include_linked: bool = True,
|
||||
) -> typing.Iterable[bpy.types.Object]:
|
||||
"""Filters given objects and returns only those that contain the polygoniq_addon property"""
|
||||
addon_name_filter = None if addon_name is None else lambda x: x == addon_name
|
||||
for obj in objects:
|
||||
if is_polygoniq_object(obj, addon_name_filter, include_editable, include_linked):
|
||||
yield obj
|
||||
|
||||
|
||||
class TraffiqAssetPart(enum.Enum):
|
||||
Body = 'Body'
|
||||
Lights = 'Lights'
|
||||
Wheel = 'Wheel'
|
||||
Brake = 'Brake'
|
||||
|
||||
|
||||
def is_traffiq_asset_part(obj: bpy.types.Object, part: TraffiqAssetPart) -> bool:
|
||||
addon_name = obj.get("polygoniq_addon", "")
|
||||
if addon_name != "traffiq":
|
||||
return False
|
||||
|
||||
obj_name = utils_bpy.remove_object_duplicate_suffix(obj.name)
|
||||
if part in {TraffiqAssetPart.Body, TraffiqAssetPart.Lights}:
|
||||
splitted_name = obj_name.rsplit("_", 1)
|
||||
if len(splitted_name) != 2:
|
||||
return False
|
||||
|
||||
_, obj_part_name = splitted_name
|
||||
if obj_part_name != part.name:
|
||||
return False
|
||||
return True
|
||||
|
||||
elif part in {TraffiqAssetPart.Wheel, TraffiqAssetPart.Brake}:
|
||||
splitted_name = obj_name.rsplit("_", 3)
|
||||
if len(splitted_name) != 4:
|
||||
return False
|
||||
|
||||
_, obj_part_name, position, wheel_number = splitted_name
|
||||
if obj_part_name != part.name:
|
||||
return False
|
||||
if position not in {"FL", "FR", "BL", "BR", "F", "B"}:
|
||||
return False
|
||||
if not wheel_number.isdigit():
|
||||
return False
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
DecomposedCarType = typing.Tuple[
|
||||
bpy.types.Object,
|
||||
bpy.types.Object,
|
||||
bpy.types.Object,
|
||||
typing.List[bpy.types.Object],
|
||||
typing.List[bpy.types.Object],
|
||||
]
|
||||
|
||||
|
||||
def get_root_object_of_asset(asset: bpy.types.Object) -> typing.Optional[bpy.types.Object]:
|
||||
"""Returns the root linked object if given a linked asset (instanced collection empty).
|
||||
Returns the object itself if given an editable asset. In case there are multiple roots
|
||||
or no roots at all it returns None and logs a warning.
|
||||
"""
|
||||
|
||||
if asset.instance_type == 'COLLECTION':
|
||||
# we have to iterate through objects in the collection and return the one
|
||||
# that has no parent.
|
||||
|
||||
root_obj = None
|
||||
for obj in asset.instance_collection.objects:
|
||||
if obj.parent is None:
|
||||
if root_obj is not None:
|
||||
logger.warning(
|
||||
f"Found multiple root objects in the given collection instance "
|
||||
f"empty (name='{asset.name}')"
|
||||
)
|
||||
return None
|
||||
|
||||
root_obj = obj
|
||||
|
||||
if root_obj is None:
|
||||
logger.warning(
|
||||
f"Failed to find the root object of a given collection instance empty "
|
||||
f"(name='{asset.name}')"
|
||||
)
|
||||
|
||||
return root_obj
|
||||
|
||||
else:
|
||||
# given object is editable
|
||||
return asset
|
||||
|
||||
|
||||
def get_entire_object_hierarchy(obj: bpy.types.Object) -> typing.Iterable[bpy.types.Object]:
|
||||
"""List entire hierarchy of an instanced or editable object
|
||||
|
||||
Returns object hierarchy (the object itself and all descendants) in case the object is
|
||||
editable. In case the object is instanced it looks through the instance_collection.objects
|
||||
and returns all descendants from there.
|
||||
|
||||
Example: If you pass a traffiq car object it will return body, wheels and lights.
|
||||
"""
|
||||
|
||||
for child in obj.children:
|
||||
yield from get_entire_object_hierarchy(child)
|
||||
|
||||
if obj.instance_type == 'COLLECTION':
|
||||
yield from obj.instance_collection.objects
|
||||
else:
|
||||
yield obj
|
||||
|
||||
|
||||
def decompose_traffiq_vehicle(obj: bpy.types.Object) -> DecomposedCarType:
|
||||
if obj is None:
|
||||
return None, None, None, [], []
|
||||
|
||||
root_object = get_root_object_of_asset(obj)
|
||||
body = None
|
||||
lights = None
|
||||
wheels = []
|
||||
brakes = []
|
||||
|
||||
hierarchy_objects = get_entire_object_hierarchy(obj)
|
||||
for hierarchy_obj in hierarchy_objects:
|
||||
if is_traffiq_asset_part(hierarchy_obj, TraffiqAssetPart.Body):
|
||||
# there should be only one body
|
||||
assert body is None
|
||||
body = hierarchy_obj
|
||||
elif is_traffiq_asset_part(hierarchy_obj, TraffiqAssetPart.Lights):
|
||||
# there should be only one lights
|
||||
assert lights is None
|
||||
lights = hierarchy_obj
|
||||
elif is_traffiq_asset_part(hierarchy_obj, TraffiqAssetPart.Wheel):
|
||||
wheels.append(hierarchy_obj)
|
||||
elif is_traffiq_asset_part(hierarchy_obj, TraffiqAssetPart.Brake):
|
||||
brakes.append(hierarchy_obj)
|
||||
|
||||
return root_object, body, lights, wheels, brakes
|
||||
|
||||
|
||||
def find_traffiq_asset_parts(
|
||||
obj: bpy.types.Object, part: TraffiqAssetPart
|
||||
) -> typing.Iterable[bpy.types.Object]:
|
||||
"""Find all asset parts of a specific type."""
|
||||
|
||||
for hierarchy_obj in get_entire_object_hierarchy(obj):
|
||||
if is_traffiq_asset_part(hierarchy_obj, part):
|
||||
yield hierarchy_obj
|
||||
|
||||
|
||||
def is_pps(name: str) -> bool:
|
||||
split = name.split("_")
|
||||
if len(split) < 3:
|
||||
return False
|
||||
|
||||
return split[1] == PARTICLE_SYSTEM_TOKEN
|
||||
|
||||
|
||||
def make_selection_editable(
|
||||
context: bpy.types.Context,
|
||||
delete_base_empty: bool,
|
||||
keep_selection: bool = True,
|
||||
keep_active: bool = True,
|
||||
) -> typing.List[str]:
|
||||
def apply_botaniq_particle_system_modifiers(obj: bpy.types.Object):
|
||||
for child in obj.children:
|
||||
apply_botaniq_particle_system_modifiers(child)
|
||||
|
||||
for modifier in obj.modifiers:
|
||||
if modifier.type != 'PARTICLE_SYSTEM' or is_pps(modifier.name):
|
||||
continue
|
||||
|
||||
clear_selection(context)
|
||||
obj.select_set(True)
|
||||
bpy.ops.object.duplicates_make_real(use_base_parent=True, use_hierarchy=True)
|
||||
obj.select_set(False)
|
||||
|
||||
# Remove collection with unused origin objects previously used for particle system
|
||||
if modifier.name in bpy.data.collections:
|
||||
collection = bpy.data.collections[modifier.name]
|
||||
particle_origins = [obj for obj in collection.objects if obj.users == 1]
|
||||
bpy.data.batch_remove(particle_origins)
|
||||
if len(collection.objects) == 0:
|
||||
bpy.data.collections.remove(collection)
|
||||
|
||||
obj.modifiers.remove(modifier)
|
||||
|
||||
InstancedObjectInfo = typing.Tuple[
|
||||
bpy.types.Object, bpy.types.Collection, str, typing.Tuple[float, float, float, float]
|
||||
]
|
||||
|
||||
def find_instanced_collection_objects(
|
||||
obj: bpy.types.Object, instanced_collection_objects: typing.Dict[str, InstancedObjectInfo]
|
||||
):
|
||||
for child in obj.children:
|
||||
find_instanced_collection_objects(child, instanced_collection_objects)
|
||||
|
||||
if obj.instance_type == 'COLLECTION':
|
||||
if obj.name not in instanced_collection_objects:
|
||||
instanced_collection_objects[obj.name] = (
|
||||
obj,
|
||||
obj.instance_collection,
|
||||
obj.parent.name if obj.parent else None,
|
||||
obj.color,
|
||||
)
|
||||
|
||||
def copy_polygoniq_custom_props_from_children(obj: bpy.types.Object) -> None:
|
||||
"""Tries to copy Polygoniq custom properties from children to 'obj'.
|
||||
|
||||
Tries to find child with all polygoniq custom properties
|
||||
if such a child exists, values of its properties are copied to 'obj'.
|
||||
"""
|
||||
for child in obj.children:
|
||||
copyright = child.get("copyright", None)
|
||||
polygoniq_addon = child.get("polygoniq_addon", None)
|
||||
polygoniq_blend_path = child.get("polygoniq_addon_blend_path", None)
|
||||
if all(prop is not None for prop in [copyright, polygoniq_addon, polygoniq_blend_path]):
|
||||
obj["copyright"] = copyright
|
||||
obj["polygoniq_addon"] = polygoniq_addon
|
||||
obj["polygoniq_addon_blend_path"] = polygoniq_blend_path
|
||||
mapr_id = child.get("mapr_asset_id", None)
|
||||
mapr_data_id = child.get("mapr_asset_data_id", None)
|
||||
if mapr_id is not None:
|
||||
obj["mapr_asset_id"] = mapr_id
|
||||
if mapr_data_id is not None:
|
||||
obj["mapr_asset_data_id"] = mapr_data_id
|
||||
return
|
||||
|
||||
def get_mesh_to_objects_map(
|
||||
obj: bpy.types.Object, result: typing.DefaultDict[str, typing.List[bpy.types.ID]]
|
||||
) -> None:
|
||||
for child in obj.children:
|
||||
get_mesh_to_objects_map(child, result)
|
||||
|
||||
if obj.type == 'MESH' and obj.data is not None:
|
||||
original_mesh_name = utils_bpy.remove_object_duplicate_suffix(obj.data.name)
|
||||
result[original_mesh_name].append(obj)
|
||||
|
||||
def get_material_to_slots_map(
|
||||
obj: bpy.types.Object, result: typing.DefaultDict[str, typing.List[bpy.types.ID]]
|
||||
) -> None:
|
||||
for child in obj.children:
|
||||
get_material_to_slots_map(child, result)
|
||||
|
||||
if obj.type == 'MESH':
|
||||
for material_slot in obj.material_slots:
|
||||
if material_slot.material is None:
|
||||
continue
|
||||
|
||||
original_material_name = utils_bpy.remove_object_duplicate_suffix(
|
||||
material_slot.material.name
|
||||
)
|
||||
result[original_material_name].append(material_slot)
|
||||
|
||||
def get_armatures_to_objects_map(
|
||||
obj: bpy.types.Object, result: typing.DefaultDict[str, typing.List[bpy.types.ID]]
|
||||
) -> None:
|
||||
for child in obj.children:
|
||||
get_armatures_to_objects_map(child, result)
|
||||
|
||||
if obj.type == 'ARMATURE' and obj.data is not None:
|
||||
original_armature_name = utils_bpy.remove_object_duplicate_suffix(obj.data.name)
|
||||
result[original_armature_name].append(obj)
|
||||
|
||||
GetNameToUsersMapCallable = typing.Callable[
|
||||
[bpy.types.Object, typing.DefaultDict[str, typing.List[bpy.types.ID]]], None
|
||||
]
|
||||
|
||||
def make_datablocks_unique_per_object(
|
||||
obj: bpy.types.Object,
|
||||
get_data_to_struct_map: GetNameToUsersMapCallable,
|
||||
datablock_name: str,
|
||||
):
|
||||
datablocks_to_owner_structs: typing.DefaultDict[str, typing.List[bpy.types.ID]] = (
|
||||
collections.defaultdict(list)
|
||||
)
|
||||
get_data_to_struct_map(obj, datablocks_to_owner_structs)
|
||||
|
||||
for owner_structs in datablocks_to_owner_structs.values():
|
||||
if len(owner_structs) == 0:
|
||||
continue
|
||||
|
||||
first_datablock = getattr(owner_structs[0], datablock_name)
|
||||
if first_datablock.library is None and first_datablock.users == len(owner_structs):
|
||||
continue
|
||||
|
||||
# data block is linked from library or it is used outside of object 'obj' -> create copy
|
||||
datablock_duplicate = first_datablock.copy()
|
||||
for owner_struct in owner_structs:
|
||||
setattr(owner_struct, datablock_name, datablock_duplicate)
|
||||
|
||||
selected_objects_names = [obj.name for obj in context.selected_objects]
|
||||
prev_active_object_name = context.active_object.name if context.active_object else None
|
||||
|
||||
instanced_collection_objects: typing.Dict[str, InstancedObjectInfo] = {}
|
||||
for obj in context.selected_objects:
|
||||
find_instanced_collection_objects(obj, instanced_collection_objects)
|
||||
|
||||
for obj_name in selected_objects_names:
|
||||
if obj_name in bpy.data.objects:
|
||||
apply_botaniq_particle_system_modifiers(bpy.data.objects[obj_name])
|
||||
|
||||
# origin objects from particle systems were removed from scene
|
||||
selected_objects_names = [
|
||||
obj_name for obj_name in selected_objects_names if obj_name in bpy.data.objects
|
||||
]
|
||||
|
||||
clear_selection(context)
|
||||
for instance_object, _, _, _ in instanced_collection_objects.values():
|
||||
# Operator duplicates_make_real converts each instance collection to empty (base parent) and its contents,
|
||||
# we change the name of the instance collection object (which becomes the empty) so it doesn't clash
|
||||
# with the naming of the actual objects (and doesn't increment duplicate suffix).
|
||||
# To keep track of what was converted and to not mess up names of objects
|
||||
# we use the '[0-9]+bp_' prefix for the base parent
|
||||
i = 0
|
||||
name = f"{i}bp_" + instance_object.name
|
||||
while name in bpy.data.objects:
|
||||
i += 1
|
||||
name = f"{i}bp_" + instance_object.name
|
||||
|
||||
instance_object.name = name
|
||||
instance_object.select_set(True)
|
||||
bpy.ops.object.duplicates_make_real(use_base_parent=True, use_hierarchy=True)
|
||||
instance_object.select_set(False)
|
||||
|
||||
for obj, instance_collection, parent_name, prev_color in instanced_collection_objects.values():
|
||||
assert obj is not None
|
||||
|
||||
for child in obj.children:
|
||||
child.color = prev_color
|
||||
# Create mapr_asset_id and mapr_data_asset_id custom properties on the child if they
|
||||
# don't exist already. Otherwise the properties would not get copied because we use
|
||||
# only_existing=True with copy_custom_props.
|
||||
if child.get("mapr_asset_id", None) is None:
|
||||
child["mapr_asset_id"] = ""
|
||||
|
||||
if child.get("mapr_asset_data_id", None) is None:
|
||||
child["mapr_asset_data_id"] = ""
|
||||
|
||||
# Copy custom property values from each instanced obj to all children recursively
|
||||
# only if the property exists on the target object
|
||||
hatchery.utils.copy_custom_props(obj, child, only_existing=True, recursive=True)
|
||||
|
||||
# reorder the hierarchy in following way (car example):
|
||||
# base_parent_CAR -> [CAR, base_parent_CAR_Lights, WHEEL1..N -> [CAR_Lights]] to CAR -> [CAR_Lights, WHEEL1..N]
|
||||
if parent_name is not None and parent_name in bpy.data.objects:
|
||||
parent = bpy.data.objects[parent_name]
|
||||
for child in obj.children:
|
||||
# after setting parent object here, child.parent_type is always set to 'OBJECT'
|
||||
child.parent = parent
|
||||
child_source_name = utils_bpy.remove_object_duplicate_suffix(child.name)
|
||||
if (
|
||||
child_source_name in instance_collection.objects
|
||||
and instance_collection.objects[child_source_name].parent is not None
|
||||
):
|
||||
# set parent_type from source blend, for example our _Lights need to have parent_type = 'BONE'
|
||||
child.parent_type = instance_collection.objects[child_source_name].parent_type
|
||||
child.matrix_local = instance_collection.objects[child_source_name].matrix_local
|
||||
bpy.data.objects.remove(obj)
|
||||
continue
|
||||
|
||||
if delete_base_empty:
|
||||
if len(obj.children) > 1:
|
||||
# instanced collection contained multiple top-level objects, keep base empty as container
|
||||
splitted_name = obj.name.split("_", 1)
|
||||
if len(splitted_name) == 2 and splitted_name[0].endswith("bp"):
|
||||
obj.name = splitted_name[1]
|
||||
# empty parent newly created in duplicates_make_real does not have polygoniq custom properties
|
||||
copy_polygoniq_custom_props_from_children(obj)
|
||||
|
||||
else:
|
||||
# remove the parent from children which were not reparented above
|
||||
# if they were reparented they are no longer in obj.children and we can
|
||||
# safely delete the base parent
|
||||
for child in obj.children:
|
||||
child_matrix = child.matrix_world.copy()
|
||||
child.parent = None
|
||||
# Original child_matrix took account also for transforms of the parent, apply
|
||||
# the original matrix, otherwise child's pos/rot/scale would change after parent
|
||||
# was removed
|
||||
child.matrix_world = child_matrix
|
||||
bpy.data.objects.remove(obj)
|
||||
|
||||
selected_objects = []
|
||||
for obj_name in selected_objects_names:
|
||||
if obj_name not in bpy.data.objects:
|
||||
logger.error(f"Previously selected object: {obj_name} is no longer in bpy.data")
|
||||
continue
|
||||
|
||||
obj = bpy.data.objects[obj_name]
|
||||
# Create copy of meshes shared with other objects or linked from library
|
||||
make_datablocks_unique_per_object(obj, get_mesh_to_objects_map, "data")
|
||||
# Create copy of materials shared with other objects or linked from library
|
||||
make_datablocks_unique_per_object(obj, get_material_to_slots_map, "material")
|
||||
# Create copy of armature data shared with other objects or linked from library
|
||||
make_datablocks_unique_per_object(obj, get_armatures_to_objects_map, "data")
|
||||
|
||||
# Blender operator duplicates_make_real doesn't append animation data with drivers.
|
||||
# Thus we have to create those drivers dynamically based on bone names.
|
||||
if rigs_shared_bpy.is_object_rigged(obj):
|
||||
# set object as active to be able to go into POSE mode
|
||||
context.view_layer.objects.active = obj
|
||||
bpy.ops.object.mode_set(mode='POSE')
|
||||
driver_creator = rigs_shared_bpy.RigDrivers(obj)
|
||||
driver_creator.create_all_drivers()
|
||||
bpy.ops.object.mode_set(mode='OBJECT')
|
||||
|
||||
if keep_selection:
|
||||
selected_objects.append(obj_name)
|
||||
obj.select_set(True)
|
||||
|
||||
if keep_active and prev_active_object_name is not None:
|
||||
if prev_active_object_name in bpy.data.objects:
|
||||
context.view_layer.objects.active = bpy.data.objects[prev_active_object_name]
|
||||
|
||||
return selected_objects
|
||||
|
||||
|
||||
HierarchyNameComparator = typing.Callable[
|
||||
[bpy.types.Object, typing.Optional[bpy.types.Object]], bool
|
||||
]
|
||||
|
||||
|
||||
def find_object_in_hierarchy(
|
||||
root_obj: bpy.types.Object,
|
||||
name_comparator: HierarchyNameComparator,
|
||||
) -> typing.Optional[bpy.types.Object]:
|
||||
# We don't use get_hierarchy function, because here we can return the desired
|
||||
# object before going through the whole hierarchy
|
||||
def search_hierarchy(parent_obj: bpy.types.Object) -> typing.Optional[bpy.types.Object]:
|
||||
if name_comparator(parent_obj, root_obj):
|
||||
return parent_obj
|
||||
|
||||
for obj in parent_obj.children:
|
||||
candidate = search_hierarchy(obj)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
return search_hierarchy(root_obj)
|
||||
|
||||
|
||||
def get_root_objects_with_matched_child(
|
||||
objects: typing.Iterable[bpy.types.Object], comparator: HierarchyNameComparator
|
||||
) -> typing.Iterable[typing.Tuple[bpy.types.Object, bpy.types.Object]]:
|
||||
"""Searches hierarchies of objects and returns objects that satisfy the 'comparator', and their root objects"""
|
||||
for root_obj in find_polygoniq_root_objects(objects):
|
||||
searched_obj = find_object_in_hierarchy(root_obj, comparator)
|
||||
if searched_obj is not None:
|
||||
yield (root_obj, searched_obj)
|
||||
|
||||
|
||||
def get_hierarchy(root: bpy.types.ID) -> typing.List[bpy.types.ID]:
|
||||
"""Gathers children of 'root' recursively"""
|
||||
|
||||
assert hasattr(root, "children")
|
||||
ret = [root]
|
||||
for child in root.children:
|
||||
ret.extend(get_hierarchy(child))
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def collection_get(
|
||||
context: bpy.types.Context, name: str, parent: typing.Optional[bpy.types.Collection] = None
|
||||
) -> bpy.types.Collection:
|
||||
scene_collections = get_hierarchy(context.scene.collection)
|
||||
for coll in scene_collections:
|
||||
if utils_bpy.remove_object_duplicate_suffix(coll.name) == name:
|
||||
return coll
|
||||
|
||||
coll = bpy.data.collections.new(name)
|
||||
if parent is None:
|
||||
context.scene.collection.children.link(coll)
|
||||
else:
|
||||
parent.children.link(coll)
|
||||
|
||||
if hasattr(coll, "color_tag"): # coloring is only supported if this attribute is present
|
||||
coll_color = ASSET_PACK_COLLECTION_COLOR_MAP.get(name, None)
|
||||
if coll_color is not None:
|
||||
coll.color_tag = coll_color
|
||||
elif (
|
||||
parent is not None
|
||||
): # color direct descendants by their parent color - e.g. botaniq/weed
|
||||
parent_name = utils_bpy.remove_object_duplicate_suffix(parent.name)
|
||||
parent_color = ASSET_PACK_COLLECTION_COLOR_MAP.get(parent_name, None)
|
||||
if parent_color is not None:
|
||||
coll.color_tag = parent_color
|
||||
return coll
|
||||
|
||||
|
||||
def collection_add_object(collection: bpy.types.Collection, obj: bpy.types.Object) -> None:
|
||||
"""Unlinks 'obj' from all collections and links it into 'collection'"""
|
||||
|
||||
for coll in obj.users_collection:
|
||||
coll.objects.unlink(obj)
|
||||
|
||||
collection.objects.link(obj)
|
||||
|
||||
|
||||
def copy_object_hierarchy(root_obj: bpy.types.Object) -> bpy.types.Object:
|
||||
"""Copies 'root_obj' and its hierarchy while preserving parenting, returns the root copy"""
|
||||
|
||||
def copy_hierarchy(obj: bpy.types.Object, parent: bpy.types.Object) -> None:
|
||||
obj_copy = obj.copy()
|
||||
obj_copy.parent = parent
|
||||
for child in obj.children:
|
||||
copy_hierarchy(child, obj_copy)
|
||||
|
||||
root_obj_copy = root_obj.copy()
|
||||
for obj in root_obj.children:
|
||||
copy_hierarchy(obj, root_obj_copy)
|
||||
|
||||
return root_obj_copy
|
||||
|
||||
|
||||
def collection_link_hierarchy(collection: bpy.types.Collection, root_obj: bpy.types.Object) -> None:
|
||||
"""Links 'root_obj' and its hierarachy to 'collection' and unlinks it from all other collections"""
|
||||
|
||||
for obj in get_hierarchy(root_obj):
|
||||
for coll in obj.users_collection:
|
||||
coll.objects.unlink(obj)
|
||||
collection.objects.link(obj)
|
||||
|
||||
|
||||
def collection_unlink_hierarchy(
|
||||
collection: bpy.types.Collection, root_obj: bpy.types.Object
|
||||
) -> None:
|
||||
"""Unlinks 'root_obj' and it's hierarchy from 'collection'"""
|
||||
|
||||
for obj in get_hierarchy(root_obj):
|
||||
collection.objects.unlink(obj)
|
||||
|
||||
|
||||
def find_layer_collection(
|
||||
view_layer_root: bpy.types.LayerCollection, target: bpy.types.Collection
|
||||
) -> typing.Optional[bpy.types.LayerCollection]:
|
||||
"""Finds corresponding LayerCollection from 'view_layer_coll' hierarchy
|
||||
which contains 'target' collection.
|
||||
"""
|
||||
|
||||
if view_layer_root.collection == target:
|
||||
return view_layer_root
|
||||
|
||||
for layer_child in view_layer_root.children:
|
||||
found_layer_collection = find_layer_collection(layer_child, target)
|
||||
if found_layer_collection is not None:
|
||||
return found_layer_collection
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def clear_selection(context: bpy.types.Context) -> None:
|
||||
for obj in context.selected_objects:
|
||||
obj.select_set(False)
|
||||
|
||||
|
||||
def append_modifiers_from_library(
|
||||
modifier_container_name: str, library_path: str, target_objs: typing.Iterable[bpy.types.Object]
|
||||
) -> None:
|
||||
"""Add all modifiers from object with given name in given .blend library to 'target_objects'.
|
||||
|
||||
It doesn't copy complex and readonly properties, e.g. properties that are driven by FCurve.
|
||||
"""
|
||||
if modifier_container_name not in bpy.data.objects:
|
||||
with bpy.data.libraries.load(library_path) as (data_from, data_to):
|
||||
assert modifier_container_name in data_from.objects
|
||||
data_to.objects = [modifier_container_name]
|
||||
|
||||
assert modifier_container_name in bpy.data.objects
|
||||
modifier_container = bpy.data.objects[modifier_container_name]
|
||||
|
||||
for obj in target_objs:
|
||||
for src_modifier in modifier_container.modifiers:
|
||||
assert src_modifier.name not in obj.modifiers
|
||||
dest_modifier = obj.modifiers.new(src_modifier.name, src_modifier.type)
|
||||
|
||||
# collect names of writable properties
|
||||
properties = [p.identifier for p in src_modifier.bl_rna.properties if not p.is_readonly]
|
||||
|
||||
# copy those properties
|
||||
for prop in properties:
|
||||
setattr(dest_modifier, prop, getattr(src_modifier, prop))
|
||||
|
||||
|
||||
def update_custom_prop(
|
||||
context: bpy.types.Context,
|
||||
objs: typing.Iterable[bpy.types.Object],
|
||||
prop_name: str,
|
||||
value: CustomAttributeValueType,
|
||||
update_tag_refresh: typing.Set[str] = {'OBJECT'},
|
||||
) -> None:
|
||||
"""Update custom properties of given objects and force 3D view to redraw
|
||||
|
||||
When we set values of custom properties from code, affected objects don't get updated in 3D View
|
||||
automatically. We need to call obj.update_tag() and then refresh 3D view areas manually.
|
||||
|
||||
'update_tag_refresh' set of enums {'OBJECT', 'DATA', 'TIME'}, updating DATA is really slow
|
||||
as it forces Blender to recompute the whole mesh, we should use 'OBJECT' wherever it's enough.
|
||||
"""
|
||||
for obj in objs:
|
||||
if prop_name in obj:
|
||||
obj[prop_name] = value
|
||||
obj.update_tag(refresh=update_tag_refresh)
|
||||
|
||||
for area in context.screen.areas:
|
||||
if area.type == 'VIEW_3D':
|
||||
area.tag_redraw()
|
||||
@@ -0,0 +1,71 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import ast
|
||||
import re
|
||||
import typing
|
||||
import zipfile
|
||||
import pathlib
|
||||
|
||||
BL_INFO_REGEX = r"^bl_info[\s]*=[\s]*(\{[^\}]*\})"
|
||||
|
||||
|
||||
def find_bl_info_in_string(input: str) -> typing.Optional[typing.Dict[str, typing.Any]]:
|
||||
match = re.search(BL_INFO_REGEX, input, flags=re.MULTILINE)
|
||||
if not match:
|
||||
return None
|
||||
|
||||
# Use ast.literal_eval as it restricts evaluation only to literal structures
|
||||
# https://docs.python.org/3/library/ast.html#ast.literal_eval
|
||||
return ast.literal_eval(match.group(1))
|
||||
|
||||
|
||||
def get_bl_info_from_init_py(init_py_path: str) -> typing.Optional[typing.Dict[str, typing.Any]]:
|
||||
"""Retrieves the bl_info dictionary of given __init__.py file without running it. It only
|
||||
evaluates the bl_info dictionary itself. Assumes that bl_info is self-contained. This is
|
||||
the same assumption that Blender itself requires.
|
||||
"""
|
||||
|
||||
with open(init_py_path) as f:
|
||||
src = f.read()
|
||||
|
||||
return find_bl_info_in_string(src)
|
||||
|
||||
|
||||
def infer_version_from_bl_info(init_py_path: str) -> typing.Optional[typing.Tuple[int, int, int]]:
|
||||
"""Figures out the version of given __init__.py file without running the whole thing. Returns
|
||||
None in case of failure.
|
||||
"""
|
||||
|
||||
bl_info = get_bl_info_from_init_py(init_py_path)
|
||||
if bl_info is None:
|
||||
return None
|
||||
|
||||
return bl_info.get("version")
|
||||
|
||||
|
||||
def infer_version_from_bl_info_from_zip_file(
|
||||
zip_file_path: str,
|
||||
) -> typing.Optional[typing.Tuple[int, int, int]]:
|
||||
if not zipfile.is_zipfile(zip_file_path):
|
||||
return None
|
||||
|
||||
zip_file = zipfile.ZipFile(zip_file_path, 'r')
|
||||
# Find the root __init__.py file
|
||||
root_init_py_path = None
|
||||
for file_ in zip_file.namelist():
|
||||
path = pathlib.Path(file_)
|
||||
# one part for the root folder, second for the __init__.py itself
|
||||
if len(path.parts) == 2 and path.name == "__init__.py":
|
||||
root_init_py_path = file_
|
||||
break
|
||||
|
||||
assert root_init_py_path is not None
|
||||
|
||||
with zip_file.open(root_init_py_path) as zf:
|
||||
src = zf.read().decode()
|
||||
|
||||
bl_info = find_bl_info_in_string(src)
|
||||
if bl_info is None:
|
||||
return None
|
||||
|
||||
return bl_info.get("version")
|
||||
@@ -0,0 +1,148 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
# adapted code from http://www.easyrgb.com/en/math.php
|
||||
import math
|
||||
import numpy
|
||||
|
||||
|
||||
def RGB_to_XYZ(rgb: tuple[float, float, float]) -> tuple[float, float, float]:
|
||||
"""Converts RGB coordinates to XYZ coordinates
|
||||
|
||||
Expects RGB values between 0 and 1.
|
||||
Returns XYZ values between (0 to 0.9505, 0 to 1.0000, 0 to 1.0888).
|
||||
For use with Observer = 2°, Illuminant = D65.
|
||||
"""
|
||||
gamma_neutral = [0.0, 0.0, 0.0]
|
||||
for i, color in enumerate(rgb):
|
||||
if color > 0.04045:
|
||||
color = ((color + 0.055) / 1.055) ** 2.4
|
||||
else:
|
||||
color = color / 12.92
|
||||
gamma_neutral[i] = color
|
||||
# Observer = 2°, Illuminant = D65
|
||||
x = gamma_neutral[0] * 0.4124 + gamma_neutral[1] * 0.3576 + gamma_neutral[2] * 0.1805
|
||||
y = gamma_neutral[0] * 0.2126 + gamma_neutral[1] * 0.7152 + gamma_neutral[2] * 0.0722
|
||||
z = gamma_neutral[0] * 0.0193 + gamma_neutral[1] * 0.1192 + gamma_neutral[2] * 0.9505
|
||||
return (x, y, z)
|
||||
|
||||
|
||||
def XYZ_to_LAB(xyz: tuple[float, float, float]) -> tuple[float, float, float]:
|
||||
"""Converts XYZ coordinates to CIELAB coordinates
|
||||
|
||||
Expects XYZ values between (0 to 0.9505, 0 to 1.0000, 0 to 1.0888).
|
||||
Returns LAB values as (0 to 100, -128 to 128, -128 to 128).
|
||||
For use with Observer = 2°, Illuminant = D65.
|
||||
"""
|
||||
X, Y, Z = xyz
|
||||
|
||||
# Normalize the input values
|
||||
# Observer = 2°, Illuminant = D65
|
||||
var_X = X / 0.95047
|
||||
var_Y = Y
|
||||
var_Z = Z / 1.08883
|
||||
|
||||
# Apply the transformation functions
|
||||
var_X = (var_X ** (1 / 3)) if var_X > 0.008856 else (7.787 * var_X + 16 / 116)
|
||||
var_Y = (var_Y ** (1 / 3)) if var_Y > 0.008856 else (7.787 * var_Y + 16 / 116)
|
||||
var_Z = (var_Z ** (1 / 3)) if var_Z > 0.008856 else (7.787 * var_Z + 16 / 116)
|
||||
|
||||
CIE_L_star = (116 * var_Y) - 16
|
||||
CIE_a_star = 500 * (var_X - var_Y)
|
||||
CIE_b_star = 200 * (var_Y - var_Z)
|
||||
|
||||
return (CIE_L_star, CIE_a_star, CIE_b_star)
|
||||
|
||||
|
||||
def perceptual_color_distance(
|
||||
rgb_1: tuple[float, float, float],
|
||||
rgb_2: tuple[float, float, float],
|
||||
weight_luminosity: float = 1.0,
|
||||
weight_chroma: float = 1.0,
|
||||
weight_hue: float = 1.0,
|
||||
) -> float:
|
||||
"""Implements CIEDE2000 formula for perceptual color distance.
|
||||
|
||||
Expects RGB values between 0 and 1.
|
||||
Returns a value between 0 and 1, where 0 represents identical color and 1 an opposite color.
|
||||
"""
|
||||
lab_1 = XYZ_to_LAB(RGB_to_XYZ(rgb_1))
|
||||
lab_2 = XYZ_to_LAB(RGB_to_XYZ(rgb_2))
|
||||
|
||||
# Implementation borrowed from: https://github.com/gtaylor/python-colormath
|
||||
|
||||
lab_color_vector = numpy.array([lab_1[0], lab_1[1], lab_1[2]])
|
||||
L, a, b = lab_color_vector
|
||||
|
||||
lab_color_matrix = numpy.array([(lab_2[0], lab_2[1], lab_2[2])])
|
||||
|
||||
avg_Lp = (L + lab_color_matrix[:, 0]) / 2.0
|
||||
|
||||
C1 = numpy.sqrt(numpy.sum(numpy.power(lab_color_vector[1:], 2)))
|
||||
C2 = numpy.sqrt(numpy.sum(numpy.power(lab_color_matrix[:, 1:], 2), axis=1))
|
||||
|
||||
avg_C1_C2 = (C1 + C2) / 2.0
|
||||
|
||||
G = 0.5 * (
|
||||
1
|
||||
- numpy.sqrt(
|
||||
numpy.power(avg_C1_C2, 7.0) / (numpy.power(avg_C1_C2, 7.0) + numpy.power(25.0, 7.0))
|
||||
)
|
||||
)
|
||||
|
||||
a1p = (1.0 + G) * a
|
||||
a2p = (1.0 + G) * lab_color_matrix[:, 1]
|
||||
|
||||
C1p = numpy.sqrt(numpy.power(a1p, 2) + numpy.power(b, 2))
|
||||
C2p = numpy.sqrt(numpy.power(a2p, 2) + numpy.power(lab_color_matrix[:, 2], 2))
|
||||
|
||||
avg_C1p_C2p = (C1p + C2p) / 2.0
|
||||
|
||||
h1p = numpy.degrees(numpy.arctan2(b, a1p))
|
||||
h1p += (h1p < 0) * 360
|
||||
|
||||
h2p = numpy.degrees(numpy.arctan2(lab_color_matrix[:, 2], a2p))
|
||||
h2p += (h2p < 0) * 360
|
||||
|
||||
avg_Hp = (((numpy.fabs(h1p - h2p) > 180) * 360) + h1p + h2p) / 2.0
|
||||
|
||||
T = (
|
||||
1
|
||||
- 0.17 * numpy.cos(numpy.radians(avg_Hp - 30))
|
||||
+ 0.24 * numpy.cos(numpy.radians(2 * avg_Hp))
|
||||
+ 0.32 * numpy.cos(numpy.radians(3 * avg_Hp + 6))
|
||||
- 0.2 * numpy.cos(numpy.radians(4 * avg_Hp - 63))
|
||||
)
|
||||
|
||||
diff_h2p_h1p = h2p - h1p
|
||||
delta_hp = diff_h2p_h1p + (numpy.fabs(diff_h2p_h1p) > 180) * 360
|
||||
delta_hp -= (h2p > h1p) * 720
|
||||
|
||||
delta_Lp = lab_color_matrix[:, 0] - L
|
||||
delta_Cp = C2p - C1p
|
||||
delta_Hp = 2 * numpy.sqrt(C2p * C1p) * numpy.sin(numpy.radians(delta_hp) / 2.0)
|
||||
|
||||
S_L = 1 + (
|
||||
(0.015 * numpy.power(avg_Lp - 50, 2)) / numpy.sqrt(20 + numpy.power(avg_Lp - 50, 2.0))
|
||||
)
|
||||
S_C = 1 + 0.045 * avg_C1p_C2p
|
||||
S_H = 1 + 0.015 * avg_C1p_C2p * T
|
||||
|
||||
delta_ro = 30 * numpy.exp(-(numpy.power(((avg_Hp - 275) / 25), 2.0)))
|
||||
R_C = numpy.sqrt(
|
||||
(numpy.power(avg_C1p_C2p, 7.0)) / (numpy.power(avg_C1p_C2p, 7.0) + numpy.power(25.0, 7.0))
|
||||
)
|
||||
R_T = -2 * R_C * numpy.sin(2 * numpy.radians(delta_ro))
|
||||
|
||||
distance = numpy.sqrt(
|
||||
numpy.power(delta_Lp / (S_L * weight_luminosity), 2)
|
||||
+ numpy.power(delta_Cp / (S_C * weight_chroma), 2)
|
||||
+ numpy.power(delta_Hp / (S_H * weight_hue), 2)
|
||||
+ R_T * (delta_Cp / (S_C * weight_chroma)) * (delta_Hp / (S_H * weight_hue))
|
||||
)[0]
|
||||
|
||||
# distance can be theoretically uncapped, but values above 100 are considered extremely different
|
||||
cap = 100
|
||||
if distance > cap:
|
||||
distance == cap
|
||||
|
||||
return distance / cap
|
||||
+178
@@ -0,0 +1,178 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
# Functionalities to work with geometry nodes modifiers
|
||||
import bpy
|
||||
import typing
|
||||
from . import node_utils_bpy
|
||||
|
||||
# Mapping of input.identifier to (input.name, input.value)
|
||||
NodeGroupInputs = typing.Dict[
|
||||
str, typing.Tuple[node_utils_bpy.NodeSocketInterfaceCompat, typing.Any]
|
||||
]
|
||||
|
||||
|
||||
class NodesModifierInput:
|
||||
"""Mapping of one node group and its inputs"""
|
||||
|
||||
def __init__(self, modifier: bpy.types.NodesModifier) -> None:
|
||||
assert modifier.node_group is not None
|
||||
self.inputs: NodeGroupInputs = {}
|
||||
self.node_group = modifier.node_group
|
||||
self.original_inputs = node_utils_bpy.get_node_tree_inputs_map(modifier.node_group)
|
||||
for input_ in self.original_inputs.values():
|
||||
if input_.identifier in modifier:
|
||||
self.inputs[input_.identifier] = (input_, modifier[input_.identifier])
|
||||
|
||||
|
||||
def get_modifiers_inputs_map(
|
||||
modifiers: typing.Iterable[bpy.types.Modifier],
|
||||
) -> typing.Dict[str, NodesModifierInput]:
|
||||
"""Returns mapping of geometry nodes modifiers to their respective inputs"""
|
||||
ret: typing.Dict[str, NodesModifierInput] = {}
|
||||
for mod in modifiers:
|
||||
if mod.type != 'NODES':
|
||||
continue
|
||||
|
||||
mod = typing.cast(bpy.types.NodesModifier, mod)
|
||||
if mod.node_group is None:
|
||||
continue
|
||||
|
||||
ret[mod.name] = NodesModifierInput(mod)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
class NodesModifierInputsNameView:
|
||||
"""View of Geometry Nodes modifier that allows changing inputs by input name"""
|
||||
|
||||
def __init__(self, mod: bpy.types.Modifier):
|
||||
assert mod.type == 'NODES'
|
||||
self.mod = mod
|
||||
self.name_to_identifier_map = {}
|
||||
self.node_tree_inputs = node_utils_bpy.get_node_tree_inputs_map(mod.node_group)
|
||||
for input_ in self.node_tree_inputs.values():
|
||||
# Is the input exposed in the modifier -> modifiers["RG_"]
|
||||
if input_.identifier in mod:
|
||||
self.name_to_identifier_map[input_.name] = input_.identifier
|
||||
|
||||
def set_input_value(self, input_name: str, value: typing.Any) -> None:
|
||||
identifier = self.name_to_identifier_map.get(input_name)
|
||||
input_ = self.node_tree_inputs.get(identifier, None)
|
||||
# Input cannot be None, this would fail on the identifier already, we expect
|
||||
# setting of the inputs to throw errors if the input doesn't exist to not fail
|
||||
# silently.
|
||||
assert input_ is not None
|
||||
|
||||
socket_type = node_utils_bpy.get_socket_type(input_)
|
||||
# bool needs special handling, as through versions it became statically typed
|
||||
# boolean from an integer value of 0 or 1
|
||||
if socket_type == "NodeSocketBool":
|
||||
self.mod[identifier] = bool(value)
|
||||
else:
|
||||
self.mod[identifier] = value
|
||||
|
||||
def set_obj_input_value(self, input_name: str, obj_name: str) -> None:
|
||||
identifier = self.name_to_identifier_map.get(input_name)
|
||||
# Object reference has to be set directly from bpy.data.objects
|
||||
self.mod[identifier] = bpy.data.objects[obj_name]
|
||||
|
||||
def set_material_input_value(self, input_name: str, mat_name: str) -> None:
|
||||
identifier = self.name_to_identifier_map.get(input_name)
|
||||
# Materials reference has to be set directly from bpy.data.materials
|
||||
self.mod[identifier] = bpy.data.materials[mat_name]
|
||||
|
||||
def set_collection_input_value(self, input_name: str, collection_name: str) -> None:
|
||||
identifier = self.name_to_identifier_map.get(input_name)
|
||||
# Collections reference has to be set directly from bpy.data.collections
|
||||
self.mod[identifier] = bpy.data.collections[collection_name]
|
||||
|
||||
def set_array_input_value(self, input_name: str, value: typing.List[typing.Any]) -> None:
|
||||
identifier = self.name_to_identifier_map.get(input_name)
|
||||
for i, v in enumerate(value):
|
||||
self.mod[identifier][i] = v
|
||||
|
||||
def get_input_value(self, input_name: str) -> typing.Any:
|
||||
identifier = self.name_to_identifier_map.get(input_name)
|
||||
return self.mod[identifier]
|
||||
|
||||
def __contains__(self, input_name: str) -> bool:
|
||||
return input_name in self.name_to_identifier_map
|
||||
|
||||
|
||||
class GeoNodesModifierInputsPanelMixin:
|
||||
"""Mixin for displaying Geometry Nodes modifier inputs.
|
||||
|
||||
Adds functionally to draw inputs of Geometry Nodes modifiers of active objects
|
||||
using a provided template.
|
||||
"""
|
||||
|
||||
DRAW_ALL = -1
|
||||
|
||||
def draw_object_modifiers_node_group_inputs_template(
|
||||
self,
|
||||
obj: bpy.types.Object,
|
||||
layout: bpy.types.UILayout,
|
||||
inputs: node_utils_bpy.NodeSocketsDrawTemplate,
|
||||
draw_modifier_header: bool = False,
|
||||
max_occurrences: int = 1,
|
||||
) -> None:
|
||||
mods = get_geometry_nodes_modifiers_by_node_group(obj, inputs.name)
|
||||
if len(mods) == 0:
|
||||
return
|
||||
root_layout = layout
|
||||
for i, mod in enumerate(mods):
|
||||
if (
|
||||
max_occurrences != GeoNodesModifierInputsPanelMixin.DRAW_ALL
|
||||
and i >= max_occurrences
|
||||
):
|
||||
break
|
||||
if draw_modifier_header:
|
||||
layout = self.draw_geonodes_modifier_ui_box(root_layout, mod)
|
||||
if not mod.show_expanded:
|
||||
continue
|
||||
col = layout.column(align=True)
|
||||
inputs.draw_from_geonodes_modifier(col, mods[i])
|
||||
|
||||
def draw_active_object_modifiers_node_group_inputs_template(
|
||||
self,
|
||||
layout: bpy.types.UILayout,
|
||||
context: bpy.types.Context,
|
||||
inputs: node_utils_bpy.NodeSocketsDrawTemplate,
|
||||
draw_modifier_header: bool = False,
|
||||
max_occurrences: int = 1,
|
||||
) -> None:
|
||||
obj = context.active_object
|
||||
if obj is None:
|
||||
return
|
||||
self.draw_object_modifiers_node_group_inputs_template(
|
||||
obj, layout, inputs, draw_modifier_header, max_occurrences
|
||||
)
|
||||
|
||||
def draw_show_viewport_and_render(
|
||||
self, layout: bpy.types.UILayout, mod: bpy.types.NodesModifier
|
||||
) -> None:
|
||||
layout.prop(mod, "show_viewport", text="")
|
||||
layout.prop(mod, "show_render", text="")
|
||||
|
||||
def draw_geonodes_modifier_ui_box(
|
||||
self, layout: bpy.types.UILayout, mod: bpy.types.NodesModifier
|
||||
) -> bpy.types.UILayout:
|
||||
box = layout.box()
|
||||
row = box.row(align=True)
|
||||
row.prop(mod, "show_expanded", text="", emboss=False)
|
||||
row.prop(mod, "name", text="")
|
||||
row.prop(mod, "show_in_editmode", text="")
|
||||
self.draw_show_viewport_and_render(row, mod)
|
||||
row.operator("object.modifier_copy", text="", icon='DUPLICATE').modifier = mod.name
|
||||
row.operator("object.modifier_remove", text="", icon='X', emboss=False).modifier = mod.name
|
||||
return box
|
||||
|
||||
|
||||
def get_geometry_nodes_modifiers_by_node_group(
|
||||
obj: bpy.types.Object, node_group_name: str
|
||||
) -> typing.List[bpy.types.NodesModifier]:
|
||||
output: typing.List[bpy.types.NodesModifier] = []
|
||||
for mod in obj.modifiers:
|
||||
if mod.type == 'NODES' and mod.node_group is not None:
|
||||
if mod.node_group.name == node_group_name:
|
||||
output.append(mod)
|
||||
return output
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,76 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
import bpy
|
||||
import typing
|
||||
import addon_utils
|
||||
|
||||
|
||||
def get_addon_version_in_blender(full_name: str) -> typing.Optional[typing.Iterable[int]]:
|
||||
"""Retrieves the version of given addon by full name
|
||||
|
||||
The given name of the addon is the actual full / implementation name.
|
||||
For example "botaniq_lite" or "traffiq_starter".
|
||||
"""
|
||||
|
||||
for module in addon_utils.modules():
|
||||
name = getattr(module, "__name__")
|
||||
if name is None or name != full_name:
|
||||
continue
|
||||
enabled, _ = addon_utils.check(name)
|
||||
if not enabled:
|
||||
continue
|
||||
|
||||
bl_info = getattr(module, "bl_info", None)
|
||||
if bl_info is None:
|
||||
continue
|
||||
version = bl_info.get("version")
|
||||
return version
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def install_addon_zip(zip_file_path: str, module_name: str) -> None:
|
||||
"""From zip file in 'zip_file_path' installs module named 'module_name'"""
|
||||
if not zipfile.is_zipfile(zip_file_path):
|
||||
raise RuntimeError(f"{zip_file_path} is not a valid ZIP file!")
|
||||
|
||||
path_addons = bpy.utils.user_resource('SCRIPTS', path="addons", create=True)
|
||||
|
||||
os.makedirs(path_addons, exist_ok=True)
|
||||
file_to_extract = zipfile.ZipFile(zip_file_path, 'r')
|
||||
|
||||
def module_filesystem_remove(path_base: str, module_name: str) -> None:
|
||||
# ported from bl_operators/userpref.py
|
||||
module_name = os.path.splitext(module_name)[0]
|
||||
for f in os.listdir(path_base):
|
||||
f_base = os.path.splitext(f)[0]
|
||||
if f_base == module_name:
|
||||
f_full = os.path.join(path_base, f)
|
||||
|
||||
if os.path.isdir(f_full):
|
||||
os.rmdir(f_full)
|
||||
else:
|
||||
os.remove(f_full)
|
||||
|
||||
# remove existing addon files
|
||||
for f in file_to_extract.namelist():
|
||||
module_filesystem_remove(path_addons, f)
|
||||
|
||||
file_to_extract.extractall(path_addons)
|
||||
|
||||
def refresh_and_enable(module_name: str):
|
||||
addon_utils.modules_refresh()
|
||||
bpy.ops.preferences.addon_enable(module=module_name)
|
||||
|
||||
# we do the actual update in the blender event loop to avoid crashes in case
|
||||
# grumpy_cat is updating itself
|
||||
bpy.app.timers.register(
|
||||
lambda: refresh_and_enable(module_name), first_interval=0, persistent=True
|
||||
)
|
||||
|
||||
|
||||
def uninstall_addon_module_name(module_name: str) -> None:
|
||||
bpy.ops.preferences.addon_disable(module=module_name)
|
||||
bpy.ops.preferences.addon_remove(module=module_name)
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/python3
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import bpy
|
||||
import math
|
||||
import mathutils
|
||||
import numpy
|
||||
import unittest
|
||||
import typing
|
||||
|
||||
|
||||
def plane_from_points(points):
|
||||
assert len(points) == 3
|
||||
p1, p2, p3 = points
|
||||
|
||||
v1 = p3 - p1
|
||||
v2 = p2 - p1
|
||||
|
||||
normal = numpy.cross(v1, v2)
|
||||
normal_magnitude = numpy.linalg.norm(normal)
|
||||
normal /= normal_magnitude
|
||||
offset = numpy.dot(normal, p3)
|
||||
centroid = numpy.sum(points, 0) / len(points)
|
||||
|
||||
return (normal, offset, centroid)
|
||||
|
||||
|
||||
def fit_plane_to_points(points):
|
||||
assert len(points) >= 3
|
||||
return plane_from_points(points[:3])
|
||||
|
||||
# TODO: This is borked :-(
|
||||
centroid = numpy.sum(points, 0) / len(points)
|
||||
centered_points = points - centroid
|
||||
svd = numpy.linalg.svd(numpy.transpose(centered_points))
|
||||
plane_normal = svd[0][2]
|
||||
# now that we have the normal let's fit the centroid to the plane to find the offset
|
||||
offset = numpy.dot(plane_normal, centroid)
|
||||
return (plane_normal, offset, centroid)
|
||||
|
||||
|
||||
def is_obj_flat(obj: bpy.types.Object) -> bool:
|
||||
return any(math.isclose(d, 0.0) for d in obj.dimensions)
|
||||
|
||||
|
||||
def mean_position(vs: typing.Iterable[mathutils.Vector]) -> mathutils.Vector:
|
||||
sum_v = mathutils.Vector()
|
||||
n = 0
|
||||
for v in vs:
|
||||
n += 1
|
||||
sum_v += v
|
||||
|
||||
return sum_v / n
|
||||
|
||||
|
||||
class PlaneFittingTest(unittest.TestCase):
|
||||
def test_3pts(self):
|
||||
# unit plane - (0, 0, 1), 0
|
||||
normal, offset, _ = fit_plane_to_points([(1, -1, 0), (-1, 0, 0), (0, 1, 0)])
|
||||
self.assertAlmostEqual(normal[0], 0)
|
||||
self.assertAlmostEqual(normal[1], 0)
|
||||
self.assertAlmostEqual(normal[2], 1)
|
||||
self.assertAlmostEqual(offset, 0)
|
||||
|
||||
normal, offset, _ = fit_plane_to_points([(2, -2, 0), (-1, 0, 0), (0, 1, 0)])
|
||||
self.assertAlmostEqual(normal[0], 0)
|
||||
self.assertAlmostEqual(normal[1], 0)
|
||||
self.assertAlmostEqual(normal[2], 1)
|
||||
self.assertAlmostEqual(offset, 0)
|
||||
|
||||
# offset unit plane - (0, 0, 1), 1
|
||||
normal, offset, _ = fit_plane_to_points([(2, -2, 1), (-1, 0, 1), (0, 1, 1)])
|
||||
self.assertAlmostEqual(normal[0], 0)
|
||||
self.assertAlmostEqual(normal[1], 0)
|
||||
self.assertAlmostEqual(normal[2], 1)
|
||||
self.assertAlmostEqual(offset, 1)
|
||||
|
||||
def test_4pts(self):
|
||||
# unit plane - (0, 0, 1), 0
|
||||
normal, offset, _ = fit_plane_to_points([(1, -1, 0), (-1, 0, 0), (0, 1, 0), (1, 1, 0)])
|
||||
self.assertAlmostEqual(normal[0], 0)
|
||||
self.assertAlmostEqual(normal[1], 0)
|
||||
self.assertAlmostEqual(normal[2], 1)
|
||||
self.assertAlmostEqual(offset, 0)
|
||||
|
||||
# can't fit precisely! unit plane - (0, 0, 1), 0
|
||||
large = 100000000000
|
||||
normal, offset, _ = fit_plane_to_points(
|
||||
[
|
||||
(-large, -large, 0.1),
|
||||
(-large, large, -0.1),
|
||||
(large, -large, 0.1),
|
||||
(large, large, -0.1),
|
||||
]
|
||||
)
|
||||
self.assertAlmostEqual(normal[0], 0)
|
||||
self.assertAlmostEqual(normal[1], 0)
|
||||
self.assertAlmostEqual(normal[2], 1)
|
||||
self.assertAlmostEqual(offset, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,155 @@
|
||||
#!/usr/bin/python3
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import bpy
|
||||
import datetime
|
||||
import tempfile
|
||||
import typing
|
||||
import time
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
from . import telemetry_module_bpy
|
||||
|
||||
|
||||
def logged_operator(cls: typing.Type[bpy.types.Operator]):
|
||||
assert issubclass(
|
||||
cls, bpy.types.Operator
|
||||
), "logged_operator only accepts classes inheriting bpy.types.Operator"
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{cls.__module__}")
|
||||
|
||||
if hasattr(cls, "draw"):
|
||||
cls._original_draw = cls.draw
|
||||
|
||||
def new_draw(self, context: bpy.types.Context):
|
||||
try:
|
||||
return cls._original_draw(self, context)
|
||||
except:
|
||||
logger.exception(f"Uncaught exception raised in {cls}.draw")
|
||||
|
||||
cls.draw = new_draw
|
||||
|
||||
if hasattr(cls, "modal"):
|
||||
cls._original_modal = cls.modal
|
||||
|
||||
def new_modal(self, context: bpy.types.Context, event: bpy.types.Event):
|
||||
try:
|
||||
return cls._original_modal(self, context, event)
|
||||
except:
|
||||
logger.exception(f"Uncaught exception raised in {cls}.modal")
|
||||
# If exception is thrown out of the modal we want to exit it. If there are possible
|
||||
# exceptions that can occur, they should be handled in the modal itself.
|
||||
return {'FINISHED'}
|
||||
|
||||
cls.modal = new_modal
|
||||
|
||||
if hasattr(cls, "execute"):
|
||||
cls._original_execute = cls.execute
|
||||
|
||||
def new_execute(self, context: bpy.types.Context):
|
||||
logger.info(
|
||||
f"{cls.__name__} operator execute started with arguments {self.as_keywords()}"
|
||||
)
|
||||
start_time = time.time()
|
||||
try:
|
||||
ret = cls._original_execute(self, context)
|
||||
logger.info(
|
||||
f"{cls.__name__} operator execute finished in {time.time() - start_time:.3f} "
|
||||
f"seconds with result {ret}"
|
||||
)
|
||||
return ret
|
||||
except:
|
||||
logger.exception(f"Uncaught exception raised in {cls}.execute")
|
||||
# We return finished even in case an error happened, that way the user will be able
|
||||
# to undo any changes the operator has made up until the error happened
|
||||
return {'FINISHED'}
|
||||
|
||||
cls.execute = new_execute
|
||||
|
||||
if hasattr(cls, "invoke"):
|
||||
cls._original_invoke = cls.invoke
|
||||
|
||||
def new_invoke(self, context: bpy.types.Context, event: bpy.types.Event):
|
||||
logger.debug(f"{cls.__name__} operator invoke started")
|
||||
try:
|
||||
ret = cls._original_invoke(self, context, event)
|
||||
logger.debug(f"{cls.__name__} operator invoke finished")
|
||||
return ret
|
||||
except:
|
||||
logger.exception(f"Uncaught exception raised in {cls}.invoke")
|
||||
# We return finished even in case an error happened, that way the user will be able
|
||||
# to undo any changes the operator has made up until the error happened
|
||||
return {'FINISHED'}
|
||||
|
||||
cls.invoke = new_invoke
|
||||
|
||||
return cls
|
||||
|
||||
|
||||
def logged_panel(cls: typing.Type[bpy.types.Panel]):
|
||||
assert issubclass(
|
||||
cls, bpy.types.Panel
|
||||
), "logged_panel only accepts classes inheriting bpy.types.Panel"
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{cls.__module__}")
|
||||
|
||||
if hasattr(cls, "draw_header"):
|
||||
cls._original_draw_header = cls.draw_header
|
||||
|
||||
def new_draw_header(self, context: bpy.types.Context):
|
||||
try:
|
||||
return cls._original_draw_header(self, context)
|
||||
except:
|
||||
logger.exception(f"Uncaught exception raised in {cls}.draw_header")
|
||||
|
||||
cls.draw_header = new_draw_header
|
||||
|
||||
if hasattr(cls, "draw"):
|
||||
cls._original_draw = cls.draw
|
||||
|
||||
def new_draw(self, context: bpy.types.Context):
|
||||
try:
|
||||
return cls._original_draw(self, context)
|
||||
except:
|
||||
logger.exception(f"Uncaught exception raised in {cls}.draw")
|
||||
|
||||
cls.draw = new_draw
|
||||
|
||||
return cls
|
||||
|
||||
|
||||
def logged_preferences(cls: typing.Type[bpy.types.AddonPreferences]):
|
||||
assert issubclass(
|
||||
cls, bpy.types.AddonPreferences
|
||||
), "logged_preferences only accepts classes inheriting bpy.types.AddonPreferences"
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{cls.__module__}")
|
||||
|
||||
if hasattr(cls, "draw"):
|
||||
cls._original_draw = cls.draw
|
||||
|
||||
def new_draw(self, context: bpy.types.Context):
|
||||
try:
|
||||
return cls._original_draw(self, context)
|
||||
except:
|
||||
logger.exception(f"Uncaught exception raised in {cls}.draw")
|
||||
|
||||
cls.draw = new_draw
|
||||
|
||||
return cls
|
||||
|
||||
|
||||
def pack_logs(telemetry: telemetry_module_bpy.TelemetryWrapper) -> str:
|
||||
"""Pack all logs into zip, create new timestamped directory in tempdir and save the zip there."""
|
||||
temp_folder = tempfile.gettempdir()
|
||||
log_path = os.path.join(temp_folder, "polygoniq_logs")
|
||||
os.makedirs(log_path, exist_ok=True)
|
||||
with open(os.path.join(log_path, "latest_telemetry.txt"), "w") as f:
|
||||
f.write(telemetry.dump())
|
||||
now = datetime.datetime.now()
|
||||
output_folder_name = f"polygoniq_logs--{now.year:04d}-{now.month:02d}-{now.day:02d}T{now.hour:02d}-{now.minute:02d}-{now.second:02d}"
|
||||
output_folder_path = os.path.join(temp_folder, output_folder_name)
|
||||
os.mkdir(output_folder_path)
|
||||
shutil.make_archive(os.path.join(output_folder_path, "polygoniq_logs"), "zip", log_path)
|
||||
return output_folder_path
|
||||
@@ -0,0 +1,132 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import bpy
|
||||
import numpy
|
||||
import typing
|
||||
from . import node_utils_bpy
|
||||
|
||||
|
||||
def safe_get_active_material(
|
||||
obj: typing.Optional[bpy.types.Object],
|
||||
) -> typing.Optional[bpy.types.Material]:
|
||||
"""Returns active material of object. Returns None if object is None"""
|
||||
if obj is None:
|
||||
return None
|
||||
|
||||
return obj.active_material
|
||||
|
||||
|
||||
def is_material_slot_used_on_geometry(
|
||||
obj: bpy.types.Object,
|
||||
material_index: int,
|
||||
used_indices: typing.Optional[typing.FrozenSet[int]] = None,
|
||||
) -> bool:
|
||||
"""Returns whether a material slot on given index contains a material that is used
|
||||
by a given Object's geometry.
|
||||
|
||||
Pass used_indices if this function is used in a loop for performance reasons.
|
||||
"""
|
||||
try:
|
||||
slot = obj.material_slots[material_index]
|
||||
except IndexError:
|
||||
raise Exception(f"Invalid material index {material_index} on {obj}")
|
||||
|
||||
if slot.material is None:
|
||||
return False
|
||||
|
||||
if used_indices is None:
|
||||
used_indices = get_material_slots_used_by_mesh(obj)
|
||||
used_indices |= get_material_slots_used_by_spline(obj)
|
||||
used_indices |= get_material_slots_used_by_text(obj)
|
||||
|
||||
return material_index in used_indices
|
||||
|
||||
|
||||
def is_material_used_on_geonodes(
|
||||
obj: bpy.types.Object,
|
||||
material_index: int,
|
||||
geonode_materials: typing.Optional[typing.FrozenSet[bpy.types.Material]] = None,
|
||||
) -> bool:
|
||||
"""Returns whether a material slot on given index contains a material that is used
|
||||
by a given Object's geometry nodes modifiers.
|
||||
|
||||
Pass geonode_materials if this function is used in a loop for performance reasons.
|
||||
"""
|
||||
try:
|
||||
slot = obj.material_slots[material_index]
|
||||
except IndexError:
|
||||
raise Exception(f"Invalid material index {material_index} on {obj}")
|
||||
|
||||
if slot.material is None:
|
||||
return False
|
||||
|
||||
if geonode_materials is None:
|
||||
geonode_materials = get_materials_used_by_geonodes(obj)
|
||||
|
||||
obj_mat_name = slot.material.name
|
||||
geonode_mats_names = [material.name for material in geonode_materials]
|
||||
return obj_mat_name in geonode_mats_names
|
||||
|
||||
|
||||
def get_material_slots_used_by_mesh(obj: bpy.types.Object) -> typing.FrozenSet[int]:
|
||||
"""Return a FrozenSet[material_index] used by a given Object's mesh"""
|
||||
if not hasattr(obj.data, "polygons"):
|
||||
return frozenset()
|
||||
|
||||
material_indices = numpy.zeros(len(obj.data.polygons), dtype=numpy.int32)
|
||||
obj.data.polygons.foreach_get('material_index', material_indices)
|
||||
unique_indices = numpy.unique(material_indices)
|
||||
return frozenset(unique_indices)
|
||||
|
||||
|
||||
def get_material_slots_used_by_spline(obj: bpy.types.Object) -> typing.FrozenSet[int]:
|
||||
"""Return a FrozenSet[material_index] used by a given Object's splines"""
|
||||
if not hasattr(obj.data, "splines"):
|
||||
return frozenset()
|
||||
|
||||
seen_indices = set()
|
||||
for spline in obj.data.splines:
|
||||
seen_indices.add(spline.material_index)
|
||||
|
||||
return frozenset(seen_indices)
|
||||
|
||||
|
||||
def get_material_slots_used_by_text(obj: bpy.types.Object) -> typing.FrozenSet[int]:
|
||||
"""Return a FrozenSet[material_index] used by a given Object's texts"""
|
||||
if not hasattr(obj.data, "body_format"):
|
||||
return frozenset()
|
||||
|
||||
seen_indices = set()
|
||||
for character in obj.data.body_format:
|
||||
seen_indices.add(character.material_index)
|
||||
|
||||
return frozenset(seen_indices)
|
||||
|
||||
|
||||
def get_materials_used_by_geonodes(obj: bpy.types.Object) -> typing.FrozenSet[bpy.types.Material]:
|
||||
"""Returns a FrozenSet[Material] used by a given Object's geometry nodes modifiers."""
|
||||
|
||||
used_materials = set()
|
||||
for mod in obj.modifiers:
|
||||
if mod.type != 'NODES':
|
||||
continue
|
||||
|
||||
if mod.node_group is None:
|
||||
continue
|
||||
|
||||
# Scan modifier inputs
|
||||
for input_ in node_utils_bpy.get_node_tree_inputs_map(mod.node_group).values():
|
||||
if node_utils_bpy.get_socket_type(input_) == 'NodeSocketMaterial':
|
||||
mat = mod[input_.identifier]
|
||||
if mat is not None:
|
||||
used_materials.add(mat)
|
||||
|
||||
for node in node_utils_bpy.find_nodes_in_tree(mod.node_group):
|
||||
for node_input in filter(lambda i: i.type == 'MATERIAL', node.inputs):
|
||||
if node_input.default_value is not None:
|
||||
used_materials.add(node_input.default_value)
|
||||
if hasattr(node, 'material'):
|
||||
if node.material is not None:
|
||||
used_materials.add(node.material)
|
||||
|
||||
return frozenset(used_materials)
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import typing
|
||||
import importlib
|
||||
import importlib.util
|
||||
import dataclasses
|
||||
import sys
|
||||
import os
|
||||
import bpy
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
if "utils_bpy" not in locals():
|
||||
from . import utils_bpy
|
||||
else:
|
||||
import importlib
|
||||
|
||||
utils_bpy = importlib.reload(utils_bpy)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class RequiredModule:
|
||||
"""Container class for defining required module names
|
||||
|
||||
Example: RequiredModule("PIL.Image", "Pillow")
|
||||
"""
|
||||
|
||||
import_name: str # Name used to import the module in source code
|
||||
install_name: str # Name used to install the module with pip
|
||||
|
||||
|
||||
class ModuleProvider:
|
||||
"""Class that encapsulates installation of additional Python modules.
|
||||
|
||||
It is supposed to be used as singleton with only one instance and one 'install_path'. Because
|
||||
all addons using ModuleProvider should install their dependencies to the same place.
|
||||
Otherwise they could install potentially incompatible modules.
|
||||
|
||||
TODO: Adjust this after transition to engon
|
||||
Currently 'install_path' is stored in preferences in each addon using ModuleProvider and nothing
|
||||
enforces they store the same path. This will be inherently resolved after transition to one
|
||||
common addon - engon which would define only one 'install_path' property in preferences.
|
||||
It shouldn't be problem till that as we won't release multiple addons that needs additional
|
||||
modules before full transition to engon.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._install_path: typing.Optional[str] = None
|
||||
# Cache which allows fast query if module given by name is installed.
|
||||
# If module is not in the cache, we try to import it (which is slow) and then store boolean
|
||||
# indicating whether the module can be imported or not.
|
||||
self._installed_modules_cache: typing.Dict[str, bool] = {}
|
||||
|
||||
def is_initialized(self) -> bool:
|
||||
return self._install_path is not None
|
||||
|
||||
@property
|
||||
def install_path(self) -> str:
|
||||
if not self.is_initialized():
|
||||
raise RuntimeError("Accessing uninitialized install path in ModuleProvider!")
|
||||
assert self._install_path is not None
|
||||
return self._install_path
|
||||
|
||||
@install_path.setter
|
||||
def install_path(self, value: str) -> None:
|
||||
if not os.path.isdir(value):
|
||||
raise ValueError("Provided install_path is not a valid, existing directory!")
|
||||
self._install_path = value
|
||||
# installed path changed, clear the cache of available modules
|
||||
self._installed_modules_cache.clear()
|
||||
|
||||
def is_module_installed(self, module_name: str) -> bool:
|
||||
"""Returns True if module is installed either in sys.path or in self.install_path"""
|
||||
if module_name in self._installed_modules_cache:
|
||||
return self._installed_modules_cache[module_name]
|
||||
module_found = self._get_module_spec(module_name) is not None
|
||||
logger.debug(f"Module '{module_name}' was {'found' if module_found else 'not found'}")
|
||||
self._installed_modules_cache[module_name] = module_found
|
||||
return module_found
|
||||
|
||||
def install_modules(self, module_install_names: typing.Iterable[str]) -> None:
|
||||
# Toggle console to show progress to users.
|
||||
# Console is available only on Windows :( and we can't check if it's already opened,
|
||||
# so we expect users don't have it usually opened.
|
||||
if sys.platform == "win32":
|
||||
bpy.ops.wm.console_toggle()
|
||||
python_exe = sys.executable
|
||||
logger.info(f"Preparing to install modules '{module_install_names}'")
|
||||
|
||||
try:
|
||||
args = [python_exe, "-m", "ensurepip", "--default-pip"]
|
||||
logger.info(f"Running ensurepip")
|
||||
|
||||
if utils_bpy.run_logging_subprocess(args) != 0:
|
||||
logger.error("Couldn't ensured pip in Blender's python!")
|
||||
|
||||
for module_install_name in module_install_names:
|
||||
args = [
|
||||
python_exe,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--upgrade",
|
||||
module_install_name,
|
||||
"--target",
|
||||
self.install_path,
|
||||
]
|
||||
logger.info(f"Installing '{module_install_name}'")
|
||||
|
||||
if utils_bpy.run_logging_subprocess(args) == 0:
|
||||
logger.info(f"Modules '{module_install_name}' successfully installed")
|
||||
self._installed_modules_cache[module_install_name] = True
|
||||
else:
|
||||
logger.error(f"Error occurred while installing '{module_install_name}' module!")
|
||||
|
||||
finally:
|
||||
if sys.platform == "win32":
|
||||
bpy.ops.wm.console_toggle()
|
||||
|
||||
def enable_module(self, module_name: str) -> None:
|
||||
"""Stores module into sys.modules, so we can import it later from any other place"""
|
||||
if module_name in sys.modules:
|
||||
return
|
||||
|
||||
module_spec = self._get_module_spec(module_name)
|
||||
if module_spec is None:
|
||||
raise RuntimeError(f"Module {module_name} is not installed, can't enable it!")
|
||||
else:
|
||||
# Load module from module_spec
|
||||
import importlib
|
||||
|
||||
try:
|
||||
was_in_path = self.install_path in sys.path
|
||||
if not was_in_path:
|
||||
sys.path.insert(0, self.install_path)
|
||||
importlib.import_module(module_name)
|
||||
logger.debug(
|
||||
f"Module '{module_name}' successfully enabled, it can be imported now!"
|
||||
)
|
||||
finally:
|
||||
if not was_in_path and self.install_path in sys.path:
|
||||
sys.path.remove(self.install_path)
|
||||
|
||||
def _get_module_spec(self, module_name: str) -> typing.Optional[importlib.machinery.ModuleSpec]:
|
||||
was_in_path = self.install_path in sys.path
|
||||
try:
|
||||
if not was_in_path:
|
||||
sys.path.insert(0, self.install_path)
|
||||
|
||||
return importlib.util.find_spec(module_name)
|
||||
except ModuleNotFoundError:
|
||||
# Module was found but it's not valid (doesn't contain __path__), we need to re-install
|
||||
return None
|
||||
finally:
|
||||
if not was_in_path and self.install_path in sys.path:
|
||||
sys.path.remove(self.install_path)
|
||||
@@ -0,0 +1,590 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import bpy
|
||||
import typing
|
||||
import itertools
|
||||
import collections
|
||||
import dataclasses
|
||||
|
||||
if "utils_bpy" not in locals():
|
||||
from . import utils_bpy
|
||||
else:
|
||||
import importlib
|
||||
|
||||
utils_bpy = importlib.reload(utils_bpy)
|
||||
|
||||
|
||||
# Type that's compatible with both old and new node tree interfaces
|
||||
if bpy.app.version < (4, 0, 0):
|
||||
NodeSocketInterfaceCompat = bpy.types.NodeSocketInterfaceStandard
|
||||
else:
|
||||
NodeSocketInterfaceCompat = bpy.types.NodeTreeInterfaceSocket
|
||||
|
||||
|
||||
def get_node_tree_inputs_map(
|
||||
node_tree: bpy.types.NodeTree,
|
||||
) -> typing.Dict[str, NodeSocketInterfaceCompat]:
|
||||
"""Returns map of {identifier: input} of given 'node_tree' reassuring compatibility pre and post Blender 4.0"""
|
||||
assert isinstance(node_tree, bpy.types.NodeTree)
|
||||
if bpy.app.version < (4, 0, 0):
|
||||
return {input_.identifier: input_ for input_ in node_tree.inputs}
|
||||
else:
|
||||
return {
|
||||
item.identifier: item
|
||||
for item in node_tree.interface.items_tree
|
||||
if item.in_out == 'INPUT' and item.item_type == 'SOCKET'
|
||||
}
|
||||
|
||||
|
||||
def get_socket_type(socket: NodeSocketInterfaceCompat) -> str:
|
||||
"""Returns the Blender 4.0 version of socket type from a NodeTree.
|
||||
|
||||
Note 1: This accepts either `bpy.types.NodeSocketInterfaceStandard` or
|
||||
`bpy.types.NodeTreeInterfaceSocket` based on Blender version, but basically it is what
|
||||
the `.inputs` or `.interface.items_tree` gives you.
|
||||
Note 2: NodeTree is different from a NodeGroup!
|
||||
"""
|
||||
|
||||
# Inspired by a post on the 'bpy' discord by a user named 'Reigen'
|
||||
if bpy.app.version < (4, 0, 0):
|
||||
assert isinstance(socket, bpy.types.NodeSocketInterfaceStandard)
|
||||
socket_type = socket.type
|
||||
else:
|
||||
assert isinstance(
|
||||
socket, bpy.types.NodeTreeInterfaceSocket
|
||||
), "Given socket is not a Node Tree interface! Isn't it a node group?"
|
||||
socket_type = socket.bl_socket_idname
|
||||
|
||||
# We remap the values to their newer versions, in 4.0 the values changed
|
||||
# from 'key' to the 'value' in MAP
|
||||
key = socket_type.upper()
|
||||
MAP = {
|
||||
'STRING': 'NodeSocketString',
|
||||
'BOOLEAN': 'NodeSocketBool',
|
||||
'MATERIAL': 'NodeSocketMaterial',
|
||||
'VECTOR': 'NodeSocketVector',
|
||||
'INT': 'NodeSocketInt',
|
||||
'GEOMETRY': 'NodeSocketGeometry',
|
||||
'COLLECTION': 'NodeSocketCollection',
|
||||
'TEXTURE': 'NodeSocketTexture',
|
||||
'VALUE': 'NodeSocketFloat',
|
||||
'RGBA': 'NodeSocketColor',
|
||||
'OBJECT': 'NodeSocketObject',
|
||||
'IMAGE': 'NodeSocketImage',
|
||||
'ROTATION': 'NodeSocketRotation',
|
||||
}
|
||||
|
||||
new_value = MAP.get(key, None)
|
||||
if bpy.app.version >= (4, 0, 0):
|
||||
assert new_value is None
|
||||
return socket_type
|
||||
else:
|
||||
return new_value
|
||||
|
||||
|
||||
def find_nodes_in_tree(
|
||||
node_tree: typing.Optional[bpy.types.NodeTree],
|
||||
filter_: typing.Optional[typing.Callable[[bpy.types.Node], bool]] = None,
|
||||
local_only: bool = False,
|
||||
) -> typing.Set[bpy.types.Node]:
|
||||
"""Returns a set of nodes from a given node tree that comply with the filter"""
|
||||
ret = set()
|
||||
if node_tree is None:
|
||||
return ret
|
||||
for node in node_tree.nodes:
|
||||
if getattr(node, "node_tree", None) is not None:
|
||||
if node.node_tree.library is None or not local_only:
|
||||
ret.update(find_nodes_in_tree(node.node_tree, filter_, local_only))
|
||||
|
||||
if filter_ is not None and not filter_(node):
|
||||
continue
|
||||
|
||||
ret.add(node)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def get_top_level_material_nodes_with_name(
|
||||
obj: bpy.types.Object,
|
||||
node_names: typing.Set[str],
|
||||
) -> typing.Iterable[bpy.types.Node]:
|
||||
"""Searches for top level nodes or node groups = not nodes nested in other node groups.
|
||||
|
||||
Raise exception if 'obj' is instanced collection. If linked object links materials from another
|
||||
blend then Blender API doesn't allow us easily access these materials. We would be able only
|
||||
to access materials that are local inside blend of linked object. This could be confusing
|
||||
behavior of this function, so this function doesn't search for any nodes in linked objects.
|
||||
"""
|
||||
assert obj.instance_collection != 'COLLECTION'
|
||||
|
||||
for material_slot in obj.material_slots:
|
||||
if material_slot.material is None:
|
||||
continue
|
||||
if material_slot.material.node_tree is None:
|
||||
continue # material is not using nodes or the node_tree is invalid
|
||||
for node in material_slot.material.node_tree.nodes:
|
||||
if node.type == 'GROUP':
|
||||
if utils_bpy.remove_object_duplicate_suffix(node.node_tree.name) in node_names:
|
||||
yield node
|
||||
else:
|
||||
if utils_bpy.remove_object_duplicate_suffix(node.name) in node_names:
|
||||
yield node
|
||||
|
||||
|
||||
def find_nodes_by_bl_idname(
|
||||
nodes: typing.Iterable[bpy.types.Node], bl_idname: str, recursive: bool = False
|
||||
) -> typing.Iterable[bpy.types.Node]:
|
||||
for node in nodes:
|
||||
if node.bl_idname == bl_idname:
|
||||
yield node
|
||||
if recursive and node.node_tree is not None:
|
||||
yield from find_nodes_by_bl_idname(node.node_tree.nodes, bl_idname)
|
||||
|
||||
|
||||
def find_nodes_by_name(node_tree: bpy.types.NodeTree, name: str) -> typing.Set[bpy.types.Node]:
|
||||
"""Returns set of nodes from 'node_tree' which name without duplicate suffix is 'name'"""
|
||||
nodes = find_nodes_in_tree(
|
||||
node_tree, lambda x: utils_bpy.remove_object_duplicate_suffix(x.name) == name
|
||||
)
|
||||
return nodes
|
||||
|
||||
|
||||
def find_nodegroups_by_name(
|
||||
node_tree: typing.Optional[bpy.types.NodeTree], name: str, use_node_tree_name: bool = True
|
||||
) -> typing.Set[bpy.types.NodeGroup]:
|
||||
"""Returns set of node groups from 'node_tree' which name without duplicate suffix is 'name'
|
||||
|
||||
Nodegroups have node.label, node.name and node.node_tree.name, if node.label is empty,
|
||||
Blender UI, displays node_tree.name in nodegroup header. That's why node.name is often not
|
||||
renamed to anything reasonable. So most of the times we want to search nodegroups by
|
||||
node_tree.name. If use_node_tree_name is True and the nodegroup has no node_tree, it is skipped.
|
||||
"""
|
||||
|
||||
def nodegroup_filter(node: bpy.types.Node) -> bool:
|
||||
if node.type != 'GROUP':
|
||||
return False
|
||||
if use_node_tree_name and node.node_tree is None:
|
||||
return False
|
||||
|
||||
name_for_comparing = node.node_tree.name if use_node_tree_name else node.name
|
||||
return utils_bpy.remove_object_duplicate_suffix(name_for_comparing) == name
|
||||
|
||||
nodes = find_nodes_in_tree(node_tree, nodegroup_filter)
|
||||
return nodes
|
||||
|
||||
|
||||
def find_incoming_nodes(node: bpy.types.Node) -> typing.Set[bpy.types.Node]:
|
||||
"""Finds and returns all nodes connecting to 'node'"""
|
||||
ret: typing.Set[bpy.types.Node] = set()
|
||||
for input_ in node.inputs:
|
||||
for link in input_.links:
|
||||
ret.add(link.from_node)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def find_link_connected_to(
|
||||
links: typing.Iterable[bpy.types.NodeLink],
|
||||
to_node: bpy.types.Node,
|
||||
to_socket_name: str,
|
||||
skip_reroutes: bool = False,
|
||||
) -> typing.Optional[bpy.types.NodeLink]:
|
||||
"""Find the link connected to given target node (to_node) to given socket name (to_socket_name)
|
||||
|
||||
There can be at most 1 such link. In Blender it is not allowed to connect more than one link
|
||||
to a socket. It is allowed to connect multiple links *from* one socket, but not *to* one socket.
|
||||
"""
|
||||
|
||||
ret: typing.List[bpy.types.NodeLink] = []
|
||||
for link in links:
|
||||
if to_node != link.to_node:
|
||||
continue
|
||||
if to_socket_name != link.to_socket.name:
|
||||
continue
|
||||
|
||||
if skip_reroutes and isinstance(link.from_node, bpy.types.NodeReroute):
|
||||
return find_link_connected_to(links, link.from_node, link.from_node.inputs[0].name)
|
||||
|
||||
ret.append(link)
|
||||
|
||||
if len(ret) > 1:
|
||||
raise RuntimeError(
|
||||
"Found multiple nodes connected to given node and socket. This is not valid!"
|
||||
)
|
||||
elif len(ret) == 0:
|
||||
return None
|
||||
return ret[0]
|
||||
|
||||
|
||||
def find_links_connected_from(
|
||||
links: typing.Iterable[bpy.types.NodeLink], from_node: bpy.types.Node, from_socket_name: str
|
||||
) -> typing.Iterable[bpy.types.NodeLink]:
|
||||
"""Find links connected from given node (from_node) from given socket name (from_socket_name)
|
||||
|
||||
There can be any number of such links.
|
||||
"""
|
||||
for link in links:
|
||||
if from_node != link.from_node:
|
||||
continue
|
||||
if from_socket_name != link.from_socket.name:
|
||||
continue
|
||||
|
||||
yield link
|
||||
|
||||
|
||||
def is_node_socket_connected_to(
|
||||
links: typing.Iterable[bpy.types.NodeLink],
|
||||
from_node: bpy.types.Node,
|
||||
from_socket_name: str,
|
||||
to_nodes: typing.List[bpy.types.Node],
|
||||
to_socket_name: typing.Optional[str],
|
||||
recursive: bool = True,
|
||||
) -> bool:
|
||||
for link in find_links_connected_from(links, from_node, from_socket_name):
|
||||
if link.to_node in to_nodes and (
|
||||
to_socket_name is None or to_socket_name == link.to_socket.name
|
||||
):
|
||||
return True
|
||||
if recursive and is_node_socket_connected_to(
|
||||
links, link.to_node, link.to_socket.name, to_nodes, to_socket_name, True
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def get_node_input_socket(
|
||||
node: bpy.types.Node, socket_name: str
|
||||
) -> typing.Optional[bpy.types.NodeSocket]:
|
||||
ret = None
|
||||
for input_ in node.inputs:
|
||||
if input_.name != socket_name:
|
||||
continue
|
||||
if ret is not None:
|
||||
raise RuntimeError("Multiple matches!")
|
||||
ret = input_
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def get_node_output_socket(
|
||||
node: bpy.types.Node, socket_name: str
|
||||
) -> typing.Optional[bpy.types.NodeSocket]:
|
||||
ret = None
|
||||
for output in node.outputs:
|
||||
if output.name != socket_name:
|
||||
continue
|
||||
if ret is not None:
|
||||
raise RuntimeError("Multiple matches!")
|
||||
ret = output
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def find_nodegroup_users(
|
||||
nodegroup_name: str,
|
||||
) -> typing.Iterable[typing.Tuple[bpy.types.Object, typing.Iterable[bpy.types.Object]]]:
|
||||
"""Returns iterable of (obj, user_objs) that use nodegroup with name 'nodegroup_name'
|
||||
|
||||
In case of instanced object this checks the instanced collection and the nested
|
||||
objects in order to find the mesh object that can be potentional user of 'nodegroup_name'.
|
||||
In this case this returns the original instanced object and list of non-empty objects that are
|
||||
instanced.
|
||||
|
||||
In case of editable objects this returns the object itself and list with the object in it.
|
||||
"""
|
||||
|
||||
def find_origin_objects(instancer_obj: bpy.types.Object) -> typing.Iterable[bpy.types.Object]:
|
||||
if instancer_obj.type != 'EMPTY':
|
||||
return [instancer_obj]
|
||||
|
||||
objects = {instancer_obj}
|
||||
while len(objects) > 0:
|
||||
obj = objects.pop()
|
||||
if (
|
||||
obj.type == 'EMPTY'
|
||||
and obj.instance_type == 'COLLECTION'
|
||||
and obj.instance_collection is not None
|
||||
):
|
||||
objects.update(obj.instance_collection.all_objects)
|
||||
else:
|
||||
yield obj
|
||||
|
||||
# Firstly gather all the materials that use the nodegroup with given name
|
||||
materials_using_nodegroup = set()
|
||||
for material in bpy.data.materials:
|
||||
if material.node_tree is None:
|
||||
continue
|
||||
|
||||
nodes = find_nodes_in_tree(
|
||||
material.node_tree,
|
||||
lambda x: isinstance(x, bpy.types.ShaderNodeGroup)
|
||||
and x.node_tree.name == nodegroup_name,
|
||||
)
|
||||
|
||||
if len(nodes) > 0:
|
||||
materials_using_nodegroup.add(material)
|
||||
|
||||
if len(materials_using_nodegroup) == 0:
|
||||
return []
|
||||
|
||||
# Go through all objects and yield ones that have one of the found materials
|
||||
for obj in bpy.data.objects:
|
||||
# We skip objects with library here as they will be gathered by 'find_origin_objects'
|
||||
if obj.library is not None:
|
||||
continue
|
||||
|
||||
# In case of instanced collection we find the actual instanced objects and gather all
|
||||
# used materials.
|
||||
if (
|
||||
obj.type == 'EMPTY'
|
||||
and obj.instance_type == 'COLLECTION'
|
||||
and obj.instance_collection is not None
|
||||
):
|
||||
instance_materials = set()
|
||||
instanced_objs = set(itertools.chain(find_origin_objects(obj)))
|
||||
for instanced_obj in instanced_objs:
|
||||
instance_materials.update(
|
||||
{
|
||||
slot.material
|
||||
for slot in instanced_obj.material_slots
|
||||
if slot.material is not None
|
||||
}
|
||||
)
|
||||
|
||||
if len(instance_materials.intersection(materials_using_nodegroup)) > 0:
|
||||
yield obj, instanced_objs
|
||||
|
||||
else:
|
||||
if not hasattr(obj, "material_slots"):
|
||||
continue
|
||||
|
||||
obj_materials = {
|
||||
slot.material for slot in obj.material_slots if slot.material is not None
|
||||
}
|
||||
|
||||
if len(obj_materials & materials_using_nodegroup) > 0:
|
||||
yield obj, [obj]
|
||||
|
||||
|
||||
def get_channel_nodes_map(
|
||||
node_tree: bpy.types.NodeTree,
|
||||
) -> typing.DefaultDict[str, typing.List[bpy.types.ShaderNodeTexImage]]:
|
||||
"""Returns all image nodes from given nodegroup mapping to filepath"""
|
||||
image_nodes = find_nodes_in_tree(
|
||||
node_tree, lambda x: isinstance(x, bpy.types.ShaderNodeTexImage)
|
||||
)
|
||||
|
||||
channel_nodes_map: typing.DefaultDict[str, typing.List[bpy.types.ShaderNodeTexImage]] = (
|
||||
collections.defaultdict(list)
|
||||
)
|
||||
|
||||
for node in image_nodes:
|
||||
name_wo_suffix = utils_bpy.remove_object_duplicate_suffix(node.name)
|
||||
split = name_wo_suffix.rsplit("_", 1)
|
||||
if len(split) == 2:
|
||||
# channel name = {"mq_Diffuse", "mq_Normal", "mq_Height", ...}
|
||||
channel_name, _ = split
|
||||
else:
|
||||
# fallback channel name to display the information about texture node anyways
|
||||
channel_name = "unknown"
|
||||
|
||||
channel_nodes_map[channel_name].append(node)
|
||||
|
||||
for list_ in channel_nodes_map.values():
|
||||
list_.sort(key=lambda x: x.name)
|
||||
|
||||
return channel_nodes_map
|
||||
|
||||
|
||||
def filter_node_socket_name(
|
||||
socket: bpy.types.NodeSocket | NodeSocketInterfaceCompat,
|
||||
*names: str,
|
||||
case_sensitive: bool = False,
|
||||
) -> bool:
|
||||
socket_name = socket.name if case_sensitive else socket.name.lower()
|
||||
names = names if case_sensitive else map(lambda x: x.lower(), names)
|
||||
for name in names:
|
||||
if name in socket_name:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class NodeSocketsDrawTemplate:
|
||||
"""Template for drawing node sockets from a nodegroup in a material or geonodes modifier.
|
||||
|
||||
The 'filter_' and 'socket_names_drawn_first' are optional and they are mutually exclusive if provided.
|
||||
If 'socket_names_drawn_first' is not None, their relative inputs are drawn first if they exist
|
||||
and 'filter_' is applied to the rest.
|
||||
"""
|
||||
|
||||
name: str
|
||||
filter_: typing.Callable[[bpy.types.NodeSocket | NodeSocketInterfaceCompat], bool] = (
|
||||
lambda _: True
|
||||
)
|
||||
socket_names_drawn_first: typing.Optional[typing.List[str]] = None
|
||||
|
||||
def draw_from_material(
|
||||
self,
|
||||
mat: bpy.types.Material,
|
||||
layout: bpy.types.UILayout,
|
||||
draw_max_first_occurrences: int = 1,
|
||||
) -> None:
|
||||
if draw_max_first_occurrences < 1:
|
||||
return
|
||||
nodegroups = list(
|
||||
itertools.chain(
|
||||
find_nodes_by_name(mat.node_tree, self.name),
|
||||
find_nodegroups_by_name(mat.node_tree, self.name),
|
||||
)
|
||||
)
|
||||
|
||||
if len(nodegroups) == 0:
|
||||
layout.label(text=f"No '{self.name}' nodegroup found", icon='INFO')
|
||||
return
|
||||
|
||||
for i, group in enumerate(nodegroups):
|
||||
if i >= draw_max_first_occurrences:
|
||||
break
|
||||
|
||||
inputs = list(filter(is_drawable_node_input, group.inputs))
|
||||
self._draw_template(
|
||||
inputs, lambda input_: layout.row().prop(input_, "default_value", text=input_.name)
|
||||
)
|
||||
|
||||
def draw_from_geonodes_modifier(
|
||||
self,
|
||||
layout: bpy.types.UILayout,
|
||||
mod: bpy.types.NodesModifier,
|
||||
) -> None:
|
||||
assert mod.type == 'NODES'
|
||||
if mod.node_group is None or mod.node_group.name != self.name:
|
||||
layout.label(text=f"No '{self.name}' nodegroup found", icon='INFO')
|
||||
return
|
||||
|
||||
inputs = list(
|
||||
filter(is_drawable_node_tree_input, get_node_tree_inputs_map(mod.node_group).values())
|
||||
)
|
||||
self._draw_template(inputs, lambda input_: draw_modifier_input(layout, mod, input_))
|
||||
|
||||
def _draw_template(
|
||||
self,
|
||||
inputs: typing.List[NodeSocketInterfaceCompat] | typing.List[bpy.types.NodeSocket],
|
||||
draw_function: typing.Callable[[NodeSocketInterfaceCompat | bpy.types.NodeSocket], None],
|
||||
) -> None:
|
||||
already_drawn = set()
|
||||
if self.socket_names_drawn_first is not None:
|
||||
socket_name_to_input_map = {input_.name.lower(): input_ for input_ in inputs}
|
||||
for name in self.socket_names_drawn_first:
|
||||
input_ = socket_name_to_input_map.get(name.lower(), None)
|
||||
if input_ is None:
|
||||
continue
|
||||
already_drawn.add(input_)
|
||||
draw_function(input_)
|
||||
|
||||
for input_ in inputs:
|
||||
if input_ not in already_drawn and self.filter_(input_):
|
||||
draw_function(input_)
|
||||
|
||||
|
||||
def is_drawable_node_input(input_: bpy.types.NodeSocket) -> bool:
|
||||
return (
|
||||
hasattr(input_, "default_value")
|
||||
and input_.enabled
|
||||
and not input_.hide_value
|
||||
and not input_.is_linked
|
||||
)
|
||||
|
||||
|
||||
def is_drawable_node_tree_input(input_: NodeSocketInterfaceCompat) -> bool:
|
||||
return get_socket_type(input_) != 'NodeSocketGeometry' and not input_.hide_value
|
||||
|
||||
|
||||
def draw_node_inputs_filtered(
|
||||
layout: bpy.types.UILayout,
|
||||
node: bpy.types.Node,
|
||||
filter_: typing.Callable[[bpy.types.NodeSocket], bool] = lambda _: True,
|
||||
) -> None:
|
||||
for input_ in node.inputs:
|
||||
if not is_drawable_node_input(input_):
|
||||
continue
|
||||
|
||||
if filter_(input_):
|
||||
layout.row().prop(input_, "default_value", text=input_.name)
|
||||
|
||||
|
||||
def draw_modifier_input(
|
||||
layout: bpy.types.UILayout, mod: bpy.types.NodesModifier, input_: NodeSocketInterfaceCompat
|
||||
):
|
||||
if get_socket_type(input_) == 'NodeSocketObject':
|
||||
layout.row().prop_search(
|
||||
mod,
|
||||
f"[\"{input_.identifier}\"]",
|
||||
bpy.data,
|
||||
"objects",
|
||||
text=input_.name,
|
||||
icon='OBJECT_DATA',
|
||||
)
|
||||
elif get_socket_type(input_) == 'NodeSocketMaterial':
|
||||
layout.row().prop_search(
|
||||
mod,
|
||||
f"[\"{input_.identifier}\"]",
|
||||
bpy.data,
|
||||
"materials",
|
||||
text=input_.name,
|
||||
icon='MATERIAL_DATA',
|
||||
)
|
||||
elif get_socket_type(input_) == 'NodeSocketCollection':
|
||||
layout.row().prop_search(
|
||||
mod,
|
||||
f"[\"{input_.identifier}\"]",
|
||||
bpy.data,
|
||||
"collections",
|
||||
text=input_.name,
|
||||
icon='OUTLINER_COLLECTION',
|
||||
)
|
||||
else:
|
||||
layout.row().prop(mod, f"[\"{input_.identifier}\"]", text=input_.name)
|
||||
|
||||
|
||||
def draw_node_tree(
|
||||
layout: bpy.types.UILayout,
|
||||
node_tree: bpy.types.NodeTree,
|
||||
depth_limit: int = 5,
|
||||
) -> None:
|
||||
def draw_node_and_recurse(
|
||||
layout: bpy.types.UILayout,
|
||||
node: bpy.types.Node,
|
||||
parent_node: typing.Optional[bpy.types.Node],
|
||||
depth: int,
|
||||
) -> None:
|
||||
if depth == depth_limit:
|
||||
return
|
||||
|
||||
box = layout.box()
|
||||
row = box.row()
|
||||
row.prop(
|
||||
node, "hide", text="", emboss=False, icon='TRIA_RIGHT' if node.hide else 'TRIA_DOWN'
|
||||
)
|
||||
row.label(text=node.name)
|
||||
if parent_node is not None:
|
||||
right = row.row()
|
||||
right.enabled = False
|
||||
right.label(text=f"(from {parent_node.name} node)")
|
||||
|
||||
if not node.hide:
|
||||
col = box.column(align=True)
|
||||
node.draw_buttons(bpy.context, col)
|
||||
draw_node_inputs_filtered(col, node)
|
||||
for incoming_node in find_incoming_nodes(node):
|
||||
draw_node_and_recurse(layout, incoming_node, node, depth + 1)
|
||||
|
||||
material_output_nodes = find_nodes_in_tree(
|
||||
node_tree, lambda x: isinstance(x, bpy.types.ShaderNodeOutputMaterial)
|
||||
)
|
||||
|
||||
if len(material_output_nodes) != 1:
|
||||
return
|
||||
|
||||
draw_node_and_recurse(layout, material_output_nodes.pop(), None, 0)
|
||||
@@ -0,0 +1,125 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
|
||||
import bpy
|
||||
import bpy.utils.previews
|
||||
import os
|
||||
import logging
|
||||
import typing
|
||||
import threading
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
class PreviewManager:
|
||||
"""Loads previews from provided paths on demand based on basenames or custom ids."""
|
||||
|
||||
def __init__(self):
|
||||
self.preview_collection = bpy.utils.previews.new()
|
||||
self.lock = threading.Lock()
|
||||
self.id_path_map: typing.Dict[str, str] = {}
|
||||
self.allowed_extensions = {".png", ".jpg"}
|
||||
|
||||
def add_preview_path(self, path: str, id_override: typing.Optional[str] = None) -> None:
|
||||
"""Adds 'path' as a possible place from where preview can be loaded if requested.
|
||||
|
||||
By default the ID of the preview is the basename of the file without extension. If 'path'
|
||||
is a single file, then 'id_override' can be used to override the default behavior.
|
||||
|
||||
If 'path' is a directory, then all files with allowed extension are considered.
|
||||
|
||||
The preview is then loaded on demand when requested by its ID using 'get_icon_id'.
|
||||
"""
|
||||
self._update_path_map_entry(path, id_override)
|
||||
|
||||
def get_icon_id(self, id_: str) -> int:
|
||||
"""Return icon_id for preview with id 'id_'
|
||||
|
||||
Returns question mark icon id if 'id_' is not found.
|
||||
"""
|
||||
if id_ in self.preview_collection:
|
||||
return self.preview_collection[id_].icon_id
|
||||
else:
|
||||
path = self.id_path_map.get(id_, None)
|
||||
if path is None:
|
||||
return 1
|
||||
|
||||
# There might be paths, that weren't removed from the map, but the file was already
|
||||
# deleted on the filesystem. In that case (else branch) we remove the id_ from
|
||||
# the path map.
|
||||
if os.path.isfile(path):
|
||||
logger.debug(f"Preview: {id_} loaded on demand {id_}")
|
||||
self._load_preview(path, id_)
|
||||
assert id_ in self.preview_collection
|
||||
return self.preview_collection[id_].icon_id
|
||||
else:
|
||||
del self.id_path_map[id_]
|
||||
|
||||
# Unknown preview ID
|
||||
return 1
|
||||
|
||||
def get_polygoniq_addon_icon_id(self, addon_name: str) -> int:
|
||||
return self.get_icon_id(f"logo_{addon_name}")
|
||||
|
||||
def get_engon_feature_icon_id(self, feature_name: str) -> int:
|
||||
return self.get_icon_id(f"logo_{feature_name}_features")
|
||||
|
||||
def clear(self, ids: typing.Optional[typing.Set[str]] = None) -> None:
|
||||
"""Clears the whole preview collection or only 'ids' if provided.
|
||||
|
||||
This doesn't clear the paths where previews can be found. If there is some invalid path,
|
||||
it is cleared when the preview should be loaded, but it wasn't be found.
|
||||
"""
|
||||
if ids is None:
|
||||
self.preview_collection.clear()
|
||||
else:
|
||||
for id_ in ids:
|
||||
if id_ in self.preview_collection:
|
||||
del self.preview_collection[id_]
|
||||
|
||||
def _update_path_map_entry(self, path: str, id_override: typing.Optional[str] = None) -> None:
|
||||
if os.path.isdir(path):
|
||||
if id_override is not None:
|
||||
raise RuntimeError("id_override is not allowed for directories!")
|
||||
|
||||
for file in os.listdir(path):
|
||||
filename, ext = os.path.splitext(file)
|
||||
basename = os.path.basename(filename)
|
||||
if ext.lower() in self.allowed_extensions:
|
||||
self.id_path_map[basename] = os.path.join(path, file)
|
||||
if basename in self.preview_collection:
|
||||
del self.preview_collection[basename]
|
||||
|
||||
elif os.path.isfile(path):
|
||||
filename, ext = os.path.splitext(path)
|
||||
basename = os.path.basename(filename)
|
||||
key = id_override if id_override is not None else basename
|
||||
if ext.lower() in self.allowed_extensions:
|
||||
self.id_path_map[key] = path
|
||||
if key in self.preview_collection:
|
||||
del self.preview_collection[key]
|
||||
|
||||
def _load_preview(self, full_path: str, id_: str) -> None:
|
||||
"""Loads previews from 'full_path' and saves on key 'id_'
|
||||
|
||||
Assumes 'full_path' is already existing file in the filesystem.
|
||||
"""
|
||||
|
||||
with self.lock:
|
||||
if id_ in self.preview_collection:
|
||||
return
|
||||
|
||||
assert os.path.isfile(full_path)
|
||||
try:
|
||||
self.preview_collection.load(id_, full_path, 'IMAGE', True)
|
||||
except KeyError as e:
|
||||
logger.exception(f"Preview {id_} already loaded!")
|
||||
|
||||
def __del__(self):
|
||||
self.preview_collection.close()
|
||||
|
||||
def __contains__(self, id_: str) -> bool:
|
||||
return id_ in self.preview_collection
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"{self.__class__.__name__}: Loaded {len(self.preview_collection)} previews."
|
||||
@@ -0,0 +1,96 @@
|
||||
#!/usr/bin/python3
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
|
||||
import bpy
|
||||
import re
|
||||
import os
|
||||
import typing
|
||||
from . import utils_bpy
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
def polygoniq_duplicate_data_filter(
|
||||
data: bpy.types.ID, data_filepaths: typing.Optional[typing.Set[str]] = None
|
||||
) -> bool:
|
||||
"""Filters polygoniq duplicate data based on addon prefix and duplicate suffix.
|
||||
|
||||
If 'data_filepaths' argument is provided, images with path common to paths provided are also
|
||||
considered duplicates.
|
||||
"""
|
||||
# Pattern to check if the object contains a duplicate suffix - .001 - .999 after the name
|
||||
pattern = re.compile(r"^\.[0-9]{3}$")
|
||||
if not pattern.match(data.name[-4:]):
|
||||
return False
|
||||
|
||||
if data_filepaths is None:
|
||||
data_filepaths = set()
|
||||
|
||||
KNOWN_PREFIXES = ("aq_", "bq_", "mq_", "tq_", "iq_", "eq_", "st_", "am154_", "am176_")
|
||||
|
||||
orig_name = utils_bpy.remove_object_duplicate_suffix(data.name)
|
||||
if isinstance(data, bpy.types.NodeTree):
|
||||
return orig_name.startswith(KNOWN_PREFIXES)
|
||||
|
||||
if isinstance(data, bpy.types.Material):
|
||||
return orig_name.startswith(KNOWN_PREFIXES)
|
||||
|
||||
if isinstance(data, bpy.types.Image):
|
||||
img_path = os.path.abspath(bpy.path.abspath(data.filepath, library=data.library))
|
||||
for path in data_filepaths:
|
||||
try:
|
||||
if os.path.commonpath([img_path, path]) == path:
|
||||
return True
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# TODO: log warning or raise exception?
|
||||
return False
|
||||
|
||||
|
||||
DuplicateFilter = typing.Callable[[bpy.types.ID, typing.Optional[typing.Set[str]]], bool]
|
||||
|
||||
|
||||
def _is_duplicate_filtered(
|
||||
data: bpy.types.ID,
|
||||
filters: typing.Iterable[DuplicateFilter],
|
||||
install_paths: typing.Optional[typing.Set[str]] = None,
|
||||
) -> bool:
|
||||
filtered = False
|
||||
for filter_ in filters:
|
||||
if not filter_(data, install_paths):
|
||||
filtered = True
|
||||
break
|
||||
|
||||
return filtered
|
||||
|
||||
|
||||
def remove_duplicate_datablocks(
|
||||
datablocks: bpy.types.bpy_prop_collection,
|
||||
filters: typing.Optional[typing.Iterable[DuplicateFilter]] = None,
|
||||
install_paths: typing.Optional[typing.Set[str]] = None,
|
||||
) -> typing.List[str]:
|
||||
to_remove = []
|
||||
|
||||
for datablock in datablocks:
|
||||
if filters is not None and _is_duplicate_filtered(datablock, filters, install_paths):
|
||||
continue
|
||||
|
||||
# ok, so it's a duplicate, let's figure out the "proper" datablock
|
||||
orig_datablock_name = utils_bpy.remove_object_duplicate_suffix(datablock.name)
|
||||
if orig_datablock_name in datablocks:
|
||||
orig_node_group = datablocks[orig_datablock_name]
|
||||
datablock.user_remap(orig_node_group)
|
||||
if datablock.users == 0:
|
||||
to_remove.append(datablock)
|
||||
else:
|
||||
# the original datablock is gone, we should rename this one
|
||||
datablock.name = orig_datablock_name
|
||||
ret = []
|
||||
for datablock in to_remove:
|
||||
ret.append(datablock.name)
|
||||
datablocks.remove(datablock)
|
||||
return ret
|
||||
@@ -0,0 +1,150 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
# Code is inspired by the 'MeasureIt' addon by Antonio Vazquez that is shipped natively in Blender
|
||||
|
||||
import bpy
|
||||
import bpy_extras
|
||||
import blf
|
||||
import dataclasses
|
||||
import gpu
|
||||
import gpu_extras.batch
|
||||
import gpu_extras.presets
|
||||
import mathutils
|
||||
import logging
|
||||
import typing
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
if not bpy.app.background:
|
||||
# Blender 4.0 dropped the 3D_ and 2D_ prefixes from the shader names
|
||||
SHADER_LINE_BUILTIN = (
|
||||
gpu.shader.from_builtin('POLYLINE_UNIFORM_COLOR')
|
||||
if bpy.app.version >= (4, 0, 0)
|
||||
else gpu.shader.from_builtin('3D_POLYLINE_UNIFORM_COLOR')
|
||||
)
|
||||
|
||||
SHADER_2D_UNIFORM_COLOR_BUILTIN = (
|
||||
gpu.shader.from_builtin('UNIFORM_COLOR')
|
||||
if bpy.app.version >= (4, 0, 0)
|
||||
else gpu.shader.from_builtin('2D_UNIFORM_COLOR')
|
||||
)
|
||||
else:
|
||||
logger.info(f"'{__name__}' module is not available in background mode!")
|
||||
|
||||
|
||||
VIEWPORT_SIZE = (0, 0)
|
||||
Color = typing.Tuple[float, float, float, float]
|
||||
|
||||
|
||||
def set_context(context: bpy.types.Context) -> None:
|
||||
"""Sets viewport size from context, to be further used as native bpy uniform in shaders"""
|
||||
global VIEWPORT_SIZE
|
||||
VIEWPORT_SIZE = (context.region.width, context.region.height)
|
||||
|
||||
|
||||
def line(v1: mathutils.Vector, v2: mathutils.Vector, color: Color, width: float):
|
||||
"""Draws a line from 'v1' to 'v2' of desired 'color' and 'width'"""
|
||||
pos = [v1, v2]
|
||||
batch = gpu_extras.batch.batch_for_shader(SHADER_LINE_BUILTIN, 'LINES', {"pos": pos})
|
||||
SHADER_LINE_BUILTIN.bind()
|
||||
SHADER_LINE_BUILTIN.uniform_float("color", color)
|
||||
SHADER_LINE_BUILTIN.uniform_float("lineWidth", width)
|
||||
SHADER_LINE_BUILTIN.uniform_float("viewportSize", VIEWPORT_SIZE)
|
||||
batch.draw(SHADER_LINE_BUILTIN)
|
||||
|
||||
|
||||
def rectangle(pos: typing.Tuple[float, float], size: typing.Tuple[float, float], color: Color):
|
||||
"""Draws rectangle starting at 'pos' of width and height from 'size' of desired 'color'"""
|
||||
batch = gpu_extras.batch.batch_for_shader(
|
||||
SHADER_2D_UNIFORM_COLOR_BUILTIN,
|
||||
'TRI_FAN',
|
||||
{
|
||||
"pos": [
|
||||
(pos[0], pos[1]),
|
||||
(pos[0] + size[0], pos[1]),
|
||||
(pos[0] + size[0], pos[1] + size[1]),
|
||||
(pos[0], pos[1] + size[1]),
|
||||
]
|
||||
},
|
||||
)
|
||||
SHADER_2D_UNIFORM_COLOR_BUILTIN.bind()
|
||||
SHADER_2D_UNIFORM_COLOR_BUILTIN.uniform_float("color", color)
|
||||
batch.draw(SHADER_2D_UNIFORM_COLOR_BUILTIN)
|
||||
|
||||
|
||||
def circle(center: mathutils.Vector, radius: float, color: Color, segments: int):
|
||||
gpu_extras.presets.draw_circle_2d(center, color, radius, segments=segments)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class TextStyle:
|
||||
"""Style of rendered text
|
||||
|
||||
If 'consider_ui_scale' is True, then actual 'font_size' is constructed
|
||||
on initialization based on preferences user interface scale
|
||||
"""
|
||||
|
||||
font_id: int = 0
|
||||
font_size: int = 15
|
||||
color: Color = (1.0, 1.0, 1.0, 1.0)
|
||||
dpi: int = 72
|
||||
consider_ui_scale: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
if self.consider_ui_scale:
|
||||
self.font_size *= bpy.context.preferences.system.ui_scale
|
||||
|
||||
|
||||
def text(pos: mathutils.Vector, string: str, style: TextStyle) -> None:
|
||||
blf.position(style.font_id, pos[0], pos[1], 0)
|
||||
if bpy.app.version >= (4, 0, 0): # dpi argument has been dropped in Blender 4.0
|
||||
blf.size(style.font_id, style.font_size)
|
||||
else:
|
||||
blf.size(style.font_id, style.font_size, style.dpi)
|
||||
blf.color(style.font_id, *style.color)
|
||||
blf.draw(style.font_id, str(string))
|
||||
|
||||
|
||||
def text_3d(
|
||||
world_pos: mathutils.Vector,
|
||||
string: str,
|
||||
style: TextStyle,
|
||||
region: bpy.types.Region,
|
||||
rv3d: bpy.types.RegionView3D,
|
||||
) -> None:
|
||||
pos_2d = bpy_extras.view3d_utils.location_3d_to_region_2d(region, rv3d, world_pos)
|
||||
text(pos_2d, string, style)
|
||||
|
||||
|
||||
def text_box(
|
||||
pos: mathutils.Vector,
|
||||
width: int,
|
||||
padding: int,
|
||||
text_margin: float,
|
||||
background: typing.Optional[Color],
|
||||
texts: typing.List[typing.Tuple[str, TextStyle]],
|
||||
) -> None:
|
||||
height = sum(t[1].font_size for t in texts) + (len(texts) - 1) * text_margin
|
||||
if background is not None:
|
||||
rectangle(pos, (width, height + 2 * padding), background)
|
||||
|
||||
x_pos = pos.x + padding
|
||||
y_pos = pos.y + height
|
||||
for string, style in texts:
|
||||
text((x_pos, y_pos), string, style)
|
||||
y_pos -= style.font_size + text_margin
|
||||
|
||||
|
||||
def text_box_3d(
|
||||
world_pos: mathutils.Vector,
|
||||
width: int,
|
||||
padding: int,
|
||||
text_margin: float,
|
||||
background: typing.Optional[Color],
|
||||
texts: typing.List[typing.Tuple[str, TextStyle]],
|
||||
region: bpy.types.Region,
|
||||
rv3d: bpy.types.RegionView3D,
|
||||
) -> None:
|
||||
"""Draws text box based on world position aligned to view"""
|
||||
pos_2d = bpy_extras.view3d_utils.location_3d_to_region_2d(region, rv3d, world_pos)
|
||||
text_box(pos_2d, width, padding, text_margin, background, texts)
|
||||
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/python3
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import bpy
|
||||
import typing
|
||||
|
||||
|
||||
class TraffiqRigProperties:
|
||||
CAR_RIG = "tq_Car_Rig"
|
||||
WHEELS_Y_ROLLING = "tq_WheelsYRolling"
|
||||
STEERING = "tq_SteeringRotation"
|
||||
WHEEL_ROTATION = "tq_WheelRotation"
|
||||
SUSPENSION_FACTOR = "tq_SuspensionFactor"
|
||||
SUSPENSION_ROLLING_FACTOR = "tq_SuspensionRollingFactor"
|
||||
|
||||
@classmethod
|
||||
def is_rig_property(cls, prop: str) -> bool:
|
||||
if prop.startswith(TraffiqRigProperties.WHEEL_ROTATION):
|
||||
return True
|
||||
|
||||
return prop in {
|
||||
cls.CAR_RIG,
|
||||
cls.WHEELS_Y_ROLLING,
|
||||
cls.STEERING,
|
||||
cls.WHEEL_ROTATION,
|
||||
cls.SUSPENSION_FACTOR,
|
||||
cls.SUSPENSION_ROLLING_FACTOR,
|
||||
}
|
||||
|
||||
|
||||
class RigDrivers:
|
||||
"""Class used to generate back drivers for rig variables
|
||||
|
||||
Unfortunately blender operator duplicates_make_real doesn't
|
||||
append animation data, where drivers are stored https://developer.blender.org/T81577
|
||||
|
||||
Our version of rigacar generates the drivers in the source files, but they aren't
|
||||
available after duplicates_make_real is called in converted to editable, thus this class
|
||||
exists and is used to create those drivers dynamically based on bone names.
|
||||
"""
|
||||
|
||||
INFLUENCE_VAR_NAME = "influence"
|
||||
ROTATION_EULER_X_VAR_NAME = "rotationAngle"
|
||||
|
||||
def __init__(self, obj: bpy.types.Object):
|
||||
assert "tq_Car_Rig" in obj.data
|
||||
self.target_obj = obj
|
||||
self.pose = obj.pose
|
||||
|
||||
def create_all_drivers(self):
|
||||
for bone in self.pose.bones.values():
|
||||
if bone.name.startswith("MCH_WheelRotation"):
|
||||
_, _, suffix = bone.name.split("_", 2)
|
||||
data_path = f'["{TraffiqRigProperties.WHEEL_ROTATION}_{suffix}"]'
|
||||
self.__create_rotation_euler_x_driver(bone, data_path)
|
||||
elif bone.name == "MCH_SteeringRotation":
|
||||
self.__create_translation_x_driver(bone, f'["{TraffiqRigProperties.STEERING}"]')
|
||||
elif bone.name == "MCH_Axis":
|
||||
front_constraint = bone.constraints.get("Rotation from MCH_Axis_F", None)
|
||||
if front_constraint is not None:
|
||||
self.__create_constraint_influence_driver(
|
||||
front_constraint,
|
||||
f'["{TraffiqRigProperties.SUSPENSION_ROLLING_FACTOR}"]',
|
||||
1.0,
|
||||
)
|
||||
rear_constraint = bone.constraints.get("Rotation from MCH_Axis_B", None)
|
||||
if rear_constraint is not None:
|
||||
self.__create_constraint_influence_driver(
|
||||
rear_constraint,
|
||||
f'["{TraffiqRigProperties.SUSPENSION_ROLLING_FACTOR}"]',
|
||||
0.5,
|
||||
)
|
||||
|
||||
def __create_constraint_influence_driver(
|
||||
self,
|
||||
constraint: bpy.types.CopyLocationConstraint,
|
||||
driver_data_path: str,
|
||||
base_influence: typing.Optional[float] = 1.0,
|
||||
) -> None:
|
||||
fcurve = constraint.driver_add("influence")
|
||||
drv = fcurve.driver
|
||||
drv.type = 'AVERAGE'
|
||||
var = drv.variables.get(RigDrivers.INFLUENCE_VAR_NAME, None)
|
||||
if var is None:
|
||||
var = drv.variables.new()
|
||||
var.name = RigDrivers.INFLUENCE_VAR_NAME
|
||||
var.type = 'SINGLE_PROP'
|
||||
|
||||
targ = var.targets[0]
|
||||
targ.id_type = 'OBJECT'
|
||||
targ.id = self.target_obj
|
||||
targ.data_path = driver_data_path
|
||||
|
||||
if base_influence != 1.0:
|
||||
fmod = fcurve.modifiers[0]
|
||||
fmod.mode = 'POLYNOMIAL'
|
||||
fmod.poly_order = 1
|
||||
fmod.coefficients = (0, base_influence)
|
||||
|
||||
def __create_translation_x_driver(self, bone: bpy.types.PoseBone, driver_data_path: str):
|
||||
fcurve = bone.driver_add("location", 0)
|
||||
drv = fcurve.driver
|
||||
drv.type = 'AVERAGE'
|
||||
var = drv.variables.get(RigDrivers.ROTATION_EULER_X_VAR_NAME, None)
|
||||
if var is None:
|
||||
var = drv.variables.new()
|
||||
var.name = RigDrivers.ROTATION_EULER_X_VAR_NAME
|
||||
var.type = 'SINGLE_PROP'
|
||||
|
||||
targ = var.targets[0]
|
||||
targ.id_type = 'OBJECT'
|
||||
targ.id = self.target_obj
|
||||
targ.data_path = driver_data_path
|
||||
|
||||
def __create_rotation_euler_x_driver(self, bone: bpy.types.PoseBone, driver_data_path: str):
|
||||
fcurve = bone.driver_add("rotation_euler", 0)
|
||||
drv = fcurve.driver
|
||||
drv.type = 'AVERAGE'
|
||||
var = drv.variables.get(RigDrivers.ROTATION_EULER_X_VAR_NAME, None)
|
||||
if var is None:
|
||||
var = drv.variables.new()
|
||||
var.name = RigDrivers.ROTATION_EULER_X_VAR_NAME
|
||||
var.type = 'SINGLE_PROP'
|
||||
|
||||
targ = var.targets[0]
|
||||
targ.id_type = 'OBJECT'
|
||||
targ.id = self.target_obj
|
||||
targ.data_path = driver_data_path
|
||||
|
||||
|
||||
def is_object_rigged(obj: bpy.types.Object) -> bool:
|
||||
if obj is None:
|
||||
return False
|
||||
|
||||
if obj.data is None:
|
||||
return False
|
||||
|
||||
return TraffiqRigProperties.CAR_RIG in obj.data and obj.data[TraffiqRigProperties.CAR_RIG] == 1
|
||||
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/python3
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
import typing
|
||||
import math
|
||||
import copy
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
if "linalg_bpy" not in locals():
|
||||
from . import linalg_bpy
|
||||
from . import utils_bpy
|
||||
else:
|
||||
import importlib
|
||||
|
||||
linalg_bpy = importlib.reload(linalg_bpy)
|
||||
utils_bpy = importlib.reload(utils_bpy)
|
||||
|
||||
|
||||
def find_bounding_wheels(wheels: typing.List[bpy.types.Object]) -> typing.List[bpy.types.Object]:
|
||||
# we take first front wheels and then find maximum index of rear wheels and return it as a list
|
||||
assert len(wheels) > 4
|
||||
|
||||
frontmost_wheels = []
|
||||
rearmost_wheels = {}
|
||||
for wheel_obj in wheels:
|
||||
_, position, wheel_number = utils_bpy.remove_object_duplicate_suffix(wheel_obj.name).split(
|
||||
"_"
|
||||
)[-3:]
|
||||
if position.endswith("F"):
|
||||
if int(wheel_number) == 0:
|
||||
frontmost_wheels.append(wheel_obj)
|
||||
else:
|
||||
if position not in rearmost_wheels:
|
||||
rearmost_wheels[position] = (wheel_obj, wheel_number)
|
||||
else:
|
||||
if wheel_number > rearmost_wheels[position][1]:
|
||||
rearmost_wheels[position] = (wheel_obj, wheel_number)
|
||||
rearmost_wheels_list = [v[0] for v in rearmost_wheels.values()]
|
||||
return frontmost_wheels + rearmost_wheels_list
|
||||
|
||||
|
||||
def get_wheel_contact_points(
|
||||
wheels: typing.List[bpy.types.Object], instance: bpy.types.Object, debug: bool = False
|
||||
) -> typing.List[mathutils.Vector]:
|
||||
wheel_contact_points = []
|
||||
one_track_vehicle = True if len(wheels) == 2 else False
|
||||
|
||||
# when vehicle has more than 4 wheels take only the outer ones
|
||||
if len(wheels) > 4:
|
||||
wheels = find_bounding_wheels(wheels)
|
||||
|
||||
for wheel_obj in wheels:
|
||||
wheel_center = wheel_obj.location
|
||||
radius = wheel_obj.dimensions.y / 2
|
||||
contact_point = wheel_center - mathutils.Vector((0, 0, radius))
|
||||
|
||||
contact_point_world_space = instance.matrix_world @ contact_point
|
||||
wheel_contact_points.append(contact_point_world_space)
|
||||
|
||||
# hack-fix for one track vehicles, just pretend it has another 2
|
||||
# wheels nearby so we can raycast the plane
|
||||
if one_track_vehicle:
|
||||
fixture_contact_point_ws = (
|
||||
instance.matrix_world @ mathutils.Matrix.Translation((0.1, 0, 0)) @ contact_point
|
||||
)
|
||||
wheel_contact_points.append(fixture_contact_point_ws)
|
||||
|
||||
if debug:
|
||||
bpy.ops.object.empty_add(location=contact_point_world_space)
|
||||
obj = bpy.context.object
|
||||
obj.name = "B: " + wheel_obj.name
|
||||
obj.show_name = True
|
||||
|
||||
return wheel_contact_points
|
||||
|
||||
|
||||
GetRayCastedPlaneCallable = typing.Callable[
|
||||
[], typing.Tuple[typing.List[mathutils.Vector], typing.Optional[typing.List[mathutils.Vector]]]
|
||||
]
|
||||
|
||||
|
||||
def snap_to_ground_iterate(
|
||||
instance: bpy.types.Object,
|
||||
obj: bpy.types.Object,
|
||||
instance_old_matrix_world: mathutils.Matrix,
|
||||
get_ray_casted_plane: GetRayCastedPlaneCallable,
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
"""Snap to ground iteratively, we first estimate final rotation until angular delta
|
||||
is lower than our tolerance. Only then we can get an accurate raycast position delta.
|
||||
"""
|
||||
ANGULAR_DELTA_TOLERANCE = math.radians(1)
|
||||
MAXIMUM_ITERATIONS = 10
|
||||
|
||||
iteration = 1
|
||||
while True:
|
||||
bottom_corners, altered_bottom_corners = get_ray_casted_plane()
|
||||
if altered_bottom_corners is None:
|
||||
if debug:
|
||||
logger.debug(
|
||||
f"Failed to raycast all corners while estimating rotation "
|
||||
f"for {obj.name}, instance={instance.name}. Skipping..."
|
||||
)
|
||||
instance.matrix_world = instance_old_matrix_world
|
||||
return
|
||||
|
||||
orig_plane_normal, _, orig_plane_centroid = linalg_bpy.fit_plane_to_points(bottom_corners)
|
||||
altered_plane_normal, _, altered_plane_centroid = linalg_bpy.fit_plane_to_points(
|
||||
altered_bottom_corners
|
||||
)
|
||||
if debug:
|
||||
orig_plane_rotation = mathutils.Vector([0, 0, 1]).rotation_difference(orig_plane_normal)
|
||||
altered_plane_rotation = mathutils.Vector([0, 0, 1]).rotation_difference(
|
||||
altered_plane_normal
|
||||
)
|
||||
bpy.ops.mesh.primitive_plane_add(
|
||||
location=orig_plane_centroid, rotation=orig_plane_rotation.to_euler(), size=3
|
||||
)
|
||||
bpy.ops.mesh.primitive_plane_add(
|
||||
location=altered_plane_centroid, rotation=altered_plane_rotation.to_euler(), size=3
|
||||
)
|
||||
|
||||
delta_rotation = mathutils.Vector(orig_plane_normal).rotation_difference(
|
||||
altered_plane_normal
|
||||
)
|
||||
|
||||
# Since matrix_world is composed as location @ rotation @ scale, we need to decompose it
|
||||
# into separate matrices, multiply only rotation and then compose it back.
|
||||
# See https://blender.stackexchange.com/a/44783
|
||||
# We could also use e.g. instance.rotation_quaternion but we would need to call
|
||||
# bpy.context.view_layer.update() after each change to update matrix_world, which is slower.
|
||||
orig_loc, orig_rot, orig_scale = instance.matrix_world.decompose()
|
||||
orig_loc_mat = mathutils.Matrix.Translation(orig_loc)
|
||||
orig_rot_mat = orig_rot.to_matrix().to_4x4()
|
||||
delta_rot_mat = delta_rotation.to_matrix().to_4x4()
|
||||
orig_scale_mat = mathutils.Matrix.Diagonal(orig_scale).to_4x4()
|
||||
# assemble the new matrix
|
||||
instance.matrix_world = orig_loc_mat @ delta_rot_mat @ orig_rot_mat @ orig_scale_mat
|
||||
|
||||
if debug:
|
||||
logger.debug(f"iteration: {iteration}, angular error: {delta_rotation.angle}")
|
||||
if abs(delta_rotation.angle) < ANGULAR_DELTA_TOLERANCE:
|
||||
break
|
||||
iteration += 1
|
||||
if iteration > MAXIMUM_ITERATIONS:
|
||||
break
|
||||
|
||||
bottom_corners, altered_bottom_corners = get_ray_casted_plane()
|
||||
if altered_bottom_corners is None:
|
||||
if debug:
|
||||
logger.debug(
|
||||
f"Failed to raycast all corners while estimating position "
|
||||
f"for {obj.name}, instance={instance.name}. Skipping..."
|
||||
)
|
||||
instance.matrix_world = instance_old_matrix_world
|
||||
return
|
||||
|
||||
orig_plane_normal, _, orig_plane_centroid = linalg_bpy.fit_plane_to_points(bottom_corners)
|
||||
altered_plane_normal, _, altered_plane_centroid = linalg_bpy.fit_plane_to_points(
|
||||
altered_bottom_corners
|
||||
)
|
||||
delta_location = altered_plane_centroid - orig_plane_centroid
|
||||
instance.matrix_world = mathutils.Matrix.Translation(delta_location) @ instance.matrix_world
|
||||
|
||||
|
||||
def ray_cast_plane(
|
||||
ground_objects: typing.Iterable[bpy.types.Object],
|
||||
bottom_corners: typing.List[mathutils.Vector],
|
||||
grace_padding: float = 0.1,
|
||||
debug: bool = False,
|
||||
) -> typing.Tuple[typing.List[mathutils.Vector], typing.Optional[typing.List[mathutils.Vector]]]:
|
||||
"""Raycast from 'bottom_corners' points downwards to 'ground_objects'.
|
||||
Return 'bottom_corners' and list of intersection points closest to each bottom_corner point.
|
||||
"""
|
||||
altered_bottom_corners = copy.deepcopy(bottom_corners)
|
||||
altered_bottom_distances = [math.inf for _ in bottom_corners]
|
||||
for ground_object in ground_objects:
|
||||
for i, bottom_corner in enumerate(bottom_corners):
|
||||
if debug:
|
||||
logger.debug("Raycast from: " + str(bottom_corner))
|
||||
bottom_corner_obj_space = ground_object.matrix_world.inverted() @ (
|
||||
bottom_corner + mathutils.Vector([0, 0, grace_padding])
|
||||
)
|
||||
bottom_corner2_obj_space = ground_object.matrix_world.inverted() @ (
|
||||
bottom_corner + mathutils.Vector([0, 0, grace_padding - 1])
|
||||
)
|
||||
direction_obj_space = bottom_corner2_obj_space - bottom_corner_obj_space
|
||||
try:
|
||||
result, new_bottom_corner_obj_space, _, _ = ground_object.ray_cast(
|
||||
bottom_corner_obj_space, direction_obj_space
|
||||
)
|
||||
except:
|
||||
logger.exception("Uncaught exception while raycasting to the ground")
|
||||
result = None
|
||||
new_bottom_corner_obj_space = bottom_corner_obj_space
|
||||
|
||||
if not result:
|
||||
continue
|
||||
new_bottom_corner = ground_object.matrix_world @ new_bottom_corner_obj_space
|
||||
distance = (bottom_corners[i] - new_bottom_corner).length
|
||||
if distance < altered_bottom_distances[i]:
|
||||
altered_bottom_corners[i] = new_bottom_corner
|
||||
if debug:
|
||||
bpy.ops.object.empty_add(type="SINGLE_ARROW", location=new_bottom_corner)
|
||||
altered_bottom_distances[i] = distance
|
||||
|
||||
if math.inf in altered_bottom_distances:
|
||||
return bottom_corners, None
|
||||
else:
|
||||
return bottom_corners, altered_bottom_corners
|
||||
|
||||
|
||||
def snap_to_ground_separate_wheels(
|
||||
instance: bpy.types.Object,
|
||||
obj: bpy.types.Object,
|
||||
wheels: typing.List[bpy.types.Object],
|
||||
ground_objects: typing.List[bpy.types.Object],
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
instance_old_matrix_world = copy.deepcopy(instance.matrix_world)
|
||||
|
||||
def get_ray_casted_plane() -> (
|
||||
typing.Tuple[typing.List[mathutils.Vector], typing.Optional[typing.List[mathutils.Vector]]]
|
||||
):
|
||||
bottom_corners = get_wheel_contact_points(wheels, instance, debug)
|
||||
return ray_cast_plane(ground_objects, bottom_corners)
|
||||
|
||||
snap_to_ground_iterate(instance, obj, instance_old_matrix_world, get_ray_casted_plane, debug)
|
||||
|
||||
|
||||
def snap_to_ground_adjust_rotation(
|
||||
instance: bpy.types.Object,
|
||||
obj: bpy.types.Object,
|
||||
ground_objects: typing.List[bpy.types.Object],
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
instance_old_matrix_world = copy.deepcopy(instance.matrix_world)
|
||||
|
||||
def get_ray_casted_plane():
|
||||
# get bounding box corners in world space
|
||||
bbox_corners = [
|
||||
instance.matrix_world @ mathutils.Vector(corner) for corner in obj.bound_box
|
||||
]
|
||||
# I hope Blender never changes this, it's quite difficult to autodetect
|
||||
bottom_corners = [bbox_corners[0], bbox_corners[3], bbox_corners[4], bbox_corners[7]]
|
||||
return ray_cast_plane(ground_objects, bottom_corners)
|
||||
|
||||
snap_to_ground_iterate(instance, obj, instance_old_matrix_world, get_ray_casted_plane, debug)
|
||||
|
||||
|
||||
def snap_to_ground_no_rotation(
|
||||
instance: bpy.types.Object,
|
||||
obj: bpy.types.Object,
|
||||
ground_objects: typing.List[bpy.types.Object],
|
||||
debug: bool = False,
|
||||
) -> None:
|
||||
def get_ray_casted_point(
|
||||
grace_padding: float = 0.1,
|
||||
) -> typing.Tuple[mathutils.Vector, mathutils.Vector]:
|
||||
if obj.data is None:
|
||||
# obj is not 'MESH', it can be 'EMPTY' for example, don't do anything with it
|
||||
return None, None
|
||||
# get lowest point in world space
|
||||
obj_lowest_vertex = min(obj.data.vertices, key=lambda v: (instance.matrix_world @ v.co).z)
|
||||
obj_lowest_point = instance.matrix_world @ obj_lowest_vertex.co
|
||||
altered_highest_point = None
|
||||
altered_highest_point_distance = math.inf
|
||||
|
||||
for ground_object in ground_objects:
|
||||
if debug:
|
||||
logger.debug("Raycast from: " + str(obj_lowest_point))
|
||||
lowest_point_obj_space = ground_object.matrix_world.inverted() @ (
|
||||
obj_lowest_point + mathutils.Vector([0, 0, grace_padding])
|
||||
)
|
||||
lowest_point2_obj_space = ground_object.matrix_world.inverted() @ (
|
||||
obj_lowest_point + mathutils.Vector([0, 0, grace_padding - 1])
|
||||
)
|
||||
direction_obj_space = lowest_point2_obj_space - lowest_point_obj_space
|
||||
try:
|
||||
result, altered_point_obj_space, _, _ = ground_object.ray_cast(
|
||||
lowest_point_obj_space, direction_obj_space
|
||||
)
|
||||
except Exception as e:
|
||||
logger.exception("Uncaught exception while raycasting to the ground")
|
||||
result = None
|
||||
altered_point_obj_space = lowest_point_obj_space
|
||||
|
||||
if not result:
|
||||
continue
|
||||
altered_point = ground_object.matrix_world @ altered_point_obj_space
|
||||
distance = (obj_lowest_point - altered_point).length
|
||||
if distance < altered_highest_point_distance:
|
||||
altered_highest_point = altered_point
|
||||
if debug:
|
||||
bpy.ops.object.empty_add(location=altered_point)
|
||||
altered_highest_point_distance = distance
|
||||
|
||||
if math.isinf(altered_highest_point_distance):
|
||||
return obj_lowest_point, None
|
||||
else:
|
||||
return obj_lowest_point, altered_highest_point
|
||||
|
||||
obj_lowest_point, altered_highest_point = get_ray_casted_point()
|
||||
if altered_highest_point is None:
|
||||
if debug:
|
||||
logger.debug(
|
||||
f"Failed to raycast the highest altered point while estimating position "
|
||||
f"for {obj.name}, instance={instance.name}. Skipping..."
|
||||
)
|
||||
return
|
||||
|
||||
delta_location = altered_highest_point - obj_lowest_point
|
||||
instance.matrix_world = mathutils.Matrix.Translation(delta_location) @ instance.matrix_world
|
||||
@@ -0,0 +1,122 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
# Module containing various utilities and wrappers around bpy to ease work with bpy bezier splines
|
||||
|
||||
import bpy
|
||||
import mathutils
|
||||
import typing
|
||||
|
||||
|
||||
def copy_bezier_point(src: bpy.types.BezierSplinePoint, dst: bpy.types.BezierSplinePoint) -> None:
|
||||
dst.co = src.co
|
||||
dst.handle_left = src.handle_left
|
||||
dst.handle_left_type = src.handle_left_type
|
||||
dst.handle_right = src.handle_right
|
||||
dst.handle_right_type = src.handle_right_type
|
||||
dst.tilt = src.tilt
|
||||
dst.radius = src.radius
|
||||
|
||||
|
||||
def add_bezier_point_to_spline(
|
||||
spline: bpy.types.Spline,
|
||||
position: mathutils.Vector,
|
||||
prepend: bool = False,
|
||||
handle_type: str = 'VECTOR',
|
||||
) -> bpy.types.BezierSplinePoint:
|
||||
assert spline.type == 'BEZIER'
|
||||
spline.bezier_points.add(1)
|
||||
new_point = spline.bezier_points[-1]
|
||||
if prepend:
|
||||
# It is not possible to prepend points, thus we move the data of the other points...
|
||||
# We could also extrude, but that would introduce bpy.ops overhead here.
|
||||
for i in range(len(spline.bezier_points) - 1, 0, -1):
|
||||
copy_bezier_point(spline.bezier_points[i - 1], spline.bezier_points[i])
|
||||
|
||||
new_point = spline.bezier_points[0]
|
||||
|
||||
new_point.co = position
|
||||
new_point.handle_left_type = handle_type
|
||||
new_point.handle_right_type = handle_type
|
||||
return new_point
|
||||
|
||||
|
||||
def remove_bezier_point(
|
||||
curve_obj: bpy.types.Object, spline: bpy.types.Spline, remove_idx: int
|
||||
) -> None:
|
||||
"""Removes bezier point from a spline by changing selection and calling bpy.ops.curve.delete"""
|
||||
bpy.context.view_layer.objects.active = curve_obj
|
||||
bpy.ops.curve.select_all(action='DESELECT')
|
||||
for i, bezier_point in enumerate(spline.bezier_points):
|
||||
bezier_point.select_control_point = i == remove_idx
|
||||
|
||||
if 'FINISHED' not in bpy.ops.curve.delete(type='VERT'):
|
||||
raise RuntimeError(f"Failed to remove bezier point from '{repr(spline)}' on {remove_idx}")
|
||||
|
||||
|
||||
def join_splines(
|
||||
curve: bpy.types.Curve,
|
||||
spline1: bpy.types.Spline,
|
||||
spline2: bpy.types.Spline,
|
||||
reverse: bool = False,
|
||||
prepend: bool = False,
|
||||
) -> bpy.types.Spline:
|
||||
"""Joins splines 'spline1' and 'spline2' into a new spline in 'curve', new spline is returned."""
|
||||
points = reversed(spline2.bezier_points) if reverse else spline2.bezier_points
|
||||
l1 = len(spline1.bezier_points)
|
||||
l2 = len(spline2.bezier_points)
|
||||
new_spline = curve.splines.new(type='BEZIER')
|
||||
new_spline.bezier_points.add(l1 + l2 - 2)
|
||||
dst_start_idx = 0 if prepend else l1 - 1
|
||||
if prepend:
|
||||
# Copy the original point after the prepended point, so it is not lost
|
||||
for i, point in reversed(list(enumerate(spline1.bezier_points[:l1]))):
|
||||
copy_bezier_point(point, new_spline.bezier_points[l2 - 1 + i])
|
||||
else:
|
||||
# Copy the original values before the appended ones
|
||||
for i, point in enumerate(spline1.bezier_points):
|
||||
copy_bezier_point(point, new_spline.bezier_points[i])
|
||||
|
||||
for i, bezier_point in enumerate(points):
|
||||
copy_bezier_point(bezier_point, new_spline.bezier_points[dst_start_idx + i])
|
||||
|
||||
return new_spline
|
||||
|
||||
|
||||
def split_spline(
|
||||
curve: bpy.types.Curve, spline: bpy.types.Spline, split_idx: int
|
||||
) -> typing.Tuple[bpy.types.Spline, bpy.types.Spline]:
|
||||
"""Splits bezier spline 'spline' into two splines inside 'curve' on 'split_idx'
|
||||
|
||||
Point on 'split_idx' will become present on both splines. Original spline has to be removed
|
||||
by caller.
|
||||
"""
|
||||
left = curve.splines.new(type='BEZIER')
|
||||
right = curve.splines.new(type='BEZIER')
|
||||
left.bezier_points.add(split_idx)
|
||||
right.bezier_points.add(len(spline.bezier_points) - split_idx - 1)
|
||||
|
||||
for i in range(len(spline.bezier_points)):
|
||||
if i < split_idx:
|
||||
copy_bezier_point(spline.bezier_points[i], left.bezier_points[i])
|
||||
elif i == split_idx:
|
||||
copy_bezier_point(spline.bezier_points[i], left.bezier_points[i])
|
||||
copy_bezier_point(spline.bezier_points[i], right.bezier_points[i - split_idx])
|
||||
else:
|
||||
copy_bezier_point(spline.bezier_points[i], right.bezier_points[i - split_idx])
|
||||
|
||||
return left, right
|
||||
|
||||
|
||||
def new_bezier_spline(
|
||||
curve_obj: bpy.types.Object, position: mathutils.Vector, handle_type: str
|
||||
) -> typing.Tuple[bpy.types.Spline, bpy.types.BezierSplinePoint]:
|
||||
"""Creates new spline on 'curve_obj', returns new spline and its first bezier point
|
||||
|
||||
Arguments 'position' and 'handle_type' apply to the 0th created point that's in each
|
||||
new created spline in Blender.
|
||||
"""
|
||||
spline = curve_obj.splines.new(type='BEZIER')
|
||||
bezier_point = spline.bezier_points[0]
|
||||
bezier_point.co = position
|
||||
bezier_point.handle_left_type = handle_type
|
||||
bezier_point.handle_right_type = handle_type
|
||||
return spline, bezier_point
|
||||
@@ -0,0 +1,553 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
# original author: Xavier Halloran
|
||||
# https://gitlab.com/Reivax
|
||||
|
||||
"""
|
||||
split_file_reader
|
||||
Copyright (C) 2022 Xavier Halloran, United States
|
||||
|
||||
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 3 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, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import typing
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
# The generator has a need to know file direction to set the pointer correctly.
|
||||
_BACKWARD = -1
|
||||
_STATIONARY = 0
|
||||
_FORWARD = 1
|
||||
|
||||
|
||||
class SplitFileReader(io.RawIOBase):
|
||||
"""Acts file-like for a list of files opened readably in binary mode.
|
||||
|
||||
Provides `readable`, `writable`, `seekable`, `tellable`.
|
||||
Implements `read`, `readinto`, `seek`, `tell`
|
||||
Prohibits `write`, `writelines`, readline`, `readlines`, `truncate`
|
||||
Also implements `open`, `close`, `closed`, `__repr__`, `__iter__` and `__next__`., `__enter__`, and `__exit__`
|
||||
|
||||
This library makes use of multiple file objects. Unlike the io.IOBase specs, this class can make multiple system
|
||||
calls for any given `seek` or `read` call; therefore it does not extend the io.IOBase Abstract Base Class. This
|
||||
class cannot write.
|
||||
|
||||
This class can be used directly with ZipFile or TarFile, as follows:
|
||||
|
||||
with SplitFileReader([zip_files]) as sfr:
|
||||
with zipfile.ZipFile(sfr, mode="r") as zf:
|
||||
|
||||
with SplitFileReader([tar_files]) as sfr:
|
||||
with tarfile.open(fileobj=sfr, mode="r") as tf:
|
||||
|
||||
There is no actual enforcement of integrity of the `files` list, one could swap out names other than the
|
||||
currently open file. The list order is important, and it must be indexable. The entries in `files` do not
|
||||
need to be unique.
|
||||
|
||||
`close` must be called just like any other file, or a single file descriptor may be left open. Making use of the
|
||||
context managed approach will take care of that as well. If str or path-like values are passed, they will be closed
|
||||
automatically; but if file-like objects are passed, they will not be closed, manage those objects externally with
|
||||
their own context managers.
|
||||
|
||||
This class is not thread-safe; no method is idempotent, all of them affect the object state. However, since the
|
||||
underlying files are all read-only, multiple concurrent instances of this class, attached to the same underlying
|
||||
files, is allowed.
|
||||
"""
|
||||
|
||||
def __init__( # noqa: PLR0913
|
||||
self,
|
||||
files: typing.List[typing.Union[str, os.PathLike, typing.Any]],
|
||||
mode: str = "rb",
|
||||
stream_only: bool = False,
|
||||
validate_all_readable: bool = False,
|
||||
iter_size: int = 1,
|
||||
) -> None:
|
||||
"""Creates the file-like object around a series of files. At return, there will be a single open file descriptor,
|
||||
on the first file in the list.
|
||||
|
||||
`files` may be any of os.PathLike, a `str` or any `file-like` object available. If it is a `str` or `PathLike`
|
||||
a new `open()` will be called with that value as a parameter in a context manager. Otherwise, the file-like
|
||||
will have `seek`, `tell`, and `read` called on it directly. Any mix of types is allowed in the list.
|
||||
"""
|
||||
if mode not in ["rb", "br", "r"]:
|
||||
# On Unix, "r" and "rb" are the same. On windows, "r" will alter line endings.
|
||||
raise ValueError(f"mode must be 'rb', was {mode}")
|
||||
if stream_only and validate_all_readable:
|
||||
raise ValueError("`stream_only` and `validate_all_readable` cannot both be set.")
|
||||
# Need to track a list of files, in order, to concat. Must be random-accessible.
|
||||
self._files = files
|
||||
# When using this class as an iterable, or if attached to some sort of streaming output systems, set this to
|
||||
# prevent seeking. tarfile stream reading blocks seek on its own, this is not a requirement for that.
|
||||
self._stream_only = stream_only
|
||||
# Only applicable to using this object as an iterable. On next(), this is the length applied to the read()
|
||||
# function. This can be set at any time between read/__next__ calls.
|
||||
self._iter_size = iter_size
|
||||
|
||||
# index of where in the `files` list to currently process. Starts at -1, to allow the generator to advance
|
||||
# into the first file immediately.
|
||||
self._current_file_desc_idx = 0
|
||||
# Create the generator function to move through the list.
|
||||
self._file_desc_generator = self._generate_next_file(_STATIONARY)
|
||||
# Init the file pointer, and open a true file pointer to an underlying file-like object.
|
||||
self._current_file_desc = next(self._file_desc_generator)
|
||||
|
||||
# Value that `tell()` responds with.
|
||||
self._told = 0
|
||||
|
||||
if validate_all_readable:
|
||||
self.test_all_readable()
|
||||
|
||||
def readable(self) -> bool:
|
||||
return True
|
||||
|
||||
def readall(self) -> bytes:
|
||||
return self._read(-1, read_once=False)
|
||||
|
||||
def read1(self, size: typing.Optional[int] = None) -> typing.AnyStr:
|
||||
"""Read the specified amount, making underlying file boundaries invisible to the caller.
|
||||
|
||||
If the current file pointer has been set to None, indicating an earlier call to `close()`, raises IOError.
|
||||
|
||||
May make multiple system calls, but will only make a single `read` system call in total. May close and open
|
||||
a file pointer and attempt a `seek`.
|
||||
"""
|
||||
if size is None or size < 0:
|
||||
return self._read(-1, read_once=True)
|
||||
else:
|
||||
return self._read(size, read_once=True)
|
||||
|
||||
def read(self, size: typing.Optional[int] = None) -> typing.AnyStr:
|
||||
"""
|
||||
Read the specified amount, making underlying file boundaries invisible to the caller.
|
||||
|
||||
If the current file pointer has been set to None, indicating an earlier call to `close()`, raises IOError.
|
||||
|
||||
May make multiple system calls, but only one to each File Descriptor.
|
||||
"""
|
||||
if size is None or size < 0:
|
||||
return self.readall()
|
||||
else:
|
||||
return self._read(size)
|
||||
|
||||
def _read(self, target_size: int, read_once=False):
|
||||
if not self._current_file_desc:
|
||||
raise OSError("SplitFileReader is closed.")
|
||||
if target_size >= 0:
|
||||
# file.read() may return zero-length data, even if only 1 byte is requested and there is actually more data.
|
||||
# This is because the end of a single file may have been reached, and more files need to be opened.
|
||||
ret = self._current_file_desc.read(target_size)
|
||||
remaining = target_size - len(ret)
|
||||
# Reads less than the total size are indicative that the end of a file has been reached, and the next one
|
||||
# should be cycled in.
|
||||
while remaining > 0:
|
||||
if not self._safe_advance_file_desc(_FORWARD):
|
||||
# More requested to be read, but there are no more files to open.
|
||||
break
|
||||
if read_once:
|
||||
# read1 calls only do a single filestream read, but file pointers still need to advance.
|
||||
break
|
||||
read = self._current_file_desc.read(remaining)
|
||||
remaining -= len(read)
|
||||
ret += read
|
||||
self._told += len(ret)
|
||||
else:
|
||||
# Read -1/None behaves differently.
|
||||
ret = self._current_file_desc.read(target_size)
|
||||
while self._safe_advance_file_desc(_FORWARD):
|
||||
read = self._current_file_desc.read(target_size)
|
||||
ret += read
|
||||
self._told += len(ret)
|
||||
return ret
|
||||
|
||||
def readinto(self, buffer: bytearray) -> typing.Optional[int]:
|
||||
"""This is the copy/paste implementation of `io.FileIO.readinto()`"""
|
||||
data = self._read(len(buffer), read_once=False)
|
||||
n = len(data)
|
||||
buffer[:n] = data
|
||||
return n
|
||||
|
||||
def readinto1(self, buffer: bytearray) -> typing.Optional[int]:
|
||||
"""This is the copy/paste implementation of `io.FileIO.readinto()`"""
|
||||
data = self._read(len(buffer), read_once=True)
|
||||
n = len(data)
|
||||
buffer[:n] = data
|
||||
return n
|
||||
|
||||
def seekable(self) -> bool:
|
||||
return not self._stream_only
|
||||
|
||||
def seek(self, offset: int, whence: int = 0) -> int: # noqa: PLR0912
|
||||
"""Move the file pointer along a file. May advance over zero or more actual files.
|
||||
|
||||
POSIX allows to seek before or after the end of a file, even in read-only mode. `seek()` before the start of
|
||||
the first file will fail; `seek()` beyond the end of the last file is fine.
|
||||
|
||||
If the current file pointer has been set to None, indicating an earlier call to `close()`, raises IOError.
|
||||
|
||||
If the net action of a seek() will move nowhere, no seek call is passed to the underlying file descriptor.
|
||||
Some tools, like `zipfile.ZipFile` after a `read()`, will cause a `seek()` to the end of the read location.
|
||||
This is a redundant call, with no net movement, but can make network disk based seeks very expensive, especially
|
||||
for large numbers of large files, so does nothing here.
|
||||
|
||||
`os.SEEK_SET`, `os.SEEK_CUR`, and `os.SEEK_END` are whence 0, 1, and 2, respectively. `os.SEEK_HOLE` and
|
||||
`os.SEEK_DATA` are not supported.
|
||||
"""
|
||||
if self._stream_only:
|
||||
raise OSError("Seek performed on a streaming file.")
|
||||
|
||||
if not self._current_file_desc:
|
||||
raise OSError("SplitFileReader is closed.")
|
||||
|
||||
if whence == 0:
|
||||
# From the start
|
||||
# Do not always immediately `_seek_to_head`, and then scan forward the offset. There are many libraries
|
||||
# out there that make excessive use of `seek(x, 0)` when either `seek(x, 1) or even `seek(0, 1)` would be
|
||||
# more reasonable. zipfile is one such library, making use of one `seek(x, 2)`, and then exclusively
|
||||
# `seek(x, 0)`
|
||||
how_far_to_go = -(self._told - offset)
|
||||
if offset == 0:
|
||||
# A `seek(0, 0)` is just a shortcut to `_seek_to_head`
|
||||
self._seek_to_head()
|
||||
elif how_far_to_go == 0:
|
||||
pass
|
||||
elif how_far_to_go > 0:
|
||||
self._scan_forward(how_far_to_go)
|
||||
elif how_far_to_go < 0:
|
||||
self._scan_backward(how_far_to_go)
|
||||
|
||||
elif whence == 1:
|
||||
# From the current position
|
||||
how_far_to_go = offset
|
||||
if how_far_to_go > 0:
|
||||
self._scan_forward(how_far_to_go)
|
||||
elif how_far_to_go < 0:
|
||||
self._scan_backward(how_far_to_go)
|
||||
|
||||
elif whence == 2: # noqa: PLR2004
|
||||
# From the end.
|
||||
how_far_to_go = offset
|
||||
# Without a-priori knowledge of the total file sizes, we can't really calculate the offset to go.
|
||||
# So, zip all thw way to the end, then navigate as appropriate.
|
||||
self._seek_to_tail()
|
||||
if how_far_to_go < 0:
|
||||
self._scan_backward(offset)
|
||||
elif how_far_to_go > 0:
|
||||
self._scan_forward(how_far_to_go)
|
||||
|
||||
else:
|
||||
raise IndexError("Whence must be 0, 1, or 2")
|
||||
return self.tell()
|
||||
|
||||
def _scan_forward(self, offset: int) -> None:
|
||||
# Forward seeking is tricky; it is possible to seek beyond the end of a file, even in read-only mode. So seek
|
||||
# immediately to the end of the current file descriptor, and check the distance moved. If moved too far, back up
|
||||
# to the correct position. If moved not far enough, go to the next file descriptor, and try again.
|
||||
|
||||
remaining = offset
|
||||
while remaining > 0:
|
||||
start_pos = self._current_file_desc.tell()
|
||||
# Go all the way to the _end_ of file explicitly, because it is allowed to seek() beyond that and get
|
||||
# misleading tell() information.
|
||||
self._current_file_desc.seek(0, 2)
|
||||
# Track the actual net movement.
|
||||
end_pos = self._current_file_desc.tell()
|
||||
moved = end_pos - start_pos
|
||||
# Did the seek to the end of the file go too far?
|
||||
if moved > remaining:
|
||||
# Overshot the seek forward. Move backward again.
|
||||
corrective_move = remaining - moved
|
||||
self._current_file_desc.seek(corrective_move, 1)
|
||||
end_pos = self._current_file_desc.tell()
|
||||
moved = end_pos - start_pos
|
||||
# It moved backwards, but `moved` is still positive, because it holds the net movement in this loop.
|
||||
self._told += moved
|
||||
remaining = 0
|
||||
# Did the seek to the end of the file not go far enough?
|
||||
else:
|
||||
remaining -= moved
|
||||
self._told += moved
|
||||
# The generator for advancing file descriptors will ensure the pointer is at the start of the file part
|
||||
if not self._safe_advance_file_desc(_FORWARD):
|
||||
break
|
||||
|
||||
def _scan_backward(self, offset: int) -> None:
|
||||
# The backward scan is implemented by moving the file pointer all the way to the front of the current file
|
||||
# descriptor, and checking if the movement has gone far enough. If more to go, move to the previous file,
|
||||
# and repeat. If too far, seek forward again to the correct position.
|
||||
|
||||
# This is a negative value.
|
||||
remaining = offset
|
||||
# Remaining is a negative amount, because the scan is going backwards.
|
||||
while remaining < 0:
|
||||
start_pos = self._current_file_desc.tell()
|
||||
self._current_file_desc.seek(0, 0)
|
||||
end_pos = self._current_file_desc.tell()
|
||||
# This is a negative value
|
||||
moved = end_pos - start_pos
|
||||
if moved < remaining:
|
||||
# Overshot the backup. Move forward again.
|
||||
corrective_seek = -(moved - remaining)
|
||||
self._current_file_desc.seek(corrective_seek, 1)
|
||||
end_pos = self._current_file_desc.tell()
|
||||
moved = end_pos - start_pos
|
||||
# It moved forwards, but `moved` is still negative, because it holds the net movement in this loop.
|
||||
self._told += moved
|
||||
remaining = 0
|
||||
else:
|
||||
# It moved backwards, and `moved` is negative.
|
||||
remaining -= moved
|
||||
self._told += moved
|
||||
# The generator for advancing file descriptors will ensure the pointer is at the tail of the file part
|
||||
if not self._safe_advance_file_desc(_BACKWARD):
|
||||
break
|
||||
|
||||
def _seek_to_head(self) -> None:
|
||||
"""Set the position to zero, tell to zero, and at the head of the first file.
|
||||
|
||||
No need to traverse the list and move through the intermediaries, as the zero position is always known.
|
||||
"""
|
||||
self._current_file_desc_idx = 0
|
||||
self._current_file_desc = self._file_desc_generator.send(_STATIONARY)
|
||||
self._current_file_desc.seek(0, 0)
|
||||
self._told = 0
|
||||
|
||||
def _seek_to_tail(self) -> None:
|
||||
# Unlike a true file-like object, zipping to the end will omit some information. Must open each file, in order,
|
||||
# and skip to their end, to count the individual file sizes, all the way to the end of that list.
|
||||
|
||||
# This may be slow if the disk is slow.
|
||||
|
||||
# This process could be accelerated by doing a one-time pass and counting the file sizes directly, but this may
|
||||
# not be desirable. In practice, `seek(x, 2)` is rare, used by ZipFile, and even then, just at the start.
|
||||
|
||||
while True:
|
||||
# Save starting position, might not be zero.
|
||||
start = self._current_file_desc.tell()
|
||||
# The generator for advancing file descriptors will ensure the pointer is at the start of the file part.
|
||||
# Go to the end.
|
||||
self._current_file_desc.seek(0, 2)
|
||||
# Check position
|
||||
end = self._current_file_desc.tell()
|
||||
# Keep count of the file sizes.
|
||||
self._told += end - start
|
||||
# Don't roll off the last one.
|
||||
if not self._safe_advance_file_desc(_FORWARD):
|
||||
break
|
||||
|
||||
def _safe_advance_file_desc(self, direction: int) -> bool:
|
||||
"""Advance the file descriptor, but do not advance off the end, either way.
|
||||
|
||||
Return true if advanced, False if stalled.
|
||||
"""
|
||||
if (direction == _FORWARD and self._current_file_desc_idx + 1 >= len(self._files)) or (
|
||||
direction == _BACKWARD and self._current_file_desc_idx <= 0
|
||||
):
|
||||
return False
|
||||
else:
|
||||
self._advance_file_desc(direction)
|
||||
return True
|
||||
|
||||
def _advance_file_desc(self, direction: int) -> None:
|
||||
"""Move the file actual file descriptor around, to allow this class to continue to act as a single file-like reader
|
||||
|
||||
Fix the file pointer position to either the start or end of the underlying real file, depending on the
|
||||
direction of movement.
|
||||
To close, call `close()` directly on the generator.
|
||||
"""
|
||||
try:
|
||||
self._current_file_desc = self._file_desc_generator.send(direction)
|
||||
# Force the file pointer to the front ot the file. This should be a given.
|
||||
if direction == _BACKWARD:
|
||||
self._current_file_desc.seek(0, 2)
|
||||
# If going backwards, need to make sure that the rollover put the file pointer at the _end_ of the file,
|
||||
# not the start.
|
||||
elif direction == _FORWARD:
|
||||
self._current_file_desc.seek(0, 0)
|
||||
except StopIteration:
|
||||
self._current_file_desc = None
|
||||
|
||||
def _generate_next_file(
|
||||
self, direction: int = _FORWARD
|
||||
) -> typing.Generator[typing.BinaryIO, int, None]:
|
||||
# Only call from `_advance_file_desc`, or, `_seek_to_head` for shortcut operation.
|
||||
# Only create in `__init__`
|
||||
# `send()` the direction of travel to this generator. Backward -1, Stationary 0, Forward 1, or Closing 2.
|
||||
# This can raise a `FileNotFoundError`, and as such may propagate up through `seek` or `read`
|
||||
# Using this generator allows the context manager to open and close the file pointers automatically when required.
|
||||
|
||||
while True:
|
||||
self._current_file_desc_idx += direction
|
||||
if self._current_file_desc_idx < 0 or self._current_file_desc_idx >= len(self._files):
|
||||
logger.info("Moved off end of files list. No current fd.")
|
||||
direction = yield None
|
||||
else:
|
||||
file = self._files[self._current_file_desc_idx]
|
||||
# This logic is boosted right out of the zipfile.ZipFile.__init__, which takes either a filepath,
|
||||
# path-like, or file-like for its `file` argument.
|
||||
# Check if we were passed a path-like object
|
||||
if isinstance(file, os.PathLike):
|
||||
file = os.fspath(file)
|
||||
if isinstance(file, str):
|
||||
# No, it's a filename
|
||||
self._filePassed = 0
|
||||
self.filename = file
|
||||
with open(file, "rb") as self._current_file_desc:
|
||||
logger.info(f"Opening new fd on {file}.")
|
||||
direction = yield self._current_file_desc
|
||||
logger.info(f"Closing fd on {file}.")
|
||||
else:
|
||||
# No, its (probably) already file-like.
|
||||
self._current_file_desc = file
|
||||
logger.info(f"Passthrough file-like yielding {file}.")
|
||||
direction = yield self._current_file_desc
|
||||
logger.info(f"Passthrough file-like done {file}.")
|
||||
|
||||
def test_all_readable(self):
|
||||
"""Validate every file in the `files` parameter at `__init__` is actually readable.
|
||||
|
||||
Seeks to beginning of file list, then opens each file in read-only mode, seeks to the end of each of them, then
|
||||
back to current position. Will raise `IOError` or `FileNotFoundError` or other, appropriate error for files
|
||||
that cannot be opened for reading and seeking.
|
||||
"""
|
||||
if self._stream_only:
|
||||
raise OSError("Seek performed on a streaming file.")
|
||||
|
||||
saved_tell = self._told
|
||||
self._seek_to_head()
|
||||
self._seek_to_tail()
|
||||
self.seek(saved_tell, 0)
|
||||
|
||||
def tellable(self) -> bool:
|
||||
return True
|
||||
|
||||
def tell(self) -> int:
|
||||
"""Logically identical to tell() on any other file-like object.
|
||||
|
||||
Returns the offset as a sum of all previous file sizes, plus current file tell()
|
||||
"""
|
||||
if self.closed:
|
||||
raise ValueError("tell on a closed file")
|
||||
return self._told
|
||||
|
||||
@classmethod
|
||||
def open(cls, *args, **kwargs):
|
||||
"""Wraps the init constructor"""
|
||||
return cls(*args, **kwargs)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Closes the existing file descriptor, sets the current file descriptor to None, and disables the ability to seek or read"""
|
||||
logger.info("Closing last file descriptor.")
|
||||
self._file_desc_generator.close()
|
||||
self._current_file_desc = None
|
||||
|
||||
@property
|
||||
def closed(self) -> bool:
|
||||
"""Checks the status of the underlying streams.
|
||||
|
||||
If there is no open File Descriptor attached to any file in the
|
||||
list, then this object is closed.
|
||||
"""
|
||||
return self._current_file_desc is None
|
||||
|
||||
def __iter__(self) -> typing.Iterable:
|
||||
# Iterable operation implies streaming mode, logically, although there is not a technical reason why this
|
||||
# module could not permit a seek() between calls to __iter__.
|
||||
self._stream_only = True
|
||||
return self
|
||||
|
||||
def set_iter_size(self, iter_size: int = 1) -> None:
|
||||
self._iter_size = iter_size
|
||||
|
||||
def __next__(self) -> typing.AnyStr:
|
||||
if not self._stream_only:
|
||||
raise OSError("Not in streaming mode.")
|
||||
read = self.read(self._iter_size)
|
||||
if not read:
|
||||
raise StopIteration
|
||||
return read
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
def __repr__(self):
|
||||
from json import dumps
|
||||
|
||||
try:
|
||||
# Might be closed, or might be an empty list.
|
||||
cfile = dumps(self._files[self._current_file_desc_idx])
|
||||
ctell = self._current_file_desc.tell()
|
||||
cdesc = self._current_file_desc.fileno()
|
||||
except IndexError:
|
||||
cfile = None
|
||||
ctell = 0
|
||||
cdesc = 0
|
||||
|
||||
return "<{cls}, {id}: Tell: {tell}, File Desc: {fdesc}, File Name: {fname}, File Tell: {ftell}>".format(
|
||||
cls=self.__class__.__name__,
|
||||
id=hex(id(self)),
|
||||
tell=self.tell(),
|
||||
fdesc=cdesc,
|
||||
fname=cfile,
|
||||
ftell=ctell,
|
||||
)
|
||||
|
||||
# The following methods exist to support the io.RawIO behavior, and mostly disables their use.
|
||||
|
||||
# This permits the SplitFileReader to work within a context that expects the io.IOBase capabilities,
|
||||
# such as TextIOWrapper
|
||||
|
||||
def writable(self) -> bool:
|
||||
return False
|
||||
|
||||
def write(self, b: typing.Union[bytes, bytearray]) -> typing.Optional[int]:
|
||||
# No writing allowed with this class.
|
||||
raise io.UnsupportedOperation(f"{self.__class__.__name__} cannot write.")
|
||||
|
||||
def writelines(self, lines: typing.Iterable[typing.Union[bytes, bytearray]]) -> None:
|
||||
# No writing allowed with this class.
|
||||
raise io.UnsupportedOperation(f"{self.__class__.__name__} cannot write.")
|
||||
|
||||
def truncate(self, size: typing.Optional[int] = None) -> int:
|
||||
# No writing allowed with this class.
|
||||
raise io.UnsupportedOperation(f"{self.__class__.__name__} cannot truncate.")
|
||||
|
||||
def isatty(self) -> bool:
|
||||
# Definitely cannot be a TTY.
|
||||
return False
|
||||
|
||||
def flush(self) -> None:
|
||||
# No writing allowed with this class.
|
||||
return
|
||||
|
||||
def fileno(self) -> int:
|
||||
# It is usually used by os.stat to get a filesize, which is meaningless here.
|
||||
raise io.UnsupportedOperation(f"{self.__class__.__name__} should not return a fileno")
|
||||
|
||||
def readline(self, size: int = 0) -> bytes:
|
||||
raise io.UnsupportedOperation(
|
||||
f"{self.__class__.__name__} cannot decode text; use io.TextIOWrapper."
|
||||
)
|
||||
|
||||
def readlines(self, hint: int = 0) -> typing.List[bytes]:
|
||||
raise io.UnsupportedOperation(
|
||||
f"{self.__class__.__name__} cannot decode text; use io.TextIOWrapper."
|
||||
)
|
||||
@@ -0,0 +1,435 @@
|
||||
#!/usr/bin/python3
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import bpy
|
||||
import addon_utils
|
||||
import datetime
|
||||
import functools
|
||||
import hashlib
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import platform
|
||||
import socket
|
||||
import traceback
|
||||
import typing
|
||||
import uuid
|
||||
import enum
|
||||
import threading
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
API_VERSION = 2
|
||||
|
||||
# useful for debugging
|
||||
PRINT_MESSAGES = False
|
||||
|
||||
BOOTSTRAPPED = False
|
||||
BOOTSTRAP_LOCK = threading.Lock()
|
||||
SESSION = None
|
||||
MACHINE = None
|
||||
MESSAGES = []
|
||||
|
||||
|
||||
class VerboseLevel(enum.IntEnum):
|
||||
"""Determines what messages are printed to console when logging
|
||||
Lower number -> Lower restrictions (DEBUG includes all categories)
|
||||
"""
|
||||
|
||||
DEBUG = 0
|
||||
INFO = 1
|
||||
WARNING = 2
|
||||
ERROR = 3
|
||||
NONE = 4
|
||||
|
||||
|
||||
VERBOSE_LEVEL = getattr(VerboseLevel, os.environ.get("PQ_TELEMETRY", "WARNING"))
|
||||
|
||||
|
||||
class Session:
|
||||
def __init__(self):
|
||||
self._uuid = uuid.uuid4().hex
|
||||
self.telemetry_api_version = API_VERSION
|
||||
self.telemetry_implementation_path = os.path.abspath(__file__)
|
||||
self.start_timestamp = datetime.datetime.utcnow().isoformat()
|
||||
|
||||
|
||||
class Machine:
|
||||
def __init__(self):
|
||||
def safe_get(fn, default="N/A"):
|
||||
"""Run given functor to retrieve data. Catch all exceptions and provide
|
||||
a default value in case of failure.
|
||||
"""
|
||||
try:
|
||||
return fn()
|
||||
except:
|
||||
return default
|
||||
|
||||
self._uuid = uuid.UUID(int=uuid.getnode()).hex
|
||||
|
||||
self.hardware = {
|
||||
"architecture": platform.machine(),
|
||||
"processor": platform.processor(),
|
||||
"cpu_count": multiprocessing.cpu_count(),
|
||||
}
|
||||
|
||||
self.operating_system = (platform.system(), platform.release(), platform.version())
|
||||
|
||||
self.networking = {
|
||||
"hostname": safe_get(lambda: socket.gethostname()),
|
||||
"ip-address": safe_get(lambda: socket.gethostbyname(socket.gethostname())),
|
||||
"has-ipv6": socket.has_ipv6,
|
||||
}
|
||||
|
||||
self.python = {
|
||||
"version": platform.python_version(),
|
||||
"build": platform.python_build(),
|
||||
}
|
||||
|
||||
self.blender = {
|
||||
"version": bpy.app.version_string,
|
||||
"path": bpy.app.binary_path,
|
||||
"window_size": Machine.get_blender_window_size(),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def get_blender_window_size():
|
||||
width = -1
|
||||
height = -1
|
||||
|
||||
try:
|
||||
width = int(bpy.context.window_manager.windows[0].width)
|
||||
height = int(bpy.context.window_manager.windows[0].height)
|
||||
except:
|
||||
pass
|
||||
|
||||
return width, height
|
||||
|
||||
@staticmethod
|
||||
def get_blender_addons() -> (
|
||||
typing.Dict[str, typing.Union[typing.List[str], typing.Dict[str, typing.Any]]]
|
||||
):
|
||||
addon_utils_modules: typing.Dict[str, typing.Dict[str, typing.Any]] = {}
|
||||
for module in addon_utils.modules():
|
||||
try:
|
||||
name = module.__name__
|
||||
assert name not in addon_utils_modules
|
||||
bl_info = getattr(module, "bl_info", {})
|
||||
path = str(module.__file__)
|
||||
addon_utils_modules[name] = {"path": path, "bl_info": bl_info}
|
||||
|
||||
except Exception as e:
|
||||
addon_utils_modules[uuid.uuid4().hex] = {
|
||||
"error": f"Uncaught Exception while querying modules: {e}"
|
||||
}
|
||||
|
||||
loaded_modules = []
|
||||
missing_modules = []
|
||||
|
||||
for addon in bpy.context.preferences.addons:
|
||||
loaded_default, loaded_state = addon_utils.check(addon.module)
|
||||
if not loaded_default:
|
||||
continue
|
||||
|
||||
if loaded_state:
|
||||
loaded_modules.append(str(addon.module))
|
||||
else:
|
||||
missing_modules.append(str(addon.module))
|
||||
|
||||
return {
|
||||
"loaded": loaded_modules,
|
||||
"missing": missing_modules,
|
||||
"addon_utils_modules": addon_utils_modules,
|
||||
}
|
||||
|
||||
|
||||
class MessageType:
|
||||
SESSION_STARTED = "session_started"
|
||||
MACHINE_REGISTERED = "machine_registered"
|
||||
# this is used by polygoniq addons to report version, etc...
|
||||
ADDON_REPORTED = "addon_reported"
|
||||
# this reports all registered addons, polygoniq or other vendors
|
||||
ALL_ADDONS_REPORTED = "all_addons_reported"
|
||||
UNCAUGHT_EXCEPTION = "uncaught_exception"
|
||||
WARNING_MESSAGE = "warning_message"
|
||||
ERROR_MESSAGE = "error_message"
|
||||
DEBUG_MESSAGE = "debug_message"
|
||||
|
||||
|
||||
class Message:
|
||||
def __init__(
|
||||
self,
|
||||
type: str,
|
||||
data: typing.Any = None,
|
||||
text: typing.Optional[str] = None,
|
||||
product: str = "unknown",
|
||||
):
|
||||
self._session_uuid: str = "unknown"
|
||||
|
||||
self._timestamp = datetime.datetime.utcnow().isoformat()
|
||||
self._type = type
|
||||
self.data: typing.Any = None
|
||||
if text is not None:
|
||||
assert data is None
|
||||
self.data = {"text": text}
|
||||
else:
|
||||
self.data = data
|
||||
self.product = product
|
||||
|
||||
|
||||
class PrivateWrapper:
|
||||
"""Used to wrap private data such as object names in a way that can be recovered
|
||||
locally but is hidden when telemetry is sent remotely.
|
||||
|
||||
This allows more information to be used in local debugging without leaking
|
||||
users scene.
|
||||
"""
|
||||
|
||||
def __init__(self, value: str):
|
||||
self.value = value
|
||||
|
||||
@property
|
||||
def private_value(self):
|
||||
return "private:" + hashlib.sha256(self.value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
class TelemetryJSONEncoder(json.JSONEncoder):
|
||||
def default(self, obj: typing.Any) -> typing.Any:
|
||||
if isinstance(obj, Machine):
|
||||
altered_dict = obj.__dict__.copy()
|
||||
altered_dict["__class__"] = "telemetry.Machine"
|
||||
return altered_dict
|
||||
|
||||
elif isinstance(obj, Session):
|
||||
altered_dict = obj.__dict__.copy()
|
||||
altered_dict["__class__"] = "telemetry.Session"
|
||||
return altered_dict
|
||||
|
||||
elif isinstance(obj, Message):
|
||||
altered_dict = obj.__dict__.copy()
|
||||
altered_dict["__class__"] = "telemetry.Message"
|
||||
return altered_dict
|
||||
|
||||
elif isinstance(obj, PrivateWrapper):
|
||||
return obj.value
|
||||
|
||||
return json.JSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
class RemoteTelemetryJSONEncoder(TelemetryJSONEncoder):
|
||||
def default(self, obj: typing.Any) -> typing.Any:
|
||||
if isinstance(obj, PrivateWrapper):
|
||||
return obj.private_value
|
||||
|
||||
return TelemetryJSONEncoder.default(self, obj)
|
||||
|
||||
|
||||
def _log(msg: Message) -> None:
|
||||
global SESSION
|
||||
global MESSAGES
|
||||
global PRINT_MESSAGES
|
||||
|
||||
if SESSION is not None:
|
||||
msg._session_uuid = SESSION._uuid
|
||||
|
||||
MESSAGES.append(msg)
|
||||
if PRINT_MESSAGES:
|
||||
print(json.dumps(msg, indent=4, sort_keys=True, cls=TelemetryJSONEncoder))
|
||||
|
||||
|
||||
def log_installed_addons() -> None:
|
||||
global MACHINE
|
||||
assert MACHINE is not None, "logging before telemetry has been bootstrapped!"
|
||||
|
||||
_log(
|
||||
Message(MessageType.ALL_ADDONS_REPORTED, data=Machine.get_blender_addons(), product="polib")
|
||||
)
|
||||
|
||||
|
||||
def bootstrap_telemetry():
|
||||
global BOOTSTRAPPED
|
||||
global BOOTSTRAP_LOCK
|
||||
global MACHINE
|
||||
global SESSION
|
||||
|
||||
# it is very unlikely but 2 addons might concurrently bootstrap telemetry which
|
||||
# would result in multiple machine definitions and overwrites
|
||||
with BOOTSTRAP_LOCK:
|
||||
if BOOTSTRAPPED:
|
||||
return
|
||||
# due to reloading of modules this can happen multiple times!
|
||||
# raise RuntimeError("Telemetry already bootstrapped!")
|
||||
|
||||
SESSION = Session()
|
||||
_log(Message(MessageType.SESSION_STARTED, data=SESSION, product="polib"))
|
||||
|
||||
MACHINE = Machine()
|
||||
_log(Message(MessageType.MACHINE_REGISTERED, data=MACHINE, product="polib"))
|
||||
|
||||
# wait 5 seconds to give all addons time to register
|
||||
bpy.app.timers.register(lambda: log_installed_addons(), first_interval=5, persistent=True)
|
||||
|
||||
BOOTSTRAPPED = True
|
||||
|
||||
|
||||
class TelemetryWrapper:
|
||||
def __init__(self, product: str):
|
||||
self.product = product
|
||||
self.PrivateWrapper = PrivateWrapper
|
||||
|
||||
def log(self, msg: Message) -> None:
|
||||
_log(msg)
|
||||
|
||||
def dump(self) -> str:
|
||||
global MESSAGES
|
||||
return json.dumps(MESSAGES, indent=4, sort_keys=True, cls=TelemetryJSONEncoder)
|
||||
|
||||
def report_addon(self, bl_info, init_path: str) -> None:
|
||||
data = {}
|
||||
data["__init__path"] = os.path.abspath(init_path)
|
||||
data["name"] = bl_info["name"]
|
||||
data["version"] = bl_info["version"]
|
||||
self.log(Message(MessageType.ADDON_REPORTED, data=data, product=self.product))
|
||||
|
||||
def log_exception(self, e: Exception) -> None:
|
||||
"""Deprecated!
|
||||
|
||||
Use the python logging module (logger.exception) instead..
|
||||
"""
|
||||
self.log(
|
||||
Message(
|
||||
MessageType.UNCAUGHT_EXCEPTION,
|
||||
data=traceback.format_exception(type(e), e, e.__traceback__),
|
||||
product=self.product,
|
||||
)
|
||||
)
|
||||
|
||||
def exception(self, f):
|
||||
"""A decorator that wraps the passed in function and logs
|
||||
exceptions in telemetry should they occur
|
||||
"""
|
||||
|
||||
@functools.wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
try:
|
||||
return f(*args, **kwargs)
|
||||
except Exception as e:
|
||||
logger.exception(f"Uncaught exception raised in {f}")
|
||||
raise e
|
||||
|
||||
return wrapped
|
||||
|
||||
def log_warning(self, message: str) -> None:
|
||||
"""Deprecated!
|
||||
|
||||
Use the python logging module (logger.warning) instead..
|
||||
"""
|
||||
|
||||
global VERBOSE_LEVEL
|
||||
|
||||
self.log(
|
||||
Message(
|
||||
MessageType.WARNING_MESSAGE,
|
||||
data=[message] + traceback.extract_stack().format(),
|
||||
product=self.product,
|
||||
)
|
||||
)
|
||||
if VERBOSE_LEVEL <= VerboseLevel.WARNING:
|
||||
print(f"WARNING[{self.product}]: {message}")
|
||||
|
||||
def log_debug(self, message: str) -> None:
|
||||
"""Deprecated!
|
||||
|
||||
Use the python logging module (logger.debug) instead..
|
||||
"""
|
||||
global VERBOSE_LEVEL
|
||||
|
||||
self.log(
|
||||
Message(
|
||||
MessageType.DEBUG_MESSAGE,
|
||||
data=[message] + traceback.extract_stack().format(),
|
||||
product=self.product,
|
||||
)
|
||||
)
|
||||
if VERBOSE_LEVEL <= VerboseLevel.DEBUG:
|
||||
print(f"DEBUG[{self.product}]: {message}")
|
||||
|
||||
def log_error(self, message: str) -> None:
|
||||
"""Deprecated!
|
||||
|
||||
Use the python logging module (logger.error) instead..
|
||||
"""
|
||||
global VERBOSE_LEVEL
|
||||
|
||||
self.log(
|
||||
Message(
|
||||
MessageType.ERROR_MESSAGE,
|
||||
data=[message] + traceback.extract_stack().format(),
|
||||
product=self.product,
|
||||
)
|
||||
)
|
||||
if VERBOSE_LEVEL <= VerboseLevel.ERROR:
|
||||
print(f"ERROR[{self.product}]: {message}")
|
||||
|
||||
def wrap_blender_class(self, cls_):
|
||||
if hasattr(cls_, "__init__"):
|
||||
cls_.__init__ = self.exception(cls_.__init__)
|
||||
|
||||
# we have to use these wrappers because bpy doesn't accept decorators for some reason
|
||||
# shows up as "ValueError: expected Operator, ... class "draw" function to have 2 args, found 0"
|
||||
def draw_wrapper(self_, context):
|
||||
try:
|
||||
return self_._original_draw(context)
|
||||
except Exception as e:
|
||||
logger.exception(f"Uncaught exception raised in {cls_}.draw")
|
||||
raise e
|
||||
|
||||
def invoke_wrapper(self_, context, event):
|
||||
try:
|
||||
return self_._original_invoke(context, event)
|
||||
except Exception as e:
|
||||
logger.exception(f"Uncaught exception raised in {cls_}.invoke")
|
||||
raise e
|
||||
|
||||
def execute_wrapper(self_, context):
|
||||
try:
|
||||
return self_._original_execute(context)
|
||||
except Exception as e:
|
||||
logger.exception(f"Uncaught exception raised in {cls_}.execute")
|
||||
raise e
|
||||
|
||||
if hasattr(cls_, "draw"):
|
||||
if not hasattr(cls_, "_original_draw"):
|
||||
cls_._original_draw = cls_.draw
|
||||
cls_.draw = draw_wrapper
|
||||
|
||||
if hasattr(cls_, "invoke"):
|
||||
if not hasattr(cls_, "_original_invoke"):
|
||||
cls_._original_invoke = cls_.invoke
|
||||
cls_.invoke = invoke_wrapper
|
||||
|
||||
if hasattr(cls_, "execute"):
|
||||
if not hasattr(cls_, "_original_execute"):
|
||||
cls_._original_execute = cls_.execute
|
||||
cls_.execute = execute_wrapper
|
||||
|
||||
def Message(self, type: str, data: typing.Any = None, text: typing.Optional[str] = None):
|
||||
return Message(type, data, text, self.product)
|
||||
|
||||
|
||||
RETURNED_TELEMETRY_CLASSES: typing.Dict[str, TelemetryWrapper] = {}
|
||||
|
||||
|
||||
def get_telemetry(product: str) -> TelemetryWrapper:
|
||||
global RETURNED_TELEMETRY_CLASSES
|
||||
|
||||
if product in RETURNED_TELEMETRY_CLASSES:
|
||||
return RETURNED_TELEMETRY_CLASSES[product]
|
||||
else:
|
||||
RETURNED_TELEMETRY_CLASSES[product] = TelemetryWrapper(product)
|
||||
return RETURNED_TELEMETRY_CLASSES[product]
|
||||
|
||||
|
||||
__all__ = ["API_VERSION", "bootstrap_telemetry", "get_telemetry"]
|
||||
@@ -0,0 +1,344 @@
|
||||
#!/usr/bin/python3
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import bpy
|
||||
import addon_utils
|
||||
import sys
|
||||
import typing
|
||||
import re
|
||||
import functools
|
||||
import textwrap
|
||||
import os
|
||||
from . import utils_bpy
|
||||
from . import preview_manager_bpy
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
# Global icon manager for polib icons, it NEEDS to be CLEARED from each addon module separately
|
||||
# as we cannot detect from inside of polib, whether it is in use or not.
|
||||
# This means the preview manager can be cleared even if it is already used, but the icons will
|
||||
# be reloaded on demand on the next use.
|
||||
ICON_DIR_NAME = "icons"
|
||||
icon_manager = preview_manager_bpy.PreviewManager()
|
||||
icon_manager.add_preview_path(os.path.join(os.path.dirname(__file__), ICON_DIR_NAME))
|
||||
|
||||
|
||||
class SocialMediaURL:
|
||||
DISCORD = "https://polygoniq.com/discord/"
|
||||
FACEBOOK = "https://www.facebook.com/polygoniq/"
|
||||
INSTAGRAM = "https://www.instagram.com/polygoniq.xyz/"
|
||||
BLENDERMARKET = "https://blendermarket.com/creators/polygoniq?ref=673"
|
||||
WEBPAGE = "https://polygoniq.com/"
|
||||
GUMROAD = "https://gumroad.com/polygoniq"
|
||||
|
||||
|
||||
def get_asset_pack_icon_parameters(
|
||||
icon_id: typing.Optional[int], bpy_icon_name: str
|
||||
) -> typing.Dict:
|
||||
"""Returns dict of parameters that can be expanded in UILayout.label()
|
||||
|
||||
Uses our icon with given 'icon_id' and populates the 'icon_value',
|
||||
or populates the 'icon' by the Blender's icon name 'bpy_icon' as fallback.
|
||||
"""
|
||||
if icon_id is not None:
|
||||
return {"icon_value": icon_id}
|
||||
else:
|
||||
return {"icon": bpy_icon_name}
|
||||
|
||||
|
||||
def draw_social_media_buttons(layout: bpy.types.UILayout, show_text: bool = False):
|
||||
layout.operator(
|
||||
"wm.url_open",
|
||||
text="Discord" if show_text else "",
|
||||
icon_value=icon_manager.get_icon_id("logo_discord"),
|
||||
).url = SocialMediaURL.DISCORD
|
||||
|
||||
layout.operator(
|
||||
"wm.url_open",
|
||||
text="Facebook" if show_text else "",
|
||||
icon_value=icon_manager.get_icon_id("logo_facebook"),
|
||||
).url = SocialMediaURL.FACEBOOK
|
||||
|
||||
layout.operator(
|
||||
"wm.url_open",
|
||||
text="Instagram" if show_text else "",
|
||||
icon_value=icon_manager.get_icon_id("logo_instagram"),
|
||||
).url = SocialMediaURL.INSTAGRAM
|
||||
|
||||
layout.operator(
|
||||
"wm.url_open",
|
||||
text="BlenderMarket" if show_text else "",
|
||||
icon_value=icon_manager.get_icon_id("logo_blendermarket"),
|
||||
).url = SocialMediaURL.BLENDERMARKET
|
||||
|
||||
layout.operator(
|
||||
"wm.url_open",
|
||||
text="Gumroad" if show_text else "",
|
||||
icon_value=icon_manager.get_icon_id("logo_gumroad"),
|
||||
).url = SocialMediaURL.GUMROAD
|
||||
|
||||
layout.operator(
|
||||
"wm.url_open",
|
||||
text="Website" if show_text else "",
|
||||
icon_value=icon_manager.get_icon_id("logo_polygoniq"),
|
||||
).url = SocialMediaURL.WEBPAGE
|
||||
|
||||
|
||||
def draw_settings_footer(layout: bpy.types.UILayout):
|
||||
row = layout.row(align=True)
|
||||
row.alignment = 'CENTER'
|
||||
row.scale_x = 1.27
|
||||
row.scale_y = 1.27
|
||||
draw_social_media_buttons(row, show_text=False)
|
||||
row.label(text="© polygoniq xyz s.r.o")
|
||||
|
||||
|
||||
def show_message_box(message: str, title: str, icon: str = 'INFO') -> None:
|
||||
lines = message.split("\n")
|
||||
|
||||
def draw(self, context):
|
||||
for line in lines:
|
||||
row = self.layout.row()
|
||||
row.label(text=line)
|
||||
|
||||
bpy.context.window_manager.popup_menu(draw, title=title, icon=icon)
|
||||
|
||||
|
||||
def multi_column(
|
||||
layout: bpy.types.UILayout, column_sizes: typing.List[float], align: bool = False
|
||||
) -> typing.List[bpy.types.UILayout]:
|
||||
columns = []
|
||||
for i in range(len(column_sizes)):
|
||||
# save first column, create split from the other with recalculated size
|
||||
size = 1.0 - sum(column_sizes[:i]) if i > 0 else 1.0
|
||||
|
||||
s = layout.split(factor=column_sizes[i] / size, align=align)
|
||||
a = s.column(align=align)
|
||||
b = s.column(align=align)
|
||||
columns.append(a)
|
||||
layout = b
|
||||
|
||||
return columns
|
||||
|
||||
|
||||
def scaled_row(layout: bpy.types.UILayout, scale: float, align: bool = False) -> bpy.types.UILayout:
|
||||
row = layout.row(align=align)
|
||||
row.scale_x = row.scale_y = scale
|
||||
return row
|
||||
|
||||
|
||||
def row_with_label(
|
||||
layout: bpy.types.UILayout,
|
||||
text: str = "",
|
||||
align: bool = False,
|
||||
enabled: bool = False,
|
||||
icon: str = 'NONE',
|
||||
) -> bpy.types.UILayout:
|
||||
"""Creates a row with label based on 'layout'.
|
||||
|
||||
Additional parameters specify appearance of this row and label. For example enabled = False can
|
||||
be used to display row that is grayed out. This can be useful to separate UI flow.
|
||||
"""
|
||||
row = layout.row(align=align)
|
||||
row.enabled = enabled
|
||||
row.label(text=text, icon=icon)
|
||||
return row
|
||||
|
||||
|
||||
def collapsible_box(
|
||||
layout: bpy.types.UILayout,
|
||||
data: typing.Any,
|
||||
show_prop_name: str,
|
||||
title: str,
|
||||
content_draw: typing.Callable[[bpy.types.UILayout], None],
|
||||
docs_module: typing.Optional[str] = None,
|
||||
docs_rel_url: str = "",
|
||||
) -> bpy.types.UILayout:
|
||||
"""Creates a collapsible box with 'title' and 'content' inside, based on 'layout'.
|
||||
|
||||
The box is shown based on the 'show_prop_name' property of 'data' object. Optionally, a button
|
||||
leading to a documentation page can be added based on 'docs_module' and 'docs_rel_url'.
|
||||
"""
|
||||
show = getattr(data, show_prop_name)
|
||||
if show is None:
|
||||
raise ValueError(f"Property '{show_prop_name}' not found in data object!")
|
||||
box = layout.box()
|
||||
row = box.row()
|
||||
row.prop(
|
||||
data,
|
||||
show_prop_name,
|
||||
icon='DISCLOSURE_TRI_DOWN' if show else 'DISCLOSURE_TRI_RIGHT',
|
||||
text="",
|
||||
emboss=False,
|
||||
)
|
||||
row.label(text=title)
|
||||
if docs_module is not None:
|
||||
draw_doc_button(row, docs_module, docs_rel_url)
|
||||
if show:
|
||||
content_draw(box)
|
||||
|
||||
return box
|
||||
|
||||
|
||||
def center_mouse(context: bpy.types.Context) -> None:
|
||||
region = context.region
|
||||
x = region.width // 2 + region.x
|
||||
y = region.height // 2 + region.y
|
||||
context.window.cursor_warp(x, y)
|
||||
|
||||
|
||||
def get_mouseovered_region(
|
||||
context: bpy.types.Context, event: bpy.types.Event
|
||||
) -> typing.Tuple[typing.Optional[bpy.types.Area], typing.Optional[bpy.types.Region]]:
|
||||
"""Returns tuple (area, region) of underlying area and region in mouse event 'event'"""
|
||||
|
||||
# Method taken from the 'Screencast Keys' addon
|
||||
# available at: https://github.com/nutti/Screencast-Keys
|
||||
x, y = event.mouse_x, event.mouse_y
|
||||
for area in context.screen.areas:
|
||||
for region in area.regions:
|
||||
if region.type == "":
|
||||
continue
|
||||
within_x = region.x <= x < region.x + region.width
|
||||
within_y = region.y <= y < region.y + region.height
|
||||
if within_x and within_y:
|
||||
return area, region
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def get_all_space_types() -> typing.Dict[str, bpy.types.Space]:
|
||||
"""Returns mapping of space type to its class - 'VIEW_3D -> bpy.types.SpaceView3D"""
|
||||
|
||||
# Code taken and adjusted from ScreenCastKeys addon -> https://github.com/nutti/Screencast-Keys/
|
||||
def add_if_exist(
|
||||
cls_name: str, space_name: str, space_types: typing.Dict[str, bpy.types.Space]
|
||||
) -> None:
|
||||
cls = getattr(sys.modules["bpy.types"], cls_name, None)
|
||||
if cls is not None:
|
||||
space_types[space_name] = cls
|
||||
|
||||
space_types = {}
|
||||
add_if_exist("SpaceView3D", 'VIEW_3D', space_types)
|
||||
add_if_exist("SpaceClipEditor", 'CLIP_EDITOR', space_types)
|
||||
add_if_exist("SpaceConsole", 'CONSOLE', space_types)
|
||||
add_if_exist("SpaceDopeSheetEditor", 'DOPESHEET_EDITOR', space_types)
|
||||
add_if_exist("SpaceFileBrowser", 'FILE_BROWSER', space_types)
|
||||
add_if_exist("SpaceGraphEditor", 'GRAPH_EDITOR', space_types)
|
||||
add_if_exist("SpaceImageEditor", 'IMAGE_EDITOR', space_types)
|
||||
add_if_exist("SpaceInfo", 'INFO', space_types)
|
||||
add_if_exist("SpaceLogicEditor", 'LOGIC_EDITOR', space_types)
|
||||
add_if_exist("SpaceNLA", 'NLA_EDITOR', space_types)
|
||||
add_if_exist("SpaceNodeEditor", 'NODE_EDITOR', space_types)
|
||||
add_if_exist("SpaceOutliner", 'OUTLINER', space_types)
|
||||
add_if_exist("SpacePreferences", 'PREFERENCES', space_types)
|
||||
add_if_exist("SpaceUserPreferences", 'PREFERENCES', space_types)
|
||||
add_if_exist("SpaceProperties", 'PROPERTIES', space_types)
|
||||
add_if_exist("SpaceSequenceEditor", 'SEQUENCE_EDITOR', space_types)
|
||||
add_if_exist("SpaceSpreadsheet", 'SPREADSHEET', space_types)
|
||||
add_if_exist("SpaceTextEditor", 'TEXT_EDITOR', space_types)
|
||||
add_if_exist("SpaceTimeline", 'TIMELINE', space_types)
|
||||
|
||||
return space_types
|
||||
|
||||
|
||||
def expand_addon_prefs(module_name: str) -> None:
|
||||
"""Opens preferences of an add-on based on its module name"""
|
||||
mod_info = utils_bpy.get_addon_mod_info(module_name)
|
||||
mod_info["show_expanded"] = True
|
||||
|
||||
|
||||
def draw_doc_button(layout: bpy.types.UILayout, module: str, rel_url: str = "") -> None:
|
||||
"""Draws a button leading to an add-on's docs URL based on its module name.
|
||||
|
||||
Points to the homepage by default, but can be changed by passing 'rel_url' parameter.
|
||||
"""
|
||||
|
||||
url = f"{utils_bpy.get_addon_docs_page(module)}/{rel_url}"
|
||||
layout.operator("wm.url_open", text="", icon='HELP', emboss=False).url = url
|
||||
|
||||
|
||||
def draw_markdown_text(layout: bpy.types.UILayout, text: str, max_length: int = 100) -> None:
|
||||
col = layout.column(align=True)
|
||||
|
||||
# Remove unicode characters from the text
|
||||
# We do this to remove emojis, because Blender does not support them
|
||||
text = text.encode("ascii", "ignore").decode()
|
||||
|
||||
# Remove markdown images
|
||||
text = re.sub(r"!\[[^\]]*\]\([^)]*\)", "", text)
|
||||
|
||||
# Convert markdown links to just the description
|
||||
text = re.sub(r"\[([^\]]*)\]\([^)]*\)", r"\1", text)
|
||||
|
||||
# Convert bold and italic text to UPPERCASE
|
||||
text = re.sub(r"(\*\*|__)(.*?)\1", lambda match: match.group(2).upper(), text)
|
||||
text = re.sub(r"(\*|_)(.*?)\1", lambda match: match.group(2).upper(), text)
|
||||
|
||||
# Replace bullet list markers with classic bullet character (•), respecting indentation
|
||||
text = re.sub(r"(^|\n)(\s*)([-*+])\s", r"\1\2• ", text)
|
||||
|
||||
# Regex for matching markdown headings
|
||||
headings = re.compile(r"^#+")
|
||||
|
||||
lines = text.split("\r\n")
|
||||
# Let's offset the text based on the heading level to make it more readable
|
||||
offset = 0
|
||||
for line in lines:
|
||||
heading = headings.search(line)
|
||||
if heading:
|
||||
offset = len(heading.group()) - 1
|
||||
line = line.replace(heading.group(), "")
|
||||
line = line.strip().upper()
|
||||
|
||||
# Let's do a separator for empty lines
|
||||
if len(line) == 0:
|
||||
col.separator()
|
||||
continue
|
||||
split_lines = textwrap.wrap(line, max_length)
|
||||
for split_line in split_lines:
|
||||
col.label(text=4 * offset * " " + split_line)
|
||||
|
||||
|
||||
def show_release_notes_popup(
|
||||
context: bpy.types.Context, module_name: str, release_tag: str = ""
|
||||
) -> None:
|
||||
if bpy.app.version >= (4, 2, 0) and not bpy.app.online_access:
|
||||
show_message_box(
|
||||
"This requires online access. You have to \"Allow Online Access\" in "
|
||||
"\"Preferences -> System -> Network\" to proceed",
|
||||
"Online Access Disabled",
|
||||
icon='INTERNET',
|
||||
)
|
||||
return
|
||||
|
||||
mod_info = utils_bpy.get_addon_mod_info(module_name)
|
||||
# Get only the name without suffix (_full, _lite, etc.)
|
||||
addon_name = mod_info["name"].split("_", 1)[0]
|
||||
|
||||
release_info = utils_bpy.get_addon_release_info(addon_name, release_tag)
|
||||
error_msg = f"Cannot retrieve release info for {addon_name}!"
|
||||
if release_info is None:
|
||||
logger.error(error_msg)
|
||||
show_message_box(error_msg, "Error", icon='ERROR')
|
||||
return
|
||||
|
||||
version = release_info.get("tag_name", None)
|
||||
if version is None:
|
||||
logger.error("Release info does not contain version!")
|
||||
show_message_box(error_msg, "Error", icon='ERROR')
|
||||
return
|
||||
|
||||
body = release_info.get("body", None)
|
||||
if not body:
|
||||
logger.error("Release info does not contain body!")
|
||||
show_message_box(error_msg, "Error", icon='ERROR')
|
||||
return
|
||||
|
||||
context.window_manager.popup_menu(
|
||||
lambda self, context: draw_markdown_text(self.layout, text=body, max_length=100),
|
||||
title=f"{addon_name} {version} Release Notes",
|
||||
icon='INFO',
|
||||
)
|
||||
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/python3
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import bpy
|
||||
import addon_utils
|
||||
import sys
|
||||
import os
|
||||
import pathlib
|
||||
import typing
|
||||
import datetime
|
||||
import functools
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
import ssl
|
||||
import json
|
||||
import subprocess
|
||||
import math
|
||||
import time
|
||||
import re
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
POLYGONIQ_DOCS_URL = "https://docs.polygoniq.com"
|
||||
POLYGONIQ_GITHUB_REPO_API_URL = "https://api.github.com/repos/polygoniq"
|
||||
|
||||
|
||||
def autodetect_install_path(
|
||||
product: str, init_path: str, install_path_checker: typing.Callable[[str], bool]
|
||||
) -> str:
|
||||
# TODO: We should submit a patch to blender_vscode and deal with this from there in the future
|
||||
try:
|
||||
vscode_product_path = os.path.expanduser(
|
||||
os.path.join("~", "polygoniq", "blender_addons", product)
|
||||
)
|
||||
try:
|
||||
if (
|
||||
os.path.commonpath(
|
||||
[os.path.abspath(os.path.realpath(init_path)), vscode_product_path]
|
||||
)
|
||||
== vscode_product_path
|
||||
):
|
||||
staging_path_base = os.path.expanduser(
|
||||
os.path.join("~", "polygoniq", "bazel-bin", "blender_addons", product)
|
||||
)
|
||||
# Possible sources of built assets from bazel
|
||||
FLIP_OF_THE_COIN = [
|
||||
os.path.join(staging_path_base, f"{product}_staging"),
|
||||
os.path.join(staging_path_base, f"data_final"),
|
||||
]
|
||||
for flip in FLIP_OF_THE_COIN:
|
||||
if os.path.isdir(flip):
|
||||
print(
|
||||
f"Detected blender_vscode development environment. Going to use {flip} as "
|
||||
f"the install path for {product}."
|
||||
)
|
||||
return flip
|
||||
except:
|
||||
# not on the same drive
|
||||
pass
|
||||
|
||||
except ValueError: # Paths don't have the same drive
|
||||
pass
|
||||
|
||||
big_zip_path = os.path.abspath(os.path.dirname(init_path))
|
||||
if install_path_checker(big_zip_path):
|
||||
print(f"{product} install dir autodetected as {big_zip_path} (big zip embedded)")
|
||||
return big_zip_path
|
||||
|
||||
if sys.platform == "win32":
|
||||
SHOTS_IN_THE_DARK = [
|
||||
f"C:/{product}",
|
||||
f"D:/{product}",
|
||||
f"C:/polygoniq/{product}",
|
||||
f"D:/polygoniq/{product}",
|
||||
]
|
||||
|
||||
for shot in SHOTS_IN_THE_DARK:
|
||||
if install_path_checker(shot):
|
||||
print(f"{product} install dir autodetected as {shot}")
|
||||
return os.path.abspath(shot)
|
||||
|
||||
elif sys.platform in ["linux", "darwin"]:
|
||||
SHOTS_IN_THE_DARK = [
|
||||
os.path.expanduser(f"~/{product}"),
|
||||
os.path.expanduser(f"~/Desktop/{product}"),
|
||||
os.path.expanduser(f"~/Documents/{product}"),
|
||||
os.path.expanduser(f"~/Downloads/{product}"),
|
||||
os.path.expanduser(f"~/polygoniq/{product}"),
|
||||
os.path.expanduser(f"~/Desktop/polygoniq/{product}"),
|
||||
os.path.expanduser(f"~/Documents/polygoniq/{product}"),
|
||||
os.path.expanduser(f"~/Downloads/polygoniq/{product}"),
|
||||
f"/var/lib/{product}",
|
||||
f"/usr/local/{product}",
|
||||
f"/opt/{product}",
|
||||
]
|
||||
|
||||
for shot in SHOTS_IN_THE_DARK:
|
||||
if install_path_checker(shot):
|
||||
print(f"{product} install dir autodetected as {shot}")
|
||||
return os.path.abspath(shot)
|
||||
|
||||
print(
|
||||
f"{product} is not installed in one of the default locations, please make "
|
||||
f"sure the path is set in {product} addon preferences!",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return ""
|
||||
|
||||
|
||||
def absolutize_preferences_path(
|
||||
self: bpy.types.AddonPreferences, context: bpy.types.Context, path_property_name: str
|
||||
) -> None:
|
||||
assert hasattr(self, path_property_name)
|
||||
abs_ = os.path.abspath(getattr(self, path_property_name))
|
||||
if abs_ != getattr(self, path_property_name):
|
||||
setattr(self, path_property_name, abs_)
|
||||
|
||||
|
||||
def contains_object_duplicate_suffix(name: str) -> bool:
|
||||
pattern = re.compile(r"^\.[0-9]{3}$")
|
||||
return bool(pattern.match(name[-4:]))
|
||||
|
||||
|
||||
def remove_object_duplicate_suffix(name: str) -> str:
|
||||
splitted_name = name.rsplit(".", 1)
|
||||
if len(splitted_name) == 1:
|
||||
return splitted_name[0]
|
||||
|
||||
if splitted_name[1].isnumeric():
|
||||
return splitted_name[0]
|
||||
|
||||
return name
|
||||
|
||||
|
||||
def generate_unique_name(old_name: str, container: typing.Iterable[typing.Any]) -> str:
|
||||
# TODO: Unify this with renderset unique naming generation
|
||||
name_without_suffix = remove_object_duplicate_suffix(old_name)
|
||||
i = 1
|
||||
new_name = name_without_suffix
|
||||
while new_name in container:
|
||||
new_name = f"{name_without_suffix}.{i:03d}"
|
||||
i += 1
|
||||
|
||||
return new_name
|
||||
|
||||
|
||||
def convert_size(size_bytes: int) -> str:
|
||||
if size_bytes == 0:
|
||||
return "0 B"
|
||||
size_name = ("B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB")
|
||||
index = int(math.floor(math.log(size_bytes, 1024)))
|
||||
size = round(size_bytes / math.pow(1024, index), 2)
|
||||
return f"{size} {size_name[index]}"
|
||||
|
||||
|
||||
def blender_cursor(cursor_name: str = 'WAIT'):
|
||||
"""Decorator that sets a modal cursor in Blender to whatever the caller desires,
|
||||
then sets it back when the function returns. This is useful for long running
|
||||
functions or operators. Showing a WAIT cursor makes it less likely that the user
|
||||
will think that Blender froze.
|
||||
|
||||
Unfortunately this can only be used in cases we control and only when 'context' is
|
||||
available.
|
||||
|
||||
TODO: Maybe we could use bpy.context and drop the context requirement?
|
||||
"""
|
||||
|
||||
def cursor_decorator(fn):
|
||||
def wrapper(self, context: bpy.types.Context, *args, **kwargs):
|
||||
context.window.cursor_modal_set(cursor_name)
|
||||
try:
|
||||
return fn(self, context, *args, **kwargs)
|
||||
finally:
|
||||
context.window.cursor_modal_restore()
|
||||
|
||||
return wrapper
|
||||
|
||||
return cursor_decorator
|
||||
|
||||
|
||||
def timeit(fn):
|
||||
def timed(*args, **kw):
|
||||
ts = time.time()
|
||||
result = fn(*args, **kw)
|
||||
te = time.time()
|
||||
print(f"{fn.__name__!r} {(te - ts) * 1000:2.2f} ms")
|
||||
return result
|
||||
|
||||
return timed
|
||||
|
||||
|
||||
def timed_cache(**timedelta_kwargs):
|
||||
def _wrapper(f):
|
||||
update_delta = datetime.timedelta(**timedelta_kwargs)
|
||||
next_update = datetime.datetime.utcnow() + update_delta
|
||||
f = functools.lru_cache(None)(f)
|
||||
|
||||
@functools.wraps(f)
|
||||
def _wrapped(*args, **kwargs):
|
||||
nonlocal next_update
|
||||
now = datetime.datetime.utcnow()
|
||||
if now >= next_update:
|
||||
f.cache_clear()
|
||||
next_update = now + update_delta
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return _wrapped
|
||||
|
||||
return _wrapper
|
||||
|
||||
|
||||
def xdg_open_file(path):
|
||||
if sys.platform == "win32":
|
||||
os.startfile(path)
|
||||
elif sys.platform == "darwin":
|
||||
subprocess.call(["open", path])
|
||||
else:
|
||||
subprocess.call(["xdg-open", path])
|
||||
|
||||
|
||||
def fork_running_blender(blend_path: typing.Optional[str] = None) -> None:
|
||||
"""Opens new instance of Blender which keeps running even if the original instance is closed.
|
||||
|
||||
Opens 'blend_path' if provided, otherwise Blender will open with an empty scene.
|
||||
"""
|
||||
blender_executable = bpy.app.binary_path
|
||||
args = [blender_executable]
|
||||
|
||||
if blend_path is not None:
|
||||
args += [blend_path]
|
||||
|
||||
if sys.platform in ["win32", "cygwin"]:
|
||||
# Detach child process and close its stdin/stdout/stderr, so it can keep running
|
||||
# after parent Blender is closed.
|
||||
# https://stackoverflow.com/questions/52449997/how-to-detach-python-child-process-on-windows-without-setsid
|
||||
flags = 0
|
||||
flags |= subprocess.DETACHED_PROCESS
|
||||
flags |= subprocess.CREATE_NEW_PROCESS_GROUP
|
||||
flags |= subprocess.CREATE_NO_WINDOW
|
||||
subprocess.Popen(args, close_fds=True, creationflags=flags)
|
||||
elif sys.platform in ["darwin", "linux", "linux2"]: # POSIX systems
|
||||
subprocess.Popen(args, start_new_session=True)
|
||||
else:
|
||||
raise RuntimeError(f"Unsupported OS: sys.platform={sys.platform}")
|
||||
|
||||
|
||||
def run_logging_subprocess(
|
||||
subprocess_args: typing.List[str], logger_: typing.Optional[logging.Logger] = None
|
||||
) -> int:
|
||||
"""Runs `subprocess_args` as subprocess and logs stdout and stderr of the subprocess.
|
||||
|
||||
If 'logger_' is None, logger from polib will be used.
|
||||
|
||||
Returns returncode from the subprocess, 0 means that no errors ocurred.
|
||||
"""
|
||||
if logger_ is None:
|
||||
logger_ = logger
|
||||
|
||||
process = subprocess.Popen(subprocess_args, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
|
||||
|
||||
# Read from indexing process till it's running
|
||||
for line in process.stdout:
|
||||
logger_.info(line.decode())
|
||||
process.wait()
|
||||
return process.returncode
|
||||
|
||||
|
||||
def normalize_path(path: str) -> str:
|
||||
"""Makes path OS independent."""
|
||||
return path.replace("\\", "/")
|
||||
|
||||
|
||||
def get_case_sensitive_path(path: str) -> str:
|
||||
"""Returns the path with capitalization as it appears on disk.
|
||||
|
||||
Some OSes such as Windows do not consider capitalization while
|
||||
resolving paths, while others such as UNIX-based systems do.
|
||||
"""
|
||||
if not os.path.exists(path):
|
||||
raise FileNotFoundError(f"File not found: {path}")
|
||||
|
||||
components = pathlib.Path(path).parts
|
||||
|
||||
case_sensitive_path = "."
|
||||
if os.path.isabs(path):
|
||||
# First component of absolute path is either a drive letter or a `/`, no need to check case
|
||||
case_sensitive_path = components[0]
|
||||
components = components[1:]
|
||||
|
||||
# Reconstruct the path with case-sensitive names
|
||||
for component in components:
|
||||
# Check the case of each entry in the directory
|
||||
# Using os.path.realpath is not reliable, as it does
|
||||
# not return case-sensitive paths for google drive files
|
||||
entries = os.listdir(case_sensitive_path)
|
||||
# Path may contain current directory or up one level notation, we don't use realpath,
|
||||
# because we don't want to change the format of the input path
|
||||
entries.extend([".", ".."])
|
||||
case_sensitive_entry = None
|
||||
for entry in entries:
|
||||
# pathlib makes sure correct case-sensitivity is used on every OS
|
||||
if pathlib.Path(entry) == pathlib.Path(component):
|
||||
case_sensitive_entry = entry
|
||||
break
|
||||
assert case_sensitive_entry is not None
|
||||
case_sensitive_path = os.path.join(case_sensitive_path, case_sensitive_entry)
|
||||
|
||||
return case_sensitive_path
|
||||
|
||||
|
||||
def isfile_case_sensitive(path: str) -> bool:
|
||||
"""Similar to os.path.isfile, but case sensitive.
|
||||
|
||||
Case sensitive checks are needed on Windows and other case insensitive platforms.
|
||||
"""
|
||||
# fast case insensitive check, filters out folders
|
||||
if not os.path.isfile(path):
|
||||
return False
|
||||
|
||||
case_sensitive_path = get_case_sensitive_path(path)
|
||||
return normalize_path(path) == normalize_path(case_sensitive_path)
|
||||
|
||||
|
||||
def get_bpy_filepath_relative_to_dir(input_dir: str, filepath: str, library=None) -> str:
|
||||
file_abspath = bpy.path.abspath(filepath, library=library)
|
||||
rel_path = bpy.path.relpath(file_abspath, start=input_dir)
|
||||
return normalize_path(rel_path.removeprefix("//"))
|
||||
|
||||
|
||||
def get_first_existing_ancestor_directory(
|
||||
file_path: str, whitelist: typing.Optional[set[str]] = None
|
||||
) -> typing.Optional[str]:
|
||||
if whitelist is None:
|
||||
whitelist = set()
|
||||
if file_path not in whitelist and not os.path.exists(file_path):
|
||||
return None
|
||||
current_dir = pathlib.Path(os.path.dirname(file_path)).resolve()
|
||||
while not os.path.exists(current_dir):
|
||||
current_dir = current_dir.parent.resolve()
|
||||
return str(current_dir)
|
||||
|
||||
|
||||
def get_all_datablocks(data: bpy.types.BlendData) -> typing.List[typing.Tuple[bpy.types.ID, str]]:
|
||||
"""returns all datablocks and their BlendData type in the currently loaded blend file"""
|
||||
# Return a materialized list, don't use generators here, those may result in Blender
|
||||
# crashing due to memory issues
|
||||
ret = []
|
||||
for member_variable_name in dir(data):
|
||||
member_variable = getattr(data, member_variable_name)
|
||||
if isinstance(member_variable, bpy.types.bpy_prop_collection):
|
||||
for datablock in member_variable:
|
||||
if isinstance(datablock, bpy.types.ID):
|
||||
ret.append((datablock, member_variable_name))
|
||||
return ret
|
||||
|
||||
|
||||
def get_addon_mod_info(module_name: str) -> typing.Dict[str, typing.Any]:
|
||||
"""Returns module bl_info based on its module name."""
|
||||
for mod in addon_utils.modules(refresh=False):
|
||||
if mod.__name__ == module_name:
|
||||
mod_info = addon_utils.module_bl_info(mod)
|
||||
return mod_info
|
||||
raise ValueError(f"No module '{module_name}' was found!")
|
||||
|
||||
|
||||
def get_release_tag_from_version(version: typing.Tuple[int, int, int]) -> str:
|
||||
return f"v{'.'.join(map(str, version))}"
|
||||
|
||||
|
||||
def get_addon_docs_page(module_name: str) -> str:
|
||||
"""Returns url of add-on docs based on its module name."""
|
||||
mod_info = get_addon_mod_info(module_name)
|
||||
# Get only the name without suffix (_full, _lite, etc.)
|
||||
name = mod_info["name"].split("_", 1)[0]
|
||||
version = ".".join(map(str, mod_info["version"]))
|
||||
return f"{POLYGONIQ_DOCS_URL}/{name}/{version}"
|
||||
|
||||
|
||||
def get_addon_release_info(
|
||||
addon_name: str, release_tag: str = ""
|
||||
) -> typing.Optional[typing.Dict[str, typing.Any]]:
|
||||
if release_tag != "":
|
||||
url = f"{POLYGONIQ_GITHUB_REPO_API_URL}/{addon_name}/releases/tags/{release_tag}"
|
||||
else:
|
||||
url = f"{POLYGONIQ_GITHUB_REPO_API_URL}/{addon_name}/releases/latest"
|
||||
request = urllib.request.Request(url)
|
||||
try:
|
||||
ssl_context = ssl._create_unverified_context()
|
||||
except:
|
||||
# Some blender packaged python versions don't have this, largely
|
||||
# useful for local network setups otherwise minimal impact.
|
||||
ssl_context = None
|
||||
try:
|
||||
if ssl_context is not None:
|
||||
response = urllib.request.urlopen(request, context=ssl_context)
|
||||
else:
|
||||
response = urllib.request.urlopen(request)
|
||||
except (urllib.error.HTTPError, urllib.error.URLError) as e:
|
||||
logger.error(e)
|
||||
else:
|
||||
result_string = response.read()
|
||||
response.close()
|
||||
try:
|
||||
return json.JSONDecoder().decode(result_string.decode())
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error("API response has invalid JSON format")
|
||||
@@ -0,0 +1,65 @@
|
||||
# copyright (c) 2018- polygoniq xyz s.r.o.
|
||||
|
||||
import bpy
|
||||
import typing
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(f"polygoniq.{__name__}")
|
||||
|
||||
|
||||
def get_images_used_in_node(node: bpy.types.Node) -> typing.Set[bpy.types.Image]:
|
||||
ret = set()
|
||||
|
||||
if hasattr(node, "node_tree"):
|
||||
if node.node_tree is not None:
|
||||
for child_node in node.node_tree.nodes:
|
||||
ret.update(get_images_used_in_node(child_node))
|
||||
|
||||
if hasattr(node, "image"):
|
||||
if node.image is not None:
|
||||
ret.add(node.image)
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def get_images_used_in_material(
|
||||
material: typing.Optional[bpy.types.Material],
|
||||
) -> typing.Set[bpy.types.Image]:
|
||||
if material is None:
|
||||
return set()
|
||||
|
||||
if not material.use_nodes:
|
||||
# TODO: We will probably have to implement this :-(
|
||||
logger.warning(
|
||||
f"Can't get used textures from material '{material.name}' that is not using "
|
||||
f"the node system!"
|
||||
)
|
||||
return set()
|
||||
|
||||
assert material.node_tree is not None, "use_nodes is True, yet node_tree is None"
|
||||
ret = set()
|
||||
for node in material.node_tree.nodes:
|
||||
ret.update(get_images_used_in_node(node))
|
||||
|
||||
return ret
|
||||
|
||||
|
||||
def get_images_used_in_object(obj: bpy.types.Object) -> typing.Set[bpy.types.Image]:
|
||||
ret = set()
|
||||
for material_slot in obj.material_slots:
|
||||
if material_slot.material is None:
|
||||
continue
|
||||
|
||||
ret.update(get_images_used_in_material(material_slot.material))
|
||||
|
||||
if obj.instance_type == 'COLLECTION' and obj.instance_collection is not None:
|
||||
for instanced_object in obj.instance_collection.objects:
|
||||
ret.update(get_images_used_in_object(instanced_object))
|
||||
|
||||
for particle_system in obj.particle_systems:
|
||||
particle_settings: bpy.types.ParticleSystem = particle_system.settings
|
||||
if particle_settings.instance_object is None:
|
||||
continue
|
||||
ret.update(get_images_used_in_object(particle_settings.instance_object))
|
||||
|
||||
return ret
|
||||
Reference in New Issue
Block a user