diff --git a/__init__.py b/__init__.py index edef9a1..6f429ae 100644 --- a/__init__.py +++ b/__init__.py @@ -85,6 +85,7 @@ from . import pack_info_search_paths from . import asset_helpers from . import preferences + from . import convert_selection from . import panel from . import browser from . import blend_maintenance @@ -104,12 +105,12 @@ bl_info = { "name": "engon", "author": "polygoniq xyz s.r.o.", - "version": (1, 1, 0), # bump doc_url as well! + "version": (1, 2, 0), # bump doc_url as well! "blender": (3, 3, 0), "location": "polygoniq tab in the sidebar of the 3D View window", "description": "", "category": "Object", - "doc_url": "https://docs.polygoniq.com/engon/1.1.0/", + "doc_url": "https://docs.polygoniq.com/engon/1.2.0/", "tracker_url": "https://polygoniq.com/discord/" } @@ -124,6 +125,7 @@ def register(): ui_utils.register() pack_info_search_paths.register() preferences.register() + convert_selection.register() panel.register() scatter.register() blend_maintenance.register() @@ -163,6 +165,7 @@ def unregister(): blend_maintenance.unregister() scatter.unregister() panel.unregister() + convert_selection.unregister() preferences.unregister() pack_info_search_paths.unregister() ui_utils.unregister() @@ -170,7 +173,7 @@ def 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(__name__): + if module_name.startswith(__package__): del sys.modules[module_name] addon_updater_ops.unregister() diff --git a/asset_registry.py b/asset_registry.py index d22c542..c690ef1 100644 --- a/asset_registry.py +++ b/asset_registry.py @@ -65,6 +65,8 @@ def from_json_dict(pack_info_path: str, json_dict: typing.Dict[typing.Any, typin f"Given json dict contains vendor but its type is '{type(vendor)}' " f"instead of the expected 'str'!") engon_features = json_dict.get("engon_features", []) + # min_engon_version default is the version, when the field was introduced - 1.2.0 + min_engon_version = json_dict.get("min_engon_version", [1, 2, 0]) pack_info_path = os.path.realpath(os.path.abspath(pack_info_path)) pack_info_parent_path = os.path.dirname(pack_info_path) install_path = os.path.realpath(os.path.abspath(pack_info_parent_path)) @@ -103,6 +105,7 @@ def from_json_dict(pack_info_path: str, json_dict: typing.Dict[typing.Any, typin typing.cast(typing.Tuple[int, int, int], tuple(version)), vendor, engon_features, + tuple(min_engon_version), install_path, pack_info_path, index_paths, @@ -130,6 +133,7 @@ def __init__( version: typing.Tuple[int, int, int], vendor: str, engon_features: typing.List[str], + min_engon_version: typing.Tuple[int, int, int], install_path: str, pack_info_path: str, index_paths: typing.List[str], @@ -156,6 +160,7 @@ def __init__( if len(engon_features) == 0: raise NotImplementedError("At least one engon feature required in each asset pack!") self.engon_feature = engon_features[0] + self.min_engon_version = min_engon_version self.install_path = install_path self.pack_info_path = pack_info_path self.index_paths = index_paths diff --git a/blend_maintenance/asset_changes.py b/blend_maintenance/asset_changes.py index 2b42f2b..dcea2cb 100644 --- a/blend_maintenance/asset_changes.py +++ b/blend_maintenance/asset_changes.py @@ -246,7 +246,10 @@ class AssetPackMigrations(typing.NamedTuple): "collections": [RegexMapping(re.compile("^(AM154-)(.*)"), r"am154_\2")], "meshes": [RegexMapping(re.compile("^(AM154-)(.*)"), r"am154_\2")], "objects": [RegexMapping(re.compile("^(AM154-)(.*)"), r"am154_\2")], - "materials": [RegexMapping(re.compile("^(bq_)(.*)"), r"am154_\2")], + "materials": [ + RegexMapping(re.compile("^(bq_)(.*)"), r"am154_\2"), + RegexMapping(re.compile("(.*)(_bqm)$"), r"am154_\1") + ], "node_groups": [RegexMapping(re.compile("^(bq_)(.*)"), r"am154_\2")], } ) diff --git a/blender_manifest.toml b/blender_manifest.toml index 25b58ac..964aa0a 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -3,7 +3,7 @@ schema_version = "1.0.0" # Example of manifest file for a Blender extension # Change the values according to your extension id = "engon" -version = "1.0.3" +version = "1.2.0" name = "engon" tagline = "Browse assets, filter and sort them, scatter, animate, manipulate rigs" maintainer = "polygoniq " diff --git a/browser/browser.py b/browser/browser.py index 69e8305..5bb9360 100644 --- a/browser/browser.py +++ b/browser/browser.py @@ -405,6 +405,10 @@ def prefs_navbar_draw_override(self, context: bpy.types.Context) -> None: else: categories.draw_tree_category_navigation(context, layout) + layout.separator() + row = layout.row(align=True) + row.operator(spawn.MAPR_BrowserSpawnAllDisplayed.bl_idname, icon='IMGDISPLAY') + layout.separator() filters.draw(context, layout) diff --git a/browser/dev.py b/browser/dev.py index a76991e..18641f4 100644 --- a/browser/dev.py +++ b/browser/dev.py @@ -22,6 +22,7 @@ import os import typing import mapr +import polib import logging from . import filters from . import previews @@ -32,8 +33,8 @@ MODULE_CLASSES: typing.List[typing.Any] = [] # Top secret path to the dev location -EXPECTED_DEV_PATH = os.path.expanduser("~/polygoniq/") -IS_DEV = os.path.exists(os.path.realpath(os.path.abspath(os.path.join(EXPECTED_DEV_PATH, ".git")))) +EXPECTED_DEV_PATH = os.path.realpath(os.path.expanduser("~/polygoniq/")) +IS_DEV = os.path.exists(os.path.join(EXPECTED_DEV_PATH, ".git")) class MAPR_BrowserDeleteCache(bpy.types.Operator): @@ -93,7 +94,7 @@ def execute(self, context: bpy.types.Context): if getattr(self, "asset_path", None) is None: raise RuntimeError("asset_path is initialized in invoke, use INVOKE_DEFAULT!") - bpy.ops.wm.open_mainfile(filepath=self.asset_path, load_ui=False) + polib.utils_bpy.fork_running_blender(self.asset_path) return {'FINISHED'} def invoke(self, context: bpy.types.Context, event: bpy.types.Event): diff --git a/browser/spawn.py b/browser/spawn.py index 2754fe3..916bcdb 100644 --- a/browser/spawn.py +++ b/browser/spawn.py @@ -26,11 +26,14 @@ import math import mathutils import hatchery +from . import filters from .. import preferences from .. import asset_registry from .. import asset_helpers logger = logging.getLogger(f"polygoniq.{__name__}") +SPAWN_ALL_DISPLAYED_ASSETS_WARNING_LIMIT = 30 + MODULE_CLASSES: typing.List[typing.Any] = [] @@ -121,6 +124,12 @@ def execute(self, context: bpy.types.Context): self.report({'ERROR'}, f"Asset with id {self.asset_id} not found") return {'CANCELLED'} + # If no object is selected we will spawn a sphere and assign material on it + if asset.type_ == mapr.asset_data.AssetDataType.blender_material and len(context.selected_objects) == 0: + bpy.ops.mesh.primitive_uv_sphere_add() + bpy.ops.object.shade_smooth() + bpy.ops.object.material_slot_add() + self._spawn(context, asset, prefs.spawn_options.get_spawn_options(asset, context)) # Make editable and remove duplicates is currently out of hatchery and works based on # assumption of correct context, which is suboptimal, but at current time the functions @@ -152,6 +161,36 @@ def execute(self, context: bpy.types.Context): MODULE_CLASSES.append(MAPR_BrowserSpawnAsset) +@polib.log_helpers_bpy.logged_operator +class MAPR_BrowserSpawnAllDisplayed(bpy.types.Operator): + bl_idname = "engon.browser_spawn_all_displayed" + bl_label = "Spawn All Displayed" + bl_description = "Spawn all currently displayed assets" + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event): + if len(filters.asset_repository.current_assets) > SPAWN_ALL_DISPLAYED_ASSETS_WARNING_LIMIT: + return context.window_manager.invoke_props_dialog(self) + else: + return self.execute(context) + + def draw(self, context: bpy.types.Context) -> None: + layout = self.layout + layout.label( + text=f"This operation will spawn {len(filters.asset_repository.current_assets)} assets, continue?") + + @polib.utils_bpy.blender_cursor('WAIT') + def execute(self, context: bpy.types.Context): + prefs = preferences.prefs_utils.get_preferences(context).mapr_preferences + assets = filters.asset_repository.current_assets + for asset in assets: + MAPR_SpawnAssetBase._spawn( + self, context, asset, prefs.spawn_options.get_spawn_options(asset, context)) + return {'FINISHED'} + + +MODULE_CLASSES.append(MAPR_BrowserSpawnAllDisplayed) + + @polib.log_helpers_bpy.logged_operator class MAPR_BrowserDrawGeometryNodesAsset(MAPR_SpawnAssetBase): """Specialized spawn operator to add geometry nodes asset and start draw mode.""" diff --git a/convert_selection.py b/convert_selection.py new file mode 100644 index 0000000..56109b9 --- /dev/null +++ b/convert_selection.py @@ -0,0 +1,187 @@ +# copyright (c) 2018- polygoniq xyz s.r.o. + +import bpy +import polib +import mapr +import hatchery +import typing +import logging +from . import asset_registry +from . import preferences +logger = logging.getLogger(f"polygoniq.{__name__}") + + +MODULE_CLASSES: typing.List[typing.Any] = [] + +# The 'make_selection_linked' implementation is here because we need to access the 'asset_registry' +# instance. The 'make_selection_editable' is in the 'polib.asset_pack_bpy' module, where it can +# be used without dependency on the 'mapr' module. +# If we ever need 'make_selection_linked' in other places, let's move it to 'polib' and add the +# dependency to 'mapr'. + + +def make_selection_linked( + context: bpy.types.Context, + asset_provider: mapr.asset_provider.AssetProvider, + file_provider: mapr.file_provider.FileProvider +) -> typing.List[bpy.types.Object]: + previous_active_obj_name = context.active_object.name if context.active_object else None + converted_objects = [] + + spawner = mapr.blender_asset_spawner.AssetSpawner( + asset_provider, file_provider) + + for obj in polib.asset_pack_bpy.find_polygoniq_root_objects(context.selected_objects): + if obj.instance_type == 'COLLECTION': + continue + + id_from_object = obj.get(mapr.blender_asset_spawner.ASSET_ID_PROP_NAME, None) + if id_from_object is None: + # Object can have missing id if it comes from pre-engon asset pack + logger.error( + f"Object '{obj.name}' has no asset id, cannot convert to linked.") + continue + + asset = asset_provider.get_asset(id_from_object) + if asset is None: + # This can happen if the asset id of the object present in scene is not known + # to engon - e.g. if corresponding asset pack is not loaded. + logger.error( + f"Asset with id '{id_from_object}' not found in any installed or registered " + "Asset Pack, cannot convert to linked." + ) + continue + + if asset.type_ != mapr.asset_data.AssetDataType.blender_model: + continue + + old_model_matrix = obj.matrix_world.copy() + old_collections = list(obj.users_collection) + old_color = tuple(obj.color) + old_parent = obj.parent + + # This way old object names won't interfere with the new ones + hierarchy_objects = polib.asset_pack_bpy.get_hierarchy(obj) + for hierarchy_obj in hierarchy_objects: + hierarchy_obj.name = polib.utils_bpy.generate_unique_name( + f"del_{hierarchy_obj.name}", bpy.data.objects) + + # Spawn the asset if its mapr id is found + spawned_data = spawner.spawn(context, asset, hatchery.spawn.ModelSpawnOptions( + parent_collection=None, + select_spawned=False + )) + if spawned_data is None: + logger.error(f"Failed to spawn asset {asset.id_}") + continue + + assert isinstance(spawned_data, hatchery.spawn.ModelSpawnedData) + + instance_root = spawned_data.instancer + instance_root.matrix_world = old_model_matrix + instance_root.parent = old_parent + instance_root.color = old_color + + for coll in old_collections: + if instance_root.name not in coll.objects: + coll.objects.link(instance_root) + + converted_objects.append(instance_root) + + bpy.data.batch_remove(hierarchy_objects) + + # Force Blender to evaluate view_layer data after programmatically removing/linking objects. + # https://docs.blender.org/api/current/info_gotcha.html#no-updates-after-setting-values + context.view_layer.update() + + # Select root instances of the newly created objects, user had to have them selected before, + # otherwise they wouldn't be converted at all. + for obj in converted_objects: + obj.select_set(True) + + if previous_active_obj_name is not None and \ + previous_active_obj_name in context.view_layer.objects: + context.view_layer.objects.active = bpy.data.objects[previous_active_obj_name] + + return converted_objects + + +@polib.log_helpers_bpy.logged_operator +class MakeSelectionEditable(bpy.types.Operator): + bl_idname = "engon.make_selection_editable" + bl_label = "Convert to Editable" + bl_description = "Converts Collections into Mesh Data with Editable Materials" + + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + return context.mode == 'OBJECT' and len(context.selected_objects) > 0 + + @polib.utils_bpy.blender_cursor('WAIT') + def execute(self, context: bpy.types.Context): + selected_objects_and_parents_names = polib.asset_pack_bpy.make_selection_editable( + context, True, keep_selection=True, keep_active=True) + pack_paths = asset_registry.instance.get_packs_paths() + + logger.info( + f"Resulting objects and parents: {selected_objects_and_parents_names}") + + prefs = preferences.prefs_utils.get_preferences(context).mapr_preferences + if prefs.spawn_options.remove_duplicates: + filters = [polib.remove_duplicates_bpy.polygoniq_duplicate_data_filter] + polib.remove_duplicates_bpy.remove_duplicate_datablocks( + bpy.data.materials, filters, pack_paths) + polib.remove_duplicates_bpy.remove_duplicate_datablocks( + bpy.data.images, filters, pack_paths) + polib.remove_duplicates_bpy.remove_duplicate_datablocks( + bpy.data.node_groups, filters, pack_paths) + + return {'FINISHED'} + + +MODULE_CLASSES.append(MakeSelectionEditable) + + +@polib.log_helpers_bpy.logged_operator +class MakeSelectionLinked(bpy.types.Operator): + bl_idname = "engon.make_selection_linked" + bl_label = "Convert to Linked" + bl_description = "Converts selected objects to their linked variants from " \ + "engon asset packs. WARNING: This operation removes " \ + "all local changes. Doesn't work on particle systems, " \ + "only polygoniq assets are supported by this operator" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def poll(cls, context: bpy.types.Context): + return context.mode == 'OBJECT' and next( + polib.asset_pack_bpy.get_polygoniq_objects( + context.selected_objects, include_linked=False), + None + ) is not None + + @polib.utils_bpy.blender_cursor('WAIT') + def execute(self, context: bpy.types.Context): + converted_objects = make_selection_linked( + context, + asset_registry.instance.master_asset_provider, + asset_registry.instance.master_file_provider + ) + + self.report({'INFO'}, f"Converted {len(converted_objects)} object(s) to linked") + + return {'FINISHED'} + + +MODULE_CLASSES.append(MakeSelectionLinked) + + +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) diff --git a/panel.py b/panel.py index de1123d..949135d 100644 --- a/panel.py +++ b/panel.py @@ -19,14 +19,20 @@ # ##### END GPL LICENSE BLOCK ##### import bpy +import enum +import math +import mathutils import typing import logging import random +import mapr import polib +import hatchery from . import browser from . import asset_registry from . import preferences from . import blend_maintenance +from . import convert_selection logger = logging.getLogger(f"polygoniq.{__name__}") @@ -39,73 +45,6 @@ class EngonPanelMixin: MODULE_CLASSES: typing.List[typing.Any] = [] -@polib.log_helpers_bpy.logged_operator -class MakeSelectionEditable(bpy.types.Operator): - bl_idname = "engon.make_selection_editable" - bl_label = "Convert to Editable" - bl_description = "Converts Collections into Mesh Data with Editable Materials" - - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context): - return context.mode == 'OBJECT' and len(context.selected_objects) > 0 - - @polib.utils_bpy.blender_cursor('WAIT') - def execute(self, context): - selected_objects_and_parents_names = polib.asset_pack_bpy.make_selection_editable( - context, True, keep_selection=True, keep_active=True) - pack_paths = asset_registry.instance.get_packs_paths() - - logger.info( - f"Resulting objects and parents: {selected_objects_and_parents_names}") - - prefs = preferences.prefs_utils.get_preferences(context).mapr_preferences - if prefs.spawn_options.remove_duplicates: - filters = [polib.remove_duplicates_bpy.polygoniq_duplicate_data_filter] - polib.remove_duplicates_bpy.remove_duplicate_datablocks( - bpy.data.materials, filters, pack_paths) - polib.remove_duplicates_bpy.remove_duplicate_datablocks( - bpy.data.images, filters, pack_paths) - polib.remove_duplicates_bpy.remove_duplicate_datablocks( - bpy.data.node_groups, filters, pack_paths) - - return {'FINISHED'} - - -MODULE_CLASSES.append(MakeSelectionEditable) - - -@polib.log_helpers_bpy.logged_operator -class MakeSelectionLinked(bpy.types.Operator): - bl_idname = "engon.make_selection_linked" - bl_label = "Convert to Linked" - bl_description = "Converts selected objects to their linked variants from " \ - "engon asset packs. WARNING: This operation removes " \ - "all local changes. Doesn't work on particle systems, " \ - "only polygoniq assets are supported by this operator" - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: bpy.types.Context): - return context.mode == 'OBJECT' and next( - polib.asset_pack_bpy.get_polygoniq_objects( - context.selected_objects, include_linked=False), - None - ) is not None - - @polib.utils_bpy.blender_cursor('WAIT') - def execute(self, context: bpy.types.Context): - converted_objects = polib.asset_pack_bpy.make_selection_linked( - context, asset_registry.instance.get_install_paths_by_engon_feature()) - logger.info(f"Resulting converted objects: {converted_objects}") - - return {'FINISHED'} - - -MODULE_CLASSES.append(MakeSelectionLinked) - - @polib.log_helpers_bpy.logged_operator class SnapToGround(bpy.types.Operator): bl_idname = "engon.snap_to_ground_bpy" @@ -269,6 +208,146 @@ def execute(self, context): MODULE_CLASSES.append(ResetTransform) +@polib.log_helpers_bpy.logged_operator +class SpreadObjects(bpy.types.Operator): + bl_idname = "engon.spread_objects" + bl_label = "Spread Objects" + bl_description = "Spreads selected objects into a grid" + bl_options = {'REGISTER', 'UNDO'} + + class DistributionType(enum.Enum): + LINE = "Line" + ROWS = "Rows" + SQUARE_GRID = "Square Grid" + + distribution_type: bpy.props.EnumProperty( + name="Distribution Type", + description="How to spread the objects", + items=[ + (DistributionType.LINE.value, DistributionType.LINE.value, "Spread assets in one line"), + (DistributionType.ROWS.value, DistributionType.ROWS.value, "Spread assets in rows and colums"), + (DistributionType.SQUARE_GRID.value, DistributionType.SQUARE_GRID.value, "Spread assets in grid"), + ], + default=DistributionType.LINE.value, + ) + + use_bounding_box_for_offset: bpy.props.BoolProperty( + name="Use Bounding Box for Offset", + description="If enabled, each objects bounding box is used in addition to the fixed " + "X, Y Offset. Otherwise just the fixed X and Y offset is used", + default=True + ) + + column_x_offset: bpy.props.FloatProperty( + name="X Offset", + default=0.1, + min=0.0 + ) + + row_y_offset: bpy.props.FloatProperty( + name="Y Offset", + default=0.1, + min=0.0 + ) + + automatic_square_grid: bpy.props.BoolProperty( + name="Automatic Square Grid", + description="If enabled, the number of objects in one row is automatically calculated to " + "make the grid close to a square", + default=True + ) + + objects_in_a_row: bpy.props.IntProperty( + name="Objects in a Row", + description="How many objects are there in one row of the grid. Only used if Automatic " + "Square Grid is disabled", + default=10, + min=1 + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + # at least two objects required to do anything useful + return len(context.selected_objects) >= 2 + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event): + return context.window_manager.invoke_props_dialog(self, width=300) + + def draw(self, context): + layout = self.layout + + row = layout.row(align=False) + row.prop(self, "use_bounding_box_for_offset", text="") + row.label(text="Use Bounding Box for Offset") + + row = layout.row(align=False) + row.prop(self, "column_x_offset", text="X Offset") + row.prop(self, "row_y_offset", text="Y Offset") + + row = layout.row(align=False) + row.label(text="Distribution Type") + row.prop(self, "distribution_type", text="") + + if self.distribution_type == self.DistributionType.ROWS.value: + row = layout.row(align=False) + row.prop(self, "objects_in_a_row", text="") + + def execute(self, context: bpy.types.Context): + if self.distribution_type == self.DistributionType.LINE.value: + row_size = len(context.selected_objects) + elif self.distribution_type == self.DistributionType.ROWS.value: + row_size = self.objects_in_a_row + elif self.distribution_type == self.DistributionType.SQUARE_GRID.value: + row_size = math.ceil(math.sqrt(len(context.selected_objects))) + else: + raise ValueError("Invalid distribution option") + + number_of_rows = math.ceil(len(context.selected_objects) / row_size) + + cursor_location = bpy.context.scene.cursor.location + current_row_y = cursor_location.y + selected_objects_sorted = sorted(context.selected_objects, key=lambda obj: obj.name) + for i in range(number_of_rows): + current_column_x = cursor_location.x + objects_in_row = selected_objects_sorted[i * row_size:(i + 1) * row_size] + # we need to build up a future_row_y based on placed bounding boxes if using offset + # by bounding boxes. if fixed offset is used this will just stay at current_row_y + future_row_y = current_row_y + for obj in objects_in_row: + obj.matrix_world.translation = mathutils.Vector((0.0, 0.0, 0.0)) + bbox_at_origin = hatchery.bounding_box.AlignedBox() + bbox_at_origin.extend_by_object(obj) + + if not bbox_at_origin.is_valid(): + bbox_at_origin.extend_by_point(mathutils.Vector((0.0, 0.0, 0.0))) + + obj.matrix_world.translation = mathutils.Vector(( + current_column_x, current_row_y, cursor_location[2])) + + if self.use_bounding_box_for_offset: + min_offset = bbox_at_origin.min + min_offset[2] = 0.0 + obj.matrix_world.translation -= min_offset + + bbox_placed = hatchery.bounding_box.AlignedBox() + bbox_placed.extend_by_object(obj) + + if not bbox_placed.is_valid(): + bbox_placed.extend_by_point(obj.location) + + current_column_x = bbox_placed.max.x + future_row_y = max(future_row_y, bbox_placed.max.y) + + current_column_x += self.column_x_offset + + current_row_y = future_row_y + self.row_y_offset + + return {'FINISHED'} + + +MODULE_CLASSES.append(SpreadObjects) + + @polib.log_helpers_bpy.logged_panel class EngonPanel(EngonPanelMixin, bpy.types.Panel): bl_idname = "VIEW_3D_PT_engon" @@ -323,8 +402,9 @@ def draw(self, context: bpy.types.Context): col.label(text="Convert selection:") row = polib.ui_bpy.scaled_row(col, 1.5, align=True) - row.operator(MakeSelectionLinked.bl_idname, text="Linked", icon='LINKED') - row.operator(MakeSelectionEditable.bl_idname, text="Editable", icon='MESH_DATA') + row.operator(convert_selection.MakeSelectionLinked.bl_idname, text="Linked", icon='LINKED') + row.operator(convert_selection.MakeSelectionEditable.bl_idname, + text="Editable", icon='MESH_DATA') row.prop(mapr_prefs.spawn_options, "remove_duplicates", text="", toggle=1, icon='FULLSCREEN_EXIT') col.separator() @@ -335,6 +415,8 @@ def draw(self, context: bpy.types.Context): row.operator(RandomizeTransform.bl_idname, text="Random", icon='ORIENTATION_GIMBAL') row.operator(ResetTransform.bl_idname, text="", icon='LOOP_BACK') col.separator() + row = col.row() + row.operator(SpreadObjects.bl_idname, icon='IMGDISPLAY') MODULE_CLASSES.append(EngonPanel) diff --git a/preferences/__init__.py b/preferences/__init__.py index 17ec4fc..8c09e35 100644 --- a/preferences/__init__.py +++ b/preferences/__init__.py @@ -18,6 +18,7 @@ # # ##### END GPL LICENSE BLOCK ##### +from .. import addon_updater from .. import addon_updater_ops import bpy import bpy_extras @@ -46,6 +47,26 @@ MODULE_CLASSES: typing.List[typing.Any] = [] +class ShowReleaseNotes(bpy.types.Operator): + bl_idname = "engon.show_release_notes" + bl_label = "Show Release Notes" + bl_description = "Show the release notes for the latest version of blend1" + bl_options = {'REGISTER'} + + release_tag: bpy.props.StringProperty( + name="Release Tag", + default="", + ) + + def execute(self, context: bpy.types.Context): + polib.ui_bpy.draw_release_notes( + context, polib.utils_bpy.get_top_level_package_name(__package__), self.release_tag) + return {'FINISHED'} + + +MODULE_CLASSES.append(ShowReleaseNotes) + + @polib.log_helpers_bpy.logged_preferences @addon_updater_ops.make_annotations class Preferences(bpy.types.AddonPreferences): @@ -199,7 +220,7 @@ def draw(self, context: bpy.types.Context) -> None: self, "show_updater_settings", "Updates", - functools.partial(addon_updater_ops.update_settings_ui, self, context) + functools.partial(self.draw_update_settings, context) ) box = col.box() @@ -214,6 +235,26 @@ def draw(self, context: bpy.types.Context) -> None: polib.ui_bpy.draw_settings_footer(self.layout) + def draw_update_settings(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: + col = layout.column() + addon_updater_ops.update_settings_ui(self, context, col) + split = col.split(factor=0.5) + left_row = split.row() + left_row.enabled = bool(addon_updater.Updater.update_ready) + left_row.operator( + ShowReleaseNotes.bl_idname, + text="Latest Release Notes", + icon='PRESET_NEW' + ).release_tag = "" + right_row = split.row() + current_release_tag = polib.utils_bpy.get_release_tag_from_version( + addon_updater.Updater.current_version) + right_row.operator( + ShowReleaseNotes.bl_idname, + text="Current Release Notes", + icon='PRESET' + ).release_tag = current_release_tag + def draw_save_userpref_prompt(self, layout: bpy.types.UILayout): row = layout.row() row.prop(self, "save_prefs") diff --git a/preferences/general_preferences.py b/preferences/general_preferences.py index 55cb2b3..acae7c7 100644 --- a/preferences/general_preferences.py +++ b/preferences/general_preferences.py @@ -193,6 +193,15 @@ def draw_asset_packs(self, layout: bpy.types.UILayout) -> None: op.filepath = os.path.expanduser("~" + os.sep) row.operator(PackInfoSearchPathList_RefreshPacks.bl_idname, icon='FILE_REFRESH', text="") + try: + engon_version = polib.utils_bpy.get_addon_mod_info( + polib.utils_bpy.get_top_level_package_name(__package__))["version"] + except (ValueError, KeyError): + # This shouldn't happen at all, because we are in the same __package__ that we are + # searching for the version, but just to be sure and to always display asset pack + # preferences, we catch this. + engon_version = None + for pack in asset_registry.instance.get_registered_packs(): subbox: bpy.types.UILayout = layout.box() @@ -232,6 +241,17 @@ def draw_asset_packs(self, layout: bpy.types.UILayout) -> None: label_col.label(text=f"Installation path:") value_col.label(text=f"{pack.install_path}") + if engon_version is not None and engon_version < pack.min_engon_version: + col = subbox.column(align=True) + col.label( + text=f"engon {'.'.join(map(str, pack.min_engon_version))} or newer is recommended for this Asset Pack!", + icon='ERROR' + ) + col.label( + text="Some features might not work correctly, please update engon in the " + "'Updates' section.", + ) + def draw_pack_info_search_paths( self, context: bpy.types.Context, diff --git a/preferences/mapr_preferences.py b/preferences/mapr_preferences.py index 1a1a8f5..1915c76 100644 --- a/preferences/mapr_preferences.py +++ b/preferences/mapr_preferences.py @@ -196,18 +196,6 @@ def can_spawn( "Select particle system and assign instance collection to it." ) return True, None - elif asset.type_ == mapr.asset_data.AssetDataType.blender_material: - material_assignable_objects = [ - hatchery.utils.can_have_materials_assigned(o) for o in context.selected_objects] - - # Check whether there is any selected object that has assignable material - if len(material_assignable_objects) == 0: - return False, ( - "Can't spawn material - No valid selected objects!", - "Select objects that can have material assigned." - ) - else: - return True, None elif asset.type_ == mapr.asset_data.AssetDataType.blender_particle_system: if context.active_object is None: return False, ( @@ -225,6 +213,8 @@ def can_spawn( return True, None elif asset.type_ == mapr.asset_data.AssetDataType.blender_world: return True, None + elif asset.type_ == mapr.asset_data.AssetDataType.blender_material: + return True, None elif asset.type_ == mapr.asset_data.AssetDataType.blender_geometry_nodes: if self.use_collection == 'PARTICLE_SYSTEM': return False, ( diff --git a/python_deps/hatchery/textures.py b/python_deps/hatchery/textures.py index 590fd29..460e5cb 100644 --- a/python_deps/hatchery/textures.py +++ b/python_deps/hatchery/textures.py @@ -41,7 +41,7 @@ def change_texture_size(max_size: int, image: bpy.types.Image): 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_textures, there could + # 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 diff --git a/python_deps/mapr/known_metadata.py b/python_deps/mapr/known_metadata.py index b26d14b..e6312a2 100644 --- a/python_deps/mapr/known_metadata.py +++ b/python_deps/mapr/known_metadata.py @@ -66,6 +66,9 @@ }, "Drawable": { "description": "Asset that can be drawn using pen tools" + }, + "Photoscan": { + "description": "Assets created using photogrammetry" } } @@ -252,6 +255,11 @@ "description": "Polygonal resolution of model", "search_weight": 0.0, }, + "st_original": { + "description": "Path to the original source file and optionally the object name in the source 3DShaker asset pack", + "show_filter": False, + "search_weight": 0.0, + } } diff --git a/python_deps/polib/asset_pack_bpy.py b/python_deps/polib/asset_pack_bpy.py index 774c32a..1c43300 100644 --- a/python_deps/polib/asset_pack_bpy.py +++ b/python_deps/polib/asset_pack_bpy.py @@ -3,8 +3,6 @@ import bpy import bpy.utils.previews -import os -import os.path import typing import collections import enum @@ -341,119 +339,6 @@ def is_pps(name: str) -> bool: return split[1] == PARTICLE_SYSTEM_TOKEN -def make_selection_linked( - context: bpy.types.Context, - install_paths_by_features: typing.Dict[str, typing.List[str]] -) -> typing.List[bpy.types.Object]: - previous_selection = [obj.name for obj in context.selected_objects] - previous_active_object_name = context.active_object.name if context.active_object else None - - converted_objects = [] - for obj in find_polygoniq_root_objects(context.selected_objects): - if obj.instance_type == 'COLLECTION': - continue - - path_property = obj.get("polygoniq_addon_blend_path", None) - if path_property is None: - continue - - # Particle systems are skipped. After converting to editable - # all instances of particle system are separate objects. It - # is not easy to decide which object belonged to what preset. - if path_property.startswith("blends/particles"): - continue - - # Asset Addons are now Asset Packs - # "polygoniq_addon" refers to the Asset Pack's engon feature - # TODO: Rework this after mapr_ids get merged - feature_property = obj.get("polygoniq_addon", None) - if feature_property is None: - continue - - install_paths_by_feature = install_paths_by_features.get(feature_property, None) - if install_paths_by_feature is None: - logger.warning( - f"Obj {obj.name} contains property: {feature_property} but the Asset Pack is not installed!") - continue - - asset_path: str = "" - asset_paths_list: typing.List[str] = [] - for install_path in install_paths_by_feature: - asset_path = os.path.join(install_path, os.path.normpath(path_property)) - asset_paths_list.append(f"'{asset_path}'") - if not os.path.isfile(asset_path): - logger.info( - f"Could not find {obj.name} in {asset_path} because " - f"it doesn't exist, perhaps the asset isn't in this version anymore.") - continue - if os.path.isfile(asset_path): - logger.info(f"Found {obj.name} in {asset_path}. Proceeding to linking.") - break - if not os.path.isfile(asset_path): - asset_paths_str = ", ".join(asset_paths_list) - logger.warning( - f"Cannot link {obj.name} from any of the paths: {asset_paths_str}, because " - f"it doesn't exist.") - continue - - instance_root = None - old_model_matrix = obj.matrix_world.copy() - old_collections = list(obj.users_collection) - old_color = tuple(obj.color) - old_parent = obj.parent - - # This way old object names won't interfere with the new ones - hierarchy_objects = get_hierarchy(obj) - for hierarchy_obj in hierarchy_objects: - hierarchy_obj.name = utils_bpy.generate_unique_name( - f"del_{hierarchy_obj.name}", bpy.data.objects) - - model_data = hatchery.spawn.spawn_model( - asset_path, - context, - hatchery.spawn.ModelSpawnOptions( - parent_collection=None, - select_spawned=False - ) - ) - instance_root = model_data.instancer - if instance_root is not None: - instance_root.color = old_color - - if instance_root is None: - logger.error(f"Failed to link asset {obj} with " - f"{feature_property}, instance is None") - continue - - instance_root.matrix_world = old_model_matrix - instance_root.parent = old_parent - - for coll in old_collections: - if instance_root.name not in coll.objects: - coll.objects.link(instance_root) - - converted_objects.append(instance_root) - - bpy.data.batch_remove(hierarchy_objects) - - # Force Blender to evaluate view_layer data after programmatically removing/linking objects. - # https://docs.blender.org/api/current/info_gotcha.html#no-updates-after-setting-values - context.view_layer.update() - - for obj_name in previous_selection: - obj = context.view_layer.objects.get(obj_name, None) - # Linked version doesn't necessary contain the same objects - # e. g. traffiq linked version doesn't contain wheels, brakes, ... - if obj is not None: - obj.select_set(True) - - if previous_active_object_name is not None and \ - previous_active_object_name in context.view_layer.objects: - context.view_layer.objects.active = bpy.data.objects[previous_active_object_name] - - return converted_objects - - 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: diff --git a/python_deps/polib/remove_duplicates_bpy.py b/python_deps/polib/remove_duplicates_bpy.py index c00e852..e290904 100644 --- a/python_deps/polib/remove_duplicates_bpy.py +++ b/python_deps/polib/remove_duplicates_bpy.py @@ -29,7 +29,7 @@ def polygoniq_duplicate_data_filter( if data_filepaths is None: data_filepaths = set() - KNOWN_PREFIXES = ("aq_", "bq_", "mq_", "tq_") + 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): diff --git a/python_deps/polib/ui_bpy.py b/python_deps/polib/ui_bpy.py index fcefed9..80e2fc2 100644 --- a/python_deps/polib/ui_bpy.py +++ b/python_deps/polib/ui_bpy.py @@ -5,9 +5,14 @@ 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 @@ -240,12 +245,8 @@ def add_if_exist( def expand_addon_prefs(module_name: str) -> None: """Opens preferences of an add-on 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) - mod_info["show_expanded"] = True - return - raise ValueError(f"No module '{module_name}' was found!") + 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: @@ -261,3 +262,80 @@ def draw_doc_button(layout: bpy.types.UILayout, module: str, rel_url: str = "") 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 draw_release_notes( + context: bpy.types.Context, + module_name: str, + release_tag: str = "" +) -> None: + 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' + ) diff --git a/python_deps/polib/utils_bpy.py b/python_deps/polib/utils_bpy.py index a96cf48..d071232 100644 --- a/python_deps/polib/utils_bpy.py +++ b/python_deps/polib/utils_bpy.py @@ -9,7 +9,10 @@ import typing import datetime import functools -import itertools +import urllib.request +import urllib.error +import ssl +import json import subprocess import math import time @@ -19,6 +22,7 @@ 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: @@ -208,6 +212,32 @@ def xdg_open_file(path): 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 @@ -322,13 +352,54 @@ def get_all_datablocks(data: bpy.types.BlendData) -> typing.List[typing.Tuple[bp return ret -def get_addon_docs_page(module_name: str) -> str: - """Returns url of add-on docs based on its module name.""" +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) - # 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}" + 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")