From 3f5c62292a433882cf7282346a703abe033b2ab8 Mon Sep 17 00:00:00 2001 From: Zdenek Dolezal Date: Fri, 29 Nov 2024 17:04:46 +0100 Subject: [PATCH] Updated engon from monorepo commit d04bde2c4a28f9527054baf73546db76611ab580 --- __init__.py | 25 +- aquatiq/__init__.py | 38 - aquatiq/materials.py | 71 -- aquatiq/panel.py | 499 ------------ asset_helpers.py | 11 + asset_registry.py | 8 +- blend_maintenance/asset_changes.py | 28 + blender_manifest.toml | 4 +- botaniq/panel.py | 712 ------------------ browser/browser.py | 109 ++- browser/filters.py | 45 +- browser/spawn.py | 81 +- browser/utils.py | 7 - clicker.py | 584 ++++++++++++++ convert_selection.py | 2 +- features/__init__.py | 72 ++ .../aquatiq_material_limitation_warning.py | 133 ++++ .../aquatiq_paint_mask.py | 90 ++- features/asset_pack_panels.py | 129 ++++ features/botaniq_adjustments.py | 244 ++++++ .../botaniq_animations}/__init__.py | 6 +- .../botaniq_animations}/animations.py | 81 +- .../botaniq_animations/botaniq_animations.py | 443 +++++++++++ features/colorize.py | 225 ++++-- features/emergency_lights.py | 157 ++++ features/feature_utils.py | 612 +++++++++++++++ features/license_plates_generator.py | 174 +++++ features/light_adjustments.py | 289 ++++--- {aquatiq => features}/puddles.py | 43 ++ features/rain_generator.py | 146 ++++ features/river_generator.py | 187 +++++ .../road_generator/__init__.py | 0 .../road_generator/asset_helpers.py | 0 .../road_generator/build_roads_modal.py | 34 +- .../road_generator/crossroad_builder.py | 0 {traffiq => features}/road_generator/panel.py | 26 +- {traffiq => features}/road_generator/props.py | 0 .../road_generator/road_builder.py | 0 .../road_generator/road_network.py | 0 .../road_generator/road_type.py | 0 features/traffiq_lights_settings.py | 186 +++++ features/traffiq_paint_adjustments.py | 279 +++++++ traffiq/rigs.py => features/traffiq_rigs.py | 285 ++++++- features/traffiq_wear.py | 215 ++++++ features/vegetation_generator.py | 79 ++ features/vine_generator.py | 169 +++++ interniq/__init__.py | 29 - interniq/panel.py | 54 -- keymaps.py | 5 +- materialiq/textures.py | 2 +- pack_info_search_paths.py | 10 +- preferences/__init__.py | 76 +- preferences/aquatiq_preferences.py | 51 -- preferences/botaniq_preferences.py | 264 ------- preferences/browser_preferences.py | 2 +- preferences/colorize_preferences.py | 87 --- preferences/general_preferences.py | 42 ++ preferences/light_adjustments_preferences.py | 116 --- preferences/traffiq_preferences.py | 258 ------- python_deps/hatchery/spawn.py | 5 +- python_deps/mapr/asset.py | 33 +- python_deps/mapr/category.py | 4 + python_deps/mapr/filters.py | 111 ++- python_deps/mapr/known_metadata.py | 23 +- python_deps/mapr/local_json_provider.py | 59 +- python_deps/polib/asset_pack.py | 1 + python_deps/polib/asset_pack_bpy.py | 45 +- python_deps/polib/color_utils_bpy.py | 5 + python_deps/polib/custom_props_bpy.py | 68 +- python_deps/polib/geonodes_mod_utils_bpy.py | 10 +- python_deps/polib/linalg_bpy.py | 134 ++++ python_deps/polib/node_utils_bpy.py | 42 +- python_deps/polib/render_bpy.py | 526 ++++++++++++- python_deps/polib/telemetry_module_bpy.py | 4 +- python_deps/polib/ui_bpy.py | 43 +- python_deps/polib/utils_bpy.py | 31 +- scatter.py | 20 +- traffiq/__init__.py | 38 - traffiq/lights.py | 100 --- traffiq/panel.py | 555 -------------- 80 files changed, 5989 insertions(+), 3392 deletions(-) delete mode 100644 aquatiq/__init__.py delete mode 100644 aquatiq/materials.py delete mode 100644 aquatiq/panel.py delete mode 100644 botaniq/panel.py create mode 100644 clicker.py create mode 100644 features/aquatiq_material_limitation_warning.py rename aquatiq/paint_mask.py => features/aquatiq_paint_mask.py (68%) create mode 100644 features/asset_pack_panels.py create mode 100644 features/botaniq_adjustments.py rename {botaniq => features/botaniq_animations}/__init__.py (90%) rename {botaniq => features/botaniq_animations}/animations.py (96%) create mode 100644 features/botaniq_animations/botaniq_animations.py create mode 100644 features/emergency_lights.py create mode 100644 features/feature_utils.py create mode 100644 features/license_plates_generator.py rename {aquatiq => features}/puddles.py (87%) create mode 100644 features/rain_generator.py create mode 100644 features/river_generator.py rename {traffiq => features}/road_generator/__init__.py (100%) rename {traffiq => features}/road_generator/asset_helpers.py (100%) rename {traffiq => features}/road_generator/build_roads_modal.py (96%) rename {traffiq => features}/road_generator/crossroad_builder.py (100%) rename {traffiq => features}/road_generator/panel.py (98%) rename {traffiq => features}/road_generator/props.py (100%) rename {traffiq => features}/road_generator/road_builder.py (100%) rename {traffiq => features}/road_generator/road_network.py (100%) rename {traffiq => features}/road_generator/road_type.py (100%) create mode 100644 features/traffiq_lights_settings.py create mode 100644 features/traffiq_paint_adjustments.py rename traffiq/rigs.py => features/traffiq_rigs.py (79%) create mode 100644 features/traffiq_wear.py create mode 100644 features/vegetation_generator.py create mode 100644 features/vine_generator.py delete mode 100644 interniq/__init__.py delete mode 100644 interniq/panel.py delete mode 100644 preferences/aquatiq_preferences.py delete mode 100644 preferences/botaniq_preferences.py delete mode 100644 preferences/colorize_preferences.py delete mode 100644 preferences/light_adjustments_preferences.py delete mode 100644 preferences/traffiq_preferences.py delete mode 100644 traffiq/__init__.py delete mode 100644 traffiq/lights.py delete mode 100644 traffiq/panel.py diff --git a/__init__.py b/__init__.py index b9d3d62..fe10767 100644 --- a/__init__.py +++ b/__init__.py @@ -113,12 +113,9 @@ from . import browser from . import blend_maintenance - from . import aquatiq - from . import botaniq - from . import interniq from . import materialiq - from . import traffiq from . import scatter + from . import clicker from . import features from . import keymaps @@ -135,12 +132,12 @@ bl_info = { "name": "engon", "author": "polygoniq xyz s.r.o.", - "version": (1, 3, 0), # bump doc_url and version in register as well! + "version": (1, 4, 0), # bump doc_url and version in register as well! "blender": (3, 6, 0), "location": "polygoniq tab in the sidebar of the 3D View window", "description": "", "category": "Object", - "doc_url": "https://docs.polygoniq.com/engon/1.3.0/", + "doc_url": "https://docs.polygoniq.com/engon/1.4.0/", "tracker_url": "https://polygoniq.com/discord/", } @@ -161,22 +158,19 @@ def _post_register(): def register(): # We pass mock "bl_info" to the updater, as from Blender 4.2.0, the "bl_info" is # no longer available in this scope. - addon_updater_ops.register({"version": (1, 3, 0)}) + addon_updater_ops.register({"version": (1, 4, 0)}) ui_utils.register() pack_info_search_paths.register() - preferences.register() convert_selection.register() panel.register() scatter.register() + clicker.register() blend_maintenance.register() browser.register() - aquatiq.register() - botaniq.register() - interniq.register() materialiq.register() - traffiq.register() features.register() + preferences.register() keymaps.register() bpy.app.timers.register( @@ -192,18 +186,15 @@ def register(): def unregister(): keymaps.unregister() + preferences.unregister() features.unregister() - traffiq.unregister() materialiq.unregister() - interniq.unregister() - botaniq.unregister() - aquatiq.unregister() browser.unregister() blend_maintenance.unregister() + clicker.unregister() scatter.unregister() panel.unregister() convert_selection.unregister() - preferences.unregister() pack_info_search_paths.unregister() ui_utils.unregister() diff --git a/aquatiq/__init__.py b/aquatiq/__init__.py deleted file mode 100644 index e1e9914..0000000 --- a/aquatiq/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -# 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 ##### - -from . import paint_mask -from . import puddles -from . import panel -from . import materials - - -def register(): - paint_mask.register() - puddles.register() - panel.register() - materials.register() - - -def unregister(): - materials.unregister() - panel.unregister() - puddles.unregister() - paint_mask.unregister() diff --git a/aquatiq/materials.py b/aquatiq/materials.py deleted file mode 100644 index b73c781..0000000 --- a/aquatiq/materials.py +++ /dev/null @@ -1,71 +0,0 @@ -# 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 logging -from .. import polib - -logger = logging.getLogger(f"polygoniq.{__name__}") - - -MODULE_CLASSES: typing.List[typing.Type] = [] - - -class MaterialWarning: - VOLUME = "Object has to have volume for this material to work correctly." - SHORELINE = ( - "Material is from the complex shoreline scene, open it to see how it works with\n" - "other materials to create the best result." - ) - - -MATERIAL_WARNING_MAP = { - "aq_Water_Ocean": {MaterialWarning.SHORELINE, MaterialWarning.VOLUME}, - "aq_Water_Shoreline": {MaterialWarning.SHORELINE}, - "aq_Water_SwimmingPool": {MaterialWarning.VOLUME}, - "aq_Water_Lake": {MaterialWarning.VOLUME}, - "aq_Water_Pond": {MaterialWarning.VOLUME}, -} - - -def get_material_warnings_obj_based(obj: bpy.types.Object, material_name: str) -> typing.Set[str]: - - warnings = MATERIAL_WARNING_MAP.get(material_name, None) - if warnings is None: - return set() - - # Create copy of the original set so the original isn't modified - warnings = set(warnings) - if not polib.linalg_bpy.is_obj_flat(obj): - warnings -= {MaterialWarning.VOLUME} - - return warnings - - -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/aquatiq/panel.py b/aquatiq/panel.py deleted file mode 100644 index 213e026..0000000 --- a/aquatiq/panel.py +++ /dev/null @@ -1,499 +0,0 @@ -# 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 -from .. import polib -from . import paint_mask -from . import puddles -from . import materials -from .. import preferences -from .. import asset_registry -from .. import asset_helpers -from .. import ui_utils -from .. import __package__ as base_package - - -AQ_PAINT_VERTICES_WARNING_THRESHOLD = 16 - - -MODULE_CLASSES: typing.List[typing.Type] = [] - - -class AquatiqPanelInfoMixin: - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "polygoniq" - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - return len(asset_registry.instance.get_packs_by_engon_feature("aquatiq")) > 0 - - -class RainGeneratorPanelMixin( - AquatiqPanelInfoMixin, polib.geonodes_mod_utils_bpy.GeoNodesModifierInputsPanelMixin -): - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - obj = context.active_object - if obj is None: - return False - return ( - len( - polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - obj, asset_helpers.AQ_RAIN_GENERATOR_NODE_GROUP_NAME - ) - ) - > 0 - ) - - -class RiverGeneratorPanelMixin( - AquatiqPanelInfoMixin, polib.geonodes_mod_utils_bpy.GeoNodesModifierInputsPanelMixin -): - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - obj = context.active_object - if obj is None: - return False - return ( - len( - polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - obj, asset_helpers.AQ_RIVER_GENERATOR_NODE_GROUP_NAME - ) - ) - > 0 - ) - - -@polib.log_helpers_bpy.logged_panel -class AquatiqPanel(AquatiqPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_aquatiq" - bl_label = "aquatiq" - bl_order = 10 - bl_options = {'DEFAULT_CLOSED'} - - def draw_header(self, context: bpy.types.Context): - self.layout.label( - text="", icon_value=polib.ui_bpy.icon_manager.get_engon_feature_icon_id("aquatiq") - ) - - def draw_header_preset(self, context: bpy.types.Context) -> None: - polib.ui_bpy.draw_doc_button( - self.layout, - base_package, - rel_url="panels/aquatiq/panel_overview", - ) - - def draw(self, context: bpy.types.Context): - pass - - -MODULE_CLASSES.append(AquatiqPanel) - - -class MaterialsPanel(AquatiqPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_aquatiq_materials" - bl_parent_id = AquatiqPanel.bl_idname - bl_label = "Material Adjustments" - - def draw_header(self, context: bpy.types.Context) -> None: - self.layout.label(text="", icon='MOD_OCEAN') - - def draw_header_preset(self, context: bpy.types.Context) -> None: - self.draw_material_limitations(self.layout, context.active_object) - - def draw_material_limitations(self, layout: bpy.types.UILayout, obj: bpy.types.Object): - if obj is None: - return - - active_material = obj.active_material - if active_material is None: - return - - material_name = polib.utils_bpy.remove_object_duplicate_suffix(active_material.name) - warnings = materials.get_material_warnings_obj_based(obj, material_name) - - if len(warnings) == 0: - return - - layout.alert = True - op = layout.operator(ui_utils.ShowPopup.bl_idname, text="", icon='ERROR') - op.message = "\n".join(warnings) - op.title = "Material limitations warning" - op.icon = 'ERROR' - - def draw_vertex_paint_ui(self, context: bpy.types.Context): - layout = self.layout - prefs = preferences.prefs_utils.get_preferences(context).aquatiq_preferences - brush = context.tool_settings.vertex_paint.brush - unified_settings = context.tool_settings.unified_paint_settings - - if context.vertex_paint_object is None or context.vertex_paint_object.data is None: - return - - mesh_data = context.vertex_paint_object.data - - if len(mesh_data.vertices) <= AQ_PAINT_VERTICES_WARNING_THRESHOLD: - layout.label(text="Subdivide the mesh for more control!", icon='ERROR') - - col = layout.column(align=True) - polib.ui_bpy.row_with_label(col, text="Mask") - row = col.row(align=True) - row.prop(prefs, "draw_mask_factor", slider=True) - # Wrap color in another row to scale it so it is more rectangular - color_wrapper = row.row(align=True) - color_wrapper.scale_x = 0.3 - color_wrapper.prop(brush, "color", text="") - - col = layout.column(align=True) - row = col.row(align=True) - row.operator( - paint_mask.ApplyMask.bl_idname, text="Boundaries", icon='MATPLANE' - ).only_boundaries = True - row.operator( - paint_mask.ApplyMask.bl_idname, text="Fill", icon='SNAP_FACE' - ).only_boundaries = False - - col = layout.column(align=True) - polib.ui_bpy.row_with_label(col, text="Brush") - col.prop(brush, "strength") - col.prop(unified_settings, "size", slider=True) - - col = layout.column(align=True) - polib.ui_bpy.row_with_label(col, text="Paint Only to Selected") - row = col.row(align=True) - row.prop(mesh_data, "use_paint_mask_vertex", text="Vertex") - row.prop(mesh_data, "use_paint_mask", text="Face") - - layout.operator(paint_mask.ReturnToObjectMode.bl_idname, text="Return", icon='LOOP_BACK') - - def draw(self, context: bpy.types.Context): - if context.mode == 'PAINT_VERTEX': - self.draw_vertex_paint_ui(context) - return - - layout: bpy.types.UILayout = self.layout - - asset_col = layout.column(align=True) - polib.ui_bpy.scaled_row(asset_col, 1.2).operator( - paint_mask.EnterVertexPaintMode.bl_idname, text="Paint Alpha Mask", icon='MOD_MASK' - ) - - -MODULE_CLASSES.append(MaterialsPanel) - - -class PuddlesPanel(AquatiqPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_aquatiq_puddles" - bl_parent_id = MaterialsPanel.bl_idname - bl_label = "Puddles" - - template = polib.node_utils_bpy.NodeSocketsDrawTemplate( - asset_helpers.AQ_PUDDLES_NODEGROUP_NAME, - filter_=lambda x: not polib.node_utils_bpy.filter_node_socket_name( - x, - "Water Color", - "Noise Scale", - ), - ) - - @classmethod - def poll(self, context: bpy.types.Context) -> bool: - return context.mode != 'PAINT_VERTEX' - - def draw_header(self, context: bpy.types.Context): - self.layout.label(text="", icon='MATFLUID') - - def draw(self, context: bpy.types.Context): - layout: bpy.types.UILayout = self.layout - - layout.operator(puddles.AddPuddles.bl_idname, icon='ADD') - layout.operator(puddles.RemovePuddles.bl_idname, icon='PANEL_CLOSE') - - if context.active_object is not None and puddles.check_puddles_nodegroup_count( - [context.active_object], lambda x: x != 0 - ): - col = layout.column(align=True) - PuddlesPanel.template.draw_from_material(context.active_object.active_material, col) - - -MODULE_CLASSES.append(PuddlesPanel) - - -class RainGeneratorPanel(RainGeneratorPanelMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_aquatiq_rain_generator" - bl_parent_id = AquatiqPanel.bl_idname - bl_label = "Rain Generator" - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - return ( - super().poll(context) - or bpy.data.node_groups.get(asset_helpers.AQ_RAIN_GENERATOR_NODE_GROUP_NAME, None) - is not None - ) - - def draw_header(self, context: bpy.types.Context) -> None: - self.layout.label(text="", icon='OUTLINER_DATA_LIGHTPROBE') - - def draw(self, context: bpy.types.Context): - layout: bpy.types.UILayout = self.layout - obj = context.active_object - if ( - obj is None - or len( - polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - obj, asset_helpers.AQ_RAIN_GENERATOR_NODE_GROUP_NAME - ) - ) - == 0 - ): - layout.label(text="Select a Rain Generator object") - - -MODULE_CLASSES.append(RainGeneratorPanel) - - -class RainGeneratorGeneralAdjustmentsPanel(RainGeneratorPanelMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_aquatiq_rain_generator_general_adjustments" - bl_parent_id = RainGeneratorPanel.bl_idname - bl_label = "General Adjustments" - - template = polib.node_utils_bpy.NodeSocketsDrawTemplate( - asset_helpers.AQ_RAIN_GENERATOR_NODE_GROUP_NAME, - filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name( - x, "Self Object", "Realize Instances", "Collision", "Rain", "Randomize" - ), - socket_names_drawn_first=[ - "Self Object", - "Collision Collection", - ], - ) - - def draw(self, context: bpy.types.Context): - self.draw_active_object_modifiers_node_group_inputs_template( - self.layout, - context, - RainGeneratorGeneralAdjustmentsPanel.template, - ) - - -MODULE_CLASSES.append(RainGeneratorGeneralAdjustmentsPanel) - - -class RainGeneratorSplashEffectsPanel(RainGeneratorPanelMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_aquatiq_rain_generator_splash_effects" - bl_parent_id = RainGeneratorPanel.bl_idname - bl_label = "Splash Effects" - bl_options = {'DEFAULT_CLOSED'} - - template = polib.node_utils_bpy.NodeSocketsDrawTemplate( - asset_helpers.AQ_RAIN_GENERATOR_NODE_GROUP_NAME, - filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name(x, "Splashes", "2D Effects"), - ) - - def draw(self, context: bpy.types.Context): - self.draw_active_object_modifiers_node_group_inputs_template( - self.layout, - context, - RainGeneratorSplashEffectsPanel.template, - ) - - -MODULE_CLASSES.append(RainGeneratorSplashEffectsPanel) - - -class RainGeneratorCameraAdjustmentsPanel(RainGeneratorPanelMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_aquatiq_rain_generator_camera_adjustments" - bl_parent_id = RainGeneratorPanel.bl_idname - bl_label = "Camera Adjustments" - bl_options = {'DEFAULT_CLOSED'} - - template = polib.node_utils_bpy.NodeSocketsDrawTemplate( - asset_helpers.AQ_RAIN_GENERATOR_NODE_GROUP_NAME, - filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name(x, "Camera", "Culling"), - socket_names_drawn_first=["Camera Culling Camera"], - ) - - def draw(self, context: bpy.types.Context): - self.draw_active_object_modifiers_node_group_inputs_template( - self.layout, - context, - RainGeneratorCameraAdjustmentsPanel.template, - ) - - -MODULE_CLASSES.append(RainGeneratorCameraAdjustmentsPanel) - - -class RiverGeneratorPanel(RiverGeneratorPanelMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_aquatiq_river_generator" - bl_parent_id = AquatiqPanel.bl_idname - bl_label = "River Generator" - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - return ( - super().poll(context) - or bpy.data.node_groups.get(asset_helpers.AQ_RIVER_GENERATOR_NODE_GROUP_NAME, None) - is not None - ) - - def draw_header(self, context: bpy.types.Context) -> None: - self.layout.label(text="", icon='FORCE_FORCE') - - def draw(self, context: bpy.types.Context): - layout: bpy.types.UILayout = self.layout - obj = context.active_object - if ( - obj is None - or len( - polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - obj, asset_helpers.AQ_RIVER_GENERATOR_NODE_GROUP_NAME - ) - ) - == 0 - ): - layout.label(text="Select a River Generator object") - - -MODULE_CLASSES.append(RiverGeneratorPanel) - - -class RiverGeneratorGeneralAdjustmentsPanel(RiverGeneratorPanelMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_aquatiq_river_generator_general_adjustments" - bl_parent_id = RiverGeneratorPanel.bl_idname - bl_label = "General Adjustments" - - template = polib.node_utils_bpy.NodeSocketsDrawTemplate( - asset_helpers.AQ_RIVER_GENERATOR_NODE_GROUP_NAME, - filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name( - x, - "Self Object", - "Resolution", - "Width", - "Depth", - "Seed", - "Animation Speed", - ) - and not polib.node_utils_bpy.filter_node_socket_name( - x, - "Bank Width", - ), - socket_names_drawn_first=["Self Object"], - ) - - def draw(self, context: bpy.types.Context): - self.draw_active_object_modifiers_node_group_inputs_template( - self.layout, - context, - RiverGeneratorGeneralAdjustmentsPanel.template, - ) - - -MODULE_CLASSES.append(RiverGeneratorGeneralAdjustmentsPanel) - - -class RiverGeneratorBankRiverbedAdjustmentsPanel(RiverGeneratorPanelMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_aquatiq_river_generator_bank_riverbed_adjustments" - bl_parent_id = RiverGeneratorPanel.bl_idname - bl_label = "Bank and Riverbed Adjustments" - bl_options = {'DEFAULT_CLOSED'} - - template = polib.node_utils_bpy.NodeSocketsDrawTemplate( - asset_helpers.AQ_RIVER_GENERATOR_NODE_GROUP_NAME, - filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name(x, "Bank", "Riverbed"), - socket_names_drawn_first=["Bank Material", "Riverbed Material"], - ) - - def draw(self, context: bpy.types.Context): - self.draw_active_object_modifiers_node_group_inputs_template( - self.layout, - context, - RiverGeneratorBankRiverbedAdjustmentsPanel.template, - ) - - -MODULE_CLASSES.append(RiverGeneratorBankRiverbedAdjustmentsPanel) - - -class RiverGeneratorScatterPanel(RiverGeneratorPanelMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_aquatiq_river_generator_scatter" - bl_parent_id = RiverGeneratorPanel.bl_idname - bl_label = "Rocks and Vegetation" - bl_options = {'DEFAULT_CLOSED'} - - template = polib.node_utils_bpy.NodeSocketsDrawTemplate( - asset_helpers.AQ_RIVER_GENERATOR_NODE_GROUP_NAME, - filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name(x, "Vegetation", "Rocks"), - socket_names_drawn_first=["Rocks", "Vegetation"], - ) - - def draw(self, context: bpy.types.Context): - self.draw_active_object_modifiers_node_group_inputs_template( - self.layout, - context, - RiverGeneratorScatterPanel.template, - ) - - -MODULE_CLASSES.append(RiverGeneratorScatterPanel) - - -class RiverGeneratorAdvancedAdjustmentsPanel(RiverGeneratorPanelMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_aquatiq_river_generator_advanced_adjustments" - bl_parent_id = RiverGeneratorPanel.bl_idname - bl_label = "Advanced Adjustments" - bl_options = {'DEFAULT_CLOSED'} - - template = polib.node_utils_bpy.NodeSocketsDrawTemplate( - asset_helpers.AQ_RIVER_GENERATOR_NODE_GROUP_NAME, - filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name( - x, "Noise", "Foam", "Caustic", "Collision" - ) - and not polib.node_utils_bpy.filter_node_socket_name( - x, - "Rocks Collision Complexity", - ), - socket_names_drawn_first=["Collision"], - ) - - def draw(self, context: bpy.types.Context): - self.draw_active_object_modifiers_node_group_inputs_template( - self.layout, - context, - RiverGeneratorAdvancedAdjustmentsPanel.template, - ) - - -MODULE_CLASSES.append(RiverGeneratorAdvancedAdjustmentsPanel) - - -def register(panel_name: str = "aquatiq"): - AquatiqPanel.bl_label = panel_name - 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/asset_helpers.py b/asset_helpers.py index 45cc34f..f26e8d2 100644 --- a/asset_helpers.py +++ b/asset_helpers.py @@ -48,11 +48,13 @@ BOTANIQ_ALL_SEASONS_RAW = "spring-summer-autumn-winter" BQ_COLLECTION_NAME = "botaniq" BQ_VINE_GENERATOR_NODE_GROUP_NAME = "bq_Vine_Generator" +BQ_CURVES_GENERATOR_NODE_GROUP_NAME = "bq_Generator_Curves" BQ_ANIM_LIBRARY_BLEND = "bq_Library_Animation_Data.blend" # traffiq constants TQ_MODIFIER_LIBRARY_BLEND = "tq_Library_Modifiers.blend" TQ_EMERGENCY_LIGHTS_NODE_GROUP_NAME = "tq_Emergency_Lights" +TQ_LICENSE_PLATE_NODE_GROUP_NAME_PREFIX = "tq_License-Plate_" PARTICLE_SYSTEM_PREFIX = f"engon_{polib.asset_pack.PARTICLE_SYSTEM_TOKEN}_" @@ -100,9 +102,18 @@ def is_obj_with_engon_feature( mapr_asset_id = obj.get("mapr_asset_id", None) if mapr_asset_id is None: + # The asset might be ours but if it doesn't have a MAPR ID we can't figure out which asset + # pack it's from. return False asset_pack = asset_registry.instance.get_asset_pack_of_asset(mapr_asset_id) + if asset_pack is None: + # The asset has a mapr ID but we can't find an enabled asset pack that has it + # this is unusual and most probably somebody spawned an asset from an asset pack, then + # disabled or uninstalled the pack. Either way we can't figure out which engon feature it + # has if it's not present. + return False + return feature in asset_pack.engon_features diff --git a/asset_registry.py b/asset_registry.py index 8a71ddb..7476d79 100644 --- a/asset_registry.py +++ b/asset_registry.py @@ -166,8 +166,6 @@ def __init__( # Which engon set of features should be enable when this asset pack is present. For # example the core botaniq assets open the botaniq features - scatter, wind animation, etc. # The same features are also opened by the Evermotion asset packs. - if len(engon_features) == 0: - raise NotImplementedError("At least one engon feature required in each asset pack!") self.engon_features = engon_features self.min_engon_version = min_engon_version self.install_path = install_path @@ -198,7 +196,7 @@ def __init__( # we remember which providers we registered to MAPR to be able to unregister them self.asset_providers: typing.List[mapr.asset_provider.AssetProvider] = [] - self.file_providers: typing.List[mapr.asset_provider.FileProvider] = [] + self.file_providers: typing.List[mapr.file_provider.FileProvider] = [] # we remember root pack multiplexers to be able to query against one pack easily self.asset_multiplexer: typing.Optional[mapr.asset_provider.AssetProviderMultiplexer] = None @@ -400,10 +398,10 @@ def __init__(self): collections.defaultdict(list) ) self._packs_by_pack_info_path: typing.Dict[str, AssetPack] = {} - self.master_asset_provider: mapr.asset_provider.AssetProvider = ( + self.master_asset_provider: mapr.asset_provider.AssetProviderMultiplexer = ( mapr.asset_provider.CachedAssetProviderMultiplexer() ) - self.master_file_provider: mapr.file_provider.FileProvider = ( + self.master_file_provider: mapr.file_provider.FileProviderMultiplexer = ( mapr.file_provider.FileProviderMultiplexer() ) self.on_changed: typing.List[ diff --git a/blend_maintenance/asset_changes.py b/blend_maintenance/asset_changes.py index 76c81a2..1bd785c 100644 --- a/blend_maintenance/asset_changes.py +++ b/blend_maintenance/asset_changes.py @@ -26,6 +26,33 @@ class AssetPackMigrations(typing.NamedTuple): # versions in the names reflect the last version of asset pack without given changes + +_botaniq_7_0_0_drop_dead_from_leaf_names = { + r"^bq_Leaf-Dead-Group_Fagus-sylvatica_([A])_spring-summer": r"bq_Leaf-Group_Fagus-sylvatica_\1_spring-summer", + r"^bq_Leaf-Dead_Fagus-sylvatica_([ABC])_spring-summer-autumn-winter": r"bq_Leaf_Fagus-sylvatica_\1_spring-summer-autumn-winter", +} + +botaniq_7_0_0_drop_dead_from_leaf_names = AssetPackMigration( + [ + RegexMapping(re.compile(f"{pattern}.blend$"), f"{replacement}.blend") + for pattern, replacement in _botaniq_7_0_0_drop_dead_from_leaf_names.items() + ], + { + "collections": [ + RegexMapping(re.compile(pattern), replacement) + for pattern, replacement in _botaniq_7_0_0_drop_dead_from_leaf_names.items() + ], + "meshes": [ + RegexMapping(re.compile(pattern), replacement) + for pattern, replacement in _botaniq_7_0_0_drop_dead_from_leaf_names.items() + ], + "objects": [ + RegexMapping(re.compile(pattern), replacement) + for pattern, replacement in _botaniq_7_0_0_drop_dead_from_leaf_names.items() + ], + }, +) + botaniq_6_8_0_unify_bq_prefix = AssetPackMigration( [ RegexMapping(re.compile(r"^(?!bq_|Library_Botaniq)([^\\/]+)(\.blend$)"), r"bq_\1\2"), @@ -292,6 +319,7 @@ class AssetPackMigrations(typing.NamedTuple): botaniq_6_8_0_rename_vases_to_pots, botaniq_6_8_0_english_names_to_latin, botaniq_6_8_0_decapitalize_cortaderia, + botaniq_7_0_0_drop_dead_from_leaf_names, ], ), AssetPackMigrations( diff --git a/blender_manifest.toml b/blender_manifest.toml index d6bf676..04da7ed 100644 --- a/blender_manifest.toml +++ b/blender_manifest.toml @@ -1,13 +1,13 @@ schema_version = "1.0.0" id = "engon" -version = "1.3.0" +version = "1.4.0" name = "engon" tagline = "Browse assets, filter and sort, scatter, animate, adjust rigs" maintainer = "polygoniq " type = "add-on" -website = "https://docs.polygoniq.com/engon/1.3.0/" +website = "https://docs.polygoniq.com/engon/1.4.0/" tags = ["Add Mesh", "Animation", "Material", "Mesh", "Object", "Scene", "User Interface"] blender_version_min = "4.2.0" diff --git a/botaniq/panel.py b/botaniq/panel.py deleted file mode 100644 index a42b630..0000000 --- a/botaniq/panel.py +++ /dev/null @@ -1,712 +0,0 @@ -# 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 re -import random -import bpy -import itertools -import typing -import math -from . import animations -from .. import polib -from .. import asset_helpers -from .. import asset_registry -from .. import preferences -from .. import __package__ as base_package -import logging - -logger = logging.getLogger(f"polygoniq.{__name__}") - - -MODULE_CLASSES: typing.List[typing.Type] = [] - - -@polib.log_helpers_bpy.logged_operator -class SetColor(bpy.types.Operator): - bl_idname = "engon.botaniq_set_color_of_active_obj" - bl_label = "Set Color Of Active Object" - bl_description = "Set color of the active object" - bl_options = {'REGISTER', 'UNDO'} - - color: bpy.props.FloatVectorProperty( - name="Color", subtype='COLOR', default=(1.0, 1.0, 1.0, 1.0), size=4, min=0.0, max=1.0 - ) - - def execute(self, context: bpy.types.Context): - context.active_object.color = self.color - return {'FINISHED'} - - -MODULE_CLASSES.append(SetColor) - - -@polib.log_helpers_bpy.logged_operator -class RandomizeFloatProperty(bpy.types.Operator): - bl_idname = "engon.botaniq_randomize_float_property" - bl_label = "Randomize Float Property" - bl_description = ( - "Set random value from specified interval for a custom property of selected objects" - ) - bl_options = {'REGISTER', 'UNDO'} - - custom_property_name: bpy.props.StringProperty(options={'HIDDEN'}) - - def draw(self, context: bpy.types.Context): - layout = self.layout - prefs = preferences.prefs_utils.get_preferences(context).botaniq_preferences - layout.prop(prefs, "float_min", slider=True) - layout.prop(prefs, "float_max", slider=True) - - def invoke(self, context: bpy.types.Context, event: bpy.types.Event): - return context.window_manager.invoke_props_dialog(self) - - def execute(self, context: bpy.types.Context): - prefs = preferences.prefs_utils.get_preferences(context).botaniq_preferences - for obj in set(context.selected_objects).union( - asset_helpers.gather_instanced_objects(context.selected_objects) - ): - custom_prop = obj.get(self.custom_property_name, None) - if custom_prop is None: - continue - random_value = random.uniform(prefs.float_min, prefs.float_max) - polib.custom_props_bpy.update_custom_prop( - context, [obj], self.custom_property_name, random_value - ) - - logger.info( - f"Property {self.custom_property_name} randomized on asset {obj.name} with " - f"range ({prefs.float_min, prefs.float_max}) and resulting value {random_value}" - ) - return {'FINISHED'} - - -MODULE_CLASSES.append(RandomizeFloatProperty) - - -class BotaniqPanelInfoMixin: - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "polygoniq" - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - return len(asset_registry.instance.get_packs_by_engon_feature("botaniq")) > 0 - - -@polib.log_helpers_bpy.logged_panel -class BotaniqPanel(BotaniqPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_botaniq" - bl_label = "botaniq" - bl_order = 10 - bl_options = {'DEFAULT_CLOSED'} - - def draw_header(self, context: bpy.types.Context): - self.layout.label( - text="", icon_value=polib.ui_bpy.icon_manager.get_engon_feature_icon_id("botaniq") - ) - - def draw_header_preset(self, context: bpy.types.Context) -> None: - polib.ui_bpy.draw_doc_button( - self.layout, - base_package, - rel_url="panels/botaniq/panel_overview", - ) - - def draw(self, context: bpy.types.Context): - pass - - -MODULE_CLASSES.append(BotaniqPanel) - - -class AdjustmentMixin: - @classmethod - def has_pps(cls, obj: bpy.types.Object) -> bool: - for particle_system in obj.particle_systems: - if polib.asset_pack.is_pps_name(particle_system.name): - return True - return False - - @classmethod - def get_selected_botaniq_assets( - cls, context: bpy.types.Context - ) -> typing.Iterable[bpy.types.Object]: - objects = set(context.selected_objects) - if context.active_object is not None: - objects.add(context.active_object) - - return filter( - lambda obj: asset_helpers.is_obj_with_engon_feature(obj, "botaniq"), - polib.asset_pack_bpy.find_polygoniq_root_objects(objects), - ) - - @classmethod - def get_selected_particle_system_targets( - cls, context: bpy.types.Context - ) -> typing.Iterable[bpy.types.Object]: - objects = set(context.selected_objects) - if context.active_object is not None: - objects.add(context.active_object) - return filter(lambda obj: AdjustmentMixin.has_pps(obj), objects) - - -@polib.log_helpers_bpy.logged_panel -class AdjustmentsPanel(BotaniqPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_botaniq_adjustments" - bl_parent_id = BotaniqPanel.bl_idname - bl_label = "Adjustments" - - # The maximum number of assets to be displayed with details in the panel - MAX_DISPLAYED_ASSETS = 10 - - def draw_header(self, context: bpy.types.Context) -> None: - self.layout.label(text="", icon='MOD_HUE_SATURATION') - - def get_season_from_value(self, value: float) -> str: - # We need to change seasons at 0.125, 0.325, 0.625, 0.875 - # The list of seasons and values holds the "center" value of the season, not the boundaries - # We need to do a bit of math to move it to get proper ranges. -0.125 moves from center to - # start of the range, + 1.0 because fmod doesn't work with negative values - adjusted_value: float = math.fmod(value - 0.125 + 1.0, 1.0) - for season, max_value in reversed(polib.asset_pack.BOTANIQ_SEASONS_WITH_COLOR_CHANNEL): - if adjusted_value <= max_value: - return season - return "unknown" - - def draw_obj_properties( - self, - obj: bpy.types.Object, - left_col: bpy.types.UILayout, - right_col: bpy.types.UILayout, - spaces: int = 0, - ) -> None: - row = left_col.row() - row.label(text=f"{spaces * ' '}{obj.name}") - row = right_col.row() - - brightness = obj.get(polib.custom_props_bpy.CustomPropertyNames.BQ_BRIGHTNESS, None) - season = obj.get(polib.custom_props_bpy.CustomPropertyNames.BQ_SEASON_OFFSET, None) - random_per_branch = obj.get( - polib.custom_props_bpy.CustomPropertyNames.BQ_RANDOM_PER_BRANCH, None - ) - random_per_leaf = obj.get( - polib.custom_props_bpy.CustomPropertyNames.BQ_RANDOM_PER_LEAF, None - ) - if brightness is not None: - row.label(text=f"{brightness:.2f}") - else: - row.label(text="-") - - if season is not None: - row.label(text=f"{self.get_season_from_value(season)}") - else: - row.label(text="-") - - if random_per_branch is not None: - row.label(text=f"{random_per_branch:.2f}") - else: - row.label(text="-") - - if random_per_leaf is not None: - row.label(text=f"{random_per_leaf:.2f}") - else: - row.label(text="-") - - def draw(self, context: bpy.types.Context): - layout = self.layout - - # we can only make the adjustments on botaniq or botaniq asset pack assets or targets of - # botaniq particle systems - assets = set(AdjustmentMixin.get_selected_botaniq_assets(context)) - ps_objects = set(AdjustmentMixin.get_selected_particle_system_targets(context)) - ps_object_to_instanced_objects = { - obj: set(asset_helpers.gather_instanced_objects([obj])) for obj in ps_objects - } - # Objects that are not in particle systems and are not particle system containers - assets = ( - assets - ps_objects - set(itertools.chain(*ps_object_to_instanced_objects.values())) - ) - if len(assets) == 0 and len(ps_objects) == 0: - layout.label(text="No botaniq assets or particle systems selected!") - return - - row = layout.row() - left_col = row.column(align=True) - left_col.scale_x = 2.0 - right_col = row.column(align=True) - row = left_col.row() - row.enabled = False - row.label(text="Selected Assets:") - row = right_col.row() - row.enabled = False - row.label(text="Brightness") - row.label(text="Season") - row.label(text="Branch Hue") - row.label(text="Leaf Hue") - - displayed_assets = 0 - for obj in assets: - if displayed_assets >= AdjustmentsPanel.MAX_DISPLAYED_ASSETS: - left_col.label(text=f"... and {len(assets) - displayed_assets} additional asset(s)") - break - - self.draw_obj_properties(obj, left_col, right_col) - displayed_assets += 1 - - # Let's always draw the objects of the particle system, don't trim the objects inside - for i, (scatter_obj, instanced_objects) in enumerate( - ps_object_to_instanced_objects.items() - ): - if displayed_assets >= AdjustmentsPanel.MAX_DISPLAYED_ASSETS: - left_col.label( - text=f"... and {len(ps_object_to_instanced_objects) - i} additional scatter(s)" - ) - break - - left_col.label(text=scatter_obj.name, icon='PARTICLES') - # Empty text to just keep the layout flow - right_col.label(text="") - for obj in instanced_objects: - self.draw_obj_properties(obj, left_col, right_col, spaces=4) - displayed_assets += 1 - - prefs = preferences.prefs_utils.get_preferences(context).botaniq_preferences - row = layout.row(align=True) - row.label(text="", icon='LIGHT_SUN') - row.prop(prefs, "brightness", text="Brightness", slider=True) - row.operator( - RandomizeFloatProperty.bl_idname, text="", icon='FILE_3D' - ).custom_property_name = polib.custom_props_bpy.CustomPropertyNames.BQ_BRIGHTNESS - - row = layout.row(align=True) - row.label(text="", icon='BRUSH_MIX') - row.prop( - prefs, - "season_offset", - icon='BRUSH_MIX', - text=f"Season: {self.get_season_from_value(prefs.season_offset)}", - slider=True, - ) - row.operator( - RandomizeFloatProperty.bl_idname, text="", icon='FILE_3D' - ).custom_property_name = polib.custom_props_bpy.CustomPropertyNames.BQ_SEASON_OFFSET - - row = layout.row(align=True) - row.label(text="", icon='COLORSET_12_VEC') - row.prop(prefs, "hue_per_branch", text="Hue per Branch", slider=True) - row.operator( - RandomizeFloatProperty.bl_idname, text="", icon='FILE_3D' - ).custom_property_name = polib.custom_props_bpy.CustomPropertyNames.BQ_RANDOM_PER_BRANCH - - row = layout.row(align=True) - row.label(text="", icon='COLORSET_02_VEC') - row.prop(prefs, "hue_per_leaf", text="Hue per Leaf", slider=True) - row.operator( - RandomizeFloatProperty.bl_idname, text="", icon='FILE_3D' - ).custom_property_name = polib.custom_props_bpy.CustomPropertyNames.BQ_RANDOM_PER_LEAF - - -MODULE_CLASSES.append(AdjustmentsPanel) - - -@polib.log_helpers_bpy.logged_panel -class AnimationsPanel(BotaniqPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_botaniq_animations" - bl_parent_id = BotaniqPanel.bl_idname - bl_label = "Animations" - - def draw_header(self, context: bpy.types.Context): - self.layout.label(text="", icon='FORCE_WIND') - - def draw_object_anim_details( - self, - context: bpy.types.Context, - layout: bpy.types.UILayout, - obj: bpy.types.Object, - animated_object: bpy.types.Object, - ) -> None: - """Draws animation details of 'obj' into 'layout' based on 'context' and 'animated_obj'. - - 'animated_obj' is the animated object of 'obj' which can be either itself or the object - from it's instanced collection. Check 'animations.get_instanced_mesh_object'. - """ - col = layout.column(align=True) - col.label(text="Active Object", icon='INFO') - if obj.instance_collection is None: - col.label(text=obj.name, icon='OBJECT_DATA') - else: - split = col.split(factor=0.75, align=True) - split.label(text=obj.name, icon='OUTLINER_COLLECTION') - split.operator( - animations.AnimationMakeInstanceUnique.bl_idname, - text=f"{obj.instance_collection.users - 1}", - ) - - action = animated_object.animation_data.action - animation_type, preset = animations.parse_action_name(action) - animation_style = animations.get_wind_style(action) - - col = layout.column(align=True) - split_factor = 0.5 - # Animation Type - split = col.split(factor=split_factor, align=True) - split.label(text="Animation:") - split.label(text=str(animation_type)) - - # Preset - split = col.split(factor=split_factor, align=True) - split.label(text="Preset:") - split.label(text=str(preset)) - - # Style - split = col.split(factor=split_factor, align=True) - split.label(text="Style:") - split.label(text=str(animation_style.value)) - - # Strength - wind_strength = animations.infer_strength_from_action(action) - if wind_strength is not None: - split = col.split(factor=split_factor, align=True) - split.label(text="Strength:") - split.label(text=f"{wind_strength:.3f}x") - - if animation_style == preferences.botaniq_preferences.WindStyle.LOOP: - frame_range = animations.get_frame_range(action) - if frame_range is not None: - # Loop Interval - loop_interval = frame_range[1] - frame_range[0] - split = col.split(factor=split_factor, align=True) - split.label(text="Loop Interval:") - split.label(text=f"{round(loop_interval)} frames") - - # Duration - scene_interval = context.scene.frame_end - context.scene.frame_start - scene_fps = animations.get_scene_fps( - context.scene.render.fps, context.scene.render.fps_base - ) - split = col.split(factor=split_factor, align=True) - split.label(text="Duration:") - split.label(text=f"{scene_interval / scene_fps:.1f} s") - - # Speed - speed = loop_interval / animations.get_scene_fps_adjusted_interval(scene_fps) - split = col.split(factor=split_factor, align=True) - split.label(text="Speed:") - split.label(text=f"{speed:.1f}x") - - def draw(self, context: bpy.types.Context): - wind_properties = preferences.prefs_utils.get_preferences( - context - ).botaniq_preferences.wind_anim_properties - layout = self.layout - - row = polib.ui_bpy.scaled_row(layout, 1.5, align=True) - row.operator(animations.AnimationAddWind.bl_idname, text="Add Animation", icon='ADD') - layout.separator() - - row = layout.row(align=True) - row.operator( - animations.AnimationMakeInstanced.bl_idname, text="Make Instance", icon='GROUP' - ) - row.operator( - animations.AnimationRemoveWind.bl_idname, text="Remove Animation", icon='REMOVE' - ) - - row = layout.row(align=True) - row.operator(animations.AnimationMute.bl_idname, text="Mute/Unmute Animation") - - if next(animations.get_animated_objects(context.selected_objects), None) is not None: - col = layout.column(align=True) - col.label(text="Preset & Strength") - row = col.split(align=True, factor=0.75) - row.prop(wind_properties, "preset", text="") - row.operator(animations.AnimationApplyPreset.bl_idname, text="Set") - - row = col.split(align=True, factor=0.75) - row.prop(wind_properties, "strength", text="Strength") - row.operator(animations.AnimationApplyStrength.bl_idname, text="Set") - - col = layout.column(align=True) - col.label(text="Animation Style") - row = col.split(align=True) - row.operator( - animations.AnimationSetAnimStyle.bl_idname, text="Loop / Procedural Switch" - ) - col.separator() - - row = col.split(align=True, factor=0.75) - row.prop(wind_properties, "looping", text="Loop Frames") - row.operator(animations.AnimationApplyLoop.bl_idname, text="Set") - row = col.split(align=True) - row.operator(animations.AnimationSetFrames.bl_idname, text="Set Scene Frames") - col.separator() - - row = col.row(align=True) - row.operator(animations.AnimationRandomizeOffset.bl_idname) - - col = layout.column(align=True) - col.label(text="Alembic Bake") - col.prop(wind_properties, "bake_folder") - col.operator(animations.AnimationBake.bl_idname, text="Bake to Alembic") - - active_object = context.active_object - if active_object is None: - return - - if animations.has_6_6_or_older_action(context.active_object): - col = layout.column(align=True) - col.label(text="Asset has old Animation", icon='ERROR') - col.label(text="Please respawn the asset or use") - col.label(text="'Convert to Linked' and 'Convert to Editable' and") - col.label(text="re-apply the animation.") - - animated_object = animations.get_instanced_mesh_object(active_object) - if animated_object is None: - return - - if not animations.is_animated(animated_object): - return - - self.draw_object_anim_details(context, layout, active_object, animated_object) - - -MODULE_CLASSES.append(AnimationsPanel) - - -@polib.log_helpers_bpy.logged_panel -class AnimationAdvancedPanel(BotaniqPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_botaniq_animations_advanced" - bl_parent_id = AnimationsPanel.bl_idname - bl_label = "Animations Advanced" - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - if context.active_object is None: - return False - - if context.mode != 'OBJECT': - return False - - instanced_obj = animations.get_instanced_mesh_object(context.active_object) - if instanced_obj is None: - return False - - return animations.is_animated(instanced_obj) - - def draw_header(self, context: bpy.types.Context): - self.layout.label(text="", icon='OPTIONS') - - def draw(self, context: bpy.types.Context): - layout = self.layout - - animated_object = animations.get_instanced_mesh_object(context.active_object) - if animated_object is None: - return - - col = layout.column(align=True) - row = col.row(align=True) - row.label(text="Modifier") - sub_col = row.column() - sub_col.alignment = 'RIGHT' - sub_col.label(text="Strength") - sub_col = row.column() - sub_col.alignment = 'RIGHT' - sub_col.label(text="Enabled") - - action = animated_object.animation_data.action - scale_mod_prop_map = animations.get_envelope_multiplier_mod_prop_map(action) - for mod_name, fmod_limits in sorted( - animations.get_animation_state_control_modifiers(action), key=lambda x: x[0] - ): - row = col.row(align=True) - row.label(text=f"{mod_name[len('bq_'):]}") - - mod, prop = scale_mod_prop_map.get(mod_name, (None, None)) - amplitude_col = row.column(align=True) - amplitude_col.alignment = 'RIGHT' - if mod is not None: - amplitude_col.prop(mod, prop, text="") - else: - amplitude_col.label(text="Not Controllable") - - row.prop(animated_object.modifiers[mod_name], "show_viewport", text="") - # For some reason next icon from the desired one has to be used: AUTO => CHECKMARK - row.prop( - fmod_limits, "mute", text="", icon='AUTO' if fmod_limits.mute is True else 'BLANK1' - ) - - -MODULE_CLASSES.append(AnimationAdvancedPanel) - - -class VineGeneratorPanelMixin( - BotaniqPanelInfoMixin, polib.geonodes_mod_utils_bpy.GeoNodesModifierInputsPanelMixin -): - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - obj = context.active_object - if obj is None: - return False - return ( - len( - polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - obj, asset_helpers.BQ_VINE_GENERATOR_NODE_GROUP_NAME - ) - ) - > 0 - ) - - -class VineGeneratorPanel(VineGeneratorPanelMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_botaniq_vine_generator" - bl_parent_id = BotaniqPanel.bl_idname - bl_label = "Vine Generator" - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - return ( - super().poll(context) - or bpy.data.node_groups.get(asset_helpers.BQ_VINE_GENERATOR_NODE_GROUP_NAME, None) - is not None - ) - - def draw_header(self, context: bpy.types.Context) -> None: - self.layout.label(text="", icon="GRAPH") - - def draw(self, context: bpy.types.Context): - layout: bpy.types.UILayout = self.layout - obj = context.active_object - if ( - obj is None - or len( - polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - obj, asset_helpers.BQ_VINE_GENERATOR_NODE_GROUP_NAME - ) - ) - == 0 - ): - layout.label(text="Select a Vine Generator object") - - -MODULE_CLASSES.append(VineGeneratorPanel) - - -class VineGeneratorGeneralAdjustmentsPanel(VineGeneratorPanelMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_botaniq_vine_generator_general_adjustments" - bl_parent_id = VineGeneratorPanel.bl_idname - bl_label = "General Adjustments" - - template = polib.node_utils_bpy.NodeSocketsDrawTemplate( - asset_helpers.BQ_VINE_GENERATOR_NODE_GROUP_NAME, - filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name( - x, - "Target Object", - "Target Collection", - "Merge Distance", - "Curve Subdivision", - "Cast to Target", - "Angle Threshold", - "Normal Orientation", - "Seed", - ), - socket_names_drawn_first=["Target Object", "Target Collection"], - ) - - def draw(self, context: bpy.types.Context): - self.draw_active_object_modifiers_node_group_inputs_template( - self.layout, - context, - VineGeneratorGeneralAdjustmentsPanel.template, - ) - - -MODULE_CLASSES.append(VineGeneratorGeneralAdjustmentsPanel) - - -class VineGeneratorStemAdjustmentsPanel(VineGeneratorPanelMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_botaniq_vine_generator_stem_adjustments" - bl_parent_id = VineGeneratorPanel.bl_idname - bl_label = "Stem Adjustments" - bl_options = {'DEFAULT_CLOSED'} - - template = polib.node_utils_bpy.NodeSocketsDrawTemplate( - asset_helpers.BQ_VINE_GENERATOR_NODE_GROUP_NAME, - filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name( - x, - "Stem", - ), - socket_names_drawn_first=[ - "Stem Material", - ], - ) - - def draw(self, context: bpy.types.Context): - self.draw_active_object_modifiers_node_group_inputs_template( - self.layout, - context, - VineGeneratorStemAdjustmentsPanel.template, - ) - - -MODULE_CLASSES.append(VineGeneratorStemAdjustmentsPanel) - - -class VineGeneratorLeavesAdjustmentsPanel(VineGeneratorPanelMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_botaniq_vine_generator_leaves_adjustments" - bl_parent_id = VineGeneratorPanel.bl_idname - bl_label = "Leaves Adjustments" - bl_options = {'DEFAULT_CLOSED'} - - template = polib.node_utils_bpy.NodeSocketsDrawTemplate( - asset_helpers.BQ_VINE_GENERATOR_NODE_GROUP_NAME, - filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name( - x, - "Leaves", - "Leaf", - "Min Scale", - "Max Scale", - "Rotation Sky", - "Deviation Sky", - ), - socket_names_drawn_first=[ - "Leaves Collection", - ], - ) - - def draw(self, context: bpy.types.Context): - self.draw_active_object_modifiers_node_group_inputs_template( - self.layout, - context, - VineGeneratorLeavesAdjustmentsPanel.template, - ) - - -MODULE_CLASSES.append(VineGeneratorLeavesAdjustmentsPanel) - - -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/browser/browser.py b/browser/browser.py index 589042c..4f6af7e 100644 --- a/browser/browser.py +++ b/browser/browser.py @@ -166,7 +166,7 @@ def execute(self, context: bpy.types.Context): shutil.rmtree(FAILED_THUMBNAILS_PATH, ignore_errors=True) previews.preview_manager.clear() - utils.tag_prefs_redraw(context) + polib.ui_bpy.tag_areas_redraw(context, {'PREFERENCES'}) return {'FINISHED'} def invoke(self, context: bpy.types.Context, event: bpy.types.Event): @@ -181,9 +181,10 @@ def invoke(self, context: bpy.types.Context, event: bpy.types.Event): @polib.log_helpers_bpy.logged_operator -class MAPR_ShowAssetDetail(bpy.types.Operator): +class MAPR_BrowserShowAssetDetail(bpy.types.Operator): bl_idname = "engon.browser_show_asset_detail" bl_label = "Show Asset Detail" + bl_description = "Shows detailed information about the asset - its preview, tags and parameters" asset_id: bpy.props.StringProperty( name="Asset ID", description="ID of asset to spawn into scene", options={'HIDDEN'} @@ -204,7 +205,7 @@ def draw_parameters(self, layout: bpy.types.UILayout) -> None: heading.label(text="No parameters found") return - heading.label(text="Parameters") + heading.label(text="Parameters", icon='PROPERTIES') already_considered_parameters: typing.Set[str] = set() for i, (group_name, group_parameters) in enumerate( mapr.known_metadata.PARAMETER_GROUPING.items() @@ -263,13 +264,15 @@ def draw(self, context: bpy.types.Context) -> None: box = layout.box() title = box.row() - title.label(text=f"{self.asset.title}") + title.label( + text=f"{self.asset.title}", icon=utils.get_icon_of_asset_data_type(self.asset.type_) + ) layout.template_icon(previews.preview_manager.get_icon_id(self.asset.id_), scale=12.0) box = layout.box() heading = box.row() heading.enabled = False if len(self.asset.tags) > 0: - heading.label(text="Tags") + heading.label(text="Tags", icon='COLOR') col = box.column() for tag in sorted(self.asset.tags): col.label(text=tag) @@ -302,7 +305,73 @@ def invoke(self, context: bpy.types.Context, event: bpy.types.Event): return context.window_manager.invoke_popup(self) -MODULE_CLASSES.append(MAPR_ShowAssetDetail) +MODULE_CLASSES.append(MAPR_BrowserShowAssetDetail) + + +@polib.log_helpers_bpy.logged_operator +class MAPR_ShowAssetMenu(bpy.types.Operator): + bl_idname = "engon.browser_show_asset_menu" + bl_label = "Asset Menu" + bl_description = ( + "Opens asset menu with additional actions for the asset. This contains less common " + "operations like drawing, spawning into particles, replacing selected or viewing asset detail" + ) + + asset_id: bpy.props.StringProperty( + name="Asset ID", description="ID of asset", options={'HIDDEN'} + ) + + # Reference to the asset the menu will be drawn with, has to be set before the menu is drawn. + current_asset: typing.Optional[mapr.asset.Asset] = None + + @staticmethod + def asset_menu_draw(menu_self, context: bpy.types.Context) -> None: + layout: bpy.types.UILayout = menu_self.layout + asset = MAPR_ShowAssetMenu.current_asset + assert asset is not None, "Asset has to be set before drawing the menu" + + col = layout.column(align=True) + if asset.type_ in { + mapr.asset_data.AssetDataType.blender_model, + mapr.asset_data.AssetDataType.blender_geometry_nodes, + }: + col.operator( + spawn.MAPR_BrowserReplaceSelected.bl_idname, icon='FILE_REFRESH' + ).asset_id = asset.id_ + + if ( + asset.type_ == mapr.asset_data.AssetDataType.blender_model + and spawn.MAPR_BrowserSpawnModelIntoParticleSystem.poll(context) + ): + col.operator( + spawn.MAPR_BrowserSpawnModelIntoParticleSystem.bl_idname, icon='PARTICLES' + ).asset_id = asset.id_ + + if "Drawable" in asset.tags or asset.id_ in DRAWABLE_GEONODES_ASSET_IDS: + col.operator( + spawn.MAPR_BrowserDrawGeometryNodesAsset.bl_idname, icon='GREASEPENCIL' + ).asset_id = asset.id_ + + row = col.row() + row.operator_context = 'INVOKE_DEFAULT' + row.operator(MAPR_BrowserShowAssetDetail.bl_idname, icon='VIEWZOOM').asset_id = asset.id_ + + def execute(self, context: bpy.types.Context): + asset = asset_registry.instance.master_asset_provider.get_asset(self.asset_id) + if asset is None: + logger.error(f"Asset with ID '{self.asset_id}' not found! Cannot open asset menu.") + return {'CANCELLED'} + + MAPR_ShowAssetMenu.current_asset = asset + context.window_manager.popup_menu( + MAPR_ShowAssetMenu.asset_menu_draw, + title=f"{asset.title}", + icon=utils.get_icon_of_asset_data_type(asset.type_), + ) + return {'FINISHED'} + + +MODULE_CLASSES.append(MAPR_ShowAssetMenu) def draw_asset_buttons_row( @@ -326,24 +395,9 @@ def draw_asset_buttons_row( icon=utils.get_icon_of_asset_data_type(asset.type_), ).asset_id = str(asset.id_) - use_separator = False - if "Drawable" in asset.tags or asset.id_ in DRAWABLE_GEONODES_ASSET_IDS: - row.operator( - spawn.MAPR_BrowserDrawGeometryNodesAsset.bl_idname, text="", icon='GREASEPENCIL' - ).asset_id = str(asset.id_) - use_separator = True - - if ( - asset.type_ == mapr.asset_data.AssetDataType.blender_model - and spawn.MAPR_BrowserSpawnModelIntoParticleSystem.poll(context) - ): - row.operator( - spawn.MAPR_BrowserSpawnModelIntoParticleSystem.bl_idname, text="", icon='PARTICLES' - ).asset_id = str(asset.id_) - use_separator = True - - if use_separator: - row.separator() + row.operator(MAPR_ShowAssetMenu.bl_idname, text="", icon='DOWNARROW_HLT').asset_id = str( + asset.id_ + ) prefs = preferences.prefs_utils.get_preferences(context).browser_preferences if prefs.debug and dev.IS_DEV: @@ -352,7 +406,6 @@ def draw_asset_buttons_row( dev.MAPR_BrowserOpenAssetSourceBlend.bl_idname, text="", icon='HOME' ).asset_id = str(asset.id_) - row.operator(MAPR_ShowAssetDetail.bl_idname, text="", icon='VIEWZOOM').asset_id = str(asset.id_) if prefs.debug: row = layout.row() row.enabled = False @@ -610,7 +663,7 @@ def hijack_preferences(cls, context: bpy.types.Context) -> None: if _draw_funcs is not None: userpref_type.draw._draw_funcs = _draw_funcs - utils.tag_prefs_redraw(context) + polib.ui_bpy.tag_areas_redraw(context, {'PREFERENCES'}) @classmethod def open_browser( @@ -744,7 +797,7 @@ def return_preferences(context: bpy.types.Context, store_state_to_prefs: bool = preferences.prefs_utils.get_preferences(context).browser_preferences.prefs_hijacked = ( False ) - utils.tag_prefs_redraw(context) + polib.ui_bpy.tag_areas_redraw(context, {'PREFERENCES'}) @staticmethod def abandon_window(window: bpy.types.Window) -> None: @@ -838,7 +891,7 @@ def execute(self, context: bpy.types.Context): def _active_object_changed(): # We redraw the browser when active object changed, because some of the operators are # only display when active object is of a certain type and we need to update the UI. - utils.tag_prefs_redraw(bpy.context) + polib.ui_bpy.tag_areas_redraw(bpy.context, {'PREFERENCES'}) def _subscribe_msg_bus(): diff --git a/browser/filters.py b/browser/filters.py index de90f26..5f2c34c 100644 --- a/browser/filters.py +++ b/browser/filters.py @@ -31,6 +31,7 @@ import mathutils import threading import functools +import random from .. import polib from .. import mapr from .. import asset_registry @@ -69,12 +70,12 @@ def _query(): # thing how we touch blender is loading previews and tagging redraw self.last_view = self.asset_provider.query(query) self.is_loading = False - utils.tag_prefs_redraw(bpy.context) + polib.ui_bpy.tag_areas_redraw(bpy.context, {'PREFERENCES'}) if on_complete is not None: on_complete(self.last_view) self.is_loading = True - utils.tag_prefs_redraw(bpy.context) + polib.ui_bpy.tag_areas_redraw(bpy.context, {'PREFERENCES'}) if USE_THREADED_QUERY: thread = threading.Thread(target=_query) thread.start() @@ -745,6 +746,14 @@ def is_default(self) -> bool: class BrowserSearchFilter(bpy.types.PropertyGroup, mapr.filters.SearchFilter, BrowserFilter): """Filters out items based on text input from user""" + SEARCH_PLACEHOLDER_TEXT = [ + "Search", + "Missing an asset?", + "Looking for anything?", + "Something to spawn?", + "Find assets that fit!", + ] + enabled: bpy.props.BoolProperty(get=lambda _: True, set=lambda _, __: None) # OVERRIDES 'search' from 'mapr.filters.SearchFilter' @@ -761,20 +770,36 @@ class BrowserSearchFilter(bpy.types.PropertyGroup, mapr.filters.SearchFilter, Br update=lambda self, context: self.recent_search_updated(context), ) + search_placeholder: bpy.props.StringProperty( + name="Search Placeholder", description="Search bar placeholder text", options={'HIDDEN'} + ) + def init(self): self.enabled = True self.name = "builtin:search" + # randomly pick a search bar placeholder message + self.search_placeholder = random.choice(BrowserSearchFilter.SEARCH_PLACEHOLDER_TEXT) + def draw(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: layout.prop_menu_enum(self, "recent_search", text="", icon='DOWNARROW_HLT') sub = layout.row(align=True) sub.scale_x = 1.2 - sub.prop( - self, - "search", - text="", - icon_value=polib.ui_bpy.icon_manager.get_icon_id("icon_engon_search"), - ) + if bpy.app.version < (4, 1, 0): + sub.prop( + self, + "search", + text="", + icon_value=polib.ui_bpy.icon_manager.get_icon_id("icon_engon_search"), + ) + else: + sub.prop( + self, + "search", + text="", + icon_value=polib.ui_bpy.icon_manager.get_icon_id("icon_engon_search"), + placeholder=self.search_placeholder, + ) if self.is_applied(): layout.operator( @@ -894,7 +919,7 @@ def draw(self, context: bpy.types.Context, layout: bpy.types.UILayout) -> None: row.enabled = False row.label(text="No applicable filters", icon='PANEL_CLOSE') else: - layout.label(text="Filters") + layout.label(text="Filters", icon='PROPERTIES') col = layout.column() col.enabled = not asset_repository.is_loading for group, filters_ in self.groups.items(): @@ -1164,7 +1189,7 @@ def _draw_tags(context: bpy.types.Context, layout: bpy.types.UILayout): row.label(text="No tags found", icon='PANEL_CLOSE') return - layout.label(text="Tags") + layout.label(text="Tags", icon='COLOR') col = layout.column() col.enabled = not asset_repository.is_loading row = col.row() diff --git a/browser/spawn.py b/browser/spawn.py index a860534..b39244b 100644 --- a/browser/spawn.py +++ b/browser/spawn.py @@ -41,6 +41,7 @@ class MAPR_SpawnAssetBase(bpy.types.Operator): asset_id: bpy.props.StringProperty(name="Asset ID", description="ID of asset to spawn") + bl_options = {'REGISTER', 'UNDO'} @classmethod def description(cls, context: bpy.types.Context, props: bpy.types.OperatorProperties) -> str: @@ -115,7 +116,7 @@ def _get_asset(self) -> typing.Optional[mapr.asset.Asset]: @polib.log_helpers_bpy.logged_operator class MAPR_BrowserSpawnAsset(MAPR_SpawnAssetBase): bl_idname = "engon.browser_spawn_asset" - bl_label = "Spawn" + bl_label = "Spawn Asset" @polib.utils_bpy.blender_cursor('WAIT') def execute(self, context: bpy.types.Context): @@ -202,7 +203,7 @@ class MAPR_BrowserDrawGeometryNodesAsset(MAPR_SpawnAssetBase): bl_label = "Draw Geometry Nodes" @classmethod - def description(cls, context: bpy.types.Context, props: bpy.types.OperatorProperties) -> None: + def description(cls, context: bpy.types.Context, props: bpy.types.OperatorProperties) -> str: asset = asset_registry.instance.master_asset_provider.get_asset(props.asset_id) if asset is None: return f"Asset with id {props.asset_id} cannot be spawned" @@ -331,6 +332,82 @@ def execute(self, context: bpy.types.Context): MODULE_CLASSES.append(MAPR_BrowserSpawnModelIntoParticleSystem) +@polib.log_helpers_bpy.logged_operator +class MAPR_BrowserReplaceSelected(MAPR_SpawnAssetBase): + bl_idname = "engon.browser_replace_selected" + bl_label = "Replace Selected" + bl_options = {'REGISTER', 'UNDO'} + + @classmethod + def description(cls, context: bpy.types.Context, props: bpy.types.OperatorProperties) -> str: + asset = asset_registry.instance.master_asset_provider.get_asset(props.asset_id) + if asset is None: + return f"Asset with id '{props.asset_id}' cannot be spawned" + + return f"Replace selected objects with '{asset.title}'. Empty objects are not considered for replacing" + + @classmethod + def get_objects_to_replace(cls, context: bpy.types.Context) -> typing.Set[bpy.types.Object]: + return { + obj + for obj in context.selected_objects + if not (obj.type == 'EMPTY' and obj.instance_collection is None) + } + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + return len(cls.get_objects_to_replace(context)) > 0 + + @polib.utils_bpy.blender_cursor('WAIT') + def execute(self, context: bpy.types.Context): + prefs = preferences.prefs_utils.get_preferences(context).browser_preferences + asset = self._get_asset() + if asset is None: + self.report({'ERROR'}, f"Asset with id {self.asset_id} not found") + return {'CANCELLED'} + + objects_to_replace = MAPR_BrowserReplaceSelected.get_objects_to_replace(context) + spawn_options: hatchery.spawn.ModelSpawnOptions = prefs.spawn_options.get_spawn_options( + asset, context + ) + spawn_options.parent_collection = None + spawn_options.select_spawned = False + spawned_data = self._spawn(context, asset, spawn_options) + + if spawned_data is None: + logger.error(f"Failed to spawn asset to replace selected objects '{self.asset_id}'!") + return {'CANCELLED'} + + if isinstance(spawned_data, hatchery.spawn.ModelSpawnedData): + new_object = spawned_data.instancer + elif isinstance(spawned_data, hatchery.spawn.GeometryNodesSpawnedData): + new_object = spawned_data.container_obj + else: + raise ValueError( + f"Unsupported spawned data type: '{type(spawned_data)}'. This should be handled by the caller." + ) + + # We go through all selected objects and replace them with the new object. We keep + # the old objects in the `bpy.data.` - they will be removed when the .blend file is saved + # if there are no more users of the object. + for i, obj in enumerate(objects_to_replace): + # Use the spawned object for the first iteration, otherwise create a copy + obj_copy = new_object.copy() if i > 0 else new_object + for old_collection in obj.users_collection: + old_collection.objects.unlink(obj) + old_collection.objects.link(obj_copy) + obj_copy.matrix_world = obj.matrix_world + obj_copy.select_set(True) + + if prefs.spawn_options.remove_duplicates: + self._remove_duplicates() + + return {'FINISHED'} + + +MODULE_CLASSES.append(MAPR_BrowserReplaceSelected) + + @polib.log_helpers_bpy.logged_panel class SpawnOptionsPopoverPanel(bpy.types.Panel): bl_idname = "PREFERENCES_PT_mapr_spawn_options" diff --git a/browser/utils.py b/browser/utils.py index ff135de..bfde2b4 100644 --- a/browser/utils.py +++ b/browser/utils.py @@ -42,10 +42,3 @@ def get_icon_of_asset_data_type(type_: mapr.asset_data.AssetDataType) -> str: mapr.asset_data.AssetDataType.blender_world: 'WORLD', mapr.asset_data.AssetDataType.blender_geometry_nodes: 'GEOMETRY_NODES', }.get(type_, 'QUESTION') - - -def tag_prefs_redraw(context: bpy.types.Context) -> None: - for window in context.window_manager.windows: - for area in window.screen.areas: - if area.type == 'PREFERENCES': - area.tag_redraw() diff --git a/clicker.py b/clicker.py new file mode 100644 index 0000000..e10f2f7 --- /dev/null +++ b/clicker.py @@ -0,0 +1,584 @@ +# 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 ##### + +# Code inspired by the 'CLICKR' addon by Oliver J Post + +import bpy +import bpy_extras +import math +import mathutils +import typing +import logging +import random +from . import polib +from . import hatchery +from . import panel +from . import preferences + +logger = logging.getLogger(f"polygoniq.{__name__}") + + +MODULE_CLASSES: typing.List[typing.Type] = [] + + +def get_target_collection(context: bpy.types.Context) -> bpy.types.Collection: + if context.scene.pq_clicker_target_collection is None: + context.scene.pq_clicker_target_collection = polib.asset_pack_bpy.collection_get( + context, "Clicker" + ) + return context.scene.pq_clicker_target_collection + + +def get_collision_collection(context: bpy.types.Context) -> bpy.types.Collection: + if context.scene.pq_clicker_collision_collection is None: + return context.scene.collection + + return context.scene.pq_clicker_collision_collection + + +def get_clicker_props( + context: bpy.types.Context, +) -> preferences.general_preferences.ClickerProperties: + return preferences.prefs_utils.get_preferences(context).general_preferences.clicker_props + + +@polib.log_helpers_bpy.logged_operator +class Clicker(bpy.types.Operator): + bl_idname = "engon.clicker" + bl_label = "Clicker" + bl_description = ( + "Select objects and place them interactively under your cursor directly in the " + "scene. Customize variability with options to randomize rotation, tilt, and scale. Hold " + "CTRL to align objects to the surface. Rotate the placed objects with mouse movement. " + "Automatically aligns origins of meshes to the bottom of the object if specified" + ) + bl_options = {'REGISTER', 'UNDO'} + + ROTATION_SPEED = 0.01 + SCALE_SPEED = 0.01 + + draw_2d_handler_ref = None + + is_running = False + + def __init__(self): + self.models_collection: typing.Optional[bpy.types.Collection] = None + self.target_collection: typing.Optional[bpy.types.Collection] = None + self.collision_collection = bpy.context.scene.collection + + self.current_object: typing.Optional[bpy.types.Object] = None + self.placed_object: typing.Optional[bpy.types.Object] = None + self.place_mouse_position: typing.Optional[mathutils.Vector] = None + self.current_object_hierarchy_names: typing.Set[str] = set() + # Store the last adjustment of z rotation, so next placed objects continue with the same + # base rotation. + self.last_z_rotation_adjustment = 0.0 + # Store the base rotation matrix of the current object so we can take it into account after + # aligning with surface normal. + self.current_object_base_rotation = mathutils.Matrix() + + # Properties controlling the UI state - e. g. tint of the buttons, based on the events + self.is_scaling = False + + Clicker.draw_2d_handler_ref = bpy.types.SpaceView3D.draw_handler_add( + self.draw_px, (), 'WINDOW', 'POST_PIXEL' + ) + + @staticmethod + def remove_draw_handlers() -> None: + if hasattr(Clicker, "draw_2d_handler_ref") and Clicker.draw_2d_handler_ref is not None: + bpy.types.SpaceView3D.draw_handler_remove(Clicker.draw_2d_handler_ref, 'WINDOW') + Clicker.draw_2d_handler_ref = None + + def __del__(self): + Clicker.remove_draw_handlers() + + def draw_px(self): + clicker_props = get_clicker_props(bpy.context) + ui_scale = bpy.context.preferences.system.ui_scale + half_width = bpy.context.region.width / 2.0 + + polib.render_bpy.mouse_info( + half_width - 440 * ui_scale, + 20, + "Place object", + left_click=True, + ) + + polib.render_bpy.mouse_info( + half_width - 300 * ui_scale, + 20, + "Rotate", + left_click=True, + indicate_left=True, + indicate_right=True, + ) + + # Click and hold ALT + polib.render_bpy.mouse_info( + half_width - 200 * ui_scale, + 20, + "", + left_click=True, + indicate_left=True, + indicate_right=True, + ) + polib.render_bpy.key_info( + half_width - 175 * ui_scale, 20, "ALT", "Scale", pressed=self.is_scaling + ) + + polib.render_bpy.key_info( + half_width - 90 * ui_scale, + 20, + "CTRL", + "Align to surface (Hold)", + pressed=clicker_props.align_to_surface, + ) + polib.render_bpy.key_info(half_width + 120 * ui_scale, 20, "R", "Select random object") + + polib.render_bpy.key_info(half_width + 330 * ui_scale, 20, "ESC", "Exit") + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + return len(context.selected_objects) > 0 and context.mode == 'OBJECT' + + def execute(self, context: bpy.types.Context): + return {'FINISHED'} + + def _cleanup( + self, + context: bpy.types.Context, + event: typing.Optional[bpy.types.Event] = None, + exception: typing.Optional[Exception] = None, + ) -> typing.Set[str]: + Clicker.is_running = False + Clicker.remove_draw_handlers() + + context.window.cursor_modal_restore() + if self.models_collection is not None: + try: + bpy.data.collections.remove(self.models_collection) + except ReferenceError: + logger.exception(f"Reference to the model collection was lost, couldn't clean up.") + + if self.current_object is not None: + try: + bpy.data.objects.remove(self.current_object) + except ReferenceError: + logger.exception(f"Reference to the current object was lost, couldn't clean up.") + + return {'CANCELLED'} + + @polib.utils_bpy.safe_modal(on_exception=_cleanup) + def modal(self, context: bpy.types.Context, event: bpy.types.Event): + if self.current_object is None: + self.report({'ERROR'}, "No object to place, select at least one object.") + return {'CANCELLED'} + + if event.type == 'ESC': + self._cleanup(context, event) + return {'FINISHED'} + + self.is_scaling = False + + # PASS_THROUGH for events that are in N-panel so user can control the clicker properties. + area, region = polib.ui_bpy.get_mouseovered_region(context, event) + if ( + area is not None + and region is not None + and area.type == 'VIEW_3D' + and region.type == 'UI' + ): + context.window.cursor_modal_restore() + return {'PASS_THROUGH'} + + if area is not None and area.type != 'VIEW_3D': + context.window.cursor_modal_set('STOP') + return {'RUNNING_MODAL'} + + context.window.cursor_modal_set('CROSSHAIR') + if event.type == 'MOUSEMOVE': + if self.placed_object is not None: + if event.alt: + self.is_scaling = True + self.update_placed_object_scale(event) + else: + self.update_placed_object_rotation(event) + else: + self.update_current_object_transform(context, event) + return {'RUNNING_MODAL'} + + elif event.type == 'LEFTMOUSE': + if event.value == 'PRESS': + self.place_object(event) + if event.value == 'RELEASE': + self.choose_next_object(context) + return {'RUNNING_MODAL'} + + elif event.type == 'R': + if event.value == 'RELEASE': + self.choose_next_object(context) + self.update_current_object_transform(context, event) + + return {'RUNNING_MODAL'} + + elif event.type in {'LEFT_CTRL', 'RIGHT_CTRL'}: + clicker_props = get_clicker_props(context) + if event.value == 'PRESS': + clicker_props.align_to_surface = True + elif event.value == 'RELEASE': + clicker_props.align_to_surface = False + self.reset_current_object_rotation() + + self.update_current_object_transform(context, event) + + return {'RUNNING_MODAL'} + + # PASS_THROUGH for navigation events, otherwise user could control blender data + # under our hands, which wouldn't be nice! + if event.type in {'MIDDLEMOUSE', 'WHEELUPMOUSE', 'WHEELDOWNMOUSE'}: + return {'PASS_THROUGH'} + + return {'RUNNING_MODAL'} + + def update_current_object_transform( + self, context: bpy.types.Context, event: bpy.types.Event + ) -> None: + assert self.current_object is not None + raycast_hit = polib.linalg_bpy.raycast_screen_to_world( + context, + (event.mouse_region_x, event.mouse_region_y), + self.current_object_hierarchy_names, + self.collision_collection, + ) + # If we didn't hit anything, place the object at the mouse cursor position with reset rotation + if raycast_hit is None: + pos = (event.mouse_region_x, event.mouse_region_y) + region = context.region + region3d = context.region_data + view_vector = bpy_extras.view3d_utils.region_2d_to_vector_3d(region, region3d, pos) + position = bpy_extras.view3d_utils.region_2d_to_location_3d( + region, region3d, pos, view_vector + ) + self.reset_current_object_rotation(position) + return + + self.current_object.location = raycast_hit.position + + if get_clicker_props(context).align_to_surface: + normal: mathutils.Vector = raycast_hit.normal.normalized() + surface_normal_quaternion = normal.to_track_quat('Z', 'Y') + _, _, scale = self.current_object.matrix_world.decompose() + scale_matrix = mathutils.Matrix.Diagonal(scale).to_4x4() + + rotation_matrix = ( + surface_normal_quaternion.to_matrix().to_4x4() @ self.current_object_base_rotation + ) + + self.current_object.matrix_world = ( + mathutils.Matrix.Translation(raycast_hit.position) @ rotation_matrix @ scale_matrix + ) + + def reset_current_object_rotation( + self, position: typing.Optional[mathutils.Vector] = None + ) -> None: + assert self.current_object is not None + current_position, _, scale = self.current_object.matrix_world.decompose() + if position is None: + position = current_position + + scale_matrix = mathutils.Matrix.Diagonal(scale).to_4x4() + self.current_object.matrix_world = ( + mathutils.Matrix.Translation(position) + @ self.current_object_base_rotation + @ scale_matrix + ) + + def update_placed_object_rotation(self, event: bpy.types.Event) -> None: + assert self.placed_object is not None + rotation_diff = (event.mouse_x - event.mouse_prev_x) * Clicker.ROTATION_SPEED + + rotation_local_z_matrix = mathutils.Matrix.Rotation(rotation_diff, 4, 'Z') + self.placed_object.matrix_world @= rotation_local_z_matrix + self.last_z_rotation_adjustment += rotation_diff + + def update_placed_object_scale(self, event: bpy.types.Event) -> None: + assert self.placed_object is not None + self.placed_object.scale += mathutils.Vector( + ((event.mouse_x - event.mouse_prev_x) * Clicker.SCALE_SPEED,) * 3 + ) + + def place_object(self, event: bpy.types.Event) -> None: + assert self.current_object is not None + self.placed_object = self.current_object.copy() + polib.asset_pack_bpy.collection_add_object(self.target_collection, self.placed_object) + self.current_object.hide_viewport = True + self.place_mouse_position = mathutils.Vector([event.mouse_region_x, event.mouse_region_y]) + logger.info(f"Object placed: '{self.placed_object.name}'") + + def choose_next_object(self, context: bpy.types.Context) -> None: + clicker_props = get_clicker_props(context) + if self.current_object is not None: + bpy.data.objects.remove(self.current_object) + + # If there was a previously placed object, choosing next object starts right after it was + # finished - it's rotation and scale is set, and we can log the information. + if self.placed_object is not None: + logger.info( + f"Object '{self.placed_object.name}' finalized at {self.placed_object.location} " + f"with scale {self.placed_object.scale} and rotation {self.placed_object.rotation_euler}" + ) + + self.current_object_hierarchy_names.clear() + random_object = random.choice(self.models_collection.objects) + self.current_object = random_object.copy() + assert self.current_object is not None + polib.asset_pack_bpy.collection_add_object(self.target_collection, self.current_object) + + # Add current hierarchy and the instancer to the current_object_hierarchy_names, that + # will be further used when raycasting on the geometry to completely exclude the currently + # placed object from raycasting. + for obj in polib.asset_pack_bpy.get_entire_object_hierarchy(self.current_object): + self.current_object_hierarchy_names.add(obj.name) + self.current_object_hierarchy_names.add(self.current_object.name) + + # Rotation randomization, use rotation from the last object if it exists so next clicked + # asset base rotation aligned with the previous one. + location, rotation, scale = self.current_object.matrix_world.decompose() + rotation_matrix = rotation.to_matrix().to_4x4() + rotation_matrix = ( + mathutils.Euler((0, 0, self.last_z_rotation_adjustment), 'XYZ').to_matrix() + @ rotation.to_matrix() + ).to_4x4() + + rotation_matrix @= self._get_randomized_rotation_matrix(context) + self.current_object_base_rotation = rotation_matrix + + # Scale randomization + random_scale = random.uniform(-clicker_props.random_scale, clicker_props.random_scale) + scale_matrix = mathutils.Matrix.Diagonal( + ( + scale[0] + random_scale, + scale[1] + random_scale, + math.fabs(scale[2] + random_scale), + 1.0, + ) + ) + self.current_object.matrix_world = ( + mathutils.Matrix.Translation(location) @ rotation_matrix @ scale_matrix + ) + + self.current_object.select_set(False) + + self.placed_object = None + self.place_mouse_position = None + + logger.info(f"Chosen next object: '{self.current_object.name}'") + + def adjust_origin(self, obj: bpy.types.Object) -> None: + if obj.type != 'MESH': + return + + bbox = hatchery.bounding_box.AlignedBox() + bbox.extend_by_object(obj) + eccentricity = bbox.get_eccentricity() + offset = bbox.min + mathutils.Vector((eccentricity.x, eccentricity.y, 0.0)) + offset_local = obj.matrix_world.inverted() @ offset + obj.data.transform(mathutils.Matrix.Translation(-offset_local)) + + def prepare_instanced_objects( + self, context: bpy.types.Context, root_objs: typing.Set[bpy.types.Object] + ) -> None: + """Prepare a set of root objects for instancing. Populates the 'self.models_collection'. + + In case of instanced objects, create a new baseline copy that will be instanced. + In case of editable objects, duplicate the hierarchy and wrap the hierarchy into a collection + that will be then instanced. + """ + clicker_props = get_clicker_props(context) + for obj in root_objs: + if ( + obj.type == 'EMPTY' + and obj.instance_collection is not None + and obj.instance_type == 'COLLECTION' + ): + # Don't use the original object, copy the instanced collection + obj_copy = obj.copy() + self.models_collection.objects.link(obj_copy) + else: + # Create a new collection for the object's hierarchy and instance the empty + # out of the editable objects. + coll = bpy.data.collections.new(f"{obj.name}_clicker_instance") + hierarchy = list(polib.asset_pack_bpy.get_entire_object_hierarchy(obj)) + with context.temp_override(selected_objects=hierarchy, undo=False): + # We can link mesh data only if we don't need to adjust the origin + bpy.ops.object.duplicate(linked=not clicker_props.origin_to_bottom) + new_root = polib.asset_pack_bpy.find_root_objects( + context.selected_objects, only_polygoniq=False + ).pop() + new_root.location = (0, 0, 0) + + for obj in hierarchy: + self.adjust_origin(obj) + + polib.asset_pack_bpy.collection_link_hierarchy(coll, new_root) + + empty = bpy.data.objects.new(obj.name, None) + empty.instance_type = 'COLLECTION' + empty.instance_collection = coll + self.models_collection.objects.link(empty) + + def cancel(self, context: bpy.types.Context): + self._cleanup(context) + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event): + if Clicker.is_running: + logger.error("Another instance of the clicker operator is already running!") + return {'CANCELLED'} + + models_collection = polib.asset_pack_bpy.collection_get(context, "tmp_Clicker_Models") + root_objs = polib.asset_pack_bpy.find_root_objects( + context.selected_objects, only_polygoniq=False + ) + + if len(root_objs) == 0: + self.report({'ERROR'}, "Please select at least one object to place!") + return {'CANCELLED'} + + self.models_collection = models_collection + self.target_collection = get_target_collection(context) + self.collision_collection = get_collision_collection(context) + + self.prepare_instanced_objects(context, root_objs) + + self.models_collection.hide_render = True + self.models_collection.hide_viewport = True + + self.choose_next_object(context) + context.window_manager.modal_handler_add(self) + Clicker.is_running = True + logger.info( + f"Clicker started with '{[obj.name for obj in self.models_collection.objects]}' objects." + ) + return {'RUNNING_MODAL'} + + def _get_randomized_rotation_matrix(self, context: bpy.types.Context) -> mathutils.Matrix: + clicker_props = get_clicker_props(context) + rotation_matrix = mathutils.Matrix.Rotation( + # random tilt in X axis between -90 and 90 degrees + random.uniform(-clicker_props.random_tilt, clicker_props.random_tilt) * 0.5 * math.pi, + 4, + 'X', + ) + rotation_matrix @= mathutils.Matrix.Rotation( + # random tilt in Y axis between -90 and 90 degrees + random.uniform(-clicker_props.random_tilt, clicker_props.random_tilt) * 0.5 * math.pi, + 4, + 'Y', + ) + rotation_matrix @= mathutils.Matrix.Rotation( + # random rotation in Z axis between -180 and 180 degrees + random.uniform(-clicker_props.random_rotation_z, clicker_props.random_rotation_z) + * math.pi, + 4, + 'Z', + ) + return rotation_matrix + + def _sanity_check_collections(self) -> bool: + try: + return self.models_collection is not None and self.target_collection is not None + except RuntimeError: + return False + + +MODULE_CLASSES.append(Clicker) + + +@polib.log_helpers_bpy.logged_panel +class ClickerPanel(panel.EngonPanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_clicker" + bl_parent_id = panel.EngonPanel.bl_idname + bl_label = "Clicker" + bl_options = {'DEFAULT_CLOSED'} + + def draw_header(self, context: bpy.types.Context) -> None: + self.layout.label(text="", icon='PIVOT_CURSOR') + + def draw(self, context: bpy.types.Context) -> None: + props = preferences.prefs_utils.get_preferences(context).general_preferences.clicker_props + layout = self.layout + row = layout.row(align=True) + row.scale_x = row.scale_y = 1.3 + if Clicker.is_running: + row.label(text="Click in the scene to place assets", icon='RESTRICT_SELECT_ON') + else: + row.operator(Clicker.bl_idname, text="Click Assets", icon='PIVOT_CURSOR') + + col = layout.column(align=True) + col.enabled = not Clicker.is_running + col.label(text="Output Collection") + col.prop(context.scene, "pq_clicker_target_collection", text="") + + col = layout.column(align=True) + col.enabled = not Clicker.is_running + col.label(text="Surface Collection") + col.prop(context.scene, "pq_clicker_collision_collection", text="") + + layout.prop(props, "align_to_surface") + layout.prop(props, "origin_to_bottom") + + col = layout.column(align=True) + col.label(text="Randomization") + col.prop(props, "random_rotation_z") + col.prop(props, "random_tilt") + col.prop(props, "random_scale") + + +MODULE_CLASSES.append(ClickerPanel) + + +def register(): + bpy.types.Scene.pq_clicker_target_collection = bpy.props.PointerProperty( + name="Clicker Output Collection", + description="Collection where the clicked objects will be automatically placed, default " + "is 'Clicker'", + type=bpy.types.Collection, + ) + + bpy.types.Scene.pq_clicker_collision_collection = bpy.props.PointerProperty( + name="Clicker Collision Surface Collection", + description="Objects from this collection will act as a surface to click on, if nothing " + "is specified the 'Scene Collection' is used. Use this to limit the placement area or to " + "gain performance in large scenes", + type=bpy.types.Collection, + ) + + for cls in MODULE_CLASSES: + bpy.utils.register_class(cls) + + +def unregister(): + Clicker.remove_draw_handlers() + for cls in reversed(MODULE_CLASSES): + bpy.utils.unregister_class(cls) + + del bpy.types.Scene.pq_clicker_collision_collection + del bpy.types.Scene.pq_clicker_target_collection diff --git a/convert_selection.py b/convert_selection.py index 2753df0..87f0439 100644 --- a/convert_selection.py +++ b/convert_selection.py @@ -31,7 +31,7 @@ def make_selection_linked( spawner = mapr.blender_asset_spawner.AssetSpawner(asset_provider, file_provider) - for obj in polib.asset_pack_bpy.find_polygoniq_root_objects(context.selected_objects): + for obj in polib.asset_pack_bpy.find_root_objects(context.selected_objects): if obj.instance_type == 'COLLECTION': continue diff --git a/features/__init__.py b/features/__init__.py index cc455c6..816ea10 100644 --- a/features/__init__.py +++ b/features/__init__.py @@ -18,15 +18,87 @@ # # ##### END GPL LICENSE BLOCK ##### +from . import feature_utils +from . import asset_pack_panels + from . import colorize + from . import light_adjustments +from . import puddles +from . import aquatiq_paint_mask +from . import aquatiq_material_limitation_warning + +from . import botaniq_adjustments +from . import botaniq_animations +from . import vegetation_generator + +from . import traffiq_paint_adjustments +from . import traffiq_wear +from . import traffiq_lights_settings +from . import traffiq_rigs +from . import emergency_lights +from . import license_plates_generator + +from . import road_generator +from . import vine_generator +from . import river_generator +from . import rain_generator + def register(): + feature_utils.register() + asset_pack_panels.register() + colorize.register() light_adjustments.register() + puddles.register() + aquatiq_paint_mask.register() + aquatiq_material_limitation_warning.register() + + botaniq_adjustments.register() + botaniq_animations.register() + vegetation_generator.register() + + traffiq_paint_adjustments.register() + traffiq_wear.register() + traffiq_lights_settings.register() + traffiq_rigs.register() + license_plates_generator.register() + + emergency_lights.register() + + road_generator.register() + vine_generator.register() + river_generator.register() + rain_generator.register() + def unregister(): + rain_generator.unregister() + river_generator.unregister() + vine_generator.unregister() + road_generator.unregister() + + emergency_lights.unregister() + + license_plates_generator.unregister() + traffiq_rigs.unregister() + traffiq_lights_settings.unregister() + traffiq_wear.unregister() + traffiq_paint_adjustments.unregister() + + vegetation_generator.unregister() + botaniq_animations.unregister() + botaniq_adjustments.unregister() + light_adjustments.unregister() colorize.unregister() + + aquatiq_material_limitation_warning.unregister() + aquatiq_paint_mask.unregister() + puddles.unregister() + + asset_pack_panels.unregister() + feature_utils.unregister() diff --git a/features/aquatiq_material_limitation_warning.py b/features/aquatiq_material_limitation_warning.py new file mode 100644 index 0000000..44e10ce --- /dev/null +++ b/features/aquatiq_material_limitation_warning.py @@ -0,0 +1,133 @@ +# 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 logging +from . import feature_utils +from . import asset_pack_panels +from .. import ui_utils +from .. import polib + +logger = logging.getLogger(f"polygoniq.{__name__}") + + +MODULE_CLASSES: typing.List[typing.Type] = [] + + +class MaterialWarning: + VOLUME = "Object has to have volume for this material to work correctly." + SHORELINE = ( + "Material is from the complex shoreline scene, open it to see how it works with\n" + "other materials to create the best result." + ) + + +MATERIAL_WARNING_MAP = { + "aq_Water_Ocean": {MaterialWarning.SHORELINE, MaterialWarning.VOLUME}, + "aq_Water_Shoreline": {MaterialWarning.SHORELINE}, + "aq_Water_Swimming-Pool": {MaterialWarning.VOLUME}, + "aq_Water_Lake": {MaterialWarning.VOLUME}, + "aq_Water_Pond": {MaterialWarning.VOLUME}, +} + + +def get_material_warnings_obj_based(obj: bpy.types.Object, material_name: str) -> typing.Set[str]: + + warnings = MATERIAL_WARNING_MAP.get(material_name, None) + if warnings is None: + return set() + + # Create copy of the original set so the original isn't modified + warnings = set(warnings) + if not polib.linalg_bpy.is_obj_flat(obj): + warnings -= {MaterialWarning.VOLUME} + + return warnings + + +@feature_utils.register_feature +@polib.log_helpers_bpy.logged_panel +class AquatiqMaterialLimitationsPanel(feature_utils.EngonFeaturePanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_aquatiq_material_limitation_warning" + bl_parent_id = asset_pack_panels.AquatiqPanel.bl_idname + bl_label = "Material Limitations" + + feature_name = "aquatiq_material_limitation_warning" + + @classmethod + def get_material_limitations(cls, obj: typing.Optional[bpy.types.Object]) -> typing.Set[str]: + if obj is None: + return set() + + active_material = obj.active_material + if active_material is None: + return set() + + material_name = polib.utils_bpy.remove_object_duplicate_suffix(active_material.name) + warnings = get_material_warnings_obj_based(obj, material_name) + return warnings + + @classmethod + def poll(cls, context: bpy.types.Context): + return ( + super().poll(context) + and context.active_object is not None + and len(cls.get_material_limitations(context.active_object)) > 0 + ) + + def draw_header(self, context: bpy.types.Context) -> None: + self.layout.alert = True + self.layout.label(text="", icon='ERROR') + + def draw_material_limitations( + self, layout: bpy.types.UILayout, obj: typing.Optional[bpy.types.Object] + ): + warnings = self.get_material_limitations(obj) + + if len(warnings) == 0: + return + + layout.alert = True + op = layout.operator( + ui_utils.ShowPopup.bl_idname, + text=f"See {len(warnings)} warning{'s' if len(warnings) > 1 else ''}", + icon='ERROR', + ) + op.message = "\n".join(warnings) + op.title = "Material limitations warning" + op.icon = 'ERROR' + + def draw(self, context: bpy.types.Context): + self.draw_material_limitations(self.layout, context.active_object) + + +MODULE_CLASSES.append(AquatiqMaterialLimitationsPanel) + + +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/aquatiq/paint_mask.py b/features/aquatiq_paint_mask.py similarity index 68% rename from aquatiq/paint_mask.py rename to features/aquatiq_paint_mask.py index e69f288..f76aa43 100644 --- a/aquatiq/paint_mask.py +++ b/features/aquatiq_paint_mask.py @@ -22,6 +22,8 @@ import bpy import typing import logging +from . import feature_utils +from . import asset_pack_panels from .. import polib from .. import preferences from .. import asset_helpers @@ -29,9 +31,26 @@ logger = logging.getLogger(f"polygoniq.{__name__}") +AQ_PAINT_VERTICES_WARNING_THRESHOLD = 16 MODULE_CLASSES: typing.List[typing.Type] = [] +class PaintMaskPreferences(bpy.types.PropertyGroup): + draw_mask_factor: bpy.props.FloatProperty( + name="Mask Factor", + description="Value of 1 means visible, value of 0 means hidden", + update=lambda self, context: self.update_mask_factor(context), + soft_max=1.0, + soft_min=0.0, + ) + + def update_mask_factor(self, context: bpy.types.Context): + context.tool_settings.vertex_paint.brush.color = [self.draw_mask_factor] * 3 + + +MODULE_CLASSES.append(PaintMaskPreferences) + + @polib.log_helpers_bpy.logged_operator class EnterVertexPaintMode(bpy.types.Operator): bl_idname = "engon.aquatiq_enter_vertex_paint" @@ -99,7 +118,7 @@ def execute(self, context: bpy.types.Context): active_object.data.use_paint_mask_vertex = False active_object.data.use_paint_mask = False bpy.ops.object.mode_set(mode='VERTEX_PAINT') - prefs = preferences.prefs_utils.get_preferences(context).aquatiq_preferences + prefs = preferences.prefs_utils.get_preferences(context).aquatiq_paint_mask_preferences context.tool_settings.vertex_paint.brush.color = [prefs.draw_mask_factor] * 3 return {'FINISHED'} @@ -200,6 +219,75 @@ def execute(self, context): MODULE_CLASSES.append(ReturnToObjectMode) +@feature_utils.register_feature +@polib.log_helpers_bpy.logged_panel +class AquatiqPaintMaskPanel(feature_utils.EngonFeaturePanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_aquatiq_paint_mask" + bl_parent_id = asset_pack_panels.AquatiqPanel.bl_idname + bl_label = "Paint Mask" + + feature_name = "aquatiq_paint_mask" + + def draw_header(self, context: bpy.types.Context) -> None: + self.layout.label(text="", icon='MOD_OCEAN') + + def draw_vertex_paint_ui(self, context: bpy.types.Context): + layout = self.layout + prefs = preferences.prefs_utils.get_preferences(context).aquatiq_paint_mask_preferences + brush = context.tool_settings.vertex_paint.brush + unified_settings = context.tool_settings.unified_paint_settings + + if context.vertex_paint_object is None or context.vertex_paint_object.data is None: + return + + mesh_data = context.vertex_paint_object.data + + if len(mesh_data.vertices) <= AQ_PAINT_VERTICES_WARNING_THRESHOLD: + layout.label(text="Subdivide the mesh for more control!", icon='ERROR') + + col = layout.column(align=True) + polib.ui_bpy.row_with_label(col, text="Mask") + row = col.row(align=True) + row.prop(prefs, "draw_mask_factor", slider=True) + # Wrap color in another row to scale it so it is more rectangular + color_wrapper = row.row(align=True) + color_wrapper.scale_x = 0.3 + color_wrapper.prop(brush, "color", text="") + + col = layout.column(align=True) + row = col.row(align=True) + row.operator(ApplyMask.bl_idname, text="Boundaries", icon='MATPLANE').only_boundaries = True + row.operator(ApplyMask.bl_idname, text="Fill", icon='SNAP_FACE').only_boundaries = False + + col = layout.column(align=True) + polib.ui_bpy.row_with_label(col, text="Brush") + col.prop(brush, "strength") + col.prop(unified_settings, "size", slider=True) + + col = layout.column(align=True) + polib.ui_bpy.row_with_label(col, text="Paint Only to Selected") + row = col.row(align=True) + row.prop(mesh_data, "use_paint_mask_vertex", text="Vertex") + row.prop(mesh_data, "use_paint_mask", text="Face") + + layout.operator(ReturnToObjectMode.bl_idname, text="Return", icon='LOOP_BACK') + + def draw(self, context: bpy.types.Context): + if context.mode == 'PAINT_VERTEX': + self.draw_vertex_paint_ui(context) + return + + layout: bpy.types.UILayout = self.layout + + asset_col = layout.column(align=True) + polib.ui_bpy.scaled_row(asset_col, 1.2).operator( + EnterVertexPaintMode.bl_idname, text="Paint Alpha Mask", icon='MOD_MASK' + ) + + +MODULE_CLASSES.append(AquatiqPaintMaskPanel) + + def register(): for cls in MODULE_CLASSES: bpy.utils.register_class(cls) diff --git a/features/asset_pack_panels.py b/features/asset_pack_panels.py new file mode 100644 index 0000000..e8a9607 --- /dev/null +++ b/features/asset_pack_panels.py @@ -0,0 +1,129 @@ +# 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 +from . import feature_utils +from .. import polib +from .. import __package__ as base_package +import logging + +logger = logging.getLogger(f"polygoniq.{__name__}") + + +MODULE_CLASSES: typing.List[typing.Type] = [] + + +class AssetPackPanelMixin(feature_utils.EngonFeaturePanelMixin): + """Base mixin for engon asset pack panels. Feature name holds the name of the asset pack. + + Asset pack panel appears (polls True) if and only if an asset pack that is associated + with the panel is registered. + """ + + bl_options = {'DEFAULT_CLOSED'} + layout: bpy.types.UILayout + + def draw_header(self, context: bpy.types.Context): + self.layout.label( + text="", + icon_value=polib.ui_bpy.icon_manager.get_engon_feature_icon_id(self.feature_name), + ) + + def draw_header_preset(self, context: bpy.types.Context) -> None: + polib.ui_bpy.draw_doc_button( + self.layout, + base_package, + rel_url=f"panels/{self.feature_name}/panel_overview", + ) + + +@feature_utils.register_feature +@polib.log_helpers_bpy.logged_panel +class BotaniqPanel(AssetPackPanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_botaniq" + bl_label = "botaniq" + bl_order = 10 + + feature_name = "botaniq" + + def draw(self, context: bpy.types.Context): + pass + + +MODULE_CLASSES.append(BotaniqPanel) + + +@feature_utils.register_feature +@polib.log_helpers_bpy.logged_panel +class InterniqPanel(AssetPackPanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_interniq" + bl_label = "interniq" + bl_order = 10 + + feature_name = "interniq" + + def draw(self, context: bpy.types.Context): + pass + + +MODULE_CLASSES.append(InterniqPanel) + + +@feature_utils.register_feature +@polib.log_helpers_bpy.logged_panel +class TraffiqPanel(AssetPackPanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_traffiq" + bl_label = "traffiq" + bl_order = 10 + + feature_name = "traffiq" + + def draw(self, context: bpy.types.Context): + pass + + +MODULE_CLASSES.append(TraffiqPanel) + + +@feature_utils.register_feature +@polib.log_helpers_bpy.logged_panel +class AquatiqPanel(AssetPackPanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_aquatiq" + bl_label = "aquatiq" + bl_order = 10 + + feature_name = "aquatiq" + + def draw(self, context: bpy.types.Context): + pass + + +MODULE_CLASSES.append(AquatiqPanel) + + +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/features/botaniq_adjustments.py b/features/botaniq_adjustments.py new file mode 100644 index 0000000..0af2566 --- /dev/null +++ b/features/botaniq_adjustments.py @@ -0,0 +1,244 @@ +# 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 math +from . import feature_utils +from . import asset_pack_panels +from .. import polib +from .. import preferences +from .. import asset_helpers + + +MODULE_CLASSES = [] + + +class BotaniqAdjustmentPreferences(bpy.types.PropertyGroup): + brightness: bpy.props.FloatProperty( + name="Brightness", + description="Adjust assets brightness", + default=1.0, + min=0.0, + max=10.0, + soft_max=1.0, + step=1, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + BotaniqAdjustmentsPanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.BQ_BRIGHTNESS, + self.brightness, + ), + ) + + hue_per_branch: bpy.props.FloatProperty( + name="Hue Per Branch", + description="Randomize hue per branch", + default=1.0, + min=0.0, + max=1.0, + step=1, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + BotaniqAdjustmentsPanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.BQ_RANDOM_PER_BRANCH, + self.hue_per_branch, + ), + ) + + hue_per_leaf: bpy.props.FloatProperty( + name="Hue Per Leaf", + description="Randomize hue per leaf", + default=1.0, + min=0.0, + max=1.0, + step=1, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + BotaniqAdjustmentsPanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.BQ_RANDOM_PER_LEAF, + self.hue_per_leaf, + ), + ) + + season_offset: bpy.props.FloatProperty( + name="Season Offset", + description="Change season of asset", + default=1.0, + min=0.0, + max=1.0, + step=1, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + BotaniqAdjustmentsPanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.BQ_SEASON_OFFSET, + self.season_offset, + ), + ) + + +MODULE_CLASSES.append(BotaniqAdjustmentPreferences) + + +@feature_utils.register_feature +@polib.log_helpers_bpy.logged_panel +class BotaniqAdjustmentsPanel(feature_utils.PropertyAssetFeatureControlPanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_feature_botaniq_adjustments" + bl_parent_id = asset_pack_panels.BotaniqPanel.bl_idname + bl_label = "Adjustments" + feature_name = "botaniq_adjustments" + related_custom_properties = { + polib.custom_props_bpy.CustomPropertyNames.BQ_BRIGHTNESS, + polib.custom_props_bpy.CustomPropertyNames.BQ_RANDOM_PER_BRANCH, + polib.custom_props_bpy.CustomPropertyNames.BQ_RANDOM_PER_LEAF, + polib.custom_props_bpy.CustomPropertyNames.BQ_SEASON_OFFSET, + } + + @classmethod + def filter_adjustable_assets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + return set(cls.get_selected_particle_system_targets(possible_assets)) | set( + cls.filter_adjustable_assets_simple(possible_assets) + ) + + @classmethod + def get_multiedit_adjustable_assets( + cls, context: bpy.types.Context + ) -> typing.Iterable[bpy.types.ID]: + return set(cls.get_possible_assets(context)).union( + asset_helpers.gather_instanced_objects(cls.get_possible_assets(context)) + ) + + def get_season_from_value(self, value: float) -> str: + # We need to change seasons at 0.125, 0.325, 0.625, 0.875 + # The list of seasons and values holds the "center" value of the season, not the boundaries + # We need to do a bit of math to move it to get proper ranges. -0.125 moves from center to + # start of the range, + 1.0 because fmod doesn't work with negative values + adjusted_value: float = math.fmod(value - 0.125 + 1.0, 1.0) + for season, max_value in reversed(polib.asset_pack.BOTANIQ_SEASONS_WITH_COLOR_CHANNEL): + if adjusted_value <= max_value: + return season + return "unknown" + + def draw_header(self, context: bpy.types.Context) -> None: + self.layout.label(text="", icon='MOD_HUE_SATURATION') + + def draw_properties(self, datablock: bpy.types.ID, layout: bpy.types.UILayout) -> None: + self.draw_property( + datablock, layout, polib.custom_props_bpy.CustomPropertyNames.BQ_BRIGHTNESS + ) + season_text = self.get_season_from_value( + datablock.get(polib.custom_props_bpy.CustomPropertyNames.BQ_SEASON_OFFSET, -1.0) + ) + self.draw_property( + datablock, + layout, + polib.custom_props_bpy.CustomPropertyNames.BQ_SEASON_OFFSET, + text=season_text, + ) + self.draw_property( + datablock, layout, polib.custom_props_bpy.CustomPropertyNames.BQ_RANDOM_PER_BRANCH + ) + self.draw_property( + datablock, layout, polib.custom_props_bpy.CustomPropertyNames.BQ_RANDOM_PER_LEAF + ) + + def draw_multiedit( + self, + context: bpy.types.Context, + layout: bpy.types.UILayout, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> None: + self.draw_multiedit_header(layout) + + prefs = preferences.prefs_utils.get_preferences(context).botaniq_adjustment_preferences + row = layout.row(align=True) + + row.label(text="", icon='LIGHT_SUN') + row.prop(prefs, "brightness", text="Brightness", slider=True) + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.BQ_BRIGHTNESS, + feature_utils.RandomizeFloatPropertyOperator, + row, + ) + + row = layout.row(align=True) + row.label(text="", icon='BRUSH_MIX') + row.prop( + prefs, + "season_offset", + icon='BRUSH_MIX', + text=f"Season: {self.get_season_from_value(prefs.season_offset)}", + slider=True, + ) + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.BQ_SEASON_OFFSET, + feature_utils.RandomizeFloatPropertyOperator, + row, + ) + + row = layout.row(align=True) + row.label(text="", icon='COLORSET_12_VEC') + row.prop(prefs, "hue_per_branch", text="Hue per Branch", slider=True) + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.BQ_RANDOM_PER_BRANCH, + feature_utils.RandomizeFloatPropertyOperator, + row, + ) + + row = layout.row(align=True) + row.label(text="", icon='COLORSET_02_VEC') + row.prop(prefs, "hue_per_leaf", text="Hue per Leaf", slider=True) + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.BQ_RANDOM_PER_LEAF, + feature_utils.RandomizeFloatPropertyOperator, + row, + ) + + def draw(self, context: bpy.types.Context) -> None: + layout = self.layout + + possible_assets = self.get_possible_assets(context) + if self.conditionally_draw_warning_no_adjustable_assets(possible_assets, layout): + return + + self.draw_adjustable_assets_property_table( + possible_assets, + ["Brightness", "Season", "Branch Hue", "Leaf Hue"], + layout, + lambda layout, obj: self.draw_properties(obj, layout), + ) + layout.separator() + self.draw_multiedit(context, layout, possible_assets) + + +MODULE_CLASSES.append(BotaniqAdjustmentsPanel) + + +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/botaniq/__init__.py b/features/botaniq_animations/__init__.py similarity index 90% rename from botaniq/__init__.py rename to features/botaniq_animations/__init__.py index 4ab53e5..7830973 100644 --- a/botaniq/__init__.py +++ b/features/botaniq_animations/__init__.py @@ -19,14 +19,14 @@ # ##### END GPL LICENSE BLOCK ##### from . import animations -from . import panel +from . import botaniq_animations def register(): animations.register() - panel.register() + botaniq_animations.register() def unregister(): - panel.unregister() + botaniq_animations.unregister() animations.unregister() diff --git a/botaniq/animations.py b/features/botaniq_animations/animations.py similarity index 96% rename from botaniq/animations.py rename to features/botaniq_animations/animations.py index 7160496..0486cdc 100644 --- a/botaniq/animations.py +++ b/features/botaniq_animations/animations.py @@ -24,18 +24,19 @@ import os import random import logging -from .. import polib -from .. import mapr -from .. import preferences -from .. import asset_helpers -from .. import asset_registry +from . import botaniq_animations +from ... import polib +from ... import mapr +from ... import preferences +from ... import asset_helpers +from ... import asset_registry logger = logging.getLogger(f"polygoniq.{__name__}") MODULE_CLASSES: typing.List[typing.Type] = [] -DEFAULT_PRESET = preferences.botaniq_preferences.WindPreset.WIND +DEFAULT_PRESET = botaniq_animations.WindPreset.WIND DEFAULT_WIND_STRENGTH = 0.5 MODIFIER_STACK_NAME_PREFIX = "bq_Modifier-Stack" ANIMATION_INSTANCES_COLLECTION = "animation_instances" @@ -516,7 +517,7 @@ def set_animation_frame_range( keyframe.handle_right.x = keyframe.co.x - (r_handle_distance * multiplier) -def get_wind_style(action: bpy.types.Action) -> preferences.botaniq_preferences.WindStyle: +def get_wind_style(action: bpy.types.Action) -> botaniq_animations.WindStyle: """Infers animation style from looking at status of FCurve Noise modifier in stack.""" for fcurve in action.fcurves: if len(fcurve.modifiers) < len(WIND_ANIMATION_FCURVE_STYLE_MODS): @@ -528,16 +529,16 @@ def get_wind_style(action: bpy.types.Action) -> preferences.botaniq_preferences. continue if noise.mute: - return preferences.botaniq_preferences.WindStyle.LOOP - return preferences.botaniq_preferences.WindStyle.PROCEDURAL + return botaniq_animations.WindStyle.LOOP + return botaniq_animations.WindStyle.PROCEDURAL - return preferences.botaniq_preferences.WindStyle.UNKNOWN + return botaniq_animations.WindStyle.UNKNOWN def change_anim_style( obj: bpy.types.Object, helper_objs: typing.Iterable[bpy.types.Object], - style: preferences.botaniq_preferences.WindStyle, + style: botaniq_animations.WindStyle, ) -> None: """Set animation style of given objects and its given helper empties. @@ -548,7 +549,7 @@ def change_anim_style( """ def change_anim_style_of_action( - action: bpy.types.Action, style: preferences.botaniq_preferences.WindStyle + action: bpy.types.Action, style: botaniq_animations.WindStyle ) -> None: """Set animation style of action to the provided one by changing the mute status on specific FCurves.""" for fcurve in action.fcurves: @@ -566,7 +567,7 @@ def change_anim_style_of_action( loop_mod_type, loop_mod_status = LOOPING_STACK_STATUS[i] if loop_mod_type != modifier.type: continue - if style == preferences.botaniq_preferences.WindStyle.LOOP: + if style == botaniq_animations.WindStyle.LOOP: modifier.mute = not loop_mod_status else: modifier.mute = loop_mod_status @@ -598,7 +599,7 @@ def change_preset( if obj.animation_data is None: obj.animation_data_create() - animation_style = preferences.botaniq_preferences.WindStyle.LOOP + animation_style = botaniq_animations.WindStyle.LOOP else: old_action = obj.animation_data.action animation_style = get_wind_style(old_action) @@ -632,7 +633,7 @@ class AnimationOperatorBase(bpy.types.Operator): def get_target_objects(context: bpy.types.Context) -> typing.Iterable[bpy.types.Object]: wind_properties = preferences.prefs_utils.get_preferences( context - ).botaniq_preferences.wind_anim_properties + ).botaniq_animations_preferences.wind_anim_properties if wind_properties.operator_target == 'SELECTED': target_objects = context.selected_objects @@ -650,7 +651,7 @@ def invoke(self, context: bpy.types.Context, event: bpy.types.Event): def draw(self, context: bpy.types.Context) -> None: wind_properties = preferences.prefs_utils.get_preferences( context - ).botaniq_preferences.wind_anim_properties + ).botaniq_animations_preferences.wind_anim_properties self.layout.prop(wind_properties, "operator_target", text="") @@ -681,7 +682,7 @@ def draw(self, context: bpy.types.Context): layout.use_property_split = True props = preferences.prefs_utils.get_preferences( context - ).botaniq_preferences.wind_anim_properties + ).botaniq_animations_preferences.wind_anim_properties layout.prop(props, "animation_type", text="Animation Type") layout.prop(props, "auto_make_instance") @@ -701,7 +702,7 @@ def restore_state(self, context: bpy.types.Context) -> None: if self.previous_active_object_name in context.view_layer.objects: context.view_layer.objects.active = bpy.data.objects[self.previous_active_object_name] - def is_animable(self, prefs: preferences.Preferences, obj: bpy.types.Object) -> bool: + def is_animable(self, obj: bpy.types.Object) -> bool: asset_provider = asset_registry.instance.master_asset_provider assert asset_provider is not None @@ -810,7 +811,7 @@ def build_animation_type_objs_map( """ # Return early with direct map of animation_type -> objects if selected animation type # is different from BEST_FIT - if animation_type != preferences.botaniq_preferences.AnimationType.WIND_BEST_FIT.value: + if animation_type != botaniq_animations.AnimationType.WIND_BEST_FIT.value: return {animation_type: list(objects)} animation_type_objs_map: typing.Dict[str, typing.List[bpy.types.Object]] = ( @@ -890,9 +891,7 @@ def animate_objects( helper_objs = get_animated_objects_hierarchy( obj, load_helper_object_names(animation_library_path) ) - change_anim_style( - obj, helper_objs, preferences.botaniq_preferences.WindStyle.PROCEDURAL - ) + change_anim_style(obj, helper_objs, botaniq_animations.WindStyle.PROCEDURAL) animated_object_names.append(obj.name) if make_instance: @@ -926,7 +925,7 @@ def report_old_action(self, obj: bpy.types.Object) -> None: ) def execute(self, context: bpy.types.Context): - prefs = preferences.prefs_utils.get_preferences(context).botaniq_preferences + prefs = preferences.prefs_utils.get_preferences(context).botaniq_animations_preferences props = prefs.wind_anim_properties selected_objects = context.selected_objects auto_make_instance: bool = prefs.wind_anim_properties.auto_make_instance @@ -950,12 +949,12 @@ def execute(self, context: bpy.types.Context): ) return {'CANCELLED'} - if self.is_animable(prefs, obj) and self.has_missing_6_7_anim_data(obj): + if self.is_animable(obj) and self.has_missing_6_7_anim_data(obj): self.report_missing_data(obj) return {'CANCELLED'} for instance_obj in asset_helpers.gather_instanced_objects(selected_objects): - if not self.is_animable(prefs, instance_obj): + if not self.is_animable(instance_obj): continue if self.has_missing_6_7_anim_data(instance_obj): @@ -971,7 +970,7 @@ def execute(self, context: bpy.types.Context): try: scatter_objs = asset_helpers.gather_instanced_objects(selected_objects) extended_selected_objects = set(selected_objects) | set(scatter_objs) - animable_objects = {o for o in extended_selected_objects if self.is_animable(prefs, o)} + animable_objects = {o for o in extended_selected_objects if self.is_animable(o)} # Source map has to be build out of all objects in the scene, so we can detect that # the newly animated asset is in particle system -> we can change the particle system @@ -1101,7 +1100,7 @@ def execute(self, context: bpy.types.Context): anim_objs = AnimationRemoveWind.get_objs_with_animation_leftovers(context.selected_objects) # Filter out children animation empties - they are deleted # in AnimationRemoveWind.remove_animation together with parent object - anim_root_objs = polib.asset_pack_bpy.find_polygoniq_root_objects(anim_objs) + anim_root_objs = polib.asset_pack_bpy.find_root_objects(anim_objs) for obj in anim_root_objs: AnimationRemoveWind.remove_animation(obj, helper_object_names) @@ -1204,7 +1203,7 @@ class AnimationApplyStrength(AnimationOperatorBase): def execute(self, context: bpy.types.Context): wind_properties = preferences.prefs_utils.get_preferences( context - ).botaniq_preferences.wind_anim_properties + ).botaniq_animations_preferences.wind_anim_properties target_objects = AnimationOperatorBase.get_target_objects(context) animated_objects = list(get_animated_objects(target_objects)) logger.info( @@ -1231,7 +1230,7 @@ class AnimationApplyPreset(AnimationOperatorBase): def execute(self, context: bpy.types.Context): wind_properties = preferences.prefs_utils.get_preferences( context - ).botaniq_preferences.wind_anim_properties + ).botaniq_animations_preferences.wind_anim_properties target_objects = AnimationOperatorBase.get_target_objects(context) animated_objects = list(get_animated_objects(target_objects)) logger.info( @@ -1265,7 +1264,7 @@ class AnimationApplyLoop(AnimationOperatorBase): def execute(self, context: bpy.types.Context): wind_properties = preferences.prefs_utils.get_preferences( context - ).botaniq_preferences.wind_anim_properties + ).botaniq_animations_preferences.wind_anim_properties loop_value = wind_properties.looping target_objects = AnimationOperatorBase.get_target_objects(context) @@ -1297,17 +1296,17 @@ class AnimationSetAnimStyle(AnimationOperatorBase): description="Choose the desired animation style", items=[ ( - preferences.botaniq_preferences.WindStyle.PROCEDURAL.name, - preferences.botaniq_preferences.WindStyle.PROCEDURAL.value, + botaniq_animations.WindStyle.PROCEDURAL.name, + botaniq_animations.WindStyle.PROCEDURAL.value, "Procedural botaniq animation", ), ( - preferences.botaniq_preferences.WindStyle.LOOP.name, - preferences.botaniq_preferences.WindStyle.LOOP.value, + botaniq_animations.WindStyle.LOOP.name, + botaniq_animations.WindStyle.LOOP.value, "Looping botaniq animation", ), ], - default=preferences.botaniq_preferences.WindStyle.PROCEDURAL.name, + default=botaniq_animations.WindStyle.PROCEDURAL.name, ) def draw(self, context: bpy.types.Context) -> None: @@ -1318,10 +1317,10 @@ def draw(self, context: bpy.types.Context) -> None: def execute(self, context: bpy.types.Context): animation_library_path = get_animation_library_path() - if self.style == preferences.botaniq_preferences.WindStyle.PROCEDURAL.name: - style_enum = preferences.botaniq_preferences.WindStyle.PROCEDURAL - elif self.style == preferences.botaniq_preferences.WindStyle.LOOP.name: - style_enum = preferences.botaniq_preferences.WindStyle.LOOP + if self.style == botaniq_animations.WindStyle.PROCEDURAL.name: + style_enum = botaniq_animations.WindStyle.PROCEDURAL + elif self.style == botaniq_animations.WindStyle.LOOP.name: + style_enum = botaniq_animations.WindStyle.LOOP else: raise ValueError(f"Unknown operation '{self.style}', expected LOOP or PROCEDURAL!") @@ -1350,7 +1349,7 @@ class AnimationSetFrames(bpy.types.Operator): def execute(self, context: bpy.types.Context): wind_properties = preferences.prefs_utils.get_preferences( context - ).botaniq_preferences.wind_anim_properties + ).botaniq_animations_preferences.wind_anim_properties loop_value = wind_properties.looping bpy.context.scene.frame_start = 1 @@ -1595,7 +1594,7 @@ def execute(self, context: bpy.types.Context): wind_properties = preferences.prefs_utils.get_preferences( context - ).botaniq_preferences.wind_anim_properties + ).botaniq_animations_preferences.wind_anim_properties bake_folder = wind_properties.bake_folder if not os.path.isdir(bake_folder): os.makedirs(bake_folder) diff --git a/features/botaniq_animations/botaniq_animations.py b/features/botaniq_animations/botaniq_animations.py new file mode 100644 index 0000000..1d2f9b8 --- /dev/null +++ b/features/botaniq_animations/botaniq_animations.py @@ -0,0 +1,443 @@ +# 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 os +import enum + +from bpy.types import ID +from . import animations +from .. import feature_utils +from .. import asset_pack_panels +from ... import polib +from ... import preferences + + +MODULE_CLASSES = [] + + +class WindPreset(enum.Enum): + BREEZE = "Breeze" + WIND = "Wind" + STORM = "Storm" + UNKNOWN = "Unknown" + + +class AnimationType(enum.Enum): + WIND_BEST_FIT = "Wind-Best-Fit" + WIND_TREE = "Wind-Tree" + WIND_PALM = "Wind-Palm" + WIND_LOW_VEGETATION = "Wind-Low-Vegetation" + WIND_LOW_VEGETATION_PLANTS = "Wind-Low-Vegetation-Plants" + WIND_SIMPLE = "Wind-Simple" + UNKNOWN = "Unknown" + + +class WindStyle(enum.Enum): + LOOP = "Loop" + PROCEDURAL = "Procedural" + UNKNOWN = "Unknown" + + +class WindAnimationProperties(bpy.types.PropertyGroup): + auto_make_instance: bpy.props.BoolProperty( + name="Automatic Make Instance", + description="Automatically make instance out of object when spawning animation. " + "Better performance, but assets share data, customization per instance", + default=False, + ) + + animation_type: bpy.props.EnumProperty( + name="Wind animation type", + description="Select one of predefined animations types." + "This changes the animation and animation modifier stack", + items=( + ( + AnimationType.WIND_BEST_FIT.value, + AnimationType.WIND_BEST_FIT.value, + "Different animation types based on the selection", + 'SHADERFX', + 0, + ), + ( + AnimationType.WIND_TREE.value, + AnimationType.WIND_TREE.value, + "Animation mostly suited for tree assets", + 'BLANK1', + 1, + ), + ( + AnimationType.WIND_PALM.value, + AnimationType.WIND_PALM.value, + "Animation mostly suited for palm assets", + 'BLANK1', + 2, + ), + ( + AnimationType.WIND_LOW_VEGETATION.value, + AnimationType.WIND_LOW_VEGETATION.value, + "Animation mostly suited for low vegetation assets", + 'BLANK1', + 3, + ), + ( + AnimationType.WIND_LOW_VEGETATION_PLANTS.value, + AnimationType.WIND_LOW_VEGETATION_PLANTS.value, + "Animation mostly suited for low vegetation plant assets", + 'BLANK1', + 4, + ), + ( + AnimationType.WIND_SIMPLE.value, + AnimationType.WIND_SIMPLE.value, + "Simple animation, works only on assets with Leaf_ or Grass_ materials", + 'BLANK1', + 5, + ), + ), + ) + + preset: bpy.props.EnumProperty( + name="Wind animation preset", + description="Select one of predefined animations presets." + "This changes detail of animation and animation modifier stack", + items=( + (WindPreset.BREEZE.value, WindPreset.BREEZE.value, "Light breeze wind", 'BOIDS', 0), + (WindPreset.WIND.value, WindPreset.WIND.value, "Moderate wind", 'CURVES_DATA', 1), + (WindPreset.STORM.value, WindPreset.STORM.value, "Strong storm wind", 'MOD_NOISE', 2), + ), + ) + + strength: bpy.props.FloatProperty( + name="Wind strength", + description="Strength of the wind applied on the trees", + default=0.25, + min=0.0, + soft_max=1.0, + ) + + looping: bpy.props.IntProperty( + name="Loop time", + description="At how many frames should the animation repeat. Minimal value to ensure good " + "animation appearance is 80", + default=120, + min=80, + ) + + bake_folder: bpy.props.StringProperty( + name="Bake Folder", + description="Folder where baked .abc animations are saved", + default=os.path.realpath(os.path.expanduser("~/botaniq_animations/")), + subtype='DIR_PATH', + ) + + # Used to choose target of most wind animation operators but not all. + # It's not used in operators where it doesn't make sense, + # e.g. Add Animation works on selected objects. + operator_target: bpy.props.EnumProperty( + name="Target", + description="Choose to what objects the operator should apply", + items=[ + ('SELECTED', "Selected Objects", "All selected objects"), + ('SCENE', "Scene Objects", "All objects in current scene"), + ('ALL', "All Objects", "All objects in the .blend file"), + ], + default='SCENE', + ) + + +MODULE_CLASSES.append(WindAnimationProperties) + + +class BotaniqAnimationsPreferences(bpy.types.PropertyGroup): + + wind_anim_properties: bpy.props.PointerProperty( + name="Animation Properties", + description="Wind animation related property group", + type=WindAnimationProperties, + ) + + +MODULE_CLASSES.append(BotaniqAnimationsPreferences) + + +@feature_utils.register_feature +@polib.log_helpers_bpy.logged_panel +class AnimationsPanel(feature_utils.EngonAssetFeatureControlPanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_botaniq_animations" + bl_parent_id = asset_pack_panels.BotaniqPanel.bl_idname + bl_label = "Animations" + + feature_name = "botaniq_animations" + + @classmethod + def filter_adjustable_assets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + return filter( + lambda obj: obj is not None + and isinstance(obj, bpy.types.Object) + and animations.is_animated(obj), + possible_assets, + ) + + def draw_header(self, context: bpy.types.Context): + self.layout.label(text="", icon='FORCE_WIND') + + def draw_object_anim_details( + self, + context: bpy.types.Context, + layout: bpy.types.UILayout, + obj: bpy.types.Object, + animated_object: bpy.types.Object, + ) -> None: + """Draws animation details of 'obj' into 'layout' based on 'context' and 'animated_obj'. + + 'animated_obj' is the animated object of 'obj' which can be either itself or the object + from it's instanced collection. Check 'animations.get_instanced_mesh_object'. + """ + col = layout.column(align=True) + col.label(text="Active Object", icon='INFO') + if obj.instance_collection is None: + col.label(text=obj.name, icon='OBJECT_DATA') + else: + split = col.split(factor=0.75, align=True) + split.label(text=obj.name, icon='OUTLINER_COLLECTION') + split.operator( + animations.AnimationMakeInstanceUnique.bl_idname, + text=f"{obj.instance_collection.users - 1}", + ) + + action = animated_object.animation_data.action + animation_type, preset = animations.parse_action_name(action) + animation_style = animations.get_wind_style(action) + + col = layout.column(align=True) + split_factor = 0.5 + # Animation Type + split = col.split(factor=split_factor, align=True) + split.label(text="Animation:") + split.label(text=str(animation_type)) + + # Preset + split = col.split(factor=split_factor, align=True) + split.label(text="Preset:") + split.label(text=str(preset)) + + # Style + split = col.split(factor=split_factor, align=True) + split.label(text="Style:") + split.label(text=str(animation_style.value)) + + # Strength + wind_strength = animations.infer_strength_from_action(action) + if wind_strength is not None: + split = col.split(factor=split_factor, align=True) + split.label(text="Strength:") + split.label(text=f"{wind_strength:.3f}x") + + if animation_style == WindStyle.LOOP: + frame_range = animations.get_frame_range(action) + if frame_range is not None: + # Loop Interval + loop_interval = frame_range[1] - frame_range[0] + split = col.split(factor=split_factor, align=True) + split.label(text="Loop Interval:") + split.label(text=f"{round(loop_interval)} frames") + + # Duration + scene_interval = context.scene.frame_end - context.scene.frame_start + scene_fps = animations.get_scene_fps( + context.scene.render.fps, context.scene.render.fps_base + ) + split = col.split(factor=split_factor, align=True) + split.label(text="Duration:") + split.label(text=f"{scene_interval / scene_fps:.1f} s") + + # Speed + speed = loop_interval / animations.get_scene_fps_adjusted_interval(scene_fps) + split = col.split(factor=split_factor, align=True) + split.label(text="Speed:") + split.label(text=f"{speed:.1f}x") + + def draw(self, context: bpy.types.Context): + wind_properties = preferences.prefs_utils.get_preferences( + context + ).botaniq_animations_preferences.wind_anim_properties + layout = self.layout + + row = polib.ui_bpy.scaled_row(layout, 1.5, align=True) + row.operator(animations.AnimationAddWind.bl_idname, text="Add Animation", icon='ADD') + layout.separator() + + row = layout.row(align=True) + row.operator( + animations.AnimationMakeInstanced.bl_idname, text="Make Instance", icon='GROUP' + ) + row.operator( + animations.AnimationRemoveWind.bl_idname, text="Remove Animation", icon='REMOVE' + ) + + row = layout.row(align=True) + row.operator(animations.AnimationMute.bl_idname, text="Mute/Unmute Animation") + + if next(animations.get_animated_objects(context.selected_objects), None) is not None: + col = layout.column(align=True) + col.label(text="Preset & Strength") + row = col.split(align=True, factor=0.75) + row.prop(wind_properties, "preset", text="") + row.operator(animations.AnimationApplyPreset.bl_idname, text="Set") + + row = col.split(align=True, factor=0.75) + row.prop(wind_properties, "strength", text="Strength") + row.operator(animations.AnimationApplyStrength.bl_idname, text="Set") + + col = layout.column(align=True) + col.label(text="Animation Style") + row = col.split(align=True) + row.operator( + animations.AnimationSetAnimStyle.bl_idname, text="Loop / Procedural Switch" + ) + col.separator() + + row = col.split(align=True, factor=0.75) + row.prop(wind_properties, "looping", text="Loop Frames") + row.operator(animations.AnimationApplyLoop.bl_idname, text="Set") + row = col.split(align=True) + row.operator(animations.AnimationSetFrames.bl_idname, text="Set Scene Frames") + col.separator() + + row = col.row(align=True) + row.operator(animations.AnimationRandomizeOffset.bl_idname) + + col = layout.column(align=True) + col.label(text="Alembic Bake") + col.prop(wind_properties, "bake_folder") + col.operator(animations.AnimationBake.bl_idname, text="Bake to Alembic") + + if self.conditionally_draw_warning_no_adjustable_active_object( + context, layout, warning_text="Active object is not an asset with an animation!" + ): + return + + active_object = context.active_object + if animations.has_6_6_or_older_action(context.active_object): + col = layout.column(align=True) + col.label(text="Asset has old Animation", icon='ERROR') + col.label(text="Please respawn the asset or use") + col.label(text="'Convert to Linked' and 'Convert to Editable' and") + col.label(text="re-apply the animation.") + + animated_object = animations.get_instanced_mesh_object(active_object) + if self.conditionally_draw_warning_no_adjustable_assets( + filter(lambda obj: obj is not None, [animated_object]), + layout, + warning_text="Active object is not an asset with an animation!", + ): + return + assert animated_object is not None + + self.draw_object_anim_details(context, layout, active_object, animated_object) + + +MODULE_CLASSES.append(AnimationsPanel) + + +@polib.log_helpers_bpy.logged_panel +class AnimationAdvancedPanel(feature_utils.EngonAssetFeatureControlPanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_botaniq_animations_advanced" + bl_parent_id = AnimationsPanel.bl_idname + bl_label = "Animations Advanced" + + feature_name = "botaniq_animations" + + @classmethod + def filter_adjustable_assets(cls, possible_assets: typing.Iterable[ID]) -> typing.Iterable[ID]: + return filter( + lambda obj: obj is not None + and animations.get_instanced_mesh_object(obj) + and animations.is_animated(obj), + possible_assets, + ) + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + return context.mode == 'OBJECT' + + def draw_header(self, context: bpy.types.Context): + self.layout.label(text="", icon='OPTIONS') + + def draw(self, context: bpy.types.Context): + layout = self.layout + + if self.conditionally_draw_warning_no_adjustable_active_object( + context, layout, warning_text="Active object is not an asset with an animation!" + ): + return + + animated_object = animations.get_instanced_mesh_object(context.active_object) + assert animated_object is not None + + col = layout.column(align=True) + row = col.row(align=True) + row.label(text="Modifier") + sub_col = row.column() + sub_col.alignment = 'RIGHT' + sub_col.label(text="Strength") + sub_col = row.column() + sub_col.alignment = 'RIGHT' + sub_col.label(text="Enabled") + + action = animated_object.animation_data.action + scale_mod_prop_map = animations.get_envelope_multiplier_mod_prop_map(action) + for mod_name, fmod_limits in sorted( + animations.get_animation_state_control_modifiers(action), key=lambda x: x[0] + ): + row = col.row(align=True) + row.label(text=f"{mod_name[len('bq_'):]}") + + mod, prop = scale_mod_prop_map.get(mod_name, (None, None)) + amplitude_col = row.column(align=True) + amplitude_col.alignment = 'RIGHT' + if mod is not None: + amplitude_col.prop(mod, prop, text="") + else: + amplitude_col.label(text="Not Controllable") + + row.prop(animated_object.modifiers[mod_name], "show_viewport", text="") + # For some reason next icon from the desired one has to be used: AUTO => CHECKMARK + row.prop( + fmod_limits, "mute", text="", icon='AUTO' if fmod_limits.mute is True else 'BLANK1' + ) + + +MODULE_CLASSES.append(AnimationAdvancedPanel) + + +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/features/colorize.py b/features/colorize.py index 8694bab..26ac154 100644 --- a/features/colorize.py +++ b/features/colorize.py @@ -1,72 +1,206 @@ # 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 +from . import feature_utils from .. import polib -from .. import asset_registry -from .. import interniq from .. import preferences +from . import asset_pack_panels + MODULE_CLASSES = [] -class ColorizePanelInfoMixin: - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "polygoniq" +class ColorizePreferences(bpy.types.PropertyGroup): + primary_color: bpy.props.FloatVectorProperty( + name="Primary color", + subtype='COLOR', + description="Changes primary color of assets", + default=(1.0, 1.0, 1.0), + size=3, + min=0.0, + max=1.0, + step=1, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + ColorizePanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.PQ_PRIMARY_COLOR, + self.primary_color, + ), + ) + + primary_color_factor: bpy.props.FloatProperty( + name="Primary factor", + description="Changes intensity of the primary color", + default=0.0, + min=0.0, + max=1.0, + step=1, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + ColorizePanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.PQ_PRIMARY_COLOR_FACTOR, + self.primary_color_factor, + ), + ) + + secondary_color: bpy.props.FloatVectorProperty( + name="Secondary color", + subtype='COLOR', + description="Changes secondary color of assets", + default=(1.0, 1.0, 1.0), + size=3, + min=0.0, + max=1.0, + step=1, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + ColorizePanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.PQ_SECONDARY_COLOR, + self.secondary_color, + ), + ) + + secondary_color_factor: bpy.props.FloatProperty( + name="Secondary factor", + description="Changes intensity of the secondary color", + default=0.0, + min=0.0, + max=1.0, + step=1, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + ColorizePanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.PQ_SECONDARY_COLOR_FACTOR, + self.secondary_color_factor, + ), + ) - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - return len(asset_registry.instance.get_packs_by_engon_feature("colorize")) > 0 +MODULE_CLASSES.append(ColorizePreferences) + +@feature_utils.register_feature @polib.log_helpers_bpy.logged_panel -class ColorizePanel(ColorizePanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_colorize" +class ColorizePanel(feature_utils.PropertyAssetFeatureControlPanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_feature_colorize" # TODO: this feature is currently interniq-only, but in the future it should be moved to engon panel, # once all other asset packs implement colorize - bl_parent_id = interniq.panel.InterniqPanel.bl_idname + bl_parent_id = asset_pack_panels.InterniqPanel.bl_idname bl_label = "Colorize" + feature_name = "colorize" + related_custom_properties = { + polib.custom_props_bpy.CustomPropertyNames.PQ_PRIMARY_COLOR, + polib.custom_props_bpy.CustomPropertyNames.PQ_SECONDARY_COLOR, + polib.custom_props_bpy.CustomPropertyNames.PQ_PRIMARY_COLOR_FACTOR, + polib.custom_props_bpy.CustomPropertyNames.PQ_SECONDARY_COLOR_FACTOR, + } + + @classmethod + def filter_adjustable_assets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + return cls.filter_adjustable_assets_simple(possible_assets) def draw_header(self, context: bpy.types.Context) -> None: self.layout.label(text="", icon='MOD_HUE_SATURATION') - def draw_properties(self, obj: bpy.types.Object, layout: bpy.types.UILayout) -> None: + def draw_properties(self, datablock: bpy.types.ID, layout: bpy.types.UILayout) -> None: primary_layout = layout.column().row(align=True) - polib.ui_bpy.draw_property( - obj, + self.draw_property( + datablock, primary_layout, polib.custom_props_bpy.CustomPropertyNames.PQ_PRIMARY_COLOR, ) - polib.ui_bpy.draw_property( - obj, + self.draw_property( + datablock, primary_layout, polib.custom_props_bpy.CustomPropertyNames.PQ_PRIMARY_COLOR_FACTOR, ) secondary_layout = layout.column().row(align=True) - polib.ui_bpy.draw_property( - obj, + self.draw_property( + datablock, secondary_layout, polib.custom_props_bpy.CustomPropertyNames.PQ_SECONDARY_COLOR, ) - polib.ui_bpy.draw_property( - obj, + self.draw_property( + datablock, secondary_layout, polib.custom_props_bpy.CustomPropertyNames.PQ_SECONDARY_COLOR_FACTOR, ) - def draw(self, context: bpy.types.Context) -> None: - layout = self.layout + def draw_multiedit( + self, + context: bpy.types.Context, + layout: bpy.types.UILayout, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> None: + self.draw_multiedit_header(layout) - selected_objects = set(context.selected_objects) - colorable_objects = list( - filter( - lambda obj: polib.asset_pack_bpy.has_engon_property_feature(obj, "colorize"), - polib.asset_pack_bpy.get_polygoniq_objects(selected_objects), + adjustable_assets = list(self.filter_adjustable_assets(possible_assets)) + prefs = preferences.prefs_utils.get_preferences(context).colorize_preferences + row = layout.row(align=True) + + if any( + o.get(polib.custom_props_bpy.CustomPropertyNames.PQ_PRIMARY_COLOR_FACTOR) is not None + for o in adjustable_assets + ): + row.label(text="", icon='COLOR') + row.prop(prefs, "primary_color", text="") + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.PQ_PRIMARY_COLOR, + feature_utils.RandomizeColorPropertyOperator, + row, ) - ) + row.prop(prefs, "primary_color_factor", text="Factor", slider=True) + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.PQ_PRIMARY_COLOR_FACTOR, + feature_utils.RandomizeFloatPropertyOperator, + row, + ) + if any( + o.get(polib.custom_props_bpy.CustomPropertyNames.PQ_SECONDARY_COLOR_FACTOR) is not None + for o in adjustable_assets + ): + row = layout.row(align=True) + row.label(text="", icon='RESTRICT_COLOR_ON') + row.prop(prefs, "secondary_color", text="") + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.PQ_SECONDARY_COLOR, + feature_utils.RandomizeColorPropertyOperator, + row, + ) + row.prop(prefs, "secondary_color_factor", text="Factor", slider=True) + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.PQ_SECONDARY_COLOR_FACTOR, + feature_utils.RandomizeFloatPropertyOperator, + row, + ) + + def draw(self, context: bpy.types.Context) -> None: + layout = self.layout + possible_assets = self.get_possible_assets(context) - if len(colorable_objects) == 0: - layout.label(text="No colorable assets selected!") + if self.conditionally_draw_warning_no_adjustable_assets(possible_assets, layout): return row = self.layout.row() @@ -90,36 +224,15 @@ def draw(self, context: bpy.types.Context) -> None: row.label(text="Secondary") row.label(text="Factor") - polib.ui_bpy.draw_property_table( - colorable_objects, + self.draw_adjustable_assets_property_table_body( + possible_assets, left_col, right_col, lambda layout, obj: self.draw_properties(obj, layout), ) - prefs = preferences.prefs_utils.get_preferences(context).colorize_preferences - row = layout.row() - row.label(text="Edit All Selected:") - row.enabled = False - - row = layout.row(align=True) - - if any( - o.get(polib.custom_props_bpy.CustomPropertyNames.PQ_PRIMARY_COLOR_FACTOR) is not None - for o in colorable_objects - ): - row.label(text="", icon='COLOR') - row.prop(prefs, "primary_color", text="") - row.prop(prefs, "primary_color_factor", text="Factor", slider=True) - - if any( - o.get(polib.custom_props_bpy.CustomPropertyNames.PQ_SECONDARY_COLOR_FACTOR) is not None - for o in colorable_objects - ): - row = layout.row(align=True) - row.label(text="", icon='RESTRICT_COLOR_ON') - row.prop(prefs, "secondary_color", text="") - row.prop(prefs, "secondary_color_factor", text="Factor", slider=True) + layout.separator() + self.draw_multiedit(context, layout, possible_assets) MODULE_CLASSES.append(ColorizePanel) diff --git a/features/emergency_lights.py b/features/emergency_lights.py new file mode 100644 index 0000000..92adb6d --- /dev/null +++ b/features/emergency_lights.py @@ -0,0 +1,157 @@ +# 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 +from . import feature_utils +from .. import polib +from .. import asset_helpers + +from . import asset_pack_panels + + +MODULE_CLASSES = [] + + +def get_emergency_lights_container_from_hierarchy_with_root( + obj: bpy.types.Object, +) -> typing.Tuple[typing.Optional[bpy.types.Object], typing.Optional[bpy.types.Object]]: + """Returns the first object in the hierarchy that contains emergency lights and the root of the hierarchy + + Returns None if no such object is found in the hierarchy of the given object. + """ + + def _contains_emergency_lights(obj: bpy.types.Object) -> bool: + return ( + len( + polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( + obj, asset_helpers.TQ_EMERGENCY_LIGHTS_NODE_GROUP_NAME + ) + ) + > 0 + ) + + emergency_lights = list( + polib.asset_pack_bpy.get_root_objects_with_matched_child( + [obj], lambda obj, _: _contains_emergency_lights(obj) + ) + ) + if len(emergency_lights) == 0: + return None, None + assert len(emergency_lights) == 1 + return emergency_lights[0] + + +@feature_utils.register_feature +@polib.log_helpers_bpy.logged_panel +class EmergencyLightsPanel( + bpy.types.Panel, + feature_utils.EngonAssetFeatureControlPanelMixin, + polib.geonodes_mod_utils_bpy.GeoNodesModifierInputsPanelMixin, +): + bl_idname = "VIEW_3D_PT_engon_feature_emergency_lights" + bl_parent_id = asset_pack_panels.TraffiqPanel.bl_idname + bl_label = "Emergency Lights" + feature_name = "emergency_lights" + + template = polib.node_utils_bpy.NodeSocketsDrawTemplate( + asset_helpers.TQ_EMERGENCY_LIGHTS_NODE_GROUP_NAME, filter_=lambda _: True + ) + + @classmethod + def get_possible_assets( + cls, + context: bpy.types.Context, + ) -> typing.Iterable[bpy.types.ID]: + if context.active_object is not None: + return [context.active_object] + return [] + + @classmethod + def filter_adjustable_assets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + lights: typing.Iterable[bpy.types.Object] = [] + for obj in possible_assets: + _, emergency_lights = get_emergency_lights_container_from_hierarchy_with_root(obj) + if emergency_lights is not None: + lights.append(emergency_lights) + + return lights + + def draw_header(self, context: bpy.types.Context) -> None: + self.layout.label(text="", icon='LIGHT_SUN') + + def draw_multiedit( + self, + context: bpy.types.Context, + layout: bpy.types.UILayout, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> None: + raise NotImplementedError() + + def draw(self, context: bpy.types.Context) -> None: + col = self.layout.column() + + emergency_lights: typing.Optional[bpy.types.Object] = None + if self.conditionally_draw_warning_no_adjustable_active_object( + context, + col, + warning_text=f"Active asset does not support {self.get_feature_name_readable()} feature or is not editable!", + ): + return + possible_asset_list = list(self.filter_adjustable_assets(self.get_possible_assets(context))) + assert len(possible_asset_list) == 1 + obj = possible_asset_list[0] + + root_object, emergency_lights = get_emergency_lights_container_from_hierarchy_with_root(obj) + # TODO: differentiate between linked asset and asset without emergency lights + assert emergency_lights is not None + assert root_object is not None + + modifiers = polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( + emergency_lights, asset_helpers.TQ_EMERGENCY_LIGHTS_NODE_GROUP_NAME + ) + mod = modifiers[0] + row = col.row() + left_col = row.column() + left_col.enabled = False + left_col.label(text=root_object.name) + right_col = row.column() + row = right_col.row(align=True) + row.alignment = 'RIGHT' + self.draw_show_viewport_and_render(row, mod) + self.draw_object_modifiers_node_group_inputs_template( + emergency_lights, col, EmergencyLightsPanel.template + ) + + +MODULE_CLASSES.append(EmergencyLightsPanel) + + +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/features/feature_utils.py b/features/feature_utils.py new file mode 100644 index 0000000..8d56788 --- /dev/null +++ b/features/feature_utils.py @@ -0,0 +1,612 @@ +# 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 typing +import bpy +import itertools +import random +import sys +from .. import polib +from .. import asset_registry +from .. import asset_helpers +import logging + +logger = logging.getLogger(f"polygoniq.{__name__}") + +MODULE_CLASSES: typing.List[typing.Type] = [] + + +# Older asset packs only implemented one feature - themselves. +# For backwards compatibility, we need to map features to the old asset packs. +# Features prefixed with asset pack name are still tightly coupled to the asset pack, +# usually via assetpack-specific propeties. +# Features without prefix are sufficiently uncoupled that they possibly +# make sense on their own or within another asset pack. +BACKWARDS_COMPATIBILITY_ASSET_PACKS_MAP = { + "traffiq_paint_adjustments": {"traffiq"}, + "traffiq_lights_settings": {"traffiq"}, + "traffiq_wear": {"traffiq"}, + "traffiq_rigs": {"traffiq"}, + "emergency_lights": {"traffiq"}, + "road_generator": {"traffiq"}, + "botaniq_adjustments": {"botaniq"}, + "botaniq_animations": {"botaniq"}, + "vine_generator": {"botaniq"}, + "river_generator": {"aquatiq"}, + "rain_generator": {"aquatiq"}, + "puddles": {"aquatiq"}, + "aquatiq_paint_mask": {"aquatiq"}, + "aquatiq_material_limitation_warning": {"aquatiq"}, +} + + +class RandomizePropertyOperator(bpy.types.Operator): + """Base class for operators that randomize properties on engon property feautres""" + + bl_description = "Set random value for a custom property of selected objects" + bl_options = {'REGISTER', 'UNDO'} + + custom_property_name: bpy.props.StringProperty(options={'HIDDEN'}) + engon_feature_name: bpy.props.StringProperty(options={'HIDDEN'}) + + def get_affected_assets(self, context: bpy.types.Context) -> typing.Iterable[bpy.types.ID]: + feature: type(EngonAssetFeatureControlPanelMixin) = NAME_FEATURE_MAP.get( + self.engon_feature_name + ) + assert issubclass(feature, EngonAssetFeatureControlPanelMixin) + return feature.get_multiedit_adjustable_assets(context) + + def get_random_value(self) -> polib.custom_props_bpy.CustomAttributeValueType: + raise NotImplementedError("This method must be overriden and implemented in a subclass") + + def execute(self, context: bpy.types.Context): + affected_assets = list(self.get_affected_assets(context)) + for asset in self.get_affected_assets(context): + custom_prop = asset.get(self.custom_property_name, None) + if custom_prop is None: + continue + + polib.custom_props_bpy.update_custom_prop( + context, + [asset], + self.custom_property_name, + self.get_random_value(), + ) + + self.report( + {'INFO'}, + f"Randomized {self.custom_property_name} on {len(affected_assets)} " + f"asset{'s' if len(affected_assets) > 1 else ''}", + ) + return {'FINISHED'} + + +@polib.log_helpers_bpy.logged_operator +class RandomizeFloatPropertyOperator(RandomizePropertyOperator): + bl_idname = "engon.randomize_float_property" + bl_label = "Randomize Float Property" + + float_min: bpy.props.FloatProperty( + name="Minimum Value", + description="Minimum value for randomization", + default=0.0, + soft_min=0.0, + soft_max=1.0, + ) + float_max: bpy.props.FloatProperty( + name="Maximum Value", + description="Maximum value for randomization", + default=1.0, + soft_min=0.0, + soft_max=1.0, + ) + + def draw(self, context: bpy.types.Context): + layout = self.layout + layout.prop(self, "float_min", slider=True) + layout.prop(self, "float_max", slider=True) + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event): + return context.window_manager.invoke_props_dialog(self) + + def get_random_value(self) -> polib.custom_props_bpy.CustomAttributeValueType: + return random.uniform(self.float_min, self.float_max) + + +MODULE_CLASSES.append(RandomizeFloatPropertyOperator) + + +@polib.log_helpers_bpy.logged_operator +class RandomizeIntegerPropertyOperator(RandomizePropertyOperator): + bl_idname = "engon.randomize_integer_property" + bl_label = "Randomize Integer Property" + + int_min: bpy.props.IntProperty( + name="Minimum Value", + description="Minimum value for randomization", + default=0, + min=0, + # only temperature of light adjustments is using RandomizeIntegerProperty, let's cater to it for now + soft_max=12_000, + ) + int_max: bpy.props.IntProperty( + name="Maximum Value", + description="Maximum value for randomization", + default=12_000, + min=0, + soft_max=12_000, + ) + + def draw(self, context: bpy.types.Context): + layout = self.layout + layout.prop(self, "int_min", slider=True) + layout.prop(self, "int_max", slider=True) + + def invoke(self, context: bpy.types.Context, event: bpy.types.Event): + return context.window_manager.invoke_props_dialog(self) + + def get_random_value(self) -> polib.custom_props_bpy.CustomAttributeValueType: + return random.uniform(self.int_min, self.int_max) + + +MODULE_CLASSES.append(RandomizeIntegerPropertyOperator) + + +@polib.log_helpers_bpy.logged_operator +class RandomizeColorPropertyOperator(RandomizePropertyOperator): + # It does not make sense to set a min-max for color properties, + # at least not in RGB space as it is a very unintuitive way to define a "range" of colors. + # HSL might work, but such controls are hard to implement in blender. + bl_idname = "engon.randomize_color_property" + bl_label = "Randomize Color Property" + + def get_random_value(self) -> polib.custom_props_bpy.CustomAttributeValueType: + return [random.uniform(0.0, 1.0), random.uniform(0.0, 1.0), random.uniform(0.0, 1.0)] + + +MODULE_CLASSES.append(RandomizeColorPropertyOperator) + + +class EngonFeaturePanelMixin: + """Abstract base mixin for engon features panels. + + Features are defined on asset packs. Engon feature panel appears (polls true) + if and only if the feature is implemented in at least one asset pack. + + This class is meant to be used as a abstract mixin for classes inheriting from bpy.types.Panel. + Due to both `abc.ABCMeta` and `bpy_types.RNAMeta` implementing their own `register` methods, + it is not possible to simultaneously inherit from `abc.ABCMeta` and `bpy_types.RNAMeta`. + This class and it's children use `raise NotImplementedError()` instead of `@absctractmethod`, + as the decorators do not work without inheriting from `abc.ABCMeta`. + """ + + bl_space_type = 'VIEW_3D' + bl_region_type = 'UI' + bl_category = "polygoniq" + + feature_name: str + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + backwards_compatibility_packs = BACKWARDS_COMPATIBILITY_ASSET_PACKS_MAP.get( + cls.feature_name, [] + ) + + is_implemented = ( + len(asset_registry.instance.get_packs_by_engon_feature(cls.feature_name)) > 0 + ) + is_implemented_with_backwards_compatibility = any( + map( + lambda pack: len(asset_registry.instance.get_packs_by_engon_feature(pack)) > 0, + backwards_compatibility_packs, + ) + ) + + return is_implemented or is_implemented_with_backwards_compatibility + + @classmethod + def get_feature_name_readable(cls) -> str: + return cls.feature_name.replace("_", " ") + + +NAME_FEATURE_MAP: typing.Dict[str, type(EngonFeaturePanelMixin)] = dict() +PROPERTY_FEATURE_PROPERTIES_MAP: typing.Dict[str, typing.Set[str]] = dict() + + +def register_feature(cls: type(EngonFeaturePanelMixin)): + """Registers a feature in NAME_FEATURE_MAP""" + if not hasattr(cls, "feature_name"): + raise AttributeError( + f"EngonFeaturePanelMixin {cls.__name__} does not have a 'feature_name' attribute." + ) + + feature_name = cls.feature_name + if NAME_FEATURE_MAP.get(feature_name, None) is not None: + raise ValueError( + f"Feature '{feature_name}' is already registered by '{NAME_FEATURE_MAP[feature_name].__name__}'." + ) + NAME_FEATURE_MAP[feature_name] = cls + + if issubclass(cls, PropertyAssetFeatureControlPanelMixin): + if not hasattr(cls, "related_custom_properties"): + raise AttributeError( + f"PropertyAssetFeatureControlPanelMixin {cls.__name__} does not have a 'related_custom_properties' attribute." + ) + + PROPERTY_FEATURE_PROPERTIES_MAP[feature_name] = cls.related_custom_properties + + return cls + + +def has_engon_property_feature( + datablock: bpy.types.ID, + feature: str, + include_editable: bool = True, + include_linked: bool = True, +) -> bool: + if not polib.asset_pack_bpy.is_polygoniq_object(datablock): + return False + + # check if obj has at least one of the given properties of the property features + feature_properties = PROPERTY_FEATURE_PROPERTIES_MAP.get(feature, []) + + for feature_property in feature_properties: + if polib.custom_props_bpy.has_property( + datablock, + feature_property, + include_editable=include_editable, + include_linked=include_linked, + ): + return True + return False + + +class EngonAssetFeatureControlPanelMixin(EngonFeaturePanelMixin): + """Abstract mixin for displaying engon asset features in panels. + + Asset feature is a feature that controls spawned assets, e.g. asset's materials, rigs, lights... + """ + + @classmethod + def has_pps(cls, obj: bpy.types.Object) -> bool: + if not hasattr(obj, "particle_systems"): + return False + for particle_system in obj.particle_systems: + if polib.asset_pack.is_pps_name(particle_system.name): + return True + return False + + @classmethod + def extend_with_active_object( + cls, + context: bpy.types.Context, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + if context.active_object is not None: + possible_assets = set(possible_assets) + possible_assets.add(context.active_object) + return possible_assets + + @classmethod + def get_possible_assets(cls, context: bpy.types.Context) -> typing.Iterable[bpy.types.ID]: + return cls.extend_with_active_object(context, context.selected_objects) + + @classmethod + def get_multiedit_adjustable_assets( + cls, context: bpy.types.Context + ) -> typing.Iterable[bpy.types.ID]: + return cls.filter_adjustable_assets(cls.get_possible_assets(context)) + + @classmethod + def filter_adjustable_assets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + raise NotImplementedError("This method must be overriden and implemented in a subclass") + + @classmethod + def has_adjustable_assets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> bool: + return len(list(cls.filter_adjustable_assets(possible_assets))) > 0 + + @classmethod + def has_adjustable_active_object( + cls, + context: bpy.types.Context, + ) -> bool: + adjustable_objects = set() + if context.active_object is not None: + adjustable_objects.add(context.active_object) + return cls.has_adjustable_assets(adjustable_objects) + + def draw_multiedit_header(self, layout: bpy.types.UILayout): + row = layout.row(align=True) + row.enabled = False + row.label(text="Edit all selected:") + + def draw_multiedit( + self, + context: bpy.types.Context, + layout: bpy.types.UILayout, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> None: + raise NotImplementedError("This method must be overriden and implemented in a subclass") + + def conditionally_draw_warning_no_adjustable_assets( + self, + possible_assets: typing.Iterable[bpy.types.ID], + layout: bpy.types.UILayout, + warning_text: typing.Optional[str] = None, + ) -> bool: + if warning_text is None: + warning_text = f"No assets with {self.get_feature_name_readable()} feature selected!" + if not self.has_adjustable_assets(possible_assets): + layout.label(text=warning_text) + return True + return False + + def conditionally_draw_warning_no_adjustable_active_object( + self, + context: bpy.types.Context, + layout: bpy.types.UILayout, + warning_text: typing.Optional[str] = None, + include_children: bool = False, + ) -> bool: + if warning_text is None: + warning_text = ( + f"Active object is not an asset with {self.get_feature_name_readable()} feature!" + ) + adjustable_objects = set() + if context.active_object is not None: + if include_children: + adjustable_objects.update( + polib.asset_pack_bpy.get_entire_object_hierarchy(context.active_object) + ) + else: + adjustable_objects.add(context.active_object) + return self.conditionally_draw_warning_no_adjustable_assets( + adjustable_objects, layout, warning_text + ) + + def conditionally_draw_warning_not_cycles( + self, + context: bpy.types.Context, + layout: bpy.types.UILayout, + ) -> bool: + if context.scene.render.engine != 'CYCLES': + row = layout.row() + row.alert = True + row.label( + text=f"{self.get_feature_name_readable().capitalize()} feature is only supported in Cycles!", + icon='ERROR', + ) + return True + return False + + +class GeonodesAssetFeatureControlPanelMixin( + polib.geonodes_mod_utils_bpy.GeoNodesModifierInputsPanelMixin, + EngonAssetFeatureControlPanelMixin, +): + """Abstract mixin for displaying geometry nodes asset controls in panels. + + Geometry nodes assets are assets defined by primarily using a geometry nodes generator, + e.g. rain, river, vines generator... + """ + + """Primary node group of the geometry nodes generator.""" + node_group_name: str + exact_match: bool = True + + @classmethod + def filter_adjustable_assets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + return filter( + lambda a: isinstance(a, bpy.types.Object) + and len( + polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( + a, cls.node_group_name, cls.exact_match + ) + ) + > 0, + possible_assets, + ) + + def draw_multiedit( + self, + context: bpy.types.Context, + layout: bpy.types.UILayout, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> None: + raise NotImplementedError() + + +class GeoNodesAssetFeatureSecondaryControlPanelMixin( + GeonodesAssetFeatureControlPanelMixin, +): + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + return cls.has_adjustable_active_object(context) + + +class PropertyAssetFeatureControlPanelMixin(EngonAssetFeatureControlPanelMixin): + """Abstract mixin for displaying engon asset features based on properties.""" + + related_custom_properties: typing.Set[str] + + @classmethod + def get_selected_particle_system_targets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + objects = set(possible_assets) + return filter(lambda obj: cls.has_pps(obj), objects) + + @classmethod + def filter_adjustable_assets_simple( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + """Filter assets out of possible assets that have the property feature.""" + return set( + filter( + lambda obj: has_engon_property_feature(obj, cls.feature_name), + possible_assets, + ) + ) + + @classmethod + def filter_adjustable_assets_hierarchical( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + """Filter assets out of possible assets and their children that have the property feature.""" + possible_assets_and_children = itertools.chain( + possible_assets, *(polib.asset_pack_bpy.get_hierarchy(obj) for obj in possible_assets) + ) + # Empties that don't instance anything are a leftover after making compound assets editable. + # They inherit properties from parent but don't control anything, let's filter them out. + possible_assets_and_children = filter( + lambda obj: obj.type != 'EMPTY' or obj.instance_type != 'NON', + possible_assets_and_children, + ) + + return cls.filter_adjustable_assets_simple(possible_assets_and_children) + + @classmethod + def filter_adjustable_assets_in_pps( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + return set(cls.get_selected_particle_system_targets(possible_assets)) + + def draw_property( + self, + datablock: bpy.types.ID, + layout: bpy.types.UILayout, + prop_name: str, + text: str = "", + ) -> None: + if prop_name in datablock: + layout.prop(datablock, f'["{prop_name}"]', text=text) + else: + layout.label(text="-") + + def draw_adjustable_assets_property_table_header( + self, + property_names: typing.Iterable[str], + layout: bpy.types.UILayout, + ) -> typing.Tuple[bpy.types.UILayout, bpy.types.UILayout]: + row = layout.row() + + left_col = row.column(align=True) + left_col.scale_x = 2.0 + right_col = row.column(align=True) + + row = left_col.row() + row.enabled = False + row.label(text="Selected Assets:") + + row = right_col.row(align=True) + row.enabled = False + + for prop_name in property_names: + row.label(text=prop_name) + + return (left_col, right_col) + + def draw_adjustable_assets_property_table_body( + self, + possible_assets: typing.Iterable[bpy.types.ID], + left_col: bpy.types.UILayout, + right_col: bpy.types.UILayout, + draw_property_func: typing.Callable[[bpy.types.UILayout, bpy.types.ID], None], + max_displayed_assets: int = 10, + indent: int = 0, + ) -> None: + displayable_objects = list(self.filter_adjustable_assets(possible_assets)) + displayable_pps = filter(self.has_pps, displayable_objects) + displayable_assets = list(filter(lambda obj: not self.has_pps(obj), displayable_objects)) + displayed_assets = 0 + for obj in displayable_assets: + row = left_col.row() + if displayed_assets >= max_displayed_assets: + row.label( + text=f"... and {len(displayable_assets) - displayed_assets} additional asset(s)" + ) + break + row.label(text=f"{' ' * 4 * indent}{obj.name}") + row = right_col.row(align=True) + draw_property_func(row, obj) + displayed_assets += 1 + for pps in displayable_pps: + row = left_col.row() + row.label(text=pps.name, icon='PARTICLES') + row = right_col.row() # empty + row.label(text="") + self.draw_adjustable_assets_property_table_body( + set(asset_helpers.gather_instanced_objects([pps])), + left_col, + right_col, + draw_property_func, + # passing the original max_displayed_assets value + # effectively resets the limit for this particular particle system + max_displayed_assets=max_displayed_assets, + indent=indent + 1, + ) + + def draw_adjustable_assets_property_table( + self, + possible_assets: typing.Iterable[bpy.types.ID], + property_names: typing.Iterable[str], + layout: bpy.types.UILayout, + draw_property_func: typing.Callable[[bpy.types.UILayout, bpy.types.ID], None], + max_displayed_assets: int = 10, + ) -> None: + left_col, right_col = self.draw_adjustable_assets_property_table_header( + property_names, layout + ) + self.draw_adjustable_assets_property_table_body( + possible_assets, left_col, right_col, draw_property_func, max_displayed_assets + ) + + def draw_randomize_property_operator( + self, + property_name: str, + randomize_operator: type[RandomizePropertyOperator], + layout: bpy.types.UILayout, + ): + op = layout.operator(randomize_operator.bl_idname, text="", icon='FILE_3D') + op.custom_property_name = property_name + op.engon_feature_name = self.feature_name + + +def register(): + for cls in MODULE_CLASSES: + bpy.utils.register_class(cls) + + +def unregister(): + for cls in MODULE_CLASSES: + bpy.utils.unregister_class(cls) diff --git a/features/license_plates_generator.py b/features/license_plates_generator.py new file mode 100644 index 0000000..4b6a4da --- /dev/null +++ b/features/license_plates_generator.py @@ -0,0 +1,174 @@ +# 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 logging +from . import feature_utils +from . import asset_pack_panels +from .. import polib +from .. import asset_helpers + +logger = logging.getLogger(f"polygoniq.{__name__}") + + +MODULE_CLASSES: typing.List[typing.Type] = [] + + +FRONT_PLATE_PARENT_NAME_SUFFIX = "_License-Plate_F" +BACK_PLATE_PARENT_NAME_SUFFIX = "_License-Plate_B" + + +@feature_utils.register_feature +class LicensePlatesGeneratorPanelMixin(feature_utils.GeonodesAssetFeatureControlPanelMixin): + feature_name = "license_plates_generator" + node_group_name = asset_helpers.TQ_LICENSE_PLATE_NODE_GROUP_NAME_PREFIX + exact_match = False + + +@polib.log_helpers_bpy.logged_panel +class LicensePlatesGeneratorPanel( + LicensePlatesGeneratorPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_engon_license_plates_generator" + bl_parent_id = asset_pack_panels.TraffiqPanel.bl_idname + bl_label = "License Plates" + + def draw_header(self, context: bpy.types.Context) -> None: + self.layout.label(text="", icon='EVENT_L') + + def draw(self, context: bpy.types.Context): + layout: bpy.types.UILayout = self.layout + self.conditionally_draw_warning_no_adjustable_active_object( + context, + layout, + include_children=True, + warning_text=f"Active asset does not support {self.get_feature_name_readable()} feature or is not editable!", + ) + + +MODULE_CLASSES.append(LicensePlatesGeneratorPanel) + + +class LicensePlatesAdjustmentsPanelMixin( + LicensePlatesGeneratorPanelMixin, + feature_utils.GeoNodesAssetFeatureSecondaryControlPanelMixin, +): + bl_parent_id = LicensePlatesGeneratorPanel.bl_idname + bl_options = {'DEFAULT_CLOSED'} + + # Differentiate front, back and generic panels + filter_: typing.Callable[[bpy.types.Object], bool] = lambda obj: True + + template = polib.node_utils_bpy.NodeSocketsDrawTemplate( + asset_helpers.TQ_LICENSE_PLATE_NODE_GROUP_NAME_PREFIX, exact_match=False + ) + + @classmethod + def poll(cls, context: bpy.types.Context): + if context.active_object is None: + return False + possible_assets = filter( + cls.filter_, + polib.asset_pack_bpy.get_entire_object_hierarchy(context.active_object), + ) + return ( + len(list(LicensePlatesGeneratorPanelMixin.filter_adjustable_assets(possible_assets))) + > 0 + ) + + def draw(self, context: bpy.types.Context): + if context.active_object is None: + return + for obj in polib.asset_pack_bpy.get_entire_object_hierarchy(context.active_object): + if self.__class__.filter_( + obj + ) and LicensePlatesGeneratorPanelMixin.filter_adjustable_assets([obj]): + break + else: + return + with context.temp_override(active_object=obj): + self.draw_active_object_modifiers_node_group_inputs_template( + self.layout, + context, + self.__class__.template, + ) + + +class FrontPlatePanel( + LicensePlatesAdjustmentsPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_engon_license_plates_generator_front" + bl_label = "Front Plate" + + filter_ = lambda obj: obj.parent is not None and polib.utils_bpy.remove_object_duplicate_suffix( + obj.parent.name + ).endswith(FRONT_PLATE_PARENT_NAME_SUFFIX) + + +MODULE_CLASSES.append(FrontPlatePanel) + + +class BackPlatePanel( + LicensePlatesAdjustmentsPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_engon_license_plates_generator_back" + bl_label = "Back Plate" + + filter_ = lambda obj: obj.parent is not None and polib.utils_bpy.remove_object_duplicate_suffix( + obj.parent.name + ).endswith(BACK_PLATE_PARENT_NAME_SUFFIX) + + +MODULE_CLASSES.append(BackPlatePanel) + + +class GenericPlatePanel( + LicensePlatesAdjustmentsPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_engon_license_plates_generator_generic" + bl_label = "Generic Plate" + + filter_ = lambda obj: obj.parent is None or not ( + polib.utils_bpy.remove_object_duplicate_suffix(obj.parent.name).endswith( + FRONT_PLATE_PARENT_NAME_SUFFIX + ) + or polib.utils_bpy.remove_object_duplicate_suffix(obj.parent.name).endswith( + BACK_PLATE_PARENT_NAME_SUFFIX + ) + ) + + +MODULE_CLASSES.append(GenericPlatePanel) + + +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/features/light_adjustments.py b/features/light_adjustments.py index 88645e7..c6efb25 100644 --- a/features/light_adjustments.py +++ b/features/light_adjustments.py @@ -1,149 +1,246 @@ # 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 itertools -import typing import math +import typing +from . import feature_utils from .. import polib -from .. import asset_registry -from .. import interniq from .. import preferences +from . import asset_pack_panels MODULE_CLASSES = [] -class LightAdjustmentsPanelInfoMixin: - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "polygoniq" +class LightAdjustmentsPreferences(bpy.types.PropertyGroup): + @staticmethod + def update_prop_with_use_rgb( + context: bpy.types.Context, + objs: typing.Iterable[bpy.types.Object], + prop_name: str, + value: polib.custom_props_bpy.CustomAttributeValueType, + use_rgb_value: bool, + ) -> None: + materialized_objs = list(objs) + polib.custom_props_bpy.update_custom_prop( + context, + materialized_objs, + polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_USE_RGB, + use_rgb_value, + ) + polib.custom_props_bpy.update_custom_prop( + context, + materialized_objs, + prop_name, + value, + ) - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - return len(asset_registry.instance.get_packs_by_engon_feature("light_adjustments")) > 0 + use_rgb: bpy.props.BoolProperty( + name="Use Direct Coloring instead of Temperature", + description="Use Direct Coloring instead of Temperature", + default=False, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + LightAdjustmentsPanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_USE_RGB, + self.use_rgb, + ), + ) + light_temperature: bpy.props.IntProperty( + name="Light Temperature", + subtype='TEMPERATURE', + description='Changes light temperature in Kelvins ranging from warm to cool', + default=5000, + min=0, # blender "Temperature" shader node gets this wrong, 0K should be black, but its red + max=12_000, # blender "Temperature" shader node supports up to 12kK + update=lambda self, context: LightAdjustmentsPreferences.update_prop_with_use_rgb( + context, + LightAdjustmentsPanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_KELVIN, + self.light_temperature, + False, + ), + ) + light_rgb: bpy.props.FloatVectorProperty( + name="Light Color", + subtype='COLOR', + description='Changes light color across the RGB spectrum', + default=(1.0, 1.0, 1.0), + size=3, + min=0.0, + max=1.0, + step=1, + update=lambda self, context: LightAdjustmentsPreferences.update_prop_with_use_rgb( + context, + LightAdjustmentsPanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_RGB, + self.light_rgb, + True, + ), + ) + + light_strength: bpy.props.FloatProperty( + name="Light Strength", + default=0.0, + description='Changes the intensity of the light', + min=0.0, + subtype='FACTOR', + soft_max=200, # mostly> interior use, exterior lights can go to 2000 or more + step=1, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + LightAdjustmentsPanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_STRENGTH, + self.light_strength, + ), + ) + + +MODULE_CLASSES.append(LightAdjustmentsPreferences) + + +@feature_utils.register_feature @polib.log_helpers_bpy.logged_panel -class LightAdjustmentsPanel(LightAdjustmentsPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_light_adjustments" +class LightAdjustmentsPanel(feature_utils.PropertyAssetFeatureControlPanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_feature_light_adjustments" # TODO: this feature is currently interniq-only, but in the future it should be moved to engon panel, # once all other asset packs implement light adjustments - bl_parent_id = interniq.panel.InterniqPanel.bl_idname + bl_parent_id = asset_pack_panels.InterniqPanel.bl_idname bl_label = "Light Adjustments" + feature_name = "light_adjustments" + related_custom_properties = { + polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_USE_RGB, + polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_KELVIN, + polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_RGB, + polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_STRENGTH, + } - @staticmethod - def get_adjustable_objects( - possible_objects: typing.Iterable[bpy.types.ID], + @classmethod + def filter_adjustable_assets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], ) -> typing.Iterable[bpy.types.ID]: - possible_objects_and_children = itertools.chain.from_iterable( - polib.asset_pack_bpy.get_hierarchy(obj) for obj in possible_objects - ) - return filter( - lambda obj: polib.asset_pack_bpy.has_engon_property_feature(obj, "light_adjustments"), - possible_objects_and_children, - ) + return cls.filter_adjustable_assets_hierarchical(possible_assets) + + def conditionally_draw_warning_unapplied_scale(self, context, layout): + unapplied_scale_objects = [] + for obj in self.filter_adjustable_assets(context.selected_objects): + if isinstance(obj, bpy.types.Object) and not all( + math.isclose(s, 1.0, rel_tol=1e-3) for s in obj.scale + ): + unapplied_scale_objects.append(obj) + if len(unapplied_scale_objects) > 0: + more_objects_warning = "" + if len(unapplied_scale_objects) > 1: + more_objects_warning = f' and {len(unapplied_scale_objects) - 1} other objects' + row = layout.row() + row.alert = True + row.label( + text=f"Unapplied scale on {unapplied_scale_objects[0].name}{more_objects_warning}, " + f"this might result in incorrect light strength", + icon='ERROR', + ) def draw_header(self, context: bpy.types.Context) -> None: self.layout.label(text="", icon='LIGHT') - def draw_properties(self, obj: bpy.types.ID, layout: bpy.types.UILayout) -> None: + def draw_properties(self, datablock: bpy.types.ID, layout: bpy.types.UILayout) -> None: layout = layout.row(align=True) - polib.ui_bpy.draw_property( - obj, + self.draw_property( + datablock, layout, polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_USE_RGB, # text = invisible character, so the checkbox is aligned properly text=" ", ) - if obj.get(polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_USE_RGB): - polib.ui_bpy.draw_property( - obj, + if datablock.get(polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_USE_RGB): + self.draw_property( + datablock, layout, polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_RGB, ) else: - polib.ui_bpy.draw_property( - obj, + self.draw_property( + datablock, layout, polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_KELVIN, ) - polib.ui_bpy.draw_property( - obj, + self.draw_property( + datablock, layout, polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_STRENGTH, ) - def draw(self, context: bpy.types.Context) -> None: - layout = self.layout - - adjustable_lights = list(self.get_adjustable_objects(context.selected_objects)) - if len(adjustable_lights) == 0: - layout.label(text="No assets with adjustable lights selected!") - return - - unapplied_scale_lights = [] - for obj in adjustable_lights: - if isinstance(obj, bpy.types.Object) and not all( - math.isclose(s, 1.0, rel_tol=1e-3) for s in obj.scale - ): - unapplied_scale_lights.append(obj) + def draw_multiedit( + self, + context: bpy.types.Context, + layout: bpy.types.UILayout, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> None: + self.draw_multiedit_header(layout) - if len(unapplied_scale_lights) > 0: - more_objects_warning = "" - leftover_lights_count = len(unapplied_scale_lights) - 1 - if leftover_lights_count > 0: - more_objects_warning = f" and {leftover_lights_count} other light" - more_objects_warning += 's' if leftover_lights_count > 1 else '' - - col = layout.column(align=True) - col.alert = True - col.label( - text="Because of unapplied scale, strength is incorrect on:", - icon='ERROR', + prefs = preferences.prefs_utils.get_preferences(context).light_adjustments_preferences + row = layout.row(align=True) + row.prop(prefs, "use_rgb", text="Direct Coloring") + if prefs.use_rgb: + row.prop(prefs, "light_rgb", text="") + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_RGB, + feature_utils.RandomizeColorPropertyOperator, + row, ) - col.label(text=f"{unapplied_scale_lights[0].name}{more_objects_warning}!") - - if context.scene.render.engine != 'CYCLES': - row = layout.row() - row.alert = True - row.label(text="Lights are only supported in CYCLES!", icon='ERROR') + else: + row.prop(prefs, "light_temperature", text="Temperature (K)", slider=True) + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_KELVIN, + feature_utils.RandomizeIntegerPropertyOperator, + row, + ) + row.prop(prefs, "light_strength", text="Strength (W)", slider=True) + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_STRENGTH, + feature_utils.RandomizeFloatPropertyOperator, + row, + ) - row = self.layout.row() + def draw(self, context: bpy.types.Context) -> None: + layout = self.layout - left_col = row.column(align=True) - left_col.scale_x = 2.0 - right_col = row.column(align=True) + possible_assets = self.get_possible_assets(context) - row = left_col.row() - row.enabled = False - row.label(text="Selected Assets:") + if self.conditionally_draw_warning_no_adjustable_assets(possible_assets, layout): + return - row = right_col.row(align=True) - row.enabled = False - row.label(text="Direct Coloring") - row.label(text="Color/Temperature") - row.label(text="Strength") + self.conditionally_draw_warning_not_cycles(context, layout) + self.conditionally_draw_warning_unapplied_scale(context, layout) - # TODO: selected assets table - polib.ui_bpy.draw_property_table( - adjustable_lights, - left_col, - right_col, + self.draw_adjustable_assets_property_table( + possible_assets, + ["Direct Coloring", "Color/Temperature", "Strength"], + layout, lambda layout, obj: self.draw_properties(obj, layout), ) - prefs = preferences.prefs_utils.get_preferences(context).light_adjustments_preferences - row = layout.row() - row.label(text="Edit All Selected:") - row.enabled = False - row = layout.row(align=True) - - row.prop(prefs, "use_rgb", text="Direct Coloring") - row = layout.row(align=True) - if prefs.use_rgb: - row.prop(prefs, "light_rgb", text="") - else: - row.prop(prefs, "light_temperature", text="Temperature (K)", slider=True) - row.prop(prefs, "light_strength", text="Strength (W)", slider=True) + self.draw_multiedit(context, layout, possible_assets) MODULE_CLASSES.append(LightAdjustmentsPanel) diff --git a/aquatiq/puddles.py b/features/puddles.py similarity index 87% rename from aquatiq/puddles.py rename to features/puddles.py index 6f364bb..53e9c4a 100644 --- a/aquatiq/puddles.py +++ b/features/puddles.py @@ -23,6 +23,8 @@ import typing import logging import mathutils +from . import feature_utils +from . import asset_pack_panels from .. import polib from .. import asset_helpers @@ -258,6 +260,47 @@ def execute(self, context: bpy.types.Context): MODULE_CLASSES.append(RemovePuddles) +@feature_utils.register_feature +@polib.log_helpers_bpy.logged_panel +class PuddlesPanel(feature_utils.EngonFeaturePanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_puddles" + bl_parent_id = asset_pack_panels.AquatiqPanel.bl_idname + bl_label = "Puddles" + + feature_name = "puddles" + + template = polib.node_utils_bpy.NodeSocketsDrawTemplate( + asset_helpers.AQ_PUDDLES_NODEGROUP_NAME, + filter_=lambda x: not polib.node_utils_bpy.filter_node_socket_name( + x, + "Water Color", + "Noise Scale", + ), + ) + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + return super().poll(context) and context.mode != 'PAINT_VERTEX' + + def draw_header(self, context: bpy.types.Context): + self.layout.label(text="", icon='MATFLUID') + + def draw(self, context: bpy.types.Context): + layout: bpy.types.UILayout = self.layout + + layout.operator(AddPuddles.bl_idname, icon='ADD') + layout.operator(RemovePuddles.bl_idname, icon='PANEL_CLOSE') + + if context.active_object is not None and check_puddles_nodegroup_count( + [context.active_object], lambda x: x != 0 + ): + col = layout.column(align=True) + PuddlesPanel.template.draw_from_material(context.active_object.active_material, col) + + +MODULE_CLASSES.append(PuddlesPanel) + + def register(): for cls in MODULE_CLASSES: bpy.utils.register_class(cls) diff --git a/features/rain_generator.py b/features/rain_generator.py new file mode 100644 index 0000000..a48d315 --- /dev/null +++ b/features/rain_generator.py @@ -0,0 +1,146 @@ +# 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 +from . import feature_utils +from .. import polib +from .. import asset_helpers + +from . import asset_pack_panels + +MODULE_CLASSES: typing.List[typing.Type] = [] + + +@feature_utils.register_feature +class RainGeneratorPanelMixin(feature_utils.GeonodesAssetFeatureControlPanelMixin): + feature_name = "rain_generator" + node_group_name = asset_helpers.AQ_RAIN_GENERATOR_NODE_GROUP_NAME + + +@polib.log_helpers_bpy.logged_panel +class RainGeneratorPanel(RainGeneratorPanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_aquatiq_rain_generator" + bl_parent_id = asset_pack_panels.AquatiqPanel.bl_idname + bl_label = "Rain Generator" + + def draw_header(self, context: bpy.types.Context) -> None: + self.layout.label(text="", icon='OUTLINER_DATA_LIGHTPROBE') + + def draw(self, context: bpy.types.Context): + layout: bpy.types.UILayout = self.layout + self.conditionally_draw_warning_no_adjustable_active_object(context, layout) + + +MODULE_CLASSES.append(RainGeneratorPanel) + + +class RainGeneratorGeneralAdjustmentsPanel( + RainGeneratorPanelMixin, + feature_utils.GeoNodesAssetFeatureSecondaryControlPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_aquatiq_rain_generator_general_adjustments" + bl_parent_id = RainGeneratorPanel.bl_idname + bl_label = "General Adjustments" + + template = polib.node_utils_bpy.NodeSocketsDrawTemplate( + asset_helpers.AQ_RAIN_GENERATOR_NODE_GROUP_NAME, + filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name( + x, "Self Object", "Realize Instances", "Collision", "Rain", "Randomize" + ), + socket_names_drawn_first=[ + "Self Object", + "Collision Collection", + ], + ) + + def draw(self, context: bpy.types.Context): + self.draw_active_object_modifiers_node_group_inputs_template( + self.layout, + context, + RainGeneratorGeneralAdjustmentsPanel.template, + ) + + +MODULE_CLASSES.append(RainGeneratorGeneralAdjustmentsPanel) + + +class RainGeneratorSplashEffectsPanel( + RainGeneratorPanelMixin, + feature_utils.GeoNodesAssetFeatureSecondaryControlPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_aquatiq_rain_generator_splash_effects" + bl_parent_id = RainGeneratorPanel.bl_idname + bl_label = "Splash Effects" + bl_options = {'DEFAULT_CLOSED'} + + template = polib.node_utils_bpy.NodeSocketsDrawTemplate( + asset_helpers.AQ_RAIN_GENERATOR_NODE_GROUP_NAME, + filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name(x, "Splashes", "2D Effects"), + ) + + def draw(self, context: bpy.types.Context): + self.draw_active_object_modifiers_node_group_inputs_template( + self.layout, + context, + RainGeneratorSplashEffectsPanel.template, + ) + + +MODULE_CLASSES.append(RainGeneratorSplashEffectsPanel) + + +class RainGeneratorCameraAdjustmentsPanel( + RainGeneratorPanelMixin, + feature_utils.GeoNodesAssetFeatureSecondaryControlPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_aquatiq_rain_generator_camera_adjustments" + bl_parent_id = RainGeneratorPanel.bl_idname + bl_label = "Camera Adjustments" + bl_options = {'DEFAULT_CLOSED'} + + template = polib.node_utils_bpy.NodeSocketsDrawTemplate( + asset_helpers.AQ_RAIN_GENERATOR_NODE_GROUP_NAME, + filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name(x, "Camera", "Culling"), + socket_names_drawn_first=["Camera Culling Camera"], + ) + + def draw(self, context: bpy.types.Context): + self.draw_active_object_modifiers_node_group_inputs_template( + self.layout, + context, + RainGeneratorCameraAdjustmentsPanel.template, + ) + + +MODULE_CLASSES.append(RainGeneratorCameraAdjustmentsPanel) + + +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/features/river_generator.py b/features/river_generator.py new file mode 100644 index 0000000..17062dd --- /dev/null +++ b/features/river_generator.py @@ -0,0 +1,187 @@ +# 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 +from . import feature_utils +from .. import polib +from .. import asset_helpers + +from . import asset_pack_panels + +MODULE_CLASSES: typing.List[typing.Type] = [] + + +@feature_utils.register_feature +class RiverGeneratorPanelMixin(feature_utils.GeonodesAssetFeatureControlPanelMixin): + feature_name = "river_generator" + node_group_name = asset_helpers.AQ_RIVER_GENERATOR_NODE_GROUP_NAME + + +@polib.log_helpers_bpy.logged_panel +class RiverGeneratorPanel(RiverGeneratorPanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_aquatiq_river_generator" + bl_parent_id = asset_pack_panels.AquatiqPanel.bl_idname + bl_label = "River Generator" + + def draw_header(self, context: bpy.types.Context) -> None: + self.layout.label(text="", icon='FORCE_FORCE') + + def draw(self, context: bpy.types.Context): + layout: bpy.types.UILayout = self.layout + self.conditionally_draw_warning_no_adjustable_active_object(context, layout) + + +MODULE_CLASSES.append(RiverGeneratorPanel) + + +class RiverGeneratorGeneralAdjustmentsPanel( + RiverGeneratorPanelMixin, + feature_utils.GeoNodesAssetFeatureSecondaryControlPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_aquatiq_river_generator_general_adjustments" + bl_parent_id = RiverGeneratorPanel.bl_idname + bl_label = "General Adjustments" + + template = polib.node_utils_bpy.NodeSocketsDrawTemplate( + asset_helpers.AQ_RIVER_GENERATOR_NODE_GROUP_NAME, + filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name( + x, + "Self Object", + "Resolution", + "Width", + "Depth", + "Seed", + "Animation Speed", + ) + and not polib.node_utils_bpy.filter_node_socket_name( + x, + "Bank Width", + ), + socket_names_drawn_first=["Self Object"], + ) + + def draw(self, context: bpy.types.Context): + self.draw_active_object_modifiers_node_group_inputs_template( + self.layout, + context, + RiverGeneratorGeneralAdjustmentsPanel.template, + ) + + +MODULE_CLASSES.append(RiverGeneratorGeneralAdjustmentsPanel) + + +class RiverGeneratorBankRiverbedAdjustmentsPanel( + RiverGeneratorPanelMixin, + feature_utils.GeoNodesAssetFeatureSecondaryControlPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_aquatiq_river_generator_bank_riverbed_adjustments" + bl_parent_id = RiverGeneratorPanel.bl_idname + bl_label = "Bank and Riverbed Adjustments" + bl_options = {'DEFAULT_CLOSED'} + + template = polib.node_utils_bpy.NodeSocketsDrawTemplate( + asset_helpers.AQ_RIVER_GENERATOR_NODE_GROUP_NAME, + filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name(x, "Bank", "Riverbed"), + socket_names_drawn_first=["Bank Material", "Riverbed Material"], + ) + + def draw(self, context: bpy.types.Context): + self.draw_active_object_modifiers_node_group_inputs_template( + self.layout, + context, + RiverGeneratorBankRiverbedAdjustmentsPanel.template, + ) + + +MODULE_CLASSES.append(RiverGeneratorBankRiverbedAdjustmentsPanel) + + +class RiverGeneratorScatterPanel( + RiverGeneratorPanelMixin, + feature_utils.GeoNodesAssetFeatureSecondaryControlPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_aquatiq_river_generator_scatter" + bl_parent_id = RiverGeneratorPanel.bl_idname + bl_label = "Rocks and Vegetation" + bl_options = {'DEFAULT_CLOSED'} + + template = polib.node_utils_bpy.NodeSocketsDrawTemplate( + asset_helpers.AQ_RIVER_GENERATOR_NODE_GROUP_NAME, + filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name(x, "Vegetation", "Rocks"), + socket_names_drawn_first=["Rocks", "Vegetation"], + ) + + def draw(self, context: bpy.types.Context): + self.draw_active_object_modifiers_node_group_inputs_template( + self.layout, + context, + RiverGeneratorScatterPanel.template, + ) + + +MODULE_CLASSES.append(RiverGeneratorScatterPanel) + + +class RiverGeneratorAdvancedAdjustmentsPanel( + RiverGeneratorPanelMixin, + feature_utils.GeoNodesAssetFeatureSecondaryControlPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_aquatiq_river_generator_advanced_adjustments" + bl_parent_id = RiverGeneratorPanel.bl_idname + bl_label = "Advanced Adjustments" + bl_options = {'DEFAULT_CLOSED'} + + template = polib.node_utils_bpy.NodeSocketsDrawTemplate( + asset_helpers.AQ_RIVER_GENERATOR_NODE_GROUP_NAME, + filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name( + x, "Noise", "Foam", "Caustic", "Collision" + ) + and not polib.node_utils_bpy.filter_node_socket_name( + x, + "Rocks Collision Complexity", + ), + socket_names_drawn_first=["Collision"], + ) + + def draw(self, context: bpy.types.Context): + self.draw_active_object_modifiers_node_group_inputs_template( + self.layout, + context, + RiverGeneratorAdvancedAdjustmentsPanel.template, + ) + + +MODULE_CLASSES.append(RiverGeneratorAdvancedAdjustmentsPanel) + + +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/traffiq/road_generator/__init__.py b/features/road_generator/__init__.py similarity index 100% rename from traffiq/road_generator/__init__.py rename to features/road_generator/__init__.py diff --git a/traffiq/road_generator/asset_helpers.py b/features/road_generator/asset_helpers.py similarity index 100% rename from traffiq/road_generator/asset_helpers.py rename to features/road_generator/asset_helpers.py diff --git a/traffiq/road_generator/build_roads_modal.py b/features/road_generator/build_roads_modal.py similarity index 96% rename from traffiq/road_generator/build_roads_modal.py rename to features/road_generator/build_roads_modal.py index d030d3d..01fa580 100644 --- a/traffiq/road_generator/build_roads_modal.py +++ b/features/road_generator/build_roads_modal.py @@ -180,7 +180,6 @@ def draw_px(self) -> None: # Draw modal information polib.render_bpy.text_box( self.text_ui_origin, - 300 * self.ui_scale, 10 * self.ui_scale, 10 * self.ui_scale, (0, 0, 0, 0.5), @@ -229,7 +228,6 @@ def draw_px(self) -> None: dbg_style = polib.render_bpy.TextStyle() polib.render_bpy.text_box( self.text_ui_origin + mathutils.Vector((0, 300)), - 300, 10, 10, (0, 0, 0, 0.5), @@ -249,7 +247,6 @@ def draw_px(self) -> None: polib.render_bpy.text_box( self.text_ui_origin + mathutils.Vector((50, 600)), - 1200, 10, 10, None, @@ -270,8 +267,18 @@ def draw_px(self) -> None: region_3d, ) - def modal_exc_safe(self, context: bpy.types.Context, event: bpy.types.Event): - """Same as 'modal', but in case of an exception the operator cleans up""" + def _cleanup( + self, + context: bpy.types.Context, + event: typing.Optional[bpy.types.Event] = None, + exception: typing.Optional[Exception] = None, + ) -> None: + BuildRoads.remove_draw_handlers() + context.area.tag_redraw() + BuildRoads.is_running = False + + @polib.utils_bpy.safe_modal(on_exception=_cleanup) + def modal(self, context: bpy.types.Context, event: bpy.types.Event): event_handled = False # Pass through all events that are not directly in the 3D viewport @@ -328,14 +335,6 @@ def modal_exc_safe(self, context: bpy.types.Context, event: bpy.types.Event): context.area.tag_redraw() return {'RUNNING_MODAL'} if event_handled else {'PASS_THROUGH'} - def modal(self, context: bpy.types.Context, event: bpy.types.Event): - try: - return self.modal_exc_safe(context, event) - except Exception as e: - traceback.print_exception(e) - self._cleanup(context) - return {'CANCELLED'} - def cancel(self, context: bpy.types.Context) -> None: self._cleanup(context) @@ -373,9 +372,7 @@ def invoke(self, context: bpy.types.Context, event: bpy.types.Event): context.window_manager.modal_handler_add(self) bpy.ops.view3d.view_axis(type='TOP') - for area in context.window.screen.areas: - if area.type == 'VIEW_3D': - area.tag_redraw() + polib.ui_bpy.tag_areas_redraw(context, {'VIEW_3D'}) # Change the is_running state right before returning in case of any error happening # before which would lock the UI. @@ -448,11 +445,6 @@ def _to_overlay_pos(self, pos: mathutils.Vector) -> mathutils.Vector: new_pos[2] = OVERLAY_Z return new_pos - def _cleanup(self, context: bpy.types.Context) -> None: - BuildRoads.remove_draw_handlers() - context.area.tag_redraw() - BuildRoads.is_running = False - MODULE_CLASSES.append(BuildRoads) diff --git a/traffiq/road_generator/crossroad_builder.py b/features/road_generator/crossroad_builder.py similarity index 100% rename from traffiq/road_generator/crossroad_builder.py rename to features/road_generator/crossroad_builder.py diff --git a/traffiq/road_generator/panel.py b/features/road_generator/panel.py similarity index 98% rename from traffiq/road_generator/panel.py rename to features/road_generator/panel.py index 7b66a85..cc79635 100644 --- a/traffiq/road_generator/panel.py +++ b/features/road_generator/panel.py @@ -20,11 +20,12 @@ import bpy import logging -from ... import polib from . import props from . import build_roads_modal from . import asset_helpers -from .. import panel as main_panel +from .. import feature_utils +from .. import asset_pack_panels +from ... import polib logger = logging.getLogger(f"polygoniq.{__name__}") @@ -230,9 +231,12 @@ def execute(self, context: bpy.types.Context): MODULE_CLASSES.append(AddRoadGeneratorModifier) +@feature_utils.register_feature class RoadGeneratorPanelMixin( - main_panel.TraffiqPanelInfoMixin, polib.geonodes_mod_utils_bpy.GeoNodesModifierInputsPanelMixin + feature_utils.EngonFeaturePanelMixin, + polib.geonodes_mod_utils_bpy.GeoNodesModifierInputsPanelMixin, ): + feature_name = "road_generator" pass @@ -240,7 +244,7 @@ class RoadGeneratorPanelMixin( class RoadGeneratorPanel(RoadGeneratorPanelMixin, bpy.types.Panel): bl_idname = "VIEW_3D_PT_engon_build_roads_modal" bl_label = "Road Generator (Beta)" - bl_parent_id = main_panel.TraffiqPanel.bl_idname + bl_parent_id = asset_pack_panels.TraffiqPanel.bl_idname def draw_header(self, context: bpy.types.Context): self.layout.label(text="", icon='MOD_SIMPLEDEFORM') @@ -342,7 +346,7 @@ def poll(cls, context: bpy.types.Context) -> bool: obj is not None and len( polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - obj, RoadGeneratorInputCurvePanel.template.name + obj, RoadGeneratorInputCurvePanel.template.name_prefix ) ) > 0 @@ -377,7 +381,7 @@ def poll(cls, context: bpy.types.Context) -> bool: obj is not None and len( polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - obj, RoadGeneratorProfilePanel.template.name + obj, RoadGeneratorProfilePanel.template.name_prefix ) ) > 0 @@ -413,7 +417,7 @@ def poll(cls, context: bpy.types.Context) -> bool: obj is not None and len( polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - obj, RoadGeneratorRoadMarkingPanel.template.name + obj, RoadGeneratorRoadMarkingPanel.template.name_prefix ) ) > 0 @@ -449,7 +453,7 @@ def poll(cls, context: bpy.types.Context) -> bool: obj is not None and len( polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - obj, RoadGeneratorDistributePanel.template.name + obj, RoadGeneratorDistributePanel.template.name_prefix ) ) > 0 @@ -485,7 +489,7 @@ def poll(cls, context: bpy.types.Context) -> bool: obj is not None and len( polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - obj, RoadGeneratorCrosswalkPanel.template.name + obj, RoadGeneratorCrosswalkPanel.template.name_prefix ) ) > 0 @@ -522,7 +526,7 @@ def poll(cls, context: bpy.types.Context) -> bool: obj is not None and len( polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - obj, RoadGeneratorScatterPanel.template.name + obj, RoadGeneratorScatterPanel.template.name_prefix ) ) > 0 @@ -558,7 +562,7 @@ def poll(cls, context: bpy.types.Context) -> bool: obj is not None and len( polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - obj, RoadGeneratorCleanupPanel.template.name + obj, RoadGeneratorCleanupPanel.template.name_prefix ) ) > 0 diff --git a/traffiq/road_generator/props.py b/features/road_generator/props.py similarity index 100% rename from traffiq/road_generator/props.py rename to features/road_generator/props.py diff --git a/traffiq/road_generator/road_builder.py b/features/road_generator/road_builder.py similarity index 100% rename from traffiq/road_generator/road_builder.py rename to features/road_generator/road_builder.py diff --git a/traffiq/road_generator/road_network.py b/features/road_generator/road_network.py similarity index 100% rename from traffiq/road_generator/road_network.py rename to features/road_generator/road_network.py diff --git a/traffiq/road_generator/road_type.py b/features/road_generator/road_type.py similarity index 100% rename from traffiq/road_generator/road_type.py rename to features/road_generator/road_type.py diff --git a/features/traffiq_lights_settings.py b/features/traffiq_lights_settings.py new file mode 100644 index 0000000..b6eafe3 --- /dev/null +++ b/features/traffiq_lights_settings.py @@ -0,0 +1,186 @@ +# 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 math +from . import feature_utils +from .. import polib +from .. import preferences +from . import asset_pack_panels + + +MODULE_CLASSES = [] + + +MAIN_LIGHT_STATUS = [ + ("0", "Off", "Front and rear lights are off"), + ("0.25", "Parking", "Parking lights are on"), + ("0.50", "Low-Beam", "Low-Beam lights are on"), + ("0.75", "High-Beam", "High-Beam lights are on"), +] + + +def get_main_lights_status_text(value: float) -> str: + ret = "Unknown" + for min_value, status, _ in MAIN_LIGHT_STATUS: + if value < float(min_value): + return ret + ret = status + if math.isclose(value, 420.0, abs_tol=0.001): + ret = "Blaze it" + return ret + + +class TraffiqLightsSettingsPreferences(bpy.types.PropertyGroup): + main_lights_status: bpy.props.EnumProperty( + name="Main Lights Status", + items=MAIN_LIGHT_STATUS, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + TraffiqLightsSettingsPanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.TQ_LIGHTS, + float(self.main_lights_status), + ), + ) + + main_lights_custom_strength: bpy.props.FloatProperty( + name="Light Strength", + description="Custom value for main lights status", + min=0.0, + soft_max=10.0, + max=1_000_000.0, + default=1.0, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + TraffiqLightsSettingsPanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.TQ_LIGHTS, + self.main_lights_custom_strength, + ), + ) + + main_lights_use_custom_strength: bpy.props.BoolProperty( + name="Custom Value", + description="If true, main lights will be set to custom float value", + default=False, + ) + + +MODULE_CLASSES.append(TraffiqLightsSettingsPreferences) + + +@feature_utils.register_feature +@polib.log_helpers_bpy.logged_panel +class TraffiqLightsSettingsPanel( + feature_utils.PropertyAssetFeatureControlPanelMixin, bpy.types.Panel +): + bl_idname = "VIEW_3D_PT_engon_feature_traffiq_lights_settings" + bl_parent_id = asset_pack_panels.TraffiqPanel.bl_idname + bl_label = "Lights Settings" + feature_name = "traffiq_lights_settings" + related_custom_properties = {polib.custom_props_bpy.CustomPropertyNames.TQ_LIGHTS} + + @classmethod + def find_unique_lights_containers_with_roots( + cls, objects: typing.Iterable[bpy.types.Object] + ) -> typing.Iterable[typing.Tuple[bpy.types.Object, bpy.types.Object]]: + return polib.asset_pack_bpy.get_root_objects_with_matched_child( + objects, + lambda x, _: x.get(polib.custom_props_bpy.CustomPropertyNames.TQ_LIGHTS, None) + is not None, + ) + + @classmethod + def filter_adjustable_assets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + light_tuples: typing.Iterable[typing.Tuple[bpy.types.Object, bpy.types.Object]] = ( + cls.find_unique_lights_containers_with_roots( + a for a in possible_assets if isinstance(a, bpy.types.Object) + ) + ) + + return cls.filter_adjustable_assets_simple(map(lambda t: t[1], light_tuples)) + + def draw_header(self, context: bpy.types.Context) -> None: + self.layout.label(text="", icon='OUTLINER_OB_LIGHT') + + def draw_properties(self, datablock: bpy.types.ID, layout: bpy.types.UILayout) -> None: + layout.prop( + datablock, + f'["{polib.custom_props_bpy.CustomPropertyNames.TQ_LIGHTS}"]', + text=get_main_lights_status_text( + datablock[polib.custom_props_bpy.CustomPropertyNames.TQ_LIGHTS] + ), + # slider=True, + ) + + def draw_multiedit( + self, + context: bpy.types.Context, + layout: bpy.types.UILayout, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> None: + self.draw_multiedit_header(layout) + + prefs = preferences.prefs_utils.get_preferences(context).traffiq_lights_settings_preferences + row = layout.row() + row.prop(prefs, "main_lights_use_custom_strength") + if prefs.main_lights_use_custom_strength: + row.prop(prefs, "main_lights_custom_strength") + else: + row.prop( + prefs, + "main_lights_status", + text="Status", + icon='LIGHTPROBE_GRID' if bpy.app.version < (4, 1, 0) else 'LIGHTPROBE_VOLUME', + ) + + def draw(self, context: bpy.types.Context) -> None: + layout = self.layout + + possible_assets = self.get_possible_assets(context) + + if self.conditionally_draw_warning_no_adjustable_assets(possible_assets, layout): + return + self.conditionally_draw_warning_not_cycles(context, layout) + + self.draw_adjustable_assets_property_table( + possible_assets, + ["Light Strength"], + layout, + lambda layout, obj: self.draw_properties(obj, layout), + ) + + self.draw_multiedit(context, layout, possible_assets) + + +MODULE_CLASSES.append(TraffiqLightsSettingsPanel) + + +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/features/traffiq_paint_adjustments.py b/features/traffiq_paint_adjustments.py new file mode 100644 index 0000000..a3a8a59 --- /dev/null +++ b/features/traffiq_paint_adjustments.py @@ -0,0 +1,279 @@ +# 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 random +from . import feature_utils +from .. import polib +from .. import preferences +from . import asset_pack_panels + + +MODULE_CLASSES = [] + + +class TraffiqPaintAdjustmentPreferences(bpy.types.PropertyGroup): + @staticmethod + def update_car_paint_color_prop( + context, + affected_assets: typing.Iterable[bpy.types.Object], + value: typing.Tuple[float, float, float, float], + ): + # Don't allow to accidentally set color to random + if all(v > 0.99 for v in value[:3]): + value = (0.99, 0.99, 0.99, value[3]) + + polib.custom_props_bpy.update_custom_prop( + context, + affected_assets, + polib.custom_props_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR, + value, + ) + + primary_color: bpy.props.FloatVectorProperty( + name="Color", + subtype='COLOR', + description="Changes primary color of assets", + min=0.0, + max=1.0, + default=(0.8, 0.8, 0.8, 1.0), + size=4, + update=lambda self, context: TraffiqPaintAdjustmentPreferences.update_car_paint_color_prop( + context, + TraffiqPaintAdjustmentsPanel.get_multiedit_adjustable_assets(context), + self.primary_color, + ), + ) + flakes_amount: bpy.props.FloatProperty( + name="Flakes Amount", + description="Changes amount of flakes in the car paint", + default=0.0, + min=0.0, + max=1.0, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + TraffiqPaintAdjustmentsPanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.TQ_FLAKES_AMOUNT, + self.flakes_amount, + ), + ) + clearcoat: bpy.props.FloatProperty( + name="Clearcoat", + description="Changes clearcoat property of car paint", + default=0.2, + min=0.0, + max=1.0, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + TraffiqPaintAdjustmentsPanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.TQ_CLEARCOAT, + self.clearcoat, + ), + ) + + +MODULE_CLASSES.append(TraffiqPaintAdjustmentPreferences) + +RANDOM_COLOR = (1.0, 1.0, 1.0, 1.0) + + +@polib.log_helpers_bpy.logged_operator +class SetColor(bpy.types.Operator): + bl_idname = "engon.traffiq_paint_adjustments_set_color" + bl_label = "Set Color to given value" + bl_description = "Set color of selected assets to given value" + + bl_options = {'REGISTER', 'UNDO'} + + color: bpy.props.FloatVectorProperty( + name="Color", + subtype='COLOR', + default=(1.0, 1.0, 1.0, 1.0), + size=4, + ) + obj_name: bpy.props.StringProperty( + name="Object Name", + description="Name of the object to set color to. If unset, all selected objects will be affected", + default="", + ) + + @classmethod + def poll(cls, context: bpy.types.Context) -> bool: + return context.mode == 'OBJECT' + + def execute(self, context: bpy.types.Context): + affected_objects = [] + if self.obj_name is not None and self.obj_name != "": + affected_objects = [bpy.data.objects[self.obj_name]] + else: + possible_assets = TraffiqPaintAdjustmentsPanel.extend_with_active_object( + context, context.selected_objects + ) + affected_objects = TraffiqPaintAdjustmentsPanel.filter_adjustable_assets( + possible_assets + ) + + for obj in affected_objects: + if obj.get(polib.custom_props_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR, None) is None: + continue + + obj[polib.custom_props_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR] = self.color + obj.update_tag(refresh={'OBJECT'}) + + for area in context.screen.areas: + if area.type == 'VIEW_3D': + area.tag_redraw() + + return {'FINISHED'} + + +MODULE_CLASSES.append(SetColor) + + +@feature_utils.register_feature +@polib.log_helpers_bpy.logged_panel +class TraffiqPaintAdjustmentsPanel( + feature_utils.PropertyAssetFeatureControlPanelMixin, bpy.types.Panel +): + bl_idname = "VIEW_3D_PT_engon_feature_traffiq_paint_adjustments" + bl_parent_id = asset_pack_panels.TraffiqPanel.bl_idname + bl_label = "Paint Adjustments" + feature_name = "traffiq_paint_adjustments" + related_custom_properties = { + polib.custom_props_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR, + polib.custom_props_bpy.CustomPropertyNames.TQ_FLAKES_AMOUNT, + polib.custom_props_bpy.CustomPropertyNames.TQ_CLEARCOAT, + } + + @classmethod + def filter_adjustable_assets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + return cls.filter_adjustable_assets_hierarchical(possible_assets) + + def draw_header(self, context: bpy.types.Context) -> None: + self.layout.label(text="", icon='MOD_HUE_SATURATION') + + def draw_properties(self, datablock: bpy.types.ID, layout: bpy.types.UILayout) -> None: + current_color = datablock.get( + polib.custom_props_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR, None + ) + if current_color is None: + layout.label(text="-") + elif tuple(current_color) == RANDOM_COLOR: + layout.label(text="Random") + op = layout.operator( + SetColor.bl_idname, + text="", + icon='CANCEL', + ) + op.color = ( + random.uniform(0.0, 1.0), + random.uniform(0.0, 1.0), + random.uniform(0.0, 1.0), + 1.0, + ) + op.obj_name = datablock.name + else: + self.draw_property( + datablock, layout, polib.custom_props_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR + ) + op = layout.operator( + SetColor.bl_idname, + text="", + icon='FILE_3D', + ) + op.color = RANDOM_COLOR + op.obj_name = datablock.name + self.draw_property( + datablock, layout, polib.custom_props_bpy.CustomPropertyNames.TQ_CLEARCOAT + ) + self.draw_property( + datablock, layout, polib.custom_props_bpy.CustomPropertyNames.TQ_FLAKES_AMOUNT + ) + + def draw_multiedit( + self, + context: bpy.types.Context, + layout: bpy.types.UILayout, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> None: + self.draw_multiedit_header(layout) + + prefs = preferences.prefs_utils.get_preferences( + context + ).traffiq_paint_adjustments_preferences + col = layout.column() + row = col.row(align=True) + row.prop(prefs, "primary_color") + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR, + feature_utils.RandomizeColorPropertyOperator, + row, + ) + row = col.row(align=True) + row.prop(prefs, "clearcoat", slider=True) + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.TQ_CLEARCOAT, + feature_utils.RandomizeFloatPropertyOperator, + row, + ) + row = col.row(align=True) + row.prop(prefs, "flakes_amount", slider=True) + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.TQ_FLAKES_AMOUNT, + feature_utils.RandomizeFloatPropertyOperator, + row, + ) + row = self.layout.row() + op = row.operator(SetColor.bl_idname, icon='COLOR', text="Set Color to Random in Shader") + op.obj_name = "" + op.color = RANDOM_COLOR + + def draw(self, context: bpy.types.Context) -> None: + layout = self.layout + possible_assets = self.get_possible_assets(context) + + if self.conditionally_draw_warning_no_adjustable_assets(possible_assets, layout): + return + + self.draw_adjustable_assets_property_table( + possible_assets, + ["Color", "Clearcoat", "Flakes Amount"], + layout, + lambda layout, obj: self.draw_properties(obj, layout), + ) + + self.draw_multiedit(context, layout, possible_assets) + + +MODULE_CLASSES.append(TraffiqPaintAdjustmentsPanel) + + +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/traffiq/rigs.py b/features/traffiq_rigs.py similarity index 79% rename from traffiq/rigs.py rename to features/traffiq_rigs.py index 8bc4b26..a6e826b 100644 --- a/traffiq/rigs.py +++ b/features/traffiq_rigs.py @@ -31,8 +31,11 @@ import mathutils import collections import logging +from . import feature_utils from .. import polib from .. import preferences +from . import asset_pack_panels + logger = logging.getLogger(f"polygoniq.{__name__}") @@ -40,6 +43,28 @@ MODULE_CLASSES: typing.List[typing.Type] = [] +class TraffiqRigsPreferences(bpy.types.PropertyGroup): + auto_bake_steering: bpy.props.BoolProperty( + name="Auto Bake Steering", + description="If true, follow path operator will automatically try to bake steering", + default=True, + ) + auto_bake_wheels: bpy.props.BoolProperty( + name="Auto Bake Wheel Rotation", + description="If true, follow path operator will automatically try to bake wheel rotation", + default=True, + ) + auto_reset_transforms: bpy.props.BoolProperty( + name="Auto Reset Transforms", + description="If true, follow path operator will automatically reset transforms" + "of needed objects to give the expected results", + default=True, + ) + + +MODULE_CLASSES.append(TraffiqRigsPreferences) + + class GroundSensorsManipulator: def __init__(self, pose: bpy.types.Pose): self.ground_sensors = self.__find_ground_sensors(pose) @@ -575,9 +600,7 @@ def poll(cls, context: bpy.types.Context): ) def draw(self, context: bpy.types.Context): - rig_properties = preferences.prefs_utils.get_preferences( - context - ).traffiq_preferences.rig_properties + rig_properties = preferences.prefs_utils.get_preferences(context).traffiq_rigs_preferences layout = self.layout layout.prop(rig_properties, "auto_bake_steering", text="Bake Steering") layout.prop(rig_properties, "auto_bake_wheels", text="Bake Wheel Rotation") @@ -598,9 +621,7 @@ def invoke(self, context: bpy.types.Context, event: bpy.types.Event): return context.window_manager.invoke_props_dialog(self) def execute(self, context: bpy.types.Context): - rig_properties = preferences.prefs_utils.get_preferences( - context - ).traffiq_preferences.rig_properties + rig_properties = preferences.prefs_utils.get_preferences(context).traffiq_rigs_preferences target_path = context.scene.tq_target_path_object if target_path is None: self.report({'ERROR'}, "No target path selected!") @@ -728,13 +749,15 @@ def poll(cls, context: bpy.types.Context) -> bool: def invoke(self, context: bpy.types.Context, event: bpy.types.Event): active_object: bpy.types.Object = context.active_object - self.root_bone: bpy.types.PoseBone = active_object.pose.bones.get("Root", None) + self.root_bone: typing.Optional[bpy.types.PoseBone] = active_object.pose.bones.get( + "Root", None + ) if self.root_bone is None: self.report({'ERROR'}, "No root bone found") return {'CANCELLED'} - self.fp_constraint: bpy.types.FollowPathConstraint = self.root_bone.constraints.get( - FollowPath.CONSTRAINT_NAME + self.fp_constraint: typing.Optional[bpy.types.FollowPathConstraint] = ( + self.root_bone.constraints.get(FollowPath.CONSTRAINT_NAME) ) if self.fp_constraint is None: self.report({'ERROR'}, f"Follow path constraint not found on '{active_object.name}'") @@ -909,10 +932,247 @@ def execute(self, context: bpy.types.Context): MODULE_CLASSES.append(RemoveAnimation) +@feature_utils.register_feature +@polib.log_helpers_bpy.logged_panel +class TraffiqRigsPanel(bpy.types.Panel, feature_utils.PropertyAssetFeatureControlPanelMixin): + bl_idname = "VIEW_3D_PT_engon_feature_traffiq_rigs" + bl_parent_id = asset_pack_panels.TraffiqPanel.bl_idname + bl_label = "Rigs" + feature_name = "traffiq_rigs" + related_custom_properties = { + polib.custom_props_bpy.CustomPropertyNames.TQ_WHEEL_ROTATION, + polib.custom_props_bpy.CustomPropertyNames.TQ_STEERING, + polib.custom_props_bpy.CustomPropertyNames.TQ_SUSPENSION_FACTOR, + polib.custom_props_bpy.CustomPropertyNames.TQ_SUSPENSION_ROLLING_FACTOR, + polib.custom_props_bpy.CustomPropertyNames.TQ_WHEELS_Y_ROLLING, + polib.custom_props_bpy.CustomPropertyNames.TQ_CAR_RIG, + } + + @classmethod + def filter_adjustable_assets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + return cls.filter_adjustable_assets_simple(possible_assets) + + def draw_header(self, context: bpy.types.Context) -> None: + self.layout.label(text="", icon='AUTO') + + def draw_properties(self, datablock: bpy.types.ID, layout: bpy.types.UILayout) -> None: + raise NotImplementedError() + + def draw(self, context: bpy.types.Context): + layout = self.layout.column() + layout.use_property_decorate = False + layout.use_property_split = True + if self.conditionally_draw_warning_no_adjustable_active_object(context, layout): + return + + layout.prop(context.scene, "tq_target_path_object", text="Path", icon='CON_FOLLOWPATH') + layout.prop(context.scene, "tq_ground_object", text="Ground", icon='IMPORT') + col = layout.column(align=True) + row = col.row() + row.scale_x = row.scale_y = 1.5 + row.operator(FollowPath.bl_idname, icon='TRACKING') + row = col.row() + row.scale_x = row.scale_y = 1.25 + row.operator(ChangeFollowPathSpeed.bl_idname, icon='FORCE_FORCE') + + layout.separator() + + col = layout.column(align=True) + col.operator(BakeSteering.bl_idname, icon='GIZMO') + col.operator(BakeWheelRotation.bl_idname, icon='PHYSICS') + layout.separator() + + self.layout.operator(RemoveAnimation.bl_idname, icon='PANEL_CLOSE') + + +MODULE_CLASSES.append(TraffiqRigsPanel) + + +def get_position_display_name(position: str) -> str: + """Returns human readable form of our wheel position naming conventions + (e. g. BL_0 -> Back Left (0)) + """ + + raw_position_to_display_map = { + "BL": "Back Left", + "BR": "Back Right", + "FR": "Front Right", + "FL": "Front Left", + "F": "Front", + "B": "Back", + } + + position_split = position.split("_", 1) + if len(position_split) == 2: + position, index = position_split + else: + position, index = position_split[0], "0" + + index_suffix = f" ({index})" if int(index) > 0 else "" + return f"{raw_position_to_display_map.get(position, '')}{index_suffix}" + + +@polib.log_helpers_bpy.logged_panel +class RigsGroundSensorsPanel(feature_utils.EngonAssetFeatureControlPanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_traffiq_rigs_ground_sensors" + bl_parent_id = TraffiqRigsPanel.bl_idname + bl_label = "Rig Ground Sensors" + + feature_name = "traffiq_rigs" + + @classmethod + def get_possible_assets( + cls, + context: bpy.types.Context, + ) -> typing.Iterable[bpy.types.ID]: + if context.active_object is not None: + return [context.active_object] + return [] + + @classmethod + def filter_adjustable_assets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + return filter( + lambda obj: isinstance(obj, bpy.types.Object) + and polib.rigs_shared_bpy.is_object_rigged(obj), + possible_assets, + ) + + def draw_header(self, context: bpy.types.Context): + self.layout.label(text="", icon='IMPORT') + + def draw(self, context: bpy.types.Context): + layout = self.layout.column() + if self.conditionally_draw_warning_no_adjustable_active_object( + context, layout, warning_text="Active asset is not rigged or is not editable" + ): + return + + layout.use_property_split = True + layout.use_property_decorate = False + layout.prop(context.scene, "tq_ground_object", text="Ground", icon='IMPORT') + layout.operator(SetGroundSensors.bl_idname, text="Set Ground Object For All") + layout.separator() + + sensors_manipulator = GroundSensorsManipulator(context.active_object.pose) + for name, constraint in sensors_manipulator.ground_sensors_constraints.items(): + if constraint is None: + continue + + layout.label(text=self.get_ground_sensor_display_name(name), icon='IMPORT') + layout.prop(constraint, "target", text="Ground") + layout.prop(constraint, "shrinkwrap_type") + layout.prop(constraint, "project_limit") + layout.prop(constraint, "influence") + layout.separator() + + def get_ground_sensor_display_name(self, name: str): + if "Axle" in name: + _, _, position = name.split("_", 2) + return f"{get_position_display_name(position)} Axle [{name}]" + else: + _, position = name.split("_", 1) + return f"{get_position_display_name(position)} [{name}]" + + +MODULE_CLASSES.append(RigsGroundSensorsPanel) + + +@polib.log_helpers_bpy.logged_panel +class RigsRigPropertiesPanel(feature_utils.EngonAssetFeatureControlPanelMixin, bpy.types.Panel): + bl_idname = "VIEW_3D_PT_engon_traffiq_rigs_rig_properties" + bl_parent_id = TraffiqRigsPanel.bl_idname + bl_label = "Rig Properties" + + feature_name = "traffiq_rigs" + + @classmethod + def get_possible_assets( + cls, + context: bpy.types.Context, + ) -> typing.Iterable[bpy.types.ID]: + if context.active_object is not None: + return [context.active_object] + return [] + + @classmethod + def filter_adjustable_assets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + return filter( + lambda obj: isinstance(obj, bpy.types.Object) + and polib.rigs_shared_bpy.is_object_rigged(obj) + and check_rig_drivers(obj), + possible_assets, + ) + + def draw_header(self, context: bpy.types.Context): + self.layout.label(text="", icon='OPTIONS') + + def draw(self, context: bpy.types.Context): + layout = self.layout + + if self.conditionally_draw_warning_no_adjustable_active_object( + context, layout, warning_text="Active asset is not rigged or is not editable" + ): + return + + possible_asset_list = list(self.filter_adjustable_assets(self.get_possible_assets(context))) + assert len(possible_asset_list) == 1 + + active_object = possible_asset_list[0] + assert isinstance(active_object, bpy.types.Object) + + layout.label(text="Wheels") + for prop in active_object.keys(): + if prop.startswith(polib.custom_props_bpy.CustomPropertyNames.TQ_WHEEL_ROTATION): + self.display_custom_property(active_object, layout, prop) + + layout.label(text="Suspension") + self.display_custom_property( + active_object, layout, polib.custom_props_bpy.CustomPropertyNames.TQ_SUSPENSION_FACTOR + ) + self.display_custom_property( + active_object, + layout, + polib.custom_props_bpy.CustomPropertyNames.TQ_SUSPENSION_ROLLING_FACTOR, + ) + + layout.label(text="Steering") + self.display_custom_property( + active_object, layout, polib.custom_props_bpy.CustomPropertyNames.TQ_STEERING + ) + + def display_custom_property( + self, obj: bpy.types.Object, layout: bpy.types.UILayout, prop_name: str + ) -> None: + if prop_name.startswith("tq_"): + prop_display_name = prop_name[len("tq_") :] + else: + prop_display_name = prop_name + + if prop_name.startswith(polib.custom_props_bpy.CustomPropertyNames.TQ_WHEEL_ROTATION): + _, position = prop_display_name.split("_", 1) + prop_display_name = f"{get_position_display_name(position)}" + + if prop_name in obj.keys(): + layout.prop(obj, f'["{prop_name}"]', text=prop_display_name) + else: + layout.label(text=f"Property {prop_name} N/A") + + +MODULE_CLASSES.append(RigsRigPropertiesPanel) + + def register(): for cls in MODULE_CLASSES: bpy.utils.register_class(cls) - bpy.types.Scene.tq_target_path_object = bpy.props.PointerProperty( name="Follow Path Target", description="Path which rigged car should follow", @@ -928,8 +1188,7 @@ def register(): def unregister(): - for cls in reversed(MODULE_CLASSES): - bpy.utils.unregister_class(cls) - del bpy.types.Scene.tq_ground_object del bpy.types.Scene.tq_target_path_object + for cls in reversed(MODULE_CLASSES): + bpy.utils.unregister_class(cls) diff --git a/features/traffiq_wear.py b/features/traffiq_wear.py new file mode 100644 index 0000000..005e302 --- /dev/null +++ b/features/traffiq_wear.py @@ -0,0 +1,215 @@ +# 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 +from . import feature_utils +from .. import polib +from .. import preferences +from . import asset_pack_panels + + +MODULE_CLASSES = [] + + +BUMPS_MODIFIER_NAME = "tq_bumps_displacement" +BUMPS_MODIFIERS_CONTAINER_NAME = "tq_Bump_Modifiers_Container" + + +class TraffiqWearPreferences(bpy.types.PropertyGroup): + @staticmethod + def update_bumps_prop( + context: bpy.types.Context, + possible_objects: typing.Iterable[bpy.types.Object], + value: float, + ): + # Cache objects that support bumps + bumps_objs = [ + obj + for obj in possible_objects + if polib.custom_props_bpy.CustomPropertyNames.TQ_BUMPS in obj + ] + + modifier_library_path = None + + # Add bumps modifier that improves bumps effect on editable objects. + # Bumps work for linked assets but looks better on editable ones with added modifier + for obj in bumps_objs: + # Object is not editable mesh + if obj.data is None or obj.type != "MESH": + continue + # If modifier is not assigned to the object, append it from library + if BUMPS_MODIFIER_NAME not in obj.modifiers: + if modifier_library_path is None: + modifier_library_path = asset_helpers.get_asset_pack_library_path( + "traffiq", asset_helpers.TQ_MODIFIER_LIBRARY_BLEND + ) + if modifier_library_path is None: + raise RuntimeError("Modifier library of traffiq not found!") + polib.asset_pack_bpy.append_modifiers_from_library( + BUMPS_MODIFIERS_CONTAINER_NAME, modifier_library_path, [obj] + ) + logger.info(f"Added bumps modifier on: {obj.name}") + + assert BUMPS_MODIFIER_NAME in obj.modifiers + obj.modifiers[BUMPS_MODIFIER_NAME].strength = value + + polib.custom_props_bpy.update_custom_prop( + context, bumps_objs, polib.custom_props_bpy.CustomPropertyNames.TQ_BUMPS, value + ) + + dirt_wear_strength: bpy.props.FloatProperty( + name="Dirt", + description="Makes assets look dirty", + default=0.0, + min=0.0, + max=1.0, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + TraffiqWearAdjustmentsPanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.TQ_DIRT, + self.dirt_wear_strength, + ), + ) + scratches_wear_strength: bpy.props.FloatProperty( + name="Scratches", + description="Makes assets look scratched", + default=0.0, + min=0.0, + max=1.0, + update=lambda self, context: polib.custom_props_bpy.update_custom_prop( + context, + TraffiqWearAdjustmentsPanel.get_multiedit_adjustable_assets(context), + polib.custom_props_bpy.CustomPropertyNames.TQ_SCRATCHES, + self.scratches_wear_strength, + ), + ) + bumps_wear_strength: bpy.props.FloatProperty( + name="Bumps", + description="Makes assets look dented, appends displacement modifier for better effect if object is editable", + default=0.0, + min=0.0, + soft_max=1.0, + update=lambda self, context: TraffiqWearPreferences.update_bumps_prop( + context, + TraffiqWearAdjustmentsPanel.get_multiedit_adjustable_assets(context), + self.bumps_wear_strength, + ), + ) + + +MODULE_CLASSES.append(TraffiqWearPreferences) + + +@feature_utils.register_feature +@polib.log_helpers_bpy.logged_panel +class TraffiqWearAdjustmentsPanel( + feature_utils.PropertyAssetFeatureControlPanelMixin, bpy.types.Panel +): + bl_idname = "VIEW_3D_PT_engon_feature_traffiq_wear_adjustments" + bl_parent_id = asset_pack_panels.TraffiqPanel.bl_idname + bl_label = "Wear Adjustments" + feature_name = "traffiq_wear" + related_custom_properties = { + polib.custom_props_bpy.CustomPropertyNames.TQ_DIRT, + polib.custom_props_bpy.CustomPropertyNames.TQ_SCRATCHES, + polib.custom_props_bpy.CustomPropertyNames.TQ_BUMPS, + } + + @classmethod + def filter_adjustable_assets( + cls, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> typing.Iterable[bpy.types.ID]: + return cls.filter_adjustable_assets_hierarchical(possible_assets) + + def draw_header(self, context: bpy.types.Context) -> None: + self.layout.label(text="", icon='UV') + + def draw_properties(self, datablock: bpy.types.ID, layout: bpy.types.UILayout) -> None: + self.draw_property(datablock, layout, polib.custom_props_bpy.CustomPropertyNames.TQ_DIRT) + self.draw_property( + datablock, layout, polib.custom_props_bpy.CustomPropertyNames.TQ_SCRATCHES + ) + self.draw_property(datablock, layout, polib.custom_props_bpy.CustomPropertyNames.TQ_BUMPS) + + def draw_multiedit( + self, + context: bpy.types.Context, + layout: bpy.types.UILayout, + possible_assets: typing.Iterable[bpy.types.ID], + ) -> None: + self.draw_multiedit_header(layout) + + prefs = preferences.prefs_utils.get_preferences(context).traffiq_wear_preferences + col = layout.column() + + row = col.row(align=True) + row.prop(prefs, "dirt_wear_strength", slider=True) + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.TQ_DIRT, + feature_utils.RandomizeFloatPropertyOperator, + row, + ) + + row = col.row(align=True) + row.prop(prefs, "scratches_wear_strength", slider=True) + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.TQ_SCRATCHES, + feature_utils.RandomizeFloatPropertyOperator, + row, + ) + + row = col.row(align=True) + row.prop(prefs, "bumps_wear_strength", slider=True) + self.draw_randomize_property_operator( + polib.custom_props_bpy.CustomPropertyNames.TQ_BUMPS, + feature_utils.RandomizeFloatPropertyOperator, + row, + ) + + def draw(self, context: bpy.types.Context) -> None: + layout = self.layout + + possible_assets = self.get_possible_assets(context) + if self.conditionally_draw_warning_no_adjustable_assets(possible_assets, layout): + return + + self.draw_adjustable_assets_property_table( + possible_assets, + ["Dirt", "Scratches", "Bumps"], + layout, + lambda layout, obj: self.draw_properties(obj, layout), + ) + + self.draw_multiedit(context, layout, possible_assets) + + +MODULE_CLASSES.append(TraffiqWearAdjustmentsPanel) + + +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/features/vegetation_generator.py b/features/vegetation_generator.py new file mode 100644 index 0000000..df83312 --- /dev/null +++ b/features/vegetation_generator.py @@ -0,0 +1,79 @@ +# 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 logging +from . import feature_utils +from . import asset_pack_panels +from .. import polib +from .. import asset_helpers + +logger = logging.getLogger(f"polygoniq.{__name__}") + + +MODULE_CLASSES: typing.List[typing.Type] = [] + + +@feature_utils.register_feature +class VegetationGeneratorPanelMixin(feature_utils.GeonodesAssetFeatureControlPanelMixin): + feature_name = "vegetation_generator" + node_group_name = asset_helpers.BQ_CURVES_GENERATOR_NODE_GROUP_NAME + + +@polib.log_helpers_bpy.logged_panel +class VegetationGeneratorPanel( + VegetationGeneratorPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_engon_vegetation_generator" + bl_parent_id = asset_pack_panels.BotaniqPanel.bl_idname + bl_label = "Vegetation Generator" + + template = polib.node_utils_bpy.NodeSocketsDrawTemplate( + asset_helpers.BQ_CURVES_GENERATOR_NODE_GROUP_NAME, + ) + + def draw_header(self, context: bpy.types.Context) -> None: + self.layout.label(text="", icon='OUTLINER_DATA_CURVES') + + def draw(self, context: bpy.types.Context): + layout: bpy.types.UILayout = self.layout + if self.conditionally_draw_warning_no_adjustable_active_object(context, layout): + return + self.draw_active_object_modifiers_node_group_inputs_template( + self.layout, + context, + VegetationGeneratorPanel.template, + ) + + +MODULE_CLASSES.append(VegetationGeneratorPanel) + + +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/features/vine_generator.py b/features/vine_generator.py new file mode 100644 index 0000000..5eab7b0 --- /dev/null +++ b/features/vine_generator.py @@ -0,0 +1,169 @@ +# 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 +from . import feature_utils +from .. import polib +from .. import asset_helpers +from . import asset_pack_panels + +MODULE_CLASSES: typing.List[typing.Type] = [] + + +@feature_utils.register_feature +class VineGeneratorPanelMixin(feature_utils.GeonodesAssetFeatureControlPanelMixin): + feature_name = "vine_generator" + node_group_name = asset_helpers.BQ_VINE_GENERATOR_NODE_GROUP_NAME + + +@polib.log_helpers_bpy.logged_panel +class VineGeneratorPanel( + VineGeneratorPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_botaniq_vine_generator" + bl_parent_id = asset_pack_panels.BotaniqPanel.bl_idname + bl_label = "Vine Generator" + + def draw_header(self, context: bpy.types.Context) -> None: + self.layout.label(text="", icon="GRAPH") + + def draw(self, context: bpy.types.Context): + layout: bpy.types.UILayout = self.layout + self.conditionally_draw_warning_no_adjustable_active_object(context, layout) + + +MODULE_CLASSES.append(VineGeneratorPanel) + + +class VineGeneratorGeneralAdjustmentsPanel( + VineGeneratorPanelMixin, + feature_utils.GeoNodesAssetFeatureSecondaryControlPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_botaniq_vine_generator_general_adjustments" + bl_parent_id = VineGeneratorPanel.bl_idname + bl_label = "General Adjustments" + + template = polib.node_utils_bpy.NodeSocketsDrawTemplate( + asset_helpers.BQ_VINE_GENERATOR_NODE_GROUP_NAME, + filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name( + x, + "Target Object", + "Target Collection", + "Merge Distance", + "Curve Subdivision", + "Cast to Target", + "Angle Threshold", + "Normal Orientation", + "Seed", + ), + socket_names_drawn_first=["Target Object", "Target Collection"], + ) + + def draw(self, context: bpy.types.Context): + self.draw_active_object_modifiers_node_group_inputs_template( + self.layout, + context, + VineGeneratorGeneralAdjustmentsPanel.template, + ) + + +MODULE_CLASSES.append(VineGeneratorGeneralAdjustmentsPanel) + + +class VineGeneratorStemAdjustmentsPanel( + VineGeneratorPanelMixin, + feature_utils.GeoNodesAssetFeatureSecondaryControlPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_botaniq_vine_generator_stem_adjustments" + bl_parent_id = VineGeneratorPanel.bl_idname + bl_label = "Stem Adjustments" + bl_options = {'DEFAULT_CLOSED'} + + template = polib.node_utils_bpy.NodeSocketsDrawTemplate( + asset_helpers.BQ_VINE_GENERATOR_NODE_GROUP_NAME, + filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name( + x, + "Stem", + ), + socket_names_drawn_first=[ + "Stem Material", + ], + ) + + def draw(self, context: bpy.types.Context): + self.draw_active_object_modifiers_node_group_inputs_template( + self.layout, + context, + VineGeneratorStemAdjustmentsPanel.template, + ) + + +MODULE_CLASSES.append(VineGeneratorStemAdjustmentsPanel) + + +class VineGeneratorLeavesAdjustmentsPanel( + VineGeneratorPanelMixin, + feature_utils.GeoNodesAssetFeatureSecondaryControlPanelMixin, + bpy.types.Panel, +): + bl_idname = "VIEW_3D_PT_botaniq_vine_generator_leaves_adjustments" + bl_parent_id = VineGeneratorPanel.bl_idname + bl_label = "Leaves Adjustments" + bl_options = {'DEFAULT_CLOSED'} + + template = polib.node_utils_bpy.NodeSocketsDrawTemplate( + asset_helpers.BQ_VINE_GENERATOR_NODE_GROUP_NAME, + filter_=lambda x: polib.node_utils_bpy.filter_node_socket_name( + x, + "Leaves", + "Leaf", + "Min Scale", + "Max Scale", + "Rotation Sky", + "Deviation Sky", + ), + socket_names_drawn_first=[ + "Leaves Collection", + ], + ) + + def draw(self, context: bpy.types.Context): + self.draw_active_object_modifiers_node_group_inputs_template( + self.layout, + context, + VineGeneratorLeavesAdjustmentsPanel.template, + ) + + +MODULE_CLASSES.append(VineGeneratorLeavesAdjustmentsPanel) + + +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/interniq/__init__.py b/interniq/__init__.py deleted file mode 100644 index c1d7c1b..0000000 --- a/interniq/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -# 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 ##### - -from . import panel - - -def register(): - panel.register() - - -def unregister(): - panel.unregister() diff --git a/interniq/panel.py b/interniq/panel.py deleted file mode 100644 index 8e174c2..0000000 --- a/interniq/panel.py +++ /dev/null @@ -1,54 +0,0 @@ -# copyright (c) 2018- polygoniq xyz s.r.o. - -import bpy -from .. import polib -from .. import asset_registry -from .. import __package__ as base_package - -MODULE_CLASSES = [] - - -class InterniqPanelInfoMixin: - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "polygoniq" - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - return len(asset_registry.instance.get_packs_by_engon_feature("interniq")) > 0 - - -@polib.log_helpers_bpy.logged_panel -class InterniqPanel(InterniqPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_interniq" - bl_label = "interniq" - bl_order = 10 - bl_options = {'DEFAULT_CLOSED'} - - def draw_header(self, context: bpy.types.Context) -> None: - self.layout.label( - text="", icon_value=polib.ui_bpy.icon_manager.get_engon_feature_icon_id("interniq") - ) - - def draw_header_preset(self, context: bpy.types.Context) -> None: - polib.ui_bpy.draw_doc_button( - self.layout, - base_package, - rel_url="panels/interniq/panel_overview", - ) - - def draw(self, context: bpy.types.Context) -> None: - pass - - -MODULE_CLASSES.append(InterniqPanel) - - -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/keymaps.py b/keymaps.py index 95bbcb0..e68bc93 100644 --- a/keymaps.py +++ b/keymaps.py @@ -43,7 +43,10 @@ KeymapItemDefinition( "Toggle engon Browser", "engon.browser_toggle_area", 'E', 'PRESS', False, False, False ), - ] + ], + KeymapDefinition('3D View', 'VIEW_3D', 'WINDOW'): [ + KeymapItemDefinition("Click Assets", "engon.clicker", 'C', 'PRESS', False, False, True), + ], } diff --git a/materialiq/textures.py b/materialiq/textures.py index 42a13d7..67400c2 100644 --- a/materialiq/textures.py +++ b/materialiq/textures.py @@ -92,7 +92,7 @@ def execute(self, context: bpy.types.Context): @polib.log_helpers_bpy.logged_operator class SyncTextureNodes(bpy.types.Operator): bl_idname = "engon.materialiq_sync_texture_nodes" - bl_label = "Sync Texture Nodes (Beta)" + bl_label = "Sync Texture Nodes" bl_description = ( "Synchronizes values of all texture nodes inside active material (for the " "same image) with values from texture node displayed in Textures Panel. Currently " diff --git a/pack_info_search_paths.py b/pack_info_search_paths.py index baf6563..580aca6 100644 --- a/pack_info_search_paths.py +++ b/pack_info_search_paths.py @@ -214,7 +214,15 @@ def _get_discovered_asset_packs( # Try to load an Asset Pack from the file loaded_pack = asset_registry.AssetPack.load_from_json(pack_info_file) except (NotImplementedError, ValueError): - # An Asset Pack couldn't be loaded from the file + logger.exception(f"Could not load Asset Pack from file '{pack_info_file}'!") + continue + except PermissionError: + logger.error(f"Permission denied for file '{pack_info_file}'!") + continue + except: + logger.exception( + f"Unknown error while loading Asset Pack from file '{pack_info_file}'!" + ) continue discovered_packs.append(loaded_pack) return discovered_packs diff --git a/preferences/__init__.py b/preferences/__init__.py index d578382..9180fa9 100644 --- a/preferences/__init__.py +++ b/preferences/__init__.py @@ -21,11 +21,7 @@ from .. import addon_updater from .. import addon_updater_ops import bpy -import bpy_extras import typing -import os -import glob -import json import functools # we don't use this module in this file but we use it elsewhere in engon, we import @@ -34,13 +30,9 @@ from . import general_preferences from . import browser_preferences from . import what_is_new_preferences -from . import aquatiq_preferences -from . import botaniq_preferences -from . import traffiq_preferences -from . import colorize_preferences -from . import light_adjustments_preferences from .. import keymaps from .. import ui_utils +from .. import features from .. import polib from .. import __package__ as base_package @@ -129,34 +121,58 @@ class Preferences(bpy.types.AddonPreferences): type=what_is_new_preferences.WhatIsNewPreferences, ) + botaniq_adjustment_preferences: bpy.props.PointerProperty( + name="Botaniq Adjustment Preferences", + description="Preferences related to the botaniq adjustment feature", + type=features.botaniq_adjustments.BotaniqAdjustmentPreferences, + ) + colorize_preferences: bpy.props.PointerProperty( name="Colorize Preferences", description="Preferences related to the colorize engon feature", - type=colorize_preferences.ColorizePreferences, + type=features.colorize.ColorizePreferences, ) light_adjustments_preferences: bpy.props.PointerProperty( - name="Colorize Preferences", - description="Preferences related to the colorize engon feature", - type=light_adjustments_preferences.LightAdjustmentsPreferences, + name="Light Adjustments Preferences", + description="Preferences related to the light adjustments engon feature", + type=features.light_adjustments.LightAdjustmentsPreferences, + ) + + aquatiq_paint_mask_preferences: bpy.props.PointerProperty( + name="Aquatiq Paint Mask Preferences", + description="Preferences related to the aquatiq paint mask engon feature", + type=features.aquatiq_paint_mask.PaintMaskPreferences, + ) + + botaniq_animations_preferences: bpy.props.PointerProperty( + name="Botaniq Animations Preferences", + description="Preferences related to the botaniq animations engon feature", + type=features.botaniq_animations.botaniq_animations.BotaniqAnimationsPreferences, + ) + + traffiq_lights_settings_preferences: bpy.props.PointerProperty( + name="Traffiq Light Settings Preferences", + description="Preferences related to the traffiq lights settings engon feature", + type=features.traffiq_lights_settings.TraffiqLightsSettingsPreferences, ) - aquatiq_preferences: bpy.props.PointerProperty( - name="Aquatiq Preferences", - description="Preferences related to the aquatiq asset pack", - type=aquatiq_preferences.AquatiqPreferences, + traffiq_paint_adjustments_preferences: bpy.props.PointerProperty( + name="Traffiq Paint Adjustments Preferences", + description="Preferences related to the traffiq paint adjustments engon feature", + type=features.traffiq_paint_adjustments.TraffiqPaintAdjustmentPreferences, ) - botaniq_preferences: bpy.props.PointerProperty( - name="Botaniq Preferences", - description="Preferences related to the botaniq asset pack", - type=botaniq_preferences.BotaniqPreferences, + traffiq_wear_preferences: bpy.props.PointerProperty( + name="Traffiq Wear Preferences", + description="Preferences related to the traffiq wear preferences engon feature", + type=features.traffiq_wear.TraffiqWearPreferences, ) - traffiq_preferences: bpy.props.PointerProperty( - name="Traffiq Preferences", - description="Preferences related to the traffiq asset pack", - type=traffiq_preferences.TraffiqPreferences, + traffiq_rigs_preferences: bpy.props.PointerProperty( + name="Traffiq Rigs Preferences", + description="Preferences related to the traffiq rigs engon feature", + type=features.traffiq_rigs.TraffiqRigsPreferences, ) first_time_register: bpy.props.BoolProperty( @@ -299,11 +315,6 @@ def register(): general_preferences.register() browser_preferences.register() what_is_new_preferences.register() - aquatiq_preferences.register() - botaniq_preferences.register() - traffiq_preferences.register() - colorize_preferences.register() - light_adjustments_preferences.register() for cls in MODULE_CLASSES: bpy.utils.register_class(cls) @@ -311,11 +322,6 @@ def register(): def unregister(): for cls in reversed(MODULE_CLASSES): bpy.utils.unregister_class(cls) - light_adjustments_preferences.unregister() - colorize_preferences.unregister() - traffiq_preferences.unregister() - botaniq_preferences.unregister() - aquatiq_preferences.unregister() what_is_new_preferences.unregister() browser_preferences.unregister() general_preferences.unregister() diff --git a/preferences/aquatiq_preferences.py b/preferences/aquatiq_preferences.py deleted file mode 100644 index 2d7d6b8..0000000 --- a/preferences/aquatiq_preferences.py +++ /dev/null @@ -1,51 +0,0 @@ -# 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 - - -MODULE_CLASSES: typing.List[typing.Any] = [] - - -class AquatiqPreferences(bpy.types.PropertyGroup): - draw_mask_factor: bpy.props.FloatProperty( - name="Mask Factor", - description="Value of 1 means visible, value of 0 means hidden", - update=lambda self, context: self.update_mask_factor(context), - soft_max=1.0, - soft_min=0.0, - ) - - def update_mask_factor(self, context: bpy.types.Context): - context.tool_settings.vertex_paint.brush.color = [self.draw_mask_factor] * 3 - - -MODULE_CLASSES.append(AquatiqPreferences) - - -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/preferences/botaniq_preferences.py b/preferences/botaniq_preferences.py deleted file mode 100644 index 1dc0638..0000000 --- a/preferences/botaniq_preferences.py +++ /dev/null @@ -1,264 +0,0 @@ -# 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 os -import enum -from .. import polib -from .. import asset_helpers - - -MODULE_CLASSES: typing.List[typing.Any] = [] - - -class WindPreset(enum.Enum): - BREEZE = "Breeze" - WIND = "Wind" - STORM = "Storm" - UNKNOWN = "Unknown" - - -class AnimationType(enum.Enum): - WIND_BEST_FIT = "Wind-Best-Fit" - WIND_TREE = "Wind-Tree" - WIND_PALM = "Wind-Palm" - WIND_LOW_VEGETATION = "Wind-Low-Vegetation" - WIND_LOW_VEGETATION_PLANTS = "Wind-Low-Vegetation-Plants" - WIND_SIMPLE = "Wind-Simple" - UNKNOWN = "Unknown" - - -class WindStyle(enum.Enum): - LOOP = "Loop" - PROCEDURAL = "Procedural" - UNKNOWN = "Unknown" - - -class WindAnimationProperties(bpy.types.PropertyGroup): - auto_make_instance: bpy.props.BoolProperty( - name="Automatic Make Instance", - description="Automatically make instance out of object when spawning animation. " - "Better performance, but assets share data, customization per instance", - default=False, - ) - - animation_type: bpy.props.EnumProperty( - name="Wind animation type", - description="Select one of predefined animations types." - "This changes the animation and animation modifier stack", - items=( - ( - AnimationType.WIND_BEST_FIT.value, - AnimationType.WIND_BEST_FIT.value, - "Different animation types based on the selection", - 'SHADERFX', - 0, - ), - ( - AnimationType.WIND_TREE.value, - AnimationType.WIND_TREE.value, - "Animation mostly suited for tree assets", - 'BLANK1', - 1, - ), - ( - AnimationType.WIND_PALM.value, - AnimationType.WIND_PALM.value, - "Animation mostly suited for palm assets", - 'BLANK1', - 2, - ), - ( - AnimationType.WIND_LOW_VEGETATION.value, - AnimationType.WIND_LOW_VEGETATION.value, - "Animation mostly suited for low vegetation assets", - 'BLANK1', - 3, - ), - ( - AnimationType.WIND_LOW_VEGETATION_PLANTS.value, - AnimationType.WIND_LOW_VEGETATION_PLANTS.value, - "Animation mostly suited for low vegetation plant assets", - 'BLANK1', - 4, - ), - ( - AnimationType.WIND_SIMPLE.value, - AnimationType.WIND_SIMPLE.value, - "Simple animation, works only on assets with Leaf_ or Grass_ materials", - 'BLANK1', - 5, - ), - ), - ) - - preset: bpy.props.EnumProperty( - name="Wind animation preset", - description="Select one of predefined animations presets." - "This changes detail of animation and animation modifier stack", - items=( - (WindPreset.BREEZE.value, WindPreset.BREEZE.value, "Light breeze wind", 'BOIDS', 0), - (WindPreset.WIND.value, WindPreset.WIND.value, "Moderate wind", 'CURVES_DATA', 1), - (WindPreset.STORM.value, WindPreset.STORM.value, "Strong storm wind", 'MOD_NOISE', 2), - ), - ) - - strength: bpy.props.FloatProperty( - name="Wind strength", - description="Strength of the wind applied on the trees", - default=0.25, - min=0.0, - soft_max=1.0, - ) - - looping: bpy.props.IntProperty( - name="Loop time", - description="At how many frames should the animation repeat. Minimal value to ensure good " - "animation appearance is 80", - default=120, - min=80, - ) - - bake_folder: bpy.props.StringProperty( - name="Bake Folder", - description="Folder where baked .abc animations are saved", - default=os.path.realpath(os.path.expanduser("~/botaniq_animations/")), - subtype='DIR_PATH', - ) - - # Used to choose target of most wind animation operators but not all. - # It's not used in operators where it doesn't make sense, - # e.g. Add Animation works on selected objects. - operator_target: bpy.props.EnumProperty( - name="Target", - description="Choose to what objects the operator should apply", - items=[ - ('SELECTED', "Selected Objects", "All selected objects"), - ('SCENE', "Scene Objects", "All objects in current scene"), - ('ALL', "All Objects", "All objects in the .blend file"), - ], - default='SCENE', - ) - - -MODULE_CLASSES.append(WindAnimationProperties) - - -class BotaniqPreferences(bpy.types.PropertyGroup): - float_min: bpy.props.FloatProperty( - name="Min Value", - description="Miniumum float value", - default=0.0, - min=0.0, - max=1.0, - step=0.1, - ) - - float_max: bpy.props.FloatProperty( - name="Max Value", description="Maximum float value", default=1.0, min=0.0, max=1.0, step=0.1 - ) - - brightness: bpy.props.FloatProperty( - name="Brightness", - description="Adjust assets brightness", - default=1.0, - min=0.0, - max=10.0, - soft_max=1.0, - step=0.1, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - self.get_adjustment_affected_objects(context), - polib.custom_props_bpy.CustomPropertyNames.BQ_BRIGHTNESS, - self.brightness, - ), - ) - - hue_per_branch: bpy.props.FloatProperty( - name="Hue Per Branch", - description="Randomize hue per branch", - default=1.0, - min=0.0, - max=1.0, - step=0.1, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - self.get_adjustment_affected_objects(context), - polib.custom_props_bpy.CustomPropertyNames.BQ_RANDOM_PER_BRANCH, - self.hue_per_branch, - ), - ) - - hue_per_leaf: bpy.props.FloatProperty( - name="Hue Per Leaf", - description="Randomize hue per leaf", - default=1.0, - min=0.0, - max=1.0, - step=0.1, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - self.get_adjustment_affected_objects(context), - polib.custom_props_bpy.CustomPropertyNames.BQ_RANDOM_PER_LEAF, - self.hue_per_leaf, - ), - ) - - season_offset: bpy.props.FloatProperty( - name="Season Offset", - description="Change season of asset", - default=1.0, - min=0.0, - max=1.0, - step=0.1, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - self.get_adjustment_affected_objects(context), - polib.custom_props_bpy.CustomPropertyNames.BQ_SEASON_OFFSET, - self.season_offset, - ), - ) - - wind_anim_properties: bpy.props.PointerProperty( - name="Animation Properties", - description="Wind animation related property group", - type=WindAnimationProperties, - ) - - def get_adjustment_affected_objects(self, context: bpy.types.Context): - extended_objects = set(context.selected_objects) - if context.active_object is not None: - extended_objects.add(context.active_object) - - return set(extended_objects).union(asset_helpers.gather_instanced_objects(extended_objects)) - - -MODULE_CLASSES.append(BotaniqPreferences) - - -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/preferences/browser_preferences.py b/preferences/browser_preferences.py index 0a58a42..100ff68 100644 --- a/preferences/browser_preferences.py +++ b/preferences/browser_preferences.py @@ -143,7 +143,7 @@ def get_spawn_options( ) elif asset.type_ == mapr.asset_data.AssetDataType.blender_material: return hatchery.spawn.MaterialSpawnOptions( - int(self.texture_size), self.use_displacement, context.selected_objects + int(self.texture_size), self.use_displacement, set(context.selected_objects) ) elif asset.type_ == mapr.asset_data.AssetDataType.blender_particle_system: return hatchery.spawn.ParticleSystemSpawnOptions( diff --git a/preferences/colorize_preferences.py b/preferences/colorize_preferences.py deleted file mode 100644 index 95a8e20..0000000 --- a/preferences/colorize_preferences.py +++ /dev/null @@ -1,87 +0,0 @@ -# copyright (c) 2018- polygoniq xyz s.r.o. - -import bpy -import typing -from .. import polib - - -MODULE_CLASSES: typing.List[typing.Any] = [] - - -class ColorizePreferences(bpy.types.PropertyGroup): - primary_color: bpy.props.FloatVectorProperty( - name="Primary color", - subtype='COLOR', - description="Changes primary color of assets", - default=(1.0, 1.0, 1.0), - size=3, - min=0.0, - max=1.0, - step=1, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - context.selected_objects, - polib.custom_props_bpy.CustomPropertyNames.PQ_PRIMARY_COLOR, - self.primary_color, - ), - ) - - primary_color_factor: bpy.props.FloatProperty( - name="Primary factor", - description="Changes intensity of the primary color", - default=0.0, - min=0.0, - max=1.0, - step=1, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - context.selected_objects, - polib.custom_props_bpy.CustomPropertyNames.PQ_PRIMARY_COLOR_FACTOR, - self.primary_color_factor, - ), - ) - - secondary_color: bpy.props.FloatVectorProperty( - name="Secondary color", - subtype='COLOR', - description="Changes secondary color of assets", - default=(1.0, 1.0, 1.0), - size=3, - min=0.0, - max=1.0, - step=1, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - context.selected_objects, - polib.custom_props_bpy.CustomPropertyNames.PQ_SECONDARY_COLOR, - self.secondary_color, - ), - ) - - secondary_color_factor: bpy.props.FloatProperty( - name="Secondary factor", - description="Changes intensity of the secondary color", - default=0.0, - min=0.0, - max=1.0, - step=1, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - context.selected_objects, - polib.custom_props_bpy.CustomPropertyNames.PQ_SECONDARY_COLOR_FACTOR, - self.secondary_color_factor, - ), - ) - - -MODULE_CLASSES.append(ColorizePreferences) - - -def register(): - for cls in MODULE_CLASSES: - bpy.utils.register_class(cls) - - -def unregister(): - for cls in MODULE_CLASSES: - bpy.utils.unregister_class(cls) diff --git a/preferences/general_preferences.py b/preferences/general_preferences.py index b1a3ade..0b3d12c 100644 --- a/preferences/general_preferences.py +++ b/preferences/general_preferences.py @@ -74,6 +74,47 @@ def active_display_type_updated(self, context: bpy.types.Context) -> None: MODULE_CLASSES.append(ScatterProperties) +class ClickerProperties(bpy.types.PropertyGroup): + random_rotation_z: bpy.props.FloatProperty( + name="Random Rotation Z", + description="Maximum additional random rotation around Z axis", + default=0.0, + min=0, + max=1, + subtype='FACTOR', + ) + random_tilt: bpy.props.FloatProperty( + name="Random Tilt", + description="Maximum additional random tilt the clicked asset in XY axis", + default=0.0, + min=0, + max=1, + subtype='FACTOR', + ) + random_scale: bpy.props.FloatProperty( + name="Random Scale", + description="Maximum additional random scale of the clicked asset", + min=0.0, + default=0.0, + ) + align_to_surface: bpy.props.BoolProperty( + name="Align to Surface", + description="If enabled, clicked assets will be aligned to the surface normal", + default=False, + ) + origin_to_bottom: bpy.props.BoolProperty( + name="Align Origin to Bottom", + description=( + "If enabled, clicked assets origins will be aligned to the bottom of the asset's" + "bounding box. This option makes the object data unique per instanced object" + ), + default=True, + ) + + +MODULE_CLASSES.append(ClickerProperties) + + class GeneralPreferences(bpy.types.PropertyGroup): pack_info_search_paths: bpy.props.CollectionProperty( name="Pack Info Search Paths", type=pack_info_search_paths.PackInfoSearchPath @@ -84,6 +125,7 @@ class GeneralPreferences(bpy.types.PropertyGroup): ) scatter_props: bpy.props.PointerProperty(type=ScatterProperties, name="Scatter Properties") + clicker_props: bpy.props.PointerProperty(type=ClickerProperties, name="Clicker Properties") def get_pack_info_paths(self) -> typing.Iterable[str]: environment_globs = os.environ.get("ENGON_ADDITIONAL_PACK_INFO_GLOBS", None) diff --git a/preferences/light_adjustments_preferences.py b/preferences/light_adjustments_preferences.py deleted file mode 100644 index 3259e6f..0000000 --- a/preferences/light_adjustments_preferences.py +++ /dev/null @@ -1,116 +0,0 @@ -# copyright (c) 2018- polygoniq xyz s.r.o. - -import bpy -import typing -from .. import polib -from .. import features - - -MODULE_CLASSES: typing.List[typing.Any] = [] - - -class LightAdjustmentsPreferences(bpy.types.PropertyGroup): - @staticmethod - def update_prop_with_use_rgb( - context: bpy.types.Context, - objs: typing.Iterable[bpy.types.Object], - prop_name: str, - value: polib.custom_props_bpy.CustomAttributeValueType, - use_rgb_value: bool, - ) -> None: - materialized_objs = list(objs) - polib.custom_props_bpy.update_custom_prop( - context, - materialized_objs, - polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_USE_RGB, - use_rgb_value, - ) - polib.custom_props_bpy.update_custom_prop( - context, - materialized_objs, - prop_name, - value, - ) - - use_rgb: bpy.props.BoolProperty( - name="Use Direct Coloring instead of Temperature", - description="Use Direct Coloring instead of Temperature", - default=False, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - features.light_adjustments.LightAdjustmentsPanel.get_adjustable_objects( - context.selected_objects - ), - polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_USE_RGB, - self.use_rgb, - ), - ) - - light_temperature: bpy.props.IntProperty( - name="Light Temperature", - subtype='TEMPERATURE', - description='Changes light temperature in Kelvins ranging from warm to cool', - default=5000, - min=0, # blender "Temperature" shader node gets this wrong, 0K should be black, but its red - max=12_000, # blender "Temperature" shader node supports up to 12kK - update=lambda self, context: LightAdjustmentsPreferences.update_prop_with_use_rgb( - context, - features.light_adjustments.LightAdjustmentsPanel.get_adjustable_objects( - context.selected_objects - ), - polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_KELVIN, - self.light_temperature, - False, - ), - ) - - light_rgb: bpy.props.FloatVectorProperty( - name="Light Color", - subtype='COLOR', - description='Changes light color across the RGB spectrum', - default=(1.0, 1.0, 1.0), - size=3, - min=0.0, - max=1.0, - step=1, - update=lambda self, context: LightAdjustmentsPreferences.update_prop_with_use_rgb( - context, - features.light_adjustments.LightAdjustmentsPanel.get_adjustable_objects( - context.selected_objects - ), - polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_RGB, - self.light_rgb, - True, - ), - ) - - light_strength: bpy.props.FloatProperty( - name="Light Strength", - default=0.0, - description='Changes the intensity of the light', - min=0.0, - subtype='FACTOR', - soft_max=200, # mostly> interior use, exterior lights can go to 2000 or more - step=1, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - features.light_adjustments.LightAdjustmentsPanel.get_adjustable_objects( - context.selected_objects - ), - polib.custom_props_bpy.CustomPropertyNames.PQ_LIGHT_STRENGTH, - self.light_strength, - ), - ) - - -MODULE_CLASSES.append(LightAdjustmentsPreferences) - - -def register(): - for cls in MODULE_CLASSES: - bpy.utils.register_class(cls) - - -def unregister(): - for cls in MODULE_CLASSES: - bpy.utils.unregister_class(cls) diff --git a/preferences/traffiq_preferences.py b/preferences/traffiq_preferences.py deleted file mode 100644 index 818463f..0000000 --- a/preferences/traffiq_preferences.py +++ /dev/null @@ -1,258 +0,0 @@ -# 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 logging -from .. import polib -from .. import asset_helpers - -logger = logging.getLogger(f"polygoniq.{__name__}") - - -MODULE_CLASSES: typing.List[typing.Any] = [] - - -BUMPS_MODIFIER_NAME = "tq_bumps_displacement" -BUMPS_MODIFIERS_CONTAINER_NAME = "tq_Bump_Modifiers_Container" - - -MAIN_LIGHT_STATUS = ( - ("0", "Off", "Front and rear lights are off"), - ("0.25", "Parking", "Parking lights are on"), - ("0.50", "Low-Beam", "Low-Beam lights are on"), - ("0.75", "High-Beam", "High-Beam lights are on"), -) - - -class CarPaintProperties(bpy.types.PropertyGroup): - @staticmethod - def update_car_paint_color_prop(context, value: typing.Tuple[float, float, float, float]): - # Don't allow to accidentally set color to random - if all(v > 0.99 for v in value[:3]): - value = (0.99, 0.99, 0.99, value[3]) - - polib.custom_props_bpy.update_custom_prop( - context, - context.selected_objects, - polib.custom_props_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR, - value, - ) - - primary_color: bpy.props.FloatVectorProperty( - name="Color", - subtype='COLOR', - description="Changes primary color of assets", - min=0.0, - max=1.0, - default=(0.8, 0.8, 0.8, 1.0), - size=4, - update=lambda self, context: CarPaintProperties.update_car_paint_color_prop( - context, self.primary_color - ), - ) - flakes_amount: bpy.props.FloatProperty( - name="Flakes Amount", - description="Changes amount of flakes in the car paint", - default=0.0, - min=0.0, - max=1.0, - step=0.1, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - context.selected_objects, - polib.custom_props_bpy.CustomPropertyNames.TQ_FLAKES_AMOUNT, - self.flakes_amount, - ), - ) - clearcoat: bpy.props.FloatProperty( - name="Clearcoat", - description="Changes clearcoat property of car paint", - default=0.2, - min=0.0, - max=1.0, - step=0.1, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - context.selected_objects, - polib.custom_props_bpy.CustomPropertyNames.TQ_CLEARCOAT, - self.clearcoat, - ), - ) - - -MODULE_CLASSES.append(CarPaintProperties) - - -class WearProperties(bpy.types.PropertyGroup): - @staticmethod - def update_bumps_prop(context: bpy.types.Context, value: float): - # Cache objects that support bumps - bumps_objs = [ - obj - for obj in context.selected_objects - if polib.custom_props_bpy.CustomPropertyNames.TQ_BUMPS in obj - ] - - modifier_library_path = None - - # Add bumps modifier that improves bumps effect on editable objects. - # Bumps work for linked assets but looks better on editable ones with added modifier - for obj in bumps_objs: - # Object is not editable mesh - if obj.data is None or obj.type != "MESH": - continue - # If modifier is not assigned to the object, append it from library - if BUMPS_MODIFIER_NAME not in obj.modifiers: - if modifier_library_path is None: - modifier_library_path = asset_helpers.get_asset_pack_library_path( - "traffiq", asset_helpers.TQ_MODIFIER_LIBRARY_BLEND - ) - if modifier_library_path is None: - raise RuntimeError("Modifier library of traffiq not found!") - polib.asset_pack_bpy.append_modifiers_from_library( - BUMPS_MODIFIERS_CONTAINER_NAME, modifier_library_path, [obj] - ) - logger.info(f"Added bumps modifier on: {obj.name}") - - assert BUMPS_MODIFIER_NAME in obj.modifiers - obj.modifiers[BUMPS_MODIFIER_NAME].strength = value - - polib.custom_props_bpy.update_custom_prop( - context, bumps_objs, polib.custom_props_bpy.CustomPropertyNames.TQ_BUMPS, value - ) - - dirt_wear_strength: bpy.props.FloatProperty( - name="Dirt", - description="Makes assets look dirty", - default=0.0, - min=0.0, - max=1.0, - step=0.1, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - context.selected_objects, - polib.custom_props_bpy.CustomPropertyNames.TQ_DIRT, - self.dirt_wear_strength, - ), - ) - scratches_wear_strength: bpy.props.FloatProperty( - name="Scratches", - description="Makes assets look scratched", - default=0.0, - min=0.0, - max=1.0, - step=0.1, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - context.selected_objects, - polib.custom_props_bpy.CustomPropertyNames.TQ_SCRATCHES, - self.scratches_wear_strength, - ), - ) - bumps_wear_strength: bpy.props.FloatProperty( - name="Bumps", - description="Makes assets look dented, appends displacement modifier for better effect if object is editable", - default=0.0, - min=0.0, - soft_max=1.0, - step=0.1, - update=lambda self, context: WearProperties.update_bumps_prop( - context, self.bumps_wear_strength - ), - ) - - -MODULE_CLASSES.append(WearProperties) - - -class RigProperties(bpy.types.PropertyGroup): - auto_bake_steering: bpy.props.BoolProperty( - name="Auto Bake Steering", - description="If true, follow path operator will automatically try to bake steering", - default=True, - ) - auto_bake_wheels: bpy.props.BoolProperty( - name="Auto Bake Wheel Rotation", - description="If true, follow path operator will automatically try to bake wheel rotation", - default=True, - ) - auto_reset_transforms: bpy.props.BoolProperty( - name="Auto Reset Transforms", - description="If true, follow path operator will automatically reset transforms" - "of needed objects to give the expected results", - default=True, - ) - - -MODULE_CLASSES.append(RigProperties) - - -class LightsProperties(bpy.types.PropertyGroup): - main_lights_status: bpy.props.EnumProperty( - name="Main Lights Status", - items=MAIN_LIGHT_STATUS, - update=lambda self, context: polib.custom_props_bpy.update_custom_prop( - context, - ( - lights_obj - for _, lights_obj in self.find_unique_lights_containers_with_roots( - context.selected_objects - ) - ), - polib.custom_props_bpy.CustomPropertyNames.TQ_LIGHTS, - float(self.main_lights_status), - ), - ) - - def find_unique_lights_containers_with_roots( - self, objects: typing.Iterable[bpy.types.Object] - ) -> typing.Iterable[typing.Tuple[bpy.types.Object, bpy.types.Object]]: - return polib.asset_pack_bpy.get_root_objects_with_matched_child( - objects, - lambda x, _: x.get(polib.custom_props_bpy.CustomPropertyNames.TQ_LIGHTS, None) - is not None, - ) - - -MODULE_CLASSES.append(LightsProperties) - - -class TraffiqPreferences(bpy.types.PropertyGroup): - car_paint_properties: bpy.props.PointerProperty(type=CarPaintProperties) - - wear_properties: bpy.props.PointerProperty(type=WearProperties) - - lights_properties: bpy.props.PointerProperty(type=LightsProperties) - - rig_properties: bpy.props.PointerProperty(type=RigProperties) - - -MODULE_CLASSES.append(TraffiqPreferences) - - -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/python_deps/hatchery/spawn.py b/python_deps/hatchery/spawn.py index ffb60e8..3d1796c 100644 --- a/python_deps/hatchery/spawn.py +++ b/python_deps/hatchery/spawn.py @@ -144,9 +144,11 @@ def spawn_material( material = load.load_material(path) # If no object is selected we will spawn a sphere and assign material on it - if len(options.target_objects) == 0 and context.active_object is not None: + if len(options.target_objects) == 0: bpy.ops.mesh.primitive_uv_sphere_add() bpy.ops.object.shade_smooth() + # The spawned sphere is the active object + assert context.active_object is not None context.active_object.name = material.name options.target_objects.add(context.active_object) @@ -308,6 +310,7 @@ def spawn_geometry_nodes( obj = load.load_master_object(path) if options.parent_collection is not None: options.parent_collection.objects.link(obj) + obj.location = context.scene.cursor.location # 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 diff --git a/python_deps/mapr/asset.py b/python_deps/mapr/asset.py index ec9e812..9d9d581 100644 --- a/python_deps/mapr/asset.py +++ b/python_deps/mapr/asset.py @@ -53,6 +53,8 @@ class Asset: numeric_parameters: NumericParameters = dataclasses.field(default_factory=dict) vector_parameters: VectorParameters = dataclasses.field(default_factory=dict) text_parameters: TextParameters = dataclasses.field(default_factory=dict) + # Search matter that's not coming from this asset e.g. category search matter + foreign_search_matter: typing.Dict[str, float] = dataclasses.field(default_factory=dict) @functools.cached_property def parameters(self) -> typing.Dict[str, typing.Any]: @@ -63,10 +65,14 @@ def parameters(self) -> typing.Dict[str, typing.Any]: def search_matter(self) -> typing.DefaultDict[str, float]: """Return a dictionary of lowercase text searchable tokens, each mapped to its search weight - Search weight 0 means excluded from search. Weight 1 is the default. Since tokens with - weight 0 never contribute to the search we exclude them. We guarantee all tokens to map to - weight > 0. + Search weight 0 means excluded from search. Since tokens with weight 0 never contribute to + the search we exclude them. We guarantee all tokens to map to weight > 0. """ + TITLE_DEFAULT_WEIGHT = 2.0 + TAG_DEFAULT_WEIGHT = 1.0 + TEXT_PARAMETERS_DEFAULT_WEIGHT = 0.5 + NUMERIC_PARAMETERS_DEFAULT_WEIGHT = 0.5 + VECTOR_PARAMETERS_DEFAULT_WEIGHT = 0.5 ret: typing.DefaultDict[str, float] = self.type_.search_matter ret[self.title.lower()] = max(1.0, ret[self.title.lower()]) @@ -74,10 +80,15 @@ def search_matter(self) -> typing.DefaultDict[str, float]: # The title tokens are weighted individually title_tokens = self.title.lower().split(" ") for kw in title_tokens: - ret[kw] = max(1.0, ret[kw]) + ret[kw] = max(TITLE_DEFAULT_WEIGHT, ret[kw]) + + for foreign_search_matter, weight in self.foreign_search_matter.items(): + ret[foreign_search_matter.lower()] = max(weight, ret[foreign_search_matter.lower()]) for tag in self.tags: - search_weight = float(known_metadata.TAGS.get(tag, {}).get("search_weight", 1.0)) + search_weight = float( + known_metadata.TAGS.get(tag, {}).get("search_weight", TAG_DEFAULT_WEIGHT) + ) if search_weight <= 0.0: continue token = tag.lower() @@ -85,7 +96,9 @@ def search_matter(self) -> typing.DefaultDict[str, float]: for name, value in self.text_parameters.items(): search_weight = float( - known_metadata.TEXT_PARAMETERS.get(name, {}).get("search_weight", 1.0) + known_metadata.TEXT_PARAMETERS.get(name, {}).get( + "search_weight", TEXT_PARAMETERS_DEFAULT_WEIGHT + ) ) if search_weight <= 0.0: continue @@ -94,7 +107,9 @@ def search_matter(self) -> typing.DefaultDict[str, float]: for name, value in self.numeric_parameters.items(): search_weight = float( - known_metadata.NUMERIC_PARAMETERS.get(name, {}).get("search_weight", 1.0) + known_metadata.NUMERIC_PARAMETERS.get(name, {}).get( + "search_weight", NUMERIC_PARAMETERS_DEFAULT_WEIGHT + ) ) if search_weight <= 0.0: continue @@ -103,7 +118,9 @@ def search_matter(self) -> typing.DefaultDict[str, float]: for name, value in self.vector_parameters.items(): search_weight = float( - known_metadata.VECTOR_PARAMETERS.get(name, {}).get("search_weight", 1.0) + known_metadata.VECTOR_PARAMETERS.get(name, {}).get( + "search_weight", VECTOR_PARAMETERS_DEFAULT_WEIGHT + ) ) if search_weight <= 0.0: continue diff --git a/python_deps/mapr/category.py b/python_deps/mapr/category.py index e6bad2d..36230c0 100644 --- a/python_deps/mapr/category.py +++ b/python_deps/mapr/category.py @@ -9,6 +9,10 @@ logger = logging.getLogger(f"polygoniq.{__name__}") +# Search weight of the category, used as 'foreign_search_matter' in `asset.py:Asset`. +TITLE_SEARCH_WEIGHT = 2.0 + + CategoryID = str diff --git a/python_deps/mapr/filters.py b/python_deps/mapr/filters.py index 4ef1a07..510d1f6 100644 --- a/python_deps/mapr/filters.py +++ b/python_deps/mapr/filters.py @@ -256,50 +256,117 @@ def __init__(self, search: str): self.needle_keywords = SearchFilter.keywords_from_search(search) def filter_(self, asset_: asset.Asset) -> bool: + EXACT_MATCH_COEFFICIENT = 5.0 + PREFIX_MATCH_COEFFICIENT = 3.0 + INFIX_MATCH_COEFFICIENT = 2.0 + SUFFIX_MATCH_COEFFICIENT = 2.0 + + SUBSEQUENT_MATCH_COEFFICIENT = 5.0 + MULTIPLE_MATCH_COEFFICIENT = 1.0 + + def get_affix_score( + needle_keyword: str, + haystack_keyword: str, + haystack_keyword_weight: float, + require_exact_match: bool, + ) -> float: + affix_score = 0.0 + if require_exact_match: + if haystack_keyword == needle_keyword[1:-1]: + affix_score = haystack_keyword_weight * EXACT_MATCH_COEFFICIENT + return affix_score + + index = haystack_keyword.find(needle_keyword) + if index == -1: + # no match + return affix_score + if index == 0 and len(haystack_keyword) == len(needle_keyword): + # bump exact matches even if exact match not requested + affix_score = EXACT_MATCH_COEFFICIENT * haystack_keyword_weight + elif index == 0: + # prefix match + affix_score = PREFIX_MATCH_COEFFICIENT * haystack_keyword_weight + elif index < len(haystack_keyword) - len(needle_keyword): + # infix match + affix_score = INFIX_MATCH_COEFFICIENT * haystack_keyword_weight + else: + # suffix match + affix_score = SUFFIX_MATCH_COEFFICIENT * haystack_keyword_weight + return affix_score + + def get_multiplicity_score( + subsequent_match_count: float, multiple_match_count: float + ) -> float: + multiplicity_score = 1.0 + if subsequent_match_count <= 1 and multiple_match_count <= 1: + return multiplicity_score + if max_subsequent_matches >= multiple_match_count: + multiplicity_score = SUBSEQUENT_MATCH_COEFFICIENT * max_subsequent_matches + else: + multiplicity_score = MULTIPLE_MATCH_COEFFICIENT * multiple_match_count + return multiplicity_score + # we make sure all needle keywords are present in given haystack for the haystack not to be # filtered if len(self.needle_keywords) == 0: return True - relevancy_score = 0.0 + max_relevancy_score = 0.0 + multiple_match_count = 0 + subsequent_match_count = 0 + max_subsequent_matches = 0 + for needle_keyword in self.needle_keywords: - should_exact_match = needle_keyword.startswith('"') and needle_keyword.endswith('"') + match_flag = False + require_exact_match = needle_keyword.startswith('"') and needle_keyword.endswith('"') for haystack_keyword, haystack_keyword_weight in asset_.search_matter.items(): - + relevancy_score = 0.0 # this is guaranteed by the API assert haystack_keyword_weight > 0.0 - if should_exact_match: - relevancy_score += ( - haystack_keyword_weight if haystack_keyword == needle_keyword[1:-1] else 0.0 - ) - else: - if needle_keyword == haystack_keyword: - # bump exact matches even if exact match not requested - relevancy_score += 3.0 * haystack_keyword_weight - elif haystack_keyword.find(needle_keyword) >= 0: - relevancy_score += haystack_keyword_weight - - SEARCH_ASSET_SCORE[asset_.id_] = relevancy_score - return relevancy_score > 0.0 + relevancy_score = get_affix_score( + needle_keyword, haystack_keyword, haystack_keyword_weight, require_exact_match + ) + match_flag = relevancy_score > 0.0 or match_flag + max_relevancy_score = max(max_relevancy_score, relevancy_score) + + if require_exact_match and not match_flag: + # exclude results which do not contain keywords in quotation marks (even if other keywords would match) + SEARCH_ASSET_SCORE[asset_.id_] = 0.0 + return False + + if match_flag: + subsequent_match_count += 1 + multiple_match_count += 1 + max_subsequent_matches = max(max_subsequent_matches, subsequent_match_count) + else: + subsequent_match_count = 0 + + max_relevancy_score *= get_multiplicity_score(max_subsequent_matches, multiple_match_count) + + SEARCH_ASSET_SCORE[asset_.id_] = max_relevancy_score + return max_relevancy_score > 0.0 @staticmethod @functools.lru_cache(maxsize=128) - def keywords_from_search(search: str) -> typing.Set[str]: - """Returns a set of lowercase keywords to search for in the assets""" + def keywords_from_search(search: str) -> typing.List[str]: + """Returns a list of lowercase keywords to search for in the assets""" - def translate_keywords(keywords: typing.Set[str]) -> typing.Set[str]: + def translate_keywords(keywords: typing.List[str]) -> typing.List[str]: # Be careful when adding new keywords as it will make impossible to find anything using the original keyword. # E.g. if we'd have tag `hdr` it would not be possible to find it now. Or anything named `hdr_something` cannot be find by `hdr` + translator = {"hdri": "world", "hdr": "world"} - ret: typing.Set[str] = set() + ret: typing.List[str] = [] for kw in keywords: - ret.add(translator.get(kw, kw)) + ret.append(translator.get(kw, kw)) return ret - return translate_keywords({kw.lower() for kw in re.split(r"[ ,_\-]+", search) if kw != ""}) + # put search keywords to a dictionary first to prevent duplicate keywords while keeping the order in which they were searched + keywords = {kw.lower(): None for kw in re.split(r"[ ,_\-]+", search) if kw != ""} + return translate_keywords(list(keywords.keys())) def as_dict(self) -> typing.Dict: return {self.name: self.search} diff --git a/python_deps/mapr/known_metadata.py b/python_deps/mapr/known_metadata.py index 3068cbc..f61d535 100644 --- a/python_deps/mapr/known_metadata.py +++ b/python_deps/mapr/known_metadata.py @@ -12,6 +12,7 @@ "Electronics": {"description": ""}, "Enterprise": {"description": ""}, "Entertainment": {"description": ""}, + "Textiles": {"description": ""}, "Furniture": {"description": ""}, "Hallway": {"description": ""}, "Indoor": {"description": ""}, @@ -158,15 +159,27 @@ "description": "Most fitting style for this piece of furniture or room", }, "species": {"description": "Scientific (usually Latin) taxonomy name for the species"}, - "species_en": {"description": "English common name for the species"}, + "species_en": {"description": "English common name for the species", "search_weight": 1.0}, "class": {"description": "Scientific (usually Latin) taxonomy class name for the species"}, - "class_en": {"description": "English common name for class of the species"}, + "class_en": { + "description": "English common name for class of the species", + "search_weight": 1.0, + }, "order": {"description": "Scientific (usually Latin) taxonomy order name for the species"}, - "order_en": {"description": "English common name for order of the species"}, + "order_en": { + "description": "English common name for order of the species", + "search_weight": 1.0, + }, "family": {"description": "Scientific (usually Latin) taxonomy family name for the species"}, - "family_en": {"description": "English common name for family of the species"}, + "family_en": { + "description": "English common name for family of the species", + "search_weight": 1.0, + }, "genus": {"description": "Scientific (usually Latin) taxonomy genus name for the species"}, - "genus_en": {"description": "English common name for genus of the species"}, + "genus_en": { + "description": "English common name for genus of the species", + "search_weight": 1.0, + }, "conservation_status": { "choices": [ "? - Not evaluated (NE)", diff --git a/python_deps/mapr/local_json_provider.py b/python_deps/mapr/local_json_provider.py index cf09083..868db3d 100644 --- a/python_deps/mapr/local_json_provider.py +++ b/python_deps/mapr/local_json_provider.py @@ -48,6 +48,12 @@ def __init__( self.child_assets: typing.DefaultDict[category.CategoryID, typing.List[asset.AssetID]] = ( collections.defaultdict(list) ) + + # maps asset ID to IDs of categories it is in (including parents recursively) + self.asset_categories: typing.DefaultDict[ + asset.AssetID, typing.Set[category.CategoryID] + ] = collections.defaultdict(set) + # maps category ID to its metadata self.categories: typing.Dict[category.CategoryID, category.Category] = {} # maps asset ID to its metadata @@ -102,13 +108,21 @@ def load_index(self): self.child_assets = index_json.get("child_assets", {}) self.child_asset_data = index_json.get("child_asset_data", {}) - for category_id, category_metadata_json in index_json.get("category_metadata", {}).items(): - self.categories[category_id] = category.Category( - id_=category_id, + for category_part, category_metadata_json in index_json.get( + "category_metadata", {} + ).items(): + self.categories[category_part] = category.Category( + id_=category_part, title=category_metadata_json.get("title", "unknown"), preview_file=category_metadata_json.get("preview_file", None), ) + asset_to_category_id: typing.Dict[str, str] = {} + for child_category_id, asset_ids in self.child_assets.items(): + for asset_id in asset_ids: + asset_to_category_id[asset_id] = child_category_id + + self.asset_categories = self.map_assets_to_categories() for asset_id, asset_metadata_json in index_json.get("asset_metadata", {}).items(): # Update vector parameters with color parameters for older asset packs # compatibility (prior to engon 1.2.0). Color parameters were defined solely prior to @@ -116,6 +130,14 @@ def load_index(self): vector_parameters = asset_metadata_json.get("vector_parameters", {}) vector_parameters.update(asset_metadata_json.get("color_parameters", {})) + foreign_search_matter: typing.Dict[str, float] = {} + foreign_search_matter.update( + { + self.categories[category_id].title: category.TITLE_SEARCH_WEIGHT + for category_id in self.asset_categories.get(asset_id, set()) + } + ) + asset_metadata = asset.Asset( id_=asset_id, title=asset_metadata_json.get("title", "unknown"), @@ -125,6 +147,7 @@ def load_index(self): numeric_parameters=asset_metadata_json.get("numeric_parameters", {}), vector_parameters=vector_parameters, text_parameters=asset_metadata_json.get("text_parameters", {}), + foreign_search_matter=foreign_search_matter, ) # clear search matter cache since we updated search matter # we instantiated the class right here so this will do nothing but we include it for @@ -190,6 +213,36 @@ def get_asset_data( ) -> typing.Optional[asset_data.AssetData]: return self.asset_data.get(asset_data_id, None) + def map_assets_to_categories( + self, + ) -> typing.DefaultDict[asset.AssetID, typing.Set[category.CategoryID]]: + """Returns a mapping of asset ID to category IDs it is in, including parent categories. + + Constructed based on 'child_asset_data' and 'child_categories' mappings. These + should be populated prior to calling this method. + """ + # Reverse mapping of child to parent categories + category_parent_mapping: typing.Dict[category.CategoryID, category.CategoryID] = {} + for parent, children in self.child_categories.items(): + category_parent_mapping.update({child: parent for child in children}) + + # Find all parent categories recursively + def find_parents(category_id, all_parents): + if category_id in category_parent_mapping: + parent = category_parent_mapping[category_id] + all_parents.add(parent) + find_parents(parent, all_parents) + + asset_to_categories: typing.DefaultDict[asset.AssetID, typing.Set[category.CategoryID]] = ( + collections.defaultdict(set) + ) + for category_id, asset_ids in self.child_assets.items(): + for asset_id in asset_ids: + asset_to_categories[asset_id].add(category_id) + find_parents(category_id, asset_to_categories[asset_id]) + + return asset_to_categories + def record_file_id(self, file_id: file_provider.FileID) -> None: relative_path = file_id[len(self.file_id_prefix) + 1 :] basename = os.path.basename(relative_path) diff --git a/python_deps/polib/asset_pack.py b/python_deps/polib/asset_pack.py index 457b370..67e2ec7 100644 --- a/python_deps/polib/asset_pack.py +++ b/python_deps/polib/asset_pack.py @@ -10,6 +10,7 @@ "traffiq": 'COLOR_02', # orange "aquatiq": 'COLOR_05', # blue "interniq": 'COLOR_03', # yellow + "engon_particle_systems": 'COLOR_04', # green } diff --git a/python_deps/polib/asset_pack_bpy.py b/python_deps/polib/asset_pack_bpy.py index 6ba1725..21293e2 100644 --- a/python_deps/polib/asset_pack_bpy.py +++ b/python_deps/polib/asset_pack_bpy.py @@ -56,13 +56,13 @@ def filter_out_descendants_from_objects( def is_polygoniq_object( - obj: bpy.types.ID, + datablock: bpy.types.ID, addon_name_filter: typing.Optional[typing.Callable[[str], bool]] = None, include_editable: bool = True, include_linked: bool = True, ) -> bool: return custom_props_bpy.has_property( - obj, + datablock, "polygoniq_addon", addon_name_filter, include_editable=include_editable, @@ -70,31 +70,15 @@ def is_polygoniq_object( ) -def has_engon_property_feature( - obj: bpy.types.ID, feature: str, include_editable: bool = True, include_linked: bool = True -) -> bool: - if not is_polygoniq_object(obj): - return False - - # check if obj has at least one of the given properties of the property features - feature_properties = custom_props_bpy.PROPERTY_FEATURE_PROPERTIES_MAP.get(feature, []) - - for feature_property in feature_properties: - if custom_props_bpy.has_property( - obj, - feature_property, - include_editable=include_editable, - include_linked=include_linked, - ): - return True - return False - - -def find_polygoniq_root_objects( - objects: typing.Iterable[bpy.types.Object], addon_name: typing.Optional[str] = None +def find_root_objects( + objects: typing.Iterable[bpy.types.Object], + addon_name: typing.Optional[str] = None, + only_polygoniq: bool = True, ) -> typing.Set[bpy.types.Object]: """Finds and returns polygoniq root objects in 'objects'. + 'only_polygoniq' parameter can be used to filter out non-polygoniq 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. @@ -116,6 +100,8 @@ def find_polygoniq_root_objects( if current_obj.parent is None: if is_polygoniq_object(current_obj, addon_name_filter): root_objects.add(current_obj) + if not only_polygoniq: + root_objects.add(current_obj) break if is_polygoniq_object(current_obj, addon_name_filter) and not is_polygoniq_object( @@ -640,10 +626,15 @@ def search_hierarchy(parent_obj: bpy.types.Object) -> typing.Optional[bpy.types. def get_root_objects_with_matched_child( - objects: typing.Iterable[bpy.types.Object], comparator: HierarchyNameComparator + objects: typing.Iterable[bpy.types.Object], + comparator: HierarchyNameComparator, + only_polygoniq=True, ) -> 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): + """Searches hierarchies of objects and returns objects that satisfy the 'comparator', and their root objects. + + 'only_polygoniq' parameter can be used to filter out non-polygoniq objects. + """ + for root_obj in find_root_objects(objects, only_polygoniq=only_polygoniq): searched_obj = find_object_in_hierarchy(root_obj, comparator) if searched_obj is not None: yield (root_obj, searched_obj) diff --git a/python_deps/polib/color_utils_bpy.py b/python_deps/polib/color_utils_bpy.py index 065b067..b707b75 100644 --- a/python_deps/polib/color_utils_bpy.py +++ b/python_deps/polib/color_utils_bpy.py @@ -5,6 +5,7 @@ # adapted code from http://www.easyrgb.com/en/math.php import numpy +import math def RGB_to_XYZ(rgb: tuple[float, float, float]) -> tuple[float, float, float]: @@ -148,3 +149,7 @@ def perceptual_color_distance( distance == cap return distance / cap + + +def is_close_color(color1, color2): + return all([math.isclose(c1, c2, abs_tol=0.001) for (c1, c2) in zip(color1, color2)]) diff --git a/python_deps/polib/custom_props_bpy.py b/python_deps/polib/custom_props_bpy.py index 9b48119..72b92b5 100644 --- a/python_deps/polib/custom_props_bpy.py +++ b/python_deps/polib/custom_props_bpy.py @@ -8,6 +8,8 @@ import functools import typing +from . import ui_bpy + CustomAttributeValueType = typing.Union[ str, @@ -20,31 +22,32 @@ ] -# TODO: Refactor to property features class CustomPropertyNames: """Lists names of properties that control shader features through attributes.""" - # traffiq specific custom property names + # traffiq_wear feature TQ_DIRT = "tq_dirt" TQ_SCRATCHES = "tq_scratches" TQ_BUMPS = "tq_bumps" + # traffiq_paint feature TQ_PRIMARY_COLOR = "tq_primary_color" TQ_FLAKES_AMOUNT = "tq_flakes_amount" TQ_CLEARCOAT = "tq_clearcoat" + # traffiq_lights feature TQ_LIGHTS = "tq_main_lights" - # traffiq car rig properties - TQ_CAR_RIG = "tq_Car_Rig" - TQ_WHEELS_Y_ROLLING = "tq_WheelsYRolling" - TQ_STEERING = "tq_SteeringRotation" - TQ_WHEEL_ROTATION = "tq_WheelRotation" - TQ_SUSPENSION_FACTOR = "tq_SuspensionFactor" - TQ_SUSPENSION_ROLLING_FACTOR = "tq_SuspensionRollingFactor" - # botaniq specific custom property names + # botaniq_adjustments feature 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" - # colorize feature + # traffiq_rigs feature + TQ_WHEEL_ROTATION = "tq_WheelRotation" + TQ_STEERING = "tq_SteeringRotation" + TQ_SUSPENSION_FACTOR = "tq_SuspensionFactor" + TQ_SUSPENSION_ROLLING_FACTOR = "tq_SuspensionRollingFactor" + TQ_WHEELS_Y_ROLLING = "tq_WheelsYRolling" + TQ_CAR_RIG = "tq_Car_Rig" + # colorize_feature PQ_PRIMARY_COLOR = "pq_primary_color" PQ_PRIMARY_COLOR_FACTOR = "pq_primary_color_factor" PQ_SECONDARY_COLOR = "pq_secondary_color" @@ -54,6 +57,9 @@ class CustomPropertyNames: PQ_LIGHT_KELVIN = "pq_light_kelvin" PQ_LIGHT_RGB = "pq_light_rgb" PQ_LIGHT_STRENGTH = "pq_light_strength" + # engon scatter custom property defined for particle systems. This is API defined in runtime + # but fallbacks to custom property if the defining API is not enabled. + PPS_DENSITY = "pps_density" @classmethod def is_rig_property(cls, prop: str) -> bool: @@ -83,46 +89,36 @@ def all(cls) -> typing.Set[str]: } -PROPERTY_FEATURE_PROPERTIES_MAP = { - "colorize": [ - CustomPropertyNames.PQ_PRIMARY_COLOR, - CustomPropertyNames.PQ_SECONDARY_COLOR, - CustomPropertyNames.PQ_PRIMARY_COLOR_FACTOR, - CustomPropertyNames.PQ_SECONDARY_COLOR_FACTOR, - ], - "light_adjustments": [ - CustomPropertyNames.PQ_LIGHT_USE_RGB, - CustomPropertyNames.PQ_LIGHT_KELVIN, - CustomPropertyNames.PQ_LIGHT_RGB, - CustomPropertyNames.PQ_LIGHT_STRENGTH, - ], -} - - def has_property( - obj: bpy.types.ID, + datablock: bpy.types.ID, property_name: str, value_condition: typing.Optional[typing.Callable[[typing.Any], bool]] = None, include_editable: bool = True, include_linked: bool = True, ) -> bool: has_correct_value = ( - value_condition is None or property_name in obj and value_condition(obj[property_name]) + value_condition is None + or property_name in datablock + and value_condition(datablock[property_name]) ) if include_editable and ( # Non-object ID types cannot link, so they are always editable - not isinstance(obj, bpy.types.Object) - or obj.instance_collection is None + not isinstance(datablock, bpy.types.Object) + or datablock.instance_collection is None ): # only non-'EMPTY' objects can be considered editable - return property_name in obj and has_correct_value - if include_linked and isinstance(obj, bpy.types.Object) and obj.instance_collection is not None: + return property_name in datablock and has_correct_value + if ( + include_linked + and isinstance(datablock, bpy.types.Object) + and datablock.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: + for linked_obj in datablock.instance_collection.objects: if has_property(linked_obj, property_name, value_condition): return True return False @@ -148,9 +144,7 @@ def update_custom_prop( datablock[prop_name] = value datablock.update_tag(refresh=update_tag_refresh) - for area in context.screen.areas: - if area.type == 'VIEW_3D': - area.tag_redraw() + ui_bpy.tag_areas_redraw(context, {'VIEW_3D'}) def is_api_defined_prop(datablock: bpy.types.ID, property_name: str) -> bool: diff --git a/python_deps/polib/geonodes_mod_utils_bpy.py b/python_deps/polib/geonodes_mod_utils_bpy.py index 71efd6d..86214cd 100644 --- a/python_deps/polib/geonodes_mod_utils_bpy.py +++ b/python_deps/polib/geonodes_mod_utils_bpy.py @@ -115,7 +115,9 @@ def draw_object_modifiers_node_group_inputs_template( draw_modifier_header: bool = False, max_occurrences: int = 1, ) -> None: - mods = get_geometry_nodes_modifiers_by_node_group(obj, inputs.name) + mods = get_geometry_nodes_modifiers_by_node_group( + obj, inputs.name_prefix, inputs.exact_match + ) if len(mods) == 0: return root_layout = layout @@ -168,11 +170,13 @@ def draw_geonodes_modifier_ui_box( def get_geometry_nodes_modifiers_by_node_group( - obj: bpy.types.Object, node_group_name: str + obj: bpy.types.Object, node_group_name_prefix: str, exact_match: bool = True ) -> 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: + if (exact_match and mod.node_group.name == node_group_name_prefix) or ( + not exact_match and mod.node_group.name.startswith(node_group_name_prefix) + ): output.append(mod) return output diff --git a/python_deps/polib/linalg_bpy.py b/python_deps/polib/linalg_bpy.py index 8acc9a5..52b667c 100644 --- a/python_deps/polib/linalg_bpy.py +++ b/python_deps/polib/linalg_bpy.py @@ -2,6 +2,8 @@ # copyright (c) 2018- polygoniq xyz s.r.o. import bpy +import bpy_extras +import collections import math import mathutils import numpy @@ -53,6 +55,138 @@ def mean_position(vs: typing.Iterable[mathutils.Vector]) -> mathutils.Vector: return sum_v / n +RaycastHit = collections.namedtuple("RaycastHit", ["object", "position", "normal"]) + + +def raycast_screen_to_world( + context: bpy.types.Context, + screen_position: typing.Tuple[int, int], + excluded_objects_names: typing.Optional[typing.Set[str]] = None, + raycast_collection: typing.Optional[bpy.types.Collection] = None, + skip_particle_instances: bool = True, +) -> typing.Optional[RaycastHit]: + """Get the 3D position of the mouse cursor in the scene based on 'context' and 'screen_position'. + + Use 'excluded_objects_names' to provide a set of object names to exclude from the raycast + (e. g. self, or self instances). + + Use 'raycast_collection' to define collection of objects to raycast against. If None, + all objects are raycast. Useful when working with large scenes and you want to raycast only + against a subset of objects. + + 'skip_particle_instances' is a flag that determines whether instances coming from a particle + systems are skipped for performance gain. + """ + # This code was taken from operator_modal_view3d_raycast.py Blender python template and adjusted + # to our use case. + + if excluded_objects_names is None: + excluded_objects_names = set() + + # get the context arguments + region = context.region + region_3d = context.region_data + + # get the ray from the viewport and mouse + view_vector = bpy_extras.view3d_utils.region_2d_to_vector_3d(region, region_3d, screen_position) + ray_origin = bpy_extras.view3d_utils.region_2d_to_origin_3d(region, region_3d, screen_position) + + ray_target = ray_origin + view_vector + + def get_visible_objects_and_instances() -> ( + typing.Iterable[typing.Tuple[bpy.types.Object, mathutils.Matrix]] + ): + """Get (Object, Matrix) pairs of all the objects and instanced objects in the scene""" + depsgraph = context.evaluated_depsgraph_get() + for dup in depsgraph.object_instances: + if dup.is_instance: # Real dupli instance + if dup.is_instance and dup.particle_system is not None and skip_particle_instances: + continue + + obj = dup.instance_object + matrix = dup.matrix_world.copy() + # If there is instanced object and both its instancer and the object it instances + # are in excluded_object_names, exclude it. + if ( + dup.instance_object.name in excluded_objects_names + and dup.parent is not None + and dup.parent.name in excluded_objects_names + ): + continue + + # If the instancer isn't in the raycast collection we consider it not visible. + # The instanced object doesn't have to be in it, it can live only in bpy.data. + if ( + raycast_collection is not None + and dup.parent is not None + and dup.parent.name not in raycast_collection.all_objects + ): + continue + + else: # Usual object + obj = dup.object + matrix = obj.matrix_world.copy() + if obj.name in excluded_objects_names: + continue + + # If the object isn't in the raycast collection, we don't consider it visible. + if ( + raycast_collection is not None + and obj.name not in raycast_collection.all_objects + ): + continue + + yield (obj, matrix) + + def obj_ray_cast( + obj: bpy.types.Object, matrix: mathutils.Matrix + ) -> typing.Tuple[typing.Optional[mathutils.Vector], typing.Optional[mathutils.Vector]]: + """Raycasts a ray in object's local space, returns result in world space.""" + + # get the ray relative to the object + matrix_inv = matrix.inverted() + ray_origin_obj = matrix_inv @ ray_origin + ray_target_obj = matrix_inv @ ray_target + ray_direction_obj = ray_target_obj - ray_origin_obj + + # cast the ray + success, location, normal, _ = obj.ray_cast(ray_origin_obj, ray_direction_obj) + + if success: + # Move the normal to world space + _, rotation, _ = matrix.decompose() + normal: mathutils.Vector = normal.normalized() + normal.rotate(rotation) + return location, normal + else: + return None, None + + # cast rays and find the closest object that was hit + best_length_squared = math.inf + best_hit_obj = None + best_hit_world = None + best_normal = None + + for obj, matrix in get_visible_objects_and_instances(): + if obj.type not in {'MESH', 'CURVE'}: + continue + + hit, normal = obj_ray_cast(obj, matrix) + if hit is not None: + hit_world = matrix @ hit + length_squared = (hit_world - ray_origin).length_squared + if length_squared < best_length_squared: + best_length_squared = length_squared + best_hit_world = hit_world + best_normal = normal + best_hit_obj = obj + + if best_hit_obj is None: + return None + + return RaycastHit(best_hit_obj, best_hit_world, best_normal) + + class PlaneFittingTest(unittest.TestCase): def test_3pts(self): # unit plane - (0, 0, 1), 0 diff --git a/python_deps/polib/node_utils_bpy.py b/python_deps/polib/node_utils_bpy.py index 524c37d..6ccf14d 100644 --- a/python_deps/polib/node_utils_bpy.py +++ b/python_deps/polib/node_utils_bpy.py @@ -137,16 +137,26 @@ def find_nodes_by_bl_idname( 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]: +def find_nodes_by_name( + node_tree: bpy.types.NodeTree, name_prefix: str, exact_match: bool = True +) -> 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 + node_tree, + lambda x: (exact_match and utils_bpy.remove_object_duplicate_suffix(x.name) == name_prefix) + or ( + not exact_match + and utils_bpy.remove_object_duplicate_suffix(x.name).startswith(name_prefix) + ), ) return nodes def find_nodegroups_by_name( - node_tree: typing.Optional[bpy.types.NodeTree], name: str, use_node_tree_name: bool = True + node_tree: typing.Optional[bpy.types.NodeTree], + name_prefix: str, + use_node_tree_name: bool = True, + exact_match: bool = True, ) -> typing.Set[bpy.types.NodeGroup]: """Returns set of node groups from 'node_tree' which name without duplicate suffix is 'name' @@ -162,8 +172,12 @@ def nodegroup_filter(node: bpy.types.Node) -> bool: 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 + name_for_comparing = utils_bpy.remove_object_duplicate_suffix( + node.node_tree.name if use_node_tree_name else node.name + ) + return (exact_match and name_for_comparing == name_prefix) or ( + not exact_match and name_for_comparing.startswith(name_prefix) + ) nodes = find_nodes_in_tree(node_tree, nodegroup_filter) return nodes @@ -416,11 +430,12 @@ class NodeSocketsDrawTemplate: and 'filter_' is applied to the rest. """ - name: str + name_prefix: str filter_: typing.Callable[[bpy.types.NodeSocket | NodeSocketInterfaceCompat], bool] = ( lambda _: True ) socket_names_drawn_first: typing.Optional[typing.List[str]] = None + exact_match: bool = True def draw_from_material( self, @@ -432,13 +447,15 @@ def draw_from_material( return nodegroups = list( itertools.chain( - find_nodes_by_name(mat.node_tree, self.name), - find_nodegroups_by_name(mat.node_tree, self.name), + find_nodes_by_name(mat.node_tree, self.name_prefix, exact_match=self.exact_match), + find_nodegroups_by_name( + mat.node_tree, self.name_prefix, exact_match=self.exact_match + ), ) ) if len(nodegroups) == 0: - layout.label(text=f"No '{self.name}' nodegroup found", icon='INFO') + layout.label(text=f"No '{self.name_prefix}' nodegroup found", icon='INFO') return for i, group in enumerate(nodegroups): @@ -456,8 +473,11 @@ def draw_from_geonodes_modifier( 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') + if self.exact_match and mod.node_group.name != self.name_prefix: + layout.label(text=f"No '{self.name_prefix}' nodegroup found", icon='INFO') + return + elif not self.exact_match and not mod.node_group.name.startswith(self.name_prefix): + layout.label(text=f"No nodegroup starting with '{self.name_prefix}' found", icon='INFO') return inputs = list( diff --git a/python_deps/polib/render_bpy.py b/python_deps/polib/render_bpy.py index 983f191..63ec544 100644 --- a/python_deps/polib/render_bpy.py +++ b/python_deps/polib/render_bpy.py @@ -1,5 +1,12 @@ # copyright (c) 2018- polygoniq xyz s.r.o. # Code is inspired by the 'MeasureIt' addon by Antonio Vazquez that is shipped natively in Blender +# Parts of code for drawing rounded box and mouse are adjusted from the 'ScreenCastKeys' addon +# by nutti + + +# TODO: This whole module is super unoptimized, we should batch things for UI and ideally +# only draw should happen in these callbacks, style and things that can be computed when setup +# should be done once. import bpy import bpy_extras @@ -9,6 +16,7 @@ import gpu_extras.batch import gpu_extras.presets import mathutils +import math import logging import typing @@ -33,6 +41,10 @@ VIEWPORT_SIZE = (0, 0) +# Default size of a key or mouse indicator in px +DEFAULT_INDICATOR_SIZE = 20.0 +# Default color of the key or mouse indicator, currently a 'engon' color +DEFAULT_INDICATOR_COLOR = (0, 1.0, 162.0 / 255.0, 1.0) Color = typing.Tuple[float, float, float, float] @@ -72,6 +84,336 @@ def rectangle(pos: typing.Tuple[float, float], size: typing.Tuple[float, float], batch.draw(SHADER_2D_UNIFORM_COLOR_BUILTIN) +def arrow( + pos: typing.Tuple[float, float], + color: Color, + size: float, + rotation: float = 0.0, + line_thickness: float = 1.0, + draw_stem: bool = True, + stem_length_factor: float = 0.75, + arrow_length_factor: float = 0.4, +): + rotation = math.radians(rotation) + arrow_length = max(0.0, min(1.0, 1.0 - arrow_length_factor)) + if draw_stem: + verts = [ + (max(0.0, min(1.0, 1.0 - stem_length_factor)), 0.5), + (1.0, 0.5), + (arrow_length, 0.8), + (1.0, 0.5), + (arrow_length, 0.2), + ] + else: + verts = [ + (arrow_length, 0.8), + (1.0, 0.5), + (arrow_length, 0.2), + ] + + rotated_verts = [] + for vert in verts: + x = (vert[0] - 0.5) * 2 + y = (vert[1] - 0.5) * 2 + x_rot = x * math.cos(rotation) - y * math.sin(rotation) + y_rot = x * math.sin(rotation) + y * math.cos(rotation) + rotated_verts.append((x_rot, y_rot)) + + # We modify the given scale to be similar to the key size + verts = [(v[0] * size + pos[0], v[1] * size + pos[1]) for v in rotated_verts] + + original_blend = gpu.state.blend_get() + batch = gpu_extras.batch.batch_for_shader( + SHADER_2D_UNIFORM_COLOR_BUILTIN, + 'LINE_STRIP', + {"pos": verts}, + ) + gpu.state.line_width_set(line_thickness) + + SHADER_2D_UNIFORM_COLOR_BUILTIN.bind() + SHADER_2D_UNIFORM_COLOR_BUILTIN.uniform_float("color", color) + batch.draw(SHADER_2D_UNIFORM_COLOR_BUILTIN) + + gpu.state.blend_set(original_blend) + + +def mouse_symbol( + x: float, + y: float, + w: float, + h: float, + left_pressed: bool = False, + middle_pressed: bool = False, + right_pressed: bool = False, + indicate_left: bool = False, + indicate_right: bool = False, + indicate_up: bool = False, + indicate_down: bool = False, + round_radius: float = 5.0, + color: Color = (1.0, 1.0, 1.0, 1.0), + fill: bool = False, + line_thickness: float = 1.0, +): + """Draws mouse symbol using rounded rectangles. + + Optionally arrows can be drawn to indicate the direction of the mouse movement. + Optionally the mouse buttons can be filled to indicate they are pressed. + """ + # Code is adjusted from the ScreenCastKeys addon by nutti. + mouse_body = [x, y, w, h / 2] + left_mouse_button = [x, y + h / 2, w / 3, h / 2] + middle_mouse_button = [x + w / 3, y + h / 2, w / 3, h / 2] + right_mouse_button = [x + 2 * w / 3, y + h / 2, w / 3, h / 2] + + # Mouse body. + if fill: + rounded_rectangle( + mouse_body[0], + mouse_body[1], + mouse_body[2], + mouse_body[3], + round_radius, + fill=True, + color=color, + round_corner=(True, True, False, False), + line_thickness=line_thickness, + ) + rounded_rectangle( + mouse_body[0], + mouse_body[1], + mouse_body[2], + mouse_body[3], + round_radius, + fill=False, + color=color, + round_corner=(True, True, False, False), + line_thickness=line_thickness, + ) + + # Left button. + if fill: + rounded_rectangle( + left_mouse_button[0], + left_mouse_button[1], + left_mouse_button[2], + left_mouse_button[3], + round_radius / 2, + fill=True, + color=color, + round_corner=(False, False, False, True), + line_thickness=line_thickness, + ) + rounded_rectangle( + left_mouse_button[0], + left_mouse_button[1], + left_mouse_button[2], + left_mouse_button[3], + round_radius / 2, + fill=False, + color=color, + round_corner=(False, False, False, True), + line_thickness=line_thickness, + ) + if left_pressed: + rounded_rectangle( + left_mouse_button[0], + left_mouse_button[1], + left_mouse_button[2], + left_mouse_button[3], + round_radius / 2, + fill=True, + color=color, + round_corner=(False, False, False, True), + line_thickness=line_thickness, + ) + + # Middle button. + if fill: + rounded_rectangle( + middle_mouse_button[0], + middle_mouse_button[1], + middle_mouse_button[2], + middle_mouse_button[3], + round_radius / 2, + fill=True, + color=color, + round_corner=(False, False, False, False), + line_thickness=line_thickness, + ) + rounded_rectangle( + middle_mouse_button[0], + middle_mouse_button[1], + middle_mouse_button[2], + middle_mouse_button[3], + round_radius / 2, + fill=False, + color=color, + round_corner=(False, False, False, False), + line_thickness=line_thickness, + ) + if middle_pressed: + rounded_rectangle( + middle_mouse_button[0], + middle_mouse_button[1], + middle_mouse_button[2], + middle_mouse_button[3], + round_radius / 2, + fill=True, + color=color, + round_corner=(False, False, False, False), + line_thickness=line_thickness, + ) + + # Right button. + if fill: + rounded_rectangle( + right_mouse_button[0], + right_mouse_button[1], + right_mouse_button[2], + right_mouse_button[3], + round_radius / 2, + fill=True, + color=color, + round_corner=(False, False, True, False), + line_thickness=line_thickness, + ) + rounded_rectangle( + right_mouse_button[0], + right_mouse_button[1], + right_mouse_button[2], + right_mouse_button[3], + round_radius / 2, + fill=False, + color=color, + round_corner=(False, False, True, False), + line_thickness=line_thickness, + ) + if right_pressed: + rounded_rectangle( + right_mouse_button[0], + right_mouse_button[1], + right_mouse_button[2], + right_mouse_button[3], + round_radius / 2, + fill=True, + color=color, + round_corner=(False, False, True, False), + line_thickness=line_thickness, + ) + + ui_scale = bpy.context.preferences.system.ui_scale + margin_5 = 5 * ui_scale + margin_10 = 10 * ui_scale + arrows_scale = 0.45 + if indicate_down: + arrow( + (x - margin_10, y + h / 2 + margin_5), + color, + DEFAULT_INDICATOR_SIZE * ui_scale * arrows_scale, + rotation=90, + arrow_length_factor=0.5, + ) + if indicate_up: + arrow( + (x - margin_10, y + h / 2 - margin_5), + color, + DEFAULT_INDICATOR_SIZE * ui_scale * arrows_scale, + rotation=-90, + arrow_length_factor=0.5, + ) + if indicate_left: + arrow( + (x + margin_5, y + h + margin_10), + color, + DEFAULT_INDICATOR_SIZE * ui_scale * arrows_scale, + rotation=180, + arrow_length_factor=0.5, + ) + if indicate_right: + arrow( + (x + w - margin_5, y + h + margin_10), + color, + DEFAULT_INDICATOR_SIZE * ui_scale * arrows_scale, + arrow_length_factor=0.5, + ) + + +def rounded_rectangle( + x: float, + y: float, + w: float, + h: float, + round_radius: float, + fill: bool = False, + color: typing.Optional[Color] = None, + round_corner: typing.Optional[typing.Tuple[bool, bool, bool, bool]] = None, + line_thickness: float = 1.0, +): + if color is None: + color = (1.0, 1.0, 1.0, 1.0) + + if round_corner is None: + round_corner = (True, True, True, True) + + num_verts = 16 + n = int(num_verts / 4) + 1 + dangle = math.pi * 2 / num_verts + + radius = [round_radius if rc else 0 for rc in round_corner] + + x_origin = [ + x + radius[0], + x + w - radius[1], + x + w - radius[2], + x + radius[3], + ] + y_origin = [ + y + radius[0], + y + radius[1], + y + h - radius[2], + y + h - radius[3], + ] + angle_start = [ + math.pi * 1.0, + math.pi * 1.5, + math.pi * 0.0, + math.pi * 0.5, + ] + + original_state = gpu.state.blend_get() + gpu.state.blend_set('ALPHA') + + verts = [] + for x0, y0, angle, r in zip(x_origin, y_origin, angle_start, radius): + for _ in range(n): + x = x0 + r * math.cos(angle) + y = y0 + r * math.sin(angle) + if not fill: + verts.append((x, y, 0)) + else: + verts.append((x, y, 0)) + angle += dangle + + # repeat the first vertex to close the box + verts.append(verts[0]) + + shader = SHADER_2D_UNIFORM_COLOR_BUILTIN + batch = gpu_extras.batch.batch_for_shader( + shader, + 'TRI_FAN' if fill else 'LINE_STRIP', + {"pos": verts}, + ) + original_width = gpu.state.line_width_get() + gpu.state.line_width_set(line_thickness) + + shader.bind() + shader.uniform_float("color", color) + batch.draw(shader) + + gpu.state.blend_set(original_state) + gpu.state.line_width_set(original_width) + + def circle(center: mathutils.Vector, radius: float, color: Color, segments: int): gpu_extras.presets.draw_circle_2d(center, color, radius, segments=segments) @@ -85,7 +427,7 @@ class TextStyle: """ font_id: int = 0 - font_size: int = 15 + font_size: float = 15.0 color: Color = (1.0, 1.0, 1.0, 1.0) dpi: int = 72 consider_ui_scale: bool = True @@ -97,10 +439,7 @@ def __post_init__(self): 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) + _set_text_size(style) blf.color(style.font_id, *style.color) blf.draw(style.font_id, str(string)) @@ -118,13 +457,13 @@ def text_3d( def text_box( pos: mathutils.Vector, - width: int, - padding: int, + padding: float, 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 + width = _calculate_lines_width(texts) + 2 * padding if background is not None: rectangle(pos, (width, height + 2 * padding), background) @@ -135,9 +474,157 @@ def text_box( y_pos -= style.font_size + text_margin +def text_rounded_box( + pos: mathutils.Vector, + padding: float, + background_color: Color, + lines: typing.List[typing.Tuple[str, TextStyle]], + line_spacing: float = 2.0, +) -> None: + height = sum(t[1].font_size for t in lines) + (len(lines) - 1) * line_spacing + padding * 2 + + rounded_rectangle( + pos[0], + pos[1], + _calculate_lines_width(lines) + 2 * padding, + height, + 5.0, + fill=True, + color=background_color, + ) + + x_pos = pos.x + padding + y_pos = pos.y + padding + line_spacing + for string, style in lines: + text((x_pos, y_pos), string, style) + y_pos += style.font_size + line_spacing + + +def key_symbol( + x: float, + y: float, + key: str, + pressed: bool = False, +) -> None: + """Draws a key symbol on (x, y) position on the screen. Considers Blender's ui scale.""" + ui_scale = bpy.context.preferences.system.ui_scale + style = TextStyle( + font_size=DEFAULT_INDICATOR_SIZE - 6, + color=DEFAULT_INDICATOR_COLOR, + ) + # Adjust font sized based on the length of the key (e. g. ESC, Shift, CTRL, F12) + if len(key) > 2: + style.font_size = style.font_size / (0.475 * len(key)) + + text_width, text_height = get_text_size(key, style) + size = DEFAULT_INDICATOR_SIZE * ui_scale + # Draw outline rounded rectangle to indicate the key to press, if pressed draw filled rectangle + rounded_rectangle( + x, + y, + size, + size, + 4.0 * ui_scale, + line_thickness=2.0, + fill=pressed, + color=DEFAULT_INDICATOR_COLOR, + ) + if pressed: + # If pressed change the color to black, as we draw on the filled background. + style.color = (0.1, 0.1, 0.1, 1.0) + + # Text aligned to the middle of the key + text( + mathutils.Vector((x + (size - text_width) * 0.5, y + (size - text_height) * 0.5)), + key, + style, + ) + + +def key_info( + x: float, + y: float, + key: str, + description: str, + pressed: bool = False, +) -> None: + """Draws a key symbol with a description on (x, y) position on the screen. + + Blender's ui scale is considered for all elements. If 'pressed' is True then the key + will be drawn as filled with a black color text. + """ + key_symbol(x, y, key, pressed) + + ui_scale = bpy.context.preferences.system.ui_scale + style = TextStyle(font_size=DEFAULT_INDICATOR_SIZE - 4) + _, text_height = get_text_size(description, style) + text( + mathutils.Vector( + ( + x + (DEFAULT_INDICATOR_SIZE + 5) * ui_scale, + y + (DEFAULT_INDICATOR_SIZE * ui_scale - text_height) * 0.5, + ) + ), + description, + style, + ) + + +def mouse_info( + x: float, + y: float, + description: str, + left_click: bool = False, + middle_click: bool = False, + right_click: bool = False, + indicate_left: bool = False, + indicate_right: bool = False, + indicate_up: bool = False, + indicate_down: bool = False, +) -> None: + """Draws a mouse symbol with a text description on (x, y) position on the screen. + + 'left_click', 'middle_click', 'right_click' arguments can be used to indicate the mouse buttons + pressed - making them filled. + 'indicate_left', 'indicate_right', 'indicate_up', 'indicate_down' arguments can be used to + display arrows that indicate the direction of the mouse movement. + + Blender's ui scale is considered for all elements. + """ + ui_scale = bpy.context.preferences.system.ui_scale + mouse_width = DEFAULT_INDICATOR_SIZE * 0.75 + mouse_symbol( + x, + y, + mouse_width * ui_scale, + DEFAULT_INDICATOR_SIZE * ui_scale, + left_click, + middle_click, + right_click, + indicate_left, + indicate_right, + indicate_up, + indicate_down, + round_radius=DEFAULT_INDICATOR_SIZE * ui_scale / 4, + color=DEFAULT_INDICATOR_COLOR, + ) + + style = TextStyle(font_size=DEFAULT_INDICATOR_SIZE - 4) + _, text_height = get_text_size(description, style) + text( + mathutils.Vector( + ( + x + (mouse_width + 5) * ui_scale, + y + (DEFAULT_INDICATOR_SIZE * ui_scale - text_height) * 0.5, + ) + ), + description, + style, + ) + + def text_box_3d( world_pos: mathutils.Vector, - width: int, padding: int, text_margin: float, background: typing.Optional[Color], @@ -147,4 +634,25 @@ def text_box_3d( ) -> 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) + text_box(pos_2d, padding, text_margin, background, texts) + + +def get_text_size(string: str, style: TextStyle) -> typing.Tuple[float, float]: + """Returns size of the text in pixels""" + _set_text_size(style) + return blf.dimensions(style.font_id, string) + + +def _calculate_lines_width(lines: typing.List[typing.Tuple[str, TextStyle]]) -> float: + max_width = 0 + for string, style in lines: + width, _ = get_text_size(string, style) + max_width = max(max_width, width) + return max_width + + +def _set_text_size(style: TextStyle) -> None: + 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) diff --git a/python_deps/polib/telemetry_module_bpy.py b/python_deps/polib/telemetry_module_bpy.py index 1809b30..155f738 100644 --- a/python_deps/polib/telemetry_module_bpy.py +++ b/python_deps/polib/telemetry_module_bpy.py @@ -53,7 +53,7 @@ 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() + self.start_timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat() class Machine: @@ -168,7 +168,7 @@ def __init__( ): self._session_uuid: str = "unknown" - self._timestamp = datetime.datetime.utcnow().isoformat() + self._timestamp = datetime.datetime.now(datetime.timezone.utc).isoformat() self._type = type self.data: typing.Any = None if text is not None: diff --git a/python_deps/polib/ui_bpy.py b/python_deps/polib/ui_bpy.py index 28fc665..e11ea23 100644 --- a/python_deps/polib/ui_bpy.py +++ b/python_deps/polib/ui_bpy.py @@ -208,6 +208,15 @@ def get_mouseovered_region( return None, None +def tag_areas_redraw( + context: bpy.types.Context, area_types: typing.Optional[typing.Set[str]] = None +) -> None: + for window in context.window_manager.windows: + for area in window.screen.areas: + if area_types is None or area.type in area_types: + area.tag_redraw() + + def get_all_space_types() -> typing.Dict[str, bpy.types.Space]: """Returns mapping of space type to its class - 'VIEW_3D -> bpy.types.SpaceView3D""" @@ -343,40 +352,6 @@ def show_release_notes_popup( ) -def draw_property( - obj: bpy.types.ID, layout: bpy.types.UILayout, prop_name: str, text: str = "" -) -> None: - if prop_name in obj: - layout.prop(obj, f'["{prop_name}"]', text=text) - else: - layout.label(text="-") - - -def draw_property_table( - displayable_objects: typing.List[bpy.types.ID], - left_col: bpy.types.UILayout, - right_col: bpy.types.UILayout, - property_func: typing.Callable[[bpy.types.UILayout, bpy.types.ID], None], - label_func: typing.Callable[ - [bpy.types.UILayout, bpy.types.ID], None - ] = lambda row, obj: row.label(text=obj.name), - max_displayed_assets: int = 10, -) -> None: - displayed_assets = 0 - for obj in displayable_objects: - row = left_col.row() - if displayed_assets >= max_displayed_assets: - row.label( - text=f"... and {len(displayable_objects) - displayed_assets} additional asset(s)" - ) - break - label_func(row, obj) - row = right_col.row(align=True) - property_func(row, obj) - - displayed_assets += 1 - - def draw_conflicting_addons( layout: bpy.types.UILayout, module_name: str, conflicts: typing.List[str] ) -> None: diff --git a/python_deps/polib/utils_bpy.py b/python_deps/polib/utils_bpy.py index 052ebcd..e068551 100644 --- a/python_deps/polib/utils_bpy.py +++ b/python_deps/polib/utils_bpy.py @@ -181,6 +181,28 @@ def wrapper(self, context: bpy.types.Context, *args, **kwargs): return cursor_decorator +def safe_modal( + on_exception: typing.Optional[ + typing.Callable[[typing.Any, bpy.types.Context, bpy.types.Event, Exception], typing.Any] + ] = None +): + """Decorator that executes a modal method of modal operator and cancels on exception with a possibility of handling it""" + + def modal_decorator(fn): + def wrapper(self, context: bpy.types.Context, event: bpy.types.Event): + try: + return fn(self, context, event) + except Exception as e: + logger.exception(f"Raised exception in modal operator '{self.__class__.__name__}'") + if on_exception is not None: + return on_exception(self, context, event, e) + return {'CANCELLED'} + + return wrapper + + return modal_decorator + + def timeit(fn): def timed(*args, **kw): ts = time.time() @@ -195,13 +217,13 @@ def timed(*args, **kw): def timed_cache(**timedelta_kwargs): def _wrapper(f): update_delta = datetime.timedelta(**timedelta_kwargs) - next_update = datetime.datetime.utcnow() + update_delta + next_update = datetime.datetime.now(datetime.timezone.utc) + update_delta f = functools.lru_cache(None)(f) @functools.wraps(f) def _wrapped(*args, **kwargs): nonlocal next_update - now = datetime.datetime.utcnow() + now = datetime.datetime.now(datetime.timezone.utc) if now >= next_update: f.cache_clear() next_update = now + update_delta @@ -452,3 +474,8 @@ def get_addon_release_info( return json.JSONDecoder().decode(result_string.decode()) except json.JSONDecodeError as e: logger.error("API response has invalid JSON format") + + +def get_name_from_blend_path(path: str) -> str: # folder/structure/name.blend + asset_name, _ = os.path.splitext(os.path.basename(path)) + return asset_name diff --git a/scatter.py b/scatter.py index 21ff05f..7415d85 100644 --- a/scatter.py +++ b/scatter.py @@ -67,8 +67,8 @@ class AddEmptyScatter(bpy.types.Operator): bl_options = {'REGISTER', 'UNDO'} link_instance_collection: bpy.props.BoolProperty( - description="If true, this setting links particle system instance collection to scene. " - "Objects from instance collection are spawned on (0, -10, 0).", + description="If true, the particle system instance collection will be linked to the scene. " + "Objects from the instance collection are spawned on (0, 0, -10)", name="Link Instance Collection To Scene", default=True, ) @@ -788,6 +788,19 @@ def execute(self, context: bpy.types.Context): MODULE_CLASSES.append(ParticlesChangeDisplay) +class SCATTER_MT_Utilities(bpy.types.Menu): + bl_label = "Scatter Utilities" + bl_idname = "SCATTER_MT_Utilities" + + def draw(self, context: typing.Optional[bpy.types.Context]) -> None: + layout = self.layout + layout.operator(RenameParticleSystem.bl_idname, text="Rename", icon='GREASEPENCIL') + layout.operator("particle.duplicate_particle_system", text="Duplicate", icon='DUPLICATE') + + +MODULE_CLASSES.append(SCATTER_MT_Utilities) + + @polib.log_helpers_bpy.logged_panel class ScatterPanel(panel.EngonPanelMixin, bpy.types.Panel): bl_idname = "VIEW_3D_PT_engon_scatter" @@ -836,7 +849,8 @@ def draw_object_mode_ui(self, context: bpy.types.Context) -> None: row.alignment = 'RIGHT' row.operator(AddEmptyScatter.bl_idname, text="", icon='ADD') row.operator(RemoveParticleSystem.bl_idname, text="", icon='REMOVE') - row.operator(RenameParticleSystem.bl_idname, text="", icon='GREASEPENCIL') + sub = row.column() + sub.menu(SCATTER_MT_Utilities.bl_idname, icon='DOWNARROW_HLT', text="") row = col.row() row.template_list( diff --git a/traffiq/__init__.py b/traffiq/__init__.py deleted file mode 100644 index b8e4363..0000000 --- a/traffiq/__init__.py +++ /dev/null @@ -1,38 +0,0 @@ -# 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 ##### - -from . import panel -from . import rigs -from . import lights -from . import road_generator - - -def register(): - panel.register() - rigs.register() - lights.register() - road_generator.register() - - -def unregister(): - road_generator.unregister() - lights.unregister() - rigs.unregister() - panel.unregister() diff --git a/traffiq/lights.py b/traffiq/lights.py deleted file mode 100644 index 9b032f4..0000000 --- a/traffiq/lights.py +++ /dev/null @@ -1,100 +0,0 @@ -# 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 typing -import bpy -import logging -from .. import polib -from .. import preferences -from .. import asset_helpers - -logger = logging.getLogger(f"polygoniq.{__name__}") - - -MODULE_CLASSES: typing.List[typing.Type] = [] - - -class SetLightsStatus(bpy.types.Operator): - bl_idname = "engon.traffiq_set_lights_status" - bl_label = "Set Lights Status To Selected" - bl_description = "Set lights status to selected objects" - bl_options = {'REGISTER', 'UNDO'} - - status: bpy.props.EnumProperty( - items=preferences.traffiq_preferences.MAIN_LIGHT_STATUS, - name="Lights Status", - ) - - def execute(self, context: bpy.types.Context): - prefs = preferences.prefs_utils.get_preferences(context).traffiq_preferences - prefs.lights_properties.main_lights_status = self.status - return {'FINISHED'} - - -MODULE_CLASSES.append(SetLightsStatus) - - -def get_emergency_lights_container_from_hierarchy_with_root( - obj: bpy.types.Object, -) -> typing.Tuple[typing.Optional[bpy.types.Object], typing.Optional[bpy.types.Object]]: - """Returns the first object in the hierarchy that contains emergency lights and the root of the hierarchy - - Returns None if no such object is found in the hierarchy of the given object. - """ - - def _contains_emergency_lights(obj: bpy.types.Object) -> bool: - return ( - len( - polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - obj, asset_helpers.TQ_EMERGENCY_LIGHTS_NODE_GROUP_NAME - ) - ) - > 0 - ) - - emergency_lights = list( - polib.asset_pack_bpy.get_root_objects_with_matched_child( - [obj], lambda obj, _: _contains_emergency_lights(obj) - ) - ) - if len(emergency_lights) == 0: - return None, None - assert len(emergency_lights) == 1 - return emergency_lights[0] - - -def get_main_lights_status_text(value: float) -> str: - ret = "Unknown" - for min_value, status, _ in preferences.traffiq_preferences.MAIN_LIGHT_STATUS: - if value < float(min_value): - return ret - ret = status - - return ret - - -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/traffiq/panel.py b/traffiq/panel.py deleted file mode 100644 index e875adf..0000000 --- a/traffiq/panel.py +++ /dev/null @@ -1,555 +0,0 @@ -# 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 -from .. import polib -from . import rigs -from . import lights -from .. import preferences -from .. import asset_helpers -from .. import asset_registry -from .. import __package__ as base_package - -MODULE_CLASSES: typing.List[typing.Type] = [] - - -TQ_COLLECTION_NAME = "traffiq" - - -def set_car_paint_color( - obj: bpy.types.Object, color: typing.Tuple[float, float, float, float] -) -> None: - if polib.custom_props_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR in obj: - obj[polib.custom_props_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR] = color - obj.update_tag(refresh={'OBJECT'}) - - -def get_car_paint_color(obj: bpy.types.Object) -> typing.Tuple[float, float, float, float]: - assert polib.custom_props_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR in obj - return obj[polib.custom_props_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR] - - -def can_obj_change_car_paint_color(obj: bpy.types.Object) -> bool: - return polib.custom_props_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR in obj - - -@polib.log_helpers_bpy.logged_operator -class SetColorToRandom(bpy.types.Operator): - bl_idname = "engon.traffiq_set_color_to_random" - bl_label = "Set Color to Random" - bl_description = "Set color of selected assets to random color" - - bl_options = {'REGISTER', 'UNDO'} - - @classmethod - def poll(cls, context: bpy.types.Context): - return context.mode == 'OBJECT' - - def execute(self, context: bpy.types.Context): - for obj in context.selected_objects: - if not can_obj_change_car_paint_color(obj): - continue - - set_car_paint_color(obj, (1.0, 1.0, 1.0, 1.0)) - - for area in context.screen.areas: - if area.type == 'VIEW_3D': - area.tag_redraw() - - return {'FINISHED'} - - -MODULE_CLASSES.append(SetColorToRandom) - - -class TraffiqPanelInfoMixin: - bl_space_type = 'VIEW_3D' - bl_region_type = 'UI' - bl_category = "polygoniq" - - @classmethod - def poll(cls, context: bpy.types.Context) -> bool: - return len(asset_registry.instance.get_packs_by_engon_feature("traffiq")) > 0 - - -@polib.log_helpers_bpy.logged_panel -class TraffiqPanel(TraffiqPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_traffiq" - bl_label = "traffiq" - bl_order = 10 - bl_options = {'DEFAULT_CLOSED'} - - def draw_header(self, context: bpy.types.Context): - self.layout.label( - text="", icon_value=polib.ui_bpy.icon_manager.get_engon_feature_icon_id("traffiq") - ) - - def draw_header_preset(self, context: bpy.types.Context) -> None: - polib.ui_bpy.draw_doc_button( - self.layout, - base_package, - rel_url="panels/traffiq/panel_overview", - ) - - def draw(self, context: bpy.types.Context): - # TODO: All that was formerly here was replaced with engon universal operators, - # only sub-panels remain. Should we reorganize this? - pass - - -MODULE_CLASSES.append(TraffiqPanel) - - -@polib.log_helpers_bpy.logged_panel -class ColorsPanel(TraffiqPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_traffiq_colors" - bl_parent_id = TraffiqPanel.bl_idname - bl_label = "Color Settings" - - def draw_header(self, context: bpy.types.Context): - self.layout.label(text="", icon='COLOR') - - def draw(self, context: bpy.types.Context): - prefs = preferences.prefs_utils.get_preferences(context).traffiq_preferences - row = self.layout.row() - changeable_color_objs = [ - obj for obj in context.selected_objects if can_obj_change_car_paint_color(obj) - ] - - if len(changeable_color_objs) == 0: - row.label(text="No assets with changeable color selected!") - return - - left_col = row.column(align=True) - left_col.scale_x = 2.0 - right_col = row.column(align=True) - row = left_col.row() - row.enabled = False - row.label(text="Selected Assets:") - row = right_col.row() - row.enabled = False - row.label(text="Color") - row.label(text="Clearcoat") - row.label(text="Flakes Amount") - for obj in changeable_color_objs: - row = left_col.row() - row.label(text=obj.name) - row = right_col.row() - current_color = get_car_paint_color(obj) - if tuple(current_color) == (1.0, 1.0, 1.0, 1.0): - row.label(text="random") - else: - row.prop( - obj, - f'["{polib.custom_props_bpy.CustomPropertyNames.TQ_PRIMARY_COLOR}"]', - text="", - ) - - if polib.custom_props_bpy.CustomPropertyNames.TQ_CLEARCOAT in obj: - row.label( - text=f"{obj.get(polib.custom_props_bpy.CustomPropertyNames.TQ_CLEARCOAT):.2f}" - ) - else: - row.label(text="-") - - if polib.custom_props_bpy.CustomPropertyNames.TQ_FLAKES_AMOUNT in obj: - row.label( - text=f"{obj.get(polib.custom_props_bpy.CustomPropertyNames.TQ_FLAKES_AMOUNT):.2f}" - ) - else: - row.label(text="-") - - col = self.layout.column(align=True) - col.prop(prefs.car_paint_properties, "primary_color") - col.prop(prefs.car_paint_properties, "clearcoat", slider=True) - col.prop(prefs.car_paint_properties, "flakes_amount", slider=True) - - row = self.layout.row() - row.operator(SetColorToRandom.bl_idname, icon='COLOR') - - -MODULE_CLASSES.append(ColorsPanel) - - -@polib.log_helpers_bpy.logged_panel -class LightsPanel( - TraffiqPanelInfoMixin, - polib.geonodes_mod_utils_bpy.GeoNodesModifierInputsPanelMixin, - bpy.types.Panel, -): - bl_idname = "VIEW_3D_PT_engon_traffiq_lights" - bl_parent_id = TraffiqPanel.bl_idname - bl_label = "Lights Settings" - - template = polib.node_utils_bpy.NodeSocketsDrawTemplate( - asset_helpers.TQ_EMERGENCY_LIGHTS_NODE_GROUP_NAME, filter_=lambda _: True - ) - - def draw_header(self, context: bpy.types.Context): - self.layout.label(text="", icon='OUTLINER_OB_LIGHT') - - def draw(self, context: bpy.types.Context): - prefs = preferences.prefs_utils.get_preferences(context).traffiq_preferences - col = self.layout.column() - lights_tuples = list( - prefs.lights_properties.find_unique_lights_containers_with_roots( - context.selected_objects - ) - ) - if len(lights_tuples) == 0: - col.label(text="No assets with lights selected!") - return - - if context.scene.render.engine != 'CYCLES': - row = col.row() - row.alert = True - row.label(text="Lights are only supported in CYCLES!", icon='ERROR') - - status_col = col.column(align=True) - row = status_col.row() - row.label(text="Selected Assets:") - row.label(text="Main Lights Status") - row.enabled = False - for asset, lights_container in lights_tuples: - row = status_col.row(align=True) - row.label(text=asset.name) - row.prop( - lights_container, - f'["{polib.custom_props_bpy.CustomPropertyNames.TQ_LIGHTS}"]', - text=lights.get_main_lights_status_text( - lights_container[polib.custom_props_bpy.CustomPropertyNames.TQ_LIGHTS] - ), - ) - col.separator() - - col = col.column(align=True) - col.operator_menu_enum( - lights.SetLightsStatus.bl_idname, - property="status", - text="Selection Main Lights Status", - icon='LIGHTPROBE_GRID' if bpy.app.version < (4, 1, 0) else 'LIGHTPROBE_VOLUME', - ) - - # Emergency lights settings - self.layout.separator() - col = self.layout.column() - col.label(text="Emergency Lights", icon='LIGHT_SUN') - emergency_lights: typing.Optional[bpy.types.Object] = None - obj = context.active_object - if obj is not None: - root_object, emergency_lights = ( - lights.get_emergency_lights_container_from_hierarchy_with_root(obj) - ) - # TODO: differentiate between linked asset and asset without emergency lights - if emergency_lights is None: - col.label(text="Active object is not editable or does not contain Emergency Lights!") - return - modifiers = polib.geonodes_mod_utils_bpy.get_geometry_nodes_modifiers_by_node_group( - emergency_lights, asset_helpers.TQ_EMERGENCY_LIGHTS_NODE_GROUP_NAME - ) - mod = modifiers[0] - row = col.row() - left_col = row.column() - left_col.enabled = False - left_col.label(text=root_object.name) - right_col = row.column() - row = right_col.row(align=True) - row.alignment = 'RIGHT' - self.draw_show_viewport_and_render(row, mod) - self.draw_object_modifiers_node_group_inputs_template( - emergency_lights, col, LightsPanel.template - ) - - -MODULE_CLASSES.append(LightsPanel) - - -@polib.log_helpers_bpy.logged_panel -class WearPanel(TraffiqPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_traffiq_wear" - bl_parent_id = TraffiqPanel.bl_idname - bl_label = "Wear Sliders" - - def format_wear_node_input_value(self, obj: bpy.types.Object, prop_name: str) -> str: - prop = obj.get(prop_name, None) - return f"{prop:.2f}" if prop is not None else "-" - - def draw_header(self, context: bpy.types.Context): - self.layout.label(text="", icon='UV') - - def draw(self, context: bpy.types.Context): - prefs = preferences.prefs_utils.get_preferences(context).traffiq_preferences - row = self.layout.row() - wear_props_set = { - polib.custom_props_bpy.CustomPropertyNames.TQ_DIRT, - polib.custom_props_bpy.CustomPropertyNames.TQ_SCRATCHES, - polib.custom_props_bpy.CustomPropertyNames.TQ_BUMPS, - } - objs_with_wear = [ - ob - for ob in context.selected_objects - if len(wear_props_set.intersection(set(ob.keys()))) > 0 - ] - - if len(objs_with_wear) == 0: - row.label(text="No assets with wear selected!") - return - - left_col = row.column(align=True) - left_col.scale_x = 2.0 - right_col = row.column(align=True) - row = left_col.row() - row.enabled = False - row.label(text="Selected Assets:") - row = right_col.row() - row.enabled = False - row.label(text="Dirt") - row.label(text="Scratches") - row.label(text="Bumps") - for obj in objs_with_wear: - row = left_col.row() - row.label(text=obj.name) - row = right_col.row() - row.label( - text=self.format_wear_node_input_value( - obj, polib.custom_props_bpy.CustomPropertyNames.TQ_DIRT - ) - ) - row.label( - text=self.format_wear_node_input_value( - obj, polib.custom_props_bpy.CustomPropertyNames.TQ_SCRATCHES - ) - ) - row.label( - text=self.format_wear_node_input_value( - obj, polib.custom_props_bpy.CustomPropertyNames.TQ_BUMPS - ) - ) - - any_object_with_applicable_dirt = any( - polib.custom_props_bpy.CustomPropertyNames.TQ_DIRT in obj for obj in objs_with_wear - ) - any_object_with_applicable_scratches = any( - polib.custom_props_bpy.CustomPropertyNames.TQ_SCRATCHES in obj for obj in objs_with_wear - ) - any_object_with_applicable_bumps = any( - polib.custom_props_bpy.CustomPropertyNames.TQ_BUMPS in obj for obj in objs_with_wear - ) - - col = self.layout.column(align=True) - for wear_strength_prop, any_obj_with_applicable_wear in zip( - ["dirt_wear_strength", "scratches_wear_strength", "bumps_wear_strength"], - [ - any_object_with_applicable_dirt, - any_object_with_applicable_scratches, - any_object_with_applicable_bumps, - ], - ): - row = col.row(align=True) - row.prop(prefs.wear_properties, wear_strength_prop, slider=True) - row.enabled = any_obj_with_applicable_wear - - -MODULE_CLASSES.append(WearPanel) - - -@polib.log_helpers_bpy.logged_panel -class RigsPanel(TraffiqPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_traffiq_rigs" - bl_parent_id = TraffiqPanel.bl_idname - bl_label = "Rigs" - - def draw_header(self, context: bpy.types.Context): - self.layout.label(text="", icon='AUTO') - - def draw(self, context: bpy.types.Context): - layout = self.layout.column() - layout.use_property_decorate = False - layout.use_property_split = True - if context.active_object is None: - layout.label(text="No active object!") - return - - if not polib.rigs_shared_bpy.is_object_rigged(context.active_object): - layout.label(text="Active object doesn't contain rig!") - return - - layout.prop(context.scene, "tq_target_path_object", text="Path", icon='CON_FOLLOWPATH') - layout.prop(context.scene, "tq_ground_object", text="Ground", icon='IMPORT') - col = layout.column(align=True) - row = col.row() - row.scale_x = row.scale_y = 1.5 - row.operator(rigs.FollowPath.bl_idname, icon='TRACKING') - row = col.row() - row.scale_x = row.scale_y = 1.25 - row.operator(rigs.ChangeFollowPathSpeed.bl_idname, icon='FORCE_FORCE') - - layout.separator() - - col = layout.column(align=True) - col.operator(rigs.BakeSteering.bl_idname, icon='GIZMO') - col.operator(rigs.BakeWheelRotation.bl_idname, icon='PHYSICS') - layout.separator() - - self.layout.operator(rigs.RemoveAnimation.bl_idname, icon='PANEL_CLOSE') - - -MODULE_CLASSES.append(RigsPanel) - - -def get_position_display_name(position: str) -> str: - """Returns human readable form of our wheel position naming conventions - (e. g. BL_0 -> Back Left (0)) - """ - - raw_position_to_display_map = { - "BL": "Back Left", - "BR": "Back Right", - "FR": "Front Right", - "FL": "Front Left", - "F": "Front", - "B": "Back", - } - - position_split = position.split("_", 1) - if len(position_split) == 2: - position, index = position_split - else: - position, index = position_split[0], "0" - - index_suffix = f" ({index})" if int(index) > 0 else "" - return f"{raw_position_to_display_map.get(position, '')}{index_suffix}" - - -@polib.log_helpers_bpy.logged_panel -class RigsGroundSensorsPanel(TraffiqPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_traffiq_rigs_ground_sensors" - bl_parent_id = RigsPanel.bl_idname - bl_label = "Ground Sensors" - - @classmethod - def poll(cls, context: bpy.types.Context): - return polib.rigs_shared_bpy.is_object_rigged(context.active_object) - - def draw_header(self, context: bpy.types.Context): - self.layout.label(text="", icon='IMPORT') - - def draw(self, context: bpy.types.Context): - layout = self.layout.column() - layout.use_property_split = True - layout.use_property_decorate = False - layout.prop(context.scene, "tq_ground_object", text="Ground", icon='IMPORT') - layout.operator(rigs.SetGroundSensors.bl_idname, text="Set Ground Object For All") - layout.separator() - - sensors_manipulator = rigs.GroundSensorsManipulator(context.active_object.pose) - for name, constraint in sensors_manipulator.ground_sensors_constraints.items(): - if constraint is None: - continue - - layout.label(text=self.get_ground_sensor_display_name(name), icon='IMPORT') - layout.prop(constraint, "target", text="Ground") - layout.prop(constraint, "shrinkwrap_type") - layout.prop(constraint, "project_limit") - layout.prop(constraint, "influence") - layout.separator() - - def get_ground_sensor_display_name(self, name: str): - if "Axle" in name: - _, _, position = name.split("_", 2) - return f"{get_position_display_name(position)} Axle [{name}]" - else: - _, position = name.split("_", 1) - return f"{get_position_display_name(position)} [{name}]" - - -MODULE_CLASSES.append(RigsGroundSensorsPanel) - - -@polib.log_helpers_bpy.logged_panel -class RigsRigPropertiesPanel(TraffiqPanelInfoMixin, bpy.types.Panel): - bl_idname = "VIEW_3D_PT_engon_traffiq_rigs_rig_properties" - bl_parent_id = RigsPanel.bl_idname - bl_label = "Rig Properties" - - @classmethod - def poll(cls, context: bpy.types.Context): - return polib.rigs_shared_bpy.is_object_rigged( - context.active_object - ) and rigs.check_rig_drivers(context.active_object) - - def draw_header(self, context: bpy.types.Context): - self.layout.label(text="", icon='OPTIONS') - - def draw(self, context: bpy.types.Context): - layout = self.layout - active_object = context.active_object - layout.label(text="Wheels") - for prop in active_object.keys(): - if prop.startswith(polib.custom_props_bpy.CustomPropertyNames.TQ_WHEEL_ROTATION): - self.display_custom_property(active_object, layout, prop) - - layout.label(text="Suspension") - self.display_custom_property( - active_object, layout, polib.custom_props_bpy.CustomPropertyNames.TQ_SUSPENSION_FACTOR - ) - self.display_custom_property( - active_object, - layout, - polib.custom_props_bpy.CustomPropertyNames.TQ_SUSPENSION_ROLLING_FACTOR, - ) - - layout.label(text="Steering") - self.display_custom_property( - active_object, - layout, - polib.custom_props_bpy.CustomPropertyNames.TQ_STEERING, - ) - - def display_custom_property( - self, obj: bpy.types.Object, layout: bpy.types.UILayout, prop_name: str - ) -> None: - if prop_name.startswith("tq_"): - prop_display_name = prop_name[len("tq_") :] - else: - prop_display_name = prop_name - - if prop_name.startswith(polib.custom_props_bpy.CustomPropertyNames.TQ_WHEEL_ROTATION): - _, position = prop_display_name.split("_", 1) - prop_display_name = f"{get_position_display_name(position)}" - - if prop_name in obj.keys(): - layout.prop(obj, f'["{prop_name}"]', text=prop_display_name) - else: - layout.label(text=f"Property {prop_name} N/A") - - -MODULE_CLASSES.append(RigsRigPropertiesPanel) - - -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)